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/.gitignore b/js/packages/truapi-host/.gitignore new file mode 100644 index 00000000..288deac9 --- /dev/null +++ b/js/packages/truapi-host/.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/LICENSE b/js/packages/truapi-host/LICENSE new file mode 100644 index 00000000..ad207e8a --- /dev/null +++ b/js/packages/truapi-host/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Parity Technologies + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/js/packages/truapi-host/README.md b/js/packages/truapi-host/README.md new file mode 100644 index 00000000..5eae403c --- /dev/null +++ b/js/packages/truapi-host/README.md @@ -0,0 +1,79 @@ +# @parity/truapi-host + +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` | Shared runtime types plus generated typed host callback contracts. | +| `@parity/truapi-host/web` | Browser pairing host: `createIframeHost` (iframe MessageChannel handshake) and `createWebWorkerPairingHostRuntime`. | +| `@parity/truapi-host/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/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/worker-runtime?worker"; +import { createWebWorkerPairingHostRuntime } from "@parity/truapi-host/web"; + +const runtime = await createWebWorkerPairingHostRuntime( + new HostWorker(), + callbacks, + { + hostConfig, + }, +); + +const firstProvider = await runtime.createProvider({ productId: "first.dot" }); +const secondProvider = await runtime.createProvider({ + productId: "second.dot", +}); +``` + +`@parity/truapi-host/web` also exports `createIframeHost` for the +protocol-iframe MessageChannel handshake. Host code creates one worker runtime +and then opens one provider per product id. + +## Publishing + +This package is published by the root `Release` workflow through +`paritytech/npm_publish_automation`. Do not run `npm publish` locally. Cut a +`release:` PR with a changeset for `@parity/truapi-host`; the workflow builds +the generated host bindings, the browser WASM bundle, packs the tarball, and +publishes it when the `@parity/truapi-host@` tag does not already +exist. + +## Architecture + +```text +JS host code + protocol handlers / typed callbacks + (types from @parity/truapi-host) + | + v +createWebWorkerPairingHostRuntime + shared worker runtime: pairing session, chain runtime, WASM instance + | + +-- createProvider({ productId }) -> product core / WireProvider + | + +-- createProvider({ productId }) -> product core / WireProvider +``` diff --git a/js/packages/truapi-host/package.json b/js/packages/truapi-host/package.json new file mode 100644 index 00000000..f3988d74 --- /dev/null +++ b/js/packages/truapi-host/package.json @@ -0,0 +1,59 @@ +{ + "name": "@parity/truapi-host", + "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 ", + "repository": { + "type": "git", + "url": "git+https://github.com/paritytech/truapi.git", + "directory": "js/packages/truapi-host" + }, + "homepage": "https://github.com/paritytech/truapi#readme", + "bugs": { + "url": "https://github.com/paritytech/truapi/issues" + }, + "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", + "LICENSE" + ], + "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/scripts/build-wasm.mjs b/js/packages/truapi-host/scripts/build-wasm.mjs new file mode 100644 index 00000000..97583e4a --- /dev/null +++ b/js/packages/truapi-host/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/src/adapter-support.ts b/js/packages/truapi-host/src/adapter-support.ts new file mode 100644 index 00000000..45ddfbc0 --- /dev/null +++ b/js/packages/truapi-host/src/adapter-support.ts @@ -0,0 +1,130 @@ +// 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 { errorMessage } from "./error.js"; +import type { ChainConnect, ChainConnection } from "./runtime.js"; +import type { ChainProvider } 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, + onError?: (error: GenericError) => void, +): () => 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); + onError?.({ reason: errorMessage(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, + sendError: (error: GenericError) => void, +): () => void { + return pumpIterator( + toAsyncIterator(stream), + (value) => sendItem(unwrapStreamResult(value)), + "subscription", + sendError, + ); +} + +/** + * 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/src/error.ts b/js/packages/truapi-host/src/error.ts new file mode 100644 index 00000000..5dc5674e --- /dev/null +++ b/js/packages/truapi-host/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/src/host-callbacks-adapter.test.ts b/js/packages/truapi-host/src/host-callbacks-adapter.test.ts new file mode 100644 index 00000000..1acaac6f --- /dev/null +++ b/js/packages/truapi-host/src/host-callbacks-adapter.test.ts @@ -0,0 +1,484 @@ +import { describe, expect, it } from "bun:test"; +import { err, 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 { + AuthState, + 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 and byte blobs 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({ + notifications: { + pushNotification: async (notification) => ({ + id: notification.text.length, + }), + cancelNotification: async (id) => { + cancelled.push(id); + }, + }, + permissions: { + devicePermission: async (request) => ({ + granted: request === "Camera", + }), + remotePermission: async (request) => ({ + granted: request.permission.tag === "ChainSubmit", + }), + }, + features: { + featureSupported: async (request) => ({ + supported: + request.tag === "Chain" && request.value.genesisHash === GENESIS, + }), + }, + productStorage: { + 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({ + auth: { + authStateChanged: (state) => { + calls.push(["authStateChanged", state]); + }, + }, + coreStorage: { + 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]); + }, + }, + userConfirmation: { + 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 "AccountAccess": + 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; + } + }, + }, + preimage: { + 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?.( + AuthState.enc({ + 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: "AccountAccess", + 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(); + + 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({ + theme: { + subscribeTheme: () => themes(), + }, + }), + ); + const seen: ThemeVariant[] = []; + const dispose = raw.subscribeTheme?.((theme) => + seen.push(ThemeVariant.dec(theme!)), + ); + + await settle(); + + expect(seen).toEqual(["Dark", "Light"]); + dispose?.(); + }); + + it("propagates typed result subscription errors", async () => { + async function* themes() { + yield ok("Dark"); + yield err({ reason: "theme stream failed" }); + } + + const raw = createWasmRawCallbacks( + makeHostCallbacks({ + theme: { + subscribeTheme: () => themes(), + }, + }), + ); + const seen: ThemeVariant[] = []; + const errors: GenericError[] = []; + let resolveError!: (error: GenericError) => void; + const errorSeen = new Promise((resolve) => { + resolveError = resolve; + }); + const dispose = raw.subscribeTheme?.( + (theme) => seen.push(ThemeVariant.dec(theme!)), + (error) => { + errors.push(error); + resolveError(error); + }, + ); + + await settle(); + + expect(seen).toEqual(["Dark"]); + expect(await errorSeen).toEqual({ reason: "theme stream failed" }); + expect(errors).toEqual([{ reason: "theme stream failed" }]); + dispose?.(); + }); + + it("propagates thrown subscription iterator errors", async () => { + async function* themes() { + yield ok("Dark"); + throw new Error("theme iterator failed"); + } + + const raw = createWasmRawCallbacks( + makeHostCallbacks({ + theme: { + subscribeTheme: () => themes(), + }, + }), + ); + const seen: ThemeVariant[] = []; + const errors: GenericError[] = []; + let resolveError!: (error: GenericError) => void; + const errorSeen = new Promise((resolve) => { + resolveError = resolve; + }); + const dispose = raw.subscribeTheme?.( + (theme) => seen.push(ThemeVariant.dec(theme!)), + (error) => { + errors.push(error); + resolveError(error); + }, + ); + + await settle(); + + expect(seen).toEqual(["Dark"]); + expect(await errorSeen).toEqual({ reason: "theme iterator failed" }); + expect(errors).toEqual([{ reason: "theme iterator failed" }]); + 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({ + chain: { + 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/src/index.ts b/js/packages/truapi-host/src/index.ts new file mode 100644 index 00000000..b3744f17 --- /dev/null +++ b/js/packages/truapi-host/src/index.ts @@ -0,0 +1,3 @@ +export type { Payload, ProtocolMessage, WireProvider } from "@parity/truapi"; + +export * from "./runtime.js"; diff --git a/js/packages/truapi-host/src/runtime.ts b/js/packages/truapi-host/src/runtime.ts new file mode 100644 index 00000000..d91872eb --- /dev/null +++ b/js/packages/truapi-host/src/runtime.ts @@ -0,0 +1,83 @@ +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. The web worker pairing-host runtime adapts this typed surface +// into the byte-oriented callback bridge consumed by the WASM core. +export * from "./generated/host-callbacks.js"; +export type { + JsonRpcConnection as PlatformJsonRpcConnection, +} 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 ProductRuntimeConfig { + productId: string; + host: { + name: string; + icon?: string; + version?: string; + }; + platform?: { + type?: string; + version?: string; + }; + people: { + genesisHash: string | Uint8Array; + }; + pairing: { + deeplinkScheme: string; + }; +} + +export interface TrUApiProductProvider 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/src/test-support.ts b/js/packages/truapi-host/src/test-support.ts new file mode 100644 index 00000000..f6e0240b --- /dev/null +++ b/js/packages/truapi-host/src/test-support.ts @@ -0,0 +1,84 @@ +import type { RequiredHostCallbacks } from "./generated/host-callbacks.js"; + +/** `HostCallbacks` with every optional member required, for exhaustive test fixtures. */ +export type CompleteHostCallbacks = RequiredHostCallbacks; + +type HostCallbackOverrides = { + [K in keyof RequiredHostCallbacks]?: Partial; +}; + +/** Default no-op host callbacks with optional per-test overrides. */ +export function makeHostCallbacks( + overrides: HostCallbackOverrides = {}, +): CompleteHostCallbacks { + const defaults: CompleteHostCallbacks = { + navigation: { navigateTo: async () => {} }, + notifications: { + pushNotification: async () => ({ id: 0 }), + cancelNotification: async () => {}, + }, + permissions: { + devicePermission: async () => ({ granted: false }), + remotePermission: async () => ({ granted: false }), + }, + features: { featureSupported: async () => ({ supported: false }) }, + productStorage: { + read: async () => undefined, + write: async () => {}, + clear: async () => {}, + }, + coreStorage: { + readCoreStorage: async () => undefined, + writeCoreStorage: async () => {}, + clearCoreStorage: async () => {}, + }, + auth: { authStateChanged: () => {} }, + userConfirmation: { confirmUserAction: async () => false }, + preimage: { + submitPreimage: async () => new Uint8Array(), + async *lookupPreimage() {}, + }, + theme: { async *subscribeTheme() {} }, + chain: { + connect: async () => ({ + send() {}, + async *responses() {}, + close() {}, + }), + }, + }; + + return { + navigation: { ...defaults.navigation, ...overrides.navigation }, + notifications: { + ...defaults.notifications, + ...overrides.notifications, + }, + permissions: { + ...defaults.permissions, + ...overrides.permissions, + }, + features: { ...defaults.features, ...overrides.features }, + productStorage: { + ...defaults.productStorage, + ...overrides.productStorage, + }, + coreStorage: { + ...defaults.coreStorage, + ...overrides.coreStorage, + }, + auth: { ...defaults.auth, ...overrides.auth }, + userConfirmation: { + ...defaults.userConfirmation, + ...overrides.userConfirmation, + }, + preimage: { ...defaults.preimage, ...overrides.preimage }, + theme: { ...defaults.theme, ...overrides.theme }, + chain: { ...defaults.chain, ...overrides.chain }, + }; +} + +/** 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/src/web/create-iframe-host.test.ts b/js/packages/truapi-host/src/web/create-iframe-host.test.ts new file mode 100644 index 00000000..4f09004c --- /dev/null +++ b/js/packages/truapi-host/src/web/create-iframe-host.test.ts @@ -0,0 +1,236 @@ +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("http://localhost:5174"); + + // 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("rejects opaque product-ready for the default same-origin sandbox", () => { + 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(0); + }); + + it("accepts opaque product-ready only for an opaque sandbox", () => { + const { contentPostMessage, windowListeners, contentWindow } = dom; + + createIframeHost({ + iframeUrl: "http://localhost:5174/", + container: { appendChild: () => {} } as unknown as HTMLElement, + sandbox: "allow-forms allow-scripts", + 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/src/web/create-iframe-host.ts b/js/packages/truapi-host/src/web/create-iframe-host.ts new file mode 100644 index 00000000..4ba09cc6 --- /dev/null +++ b/js/packages/truapi-host/src/web/create-iframe-host.ts @@ -0,0 +1,158 @@ +/** + * 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 sandboxCreatesOpaqueOrigin(sandbox: string): boolean { + // Sandbox tokens are matched ASCII case-insensitively by the browser. + return !sandbox.toLowerCase().split(/\s+/).includes("allow-same-origin"); +} + +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 acceptsOpaqueOrigin = sandboxCreatesOpaqueOrigin(sandbox); + // Opaque sandbox origins cannot be addressed by URL origin. Use "*" only + // for that explicit sandbox mode; otherwise pin the capability port transfer. + const initTargetOrigin = acceptsOpaqueOrigin ? "*" : 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 && + !(acceptsOpaqueOrigin && 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/src/web/create-worker-host-runtime.ts b/js/packages/truapi-host/src/web/create-worker-host-runtime.ts new file mode 100644 index 00000000..1e69dd8d --- /dev/null +++ b/js/packages/truapi-host/src/web/create-worker-host-runtime.ts @@ -0,0 +1,979 @@ +import type { + ChainConnection, + ProductRuntimeConfig, + LogLevel, + PermissionAuthorizationRequest, + PermissionAuthorizationStatus, + RequiredHostCallbacks, + TrUApiProductProvider, +} from "../index.js"; +import type { GenericError } from "@parity/truapi"; +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"; + +export type WebWorkerHostConfig = Omit; + +export interface WorkerPairingHostRuntime { + createProvider(product: { + productId: string; + }): Promise; + disconnectSession(): Promise; + cancelPairing(): void; + notifySessionStoreChanged(): void; + getPermissionAuthorizationStatus( + productId: string, + request: PermissionAuthorizationRequest, + ): Promise; + getPermissionAuthorizationStatuses( + productId: string, + requests: PermissionAuthorizationRequest[], + ): Promise; + setPermissionAuthorizationStatus( + productId: string, + request: PermissionAuthorizationRequest, + status: PermissionAuthorizationStatus, + ): Promise; + setLogLevel(level: LogLevel): void; + dispose(): void; +} + +interface CoreState { + coreId: number; + productId: string; + listeners: Set<(message: Uint8Array) => void>; + closeListeners: Set<(error: Error) => void>; + closedError: Error | null; + disposed: boolean; +} + +interface RuntimeState { + worker: Worker; + rawCallbacks: RawCallbacks; + cores: Map; + pendingCores: Map< + number, + { + productId: string; + resolve: (provider: TrUApiProductProvider) => void; + reject: (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 } + >; + pendingBulletinAllowanceSigns: Map>; + closedError: Error | null; + logLevel: LogLevel; + disposed: boolean; + nextCoreId: number; +} + +function debugLoggingEnabled(state: RuntimeState): boolean { + return state.logLevel === "debug" || state.logLevel === "trace"; +} + +let nextDisconnectRequestId = 0; +let nextPermissionAuthorizationRequestId = 0; +let nextBulletinAllowanceSignRequestId = 0; + +interface WorkerBulletinAllowanceSigner { + publicKey: Uint8Array; + signerId: number; +} + +function encodePermissionAuthorizationRequest( + request: PermissionAuthorizationRequest, +): Uint8Array { + return PermissionAuthorizationRequestCodec.enc(request); +} + +const DEV_LOG_LEVEL_KEY = "truapi:logLevel"; + +function readPersistedLogLevel(): LogLevel | null { + return globalThis.localStorage?.getItem(DEV_LOG_LEVEL_KEY) ?? null; +} + +function persistLogLevel(level: LogLevel): void { + globalThis.localStorage?.setItem(DEV_LOG_LEVEL_KEY, level); +} + +let devLogLevelOverride: LogLevel | null = readPersistedLogLevel(); +const devGlobalTargets = new Set<{ setLogLevel?: (level: LogLevel) => void }>(); +interface TrUApiDevConsole { + setLogLevel(level: LogLevel): void; + getLogLevel(): LogLevel | null; +} + +function handleCallbackRequest( + state: RuntimeState, + msg: { + requestId: number; + name: CallbackName; + args: readonly unknown[]; + }, +): void { + const fn = Object.hasOwn(state.rawCallbacks, msg.name) + ? ( + state.rawCallbacks as unknown as Record< + string, + (...args: readonly unknown[]) => unknown + > + )[msg.name] + : undefined; + if (!fn) { + state.worker.postMessage({ + kind: "callbackResponse", + requestId: msg.requestId, + ok: false, + error: `unknown callback: ${msg.name}`, + } satisfies MainToWorker); + return; + } + const args = reviveCallbackArgs(state, msg.name, msg.args); + Promise.resolve() + .then(() => fn(...args)) + .then( + (value) => { + state.worker.postMessage({ + kind: "callbackResponse", + requestId: msg.requestId, + ok: true, + value, + } satisfies MainToWorker); + }, + (err) => { + state.worker.postMessage({ + kind: "callbackResponse", + requestId: msg.requestId, + ok: false, + error: errorMessage(err), + } satisfies MainToWorker); + }, + ); +} + +function reviveCallbackArgs( + state: RuntimeState, + name: CallbackName, + args: readonly unknown[], +): readonly unknown[] { + if (name !== "submitPreimage") return args; + return args.map((arg, index) => { + if (index !== 1 || !isWorkerBulletinAllowanceSigner(arg)) return arg; + return { + publicKey: arg.publicKey, + sign: (input: Uint8Array) => + signBulletinAllowance(state, arg.signerId, input), + }; + }); +} + +function isWorkerBulletinAllowanceSigner( + value: unknown, +): value is WorkerBulletinAllowanceSigner { + return ( + typeof value === "object" && + value !== null && + "publicKey" in value && + value.publicKey instanceof Uint8Array && + "signerId" in value && + typeof value.signerId === "number" + ); +} + +function signBulletinAllowance( + state: RuntimeState, + signerId: number, + input: Uint8Array, +): Promise { + if (state.disposed) { + return Promise.reject(state.closedError ?? new Error("runtime disposed")); + } + return new Promise((resolve, reject) => { + const requestId = ++nextBulletinAllowanceSignRequestId; + state.pendingBulletinAllowanceSigns.set(requestId, { resolve, reject }); + try { + state.worker.postMessage({ + kind: "signBulletinAllowance", + requestId, + signerId, + input, + } satisfies MainToWorker); + } catch (err) { + state.pendingBulletinAllowanceSigns.delete(requestId); + reject(err instanceof Error ? err : new Error(String(err))); + } + }); +} + +function handleSubscriptionStart( + state: RuntimeState, + msg: { + subId: number; + name: SubscriptionName; + payload: Uint8Array | null; + }, +): void { + const sendItem = (value?: unknown): void => { + if (state.disposed) return; + state.worker.postMessage({ + kind: "subscriptionItem", + subId: msg.subId, + value, + } satisfies MainToWorker); + }; + const sendError = (error: GenericError): void => { + if (state.disposed) return; + state.worker.postMessage({ + kind: "subscriptionError", + subId: msg.subId, + error: error.reason, + } satisfies MainToWorker); + }; + let dispose: (() => void) | void = undefined; + try { + dispose = startRawSubscription( + state.rawCallbacks, + msg.name, + msg.payload, + sendItem, + sendError, + ); + } 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: RuntimeState, + 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: RuntimeState, + msg: { connId: number; genesisHash: string }, +): Promise { + const chainConnect = state.rawCallbacks.chainConnect; + const onResponse = (json: string): void => { + if (state.disposed) return; + state.worker.postMessage({ + kind: "chainResponse", + connId: msg.connId, + json, + } satisfies MainToWorker); + }; + try { + const conn = await chainConnect(msg.genesisHash, onResponse); + if (!conn) { + state.worker.postMessage({ + kind: "chainConnectAck", + connId: msg.connId, + ok: false, + error: `chainConnect returned null for genesisHash ${msg.genesisHash}`, + } satisfies MainToWorker); + return; + } + state.chainConnections.set(msg.connId, conn); + state.worker.postMessage({ + kind: "chainConnectAck", + connId: msg.connId, + ok: true, + } satisfies MainToWorker); + } catch (err) { + state.worker.postMessage({ + kind: "chainConnectAck", + connId: msg.connId, + ok: false, + error: errorMessage(err), + } satisfies MainToWorker); + } +} + +function handleChainSend( + state: RuntimeState, + 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: RuntimeState, 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; +} + +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)); +} + +function rejectAll(map: Map>, error: Error): void { + for (const pending of map.values()) { + pending.reject(error); + } + map.clear(); +} + +function handleDisconnectResponse( + state: RuntimeState, + 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: RuntimeState, + 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: RuntimeState, + 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: RuntimeState, + 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 rejectPendingRuntimeRequests(state: RuntimeState, error: Error): void { + rejectAll(state.pendingDisconnects, error); + rejectAll(state.pendingPermissionAuthorizationStatuses, error); + rejectAll(state.pendingPermissionAuthorizationStatusBatches, error); + rejectAll(state.pendingSetPermissionAuthorizationStatuses, error); + rejectAll(state.pendingBulletinAllowanceSigns, error); + for (const pending of state.pendingCores.values()) { + pending.reject(error); + } + state.pendingCores.clear(); +} + +function sendWorkerRequest( + state: RuntimeState, + 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))); + } + }); +} + +function closeCoreState(core: CoreState, error: Error): void { + if (core.disposed) return; + core.disposed = true; + core.closedError = error; + for (const listener of [...core.closeListeners]) listener(error); + core.listeners.clear(); + core.closeListeners.clear(); +} + +function teardown(state: RuntimeState, error: Error, fault: boolean): void { + if (state.disposed) return; + state.disposed = true; + state.closedError = error; + rejectPendingRuntimeRequests(state, error); + for (const core of state.cores.values()) { + closeCoreState(core, error); + } + state.cores.clear(); + 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 { + state.worker.postMessage({ kind: "dispose" } satisfies MainToWorker); + } catch { + // ignore if worker already gone + } + setTimeout(() => state.worker.terminate(), 0); + } +} + +export interface CreateWebWorkerPairingHostRuntimeOptions { + logLevel?: LogLevel; + hostConfig: WebWorkerHostConfig; + initTimeoutMs?: number; +} + +export type WebWorkerHostCallbacks = RequiredHostCallbacks; + +export function createWebWorkerPairingHostRuntime( + worker: Worker, + host: WebWorkerHostCallbacks, + options: CreateWebWorkerPairingHostRuntimeOptions, +): Promise { + const callbacks = createWasmRawCallbacks(host); + + return new Promise((resolve, reject) => { + const state: RuntimeState = { + worker, + rawCallbacks: callbacks, + cores: new Map(), + pendingCores: new Map(), + subscriptionDisposers: new Map(), + chainConnections: new Map(), + pendingDisconnects: new Map(), + pendingPermissionAuthorizationStatuses: new Map(), + pendingPermissionAuthorizationStatusBatches: new Map(), + pendingSetPermissionAuthorizationStatuses: new Map(), + pendingBulletinAllowanceSigns: new Map(), + closedError: null, + logLevel: devLogLevelOverride ?? options.logLevel ?? "off", + disposed: false, + nextCoreId: 0, + }; + + let runtime: WorkerPairingHostRuntime | null = null; + + const notifyFault = (error: Error): void => { + teardown(state, error, true); + }; + + const onMessage = (ev: MessageEvent): void => { + const msg = ev.data; + switch (msg.kind) { + case "loaded": + case "ready": + break; + case "coreReady": + handleCoreReady(state, msg.coreId, runtime); + break; + case "coreError": + handleCoreError(state, msg.coreId, msg.error); + break; + case "fatalError": + console.error("[truapi worker]", msg.error); + notifyFault(new Error(`worker fatal error: ${msg.error}`)); + break; + case "frameError": + handleFrameError(state, msg.coreId, msg.error); + break; + case "disposeError": + console.warn("[truapi worker] dispose:", msg.error); + break; + case "frame": { + const core = state.cores.get(msg.coreId); + if (!core || core.disposed) break; + if (debugLoggingEnabled(state)) { + console.debug("[truapi worker] frame <-", bytesToHex(msg.bytes)); + } + for (const listener of [...core.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 "signBulletinAllowanceResponse": + settlePending( + state.pendingBulletinAllowanceSigns, + msg.requestId, + msg.ok + ? { ok: true, value: msg.signature } + : { ok: false, error: msg.error }, + ); + 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 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") { + worker.postMessage({ + kind: "init", + logLevel: devLogLevelOverride ?? options.logLevel ?? "off", + hostConfig: options.hostConfig, + } satisfies MainToWorker); + } else if (msg.kind === "ready") { + cleanupInit(); + worker.addEventListener("message", onMessage); + worker.addEventListener("error", onRuntimeError); + worker.addEventListener("messageerror", onMessageError); + runtime = buildRuntime(state); + exposeDevGlobal(runtime); + resolve(runtime); + } 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 handleCoreReady( + state: RuntimeState, + coreId: number, + runtime: WorkerPairingHostRuntime | null, +): void { + const pending = state.pendingCores.get(coreId); + if (!pending || !runtime) return; + state.pendingCores.delete(coreId); + const core: CoreState = { + coreId, + productId: pending.productId, + listeners: new Set(), + closeListeners: new Set(), + closedError: null, + disposed: false, + }; + state.cores.set(coreId, core); + pending.resolve(buildProvider(state, core, runtime)); +} + +function handleCoreError( + state: RuntimeState, + coreId: number, + error: string, +): void { + const pending = state.pendingCores.get(coreId); + if (!pending) return; + state.pendingCores.delete(coreId); + pending.reject(new Error(error)); +} + +function handleFrameError( + state: RuntimeState, + coreId: number, + error: string, +): void { + console.error("[truapi worker]", error); + const core = state.cores.get(coreId); + if (!core) return; + closeCoreState(core, new Error(`worker frame error: ${error}`)); + state.cores.delete(coreId); + try { + state.worker.postMessage({ + kind: "disposeCore", + coreId, + } satisfies MainToWorker); + } catch { + // ignore if worker is already gone + } +} + +function buildRuntime(state: RuntimeState): WorkerPairingHostRuntime { + const runtime: WorkerPairingHostRuntime = { + createProvider(product): Promise { + if (state.disposed) { + return Promise.reject( + state.closedError ?? new Error("runtime disposed"), + ); + } + return new Promise((resolve, reject) => { + const coreId = ++state.nextCoreId; + state.pendingCores.set(coreId, { + productId: product.productId, + resolve, + reject, + }); + try { + state.worker.postMessage({ + kind: "createCore", + coreId, + product, + } satisfies MainToWorker); + } catch (err) { + state.pendingCores.delete(coreId); + reject(err instanceof Error ? err : new Error(String(err))); + } + }); + }, + disconnectSession(): Promise { + return sendWorkerRequest( + state, + state.pendingDisconnects, + () => ++nextDisconnectRequestId, + undefined, + (requestId) => ({ kind: "disconnectSession", requestId }), + ); + }, + cancelPairing(): void { + if (state.disposed) return; + state.worker.postMessage({ + kind: "cancelPairing", + } satisfies MainToWorker); + }, + notifySessionStoreChanged(): void { + if (state.disposed) return; + state.worker.postMessage({ + kind: "notifySessionStoreChanged", + } satisfies MainToWorker); + }, + getPermissionAuthorizationStatus(productId, request) { + return sendWorkerRequest( + state, + state.pendingPermissionAuthorizationStatuses, + () => ++nextPermissionAuthorizationRequestId, + "NotDetermined", + (requestId) => ({ + kind: "getPermissionAuthorizationStatus", + productId, + requestId, + request: encodePermissionAuthorizationRequest(request), + }), + ); + }, + getPermissionAuthorizationStatuses(productId, requests) { + return sendWorkerRequest( + state, + state.pendingPermissionAuthorizationStatusBatches, + () => ++nextPermissionAuthorizationRequestId, + requests.map(() => "NotDetermined"), + (requestId) => ({ + kind: "getPermissionAuthorizationStatuses", + productId, + requestId, + requests: requests.map(encodePermissionAuthorizationRequest), + }), + ); + }, + setPermissionAuthorizationStatus(productId, request, status) { + return sendWorkerRequest( + state, + state.pendingSetPermissionAuthorizationStatuses, + () => ++nextPermissionAuthorizationRequestId, + undefined, + (requestId) => ({ + kind: "setPermissionAuthorizationStatus", + productId, + requestId, + request: encodePermissionAuthorizationRequest(request), + status, + }), + ); + }, + setLogLevel(level): void { + if (state.disposed) return; + state.logLevel = level; + state.worker.postMessage({ + kind: "setLogLevel", + level, + } satisfies MainToWorker); + }, + dispose(): void { + devGlobalTargets.delete(runtime); + teardown(state, new Error("runtime disposed"), false); + }, + }; + return runtime; +} + +function buildProvider( + state: RuntimeState, + core: CoreState, + runtime: WorkerPairingHostRuntime, +): TrUApiProductProvider { + const provider: TrUApiProductProvider = { + postMessage(bytes: Uint8Array): void { + if (state.disposed || core.disposed) return; + if (debugLoggingEnabled(state)) { + console.debug("[truapi worker] frame ->", bytesToHex(bytes)); + } + state.worker.postMessage({ + kind: "frame", + coreId: core.coreId, + bytes, + } satisfies MainToWorker); + }, + subscribe(callback) { + core.listeners.add(callback); + return () => { + core.listeners.delete(callback); + }; + }, + subscribeClose(callback) { + const closed = core.closedError ?? state.closedError; + if (closed) { + callback(closed); + return () => {}; + } + core.closeListeners.add(callback); + return () => { + core.closeListeners.delete(callback); + }; + }, + disconnectSession(): Promise { + if (core.disposed) return Promise.resolve(); + return runtime.disconnectSession(); + }, + getPermissionAuthorizationStatus(request) { + if (core.disposed) return Promise.resolve("NotDetermined"); + return runtime.getPermissionAuthorizationStatus(core.productId, request); + }, + getPermissionAuthorizationStatuses(requests) { + if (core.disposed) { + return Promise.resolve(requests.map(() => "NotDetermined")); + } + return runtime.getPermissionAuthorizationStatuses( + core.productId, + requests, + ); + }, + setPermissionAuthorizationStatus(request, status) { + if (core.disposed) return Promise.resolve(); + return runtime.setPermissionAuthorizationStatus( + core.productId, + request, + status, + ); + }, + setLogLevel(level): void { + if (core.disposed) return; + runtime.setLogLevel(level); + }, + dispose(): void { + if (core.disposed) return; + closeCoreState(core, new Error("provider disposed")); + state.cores.delete(core.coreId); + state.worker.postMessage({ + kind: "disposeCore", + coreId: core.coreId, + } satisfies MainToWorker); + }, + }; + return provider; +} + +function exposeDevGlobal(target: { + setLogLevel?: (level: LogLevel) => void; +}): void { + devGlobalTargets.add(target); + if (devLogLevelOverride !== null) { + target.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 [...devGlobalTargets]) { + provider.setLogLevel?.(level); + } + console.info(`[truapi worker] logLevel=${level}`); + }, + getLogLevel(): LogLevel | null { + return devLogLevelOverride; + }, + }; +} + +publishDevGlobal(); diff --git a/js/packages/truapi-host/src/web/index.ts b/js/packages/truapi-host/src/web/index.ts new file mode 100644 index 00000000..acba3a71 --- /dev/null +++ b/js/packages/truapi-host/src/web/index.ts @@ -0,0 +1,9 @@ +export type { IframeHost, IframeHostOptions } from "./create-iframe-host.js"; +export { createIframeHost } from "./create-iframe-host.js"; +export type { + CreateWebWorkerPairingHostRuntimeOptions, + WebWorkerHostConfig, + WebWorkerHostCallbacks, + WorkerPairingHostRuntime, +} from "./create-worker-host-runtime.js"; +export { createWebWorkerPairingHostRuntime } from "./create-worker-host-runtime.js"; diff --git a/js/packages/truapi-host/src/web/worker-provider.test.ts b/js/packages/truapi-host/src/web/worker-provider.test.ts new file mode 100644 index 00000000..1644364c --- /dev/null +++ b/js/packages/truapi-host/src/web/worker-provider.test.ts @@ -0,0 +1,972 @@ +import { describe, expect, it } from "bun:test"; +import { err, 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 { AuthState, CoreStorageKey } from "../generated/host-callbacks.js"; +import type { + AuthState as AuthStateValue, + PreimageHost, +} from "../generated/host-callbacks.js"; +import type { + ProductRuntimeConfig, + TrUApiProductProvider, +} from "../runtime.js"; +import { makeHostCallbacks, settle } from "../test-support.js"; +import { createWebWorkerPairingHostRuntime } from "./index.js"; +import type { CreateWebWorkerPairingHostRuntimeOptions } 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 = {}, +): ProductRuntimeConfig { + 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, + }; +} + +function hostConfigFromRuntimeConfig( + config: ProductRuntimeConfig, +): CreateWebWorkerPairingHostRuntimeOptions["hostConfig"] { + const { productId: _productId, ...hostConfig } = config; + return hostConfig; +} + +function lastMessageOfKind(worker: FakeWorker, kind: string): WorkerMessage { + const message = [...worker.messages].reverse().find((m) => m.kind === kind); + expect(message).toBeDefined(); + return message!; +} + +async function finishProviderReady( + worker: FakeWorker, + providerPromise: Promise, +) { + await settle(); + const createCore = lastMessageOfKind(worker, "createCore"); + worker.emit({ kind: "coreReady", coreId: createCore.coreId }); + return providerPromise; +} + +type ReadyOptions = Partial< + Omit +> & { + createWebWorkerPairingHostRuntime?: typeof createWebWorkerPairingHostRuntime; + runtimeConfig?: ProductRuntimeConfig; +}; + +async function createProviderFromRuntime( + worker: Worker, + host: ReturnType, + options: ReadyOptions, +): Promise { + const { + createWebWorkerPairingHostRuntime: + createRuntime = createWebWorkerPairingHostRuntime, + runtimeConfig: cfg = runtimeConfig(), + ...runtimeOptions + } = options; + const runtime = await createRuntime(worker, host, { + ...runtimeOptions, + hostConfig: hostConfigFromRuntimeConfig(cfg), + }); + const provider = await runtime.createProvider({ productId: cfg.productId }); + return { + ...provider, + dispose(): void { + provider.dispose(); + runtime.dispose(); + }, + }; +} + +async function readyProvider(worker: FakeWorker, options: ReadyOptions = {}) { + const providerPromise = createProviderFromRuntime( + asWorker(worker), + makeHostCallbacks(), + options, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + return finishProviderReady(worker, 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("createWebWorkerPairingHostRuntime", () => { + it("initializes the worker without a callback manifest", async () => { + const worker = new FakeWorker(); + const config = runtimeConfig(); + const providerPromise = createProviderFromRuntime( + 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", + hostConfig: hostConfigFromRuntimeConfig(config), + }); + + worker.emit({ kind: "ready" }); + await settle(); + const createCore = lastMessageOfKind(worker, "createCore"); + expect(createCore).toEqual({ + kind: "createCore", + coreId: 1, + product: { productId: "dotli.dot" }, + }); + worker.emit({ kind: "coreReady", coreId: 1 }); + const provider = await providerPromise; + expect(typeof provider.disconnectSession).toBe("function"); + + provider.dispose(); + }); + + it("creates multiple product cores on one worker runtime", async () => { + const worker = new FakeWorker(); + const config = runtimeConfig(); + const runtimePromise = createWebWorkerPairingHostRuntime( + asWorker(worker), + makeHostCallbacks(), + { + hostConfig: hostConfigFromRuntimeConfig(config), + }, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const runtime = await runtimePromise; + + const firstPromise = runtime.createProvider({ productId: "first.dot" }); + const secondPromise = runtime.createProvider({ productId: "second.dot" }); + + expect(worker.messages.at(-2)).toEqual({ + kind: "createCore", + coreId: 1, + product: { productId: "first.dot" }, + }); + expect(worker.messages.at(-1)).toEqual({ + kind: "createCore", + coreId: 2, + product: { productId: "second.dot" }, + }); + + worker.emit({ kind: "coreReady", coreId: 1 }); + worker.emit({ kind: "coreReady", coreId: 2 }); + const first = await firstPromise; + const second = await secondPromise; + + const firstFrames: Uint8Array[] = []; + const secondFrames: Uint8Array[] = []; + first.subscribe((frame) => firstFrames.push(frame)); + second.subscribe((frame) => secondFrames.push(frame)); + + worker.emit({ kind: "frame", coreId: 2, bytes: new Uint8Array([2]) }); + worker.emit({ kind: "frame", coreId: 1, bytes: new Uint8Array([1]) }); + expect(firstFrames).toEqual([new Uint8Array([1])]); + expect(secondFrames).toEqual([new Uint8Array([2])]); + + first.postMessage(new Uint8Array([9])); + expect(worker.messages.at(-1)).toEqual({ + kind: "frame", + coreId: 1, + bytes: new Uint8Array([9]), + }); + + first.dispose(); + expect(worker.messages.at(-1)).toEqual({ kind: "disposeCore", coreId: 1 }); + + worker.emit({ kind: "frame", coreId: 2, bytes: new Uint8Array([3]) }); + expect(firstFrames).toEqual([new Uint8Array([1])]); + expect(secondFrames).toEqual([new Uint8Array([2]), new Uint8Array([3])]); + + runtime.dispose(); + expect(worker.messages.at(-1)).toEqual({ kind: "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 { + createWebWorkerPairingHostRuntime: freshCreateWebWorkerPairingHostRuntime, + } = (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, { + createWebWorkerPairingHostRuntime: freshCreateWebWorkerPairingHostRuntime, + }); + first.dispose(); + + const secondWorker = new FakeWorker(); + const second = await readyProvider(secondWorker, { + createWebWorkerPairingHostRuntime: freshCreateWebWorkerPairingHostRuntime, + }); + + expect(secondWorker.messages[0].kind).toBe("init"); + expect(secondWorker.messages[0].logLevel).toBe("trace"); + expect( + secondWorker.messages.some((message) => { + return message.kind === "setLogLevel" && message.level === "trace"; + }), + ).toBe(true); + + 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 = createProviderFromRuntime( + asWorker(worker), + makeHostCallbacks(), + { + runtimeConfig: runtimeConfig(), + }, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const provider = await finishProviderReady(worker, 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 = createProviderFromRuntime( + asWorker(worker), + makeHostCallbacks({ + coreStorage: { + 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 finishProviderReady(worker, 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 = createProviderFromRuntime( + asWorker(worker), + makeHostCallbacks(), + { + runtimeConfig: runtimeConfig(), + }, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const provider = await finishProviderReady(worker, 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: AuthStateValue[] = []; + const providerPromise = createProviderFromRuntime( + asWorker(worker), + makeHostCallbacks({ + auth: { + authStateChanged: (state) => { + states.push(state); + }, + }, + }), + { runtimeConfig: runtimeConfig() }, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const provider = await finishProviderReady(worker, providerPromise); + const publicKey = new Uint8Array(32); + publicKey.set([1, 2]); + + worker.emit({ + kind: "callbackRequest", + requestId: 3, + name: "authStateChanged", + args: [ + AuthState.enc({ + tag: "Connected", + value: { + publicKey, + liteUsername: "alice", + }, + }), + ], + }); + await settle(); + + expect(states).toEqual([ + { + tag: "Connected", + value: { + publicKey, + liteUsername: "alice", + }, + }, + ]); + expect(worker.messages.at(-1)).toEqual({ + kind: "callbackResponse", + requestId: 3, + ok: true, + value: undefined, + }); + + provider.dispose(); + }); + + it("revives Bulletin allowance signer handles for submitPreimage", async () => { + const worker = new FakeWorker(); + const publicKey = new Uint8Array(32); + publicKey.set([1, 2, 3]); + const value = new Uint8Array([10, 11, 12]); + const signingPayload = new Uint8Array([4, 5, 6]); + const signature = new Uint8Array(64); + signature.set([9, 8, 7]); + const result = new Uint8Array([30, 31, 32]); + const seen: { + publicKey?: Uint8Array; + signature?: Uint8Array; + value?: Uint8Array; + } = {}; + + const submitPreimage: PreimageHost["submitPreimage"] = async ( + submittedValue, + signer, + ) => { + seen.value = submittedValue; + seen.publicKey = signer.publicKey; + seen.signature = await signer.sign(signingPayload); + return result; + }; + const providerPromise = createProviderFromRuntime( + asWorker(worker), + makeHostCallbacks({ preimage: { submitPreimage } }), + { runtimeConfig: runtimeConfig() }, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const provider = await finishProviderReady(worker, providerPromise); + + worker.emit({ + kind: "callbackRequest", + requestId: 21, + name: "submitPreimage", + args: [value, { publicKey, signerId: 7 }], + }); + await settle(); + + const signRequest = lastMessageOfKind(worker, "signBulletinAllowance"); + expect(signRequest.kind).toBe("signBulletinAllowance"); + expect(signRequest.signerId).toBe(7); + expect(signRequest.input).toEqual(signingPayload); + expect(typeof signRequest.requestId).toBe("number"); + + worker.emit({ + kind: "signBulletinAllowanceResponse", + requestId: signRequest.requestId, + ok: true, + signature, + }); + await settle(); + + expect(seen).toEqual({ + value, + publicKey, + signature, + }); + expect(worker.messages.at(-1)).toEqual({ + kind: "callbackResponse", + requestId: 21, + ok: true, + value: result, + }); + + provider.dispose(); + }); + + it("posts cancelPairing to the worker", async () => { + const worker = new FakeWorker(); + const config = runtimeConfig(); + const runtimePromise = createWebWorkerPairingHostRuntime( + asWorker(worker), + makeHostCallbacks(), + { + hostConfig: hostConfigFromRuntimeConfig(config), + }, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const runtime = await runtimePromise; + + runtime.cancelPairing(); + + expect(worker.messages.at(-1)).toEqual({ kind: "cancelPairing" }); + runtime.dispose(); + }); + + it("posts notifySessionStoreChanged to the worker", async () => { + const worker = new FakeWorker(); + const config = runtimeConfig(); + const runtimePromise = createWebWorkerPairingHostRuntime( + asWorker(worker), + makeHostCallbacks(), + { + hostConfig: hostConfigFromRuntimeConfig(config), + }, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const runtime = await runtimePromise; + + runtime.notifySessionStoreChanged(); + + expect(worker.messages.at(-1)).toEqual({ + kind: "notifySessionStoreChanged", + }); + runtime.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 = createProviderFromRuntime( + asWorker(worker), + makeHostCallbacks({ + // Manual async iterables whose `return()` records disposal; the + // provider disposes subscriptions and closes chain connections + // on a worker fault. + theme: { + subscribeTheme: () => + ({ + [Symbol.asyncIterator]() { + return this; + }, + next: () => new Promise(() => {}), + return: async () => { + subscriptionDisposes += 1; + return { done: true, value: undefined }; + }, + }) as unknown as AsyncIterable>, + }, + chain: { + 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 finishProviderReady(worker, providerPromise); + + worker.emit({ + kind: "subscriptionStart", + subId: 1, + name: "subscribeTheme", + payload: null, + }); + worker.emit({ + kind: "chainConnectStart", + connId: 1, + genesisHash: "0xab", + }); + await settle(); + + const closes: Error[] = []; + provider.subscribeClose!((error) => closes.push(error)); + + worker.emitError("boom"); + 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 = createProviderFromRuntime( + 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", coreId: 1, error: "bad frame" }); + + expect(worker.terminated).toBe(false); + expect(worker.messages.at(-1)).toEqual({ kind: "disposeCore", coreId: 1 }); + 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); + provider.dispose(); + }); + + it("routes payload-carrying subscriptions by name", async () => { + const worker = new FakeWorker(); + const keys: Uint8Array[] = []; + const providerPromise = createProviderFromRuntime( + asWorker(worker), + makeHostCallbacks({ + preimage: { + 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 finishProviderReady(worker, providerPromise); + + worker.emit({ + kind: "subscriptionStart", + subId: 4, + name: "lookupPreimage", + payload: new Uint8Array([9, 9]), + }); + + 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("propagates host subscription stream errors to the worker", async () => { + const worker = new FakeWorker(); + let resolveSubscriptionError!: (message: WorkerMessage) => void; + const subscriptionErrorSeen = new Promise((resolve) => { + resolveSubscriptionError = resolve; + }); + const postMessage = worker.postMessage.bind(worker); + worker.postMessage = (message) => { + postMessage(message); + if (message.kind === "subscriptionError") { + resolveSubscriptionError(message); + } + }; + const providerPromise = createProviderFromRuntime( + asWorker(worker), + makeHostCallbacks({ + theme: { + subscribeTheme: async function* () { + yield err({ + reason: "theme stream failed", + }); + }, + }, + }), + { runtimeConfig: runtimeConfig() }, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const provider = await finishProviderReady(worker, providerPromise); + + worker.emit({ + kind: "subscriptionStart", + subId: 7, + name: "subscribeTheme", + payload: null, + }); + + await settle(); + + expect(await subscriptionErrorSeen).toEqual({ + kind: "subscriptionError", + subId: 7, + error: "theme stream failed", + }); + + provider.dispose(); + }); + + it("never falls through unknown subscription names to another callback", async () => { + const worker = new FakeWorker(); + let preimageStarts = 0; + const providerPromise = createProviderFromRuntime( + asWorker(worker), + makeHostCallbacks({ + preimage: { + lookupPreimage: (() => { + preimageStarts += 1; + return () => {}; + }) as unknown as PreimageHost["lookupPreimage"], + }, + }), + { runtimeConfig: runtimeConfig() }, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const provider = await finishProviderReady(worker, 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 = createProviderFromRuntime( + asWorker(worker), + makeHostCallbacks({ + preimage: { + lookupPreimage: (() => { + preimageStarts += 1; + return () => {}; + }) as unknown as PreimageHost["lookupPreimage"], + }, + }), + { runtimeConfig: runtimeConfig() }, + ); + worker.emit({ kind: "loaded" }); + worker.emit({ kind: "ready" }); + const provider = await finishProviderReady(worker, 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 = createProviderFromRuntime( + 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 = createProviderFromRuntime( + 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({ + notifications: { + 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/src/worker-dispatch.test.ts b/js/packages/truapi-host/src/worker-dispatch.test.ts new file mode 100644 index 00000000..3b5ea55c --- /dev/null +++ b/js/packages/truapi-host/src/worker-dispatch.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "bun:test"; + +import { + dispatchChainResponse, + dispatchSubscriptionError, + dispatchSubscriptionItem, +} from "./worker-dispatch.js"; +import type { WorkerToMain } from "./worker-protocol.js"; + +describe("worker dispatch guards", () => { + it("stops a subscription when its WASM listener throws", () => { + const messages: WorkerToMain[] = []; + const listeners = new Map([ + [ + 7, + { + sendItem() { + throw new Error("panic"); + }, + sendError() {}, + }, + ], + ]); + + expect(() => + dispatchSubscriptionItem(7, "item", listeners, (msg) => + messages.push(msg), + ), + ).not.toThrow(); + + expect(listeners.has(7)).toBe(false); + expect(messages).toEqual([ + { kind: "subscriptionStop", subId: 7 }, + { kind: "disposeError", error: "subscription 7 callback failed: panic" }, + ]); + }); + + it("forwards subscription stream errors to the WASM listener", () => { + const messages: WorkerToMain[] = []; + const errors: string[] = []; + const listeners = new Map([ + [ + 8, + { + sendItem() {}, + sendError(error: string) { + errors.push(error); + }, + }, + ], + ]); + + dispatchSubscriptionError(8, "stream failed", listeners, (msg) => + messages.push(msg), + ); + + expect(errors).toEqual(["stream failed"]); + expect(messages).toEqual([]); + }); + + it("closes a chain connection when its WASM listener throws", () => { + const messages: WorkerToMain[] = []; + const listeners = new Map void>([ + [ + 11, + () => { + throw new Error("panic"); + }, + ], + ]); + + expect(() => + dispatchChainResponse(11, "{}", listeners, (msg) => messages.push(msg)), + ).not.toThrow(); + + expect(listeners.has(11)).toBe(false); + expect(messages).toEqual([ + { kind: "chainClose", connId: 11 }, + { + kind: "disposeError", + error: "chain connection 11 callback failed: panic", + }, + ]); + }); +}); diff --git a/js/packages/truapi-host/src/worker-dispatch.ts b/js/packages/truapi-host/src/worker-dispatch.ts new file mode 100644 index 00000000..8ac1396f --- /dev/null +++ b/js/packages/truapi-host/src/worker-dispatch.ts @@ -0,0 +1,71 @@ +import { errorMessage } from "./error.js"; +import type { WorkerToMain } from "./worker-protocol.js"; + +type PostToMain = (msg: WorkerToMain) => void; + +export interface SubscriptionListeners { + sendItem: (value: unknown) => void; + sendError: (error: string) => void; +} + +function reportDispatchFailure( + postToMain: PostToMain, + label: string, + err: unknown, +): void { + postToMain({ + kind: "disposeError", + error: `${label} callback failed: ${errorMessage(err)}`, + }); +} + +export function dispatchSubscriptionItem( + subId: number, + value: unknown, + listeners: Map, + postToMain: PostToMain, +): void { + const listener = listeners.get(subId); + if (!listener) return; + try { + listener.sendItem(value); + } catch (err) { + listeners.delete(subId); + postToMain({ kind: "subscriptionStop", subId }); + reportDispatchFailure(postToMain, `subscription ${subId}`, err); + } +} + +export function dispatchSubscriptionError( + subId: number, + error: string, + listeners: Map, + postToMain: PostToMain, +): void { + const listener = listeners.get(subId); + if (!listener) return; + try { + listener.sendError(error); + } catch (err) { + listeners.delete(subId); + postToMain({ kind: "subscriptionStop", subId }); + reportDispatchFailure(postToMain, `subscription ${subId} error`, err); + } +} + +export function dispatchChainResponse( + connId: number, + json: string, + listeners: Map void>, + postToMain: PostToMain, +): void { + const listener = listeners.get(connId); + if (!listener) return; + try { + listener(json); + } catch (err) { + listeners.delete(connId); + postToMain({ kind: "chainClose", connId }); + reportDispatchFailure(postToMain, `chain connection ${connId}`, err); + } +} diff --git a/js/packages/truapi-host/src/worker-permission-authorization.test.ts b/js/packages/truapi-host/src/worker-permission-authorization.test.ts new file mode 100644 index 00000000..adb49080 --- /dev/null +++ b/js/packages/truapi-host/src/worker-permission-authorization.test.ts @@ -0,0 +1,189 @@ +import { describe, expect, it } from "bun:test"; + +import type { PermissionAuthorizationStatus } from "./runtime.js"; +import type { WorkerToMain } from "./worker-protocol.js"; +import { + handleGetPermissionAuthorizationStatus, + handleGetPermissionAuthorizationStatuses, + handleSetPermissionAuthorizationStatus, + type PermissionAuthorizationRuntime, +} from "./worker-permission-authorization.js"; + +const PRODUCT_ID = "playground.dot"; + +function recordMessages() { + const messages: WorkerToMain[] = []; + return { + messages, + postToMain(msg: WorkerToMain): void { + messages.push(msg); + }, + }; +} + +function makeRuntime( + overrides: Partial = {}, +): PermissionAuthorizationRuntime { + return { + permissionAuthorizationStatus: async () => "NotDetermined", + permissionAuthorizationStatuses: async (productId, requests) => { + void productId; + return requests.map(() => "NotDetermined"); + }, + setPermissionAuthorizationStatus: async () => {}, + ...overrides, + }; +} + +describe("worker permission authorization handlers", () => { + it("responds with a single permission authorization status", async () => { + const { messages, postToMain } = recordMessages(); + const request = new Uint8Array([1, 2, 3]); + const calls: { productId: string; request: Uint8Array }[] = []; + const runtime = makeRuntime({ + permissionAuthorizationStatus: async (productId, receivedRequest) => { + calls.push({ productId, request: receivedRequest }); + return "Authorized"; + }, + }); + + await handleGetPermissionAuthorizationStatus( + runtime, + postToMain, + PRODUCT_ID, + 7, + request, + ); + + expect(calls).toEqual([{ productId: PRODUCT_ID, request }]); + expect(messages).toEqual([ + { + kind: "permissionAuthorizationStatusResponse", + requestId: 7, + ok: true, + status: "Authorized", + }, + ]); + }); + + it("responds with batched permission authorization statuses", async () => { + const { messages, postToMain } = recordMessages(); + const requests = [new Uint8Array([1]), new Uint8Array([2])]; + const statuses: PermissionAuthorizationStatus[] = ["Denied", "Authorized"]; + const calls: { productId: string; requests: Uint8Array[] }[] = []; + const runtime = makeRuntime({ + permissionAuthorizationStatuses: async (productId, receivedRequests) => { + calls.push({ productId, requests: receivedRequests }); + return statuses; + }, + }); + + await handleGetPermissionAuthorizationStatuses( + runtime, + postToMain, + PRODUCT_ID, + 8, + requests, + ); + + expect(calls).toEqual([{ productId: PRODUCT_ID, requests }]); + expect(messages).toEqual([ + { + kind: "permissionAuthorizationStatusesResponse", + requestId: 8, + ok: true, + statuses, + }, + ]); + }); + + it("responds after setting a permission authorization status", async () => { + const { messages, postToMain } = recordMessages(); + const request = new Uint8Array([9, 10]); + const calls: { + productId: string; + request: Uint8Array; + status: PermissionAuthorizationStatus; + }[] = []; + const runtime = makeRuntime({ + setPermissionAuthorizationStatus: async ( + productId, + receivedRequest, + status, + ) => { + calls.push({ productId, request: receivedRequest, status }); + }, + }); + + await handleSetPermissionAuthorizationStatus( + runtime, + postToMain, + PRODUCT_ID, + 9, + request, + "Denied", + ); + + expect(calls).toEqual([ + { productId: PRODUCT_ID, request, status: "Denied" }, + ]); + expect(messages).toEqual([ + { + kind: "setPermissionAuthorizationStatusResponse", + requestId: 9, + ok: true, + }, + ]); + }); + + it("reports permission authorization requests received before runtime is ready", async () => { + const { messages, postToMain } = recordMessages(); + const request = new Uint8Array([1]); + + await handleGetPermissionAuthorizationStatus( + null, + postToMain, + PRODUCT_ID, + 1, + request, + ); + await handleGetPermissionAuthorizationStatuses( + null, + postToMain, + PRODUCT_ID, + 2, + [request], + ); + await handleSetPermissionAuthorizationStatus( + null, + postToMain, + PRODUCT_ID, + 3, + request, + "Authorized", + ); + + expect(messages).toEqual([ + { + kind: "permissionAuthorizationStatusResponse", + requestId: 1, + ok: false, + error: "permissionAuthorizationStatus received before runtime is ready", + }, + { + kind: "permissionAuthorizationStatusesResponse", + requestId: 2, + ok: false, + error: + "permissionAuthorizationStatuses received before runtime is ready", + }, + { + kind: "setPermissionAuthorizationStatusResponse", + requestId: 3, + ok: false, + error: + "setPermissionAuthorizationStatus received before runtime is ready", + }, + ]); + }); +}); diff --git a/js/packages/truapi-host/src/worker-permission-authorization.ts b/js/packages/truapi-host/src/worker-permission-authorization.ts new file mode 100644 index 00000000..5e1b03c4 --- /dev/null +++ b/js/packages/truapi-host/src/worker-permission-authorization.ts @@ -0,0 +1,129 @@ +import type { PermissionAuthorizationStatus } from "./runtime.js"; +import type { WorkerToMain } from "./worker-protocol.js"; +import { errorMessage } from "./error.js"; + +export interface PermissionAuthorizationRuntime { + permissionAuthorizationStatus( + productId: string, + request: Uint8Array, + ): Promise; + permissionAuthorizationStatuses( + productId: string, + requests: Uint8Array[], + ): Promise; + setPermissionAuthorizationStatus( + productId: string, + request: Uint8Array, + status: PermissionAuthorizationStatus, + ): Promise; +} + +type PostToMain = (msg: WorkerToMain) => void; + +export async function handleGetPermissionAuthorizationStatus( + runtime: PermissionAuthorizationRuntime | null, + postToMain: PostToMain, + productId: string, + requestId: number, + request: Uint8Array, +): Promise { + if (!runtime) { + postToMain({ + kind: "permissionAuthorizationStatusResponse", + requestId, + ok: false, + error: "permissionAuthorizationStatus received before runtime is ready", + }); + return; + } + try { + const status = await runtime.permissionAuthorizationStatus( + productId, + request, + ); + postToMain({ + kind: "permissionAuthorizationStatusResponse", + requestId, + ok: true, + status, + }); + } catch (err) { + postToMain({ + kind: "permissionAuthorizationStatusResponse", + requestId, + ok: false, + error: errorMessage(err), + }); + } +} + +export async function handleGetPermissionAuthorizationStatuses( + runtime: PermissionAuthorizationRuntime | null, + postToMain: PostToMain, + productId: string, + requestId: number, + requests: Uint8Array[], +): Promise { + if (!runtime) { + postToMain({ + kind: "permissionAuthorizationStatusesResponse", + requestId, + ok: false, + error: "permissionAuthorizationStatuses received before runtime is ready", + }); + return; + } + try { + const statuses = await runtime.permissionAuthorizationStatuses( + productId, + requests, + ); + postToMain({ + kind: "permissionAuthorizationStatusesResponse", + requestId, + ok: true, + statuses, + }); + } catch (err) { + postToMain({ + kind: "permissionAuthorizationStatusesResponse", + requestId, + ok: false, + error: errorMessage(err), + }); + } +} + +export async function handleSetPermissionAuthorizationStatus( + runtime: PermissionAuthorizationRuntime | null, + postToMain: PostToMain, + productId: string, + requestId: number, + request: Uint8Array, + status: PermissionAuthorizationStatus, +): Promise { + if (!runtime) { + postToMain({ + kind: "setPermissionAuthorizationStatusResponse", + requestId, + ok: false, + error: "setPermissionAuthorizationStatus received before runtime is ready", + }); + return; + } + try { + await runtime.setPermissionAuthorizationStatus(productId, request, status); + postToMain({ + kind: "setPermissionAuthorizationStatusResponse", + requestId, + ok: true, + }); + } catch (err) { + postToMain({ + kind: "setPermissionAuthorizationStatusResponse", + requestId, + ok: false, + error: errorMessage(err), + }); + } +} diff --git a/js/packages/truapi-host/src/worker-protocol.ts b/js/packages/truapi-host/src/worker-protocol.ts new file mode 100644 index 00000000..193a4b83 --- /dev/null +++ b/js/packages/truapi-host/src/worker-protocol.ts @@ -0,0 +1,183 @@ +// Wire format between the main thread (`createWebWorkerPairingHostRuntime`) and the +// Web Worker that hosts the truapi-server WASM runtime. +// +// Main window / host JS +// ┌─────────────────────────────────────────────────────────────────┐ +// │ createWebWorkerPairingHostRuntime │ +// │ host callbacks: storage, DOM prompts, chain provider, logging │ +// └───────────────┬─────────────────────────────────────────────────┘ +// │ MainToWorker: init, createCore, frame, +// │ callbackResponse, subscriptionItem, +// │ chainResponse +// v +// Dedicated Worker +// ┌─────────────────────────────────────────────────────────────────┐ +// │ shared truapi-server WASM PairingHostRuntime + product runtimes │ +// │ generated raw-callback proxy │ +// └───────────────┬─────────────────────────────────────────────────┘ +// │ WorkerToMain: coreReady, 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; hostConfig: unknown } + | { kind: "createCore"; coreId: number; product: unknown } + | { kind: "disposeCore"; coreId: number } + | { kind: "setLogLevel"; level: LogLevel } + | { kind: "frame"; coreId: number; bytes: Uint8Array } + | { kind: "disconnectSession"; requestId: number } + | { kind: "cancelPairing" } + | { kind: "notifySessionStoreChanged" } + | { + kind: "getPermissionAuthorizationStatus"; + productId: string; + requestId: number; + request: Uint8Array; + } + | { + kind: "getPermissionAuthorizationStatuses"; + productId: string; + requestId: number; + requests: Uint8Array[]; + } + | { + kind: "setPermissionAuthorizationStatus"; + productId: string; + requestId: number; + request: Uint8Array; + status: PermissionAuthorizationStatus; + } + | { kind: "callbackResponse"; requestId: number; ok: true; value: unknown } + | { kind: "callbackResponse"; requestId: number; ok: false; error: string } + | { + kind: "signBulletinAllowance"; + requestId: number; + signerId: number; + input: Uint8Array; + } + | { kind: "subscriptionItem"; subId: number; value: unknown } + | { kind: "subscriptionError"; subId: number; error: string } + | { 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: "coreReady"; coreId: number } + | { kind: "coreError"; coreId: number; error: string } + | { kind: "fatalError"; error: string } + | { kind: "frameError"; coreId: number; error: string } + | { kind: "disposeError"; error: string } + | { kind: "frame"; coreId: number; 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: "signBulletinAllowanceResponse"; + requestId: number; + ok: true; + signature: Uint8Array; + } + | { + kind: "signBulletinAllowanceResponse"; + requestId: number; + ok: false; + error: string; + } + | { + 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/src/worker-runtime.ts b/js/packages/truapi-host/src/worker-runtime.ts new file mode 100644 index 00000000..8be5bd41 --- /dev/null +++ b/js/packages/truapi-host/src/worker-runtime.ts @@ -0,0 +1,513 @@ +/// +// 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 type { GenericError } from "@parity/truapi"; +import { + createWorkerRawCallbacks, + type CallbackName, +} from "./generated/worker-callbacks.js"; +import { + handleGetPermissionAuthorizationStatus, + handleGetPermissionAuthorizationStatuses, + handleSetPermissionAuthorizationStatus, + type PermissionAuthorizationRuntime, +} from "./worker-permission-authorization.js"; +import { errorMessage } from "./error.js"; +import { + dispatchChainResponse, + dispatchSubscriptionError, + dispatchSubscriptionItem, + type SubscriptionListeners, +} from "./worker-dispatch.js"; + +interface WorkerProductRuntime { + receiveFrame(frame: Uint8Array): Promise; + dispose(): void; + free(): void; +} + +interface WorkerPairingHostRuntime extends PermissionAuthorizationRuntime { + productRuntime( + product: unknown, + coreCallbacks: unknown, + ): WorkerProductRuntime; + disconnectSession(): Promise; + cancelPairing(): void; + notifySessionStoreChanged(): void; + free(): void; +} + +interface WasmModuleShape { + default: (input?: unknown) => Promise; + WasmPairingHostRuntime: new ( + callbacks: unknown, + hostConfig: unknown, + ) => WorkerPairingHostRuntime; + WasmProductRuntime: new ( + callbacks: unknown, + runtimeConfig: unknown, + ) => WorkerProductRuntime; + 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 +>(); + +interface BulletinAllowanceSigner { + publicKey: Uint8Array; + sign(input: Uint8Array): Promise; +} + +interface BulletinAllowanceSignerHandle { + publicKey: Uint8Array; + signerId: number; +} + +let nextBulletinAllowanceSignerId = 0; +const bulletinAllowanceSigners = new Map(); + +let nextSubId = 0; +const subscriptionListeners = new Map(); + +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; + const signerIds = collectBulletinAllowanceSignerIds(args); + pendingCallbacks.set(requestId, (r) => { + for (const signerId of signerIds) { + bulletinAllowanceSigners.delete(signerId); + } + if (r.ok) resolve(r.value); + else reject(new Error(r.error)); + }); + postToMain({ kind: "callbackRequest", requestId, name, args }); + }); +} + +function registerBulletinAllowanceSigner( + signer: BulletinAllowanceSigner, +): BulletinAllowanceSignerHandle { + const signerId = ++nextBulletinAllowanceSignerId; + bulletinAllowanceSigners.set(signerId, signer); + return { publicKey: signer.publicKey, signerId }; +} + +function collectBulletinAllowanceSignerIds(args: readonly unknown[]): number[] { + return args.flatMap((arg) => { + if ( + typeof arg === "object" && + arg !== null && + "signerId" in arg && + typeof arg.signerId === "number" + ) { + return [arg.signerId]; + } + return []; + }); +} + +function startSubscription( + name: SubscriptionName, + payload: Uint8Array | null, + sendItem: (value: T) => void, + sendError: (error: GenericError) => void, +): () => void { + const subId = ++nextSubId; + subscriptionListeners.set(subId, { + sendItem: sendItem as (value: unknown) => void, + sendError: (error) => sendError({ reason: error }), + }); + postToMain({ kind: "subscriptionStart", subId, name, payload }); + return () => { + subscriptionListeners.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 host-level callback object passed to the WASM runtime. */ +function buildRawCallbacks() { + return createWorkerRawCallbacks({ + callbackRequest, + registerBulletinAllowanceSigner, + startSubscription, + chainConnect, + }); +} + +function buildCoreCallbacks(coreId: number) { + return { + emitFrame(frame: Uint8Array): void { + postToMain({ kind: "frame", coreId, bytes: frame }); + }, + dispose(): void { + // Main thread owns lifecycle and disposes explicitly. + }, + }; +} + +let runtime: WorkerPairingHostRuntime | null = null; +const cores = new Map(); +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 (runtime) { + postToMain({ + kind: "fatalError", + error: "init: runtime already initialized", + }); + break; + } + wasm.setLogLevel?.(msg.logLevel); + try { + runtime = new wasm.WasmPairingHostRuntime( + buildRawCallbacks(), + msg.hostConfig, + ); + postToMain({ kind: "ready" }); + } catch (err) { + postToMain({ kind: "fatalError", error: `init: ${errorMessage(err)}` }); + } + break; + case "createCore": + if (!runtime) { + postToMain({ + kind: "coreError", + coreId: msg.coreId, + error: "createCore received before runtime is ready", + }); + break; + } + try { + const core = runtime.productRuntime( + msg.product, + buildCoreCallbacks(msg.coreId), + ); + cores.set(msg.coreId, core); + postToMain({ kind: "coreReady", coreId: msg.coreId }); + } catch (err) { + postToMain({ + kind: "coreError", + coreId: msg.coreId, + error: errorMessage(err), + }); + } + break; + case "setLogLevel": + wasm?.setLogLevel?.(msg.level); + break; + case "frame": + void handleFrame(msg.coreId, msg.bytes); + break; + case "disconnectSession": + void handleDisconnectSession(msg.requestId); + break; + case "cancelPairing": + runtime?.cancelPairing(); + break; + case "notifySessionStoreChanged": + runtime?.notifySessionStoreChanged(); + break; + case "getPermissionAuthorizationStatus": + void handleGetPermissionAuthorizationStatus( + runtime, + postToMain, + msg.productId, + msg.requestId, + msg.request, + ); + break; + case "getPermissionAuthorizationStatuses": + void handleGetPermissionAuthorizationStatuses( + runtime, + postToMain, + msg.productId, + msg.requestId, + msg.requests, + ); + break; + case "setPermissionAuthorizationStatus": + void handleSetPermissionAuthorizationStatus( + runtime, + postToMain, + msg.productId, + 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 "signBulletinAllowance": + void handleSignBulletinAllowance(msg.requestId, msg.signerId, msg.input); + break; + case "subscriptionItem": { + dispatchSubscriptionItem( + msg.subId, + msg.value, + subscriptionListeners, + postToMain, + ); + break; + } + case "subscriptionError": { + dispatchSubscriptionError( + msg.subId, + msg.error, + subscriptionListeners, + postToMain, + ); + 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": { + dispatchChainResponse( + msg.connId, + msg.json, + chainResponseListeners, + postToMain, + ); + break; + } + case "disposeCore": + disposeCore(msg.coreId); + break; + case "dispose": + try { + for (const coreId of [...cores.keys()]) { + disposeCore(coreId); + } + runtime?.free(); + } catch (err) { + postToMain({ kind: "disposeError", error: errorMessage(err) }); + } + bulletinAllowanceSigners.clear(); + runtime = null; + break; + default: { + const { kind } = msg as { kind?: unknown }; + console.warn( + `[truapi worker-runtime] unknown message kind: ${String(kind)}`, + ); + } + } +}); + +function disposeCore(coreId: number): void { + const core = cores.get(coreId); + if (!core) return; + cores.delete(coreId); + try { + core.dispose(); + core.free(); + } catch (err) { + postToMain({ kind: "disposeError", error: errorMessage(err) }); + } +} + +async function handleSignBulletinAllowance( + requestId: number, + signerId: number, + input: Uint8Array, +): Promise { + const signer = bulletinAllowanceSigners.get(signerId); + if (!signer) { + postToMain({ + kind: "signBulletinAllowanceResponse", + requestId, + ok: false, + error: `unknown Bulletin allowance signer: ${signerId}`, + }); + return; + } + + try { + const signature = await signer.sign(input); + postToMain({ + kind: "signBulletinAllowanceResponse", + requestId, + ok: true, + signature, + }); + } catch (err) { + postToMain({ + kind: "signBulletinAllowanceResponse", + requestId, + ok: false, + error: errorMessage(err), + }); + } +} + +async function handleDisconnectSession(requestId: number): Promise { + if (!runtime) { + postToMain({ + kind: "disconnectSessionResponse", + requestId, + ok: false, + error: "disconnectSession received before runtime is ready", + }); + return; + } + try { + await runtime.disconnectSession(); + postToMain({ kind: "disconnectSessionResponse", requestId, ok: true }); + } catch (err) { + postToMain({ + kind: "disconnectSessionResponse", + requestId, + ok: false, + error: errorMessage(err), + }); + } +} + +async function handleFrame(coreId: number, bytes: Uint8Array): Promise { + const core = cores.get(coreId); + if (!core) { + postToMain({ + kind: "frameError", + coreId, + error: `frame received for unknown core ${coreId}`, + }); + return; + } + try { + await core.receiveFrame(bytes); + } catch (err) { + postToMain({ + kind: "frameError", + coreId, + error: errorMessage(err), + }); + } +} diff --git a/js/packages/truapi-host/tsconfig.json b/js/packages/truapi-host/tsconfig.json new file mode 100644 index 00000000..9d2dcad8 --- /dev/null +++ b/js/packages/truapi-host/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.test.ts b/js/packages/truapi/src/sandbox.test.ts new file mode 100644 index 00000000..523e66bc --- /dev/null +++ b/js/packages/truapi/src/sandbox.test.ts @@ -0,0 +1,168 @@ +import { afterEach, describe, expect, it, mock } from "bun:test"; + +let importCounter = 0; + +async function importSandbox(): Promise { + importCounter += 1; + return import(`./sandbox.ts?test=${importCounter}`); +} + +type MessageListener = (event: MessageEvent) => void; + +function installFakeIframeWindow(options: { + referrer?: string; + ancestorOrigins?: string[]; +}) { + const listeners = new Set(); + const priorWindow = globalThis.window; + const priorDocument = globalThis.document; + const parentPostMessage = mock((_message: unknown, _origin: string) => {}); + const parent = { + postMessage: parentPostMessage, + } as unknown as Window; + const win = { + parent, + top: {} as Window, + location: { + ancestorOrigins: options.ancestorOrigins, + }, + addEventListener(name: string, callback: EventListener) { + if (name === "message") listeners.add(callback as MessageListener); + }, + removeEventListener(name: string, callback: EventListener) { + if (name === "message") listeners.delete(callback as MessageListener); + }, + } as unknown as Window & typeof globalThis; + + globalThis.window = win; + globalThis.document = { + referrer: options.referrer ?? "", + } as Document; + + return { + listeners, + parent, + parentPostMessage, + win, + dispatch(event: { + source: unknown; + origin: string; + data: unknown; + ports?: MessagePort[]; + }) { + for (const listener of [...listeners]) { + listener({ ports: [], ...event } as MessageEvent); + } + }, + restore() { + if (priorWindow === undefined) { + delete (globalThis as { window?: unknown }).window; + } else { + globalThis.window = priorWindow; + } + if (priorDocument === undefined) { + delete (globalThis as { document?: unknown }).document; + } else { + globalThis.document = priorDocument; + } + }, + }; +} + +let currentWindow: ReturnType | null = null; +const openPorts: MessagePort[] = []; + +function trackChannel(): MessageChannel { + const channel = new MessageChannel(); + openPorts.push(channel.port1, channel.port2); + return channel; +} + +afterEach(() => { + for (const port of openPorts.splice(0)) { + port.close(); + } + currentWindow?.restore(); + currentWindow = null; +}); + +describe("sandbox iframe MessagePort handshake", () => { + it("posts ready to the resolved host origin and rejects non-parent or mismatched init messages", async () => { + currentWindow = installFakeIframeWindow({ + referrer: "https://host.example/product", + }); + const sandbox = await importSandbox(); + + expect(sandbox.getClientSync()).not.toBeNull(); + expect(currentWindow.parentPostMessage.mock.calls).toEqual([ + [{ type: "truapi-ready" }, "https://host.example"], + ]); + + const wrongSource = trackChannel(); + currentWindow.dispatch({ + source: {}, + origin: "https://host.example", + data: { type: "truapi-init" }, + ports: [wrongSource.port1], + }); + const wrongOrigin = trackChannel(); + currentWindow.dispatch({ + source: currentWindow.parent, + origin: "https://attacker.example", + data: { type: "truapi-init" }, + ports: [wrongOrigin.port1], + }); + const opaqueOrigin = trackChannel(); + currentWindow.dispatch({ + source: currentWindow.parent, + origin: "null", + data: { type: "truapi-init" }, + ports: [opaqueOrigin.port1], + }); + await Promise.resolve(); + expect(currentWindow.win.__HOST_API_PORT__).toBeUndefined(); + expect(currentWindow.listeners.size).toBe(1); + + const accepted = trackChannel(); + currentWindow.dispatch({ + source: currentWindow.parent, + origin: "https://host.example", + data: { type: "truapi-init" }, + ports: [accepted.port1], + }); + await Promise.resolve(); + expect(currentWindow.win.__HOST_API_PORT__).toBe(accepted.port1); + expect(currentWindow.listeners.size).toBe(0); + }); + + it("uses a data-free wildcard ready ping only when the host origin is hidden", async () => { + currentWindow = installFakeIframeWindow({}); + const sandbox = await importSandbox(); + + expect(sandbox.getClientSync()).not.toBeNull(); + expect(currentWindow.parentPostMessage.mock.calls).toEqual([ + [{ type: "truapi-ready" }, "*"], + ]); + + const wrongSource = trackChannel(); + currentWindow.dispatch({ + source: {}, + origin: "https://host.example", + data: { type: "truapi-init" }, + ports: [wrongSource.port1], + }); + await Promise.resolve(); + expect(currentWindow.win.__HOST_API_PORT__).toBeUndefined(); + + const accepted = trackChannel(); + currentWindow.dispatch({ + source: currentWindow.parent, + origin: "https://host.example", + data: { type: "truapi-init" }, + ports: [accepted.port1], + }); + await Promise.resolve(); + expect(currentWindow.win.__HOST_API_PORT__).toBe(accepted.port1); + expect(currentWindow.listeners.size).toBe(0); + }); +}); diff --git a/js/packages/truapi/src/sandbox.ts b/js/packages/truapi/src/sandbox.ts index 51861e4e..99ebdef4 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,34 @@ 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; + +function withAbort( + promise: Promise, + signal: AbortSignal | undefined, + message: string, +): Promise { + if (!signal) return promise; + if (signal.aborted) return Promise.reject(new Error(message)); + + return new Promise((resolve, reject) => { + const onAbort = (): void => { + reject(new Error(message)); + }; + signal.addEventListener("abort", onAbort, { once: true }); + promise.then( + (value) => { + signal.removeEventListener("abort", onAbort); + resolve(value); + }, + (error: unknown) => { + signal.removeEventListener("abort", onAbort); + reject(error); + }, + ); + }); +} /** * Resolve the host-injected `MessagePort`, polling `window.__HOST_API_PORT__` @@ -93,7 +113,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 +127,77 @@ 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); + + 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); + 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 onMessage = (event: MessageEvent): void => { + if (event.source !== win.parent) return; + if (hostOrigin !== null && event.origin !== hostOrigin) 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); + // This readiness ping carries no MessagePort or account data. When the + // browser hides the parent origin, `*` is required so the parent can answer + // with the actual capability transfer, which is still source-checked above. + win.parent.postMessage({ type: "truapi-ready" }, hostOrigin ?? "*"); + }).catch((error: unknown) => { + iframePortPromise = null; + throw error; + }); + + return withAbort(iframePortPromise, signal, "waitForIframePort aborted"); +} + +/** 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 +242,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..b7ff84bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,19 @@ "typescript": "^6.0" } }, + "js/packages/truapi-host": { + "name": "@parity/truapi-host", + "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": { + "resolved": "js/packages/truapi-host", + "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/rust/crates/truapi-codegen/src/ts/host_callbacks.rs b/rust/crates/truapi-codegen/src/ts/host_callbacks.rs index c5f6a9c5..b0b0b958 100644 --- a/rust/crates/truapi-codegen/src/ts/host_callbacks.rs +++ b/rust/crates/truapi-codegen/src/ts/host_callbacks.rs @@ -226,6 +226,7 @@ fn emit_wasm_adapter( } PlatformInner::Stream(item) => { support_imports.insert("driveResultStream".to_string()); + extra_types.insert("GenericError".to_string()); collect_codec_imports(stream_item(item), codec_types, &mut imports); collect_local_codec_names( stream_item(item), @@ -358,6 +359,7 @@ fn emit_worker_callbacks( // subscription payload shape derived from `truapi-platform`. import type {{ RawCallbacks }} from "./host-callbacks-adapter.js"; + import type {{ GenericError }} from "@parity/truapi"; "# ) @@ -402,6 +404,7 @@ fn emit_worker_callbacks( out.push_str(" name: SubscriptionName,\n"); out.push_str(" payload: Uint8Array | null,\n"); out.push_str(" sendItem: (value: T) => void,\n"); + out.push_str(" sendError: (error: GenericError) => void,\n"); out.push_str(" ): () => void;\n"); for (trait_def, method) in &trait_object_callbacks { writeln!( @@ -451,6 +454,7 @@ fn emit_worker_callbacks( name: SubscriptionName, payload: Uint8Array | null, sendItem: (value?: unknown) => void, + sendError: (error: GenericError) => void, ): (() => void) | void {{ "# ) @@ -566,11 +570,11 @@ fn emit_worker_subscription_factory(methods: &[&PlatformMethod]) -> Result\n bridge.startSubscription(\"{raw}\", {param}, sendItem),\n" + " {raw}: ({param}, sendItem, sendError) =>\n bridge.startSubscription(\"{raw}\", {param}, sendItem, sendError),\n" )); } else { out.push_str(&format!( - " {raw}: (sendItem) =>\n bridge.startSubscription(\"{raw}\", null, sendItem),\n" + " {raw}: (sendItem, sendError) =>\n bridge.startSubscription(\"{raw}\", null, sendItem, sendError),\n" )); } } @@ -586,11 +590,11 @@ fn emit_start_raw_subscription_switch(methods: &[&PlatformMethod]) -> Result void".to_string()); + params.push("sendError: (error: GenericError) => void".to_string()); format!("{name}({}): (() => void) | void;", params.join(", ")) } _ if trait_object_return_name(method, platform_trait_names).is_some() => { @@ -1068,10 +1073,11 @@ fn adapter_stream_impl( .map(|p| to_camel_case(&p.name)) .collect(); names.push("sendItem".to_string()); + names.push("sendError".to_string()); let params = names.join(", "); let call = format!("{host_method}({args})"); Ok(format!( - "({params}) => driveResultStream({call}, {item_expr})" + "({params}) => driveResultStream({call}, {item_expr}, sendError)" )) } diff --git a/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts b/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts index a389ed9c..1c1867ce 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts @@ -17,6 +17,7 @@ import { ThemeVariant, } from "@parity/truapi"; import type { + GenericError, NotificationId, } from "@parity/truapi"; import { @@ -50,11 +51,11 @@ export interface RawCallbacks { devicePermission(request: Uint8Array): Promise; remotePermission(request: Uint8Array): Promise; submitPreimage(value: Uint8Array, bulletinAllowanceSigner: BulletinAllowanceSigner): Promise; - lookupPreimage(key: Uint8Array, sendItem: (item?: Uint8Array) => void): (() => void) | void; + lookupPreimage(key: Uint8Array, sendItem: (item?: Uint8Array) => void, sendError: (error: GenericError) => void): (() => void) | void; read(key: string): Promise; write(key: string, value: Uint8Array): Promise; clear(key: string): Promise; - subscribeTheme(sendItem: (item?: Uint8Array) => void): (() => void) | void; + subscribeTheme(sendItem: (item?: Uint8Array) => void, sendError: (error: GenericError) => void): (() => void) | void; confirmUserAction(review: Uint8Array): Promise; } /** Adapt typed host callbacks into the raw SCALE callback surface the @@ -75,11 +76,11 @@ export function createWasmRawCallbacks( devicePermission: async (request) => HostDevicePermissionResponse.enc(await callbacks.permissions.devicePermission(HostDevicePermissionRequest.dec(request))), remotePermission: async (request) => RemotePermissionResponse.enc(await callbacks.permissions.remotePermission(RemotePermissionRequest.dec(request))), submitPreimage: async (value, bulletinAllowanceSigner) => await callbacks.preimage.submitPreimage(value, bulletinAllowanceSigner), - lookupPreimage: (key, sendItem) => driveResultStream(callbacks.preimage.lookupPreimage(key), sendItem), + lookupPreimage: (key, sendItem, sendError) => driveResultStream(callbacks.preimage.lookupPreimage(key), sendItem, sendError), read: async (key) => await callbacks.productStorage.read(key), write: async (key, value) => await callbacks.productStorage.write(key, value), clear: async (key) => await callbacks.productStorage.clear(key), - subscribeTheme: (sendItem) => driveResultStream(callbacks.theme.subscribeTheme(), (item) => sendItem(ThemeVariant.enc(item))), + subscribeTheme: (sendItem, sendError) => driveResultStream(callbacks.theme.subscribeTheme(), (item) => sendItem(ThemeVariant.enc(item)), sendError), confirmUserAction: async (review) => await callbacks.userConfirmation.confirmUserAction(UserConfirmationReview.dec(review)), }; } diff --git a/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts b/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts index 969695f3..602f6145 100644 --- a/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts @@ -6,6 +6,7 @@ // subscription payload shape derived from `truapi-platform`. import type { RawCallbacks } from "./host-callbacks-adapter.js"; +import type { GenericError } from "@parity/truapi"; import type { BulletinAllowanceSigner } from "./host-callbacks.js"; @@ -50,6 +51,7 @@ export interface WorkerCallbackBridge { name: SubscriptionName, payload: Uint8Array | null, sendItem: (value: T) => void, + sendError: (error: GenericError) => void, ): () => void; chainConnect: ChainConnect; } @@ -91,10 +93,10 @@ function rawCallbacks(bridge: WorkerCallbackBridge): Required> { return { - lookupPreimage: (key, sendItem) => - bridge.startSubscription("lookupPreimage", key, sendItem), - subscribeTheme: (sendItem) => - bridge.startSubscription("subscribeTheme", null, sendItem), + lookupPreimage: (key, sendItem, sendError) => + bridge.startSubscription("lookupPreimage", key, sendItem, sendError), + subscribeTheme: (sendItem, sendError) => + bridge.startSubscription("subscribeTheme", null, sendItem, sendError), }; } @@ -114,6 +116,7 @@ export function startRawSubscription( name: SubscriptionName, payload: Uint8Array | null, sendItem: (value?: unknown) => void, + sendError: (error: GenericError) => void, ): (() => void) | void { switch (name) { case "lookupPreimage": @@ -121,8 +124,8 @@ export function startRawSubscription( console.warn(`[truapi worker] ${name} requires payload`); return undefined; } - return callbacks.lookupPreimage(payload, sendItem); + return callbacks.lookupPreimage(payload, sendItem, sendError); case "subscribeTheme": - return callbacks.subscribeTheme(sendItem); + return callbacks.subscribeTheme(sendItem, sendError); } } diff --git a/rust/crates/truapi-server/src/lib.rs b/rust/crates/truapi-server/src/lib.rs index e83b3d26..9d2d8489 100644 --- a/rust/crates/truapi-server/src/lib.rs +++ b/rust/crates/truapi-server/src/lib.rs @@ -6,7 +6,7 @@ //! shells. //! //! Host-facing bridges: -//! - [`wasm`] (wasm32 only): wasm-bindgen surface exposing `WasmProductRuntime`. +//! - `wasm` (wasm32 only): wasm-bindgen surface exposing `WasmProductRuntime`. pub(crate) mod chain_runtime; pub mod core; diff --git a/rust/crates/truapi-server/src/runtime.rs b/rust/crates/truapi-server/src/runtime.rs index 7004a228..c4b18ce2 100644 --- a/rust/crates/truapi-server/src/runtime.rs +++ b/rust/crates/truapi-server/src/runtime.rs @@ -51,7 +51,7 @@ use authority::{ use futures::{FutureExt, StreamExt, pin_mut}; #[cfg(test)] use parity_scale_codec::Encode; -use tracing::{info, instrument}; +use tracing::{info, instrument, warn}; use truapi::api::{ Account, Chain, Chat, CoinPayment, Entropy, LocalStorage, Notifications, Payment, Permissions, Preimage, ResourceAllocation, Signing, System, Theme, @@ -145,11 +145,22 @@ pub(super) const REMOTE_PERMISSION_DENIED_REASON: &str = "Permission denied"; /// after 180 seconds: /// const DEFAULT_REMOTE_AUTHORITY_RESPONSE_TIMEOUT: Duration = Duration::from_secs(180); +/// Preimage submission is exercised by host diagnosis with a 60s method +/// watchdog. Keep the allowance request below that so products receive a +/// TrUAPI error instead of an outer harness timeout. +const PREIMAGE_REMOTE_AUTHORITY_RESPONSE_TIMEOUT: Duration = Duration::from_secs(45); fn remote_authority_context(cx: &CallContext) -> CallContext { + remote_authority_context_with_default(cx, DEFAULT_REMOTE_AUTHORITY_RESPONSE_TIMEOUT) +} + +fn remote_authority_context_with_default( + cx: &CallContext, + default_timeout: Duration, +) -> CallContext { let mut cx = cx.clone(); if cx.timeout().is_none() { - cx.set_timeout(DEFAULT_REMOTE_AUTHORITY_RESPONSE_TIMEOUT); + cx.set_timeout(default_timeout); } cx } @@ -1737,14 +1748,18 @@ impl Preimage for ProductRuntimeHost { .platform .lookup_preimage(key) .filter_map(|item| async move { - // TODO: preserve platform stream errors as terminal - // subscription interrupts once subscription items can carry - // in-stream failures. - item.ok().map(|value| { - RemotePreimageLookupSubscribeItem::V1(v01::RemotePreimageLookupSubscribeItem { - value, - }) - }) + match item { + Ok(value) => Some(RemotePreimageLookupSubscribeItem::V1( + v01::RemotePreimageLookupSubscribeItem { value }, + )), + Err(error) => { + warn!( + reason = %error.reason, + "preimage lookup platform stream failed" + ); + None + } + } }); Subscription::new(Box::pin(stream)) } @@ -1791,7 +1806,8 @@ impl Preimage for ProductRuntimeHost { }, ))); } - let cx = remote_authority_context(cx); + let cx = + remote_authority_context_with_default(cx, PREIMAGE_REMOTE_AUTHORITY_RESPONSE_TIMEOUT); let allowance = remote_authority_call( &cx, self.authority diff --git a/rust/crates/truapi-server/src/wasm.rs b/rust/crates/truapi-server/src/wasm.rs index d46710c4..d5d89781 100644 --- a/rust/crates/truapi-server/src/wasm.rs +++ b/rust/crates/truapi-server/src/wasm.rs @@ -143,6 +143,7 @@ impl ChainProvider for WasmPlatform { struct JsSubscriptionStream { rx: mpsc::UnboundedReceiver, _send_item: SendWrapper>, + _send_error: SendWrapper>, dispose: Option>, } @@ -171,17 +172,30 @@ where T: Send + 'static, { let (tx, rx) = mpsc::unbounded::>(); + let item_tx = tx.clone(); let send_item = Closure::wrap(Box::new(move |value: JsValue| { let item = parse_item(value).map_err(generic); - let _ = tx.unbounded_send(item); + let _ = item_tx.unbounded_send(item); + }) as Box); + let send_error = Closure::wrap(Box::new(move |value: JsValue| { + let _ = tx.unbounded_send(Err(parse_generic_error(value))); }) as Box); let call_result = match payload { Some(payload) => { let arg = Uint8Array::from(payload.as_slice()); - fn_.call2(&JsValue::NULL, &arg, send_item.as_ref().unchecked_ref()) + fn_.call3( + &JsValue::NULL, + &arg, + send_item.as_ref().unchecked_ref(), + send_error.as_ref().unchecked_ref(), + ) } - None => fn_.call1(&JsValue::NULL, send_item.as_ref().unchecked_ref()), + None => fn_.call2( + &JsValue::NULL, + send_item.as_ref().unchecked_ref(), + send_error.as_ref().unchecked_ref(), + ), }; let dispose = match call_result { @@ -204,6 +218,7 @@ where Box::pin(JsSubscriptionStream { rx, _send_item: SendWrapper::new(send_item), + _send_error: SendWrapper::new(send_error), dispose, }) } @@ -260,6 +275,18 @@ fn generic(reason: String) -> v01::GenericError { v01::GenericError { reason } } +fn parse_generic_error(value: JsValue) -> v01::GenericError { + if let Some(reason) = value.as_string() { + return generic(reason); + } + if let Ok(reason) = Reflect::get(&value, &JsValue::from_str("reason")) { + if let Some(reason) = reason.as_string() { + return generic(reason); + } + } + generic(js_to_string(value)) +} + /// Await the JS callback's return value if it's a Promise; pass other /// values through unchanged. Every host callback resolves through this so /// the JS side is free to be sync or async.