diff --git a/packages/devtools/src/__tests__/ssr-requests.test.ts b/packages/devtools/src/__tests__/ssr-requests.test.ts index d2a1f45c..e5d007c6 100644 --- a/packages/devtools/src/__tests__/ssr-requests.test.ts +++ b/packages/devtools/src/__tests__/ssr-requests.test.ts @@ -1,6 +1,7 @@ import '@angular/compiler'; import { EventEmitter } from 'node:events'; -import type { IncomingMessage, ServerResponse } from 'node:http'; +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import type { AddressInfo } from 'node:net'; import { Injector, PLATFORM_ID, REQUEST, runInInjectionContext } from '@angular/core'; import { HttpRequest, HttpResponse, type HttpEvent } from '@angular/common/http'; import { createHostContext } from 'devframe/node'; @@ -104,6 +105,69 @@ describe('ssrMiddleware', () => { expect(res.sentHeaders['server-timing']).toMatch(/^db;dur=5, pangular;desc="/); }); + it('merges with a Server-Timing value passed to writeHead in any case', async () => { + const middleware = createSsrMiddleware(); + const server = createServer((req, res) => { + middleware(req, res, () => {}); + res.writeHead(200, { 'Server-Timing': 'db;dur=5', 'Content-Type': 'text/html' }); + res.end(HTML); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + try { + const { port } = server.address() as AddressInfo; + const response = await fetch(`http://127.0.0.1:${port}/`, { + headers: { accept: 'text/html' }, + }); + await response.text(); + expect(response.headers.get('server-timing')).toMatch(/^db;dur=5, pangular;desc="/); + } finally { + await new Promise((resolve) => server.close(resolve)); + } + }); + + it('merges with a Server-Timing value passed to writeHead as a raw array', async () => { + const middleware = createSsrMiddleware(); + const raw = ['Server-Timing', 'db;dur=5', 'Content-Type', 'text/html']; + const server = createServer((req, res) => { + middleware(req, res, () => {}); + res.writeHead(200, raw); + res.end(HTML); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + try { + const { port } = server.address() as AddressInfo; + const response = await fetch(`http://127.0.0.1:${port}/`, { + headers: { accept: 'text/html' }, + }); + await response.text(); + expect(response.headers.get('server-timing')).toMatch(/^db;dur=5, pangular;desc="/); + expect(raw[1]).toBe('db;dur=5'); + } finally { + await new Promise((resolve) => server.close(resolve)); + } + }); + + it('adds Server-Timing to [name, value] pairs passed to writeHead', async () => { + const middleware = createSsrMiddleware(); + const server = createServer((req, res) => { + middleware(req, res, () => {}); + res.writeHead(200, [['Content-Type', 'text/html']] as unknown as string[]); + res.end(HTML); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + try { + const { port } = server.address() as AddressInfo; + const response = await fetch(`http://127.0.0.1:${port}/`, { + headers: { accept: 'text/html' }, + }); + await response.text(); + expect(response.status).toBe(200); + expect(response.headers.get('server-timing')).toMatch(/^pangular;desc="/); + } finally { + await new Promise((resolve) => server.close(resolve)); + } + }); + it('skips non-HTML requests, hub paths and when the http inspector is off', () => { const middleware = createSsrMiddleware({ skip: ['/__devframes/'] }); const passed: string[] = []; diff --git a/packages/devtools/src/ssr-middleware.ts b/packages/devtools/src/ssr-middleware.ts index 2ee3abd8..c268f451 100644 --- a/packages/devtools/src/ssr-middleware.ts +++ b/packages/devtools/src/ssr-middleware.ts @@ -121,14 +121,35 @@ export function createSsrMiddleware(options: SsrMiddlewareOptions = {}) { timing.push(`resolve;dur=${sum((n) => n.resolvers?.ms)}`); } const existing = headerText(res.getHeader('server-timing')); - // A headers object passed to writeHead would override setHeader, so merge into it. - const headers = args.find( - (arg, i) => i > 0 && arg && typeof arg === 'object' && !Array.isArray(arg), - ) as OutgoingHttpHeaders | undefined; - const passed = headers ? headerText(headers['server-timing']) : undefined; - const value = [existing, passed, ...timing].filter(Boolean).join(', '); - if (headers && passed !== undefined) headers['server-timing'] = value; - else res.setHeader('server-timing', value); + const merge = (passed?: string) => [existing, passed, ...timing].filter(Boolean).join(', '); + // Header names are case-insensitive, so `Server-Timing` counts as the same header. + const isTiming = (name: unknown) => String(name).toLowerCase() === 'server-timing'; + // Headers passed to writeHead would override setHeader, so merge into them. Node takes + // an object, a flat `[name, value, ...]` array or `[name, value]` pairs. + const slot = args.findIndex((arg, i) => i > 0 && arg && typeof arg === 'object'); + const headers = args[slot]; + if (Array.isArray(headers)) { + // Work on a copy so the caller's array is left as it was. + const raw = [...headers] as unknown[]; + const pairs = Array.isArray(raw[0]); + const i = raw.findIndex((entry, n) => + pairs ? isTiming((entry as unknown[])[0]) : n % 2 === 0 && isTiming(entry), + ); + // Added to the array, not with setHeader: Node rejects pairs once a header is set. + if (i < 0) + raw.push(...(pairs ? [['server-timing', merge()]] : ['server-timing', merge()])); + else if (pairs) { + const [name, value] = raw[i] as [string, string | string[]]; + raw[i] = [name, merge(headerText(value))]; + } else raw[i + 1] = merge(headerText(raw[i + 1] as string | string[])); + args[slot] = raw; + } else { + const object = headers as OutgoingHttpHeaders | undefined; + const passedKey = object ? Object.keys(object).find(isTiming) : undefined; + const passed = passedKey ? headerText(object?.[passedKey]) : undefined; + if (object && passedKey && passed !== undefined) object[passedKey] = merge(passed); + else res.setHeader('server-timing', merge()); + } } return (writeHead as (...a: unknown[]) => ServerResponse).apply(this, args); } as typeof res.writeHead;