/** * RPC Client for PageController remote calls * * Flow: SidePanel → SW (relay) → ContentScript → sendResponse */ import type { ActionResult, AgentToPageMessage, BrowserState, ScrollHorizontallyOptions, ScrollOptions, } from './protocol' const RPC_CONFIG = { maxRetries: 3, retryDelayMs: 500, } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } async function tabExists(tabId: number): Promise { try { await chrome.tabs.get(tabId) return true } catch { return false } } export class RPCError extends Error { constructor( message: string, public readonly code: 'TAB_CLOSED' | 'CONTENT_SCRIPT_NOT_READY' | 'RPC_FAILED' ) { super(message) this.name = 'RPCError' } } interface RPCResponse { success: boolean result?: unknown error?: string } async function callOnce(tabId: number, method: string, args: unknown[]): Promise { const message: AgentToPageMessage = { type: 'AGENT_TO_PAGE', 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') } } 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) if (!(await tabExists(tabId))) { throw new RPCError(`Tab ${tabId} was closed`, 'TAB_CLOSED') } 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}`) await sleep(delay) continue } throw lastError } } throw new RPCError( `Content script not ready after ${RPC_CONFIG.maxRetries} attempts`, 'CONTENT_SCRIPT_NOT_READY' ) } /** * RPC client interface (no mask/dispose - content manages via storage polling) */ 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 } export function createRPCClient(tabId: number): RPCClient { 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 }, } }