Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 48 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: |
Expand All @@ -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 }}"
Expand Down
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions js/packages/truapi-debugger/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
node_modules/
dist/
*.tsbuildinfo
53 changes: 53 additions & 0 deletions js/packages/truapi-debugger/README.md
Original file line number Diff line number Diff line change
@@ -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://<dev-machine>: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.
25 changes: 25 additions & 0 deletions js/packages/truapi-debugger/package.json
Original file line number Diff line number Diff line change
@@ -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 <admin@parity.io>",
"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"
}
}
19 changes: 19 additions & 0 deletions js/packages/truapi-debugger/src/index.ts
Original file line number Diff line number Diff line change
@@ -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";
74 changes: 74 additions & 0 deletions js/packages/truapi-debugger/src/ingest.ts
Original file line number Diff line number Diff line change
@@ -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);
};
}
61 changes: 61 additions & 0 deletions js/packages/truapi-debugger/src/observed-frame.ts
Original file line number Diff line number Diff line change
@@ -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;
65 changes: 65 additions & 0 deletions js/packages/truapi-debugger/src/server.test.ts
Original file line number Diff line number Diff line change
@@ -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<void>((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();
}
});
Loading