Skip to content
27 changes: 25 additions & 2 deletions app/src/__tests__/network-ssr-requests.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,11 +48,27 @@ describe('NetworkInspector SSR requests', () => {
fetches: 2,
fetchMs: 24,
headers: {},
navigations: [
{
url: '/examples/ssr',
outcome: 'succeeded',
durationMs: 20,
guards: { names: ['adminGuard'], passed: true, ms: 3 },
resolvers: { names: [], ms: 12 },
},
],
},
]);
inspector.serverCalls.set([
call({ id: 's1', requestId: 'r1' }),
call({ id: 's2', requestId: 'r1', method: 'POST', url: 'http://localhost/api/quote' }),
call({ id: 's1', requestId: 'r1', cacheStored: true }),
call({
id: 's2',
requestId: 'r1',
method: 'POST',
url: 'http://localhost/api/quote',
cacheStored: false,
cacheSkip: 'post',
}),
]);
inspector.pages.set([
{
Expand All @@ -75,6 +91,13 @@ describe('NetworkInspector SSR requests', () => {
expect(detail.textContent).toContain('Server calls (2)');
expect(detail.textContent).toContain('Fetched again in the browser (1)');
expect(inspector.refetched().map((c) => c.id)).toEqual(['b2']);
expect(detail.textContent).toContain('Router during the render');
expect(detail.textContent).toMatch(/Guards\s*3 ms/);
expect(detail.textContent).toMatch(/Resolvers\s*12 ms/);
expect(detail.querySelector('.tag.server')!.textContent).toBe('cached');
expect(detail.textContent).toContain(
'Why: POST requests are left out unless includePostRequests is set.',
);
expect(host.querySelector('tr.selected .tag.server')!.textContent).toBe('this page');

inspector.selectedRequestId.set('r2');
Expand Down
109 changes: 105 additions & 4 deletions app/src/pages/network-inspector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,12 @@ import type { DevframeRpcClient } from 'devframe/client';
import { hostPageId } from '../page-id';
import { rpcCall as call } from '../rpc';
import { actionAllowed, actionBlockedMessage, panelConfig } from '../devtools-config';
import { HTTP_RULE_STATUSES, isHttpRuleStatus } from '@pangular-inspector/devtools/config';
import {
CACHE_SKIP_TEXT,
HTTP_RULE_STATUSES,
isHttpRuleStatus,
type CacheSkip,
} from '@pangular-inspector/devtools/config';
import { LimitNote } from '../ui/limit-note';
import { Select, type SelectOption } from '../ui/select';

Expand Down Expand Up @@ -49,11 +54,23 @@ interface HttpCall {
rulePattern?: string;
pageUrl?: string;
requestId?: string;
cacheStored?: boolean;
cacheSkip?: CacheSkip;
at: number;
error?: string;
preview?: string;
}

interface SsrNavigation {
url: string;
finalUrl?: string;
outcome: string;
reason?: string;
durationMs?: number;
guards?: { names: string[]; passed?: boolean; ms?: number };
resolvers?: { names: string[]; ms?: number };
}

interface SsrRequest {
id: string;
method: string;
Expand All @@ -63,17 +80,20 @@ interface SsrRequest {
durationMs: number;
renderMs: number;
bytes: number;
renderMode: 'server' | 'prerender' | 'client' | 'unknown';
renderMode: 'server' | 'prerender' | 'client' | 'redirect' | 'not-rendered' | 'unknown';
fetches: number;
fetchMs: number;
headers: Record<string, string>;
navigations?: SsrNavigation[];
aborted?: boolean;
}

const RENDER_MODE_LABEL: Record<SsrRequest['renderMode'], string> = {
server: 'Server',
prerender: 'Prerender',
client: 'Client',
redirect: 'Redirect',
'not-rendered': 'Not rendered',
unknown: 'Unknown',
};

Expand Down Expand Up @@ -302,6 +322,47 @@ const HTTP_STATUS_OPTIONS: SelectOption[] = [
<dd>{{ requestPage()?.title || requestPage()?.url || 'not connected' }}</dd>
</div>
</dl>
@if (req.navigations?.length) {
<h3>Router during the render</h3>
@for (n of req.navigations; track $index) {
<dl class="stats nav-stats">
<div>
<dt>Navigation</dt>
<dd>
<code>{{ n.url }}</code>
@if (n.finalUrl) {
to <code>{{ n.finalUrl }}</code>
}
({{ n.outcome }})
</dd>
</div>
@if (n.guards) {
<div>
<dt>Guards</dt>
<dd [class.bad]="guardVerdict(n) === 'rejected'">
{{ msLabel(n.guards.ms)
}}{{ guardVerdict(n) ? ', ' + guardVerdict(n) : '' }}
</dd>
</div>
}
@if (n.resolvers) {
<div>
<dt>Resolvers</dt>
<dd>{{ msLabel(n.resolvers.ms) }}</dd>
</div>
}
@if (n.durationMs !== undefined) {
<div>
<dt>Navigation total</dt>
<dd>{{ n.durationMs }} ms</dd>
</div>
}
</dl>
@if (n.reason) {
<p class="muted small">{{ n.reason }}</p>
}
}
}
<h3>Server calls ({{ requestCalls().length }})</h3>
@for (c of requestCalls(); track c.id) {
<p class="request-call">
Expand All @@ -314,23 +375,33 @@ const HTTP_STATUS_OPTIONS: SelectOption[] = [
>{{ statusLabel(c) }}</span
>
<span class="muted">{{ c.durationMs }} ms</span>
@if (c.cacheStored) {
<span class="tag server">cached</span>
} @else if (c.cacheSkip) {
<span class="tag" [title]="skipText(c.cacheSkip)">not cached</span>
}
</p>
@if (!c.cacheStored && c.cacheSkip) {
<p class="muted small skip-reason">Not cached: {{ skipText(c.cacheSkip) }}.</p>
}
} @empty {
<p class="muted small">The render made no HttpClient calls.</p>
}
@if (refetched().length) {
<h3>Fetched again in the browser ({{ refetched().length }})</h3>
<p class="muted small">
These calls ran on the server and again after hydration instead of reading the
transfer cache. Non-GET requests, <code>transferCache: false</code> and requests
with auth headers are left out of the cache.
transfer cache.
</p>
@for (c of refetched(); track c.id) {
<p class="request-call">
<span class="method">{{ c.method }}</span>
<span class="preview-url" [title]="c.url">{{ c.url }}</span>
<span class="tag fault">cache miss</span>
</p>
@if (refetchReason(c); as why) {
<p class="muted small skip-reason">Why: {{ why }}.</p>
}
}
} @else if (requestPage() && requestCalls().length) {
<p class="muted small ok-note">No server call ran again in the browser.</p>
Expand Down Expand Up @@ -1297,6 +1368,12 @@ const HTTP_STATUS_OPTIONS: SelectOption[] = [
.request-call .preview-url {
flex: 1 1 auto;
}
.skip-reason {
margin: 0 0 8px;
}
.nav-stats {
margin-bottom: 8px;
}
.preview-rule {
margin-bottom: 8px;
color: var(--text-2);
Expand Down Expand Up @@ -1724,6 +1801,30 @@ export class NetworkInspector {
}
}

/** A guard that returns a UrlTree reports shouldActivate false, so a redirect is not a rejection. */
guardVerdict(nav: SsrNavigation): 'redirected' | 'rejected' | '' {
if (nav.guards?.passed !== false) return '';
return nav.outcome === 'redirected' ? 'redirected' : 'rejected';
}

msLabel(ms: number | undefined): string {
return ms === undefined ? 'n/a' : `${ms} ms`;
}

skipText(skip: CacheSkip): string {
return CACHE_SKIP_TEXT[skip] ?? '';
}

refetchReason(call: HttpCall): string {
const key = `${call.method} ${pathOf(call.url)}`;
const matches = this.requestCalls().filter((c) => `${c.method} ${pathOf(c.url)}` === key);
const first = matches[0];
// Several matching server calls with different outcomes can't be paired, so name no reason.
return first?.cacheSkip && matches.every((c) => c.cacheSkip === first.cacheSkip)
? this.skipText(first.cacheSkip)
: '';
}

renderModeLabel(mode: SsrRequest['renderMode']): string {
return RENDER_MODE_LABEL[mode] ?? 'Unknown';
}
Expand Down
8 changes: 4 additions & 4 deletions apps/docs/src/content/agents/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,10 +316,10 @@ Live counts, input and output appear when recording is on in the [Pipes inspecto

These tools read the SSR requests that `ssrMiddleware` traced in your server. See [SSR requests](../inspectors/ssr-http.md#ssr-requests). Reads: page.

| Tool | What it answers | Arguments |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| `list-ssr-requests` | Recent traced SSR requests: id, URL, status, render mode, render time, server calls, and whether a connected page loaded the response. | `limit` |
| `explain-ssr-request` | One request end to end: timings, kept response headers, each server call, then the browser page with its hydration result and the calls the browser made again. | `id`, `url` |
| Tool | What it answers | Arguments |
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------- |
| `list-ssr-requests` | Recent traced SSR requests: id, URL, status, render mode, render time, server calls, and whether a connected page loaded the response. | `limit` |
| `explain-ssr-request` | One request end to end: timings, kept response headers, the router's guard and resolver times, each server call with its transfer cache outcome, then the browser page with its hydration result and the calls the browser made again. | `id`, `url` |

`explain-ssr-request` picks the request by `id`, else the newest one for `url`, else the newest one.

Expand Down
2 changes: 1 addition & 1 deletion apps/docs/src/content/guides/ssr-http.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ app.use(devtools.ssrMiddleware);
export const reqHandler = createNodeRequestHandler(app);
```

`ssrMiddleware` is optional. It traces each server render for the **SSR requests** table and adds a `Server-Timing` header to the response. Put it before the Angular handler.
`ssrMiddleware` is optional. It traces each server render for the **SSR requests** table and adds a `Server-Timing` header to the response. Put it before the Angular handler. With `providePangularHttp()` in the app config, each request also shows the router's guard and resolver times.

This is adapted from the demo app's `src/server.ts`. It keeps the one-time code and the origin check on, which are the defaults. See [Angular CLI and Express](../getting-started/express.md) for every option.

Expand Down
Loading
Loading