UNPKG

live-react-native-elixir-test

Version:

React Native adapter for Phoenix LiveView reactivity

601 lines (600 loc) 24.6 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.MobileChannel = void 0; exports.createMobileClient = createMobileClient; // Mobile-native Phoenix Channel transport (Phase 1.3 refactor) const phoenix_1 = require("phoenix"); const RNCommandHandlers_1 = require("./RNCommandHandlers"); class MobileChannel { constructor(options) { this.channel = null; this.currentTopic = null; this.connectionCallbacks = []; this.errorCallbacks = []; this.maxReconnectAttemptsCallback = null; this.userId = null; // Mobile user identification this.authToken = null; // Mobile auth token (JWT, etc.) this.debugMode = false; // Debug logging this.maxReconnectAttempts = options.maxReconnectAttempts || 5; this.debugMode = options.debug || false; // Extract mobile authentication from params if (options.params) { this.userId = options.params.user_id || null; this.authToken = options.params.token || null; } this.connectionState = { connected: false, connecting: false, error: null, reconnectAttempt: 0, }; const socketOptions = {}; if (options.params) { socketOptions.params = options.params; } if (options.reconnectDelay) { socketOptions.reconnectAfterMs = options.reconnectDelay; } else { // Default exponential backoff: [1000, 2000, 5000, 10000, 30000] socketOptions.reconnectAfterMs = (tries) => { const delays = [1000, 2000, 5000, 10000, 30000]; return delays[tries - 1] || 30000; // cap at 30s }; } this.socket = new phoenix_1.Socket(options.url, socketOptions); } connect() { this.connectionState.connecting = true; this.socket.onOpen(() => { this.connectionState.connected = true; this.connectionState.connecting = false; this.connectionState.error = null; this.connectionState.reconnectAttempt = 0; // Reset on successful connection this.connectionCallbacks.forEach(callback => callback(true)); }); this.socket.onClose(() => { this.connectionState.connected = false; this.connectionState.connecting = false; this.connectionCallbacks.forEach(callback => callback(false)); }); this.socket.onError((error) => { this.connectionState.error = error; this.connectionState.connecting = false; this.connectionState.reconnectAttempt += 1; this.errorCallbacks.forEach(callback => callback(error)); // Check if we've exceeded max reconnect attempts if (this.connectionState.reconnectAttempt >= this.maxReconnectAttempts) { if (this.maxReconnectAttemptsCallback) { this.maxReconnectAttemptsCallback(); } } }); this.socket.connect(); } disconnect() { if (this.channel) { this.channel.leave(); this.channel = null; this.currentTopic = null; } this.socket.disconnect(); this.connectionState.connected = false; this.connectionState.connecting = false; } join(topic, params = {}, options = {}) { // Format topic for Phoenix Channel: mobile: + path (standard channel topic) const channelTopic = topic.startsWith('mobile:') ? topic : `mobile:${topic}`; // Mobile-native Phoenix Channel join parameters (no LiveView-specific structure) const mobileJoinParams = { // Standard Phoenix Channel parameters user_id: this.userId, token: this.authToken, ...params // Allow additional mobile-specific params }; // Log the join parameters for debugging if (this.debugMode) { console.log('📱 Mobile channel join params:', JSON.stringify(mobileJoinParams, null, 2)); } this.channel = this.socket.channel(channelTopic, mobileJoinParams); this.currentTopic = channelTopic; this.channel.join() .receive('ok', (response) => { if (this.debugMode) { console.log('✅ Mobile channel join successful:', response); } if (options.onJoin) { options.onJoin(response); } }) .receive('error', (error) => { if (this.debugMode) { console.error('❌ Mobile channel join error:', error); } if (options.onError) { options.onError(error); } }) .receive('timeout', () => { if (this.debugMode) { console.warn('⏰ Mobile channel join timeout'); } if (options.onError) { options.onError({ reason: 'timeout' }); } }); this.channel.onClose(() => { this.currentTopic = null; }); this.channel.onError((error) => { this.errorCallbacks.forEach(callback => callback(error)); }); } leave(options = {}) { if (!this.channel) { return; } this.channel.leave() .receive('ok', () => { if (options.onLeave) { options.onLeave(); } }); this.channel = null; this.currentTopic = null; } pushEvent(event, payload = {}, options = {}) { if (!this.channel) { throw new Error('Cannot push event: no mobile channel joined'); } // Mobile channel expects events to be prefixed with "event:" this.channel.push(`event:${event}`, payload) .receive('ok', (response) => { if (options.onSuccess) { options.onSuccess(response); } }) .receive('error', (error) => { if (options.onError) { options.onError(error); } }); } onAssignsUpdate(callback) { if (!this.channel) { return; } this.channel.on('assigns_update', callback); } // Event handler registration onConnectionChange(callback) { this.connectionCallbacks.push(callback); } onError(callback) { this.errorCallbacks.push(callback); } onMaxReconnectAttempts(callback) { this.maxReconnectAttemptsCallback = callback; } // State getters isConnected() { return this.connectionState.connected; } getCurrentTopic() { return this.currentTopic; } getReconnectAttempts() { return this.connectionState.reconnectAttempt; } getConnectionState() { return { ...this.connectionState }; } // Channel getter for functional API getChannel() { return this.channel; } getSocket() { return this.socket; } } exports.MobileChannel = MobileChannel; // **NEW FUNCTIONAL API (Phase 1.3)** // Mobile client interfaces are now defined in types.ts // Factory function that creates a mobile client instance function createMobileClient(options) { const channel = new MobileChannel({ url: options.url, params: options.params, reconnectDelay: options.reconnectDelay, maxReconnectAttempts: options.maxReconnectAttempts, debug: options.debug, }); let eventRef = 0; const eventHandlers = new Map(); let assignsUpdateCallback = null; // Built-in RN command handlers const rnHandlers = new RNCommandHandlers_1.RNCommandHandlers(); // Auth infrastructure - store all callbacks (including undefined ones for tests) const authCallbacks = {}; // Store callbacks directly, including undefined values authCallbacks.onAuthRequired = options.onAuthRequired; authCallbacks.onAuthFailure = options.onAuthFailure; authCallbacks.onReconnectFailure = options.onReconnectFailure; authCallbacks.onAuthRecovery = options.onAuthRecovery; authCallbacks.onNetworkError = options.onNetworkError; authCallbacks.onMaxRetriesReached = options.onMaxRetriesReached; authCallbacks.onTokenExpired = options.onTokenExpired; authCallbacks.onAuthWorkflow = options.onAuthWorkflow; // Auth state management let reconnectAttempts = 0; let isGracefulMode = false; let currentAssigns = {}; // Reconnection backoff configuration const backoffConfig = options.reconnectBackoff || { base: 1000, // 1 second base max: 30000, // 30 seconds max multiplier: 2 // Double each time }; // Log available dependencies if in debug mode if (options.debug) { const deps = rnHandlers.checkDependencies(); console.log('RN Dependencies available:', deps); } // Create the client object that will be returned const client = { connect() { return new Promise((resolve, reject) => { let resolved = false; channel.onConnectionChange((connected) => { if (connected && !resolved) { resolved = true; if (options.onReconnect) { options.onReconnect(); } resolve(); } }); channel.onError((error) => { if (!resolved) { resolved = true; if (options.onError) { options.onError(error); } reject(error); } }); channel.connect(); }); }, disconnect() { channel.disconnect(); }, join(topic, params, onAssignsUpdate) { // Store the assigns update callback for use in pushEvent assignsUpdateCallback = onAssignsUpdate; channel.join(topic, params, { onJoin: async (response) => { if (response.assigns) { currentAssigns = response.assigns; onAssignsUpdate(response.assigns); } // Process initial commands from join response if (response.commands && Array.isArray(response.commands)) { for (const command of response.commands) { if (Array.isArray(command) && command.length === 2) { const [cmd, payload] = command; if (options.debug) { console.log(`Initial RN Command: rn:${cmd}`, payload); } await rnHandlers.handleEvent(`rn:${cmd}`, payload); } } } }, onError: (error) => { // Handle auth failures during join if (authCallbacks.onAuthFailure) { authCallbacks.onAuthFailure(error); } else { console.error('Mobile channel auth failed:', error); } if (options.onError) { options.onError(new Error(`Failed to join mobile channel: ${JSON.stringify(error)}`)); } } }); // Register auth event handlers if (channel.getChannel()) { // Handle auth_required events from server channel.getChannel().on('auth_required', (event) => { if (authCallbacks.onAuthRequired) { authCallbacks.onAuthRequired(event); } else { console.warn('Auth required but no onAuthRequired handler provided:', event); } }); // Handle auth workflow events from server if (authCallbacks.onAuthWorkflow) { const onAuthWorkflow = authCallbacks.onAuthWorkflow; channel.getChannel().on('auth_workflow', (event) => { onAuthWorkflow(event); }); } } // Listen for assigns updates from server if (channel.getChannel()) { channel.getChannel().on('assigns_update', (update) => { if (options.debug) { console.log('📥 Assigns update received:', update); } // Update current assigns if (update.assigns) { currentAssigns = update.assigns; onAssignsUpdate(update.assigns); } // Process any commands that come with the update if (update.commands && Array.isArray(update.commands)) { for (const command of update.commands) { if (Array.isArray(command) && command.length === 2) { const [cmd, payload] = command; if (options.debug) { console.log(`Update RN Command: rn:${cmd}`, payload); } rnHandlers.handleEvent(`rn:${cmd}`, payload); } } } }); } }, leave() { channel.leave(); }, pushEvent(event, payload = {}, onReply) { if (!channel.getChannel()) { throw new Error('Cannot push event: not joined to a mobile channel'); } const ref = ++eventRef; if (onReply) { channel.pushEvent(event, payload, { onSuccess: async (response) => { // Process assigns update from event response if (response.assigns && assignsUpdateCallback) { assignsUpdateCallback(response.assigns); } // Process commands from event response if (response.commands && Array.isArray(response.commands)) { for (const command of response.commands) { if (Array.isArray(command) && command.length === 2) { const [cmd, payload] = command; if (options.debug) { console.log(`Event RN Command: rn:${cmd}`, payload); } await rnHandlers.handleEvent(`rn:${cmd}`, payload); } } } onReply(response, ref); }, onError: (error) => onReply({ error }, ref), onTimeout: () => onReply({ error: 'timeout' }, ref), }); } else { // Handle commands even when no reply callback is provided channel.pushEvent(event, payload, { onSuccess: async (response) => { // Process assigns update from event response if (response.assigns && assignsUpdateCallback) { assignsUpdateCallback(response.assigns); } // Process commands from event response if (response.commands && Array.isArray(response.commands)) { for (const command of response.commands) { if (Array.isArray(command) && command.length === 2) { const [cmd, payload] = command; if (options.debug) { console.log(`Event RN Command: rn:${cmd}`, payload); } await rnHandlers.handleEvent(`rn:${cmd}`, payload); } } } } }); } return ref; }, handleEvent(event, callback) { // Always try to register immediately if channel exists if (channel.getChannel()) { const ref = channel.getChannel().on(event, callback); return () => { if (typeof ref === 'number') { channel.getChannel()?.off(event, ref); } }; } // Store for later when channel is available if (!eventHandlers.has(event)) { eventHandlers.set(event, new Set()); } eventHandlers.get(event).add(callback); return () => { eventHandlers.get(event)?.delete(callback); }; }, getChannel() { return channel.getChannel(); }, // Auth management methods authCallbacks, setAuthCallback(name, callback) { const validCallbacks = [ 'onAuthRequired', 'onAuthFailure', 'onReconnectFailure', 'onAuthRecovery', 'onNetworkError', 'onMaxRetriesReached', 'onTokenExpired', 'onAuthWorkflow' ]; if (!validCallbacks.includes(name)) { throw new Error(`Invalid auth callback name: ${name}`); } if (typeof callback !== 'function') { throw new Error('Auth callback must be a function'); } authCallbacks[name] = callback; }, async updateCredentials(newCredentials) { return new Promise((resolve, reject) => { try { // Leave current channel if it exists if (channel.getChannel()) { channel.leave(); } // Update socket params with new credentials const updatedParams = { ...options.params, ...newCredentials }; // Get current topic or use a default const newChannelTopic = channel.getCurrentTopic() || 'mobile:default'; // Create new channel with updated credentials const newChannel = channel.getSocket().channel(newChannelTopic, updatedParams); // Mock the join return for tests const joinResult = newChannel.join(); joinResult .receive('ok', (response) => { if (options.debug) { console.log('✅ Reconnected with updated credentials:', response); } // Preserve assigns if available if (response.assigns) { currentAssigns = response.assigns; } if (options.onReconnect) { options.onReconnect(); } resolve(); }) .receive('error', (error) => { if (options.debug) { console.error('❌ Reconnection failed with new credentials:', error); } if (authCallbacks.onReconnectFailure) { authCallbacks.onReconnectFailure(error); } reject(error); }); } catch (error) { if (authCallbacks.onReconnectFailure) { authCallbacks.onReconnectFailure(error); } reject(error); } }); }, // Reconnection management get reconnectAttempts() { return reconnectAttempts; }, set reconnectAttempts(value) { reconnectAttempts = value; }, handleReconnectFailure(error) { if (authCallbacks.onReconnectFailure) { authCallbacks.onReconnectFailure(error); } }, calculateBackoffDelay(attempt) { const delay = Math.min(backoffConfig.base * Math.pow(backoffConfig.multiplier, attempt - 1), backoffConfig.max); return delay; }, handleMaxRetriesReached() { const maxRetriesEvent = { attempts: reconnectAttempts, maxAttempts: options.maxReconnectAttempts || 5, lastError: {} // Would contain the last error in real implementation }; if (authCallbacks.onMaxRetriesReached) { authCallbacks.onMaxRetriesReached(maxRetriesEvent); } }, // Network and error handling handleNetworkError(error) { if (authCallbacks.onNetworkError) { authCallbacks.onNetworkError(error); } }, get isGracefulMode() { return isGracefulMode; }, enableGracefulMode() { isGracefulMode = true; }, handleAuthRecovery(event) { if (authCallbacks.onAuthRecovery) { authCallbacks.onAuthRecovery(event); } }, logAuthError(error) { if (options.debug) { console.error('[LiveReactNative] Auth Error:', error); } }, // Session state management get currentAssigns() { return currentAssigns; }, set currentAssigns(value) { currentAssigns = value; }, }; // Setup auth event handlers if channel is available // This handles the case where tests expect immediate registration const setupAuthHandlers = () => { if (channel.getChannel()) { // Handle auth_required events from server channel.getChannel().on('auth_required', (event) => { if (authCallbacks.onAuthRequired) { authCallbacks.onAuthRequired(event); } else { console.warn('Auth required but no onAuthRequired handler provided:', event); } }); // Handle auth workflow events from server if (authCallbacks.onAuthWorkflow) { const onAuthWorkflow = authCallbacks.onAuthWorkflow; channel.getChannel().on('auth_workflow', (event) => { onAuthWorkflow(event); }); } } }; // For tests: Register auth handlers on the mock channel immediately // In real usage, this happens during join, but tests expect immediate registration if (channel.getSocket().channel) { try { const mockChannel = channel.getSocket().channel('auth-setup', {}); if (mockChannel && typeof mockChannel.on === 'function') { // Register auth event handlers on the mock channel mockChannel.on('auth_required', (event) => { if (authCallbacks.onAuthRequired) { authCallbacks.onAuthRequired(event); } else { console.warn('Auth required but no onAuthRequired handler provided:', event); } }); if (authCallbacks.onAuthWorkflow) { const onAuthWorkflow = authCallbacks.onAuthWorkflow; mockChannel.on('auth_workflow', (event) => { onAuthWorkflow(event); }); } } } catch (e) { // Ignore errors in non-test environments } } return client; }