From 5a2eef21cfc05f3a3d4b07af39afdf62579e7abe Mon Sep 17 00:00:00 2001 From: erKam <88717125+erkamyaman@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:04:31 +0300 Subject: [PATCH 1/2] fix(http): keep the SSR timing when writeHead passes Server-Timing The SSR middleware looked for a Server-Timing value passed to writeHead only under the lowercase key. When an app passed `Server-Timing`, the middleware set its own value with setHeader and Node then replaced it with the app's header, so the pangular entry was lost and the render could not be linked to the page. The key is now matched case-insensitively and the values are merged under the key the app used. --- .../src/__tests__/ssr-requests.test.ts | 23 ++++++++++++++++++- packages/devtools/src/ssr-middleware.ts | 8 +++++-- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/packages/devtools/src/__tests__/ssr-requests.test.ts b/packages/devtools/src/__tests__/ssr-requests.test.ts index d2a1f45c..daf98cd0 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,26 @@ 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('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..ccccc723 100644 --- a/packages/devtools/src/ssr-middleware.ts +++ b/packages/devtools/src/ssr-middleware.ts @@ -125,9 +125,13 @@ export function createSsrMiddleware(options: SsrMiddlewareOptions = {}) { 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; + // Header names are case-insensitive, so `Server-Timing` counts as the same header. + const passedKey = headers + ? Object.keys(headers).find((key) => key.toLowerCase() === 'server-timing') + : undefined; + const passed = passedKey ? headerText(headers?.[passedKey]) : undefined; const value = [existing, passed, ...timing].filter(Boolean).join(', '); - if (headers && passed !== undefined) headers['server-timing'] = value; + if (headers && passedKey && passed !== undefined) headers[passedKey] = value; else res.setHeader('server-timing', value); } return (writeHead as (...a: unknown[]) => ServerResponse).apply(this, args); From 644eff5590c3b52b4abe5382f5240fb0bdfe4c14 Mon Sep 17 00:00:00 2001 From: erKam <88717125+erkamyaman@users.noreply.github.com> Date: Sun, 11 Oct 2026 00:15:47 +0300 Subject: [PATCH 2/2] fix(http): merge SSR timing into raw header arrays passed to writeHead Node also accepts writeHead headers as a flat name/value array or as [name, value] pairs. The wrapper only merged into a headers object, so a Server-Timing in an array replaced our value, and with pairs the extra setHeader call made Node reject the headers. The timing is now merged into a copy of the array, leaving the caller's array untouched. --- .../src/__tests__/ssr-requests.test.ts | 43 +++++++++++++++++++ packages/devtools/src/ssr-middleware.ts | 39 ++++++++++++----- 2 files changed, 71 insertions(+), 11 deletions(-) diff --git a/packages/devtools/src/__tests__/ssr-requests.test.ts b/packages/devtools/src/__tests__/ssr-requests.test.ts index daf98cd0..e5d007c6 100644 --- a/packages/devtools/src/__tests__/ssr-requests.test.ts +++ b/packages/devtools/src/__tests__/ssr-requests.test.ts @@ -125,6 +125,49 @@ describe('ssrMiddleware', () => { } }); + 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 ccccc723..c268f451 100644 --- a/packages/devtools/src/ssr-middleware.ts +++ b/packages/devtools/src/ssr-middleware.ts @@ -121,18 +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 merge = (passed?: string) => [existing, passed, ...timing].filter(Boolean).join(', '); // Header names are case-insensitive, so `Server-Timing` counts as the same header. - const passedKey = headers - ? Object.keys(headers).find((key) => key.toLowerCase() === 'server-timing') - : undefined; - const passed = passedKey ? headerText(headers?.[passedKey]) : undefined; - const value = [existing, passed, ...timing].filter(Boolean).join(', '); - if (headers && passedKey && passed !== undefined) headers[passedKey] = value; - else res.setHeader('server-timing', value); + 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;