From 360fab4e48533b2d2fbada726cb9815f2da574c4 Mon Sep 17 00:00:00 2001 From: erKam Date: Tue, 13 Oct 2026 18:07:52 +0000 Subject: [PATCH] fix(analog): link server calls only to the request they ran under A render without Angular's REQUEST token, as in Analog, was linked to whichever traced request was the only one in flight. An untraced render running alongside a traced one had its server calls shown under the other page's request, and two overlapping traced renders lost their links altogether. The tracers now set the request id, or an untraced marker, in an AsyncLocalStorage shared through the SSR registry: withPangularSsr and the Node middleware run the handler inside it, and the h3 event handler enters it for the rest of the request. The interceptor reads it, keeps the first value per app so a call that loses the async context still finds its own request, and links nothing when no tracer saw the render. --- apps/docs/src/content/guides/analog.md | 10 +- apps/docs/src/content/inspectors/ssr-http.md | 2 +- .../devtools/src/__tests__/ssr-fetch.test.ts | 76 ++++--- .../src/__tests__/ssr-request-context.test.ts | 190 ++++++++++++++++++ packages/devtools/src/http.ts | 12 +- packages/devtools/src/ssr-context.ts | 86 ++++++++ packages/devtools/src/ssr-fetch.ts | 6 +- packages/devtools/src/ssr-middleware.ts | 7 +- packages/devtools/src/ssr-registry.ts | 19 +- packages/devtools/src/ssr.ts | 11 +- 10 files changed, 353 insertions(+), 66 deletions(-) create mode 100644 packages/devtools/src/__tests__/ssr-request-context.test.ts create mode 100644 packages/devtools/src/ssr-context.ts diff --git a/apps/docs/src/content/guides/analog.md b/apps/docs/src/content/guides/analog.md index bead49a8..03242128 100644 --- a/apps/docs/src/content/guides/analog.md +++ b/apps/docs/src/content/guides/analog.md @@ -152,15 +152,11 @@ export default defineEventHandler(pangularSsrEventHandler()); It wraps the Node response of each HTML request, adds the `Server-Timing` header that links the page to its render, and records the render with its render mode. Analog marks its renders `ssr-analog`, which the tab shows as **Server**. It does nothing while the Vite plugin is not running, so it is safe in a production build. -Analog gives the render its own `REQUEST` token, not the one from `@angular/core`, so `withPangular()` can't read the request id from a header. Instead, the tracer links each server call to the only traced request in flight. +Analog gives the render its own `REQUEST` token, not the one from `@angular/core`, so `withPangular()` can't read the request id from a header. Instead, the tracer keeps the id in the request's async context (Node's `AsyncLocalStorage`), and each server call is linked to the request it ran under. Renders that run at the same time each keep their own calls. -If two traced renders run at once, the tracer can't tell their calls apart. The calls show in the timeline without a link to their request. +The tracer skips a request that does not accept `text/html`, such as `curl` without an `Accept` header. It also skips a path in `skip`. The calls of a render it skips show in the timeline without a link to a request, even while a traced render runs alongside. -The tracer skips a request that does not accept `text/html`, such as `curl` without an `Accept` header. It also skips a path in `skip`. A render it skips is not in flight for the tracer. - -If an untraced render runs alongside one traced render, the tracer links the untraced render's calls to the traced one. - -The tracer drops a traced request after five minutes without activity, such as a response nobody reads. A response that keeps sending chunks stays in flight. +The tracer drops a traced request after five minutes without activity, such as a response nobody reads. A response that keeps sending chunks stays in flight. A server call made after its request was dropped is not linked. ## Try the demo diff --git a/apps/docs/src/content/inspectors/ssr-http.md b/apps/docs/src/content/inspectors/ssr-http.md index 7a4ee4c1..2ae7e8d4 100644 --- a/apps/docs/src/content/inspectors/ssr-http.md +++ b/apps/docs/src/content/inspectors/ssr-http.md @@ -73,7 +73,7 @@ The request that served the selected page opens on its own. Click another row to The middleware gives each request an id. The id reaches the render in the `x-pangular-ssr-id` request header, so the interceptor tags each server call with it. It goes back to the browser in a `Server-Timing` header (`pangular;desc=""`, `render;dur=...`, `fetch;dur=...`, and `guards;dur=...` and `resolve;dur=...` when the router ran them), so the overlay links the page to the render, and the browser's own Network panel shows the same times. -A render without the `REQUEST` token from `@angular/core`, as in Analog, has no header to read. Its server calls are linked to the only traced request in flight. A render that is not traced can run alongside it, and its calls are still linked to that request. A traced request with no activity for five minutes no longer counts as in flight. See [Set up Analog](../guides/analog.md#trace-server-renders). +A render without the `REQUEST` token from `@angular/core`, as in Analog, has no header to read. The tracer keeps the id in the request's async context instead (Node's `AsyncLocalStorage`), so each server call is linked to the request it ran under, even when several renders run at the same time. The calls of a render that is not traced are not linked to any request. A traced request with no activity for five minutes no longer counts as in flight. See [Set up Analog](../guides/analog.md#trace-server-renders). #### Render modes diff --git a/packages/devtools/src/__tests__/ssr-fetch.test.ts b/packages/devtools/src/__tests__/ssr-fetch.test.ts index bedb8859..ac9f3a4c 100644 --- a/packages/devtools/src/__tests__/ssr-fetch.test.ts +++ b/packages/devtools/src/__tests__/ssr-fetch.test.ts @@ -23,12 +23,13 @@ import { pangularHttpInterceptor } from '../http.ts'; import { httpRegistry } from '../http-rules.ts'; import { pangularSsrEventHandler, withPangularSsr } from '../ssr.ts'; import { renderModeOf } from '../ssr-trace.ts'; +import { runInSsrRequest } from '../ssr-context.ts'; import type { SsrOverride } from '../ssr-overrides.ts'; import { ACTIVE_SSR_TTL_MS, SSR_REQUEST_HEADER, - soleActiveSsrRequest, ssrRegistry, + sweepActiveSsrRequests, type SsrRequest, } from '../ssr-registry.ts'; import type { HttpState } from '../types.ts'; @@ -188,12 +189,13 @@ describe('withPangularSsr', () => { await chunkAt(1_000_000 + step, ''); await chunkAt(1_000_000 + 2 * step, ''); vi.setSystemTime(1_000_000 + 2 * step + ACTIVE_SSR_TTL_MS); - expect(soleActiveSsrRequest()).toBeDefined(); + sweepActiveSsrRequests(); + expect(ssrRegistry().active.size).toBe(1); expect(recorded).toEqual([]); // Five minutes and a moment with no chunk: the sweep ends it as aborted. vi.setSystemTime(1_000_000 + 2 * step + ACTIVE_SSR_TTL_MS + 1); - expect(soleActiveSsrRequest()).toBeUndefined(); + sweepActiveSsrRequests(); expect(ssrRegistry().active.size).toBe(0); expect(recorded).toMatchObject([{ url: '/slow', aborted: true, bytes: 12 }]); } finally { @@ -212,7 +214,8 @@ describe('withPangularSsr', () => { // Still in flight before the TTL. vi.setSystemTime(1_000_000 + ACTIVE_SSR_TTL_MS); - expect(soleActiveSsrRequest()).toBeDefined(); + sweepActiveSsrRequests(); + expect(ssrRegistry().active.size).toBe(1); vi.setSystemTime(1_000_000 + ACTIVE_SSR_TTL_MS + 1); const next = await withPangularSsr(async () => html(null, { status: 302 }))(page('/next')); @@ -381,15 +384,19 @@ describe('server calls without an Angular REQUEST', () => { ssrRegistry().active.clear(); }); - const run = () => { - const injector = Injector.create({ providers: [{ provide: PLATFORM_ID, useValue: 'server' }] }); - runInInjectionContext(injector, () => - pangularHttpInterceptor(new HttpRequest('GET', 'http://localhost/api/posts'), () => - of(new HttpResponse({ status: 200, body: [] }) as HttpEvent), - ), - ).subscribe(); + const run = (requestId?: string, injector = serverInjector()) => { + const call = () => + runInInjectionContext(injector, () => + pangularHttpInterceptor(new HttpRequest('GET', 'http://localhost/api/posts'), () => + of(new HttpResponse({ status: 200, body: [] }) as HttpEvent), + ), + ).subscribe(); + if (requestId) runInSsrRequest(requestId, call); + else call(); return httpRegistry().calls!.at(-1)!; }; + const serverInjector = () => + Injector.create({ providers: [{ provide: PLATFORM_ID, useValue: 'server' }] }); it('counts a server call as activity on the trace', () => { vi.useFakeTimers({ toFake: ['Date'] }); @@ -402,44 +409,47 @@ describe('server calls without an Angular REQUEST', () => { startedAt: 1_000_000, }); vi.setSystemTime(1_000_000 + ACTIVE_SSR_TTL_MS - 1); - expect(run()).toMatchObject({ requestId: 'abc123' }); + expect(run('abc123')).toMatchObject({ requestId: 'abc123' }); // Past the TTL since the start, but not since the call. vi.setSystemTime(1_000_000 + 2 * ACTIVE_SSR_TTL_MS - 2); - expect(soleActiveSsrRequest()).toBe('abc123'); + sweepActiveSsrRequests(); + expect(ssrRegistry().active.has('abc123')).toBe(true); vi.setSystemTime(1_000_000 + 2 * ACTIVE_SSR_TTL_MS); - expect(soleActiveSsrRequest()).toBeUndefined(); + sweepActiveSsrRequests(); + expect(ssrRegistry().active.has('abc123')).toBe(false); } finally { vi.useRealTimers(); } }); - it('links to the only traced request in flight', () => { + it('links a call to the traced request it runs under', () => { ssrRegistry().active.set('abc123', { fetches: 0, fetchMs: 0, url: '/blog' }); - expect(run()).toMatchObject({ requestId: 'abc123', pageUrl: '/blog' }); + ssrRegistry().active.set('def456', { fetches: 0, fetchMs: 0, url: '/about' }); + expect(run('abc123')).toMatchObject({ requestId: 'abc123', pageUrl: '/blog' }); + expect(run('def456')).toMatchObject({ requestId: 'def456', pageUrl: '/about' }); expect(ssrRegistry().active.get('abc123')!.fetches).toBe(1); + expect(ssrRegistry().active.get('def456')!.fetches).toBe(1); }); - it('drops a trace in flight past its TTL before linking', () => { - ssrRegistry().active.set('stale', { - fetches: 0, - fetchMs: 0, - url: '/old', - startedAt: Date.now() - ACTIVE_SSR_TTL_MS - 1, - }); - ssrRegistry().active.set('abc123', { - fetches: 0, - fetchMs: 0, - url: '/blog', - startedAt: Date.now(), - }); - expect(run()).toMatchObject({ requestId: 'abc123', pageUrl: '/blog' }); - expect(ssrRegistry().active.has('stale')).toBe(false); + it('links nothing to a request that is no longer in flight', () => { + ssrRegistry().active.set('def456', { fetches: 0, fetchMs: 0, url: '/about' }); + expect(run('abc123')).not.toHaveProperty('requestId'); }); - it('links nothing while two requests are in flight', () => { + it('links nothing outside a traced request, even with one in flight', () => { ssrRegistry().active.set('abc123', { fetches: 0, fetchMs: 0, url: '/blog' }); - ssrRegistry().active.set('def456', { fetches: 0, fetchMs: 0, url: '/about' }); expect(run()).not.toHaveProperty('requestId'); + expect(run()).toMatchObject({ pageUrl: undefined }); + }); + + it('keeps an app on the request it first ran under, when a later call loses the context', () => { + ssrRegistry().active.set('abc123', { fetches: 0, fetchMs: 0, url: '/blog' }); + ssrRegistry().active.set('def456', { fetches: 0, fetchMs: 0, url: '/about' }); + const app = serverInjector(); + expect(run('abc123', app)).toMatchObject({ requestId: 'abc123' }); + // A zone.js microtask, for one, can run the call under another request's context. + expect(run('def456', app)).toMatchObject({ requestId: 'abc123' }); + expect(run(undefined, app)).toMatchObject({ requestId: 'abc123' }); }); }); diff --git a/packages/devtools/src/__tests__/ssr-request-context.test.ts b/packages/devtools/src/__tests__/ssr-request-context.test.ts new file mode 100644 index 00000000..595268ec --- /dev/null +++ b/packages/devtools/src/__tests__/ssr-request-context.test.ts @@ -0,0 +1,190 @@ +import '@angular/compiler'; +import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { Injector, PLATFORM_ID, runInInjectionContext } from '@angular/core'; +import { HttpRequest, HttpResponse, type HttpEvent } from '@angular/common/http'; +import { createApp, eventHandler, setResponseHeader, toNodeListener } from 'h3'; +import { of } from 'rxjs'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { pangularHttpInterceptor } from '../http.ts'; +import { httpRegistry, type HttpCall } from '../http-rules.ts'; +import { pangularSsrEventHandler, pangularSsrMiddleware, withPangularSsr } from '../ssr.ts'; +import { ssrRegistry, type SsrRequest } from '../ssr-registry.ts'; + +const HTML = ''; + +/** A server HttpClient call made the way an Analog render makes it: no Angular `REQUEST`. */ +function serverCall(path: string, injector = serverInjector()): HttpCall { + runInInjectionContext(injector, () => + pangularHttpInterceptor(new HttpRequest('GET', `http://localhost${path}`), () => + of(new HttpResponse({ status: 200, body: [] }) as HttpEvent), + ), + ).subscribe(); + return callTo(path); +} + +function callTo(path: string): HttpCall { + return httpRegistry().calls!.find((call) => call.url === `http://localhost${path}`)!; +} + +function serverInjector() { + return Injector.create({ providers: [{ provide: PLATFORM_ID, useValue: 'server' }] }); +} + +/** Resolves once `count` renders have reached it, so they all overlap. */ +function barrier(count: number) { + let open!: () => void; + const opened = new Promise((resolve) => (open = resolve)); + let waiting = 0; + return () => { + if (++waiting === count) open(); + return opened; + }; +} + +function timingId(response: Response): string | undefined { + return /pangular;desc="([a-f0-9]+)"/.exec(response.headers.get('server-timing') ?? '')?.[1]; +} + +async function listen(handler: (req: IncomingMessage, res: ServerResponse) => void) { + const server = createServer(handler); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const { port } = server.address() as AddressInfo; + return { + get: (path: string, accept: string) => + fetch(`http://127.0.0.1:${port}${path}`, { headers: { accept } }), + close: () => new Promise((resolve) => server.close(resolve)), + }; +} + +describe('server calls of overlapping renders', () => { + const g = globalThis as { ngDevMode?: unknown }; + const recorded: SsrRequest[] = []; + let saved: unknown; + beforeEach(() => { + saved = g.ngDevMode; + g.ngDevMode = true; + recorded.length = 0; + httpRegistry().calls = []; + ssrRegistry().record = (request) => recorded.push(request); + }); + afterEach(() => { + g.ngDevMode = saved; + ssrRegistry().record = undefined; + ssrRegistry().active.clear(); + }); + + /** An Analog-style h3 app: the tracer as a server middleware, then a render without `REQUEST`. */ + function analogApp(renders: number) { + const app = createApp(); + const trace = pangularSsrEventHandler(); + const overlap = barrier(renders); + app.use(eventHandler((event) => trace(event))); + app.use( + eventHandler(async (event) => { + await overlap(); + serverCall(`/api${event.path}`); + setResponseHeader(event, 'content-type', 'text/html'); + return HTML; + }), + ); + return listen(toNodeListener(app)); + } + + it('does not link an untraced render to a traced one running at the same time', async () => { + const server = await analogApp(2); + try { + const [traced, untraced] = await Promise.all([ + server.get('/blog', 'text/html'), + server.get('/feed', '*/*'), + ]); + await Promise.all([traced.text(), untraced.text()]); + const id = timingId(traced); + expect(id).toBeDefined(); + expect(timingId(untraced)).toBeUndefined(); + expect(callTo('/api/blog')).toMatchObject({ requestId: id, pageUrl: '/blog' }); + expect(callTo('/api/feed')).not.toHaveProperty('requestId'); + expect(callTo('/api/feed').pageUrl).toBeUndefined(); + } finally { + await server.close(); + } + }); + + it('links each of two traced renders running at the same time to its own request', async () => { + const server = await analogApp(2); + try { + const [blog, about] = await Promise.all([ + server.get('/blog', 'text/html'), + server.get('/about', 'text/html'), + ]); + await Promise.all([blog.text(), about.text()]); + expect(callTo('/api/blog')).toMatchObject({ requestId: timingId(blog) }); + expect(callTo('/api/about')).toMatchObject({ requestId: timingId(about) }); + expect(timingId(blog)).not.toBe(timingId(about)); + // The calls finished before the headers went out, so each render counts its own. + expect(blog.headers.get('server-timing')).toContain('fetch;desc="1 calls"'); + expect(about.headers.get('server-timing')).toContain('fetch;desc="1 calls"'); + } finally { + await server.close(); + } + }); + + it('links calls through the Node tracer for servers without Angular REQUEST', async () => { + const trace = pangularSsrMiddleware(); + const overlap = barrier(2); + const server = await listen((req, res) => + trace(req, res, async () => { + await overlap(); + serverCall(`/api${req.url}`); + res.setHeader('content-type', 'text/html'); + res.end(HTML); + }), + ); + try { + const [traced, untraced] = await Promise.all([ + server.get('/trips', 'text/html'), + server.get('/robots', 'text/plain'), + ]); + await Promise.all([traced.text(), untraced.text()]); + expect(callTo('/api/trips')).toMatchObject({ requestId: timingId(traced) }); + expect(callTo('/api/robots')).not.toHaveProperty('requestId'); + } finally { + await server.close(); + } + }); + + it('links calls of overlapping web-standard renders to their own request only', async () => { + const overlap = barrier(3); + const render = withPangularSsr(async (request: Request) => { + await overlap(); + serverCall(`/api${new URL(request.url).pathname}`); + return new Response(HTML, { headers: { 'content-type': 'text/html' } }); + }); + const get = (path: string, accept: string) => + render(new Request(`http://localhost${path}`, { headers: { accept } })); + const [trips, hotels, feed] = await Promise.all([ + get('/trips', 'text/html'), + get('/hotels', 'text/html'), + get('/feed', 'application/json'), + ]); + await Promise.all([trips.text(), hotels.text(), feed.text()]); + expect(callTo('/api/trips')).toMatchObject({ requestId: timingId(trips) }); + expect(callTo('/api/hotels')).toMatchObject({ requestId: timingId(hotels) }); + expect(callTo('/api/feed')).not.toHaveProperty('requestId'); + }); + + it('links a render that runs without any tracer to nothing, even with one trace in flight', async () => { + let finish!: () => void; + const pending = withPangularSsr( + () => + new Promise((resolve) => { + finish = () => resolve(new Response(HTML, { headers: { 'content-type': 'text/html' } })); + }), + )(new Request('http://localhost/slow', { headers: { accept: 'text/html' } })); + await Promise.resolve(); + expect(ssrRegistry().active.size).toBe(1); + expect(serverCall('/api/elsewhere')).not.toHaveProperty('requestId'); + finish(); + await (await pending).text(); + }); +}); diff --git a/packages/devtools/src/http.ts b/packages/devtools/src/http.ts index db0f745a..1c289214 100644 --- a/packages/devtools/src/http.ts +++ b/packages/devtools/src/http.ts @@ -10,6 +10,7 @@ import { ApplicationRef, DestroyRef, EnvironmentInjector, + Injector, PLATFORM_ID, REQUEST, TransferState, @@ -43,9 +44,9 @@ import { activeSsrRequest, noteOverride, noteSsrFetch, - soleActiveSsrRequest, ssrRegistry, } from './ssr-registry.ts'; +import { currentSsrRequest } from './ssr-context.ts'; import { matchingOverrides } from './ssr-overrides.ts'; import { watchSsrNavigations } from './ssr-navigation.ts'; import { tapConsole } from './console-tap.ts'; @@ -186,13 +187,12 @@ function devMode(): boolean { /** * The traced request of a render that has no Angular `REQUEST`, such as an - * Analog page, when it is the only one in flight. A render the tracer skipped - * (no `Accept: text/html`, a `skip` path) is not in flight, so one running at - * the same time as a single traced render is linked to it: the known limit, - * documented in the Analog guide. + * Analog page: the one the tracer marked the render's async context with + * (`ssr-context.ts`). A render the tracer did not trace, or never saw, is + * linked to nothing, even while other renders are in flight. */ function untaggedServerRequest(): { requestId?: string; pageUrl?: string } { - const requestId = soleActiveSsrRequest(); + const requestId = currentSsrRequest(inject(Injector)); if (!requestId) return {}; return { requestId, pageUrl: ssrRegistry().active.get(requestId)?.url }; } diff --git a/packages/devtools/src/ssr-context.ts b/packages/devtools/src/ssr-context.ts new file mode 100644 index 00000000..51f5cb7c --- /dev/null +++ b/packages/devtools/src/ssr-context.ts @@ -0,0 +1,86 @@ +// Which traced request the code running now belongs to. The tracers +// (`ssr-middleware.ts`, `ssr-fetch.ts`, `pangularSsrEventHandler`) set it for +// every request they see, traced or not, and the server side of +// `withPangular()` reads it when the render has no Angular `REQUEST` to carry +// the id header, as in Analog. Overlapping renders each keep their own value, +// so a server call is linked only to the request it ran under. +// +// This module is bundled into the app too, so it imports nothing from Node: +// the storage is created on the server through `process.getBuiltinModule`. + +import { activeSsrRequest, ssrRegistry } from './ssr-registry.ts'; + +/** What a tracer knows about the request being handled. No `requestId` means it was not traced. */ +export interface SsrRequestContext { + readonly requestId?: string; +} + +/** The part of Node's `AsyncLocalStorage` used here. */ +export interface SsrContextStorage { + getStore(): SsrRequestContext | undefined; + run(store: SsrRequestContext, callback: () => R): R; + enterWith(store: SsrRequestContext): void; +} + +const UNTRACED: SsrRequestContext = Object.freeze({}); + +type AsyncHooks = { AsyncLocalStorage: new () => SsrContextStorage }; + +/** + * The storage, kept on the SSR registry so every copy of this package in the + * process (the Vite plugin, the app's server bundle, a middleware loaded by + * Vite) shares one. Undefined where there is no `AsyncLocalStorage`. + */ +function storage(): SsrContextStorage | undefined { + const registry = ssrRegistry(); + if (registry.context) return registry.context; + try { + const process = (globalThis as { process?: { getBuiltinModule?: (id: string) => unknown } }) + .process; + const hooks = process?.getBuiltinModule?.('node:async_hooks') as AsyncHooks | undefined; + if (hooks?.AsyncLocalStorage) registry.context = new hooks.AsyncLocalStorage(); + } catch { + // No async context on this runtime: server calls stay unlinked. + } + return registry.context; +} + +function contextFor(requestId: string | undefined): SsrRequestContext { + return requestId ? { requestId } : UNTRACED; +} + +/** Runs `callback`, and everything it starts, as part of the request `requestId` (none: untraced). */ +export function runInSsrRequest(requestId: string | undefined, callback: () => R): R { + const context = storage(); + return context ? context.run(contextFor(requestId), callback) : callback(); +} + +/** + * Marks the rest of the current call, and everything it starts, as part of + * the request `requestId` (none: untraced). For a middleware that returns + * before the render runs, such as an h3 one, where there is no callback to + * wrap. Each request runs in its own async context, so this never reaches + * another request. + */ +export function enterSsrRequest(requestId: string | undefined): void { + storage()?.enterWith(contextFor(requestId)); +} + +const pinned = new WeakMap(); + +/** + * The traced request that `app` (one render's environment injector) runs + * under, while that request is still in flight. The context is read once, + * when the app first asks, and kept for it, so a call that runs where the + * async context was not carried along (a zone.js microtask, for one) still + * finds its own request and never another one. Undefined when no tracer saw + * the request or it was not traced. + */ +export function currentSsrRequest(app?: object): string | undefined { + let context = app ? pinned.get(app) : undefined; + if (!context) { + context = ssrRegistry().context?.getStore(); + if (context && app) pinned.set(app, context); + } + return activeSsrRequest(context?.requestId); +} diff --git a/packages/devtools/src/ssr-fetch.ts b/packages/devtools/src/ssr-fetch.ts index cf5ec89d..c722f9d6 100644 --- a/packages/devtools/src/ssr-fetch.ts +++ b/packages/devtools/src/ssr-fetch.ts @@ -1,4 +1,5 @@ import { SSR_REQUEST_HEADER, ssrRegistry, touchSsrRequest } from './ssr-registry.ts'; +import { runInSsrRequest } from './ssr-context.ts'; import { OVERRIDE_HEADER, SNIFF_BYTES, @@ -143,7 +144,8 @@ export function withPangularSsr headers.delete(SSR_REQUEST_HEADER)) : request; - return handler(clean, ...rest); + // Untraced, so its server calls never link to a render running alongside. + return runInSsrRequest(undefined, () => handler(clean, ...rest)); } const trace = beginSsrTrace(url)!; const finish = traceFinisher(trace, request.method, url); @@ -178,7 +180,7 @@ export function withPangularSsr handler(tagged, ...rest)); } catch (error) { finish.failed(); throw error; diff --git a/packages/devtools/src/ssr-middleware.ts b/packages/devtools/src/ssr-middleware.ts index 93aa098b..74cd282a 100644 --- a/packages/devtools/src/ssr-middleware.ts +++ b/packages/devtools/src/ssr-middleware.ts @@ -2,6 +2,7 @@ import type { IncomingMessage, OutgoingHttpHeaders, ServerResponse } from 'node: import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { SSR_REQUEST_HEADER, ssrRegistry, touchSsrRequest } from './ssr-registry.ts'; +import { runInSsrRequest } from './ssr-context.ts'; import { OVERRIDE_HEADER, SNIFF_BYTES, @@ -85,7 +86,8 @@ export function createSsrMiddleware(options: SsrMiddlewareOptions = {}) { const url = req.url ?? '/'; if (!wantsHtml(req.method, url, req.headers.accept, skip)) { delete req.headers[SSR_REQUEST_HEADER]; - return next(); + // Untraced, so its server calls never link to a render running alongside. + return runInSsrRequest(undefined, () => next()); } const trace = beginSsrTrace(url)!; const { id } = trace; @@ -260,6 +262,7 @@ export function createSsrMiddleware(options: SsrMiddlewareOptions = {}) { } const edits = stateEditsFor(url); if (edits.length) bufferForEdits(edits); - next(); + // A render without Angular's `REQUEST` finds its request id through this context. + runInSsrRequest(id, () => next()); }; } diff --git a/packages/devtools/src/ssr-registry.ts b/packages/devtools/src/ssr-registry.ts index 5d469b5b..305fbf1e 100644 --- a/packages/devtools/src/ssr-registry.ts +++ b/packages/devtools/src/ssr-registry.ts @@ -1,3 +1,4 @@ +import type { SsrContextStorage } from './ssr-context.ts'; import type { SsrNavigation } from './ssr-navigation.ts'; import type { SsrOverride, SsrOverrideKind } from './ssr-overrides.ts'; @@ -72,8 +73,8 @@ export function touchSsrRequest(request: ActiveRequest | undefined, now = Date.n /** * Drops traces with no progress for longer than {@link ACTIVE_SSR_TTL_MS}: a * response that was dropped unread never ends its trace, and would otherwise - * stay in `active` for the server's life (and keep {@link soleActiveSsrRequest} - * from linking). A response that keeps streaming chunks stays. + * stay in `active` for the server's life. A response that keeps streaming + * chunks stays. */ export function sweepActiveSsrRequests(now = Date.now()): void { const { active } = ssrRegistry(); @@ -95,6 +96,8 @@ export interface SsrRegistry { record?: (request: SsrRequest) => void; /** Set by the devframe server while the http inspector and `actions.http` are on. */ overrides?: SsrOverride[]; + /** Which traced request the running code belongs to, shared by every copy of the package (`ssr-context.ts`). */ + context?: SsrContextStorage; } export function noteOverride(id: string, applied: AppliedOverride) { @@ -114,18 +117,6 @@ export function activeSsrRequest(id: string | null | undefined): string | undefi return id && ssrRegistry().active.has(id) ? id : undefined; } -/** - * The one request being traced, when exactly one is in flight. A render that - * gets no Angular `REQUEST` (Analog provides its own token) is linked through - * it, since with two renders in flight a call can't be told apart. - */ -export function soleActiveSsrRequest(): string | undefined { - sweepActiveSsrRequests(); - const { active } = ssrRegistry(); - if (active.size !== 1) return undefined; - return active.keys().next().value; -} - export function noteSsrFetch(id: string, durationMs: number) { const active = ssrRegistry().active.get(id); if (!active) return; diff --git a/packages/devtools/src/ssr.ts b/packages/devtools/src/ssr.ts index c2d31934..28e54509 100644 --- a/packages/devtools/src/ssr.ts +++ b/packages/devtools/src/ssr.ts @@ -6,6 +6,8 @@ import type { IncomingMessage, ServerResponse } from 'node:http'; import { withPangularSsr as traceWebHandler, type PangularSsrOptions } from './ssr-fetch.ts'; import { createSsrMiddleware } from './ssr-middleware.ts'; +import { enterSsrRequest } from './ssr-context.ts'; +import { SSR_REQUEST_HEADER, activeSsrRequest, ssrRegistry } from './ssr-registry.ts'; export type { PangularSsrOptions, WebRequestHandler } from './ssr-fetch.ts'; @@ -52,6 +54,13 @@ export function pangularSsrEventHandler(options: PangularSsrOptions = {}) { return (event: NodeEventLike): true | undefined => { const { req, res } = event.node; trace(req, res, () => {}); - return res.writableEnded ? true : undefined; + if (res.writableEnded) return true; + // The render runs after this handler returns, outside any callback the tracer could wrap, + // so the rest of this request is marked instead: traced with its id, or untraced. + if (ssrRegistry().record) { + const id = req.headers[SSR_REQUEST_HEADER]; + enterSsrRequest(activeSsrRequest(typeof id === 'string' ? id : undefined)); + } + return undefined; }; }