diff --git a/packages/skills/lynx-devtool/SKILL.md b/packages/skills/lynx-devtool/SKILL.md index 253f960..5d70c0c 100644 --- a/packages/skills/lynx-devtool/SKILL.md +++ b/packages/skills/lynx-devtool/SKILL.md @@ -9,6 +9,8 @@ This skill allows you to interact with Lynx applications running on connected de ## Usage +### CLI + The CLI is located at `/scripts/index.mjs` relative to this skill's directory. You can run it using `node`. In the skill directory, use: @@ -17,6 +19,8 @@ In the skill directory, use: node /scripts/index.mjs ``` +When installed as a package, the CLI is also exposed as `lynx-devtool`. + **Note:** All command outputs are multi-line JSON. You can use `jq` or Node.js to process the data. ### Global Options @@ -33,6 +37,33 @@ node /scripts/index.mjs - [Get Sources](examples/get-sources.md): List parsed scripts for later debugger operations. - [Take Screenshot](examples/take-screenshot.md): Capture the current page as a screenshot. +### Node SDK + +Other Node.js projects can import the same core capability used by the CLI: + +```ts +import { createDevtoolClient } from '@lynx-js/skill-lynx-devtool/sdk'; + +const client = createDevtoolClient(); + +try { + const clients = await client.listClients(); + const sessions = await client.listSessions(); + const result = await client.cdp({ + method: 'Runtime.evaluate', + params: { + expression: '2 + 2', + returnByValue: true, + }, + }); + + console.log(result); + console.log({ clients, sessions }); +} finally { + await client.close(); +} +``` + ## Example List - [Inspecting the DOM](examples/inspecting-the-dom.md): Find a session, fetch the document tree, and inspect a specific node. diff --git a/packages/skills/lynx-devtool/package.json b/packages/skills/lynx-devtool/package.json index 43ebc81..5e8a3ae 100644 --- a/packages/skills/lynx-devtool/package.json +++ b/packages/skills/lynx-devtool/package.json @@ -3,6 +3,13 @@ "version": "0.1.0", "private": true, "type": "module", + "exports": { + "./sdk": { + "import": "./scripts/sdk.mjs", + "types": "./scripts/sdk.d.ts", + "default": "./scripts/sdk.mjs" + } + }, "files": [ "SKILL.md", "scripts", diff --git a/packages/skills/lynx-devtool/rslib.config.ts b/packages/skills/lynx-devtool/rslib.config.ts index 4a53647..a5e9491 100644 --- a/packages/skills/lynx-devtool/rslib.config.ts +++ b/packages/skills/lynx-devtool/rslib.config.ts @@ -14,6 +14,30 @@ export default defineConfig({ format: 'esm', syntax: 'es2022', dts: false, + source: { + entry: { + index: './src/index.ts', + }, + }, + output: { + filename: { + js: '[name].mjs', + }, + distPath: './scripts', + }, + autoExtension: false, + }, + { + format: 'esm', + syntax: 'es2022', + dts: { + bundle: true, + }, + source: { + entry: { + sdk: './src/sdk.ts', + }, + }, output: { filename: { js: '[name].mjs', diff --git a/packages/skills/lynx-devtool/src/commands/app.ts b/packages/skills/lynx-devtool/src/commands/app.ts index ac2b419..7ae3330 100644 --- a/packages/skills/lynx-devtool/src/commands/app.ts +++ b/packages/skills/lynx-devtool/src/commands/app.ts @@ -2,11 +2,10 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import type { Connector } from '@lynx-js/devtool-connector'; import type { Command } from 'commander'; -import { getFirstClient } from './utils.ts'; +import type { DevtoolClient } from '../sdk.ts'; -export function registerAppCommand(program: Command, connector: Connector) { +export function registerAppCommand(program: Command, client: DevtoolClient) { program .command('app') .description('Send an App request') @@ -18,14 +17,12 @@ export function registerAppCommand(program: Command, connector: Connector) { .argument('[params]', 'JSON string of parameters') .action(async (paramsStr, options) => { const { method } = options; - let { client: clientId } = options; - - if (!clientId) { - clientId = await getFirstClient(connector); - } - const params = paramsStr ? JSON.parse(paramsStr) : {}; - const result = await connector.sendAppMessage(clientId, method, params); + const result = await client.app({ + clientId: options.client, + method, + params, + }); console.log(JSON.stringify(result, null, 2)); }); } diff --git a/packages/skills/lynx-devtool/src/commands/cdp.ts b/packages/skills/lynx-devtool/src/commands/cdp.ts index c8ce7ba..34a68c2 100644 --- a/packages/skills/lynx-devtool/src/commands/cdp.ts +++ b/packages/skills/lynx-devtool/src/commands/cdp.ts @@ -2,11 +2,10 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import type { Connector } from '@lynx-js/devtool-connector'; import type { Command } from 'commander'; -import { getFirstClient, getLatestSession } from './utils.ts'; +import type { DevtoolClient } from '../sdk.ts'; -export function registerCdpCommand(program: Command, connector: Connector) { +export function registerCdpCommand(program: Command, client: DevtoolClient) { program .command('cdp') .description('Send a CDP request') @@ -25,24 +24,13 @@ export function registerCdpCommand(program: Command, connector: Connector) { .argument('[params]', 'JSON string of parameters') .action(async (paramsStr, options) => { const { method } = options; - let { client: clientId, session: sessionId } = options; - - if (!clientId) { - clientId = await getFirstClient(connector); - } - - if (!sessionId) { - sessionId = await getLatestSession(connector, clientId); - } - const params = paramsStr ? JSON.parse(paramsStr) : {}; - - const result = await connector.sendCDPMessage( - clientId, - Number(sessionId), + const result = await client.cdp({ + clientId: options.client, + sessionId: options.session, method, params, - ); + }); console.log(JSON.stringify(result, null, 2)); }); diff --git a/packages/skills/lynx-devtool/src/commands/get-console.ts b/packages/skills/lynx-devtool/src/commands/get-console.ts index b2e5d1b..f28d6eb 100644 --- a/packages/skills/lynx-devtool/src/commands/get-console.ts +++ b/packages/skills/lynx-devtool/src/commands/get-console.ts @@ -2,41 +2,12 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { ReadableStream } from 'node:stream/web'; -import { setTimeout } from 'node:timers/promises'; -import type { Connector } from '@lynx-js/devtool-connector'; import type { Command } from 'commander'; -import { getFirstClient, getLatestSession } from './utils.ts'; - -interface ConsoleCallFrame { - url: string; - lineNumber: number; - columnNumber: number; -} - -interface ConsoleStackTrace { - callFrames: ConsoleCallFrame[]; -} - -interface ConsoleArg { - type: string; - value?: unknown; - className?: string; - description?: string; - objectId?: string; - subtype?: string; -} - -interface ConsoleMessage { - type: string; - args: ConsoleArg[]; - stackTrace?: ConsoleStackTrace; - url?: string; -} +import type { ConsoleMessage, DevtoolClient } from '../sdk.ts'; export function registerGetConsoleCommand( program: Command, - connector: Connector, + client: DevtoolClient, ) { program .command('get-console') @@ -69,110 +40,41 @@ export function registerGetConsoleCommand( (value) => value.split(',').map((s) => s.trim()), ) .action(async (options) => { - let { client: clientId, session: sessionId, limit } = options; - const { offset = 0, includeStackTraces, level } = options; - - if (limit) { - limit = Math.max(1, Math.min(100, limit)); - } - - if (!clientId) { - clientId = await getFirstClient(connector); - } - - if (!sessionId) { - sessionId = await getLatestSession(connector, clientId); - } - - const numericSessionId = Number(sessionId); - - await using stream = await connector.sendCDPStream( - clientId, - ReadableStream.from([ - { - sessionId: numericSessionId, - method: 'Page.enable', - }, - { - sessionId: numericSessionId, - method: 'Runtime.enable', - }, - ]), - ); - - const messages: ConsoleMessage[] = []; - const defaultLevels = ['info', 'log', 'warning', 'error']; - const allowedLevels = level || defaultLevels; - let skipped = 0; - - const reader = stream.getReader(); - const IDLE_TIMEOUT = 500; - const MAX_TOTAL_TIME = 5000; - const startTime = Date.now(); - - try { - while (Date.now() - startTime < MAX_TOTAL_TIME) { - const result = await Promise.race([ - reader.read(), - setTimeout(IDLE_TIMEOUT, 'timeout' as const), - ]); - if (result === 'timeout') { - await reader.cancel(); - break; - } - - const { done, value } = result; - if (done) break; - - if (value.method === 'Runtime.consoleAPICalled') { - const params = value.params as ConsoleMessage; - if (allowedLevels.includes(params.type)) { - if (skipped < offset) { - skipped++; - continue; - } - - if (!includeStackTraces && params.type !== 'error') { - delete params.stackTrace; - } - - messages.push(params); + const messages = await client.getConsole({ + clientId: options.client, + sessionId: options.session, + offset: options.offset, + limit: options.limit, + includeStackTraces: options.includeStackTraces, + level: options.level, + }); + + console.log(formatConsoleMessages(messages)); + }); +} - if (limit && messages.length >= limit) { - await reader.cancel(); - break; - } +function formatConsoleMessages(messages: ConsoleMessage[]): string { + return messages + .map( + ({ type, args, stackTrace }) => + `- [${type}]: ${args + .map((arg) => { + if (arg.objectId) { + return `<${arg.description || arg.className || 'Object'} (objectId:${arg.objectId})>`; } - } - } - } finally { - reader.releaseLock(); - } - - console.log( - messages - .map( - ({ type, args, stackTrace }) => - `- [${type}]: ${args - .map((arg) => { - if (arg.objectId) { - return `<${arg.description || arg.className || 'Object'} (objectId:${arg.objectId})>`; - } - return arg.value; - }) - .join(' ')}${ - stackTrace - ? '\n' + - stackTrace.callFrames - .map( - ({ url, lineNumber, columnNumber }) => - ` at ${url}:${lineNumber}:${columnNumber}`, - ) - .join('\n') - : '' - }`, - ) - .join('\n'), - ); - }); + return arg.value; + }) + .join(' ')}${ + stackTrace + ? '\n' + + stackTrace.callFrames + .map( + ({ url, lineNumber, columnNumber }) => + ` at ${url}:${lineNumber}:${columnNumber}`, + ) + .join('\n') + : '' + }`, + ) + .join('\n'); } diff --git a/packages/skills/lynx-devtool/src/commands/get-sources.ts b/packages/skills/lynx-devtool/src/commands/get-sources.ts index 675704a..5a04cb8 100644 --- a/packages/skills/lynx-devtool/src/commands/get-sources.ts +++ b/packages/skills/lynx-devtool/src/commands/get-sources.ts @@ -2,21 +2,12 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { ReadableStream } from 'node:stream/web'; -import { setTimeout } from 'node:timers/promises'; -import type { Connector } from '@lynx-js/devtool-connector'; import type { Command } from 'commander'; -import { getFirstClient, getLatestSession } from './utils.ts'; - -interface ScriptParsedEvent { - scriptId: string; - url: string; - [key: string]: unknown; -} +import type { DevtoolClient } from '../sdk.ts'; export function registerGetSourcesCommand( program: Command, - connector: Connector, + client: DevtoolClient, ) { program .command('get-sources') @@ -30,69 +21,10 @@ export function registerGetSourcesCommand( 'Session ID (optional, will auto-discover if not provided)', ) .action(async (options) => { - let { client: clientId, session: sessionId } = options; - - if (!clientId) { - clientId = await getFirstClient(connector); - } - - if (!sessionId) { - sessionId = await getLatestSession(connector, clientId); - } - - const numericSessionId = Number(sessionId); - - const messages: { sessionId: number; method: string }[] = [ - { - sessionId: numericSessionId, - method: 'Debugger.disable', - }, - { - sessionId: numericSessionId, - method: 'Debugger.enable', - }, - ]; - - await using stream = await connector.sendCDPStream( - clientId, - ReadableStream.from(messages), - ); - - const scripts: ScriptParsedEvent[] = []; - - const reader = stream.getReader(); - const IDLE_TIMEOUT = 2000; // Increased timeout for reload - const MAX_TOTAL_TIME = 5000; // Increased max time for reload - const startTime = Date.now(); - - try { - while (Date.now() - startTime < MAX_TOTAL_TIME) { - const result = await Promise.race([ - reader.read(), - setTimeout(IDLE_TIMEOUT, 'timeout' as const), - ]); - if (result === 'timeout') { - await reader.cancel(); - break; - } - - const { done, value } = result; - if (done) break; - - if (value.method === 'Debugger.scriptParsed') { - scripts.push(value.params as ScriptParsedEvent); - } - } - } finally { - reader.releaseLock(); - } - - console.log( - JSON.stringify( - scripts.map(({ scriptId, url }) => ({ scriptId, url })), - null, - 2, - ), - ); + const sources = await client.getSources({ + clientId: options.client, + sessionId: options.session, + }); + console.log(JSON.stringify(sources, null, 2)); }); } diff --git a/packages/skills/lynx-devtool/src/commands/list-clients.ts b/packages/skills/lynx-devtool/src/commands/list-clients.ts index eb05d7f..de20be5 100644 --- a/packages/skills/lynx-devtool/src/commands/list-clients.ts +++ b/packages/skills/lynx-devtool/src/commands/list-clients.ts @@ -2,18 +2,18 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import type { Connector } from '@lynx-js/devtool-connector'; import type { Command } from 'commander'; +import type { DevtoolClient } from '../sdk.ts'; export function registerListClientsCommand( program: Command, - connector: Connector, + client: DevtoolClient, ) { program .command('list-clients') .description('List all available clients') .action(async () => { - const clients = await connector.listClients(); + const clients = await client.listClients(); console.log(JSON.stringify(clients, null, 2)); }); } diff --git a/packages/skills/lynx-devtool/src/commands/list-sessions.ts b/packages/skills/lynx-devtool/src/commands/list-sessions.ts index b3c9e47..1af2ac7 100644 --- a/packages/skills/lynx-devtool/src/commands/list-sessions.ts +++ b/packages/skills/lynx-devtool/src/commands/list-sessions.ts @@ -2,13 +2,12 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import type { Connector } from '@lynx-js/devtool-connector'; import type { Command } from 'commander'; -import { getFirstClient } from './utils.ts'; +import type { DevtoolClient } from '../sdk.ts'; export function registerListSessionsCommand( program: Command, - connector: Connector, + client: DevtoolClient, ) { program .command('list-sessions') @@ -18,13 +17,9 @@ export function registerListSessionsCommand( 'Client ID (optional, will auto-discover if not provided)', ) .action(async (options) => { - let { client: clientId } = options; - - if (!clientId) { - clientId = await getFirstClient(connector); - } - - const sessions = await connector.sendListSessionMessage(clientId); + const sessions = await client.listSessions({ + clientId: options.client, + }); console.log(JSON.stringify(sessions, null, 2)); }); } diff --git a/packages/skills/lynx-devtool/src/commands/open.ts b/packages/skills/lynx-devtool/src/commands/open.ts index 987bb9b..2d33aeb 100644 --- a/packages/skills/lynx-devtool/src/commands/open.ts +++ b/packages/skills/lynx-devtool/src/commands/open.ts @@ -2,11 +2,10 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import type { Connector } from '@lynx-js/devtool-connector'; import type { Command } from 'commander'; -import { getFirstClient } from './utils.ts'; +import type { DevtoolClient } from '../sdk.ts'; -export function registerOpenCommand(program: Command, connector: Connector) { +export function registerOpenCommand(program: Command, client: DevtoolClient) { program .command('open') .description('Open page') @@ -16,38 +15,10 @@ export function registerOpenCommand(program: Command, connector: Connector) { ) .argument('', 'The url of the page') .action(async (url, options) => { - let { client: clientId } = options; - - if (!clientId) { - clientId = await getFirstClient(connector); - } - - const openCardMessage = { - event: 'Customized', - data: { - type: 'OpenCard', - data: { - type: 'url', - url, - }, - sender: -1, - }, - from: -1, - } as const; - - let result: unknown; - try { - result = await connector.sendMessage(clientId, openCardMessage); - } catch (error) { - console.warn( - `OpenCard failed, falling back to App.openPage for ${url}`, - error, - ); - result = await connector.sendAppMessage(clientId, 'App.openPage', { - url, - }); - } - + const result = await client.open({ + clientId: options.client, + url, + }); console.log(JSON.stringify(result, null, 2)); }); } diff --git a/packages/skills/lynx-devtool/src/commands/take-screenshot.ts b/packages/skills/lynx-devtool/src/commands/take-screenshot.ts index 62a64d8..52adafd 100644 --- a/packages/skills/lynx-devtool/src/commands/take-screenshot.ts +++ b/packages/skills/lynx-devtool/src/commands/take-screenshot.ts @@ -2,16 +2,12 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import fs from 'node:fs/promises'; -import { ReadableStream } from 'node:stream/web'; -import { setTimeout } from 'node:timers/promises'; -import type { Connector } from '@lynx-js/devtool-connector'; import type { Command } from 'commander'; -import { getFirstClient, getLatestSession } from './utils.ts'; +import type { DevtoolClient } from '../sdk.ts'; export function registerTakeScreenshotCommand( program: Command, - connector: Connector, + client: DevtoolClient, ) { program .command('take-screenshot') @@ -33,64 +29,12 @@ export function registerTakeScreenshotCommand( 'Output file path (default: screenshot-.jpeg)', ) .action(async (options) => { - let { client: clientId, session: sessionId } = options; - const { output, fullscreen } = options; - - if (!clientId) { - clientId = await getFirstClient(connector); - } - - if (!sessionId) { - sessionId = await getLatestSession(connector, clientId); - } - - const numericSessionId = Number(sessionId); - const signal = AbortSignal.timeout(10_000); - const { promise, resolve } = Promise.withResolvers(); - - await using stream = await connector.sendCDPStream( - clientId, - new ReadableStream({ - async start(controller) { - controller.enqueue({ - method: 'Page.startScreencast', - params: { - format: 'jpeg', - quality: 80, - mode: fullscreen ? 'fullscreen' : 'lynxview', - }, - sessionId: numericSessionId, - }); - await Promise.race([ - promise, - setTimeout(10_000, void 0, { ref: false }), - ]); - controller.enqueue({ - method: 'Page.stopScreencast', - sessionId: numericSessionId, - }); - controller.close(); - }, - }), - { signal }, - ); - - for await (const { method, params: eventParams } of stream) { - if (method === 'Page.screencastFrame') { - const { data } = eventParams as { data: string }; - if (data) { - resolve(); - - const fileName = output ?? `screenshot-${Date.now()}.jpeg`; - await fs.writeFile(fileName, Buffer.from(data, 'base64')); - console.log(`Screenshot saved to ${fileName}`); - return; - } - } - } - - throw new Error( - 'Failed to capture screenshot, no Page.screencastFrame event received within 10 seconds.', - ); + const result = await client.takeScreenshot({ + clientId: options.client, + sessionId: options.session, + output: options.output, + fullscreen: options.fullscreen, + }); + console.log(`Screenshot saved to ${result.output}`); }); } diff --git a/packages/skills/lynx-devtool/src/commands/utils.ts b/packages/skills/lynx-devtool/src/commands/utils.ts deleted file mode 100644 index 008a6d9..0000000 --- a/packages/skills/lynx-devtool/src/commands/utils.ts +++ /dev/null @@ -1,27 +0,0 @@ -// Copyright 2026 The Lynx Authors. All rights reserved. -// Licensed under the Apache License Version 2.0 that can be found in the -// LICENSE file in the root directory of this source tree. -import type { Connector } from '@lynx-js/devtool-connector'; - -export async function getFirstClient(connector: Connector): Promise { - const clients = await connector.listClients(); - const firstClient = clients[0]; - if (!firstClient) { - throw new Error('No available clients found.'); - } - return firstClient.id; -} - -export async function getLatestSession( - connector: Connector, - clientId: string, -): Promise { - const sessions = await connector.sendListSessionMessage(clientId); - if (!sessions || sessions.length === 0) { - throw new Error(`No available sessions found for client: ${clientId}`); - } - const latestSession = sessions.reduce((max, session) => - session.session_id > max.session_id ? session : max, - ); - return String(latestSession.session_id); -} diff --git a/packages/skills/lynx-devtool/src/devtool.ts b/packages/skills/lynx-devtool/src/devtool.ts index c4fd88b..e4ead19 100644 --- a/packages/skills/lynx-devtool/src/devtool.ts +++ b/packages/skills/lynx-devtool/src/devtool.ts @@ -1,8 +1,6 @@ // Copyright 2026 The Lynx Authors. All rights reserved. // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import type { Connector } from '@lynx-js/devtool-connector'; -import type { Transport } from '@lynx-js/devtool-connector/transport'; import { Command } from 'commander'; import pkg from '../package.json' with { type: 'json' }; import { registerAppCommand } from './commands/app.ts'; @@ -13,29 +11,24 @@ import { registerListClientsCommand } from './commands/list-clients.ts'; import { registerListSessionsCommand } from './commands/list-sessions.ts'; import { registerOpenCommand } from './commands/open.ts'; import { registerTakeScreenshotCommand } from './commands/take-screenshot.ts'; +import type { DevtoolClient } from './sdk.ts'; -export function createProgram( - connector: Connector, - transports: Transport[], -): Command { +export function createProgram(client: DevtoolClient): Command { const program = new Command(); program .name('devtool') .description('CLI to interact with Lynx DevTool Connector') - .version(pkg.version) - .hook('postAction', async () => { - await Promise.allSettled(transports.map((t) => t.close())); - }); + .version(pkg.version); - registerListClientsCommand(program, connector); - registerListSessionsCommand(program, connector); - registerCdpCommand(program, connector); - registerAppCommand(program, connector); - registerOpenCommand(program, connector); - registerGetConsoleCommand(program, connector); - registerGetSourcesCommand(program, connector); - registerTakeScreenshotCommand(program, connector); + registerListClientsCommand(program, client); + registerListSessionsCommand(program, client); + registerCdpCommand(program, client); + registerAppCommand(program, client); + registerOpenCommand(program, client); + registerGetConsoleCommand(program, client); + registerGetSourcesCommand(program, client); + registerTakeScreenshotCommand(program, client); return program; } diff --git a/packages/skills/lynx-devtool/src/index.ts b/packages/skills/lynx-devtool/src/index.ts index 4079f5b..b16a6ef 100644 --- a/packages/skills/lynx-devtool/src/index.ts +++ b/packages/skills/lynx-devtool/src/index.ts @@ -1,30 +1,16 @@ // Copyright 2026 The Lynx Authors. All rights reserved. // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -import { Connector } from '@lynx-js/devtool-connector'; -import { - AndroidTransport, - DesktopTransport, - iOSTransport, - type Transport, -} from '@lynx-js/devtool-connector/transport'; import { createProgram } from './devtool.ts'; +import { createDevtoolClient } from './sdk.ts'; -function getAndroidTransportSpec(): { host: string; port: number } { - const port = Number.parseInt(process.env.ADB_SERVER_PORT ?? '5037', 10); +const client = createDevtoolClient(); - return { - host: process.env.ADB_SERVER_HOST ?? '127.0.0.1', - port: Number.isInteger(port) && port > 0 ? port : 5037, - }; +try { + await createProgram(client).parseAsync(process.argv); +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +} finally { + await client.close(); } - -const transports: Transport[] = [ - new AndroidTransport(getAndroidTransportSpec()), - new DesktopTransport(), - new iOSTransport(), -]; - -const connector = new Connector(transports); - -await createProgram(connector, transports).parseAsync(process.argv); diff --git a/packages/skills/lynx-devtool/src/sdk.ts b/packages/skills/lynx-devtool/src/sdk.ts new file mode 100644 index 0000000..a7b90bd --- /dev/null +++ b/packages/skills/lynx-devtool/src/sdk.ts @@ -0,0 +1,476 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. + +import fs from 'node:fs/promises'; +import { ReadableStream } from 'node:stream/web'; +import { setTimeout } from 'node:timers/promises'; +import { Connector } from '@lynx-js/devtool-connector'; +import { + AndroidTransport, + type Client as ConnectorClient, + DesktopTransport, + iOSTransport, + type Transport, +} from '@lynx-js/devtool-connector/transport'; + +export interface DevtoolClientInfo extends ConnectorClient { + port?: number; +} + +export interface DevtoolSessionInfo { + session_id: number; + type?: string; + url?: string; +} + +export interface CdpCommandOptions { + clientId?: string; + sessionId?: number | string; + method: string; + params?: Record; +} + +export interface AppCommandOptions { + clientId?: string; + method: string; + params?: Record; +} + +export interface ListSessionsOptions { + clientId?: string; +} + +export interface OpenPageOptions { + clientId?: string; + url: string; +} + +export interface GetConsoleOptions { + clientId?: string; + sessionId?: number | string; + offset?: number; + limit?: number; + includeStackTraces?: boolean; + level?: string[]; +} + +export interface ConsoleCallFrame { + url: string; + lineNumber: number; + columnNumber: number; +} + +export interface ConsoleStackTrace { + callFrames: ConsoleCallFrame[]; +} + +export interface ConsoleArg { + type: string; + value?: unknown; + className?: string; + description?: string; + objectId?: string; + subtype?: string; +} + +export interface ConsoleMessage { + type: string; + args: ConsoleArg[]; + stackTrace?: ConsoleStackTrace; + url?: string; +} + +export interface GetSourcesOptions { + clientId?: string; + sessionId?: number | string; +} + +export interface ScriptSource { + scriptId: string; + url: string; +} + +export interface TakeScreenshotOptions { + clientId?: string; + sessionId?: number | string; + fullscreen?: boolean; + output?: string; +} + +export interface ScreenshotResult { + output: string; +} + +export interface DevtoolClient { + listClients(): Promise; + listSessions(options?: ListSessionsOptions): Promise; + cdp(options: CdpCommandOptions): Promise; + app(options: AppCommandOptions): Promise; + open(options: OpenPageOptions): Promise; + getConsole(options?: GetConsoleOptions): Promise; + getSources(options?: GetSourcesOptions): Promise; + takeScreenshot(options?: TakeScreenshotOptions): Promise; + close(): Promise; +} + +export interface DevtoolClientOptions { + connector?: Connector; + operationTimeoutMs?: number; + transports?: Transport[]; +} + +interface ScriptParsedEvent { + scriptId: string; + url: string; + [key: string]: unknown; +} + +function getAndroidTransportSpec(): { host: string; port: number } { + const { ADB_SERVER_HOST, ADB_SERVER_PORT } = process.env; + const port = Number.parseInt(ADB_SERVER_PORT ?? '5037', 10); + + return { + host: ADB_SERVER_HOST ?? '127.0.0.1', + port: Number.isInteger(port) && port > 0 ? port : 5037, + }; +} + +export function createDefaultTransports(): Transport[] { + return [ + new AndroidTransport(getAndroidTransportSpec()), + new DesktopTransport(), + new iOSTransport(), + ]; +} + +export function createDevtoolClient( + options: DevtoolClientOptions = {}, +): DevtoolClient { + const transports = + options.transports ?? (options.connector ? [] : createDefaultTransports()); + const connector = options.connector ?? new Connector(transports); + + return new ConnectorDevtoolClient( + connector, + transports, + options.operationTimeoutMs, + ); +} + +class ConnectorDevtoolClient implements DevtoolClient { + constructor( + private readonly connector: Connector, + private readonly transports: Transport[], + private readonly operationTimeoutMs: number | undefined, + ) {} + + async listClients(): Promise { + return this.withOperationTimeout( + this.connector.listClients() as Promise, + 'list clients', + ); + } + + async listSessions( + options: ListSessionsOptions = {}, + ): Promise { + const clientId = options.clientId ?? (await this.getFirstClientId()); + return this.withOperationTimeout( + this.connector.sendListSessionMessage(clientId) as Promise< + DevtoolSessionInfo[] + >, + `list sessions for client ${clientId}`, + ); + } + + async cdp(options: CdpCommandOptions): Promise { + const clientId = options.clientId ?? (await this.getFirstClientId()); + const sessionId = + options.sessionId ?? (await this.getLatestSessionId(clientId)); + + return this.withOperationTimeout( + this.connector.sendCDPMessage( + clientId, + Number(sessionId), + options.method, + options.params ?? {}, + ), + `send CDP method ${options.method}`, + ); + } + + async app(options: AppCommandOptions): Promise { + const clientId = options.clientId ?? (await this.getFirstClientId()); + return this.withOperationTimeout( + this.connector.sendAppMessage( + clientId, + options.method, + options.params ?? {}, + ), + `send App method ${options.method}`, + ); + } + + async open(options: OpenPageOptions): Promise { + const clientId = options.clientId ?? (await this.getFirstClientId()); + const openCardMessage = { + event: 'Customized', + data: { + type: 'OpenCard', + data: { + type: 'url', + url: options.url, + }, + sender: -1, + }, + from: -1, + } as const; + + try { + return await this.withOperationTimeout( + this.connector.sendMessage(clientId, openCardMessage), + `open URL ${options.url}`, + ); + } catch { + return this.withOperationTimeout( + this.connector.sendAppMessage(clientId, 'App.openPage', { + url: options.url, + }), + `open URL ${options.url} with App.openPage`, + ); + } + } + + async getConsole(options: GetConsoleOptions = {}): Promise { + const clientId = options.clientId ?? (await this.getFirstClientId()); + const sessionId = + options.sessionId ?? (await this.getLatestSessionId(clientId)); + const limit = options.limit + ? Math.max(1, Math.min(100, options.limit)) + : undefined; + const offset = options.offset ?? 0; + const allowedLevels = options.level ?? ['info', 'log', 'warning', 'error']; + const numericSessionId = Number(sessionId); + + await using stream = await this.withOperationTimeout( + this.connector.sendCDPStream( + clientId, + ReadableStream.from([ + { + sessionId: numericSessionId, + method: 'Page.enable', + }, + { + sessionId: numericSessionId, + method: 'Runtime.enable', + }, + ]), + ), + 'start console stream', + ); + + const messages: ConsoleMessage[] = []; + let skipped = 0; + + const reader = stream.getReader(); + const idleTimeout = 500; + const maxTotalTime = 5000; + const startTime = Date.now(); + + try { + while (Date.now() - startTime < maxTotalTime) { + const result = await Promise.race([ + reader.read(), + setTimeout(idleTimeout, 'timeout' as const), + ]); + if (result === 'timeout') { + await reader.cancel(); + break; + } + + const { done, value } = result; + if (done) break; + + if (value.method === 'Runtime.consoleAPICalled') { + const params = value.params as ConsoleMessage; + if (allowedLevels.includes(params.type)) { + if (skipped < offset) { + skipped++; + continue; + } + + if (!options.includeStackTraces && params.type !== 'error') { + delete params.stackTrace; + } + + messages.push(params); + + if (limit && messages.length >= limit) { + await reader.cancel(); + break; + } + } + } + } + } finally { + reader.releaseLock(); + } + + return messages; + } + + async getSources(options: GetSourcesOptions = {}): Promise { + const clientId = options.clientId ?? (await this.getFirstClientId()); + const sessionId = + options.sessionId ?? (await this.getLatestSessionId(clientId)); + const numericSessionId = Number(sessionId); + + const messages: { sessionId: number; method: string }[] = [ + { + sessionId: numericSessionId, + method: 'Debugger.disable', + }, + { + sessionId: numericSessionId, + method: 'Debugger.enable', + }, + ]; + + await using stream = await this.withOperationTimeout( + this.connector.sendCDPStream(clientId, ReadableStream.from(messages)), + 'start source stream', + ); + + const scripts: ScriptParsedEvent[] = []; + const reader = stream.getReader(); + const idleTimeout = 2000; + const maxTotalTime = 5000; + const startTime = Date.now(); + + try { + while (Date.now() - startTime < maxTotalTime) { + const result = await Promise.race([ + reader.read(), + setTimeout(idleTimeout, 'timeout' as const), + ]); + if (result === 'timeout') { + await reader.cancel(); + break; + } + + const { done, value } = result; + if (done) break; + + if (value.method === 'Debugger.scriptParsed') { + scripts.push(value.params as ScriptParsedEvent); + } + } + } finally { + reader.releaseLock(); + } + + return scripts.map(({ scriptId, url }) => ({ scriptId, url })); + } + + async takeScreenshot( + options: TakeScreenshotOptions = {}, + ): Promise { + const clientId = options.clientId ?? (await this.getFirstClientId()); + const sessionId = + options.sessionId ?? (await this.getLatestSessionId(clientId)); + const numericSessionId = Number(sessionId); + const signal = AbortSignal.timeout(10_000); + const { promise, resolve } = Promise.withResolvers(); + + await using stream = await this.connector.sendCDPStream( + clientId, + new ReadableStream({ + async start(controller) { + controller.enqueue({ + method: 'Page.startScreencast', + params: { + format: 'jpeg', + quality: 80, + mode: options.fullscreen ? 'fullscreen' : 'lynxview', + }, + sessionId: numericSessionId, + }); + await Promise.race([ + promise, + setTimeout(10_000, void 0, { ref: false }), + ]); + controller.enqueue({ + method: 'Page.stopScreencast', + sessionId: numericSessionId, + }); + controller.close(); + }, + }), + { signal }, + ); + + for await (const { method, params: eventParams } of stream) { + if (method === 'Page.screencastFrame') { + const { data } = eventParams as { data: string }; + if (data) { + resolve(); + + const output = options.output ?? `screenshot-${Date.now()}.jpeg`; + await fs.writeFile(output, Buffer.from(data, 'base64')); + return { output }; + } + } + } + + throw new Error( + 'Failed to capture screenshot, no Page.screencastFrame event received within 10 seconds.', + ); + } + + async close(): Promise { + await Promise.allSettled( + this.transports.map((transport) => transport.close()), + ); + } + + private async getFirstClientId(): Promise { + const clients = await this.listClients(); + const firstClient = clients[0]; + if (!firstClient) { + throw new Error('No available clients found.'); + } + return firstClient.id; + } + + private async getLatestSessionId(clientId: string): Promise { + const sessions = await this.listSessions({ clientId }); + if (sessions.length === 0) { + throw new Error(`No available sessions found for client: ${clientId}`); + } + const latestSession = sessions.reduce((max, session) => + session.session_id > max.session_id ? session : max, + ); + return String(latestSession.session_id); + } + + private async withOperationTimeout( + promise: Promise, + operation: string, + ): Promise { + if (!this.operationTimeoutMs) { + return promise; + } + + return Promise.race([ + promise, + setTimeout(this.operationTimeoutMs, undefined, { ref: false }).then( + () => { + throw new Error( + `Timed out while trying to ${operation} after ${this.operationTimeoutMs}ms.`, + ); + }, + ), + ]); + } +}