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
10 changes: 3 additions & 7 deletions apps/docs/src/content/guides/analog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion apps/docs/src/content/inspectors/ssr-http.md
Original file line number Diff line number Diff line change
Expand Up @@ -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="<id>"`, `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

Expand Down
76 changes: 43 additions & 33 deletions packages/devtools/src/__tests__/ssr-fetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -188,12 +189,13 @@ describe('withPangularSsr', () => {
await chunkAt(1_000_000 + step, '<html>');
await chunkAt(1_000_000 + 2 * step, '<body>');
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 {
Expand All @@ -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'));
Expand Down Expand Up @@ -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<unknown>),
),
).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<unknown>),
),
).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'] });
Expand All @@ -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' });
});
});

Expand Down
190 changes: 190 additions & 0 deletions packages/devtools/src/__tests__/ssr-request-context.test.ts
Original file line number Diff line number Diff line change
@@ -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 = '<html><body><app-root ng-server-context="ssr-analog"></app-root></body></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<unknown>),
),
).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<void>((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<void>((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<Response>((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();
});
});
12 changes: 6 additions & 6 deletions packages/devtools/src/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
ApplicationRef,
DestroyRef,
EnvironmentInjector,
Injector,
PLATFORM_ID,
REQUEST,
TransferState,
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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 };
}
Expand Down
Loading
Loading