/** * RPC Client for PageController remote calls * * This module provides RPC functionality from SidePanel to ContentScript * via the Background (SW) relay. * * Flow: SidePanel → SW (relay) → ContentScript → sendResponse → SidePanel * * MV3 Compliant: Uses chrome.runtime.sendMessage with direct sendResponse, * no pending calls map or custom response listeners needed. */ import { type ActionResult, type BrowserState, type RPCCallMessage, type ScrollHorizontallyOptions, type ScrollOptions, generateMessageId, } from './protocol' /** RPC configuration */ const RPC_CONFIG = { /** Maximum retry attempts for transient failures */ maxRetries: 3, /** Base delay between retries in ms (exponential backoff) */ retryDelayMs: 500, } /** * Sleep for a given number of milliseconds */ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } /** * Check if a tab exists */ async function tabExists(tabId: number): Promise { try { await chrome.tabs.get(tabId) return true } catch { return false } } /** * Error thrown when RPC call fails */ export class RPCError extends Error { constructor( message: string, public readonly code: 'TAB_CLOSED' | 'CONTENT_SCRIPT_NOT_READY' | 'RPC_FAILED' ) { super(message) this.name = 'RPCError' } } /** Response type from background script */ interface RPCResponse { success: boolean result?: unknown error?: string } /** * Make a single RPC call (no retry) * Uses chrome.runtime.sendMessage which returns the response directly via sendResponse */ async function callOnce(tabId: number, method: string, args: unknown[]): Promise { const message: RPCCallMessage = { type: 'rpc:call', id: generateMessageId(), tabId, method, args, } const response = (await chrome.runtime.sendMessage(message)) as RPCResponse if (response?.success) { return response.result } else { throw new Error(response?.error || 'RPC call failed') } } /** * Make an RPC call with retry logic */ async function call(tabId: number, method: string, args: unknown[]): Promise { let lastError: Error | null = null for (let attempt = 0; attempt < RPC_CONFIG.maxRetries; attempt++) { try { return await callOnce(tabId, method, args) } catch (error) { lastError = error as Error const message = lastError.message || String(error) // Check if tab still exists if (!(await tabExists(tabId))) { throw new RPCError(`Tab ${tabId} was closed`, 'TAB_CLOSED') } // Check for retryable errors if ( message.includes('Could not establish connection') || message.includes('Receiving end does not exist') || message.includes('content script not ready') ) { const delay = RPC_CONFIG.retryDelayMs * Math.pow(2, attempt) console.debug( `[RPC] Retry ${attempt + 1}/${RPC_CONFIG.maxRetries} for ${method}, waiting ${delay}ms` ) await sleep(delay) continue } // Non-retryable error throw lastError } } throw new RPCError( `Content script not ready after ${RPC_CONFIG.maxRetries} attempts for ${method}`, 'CONTENT_SCRIPT_NOT_READY' ) } /** * RPC client interface matching PageController methods */ export interface RPCClient { tabId: number getCurrentUrl(): Promise getLastUpdateTime(): Promise getBrowserState(): Promise updateTree(): Promise cleanUpHighlights(): Promise clickElement(index: number): Promise inputText(index: number, text: string): Promise selectOption(index: number, optionText: string): Promise scroll(options: ScrollOptions): Promise scrollHorizontally(options: ScrollHorizontallyOptions): Promise executeJavascript(script: string): Promise showMask(): Promise hideMask(): Promise dispose(): Promise } /** * Create an RPC client bound to a specific tab */ export function createRPCClient(tabId: number): RPCClient { console.debug(`[RPC] Creating client for tab ${tabId}`) return { tabId, async getCurrentUrl(): Promise { return call(tabId, 'getCurrentUrl', []) as Promise }, async getLastUpdateTime(): Promise { return call(tabId, 'getLastUpdateTime', []) as Promise }, async getBrowserState(): Promise { return call(tabId, 'getBrowserState', []) as Promise }, async updateTree(): Promise { return call(tabId, 'updateTree', []) as Promise }, async cleanUpHighlights(): Promise { await call(tabId, 'cleanUpHighlights', []) }, async clickElement(index: number): Promise { return call(tabId, 'clickElement', [index]) as Promise }, async inputText(index: number, text: string): Promise { return call(tabId, 'inputText', [index, text]) as Promise }, async selectOption(index: number, optionText: string): Promise { return call(tabId, 'selectOption', [index, optionText]) as Promise }, async scroll(options: ScrollOptions): Promise { return call(tabId, 'scroll', [options]) as Promise }, async scrollHorizontally(options: ScrollHorizontallyOptions): Promise { return call(tabId, 'scrollHorizontally', [options]) as Promise }, async executeJavascript(script: string): Promise { return call(tabId, 'executeJavascript', [script]) as Promise }, async showMask(): Promise { await call(tabId, 'showMask', []) }, async hideMask(): Promise { // Best effort - don't throw if content script is gone try { await callOnce(tabId, 'hideMask', []) } catch (e) { console.debug('[RPC] hideMask failed (ignored):', e) } }, async dispose(): Promise { // Best effort - don't throw if content script is gone try { await callOnce(tabId, 'dispose', []) } catch (e) { console.debug('[RPC] dispose failed (ignored):', e) } }, } }