Skip to content
Merged
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
2 changes: 1 addition & 1 deletion packages/nuxt-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
20 changes: 13 additions & 7 deletions packages/nuxt-cli/runtime/dev-request-context.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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) {
Expand All @@ -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 {}
Expand All @@ -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 {}
Expand Down
8 changes: 4 additions & 4 deletions packages/nuxt-cli/src/dev/error-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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. */
Expand All @@ -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
}

Expand Down Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion packages/nuxt-cli/src/dev/log-channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
20 changes: 13 additions & 7 deletions packages/nuxt-cli/src/dev/serving-state.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { AsyncLocalStorage } from 'node:async_hooks'
import { randomUUID } from 'node:crypto'

export interface InflightRequest {
id: number
id: string
label: string
}

Expand All @@ -10,11 +11,16 @@ const storage = new AsyncLocalStorage<InflightRequest>()
/** 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 }
}

/**
Expand All @@ -35,9 +41,9 @@ export function runWithRequest<T>(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. */
Expand Down
2 changes: 1 addition & 1 deletion packages/nuxt-cli/src/dev/tui/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion packages/nuxt-cli/src/dev/tui/overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 3 additions & 13 deletions packages/nuxt-cli/src/dev/tui/request-overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<number, number> {
const counts = new Map<number, number>()
#errorCounts(): Map<string, number> {
const counts = new Map<string, number>()
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)
}
Expand Down
2 changes: 1 addition & 1 deletion packages/nuxt-cli/src/dev/tui/requests.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
21 changes: 12 additions & 9 deletions packages/nuxt-cli/src/dev/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

/**
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -497,9 +497,10 @@ export class NuxtDevServer extends EventEmitter<DevServerEventMap> {
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)
}
Expand Down Expand Up @@ -1528,17 +1529,19 @@ export class NuxtDevServer extends EventEmitter<DevServerEventMap> {
}

/**
* 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)
}
}
Expand Down
14 changes: 8 additions & 6 deletions packages/nuxt-cli/test/unit/dev-request-context.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) }
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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()
Expand All @@ -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 {
Expand All @@ -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'] })
Expand Down
Loading
Loading