From c5fb856d12678d904cdda2ebba7f9a68dbfb9573 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Tue, 22 Sep 2026 13:48:20 +0000 Subject: [PATCH 1/2] fix(dev): mint request ids from a random source --- .../nuxt-cli/runtime/dev-request-context.mjs | 20 +++-- packages/nuxt-cli/src/dev/error-channel.ts | 8 +- packages/nuxt-cli/src/dev/log-channel.ts | 2 +- packages/nuxt-cli/src/dev/serving-state.ts | 20 +++-- packages/nuxt-cli/src/dev/tui/events.ts | 2 +- packages/nuxt-cli/src/dev/tui/overlay.ts | 2 +- .../nuxt-cli/src/dev/tui/request-overlay.ts | 16 +--- packages/nuxt-cli/src/dev/tui/requests.ts | 2 +- packages/nuxt-cli/src/dev/utils.ts | 21 +++-- .../test/unit/dev-request-context.spec.ts | 14 +-- packages/nuxt-cli/test/unit/dev-tui.spec.ts | 88 ++++++++++--------- .../nuxt-cli/test/unit/dev/lifecycle.spec.ts | 33 ++++++- .../nuxt-cli/test/unit/error-channel.spec.ts | 35 +++++++- 13 files changed, 167 insertions(+), 96 deletions(-) diff --git a/packages/nuxt-cli/runtime/dev-request-context.mjs b/packages/nuxt-cli/runtime/dev-request-context.mjs index 81457b69e..c60180609 100644 --- a/packages/nuxt-cli/runtime/dev-request-context.mjs +++ b/packages/nuxt-cli/runtime/dev-request-context.mjs @@ -18,6 +18,7 @@ import { consola } from 'consola' */ const CHANNEL = 'nuxt:dev:log' const HEADER = 'x-nuxt-dev-request-id' +const LABEL_HEADER = 'x-nuxt-dev-request-label' const storage = new AsyncLocalStorage() @@ -29,13 +30,16 @@ export default function (nitroApp) { catch {} } -function parseRequest(header) { - if (!header) { +function parseRequest(id, label) { + if (!id) { return undefined } - const separator = header.indexOf(' ') - const id = Number(header.slice(0, separator)) - return Number.isFinite(id) ? { id, label: header.slice(separator + 1) } : undefined + let decoded = label || '' + try { + decoded = decodeURIComponent(decoded) + } + catch {} + return { id, label: decoded } } function trackRequests(nitroApp) { @@ -46,9 +50,10 @@ function trackRequests(nitroApp) { let request try { const headers = event?.node?.req?.headers - request = parseRequest(headers?.[HEADER]) + request = parseRequest(headers?.[HEADER], headers?.[LABEL_HEADER]) if (request) { delete headers[HEADER] + delete headers[LABEL_HEADER] } } catch {} @@ -65,9 +70,10 @@ function trackRequests(nitroApp) { h3.fetch = (req, ...args) => { let request try { - request = parseRequest(req?.headers?.get?.(HEADER)) + request = parseRequest(req?.headers?.get?.(HEADER), req?.headers?.get?.(LABEL_HEADER)) if (request) { req.headers.delete(HEADER) + req.headers.delete(LABEL_HEADER) } } catch {} diff --git a/packages/nuxt-cli/src/dev/error-channel.ts b/packages/nuxt-cli/src/dev/error-channel.ts index 03a50aa4b..b9ccce9a3 100644 --- a/packages/nuxt-cli/src/dev/error-channel.ts +++ b/packages/nuxt-cli/src/dev/error-channel.ts @@ -24,7 +24,7 @@ export const ERROR_BROADCAST_CHANNEL = 'nuxt:dev:error' /** Reports the app forwards; anything else on the wire is ignored. */ export type DevErrorMessage - = | { type: 'nuxt:dev:error:report', report: ErrorReport, requestId?: number, request?: string } + = | { type: 'nuxt:dev:error:report', report: ErrorReport, requestId?: string, request?: string } | { type: 'nuxt:dev:error:clear', id?: string } | { type: 'nuxt:dev:error:warning', report: ErrorReport } | { type: 'nuxt:dev:error:log', entry: LogEntry } @@ -267,7 +267,7 @@ export interface DevReportSummary { /** That position as `file:line:column`, relative to the project. */ location?: string /** The request the report was raised for, shared with the logs attributed to it. */ - requestId?: number + requestId?: string /** That request as `METHOD /path`, when the app raised this while serving one. */ request?: string /** The report rendered for a terminal. */ @@ -289,7 +289,7 @@ function findCompileReport(report: ErrorReport): ErrorReport | undefined { /** What the fork knows about a report beyond the report itself. */ export interface ReportContext { - requestId?: number + requestId?: string request?: string } @@ -352,7 +352,7 @@ export function openErrorBridge(handlers: ErrorBridgeHandlers = {}, options: Err void useErrorChannel(options).then((instance) => { switch (message.type) { case 'nuxt:dev:error:report': { - instance.setError(message.report, message.requestId === undefined ? undefined : `${message.requestId}`, message.request) + instance.setError(message.report, message.requestId, message.request) handlers.onReport?.(message.report, { requestId: message.requestId, request: message.request }) break } diff --git a/packages/nuxt-cli/src/dev/log-channel.ts b/packages/nuxt-cli/src/dev/log-channel.ts index 1185241ad..5f19553ac 100644 --- a/packages/nuxt-cli/src/dev/log-channel.ts +++ b/packages/nuxt-cli/src/dev/log-channel.ts @@ -9,7 +9,7 @@ export interface ServerLogEvent { origin: 'build' | 'runtime' /** The request this was emitted for. */ request?: string - requestId?: number + requestId?: string /** Caught on its way to the terminal rather than reported by the app. */ raw?: boolean } diff --git a/packages/nuxt-cli/src/dev/serving-state.ts b/packages/nuxt-cli/src/dev/serving-state.ts index 063463110..2dda145e5 100644 --- a/packages/nuxt-cli/src/dev/serving-state.ts +++ b/packages/nuxt-cli/src/dev/serving-state.ts @@ -1,7 +1,8 @@ import { AsyncLocalStorage } from 'node:async_hooks' +import { randomUUID } from 'node:crypto' export interface InflightRequest { - id: number + id: string label: string } @@ -10,11 +11,16 @@ const storage = new AsyncLocalStorage() /** Carries the request across the boundary the async context cannot cross. */ export const REQUEST_HEADER = 'x-nuxt-dev-request-id' -let nextId = 0 +/** Carries the request's `METHOD /path` alongside {@link REQUEST_HEADER}. */ +export const REQUEST_LABEL_HEADER = 'x-nuxt-dev-request-label' -/** Identify a request, so logs and reports can be attributed to it. */ +/** + * Identify a request, so logs and reports can be attributed to it. + * + * The id is random because it also scopes who may read the request's report. + */ export function createRequest(label: string): InflightRequest { - return { id: ++nextId, label } + return { id: randomUUID(), label } } /** @@ -35,9 +41,9 @@ export function runWithRequest(request: InflightRequest | string, run: (reque return storage.run(inflight, () => run(inflight)) } -/** The value of {@link REQUEST_HEADER} for `request`. */ -export function encodeRequest(request: InflightRequest): string { - return `${request.id} ${request.label}` +/** The value of {@link REQUEST_LABEL_HEADER} for `request`, encoded for a header. */ +export function encodeRequestLabel(request: InflightRequest): string { + return encodeURIComponent(request.label) } /** Whether this code is running to serve a request, rather than to build. */ diff --git a/packages/nuxt-cli/src/dev/tui/events.ts b/packages/nuxt-cli/src/dev/tui/events.ts index 8f4c85cee..1b3336eb3 100644 --- a/packages/nuxt-cli/src/dev/tui/events.ts +++ b/packages/nuxt-cli/src/dev/tui/events.ts @@ -35,7 +35,7 @@ export interface DevLogEvent { * Identifies the individual request, so two sequential requests to the same * path are not mistaken for one. */ - requestId?: number + requestId?: string /** How many times this has been reported, when deduplicated. */ repeats?: number } diff --git a/packages/nuxt-cli/src/dev/tui/overlay.ts b/packages/nuxt-cli/src/dev/tui/overlay.ts index 3daf6c9ea..d05c3dbdc 100644 --- a/packages/nuxt-cli/src/dev/tui/overlay.ts +++ b/packages/nuxt-cli/src/dev/tui/overlay.ts @@ -86,7 +86,7 @@ export class LogOverlay extends ScreenOverlay { const events = this.#matching() // Sized to the locale's own time format rather than the widest possible one. const timeWidth = Math.max(0, ...events.map(event => formatTime(event.time).length)) - let heading: number | undefined + let heading: string | undefined return events.map((event) => { const repeated = event.requestId !== undefined && event.requestId === heading heading = event.requestId diff --git a/packages/nuxt-cli/src/dev/tui/request-overlay.ts b/packages/nuxt-cli/src/dev/tui/request-overlay.ts index a77cc64c5..3cbf8a55b 100644 --- a/packages/nuxt-cli/src/dev/tui/request-overlay.ts +++ b/packages/nuxt-cli/src/dev/tui/request-overlay.ts @@ -26,12 +26,6 @@ const SCAN_LIMIT = 1000 const EVENT_SCAN_LIMIT = 10_000 -/** - * How much earlier than the request's own start a log may be and still belong - * to it, covering clock skew between the two feeds. - */ -const TRACE_EARLY_MS = 2000 - /** A live table of served requests: the server-side view a browser cannot show. */ export class RequestOverlay extends ScreenOverlay { #requests: RequestLog @@ -182,16 +176,12 @@ export class RequestOverlay extends ScreenOverlay { if (!this.#events || request.id === undefined) { return [] } - // Request ids restart with the server, so the id alone could pair a log - // from a previous run with a request from this one; time bounds it. - const start = request.time - request.duration - TRACE_EARLY_MS - return this.#events.recent(EVENT_SCAN_LIMIT, event => - event.requestId === request.id && event.time >= start) + return this.#events.recent(EVENT_SCAN_LIMIT, event => event.requestId === request.id) } /** Error-log counts per request id, for the markers in the table. */ - #errorCounts(): Map { - const counts = new Map() + #errorCounts(): Map { + const counts = new Map() for (const event of this.#events?.recent(EVENT_SCAN_LIMIT, event => event.requestId !== undefined && event.level <= 0) ?? []) { counts.set(event.requestId!, (counts.get(event.requestId!) ?? 0) + 1) } diff --git a/packages/nuxt-cli/src/dev/tui/requests.ts b/packages/nuxt-cli/src/dev/tui/requests.ts index a346fcac8..a5fc57f77 100644 --- a/packages/nuxt-cli/src/dev/tui/requests.ts +++ b/packages/nuxt-cli/src/dev/tui/requests.ts @@ -1,6 +1,6 @@ export interface DevRequest { /** Identity shared with attributed log events, when the server reported one. */ - id?: number + id?: string time: number method: string url: string diff --git a/packages/nuxt-cli/src/dev/utils.ts b/packages/nuxt-cli/src/dev/utils.ts index 946c13daf..9b3c1314b 100644 --- a/packages/nuxt-cli/src/dev/utils.ts +++ b/packages/nuxt-cli/src/dev/utils.ts @@ -47,7 +47,7 @@ import { resolveDefaultLoadingTemplate } from './loading-template' import { resolvePortlessURLs } from './portless' import { DEV_INTERNAL_PREFIX, DevProgress } from './progress' import { formatChangedKeys, formatRestartReason, formatSkippedReload, mergeRestartReasons, withConfigKeys } from './reason' -import { createRequest, encodeRequest, REQUEST_HEADER, runWithRequest } from './serving-state' +import { createRequest, encodeRequestLabel, REQUEST_HEADER, REQUEST_LABEL_HEADER, runWithRequest } from './serving-state' import { WarmupGate } from './warmup-gate' /** @@ -341,7 +341,7 @@ export interface DevRoute { /** A request served by the dev server, as shown in the dev UI. */ export interface DevRequestEvent { /** Identity shared with the logs attributed to this request. */ - id?: number + id?: string method: string url: string status: number @@ -497,9 +497,10 @@ export class NuxtDevServer extends EventEmitter { const method = req.method || 'GET' const url = req.url || '/' const request = createRequest(`${method} ${url}`) - const encoded = encodeRequest(request) - req.headers[REQUEST_HEADER] = encoded - req.rawHeaders.push(REQUEST_HEADER, encoded) + const label = encodeRequestLabel(request) + req.headers[REQUEST_HEADER] = request.id + req.headers[REQUEST_LABEL_HEADER] = label + req.rawHeaders.push(REQUEST_HEADER, request.id, REQUEST_LABEL_HEADER, label) if (!options.captureUIEvents) { return this.#serve(req, res) } @@ -1528,17 +1529,19 @@ export class NuxtDevServer extends EventEmitter { } /** - * Remove any wire-supplied copy of the request-attribution header, from both + * Remove any wire-supplied copy of the request-attribution headers, from both * the parsed headers and `rawHeaders` (which some frameworks reconstruct - * requests from), before the CLI sets its own value. + * requests from), before the CLI sets its own values. */ function stripRequestHeader(req: IncomingMessage): void { - if (req.headers[REQUEST_HEADER] === undefined) { + if (req.headers[REQUEST_HEADER] === undefined && req.headers[REQUEST_LABEL_HEADER] === undefined) { return } delete req.headers[REQUEST_HEADER] + delete req.headers[REQUEST_LABEL_HEADER] for (let i = req.rawHeaders.length - 2; i >= 0; i -= 2) { - if (req.rawHeaders[i]?.toLowerCase() === REQUEST_HEADER) { + const name = req.rawHeaders[i]?.toLowerCase() + if (name === REQUEST_HEADER || name === REQUEST_LABEL_HEADER) { req.rawHeaders.splice(i, 2) } } diff --git a/packages/nuxt-cli/test/unit/dev-request-context.spec.ts b/packages/nuxt-cli/test/unit/dev-request-context.spec.ts index de9e582ef..01eb709a6 100644 --- a/packages/nuxt-cli/test/unit/dev-request-context.spec.ts +++ b/packages/nuxt-cli/test/unit/dev-request-context.spec.ts @@ -14,6 +14,7 @@ const { default: plugin } = await import('../../runtime/dev-request-context.mjs' } const HEADER = 'x-nuxt-dev-request-id' +const LABEL_HEADER = 'x-nuxt-dev-request-label' function nitroApp(handler: (event: unknown) => unknown) { const app = { handler: Object.assign(handler, { __is_handler__: true }) } @@ -41,16 +42,17 @@ describe('dev request context plugin', () => { const { app, nitroApp: instance } = nitroApp(() => 'served') plugin(instance) expect(app.handler(eventFor())).toBe('served') - expect(app.handler(eventFor({ [HEADER]: '7 GET /api/hello' }))).toBe('served') + expect(app.handler(eventFor({ [HEADER]: 'req-7', [LABEL_HEADER]: 'GET%20%2Fapi%2Fhello' }))).toBe('served') expect(app.handler(eventFor({ [HEADER]: 'nonsense' }))).toBe('served') }) it('does not leave its own header on the request', () => { const { app, nitroApp: instance } = nitroApp(() => 'served') plugin(instance) - const event = eventFor({ [HEADER]: '7 GET /api/hello' }) + const event = eventFor({ [HEADER]: 'req-7', [LABEL_HEADER]: 'GET%20%2Fapi%2Fhello' }) app.handler(event) expect(event.node.req.headers[HEADER]).toBeUndefined() + expect(event.node.req.headers[LABEL_HEADER]).toBeUndefined() }) it('still serves when reporting throws', async () => { @@ -66,7 +68,7 @@ describe('dev request context plugin', () => { try { expect(reporters).toHaveLength(1) expect(() => reporters[0]!.log({ level: 3, type: 'info', args: [Object.create(null)] })).not.toThrow() - expect(app.handler(eventFor({ [HEADER]: '7 GET /api/hello' }))).toBe('served') + expect(app.handler(eventFor({ [HEADER]: 'req-7', [LABEL_HEADER]: 'GET%20%2Fapi%2Fhello' }))).toBe('served') } finally { channel.close() @@ -84,13 +86,13 @@ describe('dev request context plugin', () => { const received: unknown[] = [] const close = openDevLogChannel(log => received.push(log)) try { - app.handler(eventFor({ [HEADER]: '42 GET /api/hello' })) + app.handler(eventFor({ [HEADER]: 'req-42', [LABEL_HEADER]: 'GET%20%2Fapi%2Fhello' })) await vi.waitFor(() => expect(received).toHaveLength(1)) expect(received[0]).toMatchObject({ message: 'from the app', origin: 'runtime', request: 'GET /api/hello', - requestId: 42, + requestId: 'req-42', }) } finally { @@ -103,7 +105,7 @@ describe('dev request context plugin', () => { const { nitroApp: instance } = nitroApp(() => 'served') plugin(instance) - const received: Array<{ origin: string, requestId?: number }> = [] + const received: Array<{ origin: string, requestId?: string }> = [] const close = openDevLogChannel(log => received.push(log)) try { reporters[0]!.log({ level: 3, type: 'info', args: ['building'] }) diff --git a/packages/nuxt-cli/test/unit/dev-tui.spec.ts b/packages/nuxt-cli/test/unit/dev-tui.spec.ts index b8b433aae..cd2f08fbb 100644 --- a/packages/nuxt-cli/test/unit/dev-tui.spec.ts +++ b/packages/nuxt-cli/test/unit/dev-tui.spec.ts @@ -8,7 +8,7 @@ import { consola } from 'consola' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { currentRequest, isServingRequest, runWithRequest } from '../../src/dev/serving-state' +import { createRequest, currentRequest, isServingRequest, runWithRequest } from '../../src/dev/serving-state' import { deferShortcutContext } from '../../src/dev/shortcut-context' import { DevEventLog, noteRoute } from '../../src/dev/tui/events' import { HelpOverlay } from '../../src/dev/tui/help-overlay' @@ -766,9 +766,9 @@ describe('dev event log', () => { const merges: boolean[] = [] log.onEvent((_, merged) => merges.push(!!merged)) const now = Date.now() - log.push(event({ time: now, level: 0, type: 'error', message: 'Invalid end tag.', source: 'runtime', request: 'GET /', requestId: 7 })) + log.push(event({ time: now, level: 0, type: 'error', message: 'Invalid end tag.', source: 'runtime', request: 'GET /', requestId: 'r7' })) log.push(event({ time: now, level: 0, type: 'error', message: 'Internal server error: Invalid end tag.\n Plugin: vite:vue\n File: /pages/index.vue', source: 'build' })) - log.push(event({ time: now, level: 0, type: 'error', message: 'Invalid end tag.', source: 'runtime', request: 'GET /', requestId: 8 })) + log.push(event({ time: now, level: 0, type: 'error', message: 'Invalid end tag.', source: 'runtime', request: 'GET /', requestId: 'r8' })) const errors = log.recent(10, e => e.level <= 0) expect(errors).toHaveLength(1) @@ -776,7 +776,7 @@ describe('dev event log', () => { // The wording with the file and the plugin is the one worth keeping. expect(errors[0]!.message).toContain('vite:vue') // The first attribution wins; the entry stays tied to its request. - expect(errors[0]!.requestId).toBe(7) + expect(errors[0]!.requestId).toBe('r7') expect(merges).toEqual([false, true, true]) }) @@ -855,7 +855,7 @@ describe('dev event log', () => { const events = new DevEventLog() for (const step of order) { if (step === 'report') { - events.push({ time: Date.now(), level: 3, type: 'info', message: 'same line', source: 'runtime', request: 'GET /', requestId: 1 }, { route: 'report' }) + events.push({ time: Date.now(), level: 3, type: 'info', message: 'same line', source: 'runtime', request: 'GET /', requestId: 'r1' }, { route: 'report' }) } else { events.push({ time: Date.now(), level: 3, type: 'log', message: 'same line', source: 'runtime' }, { route: 'output' }) @@ -902,7 +902,7 @@ describe('dev event log', () => { const events = new DevEventLog() for (const route of order) { if (route === 'report') { - events.push({ time: Date.now(), level: 3, type: 'log', message: 'hello', source: 'runtime', request: 'GET /', requestId: 4 }, { route: 'report' }) + events.push({ time: Date.now(), level: 3, type: 'log', message: 'hello', source: 'runtime', request: 'GET /', requestId: 'r4' }, { route: 'report' }) } else if (route === 'reporter') { events.push({ time: Date.now(), level: 2, type: 'log', message: 'hello', source: 'runtime' }, { route: 'reporter' }) @@ -912,7 +912,7 @@ describe('dev event log', () => { } } expect(events.recent(10)).toHaveLength(1) - expect(events.recent(10)[0]).toMatchObject({ request: 'GET /', requestId: 4, rendered: 'hello\n' }) + expect(events.recent(10)[0]).toMatchObject({ request: 'GET /', requestId: 'r4', rendered: 'hello\n' }) }) it.each([ @@ -923,7 +923,7 @@ describe('dev event log', () => { const events = new DevEventLog() for (const step of order) { const [route, id] = step.split(' ') as ['report' | 'reporter', string] - events.push({ time: Date.now(), level: 3, type: 'log', message: 'same line', source: 'runtime', request: 'GET /', requestId: Number(id) }, { route }) + events.push({ time: Date.now(), level: 3, type: 'log', message: 'same line', source: 'runtime', request: 'GET /', requestId: `r${id}` }, { route }) } const entries = events.recent(10) expect(entries).toHaveLength(2) @@ -935,10 +935,10 @@ describe('dev event log', () => { it('keeps a log with the request it names when another printed the same line', () => { const events = new DevEventLog() - events.push({ time: Date.now(), level: 2, type: 'log', message: 'same line', source: 'runtime', request: 'GET /a', requestId: 1 }, { route: 'output' }) - events.push({ time: Date.now(), level: 2, type: 'log', message: 'same line', source: 'runtime', request: 'GET /b', requestId: 2 }, { route: 'output' }) - events.push({ time: Date.now(), level: 3, type: 'log', message: 'same line', source: 'runtime', request: 'GET /a', requestId: 1 }, { route: 'report' }) - events.push({ time: Date.now(), level: 3, type: 'log', message: 'same line', source: 'runtime', request: 'GET /b', requestId: 2 }, { route: 'report' }) + events.push({ time: Date.now(), level: 2, type: 'log', message: 'same line', source: 'runtime', request: 'GET /a', requestId: 'r1' }, { route: 'output' }) + events.push({ time: Date.now(), level: 2, type: 'log', message: 'same line', source: 'runtime', request: 'GET /b', requestId: 'r2' }, { route: 'output' }) + events.push({ time: Date.now(), level: 3, type: 'log', message: 'same line', source: 'runtime', request: 'GET /a', requestId: 'r1' }, { route: 'report' }) + events.push({ time: Date.now(), level: 3, type: 'log', message: 'same line', source: 'runtime', request: 'GET /b', requestId: 'r2' }, { route: 'report' }) expect(events.recent(10).map(entry => entry.request)).toEqual(['GET /a', 'GET /b']) }) @@ -954,7 +954,7 @@ describe('dev event log', () => { const message = 'Cannot read properties of undefined' for (const step of order) { const route = step.split(' ')[0] as DevLogRoute - events.push({ time: Date.now(), level: 0, type: 'error', message, source: 'runtime', requestId: route === 'output' ? undefined : 1 }, { route }) + events.push({ time: Date.now(), level: 0, type: 'error', message, source: 'runtime', requestId: route === 'output' ? undefined : 'r1' }, { route }) } expect(events.recent(10)).toHaveLength(1) @@ -963,7 +963,7 @@ describe('dev event log', () => { it('does not let output heard twice make room for another report', () => { const events = new DevEventLog() - const report = () => events.push({ time: Date.now(), level: 3, type: 'log', message: 'same line', source: 'runtime', requestId: 1 }, { route: 'report' }) + const report = () => events.push({ time: Date.now(), level: 3, type: 'log', message: 'same line', source: 'runtime', requestId: 'r1' }, { route: 'report' }) const entry = report() noteRoute(entry, 'output') noteRoute(entry, 'output') @@ -985,11 +985,11 @@ describe('dev event log', () => { it('pairs a report with printed output rather than duplicating it', () => { const events = new DevEventLog() events.push({ time: Date.now(), level: 3, type: 'log', message: 'hello', rendered: '\u001B[36mhello\u001B[39m', source: 'runtime' }, { route: 'output' }) - events.push({ time: Date.now(), level: 3, type: 'info', message: 'hello', source: 'runtime', request: 'GET /', requestId: 4 }, { route: 'report' }) + events.push({ time: Date.now(), level: 3, type: 'info', message: 'hello', source: 'runtime', request: 'GET /', requestId: 'r4' }, { route: 'report' }) const [only] = events.recent(10) expect(events.recent(10)).toHaveLength(1) - expect(only).toMatchObject({ request: 'GET /', requestId: 4, rendered: '\u001B[36mhello\u001B[39m' }) + expect(only).toMatchObject({ request: 'GET /', requestId: 'r4', rendered: '\u001B[36mhello\u001B[39m' }) }) it('filters recent events', () => { @@ -1050,10 +1050,18 @@ describe('request attribution', () => { expect(attributed).toBe('GET /nested') }) - it('gives every request its own identity', () => { - const first = runWithRequest('GET /', request => request.id) - const second = runWithRequest('GET /', request => request.id) - expect(second).not.toBe(first) + it('gives a request an identity that cannot be guessed from its route or its neighbours', () => { + const ids = Array.from({ length: 50 }, () => createRequest('GET /boom-page').id) + const value = (id: string) => BigInt(`0x${id.replaceAll('-', '')}`) + + expect(new Set(ids).size).toBe(ids.length) + for (const id of ids) { + expect(id.replaceAll('-', '')).toMatch(/^[0-9a-f]{32}$/) + expect(id).not.toContain('boom-page') + expect(id).not.toContain('GET') + } + const distances = ids.slice(1).map((id, index) => value(id) - value(ids[index]!)) + expect(new Set(distances.map(String)).size).toBe(distances.length) }) it('does not attribute work that has left the request context', async () => { @@ -1239,8 +1247,8 @@ describe('log overlay', () => { it('heads a request\'s logs once, with the request beside the time', () => { const events = new DevEventLog() - events.push(event({ message: 'first', request: 'GET /about', requestId: 1, source: 'runtime' })) - events.push(event({ message: 'second', request: 'GET /about', requestId: 1, source: 'runtime' })) + events.push(event({ message: 'first', request: 'GET /about', requestId: 'r1', source: 'runtime' })) + events.push(event({ message: 'second', request: 'GET /about', requestId: 'r1', source: 'runtime' })) const { overlay, lastFrame } = create(events) overlay.open() @@ -1254,8 +1262,8 @@ describe('log overlay', () => { it('heads each request separately when the same path is hit twice', () => { const events = new DevEventLog() - events.push(event({ message: 'one', request: 'GET /about', requestId: 1, source: 'runtime' })) - events.push(event({ message: 'two', request: 'GET /about', requestId: 2, source: 'runtime' })) + events.push(event({ message: 'one', request: 'GET /about', requestId: 'r1', source: 'runtime' })) + events.push(event({ message: 'two', request: 'GET /about', requestId: 'r2', source: 'runtime' })) const { overlay, lastFrame } = create(events) overlay.open() expect(strip(lastFrame()).split('\n').filter(line => line.includes('GET /about'))).toHaveLength(2) @@ -1264,7 +1272,7 @@ describe('log overlay', () => { it('puts the message at the same column whether or not it has a heading', () => { const events = new DevEventLog() events.push(event({ time: new Date('2024-01-01T10:20:30').getTime(), message: 'plain' })) - events.push(event({ time: new Date('2024-01-01T10:20:31').getTime(), message: 'grouped', request: 'GET /x', requestId: 1, source: 'runtime' })) + events.push(event({ time: new Date('2024-01-01T10:20:31').getTime(), message: 'grouped', request: 'GET /x', requestId: 'r1', source: 'runtime' })) const { overlay, lastFrame } = create(events) overlay.open() @@ -1278,9 +1286,9 @@ describe('log overlay', () => { it('starts a new heading when another request interleaves', () => { const events = new DevEventLog() - events.push(event({ message: 'a1', request: 'GET /a', requestId: 1, source: 'runtime' })) - events.push(event({ message: 'b1', request: 'GET /b', requestId: 2, source: 'runtime' })) - events.push(event({ message: 'a2', request: 'GET /a', requestId: 1, source: 'runtime' })) + events.push(event({ message: 'a1', request: 'GET /a', requestId: 'r1', source: 'runtime' })) + events.push(event({ message: 'b1', request: 'GET /b', requestId: 'r2', source: 'runtime' })) + events.push(event({ message: 'a2', request: 'GET /a', requestId: 'r1', source: 'runtime' })) const { overlay, lastFrame } = create(events) overlay.open() expect(strip(lastFrame()).split('\n').filter(line => line.includes('GET /a'))).toHaveLength(2) @@ -1444,7 +1452,7 @@ describe('log overlay', () => { it('copies the selected entry, request and all', async () => { const events = new DevEventLog() - events.push(event({ message: 'boom', request: 'GET /x', requestId: 1, source: 'runtime' })) + events.push(event({ message: 'boom', request: 'GET /x', requestId: 'r1', source: 'runtime' })) const { overlay, lastFrame } = create(events) overlay.open() overlay.handleKey({ name: 'up' }) @@ -1638,8 +1646,8 @@ describe('request overlay', () => { const events = new DevEventLog() const { log, overlay, lastFrame } = create({ events }) const now = Date.now() - events.push({ time: now, level: 0, type: 'error', message: 'Invalid end tag.', source: 'runtime', request: 'GET /', requestId: 7 }) - log.push([{ id: 7, time: now, method: 'GET', url: '/', status: 500, duration: 20 }]) + events.push({ time: now, level: 0, type: 'error', message: 'Invalid end tag.', source: 'runtime', request: 'GET /', requestId: 'r7' }) + log.push([{ id: 'r7', time: now, method: 'GET', url: '/', status: 500, duration: 20 }]) overlay.open() expect(lastFrame()).toContain('✗ 1') }) @@ -1648,10 +1656,10 @@ describe('request overlay', () => { const events = new DevEventLog() const { log, overlay, lastFrame } = create({ events }) const now = Date.now() - events.push({ time: now, level: 2, type: 'log', message: 'rendering /', source: 'runtime', request: 'GET /', requestId: 7 }) - events.push({ time: now, level: 0, type: 'error', message: 'Invalid end tag.', source: 'runtime', request: 'GET /', requestId: 7 }) - events.push({ time: now, level: 2, type: 'log', message: 'unrelated', source: 'runtime', request: 'GET /other', requestId: 8 }) - log.push([{ id: 7, time: now, method: 'GET', url: '/', status: 500, duration: 20 }]) + events.push({ time: now, level: 2, type: 'log', message: 'rendering /', source: 'runtime', request: 'GET /', requestId: 'r7' }) + events.push({ time: now, level: 0, type: 'error', message: 'Invalid end tag.', source: 'runtime', request: 'GET /', requestId: 'r7' }) + events.push({ time: now, level: 2, type: 'log', message: 'unrelated', source: 'runtime', request: 'GET /other', requestId: 'r8' }) + log.push([{ id: 'r7', time: now, method: 'GET', url: '/', status: 500, duration: 20 }]) overlay.open() overlay.handleKey({ name: 'down' }) @@ -1669,7 +1677,7 @@ describe('request overlay', () => { it('says so when a request has no attributed logs', () => { const events = new DevEventLog() const { log, overlay, lastFrame } = create({ events }) - log.push([{ id: 9, time: Date.now(), method: 'GET', url: '/quiet', status: 200, duration: 2 }]) + log.push([{ id: 'r9', time: Date.now(), method: 'GET', url: '/quiet', status: 200, duration: 2 }]) overlay.open() overlay.handleKey({ name: 'down' }) overlay.handleKey({ name: 'return' }) @@ -2767,7 +2775,7 @@ describe('request failures on the panel', () => { it('should name a forwarded report on the status line and count it once', async () => { await withPanel(async (ui, settle) => { ui.setStatus('ready') - ui.pushReport({ id: 'abc', name: 'TypeError', message: 'x is not a function', ansi: 'TypeError: x is not a function\n at app.vue:3:1', requestId: 1 }) + ui.pushReport({ id: 'abc', name: 'TypeError', message: 'x is not a function', ansi: 'TypeError: x is not a function\n at app.vue:3:1', requestId: 'r1' }) const frames = await settle() expect(frames).toContain('x is not a function · press l to read it') @@ -2824,7 +2832,7 @@ describe('request failures on the panel', () => { // A fork hears an app log twice: over the log channel, and again when the // app's stdout comes through its own consola. it.each([ - ['inside a request', { origin: 'runtime' as const, request: 'GET /api/log', requestId: 1 }], + ['inside a request', { origin: 'runtime' as const, request: 'GET /api/log', requestId: 'r1' }], ['outside a request', { origin: 'build' as const, request: undefined }], ])('should record an app log a fork forwards twice once (%s)', async (_name, attribution) => { await withPanel(async (ui, _settle, session) => { @@ -2849,7 +2857,7 @@ describe('request failures on the panel', () => { const level = consola.level consola.level = 3 try { - ui.pushServerLog({ level: 3, logType: 'log', message, origin: 'runtime', request: 'GET /', requestId: 1 }) + ui.pushServerLog({ level: 3, logType: 'log', message, origin: 'runtime', request: 'GET /', requestId: 'r1' }) consola.log(message) await flush() if (reprint) { @@ -2868,7 +2876,7 @@ describe('request failures on the panel', () => { const seen = session.events.recent(50).filter(event => event.message.includes('hello from the app')) expect(seen).toHaveLength(1) - expect(seen[0]).toMatchObject({ request: 'GET /', requestId: 1 }) + expect(seen[0]).toMatchObject({ request: 'GET /', requestId: 'r1' }) }) }) diff --git a/packages/nuxt-cli/test/unit/dev/lifecycle.spec.ts b/packages/nuxt-cli/test/unit/dev/lifecycle.spec.ts index 96f67e7cc..0ad88c120 100644 --- a/packages/nuxt-cli/test/unit/dev/lifecycle.spec.ts +++ b/packages/nuxt-cli/test/unit/dev/lifecycle.spec.ts @@ -543,9 +543,11 @@ describe('dev server shutdown', () => { it('should identify every request it forwards to the app', async () => { const ids: Array = [] + const labels: Array = [] const nuxt = createNuxt() nuxt.server.handler = (req: any, res) => { ids.push(req.headers['x-nuxt-dev-request-id']) + labels.push(req.headers['x-nuxt-dev-request-label']) res.end('app') } loadNuxt.mockImplementation(() => Promise.resolve(nuxt)) @@ -556,9 +558,36 @@ describe('dev server shutdown', () => { await get(server, '/about') expect(ids).toHaveLength(2) - expect(ids[0]).toMatch(/^\d+ GET \/$/) - expect(ids[1]).toMatch(/^\d+ GET \/about$/) + expect(ids[0]).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/) + expect(ids[1]).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/) expect(ids[0]).not.toBe(ids[1]) + expect(labels).toEqual(['GET%20%2F', 'GET%20%2Fabout']) + }) + + it('should replace attribution headers a client sent itself', async () => { + let seen: Record = {} + let raw: string[] = [] + const nuxt = createNuxt() + nuxt.server.handler = (req: any, res) => { + seen = req.headers + raw = req.rawHeaders + res.end('app') + } + loadNuxt.mockImplementation(() => Promise.resolve(nuxt)) + const server = createServer() + await server.init() + + const { port } = server.listener.address as AddressInfo + await fetch(`http://127.0.0.1:${port}/`, { + headers: { + 'x-nuxt-dev-request-id': 'forged', + 'x-nuxt-dev-request-label': 'GET%20%2Fsomewhere-else', + }, + }).then(response => response.text()) + + expect(seen['x-nuxt-dev-request-id']).not.toBe('forged') + expect(seen['x-nuxt-dev-request-label']).toBe('GET%20%2F') + expect(raw.filter(value => value === 'forged' || value === 'GET%20%2Fsomewhere-else')).toEqual([]) }) }) diff --git a/packages/nuxt-cli/test/unit/error-channel.spec.ts b/packages/nuxt-cli/test/unit/error-channel.spec.ts index 36f6044ca..6609e6e5e 100644 --- a/packages/nuxt-cli/test/unit/error-channel.spec.ts +++ b/packages/nuxt-cli/test/unit/error-channel.spec.ts @@ -14,6 +14,7 @@ import { normalize } from 'pathe' import { afterEach, describe, expect, it, vi } from 'vitest' import { closeErrorChannel, createCliReport, DEFAULT_ERROR_CHANNEL, ERROR_BROADCAST_CHANNEL, formatReportForTerminal, isDevErrorMessage, isErrorChannelRequest, openErrorBridge, publishCliProgress, renderErrorPage, resolveChannelPath, summariseReport, toBuildProgress, useErrorChannel } from '../../src/dev/error-channel' +import { createRequest } from '../../src/dev/serving-state' import { NuxtDevServer } from '../../src/dev/utils' function createResponse() { @@ -265,9 +266,9 @@ describe('summariseReport', () => { it('should carry the rendering and the topmost frame of the project', async () => { const error = new Error('summarise me') const report = await createCliReport(error, { cwd: process.cwd() }) - const summary = await summariseReport(report, { requestId: 7 }) + const summary = await summariseReport(report, { requestId: 'r7' }) - expect(summary).toMatchObject({ id: report.id, name: 'Error', message: 'summarise me', requestId: 7 }) + expect(summary).toMatchObject({ id: report.id, name: 'Error', message: 'summarise me', requestId: 'r7' }) expect(summary.file).toContain('error-channel.spec.ts') expect(summary.location).toMatch(/^\.\/packages\/nuxt-cli\/test\/unit\/error-channel\.spec\.ts:\d+:\d+$/) expect(summary.ansi).toContain('summarise me') @@ -335,13 +336,13 @@ describe('the CLI-owned error channel', () => { const app = new BroadcastChannel(ERROR_BROADCAST_CHANNEL) const requestReport = compileReport('/app/app.vue', 3, 1) const buildReport = compileReport('/app/pages/index.vue', 5, 2) - app.postMessage({ type: 'nuxt:dev:error:report', report: requestReport, requestId: 4, request: 'GET /broken?x=1' }) + app.postMessage({ type: 'nuxt:dev:error:report', report: requestReport, requestId: 'r4', request: 'GET /broken?x=1' }) app.postMessage({ type: 'nuxt:dev:error:report', report: buildReport }) app.close() await vi.waitUntil(() => reports.length === 2) close() - expect(setError).toHaveBeenNthCalledWith(1, requestReport, '4', 'GET /broken?x=1') + expect(setError).toHaveBeenNthCalledWith(1, requestReport, 'r4', 'GET /broken?x=1') expect(setError).toHaveBeenNthCalledWith(2, buildReport, undefined, undefined) }) @@ -441,6 +442,32 @@ describe('the CLI-owned error channel', () => { expect(body.join('')).not.toContain('boom from a page') }) + it('should not let a peer on another machine enumerate its way to a report', async () => { + const server = createServer() + const report = await createCliReport(new Error('boom from a page'), { cwd: process.cwd() }) + const instance = await useErrorChannel() + const raised = createRequest('GET /boom-page') + instance.setError(report, raised.id, raised.label) + + for (let guess = 1; guess <= 25; guess++) { + for (const scope of [`requestId=${guess}`, `requestId=${encodeURIComponent(`${guess} GET /boom-page`)}`, 'path=/boom-page']) { + const { res, statusOf, chunks } = createResponse() + const remote = Object.assign(request(`${DEFAULT_ERROR_CHANNEL}/history/${report.id}?${scope}`), { socket: { remoteAddress: '192.168.0.31' } }) + await server.handler(remote, res) + + expect(statusOf()).toBe(404) + expect(chunks.join('')).not.toContain('boom from a page') + } + } + + const { res, statusOf, chunks } = createResponse() + const owner = Object.assign(request(`${DEFAULT_ERROR_CHANNEL}/history/${report.id}?requestId=${raised.id}`), { socket: { remoteAddress: '192.168.0.31' } }) + await server.handler(owner, res) + + expect(statusOf()).toBe(200) + expect(chunks.join('')).toContain('boom from a page') + }) + it('should serve a report to the peer whose request raised it', async () => { const server = createServer() const report = await createCliReport(new Error('boom from a page'), { cwd: process.cwd() }) From 233dfe27c2cf51ba1b7a9258db34ceef2266bf84 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Tue, 22 Sep 2026 14:26:38 +0000 Subject: [PATCH 2/2] chore(deps): update my-bad to 0.2.7 --- packages/nuxt-cli/package.json | 2 +- pnpm-lock.yaml | 10 +++++----- pnpm-workspace.yaml | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/nuxt-cli/package.json b/packages/nuxt-cli/package.json index 480f97e77..aa20ed6c0 100644 --- a/packages/nuxt-cli/package.json +++ b/packages/nuxt-cli/package.json @@ -73,7 +73,7 @@ "exsolve": "^1.1.1", "fuzzysort": "^4.0.2", "get-port-please": "^3.2.0", - "my-bad": "^0.2.6", + "my-bad": "^0.2.7", "obug": "^3.0.0", "pathe": "^2.0.3", "perfect-debounce": "^2.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 99aa3111a..5f187fc27 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -416,8 +416,8 @@ importers: specifier: ^3.2.0 version: 3.2.0 my-bad: - specifier: ^0.2.6 - version: 0.2.6(vite@8.3.0) + specifier: ^0.2.7 + version: 0.2.7(vite@8.3.0) obug: specifier: ^3.0.0 version: 3.0.0 @@ -4988,8 +4988,8 @@ packages: muggle-string@0.4.1: resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} - my-bad@0.2.6: - resolution: {integrity: sha512-afKIOyZQa3KlFBOqPeDBuBNNVdqX8Dr+lSUoduKgmVfzvpQ/bB4D2NZEGetqcprZYmmVnl0RYEygh3eWD9S26Q==} + my-bad@0.2.7: + resolution: {integrity: sha512-tI/Lvgd78tocwu2hPmvAsdr0b1lTunOMrsnKtm4VH+leR3kS69v+gpMCOh5Ty5KIgPV5GH/HOBbzYogQB9Pvpw==} engines: {node: '>=22.12.0'} peerDependencies: vite: '>=6' @@ -12157,7 +12157,7 @@ snapshots: muggle-string@0.4.1: {} - my-bad@0.2.6(vite@8.3.0): + my-bad@0.2.7(vite@8.3.0): dependencies: clickable-path: 0.1.1 errx: 0.2.2 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5a1bf396e..7fd6b5122 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -7,7 +7,7 @@ minimumReleaseAgeExclude: - '@vue/*' - fuzzysort@4.0.1 - errx@0.2.1 || 0.2.2 - - my-bad@0.2.2 || 0.2.5 || 0.2.6 + - my-bad@0.2.2 || 0.2.5 || 0.2.6 || 0.2.7 verifyDepsBeforeRun: install