diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d583e5b3..80732cce 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 b18cab4f..001dd1b1 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/js/packages/truapi-debugger/.gitignore b/js/packages/truapi-debugger/.gitignore new file mode 100644 index 00000000..f4e2c6d6 --- /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 00000000..d9acea87 --- /dev/null +++ b/js/packages/truapi-debugger/README.md @@ -0,0 +1,53 @@ +# @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). +- **`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 /` serves the view. + +## 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 +``` + +Point a host's debugger URL at `ws://:9231` (the host dials out), +open `http://localhost:9231/` for the trace view. 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 00000000..3f1a2826 --- /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/index.ts b/js/packages/truapi-debugger/src/index.ts new file mode 100644 index 00000000..b01d1bce --- /dev/null +++ b/js/packages/truapi-debugger/src/index.ts @@ -0,0 +1,19 @@ +export type { + FrameDirection, + FrameRole, + ObservedFrame, + TransportObserver, +} from "./observed-frame.js"; +export { createDebugIngest } from "./ingest.js"; +export type { DebugFrameEnvelope } from "./ingest.js"; +export { createDebugSession } from "./session.js"; +export type { DebugSession } from "./session.js"; +export { createWireDebugger, createMethodNameMap } from "./wire-debugger.js"; +export type { + WireDebugger, + WireDebuggerOptions, + WireDebugSink, + WireFrameKind, + WireMethodInfo, + WireTrace, +} from "./wire-debugger.js"; diff --git a/js/packages/truapi-debugger/src/ingest.ts b/js/packages/truapi-debugger/src/ingest.ts new file mode 100644 index 00000000..6d8c702c --- /dev/null +++ b/js/packages/truapi-debugger/src/ingest.ts @@ -0,0 +1,74 @@ +/** + * 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"; + +/** + * 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; +} + +/** + * 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. + */ +export function createDebugIngest( + sink: TransportObserver, +): (envelope: DebugFrameEnvelope) => void { + return (envelope) => { + const decoded = decodeWireMessage(envelope.frame); + if (decoded.isErr()) { + sink({ + 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 = { + direction: envelope.dir, + requestId, + frameId: payload.id, + role: "unknown", + byteLength: payload.value.length, + timestamp: Date.now(), + 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 00000000..c701683e --- /dev/null +++ b/js/packages/truapi-debugger/src/observed-frame.ts @@ -0,0 +1,61 @@ +/** + * 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 { + /** 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. */ + 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/server.test.ts b/js/packages/truapi-debugger/src/server.test.ts new file mode 100644 index 00000000..92232b7c --- /dev/null +++ b/js/packages/truapi-debugger/src/server.test.ts @@ -0,0 +1,65 @@ +import { expect, test } from "bun:test"; + +import { encodeWireMessage } from "@parity/truapi"; +import * as W from "@parity/truapi/wire-table"; + +import { startDebugServer } from "./server.js"; + +interface TraceFrameView { + direction: string; + frameId: number; + method?: string; + byteLength: number; +} +interface TraceView { + requestId: string; + frames: TraceFrameView[]; +} + +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 })); + + 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 view 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"); + expect(await res.text()).toContain("TrUAPI wire traces"); + } 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 00000000..aacebac9 --- /dev/null +++ b/js/packages/truapi-debugger/src/server.ts @@ -0,0 +1,171 @@ +/** + * 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 are never + * serialized); `GET /` serves a page that polls it. + * + * 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 { createDebugSession } from "./session.js"; +import type { DebugFrameEnvelope } from "./ingest.js"; + +/** Default port the debugger listens on; a host points its debug URL here. */ +const DEFAULT_PORT = 9231; + +/** The text message a host sends per frame: the envelope with a base64 frame. */ +interface WireMessage { + channelId: string; + dir: "in" | "out"; + frame: string; +} + +/** Parse and validate one inbound WS text message into an envelope, or `null`. */ +function parseWireMessage(raw: string): DebugFrameEnvelope | 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; + return { + channelId: m.channelId, + dir: m.dir, + frame: new Uint8Array(Buffer.from(m.frame, "base64")), + }; +} + +/** A running debugger server. */ +export interface DebugServer { + /** The port the WS/HTTP server is listening on. */ + readonly port: number; + /** Stop listening and drop active connections. */ + stop(): void; +} + +/** + * 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}. + */ +export function startDebugServer(options: { port?: number } = {}): DebugServer { + const session = createDebugSession(); + + function tracesJson(): string { + // Payload-blind view: the raw `bytes` are deliberately never serialized. + const traces = session.traceEngine.traces().map((t) => ({ + requestId: t.requestId, + startedAt: t.startedAt, + lastAt: t.lastAt, + frames: t.frames.map((f) => ({ + direction: f.direction, + frameId: f.frameId, + method: session.methodNames.get(f.frameId)?.method, + role: f.role, + byteLength: f.byteLength, + timestamp: f.timestamp, + })), + })); + return JSON.stringify(traces); + } + + const server = Bun.serve({ + port: options.port ?? DEFAULT_PORT, + fetch(req, srv) { + if (srv.upgrade(req)) return undefined; + const url = new URL(req.url); + if (url.pathname === "/traces") { + return new Response(tracesJson(), { + headers: { "content-type": "application/json" }, + }); + } + return new Response(VIEW_HTML, { + headers: { "content-type": "text/html; charset=utf-8" }, + }); + }, + websocket: { + message(_ws, message) { + const raw = typeof message === "string" ? message : message.toString(); + const envelope = parseWireMessage(raw); + if (envelope) session.handleEnvelope(envelope); + }, + }, + }); + + return { + // Always a TCP port here; the `?? 0` only satisfies Bun's unix-socket union. + port: server.port ?? 0, + stop: () => server.stop(true), + }; +} + +/** Minimal self-contained trace view: polls `/traces` and renders a table. */ +const VIEW_HTML = ` + +TrUAPI debugger + +

TrUAPI wire traces

+
waiting for frames…
+ +`; + +// Entry point: `bun run src/server.ts` (or `npm run serve`) starts the server. +// Port comes from TRUAPI_DEBUGGER_PORT, else the default. +if (import.meta.main) { + const envPort = Number(Bun.env.TRUAPI_DEBUGGER_PORT); + const server = startDebugServer({ + port: Number.isFinite(envPort) && envPort > 0 ? envPort : DEFAULT_PORT, + }); + console.log(`[truapi-debugger] listening on http://localhost:${server.port}`); +} diff --git a/js/packages/truapi-debugger/src/session.ts b/js/packages/truapi-debugger/src/session.ts new file mode 100644 index 00000000..6168cada --- /dev/null +++ b/js/packages/truapi-debugger/src/session.ts @@ -0,0 +1,63 @@ +/** + * 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 * 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() {}, +}; + +/** 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; +} + +/** + * 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(): DebugSession { + 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: () => {} }); + const handleEnvelope = createDebugIngest(wireDebugger.observe); + return { handleEnvelope, traceEngine: wireDebugger, methodNames }; +} diff --git a/js/packages/truapi-debugger/src/wire-debugger.ts b/js/packages/truapi-debugger/src/wire-debugger.ts new file mode 100644 index 00000000..4faeebc0 --- /dev/null +++ b/js/packages/truapi-debugger/src/wire-debugger.ts @@ -0,0 +1,227 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: MIT +/** + * Trace engine: group a stream of observed frames into per-op traces. + * + * The host tap streams every product↔host frame to the debugger, where + * {@link createDebugIngest} decodes each into an {@link ObservedFrame} keyed on + * the wire `requestId`. This module turns that stream into a usable surface: + * + * - {@link createWireDebugger} accumulates frames into per-`requestId` traces + * so a single op can be reconstructed across product → wire → host; + * - the same `requestId` is the value product-sdk telemetry spans correlate on + * (`HostOpEvent.correlationId`), so a frame trace and a product span line up + * under one id with no extra plumbing; + * - it logs/relays each frame and never touches decoded payloads, so it works + * against any host without knowing the application protocol. + * + * @module + */ + +import type { ObservedFrame, TransportObserver } from "./observed-frame.js"; + +/** A single op's frames, in arrival order, grouped by their shared `requestId`. */ +export interface WireTrace { + /** Correlation id shared by every frame in this trace. */ + requestId: string; + /** Frames observed for this id, in the order they crossed the transport. */ + frames: ObservedFrame[]; + /** Epoch ms of the first frame. */ + startedAt: number; + /** Epoch ms of the most recent frame. */ + lastAt: number; +} + +/** Sink for fully-formatted debug lines (defaults to `console.debug`). */ +export type WireDebugSink = (line: string, frame: ObservedFrame) => void; + +/** Which of a method's wire ids a given `frameId` is. */ +export type WireFrameKind = + | "request" + | "response" + | "start" + | "stop" + | "receive" + | "interrupt"; + +/** Resolution of a bare wire `frameId` to its human-readable method. */ +export interface WireMethodInfo { + /** Dotted method path as it appears on the client, e.g. `"account.getAccount"`. */ + method: string; + /** Which of the method's wire ids this `frameId` is. */ + kind: WireFrameKind; +} + +/** `camelCase` → `CONST_CASE`, matching the wire-table's constant naming. */ +function constCase(name: string): string { + return name.replace(/([a-z0-9])([A-Z])/g, "$1_$2").toUpperCase(); +} + +/** `GET_ACCOUNT` → `getAccount`. */ +function camelCase(constName: string): string { + const [head, ...rest] = constName.toLowerCase().split("_"); + return ( + (head ?? "") + + rest.map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join("") + ); +} + +/** + * Build a reverse map from wire `frameId` to `"service.method"` name out of the + * generated wire-table module and the client's service names. + * + * The wire-table exports one `CONST_CASE` group per method (e.g. + * `ACCOUNT_GET_ACCOUNT = { request: 22, response: 23 }`); the service list - + * typically `Object.keys(createClient(transport))` - disambiguates where the + * service prefix ends (`LOCAL_STORAGE_READ` → `localStorage.read`, not + * `local.storageRead`). Non-group exports in `table` are ignored, so the whole + * `import * as W from "./generated/wire-table.js"` namespace can be passed + * directly. + */ +export function createMethodNameMap( + table: Record, + services: readonly string[], +): ReadonlyMap { + // Longest prefix first, so RESOURCE_ALLOCATION_ wins over a hypothetical RESOURCE_. + const prefixes = services + .map((service) => ({ service, prefix: `${constCase(service)}_` })) + .sort((a, b) => b.prefix.length - a.prefix.length); + + const map = new Map(); + for (const [constName, group] of Object.entries(table)) { + if (group === null || typeof group !== "object") continue; + const match = prefixes.find(({ prefix }) => constName.startsWith(prefix)); + const method = match + ? `${match.service}.${camelCase(constName.slice(match.prefix.length))}` + : camelCase(constName); + for (const [kind, id] of Object.entries(group)) { + if (typeof id !== "number") continue; + map.set(id, { method, kind: kind as WireFrameKind }); + } + } + return map; +} + +/** Options for {@link createWireDebugger}. */ +export interface WireDebuggerOptions { + /** + * Where formatted frame lines go. Defaults to `console.debug`. A host-side + * panel (e.g. dotli's wire-debug view) passes its own sink here to render the + * stream live. + */ + sink?: WireDebugSink; + /** + * Optional forward target: a second observer to receive every frame after it + * is recorded. Lets a host relay frames onward (to a panel, a socket, an OTel + * exporter) while the debugger keeps its own per-id traces. + */ + forward?: TransportObserver; + /** Cap on retained traces (LRU-evicted). Default 256. */ + maxTraces?: number; + /** + * Cap on retained frames within a single trace (oldest ring-buffered out). + * Default 1024. Without this a long-lived subscription - e.g. + * `account.connectionStatus`, which shares one `requestId` for the whole + * session - accumulates a frame per `receive` forever, since all its frames + * share a `requestId` that never LRU-evicts from {@link maxTraces}. A panel + * showing the last N frames of a subscription is no worse than one showing + * all of them. + */ + maxFramesPerTrace?: number; + /** + * Reverse map from wire `frameId` to method name (build one with + * {@link createMethodNameMap}). When set, formatted lines carry + * `account.getAccount` instead of a bare `id=22`. + */ + methodNames?: ReadonlyMap; +} + +/** A live wire debugger: an `observe` hook plus per-`requestId` trace lookup. */ +export interface WireDebugger { + /** The callback that records a frame; drive it from {@link createDebugIngest}. */ + readonly observe: TransportObserver; + /** All retained traces, most-recently-active last. */ + traces(): WireTrace[]; + /** The trace for a specific `requestId` (e.g. a product span's correlationId). */ + trace(requestId: string): WireTrace | undefined; + /** Drop all retained traces. */ + clear(): void; +} + +function formatFrame( + frame: ObservedFrame, + methodNames?: ReadonlyMap, +): string { + const arrow = frame.direction === "out" ? "→" : "←"; + const method = methodNames?.get(frame.frameId)?.method; + const label = method ? `${frame.role} ${method}` : frame.role; + return `[wire ${frame.requestId}] ${arrow} ${label} (id=${frame.frameId}, ${frame.byteLength}B)`; +} + +/** + * Build a {@link WireDebugger}. Feed its {@link WireDebugger.observe} from + * {@link createDebugIngest} to start recording. Frames are logged through + * `sink`, forwarded through `forward` (if set), and grouped into + * per-`requestId` {@link WireTrace}s for correlation with product-sdk spans. + */ +export function createWireDebugger(options: WireDebuggerOptions = {}): WireDebugger { + const sink: WireDebugSink = + options.sink ?? ((line) => console.debug(line)); + const forward = options.forward; + const maxTraces = options.maxTraces ?? 256; + const maxFramesPerTrace = options.maxFramesPerTrace ?? 1024; + const methodNames = options.methodNames; + // Insertion-ordered; re-inserting on activity keeps the map LRU-ordered. + const traces = new Map(); + + const observe: TransportObserver = (frame) => { + let trace = traces.get(frame.requestId); + if (trace) { + traces.delete(frame.requestId); + } else { + trace = { + requestId: frame.requestId, + frames: [], + startedAt: frame.timestamp, + lastAt: frame.timestamp, + }; + } + trace.frames.push(frame); + if (trace.frames.length > maxFramesPerTrace) { + // Evict oldest to keep an exact hard cap. This is O(cap) per frame once + // the cap is reached; kept deliberately simple over an O(1) ring buffer + // because `frames` is a plain in-order array read directly by consumers, + // and this runs only on the dev-only observe path where the cost (a bounded + // memmove of <=maxFramesPerTrace references) is immaterial. + trace.frames.splice(0, trace.frames.length - maxFramesPerTrace); + } + trace.lastAt = frame.timestamp; + traces.set(frame.requestId, trace); + + while (traces.size > maxTraces) { + const oldest = traces.keys().next().value; + if (oldest === undefined) break; + traces.delete(oldest); + } + + try { + sink(formatFrame(frame, methodNames), frame); + } catch { + // A debug sink must never break the observed transport. + } + if (forward) { + try { + forward(frame); + } catch { + // A forward target must never break the observed transport. + } + } + }; + + return { + observe, + traces: () => [...traces.values()], + trace: (requestId) => traces.get(requestId), + clear: () => traces.clear(), + }; +} diff --git a/js/packages/truapi-debugger/tsconfig.json b/js/packages/truapi-debugger/tsconfig.json new file mode 100644 index 00000000..d9330dd3 --- /dev/null +++ b/js/packages/truapi-debugger/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "composite": true, + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "esModuleInterop": true, + "skipLibCheck": true, + "types": ["bun"] + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"], + "references": [{ "path": "../truapi" }] +} diff --git a/js/packages/truapi-host/README.md b/js/packages/truapi-host/README.md index 44e0cd0f..f9d316bf 100644 --- a/js/packages/truapi-host/README.md +++ b/js/packages/truapi-host/README.md @@ -55,6 +55,22 @@ const secondProvider = await runtime.createProvider({ protocol-iframe MessageChannel handshake. Host code creates one worker runtime and then opens one provider per product id. +## Debugging (dev-only) + +The worker can stream every product↔core wire frame to the wire debugger. It is +off by default and enabled purely from the host page — the product needs no +changes. Set a debugger URL in the host origin's `localStorage`, then run the +debugger (`@parity/truapi-debugger`, `npm run serve`, `:9231`): + +```js +localStorage.setItem("truapi:debugger", "ws://localhost:9231"); +``` + +On the next runtime boot the worker reads that URL, dials the debugger, and (via +the Rust core's `DebugSink` tap) sends each frame as `{ channelId, dir, frame }`. +Unset in production, so nothing dials and the core installs no tap. Design: +`docs/design/wire-observability-debug-host.md`. + ## Publishing This package is published by the root `Release` workflow through diff --git a/js/packages/truapi-host/src/web/create-worker-host-runtime.ts b/js/packages/truapi-host/src/web/create-worker-host-runtime.ts index 8ea3eb86..b2951f98 100644 --- a/js/packages/truapi-host/src/web/create-worker-host-runtime.ts +++ b/js/packages/truapi-host/src/web/create-worker-host-runtime.ts @@ -116,6 +116,17 @@ function readPersistedLogLevel(): LogLevel | null { return globalThis.localStorage?.getItem(DEV_LOG_LEVEL_KEY) ?? null; } +// Dev-only, host-agnostic enablement for the wire debugger: set +// `localStorage["truapi:debugger"] = "ws://:9231"` in the browser and the +// host worker dials that debugger and streams frames to it. Unset in production, +// so nothing dials and the Rust host tap stays inert. Read here (host page) and +// forwarded to the worker in `init`; no cooperation from the embedding shell. +const DEV_DEBUGGER_URL_KEY = "truapi:debugger"; + +function readPersistedDebuggerUrl(): string | null { + return globalThis.localStorage?.getItem(DEV_DEBUGGER_URL_KEY) ?? null; +} + function persistLogLevel(level: LogLevel): void { globalThis.localStorage?.setItem(DEV_LOG_LEVEL_KEY, level); } @@ -603,6 +614,7 @@ export function createWebWorkerPairingHostRuntime( kind: "init", logLevel: devLogLevelOverride ?? options.logLevel ?? "off", hostConfig: options.hostConfig, + debuggerUrl: readPersistedDebuggerUrl(), } satisfies MainToWorker); } else if (msg.kind === "ready") { cleanupInit(); diff --git a/js/packages/truapi-host/src/web/worker-provider.test.ts b/js/packages/truapi-host/src/web/worker-provider.test.ts index 3c75785d..d7bd5da6 100644 --- a/js/packages/truapi-host/src/web/worker-provider.test.ts +++ b/js/packages/truapi-host/src/web/worker-provider.test.ts @@ -195,6 +195,7 @@ describe("createWebWorkerPairingHostRuntime", () => { kind: "init", logLevel: "debug", hostConfig: hostConfigFromRuntimeConfig(config), + debuggerUrl: null, }); worker.emit({ kind: "ready" }); diff --git a/js/packages/truapi-host/src/worker-protocol.ts b/js/packages/truapi-host/src/worker-protocol.ts index cdb3ea58..dde2f54e 100644 --- a/js/packages/truapi-host/src/worker-protocol.ts +++ b/js/packages/truapi-host/src/worker-protocol.ts @@ -55,7 +55,14 @@ export type CallbackArgs = readonly unknown[]; * host callback/subscription/chain responses requested by the worker. */ export type MainToWorker = - | { kind: "init"; logLevel: LogLevel; hostConfig: unknown } + | { + kind: "init"; + logLevel: LogLevel; + hostConfig: unknown; + // Dev-only: when set, the worker dials this debugger and streams tapped + // frames to it. Null in production, so the host tap stays inert. + debuggerUrl: string | null; + } | { kind: "createCore"; coreId: number; product: unknown } | { kind: "disposeCore"; coreId: number } | { kind: "setLogLevel"; level: LogLevel } diff --git a/js/packages/truapi-host/src/worker-runtime.ts b/js/packages/truapi-host/src/worker-runtime.ts index c986b9a5..8edd69cf 100644 --- a/js/packages/truapi-host/src/worker-runtime.ts +++ b/js/packages/truapi-host/src/worker-runtime.ts @@ -187,8 +187,85 @@ function buildRawCallbacks() { }); } -function buildCoreCallbacks(coreId: number) { +/** Encode raw frame bytes as base64 (JSON can't carry binary over the WS). */ +function toBase64(bytes: Uint8Array): string { + let binary = ""; + for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]); + return btoa(binary); +} + +/** + * Dev-only link to the debugger the host dials. Fire-and-forget by construction: + * it opens lazily, buffers a bounded backlog until the socket is up, retries a + * dropped connection, and swallows every error - a slow, absent, or crashed + * debugger only loses the trace, it can never throw into the frame path. + */ +function createDebuggerLink(url: string): { + emit(channelId: string, dir: string, frame: Uint8Array): void; +} { + let socket: WebSocket | null = null; + let open = false; + const queue: string[] = []; + const MAX_QUEUE = 1000; + + function connect(): void { + try { + socket = new WebSocket(url); + } catch { + socket = null; + return; + } + socket.addEventListener("open", () => { + open = true; + for (const message of queue.splice(0)) send(message); + }); + socket.addEventListener("close", () => { + open = false; + socket = null; + }); + socket.addEventListener("error", () => { + // A socket that fired `error` is dead: close it explicitly (tidiness), then + // null it so `emit`'s `if (!socket) connect()` reconnects. Without the null, + // a runtime that fires `error` without a following `close` would leave + // `socket` non-null and frames would buffer then drop. + open = false; + const dead = socket; + socket = null; + try { + dead?.close(); + } catch { + // already closed / closing + } + }); + } + + function send(message: string): void { + try { + socket?.send(message); + } catch { + // A dead socket must never break the frame path. + } + } + + connect(); + return { + emit(channelId, dir, frame) { + const message = JSON.stringify({ channelId, dir, frame: toBase64(frame) }); + if (open && socket) { + send(message); + return; + } + if (queue.length < MAX_QUEUE) queue.push(message); + if (!socket) connect(); + }, + }; +} + +let debuggerLink: ReturnType | null = null; + +function buildCoreCallbacks(coreId: number) { + const callbacks = { emitFrame(frame: Uint8Array): void { postToMain({ kind: "frame", coreId, bytes: frame }); }, @@ -196,6 +273,15 @@ function buildCoreCallbacks(coreId: number) { // Main thread owns lifecycle and disposes explicitly. }, }; + if (!debuggerLink) return callbacks; + // Adding `debugEmit` is what makes the Rust host install its debug sink; when + // no debugger is configured it is absent and the tap stays inert. + return { + ...callbacks, + debugEmit(channelId: string, dir: string, frame: Uint8Array): void { + debuggerLink?.emit(channelId, dir, frame); + }, + }; } let runtime: WorkerPairingHostRuntime | null = null; @@ -231,6 +317,9 @@ ctx.addEventListener("message", (ev: MessageEvent) => { break; } wasm.setLogLevel?.(msg.logLevel); + if (msg.debuggerUrl && !debuggerLink) { + debuggerLink = createDebuggerLink(msg.debuggerUrl); + } try { runtime = new wasm.WasmPairingHostRuntime( buildRawCallbacks(), diff --git a/js/packages/truapi/README.md b/js/packages/truapi/README.md index dae112d2..6546f606 100644 --- a/js/packages/truapi/README.md +++ b/js/packages/truapi/README.md @@ -69,7 +69,7 @@ sub.unsubscribe(); - **Generated domain clients and types** produced from the Rust API contract. - **SCALE codec helpers** used by the generated code, also re-exported for direct use. - **Sandbox bootstrap** (`@parity/truapi/sandbox`) that detects the host environment, builds the - matching provider, and exposes a cached client — see below. + matching provider, and exposes a cached client - see below. ## Sandbox bootstrap @@ -101,6 +101,22 @@ const unsubscribe = subscribeConnectionStatus((status) => { | `getClientSync(): TrUApiClient \| null` | Cached client; `null` outside a host container. | | `subscribeConnectionStatus(cb): () => void` | Connected / disconnected status listener. | +## Observability / debugging + +The debugger does not live in this package, and the product transport carries no debug seam - +`@parity/truapi` is genuinely untouched by observability. The host taps every product↔host frame in +its Rust core (`truapi-server`'s `DebugSink`) and streams each one - as `{ channelId, dir, frame: +bytes }`, opaque bytes - to a separate debugger app, which decodes and groups them. + +- Architecture (the tap, the envelope, the host-dials-debugger topology, `wss`/cert setup): + `docs/design/wire-observability-debug-host.md`. +- The debugger app itself (trace + envelope-decode engines + the WS server): `@parity/truapi-debugger`. + +The generated `WIRE_DECODE_TABLE` on the `./wire-decode` subpath (raw SCALE bytes → typed value) +stays here, since it is generated from this package's contract. The debugger app is payload-blind +today - it decodes only the wire envelope (`requestId`, frame id) via `decodeWireMessage`, not +payloads - so this table is unused for now; it is the decode source for a future typed-value view. + ## Wire format Frames are SCALE encoded: diff --git a/js/packages/truapi/package.json b/js/packages/truapi/package.json index fbbce3d3..acf739c0 100644 --- a/js/packages/truapi/package.json +++ b/js/packages/truapi/package.json @@ -39,6 +39,10 @@ "types": "./dist/generated/wire-table.d.ts", "import": "./dist/generated/wire-table.js" }, + "./wire-decode": { + "types": "./dist/generated/wire-decode.d.ts", + "import": "./dist/generated/wire-decode.js" + }, "./playground/services": { "types": "./dist/playground/codegen/services.d.ts", "import": "./dist/playground/codegen/services.js" diff --git a/js/packages/truapi/scripts/ensure-generated.sh b/js/packages/truapi/scripts/ensure-generated.sh index 807c0716..e32aa356 100755 --- a/js/packages/truapi/scripts/ensure-generated.sh +++ b/js/packages/truapi/scripts/ensure-generated.sh @@ -9,6 +9,7 @@ codegen_required=( "js/packages/truapi/src/generated/client.ts" "js/packages/truapi/src/generated/types.ts" "js/packages/truapi/src/generated/wire-table.ts" + "js/packages/truapi/src/generated/wire-decode.ts" "js/packages/truapi/src/playground/codegen/services.ts" "js/packages/truapi/src/explorer/codegen/types.ts" "js/packages/truapi/src/explorer/versions.ts" diff --git a/js/packages/truapi/src/client.ts b/js/packages/truapi/src/client.ts index f45481f4..51ad98af 100644 --- a/js/packages/truapi/src/client.ts +++ b/js/packages/truapi/src/client.ts @@ -152,6 +152,7 @@ export function createTransport( ): TrUApiTransport { const codecVersion = options.codecVersion ?? TRUAPI_CODEC_VERSION; let idCounter = 0; + let closedError: Error | null = null; const pending = new Map< string, @@ -212,6 +213,7 @@ export function createTransport( const decoded = decodeWireMessage(message); if (decoded.isErr()) { + // A corrupt/truncated inbound frame tears the transport down. closeWithError(decoded.error); return; } diff --git a/package-lock.json b/package-lock.json index c0d634b7..454f1f13 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,32 @@ "typescript": "^6.0" } }, + "js/packages/truapi-debugger": { + "name": "@parity/truapi-debugger", + "version": "0.0.0", + "license": "MIT", + "dependencies": { + "@parity/truapi": "file:../truapi" + }, + "devDependencies": { + "@types/bun": "^1.3.0", + "typescript": "^6.0" + } + }, + "js/packages/truapi-debugger/node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "js/packages/truapi-host": { "name": "@parity/truapi-host", "version": "0.2.1", @@ -427,6 +453,10 @@ "resolved": "js/packages/truapi", "link": true }, + "node_modules/@parity/truapi-debugger": { + "resolved": "js/packages/truapi-debugger", + "link": true + }, "node_modules/@parity/truapi-host": { "resolved": "js/packages/truapi-host", "link": true diff --git a/playground/tests/e2e/helpers.ts b/playground/tests/e2e/helpers.ts index c61550ef..9417f4e4 100644 --- a/playground/tests/e2e/helpers.ts +++ b/playground/tests/e2e/helpers.ts @@ -9,7 +9,9 @@ import { expect, type FrameLocator, type Page } from "@playwright/test"; * We hand back the FrameLocator scoped to that iframe so individual specs only * need to know about playground selectors. */ -export async function openPlaygroundInDotli(page: Page): Promise { +export async function openPlaygroundInDotli( + page: Page, +): Promise { await page.addInitScript(() => { localStorage.setItem("dotli:mode", "gateway"); localStorage.setItem("dotli:chain-backend", "rpc"); @@ -24,7 +26,7 @@ export async function openPlaygroundInDotli(page: Page): Promise { window as typeof window & { __TRUAPI_PLAYGROUND_E2E__?: boolean } ).__TRUAPI_PLAYGROUND_E2E__ = true; }); - await page.goto("/localhost:3000?dotliProductId=truapi-playground.dot"); + await page.goto(`/localhost:3000?dotliProductId=truapi-playground.dot`); // dotli renders an additional hidden iframe (host.localhost:5173?mode=direct) // alongside the proxied playground; scope to the playground src so the // FrameLocator is unique under Playwright strict mode. diff --git a/rust/crates/truapi-codegen/src/ts.rs b/rust/crates/truapi-codegen/src/ts.rs index 019c33f7..ddbd388b 100644 --- a/rust/crates/truapi-codegen/src/ts.rs +++ b/rust/crates/truapi-codegen/src/ts.rs @@ -489,6 +489,12 @@ pub fn generate( let wire_table_code = generate_wire_table(api, target_version)?; fs::write(Path::new(output_dir).join("wire-table.ts"), wire_table_code)?; + let decode_table_code = generate_decode_table(api, target_version)?; + fs::write( + Path::new(output_dir).join("wire-decode.ts"), + decode_table_code, + )?; + Ok(()) } @@ -1051,6 +1057,172 @@ fn generate_client(api: &ApiDefinition, target_version: u32, codec_version: u8) Ok(out) } +/// Generates the dev-only wire decode table (`wire-decode.ts`): a map from wire +/// `frameId` to a decoder that turns a frame's SCALE payload into a plain JS +/// value. It re-derives the exact request/response/subscription codec +/// expressions the client emitter builds (via [`emit_payload`], +/// [`emit_response`], [`emit_error_response`], and +/// [`versioned_result_codec_expr`]), so a debugger decodes wire frames against +/// the same generated codecs. Subscription `start` and `receive` frames are +/// covered; `stop`/`interrupt` frames are intentionally skipped. +fn generate_decode_table(api: &ApiDefinition, target_version: u32) -> Result { + let ctx = codec_context(&[]); + let wrappers = collect_versioned_wrappers(api); + let services = public_services(api)?; + + // (wire id, emitted table line) pairs, sorted by wire id for a stable, + // wire-ordered file that matches the wire-table layout. + let mut entries: Vec<(u8, String)> = Vec::new(); + + for service in &services { + let trait_def = service.trait_def; + for method in included_methods(trait_def, &wrappers, target_version)? { + let wire_const = wire_const_name(&trait_def.name, &method.name); + let wire_version = method_wire_version(method, &wrappers, target_version)?; + let payload = emit_payload(&method.params, &wrappers, &ctx, wire_version)?; + let wire_ids = wire_ids_for_method(trait_def, method)?; + + match (&method.kind, &method.return_type) { + (MethodKind::Request, ReturnType::Result { ok, err }) => { + let ExpandedWireIds::Request { + request_id, + response_id, + } = wire_ids + else { + unreachable!("request method resolved to subscription wire ids"); + }; + let response = emit_response(ok, &wrappers, &ctx, wire_version)?; + let error = emit_error_response(err, &wrappers, &ctx, wire_version)?; + let response_codec = match wire_version { + Some(version) => versioned_result_codec_expr( + version, + &response.inner_codec_expr, + &error.inner_codec_expr, + )?, + None => format!( + "S.Result({}, {})", + response.wire_codec_expr, error.wire_codec_expr + ), + }; + let value_suffix = if wire_version.is_some() { ".value" } else { "" }; + entries.push(( + request_id, + format!( + " [W.{wire_const}.request]: (payload) => {}.dec(payload),", + payload.wire_codec_expr + ), + )); + entries.push(( + response_id, + format!( + " [W.{wire_const}.response]: (payload) => {response_codec}.dec(payload){value_suffix}," + ), + )); + } + (MethodKind::Subscription, ReturnType::Subscription(ty)) => { + let response = emit_response(ty, &wrappers, &ctx, wire_version)?; + push_subscription_entries( + &mut entries, + &wire_const, + &payload, + &response, + wire_ids, + wire_version, + )?; + } + (MethodKind::ResultSubscription, ReturnType::ResultSubscription { item, .. }) => { + let response = emit_response(item, &wrappers, &ctx, wire_version)?; + push_subscription_entries( + &mut entries, + &wire_const, + &payload, + &response, + wire_ids, + wire_version, + )?; + } + (kind, return_type) => { + bail!( + "Generator internal mismatch for method `{}`: kind {:?} does not match return type {:?}", + method.name, + kind, + return_type + ); + } + } + } + } + + entries.sort_by_key(|(id, _)| *id); + + let mut out = String::new(); + writedoc!( + out, + r#" + // Auto-generated by truapi-codegen. Do not edit. + + import * as S from '../scale.js'; + import * as T from './types.js'; + import * as W from './wire-table.js'; + + /** Dev-only: decode a wire frame's SCALE payload to a plain JS value, keyed by frameId. + * Request/response/subscription frames only; unknown ids are absent (caller falls back to bytes). */ + export const WIRE_DECODE_TABLE: Record unknown> = {{ + "# + ) + .unwrap(); + for (_, line) in &entries { + out.push_str(line); + out.push('\n'); + } + out.push_str("};\n"); + + Ok(out) +} + +/// Emits the `.start` (start payload codec) and `.receive` (item codec) decode +/// entries for a subscription method, mirroring the client's `payload` +/// encoding and `decodeItem` expression. `stop`/`interrupt` frames are skipped. +fn push_subscription_entries( + entries: &mut Vec<(u8, String)>, + wire_const: &str, + payload: &PayloadEmission, + response: &ResponseEmission, + wire_ids: ExpandedWireIds, + wire_version: Option, +) -> Result<()> { + let ExpandedWireIds::Subscription { + start_id, + receive_id, + .. + } = wire_ids + else { + unreachable!("subscription method resolved to request wire ids"); + }; + let item_value = if let Some(version) = wire_version { + versioned_value_expr( + &format!("{}.dec(payload)", response.wire_codec_expr), + &response.wire_type_ts, + &response.inner_type_ts, + version, + ) + } else { + format!("{}.dec(payload)", response.wire_codec_expr) + }; + entries.push(( + start_id, + format!( + " [W.{wire_const}.start]: (payload) => {}.dec(payload),", + payload.wire_codec_expr + ), + )); + entries.push(( + receive_id, + format!(" [W.{wire_const}.receive]: (payload) => {item_value},"), + )); + Ok(()) +} + fn write_observable_helper(out: &mut String) { writedoc!( out, @@ -2661,6 +2833,36 @@ mod tests { ); } + #[test] + fn generate_decode_table_emits_frame_keyed_decoders() { + let api = ApiDefinition { + traits: vec![TraitDef { + name: "Example".to_string(), + module_path: Vec::new(), + methods: vec![ + request_method("feature_supported", Some(2)), + subscription_method("stream", Some(10)), + ], + docs: None, + }], + public_trait_order: vec!["Example".to_string()], + types: Vec::new(), + }; + + let source = generate_decode_table(&api, 2).expect("generate decode table"); + + assert!(source.contains("export const WIRE_DECODE_TABLE")); + assert!(source.contains("(payload: Uint8Array) => unknown")); + assert!(source.contains("[W.EXAMPLE_FEATURE_SUPPORTED.request]")); + assert!(source.contains("[W.EXAMPLE_FEATURE_SUPPORTED.response]")); + assert!(source.contains("[W.EXAMPLE_STREAM.start]")); + assert!(source.contains("[W.EXAMPLE_STREAM.receive]")); + assert!(source.contains(".dec(payload)")); + // stop/interrupt subscription frames are intentionally skipped. + assert!(!source.contains(".stop]")); + assert!(!source.contains(".interrupt]")); + } + #[test] fn generate_wire_table_rejects_duplicate_ids() { let err = generate_wire_table( diff --git a/rust/crates/truapi-server/src/host_core.rs b/rust/crates/truapi-server/src/host_core.rs index d1e8ccc5..0368c19a 100644 --- a/rust/crates/truapi-server/src/host_core.rs +++ b/rust/crates/truapi-server/src/host_core.rs @@ -42,6 +42,63 @@ pub trait FrameSink: Send + Sync { fn emit_frame(&self, frame: Vec); } +/// Dev-only sink that observes host debug events at the core's two frame choke +/// points. A host that does not enable the debugger leaves it unset and the tap +/// is inert. Fire-and-forget by construction: [`DebugSink::emit`] must not block +/// the frame path and must not fail the operation that produced the event, so a +/// slow, absent, or crashed debugger only loses the trace, never a session. +pub trait DebugSink: Send + Sync { + /// Hand one event to the sink. + fn emit(&self, event: DebugEvent); +} + +/// Identifies which product channel on a host a debug event belongs to, so one +/// debugger app can demultiplex several channels. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChannelId(pub String); + +/// Direction of a tapped frame relative to the host core. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FrameDirection { + /// Product to core (inbound to the host). + In, + /// Core to product (outbound from the host). + Out, +} + +impl FrameDirection { + /// The wire direction string, from the **product's** vantage - the vantage + /// the debugger app and the design doc use: `"out"` = the frame left the + /// product, `"in"` = it arrived at the product. This is the inverse of the + /// enum's host-vantage variants (`In` = product to core, i.e. it *left* the + /// product), so every sink serializes the same product-vantage string + /// instead of re-deriving (and risking inverting) it. + pub fn wire_str(self) -> &'static str { + match self { + FrameDirection::In => "out", + FrameDirection::Out => "in", + } + } +} + +/// One observable host debug event. Frame bytes are the untouched +/// `ProtocolMessage`; the debugger decodes them, so the core never does. The +/// enum leaves room for host-internal events (e.g. SSO) that have no wire frame, +/// so it is `#[non_exhaustive]`: adding a variant is not a breaking change. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub enum DebugEvent { + /// A SCALE wire frame crossing a product channel. + Frame { + /// Which product channel on this host. + channel_id: ChannelId, + /// Product to core, or core to product. + dir: FrameDirection, + /// Untouched encoded `ProtocolMessage` bytes. + bytes: Vec, + }, +} + /// Errors returned by [`ProductRuntime::receive_frame`]. #[derive(Debug, Error)] pub enum ProductRuntimeError { @@ -425,6 +482,8 @@ impl ProductRuntime { let transport = Arc::new(SinkTransport { sink, disposed: disposed.clone(), + has_debug: AtomicBool::new(false), + debug: Mutex::new(None), }); let admin = HostAdmin::new(services.clone(), authority.clone(), product); Self { @@ -452,6 +511,15 @@ impl ProductRuntime { return Ok(()); } + // Tap inbound before decode, so a corrupt frame is still observed. + if let Some((channel_id, debug)) = self.transport.debug() { + debug.emit(DebugEvent::Frame { + channel_id, + dir: FrameDirection::In, + bytes: frame.clone(), + }); + } + let message = ProtocolMessage::decode(&mut frame.as_slice()).map_err(|err| { ProductRuntimeError::InvalidFrame { reason: err.to_string(), @@ -516,6 +584,13 @@ impl ProductRuntime { .await } + /// Install a dev-only [`DebugSink`] that observes every product frame in + /// both directions for `channel_id`. Absent by default and inert in + /// production; fire-and-forget, so it can never stall or fail a dispatch. + pub fn set_debug_sink(&self, channel_id: ChannelId, sink: Arc) { + self.transport.set_debug_sink(channel_id, sink); + } + /// Dispose this host core. Idempotent. /// /// Disposal suppresses future outgoing frames, aborts in-flight dispatch @@ -540,6 +615,33 @@ impl ProductRuntime { struct SinkTransport { sink: Arc, disposed: Arc, + /// Fast-path flag: `false` (the production default) lets the per-frame + /// `debug()` return without touching the mutex. Set once when a sink is + /// installed; a reader that races the install just misses one frame. + has_debug: AtomicBool, + debug: Mutex)>>, +} + +impl SinkTransport { + /// The installed debug sink and its channel, if any. Lock-free `None` on the + /// production path (no sink installed); only locks once one is. + fn debug(&self) -> Option<(ChannelId, Arc)> { + if !self.has_debug.load(Ordering::Relaxed) { + return None; + } + self.debug + .lock() + .expect("host core debug sink mutex poisoned") + .clone() + } + + fn set_debug_sink(&self, channel_id: ChannelId, sink: Arc) { + *self + .debug + .lock() + .expect("host core debug sink mutex poisoned") = Some((channel_id, sink)); + self.has_debug.store(true, Ordering::Relaxed); + } } impl Transport for SinkTransport { @@ -547,7 +649,20 @@ impl Transport for SinkTransport { if self.disposed.load(Ordering::Acquire) { return; } - self.sink.emit_frame(message.encode()); + let encoded = message.encode(); + // Forward to the product first, then tap: the debugger is in the path + // but never in the critical path. + match self.debug() { + Some((channel_id, debug)) => { + self.sink.emit_frame(encoded.clone()); + debug.emit(DebugEvent::Frame { + channel_id, + dir: FrameDirection::Out, + bytes: encoded, + }); + } + None => self.sink.emit_frame(encoded), + } } fn on_message( @@ -599,6 +714,125 @@ mod tests { assert_send(runtime.receive_frame(Vec::new())); } + #[derive(Default)] + struct RecordingDebugSink { + events: Mutex)>>, + } + + impl DebugSink for RecordingDebugSink { + fn emit(&self, event: DebugEvent) { + match event { + DebugEvent::Frame { + channel_id, + dir, + bytes, + } => self + .events + .lock() + .expect("debug events mutex poisoned") + .push((channel_id, dir, bytes)), + } + } + } + + #[test] + fn debug_sink_taps_frames_in_both_directions() { + let platform = Arc::new(StubPlatform::default()); + let sink = Arc::new(RecordingSink::default()); + let debug = Arc::new(RecordingDebugSink::default()); + let (host_config, product) = runtime_config("myapp.dot"); + let runtime = ProductRuntime::from_platform_with_config( + platform, + host_config, + product, + test_spawner(), + sink.clone(), + ); + runtime.set_debug_sink(ChannelId("myapp.dot".to_string()), debug.clone()); + + let ids = subscription_ids("theme_subscribe").expect("known subscription"); + let frame = ProtocolMessage { + request_id: "theme:1".to_string(), + payload: Payload { + id: ids.start_id, + value: Vec::new(), + }, + }; + let raw = frame.encode(); + futures::executor::block_on(runtime.receive_frame(raw.clone())).unwrap(); + + // The subscription's first item is emitted asynchronously; wait for it, + // then let the tap (which runs right after delivery in `send`) settle. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while sink + .frames + .lock() + .expect("recording sink mutex poisoned") + .is_empty() + && std::time::Instant::now() < deadline + { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + std::thread::sleep(std::time::Duration::from_millis(20)); + + // Snapshot into owned vecs (never hold a lock across an assertion). + let (inbound, outbound, channels): (Vec>, Vec>, Vec) = { + let events = debug.events.lock().expect("debug events mutex poisoned"); + ( + events + .iter() + .filter(|(_, dir, _)| *dir == FrameDirection::In) + .map(|(_, _, bytes)| bytes.clone()) + .collect(), + events + .iter() + .filter(|(_, dir, _)| *dir == FrameDirection::Out) + .map(|(_, _, bytes)| bytes.clone()) + .collect(), + events.iter().map(|(cid, _, _)| cid.clone()).collect(), + ) + }; + let delivered = sink + .frames + .lock() + .expect("recording sink mutex poisoned") + .clone(); + + // Every event carries the installed channel id. + assert!( + channels + .iter() + .all(|c| *c == ChannelId("myapp.dot".to_string())), + "every event carries its channel id" + ); + // Inbound tapped once, untouched, before decode. + assert_eq!( + inbound, + vec![raw], + "inbound frame tapped exactly once, untouched" + ); + // Both directions fire, and every delivered outbound frame is tapped in + // order: the tap is in the path, not a fabricated side channel. + assert!( + !outbound.is_empty(), + "at least one outbound frame is tapped" + ); + assert_eq!( + outbound, delivered, + "every delivered outbound frame is tapped, in order" + ); + } + + #[test] + fn frame_direction_wire_str_is_product_vantage() { + // The wire string is product-vantage (what the debugger and design doc + // use), the inverse of the enum's host-vantage names: a frame the host + // tapped as `In` (product to core) *left the product*, so it serializes + // as `"out"`. This pins the convention so a sink can't re-invert it. + assert_eq!(FrameDirection::In.wire_str(), "out"); + assert_eq!(FrameDirection::Out.wire_str(), "in"); + } + #[test] fn dispose_cancels_active_subscriptions() { let theme_stream_dropped = Arc::new(AtomicBool::new(false)); diff --git a/rust/crates/truapi-server/src/lib.rs b/rust/crates/truapi-server/src/lib.rs index b3da78bc..dd98be25 100644 --- a/rust/crates/truapi-server/src/lib.rs +++ b/rust/crates/truapi-server/src/lib.rs @@ -29,8 +29,8 @@ pub mod generated; pub mod wasm; pub use host_core::{ - FrameSink, HostAdmin, PairingHostRuntime, ProductRuntime, ProductRuntimeError, - SigningHostRuntime, + ChannelId, DebugEvent, DebugSink, FrameDirection, FrameSink, HostAdmin, PairingHostRuntime, + ProductRuntime, ProductRuntimeError, SigningHostRuntime, }; pub use runtime::ResponderExit; #[cfg(not(target_arch = "wasm32"))] diff --git a/rust/crates/truapi-server/src/wasm.rs b/rust/crates/truapi-server/src/wasm.rs index 74ea5998..e3049c4d 100644 --- a/rust/crates/truapi-server/src/wasm.rs +++ b/rust/crates/truapi-server/src/wasm.rs @@ -31,8 +31,8 @@ use wasm_bindgen::prelude::*; use crate::subscription::Spawner; use crate::{ - FrameSink, PairingHostRuntime, PermissionAuthorizationRequest, PermissionAuthorizationStatus, - ProductRuntime, + ChannelId, DebugEvent, DebugSink, FrameSink, PairingHostRuntime, + PermissionAuthorizationRequest, PermissionAuthorizationStatus, ProductRuntime, }; mod generated_bridge; @@ -67,6 +67,33 @@ impl FrameSink for WasmFrameSink { } } +/// Streams tapped debug frames out to a JS `debugEmit(channelId, dir, frame)` +/// callback so the host worker can forward them to the debugger it dials. +/// Dev-only: installed only when the host provides the callback, and +/// fire-and-forget - a failing callback is logged, never propagated. +struct WasmDebugSink { + emit: SendWrapper, +} + +impl DebugSink for WasmDebugSink { + fn emit(&self, event: DebugEvent) { + let DebugEvent::Frame { + channel_id, + dir, + bytes, + } = event; + let frame = Uint8Array::from(bytes.as_slice()); + if let Err(err) = self.emit.call3( + &JsValue::NULL, + &JsValue::from_str(&channel_id.0), + &JsValue::from_str(dir.wire_str()), + &frame, + ) { + web_sys::console::error_1(&err); + } + } +} + struct WasmPlatform { bridge: SendWrapper>, } @@ -705,10 +732,20 @@ impl WasmPairingHostRuntime { ) -> Result { let product = product_context_from_js(&product)?; let channel = CoreChannel::from_js(&core_callbacks)?; + let debug_emit = get_optional_function(&core_callbacks, "debugEmit")?; + let channel_id = product.product_id.clone(); let sink = Arc::new(WasmFrameSink { emit_frame: SendWrapper::new(channel.emit_frame), }); let runtime = self.runtime.product_runtime(product, sink); + if let Some(debug_emit) = debug_emit { + runtime.set_debug_sink( + ChannelId(channel_id), + Arc::new(WasmDebugSink { + emit: SendWrapper::new(debug_emit), + }), + ); + } Ok(WasmProductRuntime::from_parts(runtime, channel.dispose)) }