Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions npm_modules/cli/debugger/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,9 +129,10 @@ node dist/index.js debugger \

Start Owl/Chromium with the printed `--remote-debugging-port` and
`--load-extension` values, then open the exact opted-in preview URL printed by
the command. Target matching removes only the injected `valdiDebugger` and
`valdiDevTools` parameters, then requires the same origin, pathname, and
remaining query parameters.
the command. Target matching ignores the reserved development parameters
`valdiDebugger`, `valdiDevTools`, and the optional, explicitly requested
`valdiTrace` parameter, then requires the same origin, pathname, and remaining
query parameters. The debugger command does not enable tracing by default.

The synthetic native-tree preview never auto-loads projected HTTP(S) image,
video, CSS background, or WebView resources. Only `data:` and `blob:` media are
Expand Down
2 changes: 1 addition & 1 deletion npm_modules/cli/src/debugger/server.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -727,7 +727,7 @@ describe('debugger server', () => {

const matching = await request(
new URL(
`/api/devtools/target?inspectedUrl=http%3A%2F%2F127.0.0.1%3A54321%2Findex.html%3FvaldiDebugger%3D1%26mode%3Ddev%26valdiDevTools%3D1%26tenant%3Dalpha&targetNonce=${WEB_PREVIEW_NONCE}`,
`/api/devtools/target?inspectedUrl=http%3A%2F%2F127.0.0.1%3A54321%2Findex.html%3FvaldiDebugger%3D1%26mode%3Ddev%26valdiDevTools%3D1%26tenant%3Dalpha%26valdiTrace%3Dchrome&targetNonce=${WEB_PREVIEW_NONCE}`,
debuggerServer.url,
).toString(),
GET_REQUEST_OPTIONS,
Expand Down
4 changes: 2 additions & 2 deletions npm_modules/cli/src/utils/owlCdpClient.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -447,14 +447,14 @@ describe('owlCdpClient', () => {
).toBeRejectedWithError(/No running Owl Chromium page matches/);
});

it('removes only injected Valdi parameters while matching the remaining query exactly', async () => {
it('removes only injected Valdi parameters, including tracing, while matching the remaining query exactly', async () => {
discovery.setDiscoveryBody(
JSON.stringify([
{
id: 'owl-page',
title: 'Valdi Owl',
type: 'page',
url: 'http://127.0.0.1:54321/index.html?valdiDevTools=1&mode=dev&tenant=alpha&valdiDebugger=1',
url: 'http://127.0.0.1:54321/index.html?valdiDevTools=1&mode=dev&tenant=alpha&valdiDebugger=1&valdiTrace=chrome',
webSocketDebuggerUrl: `ws://127.0.0.1:${discovery.port}/devtools/page/owl-page`,
},
]),
Expand Down
2 changes: 1 addition & 1 deletion npm_modules/cli/src/utils/owlCdpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const CHROMIUM_DISCOVERY_TIMEOUT_MS = 3000;
const CHROMIUM_COMMAND_TIMEOUT_MS = 8000;
const MAX_CHROMIUM_DISCOVERY_BYTES = 1024 * 1024;
const MAX_CHROMIUM_DISCOVERY_TARGETS = 256;
const VALDI_INJECTED_QUERY_PARAMETERS = ['valdiDebugger', 'valdiDevTools'];
const VALDI_INJECTED_QUERY_PARAMETERS = ['valdiDebugger', 'valdiDevTools', 'valdiTrace'];
const FATAL_UTF8_DECODER = new TextDecoder('utf8', { fatal: true });
const GUARDED_TARGET_MATCH_PROPERTY = '__valdiDevToolsTargetMatched';
export const OWL_DEVTOOLS_TARGET_NONCE_PROPERTY = '__VALDI_DEVTOOLS_TARGET_NONCE__';
Expand Down
4 changes: 2 additions & 2 deletions npm_modules/cli/src/utils/owlDevToolsExtension.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ describe('owlDevToolsExtension', () => {
let clearedHighlights = 0;
const secondPage: Record<string, unknown> = {
__VALDI_WEB_DEBUGGER__: { clearHighlight: () => (clearedHighlights += 1) },
location: { href: 'http://127.0.0.1:54321/after-navigation.html?valdiDebugger=1' },
location: { href: 'http://127.0.0.1:54321/after-navigation.html?valdiDebugger=1&valdiTrace=chrome' },
};
let inspectedPage = firstPage;
let navigationListener: (() => void) | null = null;
Expand Down Expand Up @@ -152,7 +152,7 @@ describe('owlDevToolsExtension', () => {
navigationListener!();
expect(secondPage[OWL_DEVTOOLS_TARGET_NONCE_PROPERTY]).toBe(nonce);
expect(new URL(frame.src).searchParams.get('inspectedUrl')).toBe(
'http://127.0.0.1:54321/after-navigation.html?valdiDebugger=1',
'http://127.0.0.1:54321/after-navigation.html?valdiDebugger=1&valdiTrace=chrome',
);

unloadListener!();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { Renderer } from 'valdi_core/src/Renderer';
import { UpdateAttributeDelegate, ValdiWebRendererDelegate } from './ValdiWebRendererDelegate';
import { WebDebuggerBridge } from './debug/WebDebuggerBridge';
import { isValdiWebTracingEnabled } from './tracing/ValdiWebTracing';
import { configureValdiWebTracingFromLocation } from './tracing/WebTracingConfiguration';

declare const require: (id: string) => any;

Expand All @@ -12,8 +14,10 @@ export class ValdiWebRenderer extends Renderer implements UpdateAttributeDelegat
private readonly debuggerBridge: WebDebuggerBridge;

constructor(htmlRoot: HTMLElement | ShadowRoot) {
configureValdiWebTracingFromLocation(typeof window === 'undefined' ? undefined : window.location?.search);
const delegate = new ValdiWebRendererDelegate(htmlRoot);
super('valdi-web-renderer', ['view', 'label', 'layout', 'scroll', 'image', 'textfield', 'textview', 'spinner', 'custom-view', 'video', 'shape'], delegate);
this.setTracingEnabled(isValdiWebTracingEnabled());
delegate.setAttributeDelegate(this);
this.delegate = delegate;
this.debuggerBridge = new WebDebuggerBridge(htmlRoot, delegate, this);
Expand Down
12 changes: 9 additions & 3 deletions src/valdi_modules/src/valdi/web_renderer/src/ValdiWebRuntime.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { beginValdiWebTrace, endValdiWebTrace, makeValdiWebTraceProxy } from './tracing/ValdiWebTracing';

// Declare webpack require.context
declare const require: {
(id: string): any;
Expand Down Expand Up @@ -309,12 +311,16 @@ class Runtime {
}

trace(tag: string, callback: Function) {
return callback();
const handle = beginValdiWebTrace(tag);
try {
return callback();
} finally {
endValdiWebTrace(handle);
}
}

makeTraceProxy(tag: string, callback: Function) {
// Return callback directly to avoid adding stack frames
return callback;
return makeValdiWebTraceProxy(tag, callback);
}

startTraceRecording() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { ValdiWebTracing, valdiWebTraceArguments, valdiWebTraceName } from './ValdiWebTracing';

type DevToolsColor =
| 'primary'
| 'primary-light'
| 'primary-dark'
| 'secondary'
| 'secondary-light'
| 'secondary-dark'
| 'tertiary'
| 'tertiary-light'
| 'tertiary-dark'
| 'error';

declare global {
interface Console {
timeStamp(
label: string,
start: string | number,
end: string | number | undefined,
trackName: string,
trackGroup: string,
color: DevToolsColor,
data?: Record<string, unknown>,
): void;
}
}

interface ActiveTrace {
readonly tag: string;
readonly startTime: number;
}

export interface WebTracingClock {
now(): number;
}

const TRACK_NAME = 'Valdi JS';
const TRACK_GROUP = 'Valdi';
const TRACK_COLOR: DevToolsColor = 'primary';
const BROWSER_WEB_TRACING_CLOCK: WebTracingClock = {
now: () => globalThis.performance.now(),
};

export class ChromeDevToolsTracing implements ValdiWebTracing {
private readonly activeTraces: ActiveTrace[] = [];

constructor(private readonly clock: WebTracingClock) {}

beginTrace(tag: string): void {
this.activeTraces.push({ tag, startTime: this.clock.now() });
}

endTrace(): void {
const activeTrace = this.activeTraces.pop();
if (!activeTrace) {
return;
}

console.timeStamp(
valdiWebTraceName(activeTrace.tag),
activeTrace.startTime,
this.clock.now(),
TRACK_NAME,
TRACK_GROUP,
TRACK_COLOR,
);
}

instantTrace(tag: string, args: readonly unknown[] | undefined): void {
const timestamp = this.clock.now();
console.timeStamp(
valdiWebTraceName(tag),
timestamp,
timestamp,
TRACK_NAME,
TRACK_GROUP,
TRACK_COLOR,
valdiWebTraceArguments(args),
);
}
}

export function createBrowserChromeDevToolsTracing(): ChromeDevToolsTracing {
return new ChromeDevToolsTracing(BROWSER_WEB_TRACING_CLOCK);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
export interface ValdiWebTracing {
beginTrace(tag: string): void;
endTrace(): void;
instantTrace(tag: string, args: readonly unknown[] | undefined): void;
}

const TRACE_NAME_PREFIX = 'Valdi.';

let currentTracing: ValdiWebTracing | undefined;

export function setValdiWebTracing(tracing: ValdiWebTracing | undefined): void {
currentTracing = tracing;
}

export function isValdiWebTracingEnabled(): boolean {
return currentTracing !== undefined;
}

export function beginValdiWebTrace(tag: string): ValdiWebTracing | undefined {
const tracing = currentTracing;
if (!tracing) {
return undefined;
}

try {
tracing.beginTrace(tag);
return tracing;
} catch (error) {
logTracingError('begin', tag, error);
return undefined;
}
}

export function endValdiWebTrace(tracing: ValdiWebTracing | undefined): void {
if (!tracing) {
return;
}

try {
tracing.endTrace();
} catch (error) {
console.error('[ValdiWebTracing] Failed to end trace', error);
}
}

export function instantValdiWebTrace(tag: string, args: readonly unknown[] | undefined): void {
const tracing = currentTracing;
if (!tracing) {
return;
}

try {
tracing.instantTrace(tag, args);
} catch (error) {
logTracingError('emit instant', tag, error);
}
}

export function makeValdiWebTraceProxy(tag: string, callback: Function): (...parameters: any[]) => any {
return function (this: unknown, ...parameters: any[]) {
const handle = beginValdiWebTrace(tag);
try {
return callback.apply(this, parameters);
} finally {
endValdiWebTrace(handle);
}
};
}

export function valdiWebTraceName(tag: string): string {
return `${TRACE_NAME_PREFIX}${tag}`;
}

export function valdiWebTraceArguments(args: readonly unknown[] | undefined): Record<string, unknown> | undefined {
if (!args || args.length === 0) {
return undefined;
}

const traceArguments: Record<string, unknown> = {};
for (let index = 0; index < args.length; index += 2) {
Object.defineProperty(traceArguments, String(args[index]), {
configurable: true,
enumerable: true,
value: args[index + 1],
writable: true,
});
}
return traceArguments;
}

function logTracingError(operation: string, tag: string, error: unknown): void {
console.error(`[ValdiWebTracing] Failed to ${operation} trace '${tag}'`, error);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { hasSingleWebLocationQueryParameter } from '../utils/LocationQuery';
import { createBrowserChromeDevToolsTracing } from './ChromeDevToolsTracing';
import { isValdiWebTracingEnabled, setValdiWebTracing } from './ValdiWebTracing';

const VALDI_TRACE_QUERY_KEY = 'valdiTrace';
const CHROMIUM_TRACE_QUERY_VALUE = 'chrome';
const DEVTOOLS_QUERY_KEY = 'valdiDevTools';
const DEVTOOLS_QUERY_VALUE = '1';

/** Enable Chromium tracing only when the development host explicitly requests it. */
export function configureValdiWebTracingFromLocation(locationSearch: string | undefined): boolean {
if (locationSearch === undefined || isValdiWebTracingEnabled()) {
return false;
}

if (
!hasSingleWebLocationQueryParameter(locationSearch, DEVTOOLS_QUERY_KEY, DEVTOOLS_QUERY_VALUE) ||
!hasSingleWebLocationQueryParameter(locationSearch, VALDI_TRACE_QUERY_KEY, CHROMIUM_TRACE_QUERY_VALUE)
) {
return false;
}

setValdiWebTracing(createBrowserChromeDevToolsTracing());
return true;
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,44 @@ export function hasWebLocationQueryParameter(

return false;
}

function decodeWebLocationQueryComponent(component: string): string | undefined {
try {
return decodeURIComponent(component.replace(/\+/g, ' '));
} catch {
return undefined;
}
}

/** Match one literal development flag, rejecting encoded aliases, malformed escapes, and duplicates. */
export function hasSingleWebLocationQueryParameter(
locationSearch: string | undefined,
parameterName: string,
expectedValue: string,
): boolean {
if (!locationSearch) {
return false;
}

const query = locationSearch.charAt(0) === '?' ? locationSearch.slice(1) : locationSearch;
let matchCount = 0;
for (const parameter of query.split('&')) {
const separatorIndex = parameter.indexOf('=');
const rawName = separatorIndex < 0 ? parameter : parameter.slice(0, separatorIndex);
const rawValue = separatorIndex < 0 ? undefined : parameter.slice(separatorIndex + 1);
const decodedName = decodeWebLocationQueryComponent(rawName);
const decodedValue = rawValue === undefined ? undefined : decodeWebLocationQueryComponent(rawValue);
if (decodedName === undefined || (rawValue !== undefined && decodedValue === undefined)) {
return false;
}
if (decodedName !== parameterName) {
continue;
}
matchCount++;
if (rawName !== parameterName || rawValue !== expectedValue) {
return false;
}
}

return matchCount === 1;
}
Loading
Loading