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,