Files
browser-cli/n8n-nodes-browser-cli/nodes/BrowserCli/request.ts
T
daniel156161 b91b29d516
Testing / remote-protocol-compat (0.9.3) (push) Successful in 46s
Testing / remote-protocol-compat (0.9.5) (push) Successful in 45s
Testing / test (push) Successful in 40s
feat(n8n): expand browser-cli node operations
- Add UI resources and mappings for groups, windows, sessions, storage, performance, extension, and more tab/DOM/page actions.

- Remove the synthetic Gateway Health operation so exposed operations match real browser-cli commands.

- Document the expanded command/policy matrix and cover the new request mappings with tests.

- Cap the node SVG icon at 60x60, bump the n8n package to 0.3.0, and advertise client protocol version 0.16.0.
2026-06-19 11:55:36 +02:00

304 lines
13 KiB
TypeScript

/**
* 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.
*
* Command names and argument shapes mirror the Python SDK (browser_cli/sdk/*)
* and the server-side policy in browser_cli/command_security.py. Gating per
* operation is documented in BrowserCli.node.ts next to each operation.
*/
export type CommandParams = Record<string, unknown>;
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<string, unknown>;
}
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<string, unknown>): Record<string, unknown> {
const out: Record<string, unknown> = {};
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<string, unknown>) ?? {} };
// --- Tabs -------------------------------------------------------------
case 'tab:list':
return { command: 'tabs.list', args: {} };
case 'tab:query':
return { command: 'tabs.query', args: { search: str(params, 'search') } };
case 'tab:get':
return { command: 'tabs.status', args: compact({ tabId: tabIdArg(params.tabId) }) };
case 'tab:count':
return { command: 'tabs.count', args: compact({ pattern: str(params, 'pattern') }) };
case 'tab:filter':
return { command: 'tabs.filter', args: { pattern: str(params, 'pattern') } };
case 'tab:activeInWindow':
return { command: 'tabs.active_in_window', args: { windowId: numArg(params.windowId) } };
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) }) };
case 'tab:activate':
return { command: 'tabs.active', args: { tabId: numArg(params.tabId) } };
case 'tab:move':
return {
command: 'tabs.move',
args: compact({
tabId: numArg(params.tabId),
windowId: tabIdArg(params.windowId),
index: indexArg(params.index),
}),
};
case 'tab:reload':
return { command: 'navigate.reload', args: compact({ tabId: tabIdArg(params.tabId) }) };
case 'tab:hardReload':
return { command: 'navigate.hard_reload', args: compact({ tabId: tabIdArg(params.tabId) }) };
case 'tab:back':
return { command: 'navigate.back', args: compact({ tabId: tabIdArg(params.tabId) }) };
case 'tab:forward':
return { command: 'navigate.forward', args: compact({ tabId: tabIdArg(params.tabId) }) };
case 'tab:navigateTo':
return { command: 'navigate.to', args: { tabId: numArg(params.tabId), url: str(params, 'url') } };
case 'tab:mute':
return { command: 'tabs.mute', args: compact({ tabId: tabIdArg(params.tabId) }) };
case 'tab:unmute':
return { command: 'tabs.unmute', args: compact({ tabId: tabIdArg(params.tabId) }) };
case 'tab:pin':
return { command: 'tabs.pin', args: compact({ tabId: tabIdArg(params.tabId) }) };
case 'tab:unpin':
return { command: 'tabs.unpin', args: compact({ tabId: tabIdArg(params.tabId) }) };
case 'tab:dedupe':
return { command: 'tabs.dedupe', args: { gentleMode: str(params, 'gentleMode') || 'auto' } };
case 'tab:sort':
return { command: 'tabs.sort', args: { by: str(params, 'by') || 'domain', gentleMode: str(params, 'gentleMode') || 'auto' } };
case 'tab:mergeWindows':
return { command: 'tabs.merge_windows', args: { gentleMode: str(params, 'gentleMode') || 'auto' } };
case 'tab:screenshot':
return {
command: 'tabs.screenshot',
args: compact({
tabId: tabIdArg(params.tabId),
format: str(params, 'format') || 'png',
quality: indexArg(params.quality),
}),
};
// --- 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') }) };
case 'page:extractJson':
return { command: 'extract.json', args: { 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:attr':
return { command: 'dom.attr', args: { selector: str(params, 'selector'), attr: str(params, 'attr') } };
case 'dom:text':
return { command: 'dom.text', args: { selector: str(params, 'selector') } };
case 'dom:exists':
return { command: 'dom.exists', args: { selector: str(params, 'selector') } };
case 'dom:scroll':
return {
command: 'dom.scroll',
args: compact({ selector: str(params, 'selector'), x: indexArg(params.x), y: indexArg(params.y) }),
};
case 'dom:select':
return { command: 'dom.select', args: { selector: str(params, 'selector'), value: str(params, 'value') } };
case 'dom:hover':
return { command: 'dom.hover', args: { selector: str(params, 'selector') } };
case 'dom:check':
return { command: 'dom.check', args: { selector: str(params, 'selector') } };
case 'dom:uncheck':
return { command: 'dom.uncheck', args: { selector: str(params, 'selector') } };
case 'dom:clear':
return { command: 'dom.clear', args: { selector: str(params, 'selector') } };
case 'dom:focus':
return { command: 'dom.focus', args: { selector: str(params, 'selector') } };
case 'dom:submit':
return { command: 'dom.submit', args: { selector: str(params, 'selector') } };
case 'dom:key':
return { command: 'dom.key', args: compact({ key: str(params, 'key'), selector: str(params, 'selector') }) };
case 'dom:eval':
return { command: 'dom.eval', args: compact({ code: str(params, 'code'), tabId: tabIdArg(params.tabId) }) };
// --- Groups -----------------------------------------------------------
case 'group:list':
return { command: 'group.list', args: {} };
case 'group:query':
return { command: 'group.query', args: { search: str(params, 'search') } };
case 'group:tabs':
return { command: 'group.tabs', args: { groupId: numArg(params.groupId) } };
case 'group:count':
return { command: 'group.count', args: {} };
case 'group:create':
return { command: 'group.open', args: { name: str(params, 'name') } };
case 'group:addTab':
return { command: 'group.add_tab', args: compact({ group: str(params, 'group'), url: str(params, 'url') }) };
case 'group:move': {
const direction = str(params, 'direction');
return {
command: 'group.move',
args: { group: str(params, 'group'), forward: direction === 'forward', backward: direction === 'backward' },
};
}
case 'group:close':
return { command: 'group.close', args: { groupId: numArg(params.groupId), gentleMode: str(params, 'gentleMode') || 'auto' } };
// --- Windows ----------------------------------------------------------
case 'window:list':
return { command: 'windows.list', args: {} };
case 'window:open':
return { command: 'windows.open', args: compact({ url: str(params, 'url') }) };
case 'window:close':
return { command: 'windows.close', args: { windowId: numArg(params.windowId) } };
case 'window:rename':
return { command: 'windows.rename', args: { windowId: numArg(params.windowId), name: str(params, 'name') } };
// --- Sessions ---------------------------------------------------------
case 'session:list':
return { command: 'session.list', args: {} };
case 'session:save':
return { command: 'session.save', args: { name: str(params, 'name') } };
case 'session:load':
return { command: 'session.load', args: { name: str(params, 'name') } };
case 'session:remove':
return { command: 'session.remove', args: { name: str(params, 'name') } };
case 'session:export':
return { command: 'session.export', args: compact({ name: str(params, 'name') }) };
case 'session:diff':
return { command: 'session.diff', args: { nameA: str(params, 'nameA'), nameB: str(params, 'nameB') } };
case 'session:autoSave':
return { command: 'session.auto_save', args: { enabled: Boolean(params.enabled) } };
// --- Storage ----------------------------------------------------------
case 'storage:get':
return {
command: 'storage.get',
args: compact({ key: str(params, 'key'), type: str(params, 'storeType') || 'local', tabId: tabIdArg(params.tabId) }),
};
case 'storage:set':
return {
command: 'storage.set',
args: compact({
key: str(params, 'key'),
value: str(params, 'value'),
type: str(params, 'storeType') || 'local',
tabId: tabIdArg(params.tabId),
}),
};
// --- Performance ------------------------------------------------------
case 'perf:status':
return { command: 'perf.status', args: {} };
// --- Extension --------------------------------------------------------
case 'extension:info':
return { command: 'extension.info', args: {} };
case 'extension:capabilities':
return { command: 'extension.capabilities', args: {} };
case 'extension:reload':
return { command: 'extension.reload', args: {} };
// --- Clients ----------------------------------------------------------
case 'client:list':
return { command: 'clients.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;
}
/** A required numeric arg; non-finite values fall through as 0. */
function numArg(value: unknown): number {
const n = Number(value);
return Number.isFinite(n) ? n : 0;
}
/** An optional numeric arg that keeps 0 (a meaningful index/coordinate). */
function indexArg(value: unknown): number | undefined {
if (value === undefined || value === null || value === '') return undefined;
const n = Number(value);
return Number.isFinite(n) ? 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);
}