diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d583e5b3d..80732cce1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -183,6 +183,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) runs-on: ubuntu-latest @@ -327,7 +363,17 @@ jobs: if: always() runs-on: ubuntu-latest needs: - [rust, licenses, codegen, ts-client, ts-host, playground, explorer, e2e] + [ + rust, + licenses, + codegen, + ts-client, + ts-host, + ts-debugger, + playground, + explorer, + e2e, + ] steps: - name: Check all jobs run: | @@ -337,6 +383,7 @@ jobs: "${{ needs.codegen.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 aef6089f1..6fa54b36d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -19,6 +19,13 @@ 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. + Decodes + groups the wire frames the Rust host tap + (truapi-server's DebugSink) streams out. Holds the + trace + envelope-decode engines + a runnable WS server the host + dials into (`npm run serve`, :9231) with a minimal trace + view. @parity/truapi has no debug seam. Where the app + ultimately lives is still an open decision. playground/ Next.js interactive playground; deploys to truapi-playground.dot hosts/dotli/ dotli submodule docs/ design docs, RFCs, feature proposals diff --git a/Cargo.lock b/Cargo.lock index 6477ad805..bd1b53204 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5054,6 +5054,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..6c1dc0bb7 --- /dev/null +++ b/js/packages/truapi-debugger/README.md @@ -0,0 +1,86 @@ +# @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 decodes the wire *envelope* (the +`requestId` and frame id, via `decodeWireMessage`) and groups frames into +per-operation traces. The trace view stays payload-blind — it never decodes the +frame payload. Envelope decoding lives here, in the debugger, never in the host +core, which treats frames as opaque bytes. + +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 engines + the ingest that turns a wire envelope into a +> decoded frame) and a minimal *runnable app* (`server.ts`: the WS server a host +> dials into, plus a tiny trace view). 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 still an open decision for the host-protocol +> owner; in-repo now is the low-regret default and moving it later is cheap. See +> `docs/design/wire-observability-debug-host.md`. + +## What's here + +- **`createDebugSession()`** — the trace engine wired to the ingest. Feed it + envelopes with `handleEnvelope(...)`; read grouped traces from `traceEngine`. +- **`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 gated, + per-frame decode of a payload to a plain JS value, reusing `@parity/truapi`'s + generated `WIRE_DECODE_TABLE`. A dev-only tool that decodes every frame it can, + with no sensitive special-casing. +- **`startDebugServer(...)`** (`server.ts`) — the runnable app: a Bun WS+HTTP + server. A host dials the WS and sends one text message per frame, + `{ channelId, dir, frame }` with `frame` base64-encoded; `GET /traces` returns + the grouped traces (payload-blind), `GET /frame?id=&i=` is the per-frame + drill-down (see below), `GET /` serves the view. + +## Value decode (level 2 — dev-only, on by default) + +This is a **dev-only tool that decodes everything**. The list views stay +payload-blind — they group frames and show byte lengths, never their contents — +but the **level-2** drill-down decodes a single frame's payload to a plain JS +value, for every frame, with no "sensitive" special-casing. Its contract: + +- **On by default.** The server decodes unless + `TRUAPI_DEBUGGER_DECODE_VALUES` is set to a falsy value (`0`/`false`/`no`/`off`), + or `startDebugServer({ 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 renders either its decoded value + or, when it has no codec / no retained bytes / fails to decode, its byte length. +- **Never over the wire, never in `/traces`.** The host still emits opaque bytes + only; nothing about decode changes what it sends. `/traces` never serializes + raw bytes or decoded values. Decode happens only in the debugger, only in the + `/frame` drill-down. + +## 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 :9231, decodes by default + +# turn value decode off for a demo +TRUAPI_DEBUGGER_DECODE_VALUES=0 npm run serve +``` + +Point a host's debugger URL at `ws://:9231` (the host dials out), +open `http://localhost:9231/` for the trace view; click a frame for its +drill-down detail. 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..3f1a2826b --- /dev/null +++ b/js/packages/truapi-debugger/package.json @@ -0,0 +1,25 @@ +{ + "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", + "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..5a9c2c51b --- /dev/null +++ b/js/packages/truapi-debugger/src/decode.ts @@ -0,0 +1,88 @@ +// 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. + */ +export type FrameValueDetail = + | { kind: "decoded"; value: unknown } + | { kind: "bytes"; byteLength: number }; + +/** 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; + + const detail = (frame: ObservedFrame): FrameValueDetail => { + if (!enabled) return { kind: "bytes", byteLength: frame.byteLength }; + const decode = decodeTable[frame.frameId]; + if (!decode || !frame.bytes) { + return { kind: "bytes", byteLength: frame.byteLength }; + } + 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 byte-length view. + return { kind: "bytes", byteLength: frame.byteLength }; + } + }; + + 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..0e5900083 --- /dev/null +++ b/js/packages/truapi-debugger/src/in-app.test.ts @@ -0,0 +1,112 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { encodeWireMessage, VersionedHostAccountGetRequest } from "@parity/truapi"; +import * as W from "@parity/truapi/wire-table"; + +import { createInAppDebugger } from "./in-app.js"; + +// A minimal element stand-in — the mount only needs createElement, append, +// textContent/className/innerHTML, and remove(). No real DOM needed. +interface FakeEl { + textContent: string; + className: string; + innerHTML: string; + children: FakeEl[]; + append(...nodes: FakeEl[]): void; + remove(): void; +} +function fakeEl(): FakeEl { + return { + textContent: "", + className: "", + innerHTML: "", + children: [], + append(...nodes) { + this.children.push(...nodes); + }, + remove() {}, + }; +} + +function frameBytes(id: number, value: number[] = [0]): Uint8Array { + const r = encodeWireMessage({ + requestId: "p:1", + 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: "Left", 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; +} + +describe("createInAppDebugger", () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- shim a DOM + const g = globalThis as any; + const original = g.document; + beforeAll(() => { + g.document = { createElement: (): FakeEl => fakeEl() }; + }); + afterAll(() => { + g.document = original; + }); + + 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()); + dbg.handleFrame("shop.dot", "in", frameBytes(W.ACCOUNT_GET_ACCOUNT.response)); + + 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 = fakeEl(); + const dispose = dbg.mount(el as unknown as HTMLElement); + const list = el.children[1]; // [style, list] + // Rendered by the shared renderer — the method resolved via the wire table. + expect(list.innerHTML).toContain("account.getAccount"); + dispose(); + expect(list.children).toHaveLength(0); + }); + + test("a formerly-sensitive op is no longer special-cased (never redacted)", () => { + const dbg = createInAppDebugger(); + dbg.handleFrame("shop.dot", "out", frameBytes(W.SIGNING_SIGN_RAW.request, [1, 2])); + dbg.handleFrame("shop.dot", "in", frameBytes(W.SIGNING_SIGN_RAW.response)); + const view = dbg.session.traceEngine.traces()[0]; + expect(view).toBeDefined(); + // No denylist: the drill-down either decodes or falls back to bytes, but + // never returns the old "redacted" state. + const detail = dbg.session.frameDetail("p:1", 0, "shop.dot"); + expect(["decoded", "bytes"]).toContain(detail?.kind); + expect(detail?.kind).not.toBe("redacted"); + }); + + test("decodeValues:false keeps the mount payload-blind (bytes only)", () => { + const dbg = createInAppDebugger({ decodeValues: false }); + dbg.handleFrame("shop.dot", "out", accountGetRequestBytes()); + expect(dbg.session.decodeValues).toBe(false); + expect(dbg.session.frameDetail("p:1", 0, "shop.dot")?.kind).toBe("bytes"); + }); +}); 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..aeadf37c7 --- /dev/null +++ b/js/packages/truapi-debugger/src/in-app.ts @@ -0,0 +1,93 @@ +// 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 and renderer the + * standalone app uses, decoding every frame by default (dev-only tool). + * + * 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`). + * + * @module + */ + +import { createDebugSession, decodeTraceFrames } from "./session.js"; +import type { DebugSession, DebugSessionOptions } from "./session.js"; +import { wireTraceToView } from "./trace-view.js"; +import { renderTraceDetail } from "./trace-render.js"; +import { detectRetryStorms } from "./retry-storm.js"; +import { TRACE_DETAIL_CSS } from "./trace-styles.js"; + +/** 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. + */ + handleFrame(channelId: string, dir: "in" | "out", frame: Uint8Array): void; + /** + * Render a live, self-contained panel into `el` and keep it refreshed; returns + * a disposer that tears the panel down. Decodes every frame unless the session + * was created with `decodeValues: false`. + */ + mount(el: HTMLElement, options?: { refreshMs?: number }): () => void; +} + +/** + * Create an in-app debugger. Decode is ON by default (dev-only tool); pass + * `decodeValues: false` to keep a bundled mount payload-blind. + */ +export function createInAppDebugger( + options: DebugSessionOptions = {}, +): InAppDebugger { + const session = createDebugSession(options); + return { + session, + handleFrame(channelId, dir, frame) { + session.handleEnvelope({ channelId, dir, frame }); + }, + mount(el, mountOptions = {}) { + const style = document.createElement("style"); + style.textContent = TRACE_DETAIL_CSS; + const list = document.createElement("div"); + list.className = "td-inapp"; + el.append(style, list); + + let disposed = false; + const render = (): void => { + if (disposed) return; + const traces = session.traceEngine.traces(); + const storms = detectRetryStorms(traces); + list.innerHTML = + traces.length === 0 + ? `
no frames yet
` + : traces + .map((trace) => { + const view = wireTraceToView( + trace, + session.methodNames, + storms.get(trace) ?? [], + ); + return `
${renderTraceDetail(view, { + offerDecode: session.decodeValues, + decoded: decodeTraceFrames(session, view), + })}
`; + }) + .join(""); + }; + render(); + const timer = setInterval(render, mountOptions.refreshMs ?? 1000); + return () => { + disposed = true; + clearInterval(timer); + style.remove(); + list.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..1ac12ae01 --- /dev/null +++ b/js/packages/truapi-debugger/src/index.ts @@ -0,0 +1,45 @@ +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 { createInAppDebugger } from "./in-app.js"; +export type { InAppDebugger } from "./in-app.js"; diff --git a/js/packages/truapi-debugger/src/ingest.ts b/js/packages/truapi-debugger/src/ingest.ts new file mode 100644 index 000000000..ce9f64a85 --- /dev/null +++ b/js/packages/truapi-debugger/src/ingest.ts @@ -0,0 +1,135 @@ +/** + * 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 retained `channelId` / `requestId` length. Shared so the + * debugger server's channel registry clamps to the same bound as ingest and the + * two keys stay equal (the UI filters by the clamped key). + */ +export const DEFAULT_MAX_ID_CHARS = 256; + +/** + * 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; +} + +/** 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; + /** + * Cap on retained `channelId` / `requestId` length. 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 left `"unknown"`: lifecycle roles (request/response/receive/…) are + * derived from request/subscription correlation state, which lived in the client + * transport and is not carried on the wire. Reconstructing it from the observed + * request/response ordering is a follow-up; grouping by `requestId` does not need + * it. 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. + */ +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; + const clampId = (id: string): string => + id.length > maxIdChars ? id.slice(0, maxIdChars) : id; + return (envelope) => { + const channelId = clampId(envelope.channelId); + const decoded = decodeWireMessage(envelope.frame); + if (decoded.isErr()) { + sink({ + channelId, + direction: envelope.dir, + requestId: "malformed", + frameId: -1, + role: "malformed", + byteLength: envelope.frame.length, + timestamp: Date.now(), + }); + return; + } + const { requestId, payload } = decoded.value; + const frame: ObservedFrame = { + channelId, + direction: envelope.dir, + requestId: clampId(requestId), + 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: Date.now(), + ...(retainBytes ? { bytes: payload.value } : {}), + }; + sink(frame); + }; +} 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..ca7e64893 --- /dev/null +++ b/js/packages/truapi-debugger/src/retry-storm.ts @@ -0,0 +1,94 @@ +// 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) { + 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..c55934d0c --- /dev/null +++ b/js/packages/truapi-debugger/src/server.test.ts @@ -0,0 +1,682 @@ +import { expect, test } from "bun:test"; + +import { + encodeWireMessage, + TRUAPI_WIRE_SCHEMA_HASH, + VersionedHostSignRawRequest, +} from "@parity/truapi"; +import * as W from "@parity/truapi/wire-table"; + +import { isLoopbackDebugHost, 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: "Left", 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 is an exact allowlist (drives the Host-header guard)", () => { + expect(isLoopbackDebugHost("127.0.0.1")).toBe(true); + expect(isLoopbackDebugHost("localhost")).toBe(true); + expect(isLoopbackDebugHost("::1")).toBe(true); + // Everything else is non-loopback. A fuzzy match that read any of these as + // loopback would let a rebound page past the DNS-rebinding Host guard. + for (const host of [ + "0.0.0.0", + "127.0.0.1.evil.com", + "127.0.0.2", + "[::1]", + "LOCALHOST", + "example.com", + ]) { + expect(isLoopbackDebugHost(host)).toBe(false); + } +}); + +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(); + } +}); diff --git a/js/packages/truapi-debugger/src/server.ts b/js/packages/truapi-debugger/src/server.ts new file mode 100644 index 000000000..190f6577f --- /dev/null +++ b/js/packages/truapi-debugger/src/server.ts @@ -0,0 +1,1283 @@ +/** + * 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 { createDebugSession, decodeTraceFrames } from "./session.js"; +import { + DEFAULT_MAX_ID_CHARS, + 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 { TRACE_DETAIL_CSS } from "./trace-styles.js"; + +/** Default port the debugger listens on; a host points its debug URL here. */ +const DEFAULT_PORT = 9231; + +/** Frame roles that make an op a subscription rather than a request/response. */ +const SUBSCRIPTION_ROLES = new Set([ + "start", + "receive", + "stop", + "interrupt", +]); + +/** + * 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; + /** 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; +} + +/** + * 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 match + // that form (a bare "::1" never occurs, but accept it defensively). + return ( + host === "127.0.0.1" || + host === "localhost" || + host === "[::1]" || + host === "::1" + ); + } catch { + return false; + } +} + +/** + * Parse an optional integer query param: `undefined` if absent, `null` if + * malformed. Requires a canonical integer so `""`, `" "`, `"1e3"`, `"0x10"`, + * `"1.5"`, and `"+1"` all reject rather than silently coercing (`Number("")===0`). + */ +function optionalInt(raw: string | null): number | null | undefined { + if (raw === null) return undefined; + const t = raw.trim(); + if (!/^-?\d+$/.test(t)) return null; + const n = Number(t); + return Number.isInteger(n) ? n : null; +} + +/** + * Whether `host` is a loopback name. The `Host`-header DNS-rebinding guard keys + * on this, so an exact allowlist - never a fuzzy match that could read + * `127.0.0.1.evil.com` as loopback - is the security-relevant classification, + * unit-tested separately. + */ +export function isLoopbackDebugHost(host: string): boolean { + return host === "127.0.0.1" || host === "localhost" || host === "::1"; +} + +/** + * Whether a request's `Host` header targets an address this server is willing to + * answer for: a loopback name. + * + * 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. + */ +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 isLoopbackDebugHost(normalized); +} + +/** Parse and validate one inbound WS text message, or `null`. */ +function parseWireMessage(raw: string): ParsedWireMessage | null { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + const m = parsed as Partial; + if (typeof m.channelId !== "string") return null; + if (m.dir !== "in" && m.dir !== "out") return null; + if (typeof m.frame !== "string") return null; + 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); + return { + envelope: { + channelId: m.channelId, + dir: m.dir, + frame: new Uint8Array(Buffer.from(m.frame, "base64")), + }, + identityMismatch, + identityConfirmed: schema === TRUAPI_WIRE_SCHEMA_HASH, + dropped: typeof m.dropped === "number" && m.dropped > 0 ? m.dropped : 0, + }; +} + +/** 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 off unless `decodeValues` is set (the CLI entry point + * derives it from `TRUAPI_DEBUGGER_DECODE_VALUES`). It only ever affects the + * `/frame` drill-down; `/traces` is 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 rawIndex = url.searchParams.get("i"); + const channel = url.searchParams.get("channel") ?? undefined; + // `Number("")`/`Number(" ")` are both 0 and pass Number.isInteger, so an + // empty or whitespace `?i=` or `?gen=` would otherwise resolve frame 0 / + // generation 0 (the oldest recycled op) with a 200; optionalInt rejects them. + const generation = optionalInt(url.searchParams.get("gen")); + const index = Number(rawIndex); + if ( + id === null || + rawIndex === null || + rawIndex.trim() === "" || + !Number.isInteger(index) || + 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` fragment: every trace 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. + */ + function viewHtml(): string { + const entries = viewsFor(session.traceEngine.traces()); + if (entries.length === 0) { + return `
no frames yet
`; + } + // 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(""); + } + + // 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; + // Clamp channelId to the same bound ingest uses so this registry's key matches + // the trace-engine key the UI filters by, and an over-long attacker-chosen id + // can't bloat the map (256 entries * an unbounded key would otherwise grow it). + const clampChannelId = (id: string): string => + id.length > DEFAULT_MAX_ID_CHARS ? id.slice(0, DEFAULT_MAX_ID_CHARS) : id; + 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; + + function recordChannel(channelId: string, parsed: ParsedWireMessage): void { + if (!parsed.identityConfirmed) sawUntrusted = true; + const now = Date.now(); + const key = clampChannelId(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(clampChannelId(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 }[]; + } + const traces = + channel === null + ? session.traceEngine.traces() + : session.traceEngine.tracesForChannel(clampChannelId(channel)); + 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 viewsFor(traces)) { + 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; + if (view.frames.some((f) => SUBSCRIPTION_ROLES.has(f.role))) { + subscriptions += 1; + if (!view.frames.some((f) => f.role === "stop")) { + liveSubscriptions += 1; + } + } + for (const f of view.frames) { + bytes += f.byteLength ?? 0; + if (f.direction === "out") out += 1; + else inbound += 1; + } + const opener = + view.frames.find((f) => f.role === "request" || f.role === "start") ?? + view.frames.find((f) => f.method !== undefined); + const method = opener?.method ?? "(unknown)"; + methodCounts.set(method, (methodCounts.get(method) ?? 0) + 1); + } + const ops = traces.length; + const topMethods = [...methodCounts.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 5) + .map(([method, count]) => ({ method, count })); + // Whole-op eviction (session-wide) and host-reported drops are loss the ops + // list can't show: `ops` counts only the survivors, so without these a + // 10k-op session that kept 256 reads as "256 ops" with no sign the rest were + // dropped. `codecMismatch` flags a host whose wire contract differs. + const evictedTraces = session.traceEngine.evictedTraces(); + const chanList = + channel === null + ? [...channels.values()] + : [...channels.values()].filter( + (c) => c.channelId === clampChannelId(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 = { + ops, + frames, + bytes, + subscriptions, + liveSubscriptions, + malformed, + orphaned, + retryStorms, + truncated, + evictedTraces, + droppedByHost, + codecMismatch, + out, + in: inbound, + avgDurationMs: ops === 0 ? 0 : Math.round(durationTotal / ops), + maxDurationMs: Math.round(durationMax), + topMethods, + }; + 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(clampChannelId(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(clampChannelId(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))).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 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) { + const url = new URL(req.url); + const htmlHeaders = { "content-type": "text/html; charset=utf-8" }; + // DNS-rebinding guard: the request's Host must be loopback. 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. + // Applies before any route dispatch. + if (!hostHeaderAllowed(req.headers.get("host"))) { + return new Response("forbidden host", { status: 403 }); + } + // 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(url.searchParams.get("channel")), { + headers: { "content-type": "application/json" }, + }); + } + if (url.pathname === "/op-list") { + return new Response( + opListHtml( + 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, url.searchParams.get("channel"), generation), + { headers: htmlHeaders }, + ); + } + if (url.pathname === "/view") { + return new Response(viewHtml(), { headers: htmlHeaders }); + } + if (url.pathname === "/frame") { + return frameResponse(url); + } + return new Response(VIEW_HTML, { headers: htmlHeaders }); + }, + websocket: { + // Cap one inbound frame at 1 MiB rather than Bun's 16 MiB default: a host + // dial is one small SCALE frame per message, so a larger payload is either + // a bug or an attempt to exhaust memory. Bun drops an over-cap message. + maxPayloadLength: 1024 * 1024, + open() { + openSockets += 1; + }, + close() { + openSockets = Math.max(0, openSockets - 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) { + recordChannel(parsed.envelope.channelId, parsed); + // Still grouped (payload-blind is safe and useful); a mismatch only + // blocks the value-decode path, via decodeTrusted. + session.handleEnvelope(parsed.envelope); + } + } catch { + // Drop the frame; the observed session is worth more than one trace. + } + }, + }, + }); + + 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…
+ +`; + +// 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 envPort = Number(Bun.env.TRUAPI_DEBUGGER_PORT); + // Dev-only tool: value decode is ON by default. Set TRUAPI_DEBUGGER_DECODE_VALUES + // to a falsy value (0/false/no/off) to turn decode off for a demo. + const decodeValues = !/^(0|false|no|off)$/i.test( + Bun.env.TRUAPI_DEBUGGER_DECODE_VALUES ?? "", + ); + const server = startDebugServer({ + port: Number.isFinite(envPort) && envPort > 0 ? envPort : DEFAULT_PORT, + decodeValues, + }); + 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..c61b5c13f --- /dev/null +++ b/js/packages/truapi-debugger/src/session.ts @@ -0,0 +1,169 @@ +/** + * 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 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; +} + +/** 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. + const wireDebugger = createWireDebugger({ methodNames, sink: () => {} }); + // 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..c643aba10 --- /dev/null +++ b/js/packages/truapi-debugger/src/trace-render.test.ts @@ -0,0 +1,108 @@ +// 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 { TraceView } from "./trace-view.js"; +import { renderFrameValueDetail, renderTraceDetail } from "./trace-render.js"; + +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 never shows a payload", () => { + const html = renderFrameValueDetail({ kind: "bytes", byteLength: 12 }); + expect(html).toContain("12B"); + expect(html).toContain("payload not shown"); + }); +}); 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..61dd0e5d5 --- /dev/null +++ b/js/packages/truapi-debugger/src/trace-render.ts @@ -0,0 +1,319 @@ +// 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 type { + TraceBadge, + TraceFrameBadge, + TraceFrameView, + TraceView, +} from "./trace-view.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 "'"; + } + }); +} + +/** `1234` → `1.23s`, `42` → `42ms`, for compact latency display. */ +function formatMs(ms: number): string { + if (ms < 1000) return `${String(Math.round(ms))}ms`; + return `${(ms / 1000).toFixed(2)}s`; +} + +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(renderOpBadge).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): string { + return `${esc(OP_BADGE_LABEL[badge])}`; +} + +function badgeTitle(badge: TraceBadge): 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 "Older frames were dropped to stay under the frame/byte cap"; + } +} + +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": + return `
${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);
+  }
+}
+
+/** Roles that mark an op as a subscription rather than a request/response. */
+const SUBSCRIPTION_ROLES: ReadonlySet = new Set([
+  "start",
+  "receive",
+  "stop",
+  "interrupt",
+]);
+
+/** The op's method: the first opening frame's method, else the first known one. */
+function operationMethod(view: TraceView): string | undefined {
+  const opener = view.frames.find(
+    (f) => f.role === "request" || f.role === "start",
+  );
+  if (opener?.method !== undefined) {
+    return opener.method;
+  }
+  return view.frames.find((f) => f.method !== undefined)?.method;
+}
+
+/** Whether the op is a subscription (has a start/receive/stop/interrupt frame). */
+function isSubscription(view: TraceView): boolean {
+  return view.frames.some((f) => SUBSCRIPTION_ROLES.has(f.role));
+}
+
+/**
+ * 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): string {
+  const method = operationMethod(view);
+  const sub = isSubscription(view);
+  const live = sub && !view.frames.some((f) => f.role === "stop");
+  const kindGlyph = sub ? "⟳" : "▶";
+  const kindClass = sub ? "td-op-sub" : "td-op-req";
+
+  const methodHtml =
+    method === undefined
+      ? `(unknown)`
+      : `${esc(method)}`;
+  const badges = view.badges.map(renderOpBadge).join("");
+  const count = view.frames.length;
+  const meta =
+    `${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 `