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; }; }