diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2db2aa193..303bd27f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -319,6 +319,42 @@ jobs: - name: Test run: npm test --prefix js/packages/truapi-host + ts-debugger: + name: "@parity/truapi-debugger" + runs-on: ubuntu-latest + needs: codegen + env: + TRUAPI_REQUIRE_GENERATED: 1 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 22 + + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: latest + + - name: Download codegen output + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: codegen-output + + - name: Install + run: npm ci --ignore-scripts + + - name: Build @parity/truapi (workspace dependency) + run: npm run build --prefix js/packages/truapi + + - name: Build + run: npm run build --prefix js/packages/truapi-debugger + + - name: Test + run: npm test --prefix js/packages/truapi-debugger + playground: name: Playground (build + lint + unit) runs-on: ubuntu-latest @@ -481,6 +517,7 @@ jobs: ios-swift, ts-client, ts-host, + ts-debugger, playground, explorer, e2e, @@ -500,6 +537,7 @@ jobs: "${{ needs.ios-swift.result }}" "${{ needs.ts-client.result }}" "${{ needs.ts-host.result }}" + "${{ needs.ts-debugger.result }}" "${{ needs.playground.result }}" "${{ needs.explorer.result }}" "${{ needs.e2e.result }}" diff --git a/CLAUDE.md b/CLAUDE.md index 3b4fc0c41..a270622b6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,6 +20,19 @@ js/packages/ `.` (shared host types), `/web` (iframe + Web Worker), `/worker-runtime` (Worker entry). WASM bundle (gitignored) under dist/wasm/web/, built via `make wasm` + truapi-debugger/ @parity/truapi-debugger (private, in-repo): the debugger. + Owns all decoding of the wire frames the Rust host tap + (truapi-server's DebugSink) streams out, and decodes + every frame by default (no denylist, no reveal toggle). + Holds the trace, envelope-decode, and value-decode + engines, the shared view model + renderers, and two + mounts over them: server.ts (standalone WS+HTTP app on + 127.0.0.1:9231 that hosts dial into, `npm run serve`; + endpoints /, /op-list, /op, /view, /channels, /stats, + /traces, /frame) and in-app.ts (createInAppDebugger: + same-page host, no server, no dial). @parity/truapi has + no debug seam. Where the app ultimately lives is still + an open decision. js/container/ TS lockdown container for the iOS host web view; `npm run build` bundles it into ios/truapi-host/Sources/TrUAPIHost/Resources/ ios/truapi-provider/ TrUAPIProvider Swift package (chain transport over UniFFI); diff --git a/Cargo.lock b/Cargo.lock index 3fd3b5a09..7339c74a7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5197,6 +5197,7 @@ name = "truapi-server" version = "0.1.0" dependencies = [ "async-trait", + "base64", "blake2b_simd", "chacha20poly1305", "console_error_panic_hook", diff --git a/js/packages/truapi-debugger/.gitignore b/js/packages/truapi-debugger/.gitignore new file mode 100644 index 000000000..f4e2c6d6b --- /dev/null +++ b/js/packages/truapi-debugger/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.tsbuildinfo diff --git a/js/packages/truapi-debugger/README.md b/js/packages/truapi-debugger/README.md new file mode 100644 index 000000000..8ad2b780f --- /dev/null +++ b/js/packages/truapi-debugger/README.md @@ -0,0 +1,153 @@ +# @parity/truapi-debugger + +The debugger-side consumer for TrUAPI wire frames. **Private, in-repo, not published.** + +The host taps every product↔host wire frame in its Rust core (`truapi-server`'s +`DebugSink`) and streams each one outward as a `{ channelId, dir, frame: bytes }` +envelope. This package is the other end: it owns **all** decoding — the wire +envelope (`requestId` and frame id, via `decodeWireMessage`), the grouping into +per-operation traces, and the per-frame payload decode. The host core treats +frames as opaque bytes and never decodes. + +This keeps `@parity/truapi` (the product package) genuinely untouched: the tap is +in the Rust host, and the debugger's decode/trace logic lives here instead of in +the product transport. + +> **Scope note.** This package holds both the debugger *library* (the trace, +> envelope-decode, and value-decode engines plus the ingest that turns a wire +> envelope into a decoded frame) and its two *mounts* — the standalone app +> (`server.ts`) and the in-app embed (`in-app.ts`). It lives in-repo because the +> debugger is coupled to the protocol this repo owns: it decodes wire frames with +> `@parity/truapi`, tracking the generated wire surface. *Where the app +> ultimately lives* (stays a truapi tool / own repo / a desktop app) is an open +> decision for the host-protocol owner; in-repo is the low-regret default and +> moving it later is cheap. + +## What's here + +- **`createDebugSession()`** — the trace engine wired to the ingest. Feed it + envelopes with `handleEnvelope(...)`; read grouped traces from `traceEngine`, + per-frame values from `frameDetail(...)` / `decodedFrames(...)`. +- **`createDebugIngest(sink)`** — decodes a `DebugFrameEnvelope` into an + `ObservedFrame` and forwards it. The layer that turns raw wire bytes into + something the trace engine can group. +- **`createWireDebugger(...)`** — accumulates observed frames into per-`requestId` + traces (correlates with product-sdk telemetry spans on the same id). +- **`createFrameDecoder(...)`** — the level-2 value decoder (see below): a + per-frame decode of a payload to a plain JS value, reusing `@parity/truapi`'s + generated `WIRE_DECODE_TABLE`. Every frame it can decode, it does, with no + sensitive special-casing. The bare factory takes `enabled: true` to opt in; a + session turns it on for you. +- **`buildTraceView` / `wireTraceToView`, `renderOperationRow`, + `renderTraceDetail`, `renderFrameValueDetail`** — the one view model and the one + set of renderers both mounts share, so the two cannot drift apart. +- **`startDebugServer(...)`** (`server.ts`) — the standalone mount, below. +- **`createInAppDebugger(...)`** (`in-app.ts`) — the in-app mount, below. + +## The two mounts + +Both render the same view model with the same renderers and the same stylesheet. +They differ in where the debugger sits relative to the host: + +```text +standalone: host process ──ws://127.0.0.1:9231──▶ debugger server ──HTTP──▶ browser + (host dials out; frames leave the app; one server, many channels) + +in-app: host in the page ──handleFrame()──▶ InAppDebugger.mount(el) + (same page as the host; no server, no dial; frames never leave the app) +``` + +- **Standalone** (`startDebugServer`): a Bun WS+HTTP server bound to + `127.0.0.1` only. Hosts dial *in* and send one text message per frame, + `{ channelId, dir, frame }` with `frame` base64-encoded, plus the wire-identity + fields a versioned host stamps (`v`, `codec`, `schema`) and an optional + `dropped` count. The browser view is a thin client over server-rendered + fragments. +- **In-app** (`createInAppDebugger`): the second mount, for a host that runs in + the page. It takes the same raw SCALE frame bytes with the same + product-vantage `dir`, holds the session in-process, and renders the fragments + directly with no polling. Browser-only (uses `document`); each browser tab is + its own tenant, so there is nothing to host or scope. + +## Value decode (level 2 — on by default) + +This is a **dev-only tool that decodes everything**. The list views stay +payload-blind — they group frames and sum byte lengths, never their contents — +and the drill-down decodes a frame's payload to a plain JS value, for every +frame, with no "sensitive" special-casing. Its contract: + +- **On by default.** The standalone server decodes unless + `TRUAPI_DEBUGGER_DECODE_VALUES` is set to a falsy value + (`0`/`false`/`no`/`off`), or `startDebugServer({ decodeValues: false })` / + `createInAppDebugger({ decodeValues: false })` in code — useful for a demo. + With decode off, every frame reports byte length only and no bytes are even + retained. +- **Reuses the generated table.** Decoding is `WIRE_DECODE_TABLE[frameId]?.(bytes)` + from `@parity/truapi/wire-decode` — the same dev-only codecs the client uses. + The debugger writes none of its own. +- **No redaction, no reveal toggle.** Every frame the table can decode is + decoded, including signing, login, and payment. A developer inspecting their + own session's traffic sees the real values; there is no denylist, no reveal + escape hatch, and no `redacted` state. A frame the codec cannot type still + shows its raw payload as `B · 0x…` hex — a dev-only tool hides nothing it + has the bytes for. Only a frame with no retained bytes (decode off) reads + `payload not shown`. +- **Refused on contract drift.** Decode is allowed only for a channel whose + declared `schema` fingerprint (`TRUAPI_WIRE_SCHEMA_HASH`) and `codec` match + this debugger's; a mismatched or absent identity is refused (`/frame` answers + 409) and banners in the view. Payload-blind grouping is unaffected. +- **Never over the wire, never in the list endpoints.** The host emits opaque + bytes only; nothing about decode changes what it sends. Decode happens in the + debugger, in the drill-down paths only. + +## Standalone endpoints + +| Endpoint | Serves | +| ------------------------------------- | --------------------------------------------------------- | +| `GET /` | The inspector page: polls the fragments below. | +| `GET /op-list?channel=&sort=` | One server-rendered row per op. `sort` is `recent`, `duration`, `frames`, or `method`; absent keeps arrival order. Payload-blind. | +| `GET /op?id=&channel=&gen=` | The selected op's drill-down, each frame's value inline. | +| `GET /view` | The drill-down as a standalone fragment, values inline. | +| `GET /channels` | Connected hosts/channels, liveness, codec-mismatch flag. | +| `GET /stats?channel=` | Aggregate roll-up: counts, bytes, durations, health, busiest methods. Payload-blind. | +| `GET /traces` | The grouped traces as JSON. Payload-blind — never serializes bytes or values. | +| `GET /frame?id=&i=&channel=` | One frame's decode as JSON (the programmatic drill-down). | + +Loopback is enforced on more than the bind: a request whose `Host` header is not +a loopback name gets a 403 (DNS-rebinding guard), and a WebSocket upgrade from a +foreign browser `Origin` is refused (CSWSH). + +## Run + +```bash +npm install # links @parity/truapi via the workspace +npm run build # tsc -b +npm run serve # bun run src/server.ts — listens on 127.0.0.1:9231, decodes by default + +# a different port, or decode off for a demo +TRUAPI_DEBUGGER_PORT=9300 npm run serve +TRUAPI_DEBUGGER_DECODE_VALUES=0 npm run serve +``` + +Point a host's debugger URL at `ws://127.0.0.1:9231` (the host dials out) and +open `http://127.0.0.1:9231/`; click an op for its drill-down detail. + +Use the literal `127.0.0.1`, not `localhost`. Both dial gates accept a `ws://` +URL on a loopback host **only** — `wss://`, certificates, and any non-loopback +target are rejected — and `localhost` passes that check but resolves `::1` first +on macOS, while the server binds `127.0.0.1` alone. A native host then dials an +address nothing is listening on and logs nothing. + +For the in-app mount, feed frames straight to the session: + +```ts +import { createInAppDebugger } from "@parity/truapi-debugger"; + +const inspector = createInAppDebugger(); +const dispose = inspector.mount(document.getElementById("wire-panel")!); +// from the host's tap, per frame: +inspector.handleFrame(channelId, "out", frameBytes); +``` + +The exact host↔debugger framing is provisional (envelope spec, track T3); +base64-in-JSON is what the server accepts today. diff --git a/js/packages/truapi-debugger/package.json b/js/packages/truapi-debugger/package.json new file mode 100644 index 000000000..90c011afa --- /dev/null +++ b/js/packages/truapi-debugger/package.json @@ -0,0 +1,26 @@ +{ + "name": "@parity/truapi-debugger", + "version": "0.0.0", + "private": true, + "description": "In-repo debugger consumer for TrUAPI wire frames: decodes and groups the frames the truapi-server host tap streams out", + "license": "MIT", + "author": "Parity Technologies ", + "type": "module", + "sideEffects": false, + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "tsc -b", + "typecheck": "tsc -b", + "serve": "bun run src/server.ts", + "test": "bun test" + }, + "devDependencies": { + "@types/bun": "^1.3.0", + "happy-dom": "^20.11.2", + "typescript": "^6.0" + }, + "dependencies": { + "@parity/truapi": "file:../truapi" + } +} diff --git a/js/packages/truapi-debugger/src/decode.test.ts b/js/packages/truapi-debugger/src/decode.test.ts new file mode 100644 index 000000000..857481910 --- /dev/null +++ b/js/packages/truapi-debugger/src/decode.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, test } from "bun:test"; + +import * as W from "@parity/truapi/wire-table"; +import { WIRE_DECODE_TABLE } from "@parity/truapi/wire-decode"; + +import { createFrameDecoder, type FrameValueDetail } from "./decode.js"; +import type { ObservedFrame } from "./observed-frame.js"; + +/** A minimal observed frame for a given id/bytes; the fields decode ignores are stubbed. */ +function frame(frameId: number, bytes?: Uint8Array): ObservedFrame { + return { + channelId: "myapp.dot", + direction: "out", + requestId: "p:1", + frameId, + role: "unknown", + byteLength: bytes?.length ?? 0, + timestamp: 0, + ...(bytes ? { bytes } : {}), + }; +} + +describe("frame decoder (real table) — decodes everything, no special-casing", () => { + test("a non-sensitive frame decodes only with the toggle on", () => { + // `connection-status.subscribe` start payload is `V1(void)` = a single 0x00 + // index byte: a real frame the generated table can decode. + const id = W.ACCOUNT_CONNECTION_STATUS_SUBSCRIBE.start; + const bytes = new Uint8Array([0]); + + const off = createFrameDecoder({ enabled: false }); + const offDetail = off.detail(frame(id, bytes)); + expect(offDetail.kind).toBe("bytes"); + if (offDetail.kind === "bytes") expect(offDetail.byteLength).toBe(1); + + const on = createFrameDecoder({ enabled: true }); + const onDetail = on.detail(frame(id, bytes)); + expect(onDetail.kind).toBe("decoded"); + // Sanity: the id really is in the generated decode table. + expect(typeof WIRE_DECODE_TABLE[id]).toBe("function"); + }); + + test("a formerly-'sensitive' signing frame decodes too (dev-only tool)", () => { + // No denylist any more: a signing request decodes like every other frame. + const decoder = createFrameDecoder({ enabled: true }); + const detail = decoder.detail( + frame(W.SIGNING_SIGN_RAW.request, new Uint8Array([0])), + ); + // It either decodes (id has a codec + valid bytes) or, on a codec throw for + // the stub bytes, falls back to bytes — never a "redacted" state. + expect(["decoded", "bytes"]).toContain(detail.kind); + // Whatever the outcome, the kind is never the old "redacted" variant. + expect(detail.kind).not.toBe("redacted"); + }); + + test("disabled decoder is bytes-only for every frame", () => { + const decoder = createFrameDecoder({ enabled: false }); + for (const id of [ + W.ACCOUNT_GET_ACCOUNT.request, + W.SIGNING_SIGN_RAW.request, + W.CHAIN_CALL_HEAD.request, + ]) { + expect(decoder.detail(frame(id, new Uint8Array([9]))).kind).toBe("bytes"); + } + }); +}); + +describe("frame decoder (injected table)", () => { + const table = { 999: (b: Uint8Array) => ({ ok: Array.from(b) }) }; + + test("decodes an id when enabled and bytes present", () => { + const decoder = createFrameDecoder({ enabled: true, decodeTable: table }); + const detail = decoder.detail(frame(999, new Uint8Array([1, 2]))); + expect(detail).toEqual({ + kind: "decoded", + value: { ok: [1, 2] }, + } satisfies FrameValueDetail); + }); + + test("decodes a secret-named field too — no content guard withholds it", () => { + const decoder = createFrameDecoder({ + enabled: true, + decodeTable: { 999: () => ({ source: { sr25519SecretKey: "0xdead" } }) }, + }); + const detail = decoder.detail(frame(999, new Uint8Array([1]))); + expect(detail.kind).toBe("decoded"); + if (detail.kind === "decoded") { + expect(detail.value).toEqual({ source: { sr25519SecretKey: "0xdead" } }); + } + }); + + test("falls back to bytes when the frame retained no bytes", () => { + const decoder = createFrameDecoder({ enabled: true, decodeTable: table }); + expect(decoder.detail(frame(999)).kind).toBe("bytes"); + }); + + test("falls back to bytes when the codec throws", () => { + const decoder = createFrameDecoder({ + enabled: true, + decodeTable: { + 999: () => { + throw new Error("bad payload"); + }, + }, + }); + expect(decoder.detail(frame(999, new Uint8Array([1]))).kind).toBe("bytes"); + }); + + test("falls back to bytes when the id has no codec", () => { + const decoder = createFrameDecoder({ enabled: true, decodeTable: table }); + expect(decoder.detail(frame(1, new Uint8Array([1]))).kind).toBe("bytes"); + }); +}); diff --git a/js/packages/truapi-debugger/src/decode.ts b/js/packages/truapi-debugger/src/decode.ts new file mode 100644 index 000000000..0a0243703 --- /dev/null +++ b/js/packages/truapi-debugger/src/decode.ts @@ -0,0 +1,101 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: MIT +/** + * Level-2 decode: turn a frame's raw SCALE payload into a plain JS value, in the + * drill-down detail path. + * + * This is the one place the debugger looks *inside* a frame. Everything else - + * the trace engine, `/traces`, the host tap - is payload-blind and stays that + * way. The rules that make that work live here: + * + * - **Dev-only tool: decode everything.** This debugger decodes every frame it + * can, with no "sensitive" special-casing. A developer inspecting their own + * session's traffic sees the real values. When decoding is disabled every + * frame reports its byte length only. + * - **Reuse, don't reinvent.** Decoding is `WIRE_DECODE_TABLE[frameId]?.(bytes)` + * from `@parity/truapi/wire-decode` - the same generated, dev-only codecs the + * client uses. The debugger writes no codecs of its own. + * + * Nothing here is ever serialized into `/traces`; the detail it produces is + * returned only from the explicit per-frame drill-down. + * + * @module + */ + +import { WIRE_DECODE_TABLE } from "@parity/truapi/wire-decode"; +import type { ObservedFrame } from "./observed-frame.js"; + +/** + * Per-frame decode result for the drill-down detail path. + * + * `"decoded"` carries the plain JS value, returned whenever the decoder is on + * and the frame's id has a codec that decodes its retained bytes. `"bytes"` is + * the fallback: the decoder is off, the frame carries no retained bytes, its id + * has no codec, or decoding threw. When the decoder is on and the bytes are + * retained, that fallback still carries the raw `hex` so a dev-only tool always + * shows *something* for a payload it could not type; `hex` is absent only in + * payload-blind mode (decoder off) or when no bytes were retained. + */ +export type FrameValueDetail = + | { kind: "decoded"; value: unknown } + | { kind: "bytes"; byteLength: number; hex?: string }; + +/** Options for {@link createFrameDecoder}. */ +export interface FrameDecoderOptions { + /** + * Master gate. `false` (the default) means the decoder never inspects a + * payload: every frame reports bytes only. + */ + enabled?: boolean; + /** + * Frame-id → decoder map. Defaults to the generated + * {@link WIRE_DECODE_TABLE}; overridable for tests. + */ + decodeTable?: Record unknown>; +} + +/** A gated per-frame value decoder for the drill-down detail path. */ +export interface FrameDecoder { + /** Whether decoding is on. `false` ⇒ every `detail` is bytes-only. */ + readonly enabled: boolean; + /** Resolve one frame to its {@link FrameValueDetail}. */ + detail(frame: ObservedFrame): FrameValueDetail; +} + +/** + * Build a {@link FrameDecoder}. Off by default: pass `enabled: true` to opt in. + * When on, every frame with a codec and retained bytes decodes to its value. + */ +export function createFrameDecoder( + options: FrameDecoderOptions = {}, +): FrameDecoder { + const enabled = options.enabled ?? false; + const decodeTable = options.decodeTable ?? WIRE_DECODE_TABLE; + + // Raw bytes as `0x…` hex so a payload the decoder can't type is still visible + // in the drill-down (a dev-only tool hides nothing it has the bytes for). + const toHex = (bytes: Uint8Array): string => + "0x" + Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); + + const detail = (frame: ObservedFrame): FrameValueDetail => { + if (!enabled) return { kind: "bytes", byteLength: frame.byteLength }; + // Decoder on: keep the raw hex on the bytes fallback so nothing reads + // "payload not shown" when the bytes are right there. + const bytesFallback = (): FrameValueDetail => ({ + kind: "bytes", + byteLength: frame.byteLength, + ...(frame.bytes ? { hex: toHex(frame.bytes) } : {}), + }); + const decode = decodeTable[frame.frameId]; + if (!decode || !frame.bytes) return bytesFallback(); + try { + return { kind: "decoded", value: decode(frame.bytes) }; + } catch { + // A malformed or version-skewed payload must not break the drill-down; + // fall back to the raw hex. + return bytesFallback(); + } + }; + + return { enabled, detail }; +} diff --git a/js/packages/truapi-debugger/src/in-app.test.ts b/js/packages/truapi-debugger/src/in-app.test.ts new file mode 100644 index 000000000..3d62f7a3a --- /dev/null +++ b/js/packages/truapi-debugger/src/in-app.test.ts @@ -0,0 +1,658 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { + encodeWireMessage, + TRUAPI_CODEC_VERSION, + TRUAPI_WIRE_SCHEMA_HASH, + VersionedHostAccountGetRequest, +} from "@parity/truapi"; +import * as W from "@parity/truapi/wire-table"; +import { Window } from "happy-dom"; + +import { createInAppDebugger, type InAppFrameIdentity } from "./in-app.js"; +import { WIRE_ENVELOPE_VERSION } from "./ingest.js"; +import { + INSPECTOR_LAYOUT_CSS, + INSPECTOR_SHELL_CSS, +} from "./inspector-styles.js"; +import { computeTraceStats, createDebugSession } from "./session.js"; +import { renderOperationRow } from "./trace-render.js"; +import { wireTraceToView } from "./trace-view.js"; + +function frameBytes( + id: number, + value: number[] = [0], + requestId = "p:1", +): Uint8Array { + const r = encodeWireMessage({ + requestId, + payload: { id, value: new Uint8Array(value) }, + }); + if (r.isErr()) throw r.error; + return r.value; +} + +/** A real, decodable account-get request wire message (non-sensitive). */ +function accountGetRequestBytes(): Uint8Array { + const value = VersionedHostAccountGetRequest.enc({ + tag: "V1", + value: { + productAccountId: { + dotNsIdentifier: "alice.dot", + derivationIndex: { tag: "Index", value: 0 }, + }, + }, + }); + const r = encodeWireMessage({ + requestId: "p:1", + payload: { id: W.ACCOUNT_GET_ACCOUNT.request, value }, + }); + if (r.isErr()) throw r.error; + return r.value; +} + +/** + * The wire identity a feeding host stamps when it was built against THIS + * debugger's wire table — the only state in which the panel decodes a payload. + */ +const ATTESTED: InAppFrameIdentity = { + v: WIRE_ENVELOPE_VERSION, + codec: TRUAPI_CODEC_VERSION, + schema: TRUAPI_WIRE_SCHEMA_HASH, +}; + +/** Selector lists of every rule in a stylesheet (no nested at-rules in ours). */ +function ruleSelectors(css: string): string[] { + return [...css.matchAll(/([^{}]+)\{[^{}]*\}/g)].map((m) => + (m[1] ?? "").trim(), + ); +} + +describe("createInAppDebugger", () => { + // The mount is a real interactive panel now (querySelector, dataset, event + // listeners), so it needs a real DOM rather than a stand-in. + /* eslint-disable @typescript-eslint/no-explicit-any -- install a DOM global */ + const g = globalThis as any; + const original = g.document; + let win: Window; + beforeAll(() => { + win = new Window(); + g.document = win.document; + }); + afterAll(() => { + g.document = original; + }); + /* eslint-enable @typescript-eslint/no-explicit-any */ + + /** A detached container to mount into. */ + const container = (): HTMLElement => + win.document.createElement("div") as unknown as HTMLElement; + + /** + * A container attached to the document, so `getComputedStyle` resolves the + * mount's injected stylesheet against it. + */ + const attached = (): HTMLElement => { + const el = container(); + win.document.body.append(el as unknown as Node & ChildNode); + return el; + }; + + /** Click the first operation row of a mounted panel. */ + const openFirstOp = (el: HTMLElement): void => { + const row = el.querySelector(".td-op"); + expect(row).not.toBeNull(); + row?.click(); + }; + + test("feeds frames in-process and decodes by default", () => { + const dbg = createInAppDebugger(); // decode ON by default (dev-only tool) + + // Two frames of one op, fed exactly as dotli's tap would (raw SCALE bytes). + // The request leg carries a real, decodable account-get payload. + dbg.handleFrame("shop.dot", "out", accountGetRequestBytes(), ATTESTED); + dbg.handleFrame( + "shop.dot", + "in", + frameBytes(W.ACCOUNT_GET_ACCOUNT.response), + ATTESTED, + ); + + expect(dbg.session.traceEngine.traces()).toHaveLength(1); + expect(dbg.session.decodeValues).toBe(true); // decodes by default + + // The drill-down surfaces the decoded value. + const detail = dbg.session.frameDetail("p:1", 0, "shop.dot"); + expect(detail?.kind).toBe("decoded"); + + const el = container(); + const dispose = dbg.mount(el); + // Rendered by the shared renderer — the method resolved via the wire table. + expect(el.querySelector(".ins-list")?.innerHTML).toContain( + "account.getAccount", + ); + dispose(); + expect(el.children).toHaveLength(0); + }); + + test("a formerly-sensitive op shows its payload like any other (no redaction)", () => { + const dbg = createInAppDebugger(); + dbg.handleFrame( + "shop.dot", + "out", + frameBytes(W.SIGNING_SIGN_RAW.request, [1, 2]), + ATTESTED, + ); + dbg.handleFrame( + "shop.dot", + "in", + frameBytes(W.SIGNING_SIGN_RAW.response), + ATTESTED, + ); + + const el = attached(); + const dispose = dbg.mount(el); + openFirstOp(el); + const html = el.querySelector(".ins-detail")?.innerHTML ?? ""; + + // No denylist: the panel shows this op's payload — decoded, or the raw hex + // when the codec can't type it — exactly as it would any other method. + expect(html).toContain("signing.signRaw"); + expect(html).toContain(`
`);
+    expect(html).not.toContain("payload not shown");
+    expect(html.toLowerCase()).not.toContain("redact");
+
+    dispose();
+    el.remove();
+  });
+
+  test("decodeValues:false keeps the mount payload-blind (bytes only)", () => {
+    const dbg = createInAppDebugger({ decodeValues: false });
+    dbg.handleFrame("shop.dot", "out", accountGetRequestBytes(), ATTESTED);
+    expect(dbg.session.decodeValues).toBe(false);
+    expect(dbg.session.frameDetail("p:1", 0, "shop.dot")?.kind).toBe("bytes");
+  });
+
+  test("the mount renders the full inspector chrome, not a bare list", () => {
+    const dbg = createInAppDebugger();
+    dbg.handleFrame("shop.dot", "out", accountGetRequestBytes(), ATTESTED);
+    dbg.handleFrame(
+      "shop.dot",
+      "in",
+      frameBytes(W.ACCOUNT_GET_ACCOUNT.response),
+      ATTESTED,
+    );
+
+    const el = container();
+    const dispose = dbg.mount(el);
+
+    // The pieces that made the standalone look like a Network tab and were
+    // absent here: a top bar with filter + sort, the aggregate strip, the
+    // list/detail split, and a status bar.
+    for (const selector of [
+      ".ins-top",
+      ".ins-filter",
+      ".ins-sort",
+      ".ins-summary",
+      ".ins-body",
+      ".ins-list",
+      ".ins-split",
+      ".ins-detail",
+      ".ins-status",
+    ]) {
+      expect(el.querySelector(selector)).not.toBeNull();
+    }
+    // The strip reports real aggregates rather than a placeholder.
+    expect(el.querySelector(".ins-summary")?.innerHTML).toContain("ops");
+    expect(el.querySelector(".ins-summary")?.className).not.toContain("empty");
+
+    dispose();
+  });
+
+  test("selecting an operation opens its frames in the detail pane", () => {
+    const dbg = createInAppDebugger();
+    dbg.handleFrame("shop.dot", "out", accountGetRequestBytes(), ATTESTED);
+    dbg.handleFrame(
+      "shop.dot",
+      "in",
+      frameBytes(W.ACCOUNT_GET_ACCOUNT.response),
+      ATTESTED,
+    );
+
+    const el = container();
+    const dispose = dbg.mount(el);
+
+    const detail = el.querySelector(".ins-detail");
+    expect(detail?.innerHTML).toContain("Select an operation");
+
+    openFirstOp(el);
+
+    // The drill-down replaces the placeholder, and the row reads as selected.
+    expect(detail?.innerHTML).not.toContain("Select an operation");
+    expect(detail?.innerHTML).toContain("account.getAccount");
+    expect(el.querySelector(".td-op.selected")).not.toBeNull();
+
+    dispose();
+  });
+
+  test("the filter narrows the operation list", () => {
+    const dbg = createInAppDebugger();
+    dbg.handleFrame("shop.dot", "out", accountGetRequestBytes(), ATTESTED);
+    dbg.handleFrame(
+      "shop.dot",
+      "in",
+      frameBytes(W.ACCOUNT_GET_ACCOUNT.response),
+      ATTESTED,
+    );
+
+    const el = container();
+    const dispose = dbg.mount(el);
+    expect(el.querySelectorAll(".td-op")).toHaveLength(1);
+
+    const filter = el.querySelector(".ins-filter");
+    expect(filter).not.toBeNull();
+    if (filter !== null) {
+      filter.value = "signing.signRaw";
+      filter.dispatchEvent(new win.Event("input") as unknown as Event);
+    }
+    expect(el.querySelectorAll(".td-op")).toHaveLength(0);
+    expect(el.querySelector(".ins-list")?.innerHTML).toContain("no operations");
+
+    dispose();
+  });
+
+  // --- wire identity ------------------------------------------------------
+
+  test("an unattested frame is grouped but never decoded", () => {
+    const dbg = createInAppDebugger();
+    // No identity: exactly the 3-arg call an embed makes today.
+    dbg.handleFrame("shop.dot", "out", accountGetRequestBytes());
+
+    expect(dbg.decodeTrusted("shop.dot")).toBe(false);
+    expect(dbg.decodeTrusted()).toBe(false);
+
+    const el = attached();
+    const dispose = dbg.mount(el);
+
+    // Payload-blind grouping needs no wire contract, so the op still lists.
+    expect(el.querySelector(".ins-list")?.innerHTML).toContain(
+      "account.getAccount",
+    );
+    // ...and the panel says the names may be wrong, as the standalone does.
+    expect(el.querySelector(".ins-list")?.innerHTML).toContain(
+      "declared no wire contract",
+    );
+    expect(el.querySelector(".ins-status")?.innerHTML).toContain(
+      "wire identity unconfirmed",
+    );
+
+    openFirstOp(el);
+    const html = el.querySelector(".ins-detail")?.innerHTML ?? "";
+    // The value is NOT surfaced: the feeder's frame ids are not attested to mean
+    // what this debugger's table says they mean.
+    expect(html).not.toContain("alice.dot");
+    expect(html).toContain("payload not shown");
+
+    dispose();
+    el.remove();
+  });
+
+  test("an attested frame decodes in the panel", () => {
+    const dbg = createInAppDebugger();
+    dbg.handleFrame("shop.dot", "out", accountGetRequestBytes(), ATTESTED);
+
+    expect(dbg.decodeTrusted("shop.dot")).toBe(true);
+
+    const el = attached();
+    const dispose = dbg.mount(el);
+    openFirstOp(el);
+    expect(el.querySelector(".ins-detail")?.innerHTML).toContain("alice.dot");
+    expect(el.querySelector(".ins-list")?.innerHTML).not.toContain("⚠");
+
+    dispose();
+    el.remove();
+  });
+
+  test("a mismatched wire schema refuses decode and banners the drift", () => {
+    const dbg = createInAppDebugger();
+    dbg.handleFrame("shop.dot", "out", accountGetRequestBytes(), {
+      ...ATTESTED,
+      schema: "0000000000000000",
+    });
+
+    expect(dbg.decodeTrusted("shop.dot")).toBe(false);
+
+    const el = attached();
+    const dispose = dbg.mount(el);
+    openFirstOp(el);
+    expect(el.querySelector(".ins-detail")?.innerHTML).not.toContain(
+      "alice.dot",
+    );
+    expect(el.querySelector(".ins-list")?.innerHTML).toContain(
+      "differs from this debugger's",
+    );
+    expect(el.querySelector(".ins-status")?.innerHTML).toContain(
+      "codec mismatch",
+    );
+
+    dispose();
+    el.remove();
+  });
+
+  test("one mismatching frame marks the channel untrusted for good", () => {
+    const dbg = createInAppDebugger();
+    dbg.handleFrame("shop.dot", "out", accountGetRequestBytes(), ATTESTED);
+    expect(dbg.decodeTrusted("shop.dot")).toBe(true);
+    // A later frame declaring a different codec version: sticky refusal.
+    dbg.handleFrame(
+      "shop.dot",
+      "in",
+      frameBytes(W.ACCOUNT_GET_ACCOUNT.response),
+      { ...ATTESTED, codec: TRUAPI_CODEC_VERSION + 1 },
+    );
+    expect(dbg.decodeTrusted("shop.dot")).toBe(false);
+  });
+
+  test("a payload-blind mount needs no attestation (nothing decodes anyway)", () => {
+    const dbg = createInAppDebugger({ decodeValues: false });
+    dbg.handleFrame("shop.dot", "out", accountGetRequestBytes());
+    expect(dbg.decodeTrusted("shop.dot")).toBe(true);
+  });
+
+  // --- one shared aggregate ----------------------------------------------
+
+  test("the summary strip reports the whole shared aggregate", () => {
+    // maxFramesPerTrace: 1 forces a `truncated` op; the malformed frame and the
+    // unanswered subscription supply the other health tallies.
+    const dbg = createInAppDebugger({ maxFramesPerTrace: 1 });
+    dbg.handleFrame(
+      "shop.dot",
+      "out",
+      frameBytes(W.ACCOUNT_CONNECTION_STATUS_SUBSCRIBE.start, [0], "p:9"),
+      { ...ATTESTED, dropped: 3 },
+    );
+    dbg.handleFrame("shop.dot", "in", new Uint8Array([0xff, 0xff, 0xff]), ATTESTED);
+    dbg.handleFrame("shop.dot", "out", accountGetRequestBytes(), ATTESTED);
+    dbg.handleFrame(
+      "shop.dot",
+      "in",
+      frameBytes(W.ACCOUNT_GET_ACCOUNT.response),
+      ATTESTED,
+    );
+
+    // The shared roll-up over the same views is the reference: the strip must
+    // agree with it rather than compute its own subset.
+    const stats = computeTraceStats(
+      dbg.session.traceEngine
+        .traces()
+        .map((t) => wireTraceToView(t, dbg.session.methodNames)),
+    );
+    expect(stats.malformed).toBe(1);
+    expect(stats.truncated).toBe(1);
+    expect(stats.subscriptions).toBe(1);
+    expect(stats.liveSubscriptions).toBe(1);
+    expect(stats.out).toBeGreaterThan(0);
+    expect(stats.in).toBeGreaterThan(0);
+
+    const el = container();
+    const dispose = dbg.mount(el);
+    const html = el.querySelector(".ins-summary")?.innerHTML ?? "";
+
+    for (const label of [
+      "ops",
+      "frames",
+      "data",
+      "subs",
+      "avg op",
+      "malformed",
+      "orphaned",
+      "retry storms",
+      "truncated",
+      "evicted",
+      "dropped",
+    ]) {
+      expect(html).toContain(`class="k">${label}`);
+    }
+    // The tallies the bespoke strip omitted entirely.
+    expect(html).toContain(
+      `${String(stats.malformed)}malformed`,
+    );
+    expect(html).toContain(
+      `${String(stats.truncated)}truncated`,
+    );
+    expect(html).toContain(
+      `3dropped`,
+    );
+    // The in/out split and the observed maximum.
+    expect(html).toContain(`${String(stats.out)}▶ ${String(stats.in)}◀`);
+    expect(html).toContain("max ");
+
+    dispose();
+  });
+
+  // --- retention ---------------------------------------------------------
+
+  test("session retention caps reach the trace engine", () => {
+    const session = createDebugSession({ maxTraces: 2 });
+    for (const requestId of ["p:1", "p:2", "p:3"]) {
+      session.handleEnvelope({
+        channelId: "shop.dot",
+        dir: "out",
+        frame: frameBytes(W.ACCOUNT_GET_ACCOUNT.request, [0], requestId),
+      });
+    }
+    expect(session.traceEngine.traces()).toHaveLength(2);
+    expect(session.traceEngine.evictedTraces()).toBe(1);
+  });
+
+  test("the embed retains less than the standalone engine's default", () => {
+    const dbg = createInAppDebugger();
+    for (let i = 0; i < 200; i++) {
+      dbg.handleFrame(
+        "shop.dot",
+        "out",
+        frameBytes(W.ACCOUNT_GET_ACCOUNT.request, [0], `p:${String(i)}`),
+        ATTESTED,
+      );
+    }
+    // The engine default is 256 ops × 1 MiB of retained payload each; inside the
+    // observed app's own tab that ceiling is the product's crash.
+    expect(dbg.session.traceEngine.traces().length).toBeLessThanOrEqual(128);
+    expect(dbg.session.traceEngine.evictedTraces()).toBeGreaterThan(0);
+  });
+
+  // --- containment -------------------------------------------------------
+
+  test("a throwing session cannot break the product's frame path", () => {
+    const dbg = createInAppDebugger();
+    // The embed's tap runs in the host's own send/receive path, so a throw here
+    // would surface to the product as a protocol failure.
+    dbg.session.handleEnvelope = () => {
+      throw new Error("boom");
+    };
+    expect(() => {
+      dbg.handleFrame("shop.dot", "out", accountGetRequestBytes(), ATTESTED);
+    }).not.toThrow();
+  });
+
+  test("every injected rule is confined to the mount root", () => {
+    const dbg = createInAppDebugger();
+    const el = container();
+    const dispose = dbg.mount(el);
+
+    const css = el.querySelector("style")?.textContent ?? "";
+    expect(css).not.toBe("");
+    const leaked = ruleSelectors(css)
+      .flatMap((list) => list.split(","))
+      .map((s) => s.trim())
+      .filter((s) => s !== "" && !s.startsWith(".td-inapp"));
+    expect(leaked).toEqual([]);
+
+    dispose();
+  });
+
+  test("the panel does not restyle the host application's own markup", () => {
+    const dbg = createInAppDebugger();
+    dbg.handleFrame("shop.dot", "out", accountGetRequestBytes(), ATTESTED);
+
+    const el = attached();
+    const dispose = dbg.mount(el);
+
+    // The host application's own debug panel uses the same `td-*` class names
+    // (they are lifted from it), so a global rule restyles it.
+    const hostPanel = win.document.createElement("div");
+    hostPanel.className = "td-op";
+    hostPanel.innerHTML = `host panel`;
+    win.document.body.append(hostPanel);
+
+    const inside = el.querySelector(".td-op-meta");
+    const outside = hostPanel.querySelector(".td-op-meta");
+    expect(inside).not.toBeNull();
+    expect(outside).not.toBeNull();
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- happy-dom element types
+    const colorOf = (node: any): string =>
+      win.getComputedStyle(node).color ?? "";
+    // The panel's own rows are styled; the host's identically-classed markup is
+    // untouched by anything the panel injected.
+    expect(colorOf(inside)).not.toBe("");
+    expect(colorOf(outside)).toBe("");
+    expect(
+      // eslint-disable-next-line @typescript-eslint/no-explicit-any -- happy-dom element types
+      win.getComputedStyle(hostPanel as any).cursor ?? "",
+    ).not.toBe("pointer");
+
+    hostPanel.remove();
+    dispose();
+    el.remove();
+  });
+
+  // --- refresh cost ------------------------------------------------------
+
+  test("an unchanged open op is not re-decoded on every refresh", () => {
+    const dbg = createInAppDebugger();
+    dbg.handleFrame("shop.dot", "out", accountGetRequestBytes(), ATTESTED);
+    dbg.handleFrame(
+      "shop.dot",
+      "in",
+      frameBytes(W.ACCOUNT_GET_ACCOUNT.response),
+      ATTESTED,
+    );
+
+    const el = container();
+    const dispose = dbg.mount(el);
+
+    let decodes = 0;
+    const real = dbg.session.decodedFrames.bind(dbg.session);
+    dbg.session.decodedFrames = ((...args: Parameters) => {
+      decodes += 1;
+      return real(...args);
+    }) as typeof dbg.session.decodedFrames;
+
+    openFirstOp(el);
+    expect(decodes).toBe(1);
+    const rendered = el.querySelector(".ins-detail")?.innerHTML ?? "";
+
+    // Three more render passes with nothing about the op changed: re-decoding and
+    // re-hexing every frame here is ~50ms of blocked main thread per second, in
+    // the product's own tab, for an identical result.
+    const filter = el.querySelector(".ins-filter");
+    for (let i = 0; i < 3; i++) {
+      filter?.dispatchEvent(new win.Event("input") as unknown as Event);
+    }
+    expect(decodes).toBe(1);
+    expect(el.querySelector(".ins-detail")?.innerHTML).toBe(rendered);
+
+    // A new frame on the open op DOES refresh it.
+    dbg.handleFrame(
+      "shop.dot",
+      "in",
+      frameBytes(W.ACCOUNT_GET_ACCOUNT.response),
+      ATTESTED,
+    );
+    filter?.dispatchEvent(new win.Event("input") as unknown as Event);
+    expect(decodes).toBe(2);
+
+    dispose();
+  });
+
+  // --- style precedence --------------------------------------------------
+
+  test("a live-and-waiting op's meta reads waiting-amber, not live-green", () => {
+    const dbg = createInAppDebugger();
+    // A subscription start with nothing back: live (no stop) AND waiting
+    // (orphaned opener). Both classes land on the same row.
+    dbg.handleFrame(
+      "shop.dot",
+      "out",
+      frameBytes(W.ACCOUNT_CONNECTION_STATUS_SUBSCRIBE.start),
+      ATTESTED,
+    );
+    const trace = dbg.session.traceEngine.traces()[0];
+    expect(trace).toBeDefined();
+    const view = wireTraceToView(trace!, dbg.session.methodNames);
+    const rowHtml = renderOperationRow(view, { now: Date.now() + 5000 });
+    expect(rowHtml).toContain("td-op-live");
+    expect(rowHtml).toContain("td-op-waiting");
+
+    const style = win.document.createElement("style");
+    style.textContent = INSPECTOR_SHELL_CSS;
+    const holder = win.document.createElement("div");
+    holder.innerHTML = rowHtml;
+    win.document.body.append(style, holder);
+
+    // A stalled op must read as a problem: the waiting colour has to beat the
+    // live one, whatever order the two rules are declared in.
+    const meta = holder.querySelector(".td-op-meta");
+    expect(meta).not.toBeNull();
+    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- happy-dom element types
+    expect(win.getComputedStyle(meta as any).color).toBe("#fbbf24");
+
+    style.remove();
+    holder.remove();
+  });
+});
+
+describe("waiting beats live in the cascade", () => {
+  /**
+   * An unanswered subscription carries BOTH `td-op-live` and `td-op-waiting`, so
+   * the two rules collide on the same element. The amber wait has to win: green
+   * says "healthy and streaming", which is the opposite of what an unanswered
+   * opener means. Asserting the COMPUTED colour rather than source order is the
+   * point - the previous rule pair was ordered wrongly at equal specificity, so
+   * the amber was dead and no assertion on the stylesheet text would have caught
+   * it.
+   */
+  test("a row that is both live and waiting computes amber, not green", () => {
+    const win = new Window();
+    const doc = win.document;
+    const style = doc.createElement("style");
+    style.textContent = `${INSPECTOR_SHELL_CSS}\n${INSPECTOR_LAYOUT_CSS}`;
+    doc.head.appendChild(style);
+
+    const row = doc.createElement("div");
+    row.className = "td-op td-op-sub td-op-live td-op-waiting";
+    const meta = doc.createElement("span");
+    meta.className = "td-op-meta";
+    row.appendChild(meta);
+    doc.body.appendChild(row);
+
+    // Amber, not the green a healthy live subscription gets.
+    expect(win.getComputedStyle(meta as never).color).toBe("#fbbf24");
+  });
+
+  test("a live row that is NOT waiting still computes green", () => {
+    const win = new Window();
+    const doc = win.document;
+    const style = doc.createElement("style");
+    style.textContent = `${INSPECTOR_SHELL_CSS}\n${INSPECTOR_LAYOUT_CSS}`;
+    doc.head.appendChild(style);
+
+    const row = doc.createElement("div");
+    row.className = "td-op td-op-sub td-op-live";
+    const meta = doc.createElement("span");
+    meta.className = "td-op-meta";
+    row.appendChild(meta);
+    doc.body.appendChild(row);
+
+    expect(win.getComputedStyle(meta as never).color).toBe("#4ade80");
+  });
+});
diff --git a/js/packages/truapi-debugger/src/in-app.ts b/js/packages/truapi-debugger/src/in-app.ts
new file mode 100644
index 000000000..139a70a20
--- /dev/null
+++ b/js/packages/truapi-debugger/src/in-app.ts
@@ -0,0 +1,618 @@
+// Copyright 2026 Parity Technologies (UK) Ltd.
+// SPDX-License-Identifier: MIT
+/**
+ * In-app mount: render the inspector from a {@link DebugSession} that lives in
+ * the SAME app as the host — no server, no dial-out, no relay. A host running in
+ * the page (dotli) feeds each tapped frame via {@link InAppDebugger.handleFrame};
+ * {@link InAppDebugger.mount} renders them with the same engine, the same
+ * renderers, and the same stylesheet the standalone app uses.
+ *
+ * This is the "host and debugger in the same bits" transport: the frames never
+ * leave the app, so each browser tab is its own tenant — nothing to host or
+ * scope. Browser-only (uses `document`).
+ *
+ * Three consequences of sharing the app follow from that, and they are the
+ * invariants this module holds:
+ *
+ *  - **The tap is in the product's frame path.** Feeding a frame can never throw
+ *    into the caller, so {@link InAppDebugger.handleFrame} is contained.
+ *  - **The feeding host is not this debugger's build.** dotli pins its own truapi
+ *    dependencies, so a frame id may mean a different method here than it did
+ *    there. Decode is therefore gated on an {@link InAppFrameIdentity} that
+ *    affirmatively matches this build's wire schema — exactly the gate the
+ *    standalone server applies to a dialing host. An unattested frame still
+ *    groups (payload-blind grouping needs no contract) but never decodes.
+ *  - **The document belongs to the host application.** Every shared rule is
+ *    scoped to the mount root before injection (`scopeCss`), so the panel cannot
+ *    restyle the host's own UI.
+ *
+ * The standalone app is a thin client over server-rendered fragments; this mount
+ * has the session in-process, so it renders the same fragments directly and needs
+ * no polling. Everything visible — the summary strip, the operation list, the
+ * drill-down — comes from the shared renderers, the shared aggregate
+ * ({@link computeTraceStats}), and the shared stylesheet, so the two mounts cannot
+ * drift apart.
+ *
+ * @module
+ */
+
+import { TRUAPI_CODEC_VERSION, TRUAPI_WIRE_SCHEMA_HASH } from "@parity/truapi";
+import {
+  computeTraceStats,
+  createDebugSession,
+  decodeTraceFrames,
+  formatStatBytes,
+  formatStatMs,
+  type TraceStats,
+} from "./session.js";
+import type { DebugSession, DebugSessionOptions } from "./session.js";
+import { normalizeId, WIRE_ENVELOPE_VERSION } from "./ingest.js";
+import { wireTraceToView, type TraceView } from "./trace-view.js";
+import { renderOperationRow, renderTraceDetail } from "./trace-render.js";
+import { detectRetryStorms } from "./retry-storm.js";
+import { TRACE_DETAIL_CSS } from "./trace-styles.js";
+import {
+  INSPECTOR_LAYOUT_CSS,
+  INSPECTOR_SHELL_CSS,
+  scopeCss,
+} from "./inspector-styles.js";
+
+/** How an operation list is ordered. */
+type SortMode = "arrival" | "slowest" | "frames";
+
+/** The mount root's class; also the CSS scope every injected rule is confined to. */
+const MOUNT_CLASS = "td-inapp";
+
+/**
+ * Retention caps for an embed, deliberately below the engine defaults the
+ * standalone runs with. The standalone is its own process — if it retains a few
+ * hundred MiB of traces, only the debugger pays. An embed retains inside the
+ * observed application's own tab, where the same ceiling is the product's crash,
+ * so the panel keeps a shorter, byte-bounded history.
+ */
+const EMBED_MAX_TRACES = 128;
+/** @see EMBED_MAX_TRACES */
+const EMBED_MAX_FRAMES_PER_TRACE = 256;
+/** @see EMBED_MAX_TRACES */
+const EMBED_MAX_BYTES_PER_TRACE = 256 * 1024;
+
+/**
+ * Cap on tracked channels, matching the standalone's registry: a host feeding
+ * frames under many distinct channelIds must not grow the identity map without
+ * bound.
+ */
+const MAX_CHANNELS = 256;
+
+/**
+ * The wire identity a feeding host stamps on a tapped frame — the same
+ * `v`/`codec`/`schema` triple a dialing host puts on the standalone's envelope.
+ *
+ * A frame id is a `u8` discriminant that gets reassigned as the API evolves, so a
+ * frame from a host built against a different wire table decodes to the WRONG
+ * method and the wrong value off this debugger's table. The embed is the mount
+ * most exposed to that: dotli pins its truapi dependencies independently of the
+ * debugger's. Without an identity a frame is grouped but not decoded.
+ */
+export interface InAppFrameIdentity {
+  /** Envelope version the feeder speaks; see {@link WIRE_ENVELOPE_VERSION}. */
+  v?: number;
+  /** The feeding host's wire codec version (`TRUAPI_CODEC_VERSION`). */
+  codec?: number;
+  /**
+   * The feeding host's wire-contract fingerprint (`TRUAPI_WIRE_SCHEMA_HASH`): a
+   * hash of every frame id and its method leg. This is the field decode is gated
+   * on — unlike `codec` (the coarse handshake number, bumped ~never), it changes
+   * whenever a frame id is reassigned.
+   */
+  schema?: string;
+  /** Frames this tap dropped before this one; surfaced in the summary strip. */
+  dropped?: number;
+}
+
+/** What one channel has declared about its wire contract, across all its frames. */
+interface ChannelIdentity {
+  /** `false` once a frame declared a `v`/`codec`/`schema` that differs. Sticky. */
+  codecOk: boolean;
+  /** Monotonic counter of the last frame seen, so eviction can pick the LRU. */
+  lastSeen: number;
+  /** `true` once a frame affirmatively declared a matching `schema`. */
+  schemaOk: boolean;
+  /** Frames the feeding tap reported dropping. */
+  dropped: number;
+}
+
+/** A same-app debugger: feed it frames, mount its panel. */
+export interface InAppDebugger {
+  /** The underlying session — grouped traces, inline value decode. */
+  readonly session: DebugSession;
+  /**
+   * Feed one tapped frame: the raw SCALE `ProtocolMessage` bytes, opaque. `dir`
+   * is product-vantage (`out` = left the product), matching the standalone tap.
+   *
+   * `identity` is the feeder's wire contract. Pass it — a frame fed WITHOUT an
+   * identity that matches this build's `TRUAPI_WIRE_SCHEMA_HASH` is grouped and
+   * listed but never decoded, because its ids cannot be trusted to mean what this
+   * debugger's table says they mean. Never throws: this runs inside the product's
+   * own frame path.
+   */
+  handleFrame(
+    channelId: string,
+    dir: "in" | "out",
+    frame: Uint8Array,
+    identity?: InAppFrameIdentity,
+  ): void;
+  /**
+   * Whether a decoded value may be surfaced for a channel's frames: it declared a
+   * matching wire schema and never declared a mismatching identity. The panel
+   * gates itself on this; an embedding host can read it to gate its own views.
+   * Always `true` when the session has decode off (nothing decodes anyway).
+   */
+  decodeTrusted(channelId?: string): boolean;
+  /**
+   * Render a live, self-contained panel into `el` and keep it refreshed; returns
+   * a disposer that tears the panel down. Decodes the open op's frames when the
+   * session has `decodeValues` on AND the op's channel is
+   * {@link InAppDebugger.decodeTrusted}.
+   */
+  mount(el: HTMLElement, options?: { refreshMs?: number }): () => void;
+}
+
+/** HTML-escape a string for interpolation into the markup this module builds. */
+function esc(value: string): string {
+  return value
+    .replace(/&/g, "&")
+    .replace(//g, ">")
+    .replace(/"/g, """);
+}
+
+/** One metric tile in the aggregate strip. */
+function stat(n: string, k: string, sub = "", cls = ""): string {
+  return (
+    `
` + + `${esc(n)}` + + (sub === "" ? "" : ` ${esc(sub)}`) + + `${esc(k)}
` + ); +} + +/** A tile that reads red when non-zero and muted at zero. */ +function warnStat(n: number, k: string): string { + return stat(String(n), k, "", n === 0 ? "warn zero" : "warn"); +} + +/** + * The aggregate summary strip: the "at a glance" row above the list. + * + * Every number comes from the shared {@link computeTraceStats}, and the tiles + * mirror the standalone's strip one-for-one — same set, same labels, same + * formatting. That is the whole point: a bespoke second roll-up here is how the + * standalone came to report `malformed 1 / truncated 1` on a stream this mount + * showed as clean. + */ +function renderSummary(stats: TraceStats): string { + if (stats.ops === 0) return "waiting for frames…"; + + const pills = stats.topMethods + .map( + ({ method, count }) => + `` + + `${esc(method)} ${String(count)}`, + ) + .join(""); + + return ( + stat(String(stats.ops), "ops") + + stat( + String(stats.frames), + "frames", + `${String(stats.out)}▶ ${String(stats.in)}◀`, + ) + + stat(formatStatBytes(stats.bytes), "data") + + stat( + String(stats.subscriptions), + "subs", + stats.liveSubscriptions > 0 + ? `${String(stats.liveSubscriptions)} live` + : "", + ) + + stat( + formatStatMs(stats.avgDurationMs), + "avg op", + `max ${formatStatMs(stats.maxDurationMs)}, observed`, + ) + + warnStat(stats.malformed, "malformed") + + warnStat(stats.orphaned, "orphaned") + + warnStat(stats.retryStorms, "retry storms") + + warnStat(stats.truncated, "truncated") + + warnStat(stats.evictedTraces, "evicted") + + warnStat(stats.droppedByHost, "dropped") + + (pills === "" ? "" : `${pills}`) + ); +} + +/** Stable identity for an op across refreshes: channel + id + generation. */ +function opKey(view: TraceView): string { + return `${view.channelId ?? ""} ${view.requestId} ${String(view.generation ?? 0)}`; +} + +/** + * Create an in-app debugger. Decode is ON by default (dev-only tool) for frames + * whose feeder attests a matching wire schema; pass `decodeValues: false` to keep + * a bundled mount payload-blind regardless. Retention defaults to the + * embed-appropriate caps ({@link EMBED_MAX_TRACES}); override any of them through + * {@link DebugSessionOptions}. + */ +export function createInAppDebugger( + options: DebugSessionOptions = {}, +): InAppDebugger { + const session = createDebugSession({ + ...options, + maxTraces: options.maxTraces ?? EMBED_MAX_TRACES, + maxFramesPerTrace: options.maxFramesPerTrace ?? EMBED_MAX_FRAMES_PER_TRACE, + maxBytesPerTrace: options.maxBytesPerTrace ?? EMBED_MAX_BYTES_PER_TRACE, + }); + + const channels = new Map(); + /** + * Channels evicted while carrying a mismatch verdict. Keys only, so this is + * bounded by the number of distinct channels that ever declared a foreign + * contract - and a channel that did so must never be able to buy back trust + * simply by being forgotten. + */ + const distrusted = new Set(); + /** Monotonic sequence for LRU ordering. */ + let seq = 0; + // Sticky: some frame arrived unattested (or mismatched) this session. The + // no-channel decode query keys on this rather than scanning the registry, whose + // records can be evicted while the frames they described survive. + let sawUnconfirmed = false; + + /** Fold one frame's declared identity into its channel's record. */ + const recordIdentity = ( + channelId: string, + identity: InAppFrameIdentity | undefined, + ): void => { + const mismatch = + (typeof identity?.v === "number" && identity.v !== WIRE_ENVELOPE_VERSION) || + (typeof identity?.codec === "number" && + identity.codec !== TRUAPI_CODEC_VERSION) || + (typeof identity?.schema === "string" && + identity.schema !== TRUAPI_WIRE_SCHEMA_HASH); + // Confirmed only by an affirmative match. An absent schema is NOT trusted: + // "omit the identity and decode anyway" is the hole this closes. + const confirmed = identity?.schema === TRUAPI_WIRE_SCHEMA_HASH; + if (!confirmed || mismatch) sawUnconfirmed = true; + // Same validation the standalone applies: a non-finite or fractional count + // would otherwise render as "Infinity" or a rounded lie in the strip, and the + // two mounts would disagree about the same feeder. + const droppedRaw = identity?.dropped; + const dropped = + typeof droppedRaw === "number" && + Number.isSafeInteger(droppedRaw) && + droppedRaw > 0 + ? droppedRaw + : 0; + const key = normalizeId(channelId); + const existing = channels.get(key); + if (existing) { + if (mismatch) existing.codecOk = false; + if (confirmed) existing.schemaOk = true; + existing.dropped += dropped; + existing.lastSeen = seq++; + // Re-insert so map order tracks recency: without this the map stays in + // insertion order and the busiest, longest-lived channel is the FIRST + // evicted under pressure. + channels.delete(key); + channels.set(key, existing); + return; + } + if (channels.size >= MAX_CHANNELS) { + // Evict the least recently seen, matching the standalone's registry. + let oldestKey: string | undefined; + let oldestSeen = Infinity; + for (const [candidate, entry] of channels) { + if (entry.lastSeen < oldestSeen) { + oldestSeen = entry.lastSeen; + oldestKey = candidate; + } + } + if (oldestKey !== undefined) { + const evicted = channels.get(oldestKey); + channels.delete(oldestKey); + // A mismatch verdict is sticky FOR THE SESSION, not for as long as the + // entry survives. Forgetting it let a flood of distinct channelIds + // launder a channel that had already declared a foreign wire contract: + // it re-registered clean on its next frame and the panel decoded its + // frames — wrong methods and wrong values, presented as truth. + if (evicted !== undefined && !evicted.codecOk) { + distrusted.add(oldestKey); + } + } + } + channels.set(key, { + codecOk: !mismatch && !distrusted.has(key), + schemaOk: confirmed, + dropped, + lastSeen: seq++, + }); + }; + + const decodeTrusted = (channelId?: string): boolean => { + // Payload-blind mode never decodes, so the gate has nothing to guard. + if (!session.decodeValues) return true; + if (channelId !== undefined) { + const c = channels.get(normalizeId(channelId)); + return c !== undefined && c.codecOk && c.schemaOk; + } + // No channel to key on: refuse once anything unattested has been seen. + return !sawUnconfirmed; + }; + + /** Channels whose method names (and values) can't be trusted to this table. */ + const untrustedChannels = (): { any: boolean; mismatch: boolean } => { + let any = false; + let mismatch = false; + for (const c of channels.values()) { + if (!c.codecOk) { + mismatch = true; + any = true; + } else if (!c.schemaOk) any = true; + } + return { any, mismatch }; + }; + + return { + session, + decodeTrusted, + handleFrame(channelId, dir, frame, identity) { + // A debug tap must never disturb the frame path. In this mount that is not + // a slogan: `handleFrame` is called from the host's own send/receive path in + // the same call stack, so a throw here surfaces to the product as a + // protocol failure. The standalone's socket callback carries the same guard + // for the same reason; here the blast radius is larger. + try { + recordIdentity(channelId, identity); + session.handleEnvelope({ channelId, dir, frame }); + } catch { + // Drop the frame; the observed session is worth more than one trace. + } + }, + mount(el, mountOptions = {}) { + const style = document.createElement("style"); + // The shared rules are FLAT (`.ins-*`, `.td-*`) because that is right for + // the standalone, which owns its page. Here they share a document with the + // host application — an unscoped rule would restyle the host's own debug + // panel, which `INSPECTOR_LAYOUT_CSS` is written to override — so every one + // of them is rewritten to `.td-inapp ` before injection. Only the + // root rules below are written already-scoped. + style.textContent = ` +.${MOUNT_CLASS} { display: grid; grid-template-rows: auto auto minmax(0, 1fr) auto; + height: 100%; min-height: 0; overflow: hidden; background: #0a0a0a; color: #e0e0e0; + font: 12px ui-monospace, SFMono-Regular, Menlo, monospace; } +.${MOUNT_CLASS} * { box-sizing: border-box; } +${scopeCss( + `${INSPECTOR_SHELL_CSS}\n${TRACE_DETAIL_CSS}\n${INSPECTOR_LAYOUT_CSS}`, + `.${MOUNT_CLASS}`, +)}`; + + const root = document.createElement("div"); + root.className = MOUNT_CLASS; + root.innerHTML = ` +
+ TrUAPI Wire Inspector + + + +
+
+
+
waiting for frames…
+
+
Select an operation to inspect its frames.
+
+
`; + el.append(style, root); + + const pick = (selector: string): T => { + const node = root.querySelector(selector); + if (node === null) throw new Error(`in-app mount: missing ${selector}`); + return node; + }; + const filterEl = pick(".ins-filter"); + const sortEl = pick(".ins-sort"); + const channelsEl = pick(".ins-channels"); + const summaryEl = pick(".ins-summary"); + const listEl = pick(".ins-list"); + const detailEl = pick(".ins-detail"); + const statusEl = pick(".ins-status"); + + let selected: string | null = null; + let channel: string | null = null; + let disposed = false; + // Fingerprint of what the detail pane is currently showing. The refresh + // ticks once a second, but re-rendering the open op means re-decoding and + // re-hexing every one of its frames — tens of ms of blocked main thread, in + // the product's own tab, for an op that has not changed. Skip unless the + // fingerprint moves. + let detailKey: string | null = null; + + const render = (): void => { + if (disposed) return; + const traces = session.traceEngine.traces(); + const storms = detectRetryStorms(traces); + // One clock per render so every waiting op in this pass agrees, and the + // 1s refresh makes a hung call visibly count up. + const now = Date.now(); + const all = traces.map((trace) => + wireTraceToView(trace, session.methodNames, storms.get(trace) ?? []), + ); + + // Channel chips only earn their row once a second host has dialed in. + const channelIds = [ + ...new Set( + all + .map((v) => v.channelId) + .filter((c): c is string => c !== undefined), + ), + ]; + channelsEl.innerHTML = + channelIds.length < 2 + ? "" + : [null, ...channelIds] + .map((c) => { + const active = c === channel ? " active" : ""; + return ( + `` + ); + }) + .join(""); + + const scoped = + channel === null ? all : all.filter((v) => v.channelId === channel); + const untrusted = untrustedChannels(); + summaryEl.className = `ins-summary${scoped.length === 0 ? " empty" : ""}`; + summaryEl.innerHTML = renderSummary( + computeTraceStats(scoped, { + evictedTraces: session.traceEngine.evictedTraces(), + droppedByHost: [...channels.values()].reduce( + (n, c) => n + c.dropped, + 0, + ), + codecMismatch: untrusted.mismatch, + }), + ); + + const needle = filterEl.value.trim().toLowerCase(); + const filtered = + needle === "" + ? scoped + : scoped.filter((v) => + (v.frames[0]?.method ?? "").toLowerCase().includes(needle), + ); + + const sort = sortEl.value as SortMode; + const ordered = [...filtered].sort((a, b) => { + if (sort === "slowest") return b.durationMs - a.durationMs; + if (sort === "frames") return b.frames.length - a.frames.length; + return a.startedAt - b.startedAt; + }); + + // A row whose channel never attested a matching wire contract carries + // method names resolved off THIS debugger's table, which may be wrong for + // it. Say so above the rows, as the standalone does, rather than only in + // the status bar. + const listedUntrusted = ordered.some((v) => !decodeTrusted(v.channelId)); + const notice = + !listedUntrusted || !session.decodeValues + ? "" + : `
⚠ ${ + untrusted.mismatch + ? "a feeding host's wire contract differs from this debugger's — method names below may be wrong and values are not decoded" + : "a feeding host declared no wire contract — method names below may be wrong and values are not decoded" + }
`; + listEl.innerHTML = + ordered.length === 0 + ? `
${scoped.length === 0 ? "waiting for frames…" : "no operations match the filter"}
` + : notice + + ordered + .map((view) => { + const row = renderOperationRow(view, { now }); + return opKey(view) === selected + ? row.replace('class="td-op ', 'class="td-op selected ') + : row; + }) + .join(""); + + const open = ordered.find((v) => opKey(v) === selected); + const trusted = open !== undefined && decodeTrusted(open.channelId); + // Everything the detail render depends on, and nothing that ticks: frame + // count and `lastAt` move whenever a frame lands, badges move when the op + // changes shape, and the trust flag moves when an identity arrives. + const nextDetailKey = + open === undefined + ? "" + : [ + opKey(open), + String(open.frames.length), + String(open.lastAt), + open.badges.join("|"), + trusted ? "t" : "u", + ].join(" "); + if (nextDetailKey !== detailKey) { + detailKey = nextDetailKey; + detailEl.innerHTML = + open === undefined + ? `
Select an operation to inspect its frames.
` + : renderTraceDetail(open, { + offerDecode: session.decodeValues, + // Same wire-identity gate the standalone applies to a dialing + // host: an unattested channel groups but surfaces no value. + decoded: trusted ? decodeTraceFrames(session, open) : undefined, + }); + } + + const evicted = session.traceEngine.evictedTraces(); + const identityWarning = untrusted.mismatch + ? `⚠ codec mismatch` + : untrusted.any || (session.decodeValues && sawUnconfirmed) + ? `⚠ wire identity unconfirmed` + : ""; + statusEl.innerHTML = + `${String(all.length)} ops` + + `in-app · decode ${session.decodeValues ? "on" : "off"}` + + (evicted > 0 + ? `${String(evicted)} evicted` + : "") + + identityWarning; + }; + + // Selecting a row is the only interaction that changes what the detail + // pane shows, so re-render at once rather than waiting for the next tick. + listEl.addEventListener("click", (event) => { + const row = (event.target as HTMLElement).closest(".td-op"); + if (row === null) return; + const key = [ + row.dataset["channelId"] ?? "", + row.dataset["requestId"] ?? "", + row.dataset["generation"] ?? "0", + ].join(" "); + selected = selected === key ? null : key; + render(); + }); + channelsEl.addEventListener("click", (event) => { + const chip = (event.target as HTMLElement).closest( + ".ins-chan", + ); + if (chip === null) return; + const value = chip.dataset["channel"] ?? ""; + channel = value === "" ? null : value; + render(); + }); + summaryEl.addEventListener("click", (event) => { + const pill = (event.target as HTMLElement).closest( + ".ins-method", + ); + if (pill === null) return; + filterEl.value = pill.dataset["method"] ?? ""; + render(); + }); + filterEl.addEventListener("input", render); + sortEl.addEventListener("change", render); + + render(); + const timer = setInterval(render, mountOptions.refreshMs ?? 1000); + return () => { + disposed = true; + clearInterval(timer); + style.remove(); + root.remove(); + }; + }, + }; +} diff --git a/js/packages/truapi-debugger/src/index.ts b/js/packages/truapi-debugger/src/index.ts new file mode 100644 index 000000000..57ac2aeec --- /dev/null +++ b/js/packages/truapi-debugger/src/index.ts @@ -0,0 +1,58 @@ +export type { + FrameDirection, + FrameRole, + ObservedFrame, + TransportObserver, +} from "./observed-frame.js"; +export { createDebugIngest } from "./ingest.js"; +export type { DebugFrameEnvelope, DebugIngestOptions } from "./ingest.js"; +export { createDebugSession } from "./session.js"; +export type { DebugSession, DebugSessionOptions } from "./session.js"; +export { createFrameDecoder } from "./decode.js"; +export type { + FrameDecoder, + FrameDecoderOptions, + FrameValueDetail, +} from "./decode.js"; +export { createWireDebugger, createMethodNameMap } from "./wire-debugger.js"; +export type { + WireDebugger, + WireDebuggerOptions, + WireDebugSink, + WireFrameKind, + WireMethodInfo, + WireTrace, +} from "./wire-debugger.js"; +export { buildTraceView, wireTraceToView } from "./trace-view.js"; +export type { + TraceBadge, + TraceFrameBadge, + TraceFrameInput, + TraceFrameView, + TraceView, + TraceViewInput, +} from "./trace-view.js"; +export { + renderTraceDetail, + renderFrameValueDetail, + renderOperationRow, +} from "./trace-render.js"; +export type { RenderTraceDetailOptions } from "./trace-render.js"; +export { detectRetryStorms } from "./retry-storm.js"; +export type { RetryStormOptions } from "./retry-storm.js"; +export { TRACE_DETAIL_CSS } from "./trace-styles.js"; +export { + INSPECTOR_LAYOUT_CSS, + INSPECTOR_SHELL_CSS, +} from "./inspector-styles.js"; +export { createInAppDebugger } from "./in-app.js"; +export type { InAppDebugger } from "./in-app.js"; +export { + operationMethod, + isSubscription, + isLiveSubscription, +} from "./trace-view.js"; +export type { TraceDropCounts } from "./wire-debugger.js"; +export { computeTraceStats } from "./session.js"; +export type { TraceStats } from "./session.js"; +export type { InAppFrameIdentity } from "./in-app.js"; diff --git a/js/packages/truapi-debugger/src/ingest.test.ts b/js/packages/truapi-debugger/src/ingest.test.ts new file mode 100644 index 000000000..38b229def --- /dev/null +++ b/js/packages/truapi-debugger/src/ingest.test.ts @@ -0,0 +1,336 @@ +import { describe, expect, test } from "bun:test"; + +import { encodeWireMessage } from "@parity/truapi"; +import * as W from "@parity/truapi/wire-table"; + +import { createDebugIngest, DEFAULT_MAX_ID_CHARS, normalizeId } from "./ingest.js"; +import type { DebugFrameEnvelope } from "./ingest.js"; +import type { ObservedFrame } from "./observed-frame.js"; +import { detectRetryStorms } from "./retry-storm.js"; +import { createMethodNameMap, createWireDebugger } from "./wire-debugger.js"; + +/** The real generated table, keyed the way `createDebugSession` keys it. */ +const METHOD_NAMES = createMethodNameMap( + W as unknown as Record, + ["account", "signing", "chain", "chat", "resourceAllocation"], +); + +/** One host-tap envelope carrying `frameId` under correlation id `requestId`. */ +function envelope( + requestId: string, + frameId: number, + value = new Uint8Array([0]), + dir: "in" | "out" = "out", + channelId = "myapp.dot", +): DebugFrameEnvelope { + const encoded = encodeWireMessage({ requestId, payload: { id: frameId, value } }); + if (encoded.isErr()) throw encoded.error; + return { channelId, dir, frame: encoded.value }; +} + +/** + * One envelope as a host tap replays it out of its backlog: `buffered`, with the + * producer's own `observedAt` rather than the flush instant. + */ +function flushed( + observedAt: number | undefined, + requestId: string, + frameId: number, + dir: "in" | "out" = "out", +): DebugFrameEnvelope { + return { + ...envelope(requestId, frameId, new Uint8Array([0]), dir), + ...(observedAt === undefined ? {} : { observedAt }), + buffered: true, + }; +} + +/** Collect every frame an ingest emits. */ +function collect(options: Parameters[1] = {}) { + const seen: ObservedFrame[] = []; + return { seen, ingest: createDebugIngest((f) => seen.push(f), options) }; +} + +describe("ingest resolves role from the wire table", () => { + test("role is a pure function of frameId, across every leg of a method", () => { + const { seen, ingest } = collect({ methodNames: METHOD_NAMES }); + + // A request/response pair and a subscription's start/receive legs. Each id + // carries its own role on the wire table; none of them needs correlation + // state, and they arrive here out of any lifecycle order on purpose. + ingest(envelope("p:1", W.ACCOUNT_GET_ACCOUNT.response)); + ingest(envelope("p:1", W.ACCOUNT_GET_ACCOUNT.request)); + ingest(envelope("p:2", W.ACCOUNT_CONNECTION_STATUS_SUBSCRIBE.receive)); + ingest(envelope("p:2", W.ACCOUNT_CONNECTION_STATUS_SUBSCRIBE.start)); + + expect(seen.map((f) => f.role)).toEqual([ + "response", + "request", + "receive", + "start", + ]); + }); + + test("an off-table id and a map-less ingest both fall back to unknown", () => { + const withMap = collect({ methodNames: METHOD_NAMES }); + // 250 is above every id the current table assigns. + withMap.ingest(envelope("p:1", 250)); + expect(withMap.seen[0]?.role).toBe("unknown"); + + const withoutMap = collect(); + withoutMap.ingest(envelope("p:1", W.ACCOUNT_GET_ACCOUNT.request)); + expect(withoutMap.seen[0]?.role).toBe("unknown"); + }); + + test("an undecodable frame is a malformed sentinel, not a drop", () => { + const { seen, ingest } = collect({ methodNames: METHOD_NAMES }); + ingest({ channelId: "myapp.dot", dir: "out", frame: new Uint8Array([0xff]) }); + + expect(seen).toHaveLength(1); + expect(seen[0]).toMatchObject({ + role: "malformed", + requestId: "malformed", + frameId: -1, + byteLength: 1, + }); + }); +}); + +describe("every consumer sees the resolved role, not just the view adapter", () => { + test("the formatted sink line names the role, not 'unknown'", () => { + const lines: string[] = []; + const wireDebugger = createWireDebugger({ + methodNames: METHOD_NAMES, + sink: (line) => lines.push(line), + }); + const ingest = createDebugIngest(wireDebugger.observe, { + methodNames: METHOD_NAMES, + }); + + ingest(envelope("p:1", W.ACCOUNT_GET_ACCOUNT.request)); + + // This is the line the default `console.debug` sink prints. It read + // "-> unknown account.getAccount" while role was resolved only downstream. + expect(lines[0]).toBe( + `[wire p:1] → request account.getAccount (id=${W.ACCOUNT_GET_ACCOUNT.request}, 1B)`, + ); + }); + + test("the forward hook receives the resolved role", () => { + const forwarded: ObservedFrame[] = []; + const wireDebugger = createWireDebugger({ + methodNames: METHOD_NAMES, + sink: () => {}, + forward: (frame) => forwarded.push(frame), + }); + const ingest = createDebugIngest(wireDebugger.observe, { + methodNames: METHOD_NAMES, + }); + + ingest(envelope("p:1", W.ACCOUNT_GET_ACCOUNT.request)); + + expect(forwarded).toHaveLength(1); + expect(forwarded[0]?.role).toBe("request"); + }); +}); + +describe("ingest bounds ids and gates raw bytes", () => { + test("channelId and requestId over the bound are digested, not sliced", () => { + const long = "x".repeat(DEFAULT_MAX_ID_CHARS + 100); + const { seen, ingest } = collect({ methodNames: METHOD_NAMES }); + + ingest( + envelope(long, W.ACCOUNT_GET_ACCOUNT.request, new Uint8Array([0]), "out", long), + ); + + // A slice of the id would be a prefix of it and would keep the whole 356-char + // parent string alive (JSC/V8 both back `slice` with a view of the parent, so + // a 250k-char id retains 250k chars while accounting for 256). The digest + // references nothing. + for (const id of [seen[0]?.channelId, seen[0]?.requestId]) { + expect(id).toBe(normalizeId(long)); + expect(long.startsWith(id ?? "")).toBe(false); + expect((id ?? "").length).toBeLessThan(40); + // The length the host actually sent stays visible to the operator. + expect(id).toContain(`:${String(long.length)}`); + } + }); + + test("two ids sharing the bound-length prefix stay two ops", () => { + // The consequence of truncating: these differ only past the cap, so they + // clamped to the same key, merged into one trace, and manufactured a + // roundTripMs between two unrelated ops (while clearing the `orphaned` badge + // each of them had earned). + const shared = "x".repeat(DEFAULT_MAX_ID_CHARS); + const wireDebugger = createWireDebugger({ + methodNames: METHOD_NAMES, + sink: () => {}, + }); + const ingest = createDebugIngest(wireDebugger.observe, { + methodNames: METHOD_NAMES, + }); + + ingest(envelope(`${shared}a`, W.ACCOUNT_GET_ACCOUNT.request)); + ingest(envelope(`${shared}b`, W.ACCOUNT_GET_ACCOUNT.request)); + + const traces = wireDebugger.traces(); + expect(traces).toHaveLength(2); + expect(new Set(traces.map((t) => t.requestId)).size).toBe(2); + }); + + test("ids within the bound are passed through untouched", () => { + const { seen, ingest } = collect({ methodNames: METHOD_NAMES }); + ingest(envelope("p:1", W.ACCOUNT_GET_ACCOUNT.request)); + expect(seen[0]?.requestId).toBe("p:1"); + expect(seen[0]?.channelId).toBe("myapp.dot"); + expect(normalizeId("x".repeat(DEFAULT_MAX_ID_CHARS))).toHaveLength( + DEFAULT_MAX_ID_CHARS, + ); + }); + + test("maxIdChars overrides the default bound", () => { + const { seen, ingest } = collect({ maxIdChars: 4 }); + ingest(envelope("p:1234567890", W.ACCOUNT_GET_ACCOUNT.request)); + expect(seen[0]?.requestId).toBe(normalizeId("p:1234567890", 4)); + expect(seen[0]?.requestId).not.toBe("p:12"); + }); + + test("raw bytes are attached only under retainBytes", () => { + const off = collect({ methodNames: METHOD_NAMES }); + off.ingest(envelope("p:1", W.ACCOUNT_GET_ACCOUNT.request, new Uint8Array([7]))); + expect(off.seen[0]?.bytes).toBeUndefined(); + // Byte length is recorded either way. + expect(off.seen[0]?.byteLength).toBe(1); + + const on = collect({ methodNames: METHOD_NAMES, retainBytes: true }); + on.ingest(envelope("p:1", W.ACCOUNT_GET_ACCOUNT.request, new Uint8Array([7]))); + expect(Array.from(on.seen[0]?.bytes ?? [])).toEqual([7]); + }); + + test("the product-vantage direction is carried through untouched", () => { + const { seen, ingest } = collect({ methodNames: METHOD_NAMES }); + ingest(envelope("p:1", W.ACCOUNT_GET_ACCOUNT.request, new Uint8Array([0]), "out")); + ingest(envelope("p:1", W.ACCOUNT_GET_ACCOUNT.response, new Uint8Array([0]), "in")); + expect(seen.map((f) => f.direction)).toEqual(["out", "in"]); + }); +}); + +/** + * A host tap buffers a backlog while the debugger is absent and flushes it in one + * loop on connect. If the ingest clock is the only clock, that loop stamps every + * frame of the whole session with the same instant: durations collapse to 0ms and + * ops minutes apart fall inside the retry-storm window. These cover both halves + * against the real trace engine and the real storm detector. + */ +describe("a flushed backlog keeps the producer's clock, not the flush instant", () => { + /** Feed envelopes through a real ingest into a real trace engine. */ + function traceEngine() { + const wireDebugger = createWireDebugger({ + methodNames: METHOD_NAMES, + sink: () => {}, + }); + return { + traces: () => wireDebugger.traces(), + ingest: createDebugIngest(wireDebugger.observe, { + methodNames: METHOD_NAMES, + }), + }; + } + + test("a 500ms round trip stays 500ms after the flush", () => { + const engine = traceEngine(); + // One op whose two frames genuinely crossed 500ms apart, both replayed out of + // the backlog in the same loop long afterwards. + engine.ingest(flushed(1_000_000, "p:1", W.ACCOUNT_GET_ACCOUNT.request, "out")); + engine.ingest(flushed(1_000_500, "p:1", W.ACCOUNT_GET_ACCOUNT.response, "in")); + + const [trace] = engine.traces(); + expect(trace?.lastAt - trace?.startedAt).toBe(500); + expect(trace?.frames.map((f) => f.timestamp)).toEqual([1_000_000, 1_000_500]); + // The frames say where their clock came from, and that they were replayed. + expect(trace?.frames.every((f) => f.timestampFromProducer === true)).toBe(true); + expect(trace?.frames.every((f) => f.buffered === true)).toBe(true); + }); + + test("six ops ten seconds apart are not a retry storm", () => { + const engine = traceEngine(); + // Six `account.getAccount` calls, one every 10s: a calm session by any + // reading. Flushed together, an ingest-stamped clock puts all six inside the + // detector's 1000ms window and badges every row "retry storm". + for (let i = 0; i < 6; i++) { + engine.ingest( + flushed(1_000_000 + i * 10_000, `p:${String(i)}`, W.ACCOUNT_GET_ACCOUNT.request), + ); + } + + const traces = engine.traces(); + expect(traces).toHaveLength(6); + expect(detectRetryStorms(traces).size).toBe(0); + }); + + test("a genuine burst is still detected through a flush", () => { + const engine = traceEngine(); + // The same six ops 100ms apart really are a storm: preserving the producer's + // clock must not blunt the signal, only stop fabricating it. + for (let i = 0; i < 6; i++) { + engine.ingest( + flushed(1_000_000 + i * 100, `p:${String(i)}`, W.ACCOUNT_GET_ACCOUNT.request), + ); + } + + expect(detectRetryStorms(engine.traces()).size).toBe(6); + }); + + test("a tap that stamps no time falls back to the ingest clock and marks the frame", () => { + const { seen, ingest } = collect({ methodNames: METHOD_NAMES }); + const before = Date.now(); + ingest(flushed(undefined, "p:1", W.ACCOUNT_GET_ACCOUNT.request)); + + // Nothing better exists for such a frame, so `timestamp` is the flush instant + // - but it is flagged `buffered` with no `timestampFromProducer`, which is the + // pair a consumer keys on to suppress its duration and its storm + // participation. + expect(seen[0]?.timestamp).toBeGreaterThanOrEqual(before); + expect(seen[0]?.timestampFromProducer).toBeUndefined(); + expect(seen[0]?.buffered).toBe(true); + }); + + test("a live frame is neither buffered nor producer-stamped", () => { + const { seen, ingest } = collect({ methodNames: METHOD_NAMES }); + ingest(envelope("p:1", W.ACCOUNT_GET_ACCOUNT.request)); + expect(seen[0]?.buffered).toBeUndefined(); + expect(seen[0]?.timestampFromProducer).toBeUndefined(); + }); + + test("an unusable observedAt is refused, not trusted into the trace list", () => { + // Anything reaching the tap can put anything here, and it feeds ordering and + // every duration. + for (const observedAt of [0, -1, Number.NaN, Infinity, -Infinity]) { + const { seen, ingest } = collect({ methodNames: METHOD_NAMES }); + const before = Date.now(); + ingest({ + ...envelope("p:1", W.ACCOUNT_GET_ACCOUNT.request), + observedAt, + }); + expect(seen[0]?.timestampFromProducer).toBeUndefined(); + expect(seen[0]?.timestamp).toBeGreaterThanOrEqual(before); + } + }); + + test("a malformed frame carries the same provenance as a decodable one", () => { + const { seen, ingest } = collect({ methodNames: METHOD_NAMES }); + ingest({ + channelId: "myapp.dot", + dir: "out", + frame: new Uint8Array([0xff]), + observedAt: 1_000_000, + buffered: true, + }); + expect(seen[0]).toMatchObject({ + role: "malformed", + timestamp: 1_000_000, + timestampFromProducer: true, + buffered: true, + }); + }); +}); diff --git a/js/packages/truapi-debugger/src/ingest.ts b/js/packages/truapi-debugger/src/ingest.ts new file mode 100644 index 000000000..c81983703 --- /dev/null +++ b/js/packages/truapi-debugger/src/ingest.ts @@ -0,0 +1,279 @@ +/** + * Ingest: turn the host tap's wire envelopes into {@link ObservedFrame}s. + * + * The Rust host tap (`truapi-server`'s `DebugSink`) emits one envelope per + * frame - `{ channelId, dir, frame: bytes }`, raw SCALE, opaque to the core. + * The debugger decodes here: {@link decodeWireMessage} recovers the correlation + * `requestId` and the wire discriminant, which is everything the trace engine + * needs to group an op. This is the layer PG's design puts "in the debugger, not + * the core". + * + * @module + */ + +import { decodeWireMessage } from "@parity/truapi"; +import type { ObservedFrame, TransportObserver } from "./observed-frame.js"; +import type { WireMethodInfo } from "./wire-debugger.js"; + +/** + * Version of the host→debugger wire envelope (`{ channelId, dir, frame }`). + * Bumped when the envelope shape changes. Producers (the Rust `WsDebugSink`, the + * web host's debugger link) stamp it alongside a codec identity so the debugger + * can refuse to decode a frame against a wire contract that isn't its own - + * frame ids are `u8` discriminants that get reassigned as the API evolves, so an + * unversioned envelope from an older host would resolve to the wrong method and + * the wrong value. + */ +export const WIRE_ENVELOPE_VERSION = 1; + +/** + * Default cap on `channelId` / `requestId` length, above which the id is + * replaced by a digest ({@link normalizeId}). Shared so the debugger server's + * channel registry normalizes to the same bound as ingest and the two keys stay + * equal (the UI filters by the normalized key). + */ +export const DEFAULT_MAX_ID_CHARS = 256; + +/** + * FNV-1a over `text`'s UTF-16 code units, in 32 bits. Not cryptographic: this + * only has to keep two *distinct* ids distinct, which a shared prefix does not. + */ +function fnv1a32(text: string, seed: number): number { + let hash = seed >>> 0; + for (let i = 0; i < text.length; i++) { + hash ^= text.charCodeAt(i); + hash = Math.imul(hash, 0x01000193) >>> 0; + } + return hash >>> 0; +} + +/** Two independently-seeded FNV-1a passes, as 16 hex chars. */ +function digest(text: string): string { + const lo = fnv1a32(text, 0x811c9dc5).toString(16).padStart(8, "0"); + const hi = fnv1a32(text, 0x9dc5811c).toString(16).padStart(8, "0"); + return `${lo}${hi}`; +} + +/** + * Bound an id's retained length: returned unchanged when it is within + * `maxChars`, otherwise replaced by `…:`. + * + * A digest, not a truncation, for two reasons. + * + * - Retention. `String.prototype.slice` yields a view that keeps its *parent* + * alive in both JSC and V8, so truncating a 250k-char id retains the whole + * 250k chars while accounting for 256 - and `retainBytes: false` is no + * mitigation, because the ids are retained on every {@link ObservedFrame} + * regardless. The digest is computed arithmetically, so nothing references the + * input. + * - Identity. Two distinct ids sharing a `maxChars` prefix truncate to the same + * key and merge into one trace, fabricating a `roundTripMs` between two + * unrelated ops and clearing a genuinely `orphaned` badge. Distinct ids digest + * to distinct keys. + * + * Rejecting the frame instead would take the op dark, which is the opposite of + * the ingest's own rule for input it cannot use (an undecodable frame becomes a + * `"malformed"` sentinel, never a drop), and it would discard legitimate frames + * from any host whose ids are merely long. The digest keeps the op observable + * and correlatable while bounding what is retained. + * + * The length suffix is diagnostic: it says how long the id the host sent + * actually was, which is the fact an operator needs to see. + */ +export function normalizeId( + id: string, + maxChars: number = DEFAULT_MAX_ID_CHARS, +): string { + if (id.length <= maxChars) return id; + return `…${digest(id)}:${String(id.length)}`; +} + +/** + * One wire frame as it crosses the host tap, matching the Rust + * `DebugEvent::Frame { channel_id, dir, bytes }`. `frame` is the untouched + * `ProtocolMessage` bytes; the debugger owns all decoding. + */ +export interface DebugFrameEnvelope { + /** Product channel the frame belongs to, e.g. `"myapp.dot"`. */ + channelId: string; + /** + * Product-vantage: `out` left the product, `in` arrived at it. The Rust host + * tap names directions host-vantage internally and flips to this convention + * on the wire (`FrameDirection::wire_str`), so both ends agree here. + */ + dir: "in" | "out"; + /** Raw SCALE `ProtocolMessage` bytes. */ + frame: Uint8Array; + /** + * Epoch ms at which the *producer* saw the frame cross the tap, stamped by the + * host link at emit time. + * + * The debugger's own clock cannot stand in for this. A host tap buffers a + * backlog while the debugger is absent and flushes it in one loop on connect, + * so every frame of a session that ran before the debugger started would be + * stamped with the same flush instant: durations collapse to 0ms and ops + * minutes apart land inside the retry-storm window. The producer is the only + * party that knows when a frame actually crossed. + * + * Optional because a host may not stamp it (a pre-identity or foreign tap); + * such frames fall back to the ingest clock and are marked as such - see + * {@link ObservedFrame.timestampFromProducer}. + */ + observedAt?: number; + /** + * The producer replayed this frame from its backlog rather than streaming it + * live, so its arrival order and arrival time are the link's, not the + * session's. Piggybacked on the envelope the same way `dropped` is. + */ + buffered?: boolean; +} + +// Both fields below are produced *only* here, and `ObservedFrame` is the contract +// every consumer reads, so they are declared onto it rather than pushing every +// consumer through an ingest-specific subtype. Fold them into +// `observed-frame.ts` proper when that file is next touched. +declare module "./observed-frame.js" { + interface ObservedFrame { + /** + * The producer replayed this frame from its backlog (the debugger was absent + * or slow) instead of streaming it live. Present only when true. + * + * Provenance, not a verdict on `timestamp`: a buffered frame that also + * carries {@link ObservedFrame.timestampFromProducer} has a real observation + * time and its timings are sound. A buffered frame *without* it has only the + * flush instant, and every duration derived from it - `roundTripMs`, the + * retry-storm window - is meaningless. + */ + buffered?: true; + /** + * `timestamp` is the producer's own observation time rather than the moment + * ingest decoded the frame. Present only when true. + */ + timestampFromProducer?: true; + } +} + +/** + * An `observedAt` fit to be used as a timestamp, or `undefined`. + * + * Anything able to reach the tap can put anything in this field, and it feeds + * trace ordering and every duration, so a non-finite or non-positive value falls + * back to the ingest clock rather than poisoning the trace list. + */ +function producerTimestamp(observedAt: number | undefined): number | undefined { + if (typeof observedAt !== "number") return undefined; + // `isSafeInteger`, not merely finite: `1e308` is a finite positive number and + // was accepted as an epoch-ms timestamp, which made `durationMs` overflow to + // `Infinity` and serialize as JSON `null` on /stats - a hole in the payload a + // client parses back. An epoch-ms value is a safe integer by construction. + if (!Number.isSafeInteger(observedAt) || observedAt <= 0) return undefined; + return observedAt; +} + +/** Options for {@link createDebugIngest}. */ +export interface DebugIngestOptions { + /** + * Retain each frame's raw SCALE bytes on the {@link ObservedFrame}. Off by + * default: byte length is always recorded, but the bytes themselves are the + * dev-only opt-in that level-2 decode needs. `/traces` never serializes them + * either way; retaining them only makes the drill-down decoder able to run. + */ + retainBytes?: boolean; + /** + * Reverse map from wire `frameId` to method info (build one with + * {@link createMethodNameMap}). When set, each frame's lifecycle `role` is + * resolved here from the frame id's wire-table `kind`, so *every* consumer - + * the default console sink, the `forward` hook, and the trace engine - sees the + * real role. Without it, `role` is left `"unknown"` and only the view adapter + * recovers it. + */ + methodNames?: ReadonlyMap; + /** + * Length above which a `channelId` / `requestId` is replaced by a digest + * ({@link normalizeId}). Anything able to reach the host tap could otherwise + * send 200k-char ids, one copy per frame; real ids are short (`myapp.dot`, + * `p:1`). Default 256. + */ + maxIdChars?: number; +} + +/** + * Ingest that decodes each {@link DebugFrameEnvelope} and forwards the resulting + * {@link ObservedFrame} to `sink` (typically a {@link WireDebugger}'s `observe`). + * + * `role` is a pure function of the frame's wire discriminant: the generated wire + * table already states, per `frameId`, which leg of a method it is, so `role` is + * resolved here from `methodNames` rather than reconstructed from correlation + * state. Resolving it at ingest is what makes it true for *every* consumer - + * the default `console.debug` sink, the `forward` hook, and the trace engine - + * instead of only for the view adapter, which resolves one layer further down + * (`wireTraceToView`) and would leave the other two reading `"unknown"`. + * + * `role` falls back to `"unknown"` in exactly two cases: no `methodNames` map was + * given, or the id is off-table (a frame from a newer host). An undecodable frame + * is surfaced as a `"malformed"` sentinel rather than dropped, so the trace + * records the failure instead of going dark. + * + * Raw payload bytes are attached only when `retainBytes` is set - the dev-only + * byte-exposure opt-in that the level-2 decoder consumes; otherwise a frame + * carries its byte length and no payload. + * + * `timestamp` is the producer's `observedAt` whenever the tap stamped a usable + * one, and the ingest clock otherwise. Which of the two it is, and whether the + * frame was replayed from the tap's backlog, are recorded on the frame + * ({@link ObservedFrame.timestampFromProducer}, {@link ObservedFrame.buffered}), + * because a flushed backlog arrives in a single loop: read as observation times, + * those instants collapse every duration to 0ms and pull ops minutes apart into + * one retry-storm window. + */ +export function createDebugIngest( + sink: TransportObserver, + options: DebugIngestOptions = {}, +): (envelope: DebugFrameEnvelope) => void { + const retainBytes = options.retainBytes ?? false; + const methodNames = options.methodNames; + const maxIdChars = options.maxIdChars ?? DEFAULT_MAX_ID_CHARS; + return (envelope) => { + const channelId = normalizeId(envelope.channelId, maxIdChars); + // Prefer the producer's observation time; the ingest clock is a fallback, and + // one that is wrong by the whole duration of the session for a flushed + // backlog. `provenance` is what lets a consumer tell the two apart instead of + // reading every timestamp as an observation time. + const producerAt = producerTimestamp(envelope.observedAt); + const timestamp = producerAt ?? Date.now(); + const provenance = { + ...(envelope.buffered === true ? { buffered: true as const } : {}), + ...(producerAt !== undefined ? { timestampFromProducer: true as const } : {}), + }; + const decoded = decodeWireMessage(envelope.frame); + if (decoded.isErr()) { + sink({ + channelId, + direction: envelope.dir, + requestId: "malformed", + frameId: -1, + role: "malformed", + byteLength: envelope.frame.length, + timestamp, + ...provenance, + }); + return; + } + const { requestId, payload } = decoded.value; + const frame: ObservedFrame = { + channelId, + direction: envelope.dir, + requestId: normalizeId(requestId, maxIdChars), + frameId: payload.id, + // Resolve the lifecycle role from the frame id's wire-table kind (the same + // kind wireTraceToView falls back to). Left "unknown" when no map is given + // or the id is off-table. + role: methodNames?.get(payload.id)?.kind ?? "unknown", + byteLength: payload.value.length, + timestamp, + ...provenance, + ...(retainBytes ? { bytes: payload.value } : {}), + }; + sink(frame); + }; +} diff --git a/js/packages/truapi-debugger/src/inspector-styles.ts b/js/packages/truapi-debugger/src/inspector-styles.ts new file mode 100644 index 000000000..758844269 --- /dev/null +++ b/js/packages/truapi-debugger/src/inspector-styles.ts @@ -0,0 +1,239 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: MIT +/** + * The inspector chrome: every rule the Network-tab shell needs that is not a + * per-frame drill-down rule (those live in `trace-styles.ts`). + * + * Shared so the two mounts cannot drift apart visually. The standalone app puts + * the shell in a full page; the in-app embed puts the same shell in a panel + * inside the host. Neither owns these rules, so a change lands in both. + * + * The rules are written FLAT (`.ins-top`, `.td-op`, …) because that is correct + * for the standalone: it owns its document, and flat rules keep the shared source + * readable and diffable against dotli's stylesheet. An embed shares a document + * with the host application, where a flat `.td-*` rule would restyle the host's + * own debug panel, so the embed does not inject these constants directly - it runs + * them through {@link scopeCss} first. That keeps one source of truth with two + * correct injections instead of a second, pre-scoped copy. + * + * Deliberately free of page-level rules (`html`, `body`, viewport units): a mount + * scopes its own container, and an embed must never restyle its host's page. + * + * @module + */ + +/** + * At-rules whose body is a list of style rules, so scoping recurses into it. + * Anything else with a block (`@keyframes`, `@font-face`, `@property`) has a body + * that is NOT selectors and is passed through untouched. + */ +const NESTED_AT_RULES: ReadonlySet = new Set([ + "media", + "supports", + "layer", + "container", +]); + +/** Index of the `}` matching the `{` at `open`, or the end of the string. */ +function matchBrace(css: string, open: number): number { + let depth = 0; + for (let i = open; i < css.length; i++) { + const c = css[i]; + // A quoted value may contain a brace (`content: "}"`); skip the string. + if (c === '"' || c === "'") { + const end = css.indexOf(c, i + 1); + if (end === -1) return css.length; + i = end; + continue; + } + if (c === "{") depth += 1; + else if (c === "}") { + depth -= 1; + if (depth === 0) return i; + } + } + return css.length; +} + +/** Prefix every selector in a comma-separated list with `scope`. */ +function scopeSelectorList(selectors: string, scope: string): string { + return selectors + .split(",") + .map((s) => s.trim()) + .filter((s) => s !== "") + .map((s) => `${scope} ${s}`) + .join(", "); +} + +/** + * Rewrite every rule in `css` so it only matches inside `scope`. + * + * This is what lets one flat shared stylesheet serve both mounts: the standalone + * injects the constants as-is (it owns the page), and an embed injects + * `scopeCss(css, ".td-inapp")` so not one rule can reach the host application's + * own markup. Every selector is prefixed, so relative precedence inside the + * block is unchanged (each selector gains the same specificity) - the cascade the + * standalone sees is the cascade the embed sees. + * + * `scope` is a selector (`".td-inapp"`), not a class name. Rules that target the + * mount root itself are the mount's own business and are written already-scoped, + * not passed through here. + */ +export function scopeCss(css: string, scope: string): string { + // Comments can contain braces and selectors; drop them before parsing. + return scopeRules(css.replace(/\/\*[\s\S]*?\*\//g, ""), scope); +} + +/** Scope one block's worth of rules (top level, or an at-rule body). */ +function scopeRules(css: string, scope: string): string { + const out: string[] = []; + let i = 0; + while (i < css.length) { + const brace = css.indexOf("{", i); + if (brace === -1) break; + let prelude = css.slice(i, brace).trim(); + const end = matchBrace(css, brace); + const body = css.slice(brace + 1, end); + // Statement at-rules (`@import`, `@charset`) end in `;` and carry no block; + // they must stay verbatim and at the top, so split them off the prelude. + const semi = prelude.lastIndexOf(";"); + if (semi !== -1) { + out.push(prelude.slice(0, semi + 1).trim()); + prelude = prelude.slice(semi + 1).trim(); + } + if (prelude.startsWith("@")) { + const name = /^@([\w-]+)/.exec(prelude)?.[1] ?? ""; + out.push( + NESTED_AT_RULES.has(name) + ? `${prelude} {\n${scopeRules(body, scope)}\n}` + : `${prelude} {${body}}`, + ); + } else if (prelude === "") { + out.push(`{${body}}`); + } else { + out.push(`${scopeSelectorList(prelude, scope)} {${body}}`); + } + i = end + 1; + } + return out.join("\n"); +} + +/** + * The shell: top bar, channel chips, the list/detail split, and operation rows. + * Pair with {@link TRACE_DETAIL_CSS} and {@link INSPECTOR_LAYOUT_CSS}. + */ +export const INSPECTOR_SHELL_CSS = ` + .ins-top { display: flex; align-items: center; gap: 12px; padding: 6px 12px; + border-bottom: 1px solid rgba(255,255,255,.08); } + .ins-title { font-weight: 600; letter-spacing: .02em; white-space: nowrap; } + .ins-title .accent { color: #4ade80; } + .ins-channels { display: flex; gap: 6px; flex: 1; flex-wrap: wrap; } + .ins-chan { display: inline-flex; align-items: center; gap: 5px; padding: 1px 9px; + border: 1px solid rgba(255,255,255,.12); border-radius: 10px; background: transparent; + color: #94a3b8; cursor: pointer; font: inherit; } + .ins-chan.active { color: #0a0a0a; background: #4ade80; border-color: #4ade80; } + .ins-chan .dot { width: 6px; height: 6px; border-radius: 50%; background: #4b5563; } + .ins-chan .dot.live { background: #4ade80; box-shadow: 0 0 4px #4ade80; } + .ins-chan.active .dot.live { background: #0a0a0a; box-shadow: none; } + .ins-body { display: grid; grid-template-columns: var(--list-w, 340px) 6px 1fr; + min-height: 0; } + .ins-list { overflow: auto; outline: none; } + .ins-split { cursor: col-resize; background: rgba(255,255,255,.05); } + .ins-split:hover { background: rgba(74,222,128,.4); } + .ins-detail { overflow: auto; padding: 8px 12px; outline: none; } + .td-op { display: flex; align-items: center; gap: 8px; padding: 4px 10px; + cursor: pointer; border-bottom: 1px solid rgba(255,255,255,.03); } + .td-op:hover { background: rgba(255,255,255,.04); } + .td-op.selected { background: rgba(74,222,128,.13); } + .ins-list:focus-visible .td-op.selected { box-shadow: inset 2px 0 0 #4ade80; } + .td-op-kind { width: 12px; text-align: center; } + .td-op-req .td-op-kind { color: #fbbf24; } + .td-op-sub .td-op-kind { color: #c084fc; } + /* Truncate the *start*, not the end: sibling methods share a service prefix + (account.getAccount vs account.getAccountAlias), so clipping the tail + renders two different methods identically. Keeping the tail makes them + distinguishable in a narrow list. */ + .td-op-method { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + direction: rtl; text-align: left; } + .td-op-method.anon { color: #525252; font-style: italic; } + .td-op-meta { color: #6b7280; font-size: 10.5px; white-space: nowrap; } + .td-op-live .td-op-meta { color: #4ade80; } + /* An op that went out and is still unanswered: counts up amber, and reads as a + problem rather than a completed 0ms call. + + PRECEDENCE (pinned by a test): a live subscription whose start frame was + never answered carries BOTH td-op-live and td-op-waiting, and waiting must + win - the row is reporting a stall, not health. Two guards, because either + alone is one edit away from silently flipping the colour back to green: this + rule sits AFTER the .td-op-live rule, and the extra .td-op raises its + specificity above it. */ + .td-op.td-op-waiting .td-op-meta { color: #fbbf24; } + .td-op-badges { display: inline-flex; gap: 4px; } + .td-op-empty, .td-detail-empty { color: #6b7280; padding: 14px; } + .td-frame.cursor { background: rgba(255,255,255,.06); box-shadow: inset 2px 0 0 #94a3b8; } +`; + +/** + * App-level layout applied on top of the shared drill-down rules: the two-column + * frame grid, the filter/sort controls, and the aggregate summary strip. + * Applied after {@link TRACE_DETAIL_CSS} because it overrides some of it. + */ +export const INSPECTOR_LAYOUT_CSS = ` + /* App-level layout for the drill-down (trace-styles.ts stays untouched). + Each frame is a two-column grid: meta on the left, a fixed-width payload + column on the right, so every frame's decoded / blurred box opens in the + same aligned partitioned space instead of trailing variable-width meta. */ + .ins-detail { padding: 6px 10px 10px; } + .td-frame { display: grid; align-items: start; column-gap: 10px; + grid-template-columns: minmax(0, 1fr); padding: 4px 8px; } + .td-frame:has(.td-frame-payload) { + grid-template-columns: minmax(0, 1fr) var(--payload-w, clamp(240px, 44%, 520px)); } + .td-frame-meta { display: flex; align-items: center; gap: 8px; min-width: 0; } + .td-frame-meta .td-frame-method { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .td-frames .td-frame:nth-child(even) { background: rgba(255,255,255,.02); } + .td-frame:hover { background: rgba(255,255,255,.05); } + /* The payload column: same width for every frame; content scrolls inside. */ + .td-frame-payload { min-width: 0; } + .td-frame-decoded > * { margin: 0; } + .td-frame-decoded .td-detail-pre { max-height: 240px; overflow: auto; margin: 0; + white-space: pre; } + /* Top-bar filter / sort controls. */ + .ins-filter { width: 148px; padding: 2px 8px; border: 1px solid rgba(255,255,255,.14); + border-radius: 5px; background: rgba(255,255,255,.03); color: #e0e0e0; font: inherit; } + .ins-filter:focus { outline: none; border-color: rgba(74,222,128,.5); } + .ins-sort { padding: 2px 6px; border: 1px solid rgba(255,255,255,.14); border-radius: 5px; + background: #0a0a0a; color: #cbd5e1; font: inherit; cursor: pointer; } + .td-op.filtered-out { display: none; } + /* Clickable top-method pills. */ + .ins-method { cursor: pointer; } + .ins-method:hover { border-color: rgba(74,222,128,.5); color: #d1fae5; } + /* Aggregate summary strip: the "at a glance" row of metric tiles. */ + .ins-summary { display: flex; gap: 6px; align-items: flex-start; flex-wrap: nowrap; + padding: 6px 12px; border-bottom: 1px solid rgba(255,255,255,.08); + background: rgba(255,255,255,.02); overflow-x: auto; } + .ins-stat { display: flex; flex-direction: column; gap: 1px; padding: 2px 10px 2px 0; + border-right: 1px solid rgba(255,255,255,.06); } + .ins-stat:last-child { border-right: 0; } + .ins-stat .n { font-size: 14px; font-weight: 600; color: #f1f5f9; + font-variant-numeric: tabular-nums; line-height: 1.15; } + .ins-stat .k { font-size: 9.5px; text-transform: uppercase; letter-spacing: .06em; color: #64748b; } + .ins-stat.warn .n { color: #f87171; } + .ins-stat.warn.zero .n { color: #475569; } + .ins-stat.good .n { color: #4ade80; } + .ins-stat .sub { color: #64748b; font-weight: 400; font-size: 10px; } + /* Pills stay on one row, pushed right; when the viewport is too narrow the + whole summary scrolls (overflow-x above) rather than the pills wrapping to a + second line. */ + .ins-methods { display: flex; align-items: center; gap: 6px; margin-left: auto; + flex: 0 0 auto; flex-wrap: nowrap; } + .ins-method { white-space: nowrap; } + .ins-method { display: inline-flex; align-items: center; gap: 5px; padding: 1px 8px; + border: 1px solid rgba(255,255,255,.08); border-radius: 10px; color: #94a3b8; + font-size: 10.5px; white-space: nowrap; } + .ins-method b { color: #cbd5e1; font-variant-numeric: tabular-nums; } + .ins-summary.empty { color: #64748b; } + .ins-status { display: flex; gap: 16px; padding: 4px 12px; color: #6b7280; + border-top: 1px solid rgba(255,255,255,.08); } + .ins-status .live { color: #4ade80; } + .ins-status .mismatch { color: #f87171; } +`; diff --git a/js/packages/truapi-debugger/src/observed-frame.ts b/js/packages/truapi-debugger/src/observed-frame.ts new file mode 100644 index 000000000..cea816c21 --- /dev/null +++ b/js/packages/truapi-debugger/src/observed-frame.ts @@ -0,0 +1,68 @@ +/** + * The frame model the debugger works in. + * + * A host tap streams raw wire frames as `{ channelId, dir, frame: bytes }` + * envelopes; {@link createDebugIngest} decodes each one into an + * {@link ObservedFrame} - correlation id, wire discriminant, byte length, and + * (dev-only) the raw bytes - which the trace and host engines consume. The core + * never decodes; decoding happens here, in the debugger. + * + * @module + */ + +/** + * Direction of an observed wire frame relative to the product: `out` left the + * product, `in` arrived at it. + */ +export type FrameDirection = "out" | "in"; + +/** + * Role of an observed frame within the request/subscription lifecycle, derived + * from its wire discriminant against the method's frame ids. + */ +export type FrameRole = + | "request" + | "response" + | "start" + | "stop" + | "receive" + | "interrupt" + | "handshake" + | "malformed" + | "unknown"; + +/** + * A single decoded wire frame. Carries the correlation `requestId`, the wire + * discriminant, a best-effort lifecycle `role`, and the encoded byte length. + * The raw `bytes` are present only when byte exposure is enabled - a dev-only + * opt-in, since the raw wire can carry key material. + */ +export interface ObservedFrame { + /** + * Product channel the frame crossed, e.g. `"myapp.dot"`. Carried from the + * host tap envelope. Because `requestId` is minted per transport (each host + * mints `p:1`, `p:2`, …), it is unique only *within* a channel; grouping and + * lookups key on `(channelId, requestId)` so two hosts' ops never merge. + */ + channelId: string; + /** Whether the frame was sent by the product (`out`) or received by it (`in`). */ + direction: FrameDirection; + /** Correlation id shared by every frame of one request/subscription, within a channel. */ + requestId: string; + /** Wire-table numeric discriminant of the frame's payload. */ + frameId: number; + /** Best-effort lifecycle role inferred from the frame id. */ + role: FrameRole; + /** Encoded SCALE payload length in bytes. */ + byteLength: number; + /** Epoch ms at which the frame was observed. */ + timestamp: number; + /** The raw SCALE payload bytes, present only when byte exposure is enabled. */ + bytes?: Uint8Array; +} + +/** + * Emit-only consumer of observed frames. The trace engine's + * {@link WireDebugger.observe} is one; a host relay is another. + */ +export type TransportObserver = (frame: ObservedFrame) => void; diff --git a/js/packages/truapi-debugger/src/operation-row.test.ts b/js/packages/truapi-debugger/src/operation-row.test.ts new file mode 100644 index 000000000..75b7bf66b --- /dev/null +++ b/js/packages/truapi-debugger/src/operation-row.test.ts @@ -0,0 +1,129 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: MIT + +import { describe, expect, test } from "bun:test"; +import type { ObservedFrame, FrameRole } from "./observed-frame.js"; +import type { WireMethodInfo, WireTrace } from "./wire-debugger.js"; +import { wireTraceToView } from "./trace-view.js"; +import { renderOperationRow } from "./trace-render.js"; + +function frame( + role: FrameRole, + frameId: number, + timestamp: number, +): ObservedFrame { + return { + direction: role === "response" || role === "receive" ? "in" : "out", + requestId: "p:1", + frameId, + role, + byteLength: 8, + timestamp, + }; +} + +function traceOf(frames: ObservedFrame[]): WireTrace { + return { + channelId: "host-a.dot", + requestId: "p:1", + frames, + startedAt: frames[0]?.timestamp ?? 0, + lastAt: frames[frames.length - 1]?.timestamp ?? 0, + }; +} + +const methodNames: ReadonlyMap = new Map([ + [22, { method: "account.getAccount", kind: "request" }], + [23, { method: "account.getAccount", kind: "response" }], + [40, { method: "account.connectionStatus", kind: "start" }], + [41, { method: "account.connectionStatus", kind: "receive" }], + [42, { method: "account.connectionStatus", kind: "stop" }], +]); + +describe("renderOperationRow", () => { + test("request/response op: method, frame count, duration, request glyph", () => { + const view = wireTraceToView( + traceOf([frame("request", 22, 1000), frame("response", 23, 1120)]), + methodNames, + ); + const html = renderOperationRow(view); + expect(html).toContain("account.getAccount"); + expect(html).toContain("2 frames"); + expect(html).toContain("120ms"); + expect(html).toContain("td-op-req"); + expect(html).toContain('data-request-id="p:1"'); + expect(html).not.toContain("td-op-live"); + }); + + test("subscription with no stop is marked live", () => { + const view = wireTraceToView( + traceOf([ + frame("start", 40, 1000), + frame("receive", 41, 1100), + frame("receive", 41, 1200), + ]), + methodNames, + ); + const html = renderOperationRow(view); + expect(html).toContain("td-op-sub"); + expect(html).toContain("td-op-live"); + expect(html).toContain("live"); + }); + + test("subscription with a stop is not live", () => { + const view = wireTraceToView( + traceOf([ + frame("start", 40, 1000), + frame("receive", 41, 1100), + frame("stop", 42, 1300), + ]), + methodNames, + ); + const html = renderOperationRow(view); + expect(html).toContain("td-op-sub"); + expect(html).not.toContain("td-op-live"); + }); + + test("op badges render as chips (orphaned request)", () => { + const view = wireTraceToView(traceOf([frame("request", 22, 1000)]), methodNames); + const html = renderOperationRow(view); + expect(html).toContain("td-badge-orphaned"); + }); + + test("carries channelId as a data attribute when present", () => { + const base = wireTraceToView( + traceOf([frame("request", 22, 1000), frame("response", 23, 1100)]), + methodNames, + ); + const view = { ...base, channelId: "host-a.dot" }; + const html = renderOperationRow(view); + expect(html).toContain('data-channel-id="host-a.dot"'); + }); + + test("omits data-channel-id when the vantage has no channel", () => { + const base = wireTraceToView( + traceOf([frame("request", 22, 1000), frame("response", 23, 1100)]), + methodNames, + ); + const view = { ...base, channelId: undefined }; + expect(renderOperationRow(view)).not.toContain("data-channel-id"); + }); + + test("payload-blind: never emits a decoded value", () => { + const view = wireTraceToView( + traceOf([frame("request", 22, 1000), frame("response", 23, 1100)]), + methodNames, + ); + const html = renderOperationRow(view); + expect(html).not.toContain("decode"); + expect(html).not.toContain(" { + const base = wireTraceToView(traceOf([frame("request", 22, 1000)])); + const view = { ...base, requestId: '">' }; + const html = renderOperationRow(view); + expect(html).not.toContain(", +): string[] { + return [...map.keys()].map((t) => t.requestId).sort(); +} + +describe("detectRetryStorms", () => { + test("flags a burst of like ops in a short window", () => { + const traces = [ + trace("a", 30, 0), + trace("b", 30, 200), + trace("c", 30, 400), + ]; + const storms = detectRetryStorms(traces); + expect(stormedIds(storms)).toEqual(["a", "b", "c"]); + expect(storms.get(traces[0])).toEqual(["retry-storm"]); + }); + + test("does not flag a burst below the threshold", () => { + const storms = detectRetryStorms([trace("a", 30, 0), trace("b", 30, 100)]); + expect(storms.size).toBe(0); + }); + + test("does not flag like ops spread wider than the window", () => { + const storms = detectRetryStorms([ + trace("a", 30, 0), + trace("b", 30, 1500), + trace("c", 30, 3000), + ]); + expect(storms.size).toBe(0); + }); + + test("groups by op signature — only the bursting method storms", () => { + // Three createTransaction (id 30) inside 400ms = a storm; two getAccount + // (id 22) far apart are not, even interleaved in time. + const traces = [ + trace("sign-1", 30, 0), + trace("get-1", 22, 50), + trace("sign-2", 30, 150), + trace("get-2", 22, 5000), + trace("sign-3", 30, 300), + ]; + const storms = detectRetryStorms(traces); + expect(stormedIds(storms)).toEqual(["sign-1", "sign-2", "sign-3"]); + }); + + test("flags only the dense sub-window within a longer sparse run", () => { + // Two early, far-apart ops then a tight burst of three: only the burst. + const traces = [ + trace("x", 30, 0), + trace("y", 30, 4000), + trace("b1", 30, 8000), + trace("b2", 30, 8300), + trace("b3", 30, 8600), + ]; + const storms = detectRetryStorms(traces); + expect(stormedIds(storms)).toEqual(["b1", "b2", "b3"]); + }); + + test("honors custom window and burst thresholds", () => { + const traces = [trace("a", 30, 0), trace("b", 30, 300)]; + // Default (minBurst 3) → nothing; minBurst 2 within 500ms → both. + expect(detectRetryStorms(traces).size).toBe(0); + const storms = detectRetryStorms(traces, { windowMs: 500, minBurst: 2 }); + expect(stormedIds(storms)).toEqual(["a", "b"]); + }); + + test("minBurst below 2 detects nothing", () => { + const traces = [trace("a", 30, 0), trace("b", 30, 10)]; + expect(detectRetryStorms(traces, { minBurst: 1 }).size).toBe(0); + }); + + test("tolerates a frameless trace without throwing", () => { + const empty: WireTrace = { + channelId: "c", + requestId: "empty", + frames: [], + startedAt: 0, + lastAt: 0, + }; + const traces = [ + empty, + trace("a", 30, 0), + trace("b", 30, 100), + trace("c", 30, 200), + ]; + const storms = detectRetryStorms(traces); + expect(stormedIds(storms)).toEqual(["a", "b", "c"]); + expect(storms.has(empty)).toBe(false); + }); + + test("is per-channel — two hosts each firing once is not a storm", () => { + // Same requestId and frameId across two channels, all within the window, + // but each channel fires the op only twice (< minBurst 3): no storm, and + // the two channels are never merged into one burst. + const traces = [ + trace("p:1", 30, 0, "hostA"), + trace("p:1", 30, 50, "hostB"), + trace("p:2", 30, 100, "hostA"), + trace("p:2", 30, 150, "hostB"), + ]; + expect(detectRetryStorms(traces).size).toBe(0); + }); + + test("flags a per-channel burst without pulling in the other channel", () => { + // hostA hammers the op 3x in-window (storm); hostB fires it once (calm). + const traces = [ + trace("p:1", 30, 0, "hostA"), + trace("p:2", 30, 200, "hostA"), + trace("p:1", 30, 250, "hostB"), + trace("p:3", 30, 400, "hostA"), + ]; + const storms = detectRetryStorms(traces); + // Only hostA's three ops storm; hostB's p:1 does not, even though it shares + // requestId "p:1" with a stormed hostA op. + expect(storms.size).toBe(3); + const stormedChannels = new Set([...storms.keys()].map((t) => t.channelId)); + expect([...stormedChannels]).toEqual(["hostA"]); + }); +}); diff --git a/js/packages/truapi-debugger/src/retry-storm.ts b/js/packages/truapi-debugger/src/retry-storm.ts new file mode 100644 index 000000000..13e0fa1a8 --- /dev/null +++ b/js/packages/truapi-debugger/src/retry-storm.ts @@ -0,0 +1,104 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: MIT +/** + * Retry-storm detection: a *cross-op* signal the single-trace renderer cannot + * see on its own. + * + * A retry storm is a burst of like ops in a short window — a product hammering + * `signing.createTransaction` five times in 400ms because each attempt failed, + * say. Whether any one op is part of a storm depends on the *other* traces, so + * it belongs in the engine/list layer, not the per-trace renderer. This module + * computes it over the whole trace set and hands each stormed trace a + * `retry-storm` {@link TraceBadge}, which the mount feeds to `wireTraceToView`'s + * `extraBadges`. The renderer stays display-only. + * + * @module + */ + +import type { TraceBadge } from "./trace-view.js"; +import type { WireTrace } from "./wire-debugger.js"; + +/** Tuning for {@link detectRetryStorms}. */ +export interface RetryStormOptions { + /** + * The window, in ms, within which like ops count as one burst. Default 1000. + */ + windowMs?: number; + /** + * How many like ops within `windowMs` make a storm. Default 3. Values below 2 + * are meaningless (a single op is never a storm) and detect nothing. + */ + minBurst?: number; +} + +/** + * The op signature two traces must share to count as "like". A storm is one host + * hammering one method, so the signature is scoped to the channel: `channelId` + * plus the opener frame's wire `frameId` (the first frame is the `request`/`start`, + * so its id identifies the method). Same channel + same op id = the same op being + * repeated; two different hosts each firing the op once is not a storm. A trace + * with no frames has no signature and never storms. + */ +function signature(trace: WireTrace): string | undefined { + const frameId = trace.frames[0]?.frameId; + return frameId === undefined ? undefined : `${trace.channelId}\u0000${frameId}`; +} + +/** + * Find every trace that is part of a retry storm and map it to its badge. + * + * Traces are grouped by op {@link signature}; within each group, a sliding + * window over `startedAt` flags any trace that sits in a span of `minBurst` or + * more ops no wider than `windowMs`. The result is keyed by the {@link WireTrace} + * object itself (not `requestId`, which is not unique across channels): only + * stormed traces appear, each mapped to `["retry-storm"]`. Feed + * `result.get(trace) ?? []` into `wireTraceToView`'s `extraBadges`. + */ +export function detectRetryStorms( + traces: readonly WireTrace[], + options: RetryStormOptions = {}, +): ReadonlyMap { + const windowMs = options.windowMs ?? 1000; + const minBurst = options.minBurst ?? 3; + const result = new Map(); + if (minBurst < 2) return result; + + const groups = new Map(); + for (const trace of traces) { + // A replayed backlog arrives in one burst. When the producer stamped its own + // observation time the spacing is real and a genuine storm still shows, so + // only the case with no producer clock is excluded: those ops all carry the + // flush instant, and six calls a genuine ten seconds apart would otherwise + // land inside the window and every one be badged "the product is hammering + // this method" on a completely calm session. + const opener = trace.frames[0]; + if (opener?.buffered === true && opener.timestampFromProducer !== true) { + continue; + } + const sig = signature(trace); + if (sig === undefined) continue; + const group = groups.get(sig); + if (group) group.push(trace); + else groups.set(sig, [trace]); + } + + for (const group of groups.values()) { + if (group.length < minBurst) continue; + const sorted = [...group].sort((a, b) => a.startedAt - b.startedAt); + let left = 0; + for (let right = 0; right < sorted.length; right++) { + while (sorted[right].startedAt - sorted[left].startedAt > windowMs) { + left++; + } + // [left, right] now spans <= windowMs, so every trace in it is within + // windowMs of every other. If that's a full burst, they all storm. + if (right - left + 1 >= minBurst) { + for (let k = left; k <= right; k++) { + result.set(sorted[k], ["retry-storm"]); + } + } + } + } + + return result; +} diff --git a/js/packages/truapi-debugger/src/server.test.ts b/js/packages/truapi-debugger/src/server.test.ts new file mode 100644 index 000000000..0f8f619d3 --- /dev/null +++ b/js/packages/truapi-debugger/src/server.test.ts @@ -0,0 +1,1282 @@ +import { expect, test } from "bun:test"; + +import { + encodeWireMessage, + TRUAPI_CODEC_VERSION, + TRUAPI_WIRE_SCHEMA_HASH, + VersionedHostSignRawRequest, +} from "@parity/truapi"; +import * as W from "@parity/truapi/wire-table"; + +import { WIRE_ENVELOPE_VERSION } from "./ingest.js"; +import { + decodeValuesFromEnv, + hostHeaderAllowed, + isLoopbackDebugHost, + portFromEnv, + startDebugServer, +} from "./server.js"; + +interface TraceFrameView { + direction: string; + frameId: number; + method?: string; + byteLength: number; +} +interface TraceView { + requestId: string; + frames: TraceFrameView[]; +} + +/** base64 of a wire message for `frameId` carrying `value` as its payload. */ +function encodeFrame(requestId: string, frameId: number, value: Uint8Array): string { + const encoded = encodeWireMessage({ requestId, payload: { id: frameId, value } }); + if (encoded.isErr()) throw encoded.error; + return Buffer.from(encoded.value).toString("base64"); +} + +/** + * base64 of a real, decodable sign-raw request wire message. Carries a + * recognizable `dotNsIdentifier` ("alice.dot") in its decoded value so a test + * can prove the value surfaced — this debugger decodes it like any other frame. + */ +function signFrame(requestId: string): string { + const value = VersionedHostSignRawRequest.enc({ + tag: "V1", + value: { + account: { + dotNsIdentifier: "alice.dot", + derivationIndex: { tag: "Index", value: 0 }, + }, + payload: { tag: "Bytes", value: { bytes: "0xdeadbeef" } }, + }, + }); + const encoded = encodeWireMessage({ + requestId, + payload: { id: W.SIGNING_SIGN_RAW.request, value }, + }); + if (encoded.isErr()) throw encoded.error; + return Buffer.from(encoded.value).toString("base64"); +} + +/** Open a WS to the server, send one envelope, wait until `/traces` is non-empty. */ +async function streamFrame( + base: string, + port: number, + frame: string, + dir: "in" | "out" = "out", +): Promise { + const ws = new WebSocket(`ws://localhost:${port}`); + await new Promise((resolve, reject) => { + ws.onopen = () => resolve(); + ws.onerror = () => reject(new Error("ws failed to open")); + }); + ws.send( + JSON.stringify({ + channelId: "myapp.dot", + dir, + frame, + schema: TRUAPI_WIRE_SCHEMA_HASH, + }), + ); + let traces: TraceView[] = []; + for (let i = 0; i < 50 && traces.length === 0; i++) { + traces = (await (await fetch(`${base}/traces`)).json()) as TraceView[]; + if (traces.length === 0) await new Promise((r) => setTimeout(r, 20)); + } + ws.close(); + return traces; +} + +test("decodes and groups a frame a host streams over the WS", async () => { + const server = startDebugServer({ port: 0 }); + const base = `http://localhost:${server.port}`; + try { + const encoded = encodeWireMessage({ + requestId: "p:1", + payload: { id: W.SYSTEM_HANDSHAKE.request, value: new Uint8Array([1, 2, 3]) }, + }); + if (encoded.isErr()) throw encoded.error; + const frame = Buffer.from(encoded.value).toString("base64"); + + const ws = new WebSocket(`ws://localhost:${server.port}`); + await new Promise((resolve, reject) => { + ws.onopen = () => resolve(); + ws.onerror = () => reject(new Error("ws failed to open")); + }); + ws.send( + JSON.stringify({ + channelId: "myapp.dot", + dir: "out", + frame, + schema: TRUAPI_WIRE_SCHEMA_HASH, + }), + ); + + let traces: TraceView[] = []; + for (let i = 0; i < 50 && traces.length === 0; i++) { + traces = (await (await fetch(`${base}/traces`)).json()) as TraceView[]; + if (traces.length === 0) await new Promise((r) => setTimeout(r, 20)); + } + ws.close(); + + expect(traces).toHaveLength(1); + expect(traces[0].requestId).toBe("p:1"); + expect(traces[0].frames[0].direction).toBe("out"); + expect(traces[0].frames[0].frameId).toBe(W.SYSTEM_HANDSHAKE.request); + // The method map resolves the wire id to a dotted name for the view. + expect(typeof traces[0].frames[0].method).toBe("string"); + } finally { + server.stop(); + } +}); + +test("the inspector page is served at /", async () => { + const server = startDebugServer({ port: 0 }); + try { + const res = await fetch(`http://localhost:${server.port}/`); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/html"); + const html = await res.text(); + expect(html).toContain("TrUAPI Wire Inspector"); + // The shell fetches the shared fragments, not a bespoke renderer. + expect(html).toContain("/op-list"); + expect(html).toContain("/op?id="); + } finally { + server.stop(); + } +}); + +test("/op-list renders one shared row per op, payload-blind", async () => { + const server = startDebugServer({ port: 0 }); + const base = `http://localhost:${server.port}`; + try { + const frame = encodeFrame( + "p:1", + W.ACCOUNT_CONNECTION_STATUS_SUBSCRIBE.start, + new Uint8Array([0]), + ); + await streamFrame(base, server.port, frame); + const html = await (await fetch(`${base}/op-list`)).text(); + expect(html).toContain("td-op"); + expect(html).toContain('data-request-id="p:1"'); + // Subscription start, no stop yet: marked live. And never a value. + expect(html).toContain("td-op-sub"); + expect(html).not.toContain("V1"); + } finally { + server.stop(); + } +}); + +test("/op renders the drill-down for one op; unknown id degrades", async () => { + const server = startDebugServer({ port: 0 }); + const base = `http://localhost:${server.port}`; + try { + const frame = encodeFrame("p:1", W.SYSTEM_HANDSHAKE.request, new Uint8Array([1])); + await streamFrame(base, server.port, frame); + const ok = await (await fetch(`${base}/op?id=p:1`)).text(); + expect(ok).toContain("td-trace"); + expect(ok).toContain('data-request-id="p:1"'); + const missing = await (await fetch(`${base}/op?id=nope`)).text(); + expect(missing).toContain("not found"); + } finally { + server.stop(); + } +}); + +test("/channels reports the hosts that have dialed in", async () => { + const server = startDebugServer({ port: 0 }); + const base = `http://localhost:${server.port}`; + try { + const frame = encodeFrame("p:1", W.SYSTEM_HANDSHAKE.request, new Uint8Array([1])); + await streamFrame(base, server.port, frame); + const data = (await (await fetch(`${base}/channels`)).json()) as { + sockets: number; + channels: { + channelId: string; + firstSeen: number; + lastSeen: number; + frameCount: number; + connected: boolean; + }[]; + }; + const ch = data.channels.find((c) => c.channelId === "myapp.dot"); + expect(ch).toBeDefined(); + expect(ch?.frameCount).toBeGreaterThanOrEqual(1); + expect(ch?.connected).toBe(true); + expect(ch?.firstSeen).toBeLessThanOrEqual(ch?.lastSeen ?? 0); + } finally { + server.stop(); + } +}); + +test("/traces is byte- and value-free even with value decode on", async () => { + const server = startDebugServer({ port: 0, decodeValues: true }); + const base = `http://localhost:${server.port}`; + try { + // A decodable, non-sensitive frame: `connection-status.subscribe` start is + // `V1(void)` = a single 0x00 byte, which the generated table decodes to a + // `{ tag: "V1" }` value - a value that must never appear in `/traces`. + const frame = encodeFrame( + "p:1", + W.ACCOUNT_CONNECTION_STATUS_SUBSCRIBE.start, + new Uint8Array([0]), + ); + const traces = await streamFrame(base, server.port, frame); + expect(traces).toHaveLength(1); + + const raw = await (await fetch(`${base}/traces`)).text(); + // No payload-bearing keys and no decoded content leak into the trace list. + for (const banned of ['"bytes"', '"value"', '"decoded"', '"tag"', "V1"]) { + expect(raw).not.toContain(banned); + } + } finally { + server.stop(); + } +}); + +test("/stats is byte- and value-free even with value decode on", async () => { + const server = startDebugServer({ port: 0, decodeValues: true }); + const base = `http://localhost:${server.port}`; + try { + // The same decodable, non-sensitive frame as the /traces test: its decoded + // value is `{ tag: "V1" }`. The aggregate must report only counts - its + // `bytes` field is a summed byte *length*, never a raw or decoded payload. + const frame = encodeFrame( + "p:1", + W.ACCOUNT_CONNECTION_STATUS_SUBSCRIBE.start, + new Uint8Array([0]), + ); + await streamFrame(base, server.port, frame); + + const raw = await (await fetch(`${base}/stats`)).text(); + // No decoded content and no raw-payload hex leaks into the aggregate. + for (const banned of ['"value"', '"decoded"', '"tag"', "V1", "0x"]) { + expect(raw).not.toContain(banned); + } + // The aggregate is present, and `bytes` is a summed length (here 1B), a count. + const stats = JSON.parse(raw) as { + ops: number; + frames: number; + bytes: number; + }; + expect(stats.ops).toBe(1); + expect(stats.frames).toBe(1); + expect(stats.bytes).toBe(1); + } finally { + server.stop(); + } +}); + +test("/frame decodes a non-sensitive frame by default; decodeValues:false reports bytes", async () => { + const frame = encodeFrame( + "p:1", + W.ACCOUNT_CONNECTION_STATUS_SUBSCRIBE.start, + new Uint8Array([0]), + ); + + // Default (dev-only tool): decode is on, so the drill-down surfaces the value. + const on = startDebugServer({ port: 0 }); + try { + expect(on.decodeValues).toBe(true); + const baseOn = `http://localhost:${on.port}`; + await streamFrame(baseOn, on.port, frame); + const detail = await (await fetch(`${baseOn}/frame?id=p:1&i=0`)).json(); + expect(detail.kind).toBe("decoded"); + expect(detail.value?.tag).toBe("V1"); + } finally { + on.stop(); + } + + // `decodeValues: false` (still supported, for demos/tests): byte length only. + const off = startDebugServer({ port: 0, decodeValues: false }); + try { + expect(off.decodeValues).toBe(false); + const baseOff = `http://localhost:${off.port}`; + await streamFrame(baseOff, off.port, frame); + const detail = await (await fetch(`${baseOff}/frame?id=p:1&i=0`)).json(); + expect(detail.kind).toBe("bytes"); + expect(detail.byteLength).toBe(1); + } finally { + off.stop(); + } +}); + +test("a signing frame decodes like any other; /traces never carries its bytes", async () => { + const server = startDebugServer({ port: 0, decodeValues: true }); + const base = `http://localhost:${server.port}`; + try { + await streamFrame(base, server.port, signFrame("p:sign")); + + // Dev-only tool: no denylist, so the frame decodes and its value surfaces. + const detail = await (await fetch(`${base}/frame?id=p:sign&i=0`)).json(); + expect(detail.kind).toBe("decoded"); + expect(JSON.stringify(detail.value)).toContain("alice.dot"); + // The decoded result never carries a "sensitive"/"redacted" marker any more. + expect(detail.sensitive).toBeUndefined(); + + // The payload-blind grouping invariant still holds: /traces never serializes + // the raw or decoded bytes, only the /frame drill-down does. + const raw = await (await fetch(`${base}/traces`)).text(); + expect(raw).not.toContain("deadbeef"); + expect(raw).not.toContain("alice.dot"); + } finally { + server.stop(); + } +}); + +test("/view renders the shared drill-down with decoded values by default", async () => { + // Default (dev-only tool): decode is on, so the drill-down renders each + // frame's value inline — no click-to-decode control. + const server = startDebugServer({ port: 0 }); + try { + const base = `http://localhost:${server.port}`; + const frame = encodeFrame( + "p:1", + W.ACCOUNT_CONNECTION_STATUS_SUBSCRIBE.start, + new Uint8Array([0]), + ); + await streamFrame(base, server.port, frame); + const html = await (await fetch(`${base}/view`)).text(); + // Shared-renderer markup, not the old table. + expect(html).toContain("td-trace"); + expect(html).toContain("td-frame"); + expect(html).toContain('data-request-id="p:1"'); + // Values render inline; the click-to-decode control is gone. + expect(html).toContain("td-frame-payload"); + expect(html).not.toContain("td-frame-decode-btn"); + expect(html).not.toContain("decode payload"); + } finally { + server.stop(); + } +}); + +test("/view is payload-blind when decode is off", async () => { + const off = startDebugServer({ port: 0, decodeValues: false }); + try { + const base = `http://localhost:${off.port}`; + const frame = encodeFrame( + "p:1", + W.ACCOUNT_CONNECTION_STATUS_SUBSCRIBE.start, + new Uint8Array([0]), + ); + await streamFrame(base, off.port, frame); + const html = await (await fetch(`${base}/view`)).text(); + expect(html).toContain('data-request-id="p:1"'); + // No payload column at all, and no decode control. + expect(html).not.toContain("td-frame-payload"); + expect(html).not.toContain("td-frame-decode-btn"); + } finally { + off.stop(); + } +}); + +test("/op decodes every frame inline via the real decodeTraceFrames path", async () => { + const server = startDebugServer({ port: 0 }); + const base = `http://localhost:${server.port}`; + try { + // A real sign-raw request whose decoded value carries "alice.dot". + await streamFrame(base, server.port, signFrame("p:sign")); + + // The op drill-down renders the decoded value inline — proving the + // session → decodeTraceFrames → renderer wiring, not just structural markup. + const html = await ( + await fetch(`${base}/op?id=p:sign&channel=myapp.dot&gen=0`) + ).text(); + expect(html).toContain("td-frame-decoded"); + expect(html).toContain("alice.dot"); + // Inline, not behind a control, and nothing withheld. + expect(html).not.toContain("td-frame-decode-btn"); + expect(html).not.toContain("redacted"); + } finally { + server.stop(); + } +}); + +test("/op refuses to decode a codec-mismatched (untrusted) channel", async () => { + const server = startDebugServer({ port: 0 }); + const base = `http://localhost:${server.port}`; + try { + // Stream a frame with a wrong wire schema hash: the channel is untrusted. + const ws = new WebSocket(`ws://localhost:${server.port}`); + await new Promise((resolve, reject) => { + ws.onopen = () => resolve(); + ws.onerror = () => reject(new Error("ws failed")); + }); + ws.send( + JSON.stringify({ + channelId: "drift.dot", + dir: "out", + frame: signFrame("p:sign"), + schema: "0000000000000000", + }), + ); + for (let i = 0; i < 50; i++) { + const t = (await (await fetch(`${base}/traces`)).json()) as TraceView[]; + if (t.length > 0) break; + await new Promise((r) => setTimeout(r, 20)); + } + ws.close(); + + const html = await ( + await fetch(`${base}/op?id=p:sign&channel=drift.dot&gen=0`) + ).text(); + // Grouped and shown, but no decoded value for the untrusted channel. + expect(html).toContain('data-request-id="p:sign"'); + expect(html).not.toContain("alice.dot"); + expect(html).toContain("payload not shown"); + } finally { + server.stop(); + } +}); + +test("/frame validates its params and 404s an unknown frame", async () => { + const server = startDebugServer({ port: 0, decodeValues: true }); + const base = `http://localhost:${server.port}`; + try { + expect((await fetch(`${base}/frame`)).status).toBe(400); + expect((await fetch(`${base}/frame?id=x&i=notint`)).status).toBe(400); + // Empty `?i=` must 400, not resolve frame 0 (Number("") === 0). + expect((await fetch(`${base}/frame?id=x&i=`)).status).toBe(400); + expect((await fetch(`${base}/frame?id=x&i=%20`)).status).toBe(400); + // Same coercion on `?gen=`: empty/whitespace/non-int must 400, not resolve + // generation 0 (the oldest recycled op) with a 200. + expect((await fetch(`${base}/frame?id=x&i=0&gen=`)).status).toBe(400); + expect((await fetch(`${base}/frame?id=x&i=0&gen=%20`)).status).toBe(400); + expect((await fetch(`${base}/frame?id=x&i=0&gen=notint`)).status).toBe(400); + expect((await fetch(`${base}/frame?id=missing&i=0`)).status).toBe(404); + } finally { + server.stop(); + } +}); + +test("a codec-mismatched host is banner-flagged and its frames refuse to decode", async () => { + const server = startDebugServer({ port: 0, decodeValues: true }); + const base = `http://localhost:${server.port}`; + try { + const frame = encodeFrame( + "p:1", + W.ACCOUNT_GET_ACCOUNT.request, + new Uint8Array([0]), + ); + // Stream one frame declaring a codec this debugger can't decode against. + const ws = new WebSocket(`ws://localhost:${server.port}`); + await new Promise((resolve, reject) => { + ws.onopen = () => resolve(); + ws.onerror = () => reject(new Error("ws failed to open")); + }); + ws.send( + JSON.stringify({ v: 1, codec: 999, channelId: "old.dot", dir: "out", frame }), + ); + // Wait until the frame is grouped (payload-blind grouping still happens). + for (let i = 0; i < 50; i++) { + const traces = (await (await fetch(`${base}/traces`)).json()) as unknown[]; + if (traces.length > 0) break; + await new Promise((r) => setTimeout(r, 20)); + } + ws.close(); + + // /channels banners the mismatch. + const channels = await (await fetch(`${base}/channels`)).json(); + expect(channels.codecMismatch).toBe(true); + // Decode is refused (409) for that host's frames — never resolved against the + // wrong contract. + const refused = await fetch(`${base}/frame?id=p:1&i=0&channel=old.dot`); + expect(refused.status).toBe(409); + } finally { + server.stop(); + } +}); + +test("a wrong-schema or unstamped host refuses to decode, but still groups", async () => { + const server = startDebugServer({ port: 0, decodeValues: true }); + const base = `http://localhost:${server.port}`; + try { + const frame = encodeFrame( + "p:1", + W.ACCOUNT_GET_ACCOUNT.request, + new Uint8Array([0]), + ); + const stream = async (envelope: Record): Promise => { + const ws = new WebSocket(`ws://localhost:${server.port}`); + await new Promise((resolve, reject) => { + ws.onopen = () => resolve(); + ws.onerror = () => reject(new Error("ws failed to open")); + }); + const want = ((await (await fetch(`${base}/traces`)).json()) as unknown[]) + .length; + ws.send(JSON.stringify(envelope)); + for (let i = 0; i < 50; i++) { + const traces = (await (await fetch(`${base}/traces`)).json()) as unknown[]; + if (traces.length > want) break; + await new Promise((r) => setTimeout(r, 20)); + } + ws.close(); + }; + // A frame stamping a wire schema this debugger can't decode against (the + // codec number alone is unchanged) must be refused, never resolved against + // the wrong contract - the case a coarse codec check misses. + await stream({ + channelId: "stale.dot", + dir: "out", + frame, + codec: 1, + schema: "deadbeefdeadbeef", + }); + expect( + (await fetch(`${base}/frame?id=p:1&i=0&channel=stale.dot`)).status, + ).toBe(409); + // A host that stamps no identity at all is refused too: absent is not trusted. + await stream({ channelId: "bare.dot", dir: "out", frame }); + expect( + (await fetch(`${base}/frame?id=p:1&i=0&channel=bare.dot`)).status, + ).toBe(409); + // Payload-blind grouping is unaffected: both ops are recorded regardless. + const traces = (await (await fetch(`${base}/traces`)).json()) as unknown[]; + expect(traces.length).toBe(2); + } finally { + server.stop(); + } +}); + +test("isLoopbackDebugHost accepts loopback literals and .localhost subdomains", () => { + expect(isLoopbackDebugHost("127.0.0.1")).toBe(true); + expect(isLoopbackDebugHost("localhost")).toBe(true); + expect(isLoopbackDebugHost("::1")).toBe(true); + // RFC 6761 reserves `.localhost`: it always resolves to loopback and cannot be + // registered, so a sub-hostname under it is loopback too. Real hosts use this - + // dotli serves its host realm from `host.localhost`, and dials the debugger + // from that origin - so rejecting it locks the shipped host out entirely. + expect(isLoopbackDebugHost("host.localhost")).toBe(true); + expect(isLoopbackDebugHost("app.host.localhost")).toBe(true); +}); + +test("isLoopbackDebugHost rejects loopback-looking names under other domains", () => { + // The dangerous direction: a loopback-shaped label under an attacker's domain. + // Reading any of these as loopback would let a rebound page past the + // DNS-rebinding Host guard and the WS Origin gate. + for (const host of [ + "0.0.0.0", + "127.0.0.1.evil.com", + "localhost.evil.com", + // `.localhost` as a *label*, not the TLD - still an attacker domain. + "localhost.com", + "notlocalhost", + "127.0.0.2", + "[::1]", + "example.com", + ]) { + expect(isLoopbackDebugHost(host)).toBe(false); + } +}); + +test("the Host guard classifies RAW header strings, case included", async () => { + // `isLoopbackDebugHost` only ever sees a WHATWG-normalized (lowercased) + // hostname, so asserting `isLoopbackDebugHost("LOCALHOST") === false` encodes a + // belief the system does NOT have: the gate lowercases first, and `Host: + // LOCALHOST` is accepted live. Assert through the gate, with raw headers. + expect(hostHeaderAllowed("LOCALHOST")).toBe(true); + expect(hostHeaderAllowed("LocalHost:9231")).toBe(true); + expect(hostHeaderAllowed("127.0.0.1:9231")).toBe(true); + expect(hostHeaderAllowed("[::1]:9231")).toBe(true); + // Absent/empty Host: a non-browser client, allowed like a missing Origin. + expect(hostHeaderAllowed(null)).toBe(true); + expect(hostHeaderAllowed("")).toBe(true); + // Case does not launder an attacker domain either. + expect(hostHeaderAllowed("EVIL.COM")).toBe(false); + expect(hostHeaderAllowed("LOCALHOST.EVIL.COM")).toBe(false); + + // And live, through the real server, with the raw header on the wire. + const server = startDebugServer({ port: 0 }); + try { + const base = `http://localhost:${server.port}`; + const status = async (host: string): Promise => + (await fetch(`${base}/traces`, { headers: { host } })).status; + expect(await status(`LOCALHOST:${server.port}`)).toBe(200); + expect(await status(`EVIL.LOCALHOST:${server.port}`)).toBe(403); + } finally { + server.stop(); + } +}); + +test("the Host guard is narrower than the Origin gate: *.localhost is not a target", async () => { + // `.localhost` is a legitimate *origin* for a page that dials in (dotli serves + // its host realm from host.localhost), but never a legitimate *target*: this + // server binds 127.0.0.1 and answers for three names only. Accepting + // `Host: x.localhost` would only widen the rebinding surface to a + // wildcard-`*.localhost` zone, for a client that cannot exist. + expect(isLoopbackDebugHost("host.localhost")).toBe(true); + expect(hostHeaderAllowed("host.localhost")).toBe(false); + expect(hostHeaderAllowed("host.localhost:9231")).toBe(false); + const server = startDebugServer({ port: 0 }); + try { + const res = await fetch(`http://localhost:${server.port}/traces`, { + headers: { host: `host.localhost:${server.port}` }, + }); + expect(res.status).toBe(403); + } finally { + server.stop(); + } +}); + +test("an unparseable Host is a 403, not a 500 out of the route dispatcher", async () => { + const server = startDebugServer({ port: 0 }); + try { + // Bun builds `req.url` from the Host header, so an out-of-range port makes + // `new URL(req.url)` throw. The rebinding gate runs first, so the header gets + // the 403 it already earns instead of a 500 plus a stack trace per request. + for (const host of ["localhost:99999", "localhost:notaport", "["]) { + const res = await fetch(`http://localhost:${server.port}/traces`, { + headers: { host }, + }); + expect(res.status).toBe(403); + } + } finally { + server.stop(); + } +}); + +test("/frame rejects out-of-range indices (negative and huge) with 404", async () => { + const server = startDebugServer({ port: 0, decodeValues: true }); + const base = `http://localhost:${server.port}`; + try { + const frame = encodeFrame( + "p:1", + W.ACCOUNT_GET_ACCOUNT.request, + new Uint8Array([0]), + ); + await streamFrame(base, server.port, frame); + // Integer but out of range ⇒ 404 (no such frame); non-integer ⇒ 400. + expect((await fetch(`${base}/frame?id=p:1&i=-1`)).status).toBe(404); + expect((await fetch(`${base}/frame?id=p:1&i=99999`)).status).toBe(404); + expect((await fetch(`${base}/frame?id=p:1&i=1.5`)).status).toBe(400); + } finally { + server.stop(); + } +}); + +test("a default server decodes every frame, including formerly-sensitive ones", async () => { + // Dev-only tool: decode is on by default, so a signing frame decodes. + const server = startDebugServer({ port: 0 }); + const base = `http://localhost:${server.port}`; + try { + expect(server.decodeValues).toBe(true); + await streamFrame(base, server.port, signFrame("p:sign")); + const detail = await (await fetch(`${base}/frame?id=p:sign&i=0`)).json(); + expect(detail.kind).toBe("decoded"); + expect(JSON.stringify(detail.value)).toContain("alice.dot"); + // No sensitive/redacted machinery: `?reveal=0` is just an unknown param, + // ignored, and the frame still decodes. + const still = await ( + await fetch(`${base}/frame?id=p:sign&i=0&reveal=0`) + ).json(); + expect(still.kind).toBe("decoded"); + } finally { + server.stop(); + } +}); + +test("a page with a non-loopback Host header is refused (DNS-rebinding guard)", async () => { + const server = startDebugServer({ port: 0 }); + const base = `http://localhost:${server.port}`; + try { + // A rebound evil.com -> 127.0.0.1 page's same-origin fetch still carries its + // own Host; a non-loopback (non-bind) Host must be refused with a 403. + const res = await fetch(`${base}/traces`, { + headers: { host: "evil.com" }, + }); + expect(res.status).toBe(403); + // A loopback Host is fine. + const ok = await fetch(`${base}/traces`, { + headers: { host: `127.0.0.1:${server.port}` }, + }); + expect(ok.status).toBe(200); + } finally { + server.stop(); + } +}); + +test("groups by (channel, requestId) — two hosts minting the same id do not merge", async () => { + const server = startDebugServer({ port: 0, decodeValues: true }); + const base = `http://localhost:${server.port}`; + try { + // Per-transport counters mean both hosts mint requestId "p:1" for different + // ops. They must NOT collapse into one trace. + // Distinct byte lengths so the per-channel drill-down is distinguishable. + const a = encodeFrame("p:1", W.ACCOUNT_GET_ACCOUNT.request, new Uint8Array([1])); + const b = encodeFrame( + "p:1", + W.CHAIN_GET_HEAD_HEADER.request, + new Uint8Array([2, 2, 2]), + ); + const send = async (frame: string, channelId: string) => { + const ws = new WebSocket(`ws://localhost:${server.port}`); + await new Promise((resolve, reject) => { + ws.onopen = () => resolve(); + ws.onerror = () => reject(new Error("ws failed to open")); + }); + ws.send( + JSON.stringify({ + channelId, + dir: "out", + frame, + schema: TRUAPI_WIRE_SCHEMA_HASH, + }), + ); + await new Promise((r) => setTimeout(r, 40)); + ws.close(); + }; + await send(a, "hostA.dot"); + await send(b, "hostB.dot"); + + interface Ch { + channelId: string; + requestId: string; + frames: TraceFrameView[]; + } + let traces: Ch[] = []; + for (let i = 0; i < 50; i++) { + traces = (await (await fetch(`${base}/traces`)).json()) as Ch[]; + if (traces.length >= 2) break; + await new Promise((r) => setTimeout(r, 20)); + } + // Two separate traces: same requestId, distinct channels, distinct frames. + expect(traces).toHaveLength(2); + const byChannel = new Map(traces.map((t) => [t.channelId, t])); + expect(byChannel.get("hostA.dot")?.requestId).toBe("p:1"); + expect(byChannel.get("hostB.dot")?.requestId).toBe("p:1"); + expect(byChannel.get("hostA.dot")?.frames[0].frameId).toBe( + W.ACCOUNT_GET_ACCOUNT.request, + ); + expect(byChannel.get("hostB.dot")?.frames[0].frameId).toBe( + W.CHAIN_GET_HEAD_HEADER.request, + ); + + // /frame disambiguates by channel: same id "p:1" resolves to the right + // host's frame (distinct byte lengths prove it's not the other channel's). + const detailA = await ( + await fetch(`${base}/frame?id=p:1&i=0&channel=hostA.dot`) + ).json(); + const detailB = await ( + await fetch(`${base}/frame?id=p:1&i=0&channel=hostB.dot`) + ).json(); + expect(detailA.byteLength).toBe(1); + expect(detailB.byteLength).toBe(3); + expect(detailA).not.toEqual(detailB); + } finally { + server.stop(); + } +}); + +/** Open a WS, run `body`, then close it. */ +async function withSocket( + port: number, + body: (ws: WebSocket) => Promise, +): Promise { + const ws = new WebSocket(`ws://localhost:${port}`); + await new Promise((resolve, reject) => { + ws.onopen = () => resolve(); + ws.onerror = () => reject(new Error("ws failed to open")); + }); + try { + await body(ws); + } finally { + ws.close(); + } +} + +/** The `/stats` fields these tests assert on. */ +interface StatsShape { + ops: number; + frames: number; + droppedByHost: number; + envelopeRejects: number; + envelopeRejectReasons: Record; + oversizedMessages: number; + abnormalCloses: number; + invalidDroppedFields: number; +} + +/** Poll `/stats` until `done` or the budget runs out; returns the last payload. */ +async function statsUntil( + base: string, + done: (s: StatsShape) => boolean, + query = "", +): Promise { + let stats = {} as StatsShape; + for (let i = 0; i < 100; i++) { + stats = (await (await fetch(`${base}/stats${query}`)).json()) as StatsShape; + if (done(stats)) return stats; + await new Promise((r) => setTimeout(r, 20)); + } + return stats; +} + +test("a matching schema with a mismatched envelope version blocks the CHANNEL-LESS decode path", async () => { + const server = startDebugServer({ port: 0, decodeValues: true }); + const base = `http://localhost:${server.port}`; + try { + // The gap a `!identityConfirmed`-only flag leaves open: this host stamps the + // matching schema hash (so `identityConfirmed`) AND a wrong envelope version + // (so `identityMismatch`). The scoped path always refused it; the unscoped one + // must too, because `codec` is the only signal for a payload-layout drift the + // schema hash is blind to — and the shipped UI itself omits `&channel=` when + // it has no channel. + await withSocket(server.port, async (ws) => { + ws.send( + JSON.stringify({ + v: 2, + codec: 1, + schema: TRUAPI_WIRE_SCHEMA_HASH, + channelId: "drift.dot", + dir: "out", + frame: signFrame("p:sign"), + }), + ); + for (let i = 0; i < 50; i++) { + const t = (await (await fetch(`${base}/traces`)).json()) as unknown[]; + if (t.length > 0) break; + await new Promise((r) => setTimeout(r, 20)); + } + }); + + // Scoped by channel: refused (this already held). + expect( + (await fetch(`${base}/frame?id=p:sign&i=0&channel=drift.dot`)).status, + ).toBe(409); + // UNSCOPED: must be refused too — the hole. + expect((await fetch(`${base}/frame?id=p:sign&i=0`)).status).toBe(409); + // And the HTML drill-down the default page actually renders must not carry the + // decoded payload either. + const html = await (await fetch(`${base}/op?id=p:sign`)).text(); + expect(html).not.toContain("alice.dot"); + expect(html).toContain("payload not shown"); + // Payload-blind grouping is unaffected. + const traces = (await (await fetch(`${base}/traces`)).json()) as unknown[]; + expect(traces.length).toBe(1); + } finally { + server.stop(); + } +}); + +test("a frame larger than the engine's per-trace budget is ingested, not killed by the WS cap", async () => { + const server = startDebugServer({ port: 0, decodeValues: true }); + const base = `http://localhost:${server.port}`; + try { + // 1.5 MiB raw payload: base64 inflates it by 4/3, so a 1 MiB message cap would + // sit BELOW what the producers can legitimately send. Bun does not drop an + // over-cap message, it closes the socket (1006) without ever calling + // `message()`, so the whole stream would die mid-session with every counter + // untouched. + const big = encodeFrame( + "p:big", + W.ACCOUNT_GET_ACCOUNT.request, + new Uint8Array(1536 * 1024).fill(7), + ); + expect(Buffer.from(big, "base64").length).toBeGreaterThan(1024 * 1024); + const traces = await streamFrame(base, server.port, big); + expect(traces).toHaveLength(1); + expect(traces[0].requestId).toBe("p:big"); + const stats = (await (await fetch(`${base}/stats`)).json()) as { + oversizedMessages: number; + abnormalCloses: number; + }; + expect(stats.oversizedMessages).toBe(0); + expect(stats.abnormalCloses).toBe(0); + } finally { + server.stop(); + } +}); + +test("a socket closed for an over-cap message is counted and surfaced on /stats", async () => { + const server = startDebugServer({ port: 0 }); + const base = `http://localhost:${server.port}`; + try { + // Above MAX_INBOUND_MESSAGE_BYTES (9 MiB): Bun closes with 1006 "Received too + // big message" and never calls `message()`. Without a counter here the loss is + // literally unobservable — /stats byte-for-byte identical before and after. + await withSocket(server.port, async (ws) => { + ws.send("x".repeat(10 * 1024 * 1024)); + await new Promise((r) => setTimeout(r, 200)); + }); + const stats = await statsUntil(base, (s) => s.oversizedMessages === 1); + expect(stats.oversizedMessages).toBe(1); + expect(stats.abnormalCloses).toBe(1); + // Nothing was ingested, and no envelope reject is claimed: the message never + // reached the parser. + expect(stats.envelopeRejects).toBe(0); + expect(stats.frames).toBe(0); + } finally { + server.stop(); + } +}); + +test("envelope-level rejects are counted by reason and surfaced on /stats", async () => { + const server = startDebugServer({ port: 0 }); + const base = `http://localhost:${server.port}`; + try { + const frame = encodeFrame("p:1", W.SYSTEM_HANDSHAKE.request, new Uint8Array([1])); + await withSocket(server.port, async (ws) => { + ws.send("{not json"); + ws.send("42"); + ws.send(JSON.stringify({ channelId: "a.dot", dir: "sideways", frame })); + // A renamed field — the shape a wire-envelope drift actually takes. + ws.send(JSON.stringify({ channel_id: "a.dot", dir: "out", frame })); + ws.send(JSON.stringify({ channelId: "a.dot", dir: "out" })); + // `""` collides with the "all channels" sentinel: that host could never be + // selected or decode-scoped, so it is refused at ingest. + ws.send(JSON.stringify({ channelId: "", dir: "out", frame })); + await new Promise((r) => setTimeout(r, 100)); + }); + const stats = await statsUntil(base, (s) => s.envelopeRejects === 6); + expect(stats.envelopeRejects).toBe(6); + expect(stats.envelopeRejectReasons).toEqual({ + "bad-json": 1, + "not-object": 1, + "bad-channel-id": 1, + "empty-channel-id": 1, + "bad-dir": 1, + "bad-frame": 1, + "ingest-threw": 0, + }); + // Six refusals and nothing ingested: /traces and /channels stay empty, which + // without the counters is indistinguishable from "the host never dialed". + expect(((await (await fetch(`${base}/traces`)).json()) as unknown[]).length).toBe(0); + const channels = (await (await fetch(`${base}/channels`)).json()) as { + channels: unknown[]; + }; + expect(channels.channels.length).toBe(0); + } finally { + server.stop(); + } +}); + +test("the inspector page renders the socket count from /channels", async () => { + const server = startDebugServer({ port: 0 }); + try { + const html = await (await fetch(`http://localhost:${server.port}/`)).text(); + // `sockets` was computed and serialized but never rendered: one socket with + // zero ops (a host that is talking and being refused) looked identical to no + // host at all. + expect(html).toContain("data.sockets"); + expect(html).toContain("socket"); + // The summary strip reports link-level loss even with zero ops. + expect(html).toContain("linkLoss"); + } finally { + server.stop(); + } +}); + +test("/channels counts an open socket", async () => { + const server = startDebugServer({ port: 0 }); + const base = `http://localhost:${server.port}`; + try { + await withSocket(server.port, async () => { + let sockets = 0; + for (let i = 0; i < 50 && sockets === 0; i++) { + sockets = ( + (await (await fetch(`${base}/channels`)).json()) as { sockets: number } + ).sockets; + if (sockets === 0) await new Promise((r) => setTimeout(r, 20)); + } + expect(sockets).toBe(1); + }); + } finally { + server.stop(); + } +}); + +test("a non-integer `dropped` cannot poison the session's droppedByHost total", async () => { + const server = startDebugServer({ port: 0 }); + const base = `http://localhost:${server.port}`; + try { + const frame = encodeFrame("p:1", W.SYSTEM_HANDSHAKE.request, new Uint8Array([1])); + await withSocket(server.port, async (ws) => { + // Raw text, because `JSON.stringify` would already turn Infinity into null: + // `1e999` is VALID JSON that parses to Infinity. Summed, the whole session's + // total becomes Infinity, which `JSON.stringify` emits as `null` and the UI + // renders as "0 dropped" for every channel — the declared + // `droppedByHost: number` contract broken by one envelope. + ws.send( + `{"channelId":"liar.dot","dir":"out","frame":"${frame}",` + + `"schema":"${TRUAPI_WIRE_SCHEMA_HASH}","dropped":1e999}`, + ); + // A real host's honest count, on another channel, must survive it. + ws.send( + JSON.stringify({ + channelId: "honest.dot", + dir: "out", + frame, + schema: TRUAPI_WIRE_SCHEMA_HASH, + dropped: 5, + }), + ); + // Wrong types are discarded too, and counted. + ws.send( + JSON.stringify({ + channelId: "liar.dot", + dir: "out", + frame, + schema: TRUAPI_WIRE_SCHEMA_HASH, + dropped: "5", + }), + ); + ws.send( + JSON.stringify({ + channelId: "liar.dot", + dir: "out", + frame, + schema: TRUAPI_WIRE_SCHEMA_HASH, + dropped: 1.5, + }), + ); + await new Promise((r) => setTimeout(r, 100)); + }); + const stats = await statsUntil(base, (s) => s.invalidDroppedFields === 3); + // A finite integer total, not `null` — and the honest host's 5 is intact. + expect(stats.droppedByHost).toBe(5); + expect(stats.invalidDroppedFields).toBe(3); + const scoped = await statsUntil( + base, + () => true, + "?channel=liar.dot", + ); + expect(scoped.droppedByHost).toBe(0); + // The raw JSON must not carry a `null` where a number is declared. + const raw = await (await fetch(`${base}/stats`)).text(); + expect(raw).not.toContain('"droppedByHost":null'); + } finally { + server.stop(); + } +}); + +test("/view renders a bounded window, not every trace with every payload", async () => { + const server = startDebugServer({ port: 0, decodeValues: true }); + const base = `http://localhost:${server.port}`; + try { + const total = 25; + await withSocket(server.port, async (ws) => { + for (let i = 0; i < total; i++) { + ws.send( + JSON.stringify({ + channelId: "myapp.dot", + dir: "out", + frame: signFrame(`p:${i}`), + schema: TRUAPI_WIRE_SCHEMA_HASH, + }), + ); + } + for (let i = 0; i < 100; i++) { + const t = (await (await fetch(`${base}/traces`)).json()) as unknown[]; + if (t.length >= total) break; + await new Promise((r) => setTimeout(r, 20)); + } + }); + + const count = (html: string): number => + html.split('data-request-id="').length - 1; + // Unbounded, this endpoint renders every retained frame's decoded value into + // one string — at the engine's own caps that is hundreds of MB and seconds of + // blocked event loop for a single GET. + const first = await (await fetch(`${base}/view`)).text(); + expect(count(first)).toBe(20); + expect(first).toContain(`showing 1-20 of ${total} ops`); + // The window is addressable, so nothing is unreachable. + const rest = await (await fetch(`${base}/view?offset=20`)).text(); + expect(count(rest)).toBe(5); + expect(rest).not.toContain("showing"); + const small = await (await fetch(`${base}/view?limit=2`)).text(); + expect(count(small)).toBe(2); + // A malformed or unbounded window is a 400, never an unbounded render. + for (const q of ["?limit=0", "?limit=101", "?limit=abc", "?offset=-1", "?offset=1.5"]) { + expect((await fetch(`${base}/view${q}`)).status).toBe(400); + } + } finally { + server.stop(); + } +}); + +test("an empty ?channel= means all channels, not a channel named ''", async () => { + const server = startDebugServer({ port: 0, decodeValues: true }); + const base = `http://localhost:${server.port}`; + try { + await streamFrame(base, server.port, signFrame("p:sign")); + // A client building the query with `?? ""` used to pin itself to a channel that + // can never exist, and got a permanent 409 "codec mismatch" that was false. + const detail = await fetch(`${base}/frame?id=p:sign&i=0&channel=`); + expect(detail.status).toBe(200); + expect(JSON.stringify(await detail.json())).toContain("alice.dot"); + const html = await (await fetch(`${base}/op?id=p:sign&channel=&gen=0`)).text(); + expect(html).toContain("alice.dot"); + // The list endpoints agree: empty means unfiltered, not "no such channel". + expect(await (await fetch(`${base}/op-list?channel=`)).text()).toContain( + 'data-request-id="p:sign"', + ); + const stats = (await (await fetch(`${base}/stats?channel=`)).json()) as { + ops: number; + }; + expect(stats.ops).toBe(1); + } finally { + server.stop(); + } +}); + +test("every numeric query param goes through the same canonical-integer parse", async () => { + const server = startDebugServer({ port: 0, decodeValues: true }); + const base = `http://localhost:${server.port}`; + try { + const frame = encodeFrame("p:1", W.ACCOUNT_GET_ACCOUNT.request, new Uint8Array([0])); + await streamFrame(base, server.port, frame); + // `?i=` used to bypass this file's own `optionalInt`, so `Number()` coercion + // resolved a real frame for four spellings of "not an integer" while `?gen=` + // correctly 400'd on the same input — split-brain inside one file. + for (const i of ["-0", "0x0", "1e1", "007", "+1", " ", ""]) { + const res = await fetch(`${base}/frame?id=p:1&i=${encodeURIComponent(i)}`); + expect(res.status).toBe(400); + } + // Canonical values still resolve (or 404 out of range), unchanged. + expect((await fetch(`${base}/frame?id=p:1&i=0`)).status).toBe(200); + expect((await fetch(`${base}/frame?id=p:1&i=-1`)).status).toBe(404); + expect((await fetch(`${base}/frame?id=p:1&i=0&gen=0`)).status).toBe(200); + // Same parse on `?gen=` and on `/view`'s window. + expect((await fetch(`${base}/frame?id=p:1&i=0&gen=-0`)).status).toBe(400); + expect((await fetch(`${base}/op?gen=0x0`)).status).toBe(400); + expect((await fetch(`${base}/view?limit=0x2`)).status).toBe(400); + } finally { + server.stop(); + } +}); + +test("the decode kill-switch fails closed on untrimmed env values", () => { + // The switch that stops full payload decode must not be defeated by the exact + // shapes a shell or a .env file produces. + for (const off of ["0", "false", "no", "off", "OFF", "0 ", " false", "false\n", "\tno\t"]) { + expect(decodeValuesFromEnv(off)).toBe(false); + } + // Anything else (including unset) means on: this is a dev tool that decodes. + for (const on of [undefined, "", " ", "1", "true", "yes", "0x0", "falsey"]) { + expect(decodeValuesFromEnv(on)).toBe(true); + } +}); + +test("TRUAPI_DEBUGGER_PORT is validated, not silently clamped or coerced", () => { + expect(portFromEnv("9231")).toBe(9231); + expect(portFromEnv(" 9231 ")).toBe(9231); + expect(portFromEnv(undefined)).toBe(9231); + expect(portFromEnv("")).toBe(9231); + expect(portFromEnv("65535")).toBe(65535); + expect(portFromEnv("1")).toBe(1); + // `Number.isFinite(x) && x > 0` accepted every one of these: 99999 binds a + // DIFFERENT port (the OS truncates to 65535) that no host's debug URL points + // at, and 1.5 crashes the process on port 1. A debugger listening somewhere + // else is indistinguishable from a host that never dialed. + for (const bad of ["99999", "65536", "1.5", "0", "-1", "1e4", "0x10", "abc", "9231x"]) { + expect(portFromEnv(bad)).toBeNull(); + } +}); + +test("a replayed backlog keeps the producer's clock through the real socket", async () => { + // The seam that hid the original bug: the producer stamped `observedAt` and + // ingest honoured it, but the server built its envelope without the field, so + // the fix was invisible through the only mount a host actually dials. Drive it + // end to end rather than unit-testing either half. + const server = startDebugServer({ port: 0 }); + const base = `http://localhost:${server.port}`; + try { + const send = ( + requestId: string, + id: number, + dir: "in" | "out", + observedAt: number, + ): string => { + const encoded = encodeWireMessage({ + requestId, + payload: { id, value: new Uint8Array([0]) }, + }); + if (encoded.isErr()) throw encoded.error; + return JSON.stringify({ + channelId: "myapp.dot", + dir, + frame: Buffer.from(encoded.value).toString("base64"), + schema: TRUAPI_WIRE_SCHEMA_HASH, + codec: TRUAPI_CODEC_VERSION, + v: WIRE_ENVELOPE_VERSION, + observedAt, + buffered: true, + }); + }; + + const ws = new WebSocket(`ws://localhost:${server.port}`); + await new Promise((resolve, reject) => { + ws.onopen = () => resolve(); + ws.onerror = () => reject(new Error("ws failed to open")); + }); + // A 900ms round trip, replayed long after the fact in one burst. + const origin = 1_700_000_000_000; + ws.send(send("p:1", W.ACCOUNT_GET_ACCOUNT.request, "out", origin)); + ws.send(send("p:1", W.ACCOUNT_GET_ACCOUNT.response, "in", origin + 900)); + + let traces: { requestId: string; startedAt: number; lastAt: number }[] = []; + for (let i = 0; i < 50 && traces.length === 0; i++) { + await new Promise((r) => setTimeout(r, 20)); + traces = (await (await fetch(`${base}/traces`)).json()) as typeof traces; + } + ws.close(); + + const op = traces.find((t) => t.requestId === "p:1"); + expect(op).toBeDefined(); + // The producer's own span, not the 0ms a flush-instant clock would report. + expect((op?.lastAt ?? 0) - (op?.startedAt ?? 0)).toBe(900); + // And the op is anchored to when it really happened, not to now. + expect(op?.startedAt).toBe(origin); + } finally { + server.stop(); + } +}); + +test("a host-terminated subscription stops counting as live on /stats", async () => { + // `interrupt` ends a subscription just as `stop` does — a chain switch or a + // revoked permission is ordinary lifecycle, not an anomaly. Testing only for + // `stop` left every such subscription "live" forever, so the tile climbed all + // session while the op list beside it showed nothing live. The two mounts + // disagreed because each had its own aggregation; both now share one. + const server = startDebugServer({ port: 0 }); + const base = `http://localhost:${server.port}`; + try { + const send = (id: number, dir: "in" | "out"): string => { + const encoded = encodeWireMessage({ + requestId: "p:1", + payload: { id, value: new Uint8Array([0]) }, + }); + if (encoded.isErr()) throw encoded.error; + return JSON.stringify({ + channelId: "myapp.dot", + dir, + frame: Buffer.from(encoded.value).toString("base64"), + schema: TRUAPI_WIRE_SCHEMA_HASH, + codec: TRUAPI_CODEC_VERSION, + v: WIRE_ENVELOPE_VERSION, + }); + }; + + const ws = new WebSocket(`ws://localhost:${server.port}`); + await new Promise((resolve, reject) => { + ws.onopen = () => resolve(); + ws.onerror = () => reject(new Error("ws failed to open")); + }); + ws.send(send(W.ACCOUNT_CONNECTION_STATUS_SUBSCRIBE.start, "out")); + ws.send(send(W.ACCOUNT_CONNECTION_STATUS_SUBSCRIBE.interrupt, "in")); + + let stats = { subscriptions: 0, liveSubscriptions: -1 }; + for (let i = 0; i < 50 && stats.subscriptions === 0; i++) { + await new Promise((r) => setTimeout(r, 20)); + stats = (await (await fetch(`${base}/stats`)).json()) as typeof stats; + } + ws.close(); + + expect(stats.subscriptions).toBe(1); + expect(stats.liveSubscriptions).toBe(0); + } finally { + server.stop(); + } +}); diff --git a/js/packages/truapi-debugger/src/server.ts b/js/packages/truapi-debugger/src/server.ts new file mode 100644 index 000000000..cfea8f1ad --- /dev/null +++ b/js/packages/truapi-debugger/src/server.ts @@ -0,0 +1,1449 @@ +/** + * The runnable debugger app: the WS server a host dials into, plus a minimal + * trace view. + * + * A host's outward WS dial sends one text message per frame - + * `{ channelId, dir, frame }`, where `frame` is the base64 of the raw SCALE + * `ProtocolMessage` bytes (JSON can't carry binary; base64 keeps the envelope on + * one line). Each message is decoded and grouped by {@link createDebugSession}. + * `GET /traces` returns the grouped traces (payload-blind - raw bytes and + * decoded values are never serialized); `GET /op` renders one op's drill-down + * with each frame's decoded value inline; `GET /frame?id=&i=` is the same + * decode as a programmatic JSON endpoint. Value decode is on by default (a + * dev-only tool decodes everything); `GET /` serves a page that polls `/op-list`. + * + * The exact host↔debugger framing is not yet standardized (envelope spec, track + * T3); base64-in-JSON is what this server accepts today. Runs under Bun + * (`bun run src/server.ts`). + * + * @module + */ + +import { TRUAPI_CODEC_VERSION, TRUAPI_WIRE_SCHEMA_HASH } from "@parity/truapi"; +import { + computeTraceStats, + createDebugSession, + decodeTraceFrames, +} from "./session.js"; +import { + normalizeId, + WIRE_ENVELOPE_VERSION, + type DebugFrameEnvelope, +} from "./ingest.js"; +import { wireTraceToView, type TraceView } from "./trace-view.js"; +import { renderOperationRow, renderTraceDetail } from "./trace-render.js"; +import { detectRetryStorms } from "./retry-storm.js"; +import { INSPECTOR_LAYOUT_CSS, INSPECTOR_SHELL_CSS } from "./inspector-styles.js"; +import { TRACE_DETAIL_CSS } from "./trace-styles.js"; + +/** Default port the debugger listens on; a host points its debug URL here. */ +const DEFAULT_PORT = 9231; + +/** Ops one `/view` request renders when the caller names no window. */ +const VIEW_DEFAULT_LIMIT = 20; + +/** Largest `?limit=` `/view` will honour, so one request stays bounded. */ +const VIEW_MAX_LIMIT = 100; + +/** + * Cap on one inbound WS message. + * + * Deliberately ABOVE every producer's own per-message ceiling. The native + * `WsDebugSink` budgets its outbound queue at 8 MiB and refuses to enqueue a + * serialized line that alone exceeds it, so 8 MiB is the largest envelope a + * conforming host can send - and base64 inflates a frame by 4/3, so a cap set at + * the engine's 1 MiB per-trace budget would sit *below* the producer and make an + * ordinary large payload fatal. Bun does not drop an over-cap message: it CLOSES + * the connection (1006, "Received too big message") without ever invoking + * `message`, so an under-set cap silently kills the host's stream mid-session. + * The cap still bounds memory (well under Bun's 16 MiB default) for anything else + * that reaches the port. + */ +const MAX_INBOUND_MESSAGE_BYTES = 9 * 1024 * 1024; + +/** Frame roles that make an op a subscription rather than a request/response. */ +/** + * The text message a host sends per frame: the envelope with a base64 frame, + * plus the optional identity fields (`v`, `codec`) a versioned host stamps. + */ +interface WireMessage { + channelId: string; + dir: "in" | "out"; + frame: string; + /** + * When the producer *observed* the frame, as opposed to when this server + * received it. A host that buffered a backlog replays it in one burst, so + * without this every op in the flush collapses to a 0 ms span and ops that + * were seconds apart land inside the retry-storm window. + */ + observedAt?: number; + /** `true` when the producer replayed this frame out of its backlog. */ + buffered?: boolean; + /** Envelope version; see {@link WIRE_ENVELOPE_VERSION}. */ + v?: number; + /** The host's wire codec version (`TRUAPI_CODEC_VERSION`). */ + codec?: number; + /** + * The host's wire-contract fingerprint (`TRUAPI_WIRE_SCHEMA_HASH`): a hash of + * every frame id and its method leg. Unlike `codec` (the coarse handshake + * number, bumped ~never), this changes whenever a frame id is reassigned - the + * case where a frame could otherwise decode to the wrong method and value off + * this debugger's table. + */ + schema?: string; + /** Frames this host dropped (link backlog full) before this one; surfaced in stats. */ + dropped?: number; +} + +/** A parsed inbound message: the envelope plus its wire-identity verdict. */ +interface ParsedWireMessage { + envelope: DebugFrameEnvelope; + /** + * `true` when the host stamped a `v`/`codec`/`schema` that does not match this + * debugger's - the API-evolved-underneath case. Blocks the value-decode path. + */ + identityMismatch: boolean; + /** + * `true` only when the host affirmatively stamped a `schema` equal to this + * debugger's. Decode is allowed only for confirmed channels: an absent schema + * (a foreign or pre-identity host) is NOT trusted to decode, closing the + * omit-identity-to-bypass hole. Payload-blind grouping is unaffected. + */ + identityConfirmed: boolean; + /** Frames the host reported dropping before this one. */ + dropped: number; + /** + * `true` when the host sent a `dropped` that is not a finite non-negative + * integer (`Infinity`, a float, a string). The frame is still ingested and the + * bogus count is discarded, but the fact is counted so a host reporting loss in + * a shape this debugger can't sum is visible rather than read as "no loss". + */ + droppedFieldInvalid: boolean; +} + +/** + * Whether a WebSocket upgrade may proceed. Non-browser clients (the CLI, curl) + * send no Origin and are allowed; a browser sends its page Origin, which must be + * a loopback host - a cross-origin page dialing the debugger to inject frames is + * refused (CSWSH), which binding to loopback alone does not prevent. + */ +function originAllowed(origin: string | null): boolean { + if (origin === null) return true; + try { + const host = new URL(origin).hostname; + // `new URL("http://[::1]").hostname` keeps the brackets ("[::1]"), so strip + // them before classifying (a bare "::1" never occurs, but is handled too). + return isLoopbackDebugHost(host === "[::1]" ? "::1" : host); + } catch { + return false; + } +} + +/** + * Parse an optional integer query param: `undefined` if absent, `null` if + * malformed. Requires a CANONICAL decimal integer, so `""`, `" "`, `"1e3"`, + * `"0x10"`, `"1.5"`, `"+1"`, `"007"`, and `"-0"` all reject rather than silently + * coercing (`Number("") === 0`, `Number("0x10") === 16`, and `frames[-0]` is + * `frames[0]`). Every numeric query param on every route goes through this, so + * one spelling of "not an integer" can't 400 on one route and resolve a real + * record on another. + */ +function optionalInt(raw: string | null): number | null | undefined { + if (raw === null) return undefined; + const t = raw.trim(); + // No leading zeros, no signed zero: exactly one spelling per value. + if (!/^(0|-?[1-9]\d*)$/.test(t)) return null; + const n = Number(t); + return Number.isInteger(n) ? n : null; +} + +/** + * Parse an optional `?channel=` param. An empty (or whitespace-only) value means + * ABSENT, not "the channel named `''`": a client that builds the query with + * `?? ""` would otherwise pin itself to a channel that can never exist, and the + * decode gate would answer with a 409 "codec mismatch" that is simply false. + */ +function optionalChannel(raw: string | null): string | null { + if (raw === null) return null; + const t = raw.trim(); + return t === "" ? null : t; +} + +/** + * The three names this server binds and answers for. Note the case: every caller + * passes a hostname already normalized by the WHATWG URL parser, which lowercases + * it, so these literals see `LOCALHOST` as `localhost`. The classifier is never + * handed a raw header. + */ +const LOOPBACK_LITERALS = new Set(["127.0.0.1", "localhost", "::1"]); + +/** + * Whether `host` is a loopback ORIGIN this server accepts a WS upgrade from: the + * loopback literals, plus subdomains of `.localhost`. + * + * The subdomain case is not a fuzzy match: RFC 6761 reserves `.localhost` as a + * special-use TLD that always resolves to loopback and cannot be registered + * publicly, so `host.localhost` is as much loopback as `localhost` is. Real hosts + * use it - dotli serves its host realm from `host.localhost`, and dials the + * debugger from that origin - and without this they can never reach the debugger. + * The dangerous shape this must still reject is the *other* direction, a + * loopback-looking label under an attacker's domain (`127.0.0.1.evil.com`, + * `localhost.evil.com`); those do not end in `.localhost` and stay rejected. + * + * `Host` headers are NOT classified here - see {@link hostHeaderAllowed}. + * The input is a WHATWG-normalized (lowercased) hostname, so this is + * case-sensitive by design. + */ +export function isLoopbackDebugHost(host: string): boolean { + return LOOPBACK_LITERALS.has(host) || host.endsWith(".localhost"); +} + +/** + * Whether a request's `Host` header targets an address this server is willing to + * answer for: one of the three names it can actually be reached at. + * + * This is the DNS-rebinding guard. Binding to loopback keeps off-box peers out, + * but a page served from `evil.com` whose DNS has been rebound to `127.0.0.1` + * can issue same-origin `fetch`es to the debugger and read decoded frames; those + * requests still carry `Host: evil.com`. Requiring a loopback Host rejects them + * with a 403. A `Host`-less request (a non-browser client that omits it) is + * allowed, matching the WS Origin gate's posture. + * + * Deliberately NARROWER than the Origin gate: `*.localhost` is a legitimate + * *origin* for a page that dials in, but never a legitimate *target* - this + * server binds `127.0.0.1`, and a browser that resolved `x.localhost` to + * loopback still sends `Host: x.localhost`, an address the debugger does not + * serve. Accepting it only widens the rebinding surface (a wildcard-DNS + * `*.localhost` zone under an attacker's control) for no reachable client. + */ +export function hostHeaderAllowed(hostHeader: string | null): boolean { + if (hostHeader === null || hostHeader === "") return true; + let hostname: string; + try { + hostname = new URL(`http://${hostHeader}`).hostname; + } catch { + return false; + } + // `new URL("http://[::1]").hostname` keeps the brackets; normalize to bare. + const normalized = hostname === "[::1]" ? "::1" : hostname; + return LOOPBACK_LITERALS.has(normalized); +} + +/** + * Why an inbound WS message was not ingested. Every rejection is counted under + * one of these and surfaced on `/stats`: a silently discarded envelope is + * indistinguishable from a host that never dialed, which is exactly the state a + * debugger must never leave its user guessing about. + */ +export type WireRejectReason = + | "bad-json" + | "not-object" + | "bad-channel-id" + | "empty-channel-id" + | "bad-dir" + | "bad-frame" + | "ingest-threw"; + +/** Every reject reason, so `/stats` always serializes the same key set. */ +const WIRE_REJECT_REASONS: readonly WireRejectReason[] = [ + "bad-json", + "not-object", + "bad-channel-id", + "empty-channel-id", + "bad-dir", + "bad-frame", + "ingest-threw", +]; + +/** One parse attempt: the message, or the reason it was refused. */ +type WireParseResult = + | { ok: true; value: ParsedWireMessage } + | { ok: false; reason: WireRejectReason }; + +/** Parse and validate one inbound WS text message. */ +function parseWireMessage(raw: string): WireParseResult { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return { ok: false, reason: "bad-json" }; + } + if (typeof parsed !== "object" || parsed === null) { + return { ok: false, reason: "not-object" }; + } + const m = parsed as Partial; + if (typeof m.channelId !== "string") { + return { ok: false, reason: "bad-channel-id" }; + } + // `""` is the "all channels" sentinel every filtering endpoint reads as absent, + // so a host using it as its own id could never be selected, filtered, or + // decode-scoped. Refuse it at ingest rather than admit an unaddressable host. + if (m.channelId === "") return { ok: false, reason: "empty-channel-id" }; + if (m.dir !== "in" && m.dir !== "out") return { ok: false, reason: "bad-dir" }; + if (typeof m.frame !== "string") return { ok: false, reason: "bad-frame" }; + const schema = typeof m.schema === "string" ? m.schema : undefined; + const identityMismatch = + (typeof m.v === "number" && m.v !== WIRE_ENVELOPE_VERSION) || + (typeof m.codec === "number" && m.codec !== TRUAPI_CODEC_VERSION) || + (schema !== undefined && schema !== TRUAPI_WIRE_SCHEMA_HASH); + // `dropped` feeds a summed `droppedByHost: number`, so anything but a finite + // non-negative integer is not a smaller number - it poisons the whole session's + // total. `1e999` is `Infinity`, which `JSON.stringify` emits as `null` and the + // UI renders as "0 dropped" for every channel; a float or a string would break + // the declared contract just as quietly. + const droppedRaw = m.dropped; + const droppedValid = + droppedRaw === undefined || + // `isSafeInteger`, not `isInteger`: 1e308 is an integer, so it passed, and + // two channels summing to Infinity serialize as JSON `null` - the UI then + // renders "0 dropped" for the whole session with nothing counted as invalid. + (typeof droppedRaw === "number" && + Number.isSafeInteger(droppedRaw) && + droppedRaw >= 0); + return { + ok: true, + value: { + envelope: { + channelId: m.channelId, + dir: m.dir, + frame: new Uint8Array(Buffer.from(m.frame, "base64")), + // Provenance travels with the frame: ingest decides whether to trust + // `observedAt` as the timestamp, and the trace engine suppresses + // retry-storm detection for a replayed backlog. Dropping these here made + // the fix invisible through the only mount a host actually dials. + ...(typeof m.observedAt === "number" ? { observedAt: m.observedAt } : {}), + ...(m.buffered === true ? { buffered: true as const } : {}), + }, + identityMismatch, + identityConfirmed: schema === TRUAPI_WIRE_SCHEMA_HASH, + dropped: + droppedValid && typeof droppedRaw === "number" && droppedRaw > 0 + ? droppedRaw + : 0, + droppedFieldInvalid: !droppedValid, + }, + }; +} + +/** A running debugger server. */ +export interface DebugServer { + /** The port the WS/HTTP server is listening on. */ + readonly port: number; + /** Whether level-2 value decode is enabled on the drill-down path. */ + readonly decodeValues: boolean; + /** Stop listening and drop active connections. */ + stop(): void; +} + +/** + * `JSON.stringify` that survives decoded SCALE values: `bigint` becomes a + * decimal string and `Uint8Array` a `0x…` hex string, both of which + * `JSON.stringify` otherwise throws on or renders as an index map. Only the + * drill-down detail path uses this; `/traces` never serializes decoded values. + */ +function safeStringify(value: unknown): string { + return JSON.stringify(value, (_key, val) => { + if (typeof val === "bigint") return val.toString(); + if (val instanceof Uint8Array) { + return `0x${Buffer.from(val).toString("hex")}`; + } + return val; + }); +} + +/** + * Start the debugger app: a Bun WS+HTTP server that decodes and groups every + * frame a host streams to it. `port: 0` binds an ephemeral port, read back from + * {@link DebugServer.port}. + * + * Level-2 value decode is ON unless `decodeValues: false` is passed - this is a + * dev-only tool that decodes everything (the CLI entry point derives the + * off-switch from `TRUAPI_DEBUGGER_DECODE_VALUES`). It affects all three + * drill-down paths, which render or serialize a decoded value: `/op` (the default + * page's detail pane), `/view` (the standalone fragment), and `/frame` (the JSON + * endpoint). The list-level endpoints - `/traces`, `/op-list`, `/stats` - are + * byte- and value-free either way. + */ +export function startDebugServer( + options: { + port?: number; + decodeValues?: boolean; + } = {}, +): DebugServer { + // Dev-only tool: decode everything by default. A caller can pass + // `decodeValues: false`. + const decodeValues = options.decodeValues ?? true; + const session = createDebugSession({ decodeValues }); + + /** Adapt one trace to a view with the shared method map. */ + const toView = ( + trace: ReturnType[number], + storms: ReturnType, + ): TraceView => + wireTraceToView(trace, session.methodNames, storms.get(trace) ?? []); + + /** + * Compute the cross-op retry-storm signal once over a trace set, then adapt + * every trace. The `traces() → detectRetryStorms → wireTraceToView` pipeline is + * shared by every list-level endpoint so the same aggregation runs once, not + * per endpoint. + */ + const viewsFor = ( + traces: ReturnType, + ): { trace: (typeof traces)[number]; view: TraceView }[] => { + const storms = detectRetryStorms(traces); + return traces.map((trace) => ({ trace, view: toView(trace, storms) })); + }; + + function tracesJson(): string { + // Payload-blind view: raw `bytes` and decoded values are deliberately never + // serialized here - values surface only in the `/op` and `/frame` drill-downs. + // `method` + // and `role` are public shape metadata derived from the frame id (the same + // id→name map the op list already exposes), not payload, so they are safe. + // Rendering each trace through the shared `wireTraceToView` also gives + // op-level badges (incl. the cross-op retry-storm signal), so the web and + // terminal frontends read one computed signal rather than each recomputing + // (or, for the CLI, silently omitting) it. + const out = viewsFor(session.traceEngine.traces()).map(({ trace: t, view }) => { + return { + channelId: t.channelId, + requestId: t.requestId, + generation: t.generation, + startedAt: t.startedAt, + lastAt: t.lastAt, + badges: view.badges, + frames: view.frames.map((f) => ({ + direction: f.direction, + frameId: f.frameId, + method: f.method, + role: f.role, + byteLength: f.byteLength, + timestamp: f.timestamp, + })), + }; + }); + return JSON.stringify(out); + } + + /** The `/frame?id=&i=[&channel=]` drill-down detail response. */ + function frameResponse(url: URL): Response { + const id = url.searchParams.get("id"); + const channel = optionalChannel(url.searchParams.get("channel")) ?? undefined; + // Both numeric params go through `optionalInt`: `Number("")`/`Number(" ")` are + // 0 and pass `Number.isInteger`, `Number("0x0")` is 0, and `frames[-0]` is + // `frames[0]`, so a bare `Number()` would resolve frame 0 / generation 0 (the + // oldest recycled op) with a 200 for four different spellings of "not a + // number". + const generation = optionalInt(url.searchParams.get("gen")); + const index = optionalInt(url.searchParams.get("i")); + if (id === null || index === null || index === undefined || generation === null) { + return new Response('{"error":"id and integer i required"}', { + status: 400, + headers: { "content-type": "application/json" }, + }); + } + if (!decodeTrusted(channel)) return codecRefusal("application/json"); + const detail = session.frameDetail(id, index, channel, generation); + if (!detail) { + return new Response('{"error":"no such frame"}', { + status: 404, + headers: { "content-type": "application/json" }, + }); + } + return new Response(safeStringify(detail), { + headers: { "content-type": "application/json" }, + }); + } + + /** + * The `/view?offset=&limit=` fragment: a WINDOW of traces rendered by the + * shared {@link renderTraceDetail}, the same renderer dotli's panel mounts. + * Each frame's value is decoded inline for a trusted channel; an untrusted + * (codec-mismatched) channel groups but shows no value. + * + * Bounded per request, and `null` for a malformed window (the caller gets a + * 400). This is the only endpoint that renders every retained frame's decoded + * value, so an unbounded response is quadratic in the session's own limits: at + * the engine's caps a single `/view` would build a multi-hundred-MB string in + * memory, block the event loop for seconds, and spike RSS by gigabytes. The + * window keeps one response proportional to what a human reads. + */ + function viewHtml(url: URL): string | null { + // `null` (malformed) must not collapse into the default the way `undefined` + // (absent) does, so the two are separated before either gets a fallback. + const rawOffset = optionalInt(url.searchParams.get("offset")); + const rawLimit = optionalInt(url.searchParams.get("limit")); + if (rawOffset === null || rawLimit === null) return null; + const offset = rawOffset ?? 0; + const limit = rawLimit ?? VIEW_DEFAULT_LIMIT; + if (offset < 0 || limit < 1 || limit > VIEW_MAX_LIMIT) return null; + const traces = session.traceEngine.traces(); + const entries = viewsFor(traces.slice(offset, offset + limit)); + if (entries.length === 0) { + return `
no frames yet
`; + } + const shown = offset + entries.length; + // Say so when the window hides ops, so a truncated read is never mistaken for + // the whole session (the failure mode the `evicted`/`dropped` tiles exist for). + const more = + shown < traces.length + ? `
showing ${offset + 1}-${shown} of ${traces.length} ops — ?offset=${shown} for more
` + : ""; + // Wrap each rendered op in `.td-drilldown` - dotli's verbatim card wrapper - + // so the standalone list gets the same per-op framing without a bespoke rule. + return ( + entries + .map( + ({ view }) => + `
` + + renderTraceDetail(view, { + offerDecode: session.decodeValues, + // Same codec/schema-drift guard the `/frame` endpoint enforces: an + // untrusted channel's frames group but never surface a decoded value. + decoded: decodeTrusted(view.channelId) + ? decodeTraceFrames(session, view) + : undefined, + }) + + `
`, + ) + .join("") + more + ); + } + + // Per-channel liveness for the inspector's host dimension. The envelope + // carries channelId; recording first/last-seen + frame count lets the UI show + // which hosts have dialed in and whether they are still active. Grouping + // traces by channel is a separate engine concern; this is only connection + // state. + // + // `connected` is RECENCY-based, not socket-based: a host counts as connected + // if it emitted a frame within the last CONNECTED_WINDOW_MS. It is NOT "has an + // open WS socket" - one WS can multiplex frames for several channelIds, so + // per-host socket liveness is not a clean fact. A host that goes quiet without + // closing its socket correctly reads as not-connected after the window. + const CONNECTED_WINDOW_MS = 5000; + // Cap the registry so a host (or anything able to reach the port) emitting + // frames under many distinct channelIds can't grow it without bound; when + // full, evict the least-recently-seen channel. + const MAX_CHANNELS = 256; + const channels = new Map< + string, + { + channelId: string; + firstSeen: number; + lastSeen: number; + frameCount: number; + // `false` once this host has sent a frame whose declared wire identity + // (`v`/`codec`/`schema`) does not match this debugger's. Sticky: a single + // mismatch marks the host untrusted for the rest of the session. + codecOk: boolean; + // `true` once this host affirmatively stamped a matching `schema`. Decode + // requires it, so a host that never declares identity is refused, not + // trusted by omission. + schemaOk: boolean; + // Frames the host reported dropping before delivery (its link backlog + // filled): a gap attributable to the link, surfaced so it is not read as + // the host "not answering". + dropped: number; + } + >(); + let openSockets = 0; + // Sticky: any host has sent an unconfirmed (mismatched or unstamped) frame this + // session. The no-channel decode path keys on this rather than scanning the live + // registry, because an untrusted host's channel record can be LRU-evicted (see + // MAX_CHANNELS) while its frames survive in the trace engine. + let sawUntrusted = false; + // Envelopes refused at ingest, by reason. Every reason is pre-seeded so the + // `/stats` key set is fixed and a client can chart a reason that is still zero. + const rejectCounts = new Map( + WIRE_REJECT_REASONS.map((r) => [r, 0]), + ); + // Sockets Bun closed abnormally (code 1006), and the subset it closed because an + // inbound message exceeded MAX_INBOUND_MESSAGE_BYTES. An over-cap message never + // reaches `message()`, so this close is the ONLY place the loss can be counted: + // without it an over-cap host's stream simply stops with every counter untouched. + let abnormalCloses = 0; + let oversizedMessages = 0; + // Frames whose `dropped` field was unusable (see `droppedFieldInvalid`). + let invalidDroppedFields = 0; + + /** Count one refused envelope under its reason. */ + function recordReject(reason: WireRejectReason): void { + rejectCounts.set(reason, (rejectCounts.get(reason) ?? 0) + 1); + } + + function recordChannel(channelId: string, parsed: ParsedWireMessage): void { + // A host is untrusted if it did not confirm the schema OR if any declared + // identity field mismatched. Keying only on `identityConfirmed` would let the + // `matching schema + mismatched v/codec` host (confirmed AND mismatched) leave + // this flag false, and with it the whole channel-less decode path open - + // discarding the one signal that sees a payload-layout drift the schema hash + // is blind to. + if (!parsed.identityConfirmed || parsed.identityMismatch) sawUntrusted = true; + if (parsed.droppedFieldInvalid) invalidDroppedFields += 1; + const now = Date.now(); + const key = normalizeId(channelId); + const existing = channels.get(key); + if (existing) { + existing.lastSeen = now; + existing.frameCount += 1; + existing.dropped += parsed.dropped; + if (parsed.identityMismatch) existing.codecOk = false; + if (parsed.identityConfirmed) existing.schemaOk = true; + return; + } + if (channels.size >= MAX_CHANNELS) { + let oldestKey: string | undefined; + let oldestSeen = Infinity; + for (const [k, c] of channels) { + if (c.lastSeen < oldestSeen) { + oldestSeen = c.lastSeen; + oldestKey = k; + } + } + if (oldestKey !== undefined) channels.delete(oldestKey); + } + channels.set(key, { + channelId: key, + firstSeen: now, + lastSeen: now, + frameCount: 1, + codecOk: !parsed.identityMismatch, + schemaOk: parsed.identityConfirmed, + dropped: parsed.dropped, + }); + } + + /** + * Whether a decoded value may be surfaced for a channel's frames. Only bites + * when decode is on (payload-blind mode never decodes anyway). Decode is + * allowed only for a channel that affirmatively stamped a matching wire + * `schema` and never mismatched. + * + * This is a COMPATIBILITY guard against honest version drift - a host built + * against a different frame table, where an id could resolve to the wrong + * method and value off this debugger's table - not authentication: + * `TRUAPI_WIRE_SCHEMA_HASH` is a public build constant, so a deliberate local + * injector could stamp it. The WS Origin gate ({@link originAllowed}) is the + * boundary against injection; this is defence in depth on top of it. + */ + function decodeTrusted(channel: string | undefined): boolean { + if (!decodeValues) return true; + if (channel !== undefined) { + const c = channels.get(normalizeId(channel)); + return c !== undefined && c.codecOk && c.schemaOk; + } + // No channel disambiguator: refuse once any host has been untrusted this + // session (sticky, so an evicted untrusted record can't launder its surviving + // frames). An all-trusted or empty session stays true, so a missing frame + // 404s rather than being masked by a refusal. + return !sawUntrusted; + } + + /** The 409 a decode path returns when the source host's wire codec mismatches. */ + function codecRefusal(contentType: string): Response { + return new Response('{"error":"decode refused: host wire codec mismatch"}', { + status: 409, + headers: { "content-type": contentType }, + }); + } + + function channelsJson(): string { + const now = Date.now(); + const list = [...channels.values()].sort((a, b) => b.lastSeen - a.lastSeen); + return JSON.stringify({ + sockets: openSockets, + // A banner signal: at least one connected host is streaming a wire codec + // this debugger can't decode against. + codecMismatch: list.some((c) => !c.codecOk), + channels: list.map((c) => ({ + ...c, + connected: now - c.lastSeen < CONNECTED_WINDOW_MS, + })), + }); + } + + /** + * The `/stats?channel=` aggregate roll-up over the ops being listed: counts, + * byte totals, durations, health-badge tallies, the request/response split, + * and the busiest methods. Payload-blind - it sums shape and timing only and + * never serializes a byte or a decoded value. Feeds the inspector's summary + * strip (the "aggregate-level value"). + */ + function statsJson(channel: string | null): string { + /** The payload-blind aggregate shape `/stats` serializes. */ + interface StatsPayload { + ops: number; + frames: number; + bytes: number; + subscriptions: number; + liveSubscriptions: number; + malformed: number; + orphaned: number; + retryStorms: number; + truncated: number; + evictedTraces: number; + droppedByHost: number; + codecMismatch: boolean; + out: number; + in: number; + avgDurationMs: number; + maxDurationMs: number; + topMethods: { method: string; count: number }[]; + /** + * Link-level loss and liveness, SESSION-WIDE (never narrowed by + * `?channel=`): a rejected envelope has no channel to attribute it to, and a + * closed socket may have carried several. Grouped so a client can tell "the + * host is quiet" from "the host is talking and this debugger is refusing or + * losing what it says". + */ + sockets: number; + envelopeRejects: number; + envelopeRejectReasons: Record; + oversizedMessages: number; + abnormalCloses: number; + invalidDroppedFields: number; + } + const traces = + channel === null + ? session.traceEngine.traces() + : session.traceEngine.tracesForChannel(normalizeId(channel)); + // ONE aggregate for both mounts. A second implementation here is exactly how + // the two silently disagreed: this block tested `!some(role === "stop")` for + // liveness, ignoring `interrupt`, so every host-terminated subscription + // (chain switch, revoked permission) counted as live forever and the tile + // climbed all session above an op list showing nothing live. + const stats = computeTraceStats(viewsFor(traces).map(({ view }) => view)); + const evictedTraces = session.traceEngine.evictedTraces(); + const chanList = + channel === null + ? [...channels.values()] + : [...channels.values()].filter( + (c) => c.channelId === normalizeId(channel), + ); + const droppedByHost = chanList.reduce((n, c) => n + c.dropped, 0); + const codecMismatch = chanList.some((c) => !c.codecOk); + // Typed so a dropped/renamed field is a compile error, not a silent gap in + // the payload a client parses back. + const payload: StatsPayload = { + ...stats, + evictedTraces, + droppedByHost, + codecMismatch, + sockets: openSockets, + envelopeRejects: [...rejectCounts.values()].reduce((n, c) => n + c, 0), + envelopeRejectReasons: Object.fromEntries(rejectCounts), + oversizedMessages, + abnormalCloses, + invalidDroppedFields, + }; + return JSON.stringify(payload); + } + + /** The op's method for sorting: the first frame that resolves to one. */ + function traceMethod( + trace: ReturnType[number], + ): string { + for (const f of trace.frames) { + const method = session.methodNames.get(f.frameId)?.method; + if (method !== undefined) return method; + } + return ""; + } + + /** + * Order the op list for the `?sort=` control. Default (`""`) keeps arrival + * order (stable under live updates); the others are one-shot reorders the + * client's keyed diff mirrors into the DOM. + */ + function sortTraces( + traces: ReturnType, + sort: string | null, + ): ReturnType { + if (!sort) return traces; + const copy = [...traces]; + switch (sort) { + case "recent": + return copy.sort((a, b) => b.lastAt - a.lastAt); + case "duration": + return copy.sort( + (a, b) => b.lastAt - b.startedAt - (a.lastAt - a.startedAt), + ); + case "frames": + return copy.sort((a, b) => b.frames.length - a.frames.length); + case "method": + return copy.sort((a, b) => traceMethod(a).localeCompare(traceMethod(b))); + default: + return traces; + } + } + + /** + * The `/op-list?channel=&sort=` primary view: one server-rendered row per op + * (the shared {@link renderOperationRow}), payload-blind. Retry-storm is a + * cross-op signal computed here and fed to each view as an extra badge. + * `channel` filters on the trace's channelId; `sort` reorders the rows. + */ + function opListHtml(channel: string | null, sort: string | null): string { + const base = + channel === null + ? session.traceEngine.traces() + : session.traceEngine.tracesForChannel(normalizeId(channel)); + // Retry-storm is per-channel (a burst of like ops from one host), so it is + // detected over exactly the traces being listed - before any reorder, since + // the storm map is keyed by the trace object, not its position. + const storms = detectRetryStorms(base); + if (base.length === 0) { + return `
no operations yet
`; + } + const rows = sortTraces(base, sort); + // If any listed op is from a host whose wire contract differs from this + // debugger's, its method names may be wrong. Warn inline above the rows - not + // only in the global banner - so the mislabeled rows carry the caveat. + // "Unreliable" = a mismatched OR merely unconfirmed host: either way its + // method names come from this debugger's table and may be wrong, so the label + // matches the decode gate's bar rather than the narrower banner. + const mismatched = new Set( + [...channels.values()] + .filter((c) => !c.codecOk || !c.schemaOk) + .map((c) => c.channelId), + ); + const notice = + mismatched.size > 0 && + rows.some((t) => mismatched.has(normalizeId(t.channelId))) + ? `
⚠ a connected host's wire contract differs from this debugger's — method names below may be wrong
` + : ""; + return ( + notice + + rows + .map((t) => renderOperationRow(toView(t, storms), { now: Date.now() })) + .join("") + ); + } + + /** + * The `/op?id=&channel=` detail fragment: the selected op via + * {@link renderTraceDetail}. `channel` disambiguates the `requestId` when more + * than one host is connected (each mints the same `p:N` ids). + */ + function opDetailHtml( + requestId: string, + channel: string | null, + generation?: number, + ): string { + const trace = session.traceEngine.trace( + requestId, + channel ?? undefined, + generation, + ); + if (!trace) { + return `
operation not found
`; + } + const storms = detectRetryStorms( + session.traceEngine.tracesForChannel(trace.channelId), + ); + const view = toView(trace, storms); + return renderTraceDetail(view, { + offerDecode: session.decodeValues, + // Codec/schema-drift guard, matching `/frame`: refuse to decode a channel + // whose wire schema did not affirmatively match this debugger's table. + decoded: decodeTrusted(channel ?? undefined) + ? decodeTraceFrames(session, view) + : undefined, + }); + } + + const htmlHeaders = { "content-type": "text/html; charset=utf-8" }; + + /** + * Route one request. Every throw is contained by the caller, so a malformed + * request can only ever cost its own response. + */ + function route(req: Request, srv: Bun.Server): Response | undefined { + const url = new URL(req.url); + // Reject cross-origin WebSocket upgrades (CSWSH): binding to loopback keeps + // off-box peers out, but a page open in the dev's own browser could still + // dial ws://127.0.0.1: to inject frames or drive the decoder over + // hostile bytes. A same-origin inspector and non-browser clients are + // allowed; a foreign browser Origin is not. + if (req.headers.get("upgrade")?.toLowerCase() === "websocket") { + if (!originAllowed(req.headers.get("origin"))) { + return new Response("forbidden origin", { status: 403 }); + } + if (srv.upgrade(req)) return undefined; + } + if (url.pathname === "/traces") { + return new Response(tracesJson(), { + headers: { "content-type": "application/json" }, + }); + } + if (url.pathname === "/channels") { + return new Response(channelsJson(), { + headers: { "content-type": "application/json" }, + }); + } + if (url.pathname === "/stats") { + return new Response(statsJson(optionalChannel(url.searchParams.get("channel"))), { + headers: { "content-type": "application/json" }, + }); + } + if (url.pathname === "/op-list") { + return new Response( + opListHtml( + optionalChannel(url.searchParams.get("channel")), + url.searchParams.get("sort"), + ), + { headers: htmlHeaders }, + ); + } + if (url.pathname === "/op") { + const id = url.searchParams.get("id"); + const generation = optionalInt(url.searchParams.get("gen")); + if (generation === null) { + return new Response(`
bad request
`, { + status: 400, + headers: htmlHeaders, + }); + } + return new Response( + id === null + ? `
select an operation
` + : opDetailHtml( + id, + optionalChannel(url.searchParams.get("channel")), + generation, + ), + { headers: htmlHeaders }, + ); + } + if (url.pathname === "/view") { + const html = viewHtml(url); + return html === null + ? new Response(`
bad request
`, { + status: 400, + headers: htmlHeaders, + }) + : new Response(html, { headers: htmlHeaders }); + } + if (url.pathname === "/frame") { + return frameResponse(url); + } + return new Response(VIEW_HTML, { headers: htmlHeaders }); + } + + const server = Bun.serve({ + port: options.port ?? DEFAULT_PORT, + // Loopback only: the debugger holds every trace (and, with decode on, + // decoded values), so it must not listen on all interfaces where a LAN peer + // could read or inject. + hostname: "127.0.0.1", + fetch(req, srv) { + // DNS-rebinding guard: the request's Host must be one this server answers + // for. This blocks a rebound `evil.com -> 127.0.0.1` page from reading + // decoded frames over same-origin fetches, which binding to loopback alone + // does not prevent. + // + // FIRST, before `new URL(req.url)`: Bun builds `req.url` from the Host + // header, so an unparseable authority (`Host: localhost:99999`) throws + // inside the URL constructor. Gating first turns that into the 403 the + // header already earns, instead of a 500 plus a stack trace per request. + if (!hostHeaderAllowed(req.headers.get("host"))) { + return new Response("forbidden host", { status: 403 }); + } + // The WS handler is explicitly exception-safe; so is the route dispatcher. + // One malformed request must cost its own response and nothing else - no + // 500 with a stack trace, and no unhandled rejection taking the process + // down mid-session. + try { + return route(req, srv); + } catch { + return new Response("bad request", { status: 400 }); + } + }, + websocket: { + maxPayloadLength: MAX_INBOUND_MESSAGE_BYTES, + open() { + openSockets += 1; + }, + close(_ws, code, reason) { + openSockets = Math.max(0, openSockets - 1); + // An over-cap message is not dropped: Bun closes the socket (1006, + // "Received too big message") without invoking `message`, so this is the + // only place the loss is observable. Count the specific case, and every + // abnormal close, so a stream that dies mid-session shows up on /stats + // instead of looking like a host that simply went quiet. + if (code === 1006) { + abnormalCloses += 1; + if (/too big/i.test(reason ?? "")) oversizedMessages += 1; + } + }, + message(_ws, message) { + // Defensive: a malformed frame must never take down the socket callback. + // parseWireMessage + the Result-based ingest don't throw today, but keep + // the invariant local so a future ingest change can't propagate here. + try { + const raw = typeof message === "string" ? message : message.toString(); + const parsed = parseWireMessage(raw); + if (parsed.ok) { + recordChannel(parsed.value.envelope.channelId, parsed.value); + // Still grouped (payload-blind is safe and useful); a mismatch only + // blocks the value-decode path, via decodeTrusted. + session.handleEnvelope(parsed.value.envelope); + } else { + recordReject(parsed.reason); + } + } catch { + // Drop the frame; the observed session is worth more than one trace. + recordReject("ingest-threw"); + } + }, + }, + }); + + return { + // Always a TCP port here; the `?? 0` only satisfies Bun's unix-socket union. + port: server.port ?? 0, + decodeValues, + stop: () => server.stop(true), + }; +} + +/** + * The wire inspector: a full-screen, host-agnostic dev tool - a Network tab for + * TrUAPI wire frames. Left is the operation list (one row per op, the primary + * view); right is the selected op's frame sequence via the shared + * {@link renderTraceDetail}. A top bar switches between the hosts that have + * dialed in; a status bar shows counts and liveness. + * + * The client is a thin shell over server-rendered fragments: it polls + * `/op-list` (the shared {@link renderOperationRow}) and `/channels`, and fetches + * `/op` when an operation is selected. Every injected fragment is produced and + * escaped server-side, so `innerHTML` is safe. `/op-list` is payload-blind + * (shape/timing only); `/op` renders each frame's decoded value inline for a + * trusted channel. `td-*` classes are owned by the shared renderer. + */ +const VIEW_HTML = ` + +TrUAPI Wire Inspector + +
+ TrUAPI Wire Inspector + + + +
+
waiting for frames…
+
+
waiting for frames…
+
+
Select an operation to inspect its frames. ↑/↓ to move, Enter to open.
+
+
connecting…
+ +`; + +/** + * Whether value decode is on, from `TRUAPI_DEBUGGER_DECODE_VALUES`. + * + * On by default (dev-only tool); `0`/`false`/`no`/`off` in any case turns it off. + * TRIMMED first: this is the switch that stops full payload decode, so it must + * fail CLOSED on the shapes a shell or a `.env` file actually produces - + * `DECODE_VALUES="0 "` and `DECODE_VALUES=$'false\n'` are how a human writes + * "off", and an untrimmed match reads both as "on". + */ +export function decodeValuesFromEnv(raw: string | undefined): boolean { + return !/^(0|false|no|off)$/i.test((raw ?? "").trim()); +} + +/** + * The listen port from `TRUAPI_DEBUGGER_PORT`: the value, `DEFAULT_PORT` when + * unset/empty, or `null` when it is not a usable port. + * + * Rejects rather than coerces. `Number.isFinite(x) && x > 0` accepts `99999`, + * which the OS truncates to a DIFFERENT port (65535) that the host's debug URL + * will not be pointing at, and `1.5`, which crashes the process on port 1. A + * silently-wrong port on a debugger is indistinguishable from a host that never + * dialed - the single most expensive failure this tool can have. + */ +export function portFromEnv(raw: string | undefined): number | null { + const t = (raw ?? "").trim(); + if (t === "") return DEFAULT_PORT; + if (!/^\d+$/.test(t)) return null; + const port = Number(t); + // 0 would bind an ephemeral port nobody can predict; 65535 is the TCP ceiling. + return port >= 1 && port <= 65535 ? port : null; +} + +// Entry point: `bun run src/server.ts` (or `npm run serve`) starts the server. +// Port comes from TRUAPI_DEBUGGER_PORT, else the default. This is a DEV-ONLY, +// loopback-only tool: value decode is ON by default (set +// TRUAPI_DEBUGGER_DECODE_VALUES to 0/false/no/off to turn decode off for a demo). +if (import.meta.main) { + const port = portFromEnv(Bun.env.TRUAPI_DEBUGGER_PORT); + if (port === null) { + console.error( + `[truapi-debugger] TRUAPI_DEBUGGER_PORT must be an integer in 1-65535,` + + ` got ${JSON.stringify(Bun.env.TRUAPI_DEBUGGER_PORT)}`, + ); + process.exit(1); + } + const server = startDebugServer({ + port, + decodeValues: decodeValuesFromEnv(Bun.env.TRUAPI_DEBUGGER_DECODE_VALUES), + }); + console.log( + `[truapi-debugger] listening on http://127.0.0.1:${server.port}` + + ` (value decode: ${server.decodeValues ? "on" : "off"})`, + ); +} diff --git a/js/packages/truapi-debugger/src/session.ts b/js/packages/truapi-debugger/src/session.ts new file mode 100644 index 000000000..527fd3be7 --- /dev/null +++ b/js/packages/truapi-debugger/src/session.ts @@ -0,0 +1,347 @@ +/** + * A debug session: the trace engine wired to the ingest. + * + * A host dials the debugger and streams {@link DebugFrameEnvelope}s over a + * socket; each is handed to {@link DebugSession.handleEnvelope}, decoded, and + * grouped into per-`requestId` traces readable via {@link DebugSession.traces}. + * + * The socket itself is deliberately not here. The debugger app is a WS server + * (hosts dial outward to it), but binding the socket is a thin edge: accept a + * connection, JSON/CBOR-decode each message into a {@link DebugFrameEnvelope}, + * and call `handleEnvelope`. Keeping that edge out of this module lets the + * session compile and unit-test without a socket transport or Node types. + * + * @module + */ + +import { + createWireDebugger, + createMethodNameMap, + type WireDebugger, + type WireMethodInfo, +} from "./wire-debugger.js"; +import { createDebugIngest, type DebugFrameEnvelope } from "./ingest.js"; +import { createFrameDecoder, type FrameValueDetail } from "./decode.js"; +import { + isLiveSubscription, + isSubscription, + operationMethod, + type TraceView, +} from "./trace-view.js"; +import * as W from "@parity/truapi/wire-table"; +import { createClient, createTransport } from "@parity/truapi"; + +/** A provider that sends and receives nothing; used only to enumerate service names. */ +const NOOP_PROVIDER = { + postMessage() {}, + subscribe() { + return () => {}; + }, + dispose() {}, +}; + +/** Options for {@link createDebugSession}. */ +export interface DebugSessionOptions { + /** + * Turn on level-2 value decode in the drill-down detail path. On by default + * (this is a dev-only tool that decodes everything). When on, the session + * retains raw frame bytes so {@link DebugSession.frameDetail} can decode a + * frame; `/traces` stays payload-blind regardless (it never reads bytes or + * decoded values). When off, `frameDetail` reports byte length only. + */ + decodeValues?: boolean; + /** + * Cap on retained operations, LRU-evicted (see + * {@link WireDebuggerOptions.maxTraces}). Defaults to the engine's own default. + * A mount that shares a tab with the observed app should lower it: the product + * pays for whatever the panel retains. + */ + maxTraces?: number; + /** + * Cap on retained frames within one operation (see + * {@link WireDebuggerOptions.maxFramesPerTrace}). Defaults to the engine's own + * default. + */ + maxFramesPerTrace?: number; + /** + * Cap on retained payload bytes within one operation (see + * {@link WireDebuggerOptions.maxBytesPerTrace}); only bites while + * {@link DebugSessionOptions.decodeValues} retains bytes. Defaults to the + * engine's own default. + */ + maxBytesPerTrace?: number; +} + +/** How many methods the busiest-methods roll-up reports. */ +const TOP_METHOD_LIMIT = 5; + +/** What the busiest-methods roll-up calls an op whose ids were all off-table. */ +const UNKNOWN_METHOD = "(unknown)"; + +/** + * Facts about a session that no single {@link TraceView} can carry, supplied by + * the mount that owns the link: whole-op eviction, link-level drops, and whether + * a feeding host's wire contract disagrees with this debugger's. + */ +export interface TraceStatsExtras { + /** Whole operations LRU-evicted (`traceEngine.evictedTraces()`). */ + evictedTraces?: number; + /** Frames the feeding host reported dropping before delivery. */ + droppedByHost?: number; + /** Whether any feeding host declared a wire contract this debugger can't decode against. */ + codecMismatch?: boolean; +} + +/** + * The payload-blind aggregate roll-up behind a mount's summary strip: counts, + * byte totals, durations, health tallies, the direction split, and the busiest + * methods. Shape and timing only - never a byte or a decoded value. + */ +export interface TraceStats { + ops: number; + frames: number; + bytes: number; + subscriptions: number; + liveSubscriptions: number; + malformed: number; + orphaned: number; + retryStorms: number; + truncated: number; + evictedTraces: number; + droppedByHost: number; + codecMismatch: boolean; + out: number; + in: number; + avgDurationMs: number; + maxDurationMs: number; + topMethods: { method: string; count: number }[]; +} + +/** + * Roll a set of {@link TraceView}s up into the summary strip's numbers. + * + * This is THE aggregate computation for every mount. A second implementation is + * how the two mounts silently disagree about the same stream (one reporting + * `malformed 1`, the other reporting no malformed at all), so the standalone + * server's `/stats` and the in-app embed's strip both go through here rather than + * each summing views their own way. + * + * `avgDurationMs` averages over ALL ops, not only completed ones: an op that is + * still open contributes its elapsed span, so a stream full of hung calls does + * not read as a fast session. + */ +export function computeTraceStats( + views: readonly TraceView[], + extras: TraceStatsExtras = {}, +): TraceStats { + let frames = 0; + let bytes = 0; + let subscriptions = 0; + let liveSubscriptions = 0; + let malformed = 0; + let orphaned = 0; + let retryStorms = 0; + let truncated = 0; + let out = 0; + let inbound = 0; + let durationTotal = 0; + let durationMax = 0; + const methodCounts = new Map(); + for (const view of views) { + frames += view.frames.length; + durationTotal += view.durationMs; + if (view.durationMs > durationMax) durationMax = view.durationMs; + if (view.badges.includes("malformed")) malformed += 1; + if (view.badges.includes("orphaned")) orphaned += 1; + if (view.badges.includes("retry-storm")) retryStorms += 1; + if (view.badges.includes("truncated")) truncated += 1; + // Subscription liveness comes from the shared definitions rather than a + // local role test, so the strip's "subs · N live" can't disagree with the + // `live` marker the op rows show. + if (isSubscription(view)) { + subscriptions += 1; + if (isLiveSubscription(view)) liveSubscriptions += 1; + } + for (const f of view.frames) { + bytes += f.byteLength ?? 0; + if (f.direction === "out") out += 1; + else inbound += 1; + } + const method = operationMethod(view) ?? UNKNOWN_METHOD; + methodCounts.set(method, (methodCounts.get(method) ?? 0) + 1); + } + const ops = views.length; + return { + ops, + frames, + bytes, + subscriptions, + liveSubscriptions, + malformed, + orphaned, + retryStorms, + truncated, + evictedTraces: extras.evictedTraces ?? 0, + droppedByHost: extras.droppedByHost ?? 0, + codecMismatch: extras.codecMismatch ?? false, + out, + in: inbound, + avgDurationMs: ops === 0 ? 0 : Math.round(durationTotal / ops), + maxDurationMs: Math.round(durationMax), + topMethods: [...methodCounts.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, TOP_METHOD_LIMIT) + .map(([method, count]) => ({ method, count })), + }; +} + +/** + * `512 B` / `1.4 KB` / `2.10 MB`, for a {@link TraceStats} byte total. Shared so + * the two mounts' summary strips read the same number the same way. + */ +export function formatStatBytes(n: number): string { + if (n < 1024) return `${String(n)} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + return `${(n / (1024 * 1024)).toFixed(2)} MB`; +} + +/** `340ms` / `1.20s`, for a {@link TraceStats} duration. Shared, as above. */ +export function formatStatMs(ms: number): string { + return ms < 1000 ? `${String(Math.round(ms))}ms` : `${(ms / 1000).toFixed(2)}s`; +} + +/** Live debug session: feed it envelopes, read back grouped traces. */ +export interface DebugSession { + /** Handle one wire envelope from the host tap. */ + handleEnvelope(envelope: DebugFrameEnvelope): void; + /** The underlying trace engine (traces, per-id lookup, clear). */ + readonly traceEngine: WireDebugger; + /** Reverse map from wire `frameId` to method, for labelling frames in a view. */ + readonly methodNames: ReadonlyMap; + /** Whether level-2 value decode is enabled for this session. */ + readonly decodeValues: boolean; + /** + * Drill-down: resolve one frame (by its trace `requestId` and index within + * that trace) to a {@link FrameValueDetail}. Pass `channelId` to disambiguate + * when more than one host is connected (each mints the same `p:N` ids). + * Returns `undefined` if no such frame exists. This is the *only* path that can + * surface a decoded value, and only when {@link DebugSessionOptions.decodeValues} + * is on; otherwise it reports byte length only. + */ + frameDetail( + requestId: string, + index: number, + channelId?: string, + generation?: number, + ): FrameValueDetail | undefined; + /** + * Decode every frame of one op in a single trace resolution, keyed by frame + * index (`seq`). This is the batch path the inline drill-down uses, so a mount + * resolves the op once rather than re-resolving it per frame. Empty when decode + * is off or the op is not found. + */ + decodedFrames( + requestId: string, + channelId?: string, + generation?: number, + ): Map; +} + +/** + * Build a {@link DebugSession}. The `frameId → method` map is derived from the + * generated wire table and client service names, so traces show + * `account.getAccount` rather than a bare `id=22`. + */ +export function createDebugSession( + options: DebugSessionOptions = {}, +): DebugSession { + // Dev-only tool: decode everything by default. The developer is looking at + // their own session's traffic, so value decode is ON unless a caller explicitly + // turns it off (tests do). + const decodeValues = options.decodeValues ?? true; + const serviceNames = Object.keys(createClient(createTransport(NOOP_PROVIDER))); + const methodNames = createMethodNameMap( + W as unknown as Record, + serviceNames, + ); + // No `sink`: a session accumulates traces for the view/`/traces`; it must not + // spam the server console with a line per frame (the sink default is + // `console.debug`). Consumers read `traceEngine`, not stdout. + // + // The retention caps are the session's memory ceiling + // (`maxTraces × maxFramesPerTrace`, bounded in bytes by `maxBytesPerTrace`), so + // they are forwarded rather than left at the engine default: a mount that lives + // in the observed app's own tab has to be able to lower them. + const wireDebugger = createWireDebugger({ + methodNames, + sink: () => {}, + ...(options.maxTraces === undefined ? {} : { maxTraces: options.maxTraces }), + ...(options.maxFramesPerTrace === undefined + ? {} + : { maxFramesPerTrace: options.maxFramesPerTrace }), + ...(options.maxBytesPerTrace === undefined + ? {} + : { maxBytesPerTrace: options.maxBytesPerTrace }), + }); + // Raw bytes are retained only when decode is on - they exist solely to feed + // the drill-down decoder, and `/traces` never serializes them. `methodNames` + // resolves each frame's role at ingest, so the engine and any forward hook see + // the real role rather than "unknown". + const handleEnvelope = createDebugIngest(wireDebugger.observe, { + retainBytes: decodeValues, + methodNames, + }); + const decoder = createFrameDecoder({ enabled: decodeValues }); + + const frameDetail = ( + requestId: string, + index: number, + channelId?: string, + generation?: number, + ): FrameValueDetail | undefined => { + const frame = wireDebugger.trace(requestId, channelId, generation)?.frames[ + index + ]; + return frame ? decoder.detail(frame) : undefined; + }; + + const decodedFrames = ( + requestId: string, + channelId?: string, + generation?: number, + ): Map => { + const decoded = new Map(); + if (!decodeValues) return decoded; + // Resolve the op once, then decode each frame off the resolved trace, rather + // than re-resolving (a linear scan over every retained trace) per frame. + const trace = wireDebugger.trace(requestId, channelId, generation); + if (!trace) return decoded; + trace.frames.forEach((frame, index) => { + const detail = decoder.detail(frame); + if (detail !== undefined) decoded.set(index, detail); + }); + return decoded; + }; + + return { + handleEnvelope, + traceEngine: wireDebugger, + methodNames, + decodeValues, + frameDetail, + decodedFrames, + }; +} + +/** + * Decode every frame of an op up front, keyed by frame `seq`, ready to hand to + * {@link renderTraceDetail}'s `decoded` option. A dev-only tool shows values + * inline rather than behind a per-frame control, so a mount decodes the whole + * op in one pass. Returns an empty map when the session has decode off. + */ +export function decodeTraceFrames( + session: DebugSession, + view: TraceView, +): Map { + return session.decodedFrames(view.requestId, view.channelId, view.generation); +} diff --git a/js/packages/truapi-debugger/src/trace-render.test.ts b/js/packages/truapi-debugger/src/trace-render.test.ts new file mode 100644 index 000000000..712730576 --- /dev/null +++ b/js/packages/truapi-debugger/src/trace-render.test.ts @@ -0,0 +1,450 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: MIT + +import { describe, expect, test } from "bun:test"; +import type { FrameValueDetail } from "./decode.js"; +import type { FrameRole, ObservedFrame } from "./observed-frame.js"; +import type { TraceView } from "./trace-view.js"; +import { wireTraceToView } from "./trace-view.js"; +import type { + TraceDropCounts, + WireMethodInfo, + WireTrace, +} from "./wire-debugger.js"; +import { + renderFrameValueDetail, + renderOperationRow, + renderTraceDetail, +} from "./trace-render.js"; + +/** Wire ids for one unary method and one subscription, as the wire table has them. */ +const WIRE: ReadonlyMap = new Map([ + [22, { method: "account.getAccount", kind: "request" }], + [23, { method: "account.getAccount", kind: "response" }], + [40, { method: "account.connectionStatus", kind: "start" }], + [41, { method: "account.connectionStatus", kind: "receive" }], + [42, { method: "account.connectionStatus", kind: "stop" }], + [43, { method: "account.connectionStatus", kind: "interrupt" }], +]); + +/** + * Build a view the way a mount does - through the wire adapter - so the badges + * under test are the ones the engine really assigns, not hand-written ones. + */ +function viewOf( + frames: readonly [number, number][], + dropped?: TraceDropCounts, +): TraceView { + const observed: ObservedFrame[] = frames.map(([frameId, timestamp]) => ({ + channelId: "localhost:3000", + // Real ingest cannot know the lifecycle role; the adapter resolves it from + // the frame id's wire-table kind. + role: "unknown" as FrameRole, + direction: "out", + requestId: "p:1", + frameId, + byteLength: 8, + timestamp, + })); + const trace: WireTrace = { + channelId: "localhost:3000", + requestId: "p:1", + generation: 0, + frames: observed, + startedAt: observed[0]?.timestamp ?? 0, + lastAt: observed[observed.length - 1]?.timestamp ?? 0, + truncated: dropped !== undefined, + dropped: dropped ?? { + framesByCount: 0, + framesByBytes: 0, + payloadsShed: 0, + }, + }; + return wireTraceToView(trace, WIRE); +} + +const view: TraceView = { + requestId: "req-1", + startedAt: 1000, + lastAt: 1150, + durationMs: 150, + frames: [ + { + seq: 0, + direction: "out", + role: "request", + method: "account.getAccount", + frameId: 22, + byteLength: 8, + timestamp: 1000, + latencyFromStartMs: 0, + badges: [], + decodable: true, + }, + { + seq: 1, + direction: "in", + role: "response", + method: "account.getAccount", + frameId: 23, + byteLength: 40, + timestamp: 1150, + latencyFromStartMs: 150, + roundTripMs: 150, + badges: [], + decodable: true, + }, + ], + badges: [], +}; + +describe("renderTraceDetail", () => { + test("renders the frame sequence with method, bytes, and round-trip", () => { + const html = renderTraceDetail(view); + expect(html).toContain("account.getAccount"); + expect(html).toContain("40B"); + expect(html).toContain("150ms"); + expect(html).toContain('data-seq="1"'); + }); + + test("is payload-blind by default: no decode control", () => { + const html = renderTraceDetail(view); + expect(html).not.toContain("decode payload"); + }); + + test("shows byte length for a decodable frame with no resolved value", () => { + // Decode on but no value supplied for the frame: it falls back to its size, + // never a click-to-decode control (a dev-only tool decodes up front). + const html = renderTraceDetail(view, { offerDecode: true }); + expect(html).not.toContain("td-frame-decode-btn"); + expect(html).toContain("payload not shown"); + }); + + test("renders a resolved decoded value in place of the control", () => { + const decoded = new Map([ + [1, { kind: "decoded", value: { free: 42 } }], + ]); + const html = renderTraceDetail(view, { offerDecode: true, decoded }); + expect(html).toContain(""free": 42"); + }); + + test("a bytes-only detail shows byte length, never a value", () => { + const decoded = new Map([ + [0, { kind: "bytes", byteLength: 96 }], + ]); + const html = renderTraceDetail(view, { offerDecode: true, decoded }); + expect(html).toContain("96B"); + expect(html).toContain("payload not shown"); + expect(html).not.toContain("free"); + }); + + test("escapes wire-sourced strings", () => { + const evil: TraceView = { + ...view, + requestId: '', + frames: [], + }; + const html = renderTraceDetail(evil); + expect(html).not.toContain(" { + const html = renderTraceDetail({ + ...view, + badges: ["orphaned", "retry-storm"], + }); + expect(html).toContain("td-badge-orphaned"); + expect(html).toContain("retry storm"); + }); +}); + +describe("renderFrameValueDetail", () => { + test("bytes-only with no retained hex shows byte length only", () => { + const html = renderFrameValueDetail({ kind: "bytes", byteLength: 12 }); + expect(html).toContain("12B"); + expect(html).toContain("payload not shown"); + }); + + test("bytes with retained hex shows the raw hex, never 'payload not shown'", () => { + const html = renderFrameValueDetail({ + kind: "bytes", + byteLength: 3, + hex: "0x010203", + }); + expect(html).toContain("0x010203"); + expect(html).not.toContain("payload not shown"); + }); +}); + +describe("renderOperationRow — an unanswered op reports how long it has waited", () => { + /** A request that went out and got nothing back: the shape of a hung call. */ + const unanswered: TraceView = { + requestId: "p:4", + channelId: "localhost:3000", + startedAt: 1_000, + lastAt: 1_000, + // One frame, so last === started and the honest span really is 0. + durationMs: 0, + frames: [ + { + seq: 0, + direction: "out", + role: "request", + method: "account.getAccountAlias", + frameId: 24, + byteLength: 97, + badges: ["orphaned"], + }, + ], + badges: ["orphaned"], + }; + + test("counts up from the request instead of reporting 0ms", () => { + // 45s after the request went out, with no reply. + const html = renderOperationRow(unanswered, { now: 46_000 }); + expect(html).toContain("waiting 45.00s"); + expect(html).not.toContain("· 0ms"); + // Flagged so the row can be styled as a problem, not a fast success. + expect(html).toContain("td-op-waiting"); + }); + + test("the wait grows as the call stays unanswered", () => { + const early = renderOperationRow(unanswered, { now: 3_000 }); + const later = renderOperationRow(unanswered, { now: 30_000 }); + expect(early).toContain("waiting 2.00s"); + expect(later).toContain("waiting 29.00s"); + }); + + test("without a clock it falls back to the recorded span", () => { + // Callers that cannot supply a clock (or replay a fixed trace) keep the old + // behaviour rather than inventing a time. + const html = renderOperationRow(unanswered); + expect(html).toContain("0ms"); + expect(html).not.toContain("waiting"); + expect(html).not.toContain("td-op-waiting"); + }); + + test("an answered op still shows its real round trip, not a wait", () => { + const answered: TraceView = { + ...unanswered, + requestId: "p:2", + lastAt: 1_150, + durationMs: 150, + frames: [ + { ...unanswered.frames[0]!, badges: [] }, + { + seq: 1, + direction: "in", + role: "response", + method: "account.getAccount", + frameId: 23, + byteLength: 35, + badges: [], + }, + ], + badges: [], + }; + const html = renderOperationRow(answered, { now: 999_999 }); + expect(html).toContain("150ms"); + expect(html).not.toContain("waiting"); + }); + + test("an unanswered subscribe (orphaned start) also counts up", () => { + // The true-positive on the `start` leg: a subscribe that never delivered. + const view = viewOf([[40, 1_000]]); + expect(view.frames[0].badges).toContain("orphaned"); + const html = renderOperationRow(view, { now: 6_000 }); + expect(html).toContain("waiting 5.00s"); + // It is a subscription with no terminator, so it is live AND waiting: the row + // carries both classes and the stylesheet's precedence rule decides the + // colour. The meta text reports the wait, not the span. + expect(html).toContain("td-op-live"); + expect(html).toContain("td-op-waiting"); + }); +}); + +describe("renderOperationRow — `waiting` needs an unanswered OPENER, not an orphan badge", () => { + // The op-level `orphaned` badge also fires on a closer with no opener. Reading + // it as "unanswered" pre-empts the honest duration with a nonsense wait. + + test("a receive that raced past the stop keeps the op's real duration", () => { + const view = viewOf([ + [40, 1_000], // start + [41, 1_100], // receive + [42, 1_200], // stop + [41, 1_205], // a receive already in flight lands after the stop + ]); + // The late receive is a closer with no opener left on the stack: orphaned. + expect(view.badges).toContain("orphaned"); + expect(view.durationMs).toBe(205); + const html = renderOperationRow(view, { now: 1_000 + 3_600_000 }); + expect(html).toContain("205ms"); + expect(html).not.toContain("waiting"); + expect(html).not.toContain("td-op-waiting"); + }); + + test("a subscription observed receive-only reports live, not a wait", () => { + // The debugger attached mid-session, so the `start` was never observed and + // every receive orphans. The sub is delivering a frame a second. + const view = viewOf([ + [41, 1_000], + [41, 2_000], + [41, 3_000], + ]); + expect(view.badges).toContain("orphaned"); + const html = renderOperationRow(view, { now: 301_000 }); + expect(html).not.toContain("waiting"); + expect(html).toContain("live"); + }); + + test("an off-table opener leaves a completed round trip reading as one", () => { + // Frame id 999 is not on this debugger's table, so the opener resolves to + // role "unknown" and its response orphans — but the call did complete. + const view = viewOf([ + [999, 1_000], + [23, 1_120], + ]); + expect(view.badges).toContain("orphaned"); + const html = renderOperationRow(view, { now: 1_000 + 3_600_000 }); + expect(html).toContain("120ms"); + expect(html).not.toContain("waiting"); + }); +}); + +describe("renderOperationRow — liveness", () => { + test("a subscription the host interrupted is not live", () => { + // `interrupt` is the host's terminator. Testing only for `stop` leaves every + // host-ended subscription reading live for the rest of the session. + const view = viewOf([ + [40, 1_000], + [41, 1_100], + [43, 1_200], // interrupt + ]); + const html = renderOperationRow(view); + expect(html).toContain("td-op-sub"); + expect(html).not.toContain("td-op-live"); + expect(html).not.toContain("live"); + }); + + test("a subscription with no terminator is still live", () => { + const html = renderOperationRow( + viewOf([ + [40, 1_000], + [41, 1_100], + ]), + ); + expect(html).toContain("td-op-live"); + }); +}); + +describe("truncation is reported per axis, not as one boolean", () => { + test("the badge carries the count and names the cap that took the frames", () => { + const view = viewOf([[40, 1_000]], { + framesByCount: 77, + framesByBytes: 0, + payloadsShed: 0, + }); + const html = renderOperationRow(view); + expect(html).toContain("td-badge-truncated"); + expect(html).toContain("truncated 77"); + expect(html).toContain("77 frames dropped (frame cap)"); + }); + + test("one frame lost does not render like seventy-seven", () => { + const one = renderOperationRow( + viewOf([[40, 1_000]], { + framesByCount: 1, + framesByBytes: 0, + payloadsShed: 0, + }), + ); + const many = renderOperationRow( + viewOf([[40, 1_000]], { + framesByCount: 77, + framesByBytes: 0, + payloadsShed: 0, + }), + ); + expect(one).toContain("truncated 1"); + expect(many).toContain("truncated 77"); + expect(one).not.toBe(many); + }); + + test("the byte axis is distinguishable from the frame axis", () => { + const html = renderTraceDetail( + viewOf([[40, 1_000]], { + framesByCount: 0, + framesByBytes: 4, + payloadsShed: 2, + }), + ); + expect(html).toContain("4 frames dropped (byte cap)"); + expect(html).toContain("2 payloads shed"); + expect(html).not.toContain("frame cap"); + }); +}); + +describe("duration formatting", () => { + test("a long wait reads in hours, not thousands of seconds", () => { + const view: TraceView = { + requestId: "p:9", + startedAt: 0, + lastAt: 0, + durationMs: 0, + frames: [ + { + seq: 0, + direction: "out", + role: "request", + method: "account.getAccount", + frameId: 22, + byteLength: 8, + timestamp: 0, + latencyFromStartMs: 0, + badges: ["orphaned"], + decodable: false, + }, + ], + badges: ["orphaned"], + }; + expect(renderOperationRow(view, { now: 10_800_000 })).toContain( + "waiting 3h 00m", + ); + expect(renderOperationRow(view, { now: 10_800_000 })).not.toContain( + "10800.00s", + ); + expect(renderOperationRow(view, { now: 205_000 })).toContain( + "waiting 3m 25s", + ); + // Under a minute still reads in seconds. + expect(renderOperationRow(view, { now: 45_000 })).toContain( + "waiting 45.00s", + ); + }); + + test("a multi-minute op's span reads in minutes", () => { + const html = renderOperationRow( + viewOf([ + [40, 0], + [41, 205_000], + ]), + ); + expect(html).toContain("3m 25s"); + }); +}); + +describe("method labels survive left-truncation", () => { + test("the method is emitted inside an explicit LTR isolate", () => { + // `.td-op-method` uses `direction: rtl` to put the ellipsis on the left, which + // reorders any label that is not a pure LTR identifier (`account.getAccount:` + // → `:account.getAccount`). The isolate keeps it one left-to-right run. + const html = renderOperationRow( + viewOf([ + [22, 1_000], + [23, 1_100], + ]), + ); + expect(html).toContain('account.getAccount'); + }); +}); diff --git a/js/packages/truapi-debugger/src/trace-render.ts b/js/packages/truapi-debugger/src/trace-render.ts new file mode 100644 index 000000000..3804a65ab --- /dev/null +++ b/js/packages/truapi-debugger/src/trace-render.ts @@ -0,0 +1,397 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: MIT +/** + * The one drill-down renderer, mounted in both the standalone app and dotli's + * panel. + * + * "One level deeper": given a selected op, render its frame sequence - + * request→response, or subscribe→receive×N→stop - with method, direction, byte + * length, latency, and orphaned/malformed/retry-storm badges. It is a pure + * `TraceView → HTML` function so the two mounts render identically; each mount + * supplies the {@link TraceView} through its own adapter (see {@link + * wireTraceToView} for the wire vantage). + * + * Payload-blind by default. Level-2 value decode is offered only when a mount + * opts in (`offerDecode`) and passes decode results back in (`decoded`); the + * renderer never touches bytes itself. Decode results come from the Core + + * Decode thread's {@link FrameValueDetail}: a frame renders either its decoded + * value or its byte length. + * + * The renderer emits HTML strings (both mounts assign `innerHTML`) using `td-*` + * classes so one stylesheet covers both. Every interpolated string that came + * off the wire (`requestId`, `method`) is escaped. + * + * @module + */ + +import type { FrameValueDetail } from "./decode.js"; +import { + isLiveSubscription, + isSubscription, + operationMethod, +} from "./trace-view.js"; +import type { + TraceBadge, + TraceFrameBadge, + TraceFrameView, + TraceView, +} from "./trace-view.js"; +import type { TraceDropCounts } from "./wire-debugger.js"; + +/** Options controlling a single drill-down render. */ +export interface RenderTraceDetailOptions { + /** + * Offer the per-frame level-2 decode affordance for decodable frames. Off by + * default: the view stays payload-blind and shows no decode control. + */ + offerDecode?: boolean; + /** + * Decoded values for this op, keyed by frame `seq`. A dev-only mount decodes + * every frame up front (calling the Core session's `frameDetail`) and passes + * the results here. A frame absent from the map falls back to its byte length. + */ + decoded?: ReadonlyMap; +} + +/** HTML-escape a wire-sourced string before it touches `innerHTML`. */ +function esc(value: string): string { + return value.replace(/[&<>"']/g, (c) => { + switch (c) { + case "&": + return "&"; + case "<": + return "<"; + case ">": + return ">"; + case '"': + return """; + default: + return "'"; + } + }); +} + +/** + * Compact duration: `42` → `42ms`, `1234` → `1.23s`, `205_000` → `3m 25s`, + * `10_800_000` → `3h 00m`. + * + * Seconds cannot be the largest unit: this also formats how long an unanswered + * call has been waiting, and a session left open renders "10800.00s" - a number + * nobody reads as three hours. + */ +function formatMs(ms: number): string { + if (ms < 1000) return `${String(Math.round(ms))}ms`; + if (ms < 60_000) return `${(ms / 1000).toFixed(2)}s`; + const pad = (n: number): string => String(n).padStart(2, "0"); + const totalSeconds = Math.floor(ms / 1000); + if (ms < 3_600_000) { + return `${String(Math.floor(totalSeconds / 60))}m ${pad(totalSeconds % 60)}s`; + } + const totalMinutes = Math.floor(totalSeconds / 60); + return `${String(Math.floor(totalMinutes / 60))}h ${pad(totalMinutes % 60)}m`; +} + +const DIRECTION_GLYPH: Record = { + out: "▶", + in: "◀", +}; + +/** + * Render the drill-down detail for one op. Returns an HTML fragment for a + * mount's detail pane (`.td-detail` in dotli, the detail column in the app). + */ +export function renderTraceDetail( + view: TraceView, + options: RenderTraceDetailOptions = {}, +): string { + const offerDecode = options.offerDecode ?? false; + const decoded = options.decoded; + + const header = renderHeader(view); + const rows = view.frames + .map((frame) => renderFrameRow(frame, offerDecode, decoded?.get(frame.seq))) + .join(""); + + return ( + `
` + + header + + `
${rows}
` + + `
` + ); +} + +function renderHeader(view: TraceView): string { + const badges = view.badges + .map((b) => renderOpBadge(b, view.dropped)) + .join(""); + const frameCount = view.frames.length; + return ( + `
` + + `${esc(view.requestId)}` + + `${String(frameCount)} frame${frameCount === 1 ? "" : "s"} · ${formatMs(view.durationMs)}` + + (badges === "" ? "" : `${badges}`) + + `
` + ); +} + +const OP_BADGE_LABEL: Record = { + orphaned: "orphaned", + malformed: "malformed", + "retry-storm": "retry storm", + truncated: "truncated", +}; + +function renderOpBadge(badge: TraceBadge, dropped?: TraceDropCounts): string { + // `truncated` carries a count when the vantage supplies one, so "1 frame lost" + // and "77 lost" don't render identically. + const label = + badge === "truncated" && dropped !== undefined + ? `truncated ${String(droppedTotal(dropped))}` + : OP_BADGE_LABEL[badge]; + return `${esc(label)}`; +} + +/** Frames missing plus payloads shed: everything the caps took from this op. */ +function droppedTotal(dropped: TraceDropCounts): number { + return dropped.framesByCount + dropped.framesByBytes + dropped.payloadsShed; +} + +/** Spell out which cap took what, so the two axes are distinguishable. */ +function truncationTitle(dropped: TraceDropCounts): string { + const parts: string[] = []; + if (dropped.framesByCount > 0) { + parts.push(`${String(dropped.framesByCount)} frames dropped (frame cap)`); + } + if (dropped.framesByBytes > 0) { + parts.push(`${String(dropped.framesByBytes)} frames dropped (byte cap)`); + } + if (dropped.payloadsShed > 0) { + parts.push( + `${String(dropped.payloadsShed)} payloads shed (single frame over the byte cap; frame kept)`, + ); + } + return parts.length === 0 + ? "Older frames were dropped to stay under the frame/byte cap" + : parts.join(" · "); +} + +function badgeTitle(badge: TraceBadge, dropped?: TraceDropCounts): string { + switch (badge) { + case "orphaned": + return "An opening frame has no matching close, or a close has no opener"; + case "malformed": + return "A frame failed to decode on the wire"; + case "retry-storm": + return "This op is one of a burst of like ops in a short window"; + case "truncated": + return dropped === undefined + ? "Older frames were dropped to stay under the frame/byte cap" + : truncationTitle(dropped); + } +} + +const FRAME_BADGE_LABEL: Record = { + malformed: "malformed", + orphaned: "orphaned", +}; + +function renderFrameRow( + frame: TraceFrameView, + offerDecode: boolean, + detail: FrameValueDetail | undefined, +): string { + const glyph = DIRECTION_GLYPH[frame.direction]; + const method = + frame.method === undefined + ? `id ${String(frame.frameId ?? "?")}` + : `${esc(frame.method)}`; + const role = `${esc(frame.role)}`; + const size = + frame.byteLength === undefined + ? "" + : `${String(frame.byteLength)}B`; + const latency = renderLatency(frame); + const badges = frame.badges + .map( + (b) => + `${esc(FRAME_BADGE_LABEL[b])}`, + ) + .join(""); + + // The frame's meta (direction, role, method, size, latency, badges) is one + // grouped cell so a mount can pin the level-2 payload into a fixed second + // column beside it - every frame's decoded box then opens in the same aligned + // space rather than trailing variable-width meta. + const meta = + `
` + + `${glyph}` + + role + + method + + size + + latency + + (badges === "" ? "" : `${badges}`) + + `
`; + + const payload = + offerDecode && frame.decodable + ? `
${renderDecodeBlock(frame, detail)}
` + : ""; + + return ( + `
` + + meta + + payload + + `
` + ); +} + +function renderLatency(frame: TraceFrameView): string { + // A closing frame that answers an opener shows its round-trip; everything + // else shows its offset from the op's first frame. + if (frame.roundTripMs !== undefined) { + return `⟳ ${formatMs(frame.roundTripMs)}`; + } + if (frame.latencyFromStartMs === 0) { + return `+0`; + } + return `+${formatMs(frame.latencyFromStartMs)}`; +} + +/** + * The level-2 payload slot for one frame. A dev-only tool decodes every frame, + * so this shows the decoded value; a frame whose value could not be resolved + * (bytes not retained, or a decode miss) shows its byte length instead. + */ +function renderDecodeBlock( + frame: TraceFrameView, + detail: FrameValueDetail | undefined, +): string { + if (detail !== undefined) { + return `
${renderFrameValueDetail(detail)}
`; + } + const size = + frame.byteLength === undefined ? "" : `${String(frame.byteLength)}B · `; + return `
${size}payload not shown
`; +} + +/** + * Render a Core-thread {@link FrameValueDetail}. Shared by both mounts so the + * outcome is identical everywhere: a frame shows its decoded value, or its byte + * length when no value is available. + */ +export function renderFrameValueDetail(detail: FrameValueDetail): string { + switch (detail.kind) { + case "bytes": + // Show the raw hex when we have it (dev-only: nothing is hidden); only a + // frame with no retained bytes reads "payload not shown". + return detail.hex !== undefined + ? `
${String(detail.byteLength)}B · ${esc(detail.hex)}
` + : `
${String(detail.byteLength)}B · payload not shown
`; + case "decoded": + return `
${esc(stringifyValue(detail.value))}
`; + } +} + +/** Pretty-print a decoded value for a `
`, tolerating cyclic/bigint inputs. */
+function stringifyValue(value: unknown): string {
+  try {
+    return JSON.stringify(
+      value,
+      (_key, v: unknown) => (typeof v === "bigint" ? `${v.toString()}n` : v),
+      2,
+    );
+  } catch {
+    return String(value);
+  }
+}
+
+/**
+ * Whether the op went out and nothing came back: an *opening* frame carrying the
+ * `orphaned` badge. This is the shape a timed-out or hung call takes on the wire
+ * - there is no "timeout" frame to observe, only a request with no reply - so it
+ * is the signal the op list has to surface as elapsed time.
+ *
+ * The op-level `orphaned` badge is NOT this predicate. It also fires on a closer
+ * with no opener, which is a different and often perfectly live shape: a
+ * `receive` that arrived after the `stop`, a subscription the debugger attached
+ * to mid-session and only ever saw receives of, an opener whose frame id was off
+ * this debugger's table. Reading the op badge as "unanswered" reports a
+ * subscription that is delivering a frame a second as "waiting 300s", and turns
+ * a completed 120ms round trip into "waiting 120s".
+ */
+function isUnanswered(view: TraceView): boolean {
+  return view.frames.some(
+    (f) =>
+      (f.role === "request" || f.role === "start") &&
+      f.badges.includes("orphaned"),
+  );
+}
+
+/**
+ * Render one operation-list row: the primary view's unit, one per op. Shows the
+ * method, a request/subscription glyph, op-level badges, frame count, and
+ * duration. A subscription with no `stop` frame is marked live.
+ *
+ * Pure and stateless: the mount toggles `.selected` and manages the keyed diff.
+ * `data-request-id` (+ `data-channel-id` when known) identify the row for
+ * selection and channel filtering. Payload-blind: only shape and timing here.
+ */
+export function renderOperationRow(
+  view: TraceView,
+  options: { now?: number } = {},
+): string {
+  const method = operationMethod(view);
+  const sub = isSubscription(view);
+  // Liveness comes from the canonical predicate: a subscription the host ended
+  // with an `interrupt` is not live either, and counting it as live inflates the
+  // live-subscription total for the rest of the session.
+  const live = isLiveSubscription(view);
+  const kindGlyph = sub ? "⟳" : "▶";
+  const kindClass = sub ? "td-op-sub" : "td-op-req";
+
+  // `.td-op-method` is truncated on the left (`direction: rtl`), which reorders
+  // any label that is not a pure LTR identifier: `account.getAccount:` renders as
+  // `:account.getAccount` and `22.getAccount` as `getAccount.22`, because `.`,
+  // `:` and digits are direction-neutral. An explicit LTR isolate around the
+  // method keeps it a single left-to-right run while the ellipsis stays on the
+  // left, where the whole point of the rtl trick is to put it.
+  const methodHtml =
+    method === undefined
+      ? `(unknown)`
+      : `${esc(method)}`;
+  const badges = view.badges
+    .map((b) => renderOpBadge(b, view.dropped))
+    .join("");
+  const count = view.frames.length;
+  // An unanswered request has one frame, so `lastAt - startedAt` is 0 and the op
+  // reads "0ms" - the opposite of the truth for the case a developer most needs
+  // to see, a call that went out and is still hanging. Report the age of the
+  // request instead, so a stuck op counts up rather than looking instant.
+  const waiting = isUnanswered(view) && options.now !== undefined;
+  const meta = waiting
+    ? `${String(count)} frame${count === 1 ? "" : "s"} · waiting ${formatMs(
+        Math.max(0, (options.now ?? 0) - view.startedAt),
+      )}`
+    : `${String(count)} frame${count === 1 ? "" : "s"} · ` +
+      (live
+        ? `live · ${formatMs(view.durationMs)}`
+        : formatMs(view.durationMs));
+
+  const channelAttr =
+    view.channelId === undefined
+      ? ""
+      : ` data-channel-id="${esc(view.channelId)}"`;
+  // Generation disambiguates ops that recycle a `(channelId, requestId)`; the
+  // client keys rows and the drill-down on it so reused ids stay distinct.
+  const genAttr = ` data-generation="${String(view.generation ?? 0)}"`;
+
+  return (
+    `
` + + `` + + methodHtml + + (badges === "" ? "" : `${badges}`) + + `${meta}` + + `
` + ); +} diff --git a/js/packages/truapi-debugger/src/trace-styles.ts b/js/packages/truapi-debugger/src/trace-styles.ts new file mode 100644 index 000000000..836d16a83 --- /dev/null +++ b/js/packages/truapi-debugger/src/trace-styles.ts @@ -0,0 +1,181 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: MIT +/** + * Canonical styling for the shared drill-down renderer's `td-*` classes + * ({@link renderTraceDetail} / {@link renderFrameValueDetail}), co-located with + * the class emitter. + * + * These rules are lifted VERBATIM from dotli's debug-panel stylesheet + * (`hosts/dotli/packages/truapi-debug/src/styles.css`, the drill-down section) + * so the standalone app and dotli render the frame sequence identically, with + * zero drift. dotli keeps its own copy for now and converges onto this one once + * the build-graph seam lets it import `@parity/truapi-debugger`. Keep the two in + * sync until then; do not hand-edit these rules here. + * + * Note the vendored `hosts/dotli` submodule is the stale pre-port copy, so most + * of these drill-down classes are NOT yet byte-comparable against it - this file + * is the source of truth for them, and the dotli-community port picks them up at + * convergence. App-level layout (grid, the summary strip, `--payload-w`, etc.) + * deliberately lives OUTSIDE this file, as overrides after `TRACE_DETAIL_CSS` in + * the standalone shell, so it never contaminates the shared rules. + */ + +/** Verbatim `td-*` drill-down rules; inline into a `