diff --git a/packages/extension/src/entrypoints/background.ts b/packages/extension/src/entrypoints/background.ts index 4a667c4..74c44d6 100644 --- a/packages/extension/src/entrypoints/background.ts +++ b/packages/extension/src/entrypoints/background.ts @@ -15,7 +15,6 @@ import { type ExtensionMessage, type QueryResponseMessage, type RPCCallMessage, - type RPCResponseMessage, type TabEventMessage, generateMessageId, isExtensionMessage, @@ -42,9 +41,9 @@ chrome.runtime.onMessage.addListener( switch (msg.type) { case 'rpc:call': - // SidePanel → SW: Forward RPC to content script - handleRPCCall(msg as RPCCallMessage) - return false // No sync response needed + // SidePanel → SW: Forward RPC to content script, return result via sendResponse + handleRPCCall(msg as RPCCallMessage, sendResponse) + return true // Async response case 'cs:query': // ContentScript → SW: Forward query to sidepanel @@ -59,15 +58,18 @@ chrome.runtime.onMessage.addListener( /** * Forward RPC call from SidePanel to ContentScript + * Uses sendResponse to return result directly (MV3 compliant) */ -async function handleRPCCall(msg: RPCCallMessage): Promise { - const { id, tabId, method, args } = msg +async function handleRPCCall( + msg: RPCCallMessage, + sendResponse: (response: { success: boolean; result?: unknown; error?: string }) => void +): Promise { + const { tabId, method, args } = msg // Create message for content script const csMessage: CSRPCMessage = { - isPageAgentMessage: true, type: 'cs:rpc', - id, + id: msg.id, method, args, } @@ -75,27 +77,11 @@ async function handleRPCCall(msg: RPCCallMessage): Promise { try { // Send to content script and wait for response const result = await chrome.tabs.sendMessage(tabId, csMessage) - - // Forward response back to sidepanel - const response: RPCResponseMessage = { - isPageAgentMessage: true, - type: 'rpc:response', - id, - success: true, - result, - } - await chrome.runtime.sendMessage(response) + sendResponse({ success: true, result }) } catch (error) { - // Forward error back to sidepanel - const response: RPCResponseMessage = { - isPageAgentMessage: true, - type: 'rpc:response', - id, + sendResponse({ success: false, error: error instanceof Error ? error.message : String(error), - } - await chrome.runtime.sendMessage(response).catch(() => { - // Sidepanel may be closed }) } } @@ -120,7 +106,6 @@ async function handleCSQuery( // Forward response back to content script if (sender.tab?.id) { const queryResponse: QueryResponseMessage = { - isPageAgentMessage: true, type: 'query:response', id, result: response, @@ -131,7 +116,6 @@ async function handleCSQuery( // Sidepanel not open or no response, return default if (sender.tab?.id) { const queryResponse: QueryResponseMessage = { - isPageAgentMessage: true, type: 'query:response', id, result: queryType === 'shouldShowMask' ? false : null, @@ -150,7 +134,6 @@ async function handleCSQuery( */ chrome.tabs.onRemoved.addListener((tabId) => { const message: TabEventMessage = { - isPageAgentMessage: true, type: 'tab:event', id: generateMessageId(), eventType: 'removed', @@ -169,7 +152,6 @@ chrome.tabs.onUpdated.addListener((tabId, changeInfo) => { if (!changeInfo.status) return const message: TabEventMessage = { - isPageAgentMessage: true, type: 'tab:event', id: generateMessageId(), eventType: 'updated', @@ -189,7 +171,6 @@ chrome.tabs.onUpdated.addListener((tabId, changeInfo) => { */ chrome.tabs.onActivated.addListener((activeInfo) => { const message: TabEventMessage = { - isPageAgentMessage: true, type: 'tab:event', id: generateMessageId(), eventType: 'activated', @@ -210,7 +191,6 @@ chrome.windows.onFocusChanged.addListener((windowId) => { // windowId is chrome.windows.WINDOW_ID_NONE (-1) when all windows lose focus const focused = windowId !== chrome.windows.WINDOW_ID_NONE const message: TabEventMessage = { - isPageAgentMessage: true, type: 'tab:event', id: generateMessageId(), eventType: 'windowFocusChanged', diff --git a/packages/extension/src/entrypoints/content.ts b/packages/extension/src/entrypoints/content.ts index baae6fc..3eab263 100644 --- a/packages/extension/src/entrypoints/content.ts +++ b/packages/extension/src/entrypoints/content.ts @@ -81,7 +81,6 @@ async function queryShouldShowMask(getController: () => PageController): Promise const queryId = generateMessageId() const queryMessage: CSQueryMessage = { - isPageAgentMessage: true, type: 'cs:query', id: queryId, queryType: 'shouldShowMask', diff --git a/packages/extension/src/messaging/protocol.ts b/packages/extension/src/messaging/protocol.ts index 18a13de..b15f118 100644 --- a/packages/extension/src/messaging/protocol.ts +++ b/packages/extension/src/messaging/protocol.ts @@ -1,13 +1,13 @@ /** * Message Protocol for PageAgentExt * - * NEW ARCHITECTURE (MV3 compliant): + * MV3 Compliant Architecture: * - SidePanel hosts the agent, all state lives there * - Background (SW) is a stateless message relay * - Content Script runs PageController * * Message flows: - * 1. RPC: SidePanel → SW → ContentScript → SW → SidePanel (PageController calls) + * 1. RPC: SidePanel → SW → ContentScript → sendResponse (PageController calls) * 2. Query: ContentScript → SW → SidePanel → SW → ContentScript (mask state check) * 3. Events: SW → SidePanel (tab events from chrome.tabs API) */ @@ -52,8 +52,7 @@ export interface ScrollHorizontallyOptions { /** Message type identifier */ type MessageType = - | 'rpc:call' // SidePanel → SW: RPC call to content script - | 'rpc:response' // SW → SidePanel: RPC response from content script + | 'rpc:call' // SidePanel → SW: RPC call to content script (response via sendResponse) | 'cs:rpc' // SW → ContentScript: Forwarded RPC call | 'cs:query' // ContentScript → SW: Query to sidepanel | 'query:response' // SW → ContentScript: Query response @@ -61,7 +60,6 @@ type MessageType = /** Base message structure */ interface BaseMessage { - isPageAgentMessage: true type: MessageType id: string // Unique message ID for request-response matching } @@ -78,14 +76,6 @@ export interface RPCCallMessage extends BaseMessage { args: unknown[] } -/** SW → SidePanel: Response from PageController */ -export interface RPCResponseMessage extends BaseMessage { - type: 'rpc:response' - success: boolean - result?: unknown - error?: string -} - /** SW → ContentScript: Forwarded RPC call */ export interface CSRPCMessage extends BaseMessage { type: 'cs:rpc' @@ -143,7 +133,6 @@ export interface TabEventMessage extends BaseMessage { /** All message types */ export type ExtensionMessage = | RPCCallMessage - | RPCResponseMessage | CSRPCMessage | CSQueryMessage | QueryResponseMessage @@ -158,12 +147,16 @@ export function generateMessageId(): string { return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}` } -/** Type guard for our messages */ +/** Known message types for type guard */ +const MESSAGE_TYPES = new Set([ + 'rpc:call', + 'cs:rpc', + 'cs:query', + 'query:response', + 'tab:event', +]) + +/** Type guard - checks if message has a known type */ export function isExtensionMessage(msg: unknown): msg is ExtensionMessage { - return ( - typeof msg === 'object' && - msg !== null && - 'isPageAgentMessage' in msg && - (msg as any).isPageAgentMessage === true - ) + return typeof msg === 'object' && msg !== null && MESSAGE_TYPES.has((msg as any).type) } diff --git a/packages/extension/src/messaging/rpc.ts b/packages/extension/src/messaging/rpc.ts index fa82b19..ddca2e8 100644 --- a/packages/extension/src/messaging/rpc.ts +++ b/packages/extension/src/messaging/rpc.ts @@ -4,17 +4,18 @@ * This module provides RPC functionality from SidePanel to ContentScript * via the Background (SW) relay. * - * Flow: SidePanel → SW (relay) → ContentScript → SW → SidePanel + * 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 RPCResponseMessage, type ScrollHorizontallyOptions, type ScrollOptions, generateMessageId, - isExtensionMessage, } from './protocol' /** RPC configuration */ @@ -23,52 +24,6 @@ const RPC_CONFIG = { maxRetries: 3, /** Base delay between retries in ms (exponential backoff) */ retryDelayMs: 500, - /** Timeout for individual RPC call in ms */ - callTimeoutMs: 30000, -} - -/** Pending RPC calls waiting for response */ -const pendingCalls = new Map< - string, - { - resolve: (value: unknown) => void - reject: (error: Error) => void - timeout: ReturnType - } ->() - -/** Whether the response listener is registered */ -let listenerRegistered = false - -/** - * Register the RPC response listener (called once) - */ -function ensureResponseListener(): void { - if (listenerRegistered) return - listenerRegistered = true - - chrome.runtime.onMessage.addListener((message: unknown) => { - if (!isExtensionMessage(message)) return - if (message.type !== 'rpc:response') return - - const response = message as RPCResponseMessage - const pending = pendingCalls.get(response.id) - if (!pending) { - console.debug('[RPC] Received response for unknown call:', response.id) - return - } - - pendingCalls.delete(response.id) - clearTimeout(pending.timeout) - - if (response.success) { - pending.resolve(response.result) - } else { - pending.reject(new Error(response.error || 'RPC call failed')) - } - }) - - console.debug('[RPC] Response listener registered') } /** @@ -96,43 +51,40 @@ async function tabExists(tabId: number): Promise { export class RPCError extends Error { constructor( message: string, - public readonly code: 'TAB_CLOSED' | 'CONTENT_SCRIPT_NOT_READY' | 'RPC_FAILED' | 'TIMEOUT' + 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 { - ensureResponseListener() - - const id = generateMessageId() const message: RPCCallMessage = { - isPageAgentMessage: true, type: 'rpc:call', - id, + id: generateMessageId(), tabId, method, args, } - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - pendingCalls.delete(id) - reject(new RPCError(`RPC ${method} timed out`, 'TIMEOUT')) - }, RPC_CONFIG.callTimeoutMs) + const response = (await chrome.runtime.sendMessage(message)) as RPCResponse - pendingCalls.set(id, { resolve, reject, timeout }) - - chrome.runtime.sendMessage(message).catch((error: Error) => { - pendingCalls.delete(id) - clearTimeout(timeout) - reject(error) - }) - }) + if (response?.success) { + return response.result + } else { + throw new Error(response?.error || 'RPC call failed') + } } /**