/** * @fileoverview Offscreen Keepalive * @description Keeps the MV3 service worker alive using an Offscreen Document + Port heartbeat. * * Architecture: * - Offscreen connects to Background (Service Worker) via a named Port. * - Offscreen sends periodic `keepalive.ping` messages while keepalive is enabled. * - Background replies with `keepalive.pong` to confirm the channel is alive. * * Contract: * - After `stop`, keepalive must fully stop: no ping loop, no Port, and no reconnection attempts. * - After `start`, keepalive must (re)connect if needed and resume the ping loop. */ import { RR_V3_KEEPALIVE_PORT_NAME, DEFAULT_KEEPALIVE_PING_INTERVAL_MS, type KeepaliveMessage, } from '@/common/rr-v3-keepalive-protocol'; // ==================== Runtime Control Protocol ==================== const KEEPALIVE_CONTROL_MESSAGE_TYPE = 'rr_v3_keepalive.control' as const; type KeepaliveControlCommand = 'start' | 'stop'; interface KeepaliveControlMessage { type: typeof KEEPALIVE_CONTROL_MESSAGE_TYPE; command: KeepaliveControlCommand; } function isKeepaliveControlMessage(value: unknown): value is KeepaliveControlMessage { if (!value || typeof value !== 'object') return false; const v = value as Record; if (v.type !== KEEPALIVE_CONTROL_MESSAGE_TYPE) return false; return v.command === 'start' || v.command === 'stop'; } // ==================== State ==================== let initialized = false; let keepalivePort: chrome.runtime.Port | null = null; let pingTimer: ReturnType | null = null; /** Whether keepalive is desired (set by start/stop commands from Background) */ let keepaliveDesired = false; let reconnectTimer: ReturnType | null = null; // ==================== Type Guards ==================== /** * Type guard for KeepaliveMessage. */ function isKeepaliveMessage(value: unknown): value is KeepaliveMessage { if (!value || typeof value !== 'object') return false; const v = value as Record; const type = v.type; if ( type !== 'keepalive.ping' && type !== 'keepalive.pong' && type !== 'keepalive.start' && type !== 'keepalive.stop' ) { return false; } return typeof v.timestamp === 'number' && Number.isFinite(v.timestamp); } // ==================== Port Management ==================== /** * Schedule a reconnect attempt to maintain the Port connection. * Only reconnect while keepalive is desired. */ function scheduleReconnect(delayMs = 1000): void { if (!initialized) return; if (!keepaliveDesired) return; if (reconnectTimer) return; reconnectTimer = setTimeout(() => { reconnectTimer = null; if (!initialized) return; if (!keepaliveDesired) return; if (!keepalivePort) { console.log('[rr-keepalive] Attempting scheduled reconnect...'); keepalivePort = connectToBackground(); } }, delayMs); } /** * Create a Port connection to Background. */ function connectToBackground(): chrome.runtime.Port | null { if (typeof chrome === 'undefined' || !chrome.runtime?.connect) { console.warn('[rr-keepalive] chrome.runtime.connect not available'); return null; } try { const port = chrome.runtime.connect({ name: RR_V3_KEEPALIVE_PORT_NAME }); port.onMessage.addListener((msg: unknown) => { if (!isKeepaliveMessage(msg)) return; if (msg.type === 'keepalive.start') { console.log('[rr-keepalive] Received start command via Port'); startPingLoop(); } else if (msg.type === 'keepalive.stop') { console.log('[rr-keepalive] Received stop command via Port'); stopPingLoop(); } else if (msg.type === 'keepalive.pong') { // Background replied to our ping. console.debug('[rr-keepalive] Received pong'); } }); port.onDisconnect.addListener(() => { console.log('[rr-keepalive] Port disconnected'); keepalivePort = null; // Only reconnect if keepalive is still desired. scheduleReconnect(1000); }); console.log('[rr-keepalive] Connected to background'); return port; } catch (e) { console.warn('[rr-keepalive] Failed to connect:', e); return null; } } // ==================== Ping Loop ==================== /** * Send a ping message to Background. */ function sendPing(): void { if (!keepalivePort) { keepalivePort = connectToBackground(); } if (!keepalivePort) return; const msg: KeepaliveMessage = { type: 'keepalive.ping', timestamp: Date.now(), }; try { keepalivePort.postMessage(msg); console.debug('[rr-keepalive] Sent ping'); } catch (e) { console.warn('[rr-keepalive] Failed to send ping:', e); keepalivePort = null; scheduleReconnect(1000); } } /** * Start the ping loop. */ function startPingLoop(): void { if (pingTimer) return; keepaliveDesired = true; // Ensure we have a Port connection. if (!keepalivePort) { keepalivePort = connectToBackground(); } // Send one ping immediately. sendPing(); // Start the interval timer. pingTimer = setInterval(() => { sendPing(); }, DEFAULT_KEEPALIVE_PING_INTERVAL_MS); console.log( `[rr-keepalive] Ping loop started (interval=${DEFAULT_KEEPALIVE_PING_INTERVAL_MS}ms)`, ); } /** * Stop the ping loop. * This must fully stop keepalive: no timer, no Port, and no reconnection attempts. */ function stopPingLoop(): void { keepaliveDesired = false; if (pingTimer) { clearInterval(pingTimer); pingTimer = null; } if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } // Disconnect the Port to fully stop keepalive. if (keepalivePort) { try { keepalivePort.disconnect(); } catch { // Ignore } keepalivePort = null; } console.log('[rr-keepalive] Ping loop stopped'); } // ==================== Public API ==================== /** * Initialize keepalive control handlers. * @description Registers the runtime control listener and waits for start/stop commands. */ export function initKeepalive(): void { if (initialized) return; initialized = true; // Check Chrome API availability. if (typeof chrome === 'undefined' || !chrome.runtime?.onMessage) { console.warn('[rr-keepalive] chrome.runtime.onMessage not available'); return; } // Listen for runtime control messages from Background. // This allows Background to send start/stop even when Port is not connected. chrome.runtime.onMessage.addListener((msg: unknown, _sender, sendResponse) => { if (!isKeepaliveControlMessage(msg)) return; if (msg.command === 'start') { console.log('[rr-keepalive] Received runtime start command'); startPingLoop(); } else { console.log('[rr-keepalive] Received runtime stop command'); stopPingLoop(); } try { sendResponse({ ok: true }); } catch { // Ignore } }); // Also establish initial Port connection for backwards compatibility. if (chrome.runtime?.connect) { keepalivePort = connectToBackground(); } console.log('[rr-keepalive] Keepalive initialized'); } /** * Check whether keepalive is active. */ export function isKeepaliveActive(): boolean { return keepaliveDesired && pingTimer !== null && keepalivePort !== null; } /** * Get the active port count (for debugging). * @deprecated Use isKeepaliveActive() instead */ export function getActivePortCount(): number { return keepalivePort ? 1 : 0; } // Re-export for backwards compatibility export { RR_V3_KEEPALIVE_PORT_NAME, type KeepaliveMessage, } from '@/common/rr-v3-keepalive-protocol';