/** * Pure (resource, operation) -> browser-cli command mapping. * * This module imports nothing from n8n so it can be unit-tested with a plain * esbuild/node toolchain. The node layer collects UI parameters into a plain * object and asks here for the command + args to run over the `serve` socket * (see `serveClient.ts`). Every operation maps to one raw extension command; * what the server returns is the *raw* command result (no SDK-side rendering), * still subject to the server's --allow-* policy noted per operation. */ export type CommandParams = Record; export interface BrowserCommand { /** Raw browser-cli command name, e.g. "tabs.list" or "navigate.open". */ command: string; /** Argument object forwarded to the command. */ args: Record; } function str(params: CommandParams, key: string): string { const value = params[key]; return value === undefined || value === null ? '' : String(value); } /** Drop keys whose value is undefined/null/"" so we don't send empty args. */ function compact(args: Record): Record { const out: Record = {}; for (const [key, value] of Object.entries(args)) { if (value !== undefined && value !== null && value !== '') out[key] = value; } return out; } /** * Map a (resource, operation) pair plus collected parameters to a single raw * browser-cli command. Throws on an unknown pairing so the node fails loudly * rather than silently issuing a wrong call. */ export function buildCommand( resource: string, operation: string, params: CommandParams, ): BrowserCommand { const key = `${resource}:${operation}`; switch (key) { // --- Raw escape hatch ------------------------------------------------- case 'command:execute': return { command: str(params, 'command'), args: (params.args as Record) ?? {} }; // --- Tabs ------------------------------------------------------------- case 'tab:list': return { command: 'tabs.list', args: {} }; case 'tab:open': { const focus = Boolean(params.focus); return { command: 'navigate.open', args: compact({ url: str(params, 'url'), focus, background: !focus }) }; } case 'tab:close': { const mode = str(params, 'mode') || 'ids'; if (mode === 'inactive') return { command: 'tabs.close', args: { inactive: true } }; if (mode === 'duplicates') return { command: 'tabs.close', args: { duplicates: true } }; return { command: 'tabs.close', args: { tabIds: parseTabIds(params.tabIds) } }; } case 'tab:getHtml': return { command: 'tabs.html', args: compact({ tabId: tabIdArg(params.tabId) }) }; // --- Page / extraction ------------------------------------------------ case 'page:info': return { command: 'page.info', args: {} }; case 'page:extractText': return { command: 'extract.text', args: compact({ selector: str(params, 'selector') }) }; case 'page:extractLinks': return { command: 'extract.links', args: compact({ selector: str(params, 'selector') }) }; case 'page:extractImages': return { command: 'extract.images', args: compact({ selector: str(params, 'selector') }) }; case 'page:extractHtml': return { command: 'extract.html', args: compact({ selector: str(params, 'selector') }) }; case 'page:extractMarkdown': return { command: 'extract.markdown', args: compact({ selector: str(params, 'selector') }) }; // --- DOM -------------------------------------------------------------- case 'dom:query': return { command: 'dom.query', args: { selector: str(params, 'selector') } }; case 'dom:click': return { command: 'dom.click', args: { selector: str(params, 'selector') } }; case 'dom:type': return { command: 'dom.type', args: { selector: str(params, 'selector'), text: str(params, 'text') } }; case 'dom:eval': return { command: 'dom.eval', args: compact({ code: str(params, 'code'), tabId: tabIdArg(params.tabId) }) }; // --- Clients ---------------------------------------------------------- case 'client:list': return { command: 'clients.list', args: {} }; // --- Gateway: serve has no health route, so ping with a safe command -- case 'gateway:health': return { command: 'tabs.list', args: {} }; default: throw new Error(`Unsupported operation "${operation}" for resource "${resource}"`); } } /** A tab ID arg; 0 (the UI default) means "active tab", so it is omitted. */ function tabIdArg(value: unknown): number | undefined { if (value === undefined || value === null || value === '') return undefined; const n = Number(value); return Number.isFinite(n) && n > 0 ? n : undefined; } /** Accept an array, a JSON array string, or a comma/space separated list. */ export function parseTabIds(value: unknown): number[] { if (Array.isArray(value)) return value.map(Number).filter(Number.isFinite); if (typeof value === 'number') return [value]; const raw = String(value ?? '').trim(); if (!raw) return []; if (raw.startsWith('[')) { try { const parsed = JSON.parse(raw); if (Array.isArray(parsed)) return parsed.map(Number).filter(Number.isFinite); } catch { /* fall through to split */ } } return raw .split(/[\s,]+/) .map((part) => Number(part)) .filter(Number.isFinite); }