import { Component, DestroyRef, ElementRef, Injector, afterNextRender, computed, effect, inject, input, linkedSignal, signal, untracked, viewChild, } from '@angular/core'; import { JsonPipe } from '@angular/common'; 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 { CACHE_SKIP_TEXT, HTTP_RULE_STATUSES, isHttpRuleStatus, type CacheSkip, } from '@pangular-inspector/devtools/internal/config'; import { prettyJson } from '../format'; import { LimitNote } from '../ui/limit-note'; import { AppState } from '../ui/app-state'; import { LiveControls, LiveFeed, followNewest } from '../ui/live-feed'; import { SortHeader, sortRows, type SortState } from '../ui/sort-header'; import { RovingList } from '../ui/roving-list'; import { Select, type SelectOption } from '../ui/select'; import { VirtualWindow } from '../ui/virtual-window'; import { HttpHeadersView } from './http-headers'; import { NetworkWaterfall } from './network-waterfall'; import { SearchField } from '../ui/search-field'; import { CallCopy } from './network/call-copy'; import { CallOriginTag } from './network/call-origin'; import { HarExport } from './network/har-export'; import { HttpLint } from './network/http-lint'; import { PayloadInsights } from './network/payload-insights'; import { RuleMatchCount } from './network/rule-match-count'; import { RulePresets } from './network/rule-presets'; import { presetRules } from '@pangular-inspector/devtools/internal/http-data'; import { rememberSelection } from '../view-memory'; import { RedactionCount } from '../ui/redaction-count'; type HttpSide = 'client' | 'server'; interface HttpRule { id: string; pattern: string; method?: string; enabled: boolean; target: HttpSide | 'both'; status?: number; delayMs?: number; body?: string; chance?: number; } interface HttpCall { id: string; url: string; method: string; status: number; durationMs: number; side: HttpSide; cacheHit: boolean; faulted: boolean; mocked?: boolean; delayMs?: number; cancelled?: boolean; ruleId?: string; rulePattern?: string; pageUrl?: string; requestId?: string; /** The Angular API that likely started the call, from a stack heuristic. */ origin?: 'httpResource' | 'resource' | 'defer'; cacheStored?: boolean; cacheSkip?: CacheSkip; at: number; error?: string; preview?: string; requestHeaders?: Record; responseHeaders?: Record; } 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; url: string; status: number; at: number; durationMs: number; renderMs: number; bytes: number; renderMode: 'server' | 'prerender' | 'client' | 'redirect' | 'not-rendered' | 'unknown'; fetches: number; fetchMs: number; headers: Record; navigations?: SsrNavigation[]; overrides?: { id: string; kind: SsrOverrideKind; applied: boolean; note?: string }[]; compressedBeforeTrace?: boolean; aborted?: boolean; } type SsrOverrideKind = 'render-error' | 'client-render' | 'state-edit'; interface SsrOverride { id: string; kind: SsrOverrideKind; pattern: string; enabled: boolean; message?: string; key?: string; value?: string; } interface OverrideDraft { kind: SsrOverrideKind; pattern: string; message: string; key: string; value: string; } const EMPTY_OVERRIDE: OverrideDraft = { kind: 'render-error', pattern: '', message: '', key: '', value: '', }; const OVERRIDE_LABEL: Record = { 'render-error': 'Render error', 'client-render': 'Client render', 'state-edit': 'TransferState edit', }; const RENDER_MODE_LABEL: Record = { server: 'Server', prerender: 'Prerender', client: 'Client', redirect: 'Redirect', 'not-rendered': 'Not rendered', unknown: 'Unknown', }; interface PayloadEntry { key: string; http?: { url?: string; status?: number; statusText?: string; responseType?: string }; source?: 'http' | 'analog' | 'hydration'; fn?: { id: string; name?: string; file?: string }; size: number; value: unknown; } interface HydrationMismatch { component: string; /** `component` is a class name (`ProductCard`), not a node name (`app-card`). */ isClass?: boolean; expected?: string; actual?: string; } interface HydrationStats { enabled: boolean; hydratedComponents?: number; hydratedNodes?: number; componentsSkippedHydration?: number; deferBlocksWithIncrementalHydration?: number; nodes?: { hydrated: number; skipped: number; mismatched: number }; mismatches?: HydrationMismatch[]; skipHydrationHosts: string[]; warnings: string[]; warningsCaptured?: boolean; stableMs?: number; } interface HttpPayload { found: boolean; size: number; entries: PayloadEntry[]; error?: string; /** Set when the payload came from Refetch, not from the HTML the tab was served with. */ refetchedAt?: number; } interface HttpPage { pageId: string; url: string; initialUrl?: string; title: string; hydration: HydrationStats | null; calls: HttpCall[]; dropped?: number; ssrRequestId?: string; firstSeenAt?: number; reportedAt: number; } interface HttpState { serverCalls: HttpCall[]; serverDropped?: number; requests?: SsrRequest[]; pages: HttpPage[]; rules: HttpRule[]; ssrOverrides?: SsrOverride[]; } interface RuleDraft { pattern: string; method: string; target: HttpRule['target']; status: string; delayMs: string; body: string; chance: string; } const EMPTY_DRAFT: RuleDraft = { pattern: '', method: '', target: 'both', status: '', delayMs: '', body: '', chance: '', }; /** A new fault rule id, unique enough for one rule list. */ function newRuleId(): string { return `r${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; } /** The chance field as a whole percent, null when it is not one from 1 to 99, undefined when empty. */ function chanceOf(text: string): number | null | undefined { if (!text.trim()) return undefined; const chance = Number(text); return Number.isInteger(chance) && chance >= 1 && chance <= 99 ? chance : null; } function pathOf(url: string): string { try { const parsed = new URL(url, 'http://x'); return parsed.pathname + parsed.search; } catch { return url; } } const REDACTED_IN_URL = /\[redacted\]|%5Bredacted%5D/gi; /** * A rule pattern for a recorded call: its path and query, without the origin, so it * matches the relative client URL and the absolute SSR URL. A relative URL stays as * recorded, since `api/x` never contains `/api/x`. A redacted value becomes `*`, * because the real request carries the secret. */ function mockPattern(url: string): string { const absolute = /^([a-z][a-z\d+.-]*:|\/\/)/i.test(url); return (absolute ? pathOf(url) : url).replace(REDACTED_IN_URL, '*'); } /** The preview as a mock body, when it is complete JSON (the rule form takes JSON only). */ function jsonBody(preview: string): string | null { const pretty = prettyJson(preview); if (pretty !== null) return pretty; try { JSON.parse(preview); return preview.trim(); } catch { return null; } } const STANDARD_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']; /** * The path of a page, as an SSR override pattern. The query is left out because the panel only * has a redacted copy of it, which would not match the real request. */ function pagePath(url: string): string { try { return new URL(url, 'http://x').pathname; } catch { return ''; } } /** * The page path as a prefilled override pattern. Patterns match anywhere in the URL, so the site * root (`/`) would match every page; it is left empty for the user to fill in. */ function pagePattern(url: string): string { const path = pagePath(url); return path === '/' ? '' : path; } /** A part the server masked in a payload key, plain or URL-encoded. */ const REDACTED_PART = /\[redacted\]|%5Bredacted%5D/i; /** The longest TransferState key `set-ssr-overrides` keeps (see `sanitizeSsrOverrides`). */ const MAX_OVERRIDE_KEY = 200; const MAX_RULES = 50; const MAX_PATTERN = 500; const MAX_BODY = 100_000; const HTTP_STATUS_OPTIONS: SelectOption[] = [ { value: '', label: 'None' }, ...HTTP_RULE_STATUSES.map(([status, reason]) => ({ value: String(status), label: `${status} ${reason}`, })), ]; @Component({ selector: 'app-network-inspector', imports: [ AppState, LiveControls, SortHeader, SearchField, CallCopy, CallOriginTag, HarExport, JsonPipe, HttpHeadersView, HttpLint, LimitNote, NetworkWaterfall, PayloadInsights, RedactionCount, RovingList, RuleMatchCount, RulePresets, Select, ], template: `

HttpClient calls made while rendering on the server and in the browser, from the live page and the devtools server, with hydration stats, the TransferState payload and fault injection.

@if (loading()) { Loading HTTP calls… } @else if (failed()) { Could not reach the devtools server.

Check that the app's dev server is running with the devtools plugin.

}
Page

{{ message() }}

SSR requests ({{ requests().length }})

@if (requests().length) {
@for (req of requests(); track req.id) { }
Request Status Render mode Render Server calls Notes
{{ req.status }} {{ renderModeLabel(req.renderMode) }} {{ req.renderMs }} ms {{ req.fetches }} ({{ req.fetchMs }} ms)
@if (req.id === selected()?.ssrRequestId) { this page } @if (req.overrides?.length) { overridden } @if (req.aborted) { aborted }
@if (selectedRequest(); as req) {

{{ req.method }} {{ req.url }}

Request id
{{ req.id }}
Render
{{ req.renderMs }} ms
Total
{{ req.durationMs }} ms
Sent
{{ req.bytes }} B
Browser page
{{ requestPage()?.title || requestPage()?.url || 'not connected' }}
@if (compressedWith(req); as encoding) {

The body was already compressed ({{ encoding }}) when ssrMiddleware saw it, so the render mode can't be read and TransferState edits can't apply. Mount ssrMiddleware after compression().

} @if (req.overrides?.length) {

Overrides from the panel

This response was changed on purpose, so it is not what the app sends on its own.

@for (o of req.overrides; track o.id + o.kind) {

{{ overrideLabel(o.kind) }} {{ o.applied ? 'applied' : 'not applied' }} @if (o.note) { {{ o.note }} }

} } @if (req.navigations?.length) {

Router during the render

@for (n of req.navigations; track $index) { @if (n.reason) {

{{ n.reason }}

} } }

Server calls ({{ requestCalls().length }})

@for (c of requestCalls(); track c.id) {

{{ c.method }} {{ c.url }} {{ statusLabel(c) }} {{ c.durationMs }} ms @if (c.cacheStored) { cached } @else if (c.cacheSkip) { not cached }

@if (!c.cacheStored && c.cacheSkip) {

Not cached: {{ skipText(c.cacheSkip) }}.

} } @empty {

The render made no HttpClient calls.

} @if (refetched().length) {

Fetched again in the browser ({{ refetched().length }})

These calls ran on the server and again after hydration instead of reading the transfer cache.

@for (c of refetched(); track c.id) {

{{ c.method }} {{ c.url }} cache miss

@if (refetchReason(c); as why) {

Why: {{ why }}.

} } } @else if (requestPage() && requestCalls().length) {

No server call ran again in the browser.

}
} } @else {

No SSR requests traced.

Add app.use(devtools.ssrMiddleware) before the Angular handler in server.ts, or wrap a web-standard handler with withPangularSsr(), then load a server rendered page.

}

HTTP timeline ({{ timeline().length }})

{{ countText() }} {{ filterStatus() }}
@if (visibleCalls().length && timelineView() === 'waterfall') { } @else if (visibleCalls().length) { @if (callWindow.enabled()) {

Rows render as you scroll, so the browser's find only sees the ones near view; the search field searches them all.

}
@if (callWindow.padTop()) { } @for (shown of shownCalls(); track shown.entry.side + shown.entry.id) { @let entry = shown.entry; } @if (callWindow.padBottom()) { }
Side Method URL Page Status Time Notes
{{ entry.side === 'server' ? 'SSR' : 'Client' }} {{ entry.method }} {{ entry.pageUrl ?? '' }} {{ statusLabel(entry) }} {{ entry.durationMs }} ms
@if (entry.origin) { } @if (entry.cacheHit) { transfer cache } @if (entry.delayMs) { delayed {{ entry.delayMs }} ms } @if (entry.mocked) { mocked } @if (entry.faulted) { faulted } @if (rulePatternOf(entry); as pattern) { rule {{ pattern }} }
} @else if (timeline().length) {

No requests match.

@if (callQuery().trim()) { Nothing matches “{{ callQuery().trim() }}” with these filters. } @else { None of the {{ timeline().length }} requests fit these filters. }

} @else {

No requests yet.

Add withPangular() to provideHttpClient, then load a page of the app.

} @if (selectedCall(); as detail) {

Response preview

@if (!detail.error && detail.preview) { }

{{ detail.method }} {{ statusLabel(detail) }} {{ detail.url }}

@if (rulePatternOf(detail); as pattern) {

Matched rule {{ pattern }}

}
{{ detail.error ?? previewText(detail) ?? '(no body)' }}
}

HTTP lint

Fault injection

@if (!canWrite()) {

{{ writesOff }}

}
Method
Apply on
Status

{{ bodyError() }}

@if (draftMocksOnServer()) {

SSR mocks are not transferred to the client: the browser requests the URL again and gets the real response unless the rule also applies on the client.

}

{{ draftHint() }}

Rules ({{ rules().length }})

    @for (rule of rules(); track rule.id) {
  • {{ targetLabel(rule.target) }} @if (rule.status === undefined) { passthrough } @else if (rule.status === 0) { network error } @else { {{ rule.status }} } @if (rule.delayMs) { {{ rule.delayMs }} ms } @if (rule.body) { mock body } @if (rule.chance) { {{ rule.chance }}% of requests } @if (mocksOnServer(rule)) {

    The SSR mock is not written to TransferState, so the browser requests this URL again.

    }
  • } @empty {
  • No rules. SSR rules apply on the next page load.
  • }

SSR overrides

Development only. These change what the server sends for matching pages, from the next full page load, and the request is marked overridden.

Override
@switch (overrideDraft().kind) { @case ('render-error') {

Throws while the server renders, so it needs providePangularHttp() and a page the engine renders. Prerendered files served as static files skip it.

} @case ('state-edit') {

{{ overrideValueError() }}

@if (overrideFromPayload(); as from) {

Filled in from the TransferState payload. The value starts empty because the panel only shows a redacted copy, so type the JSON the server should send. @if (from === 'http') { For a cached HTTP response this is the whole cache record, not only the response body. }

} } @case ('client-render') {

Serves index.csr.html instead of rendering. Needs browserDistFolder in initPangularHub().

} }

Overrides ({{ overrides().length }})

    @for (o of overrides(); track o.id) {
  • @if (o.message) { {{ o.message }} } @if (o.key) { {{ o.value === undefined ? 'remove' : 'set' }} {{ o.key }} }
  • } @empty {
  • No overrides.
  • }

Hydration

@if (selected()?.hydration; as h) {
Enabled
{{ h.enabled ? 'yes' : 'no (client render only)' }}
Hydrated components
{{ h.hydratedComponents ?? 'n/a' }}
Hydrated nodes
{{ h.hydratedNodes ?? 'n/a' }}
Skipped components
{{ h.componentsSkippedHydration ?? 'n/a' }}
App stable after
{{ h.stableMs === undefined ? 'n/a' : h.stableMs + ' ms' }}
Incremental defer blocks
{{ h.deferBlocksWithIncrementalHydration ?? 'n/a' }}
@if (h.nodes; as n) {
DOM nodes hydrated
{{ n.hydrated }}
DOM nodes skipped
{{ n.skipped }}
Mismatched components
{{ n.mismatched }}
}
@if (h.mismatches?.length) {

Mismatches ({{ h.mismatches!.length }})

    @for (m of h.mismatches; track $index) {
  • {{ m.isClass ? m.component : '<' + m.component + '>' }} @if (m.expected) { Expected {{ m.expected }} } @if (m.actual) { Actual {{ m.actual }} }
  • }
} @if (h.skipHydrationHosts.length) {

ngSkipHydration hosts

    @for (host of h.skipHydrationHosts; track $index) {
  • {{ host }}
  • }
} @if (h.warningsCaptured === false) {

Warnings

Not captured: add providePangularHttp() to the app providers.

} @else {

Warnings ({{ h.warnings.length }})

@for (warning of h.warnings; track $index) {
{{ warning }}
} @empty {

No hydration warnings.

} } } @else {

No hydration data for this page.

Open a server rendered page of the app to see its stats.

}

TransferState payload

@if (selected(); as page) {
Renders this URL again on the server, with this tab's cookies, and shows the payload it sends now. The tab does not reload.
} @if (selectedPayload(); as payload) { @if (!payload.found) {

No TransferState script: this page was not server rendered.

} @else {

{{ payload.entries.length }} entr{{ payload.entries.length === 1 ? 'y' : 'ies' }}, {{ payload.size }} bytes @if (payload.refetchedAt) { Refetched }

@if (payload.error) { }
@for (entry of payload.entries; track entry.key) {
@if (payloadLabel(entry); as label) { {{ label }} } @if (entry.http; as http) { HTTP {{ http.status }} {{ http.url ?? entry.key }} } @else { {{ entry.key }} } {{ entry.size }} B
Key {{ entry.key }}
@if (canEditEntry(entry)) { }
{{
                    entry.value | json
                  }}
}
} } @else {

Open a page of the app to see its payload.

}
`, styles: ` @use 'mixins' as m; .page-intro { margin: 0 0 12px; color: var(--text-2); line-height: 1.5; } .toolbar[hidden], .grid[hidden] { display: none; } :host { display: block; color: var(--text); font-size: 13px; } .state { display: flex; align-items: center; gap: 8px; margin: 0 0 16px; padding: 10px 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); color: var(--text-2); } .state.error { flex-direction: column; align-items: flex-start; gap: 4px; @include m.tint(danger); } .state.error strong { font-weight: 600; } .state.error span { color: var(--text); } .state.error button { margin-top: 4px; } .spinner { width: 14px; height: 14px; border: 2px solid var(--border-strong); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; } .toolbar { position: sticky; top: 0; z-index: 2; display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; margin: 0 0 16px; padding: 8px 12px; border: 1px solid var(--border); border-radius: var(--radius); background: color-mix(in srgb, var(--surface) 85%, transparent); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); } .toolbar .page-picker { display: flex; flex: 0 1 auto; align-items: center; gap: 8px; min-width: 0; } .page-picker > span { @include m.label; } .toolbar app-select { width: 280px; max-width: 420px; } .field-group { display: grid; gap: 6px; min-width: 0; color: var(--text-2); font-size: 12px; font-weight: 500; } .field-group app-select { width: 100%; } .message { flex: 1 1 200px; min-width: 0; margin: 0; color: var(--text-2); font-size: 12px; text-align: right; } .message:empty { display: none; } .grid { display: grid; gap: 16px; grid-template-columns: repeat(2, minmax(0, 1fr)); } .panel { @include m.panel; @include m.enter; min-width: 0; padding: 16px; } .wide { grid-column: 1 / -1; } h2 { @include m.label; margin: 0 0 16px; } h3 { margin: 16px 0 8px; color: var(--text-strong); font-size: 12px; font-weight: 600; } p { margin: 0; } label { display: grid; gap: 6px; color: var(--text-2); font-size: 12px; font-weight: 500; } label.inline { display: flex; align-items: center; gap: 8px; min-width: 0; color: var(--text); cursor: pointer; } .hint { color: var(--text-3); font-weight: 400; } input, select, textarea, button { font: inherit; color: var(--text); } input:not([type='checkbox']), select, textarea { width: 100%; min-width: 0; padding: 0 10px; border: 1px solid var(--control-border); border-radius: var(--radius-sm); background-color: var(--bg); transition: border-color 0.2s var(--ease), box-shadow 0.2s var(--ease); } input:not([type='checkbox']), select { height: 34px; } select { padding-right: 36px; } textarea { padding: 8px 10px; font-family: var(--font-mono); font-size: 12px; } input::placeholder, textarea::placeholder { color: var(--text-3); } input:not([type='checkbox']):hover, select:hover, textarea:hover { border-color: color-mix(in srgb, var(--control-border) 60%, var(--text-3)); } input:not([type='checkbox']):focus-visible, select:focus-visible, textarea:focus-visible { @include m.field-focus; } textarea[aria-invalid='true'] { border-color: color-mix(in srgb, var(--danger) 60%, transparent); } input[type='checkbox'] { flex: none; width: 16px; height: 16px; margin: 0; accent-color: var(--accent); cursor: pointer; } button { display: inline-flex; align-items: center; justify-content: center; height: 34px; padding: 0 14px; border: 1px solid var(--control-border); border-radius: var(--radius-sm); background: var(--surface-2); font-weight: 500; white-space: nowrap; cursor: pointer; transition: background-color 0.2s var(--ease), border-color 0.2s var(--ease), color 0.2s var(--ease), transform 0.1s var(--ease); } button:hover:not(:disabled) { border-color: var(--accent-line); background: var(--surface-3); } button:active:not(:disabled) { transform: translateY(1px); } button[type='submit'] { border-color: var(--accent); background: var(--accent); color: var(--accent-ink); font-weight: 600; } button[type='submit']:hover:not(:disabled) { border-color: var(--accent-hover); background: var(--accent-hover); } button:disabled { opacity: 0.5; cursor: not-allowed; } button.ghost { height: 28px; padding: 0 10px; border-color: transparent; background: none; color: var(--text-2); font-size: 12px; } button.ghost:hover:not(:disabled) { border-color: var(--border-strong); background: var(--surface-2); color: var(--text); } button.link { display: inline; height: auto; padding: 0; border: none; border-radius: 4px; background: none; color: var(--text); text-align: left; } button.link:hover:not(:disabled) { border: none; background: none; color: var(--accent); } button.link:active:not(:disabled) { transform: none; } :focus-visible { @include m.focus-ring; } .muted { color: var(--text-2); } .small { font-size: 12px; } .bad { color: var(--danger); } .empty { display: grid; gap: 4px; padding: 24px 16px; border: 1px dashed var(--border-strong); border-radius: var(--radius-sm); color: var(--text); text-align: center; line-height: 1.5; } .empty-actions { display: flex; justify-content: center; margin-top: 8px; } .filters { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; margin: 0 0 12px; } .live-row { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; margin: -4px 0 12px; } .search { position: relative; flex: 1 1 220px; min-width: 0; } .search-icon { position: absolute; top: 50%; left: 12px; width: 14px; height: 14px; transform: translateY(-50%); fill: none; stroke: var(--text-3); stroke-width: 2.2; stroke-linecap: round; pointer-events: none; } .search:focus-within .search-icon { stroke: var(--accent); } .filters input[type='search'] { height: var(--control-h); padding-left: 34px; } .filters .side-filter { flex: 0 0 140px; } .failed-toggle { flex: none; height: var(--control-h); font-size: 13px; font-weight: 500; } .total { flex: none; color: var(--text-2); font-size: 12px; white-space: nowrap; font-variant-numeric: tabular-nums; } /* "0 of 0" says nothing before the first call; the empty state below explains it. */ .filters.no-calls .total { display: none; } .table-wrap { max-height: min(420px, 60vh); overflow: auto; margin: 0 -16px; padding: 0 16px; } .table-wrap :focus-visible { @include m.focus-ring(-2px); } thead th { position: sticky; top: 0; z-index: 1; background: var(--surface); } table { width: 100%; min-width: 560px; border-collapse: collapse; font-variant-numeric: tabular-nums; } th, td { height: 36px; padding: 6px 12px; border-bottom: 1px solid var(--border); text-align: left; vertical-align: middle; white-space: nowrap; } th:first-child, td:first-child { padding-left: 8px; } th { @include m.label; height: 32px; border-bottom-color: var(--border-strong); } .num { text-align: right; } td.num { color: var(--text-2); } tbody tr { cursor: pointer; } tbody tr.spacer td { padding: 0; border: 0; } .window-note { margin: 0 0 6px; } tbody td { transition: background-color 0.15s var(--ease); } tbody tr:hover td { background: var(--surface-2); } tr.selected td, tr.selected:hover td { background: var(--accent-soft); } tr.selected td:first-child { box-shadow: inset 2px 0 0 var(--accent); } .method { font-family: var(--font-mono); font-size: 12px; font-weight: 600; color: var(--text-strong); } td.url { width: 100%; max-width: 0; font-family: var(--font-mono); font-size: 12px; } td.url button.link { display: block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } tr.selected td.url button.link { color: var(--text-strong); } td.page-url { max-width: 160px; overflow: hidden; color: var(--text-2); font-family: var(--font-mono); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } .rule-note { grid-column: 1 / -1; margin: 0; padding-left: 24px; color: var(--text-2); font-size: 12px; } .mismatches li { display: grid; gap: 4px; padding: 8px 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-2); } .mismatch-row { display: flex; gap: 8px; min-width: 0; font-size: 12px; } .mismatch-row code { min-width: 0; overflow-wrap: anywhere; } .mismatch-label { flex: none; width: 64px; color: var(--text-2); } .status { font-family: var(--font-mono); font-size: 12px; font-weight: 600; color: var(--text-2); } .status.ok { color: var(--ok); } .status.bad { color: var(--danger); } .dot { display: inline-block; width: 6px; height: 6px; margin-right: 6px; border-radius: 50%; background: currentColor; vertical-align: middle; } .notes { display: flex; gap: 4px; } .tag { display: inline-flex; align-items: center; height: 20px; padding: 0 8px; border: 1px solid var(--border-strong); border-radius: 99px; background: var(--surface-3); color: var(--text-2); font-size: 11px; font-weight: 600; white-space: nowrap; } .tag.server { @include m.tint(accent); } .tag.fault { @include m.tint(danger); } .tag.mock { @include m.tint(accent); } .tag.rule-ref { gap: 4px; max-width: 220px; } .tag.rule-ref code { @include m.truncate; font-family: var(--font-mono); } .tag.on { @include m.tint(ok); } .tag.off-tag { @include m.tint(warn); } pre { margin: 0; padding: 10px 12px; max-height: 240px; overflow: auto; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg); color: var(--text-2); font: 12px/1.5 var(--font-mono); white-space: pre-wrap; overflow-wrap: anywhere; } pre + pre { margin-top: 8px; } pre.warn { border-color: color-mix(in srgb, var(--warn) 30%, transparent); color: var(--warn); } pre.error { border-color: color-mix(in srgb, var(--danger) 30%, transparent); color: var(--danger); } .view-toggle { display: inline-flex; border: 1px solid var(--control-border); border-radius: var(--radius-sm); overflow: hidden; } .view-toggle button { height: calc(var(--control-h) - 2px); padding: 0 10px; border: 0; border-radius: 0; background: transparent; color: var(--text-2); } .view-toggle button + button { border-left: 1px solid var(--border-strong); } .view-toggle button[aria-pressed='true'] { background: var(--accent-soft); color: var(--text-strong); } .view-toggle button:focus-visible { @include m.focus-ring(-2px); } .preview { @include m.enter(0.2s); margin-top: 16px; } .preview-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 4px 8px; margin-bottom: 4px; } .preview-actions { display: flex; flex-wrap: wrap; gap: 4px; } pre.preview-body { max-height: 320px; } .preview-head h3 { margin: 0; } .preview-meta { display: flex; align-items: baseline; gap: 8px; min-width: 0; margin-bottom: 8px; } .request-call { display: flex; align-items: baseline; gap: 8px; min-width: 0; margin: 0 0 4px; } .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); font-size: 12px; } .preview-url { min-width: 0; overflow: hidden; color: var(--text-2); font-family: var(--font-mono); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } .rule-form { display: grid; gap: 12px; } .row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; } @media (max-width: 420px) { .row { grid-template-columns: minmax(0, 1fr); } } .field-error { margin-top: -4px; color: var(--danger); font-size: 12px; } .field-error:empty { display: none; } .form-actions { display: flex; } .rules-heading { margin-top: 24px; } .rules, .plain { display: grid; gap: 8px; margin: 0; padding: 0; list-style: none; } .rules li { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 4px 12px; padding: 8px 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface-2); transition: border-color 0.2s var(--ease), opacity 0.2s var(--ease); } .rules li:hover { border-color: var(--border-strong); } .rules li.off .rule-pattern { color: var(--text-2); text-decoration: line-through; } .rules li.off .rule-method, .rules li.off .rule-meta, .rules li.off .rule-meta .status { color: var(--text-3); } .rules li.empty-rule { display: block; padding: 16px 12px; border-style: dashed; border-color: var(--border-strong); background: none; color: var(--text-2); text-align: center; } .rule-pattern { min-width: 0; overflow: hidden; color: var(--text-strong); text-overflow: ellipsis; white-space: nowrap; } .rule-method { color: var(--accent); font-weight: 600; } .rule-meta { display: flex; flex-wrap: wrap; grid-column: 1 / -1; gap: 4px 8px; padding-left: 24px; color: var(--text-2); font-size: 12px; font-variant-numeric: tabular-nums; } .rule-meta > span + span::before { content: '·'; margin-right: 8px; color: var(--text-3); } .rule-meta .status { font-size: 11px; } .rules button.remove { padding: 2px 4px; color: var(--text-2); font-size: 12px; } .rules button.remove:hover:not(:disabled) { color: var(--danger); } .plain li { overflow-wrap: anywhere; } code { font-family: var(--font-mono); font-size: 12px; } .stats { display: grid; margin: 0; } .stats > div { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 36px; padding: 6px 0; border-bottom: 1px solid var(--border); } .stats > div:last-child { border-bottom: none; } dt { color: var(--text-2); } dd { margin: 0; color: var(--text-strong); font-weight: 600; font-variant-numeric: tabular-nums; text-align: right; } .ok-note { color: var(--text-2); } .summary-line { margin-bottom: 8px; font-variant-numeric: tabular-nums; } .payload-tools { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; margin-bottom: 12px; } .payload-tools .muted { flex: 1 1 240px; min-width: 0; } .entries { border-top: 1px solid var(--border); } details { border-bottom: 1px solid var(--border); } details pre { margin: 0 0 12px; } summary { display: flex; align-items: center; gap: 8px; min-height: 36px; padding: 6px 4px; border-radius: 4px; list-style: none; cursor: pointer; transition: background-color 0.15s var(--ease); } summary::-webkit-details-marker { display: none; } summary:hover { background: var(--surface-2); } .chevron { flex: none; width: 6px; height: 6px; margin: 0 4px; border-right: 1.5px solid var(--text-3); border-bottom: 1.5px solid var(--text-3); transform: rotate(-45deg); transition: transform 0.15s var(--ease); } details[open] .chevron { transform: rotate(45deg); } .entry-key { flex: 1 1 auto; min-width: 0; overflow: hidden; font-family: var(--font-mono); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } summary:hover .entry-key { color: var(--accent); } .entry-key-row { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; min-width: 0; margin: 0 0 8px; padding: 0 4px; } .entry-key-label { @include m.label; flex: none; } .entry-key-text { flex: 1 1 200px; min-width: 0; color: var(--text-strong); overflow-wrap: anywhere; user-select: text; } .entry-actions { display: flex; flex-wrap: wrap; gap: 4px; } .entry-size { flex: none; color: var(--text-2); font-size: 12px; font-variant-numeric: tabular-nums; } @media (max-width: 760px) { .grid { grid-template-columns: minmax(0, 1fr); } .toolbar .page-picker { flex: 1 1 100%; } .toolbar app-select { flex: 1 1 auto; width: auto; max-width: none; } .message { text-align: left; } } `, }) export class NetworkInspector { readonly rpc = input(null); readonly canWrite = computed(() => actionAllowed(this.rpc(), 'http')); protected readonly writesOff = actionBlockedMessage('http'); readonly loading = signal(false); readonly failed = signal(false); readonly serverCalls = signal([]); readonly pages = signal([]); readonly payloads = signal>({}); readonly serverDropped = signal(0); readonly requests = signal([]); readonly rules = signal([]); /** True while an added rule or override is being saved, so Add can't send it twice. */ readonly addingRule = signal(false); readonly addingOverride = signal(false); private ruleSaves = Promise.resolve(true); private overrideSaves = Promise.resolve(true); readonly overrides = signal([]); readonly overrideDraft = signal({ ...EMPTY_OVERRIDE }); /** Set when the draft came from a TransferState entry, so the form can explain the empty value. */ readonly overrideFromPayload = signal<'http' | 'other' | null>(null); readonly overrideKindOptions: SelectOption[] = [ { value: 'render-error', label: 'Render error' }, { value: 'client-render', label: 'Force Client render' }, { value: 'state-edit', label: 'Edit TransferState entry' }, ]; readonly overrideValueError = computed(() => { const value = this.overrideDraft().value.trim(); if (!value) return ''; try { JSON.parse(value); return ''; } catch { return 'The value must be valid JSON.'; } }); readonly overrideReady = computed(() => { const d = this.overrideDraft(); if (!d.pattern.trim() || this.overrides().length >= 20) return false; if (d.kind === 'state-edit') return !!d.key.trim() && !this.overrideValueError(); return true; }); private readonly hostPageId = hostPageId(); readonly selectedPageId = linkedSignal({ source: () => this.pages(), computation: (pages, previous) => previous?.value && pages.some((p) => p.pageId === previous.value) ? previous.value : (pages.find((p) => p.pageId === this.hostPageId)?.pageId ?? pages[0]?.pageId ?? null), }); readonly selectedCallId = signal(null); readonly draft = signal({ ...EMPTY_DRAFT }); readonly message = signal(''); readonly refetching = signal(false); readonly bodyPlaceholder = '{ "error": "Service unavailable" }'; readonly statusOptions = HTTP_STATUS_OPTIONS; private unsubscribe: (() => void)[] = []; private readonly destroyRef = inject(DestroyRef); private readonly injector = inject(Injector); private readonly host = inject>(ElementRef); readonly selected = computed( () => this.pages().find((p) => p.pageId === this.selectedPageId()) ?? null, ); private readonly pageRequestId = computed(() => this.selected()?.ssrRequestId ?? null); readonly selectedRequestId = linkedSignal(() => this.pageRequestId()); readonly selectedPayload = computed(() => { const pageId = this.selectedPageId(); return pageId ? (this.payloads()[pageId] ?? null) : null; }); readonly maxCalls = computed(() => panelConfig(this.rpc()).limits.httpCalls); readonly droppedCalls = computed(() => (this.selected()?.dropped ?? 0) + this.serverDropped()); readonly pageServerCalls = computed(() => { const initialUrl = this.selected()?.initialUrl; if (!initialUrl) return this.serverCalls(); return this.serverCalls().filter((c) => !c.pageUrl || c.pageUrl === initialUrl); }); /** The server calls of the SSR request that served the selected page, for the HTTP lint. */ readonly renderServerCalls = computed(() => { const id = this.pageRequestId(); return id ? this.serverCalls().filter((c) => c.requestId === id) : []; }); /** Every call of the selected page as it arrives, newest first. */ private readonly liveTimeline = computed(() => [...this.pageServerCalls(), ...(this.selected()?.calls ?? [])].sort((a, b) => b.at - a.at), ); /** Pause holds the timeline as it is while the app keeps calling. */ readonly callFeed = new LiveFeed({ name: 'network', source: this.liveTimeline, key: (c) => `${c.side}:${c.id}`, scope: this.selectedPageId, }); readonly timeline = computed(() => [...this.callFeed.items()]); /** The column the call table is sorted by; `null` keeps newest first. */ readonly callSort = signal(null); readonly callQuery = signal(''); /** The timeline as a table of every call, or a waterfall of the client calls by navigation. */ readonly timelineView = signal<'table' | 'waterfall'>('table'); readonly waterfallCalls = computed(() => this.visibleCalls().filter((c) => c.side === 'client')); readonly sideFilter = signal('all'); readonly failedOnly = signal(false); readonly sideOptions: SelectOption[] = [ { value: 'all', label: 'All sides' }, { value: 'server', label: 'SSR' }, { value: 'client', label: 'Client' }, ]; /** A long call list renders only the rows near view in its scroll box. */ readonly callWindow = new VirtualWindow({ count: computed(() => this.visibleCalls().length), estimate: 37, }); readonly shownCalls = computed(() => { const { start, end } = this.callWindow.range(); return this.visibleCalls() .slice(start, end) .map((entry, i) => ({ entry, index: start + i })); }); private readonly callsBody = viewChild>('callsBody'); readonly visibleCalls = computed(() => { const query = this.callQuery().trim().toLowerCase(); const side = this.sideFilter() ?? 'all'; const failedOnly = this.failedOnly(); const matching = this.timeline().filter( (c) => (side === 'all' || c.side === side) && (!failedOnly || this.isFailed(c)) && (!query || `${c.method} ${c.url}`.toLowerCase().includes(query) || `${c.method} ${pathOf(c.url)}`.toLowerCase().includes(query)), ); return sortRows(matching, this.callSort(), { method: (c) => c.method, url: (c) => c.url, status: (c) => (c.cancelled || !c.status ? undefined : c.status), time: (c) => c.durationMs, }) as HttpCall[]; }); /** * The count, announced only when a filter changes. The visible count also changes with every * call the app makes, which would make a screen reader talk while the user uses the app. */ /** What the view shows out of every call: the waterfall shows only client calls. */ readonly countText = computed(() => { const total = this.timeline().length; if (this.timelineView() !== 'waterfall') return `${this.visibleCalls().length} of ${total}`; const client = this.waterfallCalls().length; return `${client} client ${client === 1 ? 'call' : 'calls'} of ${total}`; }); readonly filterStatus = computed(() => { this.callQuery(); this.sideFilter(); this.failedOnly(); this.timelineView(); // A filter change that keeps the same counts still gets announced: the text flips a // trailing no-break space so the live region sees a change. this.filterFlip = !this.filterFlip; const text = untracked(() => this.countText()); return this.filterFlip ? `${text}\u00a0` : text; }); private filterFlip = true; /** Follows the visible rows, so the preview hides while filters hide its call. */ readonly selectedCall = computed( () => this.visibleCalls().find((c) => c.id === this.selectedCallId()) ?? null, ); /** The SSR request that served the selected page, for the HAR export. */ readonly pageSsrRequest = computed( () => this.requests().find((r) => r.id === this.pageRequestId()) ?? null, ); readonly selectedRequest = computed( () => this.requests().find((r) => r.id === this.selectedRequestId()) ?? null, ); readonly requestCalls = computed(() => { const id = this.selectedRequest()?.id; return id ? this.serverCalls().filter((c) => c.requestId === id) : []; }); readonly requestPage = computed(() => { const id = this.selectedRequest()?.id; return id ? (this.pages().find((p) => p.ssrRequestId === id) ?? null) : null; }); readonly refetched = computed(() => { const request = this.selectedRequest(); const page = this.requestPage(); if (!request || !page) return []; const fetched = new Set( this.requestCalls() .filter((c) => c.status) .map((c) => `${c.method} ${pathOf(c.url)}`), ); return page.calls.filter( (c) => c.side === 'client' && !c.cacheHit && !c.mocked && c.at >= request.at && fetched.has(`${c.method} ${pathOf(c.url)}`), ); }); readonly draftMocksOnServer = computed(() => { const rule = this.draftRule(); return !!rule && this.mocksOnServer(rule); }); readonly draftRule = computed | null>(() => { const draft = this.draft(); const pattern = draft.pattern.trim(); if (!pattern || pattern.length > MAX_PATTERN || this.bodyError()) return null; if (this.rules().length >= MAX_RULES) return null; const body = draft.body.trim(); const delayMs = Math.min(Math.max(Math.round(Number(draft.delayMs)) || 0, 0), 10_000); const status = draft.status.trim() ? Number(draft.status) : body ? 200 : undefined; if (status !== undefined && !isHttpRuleStatus(status)) return null; if (status === undefined && !delayMs) return null; const chance = chanceOf(draft.chance); if (chance === null) return null; return { pattern, enabled: true, target: draft.target, ...(draft.method ? { method: draft.method } : {}), ...(status !== undefined ? { status } : {}), ...(delayMs ? { delayMs } : {}), ...(body ? { body } : {}), ...(chance !== undefined ? { chance } : {}), }; }); readonly draftHint = computed(() => { const draft = this.draft(); if (this.rules().length >= MAX_RULES) { return `You can add up to ${MAX_RULES} rules. Remove one to add another.`; } if (!draft.pattern.trim()) return 'Enter a URL pattern to add a rule.'; if (draft.pattern.trim().length > MAX_PATTERN) return `The URL pattern is limited to ${MAX_PATTERN} characters.`; if (this.bodyError()) return ''; if (draft.status.trim() && !isHttpRuleStatus(Number(draft.status))) return 'Pick a status from the list.'; if (chanceOf(draft.chance) === null) return 'Chance is a whole percent from 1 to 99.'; if (!this.draftRule()) return 'Set a status, a delay or a mock body.'; if (!draft.status.trim() && draft.body.trim()) return 'With no status, the mock body returns 200.'; return ''; }); readonly bodyError = computed(() => { const body = this.draft().body.trim(); if (!body) return ''; if (body.length > MAX_BODY) return `The mock body is limited to ${MAX_BODY} characters.`; try { JSON.parse(body); return ''; } catch { return 'The mock body must be valid JSON.'; } }); constructor() { rememberSelection('network', this.selectedCallId); // Newest first, so following the newest call keeps the table scrolled to the top. followNewest(this.callFeed, () => { if (this.callSort()) return; // The SSR requests table comes first and has a .table-wrap too. const wrap = this.callsBody()?.nativeElement.closest('.table-wrap'); if (wrap) wrap.scrollTop = 0; }); effect(() => { const body = this.callsBody()?.nativeElement; if (body) this.callWindow.attach(body); }); effect(() => { const client = this.rpc(); if (client) this.load(client); }); this.destroyRef.onDestroy(() => this.stopListening()); } async load(client: DevframeRpcClient) { this.loading.set(true); this.failed.set(false); try { const rpc = client.scope('pangular').rpc; const [state, payloads] = await Promise.all([ rpc.sharedState('http'), rpc.sharedState('http-payloads'), ]); if (this.destroyRef.destroyed) return; const apply = (value: unknown) => { const snapshot = value as HttpState | undefined; this.serverCalls.set(snapshot?.serverCalls ?? []); this.serverDropped.set(snapshot?.serverDropped ?? 0); this.requests.set([...(snapshot?.requests ?? [])].reverse()); this.pages.set(snapshot?.pages ?? []); this.rules.set(snapshot?.rules ?? []); this.overrides.set(snapshot?.ssrOverrides ?? []); }; const applyPayloads = (value: unknown) => this.payloads.set( (value as { pages?: Record } | undefined)?.pages ?? {}, ); apply(state.value()); applyPayloads(payloads.value()); this.stopListening(); this.unsubscribe = [state.on('updated', apply), payloads.on('updated', applyPayloads)]; } catch { this.failed.set(true); } finally { this.loading.set(false); } } retry() { const client = this.rpc(); if (client) void this.load(client); } /** 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) : ''; } /** The response encoding when a compression middleware ran before `ssrMiddleware` saw the body. */ compressedWith(req: SsrRequest): string | undefined { // The final Content-Encoding is also set when compression runs after ssrMiddleware, as it should. if (!req.compressedBeforeTrace) return undefined; return req.headers['content-encoding'] ?? 'unknown encoding'; } renderModeLabel(mode: SsrRequest['renderMode']): string { return RENDER_MODE_LABEL[mode] ?? 'Unknown'; } statusLabel(call: HttpCall): string { if (call.cancelled) return 'cancelled'; return call.status ? String(call.status) : 'ERR'; } callTone(call: HttpCall): 'ok' | 'redirect' | 'bad' | 'neutral' { return call.cancelled ? 'neutral' : this.statusTone(call.status); } /** A failed call has a status of 400 or more, no status (ERR) or was cancelled. */ isFailed(call: HttpCall): boolean { return !!call.cancelled || !call.status || call.status >= 400; } clearCallFilters() { this.callQuery.set(''); this.sideFilter.set('all'); this.failedOnly.set(false); this.host.nativeElement.querySelector('#timeline-search')?.focus(); } rulePatternOf(call: HttpCall): string | null { if (call.rulePattern) return call.rulePattern; if (!call.ruleId) return null; return this.rules().find((rule) => rule.id === call.ruleId)?.pattern ?? null; } openCall(id: string) { this.selectedCallId.set(id); afterNextRender( () => this.host.nativeElement.querySelector('#preview-heading')?.focus(), { injector: this.injector }, ); } /** The preview as shown: JSON indented by 2 spaces, other text as recorded (both already redacted). */ previewText(call: HttpCall): string | undefined { if (call.preview === undefined) return undefined; return prettyJson(call.preview) ?? call.preview; } async copyPreview(call: HttpCall) { const text = this.previewText(call); if (!text) return; try { await navigator.clipboard.writeText(text); this.message.set('Copied.'); } catch { this.message.set('The clipboard is not available here.'); } } /** Fills the fault rule form from a recorded call and moves focus to it. Nothing is saved. */ mockCall(call: HttpCall) { const method = call.method.toUpperCase(); const pattern = mockPattern(call.url); const body = call.error || !call.preview ? null : jsonBody(call.preview); this.draft.set({ ...EMPTY_DRAFT, pattern, method, body: body ?? '' }); const what = `${method} ${pattern}`; this.message.set( body ? `Filled in a rule for ${what} with the preview as its body. Check it, then add the rule.` : call.preview && !call.error ? `Filled in a rule for ${what}. The preview is not complete JSON, so it is not used as the body.` : `Filled in a rule for ${what}. Set a status, a delay or a body, then add the rule.`, ); afterNextRender( () => this.host.nativeElement.querySelector('#rule-pattern')?.focus(), { injector: this.injector }, ); } closePreview() { const id = this.selectedCallId(); this.selectedCallId.set(null); if (!id) return; const buttons = this.host.nativeElement.querySelectorAll('button[data-call-id]'); [...buttons].find((button) => button.dataset['callId'] === id)?.focus(); } private stopListening() { for (const stop of this.unsubscribe.splice(0)) stop(); } statusTone(status: number | undefined): 'ok' | 'redirect' | 'bad' | 'neutral' { if (status === undefined) return 'neutral'; if (status === 0 || status >= 400) return 'bad'; if (status >= 300) return 'redirect'; if (status >= 200) return 'ok'; return 'neutral'; } mocksOnServer(rule: Pick): boolean { return ( rule.target !== 'client' && rule.status !== undefined && rule.status > 0 && rule.status < 400 ); } payloadLabel(entry: PayloadEntry): string | null { if (entry.source === 'hydration') return 'hydration annotations'; if (entry.fn) return `Analog server function ${entry.fn.name ?? entry.fn.id}`; if (entry.source === 'analog') return 'Analog'; return null; } targetLabel(target: HttpRule['target']): string { return target === 'both' ? 'SSR + client' : target === 'server' ? 'SSR only' : 'Client only'; } readonly pageOptions = computed(() => this.pages().map((page) => ({ value: page.pageId, label: page.title || page.url })), ); /** The usual methods, plus the draft's own when "Mock this request" filled in another one. */ readonly methodOptions = computed(() => { const draft = this.draft().method; const methods = draft && !STANDARD_METHODS.includes(draft) ? [...STANDARD_METHODS, draft] : STANDARD_METHODS; return [{ value: '', label: 'Any' }, ...methods.map((m) => ({ value: m, label: m }))]; }); readonly targetOptions: SelectOption[] = [ { value: 'both', label: 'SSR + client' }, { value: 'server', label: 'SSR only' }, { value: 'client', label: 'Client only' }, ]; selectPage(pageId: string | null) { if (pageId) this.selectedPageId.set(pageId); this.selectedCallId.set(null); this.overrideFromPayload.set(null); } setDraft(key: K, value: RuleDraft[K]) { this.draft.update((draft) => ({ ...draft, [key]: value })); } patch(key: keyof RuleDraft, event: Event) { const value = (event.target as HTMLInputElement).value; this.draft.update((draft) => ({ ...draft, [key]: value })); } async addRule(event: Event) { event.preventDefault(); const draft = this.draftRule(); if (!draft || this.addingRule()) return; const rule: HttpRule = { id: newRuleId(), ...draft }; this.addingRule.set(true); const added = await this.saveRules((rules) => [[...rules, rule], rule], 'Rule added.'); this.addingRule.set(false); if (added) { this.draft.set({ ...EMPTY_DRAFT }); this.host.nativeElement.querySelector('#rule-pattern')?.focus(); } } /** Adds the rules of a built-in preset after the current ones. */ async addPreset(id: string) { const added = presetRules(id, newRuleId); if (!added.length) return; if (this.rules().length + added.length > MAX_RULES) { this.message.set(`You can add up to ${MAX_RULES} rules. Remove one to add the preset.`); return; } await this.saveRules( (rules) => [[...rules, ...added], added[0]], `Added ${added.length === 1 ? 'a rule' : `${added.length} rules`} for every browser request.`, ); } toggleRule(id: string) { void this.saveRules((rules) => { const rule = rules.find((r) => r.id === id); if (!rule) return null; return [rules.map((r) => (r.id === id ? { ...r, enabled: !r.enabled } : r)), rule]; }, 'Rule updated.'); } async removeRule(id: string) { let index = -1; const removed = await this.saveRules((rules) => { index = rules.findIndex((r) => r.id === id); return index < 0 ? null : [rules.filter((r) => r.id !== id), rules[index]]; }, 'Rule removed.'); if (removed) this.focusAfterRemove('[data-remove-rule]', index, '#rule-list-heading'); } async clearCalls() { try { await call(this.rpc(), 'clear-http-calls'); this.selectedCallId.set(null); this.message.set('Timeline cleared.'); } catch { this.message.set('Could not clear the timeline.'); } } overrideLabel(kind: SsrOverrideKind): string { return OVERRIDE_LABEL[kind] ?? kind; } setOverrideDraft(key: K, value: OverrideDraft[K]) { this.overrideDraft.update((d) => ({ ...d, [key]: value })); // The "filled in from the payload" note only describes the entry the form was filled from. if (key === 'kind' && value !== 'state-edit') this.overrideFromPayload.set(null); } patchOverride(key: keyof OverrideDraft, event: Event) { const value = (event.target as HTMLInputElement).value; this.overrideDraft.update((d) => ({ ...d, [key]: value })); if (key === 'key') this.overrideFromPayload.set(null); } async copyKey(key: string) { try { await navigator.clipboard.writeText(key); this.message.set('Copied.'); } catch { this.message.set('The clipboard is not available here.'); } } /** * The edit needs HTTP writes and a key the server keeps exactly: it trims keys and drops longer * ones than it accepts. A key with a redacted part can never match the real one. */ canEditEntry(entry: PayloadEntry): boolean { const { key } = entry; return ( this.canWrite() && !!key && key === key.trim() && key.length <= MAX_OVERRIDE_KEY && !REDACTED_PART.test(key) ); } /** * Opens the SSR overrides form as a TransferState edit for this entry. The value stays empty: * the payload the panel holds is redacted and clipped, so copying it would write masks back. */ editEntry(entry: PayloadEntry) { if (!this.canEditEntry(entry)) return; const page = this.selected(); this.overrideDraft.set({ ...EMPTY_OVERRIDE, kind: 'state-edit', pattern: page ? pagePattern(page.initialUrl ?? page.url) : '', key: entry.key, }); this.overrideFromPayload.set(entry.http ? 'http' : 'other'); this.message.set('Editing the TransferState entry in SSR overrides.'); afterNextRender( () => { const host = this.host.nativeElement; const target = host.querySelector( this.overrideDraft().pattern ? '#override-value' : '#override-pattern', ); target?.scrollIntoView?.({ block: 'center' }); target?.focus(); }, { injector: this.injector }, ); } async addOverride(event: Event) { event.preventDefault(); if (!this.overrideReady() || this.addingOverride()) return; const d = this.overrideDraft(); const next: SsrOverride = { id: `o${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`, kind: d.kind, pattern: d.pattern.trim(), enabled: true, ...(d.kind === 'render-error' && d.message.trim() ? { message: d.message.trim() } : {}), ...(d.kind === 'state-edit' ? { key: d.key.trim() } : {}), ...(d.kind === 'state-edit' && d.value.trim() ? { value: d.value.trim() } : {}), }; this.addingOverride.set(true); const added = await this.saveOverrides( (overrides) => [...overrides, next], 'Override added. Reload the page.', ); this.addingOverride.set(false); if (added) { this.overrideDraft.set({ ...EMPTY_OVERRIDE, kind: d.kind }); this.overrideFromPayload.set(null); this.host.nativeElement.querySelector('#override-pattern')?.focus(); } } toggleOverride(id: string) { void this.saveOverrides( (overrides) => overrides.some((o) => o.id === id) ? overrides.map((o) => (o.id === id ? { ...o, enabled: !o.enabled } : o)) : null, 'Override updated. Reload the page.', ); } async removeOverride(id: string) { let index = -1; const removed = await this.saveOverrides((overrides) => { index = overrides.findIndex((o) => o.id === id); return index < 0 ? null : overrides.filter((o) => o.id !== id); }, 'Override removed.'); if (removed) { this.focusAfterRemove('[data-remove-override]', index, '#override-list-heading'); } } /** * The removed row's button is gone, so focus moves to the Remove button that took its place, * the one before it when the last row went, or the list heading when the list is empty. */ private focusAfterRemove(buttons: string, index: number, heading: string) { afterNextRender( () => { const host = this.host.nativeElement; const remaining = host.querySelectorAll(buttons); const next = remaining[Math.min(index, remaining.length - 1)]; (next ?? host.querySelector(heading))?.focus(); }, { injector: this.injector }, ); } /** Asks the page to render its URL again on the server and push the payload it gets. */ async refetchPayload(pageId: string) { if (this.refetching()) return; this.refetching.set(true); try { const result = (await call(this.rpc(), 'request-http-payload-refetch', { pageId })) as { error?: string; found?: boolean; entries?: number; status?: number } | null | undefined; if (!result) this.announce('Could not reach the devtools server.'); else if (result.error) this.announce(result.error); else if (!result.found) { this.announce(`The server answered ${result.status} without a TransferState payload.`); } else { const count = result.entries ?? 0; this.announce(`Refetched: ${count} entr${count === 1 ? 'y' : 'ies'}.`); } } catch { this.announce('Could not refetch the payload.'); } finally { this.refetching.set(false); } } /** Clears a repeated message first, so the live region speaks again for the same text. */ private announce(text: string) { if (this.message() !== text) { this.message.set(text); return; } this.message.set(''); // A newer message set before the render wins over this restore. afterNextRender( () => { if (this.message() === '') this.message.set(text); }, { injector: this.injector }, ); } /** * Saves run one after another, and each change is applied to the list the previous save left, * so two quick changes never start from the same old list. `change` returns null when its * override is gone by then. */ private saveOverrides( change: (overrides: SsrOverride[]) => SsrOverride[] | null, done: string, ): Promise { const save = this.overrideSaves.then(async () => { const next = change(this.overrides()); if (!next) return false; try { const saved = await call(this.rpc(), 'set-ssr-overrides', next); this.overrides.set(Array.isArray(saved) ? (saved as SsrOverride[]) : next); this.announce(done); return true; } catch { this.message.set('Could not save the overrides.'); return false; } }); this.overrideSaves = save; return save; } /** Like `saveOverrides`; `change` also returns the rule it changed, for the message. */ private saveRules( change: (rules: HttpRule[]) => [HttpRule[], HttpRule] | null, done: string, ): Promise { const save = this.ruleSaves.then(async () => { const next = change(this.rules()); if (!next) return false; const [rules, changed] = next; try { const saved = await call(this.rpc(), 'set-http-rules', rules); this.rules.set(Array.isArray(saved) ? (saved as HttpRule[]) : rules); this.announce( changed.target === 'client' ? done : `${done} Reload the page to apply it to SSR.`, ); return true; } catch { this.message.set('Could not save the rules.'); return false; } }); this.ruleSaves = save; return save; } }