diff --git a/.gitignore b/.gitignore index 9138e1a8..620568d2 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,12 @@ lerna-debug.log* node_modules target +# Gradle (Android workspace at repo root) +/.gradle/ +/build/ +/android/*/build/ +local.properties + # Environment / secrets (never commit real env files; keep example templates) .env .env.* @@ -39,3 +45,16 @@ playground/public/static.files # Auto-generated by truapi-codegen (typecheck fixtures for rustdoc ts blocks) playground/test/generated/ + +# Auto-generated FFI / WASM binding outputs +android/truapi-host/src/main/kotlin/generated/ +ios/truapi-host/Sources/TrUAPIHost/truapi_server.swift +ios/truapi-host/Sources/truapi_serverFFI/ +rust/crates/truapi-server/pkg/ +js/packages/truapi/src/generated/ +js/packages/truapi/dist/generated/ +js/packages/truapi-host/src/generated/ +js/packages/truapi-host/dist/generated/ +js/packages/truapi-host-wasm/src/generated/ +js/packages/truapi-host-wasm/dist/generated/ +js/packages/truapi-host-wasm/dist/wasm/ diff --git a/.prettierrc b/.prettierrc index 8224a16a..7d4a0046 100644 --- a/.prettierrc +++ b/.prettierrc @@ -10,7 +10,10 @@ "endOfLine": "lf", "overrides": [ { - "files": "js/packages/truapi/src/**/*.test.ts", + "files": [ + "js/packages/truapi/src/**/*.test.ts", + "js/packages/truapi-host-wasm/src/**/*.test.ts" + ], "options": { "tabWidth": 4, "printWidth": 100 diff --git a/js/packages/truapi-host-wasm/.gitignore b/js/packages/truapi-host-wasm/.gitignore new file mode 100644 index 00000000..288deac9 --- /dev/null +++ b/js/packages/truapi-host-wasm/.gitignore @@ -0,0 +1,11 @@ +node_modules/ +*.tsbuildinfo +# Ignore compiled TS output (top-level + the web/ and electron/ entry subdirs) +# Generated WASM artifacts under dist/wasm/ are ignored by the repo root. +dist/**/*.js +dist/**/*.d.ts +dist/**/*.js.map +dist/**/*.d.ts.map +dist/generated/ +# Codegen output from truapi-codegen --platform-ts-output. +src/generated/ diff --git a/js/packages/truapi-host-wasm/README.md b/js/packages/truapi-host-wasm/README.md new file mode 100644 index 00000000..c80822c1 --- /dev/null +++ b/js/packages/truapi-host-wasm/README.md @@ -0,0 +1,64 @@ +# @parity/truapi-host-wasm + +WASM-backed TrUAPI host runtime. It embeds the `truapi-server` Rust core (compiled to WASM) +behind a Web Worker provider, plus per-environment integration entry points. It is the +counterpart to the native Android/iOS host shells. + +## Entry points + +The package exposes tree-shakeable subpath exports — import only what your environment needs: + +| Import | Provides | +| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | +| `@parity/truapi-host-wasm` | Shared runtime types plus generated typed host callback contracts. | +| `@parity/truapi-host-wasm/web` | Browser host: `createIframeHost` (iframe MessageChannel handshake) and `createWebWorkerProvider`. | +| `@parity/truapi-host-wasm/worker-runtime` | Web Worker entrypoint (import with your bundler's `?worker` suffix) so the WASM core runs off the page main thread. | +| `@parity/truapi-host-wasm/wasm/web` | The raw browser `wasm-bindgen` glue, if you need to instantiate the core yourself. | + +## Generated WASM artefacts + +The ignored bundle under `dist/wasm/web/` is built with host-owned chain access. +Hosts wire their JSON-RPC provider through `chainConnect`; if they omit it, +chain calls fail with the core's standard unavailable error. The bundled WASM is +about 1 MB (release build with `wasm-opt`). + +Build them after editing `rust/crates/truapi-server` and before packaging, publishing, or running +tests that load the raw WASM bundle (requires `wasm-pack` on PATH): + +```bash +npm run build:wasm # or `make wasm` from the repo root +``` + +## Example — browser (Web Worker) + +```ts +import HostWorker from "@parity/truapi-host-wasm/worker-runtime?worker"; +import { createWebWorkerProvider } from "@parity/truapi-host-wasm/web"; + +const provider = await createWebWorkerProvider(new HostWorker(), callbacks, { + runtimeConfig, +}); +``` + +`@parity/truapi-host-wasm/web` also exports `createIframeHost` for the protocol-iframe +MessageChannel handshake. + +## Publishing + +The npm publish workflow is not wired yet. A release-process discussion is needed before adding a +publish job to `.github/workflows/`. Until then, consumers depend on the package via the workspace +`file:` link or by publishing locally with `npm pack`. + +## Architecture + +```text +JS host code + protocol handlers / typed callbacks + (types from @parity/truapi-host-wasm) + | + v +createWebWorkerProvider + | + v + truapi-server WASM core +``` diff --git a/js/packages/truapi-host-wasm/package.json b/js/packages/truapi-host-wasm/package.json new file mode 100644 index 00000000..c9125f87 --- /dev/null +++ b/js/packages/truapi-host-wasm/package.json @@ -0,0 +1,49 @@ +{ + "name": "@parity/truapi-host-wasm", + "version": "0.1.0", + "description": "WASM-backed TrUAPI host runtime: embeds the Rust core, with web iframe and Web Worker entry points", + "license": "MIT", + "author": "Parity Technologies ", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "sideEffects": [ + "./dist/worker-runtime.js", + "./dist/wasm/**" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./web": { + "types": "./dist/web/index.d.ts", + "import": "./dist/web/index.js" + }, + "./worker-runtime": { + "types": "./dist/worker-runtime.d.ts", + "import": "./dist/worker-runtime.js" + }, + "./wasm/web": { + "types": "./dist/wasm/web/truapi_server.d.ts", + "import": "./dist/wasm/web/truapi_server.js" + } + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsc -b", + "build:wasm": "node scripts/build-wasm.mjs", + "test": "bun test" + }, + "dependencies": { + "@parity/truapi": "file:../truapi" + }, + "devDependencies": { + "@types/bun": "^1.3.0", + "neverthrow": "^8.2.0", + "typescript": "^5.7" + } +} diff --git a/js/packages/truapi-host-wasm/scripts/build-wasm.mjs b/js/packages/truapi-host-wasm/scripts/build-wasm.mjs new file mode 100644 index 00000000..97583e4a --- /dev/null +++ b/js/packages/truapi-host-wasm/scripts/build-wasm.mjs @@ -0,0 +1,63 @@ +#!/usr/bin/env node +// Rebuild the browser truapi-server WASM artefacts generated under +// `dist/wasm/web/`. wasm-pack is required. + +import { execFile } from "node:child_process"; +import { rm } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); +const __dirname = dirname(fileURLToPath(import.meta.url)); +const pkgRoot = resolve(__dirname, ".."); +const repoRoot = resolve(pkgRoot, "../../.."); +const rustCrate = resolve(repoRoot, "rust/crates/truapi-server"); +const wasmProfile = process.env.TRUAPI_WASM_PROFILE ?? "release"; + +function args(target, outDir) { + const command = [ + "build", + "--target", + target, + "--out-dir", + outDir, + "--out-name", + "truapi_server", + ]; + if (wasmProfile === "dev") { + command.push("--dev"); + } else if (wasmProfile === "profiling") { + command.push("--profiling"); + } else if (wasmProfile !== "release") { + throw new Error( + `Unsupported TRUAPI_WASM_PROFILE=${wasmProfile}; expected release, dev, or profiling`, + ); + } + command.push(rustCrate, "--no-default-features"); + return command; +} + +async function build(target, subdir) { + const outDir = resolve(pkgRoot, "dist/wasm", subdir); + process.stdout.write( + `wasm-pack build --target ${target} --${wasmProfile} → ${outDir}\n`, + ); + try { + await execFileAsync("wasm-pack", args(target, outDir), { cwd: repoRoot }); + } catch (err) { + if (err?.code === "ENOENT") { + console.error( + "wasm-pack is required. Install it with `cargo install wasm-pack` " + + "or see https://rustwasm.github.io/wasm-pack/installer/", + ); + process.exit(1); + } + throw err; + } + // wasm-pack writes a nested `.gitignore: *`; the repo-level ignore already + // owns generated WASM outputs. + await rm(resolve(outDir, ".gitignore"), { force: true }); +} + +await build("web", "web"); diff --git a/js/packages/truapi-host-wasm/src/adapter-support.ts b/js/packages/truapi-host-wasm/src/adapter-support.ts new file mode 100644 index 00000000..ec4a2052 --- /dev/null +++ b/js/packages/truapi-host-wasm/src/adapter-support.ts @@ -0,0 +1,125 @@ +// Hand-written runtime support for the generated `createWasmRawCallbacks` +// adapter (`./generated/host-callbacks-adapter.ts`). The adapter is mechanical +// (decode params, call the typed host callback, read the result); the pieces +// here are the genuinely bespoke runtime plumbing it leans on: stream driving +// and the chain-connection handle. + +import { type GenericError, type Result } from "@parity/truapi"; +import { hexToBytes } from "@parity/truapi/scale"; + +import type { ChainConnect, ChainConnection } from "./runtime.js"; +import type { HostCallbacks } from "./generated/host-callbacks.js"; + +type WireResult = + | { success: true; value: T } + | { success: false; value: E }; + +type StreamResult = Result | WireResult; + +type MaybeAsyncIterable = AsyncIterable | Iterable; + +/** + * Normalize both generated `Result` values and the plain + * `{ success, value }` envelope used by some JS fixtures into a raw item. + */ +function unwrapStreamResult(item: StreamResult): T { + if ("success" in item) { + if (item.success === false) { + throw new Error(item.value.reason); + } + return item.value; + } + if (item.isErr()) { + throw new Error(item.error.reason); + } + return item.value; +} + +/** + * Accept sync and async host streams behind one async-iterator interface. + * Host callbacks often use async iterables in production, while tests can use + * small synchronous fixtures without a custom wrapper. + */ +function toAsyncIterator(stream: MaybeAsyncIterable): AsyncIterator { + const asyncIterable = stream as AsyncIterable; + if (typeof asyncIterable[Symbol.asyncIterator] === "function") { + return asyncIterable[Symbol.asyncIterator](); + } + const iterator = (stream as Iterable)[Symbol.iterator](); + const asyncIterator: AsyncIterator = { + next: async () => iterator.next(), + }; + if (iterator.return) { + asyncIterator.return = async () => iterator.return!(); + } + return asyncIterator; +} + +/** + * Drain an async iterator into a sink until disposed. This is used for + * callback streams where the Rust core owns cancellation but JS owns the + * iterator and any transport cleanup behind `return()`. + */ +function pumpIterator( + iterator: AsyncIterator, + onItem: (value: T) => void, + label: string, +): () => void { + let stopped = false; + void (async () => { + try { + while (!stopped) { + const next = await iterator.next(); + if (next.done) return; + onItem(next.value); + } + } catch (err) { + console.error(`[truapi host callbacks] ${label} failed:`, err); + } + })(); + return () => { + stopped = true; + void iterator.return?.(); + }; +} + +/** + * Drive a typed host stream of `Result` items into the core's `sendItem` + * sink, unwrapping each `Result` (or throwing on its error). Returns a + * disposer that stops iteration. + */ +export function driveResultStream( + stream: MaybeAsyncIterable>, + sendItem: (value: T) => void, +): () => void { + return pumpIterator( + toAsyncIterator(stream), + (value) => sendItem(unwrapStreamResult(value)), + "subscription", + ); +} + +/** + * Bridge the typed `ChainProvider.connect` callback onto the raw + * `chainConnect` the WASM core invokes: decode the genesis hash, pump the + * connection's `responses()` stream into `onResponse`, and expose + * `send`/`close`. + */ +export function chainConnectAdapter( + host: Pick, +): ChainConnect { + return async (genesisHash, onResponse): Promise => { + const connection = await host.connect(hexToBytes(genesisHash)); + const iterator = connection.responses()[Symbol.asyncIterator](); + const stopResponses = pumpIterator(iterator, onResponse, "chain responses"); + return { + send(request: string): void { + connection.send(request); + }, + close(): void { + stopResponses(); + connection.close(); + }, + }; + }; +} diff --git a/js/packages/truapi-host-wasm/src/error.ts b/js/packages/truapi-host-wasm/src/error.ts new file mode 100644 index 00000000..5dc5674e --- /dev/null +++ b/js/packages/truapi-host-wasm/src/error.ts @@ -0,0 +1,6 @@ +/** Coerce an unknown thrown value into a human-readable message string. */ +export function errorMessage(err: unknown): string { + if (err instanceof Error) return err.message; + if (typeof err === "string") return err; + return JSON.stringify(err) ?? String(err); +} diff --git a/js/packages/truapi-host-wasm/src/host-callbacks-adapter.test.ts b/js/packages/truapi-host-wasm/src/host-callbacks-adapter.test.ts new file mode 100644 index 00000000..2a2c2740 --- /dev/null +++ b/js/packages/truapi-host-wasm/src/host-callbacks-adapter.test.ts @@ -0,0 +1,355 @@ +import { describe, expect, it } from "bun:test"; +import { ok } from "neverthrow"; + +import { + HostDevicePermissionRequest, + HostDevicePermissionResponse, + HostFeatureSupportedRequest, + HostFeatureSupportedResponse, + HostPushNotificationRequest, + HostPushNotificationResponse, + RemotePermissionRequest, + RemotePermissionResponse, + ThemeVariant, +} from "@parity/truapi"; +import type { HostSignPayloadData } from "@parity/truapi"; + +import { createWasmRawCallbacks } from "./generated/host-callbacks-adapter.js"; +import { CoreStorageKey, UserConfirmationReview } from "./generated/host-callbacks.js"; +import { makeHostCallbacks, settle } from "./test-support.js"; + +// The generated `createWasmRawCallbacks` adapter speaks the symmetric SCALE +// byte boundary: codec-typed requests arrive as `Uint8Array` and are decoded +// for the typed host callback; codec-typed responses are SCALE-encoded back to +// `Uint8Array`. Primitives, strings, byte blobs and the local `AuthState` pass +// through unchanged. + +const GENESIS = `0x${"11".repeat(32)}` as `0x${string}`; +const PRODUCT_ACCOUNT = { + dotNsIdentifier: "playground.dot", + derivationIndex: 0, +}; +const SIGN_PAYLOAD: HostSignPayloadData = { + blockHash: GENESIS, + blockNumber: "0x01", + era: "0x00", + genesisHash: GENESIS, + method: "0x0102", + nonce: "0x00", + specVersion: "0x01", + tip: "0x00", + transactionVersion: "0x01", + signedExtensions: [], + version: 4, + assetId: undefined, + metadataHash: undefined, + mode: undefined, +}; + +describe("createWasmRawCallbacks", () => { + it("decodes requests and encodes typed responses", async () => { + const writes: [string, number[]][] = []; + const clears: string[] = []; + const cancelled: number[] = []; + const raw = createWasmRawCallbacks( + makeHostCallbacks({ + pushNotification: async (notification) => ({ + id: notification.text.length, + }), + cancelNotification: async (id) => { + cancelled.push(id); + }, + devicePermission: async (request) => ({ + granted: request === "Camera", + }), + remotePermission: async (request) => ({ + granted: request.permission.tag === "ChainSubmit", + }), + featureSupported: async (request) => ({ + supported: request.tag === "Chain" && request.value.genesisHash === GENESIS, + }), + read: async (key) => new TextEncoder().encode(`read:${key}`), + write: async (key, value) => { + writes.push([key, [...value]]); + }, + clear: async (key) => { + clears.push(key); + }, + }), + ); + + expect( + HostPushNotificationResponse.dec( + await raw.pushNotification!( + HostPushNotificationRequest.enc({ + text: "hello", + deeplink: undefined, + scheduledAt: undefined, + }), + ), + ).id, + ).toBe(5); + expect( + HostDevicePermissionResponse.dec( + await raw.devicePermission!(HostDevicePermissionRequest.enc("Camera")), + ).granted, + ).toBe(true); + expect( + RemotePermissionResponse.dec( + await raw.remotePermission!( + RemotePermissionRequest.enc({ + permission: { tag: "ChainSubmit" }, + }), + ), + ).granted, + ).toBe(true); + expect( + HostFeatureSupportedResponse.dec( + await raw.featureSupported!( + HostFeatureSupportedRequest.enc({ + tag: "Chain", + value: { genesisHash: GENESIS }, + }), + ), + ).supported, + ).toBe(true); + expect(await raw.read!("session")).toEqual(new TextEncoder().encode("read:session")); + + await raw.write!("session", new Uint8Array([1, 2, 3])); + await raw.clear!("session"); + await raw.cancelNotification?.(9); + + expect(writes).toEqual([["session", [1, 2, 3]]]); + expect(clears).toEqual(["session"]); + expect(cancelled).toEqual([9]); + }); + + it("bridges lifecycle, confirmations, and preimage callbacks", async () => { + const calls: unknown[][] = []; + async function* preimages() { + yield ok(undefined); + yield ok(new Uint8Array([4, 5, 6])); + } + + const raw = createWasmRawCallbacks( + makeHostCallbacks({ + authStateChanged: (state) => { + calls.push(["authStateChanged", state]); + }, + readCoreStorage: async (key) => + key.tag === "AuthSession" ? new Uint8Array([1, 2, 3]) : undefined, + writeCoreStorage: async (key, value) => { + calls.push(["writeCoreStorage", key, [...value]]); + }, + clearCoreStorage: async (key) => { + calls.push(["clearCoreStorage", key]); + }, + confirmUserAction: async (review) => { + switch (review.tag) { + case "SignPayload": + return ( + review.value.tag === "Product" && + review.value.value.account.dotNsIdentifier === "playground.dot" && + review.value.value.payload.method === "0x0102" + ); + case "SignRaw": + return ( + review.value.tag === "Product" && + review.value.value.payload.tag === "Bytes" && + review.value.value.payload.value.bytes === "0x0304" + ); + case "CreateTransaction": + return ( + review.value.tag === "Product" && + review.value.value.signer.derivationIndex === 0 && + review.value.value.callData === "0x0506" + ); + case "AccountAlias": + return ( + review.value.requestingProductId === "playground.dot" && + review.value.targetProductId === "wallet.dot" + ); + case "ResourceAllocation": + return review.value.resources[0]?.tag === "StatementStoreAllowance"; + case "PreimageSubmit": + calls.push(["confirmUserAction:PreimageSubmit", review.value.size]); + return review.value.size === 42n; + } + }, + submitPreimage: async (value) => { + calls.push(["submitPreimage", [...value]]); + return new Uint8Array([7, 8, 9]); + }, + lookupPreimage: (key) => { + calls.push(["lookupPreimage", [...key]]); + return preimages(); + }, + }), + ); + + const preimageEvents: (number[] | null)[] = []; + const disposePreimages = raw.lookupPreimage!(new Uint8Array([9]), (value) => + preimageEvents.push(value ? [...value] : null), + ); + + raw.authStateChanged?.({ + tag: "Pairing", + value: { deeplink: "polkadotapp://example" }, + }); + const authSessionKey = CoreStorageKey.enc({ tag: "AuthSession" }); + expect(await raw.readCoreStorage!(authSessionKey)).toEqual(new Uint8Array([1, 2, 3])); + await raw.writeCoreStorage!(authSessionKey, new Uint8Array([3, 2, 1])); + await raw.clearCoreStorage!(authSessionKey); + expect( + await raw.confirmUserAction?.( + UserConfirmationReview.enc({ + tag: "SignPayload", + value: { + tag: "Product", + value: { + account: PRODUCT_ACCOUNT, + payload: SIGN_PAYLOAD, + }, + }, + }), + ), + ).toBe(true); + expect( + await raw.confirmUserAction?.( + UserConfirmationReview.enc({ + tag: "SignRaw", + value: { + tag: "Product", + value: { + account: PRODUCT_ACCOUNT, + payload: { + tag: "Bytes", + value: { bytes: "0x0304" }, + }, + }, + }, + }), + ), + ).toBe(true); + expect( + await raw.confirmUserAction?.( + UserConfirmationReview.enc({ + tag: "CreateTransaction", + value: { + tag: "Product", + value: { + signer: PRODUCT_ACCOUNT, + genesisHash: GENESIS, + callData: "0x0506", + extensions: [], + txExtVersion: 0, + }, + }, + }), + ), + ).toBe(true); + expect( + await raw.confirmUserAction?.( + UserConfirmationReview.enc({ + tag: "AccountAlias", + value: { + requestingProductId: "playground.dot", + targetProductId: "wallet.dot", + }, + }), + ), + ).toBe(true); + expect( + await raw.confirmUserAction?.( + UserConfirmationReview.enc({ + tag: "ResourceAllocation", + value: { + resources: [{ tag: "StatementStoreAllowance" }], + }, + }), + ), + ).toBe(true); + expect( + await raw.confirmUserAction?.( + UserConfirmationReview.enc({ + tag: "PreimageSubmit", + value: { size: 42n }, + }), + ), + ).toBe(true); + expect(await raw.submitPreimage!(new Uint8Array([6]))).toEqual(new Uint8Array([7, 8, 9])); + + await settle(); + await settle(); + + expect(preimageEvents).toEqual([null, [4, 5, 6]]); + expect(calls).toEqual([ + ["lookupPreimage", [9]], + ["authStateChanged", { tag: "Pairing", value: { deeplink: "polkadotapp://example" } }], + ["writeCoreStorage", { tag: "AuthSession", value: undefined }, [3, 2, 1]], + ["clearCoreStorage", { tag: "AuthSession", value: undefined }], + ["confirmUserAction:PreimageSubmit", 42n], + ["submitPreimage", [6]], + ]); + + disposePreimages?.(); + }); + + it("adapts typed result subscriptions", async () => { + async function* themes() { + yield ok("Dark"); + yield ok("Light"); + } + + const raw = createWasmRawCallbacks( + makeHostCallbacks({ + subscribeTheme: () => themes(), + }), + ); + const seen: ThemeVariant[] = []; + const dispose = raw.subscribeTheme?.((theme) => seen.push(ThemeVariant.dec(theme!))); + + await settle(); + await settle(); + + expect(seen).toEqual(["Dark", "Light"]); + dispose?.(); + }); + + it("bridges typed chain connections", async () => { + const sent: string[] = []; + const responses = ['{"jsonrpc":"2.0","id":1,"result":"ok"}']; + let closes = 0; + const raw = createWasmRawCallbacks( + makeHostCallbacks({ + connect: async (genesisHash) => { + expect([...genesisHash]).toEqual(Array(32).fill(0x11)); + return { + send(request) { + sent.push(request); + }, + async *responses() { + yield* responses; + }, + close() { + closes += 1; + }, + }; + }, + }), + ); + + expect(typeof raw.chainConnect).toBe("function"); + const received: string[] = []; + const connection = await raw.chainConnect!(GENESIS, (json) => received.push(json)); + expect(connection).toBeTruthy(); + + connection!.send('{"jsonrpc":"2.0","id":1,"method":"system_health"}'); + await settle(); + + expect(sent).toEqual(['{"jsonrpc":"2.0","id":1,"method":"system_health"}']); + expect(received).toEqual(responses); + connection!.close(); + expect(closes).toBe(1); + }); +}); diff --git a/js/packages/truapi-host-wasm/src/index.ts b/js/packages/truapi-host-wasm/src/index.ts new file mode 100644 index 00000000..6236052a --- /dev/null +++ b/js/packages/truapi-host-wasm/src/index.ts @@ -0,0 +1,29 @@ +export type { Payload, ProtocolMessage, WireProvider } from "@parity/truapi"; + +export { encodeCoreStorageKey } from "./runtime.js"; + +export type { + AuthState, + Awaitable, + ChainConnect, + ChainConnection, + ChainProvider, + CoreAdmin, + CoreStorage, + CoreStorageKey, + Features, + HostCallbacks, + LogLevel, + Navigation, + Notifications, + PermissionAuthorizationRequest, + PermissionAuthorizationStatus, + Permissions, + PreimageHost, + ProductStorage, + PlatformJsonRpcConnection, + SessionUiInfo, + ThemeHost, + TrUApiHostCoreProvider, + HostCoreRuntimeConfig, +} from "./runtime.js"; diff --git a/js/packages/truapi-host-wasm/src/runtime.ts b/js/packages/truapi-host-wasm/src/runtime.ts new file mode 100644 index 00000000..89f93cf1 --- /dev/null +++ b/js/packages/truapi-host-wasm/src/runtime.ts @@ -0,0 +1,95 @@ +import type { WireProvider } from "@parity/truapi"; +import { CoreStorageKey as GeneratedCoreStorageKey } from "./generated/host-callbacks.js"; +import type { CoreAdmin, CoreStorageKey } from "./generated/host-callbacks.js"; + +// The typed capability interfaces below come straight from the +// `truapi-platform` Rust crate via `truapi-codegen --platform-ts-output`. +// They are the host-author-facing surface: each method takes/returns +// typed wrappers (`HostDevicePermissionRequest`, etc.) rather than raw +// SCALE bytes. `createWebWorkerProvider` adapts this typed surface into +// the byte-oriented callback bridge consumed by the WASM core. +export type { + AuthState, + ChainProvider, + CoreAdmin, + CoreStorage, + CoreStorageKey, + Features, + HostCallbacks, + JsonRpcConnection as PlatformJsonRpcConnection, + Navigation, + Notifications, + PermissionAuthorizationRequest, + PermissionAuthorizationStatus, + Permissions, + PreimageHost, + ProductStorage, + SessionUiInfo, + ThemeHost, +} from "./generated/host-callbacks.js"; + +/** Encode a typed core-storage slot for hosts that need an opaque backing key. */ +export function encodeCoreStorageKey(key: CoreStorageKey): Uint8Array { + return GeneratedCoreStorageKey.enc(key); +} + +/** + * Async-or-sync return. Synchronous hosts (e.g. the dotli main-thread + * shell hitting localStorage) can return a plain value; the WASM bridge + * awaits every return so an `async` impl also works. + */ +export type Awaitable = T | Promise; + +/** + * Open a JSON-RPC connection for `genesisHash`. The wasm bridge passes + * `onResponse` so the host can push JSON-RPC replies back asynchronously. + * Returning `null` (or throwing) tells the core no provider is available. + */ +export type ChainConnect = ( + genesisHash: string, + onResponse: (json: string) => void, +) => Awaitable; + +/** + * Per-connection handle returned by `chainConnect`. `send` forwards a + * SCALE-encoded JSON-RPC request; `close` tears the connection down. + */ +export interface ChainConnection { + send(request: string): void; + close(): void; +} + +/** + * Verbosity threshold for the wasm core's `tracing` output. The Rust core + * parses the string; known values are `off`, `error`, `warn`, `info`, `debug`, + * and `trace`. + */ +export type LogLevel = string; + +export interface HostCoreRuntimeConfig { + productId: string; + host: { + name: string; + icon?: string; + version?: string; + }; + platform?: { + type?: string; + version?: string; + }; + people: { + genesisHash: string | Uint8Array; + }; + pairing: { + deeplinkScheme: string; + }; +} + +export interface TrUApiHostCoreProvider extends WireProvider, CoreAdmin { + /** + * Re-tune the wasm core's log level at runtime. Present on runtimes that + * keep a live channel to the core (e.g. the Web Worker provider); absent on + * one-shot constructions that only accept `logLevel` up front. + */ + setLogLevel?(level: LogLevel): void; +} diff --git a/js/packages/truapi-host-wasm/src/test-support.ts b/js/packages/truapi-host-wasm/src/test-support.ts new file mode 100644 index 00000000..7f5c5cde --- /dev/null +++ b/js/packages/truapi-host-wasm/src/test-support.ts @@ -0,0 +1,40 @@ +import type { HostCallbacks } from "./generated/host-callbacks.js"; + +/** `HostCallbacks` with every optional member required, for exhaustive test fixtures. */ +export type CompleteHostCallbacks = Required; + +/** Default no-op host callbacks with optional per-test overrides. */ +export function makeHostCallbacks( + overrides: Partial = {}, +): CompleteHostCallbacks { + return { + navigateTo: async () => {}, + pushNotification: async () => ({ id: 0 }), + cancelNotification: async () => {}, + devicePermission: async () => ({ granted: false }), + remotePermission: async () => ({ granted: false }), + featureSupported: async () => ({ supported: false }), + readCoreStorage: async () => undefined, + writeCoreStorage: async () => {}, + clearCoreStorage: async () => {}, + read: async () => undefined, + write: async () => {}, + clear: async () => {}, + authStateChanged: () => {}, + confirmUserAction: async () => false, + submitPreimage: async () => new Uint8Array(), + async *lookupPreimage() {}, + async *subscribeTheme() {}, + connect: async () => ({ + send() {}, + async *responses() {}, + close() {}, + }), + ...overrides, + }; +} + +/** Resolve after the current microtask/immediate queue, letting pending async work run. */ +export function settle(): Promise { + return new Promise((resolve) => setImmediate(resolve)); +} diff --git a/js/packages/truapi-host-wasm/src/web/create-iframe-host.test.ts b/js/packages/truapi-host-wasm/src/web/create-iframe-host.test.ts new file mode 100644 index 00000000..9a9108bc --- /dev/null +++ b/js/packages/truapi-host-wasm/src/web/create-iframe-host.test.ts @@ -0,0 +1,215 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; + +import { createIframeHost } from "./index.js"; + +// Verify that `createIframeHost` hands a MessagePort back through `onPort`, +// constructs an iframe with the expected attributes, and posts the +// `truapi-init` handshake after the iframe reports readiness. + +function setupFakeDom() { + // Track listeners on the synthetic `window` and the iframe so the + // test can simulate the iframe `load` event after construction. + const iframeListeners = new Map void>(); + const windowListeners = new Map void>(); + const windowRemove = mock((_name: string, _fn: unknown) => {}); + const contentPostMessage = mock((_body: unknown, _origin: string) => {}); + + const contentWindow = { + postMessage: contentPostMessage, + }; + + const iframe = { + style: {} as Record, + setAttribute: mock((_name: string, _value: string) => {}), + addEventListener: (name: string, fn: (event: unknown) => void) => { + iframeListeners.set(name, fn); + }, + removeEventListener: () => {}, + remove: mock(() => {}), + referrerPolicy: "", + credentialless: false, + allow: "", + src: "", + contentWindow, + }; + + const container = { + appendChild: mock((_child: unknown) => {}), + }; + + // Spy on both MessageChannel ports so dispose() teardown is observable. + const port1 = { postMessage: mock(() => {}), close: mock(() => {}) }; + const port2 = { postMessage: mock(() => {}), close: mock(() => {}) }; + globalThis.MessageChannel = class { + port1 = port1; + port2 = port2; + } as unknown as typeof MessageChannel; + + globalThis.document = { + createElement: (tag: string) => { + expect(tag).toBe("iframe"); + return iframe as unknown as HTMLIFrameElement; + }, + } as unknown as Document; + globalThis.window = { + location: { href: "http://localhost:5174/" }, + addEventListener: (name: string, fn: (event: unknown) => void) => { + windowListeners.set(name, fn); + }, + removeEventListener: windowRemove, + } as unknown as Window & typeof globalThis; + + return { + iframe, + container, + contentPostMessage, + contentWindow, + iframeListeners, + windowListeners, + windowRemove, + port1, + port2, + }; +} + +function teardownFakeDom() { + delete (globalThis as { document?: unknown }).document; + delete (globalThis as { window?: unknown }).window; + delete (globalThis as { MessageChannel?: unknown }).MessageChannel; +} + +describe("createIframeHost", () => { + let dom: ReturnType; + + beforeEach(() => { + dom = setupFakeDom(); + }); + + afterEach(() => { + teardownFakeDom(); + }); + + it("hands back a MessagePort and configures the iframe", () => { + const { iframe, container, iframeListeners, windowRemove, port1, port2 } = dom; + + let receivedPort: MessagePort | null = null; + const host = createIframeHost({ + iframeUrl: "http://localhost:5174/", + container: container as unknown as HTMLElement, + allow: "camera; cross-origin-isolated", + onPort: (port) => { + receivedPort = port; + }, + }); + + expect(receivedPort).toBeTruthy(); + expect(typeof receivedPort!.postMessage).toBe("function"); + expect(container.appendChild.mock.calls.length).toBe(1); + expect(host.iframe).toBe(iframe as unknown as HTMLIFrameElement); + expect(iframe.credentialless).toBe(true); + expect(iframe.allow).toBe("camera; cross-origin-isolated"); + expect(iframe.src).toBe("http://localhost:5174/"); + // port transfer waits for explicit iframe readiness + expect(iframeListeners.has("load")).toBe(false); + + host.dispose(); + expect(iframe.remove.mock.calls.length).toBe(1); + // dispose removes the window message listener + expect(windowRemove.mock.calls.length).toBe(1); + expect(windowRemove.mock.calls[0][0]).toBe("message"); + // host + product ports closed on dispose + expect(port1.close.mock.calls.length).toBe(1); + expect(port2.close.mock.calls.length).toBe(1); + }); + + it("sends truapi-init on a same-origin product-ready message", () => { + const { contentPostMessage, windowListeners, contentWindow } = dom; + + createIframeHost({ + iframeUrl: "http://localhost:5174/", + container: { appendChild: () => {} } as unknown as HTMLElement, + onPort: () => {}, + }); + + const onMessage = windowListeners.get("message"); + expect(onMessage).toBeTruthy(); + + // Wrong source is dropped. + onMessage!({ + source: { other: true }, + origin: "http://localhost:5174", + data: { type: "truapi-ready" }, + }); + expect(contentPostMessage.mock.calls.length).toBe(0); + + // Wrong origin is dropped. + onMessage!({ + source: contentWindow, + origin: "http://evil.example", + data: { type: "truapi-ready" }, + }); + expect(contentPostMessage.mock.calls.length).toBe(0); + + // Correct source + origin triggers the init handshake. + onMessage!({ + source: contentWindow, + origin: "http://localhost:5174", + data: { type: "truapi-ready" }, + }); + expect(contentPostMessage.mock.calls.length).toBe(1); + const [body, origin] = contentPostMessage.mock.calls[0]; + expect(body).toEqual({ type: "truapi-init" }); + expect(origin).toBe("*"); + + // The handshake is idempotent across repeated ready events too. + onMessage!({ + source: contentWindow, + origin: "http://localhost:5174", + data: { type: "truapi-ready" }, + }); + expect(contentPostMessage.mock.calls.length).toBe(1); + }); + + it("accepts product-ready from a credentialless opaque origin", () => { + const { contentPostMessage, windowListeners, contentWindow } = dom; + + createIframeHost({ + iframeUrl: "http://localhost:5174/", + container: { appendChild: () => {} } as unknown as HTMLElement, + onPort: () => {}, + }); + + const onMessage = windowListeners.get("message"); + expect(onMessage).toBeTruthy(); + + onMessage!({ + source: contentWindow, + origin: "null", + data: { type: "truapi-ready" }, + }); + expect(contentPostMessage.mock.calls.length).toBe(1); + const [, origin] = contentPostMessage.mock.calls[0]; + expect(origin).toBe("*"); + }); + + it("rejects a mismatched allowedOrigin", () => { + expect(() => + createIframeHost({ + iframeUrl: "http://localhost:5174/", + container: { appendChild: () => {} } as unknown as HTMLElement, + onPort: () => {}, + allowedOrigin: "http://localhost:9999", + }), + ).toThrow(/origin policy mismatch/); + }); + + it("rejects non-http(s) iframe URLs", () => { + expect(() => + createIframeHost({ + iframeUrl: "file:///etc/passwd", + container: { appendChild: () => {} } as unknown as HTMLElement, + onPort: () => {}, + }), + ).toThrow(/only allows http\(s\)/); + }); +}); diff --git a/js/packages/truapi-host-wasm/src/web/create-iframe-host.ts b/js/packages/truapi-host-wasm/src/web/create-iframe-host.ts new file mode 100644 index 00000000..b5e3ce36 --- /dev/null +++ b/js/packages/truapi-host-wasm/src/web/create-iframe-host.ts @@ -0,0 +1,147 @@ +/** + * Options for `createIframeHost`. + */ +export interface IframeHostOptions { + /** URL of the product iframe. */ + iframeUrl: string; + /** Container element the iframe is appended to. */ + container: HTMLElement; + /** + * Called with one end of the MessageChannel once the iframe has loaded. + * Hosts typically pipe this into a `WireProvider` (e.g. via + * `createMessagePortProvider` from `@parity/truapi`). + */ + onPort: (port: MessagePort) => void; + /** + * Optional explicit allow-list origin. Defaults to the origin of + * `iframeUrl`. Throws if it disagrees with the iframe URL's origin. + */ + allowedOrigin?: string; + /** Optional iframe Permissions Policy allow attribute. */ + allow?: string; + /** Override the default iframe sandbox attribute. */ + sandbox?: string; +} + +/** + * Handle returned by `createIframeHost`. + */ +export interface IframeHost { + iframe: HTMLIFrameElement; + dispose: () => void; +} + +const DEFAULT_IFRAME_SANDBOX = "allow-forms allow-same-origin allow-scripts"; +type CredentiallessIframe = HTMLIFrameElement & { credentialless?: boolean }; + +function resolveAllowedOrigin( + iframeUrl: string, + allowedOrigin?: string, +): string { + const targetUrl = new URL(iframeUrl, window.location.href); + if (targetUrl.protocol !== "http:" && targetUrl.protocol !== "https:") { + throw new Error( + `Iframe host only allows http(s) product URLs, received ${targetUrl.protocol}`, + ); + } + + if (!allowedOrigin) { + return targetUrl.origin; + } + + const normalizedOrigin = new URL(allowedOrigin).origin; + if (normalizedOrigin !== targetUrl.origin) { + throw new Error( + `Iframe host origin policy mismatch, expected ${normalizedOrigin}, got ${targetUrl.origin}`, + ); + } + + return normalizedOrigin; +} + +/** + * Embed a product iframe and transfer a `MessagePort` into it. The host + * keeps the other end and passes it to a `WireProvider` (typically via + * `createMessagePortProvider`). All product traffic flows over the + * MessageChannel. + */ +export function createIframeHost(options: IframeHostOptions): IframeHost { + const { + iframeUrl, + container, + onPort, + allowedOrigin, + allow, + sandbox = DEFAULT_IFRAME_SANDBOX, + } = options; + + const channel = new MessageChannel(); + const hostPort = channel.port1; + const productPort = channel.port2; + const targetOrigin = resolveAllowedOrigin(iframeUrl, allowedOrigin); + + // Hand the host-side port to the caller immediately so it can wire up + // a provider before the iframe finishes loading. Queued postMessage + // calls are delivered once the channel is started by the provider. + onPort(hostPort); + + const iframe = document.createElement("iframe"); + iframe.style.width = "100%"; + iframe.style.height = "100%"; + iframe.style.border = "none"; + // COEP hosts need credentialless product iframes when the product origin + // does not serve matching embedder headers. + const credentiallessIframe = iframe as CredentiallessIframe; + credentiallessIframe.credentialless = true; + if (allow !== undefined) { + iframe.allow = allow; + } + iframe.setAttribute("sandbox", sandbox); + iframe.referrerPolicy = "no-referrer"; + iframe.src = iframeUrl; + const initTargetOrigin = credentiallessIframe.credentialless + ? "*" + : targetOrigin; + + let initSent = false; + const sendInit = (): void => { + if (initSent) return; + const contentWindow = iframe.contentWindow; + if (!contentWindow) return; + initSent = true; + contentWindow.postMessage({ type: "truapi-init" }, initTargetOrigin, [ + productPort, + ]); + }; + + const onWindowMessage = (event: MessageEvent): void => { + if (event.source !== iframe.contentWindow) return; + if (event.origin !== targetOrigin && event.origin !== "null") return; + if (event.data?.type === "truapi-ready") { + sendInit(); + } + }; + window.addEventListener("message", onWindowMessage); + + container.appendChild(iframe); + + return { + iframe, + dispose() { + window.removeEventListener("message", onWindowMessage); + try { + hostPort.close(); + } catch { + // already closed + } + try { + productPort.close(); + } catch { + // already closed + } + iframe.remove(); + }, + }; +} + +export type { WireProvider } from "@parity/truapi"; diff --git a/js/packages/truapi-host-wasm/src/web/create-worker-host-runtime.ts b/js/packages/truapi-host-wasm/src/web/create-worker-host-runtime.ts new file mode 100644 index 00000000..f662ec8e --- /dev/null +++ b/js/packages/truapi-host-wasm/src/web/create-worker-host-runtime.ts @@ -0,0 +1,780 @@ +import type { + ChainConnection, + HostCallbacks, + LogLevel, + PermissionAuthorizationRequest, + PermissionAuthorizationStatus, + TrUApiHostCoreProvider, + HostCoreRuntimeConfig, +} from "../index.js"; +import { PermissionAuthorizationRequest as PermissionAuthorizationRequestCodec } from "../generated/host-callbacks.js"; +import { createWasmRawCallbacks } from "../generated/host-callbacks-adapter.js"; +import type { RawCallbacks } from "../generated/host-callbacks-adapter.js"; +import type { + CallbackName, + MainToWorker, + SubscriptionName, + WorkerToMain, +} from "../worker-protocol.js"; +import { bytesToHex } from "@parity/truapi/scale"; +import { startRawSubscription } from "../generated/worker-callbacks.js"; +import { errorMessage } from "../error.js"; + +interface WorkerProviderState { + worker: Worker; + rawCallbacks: RawCallbacks; + listeners: Set<(message: Uint8Array) => void>; + closeListeners: Set<(error: Error) => void>; + subscriptionDisposers: Map void>; + chainConnections: Map; + pendingDisconnects: Map< + number, + { resolve: () => void; reject: (error: Error) => void } + >; + pendingPermissionAuthorizationStatuses: Map< + number, + { + resolve: (status: PermissionAuthorizationStatus) => void; + reject: (error: Error) => void; + } + >; + pendingPermissionAuthorizationStatusBatches: Map< + number, + { + resolve: (statuses: PermissionAuthorizationStatus[]) => void; + reject: (error: Error) => void; + } + >; + pendingSetPermissionAuthorizationStatuses: Map< + number, + { resolve: () => void; reject: (error: Error) => void } + >; + closedError: Error | null; + logLevel: LogLevel; + disposed: boolean; +} + +function debugLoggingEnabled(state: WorkerProviderState): boolean { + return state.logLevel === "debug" || state.logLevel === "trace"; +} + +let nextDisconnectRequestId = 0; +let nextPermissionAuthorizationRequestId = 0; + +function encodePermissionAuthorizationRequest( + request: PermissionAuthorizationRequest, +): Uint8Array { + return PermissionAuthorizationRequestCodec.enc(request); +} + +/** localStorage key the dev log level is persisted under, so it survives reloads. */ +const DEV_LOG_LEVEL_KEY = "truapi:logLevel"; + +/** Read the persisted dev log level. Returns null when unset. */ +function readPersistedLogLevel(): LogLevel | null { + return globalThis.localStorage?.getItem(DEV_LOG_LEVEL_KEY) ?? null; +} + +/** Persist the dev log level so it re-applies on the next reload. */ +function persistLogLevel(level: LogLevel): void { + globalThis.localStorage?.setItem(DEV_LOG_LEVEL_KEY, level); +} + +let devLogLevelOverride: LogLevel | null = readPersistedLogLevel(); +const devGlobalProviders = new Set(); +interface TrUApiDevConsole { + setLogLevel(level: LogLevel): void; + getLogLevel(): LogLevel | null; +} + +function handleCallbackRequest( + state: WorkerProviderState, + msg: { + requestId: number; + name: CallbackName; + args: readonly unknown[]; + }, +): void { + // Own-property guard: `msg.name` is worker-supplied, never walk the + // prototype chain with it. + const fn = Object.hasOwn(state.rawCallbacks, msg.name) + ? ( + state.rawCallbacks as unknown as Record< + string, + (...args: readonly unknown[]) => unknown + > + )[msg.name] + : undefined; + if (!fn) { + const reply: MainToWorker = { + kind: "callbackResponse", + requestId: msg.requestId, + ok: false, + error: `unknown callback: ${msg.name}`, + }; + state.worker.postMessage(reply); + return; + } + Promise.resolve() + .then(() => fn(...msg.args)) + .then( + (value) => { + const reply: MainToWorker = { + kind: "callbackResponse", + requestId: msg.requestId, + ok: true, + value, + }; + state.worker.postMessage(reply); + }, + (err) => { + const reply: MainToWorker = { + kind: "callbackResponse", + requestId: msg.requestId, + ok: false, + error: errorMessage(err), + }; + state.worker.postMessage(reply); + }, + ); +} + +function handleSubscriptionStart( + state: WorkerProviderState, + msg: { + subId: number; + name: SubscriptionName; + payload: Uint8Array | null; + }, +): void { + const sendItem = (value?: unknown): void => { + if (state.disposed) return; + const post: MainToWorker = { + kind: "subscriptionItem", + subId: msg.subId, + value, + }; + state.worker.postMessage(post); + }; + let dispose: (() => void) | void = undefined; + try { + dispose = startRawSubscription( + state.rawCallbacks, + msg.name, + msg.payload, + sendItem, + ); + } catch (err) { + console.error(`[truapi worker] ${msg.name} threw on start:`, err); + return; + } + if (typeof dispose === "function") { + state.subscriptionDisposers.set(msg.subId, dispose); + } +} + +function handleSubscriptionStop( + state: WorkerProviderState, + msg: { subId: number }, +): void { + const dispose = state.subscriptionDisposers.get(msg.subId); + if (!dispose) return; + state.subscriptionDisposers.delete(msg.subId); + try { + dispose(); + } catch (err) { + console.warn("[truapi worker] subscription dispose threw:", err); + } +} + +async function handleChainConnectStart( + state: WorkerProviderState, + msg: { connId: number; genesisHash: string }, +): Promise { + const chainConnect = state.rawCallbacks.chainConnect; + const onResponse = (json: string): void => { + if (state.disposed) return; + const post: MainToWorker = { + kind: "chainResponse", + connId: msg.connId, + json, + }; + state.worker.postMessage(post); + }; + try { + const conn = await chainConnect(msg.genesisHash, onResponse); + if (!conn) { + const reply: MainToWorker = { + kind: "chainConnectAck", + connId: msg.connId, + ok: false, + error: `chainConnect returned null for genesisHash ${msg.genesisHash}`, + }; + state.worker.postMessage(reply); + return; + } + state.chainConnections.set(msg.connId, conn); + const reply: MainToWorker = { + kind: "chainConnectAck", + connId: msg.connId, + ok: true, + }; + state.worker.postMessage(reply); + } catch (err) { + const reply: MainToWorker = { + kind: "chainConnectAck", + connId: msg.connId, + ok: false, + error: errorMessage(err), + }; + state.worker.postMessage(reply); + } +} + +function handleChainSend( + state: WorkerProviderState, + msg: { connId: number; request: string }, +): void { + const conn = state.chainConnections.get(msg.connId); + if (!conn) return; + try { + if (debugLoggingEnabled(state)) { + console.debug("[truapi worker] chainSend", msg.connId, msg.request); + } + conn.send(msg.request); + } catch (err) { + console.warn("[truapi worker] chain send threw:", err); + } +} + +function handleChainClose( + state: WorkerProviderState, + msg: { connId: number }, +): void { + const conn = state.chainConnections.get(msg.connId); + if (!conn) return; + state.chainConnections.delete(msg.connId); + try { + conn.close(); + } catch (err) { + console.warn("[truapi worker] chain close threw:", err); + } +} + +interface PendingEntry { + resolve: (value: T) => void; + reject: (error: Error) => void; +} + +/** Settle and remove the pending entry for `requestId`, no-op if already gone. */ +function settlePending( + map: Map>, + requestId: number, + result: { ok: true; value: T } | { ok: false; error: string }, +): void { + const pending = map.get(requestId); + if (!pending) return; + map.delete(requestId); + if (result.ok) { + pending.resolve(result.value); + } else { + pending.reject(new Error(result.error)); + } +} + +/** Reject every pending entry in `map` with `error`, then drain the map. */ +function rejectAll(map: Map>, error: Error): void { + for (const pending of map.values()) { + pending.reject(error); + } + map.clear(); +} + +function handleDisconnectResponse( + state: WorkerProviderState, + msg: + | { requestId: number; ok: true } + | { requestId: number; ok: false; error: string }, +): void { + settlePending( + state.pendingDisconnects, + msg.requestId, + msg.ok ? { ok: true, value: undefined } : { ok: false, error: msg.error }, + ); +} + +function handlePermissionAuthorizationStatusResponse( + state: WorkerProviderState, + msg: + | { + requestId: number; + ok: true; + status: PermissionAuthorizationStatus; + } + | { requestId: number; ok: false; error: string }, +): void { + settlePending( + state.pendingPermissionAuthorizationStatuses, + msg.requestId, + msg.ok ? { ok: true, value: msg.status } : { ok: false, error: msg.error }, + ); +} + +function handlePermissionAuthorizationStatusesResponse( + state: WorkerProviderState, + msg: + | { + requestId: number; + ok: true; + statuses: PermissionAuthorizationStatus[]; + } + | { requestId: number; ok: false; error: string }, +): void { + settlePending( + state.pendingPermissionAuthorizationStatusBatches, + msg.requestId, + msg.ok + ? { ok: true, value: msg.statuses } + : { ok: false, error: msg.error }, + ); +} + +function handleSetPermissionAuthorizationStatusResponse( + state: WorkerProviderState, + msg: + | { requestId: number; ok: true } + | { requestId: number; ok: false; error: string }, +): void { + settlePending( + state.pendingSetPermissionAuthorizationStatuses, + msg.requestId, + msg.ok ? { ok: true, value: undefined } : { ok: false, error: msg.error }, + ); +} + +function rejectPendingDisconnects( + state: WorkerProviderState, + error: Error, +): void { + rejectAll(state.pendingDisconnects, error); +} + +function rejectPendingPermissionAuthorizationRequests( + state: WorkerProviderState, + error: Error, +): void { + rejectAll(state.pendingPermissionAuthorizationStatuses, error); + rejectAll(state.pendingPermissionAuthorizationStatusBatches, error); + rejectAll(state.pendingSetPermissionAuthorizationStatuses, error); +} + +/** + * Register a pending request, post its message to the worker, and resolve when + * the matching response arrives. Short-circuits to `disposedFallback` once the + * provider is disposed; rolls back the pending entry if `postMessage` throws. + */ +function sendWorkerRequest( + state: WorkerProviderState, + pending: Map>, + nextId: () => number, + disposedFallback: T, + buildMessage: (requestId: number) => MainToWorker, +): Promise { + if (state.disposed) return Promise.resolve(disposedFallback); + return new Promise((resolve, reject) => { + const requestId = nextId(); + pending.set(requestId, { resolve, reject }); + try { + state.worker.postMessage(buildMessage(requestId)); + } catch (err) { + pending.delete(requestId); + reject(err instanceof Error ? err : new Error(String(err))); + } + }); +} + +/** + * Shared terminal teardown for both `dispose()` and worker faults: rejects + * pending disconnects, runs subscription disposers, closes chain connections, + * and terminates the worker. A fault additionally notifies close listeners. + */ +function teardown( + state: WorkerProviderState, + error: Error, + fault: boolean, +): void { + if (state.disposed) return; + state.disposed = true; + state.closedError = error; + rejectPendingDisconnects(state, error); + rejectPendingPermissionAuthorizationRequests(state, error); + for (const fn of state.subscriptionDisposers.values()) { + try { + fn(); + } catch { + // ignore during teardown + } + } + state.subscriptionDisposers.clear(); + for (const conn of state.chainConnections.values()) { + try { + conn.close(); + } catch { + // ignore during teardown + } + } + state.chainConnections.clear(); + if (fault) { + state.worker.terminate(); + } else { + try { + const post: MainToWorker = { kind: "dispose" }; + state.worker.postMessage(post); + } catch { + // ignore if worker already gone + } + // Give the worker a tick to free the core before terminating. + setTimeout(() => state.worker.terminate(), 0); + } + for (const listener of [...state.closeListeners]) listener(error); + state.listeners.clear(); + state.closeListeners.clear(); +} + +export interface CreateWebWorkerProviderOptions { + /** Wasm core log level. Default: `"off"`. */ + logLevel?: LogLevel; + /** Static product/pairing config passed to the Rust core. */ + runtimeConfig: HostCoreRuntimeConfig; + /** + * Milliseconds to wait for the worker to report `ready` before rejecting + * and terminating it. Default: 30000. + */ + initTimeoutMs?: number; +} + +export type WebWorkerHostCallbacks = Required; + +/** + * Spawn the truapi-server WASM in `worker` and bridge it into a + * `WireProvider`. + * + * The caller is responsible for instantiating the Worker, Vite users + * typically import the worker entry-point with `?worker`: + * + * ```ts + * import HostWorker from "@parity/truapi-host-wasm/worker-runtime?worker"; + * const worker = new HostWorker(); + * const provider = await createWebWorkerProvider(worker, callbacks, { + * runtimeConfig, + * }); + * ``` + * + * Resolves once the worker reports `ready` and rejects if the WASM + * fails to load. + */ +export function createWebWorkerProvider( + worker: Worker, + host: WebWorkerHostCallbacks, + options: CreateWebWorkerProviderOptions, +): Promise { + const callbacks = createWasmRawCallbacks(host); + + return new Promise((resolve, reject) => { + const state: WorkerProviderState = { + worker, + rawCallbacks: callbacks, + listeners: new Set(), + closeListeners: new Set(), + subscriptionDisposers: new Map(), + chainConnections: new Map(), + pendingDisconnects: new Map(), + pendingPermissionAuthorizationStatuses: new Map(), + pendingPermissionAuthorizationStatusBatches: new Map(), + pendingSetPermissionAuthorizationStatuses: new Map(), + closedError: null, + logLevel: devLogLevelOverride ?? options.logLevel ?? "off", + disposed: false, + }; + + const onMessage = (ev: MessageEvent): void => { + const msg = ev.data; + switch (msg.kind) { + case "loaded": + break; + case "ready": + break; + case "fatalError": + console.error("[truapi worker]", msg.error); + notifyFault(new Error(`worker fatal error: ${msg.error}`)); + break; + case "frameError": + console.error("[truapi worker]", msg.error); + notifyFault(new Error(`worker frame error: ${msg.error}`)); + break; + case "disposeError": + console.warn("[truapi worker] dispose:", msg.error); + break; + case "frame": + if (debugLoggingEnabled(state)) { + console.debug("[truapi worker] frame <-", bytesToHex(msg.bytes)); + } + for (const listener of [...state.listeners]) listener(msg.bytes); + break; + case "disconnectSessionResponse": + handleDisconnectResponse(state, msg); + break; + case "permissionAuthorizationStatusResponse": + handlePermissionAuthorizationStatusResponse(state, msg); + break; + case "permissionAuthorizationStatusesResponse": + handlePermissionAuthorizationStatusesResponse(state, msg); + break; + case "setPermissionAuthorizationStatusResponse": + handleSetPermissionAuthorizationStatusResponse(state, msg); + break; + case "callbackRequest": + if (debugLoggingEnabled(state)) { + console.debug("[truapi worker] callbackRequest", msg.name); + } + handleCallbackRequest(state, msg); + break; + case "subscriptionStart": + handleSubscriptionStart(state, msg); + break; + case "subscriptionStop": + handleSubscriptionStop(state, msg); + break; + case "chainConnectStart": + if (debugLoggingEnabled(state)) { + console.debug("[truapi worker] chainConnectStart", msg.connId); + } + void handleChainConnectStart(state, msg); + break; + case "chainSend": + handleChainSend(state, msg); + break; + case "chainClose": + handleChainClose(state, msg); + break; + default: { + const { kind } = msg as { kind?: unknown }; + console.warn( + `[truapi worker] unknown worker message kind: ${String(kind)}`, + ); + } + } + }; + + const notifyFault = (error: Error): void => { + teardown(state, error, true); + }; + + const onError = (e: ErrorEvent): void => { + cleanupInit(); + worker.terminate(); + reject(new Error(`worker init failed: ${e.message}`)); + }; + + const onInitMessageError = (): void => { + cleanupInit(); + worker.terminate(); + reject(new Error("worker message could not be deserialized during init")); + }; + + const onRuntimeError = (e: ErrorEvent): void => { + console.error("[truapi worker]", e.message); + notifyFault(new Error(`worker error: ${e.message}`)); + }; + + const onMessageError = (): void => { + notifyFault(new Error("worker message could not be deserialized")); + }; + + const onInitMessage = (ev: MessageEvent): void => { + const msg = ev.data; + if (msg.kind === "loaded") { + const init: MainToWorker = { + kind: "init", + logLevel: devLogLevelOverride ?? options.logLevel ?? "off", + runtimeConfig: options.runtimeConfig, + }; + worker.postMessage(init); + } else if (msg.kind === "ready") { + cleanupInit(); + worker.addEventListener("message", onMessage); + // Surface a post-init worker fault (uncaught throw, OOM, killed + // worker) to close listeners for the provider's lifetime. + worker.addEventListener("error", onRuntimeError); + worker.addEventListener("messageerror", onMessageError); + const provider = buildProvider(state); + exposeDevGlobal(provider); + resolve(provider); + } else if (msg.kind === "fatalError") { + cleanupInit(); + worker.terminate(); + reject(new Error(`worker init reported error: ${msg.error}`)); + } + }; + + const cleanupInit = (): void => { + clearTimeout(initTimeout); + worker.removeEventListener("error", onError); + worker.removeEventListener("messageerror", onInitMessageError); + worker.removeEventListener("message", onInitMessage); + }; + + const timeoutMs = options.initTimeoutMs ?? 30_000; + const initTimeout = setTimeout(() => { + cleanupInit(); + worker.terminate(); + reject(new Error(`worker init timed out after ${timeoutMs}ms`)); + }, timeoutMs); + + worker.addEventListener("error", onError); + worker.addEventListener("messageerror", onInitMessageError); + worker.addEventListener("message", onInitMessage); + }); +} + +function buildProvider(state: WorkerProviderState): TrUApiHostCoreProvider { + const provider: TrUApiHostCoreProvider = { + postMessage(bytes: Uint8Array): void { + if (state.disposed) return; + const post: MainToWorker = { kind: "frame", bytes }; + if (debugLoggingEnabled(state)) { + console.debug("[truapi worker] frame ->", bytesToHex(bytes)); + } + state.worker.postMessage(post); + }, + subscribe(callback) { + state.listeners.add(callback); + return () => { + state.listeners.delete(callback); + }; + }, + subscribeClose(callback) { + if (state.closedError) { + callback(state.closedError); + return () => {}; + } + state.closeListeners.add(callback); + return () => { + state.closeListeners.delete(callback); + }; + }, + disconnectSession(): Promise { + return sendWorkerRequest( + state, + state.pendingDisconnects, + () => ++nextDisconnectRequestId, + undefined, + (requestId) => ({ kind: "disconnectSession", requestId }), + ); + }, + cancelPairing(): void { + if (state.disposed) return; + const post: MainToWorker = { kind: "cancelPairing" }; + state.worker.postMessage(post); + }, + notifySessionStoreChanged(): void { + if (state.disposed) return; + const post: MainToWorker = { kind: "notifySessionStoreChanged" }; + state.worker.postMessage(post); + }, + getPermissionAuthorizationStatus( + request: PermissionAuthorizationRequest, + ): Promise { + return sendWorkerRequest( + state, + state.pendingPermissionAuthorizationStatuses, + () => ++nextPermissionAuthorizationRequestId, + "NotDetermined", + (requestId) => ({ + kind: "getPermissionAuthorizationStatus", + requestId, + request: encodePermissionAuthorizationRequest(request), + }), + ); + }, + getPermissionAuthorizationStatuses( + requests: PermissionAuthorizationRequest[], + ): Promise { + return sendWorkerRequest( + state, + state.pendingPermissionAuthorizationStatusBatches, + () => ++nextPermissionAuthorizationRequestId, + requests.map(() => "NotDetermined"), + (requestId) => ({ + kind: "getPermissionAuthorizationStatuses", + requestId, + requests: requests.map(encodePermissionAuthorizationRequest), + }), + ); + }, + setPermissionAuthorizationStatus( + request: PermissionAuthorizationRequest, + status: PermissionAuthorizationStatus, + ): Promise { + return sendWorkerRequest( + state, + state.pendingSetPermissionAuthorizationStatuses, + () => ++nextPermissionAuthorizationRequestId, + undefined, + (requestId) => ({ + kind: "setPermissionAuthorizationStatus", + requestId, + request: encodePermissionAuthorizationRequest(request), + status, + }), + ); + }, + setLogLevel(level: LogLevel): void { + if (state.disposed) return; + state.logLevel = level; + const post: MainToWorker = { kind: "setLogLevel", level }; + state.worker.postMessage(post); + }, + dispose() { + devGlobalProviders.delete(provider); + teardown(state, new Error("provider disposed"), false); + }, + }; + return provider; +} + +/** + * Publish `globalThis.__truapi.setLogLevel(level)` so a developer can re-tune + * the wasm core's verbosity live from the browser console without a reload. The + * level is persisted to `localStorage["truapi:logLevel"]` and re-applied on the + * next load, so it survives refreshes. Pair with the DevTools console "Verbose" + * level to surface debug/trace. + */ +function exposeDevGlobal(provider: TrUApiHostCoreProvider): void { + devGlobalProviders.add(provider); + if (devLogLevelOverride !== null) { + provider.setLogLevel?.(devLogLevelOverride); + } + publishDevGlobal(); +} + +function publishDevGlobal(): void { + const target = globalThis as { + __truapi?: TrUApiDevConsole; + }; + target.__truapi = { + setLogLevel(level: LogLevel): void { + devLogLevelOverride = level; + persistLogLevel(level); + for (const provider of [...devGlobalProviders]) { + provider.setLogLevel?.(level); + } + console.info(`[truapi worker] logLevel=${level}`); + }, + getLogLevel(): LogLevel | null { + return devLogLevelOverride; + }, + }; +} + +publishDevGlobal(); diff --git a/js/packages/truapi-host-wasm/src/web/index.ts b/js/packages/truapi-host-wasm/src/web/index.ts new file mode 100644 index 00000000..430c7098 --- /dev/null +++ b/js/packages/truapi-host-wasm/src/web/index.ts @@ -0,0 +1,7 @@ +export type { IframeHost, IframeHostOptions } from "./create-iframe-host.js"; +export { createIframeHost } from "./create-iframe-host.js"; +export type { + CreateWebWorkerProviderOptions, + WebWorkerHostCallbacks, +} from "./create-worker-host-runtime.js"; +export { createWebWorkerProvider } from "./create-worker-host-runtime.js"; diff --git a/js/packages/truapi-host-wasm/src/web/worker-provider.test.ts b/js/packages/truapi-host-wasm/src/web/worker-provider.test.ts new file mode 100644 index 00000000..a7f98d9c --- /dev/null +++ b/js/packages/truapi-host-wasm/src/web/worker-provider.test.ts @@ -0,0 +1,658 @@ +import { describe, expect, it } from "bun:test"; +import { ok } from "neverthrow"; + +import { HostPushNotificationRequest, HostPushNotificationResponse } from "@parity/truapi"; +import type { GenericError, Result, ThemeVariant } from "@parity/truapi"; + +import { createWasmRawCallbacks } from "../generated/host-callbacks-adapter.js"; +import { CoreStorageKey } from "../generated/host-callbacks.js"; +import type { AuthState, HostCallbacks } from "../generated/host-callbacks.js"; +import type { HostCoreRuntimeConfig } from "../runtime.js"; +import { makeHostCallbacks, settle } from "../test-support.js"; +import { createWebWorkerProvider } from "./index.js"; +import type { CreateWebWorkerProviderOptions } from "./index.js"; + +type WorkerMessage = Record; + +/** Minimal `Worker` stand-in that records posted messages and lets a test + * drive the `message`/`error`/`messageerror` events by hand. */ +class FakeWorker { + listeners = new Map void>>(); + messages: WorkerMessage[] = []; + terminated = false; + + addEventListener(name: string, fn: (event: unknown) => void) { + const listeners = this.listeners.get(name) ?? new Set(); + listeners.add(fn); + this.listeners.set(name, listeners); + } + + removeEventListener(name: string, fn: (event: unknown) => void) { + this.listeners.get(name)?.delete(fn); + } + + postMessage(message: WorkerMessage) { + this.messages.push(message); + } + + terminate() { + this.terminated = true; + } + + emit(message: WorkerMessage) { + for (const listener of this.listeners.get("message") ?? []) { + listener({ data: message }); + } + } + + emitError(message: string) { + for (const listener of this.listeners.get("error") ?? []) { + listener({ message }); + } + } + + emitMessageError() { + for (const listener of this.listeners.get("messageerror") ?? []) { + listener({ data: null }); + } + } +} + +/** Coerce the `FakeWorker` to the `Worker` shape the provider expects. */ +function asWorker(worker: FakeWorker): Worker { + return worker as unknown as Worker; +} + +function runtimeConfig(overrides: Partial = {}): HostCoreRuntimeConfig { + return { + productId: "dotli.dot", + host: { + name: "Polkadot Web", + icon: "https://dot.li/dotli.png", + version: "0.5.0", + }, + platform: { + type: "node", + version: process.versions.node, + }, + people: { + genesisHash: "0xa22a2424d2cbf561eaecf7da8b1b548fa9d1939f60265e942b1049616a012f71", + }, + pairing: { + deeplinkScheme: "polkadotapp", + }, + ...overrides, + }; +} + +type ReadyOptions = Partial & { + createWebWorkerProvider?: typeof createWebWorkerProvider; +}; + +async function readyProvider(worker: FakeWorker, options: ReadyOptions = {}) { + const { + createWebWorkerProvider: createProvider = createWebWorkerProvider, + runtimeConfig: cfg = runtimeConfig(), + ...rest + } = options; + const providerPromise = createProvider(asWorker(worker), makeHostCallbacks(), { + runtimeConfig: cfg, + ...rest, + }); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + return providerPromise; +} + +/** Typed view of the dev console the worker runtime publishes on `globalThis`. */ +type TruapiDevConsole = { + setLogLevel(level: string): void; + getLogLevel(): string | null; +}; +const devGlobal = globalThis as typeof globalThis & { + __truapi?: TruapiDevConsole; +}; + +describe("createWebWorkerProvider", () => { + it("initializes the worker without a callback manifest", async () => { + const worker = new FakeWorker(); + const config = runtimeConfig(); + const providerPromise = createWebWorkerProvider(asWorker(worker), makeHostCallbacks(), { + logLevel: "debug", + runtimeConfig: config, + }); + + worker.emit({ kind: "loaded" }); + expect(worker.messages.length).toBe(1); + expect(worker.messages[0]).toEqual({ + kind: "init", + logLevel: "debug", + runtimeConfig: config, + }); + + worker.emit({ kind: "ready" }); + const provider = await providerPromise; + expect(typeof provider.disconnectSession).toBe("function"); + expect(typeof provider.cancelPairing).toBe("function"); + expect(typeof provider.notifySessionStoreChanged).toBe("function"); + + provider.dispose(); + }); + + it("dev global setLogLevel updates every live worker provider", async () => { + const previous = devGlobal.__truapi; + delete devGlobal.__truapi; + const firstWorker = new FakeWorker(); + const secondWorker = new FakeWorker(); + const first = await readyProvider(firstWorker); + const second = await readyProvider(secondWorker); + + devGlobal.__truapi!.setLogLevel("debug"); + + expect(firstWorker.messages.at(-1)).toEqual({ + kind: "setLogLevel", + level: "debug", + }); + expect(secondWorker.messages.at(-1)).toEqual({ + kind: "setLogLevel", + level: "debug", + }); + expect(devGlobal.__truapi!.getLogLevel()).toBe("debug"); + + devGlobal.__truapi!.setLogLevel("off"); + first.dispose(); + second.dispose(); + if (previous === undefined) { + delete devGlobal.__truapi; + } else { + devGlobal.__truapi = previous; + } + }); + + it("dev global setLogLevel applies to providers created later", async () => { + const previous = devGlobal.__truapi; + delete devGlobal.__truapi; + const moduleUrl = `./create-worker-host-runtime.js?dev-global-${Date.now()}`; + const { createWebWorkerProvider: freshCreateWebWorkerProvider } = (await import( + moduleUrl + )) as typeof import("./create-worker-host-runtime.js"); + + expect(typeof devGlobal.__truapi!.setLogLevel).toBe("function"); + devGlobal.__truapi!.setLogLevel("trace"); + + const firstWorker = new FakeWorker(); + const first = await readyProvider(firstWorker, { + createWebWorkerProvider: freshCreateWebWorkerProvider, + }); + first.dispose(); + + const secondWorker = new FakeWorker(); + const second = await readyProvider(secondWorker, { + createWebWorkerProvider: freshCreateWebWorkerProvider, + }); + + expect(secondWorker.messages[0].kind).toBe("init"); + expect(secondWorker.messages[0].logLevel).toBe("trace"); + expect(secondWorker.messages.at(-1)).toEqual({ + kind: "setLogLevel", + level: "trace", + }); + + second.dispose(); + devGlobal.__truapi!.setLogLevel("off"); + if (previous === undefined) { + delete devGlobal.__truapi; + } else { + devGlobal.__truapi = previous; + } + }); + + it("dev global setLogLevel persists the level to localStorage", async () => { + const previousGlobal = devGlobal.__truapi; + const previousStorage = globalThis.localStorage; + delete devGlobal.__truapi; + const store = new Map(); + globalThis.localStorage = { + getItem: (key: string) => (store.has(key) ? store.get(key)! : null), + setItem: (key: string, value: string) => store.set(key, String(value)), + } as unknown as Storage; + + const worker = new FakeWorker(); + const provider = await readyProvider(worker); + + devGlobal.__truapi!.setLogLevel("debug"); + expect(store.get("truapi:logLevel")).toBe("debug"); + + devGlobal.__truapi!.setLogLevel("off"); + expect(store.get("truapi:logLevel")).toBe("off"); + + provider.dispose(); + globalThis.localStorage = previousStorage; + if (previousGlobal === undefined) { + delete devGlobal.__truapi; + } else { + devGlobal.__truapi = previousGlobal; + } + }); + + it("resolves disconnect responses", async () => { + const worker = new FakeWorker(); + const providerPromise = createWebWorkerProvider(asWorker(worker), makeHostCallbacks(), { + runtimeConfig: runtimeConfig(), + }); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const provider = await providerPromise; + + const disconnect = provider.disconnectSession(); + const msg = worker.messages.at(-1)!; + expect(msg.kind).toBe("disconnectSession"); + expect(typeof msg.requestId).toBe("number"); + + worker.emit({ + kind: "disconnectSessionResponse", + requestId: msg.requestId, + ok: true, + }); + await disconnect; + + provider.dispose(); + }); + + it("dispatches callback requests to host hooks", async () => { + const worker = new FakeWorker(); + let clears = 0; + const authSessionKey = CoreStorageKey.enc({ tag: "AuthSession" }); + const providerPromise = createWebWorkerProvider( + asWorker(worker), + makeHostCallbacks({ + clearCoreStorage: async (key) => { + expect(key).toEqual({ tag: "AuthSession", value: undefined }); + clears += 1; + }, + }), + { runtimeConfig: runtimeConfig() }, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const provider = await providerPromise; + + worker.emit({ + kind: "callbackRequest", + requestId: 7, + name: "clearCoreStorage", + args: [authSessionKey], + }); + await settle(); + + expect(clears).toBe(1); + expect(worker.messages.at(-1)).toEqual({ + kind: "callbackResponse", + requestId: 7, + ok: true, + value: undefined, + }); + + provider.dispose(); + }); + + it("reports unknown callback requests", async () => { + const worker = new FakeWorker(); + const providerPromise = createWebWorkerProvider(asWorker(worker), makeHostCallbacks(), { + runtimeConfig: runtimeConfig(), + }); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const provider = await providerPromise; + + worker.emit({ + kind: "callbackRequest", + requestId: 11, + name: "someFutureCallback", + args: [new Uint8Array([1, 2, 3])], + }); + await settle(); + + expect(worker.messages.at(-1)).toEqual({ + kind: "callbackResponse", + requestId: 11, + ok: false, + error: "unknown callback: someFutureCallback", + }); + + provider.dispose(); + }); + + it("forwards authStateChanged callback requests", async () => { + const worker = new FakeWorker(); + const states: AuthState[] = []; + const providerPromise = createWebWorkerProvider( + asWorker(worker), + makeHostCallbacks({ + authStateChanged: (state) => { + states.push(state); + }, + }), + { runtimeConfig: runtimeConfig() }, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const provider = await providerPromise; + + worker.emit({ + kind: "callbackRequest", + requestId: 3, + name: "authStateChanged", + args: [ + { + tag: "Connected", + value: { + publicKey: new Uint8Array([1, 2]), + liteUsername: "alice", + }, + }, + ], + }); + await settle(); + + expect(states).toEqual([ + { + tag: "Connected", + value: { + publicKey: new Uint8Array([1, 2]), + liteUsername: "alice", + }, + }, + ]); + expect(worker.messages.at(-1)).toEqual({ + kind: "callbackResponse", + requestId: 3, + ok: true, + value: undefined, + }); + + provider.dispose(); + }); + + it("posts cancelPairing to the worker", async () => { + const worker = new FakeWorker(); + const provider = await readyProvider(worker); + + provider.cancelPairing(); + + expect(worker.messages.at(-1)).toEqual({ kind: "cancelPairing" }); + provider.dispose(); + }); + + it("posts notifySessionStoreChanged to the worker", async () => { + const worker = new FakeWorker(); + const provider = await readyProvider(worker); + + provider.notifySessionStoreChanged(); + + expect(worker.messages.at(-1)).toEqual({ + kind: "notifySessionStoreChanged", + }); + provider.dispose(); + }); + + it("worker fault terminates the worker and runs the full teardown", async () => { + const worker = new FakeWorker(); + let subscriptionDisposes = 0; + let chainResponseStops = 0; + let chainCloses = 0; + const providerPromise = createWebWorkerProvider( + asWorker(worker), + makeHostCallbacks({ + // Manual async iterables whose `return()` records disposal; the + // provider disposes subscriptions and closes chain connections + // on a worker fault. + subscribeTheme: () => + ({ + [Symbol.asyncIterator]() { + return this; + }, + next: () => new Promise(() => {}), + return: async () => { + subscriptionDisposes += 1; + return { done: true, value: undefined }; + }, + }) as unknown as AsyncIterable>, + connect: async () => ({ + send() {}, + responses: () => + ({ + [Symbol.asyncIterator]() { + return this; + }, + next: () => new Promise(() => {}), + return: async () => { + chainResponseStops += 1; + return { done: true, value: undefined }; + }, + }) as unknown as AsyncIterable, + close() { + chainCloses += 1; + }, + }), + }), + { runtimeConfig: runtimeConfig() }, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const provider = await providerPromise; + + worker.emit({ + kind: "subscriptionStart", + subId: 1, + name: "subscribeTheme", + payload: null, + }); + worker.emit({ + kind: "chainConnectStart", + connId: 1, + genesisHash: "0xab", + }); + await settle(); + await settle(); + + const closes: Error[] = []; + provider.subscribeClose!((error) => closes.push(error)); + + worker.emitError("boom"); + await settle(); + await settle(); + + expect(worker.terminated).toBe(true); + expect(subscriptionDisposes).toBe(1); + expect(chainResponseStops).toBe(1); + expect(chainCloses).toBe(1); + expect(closes.length).toBe(1); + expect(closes[0].message).toMatch(/boom/); + + // The fault teardown is terminal; a second fault is a no-op. + worker.emitError("again"); + expect(closes.length).toBe(1); + + let lateClose: Error | null = null; + provider.subscribeClose!((error) => { + lateClose = error; + }); + expect(lateClose).toBeInstanceOf(Error); + expect(lateClose!.message).toMatch(/boom/); + }); + + it("worker fatalError during init rejects provider creation", async () => { + const worker = new FakeWorker(); + const providerPromise = createWebWorkerProvider(asWorker(worker), makeHostCallbacks(), { + runtimeConfig: runtimeConfig(), + }); + + worker.emit({ kind: "fatalError", error: "bad wasm" }); + + await expect(providerPromise).rejects.toThrow(/worker init reported error: bad wasm/); + expect(worker.terminated).toBe(true); + }); + + it("worker frameError after init closes the provider", async () => { + const worker = new FakeWorker(); + const provider = await readyProvider(worker); + const closes: Error[] = []; + provider.subscribeClose!((error) => closes.push(error)); + + worker.emit({ kind: "frameError", error: "bad frame" }); + + expect(worker.terminated).toBe(true); + expect(closes.length).toBe(1); + expect(closes[0].message).toMatch(/worker frame error: bad frame/); + + let lateClose: Error | null = null; + provider.subscribeClose!((error) => { + lateClose = error; + }); + expect(lateClose).toBeInstanceOf(Error); + }); + + it("routes payload-carrying subscriptions by name", async () => { + const worker = new FakeWorker(); + const keys: Uint8Array[] = []; + const providerPromise = createWebWorkerProvider( + asWorker(worker), + makeHostCallbacks({ + lookupPreimage: async function* (key) { + keys.push(key); + yield ok(new Uint8Array([1])); + }, + }), + { runtimeConfig: runtimeConfig() }, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const provider = await providerPromise; + + worker.emit({ + kind: "subscriptionStart", + subId: 4, + name: "lookupPreimage", + payload: new Uint8Array([9, 9]), + }); + + await settle(); + await settle(); + expect(keys).toEqual([new Uint8Array([9, 9])]); + expect(worker.messages.at(-1)).toEqual({ + kind: "subscriptionItem", + subId: 4, + value: new Uint8Array([1]), + }); + + provider.dispose(); + }); + + it("never falls through unknown subscription names to another callback", async () => { + const worker = new FakeWorker(); + let preimageStarts = 0; + const providerPromise = createWebWorkerProvider( + asWorker(worker), + makeHostCallbacks({ + lookupPreimage: (() => { + preimageStarts += 1; + return () => {}; + }) as unknown as HostCallbacks["lookupPreimage"], + }), + { runtimeConfig: runtimeConfig() }, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const provider = await providerPromise; + + worker.emit({ + kind: "subscriptionStart", + subId: 5, + name: "someFutureSubscribe", + payload: new Uint8Array([1, 2, 3]), + }); + + expect(preimageStarts).toBe(0); + expect(worker.messages.some((m) => m.kind === "subscriptionItem")).toBe(false); + + provider.dispose(); + }); + + it("does not dispatch a payload-carrying subscription without payload", async () => { + const worker = new FakeWorker(); + let preimageStarts = 0; + const providerPromise = createWebWorkerProvider( + asWorker(worker), + makeHostCallbacks({ + lookupPreimage: (() => { + preimageStarts += 1; + return () => {}; + }) as unknown as HostCallbacks["lookupPreimage"], + }), + { runtimeConfig: runtimeConfig() }, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const provider = await providerPromise; + + worker.emit({ + kind: "subscriptionStart", + subId: 6, + name: "lookupPreimage", + payload: null, + }); + + expect(preimageStarts).toBe(0); + + provider.dispose(); + }); + + it("rejects when init times out", async () => { + const worker = new FakeWorker(); + const providerPromise = createWebWorkerProvider(asWorker(worker), makeHostCallbacks(), { + runtimeConfig: runtimeConfig(), + initTimeoutMs: 20, + }); + worker.emit({ kind: "loaded" }); + await expect(providerPromise).rejects.toThrow(/worker init timed out after 20ms/); + expect(worker.terminated).toBe(true); + }); + + it("rejects on messageerror during init", async () => { + const worker = new FakeWorker(); + const providerPromise = createWebWorkerProvider(asWorker(worker), makeHostCallbacks(), { + runtimeConfig: runtimeConfig(), + }); + worker.emitMessageError(); + await expect(providerPromise).rejects.toThrow(/could not be deserialized/); + expect(worker.terminated).toBe(true); + }); + + it("decodes raw v01 push notification payloads", async () => { + let notification: HostPushNotificationRequest | undefined; + const callbacks = createWasmRawCallbacks( + makeHostCallbacks({ + pushNotification: async (request) => { + notification = request; + return { id: 42 }; + }, + }), + ); + + const encoded = await callbacks.pushNotification!( + HostPushNotificationRequest.enc({ + text: "Hello!", + deeplink: undefined, + scheduledAt: undefined, + }), + ); + + expect(HostPushNotificationResponse.dec(encoded).id).toBe(42); + expect(notification).toEqual({ + text: "Hello!", + deeplink: undefined, + scheduledAt: undefined, + }); + }); +}); diff --git a/js/packages/truapi-host-wasm/src/worker-protocol.ts b/js/packages/truapi-host-wasm/src/worker-protocol.ts new file mode 100644 index 00000000..f3dbf9c5 --- /dev/null +++ b/js/packages/truapi-host-wasm/src/worker-protocol.ts @@ -0,0 +1,160 @@ +// Wire format between the main thread (`createWebWorkerProvider`) and the +// Web Worker that hosts the truapi-server WASM core. +// +// Main window / host JS +// ┌─────────────────────────────────────────────────────────────────┐ +// │ createWebWorkerProvider │ +// │ host callbacks: storage, DOM prompts, chain provider, logging │ +// └───────────────┬─────────────────────────────────────────────────┘ +// │ MainToWorker: init, frame, callbackResponse, +// │ subscriptionItem, chainResponse +// v +// Dedicated Worker +// ┌─────────────────────────────────────────────────────────────────┐ +// │ truapi-server WASM HostCore │ +// │ generated raw-callback proxy │ +// └───────────────┬─────────────────────────────────────────────────┘ +// │ WorkerToMain: frame, callbackRequest, +// │ subscriptionStart, chainConnect +// v +// Main window dispatches those requests to the actual host callbacks. +// +// Frames (`kind: 'frame'`) carry SCALE-encoded `ProtocolMessage` bytes +// untouched in either direction. Everything else is a control message +// for callback dispatch, subscription bookkeeping, or chain connections. +// +// Frame bytes cross the boundary by structured clone, deliberately not as +// transferables: the sender keeps using its buffer (the worker side posts +// views into WASM memory) and frames are small, so the copy is the simpler +// safe choice. + +import type { LogLevel, PermissionAuthorizationStatus } from "./runtime.js"; +import type { + CallbackName, + SubscriptionName, +} from "./generated/worker-callbacks.js"; +/** + * Generated callback-name unions used by the worker transport. They keep the + * hand-written protocol aligned with the Rust platform callback catalog. + */ +export type { + CallbackName, + SubscriptionName, +} from "./generated/worker-callbacks.js"; + +/** + * Positional arguments for a callback. The wasm core calls each callback + * at a fixed arity; a uniform `unknown[]` keeps the wire protocol simple. + */ +export type CallbackArgs = readonly unknown[]; + +/** + * Messages posted by the main window to the WASM worker. These either control + * worker/core lifecycle, forward encoded TrUAPI frames into the core, or return + * host callback/subscription/chain responses requested by the worker. + */ +export type MainToWorker = + | { + kind: "init"; + logLevel: LogLevel; + runtimeConfig: unknown; + } + | { kind: "setLogLevel"; level: LogLevel } + | { kind: "frame"; bytes: Uint8Array } + | { kind: "disconnectSession"; requestId: number } + | { kind: "cancelPairing" } + | { kind: "notifySessionStoreChanged" } + | { + kind: "getPermissionAuthorizationStatus"; + requestId: number; + request: Uint8Array; + } + | { + kind: "getPermissionAuthorizationStatuses"; + requestId: number; + requests: Uint8Array[]; + } + | { + kind: "setPermissionAuthorizationStatus"; + requestId: number; + request: Uint8Array; + status: PermissionAuthorizationStatus; + } + | { kind: "callbackResponse"; requestId: number; ok: true; value: unknown } + | { kind: "callbackResponse"; requestId: number; ok: false; error: string } + | { kind: "subscriptionItem"; subId: number; value: unknown } + | { kind: "chainConnectAck"; connId: number; ok: true } + | { kind: "chainConnectAck"; connId: number; ok: false; error: string } + | { kind: "chainResponse"; connId: number; json: string } + | { kind: "dispose" }; + +/** + * Messages posted by the WASM worker back to the main window. These either + * report worker lifecycle/errors, emit encoded TrUAPI frames from the core, or + * request host callbacks, subscriptions, and chain-provider operations. + */ +export type WorkerToMain = + | { kind: "loaded" } + | { kind: "ready" } + | { kind: "fatalError"; error: string } + | { kind: "frameError"; error: string } + | { kind: "disposeError"; error: string } + | { kind: "frame"; bytes: Uint8Array } + | { kind: "disconnectSessionResponse"; requestId: number; ok: true } + | { + kind: "disconnectSessionResponse"; + requestId: number; + ok: false; + error: string; + } + | { + kind: "permissionAuthorizationStatusResponse"; + requestId: number; + ok: true; + status: PermissionAuthorizationStatus; + } + | { + kind: "permissionAuthorizationStatusResponse"; + requestId: number; + ok: false; + error: string; + } + | { + kind: "permissionAuthorizationStatusesResponse"; + requestId: number; + ok: true; + statuses: PermissionAuthorizationStatus[]; + } + | { + kind: "permissionAuthorizationStatusesResponse"; + requestId: number; + ok: false; + error: string; + } + | { + kind: "setPermissionAuthorizationStatusResponse"; + requestId: number; + ok: true; + } + | { + kind: "setPermissionAuthorizationStatusResponse"; + requestId: number; + ok: false; + error: string; + } + | { + kind: "callbackRequest"; + requestId: number; + name: CallbackName; + args: CallbackArgs; + } + | { + kind: "subscriptionStart"; + subId: number; + name: SubscriptionName; + payload: Uint8Array | null; + } + | { kind: "subscriptionStop"; subId: number } + | { kind: "chainConnectStart"; connId: number; genesisHash: string } + | { kind: "chainSend"; connId: number; request: string } + | { kind: "chainClose"; connId: number }; diff --git a/js/packages/truapi-host-wasm/src/worker-runtime.ts b/js/packages/truapi-host-wasm/src/worker-runtime.ts new file mode 100644 index 00000000..77a1d4bc --- /dev/null +++ b/js/packages/truapi-host-wasm/src/worker-runtime.ts @@ -0,0 +1,433 @@ +/// +// Worker entrypoint. Loads the web-targeted truapi-server WASM bundle and +// bridges every host callback over postMessage. The main thread keeps the +// state that needs DOM access (localStorage, prompts) while the core dispatcher +// runs here off the page main thread. + +import type { + MainToWorker, + SubscriptionName, + WorkerToMain, +} from "./worker-protocol.js"; +import { + createWorkerRawCallbacks, + type CallbackName, +} from "./generated/worker-callbacks.js"; +import { errorMessage } from "./error.js"; + +type PermissionAuthorizationStatus = + | "NotDetermined" + | "Denied" + | "Authorized"; + +interface WorkerHostCore { + receiveFrame(frame: Uint8Array): Promise; + disconnectSession(): Promise; + cancelPairing(): void; + notifySessionStoreChanged(): void; + permissionAuthorizationStatus(request: Uint8Array): Promise; + permissionAuthorizationStatuses(requests: Uint8Array[]): Promise; + setPermissionAuthorizationStatus( + request: Uint8Array, + status: string, + ): Promise; + dispose(): void; + free(): void; +} + +interface WasmModuleShape { + default: (input?: unknown) => Promise; + WasmHostCore: new ( + callbacks: unknown, + runtimeConfig: unknown, + ) => WorkerHostCore; + setLogLevel?: (level: string) => void; +} + +// Resolved at runtime, the wasm-pack artifact lives outside `src/` so a +// static import would leak into the TS rootDir. The relative path is +// resolved against `dist/worker-runtime.js` once compiled. Indirected +// through a variable so TS skips the static module-existence check. +const WASM_WEB_PATH = "./wasm/web/truapi_server.js"; +const wasmModulePromise = import( + /* @vite-ignore */ WASM_WEB_PATH +) as Promise; + +const ctx = self as unknown as DedicatedWorkerGlobalScope; + +function postToMain(msg: WorkerToMain): void { + ctx.postMessage(msg); +} + +let nextRequestId = 0; +const pendingCallbacks = new Map< + number, + (result: { ok: true; value: unknown } | { ok: false; error: string }) => void +>(); + +let nextSubId = 0; +const subscriptionItemListeners = new Map void>(); + +let nextConnId = 0; +type ChainConnectAck = { ok: true } | { ok: false; error: string }; +const chainConnectAcks = new Map void>(); +const chainResponseListeners = new Map void>(); + +function callbackRequest( + name: CallbackName, + args: readonly unknown[], +): Promise { + return new Promise((resolve, reject) => { + const requestId = ++nextRequestId; + pendingCallbacks.set(requestId, (r) => { + if (r.ok) resolve(r.value); + else reject(new Error(r.error)); + }); + postToMain({ kind: "callbackRequest", requestId, name, args }); + }); +} + +function startSubscription( + name: SubscriptionName, + payload: Uint8Array | null, + sendItem: (value: T) => void, +): () => void { + const subId = ++nextSubId; + subscriptionItemListeners.set(subId, sendItem as (value: unknown) => void); + postToMain({ kind: "subscriptionStart", subId, name, payload }); + return () => { + subscriptionItemListeners.delete(subId); + postToMain({ kind: "subscriptionStop", subId }); + }; +} + +interface WorkerChainConnection { + send(request: string): void; + close(): void; +} + +/** + * Worker-side half of the host chain-connect bridge. + * + * The Rust core runs in this worker but owns no socket. When it needs chain + * access (chainHead v1 for People-chain identity / statement-store SSO) it + * calls this; the actual transport lives on the host main thread and is reached + * over postMessage. The data crossing here is JSON-RPC strings, not SCALE: only + * the product<->core wire is SCALE. + * + * per-tab / sandboxed core-owned (this Web Worker) host-owned (main thread) + * +-------------------+ SCALE +--------------------------+ +--------------------------------+ + * | Product (iframe) |<------->| truapi-server WASM core | | host.connect() (ChainProvider) | + * | speaks TrUAPI | frames | chainHead v1, SSO, | | host-owned JSON-RPC transport | + * | never sees chains | | People-chain identity | | remote RPC, native client, ... | + * +-------------------+ +--------------------------+ +--------------------------------+ + * | ^ JSON-RPC strings (not SCALE) ^ | + * chainConnect() | | onResponse(json) connect | | responses() + * (this fn) v | | v + * worker-runtime.ts <======== postMessage ========> create-worker-host-runtime.ts + * chainConnectStart / chainSend / chainClose --> handleChainConnect* -> host.connect() + * chainConnectAck / chainResponse <-- (pumped from connection.responses()) + * + * Allocates a `connId`, posts `chainConnectStart`, and resolves a + * `{ send, close }` handle once the main thread acks. `send` posts `chainSend`, + * `close` posts `chainClose`, and every `chainResponse` for this `connId` is + * delivered to `onResponse`. + */ +function chainConnect( + genesisHash: string, + onResponse: (json: string) => void, +): Promise { + const connId = ++nextConnId; + return new Promise((resolve, reject) => { + chainConnectAcks.set(connId, (ack) => { + if (!ack.ok) { + chainResponseListeners.delete(connId); + reject(new Error(ack.error)); + return; + } + resolve({ + send(request: string) { + postToMain({ kind: "chainSend", connId, request }); + }, + close() { + chainResponseListeners.delete(connId); + postToMain({ kind: "chainClose", connId }); + }, + }); + }); + chainResponseListeners.set(connId, onResponse); + postToMain({ kind: "chainConnectStart", connId, genesisHash }); + }); +} + +/** + * Build the callback object passed to the WASM core. Most entries are + * generated proxy functions that bounce from the worker to the main window; + * `emitFrame` is filled here because it is the core-to-provider data path. + */ +function buildRawCallbacks() { + const callbacks = createWorkerRawCallbacks({ + callbackRequest, + startSubscription, + chainConnect, + }); + callbacks.emitFrame = (frame: Uint8Array): void => { + postToMain({ kind: "frame", bytes: frame }); + }; + callbacks.dispose = (): void => { + // Main thread terminates the worker, no separate cleanup needed here. + }; + return callbacks; +} + +let core: WorkerHostCore | null = null; +let wasm: WasmModuleShape | null = null; + +(async () => { + try { + wasm = await wasmModulePromise; + await wasm.default(); + postToMain({ kind: "loaded" }); + } catch (err) { + postToMain({ kind: "fatalError", error: errorMessage(err) }); + } +})(); + +ctx.addEventListener("message", (ev: MessageEvent) => { + const msg = ev.data; + switch (msg.kind) { + case "init": + if (!wasm) { + postToMain({ + kind: "fatalError", + error: "init received before WASM loaded", + }); + break; + } + if (core) { + postToMain({ + kind: "fatalError", + error: "init: core already initialized", + }); + break; + } + wasm.setLogLevel?.(msg.logLevel); + try { + core = new wasm.WasmHostCore(buildRawCallbacks(), msg.runtimeConfig); + postToMain({ kind: "ready" }); + } catch (err) { + postToMain({ kind: "fatalError", error: `init: ${errorMessage(err)}` }); + } + break; + case "setLogLevel": + wasm?.setLogLevel?.(msg.level); + break; + case "frame": + void handleFrame(msg.bytes); + break; + case "disconnectSession": + void handleDisconnectSession(msg.requestId); + break; + case "cancelPairing": + core?.cancelPairing(); + break; + case "notifySessionStoreChanged": + core?.notifySessionStoreChanged(); + break; + case "getPermissionAuthorizationStatus": + void handleGetPermissionAuthorizationStatus(msg.requestId, msg.request); + break; + case "getPermissionAuthorizationStatuses": + void handleGetPermissionAuthorizationStatuses( + msg.requestId, + msg.requests, + ); + break; + case "setPermissionAuthorizationStatus": + void handleSetPermissionAuthorizationStatus( + msg.requestId, + msg.request, + msg.status, + ); + break; + case "callbackResponse": { + const cb = pendingCallbacks.get(msg.requestId); + if (cb) { + pendingCallbacks.delete(msg.requestId); + cb( + msg.ok + ? { ok: true, value: msg.value } + : { ok: false, error: msg.error }, + ); + } + break; + } + case "subscriptionItem": { + const listener = subscriptionItemListeners.get(msg.subId); + if (listener) listener(msg.value); + break; + } + case "chainConnectAck": { + const cb = chainConnectAcks.get(msg.connId); + if (cb) { + chainConnectAcks.delete(msg.connId); + cb(msg.ok ? { ok: true } : { ok: false, error: msg.error }); + } + break; + } + case "chainResponse": { + const listener = chainResponseListeners.get(msg.connId); + if (listener) listener(msg.json); + break; + } + case "dispose": + try { + core?.dispose(); + core?.free(); + } catch (err) { + postToMain({ kind: "disposeError", error: errorMessage(err) }); + } + core = null; + break; + default: { + const { kind } = msg as { kind?: unknown }; + console.warn( + `[truapi worker-runtime] unknown message kind: ${String(kind)}`, + ); + } + } +}); + +async function handleDisconnectSession(requestId: number): Promise { + if (!core) { + postToMain({ + kind: "disconnectSessionResponse", + requestId, + ok: false, + error: "disconnectSession received before core is ready", + }); + return; + } + try { + await core.disconnectSession(); + postToMain({ kind: "disconnectSessionResponse", requestId, ok: true }); + } catch (err) { + postToMain({ + kind: "disconnectSessionResponse", + requestId, + ok: false, + error: errorMessage(err), + }); + } +} + +async function handleGetPermissionAuthorizationStatus( + requestId: number, + request: Uint8Array, +): Promise { + if (!core) { + postToMain({ + kind: "permissionAuthorizationStatusResponse", + requestId, + ok: false, + error: "permissionAuthorizationStatus received before core is ready", + }); + return; + } + try { + const status = await core.permissionAuthorizationStatus(request); + postToMain({ + kind: "permissionAuthorizationStatusResponse", + requestId, + ok: true, + status: status as PermissionAuthorizationStatus, + }); + } catch (err) { + postToMain({ + kind: "permissionAuthorizationStatusResponse", + requestId, + ok: false, + error: errorMessage(err), + }); + } +} + +async function handleGetPermissionAuthorizationStatuses( + requestId: number, + requests: Uint8Array[], +): Promise { + if (!core) { + postToMain({ + kind: "permissionAuthorizationStatusesResponse", + requestId, + ok: false, + error: "permissionAuthorizationStatuses received before core is ready", + }); + return; + } + try { + const statuses = await core.permissionAuthorizationStatuses(requests); + postToMain({ + kind: "permissionAuthorizationStatusesResponse", + requestId, + ok: true, + statuses: statuses as PermissionAuthorizationStatus[], + }); + } catch (err) { + postToMain({ + kind: "permissionAuthorizationStatusesResponse", + requestId, + ok: false, + error: errorMessage(err), + }); + } +} + +async function handleSetPermissionAuthorizationStatus( + requestId: number, + request: Uint8Array, + status: PermissionAuthorizationStatus, +): Promise { + if (!core) { + postToMain({ + kind: "setPermissionAuthorizationStatusResponse", + requestId, + ok: false, + error: "setPermissionAuthorizationStatus received before core is ready", + }); + return; + } + try { + await core.setPermissionAuthorizationStatus(request, status); + postToMain({ + kind: "setPermissionAuthorizationStatusResponse", + requestId, + ok: true, + }); + } catch (err) { + postToMain({ + kind: "setPermissionAuthorizationStatusResponse", + requestId, + ok: false, + error: errorMessage(err), + }); + } +} + +async function handleFrame(bytes: Uint8Array): Promise { + if (!core) { + postToMain({ + kind: "frameError", + error: "frame received before core is ready", + }); + return; + } + try { + await core.receiveFrame(bytes); + } catch (err) { + postToMain({ + kind: "frameError", + error: errorMessage(err), + }); + } +} diff --git a/js/packages/truapi-host-wasm/tsconfig.json b/js/packages/truapi-host-wasm/tsconfig.json new file mode 100644 index 00000000..9d2dcad8 --- /dev/null +++ b/js/packages/truapi-host-wasm/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, + "lib": ["ES2022", "DOM", "WebWorker"] + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts", "src/test-support.ts"], + "references": [{ "path": "../truapi" }] +} diff --git a/js/packages/truapi/package.json b/js/packages/truapi/package.json index 6b8e2f61..db961565 100644 --- a/js/packages/truapi/package.json +++ b/js/packages/truapi/package.json @@ -66,7 +66,7 @@ }, "scripts": { "ensure-generated": "./scripts/ensure-generated.sh", - "build": "tsc", + "build": "tsc -b", "prebuild": "npm run ensure-generated", "codegen": "cargo run -p truapi-codegen -- --input ../../../target/doc/truapi.json --output src/generated --playground-output src/playground --explorer-output src/explorer", "typecheck": "npm run build", diff --git a/js/packages/truapi/src/client.ts b/js/packages/truapi/src/client.ts index fc13022d..f45481f4 100644 --- a/js/packages/truapi/src/client.ts +++ b/js/packages/truapi/src/client.ts @@ -13,13 +13,15 @@ import { type WireProvider, } from "./transport.js"; import { + CallError, indexedTaggedUnion, Result, _void, + type CallErrorValue, type Codec, type ResultPayload, } from "./scale.js"; -import { TRUAPI_CODEC_VERSION, TRUAPI_VERSION } from "./generated/client.js"; +import { TRUAPI_CODEC_VERSION } from "./generated/client.js"; import * as T from "./generated/types.js"; import * as W from "./generated/wire-table.js"; @@ -29,13 +31,12 @@ export type { Subscription, TrUApiTransport }; * Version overrides used when constructing a transport. */ export interface CreateTransportOptions { - /** - * Highest TrUAPI protocol version exposed by the transport. - */ - truapiVersion?: number; - /** * SCALE codec version advertised during host handshake negotiation. + * + * @deprecated TODO(shared-core-wire): remove this override with + * `TrUApiTransport.codecVersion` once generated handshake requests use + * `TRUAPI_CODEC_VERSION` directly. */ codecVersion?: number; } @@ -51,7 +52,10 @@ function protocolVersionTag(version: number): `V${number}` { return `V${version}` as `V${number}`; } -type HandshakeResponse = ResultPayload; +type HandshakeResponse = ResultPayload< + undefined, + CallErrorValue +>; const HANDSHAKE_WIRE_VERSION = 1; /** @@ -63,7 +67,7 @@ function handshakeResponseCodec( return indexedTaggedUnion({ [protocolVersionTag(version)]: [ version - 1, - Result(_void, T.HostHandshakeError), + Result(_void, CallError(T.VersionedHostHandshakeError)), ] as const, }) as Codec<{ tag: `V${number}`; value: HandshakeResponse }>; } @@ -90,8 +94,14 @@ function encodeUnsupportedHandshakeResponse(version: number): Uint8Array { value: { success: false, value: { - tag: "UnsupportedProtocolVersion", - value: undefined, + tag: "Domain", + value: { + tag: "V1", + value: { + tag: "UnsupportedProtocolVersion", + value: undefined, + }, + }, }, }, }); @@ -140,7 +150,6 @@ export function createTransport( provider: WireProvider, options: CreateTransportOptions = {}, ): TrUApiTransport { - const truapiVersion = options.truapiVersion ?? TRUAPI_VERSION; const codecVersion = options.codecVersion ?? TRUAPI_CODEC_VERSION; let idCounter = 0; let closedError: Error | null = null; @@ -305,7 +314,6 @@ export function createTransport( } return { - truapiVersion, codecVersion, /** * Send one request frame and resolve with the typed Ok/Err outcome diff --git a/js/packages/truapi/src/sandbox.ts b/js/packages/truapi/src/sandbox.ts index 51861e4e..702cda26 100644 --- a/js/packages/truapi/src/sandbox.ts +++ b/js/packages/truapi/src/sandbox.ts @@ -10,11 +10,7 @@ * @module */ -import { - createIframeProvider, - createMessagePortProvider, - type WireProvider, -} from "./transport.js"; +import { createMessagePortProvider, type WireProvider } from "./transport.js"; import { createTransport } from "./client.js"; import { createClient, type TrUApiClient } from "./generated/index.js"; @@ -62,10 +58,7 @@ export function isCorrectEnvironment(): boolean { } /** - * Origin used as the `targetOrigin` for outbound `postMessage` frames. Frames - * carry signed payloads and account ids, so this fails closed: when no concrete - * origin can be pinned it returns `null` (rather than falling back to `"*"`) and - * provider construction throws. + * Origin used as the `targetOrigin` for iframe bootstrap messages. */ function resolveHostOrigin(): string | null { if (typeof document !== "undefined" && document.referrer) { @@ -80,7 +73,8 @@ function resolveHostOrigin(): string | null { return null; } -const WEBVIEW_PORT_TIMEOUT_MS = 20_000; +const HOST_PORT_TIMEOUT_MS = 20_000; +let iframePortPromise: Promise | null = null; /** * Resolve the host-injected `MessagePort`, polling `window.__HOST_API_PORT__` @@ -93,7 +87,7 @@ const WEBVIEW_PORT_TIMEOUT_MS = 20_000; */ async function waitForWebviewPort( signal?: AbortSignal, - timeoutMs = WEBVIEW_PORT_TIMEOUT_MS, + timeoutMs = HOST_PORT_TIMEOUT_MS, ): Promise { const start = Date.now(); while (Date.now() - start < timeoutMs) { @@ -107,21 +101,86 @@ async function waitForWebviewPort( ); } -/** Build the {@link WireProvider} matching the detected environment (iframe or webview). */ -function createSandboxProvider(): WireProvider { - if (isIframe()) { +/** + * Resolve the iframe `MessagePort` transferred by `createIframeHost`. + */ +function waitForIframePort( + signal?: AbortSignal, + timeoutMs = HOST_PORT_TIMEOUT_MS, +): Promise { + const existing = hostWindow()?.__HOST_API_PORT__; + if (existing) return Promise.resolve(existing); + if (iframePortPromise) return iframePortPromise; + + iframePortPromise = new Promise((resolve, reject) => { + const win = hostWindow(); + if (!win) { + reject(new Error("window is unavailable")); + return; + } + const hostOrigin = resolveHostOrigin(); - if (!hostOrigin) { - throw new Error( - "TrUAPI iframe provider could not resolve the host origin from document.referrer / ancestorOrigins.", + let done = false; + const cleanup = (): void => { + win.removeEventListener("message", onMessage); + signal?.removeEventListener("abort", onAbort); + clearTimeout(timer); + }; + const finish = (result: MessagePort | Error): void => { + if (done) return; + done = true; + cleanup(); + if (result instanceof Error) { + reject(result); + } else { + win.__HOST_API_PORT__ = result; + resolve(result); + } + }; + const onAbort = (): void => { + finish(new Error("waitForIframePort aborted")); + }; + const onMessage = (event: MessageEvent): void => { + if (event.source !== win.parent) return; + if ( + hostOrigin !== null && + event.origin !== hostOrigin && + event.origin !== "null" + ) { + return; + } + if (event.data?.type !== "truapi-init") return; + const [port] = event.ports; + if (!port) { + finish(new Error("truapi-init did not include a MessagePort")); + return; + } + finish(port); + }; + const timer = setTimeout(() => { + finish( + new Error(`Timed out waiting for iframe MessagePort (${timeoutMs}ms)`), ); - } - return createIframeProvider({ target: window.parent, hostOrigin }); - } + }, timeoutMs); + + win.addEventListener("message", onMessage); + signal?.addEventListener("abort", onAbort, { once: true }); + win.parent.postMessage({ type: "truapi-ready" }, hostOrigin ?? "*"); + }).catch((error: unknown) => { + iframePortPromise = null; + throw error; + }); + + return iframePortPromise; +} + +/** Build the {@link WireProvider} matching the detected environment (iframe or webview). */ +function createSandboxProvider(): WireProvider { const portController = new AbortController(); - const provider = createMessagePortProvider( - waitForWebviewPort(portController.signal), - ); + const portPromise = isIframe() + ? waitForIframePort(portController.signal) + : waitForWebviewPort(portController.signal); + const provider = createMessagePortProvider(portPromise); const baseDispose = provider.dispose; provider.dispose = () => { portController.abort(); @@ -166,14 +225,21 @@ export function getClientSync(): TrUApiClient | null { export function subscribeConnectionStatus( callback: (status: ConnectionStatus) => void, ): () => void { - statusListeners.add(callback); - callback(status); + let emitted = false; + const listener = (next: ConnectionStatus) => { + emitted = true; + callback(next); + }; + statusListeners.add(listener); if (status === "disconnected") { setStatus(getClientSync() ? "connected" : "disconnected"); } + if (!emitted) { + callback(status); + } return () => { - statusListeners.delete(callback); + statusListeners.delete(listener); }; } diff --git a/js/packages/truapi/src/transport.ts b/js/packages/truapi/src/transport.ts index 0f9ad4de..926ec77b 100644 --- a/js/packages/truapi/src/transport.ts +++ b/js/packages/truapi/src/transport.ts @@ -201,12 +201,11 @@ export interface SubscribeRawParams { **/ export interface TrUApiTransport { /** - * Highest TrUAPI protocol version supported by this generated client. - **/ - readonly truapiVersion: number; - - /** - * SCALE codec version negotiated through the handshake. + * SCALE codec version used by generated handshake calls. + * + * @deprecated TODO(shared-core-wire): remove this public transport field once + * generated handshake requests read `TRUAPI_CODEC_VERSION` directly instead + * of going through transport state. **/ readonly codecVersion: number; diff --git a/js/packages/truapi/tsconfig.json b/js/packages/truapi/tsconfig.json index 79d35ea5..56a11d6f 100644 --- a/js/packages/truapi/tsconfig.json +++ b/js/packages/truapi/tsconfig.json @@ -3,6 +3,7 @@ "target": "ES2022", "module": "ES2022", "moduleResolution": "bundler", + "composite": true, "declaration": true, "outDir": "dist", "rootDir": "src", diff --git a/package-lock.json b/package-lock.json index 51905f9a..36199f2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,19 @@ "typescript": "^6.0" } }, + "js/packages/truapi-host-wasm": { + "name": "@parity/truapi-host-wasm", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "@parity/truapi": "file:../truapi" + }, + "devDependencies": { + "@types/bun": "^1.3.0", + "neverthrow": "^8.2.0", + "typescript": "^5.7" + } + }, "js/packages/truapi/node_modules/typescript": { "version": "6.0.3", "dev": true, @@ -414,6 +427,10 @@ "resolved": "js/packages/truapi", "link": true }, + "node_modules/@parity/truapi-host-wasm": { + "resolved": "js/packages/truapi-host-wasm", + "link": true + }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", @@ -1173,6 +1190,20 @@ "node": ">=8.0" } }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/undici-types": { "version": "8.3.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", diff --git a/playground/tests/e2e/testing.spec.ts b/playground/tests/e2e/testing.spec.ts new file mode 100644 index 00000000..74b2ba81 --- /dev/null +++ b/playground/tests/e2e/testing.spec.ts @@ -0,0 +1,40 @@ +import { expect, test } from "@playwright/test"; +import { openPlaygroundInDotli, selectMethod, waitForOnline } from "./helpers"; + +test.describe("testing service", () => { + test("version_probe runs through the latest generated request version", async ({ + page, + }) => { + const frame = await openPlaygroundInDotli(page); + await waitForOnline(frame); + + await selectMethod(frame, "Testing", "version_probe"); + await frame.locator('[data-testid="call-button"]').click(); + + const entries = frame.locator('[data-testid="stream-entry"]'); + await expect(entries.first()).toBeVisible({ timeout: 5_000 }); + await expect(frame.locator('[data-testid="error-display"]')).toHaveCount(0); + + const text = await entries.first().innerText(); + expect(text).toContain("testing version probe:"); + expect(text).toContain("receivedVersion"); + expect(text).toContain("2"); + }); + + test("echo_error surfaces a framework call error", async ({ page }) => { + const frame = await openPlaygroundInDotli(page); + await waitForOnline(frame); + + await selectMethod(frame, "Testing", "echo_error"); + await frame.locator('[data-testid="call-button"]').click(); + + const entries = frame.locator('[data-testid="stream-entry"]'); + await expect(entries.first()).toBeVisible({ timeout: 5_000 }); + await expect(frame.locator('[data-testid="error-display"]')).toHaveCount(0); + + const text = await entries.first().innerText(); + expect(text).toContain("echo error:"); + expect(text).toContain("HostFailure"); + expect(text).toContain("forced by test"); + }); +});