Skip to content
Draft
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
37 changes: 37 additions & 0 deletions js/packages/truapi-host/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,43 @@ await runtime.activateStoredSession().catch(() => {});
const provider = await runtime.createProvider({ productId: "first.dot" });
```

## 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. Two conditions must **both** hold or nothing dials, the core installs no
tap, and nothing is logged:

1. **The host page is a dev build.** The `localStorage` read sits behind a hard
`import.meta.env.DEV` gate, which bundlers replace with a boolean literal: in
a production bundle it returns `null` unconditionally, so no stored key can
turn the tap on. A production build that shows no frames is this gate, not a
broken debugger.
2. **The host origin's `localStorage` carries a `ws://` loopback URL**, read on
the host page at runtime boot and forwarded to the worker in its `init`
message:

```js
localStorage.setItem("truapi:debugger", "ws://127.0.0.1:9231");
```

Run the debugger at the other end (`@parity/truapi-debugger`, `npm run serve`,
`127.0.0.1:9231`). On the next runtime boot the worker dials that URL and (via
the Rust core's `DebugSink` tap) sends each frame as `{ channelId, dir, frame }`.

The URL must be `ws://` on a loopback host. Anything else — `wss://`, `http://`,
a LAN or public address, a non-loopback hostname — yields an inert link and a
`wire debugger URL rejected` console warning; there is no certificate or `wss`
path. Prefer the literal `127.0.0.1` over `localhost`: `localhost` passes the
gate, but it resolves `::1` first on macOS while the debugger binds `127.0.0.1`
alone, so the same URL handed to a native host (`truapi-server`'s `WsDebugSink`
dials the first resolved address) silently never connects.

The debugger owns all decoding and decodes every frame it can, including signing
and payment payloads; its safety is the dev-build gate above, not redaction. See
`js/packages/truapi-debugger/README.md` for the tap, the envelope, and the
host-dials-debugger topology.

## Publishing

This package is published by the root `Release` workflow through
Expand Down
8 changes: 8 additions & 0 deletions js/packages/truapi-host/src/wasm-module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,4 +77,12 @@ export interface WasmModuleShape {
) => Uint8Array;
/** SS58 address for a product account public key, at the core's prefix. */
productAccountAddress: (publicKey: Uint8Array) => string;
/**
* The core's own `TRUAPI_WIRE_SCHEMA_HASH`, exported by `truapi-server`'s wasm
* bridge. Optional because `dist/wasm/web/` is gitignored and built by hand, so
* a stale bundle predating the export is a normal state to find at runtime; a
* core that cannot vouch for its table streams frames without a `schema` stamp
* and the debugger groups them without decoding.
*/
wireSchemaHash?: () => string;
}
1 change: 1 addition & 0 deletions js/packages/truapi-host/src/wasm/web/truapi_server.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,4 @@ export const WasmProductRuntime: WasmModuleShape["WasmProductRuntime"];
export const setLogLevel: (level: string) => void;
export const deriveProductAccountPublicKey: WasmModuleShape["deriveProductAccountPublicKey"];
export const productAccountAddress: WasmModuleShape["productAccountAddress"];
export const wireSchemaHash: () => string;
92 changes: 92 additions & 0 deletions js/packages/truapi-host/src/web/create-worker-host-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,16 @@ export type WebWorkerHostConfig = Omit<
>;

export interface WorkerPairingHostRuntime {
/**
* The encoding core's wire-schema hash, when the core reports one.
*
* An in-host debugger tap runs on this side of the worker boundary and has no
* other way to reach it, so without this it can only stamp frames with the
* page bundle's own constant — a different artifact from the core that
* actually encoded them. The debugger then refuses to decode, exactly as it
* should. Undefined for a core built before the export existed.
*/
readonly coreWireSchemaHash: string | undefined;
createProvider(product: {
productId: string;
executionKind?: ProductExecutionKind;
Expand Down Expand Up @@ -174,6 +184,7 @@ interface RuntimeState {
logLevel: LogLevel;
disposed: boolean;
nextCoreId: number;
coreWireSchemaHash: string | undefined;
}

function debugLoggingEnabled(state: RuntimeState): boolean {
Expand Down Expand Up @@ -201,6 +212,80 @@ function readPersistedLogLevel(): LogLevel | null {
return globalThis.localStorage?.getItem(DEV_LOG_LEVEL_KEY) ?? null;
}

// Dev-only, host-agnostic enablement for the wire debugger: in a DEV build, set
// `localStorage["truapi:debugger"] = "ws://<host>:9231"` in the browser and the
// host worker dials that debugger and streams frames to it. Read here (host page)
// and forwarded to the worker in `init`; no cooperation from the embedding shell.
const DEV_DEBUGGER_URL_KEY = "truapi:debugger";

/**
* Why the wire debugger is (not) enabled, so a no-dial is never silent.
*
* `no-key` is the one that bites. The key is read on whichever origin creates the
* runtime - the shell in an embedded host like dot.li, but an iframe realm in
* another embedding - and `localStorage` is per-origin, so a key set anywhere else
* is invisible here. Naming the origin is the whole point: the tap then stays dark
* with nothing on screen to say why.
*/
type DebuggerEnablement = {
readonly url: string | null;
readonly reason: "enabled" | "production-build" | "no-key" | "no-storage";
};

function readPersistedDebuggerUrl(): DebuggerEnablement {
// Hard dev-only gate, not a convention: bundlers (Vite) replace
// `import.meta.env.DEV` with a boolean literal, so in a PRODUCTION build this
// returns null unconditionally and the tap is inert - a stray localStorage key
// cannot turn the debugger on in prod. The wire debugger streams raw
// (now fully-decoded) frames and is strictly a development tool.
//
// The expression below must stay the *literal* `import.meta.env.DEV`, with no
// alias and no optional chaining. A bundler replaces that exact token; reading
// it through `const meta = import.meta` or as `import.meta.env?.DEV` does not
// match, so the expression survives into the bundle and is evaluated at runtime
// against an `import.meta.env` that a plain module does not have. That reads as
// `undefined`, and the gate then refuses in *every* bundled host rather than
// only production ones - which silently disables the standalone tap everywhere.
// The try/catch keeps it safe where `import.meta.env` genuinely does not exist
// (tsc output run under Node, unit tests), where the access throws.
let dev = false;
try {
dev = (import.meta as unknown as { env: { DEV?: boolean } }).env.DEV === true;
} catch {
dev = false;
}
if (!dev) return { url: null, reason: "production-build" };
const storage = globalThis.localStorage;
if (storage === undefined) return { url: null, reason: "no-storage" };
const url = storage.getItem(DEV_DEBUGGER_URL_KEY);
if (url === null || url === "") return { url: null, reason: "no-key" };
return { url, reason: "enabled" };
}

/**
* Say once, in a dev build, whether the debugger will dial - and from which
* origin. Silence here used to be indistinguishable from a working tap: the
* debugger's own socket count still moves (its UI holds one), so "connected but
* no frames" reads as a debugger bug rather than a host that never dialled.
* Never logs in a production build, where the gate is closed by construction and
* the message would be noise.
*/
function reportDebuggerEnablement(e: DebuggerEnablement): void {
if (e.reason === "production-build") return;
const origin = globalThis.location?.origin ?? "(unknown origin)";
if (e.reason === "enabled") {
console.info(`[truapi] wire debugger: dialling ${e.url} (origin ${origin})`);
return;
}
const why =
e.reason === "no-storage"
? "no localStorage in this realm"
: `no "${DEV_DEBUGGER_URL_KEY}" key on origin ${origin} - localStorage is ` +
"per-origin, so set it on THIS origin (the realm that creates the host " +
"runtime), then reload. A key on another origin is invisible here";
console.info(`[truapi] wire debugger: off (${why})`);
}

function persistLogLevel(level: LogLevel): void {
globalThis.localStorage?.setItem(DEV_LOG_LEVEL_KEY, level);
}
Expand Down Expand Up @@ -665,6 +750,7 @@ export function createWebWorkerPairingHostRuntime(
logLevel: devLogLevelOverride ?? options.logLevel ?? "off",
disposed: false,
nextCoreId: 0,
coreWireSchemaHash: undefined,
};

let runtime: WorkerPairingHostRuntime | null = null;
Expand Down Expand Up @@ -823,6 +909,9 @@ export function createWebWorkerPairingHostRuntime(
notifyFault(new Error("worker message could not be deserialized"));
};

const debuggerEnablement = readPersistedDebuggerUrl();
reportDebuggerEnablement(debuggerEnablement);

const onInitMessage = (ev: MessageEvent<WorkerToMain>): void => {
const msg = ev.data;
if (msg.kind === "loaded") {
Expand All @@ -834,8 +923,10 @@ export function createWebWorkerPairingHostRuntime(
chat: host.chat !== undefined,
permissionStatus: host.permissionStatus !== undefined,
},
debuggerUrl: debuggerEnablement.url,
} satisfies MainToWorker);
} else if (msg.kind === "ready") {
state.coreWireSchemaHash = msg.schema;
cleanupInit();
worker.addEventListener("message", onMessage);
worker.addEventListener("error", onRuntimeError);
Expand Down Expand Up @@ -923,6 +1014,7 @@ function handleFrameError(

function buildRuntime(state: RuntimeState): WorkerPairingHostRuntime {
const runtime: WorkerPairingHostRuntime = {
coreWireSchemaHash: state.coreWireSchemaHash,
createProvider(product): Promise<TrUApiProductProvider> {
if (state.disposed) {
return Promise.reject(
Expand Down
1 change: 1 addition & 0 deletions js/packages/truapi-host/src/web/worker-provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,7 @@ describe("createWebWorkerPairingHostRuntime", () => {
logLevel: "debug",
hostConfig: hostConfigFromRuntimeConfig(config),
capabilities: { chat: false, permissionStatus: false },
debuggerUrl: null,
});

worker.emit({ kind: "ready" });
Expand Down
13 changes: 12 additions & 1 deletion js/packages/truapi-host/src/worker-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ export type MainToWorker =
* the boundary.
*/
capabilities: OptionalCapabilities;
// 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 }
Expand Down Expand Up @@ -135,7 +138,15 @@ export type MainToWorker =
*/
export type WorkerToMain =
| { kind: "loaded" }
| { kind: "ready" }
| {
kind: "ready";
/**
* The encoding core's wire-schema hash, when it reports one. The page needs
* it to stamp an in-host debugger tap with the same identity a dialing host
* puts on a standalone envelope; without it a tap is grouped but not decoded.
*/
schema?: string;
}
| { kind: "coreReady"; coreId: number }
| { kind: "coreError"; coreId: number; error: string }
| { kind: "fatalError"; error: string }
Expand Down
Loading