import type { ICredentialsDecrypted, ICredentialTestFunctions, IExecuteFunctions, IDataObject, IDisplayOptions, INodeCredentialTestResult, INodeExecutionData, INodeType, INodeTypeDescription, } from 'n8n-workflow'; import { NodeConnectionTypes, NodeOperationError } from 'n8n-workflow'; import { buildCommand, type CommandParams } from './request'; import { sendServeCommand, type ServeConnectOptions } from './serveClient'; /** Only show a property for the given resource/operation combinations. */ function showFor(resource: string, operations: string[]): NonNullable { return { resource: [resource], operation: operations }; } export class BrowserCli implements INodeType { description: INodeTypeDescription = { displayName: 'Browser CLI', name: 'browserCli', icon: 'file:browserCli.svg', group: ['transform'], version: 1, subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', description: 'Control a remote browser by talking directly to a browser-cli serve endpoint', defaults: { name: 'Browser CLI' }, inputs: [NodeConnectionTypes.Main], outputs: [NodeConnectionTypes.Main], usableAsTool: true, credentials: [{ name: 'browserCliApi', required: true }], properties: [ { displayName: 'Resource', name: 'resource', type: 'options', noDataExpression: true, options: [ { name: 'Tab', value: 'tab' }, { name: 'Page', value: 'page' }, { name: 'DOM', value: 'dom' }, { name: 'Client', value: 'client' }, { name: 'Command', value: 'command' }, { name: 'Gateway', value: 'gateway' }, ], default: 'tab', }, // --- Tab operations --------------------------------------------------- { displayName: 'Operation', name: 'operation', type: 'options', noDataExpression: true, displayOptions: { show: { resource: ['tab'] } }, options: [ { name: 'List', value: 'list', action: 'List open tabs', description: 'tabs.list (safe)' }, { name: 'Open', value: 'open', action: 'Open a URL in a new tab', description: 'navigate.open (needs --allow-control)' }, { name: 'Close', value: 'close', action: 'Close tabs', description: 'tabs.close (needs --allow-control)' }, { name: 'Get HTML', value: 'getHtml', action: 'Get a tab raw HTML', description: 'tabs.html (needs --allow-read-page)' }, ], default: 'list', }, // --- Page operations -------------------------------------------------- { displayName: 'Operation', name: 'operation', type: 'options', noDataExpression: true, displayOptions: { show: { resource: ['page'] } }, options: [ { name: 'Get Info', value: 'info', action: 'Get page info', description: 'page.info (safe)' }, { name: 'Extract Text', value: 'extractText', action: 'Extract visible text', description: 'extract.text (needs --allow-read-page)' }, { name: 'Extract Links', value: 'extractLinks', action: 'Extract links', description: 'extract.links (needs --allow-read-page)' }, { name: 'Extract Images', value: 'extractImages', action: 'Extract images', description: 'extract.images (needs --allow-read-page)' }, { name: 'Extract HTML', value: 'extractHtml', action: 'Extract HTML', description: 'extract.html (needs --allow-read-page)' }, { name: 'Extract Markdown', value: 'extractMarkdown', action: 'Extract Markdown payload', description: 'extract.markdown — returns the raw page payload (not SDK-rendered) (needs --allow-read-page)' }, ], default: 'extractText', }, // --- DOM operations --------------------------------------------------- { displayName: 'Operation', name: 'operation', type: 'options', noDataExpression: true, displayOptions: { show: { resource: ['dom'] } }, options: [ { name: 'Query', value: 'query', action: 'Query elements by selector', description: 'dom.query (needs --allow-read-page)' }, { name: 'Click', value: 'click', action: 'Click an element', description: 'dom.click (needs --allow-control)' }, { name: 'Type', value: 'type', action: 'Type into an element', description: 'dom.type (needs --allow-control)' }, { name: 'Eval', value: 'eval', action: 'Evaluate JavaScript', description: 'dom.eval (needs --allow-dangerous)' }, ], default: 'query', }, // --- Client operations ------------------------------------------------ { displayName: 'Operation', name: 'operation', type: 'options', noDataExpression: true, displayOptions: { show: { resource: ['client'] } }, options: [ { name: 'List', value: 'list', action: 'List connected browser clients', description: 'clients.list (safe)' }, ], default: 'list', }, // --- Command operations ----------------------------------------------- { displayName: 'Operation', name: 'operation', type: 'options', noDataExpression: true, displayOptions: { show: { resource: ['command'] } }, options: [ { name: 'Execute', value: 'execute', action: 'Execute a raw browser-cli command', description: 'Any command name (subject to server policy)' }, ], default: 'execute', }, // --- Gateway operations ----------------------------------------------- { displayName: 'Operation', name: 'operation', type: 'options', noDataExpression: true, displayOptions: { show: { resource: ['gateway'] } }, options: [ { name: 'Health', value: 'health', action: 'Check serve connectivity', description: 'Pings the endpoint with tabs.list (safe)' }, ], default: 'health', }, // --- Shared parameter fields ----------------------------------------- { displayName: 'URL', name: 'url', type: 'string', default: '', required: true, placeholder: 'https://example.com', displayOptions: { show: showFor('tab', ['open']) }, }, { displayName: 'Focus Tab', name: 'focus', type: 'boolean', default: false, description: 'Whether to focus the new tab/window (steals OS focus). Off opens in the background.', displayOptions: { show: showFor('tab', ['open']) }, }, { displayName: 'Close By', name: 'mode', type: 'options', default: 'ids', options: [ { name: 'Tab IDs', value: 'ids' }, { name: 'Inactive Tabs', value: 'inactive' }, { name: 'Duplicate Tabs', value: 'duplicates' }, ], displayOptions: { show: showFor('tab', ['close']) }, }, { displayName: 'Tab IDs', name: 'tabIds', type: 'string', default: '', placeholder: '123, 456', description: 'Comma/space separated tab IDs, or a JSON array', displayOptions: { show: { resource: ['tab'], operation: ['close'], mode: ['ids'] } }, }, { displayName: 'Tab ID', name: 'tabId', type: 'number', default: 0, description: 'Target tab ID. Leave 0 for the active tab.', displayOptions: { show: { resource: ['tab'], operation: ['getHtml'] } }, }, { displayName: 'Selector', name: 'selector', type: 'string', default: '', placeholder: '#main, .content', description: 'CSS selector. Leave empty on extract operations to use the whole page.', displayOptions: { show: { resource: ['dom', 'page'], operation: ['query', 'click', 'type', 'extractText', 'extractLinks', 'extractImages', 'extractHtml', 'extractMarkdown'], }, }, }, { displayName: 'Text', name: 'text', type: 'string', default: '', required: true, displayOptions: { show: showFor('dom', ['type']) }, }, { displayName: 'JavaScript', name: 'code', type: 'string', typeOptions: { rows: 4 }, default: '', required: true, placeholder: 'return document.title', description: 'Evaluated in the page (dom.eval). The gateway must be started with --allow-dangerous.', displayOptions: { show: showFor('dom', ['eval']) }, }, { displayName: 'Tab ID', name: 'tabId', type: 'number', default: 0, description: 'Target tab ID. Leave 0 for the active tab.', displayOptions: { show: { resource: ['dom'], operation: ['eval'] } }, }, { displayName: 'Command', name: 'command', type: 'string', default: '', required: true, placeholder: 'tabs.list', description: 'Raw browser-cli command name, e.g. tabs.list, navigate.open, extract.markdown', displayOptions: { show: showFor('command', ['execute']) }, }, { displayName: 'Arguments', name: 'args', type: 'json', default: '{}', description: 'JSON object of command arguments', displayOptions: { show: showFor('command', ['execute']) }, }, ], }; methods = { credentialTest: { // Runs a real authenticated handshake so "Test" in the credential UI // reflects the actual serve protocol, not a stand-in HTTP request. async browserCliApiTest( this: ICredentialTestFunctions, credential: ICredentialsDecrypted, ): Promise { const options = connectOptionsFromCredentials((credential.data ?? {}) as IDataObject); options.timeoutMs = 10_000; try { const response = await sendServeCommand(options, 'clients.list', {}); if (response && response.success === false) { const message = String(response.error ?? 'serve returned an error'); // A rejected key is a real credential failure; any other server-side // message means the handshake + auth already succeeded. if (/unauthorized|untrusted|invalid signature|pubkey auth required/i.test(message)) { return { status: 'Error', message }; } return { status: 'OK', message: `Connected and authenticated. Note: ${message.split('\n')[0]}` }; } return { status: 'OK', message: 'Connected to browser-cli serve' }; } catch (error) { return { status: 'Error', message: (error as Error).message }; } }, }, }; async execute(this: IExecuteFunctions): Promise { const items = this.getInputData(); const returnData: INodeExecutionData[] = []; const connectOptions = connectOptionsFromCredentials( (await this.getCredentials('browserCliApi')) as IDataObject, ); for (let i = 0; i < items.length; i++) { try { const resource = this.getNodeParameter('resource', i) as string; const operation = this.getNodeParameter('operation', i) as string; const params = collectParams(this, resource, operation, i); const { command, args } = buildCommand(resource, operation, params); const response = await sendServeCommand(connectOptions, command, args); if (response && response.success === false) { throw new Error(String(response.error || 'serve command failed')); } const data = response && typeof response === 'object' && 'data' in response ? response.data : response; const rows = Array.isArray(data) ? data : [data]; for (const row of rows) { returnData.push({ json: isObject(row) ? (row as IDataObject) : { result: row }, pairedItem: { item: i }, }); } } catch (error) { if (this.continueOnFail()) { returnData.push({ json: { error: (error as Error).message }, pairedItem: { item: i } }); continue; } throw new NodeOperationError(this.getNode(), error as Error, { itemIndex: i }); } } return [returnData]; } } function isObject(value: unknown): boolean { return typeof value === 'object' && value !== null; } /** Map decrypted credential fields to serve connection options. */ function connectOptionsFromCredentials(creds: IDataObject): ServeConnectOptions { return { host: String(creds.host || '127.0.0.1'), port: Number(creds.port || 8765), tls: Boolean(creds.tls), rejectUnauthorized: !creds.allowUnauthorizedCerts, privateKeyPem: creds.privateKey ? String(creds.privateKey) : null, route: creds.browser ? String(creds.browser) : null, }; } /** Read the UI fields relevant to this operation into a plain params object. */ function collectParams( ctx: IExecuteFunctions, resource: string, operation: string, i: number, ): CommandParams { const get = (name: string, fallback: unknown = undefined) => ctx.getNodeParameter(name, i, fallback); const key = `${resource}:${operation}`; switch (key) { case 'command:execute': { const raw = get('args', {}); let args: Record = {}; if (typeof raw === 'string') { const trimmed = raw.trim(); args = trimmed ? JSON.parse(trimmed) : {}; } else if (isObject(raw)) { args = raw as Record; } return { command: get('command'), args }; } case 'tab:open': return { url: get('url'), focus: get('focus', false) }; case 'tab:close': return { mode: get('mode', 'ids'), tabIds: get('tabIds', '') }; case 'tab:getHtml': return { tabId: get('tabId', 0) }; case 'dom:query': case 'dom:click': return { selector: get('selector', '') }; case 'dom:type': return { selector: get('selector', ''), text: get('text', '') }; case 'dom:eval': return { code: get('code', ''), tabId: get('tabId', 0) }; case 'page:extractText': case 'page:extractLinks': case 'page:extractImages': case 'page:extractHtml': case 'page:extractMarkdown': return { selector: get('selector', '') }; default: return {}; } }