From da506903104ae01e8c2e685938935f7a0504d856 Mon Sep 17 00:00:00 2001 From: Ben Dodson Date: Wed, 26 Aug 2026 01:42:17 -0700 Subject: [PATCH] feat(debugger): add Chromium console streaming --- npm_modules/cli/debugger/devtools-panel.css | 9 + npm_modules/cli/debugger/devtools-panel.js | 138 +++++- .../cli/src/debugger/chromiumConsole.spec.ts | 181 ++++++++ .../cli/src/debugger/chromiumConsole.ts | 304 +++++++++++++ .../cli/src/debugger/consoleSseWriter.spec.ts | 91 ++++ .../cli/src/debugger/devtoolsPanel.spec.ts | 243 +++++++++++ npm_modules/cli/src/debugger/server.spec.ts | 410 ++++++++++++++++++ npm_modules/cli/src/debugger/server.ts | 342 ++++++++++++++- .../cli/src/utils/chromiumDevToolsClient.ts | 61 ++- .../cli/src/utils/owlCdpClient.spec.ts | 149 ++++--- npm_modules/cli/src/utils/owlCdpClient.ts | 53 ++- 11 files changed, 1891 insertions(+), 90 deletions(-) create mode 100644 npm_modules/cli/src/debugger/chromiumConsole.spec.ts create mode 100644 npm_modules/cli/src/debugger/chromiumConsole.ts create mode 100644 npm_modules/cli/src/debugger/consoleSseWriter.spec.ts create mode 100644 npm_modules/cli/src/debugger/devtoolsPanel.spec.ts diff --git a/npm_modules/cli/debugger/devtools-panel.css b/npm_modules/cli/debugger/devtools-panel.css index 37f6bc1e..cf849b84 100644 --- a/npm_modules/cli/debugger/devtools-panel.css +++ b/npm_modules/cli/debugger/devtools-panel.css @@ -571,6 +571,15 @@ button { color: var(--error); } +.console-entry.warn { + background: color-mix(in srgb, var(--warning) 8%, var(--background)); + color: var(--warning); +} + +.console-entry.debug { + color: var(--muted); +} + .console-entry.result .console-chevron, .console-entry.input .console-chevron { color: var(--accent); diff --git a/npm_modules/cli/debugger/devtools-panel.js b/npm_modules/cli/debugger/devtools-panel.js index 0b9224b5..ad1a6a8f 100644 --- a/npm_modules/cli/debugger/devtools-panel.js +++ b/npm_modules/cli/debugger/devtools-panel.js @@ -20,8 +20,11 @@ const state = { hoveredNodeId: null, highlightTimer: null, consoleEntries: [], + consoleEntryKeys: new Set(), consoleHistory: [], consoleHistoryIndex: 0, + consoleStream: null, + consoleStreamTargetKey: null, error: null, }; @@ -124,13 +127,7 @@ function walk(node, callback) { } function walkVisible(node, callback) { - valdiDebuggerTreeModel.walkVisible( - node, - callback, - current => state.expandedNodeIds.has(nodeId(current)), - [], - 0, - ); + valdiDebuggerTreeModel.walkVisible(node, callback, current => state.expandedNodeIds.has(nodeId(current)), [], 0); } function nodeCount() { @@ -210,17 +207,26 @@ async function connectToInspectedApplication() { } try { - const payload = await requestJson( - '/api/devtools/target', - { inspectedUrl, targetNonce: inspectedTargetNonce }, - {}, - ); + const payload = await requestJson('/api/devtools/target', { inspectedUrl, targetNonce: inspectedTargetNonce }, {}); + const previousTargetKey = state.target + ? `${state.target.id}:${state.target.sessionId}:${inspectedTargetNonce}` + : null; + const nextTargetKey = `${payload.target.id}:${payload.target.sessionId}:${inspectedTargetNonce}`; + if (previousTargetKey !== null && previousTargetKey !== nextTargetKey) { + stopConsoleStream(); + state.consoleEntries = []; + state.consoleEntryKeys.clear(); + elements.consoleMessages.innerHTML = ''; + } state.target = payload.target; elements.targetName.textContent = state.target.name || 'Valdi application'; elements.targetName.title = state.target.applicationUrl || inspectedUrl; elements.targetMetadata.textContent = `Chromium · :${state.target.debuggingPort}`; setConnected(true); - addConsoleEntry('info', `Connected to ${state.target.applicationUrl}`); + if (previousTargetKey !== nextTargetKey) { + addConsoleEntry('info', `Connected to ${state.target.applicationUrl}`); + } + startConsoleStream(); await refreshSnapshot(); startRefreshTimer(); } catch (error) { @@ -591,20 +597,106 @@ function setActiveDetail(detail) { renderInspector(); } -function addConsoleEntry(kind, value) { +function stopConsoleStream() { + if (!state.consoleStream) return; + state.consoleStream.close(); + state.consoleStream = null; + state.consoleStreamTargetKey = null; +} + +function startConsoleStream() { + if (!state.target || !state.autoRefresh || !inspectedUrl || !inspectedTargetNonce) { + stopConsoleStream(); + return; + } + const targetKey = `${state.target.id}:${state.target.sessionId}:${inspectedTargetNonce}`; + if (state.consoleStream && state.consoleStreamTargetKey === targetKey) return; + stopConsoleStream(); + + const url = new URL('/api/devtools/console/stream', window.location.origin); + url.searchParams.set('inspectedUrl', inspectedUrl); + url.searchParams.set('sessionId', state.target.sessionId); + url.searchParams.set('targetNonce', inspectedTargetNonce); + const stream = new EventSource(url.toString()); + state.consoleStream = stream; + state.consoleStreamTargetKey = targetKey; + + stream.addEventListener('console', event => { + if (state.consoleStream !== stream || !state.target) return; + let entry; + try { + entry = JSON.parse(event.data); + } catch (error) { + console.warn('[Valdi DevTools] Ignoring a malformed Chromium console event.', error); + return; + } + if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) return; + if ( + entry.sessionId !== state.target.sessionId || + entry.targetId !== state.target.id || + typeof entry.message !== 'string' + ) { + return; + } + addConsoleEntry(entry.level, entry.message, entry.timestamp, entry.source); + }); + + stream.addEventListener('stream-error', event => { + if (state.consoleStream !== stream || !state.target) return; + try { + const payload = JSON.parse(event.data); + if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) return; + if ( + payload.sessionId === state.target.sessionId && + payload.targetId === state.target.id && + typeof payload.error === 'string' + ) { + addConsoleEntry('error', payload.error); + } + } catch (error) { + console.warn('[Valdi DevTools] Ignoring a malformed Chromium console stream error.', error); + } + }); + + stream.addEventListener('stream-warning', event => { + if (state.consoleStream !== stream || !state.target) return; + try { + const payload = JSON.parse(event.data); + if (typeof payload !== 'object' || payload === null || Array.isArray(payload)) return; + if ( + payload.sessionId === state.target.sessionId && + payload.targetId === state.target.id && + typeof payload.message === 'string' + ) { + addConsoleEntry('warn', payload.message); + } + } catch (error) { + console.warn('[Valdi DevTools] Ignoring a malformed Chromium console stream warning.', error); + } + }); +} + +function addConsoleEntry(kind, value, timestamp, source) { + const normalizedKind = ['debug', 'error', 'info', 'input', 'log', 'result', 'warn'].includes(kind) ? kind : 'log'; const text = String(value); const boundedValue = - text.length > MAX_CONSOLE_ENTRY_CHARACTERS - ? `${text.slice(0, MAX_CONSOLE_ENTRY_CHARACTERS - 1)}…` - : text; - state.consoleEntries.push({ kind, value: boundedValue }); + text.length > MAX_CONSOLE_ENTRY_CHARACTERS ? `${text.slice(0, MAX_CONSOLE_ENTRY_CHARACTERS - 1)}…` : text; + const key = timestamp === undefined ? null : `${timestamp}:${normalizedKind}:${String(source ?? '')}:${boundedValue}`; + if (key !== null) { + if (state.consoleEntryKeys.has(key)) return; + state.consoleEntryKeys.add(key); + } + state.consoleEntries.push({ key, kind: normalizedKind, value: boundedValue }); if (state.consoleEntries.length > MAX_CONSOLE_ENTRIES) { - state.consoleEntries.splice(0, state.consoleEntries.length - MAX_CONSOLE_ENTRIES); + const discarded = state.consoleEntries.splice(0, state.consoleEntries.length - MAX_CONSOLE_ENTRIES); + for (const entry of discarded) { + if (entry.key !== null) state.consoleEntryKeys.delete(entry.key); + } } elements.consoleMessages.innerHTML = state.consoleEntries .map( entry => - `
${entry.kind === 'input' ? '›' : entry.kind === 'error' ? '×' : '‹'}
${escapeHtml(entry.value)}
`, + `
${entry.kind === 'input' ? '›' : entry.kind === 'error' ? '×' : entry.kind === 'warn' ? '!' : '‹'}
${escapeHtml(entry.value)}
`, ) .join(''); elements.consoleMessages.scrollTop = elements.consoleMessages.scrollHeight; @@ -658,6 +750,11 @@ function wireEvents() { elements.refreshButton.addEventListener('click', () => void refreshSnapshot()); elements.autoRefreshToggle.addEventListener('change', () => { state.autoRefresh = elements.autoRefreshToggle.checked; + if (state.autoRefresh) { + startConsoleStream(); + } else { + stopConsoleStream(); + } }); elements.treeFilter.addEventListener('input', () => { state.search = elements.treeFilter.value; @@ -732,6 +829,7 @@ function wireEvents() { applyTheme(event.data.theme); } }); + window.addEventListener('pagehide', stopConsoleStream); document.addEventListener('visibilitychange', () => { if (!document.hidden && state.activeSection === 'elements') void refreshSnapshot(); }); diff --git a/npm_modules/cli/src/debugger/chromiumConsole.spec.ts b/npm_modules/cli/src/debugger/chromiumConsole.spec.ts new file mode 100644 index 00000000..3ed9cf83 --- /dev/null +++ b/npm_modules/cli/src/debugger/chromiumConsole.spec.ts @@ -0,0 +1,181 @@ +import 'jasmine'; +import { + ChromiumConsoleLevel, + ChromiumConsoleSource, + MAX_CHROMIUM_CONSOLE_MESSAGE_LENGTH, + formatChromiumConsoleEvent, +} from './chromiumConsole'; + +describe('Chromium DevTools console event formatting', () => { + it('preserves console levels, primitive arguments, substitutions, and timestamps', () => { + const entry = formatChromiumConsoleEvent({ + method: 'Runtime.consoleAPICalled', + params: { + args: [ + { type: 'string', value: '%cCount: %d (%s)' }, + { type: 'string', value: 'color: red' }, + { type: 'number', value: 7.9 }, + { type: 'string', value: 'ready' }, + ], + timestamp: 1234, + type: 'warning', + }, + }); + + expect(entry).toEqual({ + level: ChromiumConsoleLevel.Warning, + message: 'Count: 7 (ready)', + source: ChromiumConsoleSource.Console, + timestamp: 1234, + }); + }); + + it('renders bounded object and array previews while redacting sensitive property values', () => { + const entry = formatChromiumConsoleEvent({ + method: 'Runtime.consoleAPICalled', + params: { + args: [ + { + preview: { + properties: [ + { name: 'screen', type: 'string', value: 'showcase' }, + { name: 'authorization', type: 'string', value: 'Bearer private-token' }, + { name: 'accessToken', type: 'string', value: 'private-token' }, + ], + }, + type: 'object', + }, + { + preview: { + properties: [ + { name: '0', type: 'string', value: 'first' }, + { name: '1', type: 'string', value: 'second' }, + ], + subtype: 'array', + }, + subtype: 'array', + type: 'object', + }, + ], + type: 'info', + }, + }); + + expect(entry?.message).toBe( + '{screen: showcase, authorization: [REDACTED], accessToken: [REDACTED]} [first, second]', + ); + expect(entry?.message).not.toContain('private-token'); + }); + + it('redacts headers, credentials, query parameters, and common API keys', () => { + const entry = formatChromiumConsoleEvent({ + method: 'Log.entryAdded', + params: { + entry: { + level: 'warning', + text: + 'authorization: Bearer synthetic-token\n' + + 'Cookie: session=private-cookie\n' + + 'password: correct horse battery staple\n' + + '{"access_token":"private-access-token","password":"private-password"}\n' + + 'https://example.test/callback?code=private-code&safe=ok\n' + + 'sk-proj-abcdefghijklmnopqrst', + timestamp: 42, + }, + }, + }); + + expect(entry?.message).toContain('authorization: [REDACTED]'); + expect(entry?.message).toContain('Cookie: [REDACTED]'); + expect(entry?.message).toContain('password: [REDACTED]\n'); + expect(entry?.message).toContain('"access_token":[REDACTED]'); + expect(entry?.message).toContain('?code=[REDACTED]&safe=ok'); + expect(entry?.message).not.toContain('private-'); + expect(entry?.message).not.toContain('correct horse battery staple'); + expect(entry?.message).not.toContain('abcdefghijklmnopqrst'); + }); + + it('surfaces runtime exceptions and bounds oversized messages', () => { + const exception = formatChromiumConsoleEvent({ + method: 'Runtime.exceptionThrown', + params: { + exceptionDetails: { + exception: { description: 'Error: Synthetic failure\n at render (showcase.js:4:2)' }, + text: 'Uncaught', + }, + timestamp: 84, + }, + }); + const oversized = formatChromiumConsoleEvent({ + method: 'Runtime.consoleAPICalled', + params: { + args: [ + { + type: 'string', + value: `${'x'.repeat(32_760)} sk-proj-abcdefghijklmnopqrst ${'y'.repeat(100_000)}`, + }, + ], + type: 'log', + }, + }); + + expect(exception).toEqual({ + level: ChromiumConsoleLevel.Error, + message: 'Error: Synthetic failure\n at render (showcase.js:4:2)', + source: ChromiumConsoleSource.Exception, + timestamp: 84, + }); + expect(oversized?.message.length).toBe(MAX_CHROMIUM_CONSOLE_MESSAGE_LENGTH + 1); + expect(oversized?.message.endsWith('…')).toBeTrue(); + expect(oversized?.message).not.toContain('abcdefghijklmnopqrst'); + }); + + it('does not invoke accessors or recurse through deep and proxy-like remote values', () => { + let getterCalls = 0; + const remoteObject: Record = { type: 'object', value: {} }; + let deep = remoteObject['value'] as Record; + for (let index = 0; index < 20_000; index += 1) { + const next: Record = {}; + deep['next'] = next; + deep = next; + } + Object.defineProperty(remoteObject, 'description', { + enumerable: true, + get: () => { + getterCalls += 1; + throw new Error('must not run'); + }, + }); + const revoked = Proxy.revocable({ type: 'object', description: 'private-value' }, {}); + revoked.revoke(); + + const startedAt = performance.now(); + const entry = formatChromiumConsoleEvent({ + method: 'Runtime.consoleAPICalled', + params: { args: [remoteObject, revoked.proxy], type: 'debug' }, + }); + + expect(performance.now() - startedAt).toBeLessThan(1000); + expect(getterCalls).toBe(0); + expect(entry?.message).toBe('object [Unavailable]'); + expect(entry?.message).not.toContain('private-value'); + }); + + it('bounds sparse argument arrays and ignores malformed or unrelated events', () => { + const sparse: unknown[] = []; + sparse.length = 10_000_000; + sparse[0] = { type: 'string', value: 'first' }; + + const entry = formatChromiumConsoleEvent({ + method: 'Runtime.consoleAPICalled', + params: { args: sparse, type: 'log' }, + }); + + expect(entry?.message.length).toBeLessThan(2000); + expect(entry?.message.startsWith('first')).toBeTrue(); + expect(formatChromiumConsoleEvent({ method: 'Network.requestWillBeSent', params: {} })).toBeNull(); + expect(formatChromiumConsoleEvent({ method: 'Runtime.consoleAPICalled', params: {} })).toBeNull(); + expect(formatChromiumConsoleEvent({ method: 'Log.entryAdded', params: { entry: 'invalid' } })).toBeNull(); + expect(formatChromiumConsoleEvent({ method: 'Runtime.exceptionThrown', params: {} })).toBeNull(); + }); +}); diff --git a/npm_modules/cli/src/debugger/chromiumConsole.ts b/npm_modules/cli/src/debugger/chromiumConsole.ts new file mode 100644 index 00000000..f2a7156f --- /dev/null +++ b/npm_modules/cli/src/debugger/chromiumConsole.ts @@ -0,0 +1,304 @@ +import type { ChromiumDevToolsEvent } from '../utils/chromiumDevToolsClient'; + +export const MAX_CHROMIUM_CONSOLE_MESSAGE_LENGTH = 16_384; +const MAX_CONSOLE_ARGUMENTS = 64; +const MAX_CONSOLE_FRAGMENT_LENGTH = MAX_CHROMIUM_CONSOLE_MESSAGE_LENGTH * 2; +const MAX_PREVIEW_PROPERTIES = 12; +const MISSING_PROPERTY = Symbol('missing-property'); +const UNAVAILABLE_PROPERTY = Symbol('unavailable-property'); +const SENSITIVE_CONSOLE_KEY = + /authorization|cookie|(?:access|refresh|id)[_-]?token|api[_-]?key|password|passwd|credential|secret|private[_-]?input/i; + +export enum ChromiumConsoleLevel { + Debug = 'debug', + Error = 'error', + Info = 'info', + Log = 'log', + Warning = 'warn', +} + +export enum ChromiumConsoleSource { + Browser = 'browser', + Console = 'console', + Exception = 'exception', +} + +export interface ChromiumConsoleEntry { + level: ChromiumConsoleLevel; + message: string; + source: ChromiumConsoleSource; + timestamp: number; +} + +type OwnDataProperty = unknown | typeof MISSING_PROPERTY | typeof UNAVAILABLE_PROPERTY; + +function readOwnDataProperty(value: object, key: PropertyKey): OwnDataProperty { + try { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor === undefined) return MISSING_PROPERTY; + return Object.prototype.hasOwnProperty.call(descriptor, 'value') ? descriptor.value : UNAVAILABLE_PROPERTY; + } catch { + return UNAVAILABLE_PROPERTY; + } +} + +function readOwnString(value: object, key: PropertyKey): string | undefined { + const property = readOwnDataProperty(value, key); + return typeof property === 'string' ? property : undefined; +} + +function safeArrayLength(value: unknown): number | null { + try { + if (!Array.isArray(value)) return null; + } catch { + return null; + } + const length = readOwnDataProperty(value, 'length'); + return typeof length === 'number' && Number.isSafeInteger(length) && length >= 0 ? length : null; +} + +function boundedFragment(value: string): string { + const redacted = redactConsoleText(value); + return redacted.length > MAX_CONSOLE_FRAGMENT_LENGTH + ? `${redacted.slice(0, MAX_CONSOLE_FRAGMENT_LENGTH)}…` + : redacted; +} + +function formatPrimitive(value: unknown): string | null { + switch (typeof value) { + case 'string': { + return boundedFragment(value); + } + case 'number': + case 'bigint': + case 'boolean': + case 'undefined': { + return String(value); + } + case 'symbol': { + return value.description === undefined ? 'Symbol()' : `Symbol(${boundedFragment(value.description)})`; + } + default: { + return value === null ? 'null' : null; + } + } +} + +function formatPreviewProperty(value: unknown, isArray: boolean): string { + if (typeof value !== 'object' || value === null) return '[Unavailable]'; + const name = readOwnString(value, 'name') ?? ''; + const rawValue = readOwnString(value, 'value') ?? readOwnString(value, 'type') ?? 'undefined'; + const formattedValue = SENSITIVE_CONSOLE_KEY.test(name) ? '[REDACTED]' : boundedFragment(rawValue); + return isArray ? formattedValue : `${boundedFragment(name)}: ${formattedValue}`; +} + +function formatRemotePreview(remoteObject: object): string { + const previewValue = readOwnDataProperty(remoteObject, 'preview'); + const preview = typeof previewValue === 'object' && previewValue !== null ? previewValue : null; + const propertiesValue = preview ? readOwnDataProperty(preview, 'properties') : MISSING_PROPERTY; + const propertyCount = safeArrayLength(propertiesValue); + const description = + readOwnString(remoteObject, 'description') ?? + (preview ? readOwnString(preview, 'description') : undefined) ?? + readOwnString(remoteObject, 'type') ?? + '[Unavailable]'; + if (propertyCount === null || propertyCount === 0) return boundedFragment(description); + + const subtype = (preview ? readOwnString(preview, 'subtype') : undefined) ?? readOwnString(remoteObject, 'subtype'); + const isArray = subtype === 'array'; + const formatted: string[] = []; + const count = Math.min(propertyCount, MAX_PREVIEW_PROPERTIES); + for (let index = 0; index < count; index += 1) { + const property = readOwnDataProperty(propertiesValue as object, String(index)); + formatted.push(formatPreviewProperty(property, isArray)); + } + const overflow = preview ? readOwnDataProperty(preview, 'overflow') === true : false; + if (overflow || propertyCount > MAX_PREVIEW_PROPERTIES) formatted.push('…'); + return isArray ? `[${formatted.join(', ')}]` : `{${formatted.join(', ')}}`; +} + +function formatRemoteObject(value: unknown): string { + if (value === MISSING_PROPERTY || value === UNAVAILABLE_PROPERTY) return '[Unavailable]'; + const primitive = formatPrimitive(value); + if (primitive !== null) return primitive; + if (typeof value !== 'object' || value === null) return '[Unavailable]'; + + const type = readOwnString(value, 'type'); + const subtype = readOwnString(value, 'subtype'); + if (type === 'undefined') return 'undefined'; + if (subtype === 'null') return 'null'; + + const unserializableValue = readOwnString(value, 'unserializableValue'); + if (unserializableValue !== undefined) return boundedFragment(unserializableValue); + + const remoteValue = readOwnDataProperty(value, 'value'); + if (remoteValue !== MISSING_PROPERTY && remoteValue !== UNAVAILABLE_PROPERTY) { + const formattedValue = formatPrimitive(remoteValue); + if (formattedValue !== null) return formattedValue; + } + if (subtype === 'error') { + const errorDescription = readOwnString(value, 'description'); + if (errorDescription !== undefined) return boundedFragment(errorDescription); + } + return formatRemotePreview(value); +} + +function formatConsoleArguments(value: unknown): string { + const length = safeArrayLength(value); + if (length === null || length === 0) return ''; + const count = Math.min(length, MAX_CONSOLE_ARGUMENTS); + const arguments_: unknown[] = []; + const formatted: string[] = []; + let formattedLength = 0; + let inputTruncated = false; + for (let index = 0; index < count; index += 1) { + const argument = readOwnDataProperty(value as object, String(index)); + const formattedArgument = formatRemoteObject(argument); + const separatorLength = formatted.length === 0 ? 0 : 1; + const remainingLength = MAX_CONSOLE_FRAGMENT_LENGTH - formattedLength - separatorLength; + if (remainingLength <= 0) { + formatted.push('…'); + inputTruncated = true; + break; + } + arguments_.push(argument); + if (formattedArgument.length > remainingLength) { + formatted.push(`${formattedArgument.slice(0, Math.max(0, remainingLength - 1))}…`); + inputTruncated = true; + break; + } + formatted.push(formattedArgument); + formattedLength += separatorLength + formattedArgument.length; + } + if (!inputTruncated && length > arguments_.length && formattedLength < MAX_CONSOLE_FRAGMENT_LENGTH) { + formatted.push('…'); + } + + const firstValue = + typeof arguments_[0] === 'object' && arguments_[0] !== null + ? readOwnDataProperty(arguments_[0], 'value') + : MISSING_PROPERTY; + if (typeof firstValue !== 'string' || !/%[%Ocdfios]/.test(formatted[0] ?? '')) { + return formatted.join(' '); + } + + let nextArgument = 1; + const substituted = (formatted[0] ?? '').replaceAll(/%([%Ocdfios])/g, (match: string, specifier: string) => { + if (specifier === '%') return '%'; + if (nextArgument >= arguments_.length) return match; + const argument = formatted[nextArgument++] ?? ''; + if (specifier === 'c') return ''; + if (specifier === 'd' || specifier === 'i') return String(Number.parseInt(argument, 10)); + if (specifier === 'f') return String(Number.parseFloat(argument)); + return argument; + }); + return [substituted, ...formatted.slice(nextArgument)].join(' '); +} + +function redactConsoleText(text: string): string { + return text + .replaceAll(/\b(?:sk-(?:proj-|svcacct-)?[\w-]{12,}|sess-[\w-]{12,})\b/g, '[REDACTED]') + .replaceAll(/\bbearer\s+[\w+./~-]+=*/gi, 'Bearer [REDACTED]') + .replaceAll( + /(["']?\b(?:authorization|proxy-authorization|cookie|set-cookie)["']?\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\n\r,]+)/gi, + '$1[REDACTED]', + ) + .replaceAll( + /(["']?\b(?:access[_-]?token|refresh[_-]?token|id[_-]?token|api[_-]?key|password|passwd|credential|secret|private[_-]?input)["']?\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\n\r&,;}]+)/gi, + '$1[REDACTED]', + ) + .replaceAll( + /([&?](?:access_token|refresh_token|id_token|api_key|token|code|password|key|session)=)[^\s#&]+/gi, + '$1[REDACTED]', + ); +} + +function normalizeConsoleLevel(level: unknown): ChromiumConsoleLevel { + switch (level) { + case 'error': + case 'assert': { + return ChromiumConsoleLevel.Error; + } + case 'warn': + case 'warning': { + return ChromiumConsoleLevel.Warning; + } + case 'info': { + return ChromiumConsoleLevel.Info; + } + case 'debug': + case 'verbose': + case 'trace': { + return ChromiumConsoleLevel.Debug; + } + default: { + return ChromiumConsoleLevel.Log; + } + } +} + +function makeConsoleEntry( + level: unknown, + message: string, + source: ChromiumConsoleSource, + timestamp: unknown, +): ChromiumConsoleEntry { + const redacted = boundedFragment(message); + return { + level: normalizeConsoleLevel(level), + message: + redacted.length > MAX_CHROMIUM_CONSOLE_MESSAGE_LENGTH + ? `${redacted.slice(0, MAX_CHROMIUM_CONSOLE_MESSAGE_LENGTH)}…` + : redacted, + source, + timestamp: typeof timestamp === 'number' && Number.isFinite(timestamp) ? timestamp : Date.now(), + }; +} + +export function formatChromiumConsoleEvent(event: ChromiumDevToolsEvent): ChromiumConsoleEntry | null { + if (typeof event !== 'object' || event === null) return null; + const method = readOwnString(event, 'method'); + const paramsValue = readOwnDataProperty(event, 'params'); + if (typeof paramsValue !== 'object' || paramsValue === null) return null; + + if (method === 'Runtime.consoleAPICalled') { + const message = formatConsoleArguments(readOwnDataProperty(paramsValue, 'args')); + if (!message) return null; + return makeConsoleEntry( + readOwnDataProperty(paramsValue, 'type'), + message, + ChromiumConsoleSource.Console, + readOwnDataProperty(paramsValue, 'timestamp'), + ); + } + + if (method === 'Log.entryAdded') { + const entry = readOwnDataProperty(paramsValue, 'entry'); + if (typeof entry !== 'object' || entry === null) return null; + const message = readOwnString(entry, 'text'); + if (!message) return null; + return makeConsoleEntry( + readOwnDataProperty(entry, 'level'), + message, + ChromiumConsoleSource.Browser, + readOwnDataProperty(entry, 'timestamp'), + ); + } + + if (method === 'Runtime.exceptionThrown') { + const details = readOwnDataProperty(paramsValue, 'exceptionDetails'); + if (typeof details !== 'object' || details === null) return null; + const exception = readOwnDataProperty(details, 'exception'); + const description = + typeof exception === 'object' && exception !== null ? readOwnString(exception, 'description') : undefined; + const message = description ?? readOwnString(details, 'text') ?? 'Uncaught exception'; + return makeConsoleEntry( + ChromiumConsoleLevel.Error, + message, + ChromiumConsoleSource.Exception, + readOwnDataProperty(paramsValue, 'timestamp'), + ); + } + + return null; +} diff --git a/npm_modules/cli/src/debugger/consoleSseWriter.spec.ts b/npm_modules/cli/src/debugger/consoleSseWriter.spec.ts new file mode 100644 index 00000000..78878c22 --- /dev/null +++ b/npm_modules/cli/src/debugger/consoleSseWriter.spec.ts @@ -0,0 +1,91 @@ +import 'jasmine'; +import type { ServerResponse } from 'node:http'; +import { ConsoleSseWriter, MAX_CONSOLE_SSE_BUFFERED_EVENTS } from './server'; + +class MockSseResponse { + readonly chunks: string[] = []; + readonly writeResults: boolean[] = []; + private readonly drainListeners = new Set<() => void>(); + + emitDrain(): void { + const listeners = Array.from(this.drainListeners); + this.drainListeners.clear(); + for (const listener of listeners) listener(); + } + + listenerCount(event: string): number { + return event === 'drain' ? this.drainListeners.size : 0; + } + + off(event: string, listener: () => void): this { + if (event === 'drain') this.drainListeners.delete(listener); + return this; + } + + once(event: string, listener: () => void): this { + if (event === 'drain') this.drainListeners.add(listener); + return this; + } + + write(chunk: string): boolean { + this.chunks.push(chunk); + return this.writeResults.shift() ?? true; + } +} + +describe('Chromium console SSE backpressure', () => { + it('buffers complete SSE frames while blocked and flushes them in order on drain', () => { + const response = new MockSseResponse(); + response.writeResults.push(false, true, true); + const failures: Error[] = []; + const writer = new ConsoleSseWriter(response as unknown as ServerResponse, error => failures.push(error)); + + expect(writer.send('console', { message: 'first' })).toBeTrue(); + expect(writer.send('console', { message: 'second' })).toBeTrue(); + expect(writer.send('heartbeat', { sequence: 3 })).toBeTrue(); + expect(response.chunks).toHaveSize(1); + + response.emitDrain(); + + expect(response.chunks).toHaveSize(3); + expect(response.chunks[0]).toContain('"first"'); + expect(response.chunks[1]).toContain('"second"'); + expect(response.chunks[2]).toContain('event: heartbeat'); + expect(failures).toEqual([]); + }); + + it('fails closed instead of accumulating an unbounded backpressure queue', () => { + const response = new MockSseResponse(); + response.writeResults.push(false); + const failures: Error[] = []; + const writer = new ConsoleSseWriter(response as unknown as ServerResponse, error => failures.push(error)); + + expect(writer.send('console', { sequence: 0 })).toBeTrue(); + for (let index = 0; index < MAX_CONSOLE_SSE_BUFFERED_EVENTS; index += 1) { + expect(writer.send('console', { sequence: index + 1 })).toBeTrue(); + } + expect(writer.send('console', { sequence: MAX_CONSOLE_SSE_BUFFERED_EVENTS + 1 })).toBeFalse(); + + expect(failures).toHaveSize(1); + expect(failures[0]?.message).toContain('backpressure limit'); + expect(writer.send('console', { sequence: 999 })).toBeFalse(); + response.emitDrain(); + expect(response.chunks).toHaveSize(1); + }); + + it('fails closed on unserializable payloads and removes pending drain listeners on close', () => { + const response = new MockSseResponse(); + response.writeResults.push(false); + const failures: Error[] = []; + const writer = new ConsoleSseWriter(response as unknown as ServerResponse, error => failures.push(error)); + const cyclic: Record = {}; + cyclic['self'] = cyclic; + + expect(writer.send('console', { message: 'accepted' })).toBeTrue(); + expect(response.listenerCount('drain')).toBe(1); + expect(writer.send('console', cyclic)).toBeFalse(); + + expect(failures).toHaveSize(1); + expect(response.listenerCount('drain')).toBe(0); + }); +}); diff --git a/npm_modules/cli/src/debugger/devtoolsPanel.spec.ts b/npm_modules/cli/src/debugger/devtoolsPanel.spec.ts new file mode 100644 index 00000000..0fbf581d --- /dev/null +++ b/npm_modules/cli/src/debugger/devtoolsPanel.spec.ts @@ -0,0 +1,243 @@ +import 'jasmine'; +import fs from 'node:fs'; +import path from 'node:path'; +import { Script } from 'node:vm'; + +interface StubElement { + checked: boolean; + className: string; + innerHTML: string; + scrollHeight: number; + scrollTop: number; + textContent: string; + addEventListener(type: string, listener: (event: unknown) => void): void; + dispatch(type: string): void; +} + +interface MockConsoleEventSource { + closed: boolean; + url: string; + addEventListener(type: string, listener: (event: { data: string }) => void): void; + close(): void; + emit(type: string, payload: Record): void; +} + +interface DevToolsConsolePanel { + consoleMessages: StubElement; + liveToggle: StubElement; + state: { + autoRefresh: boolean; + consoleEntries: Array<{ kind: string; value: string }>; + consoleStream: MockConsoleEventSource | null; + target: { id: string; sessionId: string } | null; + }; + evaluateConsoleExpression(expression: string): Promise; + startConsoleStream(): void; + stopConsoleStream(): void; + dispatchWindowEvent(type: string): void; +} + +describe('integrated DevTools console panel', () => { + let eventSources: MockConsoleEventSource[]; + let panel: DevToolsConsolePanel; + let requests: Array<{ body?: string; url: string }>; + + beforeEach(() => { + eventSources = []; + requests = []; + const rawSource = fs.readFileSync(path.resolve(process.cwd(), 'debugger', 'devtools-panel.js'), 'utf8'); + const source = rawSource.replace('void connectToInspectedApplication();', 'void 0;'); + const elements = new Map(); + const windowListeners = new Map void>(); + const document = { + addEventListener() {}, + documentElement: { dataset: {} }, + getElementById(id: string): StubElement { + let element = elements.get(id); + if (element === undefined) { + const listeners = new Map void>(); + element = { + checked: true, + className: '', + innerHTML: '', + scrollHeight: 100, + scrollTop: 0, + textContent: '', + addEventListener(type: string, listener: (event: unknown) => void): void { + listeners.set(type, listener); + }, + dispatch(type: string): void { + listeners.get(type)?.({}); + }, + }; + elements.set(id, element); + } + return element; + }, + querySelectorAll(): StubElement[] { + return []; + }, + }; + const window = { + addEventListener(type: string, listener: () => void): void { + windowListeners.set(type, listener); + }, + location: { + origin: 'http://127.0.0.1:18768', + search: + '?inspectedUrl=http%3A%2F%2F127.0.0.1%3A54321%2Findex.html%3FvaldiDevTools%3D1&targetNonce=panel-target-nonce-123456', + }, + parent: {}, + }; + + class MockEventSource implements MockConsoleEventSource { + closed = false; + private readonly listeners = new Map void>(); + + constructor(readonly url: string) { + eventSources.push(this); + } + + addEventListener(type: string, listener: (event: { data: string }) => void): void { + this.listeners.set(type, listener); + } + + close(): void { + this.closed = true; + } + + emit(type: string, payload: Record): void { + this.listeners.get(type)?.({ data: JSON.stringify(payload) }); + } + } + + panel = new Script( + `${source}\n({ consoleMessages: elements.consoleMessages, dispatchWindowEvent: type => windowListeners.get(type)?.(), evaluateConsoleExpression, liveToggle: elements.autoRefreshToggle, startConsoleStream, state, stopConsoleStream })`, + ).runInNewContext({ + EventSource: MockEventSource, + URL, + URLSearchParams, + console, + document, + elements, + fetch: (url: URL, options: { body?: string }) => { + requests.push({ ...(options.body === undefined ? {} : { body: options.body }), url: url.toString() }); + return Promise.resolve({ + json: () => Promise.resolve({ type: 'number', value: 4 }), + ok: true, + }); + }, + window, + windowListeners, + }) as DevToolsConsolePanel; + panel.state.target = { id: 'owl:web-preview', sessionId: 'web-preview' }; + }); + + it('binds the stream to the exact target tuple and safely renders only matching events', () => { + panel.startConsoleStream(); + const stream = eventSources[0]; + if (!stream) throw new Error('Expected the Chromium console stream to connect.'); + const streamUrl = new URL(stream.url); + + expect(streamUrl.pathname).toBe('/api/devtools/console/stream'); + expect(streamUrl.searchParams.get('sessionId')).toBe('web-preview'); + expect(streamUrl.searchParams.get('targetNonce')).toBe('panel-target-nonce-123456'); + expect(streamUrl.searchParams.get('inspectedUrl')).toBe('http://127.0.0.1:54321/index.html?valdiDevTools=1'); + + stream.emit('console', { + level: 'warn', + message: 'Synthetic warning', + sessionId: 'web-preview', + source: 'console', + targetId: 'owl:web-preview', + timestamp: 42, + }); + stream.emit('console', { + level: 'warn', + message: 'Synthetic warning', + sessionId: 'web-preview', + source: 'console', + targetId: 'owl:web-preview', + timestamp: 42, + }); + stream.emit('console', { + level: 'error', + message: 'Wrong target', + sessionId: 'another-session', + targetId: 'owl:web-preview', + timestamp: 43, + }); + + expect(panel.state.consoleEntries).toEqual([ + jasmine.objectContaining({ kind: 'warn', value: 'Synthetic warning' }), + ]); + expect(panel.consoleMessages.innerHTML).toContain('class="console-entry warn"'); + expect(panel.consoleMessages.innerHTML).toContain('Synthetic <renderer> warning'); + expect(panel.consoleMessages.innerHTML).not.toContain(''); + expect(panel.consoleMessages.innerHTML).not.toContain('Wrong target'); + }); + + it('isolates reconnects, honors the Live toggle, and tears down on pagehide', () => { + panel.startConsoleStream(); + const first = eventSources[0]; + if (!first) throw new Error('Expected the first Chromium console stream.'); + + panel.state.target = { id: 'owl:replacement', sessionId: 'replacement' }; + panel.startConsoleStream(); + const second = eventSources[1]; + if (!second) throw new Error('Expected the replacement Chromium console stream.'); + first.emit('console', { + level: 'error', + message: 'Stale target', + sessionId: 'web-preview', + targetId: 'owl:web-preview', + timestamp: 1, + }); + second.emit('console', { + level: 'info', + message: 'Replacement target', + sessionId: 'replacement', + targetId: 'owl:replacement', + timestamp: 2, + }); + + expect(first.closed).toBeTrue(); + expect(panel.state.consoleEntries).toEqual([ + jasmine.objectContaining({ kind: 'info', value: 'Replacement target' }), + ]); + + panel.liveToggle.checked = false; + panel.liveToggle.dispatch('change'); + expect(second.closed).toBeTrue(); + expect(panel.state.consoleStream).toBeNull(); + + panel.liveToggle.checked = true; + panel.liveToggle.dispatch('change'); + const resumed = eventSources[2]; + if (!resumed) throw new Error('Expected a resumed Chromium console stream.'); + panel.dispatchWindowEvent('pagehide'); + expect(resumed.closed).toBeTrue(); + expect(panel.state.consoleStream).toBeNull(); + }); + + it('keeps manual evaluation bound to the same target tuple while streaming', async () => { + panel.startConsoleStream(); + await panel.evaluateConsoleExpression('2 + 2'); + + expect(requests).toContain( + jasmine.objectContaining({ + body: JSON.stringify({ + expression: '2 + 2', + inspectedUrl: 'http://127.0.0.1:54321/index.html?valdiDevTools=1', + sessionId: 'web-preview', + targetNonce: 'panel-target-nonce-123456', + }), + url: 'http://127.0.0.1:18768/api/devtools/evaluate', + }), + ); + expect(panel.state.consoleEntries).toEqual([ + jasmine.objectContaining({ kind: 'input', value: '2 + 2' }), + jasmine.objectContaining({ kind: 'result', value: '4' }), + ]); + }); +}); diff --git a/npm_modules/cli/src/debugger/server.spec.ts b/npm_modules/cli/src/debugger/server.spec.ts index 2d72cc9d..7116e362 100644 --- a/npm_modules/cli/src/debugger/server.spec.ts +++ b/npm_modules/cli/src/debugger/server.spec.ts @@ -1,4 +1,5 @@ import 'jasmine'; +import crypto from 'node:crypto'; import * as fs from 'node:fs'; import * as http from 'node:http'; import * as net from 'node:net'; @@ -47,6 +48,205 @@ interface MockDaemon { requests: Array>; } +interface MockChromiumConsoleServer { + close: () => Promise; + debuggerSockets: Set; + methods: string[]; + port: number; + runtimeEnableReceived: Promise; + releaseRuntimeEnable(): void; +} + +interface MockChromiumConsoleServerOptions { + holdRuntimeEnable: boolean; +} + +function encodeChromiumServerMessage(payload: Record): Buffer { + const body = Buffer.from(JSON.stringify(payload), 'utf8'); + const header = body.length < 126 ? Buffer.alloc(2) : body.length <= 0xff_ff ? Buffer.alloc(4) : Buffer.alloc(10); + header[0] = 0x81; + if (body.length < 126) { + header[1] = body.length; + } else if (body.length <= 0xff_ff) { + header[1] = 126; + header.writeUInt16BE(body.length, 2); + } else { + header[1] = 127; + header.writeBigUInt64BE(BigInt(body.length), 2); + } + return Buffer.concat([header, body]); +} + +function readChromiumClientFrame(buffer: Buffer): { consumed: number; payload: Record } | null { + if (buffer.length < 2) return null; + let offset = 2; + const secondByte = buffer.readUInt8(1); + let payloadLength = secondByte & 0x7f; + if (payloadLength === 126) { + if (buffer.length < 4) return null; + payloadLength = buffer.readUInt16BE(2); + offset = 4; + } else if (payloadLength === 127) { + if (buffer.length < 10) return null; + payloadLength = Number(buffer.readBigUInt64BE(2)); + offset = 10; + } + const masked = (secondByte & 0x80) !== 0; + if (!masked || buffer.length < offset + 4 + payloadLength) return null; + const mask = buffer.subarray(offset, offset + 4); + offset += 4; + const encoded = buffer.subarray(offset, offset + payloadLength); + const decoded = Buffer.from(encoded.map((value, index) => value ^ (mask[index % mask.length] ?? 0))); + return { + consumed: offset + payloadLength, + payload: JSON.parse(decoded.toString('utf8')) as Record, + }; +} + +async function startMockChromiumConsoleServer( + applicationUrl: string, + targetNonce: string, + options: MockChromiumConsoleServerOptions, +): Promise { + const debuggerSockets = new Set(); + const pendingRuntimeEnableResponses: Array<() => void> = []; + const methods: string[] = []; + let resolveRuntimeEnableReceived: (() => void) | null = null; + const runtimeEnableReceived = new Promise(resolve => { + resolveRuntimeEnableReceived = resolve; + }); + const server = http.createServer((request, response) => { + if (request.url !== '/json/list') { + response.writeHead(404).end(); + return; + } + const address = server.address(); + if (typeof address !== 'object' || address === null) { + response.writeHead(500).end(); + return; + } + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end( + JSON.stringify([ + { + id: 'selected-page', + title: 'Selected Valdi page', + type: 'page', + url: `${applicationUrl}${applicationUrl.includes('?') ? '&' : '?'}valdiDevTools=1`, + webSocketDebuggerUrl: `ws://127.0.0.1:${address.port}/devtools/page/selected-page`, + }, + ]), + ); + }); + server.on('upgrade', (request, socket: net.Socket) => { + const key = request.headers['sec-websocket-key']; + if (request.url !== '/devtools/page/selected-page' || typeof key !== 'string') { + socket.destroy(); + return; + } + const accept = crypto.createHash('sha1').update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`).digest('base64'); + socket.write( + `HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`, + ); + debuggerSockets.add(socket); + socket.once('end', () => socket.destroy()); + socket.once('close', () => debuggerSockets.delete(socket)); + let buffered = Buffer.alloc(0); + socket.on('data', chunk => { + buffered = Buffer.concat([buffered, chunk]); + let frame = readChromiumClientFrame(buffered); + while (frame) { + buffered = buffered.subarray(frame.consumed); + const id = frame.payload['id']; + const method = frame.payload['method']; + const params = frame.payload['params'] as Record | undefined; + if (typeof id !== 'number' || typeof method !== 'string') { + socket.destroy(); + return; + } + methods.push(method); + if (method === 'Runtime.evaluate') { + const expression = typeof params?.['expression'] === 'string' ? params['expression'] : ''; + const matched = expression.includes(applicationUrl) && expression.includes(targetNonce); + socket.write( + encodeChromiumServerMessage({ + id, + result: { + result: { + type: 'object', + value: { __valdiDevToolsTargetMatched: matched, ...(matched ? { value: true } : {}) }, + }, + }, + }), + ); + } else { + const sendResponse = () => { + if (socket.destroyed) return; + socket.write(encodeChromiumServerMessage({ id, result: {} })); + if (method === 'Runtime.enable') { + socket.write( + encodeChromiumServerMessage({ + method: 'Runtime.consoleAPICalled', + params: { + args: [{ type: 'string', value: 'Synthetic output' }], + timestamp: 101, + type: 'warning', + }, + }), + ); + } + if (method === 'Log.enable') { + socket.write( + encodeChromiumServerMessage({ + method: 'Log.entryAdded', + params: { + entry: { + level: 'error', + text: 'authorization: Bearer synthetic-private-token', + timestamp: 102, + }, + }, + }), + ); + } + }; + if (method === 'Runtime.enable') { + resolveRuntimeEnableReceived?.(); + if (options.holdRuntimeEnable) { + pendingRuntimeEnableResponses.push(sendResponse); + } else { + sendResponse(); + } + } else { + sendResponse(); + } + } + frame = readChromiumClientFrame(buffered); + } + }); + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + if (typeof address !== 'object' || address === null) throw new Error('Mock Chromium server did not bind.'); + return { + close: async () => { + for (const socket of debuggerSockets) socket.destroy(); + await closeServer(server); + }, + debuggerSockets, + methods, + port: address.port, + releaseRuntimeEnable(): void { + for (const sendResponse of pendingRuntimeEnableResponses.splice(0)) sendResponse(); + }, + runtimeEnableReceived, + }; +} + const TEST_PACKET_MAGIC = Buffer.from([0x33, 0xc6, 0x00, 0x01]); function encodeDaemonPacket(payload: object): Buffer { @@ -279,6 +479,7 @@ async function readSseEvents( onEvent(payload, payloads.length - 1); if (payloads.length === count) { settled = true; + response.socket.destroy(); response.destroy(); outgoingRequest.destroy(); resolve(payloads); @@ -588,6 +789,215 @@ describe('debugger server', () => { }); }); + it('rejects console streams that do not carry the exact selected target tuple', async () => { + debuggerServer = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + webPreviewUrl: 'http://127.0.0.1:54321/index.html?tenant=alpha', + chromiumDebuggingPort: 9333, + }); + + const missingSession = await request( + new URL( + `/api/devtools/console/stream?sessionId=missing&inspectedUrl=http%3A%2F%2F127.0.0.1%3A54321%2Findex.html%3Ftenant%3Dalpha&targetNonce=${WEB_PREVIEW_NONCE}`, + debuggerServer.url, + ).toString(), + GET_REQUEST_OPTIONS, + ); + const wrongPage = await request( + new URL( + `/api/devtools/console/stream?sessionId=web-preview&inspectedUrl=http%3A%2F%2F127.0.0.1%3A54321%2Fother.html&targetNonce=${WEB_PREVIEW_NONCE}`, + debuggerServer.url, + ).toString(), + GET_REQUEST_OPTIONS, + ); + const missingNonce = await request( + new URL( + '/api/devtools/console/stream?sessionId=web-preview&inspectedUrl=http%3A%2F%2F127.0.0.1%3A54321%2Findex.html%3Ftenant%3Dalpha', + debuggerServer.url, + ).toString(), + GET_REQUEST_OPTIONS, + ); + + expect(missingSession.statusCode).toBe(404); + expect(JSON.parse(missingSession.body)).toEqual({ + error: 'The configured web preview debugger target is not available.', + }); + expect(wrongPage.statusCode).toBe(404); + expect(JSON.parse(wrongPage.body)).toEqual({ + error: 'The inspected page does not match the configured Valdi web preview target.', + }); + expect(missingNonce.statusCode).toBe(400); + expect(JSON.parse(missingNonce.body)).toEqual({ + error: 'DevTools target discovery requires a valid inspected-tab nonce.', + }); + }); + + it('streams bounded selected-target output and releases Chromium when the SSE client disconnects', async () => { + const applicationUrl = 'http://127.0.0.1:54321/index.html?tenant=alpha'; + const chromium = await startMockChromiumConsoleServer(applicationUrl, WEB_PREVIEW_NONCE, { + holdRuntimeEnable: false, + }); + try { + debuggerServer = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + webPreviewUrl: applicationUrl, + chromiumDebuggingPort: chromium.port, + }); + const streamUrl = new URL('/api/devtools/console/stream', debuggerServer.url); + streamUrl.searchParams.set('inspectedUrl', `${applicationUrl}&valdiDevTools=1`); + streamUrl.searchParams.set('sessionId', 'web-preview'); + streamUrl.searchParams.set('targetNonce', WEB_PREVIEW_NONCE); + + const entries = await readSseEvents(streamUrl.toString(), 'console', 2, () => {}); + + expect(entries).toEqual([ + jasmine.objectContaining({ + level: 'warn', + message: 'Synthetic output', + sessionId: 'web-preview', + source: 'console', + targetId: 'owl:web-preview', + }), + jasmine.objectContaining({ + level: 'error', + message: 'authorization: [REDACTED]', + sessionId: 'web-preview', + source: 'browser', + targetId: 'owl:web-preview', + }), + ]); + expect(JSON.stringify(entries)).not.toContain('synthetic-private-token'); + expect(chromium.methods.slice(0, 4)).toEqual([ + 'Runtime.evaluate', + 'Runtime.enable', + 'Log.enable', + 'Runtime.evaluate', + ]); + + const deadline = Date.now() + 1000; + while (chromium.debuggerSockets.size > 0 && Date.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, 10)); + } + expect(chromium.debuggerSockets.size).toBe(0); + } finally { + await chromium.close(); + } + }); + + it('releases Chromium when the SSE client aborts while Runtime.enable is pending', async () => { + const applicationUrl = 'http://127.0.0.1:54321/index.html?tenant=abort-during-enable'; + const chromium = await startMockChromiumConsoleServer(applicationUrl, WEB_PREVIEW_NONCE, { + holdRuntimeEnable: true, + }); + try { + debuggerServer = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + webPreviewUrl: applicationUrl, + chromiumDebuggingPort: chromium.port, + }); + const streamUrl = new URL('/api/devtools/console/stream', debuggerServer.url); + streamUrl.searchParams.set('inspectedUrl', `${applicationUrl}&valdiDevTools=1`); + streamUrl.searchParams.set('sessionId', 'web-preview'); + streamUrl.searchParams.set('targetNonce', WEB_PREVIEW_NONCE); + const stream = startStreamingRequest(streamUrl.toString(), 'GET'); + const streamResult = stream.result.catch(() => null); + stream.request.end(); + + await chromium.runtimeEnableReceived; + expect(chromium.debuggerSockets.size).toBe(1); + stream.request.destroy(); + await streamResult; + + const deadline = Date.now() + 1000; + while (chromium.debuggerSockets.size > 0 && Date.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, 10)); + } + expect(chromium.debuggerSockets.size).toBe(0); + } finally { + chromium.releaseRuntimeEnable(); + await chromium.close(); + } + }); + + it('closes promptly when debugger shutdown starts while Runtime.enable is pending', async () => { + const applicationUrl = 'http://127.0.0.1:54321/index.html?tenant=shutdown-during-enable'; + const chromium = await startMockChromiumConsoleServer(applicationUrl, WEB_PREVIEW_NONCE, { + holdRuntimeEnable: true, + }); + let chromiumClosed = false; + try { + const serverToClose = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + webPreviewUrl: applicationUrl, + chromiumDebuggingPort: chromium.port, + }); + debuggerServer = serverToClose; + const streamUrl = new URL('/api/devtools/console/stream', serverToClose.url); + streamUrl.searchParams.set('inspectedUrl', `${applicationUrl}&valdiDevTools=1`); + streamUrl.searchParams.set('sessionId', 'web-preview'); + streamUrl.searchParams.set('targetNonce', WEB_PREVIEW_NONCE); + const stream = startStreamingRequest(streamUrl.toString(), 'GET'); + const streamResult = stream.result.catch(() => null); + stream.request.end(); + + await chromium.runtimeEnableReceived; + let shutdownComplete = false; + debuggerServer = undefined; + const shutdown = serverToClose.close().then(() => { + shutdownComplete = true; + }); + const deadline = Date.now() + 1000; + while (!shutdownComplete && Date.now() < deadline) { + await new Promise(resolve => setTimeout(resolve, 10)); + } + const shutdownCompletedDuringEnable = shutdownComplete; + const chromiumReleasedDuringEnable = chromium.debuggerSockets.size === 0; + + stream.request.destroy(); + chromium.releaseRuntimeEnable(); + await chromium.close(); + chromiumClosed = true; + await shutdown; + await streamResult; + + expect(shutdownCompletedDuringEnable).toBeTrue(); + expect(chromiumReleasedDuringEnable).toBeTrue(); + } finally { + if (!chromiumClosed) await chromium.close(); + } + }); + + it('requires GET for the integrated Chromium console stream', async () => { + debuggerServer = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + webPreviewUrl: 'http://127.0.0.1:54321/index.html', + }); + + const result = await request(new URL('/api/devtools/console/stream', debuggerServer.url).toString(), { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: '{}', + }); + + expect(result.statusCode).toBe(405); + expect(JSON.parse(result.body)).toEqual({ error: 'Valdi DevTools console streaming requires GET.' }); + }); + it('requires JSON for executable integrated DevTools routes', async () => { debuggerServer = await startDebuggerServer({ assetRoot, diff --git a/npm_modules/cli/src/debugger/server.ts b/npm_modules/cli/src/debugger/server.ts index eb9e4911..759426f7 100644 --- a/npm_modules/cli/src/debugger/server.ts +++ b/npm_modules/cli/src/debugger/server.ts @@ -19,10 +19,13 @@ import { getUserConfig, resolveFilePath } from '../utils/fileUtils'; import { type CpuProfile, HERMES_PORT, HermesConnection, listHermesDevices } from '../utils/hermesClient'; import { isLoopbackHost, normalizedHostname } from '../utils/loopbackHost'; import { + type OwlChromiumConnection, + connectToOwlApplication, evaluateOwlApplicationExpression, matchesOwlApplicationUrl, readOwlDebuggerSnapshot, } from '../utils/owlCdpClient'; +import { type ChromiumConsoleEntry, formatChromiumConsoleEvent } from './chromiumConsole'; import { DebuggerInputType, sendDebuggerInput, validateDebuggerInputRequest } from './inputClient'; const DEFAULT_HOST = process.env['VALDI_DEBUGGER_HOST'] || '127.0.0.1'; @@ -46,6 +49,12 @@ const MAX_TRACE_THREAD_METADATA_COUNT = 256; export const MAX_TRACE_HTTP_RESPONSE_BYTES = 4 * 1024 * 1024; const MAX_TRACE_HTTP_STRING_BYTES = 64 * 1024; const TRACE_DAEMON_TIMEOUT_MS = 30_000; +const CHROMIUM_CONSOLE_COMMAND_TIMEOUT_MS = 8000; +const CHROMIUM_CONSOLE_IDENTITY_INTERVAL_MS = 15_000; +const CHROMIUM_CONSOLE_DEDUPLICATION_WINDOW_MS = 500; +const MAX_PENDING_CHROMIUM_CONSOLE_ENTRIES = 128; +export const MAX_CONSOLE_SSE_BUFFERED_BYTES = 512 * 1024; +export const MAX_CONSOLE_SSE_BUFFERED_EVENTS = 128; const PERFETTO_PROCESS_ID = 1; const PERFETTO_PROCESS_NAME = 'Valdi'; const PERFETTO_TRACE_CATEGORY = 'valdi'; @@ -62,6 +71,7 @@ const TRACE_CAPTURE_TARGET_STRING_KEYS = [ ] as const; const DEBUGGER_PROVIDERS_IDENTIFIER = 'ValdiDebuggerProviders'; const DEBUG_SETTINGS_IDENTIFIER = 'ValdiDebuggerSettings'; +const NOOP = (): void => {}; const MIME_TYPES: Record = { '.html': 'text/html; charset=utf-8', @@ -1504,17 +1514,119 @@ async function selectRuntimeLogFile(searchParams: URLSearchParams): Promise<{ return { logsDirectory, latest: latest ?? null }; } +export class ConsoleSseWriter { + private backpressured = false; + private bufferedBytes = 0; + private readonly bufferedFrames: string[] = []; + private closed = false; + + constructor( + private readonly response: ServerResponse, + private readonly onFailure: (error: Error) => void, + ) {} + + send(event: string, payload: unknown): boolean { + if (this.closed) return false; + let data: string | undefined; + try { + data = JSON.stringify(payload); + } catch { + this.fail(new Error('Could not serialize a Chromium console event.')); + return false; + } + if (data === undefined || !/^[a-z][a-z-]{0,63}$/.test(event)) { + this.fail(new Error('Could not encode a Chromium console event.')); + return false; + } + const frame = `event: ${event}\ndata: ${data}\n\n`; + if (this.backpressured) return this.buffer(frame); + return this.write(frame); + } + + close(): void { + if (this.closed) return; + this.closed = true; + this.response.off('drain', this.flush); + this.bufferedFrames.length = 0; + this.bufferedBytes = 0; + } + + private buffer(frame: string): boolean { + const frameBytes = Buffer.byteLength(frame); + if ( + this.bufferedFrames.length >= MAX_CONSOLE_SSE_BUFFERED_EVENTS || + frameBytes > MAX_CONSOLE_SSE_BUFFERED_BYTES - this.bufferedBytes + ) { + this.fail( + new Error( + `Chromium console stream exceeded its ${MAX_CONSOLE_SSE_BUFFERED_EVENTS} event or ${MAX_CONSOLE_SSE_BUFFERED_BYTES} byte backpressure limit.`, + ), + ); + return false; + } + this.bufferedFrames.push(frame); + this.bufferedBytes += frameBytes; + return true; + } + + private readonly flush = (): void => { + if (this.closed) return; + this.backpressured = false; + while (this.bufferedFrames.length > 0) { + const frame = this.bufferedFrames.shift(); + if (frame === undefined) return; + this.bufferedBytes -= Buffer.byteLength(frame); + if (!this.write(frame)) return; + } + }; + + private write(frame: string): boolean { + try { + if (!this.response.write(frame)) { + this.backpressured = true; + this.response.once('drain', this.flush); + } + return true; + } catch (error) { + this.fail(error instanceof Error ? error : new Error('Could not write a Chromium console event.')); + return false; + } + } + + private fail(error: Error): void { + if (this.closed) return; + this.close(); + this.onFailure(error); + } +} + function sendSse(response: ServerResponse, event: string, payload: unknown): void { response.write(`event: ${event}\n`); response.write(`data: ${JSON.stringify(payload)}\n\n`); } -function registerEventStream(request: IncomingMessage, response: ServerResponse, onClose: () => void): () => void { - let closed = false; - const close = () => { - if (closed) return; - closed = true; +interface EventStreamLifetime { + close: () => void; + detach: () => void; +} + +function registerEventStreamLifetime( + request: IncomingMessage, + response: ServerResponse, + onClose: () => void, +): EventStreamLifetime { + let active = true; + const detach = () => { + if (!active) return; + active = false; eventStreamClosers.delete(close); + request.off('aborted', close); + response.off('close', close); + response.off('finish', close); + }; + const close = () => { + if (!active) return; + detach(); onClose(); if (!response.writableEnded) response.end(); }; @@ -1522,7 +1634,216 @@ function registerEventStream(request: IncomingMessage, response: ServerResponse, request.once('aborted', close); response.once('close', close); response.once('finish', close); - return close; + return { close, detach }; +} + +function registerEventStream(request: IncomingMessage, response: ServerResponse, onClose: () => void): () => void { + return registerEventStreamLifetime(request, response, onClose).close; +} + +function isTerminalChromiumConsoleEvent(method: string): boolean { + return ( + method === 'Inspector.detached' || + method === 'Runtime.executionContextsCleared' || + method === 'Target.detachedFromTarget' + ); +} + +async function streamWebPreviewConsole( + request: IncomingMessage, + response: ServerResponse, + searchParams: URLSearchParams, +): Promise { + const target = resolveWebPreviewDebuggerTarget(searchParams.get('sessionId') ?? undefined); + const context = resolveInspectedWebPreviewContext( + target, + searchParams.get('inspectedUrl') ?? undefined, + searchParams.get('targetNonce') ?? undefined, + ); + const recentEntries = new Map(); + const pendingEntries: Array = []; + let connection: OwlChromiumConnection | null = null; + let pendingDroppedEntryCount = 0; + let sequence = 0; + let setupFailure: Error | null = null; + let writer: ConsoleSseWriter | null = null; + let closeStream = NOOP; + let removeEventListener = NOOP; + let removeConnectionClose = NOOP; + let identityTimer: NodeJS.Timeout | null = null; + let identityCheckInFlight = false; + let closing = false; + + const closeWithError = (error: Error): void => { + if (closing) return; + if (!writer) { + setupFailure ??= error; + return; + } + writer.send('stream-error', { + error: error.message, + sessionId: target.sessionId, + targetId: target.id, + }); + closeStream(); + }; + + const cleanup = (): void => { + if (closing) return; + closing = true; + if (identityTimer) clearInterval(identityTimer); + identityTimer = null; + removeEventListener(); + removeConnectionClose(); + writer?.close(); + connection?.close(); + }; + + const streamLifetime = registerEventStreamLifetime(request, response, cleanup); + closeStream = streamLifetime.close; + if (request.aborted || request.destroyed || response.destroyed || response.writableEnded) { + closeStream(); + return; + } + + const sendEntry = (entry: ChromiumConsoleEntry): void => { + const key = `${entry.level}:${entry.message}`; + const previous = recentEntries.get(key); + if ( + previous !== undefined && + previous.source !== entry.source && + Math.abs(previous.timestamp - entry.timestamp) < CHROMIUM_CONSOLE_DEDUPLICATION_WINDOW_MS + ) { + return; + } + recentEntries.delete(key); + recentEntries.set(key, entry); + if (recentEntries.size > MAX_PENDING_CHROMIUM_CONSOLE_ENTRIES) { + const oldest = recentEntries.keys().next().value; + if (oldest !== undefined) recentEntries.delete(oldest); + } + + const payload = { ...entry, sequence: ++sequence }; + if (writer) { + writer.send('console', { + ...payload, + sessionId: target.sessionId, + targetId: target.id, + }); + return; + } + if (pendingEntries.length >= MAX_PENDING_CHROMIUM_CONSOLE_ENTRIES) { + pendingEntries.shift(); + pendingDroppedEntryCount += 1; + } + pendingEntries.push(payload); + }; + + try { + const connected = await connectToOwlApplication(target.debuggingPort, target.applicationUrl, context.targetNonce); + connection = connected; + if (closing) { + connected.close(); + return; + } + + removeConnectionClose = connected.onClose(error => closeWithError(error)); + if (setupFailure) throw setupFailure; + removeEventListener = connected.onEvent(event => { + if (isTerminalChromiumConsoleEvent(event.method)) { + closeWithError(new Error('The inspected Chromium target changed or detached.')); + return; + } + const entry = formatChromiumConsoleEvent(event); + if (entry) sendEntry(entry); + }); + + await connected.call('Runtime.enable', {}, CHROMIUM_CONSOLE_COMMAND_TIMEOUT_MS); + if (setupFailure) throw setupFailure; + await connected.call('Log.enable', {}, CHROMIUM_CONSOLE_COMMAND_TIMEOUT_MS); + if (setupFailure) throw setupFailure; + if (!(await connected.matchesTarget(target.applicationUrl, context.targetNonce))) { + throw new Error('The inspected Chromium target changed while the console stream was connecting.'); + } + } catch (error) { + if (closing) return; + streamLifetime.detach(); + cleanup(); + throw setupFailure ?? error; + } + + if (request.aborted || request.destroyed || response.destroyed || response.writableEnded) { + closeStream(); + return; + } + const activeConnection = connection; + if (!activeConnection) { + closeStream(); + return; + } + + response.writeHead(200, { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-store, no-transform', + Connection: 'keep-alive', + }); + + writer = new ConsoleSseWriter(response, error => closeWithError(error)); + + if (setupFailure) { + closeWithError(setupFailure); + return; + } + + writer.send('ready', { + sessionId: target.sessionId, + targetId: target.id, + time: new Date().toISOString(), + }); + if (pendingDroppedEntryCount > 0) { + writer.send('stream-warning', { + droppedEntryCount: pendingDroppedEntryCount, + message: 'Some buffered Chromium console entries were omitted while the stream connected.', + sessionId: target.sessionId, + targetId: target.id, + }); + } + for (const entry of pendingEntries) { + writer.send('console', { + ...entry, + sessionId: target.sessionId, + targetId: target.id, + }); + } + + if (closing) return; + + identityTimer = setInterval(() => { + if (closing || identityCheckInFlight) return; + identityCheckInFlight = true; + void activeConnection + .matchesTarget(target.applicationUrl, context.targetNonce) + .then(matched => { + if (!matched) { + closeWithError(new Error('The inspected Chromium target identity changed.')); + return; + } + writer?.send('heartbeat', { + sessionId: target.sessionId, + targetId: target.id, + time: new Date().toISOString(), + }); + }) + .catch(error => + closeWithError( + error instanceof Error ? error : new Error('Could not revalidate the inspected Chromium target.'), + ), + ) + .finally(() => { + identityCheckInFlight = false; + }); + }, CHROMIUM_CONSOLE_IDENTITY_INTERVAL_MS); + identityTimer.unref(); } function broadcastDevEvent(event: string, payload: unknown): void { @@ -2931,6 +3252,15 @@ async function handleApi(request: IncomingMessage, response: ServerResponse, url return; } + if (url.pathname === '/api/devtools/console/stream') { + if (request.method !== 'GET') { + sendJson(response, 405, { error: 'Valdi DevTools console streaming requires GET.' }); + return; + } + await streamWebPreviewConsole(request, response, url.searchParams); + return; + } + if (url.pathname === '/api/devtools/evaluate' || url.pathname === '/api/devtools/highlight') { if (request.method !== 'POST') { sendJson(response, 405, { error: 'Valdi DevTools runtime actions require POST.' }); diff --git a/npm_modules/cli/src/utils/chromiumDevToolsClient.ts b/npm_modules/cli/src/utils/chromiumDevToolsClient.ts index b24f5b89..11501530 100644 --- a/npm_modules/cli/src/utils/chromiumDevToolsClient.ts +++ b/npm_modules/cli/src/utils/chromiumDevToolsClient.ts @@ -20,9 +20,16 @@ interface DevToolsCommandResponse { message?: string; }; id?: number; + method?: string; + params?: Record; result?: unknown; } +export interface ChromiumDevToolsEvent { + method: string; + params: Record; +} + function encodeWebSocketFrame(opcode: number, payload: Buffer): Buffer { const mask = crypto.randomBytes(4); let header: Buffer; @@ -58,6 +65,8 @@ function encodeWebSocketTextFrame(text: string): Buffer { /** Shared, dependency-free Chromium DevTools transport for Owl and Hermes. */ export class ChromiumDevToolsConnection { + private readonly closeListeners = new Set<(error: Error) => void>(); + private readonly eventListeners = new Set<(event: ChromiumDevToolsEvent) => void>(); private readonly pending = new Map(); private closedError: Error | null = null; private nextCommandId = 0; @@ -158,6 +167,25 @@ export class ChromiumDevToolsConnection { }); } + onClose(listener: (error: Error) => void): () => void { + if (this.closedError) { + listener(this.closedError); + return () => {}; + } + this.closeListeners.add(listener); + return () => { + this.closeListeners.delete(listener); + }; + } + + onEvent(listener: (event: ChromiumDevToolsEvent) => void): () => void { + if (this.closedError) throw this.closedError; + this.eventListeners.add(listener); + return () => { + this.eventListeners.delete(listener); + }; + } + close(): void { this.failConnection(new Error('The Chromium DevTools WebSocket closed.')); } @@ -281,7 +309,29 @@ export class ChromiumDevToolsConnection { return; } const response = parsed as DevToolsCommandResponse; - if (response.id === undefined) return; + if (response.id === undefined) { + if (typeof response.method !== 'string' || response.method.length === 0 || response.method.length > 256) { + this.failConnection(new Error('Chromium DevTools returned a malformed protocol event.')); + return; + } + const params = response.params; + if (params !== undefined && (typeof params !== 'object' || params === null || Array.isArray(params))) { + this.failConnection(new Error('Chromium DevTools returned an event with invalid parameters.')); + return; + } + const event: ChromiumDevToolsEvent = { + method: response.method, + params: params ?? {}, + }; + for (const listener of Array.from(this.eventListeners)) { + try { + listener(event); + } catch (error) { + console.warn(`[Valdi DevTools] A Chromium ${response.method} event listener failed.`, error); + } + } + return; + } if (!Number.isInteger(response.id)) { this.failConnection(new Error('Chromium DevTools returned a response with a non-numeric command id.')); return; @@ -311,6 +361,15 @@ export class ChromiumDevToolsConnection { this.closedError = error; this.receiveBuffer = Buffer.alloc(0); this.rejectPending(error); + this.eventListeners.clear(); + for (const listener of Array.from(this.closeListeners)) { + try { + listener(error); + } catch (listenerError) { + console.warn('[Valdi DevTools] A Chromium close listener failed.', listenerError); + } + } + this.closeListeners.clear(); if (!this.socket.destroyed) this.socket.destroy(); } diff --git a/npm_modules/cli/src/utils/owlCdpClient.spec.ts b/npm_modules/cli/src/utils/owlCdpClient.spec.ts index 3a8a2218..aef7f90a 100644 --- a/npm_modules/cli/src/utils/owlCdpClient.spec.ts +++ b/npm_modules/cli/src/utils/owlCdpClient.spec.ts @@ -6,6 +6,7 @@ import { Script } from 'node:vm'; import { OWL_DEVTOOLS_TARGET_NONCE_PROPERTY, OwlChromiumConnection, + connectToOwlApplication, evaluateOwlApplicationExpression, listOwlChromiumTargets, readOwlDebuggerSnapshot, @@ -15,6 +16,7 @@ interface ChromiumDiscoveryServer { closedTargetIds: string[]; events: string[]; expressions: string[]; + emitEvent(event: Record): void; port: number; server: http.Server; setDiscoveryBody(body: string): void; @@ -82,20 +84,26 @@ function decodeWebSocketFrame(frame: Buffer): DecodedWebSocketFrame { const mask = masked ? frame.subarray(offset, offset + 4) : null; if (mask) offset += mask.length; const rawPayload = frame.subarray(offset, offset + payloadLength); - const payload = mask - ? Buffer.from(rawPayload.map((value, index) => value ^ mask[index % mask.length]!)) - : rawPayload; + const payload = mask ? Buffer.from(rawPayload.map((value, index) => value ^ mask[index % mask.length]!)) : rawPayload; return { masked, opcode, payload }; } -function decodeWebSocketCommand(frame: DecodedWebSocketFrame): { id: number; params: { expression: string } } { +function decodeWebSocketCommand(frame: DecodedWebSocketFrame): { + id: number; + method: string; + params: { expression?: string }; +} { if (frame.opcode !== 0x01) throw new Error(`Expected a text command frame, received opcode ${frame.opcode}.`); - return JSON.parse(frame.payload.toString('utf8')) as { id: number; params: { expression: string } }; + return JSON.parse(frame.payload.toString('utf8')) as { + id: number; + method: string; + params: { expression?: string }; + }; } function evaluateCommandInPage(socket: Socket, frame: DecodedWebSocketFrame, page: Record): void { const command = decodeWebSocketCommand(frame); - const result = new Script(command.params.expression).runInNewContext(page) as unknown; + const result = new Script(command.params.expression ?? '').runInNewContext(page) as unknown; void Promise.resolve(result).then(value => { socket.write( encodeWebSocketResponse({ @@ -120,6 +128,7 @@ async function createChromiumDiscoveryServer(): Promise const closedTargetIds: string[] = []; const closedTargets = new Set(); const events: string[] = []; + const debuggerSockets = new Set(); const sockets = new Set(); let discoveryBody: string | null = null; let discoveryChunkDelayMs = 0; @@ -133,22 +142,24 @@ async function createChromiumDiscoveryServer(): Promise } const address = server.address() as AddressInfo; response.writeHead(discoveryStatus, { 'content-type': 'application/json' }); - const body = discoveryBody ?? JSON.stringify([ - { - id: 'owl-page', - title: 'Valdi Owl', - type: 'page', - url: 'http://127.0.0.1:54321/index.html?valdiDebugger=1&valdiDevTools=1', - webSocketDebuggerUrl: `ws://127.0.0.1:${address.port}/devtools/page/owl-page`, - }, - { - id: 'redirected-page', - title: 'Untrusted loopback redirect', - type: 'page', - url: 'http://127.0.0.1:54321/index.html', - webSocketDebuggerUrl: 'ws://127.0.0.1:1/devtools/page/redirected-page', - }, - ]); + const body = + discoveryBody ?? + JSON.stringify([ + { + id: 'owl-page', + title: 'Valdi Owl', + type: 'page', + url: 'http://127.0.0.1:54321/index.html?valdiDebugger=1&valdiDevTools=1', + webSocketDebuggerUrl: `ws://127.0.0.1:${address.port}/devtools/page/owl-page`, + }, + { + id: 'redirected-page', + title: 'Untrusted loopback redirect', + type: 'page', + url: 'http://127.0.0.1:54321/index.html', + webSocketDebuggerUrl: 'ws://127.0.0.1:1/devtools/page/redirected-page', + }, + ]); if (!discoveryChunks) { response.end(body); return; @@ -176,6 +187,8 @@ async function createChromiumDiscoveryServer(): Promise return; } const targetId = targetMatch[1]!; + debuggerSockets.add(socket); + socket.once('close', () => debuggerSockets.delete(socket)); events.push(`open:${targetId}`); const markTargetClosed = () => { if (closedTargets.has(targetId)) return; @@ -194,15 +207,15 @@ async function createChromiumDiscoveryServer(): Promise const decodedFrame = decodeWebSocketFrame(frame); if (decodedFrame.opcode === 0x01) { const command = decodeWebSocketCommand(decodedFrame); - expressions.push(command.params.expression); - events.push(`evaluate:${targetId}`); + if (command.params.expression !== undefined) expressions.push(command.params.expression); + events.push(`${command.method}:${targetId}`); } if (webSocketResponder) { webSocketResponder(socket, decodedFrame, targetId); return; } const command = decodeWebSocketCommand(decodedFrame); - const value = command.params.expression.includes('__VALDI_WEB_DEBUGGER__') ? debuggerSnapshotValue() : true; + const value = command.params.expression?.includes('__VALDI_WEB_DEBUGGER__') ? debuggerSnapshotValue() : true; socket.write( encodeWebSocketResponse({ id: command.id, @@ -228,6 +241,9 @@ async function createChromiumDiscoveryServer(): Promise } resolve({ closedTargetIds, + emitEvent(event: Record): void { + for (const socket of debuggerSockets) socket.write(encodeWebSocketResponse(event)); + }, events, expressions, port: address.port, @@ -331,17 +347,11 @@ describe('owlCdpClient', () => { ), ); - await expectAsync(listOwlChromiumTargets(discovery.port)).toBeRejectedWithError( - /more than 256 targets/, - ); + await expectAsync(listOwlChromiumTargets(discovery.port)).toBeRejectedWithError(/more than 256 targets/); }); it('reads the exact real Owl page without launching a second web renderer', async () => { - const result = await readOwlDebuggerSnapshot( - discovery.port, - 'http://127.0.0.1:54321/index.html', - TARGET_NONCE, - ); + const result = await readOwlDebuggerSnapshot(discovery.port, 'http://127.0.0.1:54321/index.html', TARGET_NONCE); expect(result['channel']).toBe('valdi-web-debugger'); expect(result['snapshot']).toEqual( @@ -349,7 +359,9 @@ describe('owlCdpClient', () => { ); expect(discovery.expressions).toHaveSize(2); expect(discovery.expressions.every(expression => expression.includes(TARGET_NONCE))).toBeTrue(); - expect(discovery.expressions.every(expression => expression.includes('http://127.0.0.1:54321/index.html'))).toBeTrue(); + expect( + discovery.expressions.every(expression => expression.includes('http://127.0.0.1:54321/index.html')), + ).toBeTrue(); expect(discovery.expressions[1]).toContain('globalThis.__VALDI_WEB_DEBUGGER__?.getSnapshot()'); }); @@ -381,6 +393,54 @@ describe('owlCdpClient', () => { expect(discovery.expressions[1]).toContain(expression); }); + it('keeps an exact nonce-bound connection open for events and reports teardown', async () => { + const page: Record = { + URL, + location: { href: 'http://127.0.0.1:54321/index.html?valdiDebugger=1&valdiDevTools=1' }, + [OWL_DEVTOOLS_TARGET_NONCE_PROPERTY]: TARGET_NONCE, + }; + discovery.setWebSocketResponder((socket, frame) => { + const command = decodeWebSocketCommand(frame); + if (command.method === 'Runtime.evaluate') { + evaluateCommandInPage(socket, frame, page); + return; + } + socket.write(encodeWebSocketResponse({ id: command.id, result: {} })); + }); + const connection = await connectToOwlApplication(discovery.port, 'http://127.0.0.1:54321/index.html', TARGET_NONCE); + const events: Array<{ method: string; params: Record }> = []; + const closeErrors: Error[] = []; + let resolveEvent: (() => void) | null = null; + const receivedEvent = new Promise(resolve => { + resolveEvent = resolve; + }); + const removeEvent = connection.onEvent(event => { + events.push(event); + resolveEvent?.(); + }); + connection.onClose(error => closeErrors.push(error)); + + await expectAsync(connection.call('Runtime.enable', {}, 1000)).toBeResolvedTo({}); + discovery.emitEvent({ + method: 'Runtime.consoleAPICalled', + params: { args: [{ type: 'string', value: 'Synthetic renderer message' }], type: 'log' }, + }); + await receivedEvent; + + expect(events).toEqual([ + { + method: 'Runtime.consoleAPICalled', + params: { args: [{ type: 'string', value: 'Synthetic renderer message' }], type: 'log' }, + }, + ]); + expect(await connection.matchesTarget('http://127.0.0.1:54321/index.html', TARGET_NONCE)).toBeTrue(); + + removeEvent(); + connection.close(); + expect(closeErrors).toHaveSize(1); + expect(closeErrors[0]?.message).toContain('closed'); + }); + it('refuses to attach to a Chromium page for a different application path', async () => { await expectAsync( readOwlDebuggerSnapshot(discovery.port, 'http://127.0.0.1:54321/other.html', TARGET_NONCE), @@ -401,18 +461,10 @@ describe('owlCdpClient', () => { ); await expectAsync( - readOwlDebuggerSnapshot( - discovery.port, - 'http://127.0.0.1:54321/index.html?tenant=alpha&mode=dev', - TARGET_NONCE, - ), + readOwlDebuggerSnapshot(discovery.port, 'http://127.0.0.1:54321/index.html?tenant=alpha&mode=dev', TARGET_NONCE), ).toBeResolved(); await expectAsync( - readOwlDebuggerSnapshot( - discovery.port, - 'http://127.0.0.1:54321/index.html?tenant=beta&mode=dev', - TARGET_NONCE, - ), + readOwlDebuggerSnapshot(discovery.port, 'http://127.0.0.1:54321/index.html?tenant=beta&mode=dev', TARGET_NONCE), ).toBeRejectedWithError(/No running Owl Chromium page matches/); }); @@ -481,11 +533,7 @@ describe('owlCdpClient', () => { evaluateCommandInPage(socket, frame, pages[targetId]!); }); - const snapshot = await readOwlDebuggerSnapshot( - discovery.port, - 'http://127.0.0.1:54321/index.html', - TARGET_NONCE, - ); + const snapshot = await readOwlDebuggerSnapshot(discovery.port, 'http://127.0.0.1:54321/index.html', TARGET_NONCE); await new Promise(resolve => setTimeout(resolve, 50)); expect(snapshot['channel']).toBe('valdi-web-debugger'); @@ -601,7 +649,7 @@ describe('owlCdpClient', () => { if (frame.opcode === 0x01) { const command = decodeWebSocketCommand(frame); commandId = command.id; - commandExpression = command.params.expression; + commandExpression = command.params.expression ?? ''; socket.write(encodeWebSocketServerFrame(0x09, Buffer.from('owl-ping'), false, true)); return; } @@ -658,5 +706,4 @@ describe('owlCdpClient', () => { OwlChromiumConnection.connect('ws://person:secret@127.0.0.1/devtools/page/owl'), ).toBeRejectedWithError(/only allows unauthenticated loopback/); }); - }); diff --git a/npm_modules/cli/src/utils/owlCdpClient.ts b/npm_modules/cli/src/utils/owlCdpClient.ts index f4f600a0..10f2eabc 100644 --- a/npm_modules/cli/src/utils/owlCdpClient.ts +++ b/npm_modules/cli/src/utils/owlCdpClient.ts @@ -1,6 +1,6 @@ import http from 'node:http'; import { TextDecoder } from 'node:util'; -import { ChromiumDevToolsConnection } from './chromiumDevToolsClient'; +import { ChromiumDevToolsConnection, type ChromiumDevToolsEvent } from './chromiumDevToolsClient'; import { isLoopbackHost } from './loopbackHost'; const CHROMIUM_DISCOVERY_TIMEOUT_MS = 3000; @@ -240,17 +240,33 @@ export class OwlChromiumConnection { return result.result?.value; } + async call(method: string, params: Record, timeoutMs: number): Promise { + return await this.connection.call(method, params, timeoutMs); + } + + async matchesTarget(applicationUrl: string, targetNonce: string): Promise { + const evaluation = await evaluateGuardedOwlExpression(this, applicationUrl, targetNonce, 'true'); + return evaluation.matched; + } + + onClose(listener: (error: Error) => void): () => void { + return this.connection.onClose(listener); + } + + onEvent(listener: (event: ChromiumDevToolsEvent) => void): () => void { + return this.connection.onEvent(listener); + } + close(): void { this.connection.close(); } } -async function evaluateOnOwlApplication( +export async function connectToOwlApplication( port: number, applicationUrl: string, targetNonce: string, - expression: string, -): Promise { +): Promise { const targets = await listOwlChromiumTargets(port); const candidates = targets.filter( candidate => candidate.type === 'page' && matchesOwlApplicationUrl(candidate.url, applicationUrl), @@ -262,20 +278,17 @@ async function evaluateOnOwlApplication( let lastProbeError: Error | null = null; for (const candidate of candidates) { let connection: OwlChromiumConnection | null = null; - let probeMatched = false; + let matched = false; try { connection = await OwlChromiumConnection.connect(candidate.webSocketDebuggerUrl); const probe = await evaluateGuardedOwlExpression(connection, applicationUrl, targetNonce, 'true'); if (!probe.matched) continue; - probeMatched = true; - const evaluation = await evaluateGuardedOwlExpression(connection, applicationUrl, targetNonce, expression); - if (evaluation.matched) return evaluation.value; + matched = true; + return connection; } catch (error) { - const evaluationError = chromiumError(error, 'Could not inspect the candidate Owl Chromium page.'); - if (probeMatched) throw evaluationError; - lastProbeError = evaluationError; + lastProbeError = chromiumError(error, 'Could not inspect the candidate Owl Chromium page.'); } finally { - connection?.close(); + if (connection && !matched) connection.close(); } } @@ -283,6 +296,22 @@ async function evaluateOnOwlApplication( throw new Error('No running Owl Chromium page matches the exact inspected DevTools tab.'); } +async function evaluateOnOwlApplication( + port: number, + applicationUrl: string, + targetNonce: string, + expression: string, +): Promise { + const connection = await connectToOwlApplication(port, applicationUrl, targetNonce); + try { + const evaluation = await evaluateGuardedOwlExpression(connection, applicationUrl, targetNonce, expression); + if (evaluation.matched) return evaluation.value; + throw new Error('The inspected Owl page changed while the debugger request was running.'); + } finally { + connection.close(); + } +} + export async function readOwlDebuggerSnapshot( port: number, applicationUrl: string,