Skip to content

Commit 60d8838

Browse files
committed
feat: enhance Qwik development tools with build analysis and SSR performance tracking
- Introduced new `Preloads` and `Build Analysis` panels in DevTools for improved insights. - Added runtime instrumentation for SSR/CSR performance and preload tracking. - Expanded plugin capabilities to generate build-analysis reports and enforce security around RPC calls. - Updated SSR performance middleware to normalize accept headers for better request handling.
1 parent be37d5d commit 60d8838

9 files changed

Lines changed: 256 additions & 8 deletions

File tree

.changeset/huge-clubs-rest.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
'@qwik.dev/devtools': patch
33
---
44

5-
chore: update .gitignore, pnpm-lock.yaml, and add Qwik development documentation
5+
feat: add preload/build analysis tooling and richer devtools instrumentation
66

7-
- Updated .gitignore to include new patterns for cursor skills and Qwik devtools.
8-
- Modified pnpm-lock.yaml to add new dependencies including rollup-plugin-visualizer and define-lazy-prop.
9-
- Introduced AGENTS.md and SKILL.md for Qwik core development guidelines, detailing architecture, best practices, and mandatory workflows.
7+
- Added new `Preloads` and `Build Analysis` panels, plus an improved `Inspect` view that resolves correctly from the app base URL on deep routes.
8+
- Added runtime instrumentation for SSR/CSR performance and preload tracking, including SSR preload snapshots, QRL-to-resource correlation, and richer diagnostics surfaced in DevTools.
9+
- Expanded the plugin and RPC layer to generate and serve build-analysis reports, expose the new preload/performance data to the UI, and add server-side guards around build-analysis execution.

packages/kit/src/server.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,39 @@ import { DEVTOOLS_VITE_MESSAGING_EVENT } from './constants';
33
import { setViteServerRpc, getViteServerContext } from './context';
44
import { createSerializedRpc } from './rpc-core';
55

6+
export interface ServerRpcRequestContext {
7+
client?: unknown;
8+
}
9+
10+
let currentServerRpcRequestContext: ServerRpcRequestContext | undefined;
11+
12+
export function getServerRpcRequestContext() {
13+
return currentServerRpcRequestContext;
14+
}
15+
16+
function runWithServerRpcRequestContext(
17+
context: ServerRpcRequestContext,
18+
fn: () => void,
19+
) {
20+
const previous = currentServerRpcRequestContext;
21+
currentServerRpcRequestContext = context;
22+
try {
23+
fn();
24+
} finally {
25+
currentServerRpcRequestContext = previous;
26+
}
27+
}
28+
629
export function createServerRpc(functions: ServerFunctions) {
730
const server = getViteServerContext();
831

932
const rpc = createSerializedRpc<ClientFunctions, ServerFunctions>(functions, {
1033
post: (data) => server.ws.send(DEVTOOLS_VITE_MESSAGING_EVENT, data),
1134
on: (handler) =>
12-
server.ws.on(DEVTOOLS_VITE_MESSAGING_EVENT, (data: any) => {
13-
handler(data);
35+
server.ws.on(DEVTOOLS_VITE_MESSAGING_EVENT, (data: any, client: unknown) => {
36+
runWithServerRpcRequestContext({ client }, () => {
37+
handler(data);
38+
});
1439
}),
1540
});
1641

packages/plugin/src/build-analysis/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import fs from 'node:fs';
22
import fsp from 'node:fs/promises';
33
import path from 'node:path';
44
import { spawn } from 'node:child_process';
5+
import { getServerRpcRequestContext } from '@devtools/kit';
56
import type {
67
BuildAnalysisRunResult,
78
BuildAnalysisStatus,
@@ -11,6 +12,10 @@ import { visualizer } from 'rollup-plugin-visualizer';
1112
import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite';
1213
import type { ServerContext } from '../types';
1314
import { detectPackageManager } from '../npm';
15+
import {
16+
getBuildAnalysisRpcGuardError,
17+
isBuildAnalysisRpcAllowed,
18+
} from './security';
1419

1520
const BUILD_ANALYSIS_VIEW_PATH = '/__qwik_devtools/build-analysis/report';
1621
const BUILD_ANALYSIS_DIR = path.join('.qwik-devtools', 'build-analysis');
@@ -261,6 +266,14 @@ export function getBuildAnalysisFunctions(
261266
};
262267
},
263268
async buildBuildAnalysisReport(): Promise<BuildAnalysisRunResult> {
269+
const rpcClient = getServerRpcRequestContext()?.client;
270+
if (!isBuildAnalysisRpcAllowed(rpcClient)) {
271+
return {
272+
success: false,
273+
error: getBuildAnalysisRpcGuardError(),
274+
};
275+
}
276+
264277
return runBuildScript(ctx.config.root);
265278
},
266279
};
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { describe, expect, test } from 'vitest';
2+
import {
3+
getRpcClientRemoteAddress,
4+
isBuildAnalysisRpcAllowed,
5+
isLoopbackAddress,
6+
isRemoteBuildAnalysisEnabled,
7+
} from './security';
8+
9+
describe('build analysis RPC security', () => {
10+
test('allows loopback websocket clients by default', () => {
11+
expect(isBuildAnalysisRpcAllowed({ socket: { remoteAddress: '127.0.0.1' } }, {})).toBe(true);
12+
expect(isBuildAnalysisRpcAllowed({ socket: { remoteAddress: '::1' } }, {})).toBe(true);
13+
expect(isBuildAnalysisRpcAllowed({ socket: { remoteAddress: '::ffff:127.0.0.1' } }, {})).toBe(
14+
true,
15+
);
16+
});
17+
18+
test('rejects non-loopback websocket clients by default', () => {
19+
expect(isBuildAnalysisRpcAllowed({ socket: { remoteAddress: '192.168.1.10' } }, {})).toBe(
20+
false,
21+
);
22+
expect(isBuildAnalysisRpcAllowed({ socket: { remoteAddress: '10.0.0.22' } }, {})).toBe(false);
23+
expect(isBuildAnalysisRpcAllowed({}, {})).toBe(false);
24+
});
25+
26+
test('supports explicit env opt-in for remote execution', () => {
27+
expect(
28+
isBuildAnalysisRpcAllowed(
29+
{ socket: { remoteAddress: '192.168.1.10' } },
30+
{ QWIK_DEVTOOLS_ALLOW_REMOTE_BUILD_ANALYSIS: 'true' },
31+
),
32+
).toBe(true);
33+
expect(
34+
isRemoteBuildAnalysisEnabled({ QWIK_DEVTOOLS_ALLOW_REMOTE_BUILD_ANALYSIS: '1' }),
35+
).toBe(true);
36+
});
37+
38+
test('extracts remote address from vite websocket client shapes', () => {
39+
expect(getRpcClientRemoteAddress({ socket: { remoteAddress: '127.0.0.1' } })).toBe(
40+
'127.0.0.1',
41+
);
42+
expect(getRpcClientRemoteAddress({ _socket: { remoteAddress: '127.0.0.1' } })).toBe(
43+
'127.0.0.1',
44+
);
45+
expect(getRpcClientRemoteAddress(null)).toBeUndefined();
46+
});
47+
48+
test('recognizes supported loopback formats', () => {
49+
expect(isLoopbackAddress('127.0.0.1')).toBe(true);
50+
expect(isLoopbackAddress('::1')).toBe(true);
51+
expect(isLoopbackAddress('::ffff:127.0.0.1')).toBe(true);
52+
expect(isLoopbackAddress('192.168.1.10')).toBe(false);
53+
});
54+
});
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
const REMOTE_BUILD_ANALYSIS_ENV = 'QWIK_DEVTOOLS_ALLOW_REMOTE_BUILD_ANALYSIS';
2+
const TRUTHY_ENV_VALUES = new Set(['1', 'true', 'yes', 'on']);
3+
4+
type RpcClientSocket = {
5+
remoteAddress?: string | null;
6+
};
7+
8+
type RpcClientLike = {
9+
socket?: RpcClientSocket | null;
10+
_socket?: RpcClientSocket | null;
11+
};
12+
13+
export function isRemoteBuildAnalysisEnabled(
14+
env: NodeJS.ProcessEnv = process.env,
15+
): boolean {
16+
const rawValue = env[REMOTE_BUILD_ANALYSIS_ENV];
17+
if (!rawValue) return false;
18+
return TRUTHY_ENV_VALUES.has(rawValue.trim().toLowerCase());
19+
}
20+
21+
export function getRpcClientRemoteAddress(client: unknown): string | undefined {
22+
if (!client || typeof client !== 'object') {
23+
return undefined;
24+
}
25+
26+
const candidate = client as RpcClientLike;
27+
return candidate.socket?.remoteAddress ?? candidate._socket?.remoteAddress ?? undefined;
28+
}
29+
30+
export function isLoopbackAddress(address: string | undefined): boolean {
31+
if (!address) return false;
32+
if (address === '127.0.0.1' || address === '::1') return true;
33+
if (address.startsWith('::ffff:')) {
34+
return isLoopbackAddress(address.slice('::ffff:'.length));
35+
}
36+
return false;
37+
}
38+
39+
export function isBuildAnalysisRpcAllowed(
40+
client: unknown,
41+
env: NodeJS.ProcessEnv = process.env,
42+
): boolean {
43+
if (isRemoteBuildAnalysisEnabled(env)) {
44+
return true;
45+
}
46+
47+
return isLoopbackAddress(getRpcClientRemoteAddress(client));
48+
}
49+
50+
export function getBuildAnalysisRpcGuardError(): string {
51+
return `Refusing to run the project build from a non-local DevTools RPC client. Reconnect from localhost or set ${REMOTE_BUILD_ANALYSIS_ENV}=1 to opt in to remote build-analysis execution.`;
52+
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { describe, expect, test } from 'vitest';
2+
import { wrapQwikLazyComponentExports } from './sourceTransforms';
3+
4+
describe('sourceTransforms', () => {
5+
test('escapes Windows module ids in wrapped lazy component exports', () => {
6+
const id = 'C:\\Users\\alice\\src\\app\\entry_component_abc123.tsx';
7+
const source = `
8+
export const Foo_component_abc123 = () => null;
9+
`;
10+
11+
const result = wrapQwikLazyComponentExports({
12+
code: source,
13+
id,
14+
exports: ['Foo_component_abc123'],
15+
});
16+
17+
expect(result.changed).toBe(true);
18+
expect(result.code).toContain(JSON.stringify(id));
19+
expect(result.code).toContain(
20+
'__qwik_wrap__(__original_Foo_component_abc123__, "Foo_component_abc123"',
21+
);
22+
expect(result.code).not.toContain(`'${id}'`);
23+
});
24+
});

packages/plugin/src/plugin/statistics/sourceTransforms.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,9 @@ function appendWrappedExport(
116116
exportName: string,
117117
id: string,
118118
): string {
119+
const serializedExportName = JSON.stringify(exportName);
120+
const serializedId = JSON.stringify(id);
119121
return `${code}
120-
export const ${exportName} = __qwik_wrap__(__original_${exportName}__, '${exportName}', '${id}');
122+
export const ${exportName} = __qwik_wrap__(__original_${exportName}__, ${serializedExportName}, ${serializedId});
121123
`;
122124
}

packages/plugin/src/plugin/statistics/ssrPerfMiddleware.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, test } from 'vitest';
22
import {
3+
attachSsrPerfInjectorMiddleware,
34
collectSsrPreloadEntries,
45
extractSsrPreloadEntriesFromHtml,
56
injectSsrDevtoolsIntoHtml,
@@ -74,4 +75,77 @@ describe('ssr preload middleware helpers', () => {
7475
expect(nextHtml).toContain('window.__QWIK_SSR_PRELOADS__');
7576
expect(nextHtml).toContain('/build/q-a.js');
7677
});
78+
79+
test('normalizes array accept headers before checking for html requests', () => {
80+
let middleware:
81+
| ((
82+
req: { headers: Record<string, string | string[] | undefined>; url?: string },
83+
res: {
84+
write: (...args: any[]) => any;
85+
end: (...args: any[]) => any;
86+
setHeader: (name: string, value: any) => void;
87+
},
88+
next: (err?: unknown) => void,
89+
) => void)
90+
| undefined;
91+
92+
attachSsrPerfInjectorMiddleware({
93+
middlewares: {
94+
use(fn: typeof middleware) {
95+
middleware = fn;
96+
},
97+
},
98+
});
99+
100+
expect(middleware).toBeTypeOf('function');
101+
102+
const html = '<html><head></head><body></body></html>';
103+
let written = '';
104+
let ended = false;
105+
const headers = new Map<string, number>();
106+
const res = {
107+
write(chunk: unknown) {
108+
written += String(chunk);
109+
return true;
110+
},
111+
end(chunk?: unknown) {
112+
if (chunk) {
113+
written += String(chunk);
114+
}
115+
ended = true;
116+
return this;
117+
},
118+
setHeader(name: string, value: number) {
119+
headers.set(name, value);
120+
},
121+
};
122+
const processWithPerf = process as typeof process & {
123+
__QWIK_SSR_PERF__?: unknown[];
124+
};
125+
processWithPerf.__QWIK_SSR_PERF__ = [{ component: 'App', phase: 'ssr', duration: 1 }];
126+
127+
try {
128+
middleware!(
129+
{
130+
headers: {
131+
accept: ['application/xhtml+xml', 'text/html'],
132+
},
133+
url: '/demo',
134+
},
135+
res,
136+
() => {},
137+
);
138+
139+
expect(ended).toBe(false);
140+
res.end(html);
141+
} finally {
142+
delete processWithPerf.__QWIK_SSR_PERF__;
143+
}
144+
145+
expect(headers.get('Content-Length')).toBeGreaterThan(0);
146+
expect(written).toContain('qwik:ssr-perf');
147+
expect(ended).toBe(true);
148+
expect(written).toContain('<html><head>');
149+
expect(written).toContain('<script>');
150+
});
77151
});

packages/plugin/src/plugin/statistics/ssrPerfMiddleware.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,18 @@ type MinimalMiddlewareRes = {
1212
setHeader: (name: string, value: any) => void;
1313
};
1414

15+
function normalizeAcceptHeader(raw: string | string[] | undefined): string {
16+
return Array.isArray(raw) ? raw.join(',') : raw || '';
17+
}
18+
1519
export function attachSsrPerfInjectorMiddleware(server: any) {
1620
server.middlewares.use(
1721
(
1822
req: MinimalMiddlewareReq,
1923
res: MinimalMiddlewareRes,
2024
next: MiddlewareNext,
2125
) => {
22-
const accept = req.headers.accept || '';
26+
const accept = normalizeAcceptHeader(req.headers.accept);
2327
if (!accept.includes('text/html')) return next();
2428

2529
const store = getStoreForSSR() as Record<string, unknown>;

0 commit comments

Comments
 (0)