diff --git a/.changeset/rename-provider-to-wireprovider.md b/.changeset/rename-provider-to-wireprovider.md index 7df1bda9..a4b59d9b 100644 --- a/.changeset/rename-provider-to-wireprovider.md +++ b/.changeset/rename-provider-to-wireprovider.md @@ -1,5 +1,5 @@ --- -"@parity/truapi": patch +"@parity/truapi": minor --- Rename the exported `Provider` transport type to `WireProvider` to make its role explicit. It is the low-level SCALE-wire-frame pipe (a `MessagePort` or iframe `postMessage` channel) that `createTransport` runs on. The `createIframeProvider` / `createMessagePortProvider` factories are unchanged; only the type name moves. Consumers importing `Provider` should import `WireProvider` instead. diff --git a/.changeset/truapi-sandbox-bootstrap.md b/.changeset/truapi-sandbox-bootstrap.md index 14eb6333..1b94d436 100644 --- a/.changeset/truapi-sandbox-bootstrap.md +++ b/.changeset/truapi-sandbox-bootstrap.md @@ -1,5 +1,5 @@ --- -"@parity/truapi": patch +"@parity/truapi": minor --- Add the `@parity/truapi/sandbox` entry point: host-environment detection (`isCorrectEnvironment`), a lazily-built cached client (`getClientSync`, `null` outside a host container), and a `subscribeConnectionStatus` connected/disconnected listener. Browser-embedded hosts can bootstrap a client without assembling the transport by hand. diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..4fb9ca56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +rust/crates/truapi-codegen/tests/golden/* linguist-generated=true +rust/crates/truapi-server/src/generated/* linguist-generated=true diff --git a/.github/PULL_REQUEST_TEMPLATE/release.md b/.github/PULL_REQUEST_TEMPLATE/release.md index a2f7c0c9..f213a957 100644 --- a/.github/PULL_REQUEST_TEMPLATE/release.md +++ b/.github/PULL_REQUEST_TEMPLATE/release.md @@ -2,7 +2,7 @@ > [!IMPORTANT] > The PR title must start with `release:` for the publish workflow to fire. -> Example: `release: @parity/truapi 0.1.1`. +> Example: `release: @parity/truapi 0.1.1` or `release: @parity/truapi-host 0.1.1`. > Don't rewrite the squash commit subject in the merge dialog — the > `release:` prefix has to land on `main`. @@ -12,10 +12,9 @@ ### Checklist -- [ ] Ran `npm run changeset` and selected the bump type (patch / minor / major) +- [ ] Ran `npm run changeset` and selected the package + bump type (patch / minor / major) - [ ] Ran `npm run version-packages` to consume the changeset - [ ] `js/packages/truapi/package.json` version is bumped - [ ] `js/packages/truapi/CHANGELOG.md` has the new entry - [ ] `rust/crates/truapi/Cargo.toml` version matches `js/packages/truapi/package.json` - [ ] No leftover files under `.changeset/` (other than `config.json`) - diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e3afa820..64c251dc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,6 +94,15 @@ jobs: - name: Run codegen run: ./scripts/codegen.sh + - name: Check generated Rust output is committed + run: | + git diff --exit-code -- \ + rust/crates/truapi-server/src/generated \ + rust/crates/truapi-server/src/wasm/generated_bridge.rs + + - name: Check Rust/TS wire table parity + run: TRUAPI_REQUIRE_GENERATED_TS=1 cargo test -p truapi-server --test wire_table_ts_parity + - name: Upload codegen output uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: @@ -105,10 +114,11 @@ jobs: js/packages/truapi/src/playground/codegen js/packages/truapi/src/explorer/codegen js/packages/truapi/src/explorer/versions.ts + js/packages/truapi-host/src/generated playground/test/generated ts-client: - name: '@parity/truapi' + name: "@parity/truapi" runs-on: ubuntu-latest needs: codegen env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 835f03fc..2af249ba 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,7 +6,7 @@ name: Release # guarded by conclusion == success and a release: commit subject, and checks # out the pinned head_sha with persist-credentials: false. No untrusted PR code # ever runs in this privileged context. -on: # zizmor: ignore[dangerous-triggers] +on: # zizmor: ignore[dangerous-triggers] workflow_run: workflows: ["CI"] types: [completed] @@ -29,6 +29,8 @@ jobs: runs-on: ubuntu-latest permissions: contents: write + id-token: write + attestations: write steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: @@ -36,19 +38,45 @@ jobs: ref: ${{ github.event.workflow_run.head_sha }} persist-credentials: false - - name: Resolve target version + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "22" + registry-url: "https://registry.npmjs.org" + + - name: Resolve target packages id: version run: | - version=$(jq -r '.version' js/packages/truapi/package.json) - tag="@parity/truapi@${version}" - if git rev-parse --verify --quiet "refs/tags/${tag}" >/dev/null; then - echo "Tag ${tag} already exists; nothing to publish." - echo "proceed=false" >> "$GITHUB_OUTPUT" - else - echo "Publishing ${tag}." + set -euo pipefail + packages=( + "@parity/truapi js/packages/truapi" + "@parity/truapi-host js/packages/truapi-host" + ) + pending="$(mktemp)" + for entry in "${packages[@]}"; do + name="${entry%% *}" + path="${entry#* }" + version="$(jq -r '.version' "${path}/package.json")" + tag="${name}@${version}" + if npm_status="$(npm view "${tag}" version --registry=https://registry.npmjs.org 2>&1)"; then + echo "Package ${tag} already exists on npm; skipping ${name}." + elif grep -Eq 'E404|404 Not Found' <<< "${npm_status}"; then + echo "Publishing ${tag}." + echo "${name}|${path}|${version}|${tag}" >> "${pending}" + else + echo "${npm_status}" >&2 + exit 1 + fi + done + if [ -s "${pending}" ]; then echo "proceed=true" >> "$GITHUB_OUTPUT" - echo "version=${version}" >> "$GITHUB_OUTPUT" - echo "tag=${tag}" >> "$GITHUB_OUTPUT" + { + echo "packages<> "$GITHUB_OUTPUT" + else + echo "No unpublished package tags found." + echo "proceed=false" >> "$GITHUB_OUTPUT" fi - if: steps.version.outputs.proceed == 'true' @@ -64,35 +92,36 @@ jobs: - if: steps.version.outputs.proceed == 'true' uses: Swatinem/rust-cache@e18b497796c12c097a38f9edb9d0641fb99eee32 # v2 - - if: steps.version.outputs.proceed == 'true' - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version: "22" - registry-url: "https://registry.npmjs.org" - - name: Install dependencies if: steps.version.outputs.proceed == 'true' run: npm ci - - name: Build @parity/truapi - if: steps.version.outputs.proceed == 'true' - run: ./scripts/codegen.sh + - name: Install wasm-pack + if: steps.version.outputs.proceed == 'true' && contains(steps.version.outputs.packages, '@parity/truapi-host|') + run: cargo install wasm-pack --version 0.14.0 --locked - - name: Tag release + - name: Build packages if: steps.version.outputs.proceed == 'true' - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - STEPS_VERSION_OUTPUTS_TAG: ${{ steps.version.outputs.tag }} run: | - gh api repos/${{ github.repository }}/git/refs \ - -f ref="refs/tags/${STEPS_VERSION_OUTPUTS_TAG}" \ - -f sha="${{ github.event.workflow_run.head_sha }}" + set -euo pipefail + ./scripts/codegen.sh + if grep -q '^@parity/truapi-host|' <<< "${STEPS_VERSION_OUTPUTS_PACKAGES}"; then + npm run build --prefix js/packages/truapi-host + npm run build:wasm --prefix js/packages/truapi-host + fi + env: + STEPS_VERSION_OUTPUTS_PACKAGES: ${{ steps.version.outputs.packages }} - name: Pack package if: steps.version.outputs.proceed == 'true' run: | + set -euo pipefail mkdir -p artifacts - (cd js/packages/truapi && npm pack --pack-destination "$GITHUB_WORKSPACE/artifacts/") + while IFS='|' read -r name path version tag; do + (cd "${path}" && npm pack --pack-destination "$GITHUB_WORKSPACE/artifacts/") + done <<< "${STEPS_VERSION_OUTPUTS_PACKAGES}" + env: + STEPS_VERSION_OUTPUTS_PACKAGES: ${{ steps.version.outputs.packages }} - name: Upload package artifacts if: steps.version.outputs.proceed == 'true' @@ -101,6 +130,12 @@ jobs: name: package path: artifacts/*.tgz + - name: Attest package artifacts + if: steps.version.outputs.proceed == 'true' + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: artifacts/*.tgz + - name: Publish via npm_publish_automation if: steps.version.outputs.proceed == 'true' uses: octokit/request-action@b91aabaa861c777dcdb14e2387e30eddf04619ae # v3.0.0 @@ -111,14 +146,45 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.NPM_PUBLISH_AUTOMATION_TOKEN }} + - name: Create release tags + if: steps.version.outputs.proceed == 'true' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_SHA: ${{ github.event.workflow_run.head_sha }} + STEPS_VERSION_OUTPUTS_PACKAGES: ${{ steps.version.outputs.packages }} + run: | + set -euo pipefail + auth_header="AUTHORIZATION: bearer ${GITHUB_TOKEN}" + push_refs=() + while IFS='|' read -r name path version tag; do + if git -c http.extraheader="${auth_header}" ls-remote --exit-code --tags origin "refs/tags/${tag}" >/dev/null 2>&1; then + echo "Tag ${tag} already exists; skipping create." + continue + fi + git tag --force "${tag}" "${RELEASE_SHA}" + push_refs+=("refs/tags/${tag}:refs/tags/${tag}") + done <<< "${STEPS_VERSION_OUTPUTS_PACKAGES}" + + if [ "${#push_refs[@]}" -gt 0 ]; then + git -c http.extraheader="${auth_header}" push origin "${push_refs[@]}" + fi + - name: Create GitHub Release if: steps.version.outputs.proceed == 'true' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - STEPS_VERSION_OUTPUTS_TAG: ${{ steps.version.outputs.tag }} - STEPS_VERSION_OUTPUTS_VERSION: ${{ steps.version.outputs.version }} + STEPS_VERSION_OUTPUTS_PACKAGES: ${{ steps.version.outputs.packages }} run: | - gh release create "${STEPS_VERSION_OUTPUTS_TAG}" \ - --title "@parity/truapi ${STEPS_VERSION_OUTPUTS_VERSION}" \ - --generate-notes \ - artifacts/*.tgz + set -euo pipefail + while IFS='|' read -r name path version tag; do + tarball_name="${name#@}" + tarball_name="${tarball_name//\//-}-${version}.tgz" + if gh release view "${tag}" >/dev/null 2>&1; then + gh release upload "${tag}" "artifacts/${tarball_name}" --clobber + else + gh release create "${tag}" \ + --title "${name} ${version}" \ + --generate-notes \ + "artifacts/${tarball_name}" + fi + done <<< "${STEPS_VERSION_OUTPUTS_PACKAGES}" 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/CLAUDE.md b/CLAUDE.md index 0116c828..d0df0ce5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -8,17 +8,39 @@ This repo is the single source of truth for the TrUAPI protocol. It vendors `dot ``` rust/crates/ - truapi/ Rust trait + type definitions for protocol versions v0.1 and v0.2 + truapi/ Rust trait + type definitions for protocol versions v0.1 and v0.2 (canonical) truapi-codegen/ rustdoc JSON → TypeScript client + Rust dispatcher truapi-macros/ #[wire(id = N)] proc-macro + truapi-platform/ Host syscall traits (storage, navigation, consent, ...) + truapi-server/ Rust runtime hosts implement; ships as WASM (browser/node) js/packages/ - truapi/ @parity/truapi TS package; generated TS lives under ignored paths -playground/ Next.js interactive playground; deploys to truapi-playground.dot -hosts/dotli/ dotli submodule -docs/ design docs, RFCs, feature proposals -scripts/codegen.sh regenerate the TS client from the Rust crate + truapi/ @parity/truapi TS package; generated TS lives under ignored paths + truapi-host/ @parity/truapi-host: WASM-backed host runtime. Subpath entries: + `.` (shared host types), `/web` (iframe + Web + Worker), `/worker-runtime` (Worker entry). + WASM bundle (gitignored) under dist/wasm/web/, built via `make wasm` +playground/ Next.js interactive playground; deploys to truapi-playground.dot +hosts/dotli/ dotli submodule +docs/ design docs, RFCs, feature proposals +scripts/codegen.sh regenerate the TS client from the Rust crate ``` +### Crate + binding invariants + +- `truapi` is canonical; runtime crates re-export rather than redefine. New + syscall traits and host-side runtime types live in `truapi-platform` and + `truapi-server`, not in `truapi`. Any additions to `truapi` itself are limited + to additive `Display` impls. +- Outside the canonical `truapi` crate and its version-conversion impls, use + structs from `truapi::latest` for concrete protocol payload/error types. + Runtime crates should take envelopes from `truapi::versioned::*` and unwrap + them into latest payloads instead of spelling `truapi::v01::*` directly. +- `truapi-server` WASM artifacts live under + `js/packages/truapi-host/dist/wasm/web/` and are gitignored. + Build them locally with `make wasm` (rerun whenever + `rust/crates/truapi-server/` changes); CI builds the bundle fresh from the + Rust source on every run. + ## Code style - Every `pub` Rust item (functions, methods, types, traits, modules, constants) carries a doc comment (`///` or `//!`). @@ -26,12 +48,28 @@ scripts/codegen.sh regenerate the TS client from the Rust crate - Do not add code comments or doc comments that narrate migrations, compatibility shims, or historical changes. Comments should describe only the current code. - Remove legacy compatibility code by default. Keep or add it only when explicitly requested. - In Rust format strings, prefer inlined variables: `"log value: {value:?}"` over `"log value: {:?}", value`. +- For Rust modules, prefer `foo.rs` plus an optional `foo/` directory for + child modules. Do not introduce new `foo/mod.rs` files unless preserving + generated output or an existing external convention. +- In runtime Rust code, prefer `core::` over `std::` for types that are + available in `core` (`core::pin::Pin`, `core::task::Poll`, `core::fmt`, and + similar). Keep `std::` for std-only APIs, tests, and std-only programs such + as `truapi-codegen`. - **No `any` in TypeScript types**: If a type can't be expressed cleanly, stop and ask the user whether to (a) refactor or import the right type or (b) add a scoped `// eslint-disable-next-line @typescript-eslint/no-explicit-any` exception. Never silently leave `any`. - Don't introduce typealias chains that just rename a public type from another crate (e.g. `pub type StorageError = crate::v01::HostLocalStorageReadError`). Use the canonical name directly. A typealias is only worth its indirection when it captures a real abstraction. -- After any code change, update `README.md` (and CLAUDE.md if the layout changed) so the top-level docs reflect what the repo actually contains. Stale docs are a regression. +- After any code change, update `README.md` (and CLAUDE.md if the layout changed) so the top-level docs reflect what the repo actually contains. Stale docs are a regression. When moving or removing docs, `rg` for the old path and update or remove stale links in README files, agent notes, skills, comments, and design docs. - In codegen emitters, prefer `indoc::writedoc!` / `formatdoc!` over chains of `writeln!`. A single `writedoc!` with a multi-line raw string keeps the emitted shape visible in source instead of fragmenting it across one-line `writeln!` calls. Reserve `writeln!` for the genuinely-one-line case (a single import, a single statement inside a loop). - In PR descriptions, issue comments, and other artifacts that outlive the conversation: describe the resulting state, not the transition between commits. Avoid "previously X, now Y", "we removed", "the old shim is gone", "this PR replaces", those read as ephemeral history once the PR is squash-merged. Write what the system _does_ after the change, not what each commit _changed_ on the way there. (Commit messages are the place for transition narrative; they survive in `git log` even after the squash.) +## Explanation style + +- For architecture, event-flow, and debugging explanations, start with a short + direct summary of the model before diving into long details. Prefer simple + statements like "the host sends a dirty signal; the core re-reads and derives + auth state" before listing each hop. +- Use diagrams only when they clarify ownership or message flow. Keep them + layered and label what is per-tab, shared, host-owned, and core-owned. + ## First-time setup ```bash @@ -116,6 +154,90 @@ submodule init + `bun install` and the per-pane `cd` discipline). Alternatively, with a deployed Polkadot Desktop Host installed, navigate to `https://dot.li/localhost:3000` from within it. +#### Local dotli + playground E2E notes + +Use `make dev DEBUG=1` from the repo root for the local host stack. It prepares +the ignored WASM/build artifacts, verifies dotli can resolve +`@parity/truapi-host`, then starts dotli on `:5173` and the playground on +`:3000`. Open `http://localhost:5173/localhost:3000`. + +When automating with Playwright, block service workers for smoke tests unless +the test is explicitly about SW behavior. Stale host/product bundles can mask +runtime fixes. Use a fresh cache-busting query string on +`http://localhost:5173/localhost:3000?...`, collect `pageerror` and +`console` messages, and fail on unexpected page errors. + +For interactive SSO checks, prefer a persistent headed Chrome profile and reuse +the same browser context across checks. SSO pairing needs a real phone QR scan, +and signing/resource-allocation flows may need web or mobile confirmation; if +the human or companion app is unavailable, skip those methods and record the +skip instead of treating it as a protocol failure. Non-interactive checks should +still verify that the playground renders, the TrUAPI debug panel receives +host/product events, generated examples can call non-confirmation methods, and +logout/relogin does not restore a stale session. + +The dotli Playwright e2e suite under `hosts/dotli/apps/host/tests/e2e/` +pairs through the signer-bot service. It requires `SIGNER_BOT_SVC_TOKEN`; +`SIGNER_BOT_BASE_URL` and `SIGNER_BOT_NETWORK` default to dotli CI's +`https://signing-bot-dev.novasama-tech.org/` and `paseo-next-v2`. Without the +token, do not treat the full suite as locally runnable. Use +`E2E_DOTLI_SMOKE=1 make e2e-dotli` for the no-phone QR smoke path. +If those signer-bot variables are not available in a worktree, check for a +repo-root `.env` and load or copy the values from there before falling back to +smoke mode. Prefer the current worktree's `.env` when it exists. + +For a fully automated local playground diagnosis run, use: + +```bash +SIGNER_BOT_SVC_TOKEN=... \ +make e2e-dotli +``` + +`make e2e-dotli` starts dotli preview and the playground, signs out any +restored host session, signs in through signer-bot by extracting the QR payload, +runs the playground Diagnosis screen, auto-accepts host-side Allow/Sign modals, +and writes `hosts/dotli/test-results/e2e-dotli/diagnosis-report.md`. + +Root CI runs the same target when it can read the private dotli submodule. It +needs `DOTLI_CHECKOUT_TOKEN` for submodule checkout; without that token, the +job warns and skips dotli e2e rather than failing unrelated PR checks. With +dotli access but without `SIGNER_BOT_SVC_TOKEN`, CI runs the no-phone smoke +path only. + +A useful no-phone smoke assertion is: + +```bash +E2E_DOTLI_SMOKE=1 make e2e-dotli +``` + +For manual debugging of that smoke path: + +1. Start `make dev DEBUG=1`. +2. Open `http://localhost:5173/localhost:3000?debug=truapi&cachebust=` with + service workers blocked. +3. Wait for `globalThis.__truapi?.setLogLevel`, call + `__truapi.setLogLevel("debug")`, and confirm the console logs + `[truapi worker] logLevel=debug providers=0`. +4. Click `#auth-button`, wait for `#auth-modal-backdrop.open`, and confirm: + the modal shows `Login with Polkadot Mobile`, `__truapi.getProviderCount()` + is greater than zero, worker frame/callback logs appear, and there are no + page errors. + +If `make dev` reports `EADDRINUSE` on `:5173` or the playground moves from +`:3000` to `:3001`, kill stale `preview-server.ts` / `next dev` processes and +restart the tmux session. Port drift causes false-negative local e2e results. + +Useful debug signals: + +```js +__truapi.setLogLevel("debug"); +sessionStorage.setItem("dotli:truapi-debug", "1"); +``` + +Reload after setting the debug-panel flag. Watch for `Unknown wire discriminant`, missing +`@parity/truapi-host` imports, worker WASM instantiation failures, and +debug-panel traffic disappearing when the login popup opens. + ## Deployment Pushes to `main` trigger `.github/workflows/deploy-playground.yml`, which builds `playground/` and publishes the static export to `truapi-playground.dot` via `bulletin-deploy`. diff --git a/Cargo.toml b/Cargo.toml index 042aff82..1fbe8141 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,3 +5,11 @@ members = ["rust/crates/*"] [workspace.package] edition = "2024" license = "MIT" + +[profile.release] +opt-level = "z" +lto = "fat" +codegen-units = 1 +panic = "abort" +strip = "symbols" +debug = false diff --git a/Makefile b/Makefile index f067ddbf..4aeb0764 100644 --- a/Makefile +++ b/Makefile @@ -3,60 +3,141 @@ # Run `make help` for the list of targets. .DEFAULT_GOAL := help -.PHONY: help setup build codegen test check playground dev matrix explorer +.PHONY: help setup build codegen test check clean playground wasm wasm-crypto-test dev dev-bootstrap dev-link-check e2e-dotli matrix explorer TRUAPI_PKG := js/packages/truapi PLAYGROUND := playground +JS_PACKAGES := js/packages EXPLORER := explorer DOTLI := hosts/dotli +HOST_WASM_PKG := $(JS_PACKAGES)/truapi-host +HOST_CALLBACKS_GENERATED := $(HOST_WASM_PKG)/src/generated/host-callbacks.ts +HOST_WASM_ADAPTER_GENERATED := $(HOST_WASM_PKG)/src/generated/host-callbacks-adapter.ts +HOST_WASM_WORKER_CALLBACKS_GENERATED := $(HOST_WASM_PKG)/src/generated/worker-callbacks.ts +HOST_WASM_WEB := $(HOST_WASM_PKG)/dist/wasm/web/truapi_server.js +DOTLI_UI := $(DOTLI)/packages/ui +DOTLI_HOST_WASM_LINK := $(DOTLI_UI)/node_modules/@parity/truapi-host +SIGNER_BOT_BASE_URL ?= https://signing-bot-dev.novasama-tech.org/ +SIGNER_BOT_NETWORK ?= paseo-next-v2 +VITE_NETWORKS ?= paseo-next-v2,previewnet +export SIGNER_BOT_BASE_URL +export SIGNER_BOT_NETWORK +export VITE_NETWORKS -# `make dev DEBUG=1` runs dotli with VITE_APP_DEBUG=true to log every wire frame. -DOTLI_PREVIEW := preview -ifdef DEBUG -DOTLI_PREVIEW := preview:debug -endif +# Local product URLs (`http://localhost:5173/localhost:3000`) are intentionally +# gated behind dotli's debug build flag, so the dev target must run the debug +# preview by default. Override with `DOTLI_PREVIEW=preview` to test production +# preview behavior. +DOTLI_PREVIEW ?= preview:debug help: ## Show this help. @awk 'BEGIN { FS = ":.*##"; printf "Usage: make \n\nTargets:\n" } \ - /^[a-zA-Z_-]+:.*?##/ { printf " %-12s %s\n", $$1, $$2 }' $(MAKEFILE_LIST) + /^[a-zA-Z0-9_-]+:.*?##/ { printf " %-12s %s\n", $$1, $$2 }' $(MAKEFILE_LIST) -setup: ## First-time setup: submodules + JS dependencies. +setup: ## First-time setup: submodules, JS dependencies, generated artifacts. git submodule update --init --recursive - cd $(TRUAPI_PKG) && npm install + # --ignore-scripts: the workspace `prepare` builds need generated sources + # that only exist after codegen.sh, which also builds the packages. + npm ci --ignore-scripts + ./scripts/codegen.sh cd $(PLAYGROUND) && yarn install --frozen-lockfile + cd $(DOTLI) && bun install --frozen-lockfile build: ## Build the Rust workspace and the TypeScript client. cargo build --workspace cd $(TRUAPI_PKG) && npm run build + cd $(HOST_WASM_PKG) && npm run build -codegen: ## Regenerate the TypeScript client from the Rust crate. +codegen: ## Regenerate generated TS/Rust artifacts from the Rust crates. ./scripts/codegen.sh cd $(PLAYGROUND) && rm -rf node_modules/@parity && yarn install +wasm: ## Rebuild the truapi-server WASM artifacts under js/packages/truapi-host/dist/wasm/. + cd $(HOST_WASM_PKG) && npm run build:wasm + +wasm-crypto-test: ## Run crypto/vector tests on wasm32 via wasm-pack/node. + wasm-pack test --node rust/crates/truapi-server --test wasm_crypto_vectors --no-default-features + test: ## Run Rust + TypeScript client tests. cargo test --workspace cd $(TRUAPI_PKG) && npm test + cd $(JS_PACKAGES)/truapi-host && npm test check: ## Full verification suite (build, fmt, clippy, test, TS tests, playground build + lint). cargo build --workspace cargo check --target wasm32-unknown-unknown -p truapi-server cargo +nightly fmt --check cargo clippy --workspace --all-targets --all-features -- -D warnings - cargo test --workspace + cargo test --workspace --all-features --all-targets cd $(TRUAPI_PKG) && npm run build && npm test + cd $(JS_PACKAGES)/truapi-host && npm install --no-fund --no-audit && npm test cd $(PLAYGROUND) && yarn build && yarn lint +clean: ## Remove local build/test artifacts without deleting dependencies. + cargo clean + rm -rf \ + $(TRUAPI_PKG)/dist \ + $(TRUAPI_PKG)/tsconfig.tsbuildinfo \ + $(HOST_WASM_PKG)/dist \ + $(HOST_WASM_PKG)/tsconfig.tsbuildinfo \ + $(PLAYGROUND)/.next \ + $(PLAYGROUND)/out \ + $(PLAYGROUND)/test-results \ + $(PLAYGROUND)/tsconfig.tsbuildinfo \ + $(PLAYGROUND)/tests/tsconfig.tsbuildinfo \ + $(DOTLI)/.turbo \ + $(DOTLI)/apps/host/dist \ + $(DOTLI)/apps/protocol/dist \ + $(DOTLI)/apps/sandbox/dist \ + $(DOTLI)/test-results + playground: ## Refresh the playground's @parity/truapi snapshot and rebuild. cd $(TRUAPI_PKG) && npm run build cd $(PLAYGROUND) && rm -rf node_modules/@parity && yarn install cd $(PLAYGROUND) && yarn build -dev: ## Start dotli host (:5173) + playground (:3000) together; open http://localhost:5173/localhost:3000. DEBUG=1 logs wire frames. +dev-bootstrap: ## Prepare ignored generated/build artifacts needed by dotli preview. + git submodule update --init --recursive + # --ignore-scripts: the workspace `prepare` builds need generated sources + # that only exist after codegen.sh, which also builds the packages. + if [ ! -d node_modules ]; then npm ci --ignore-scripts; fi + if [ ! -f "$(HOST_CALLBACKS_GENERATED)" ] || [ ! -f "$(HOST_WASM_ADAPTER_GENERATED)" ] || [ ! -f "$(HOST_WASM_WORKER_CALLBACKS_GENERATED)" ]; then ./scripts/codegen.sh; fi + cd $(HOST_WASM_PKG) && npm run build + TRUAPI_WASM_PROFILE=dev $(MAKE) wasm + cd $(PLAYGROUND) && yarn install --frozen-lockfile + cd $(DOTLI) && bun install --frozen-lockfile + $(MAKE) dev-link-check + +dev-link-check: ## Verify dotli can resolve the local @parity/truapi-host package. + @test -f "$(HOST_CALLBACKS_GENERATED)" || (echo "Missing generated host callbacks. Run: make codegen"; exit 1) + @test -f "$(HOST_WASM_ADAPTER_GENERATED)" || (echo "Missing generated host callbacks WASM adapter. Run: make codegen"; exit 1) + @test -f "$(HOST_WASM_WORKER_CALLBACKS_GENERATED)" || (echo "Missing generated host callbacks worker bridge. Run: make codegen"; exit 1) + @test -f "$(HOST_WASM_PKG)/dist/index.js" || (echo "Missing @parity/truapi-host dist. Run: npm run build --prefix $(HOST_WASM_PKG)"; exit 1) + @test -f "$(HOST_WASM_WEB)" || (echo "Missing @parity/truapi-host web WASM glue. Run: make wasm"; exit 1) + @test -e "$(DOTLI_HOST_WASM_LINK)/package.json" || (echo "dotli cannot resolve @parity/truapi-host. Run top-level: make dev"; exit 1) + cd $(DOTLI_UI) && bun -e 'await import("@parity/truapi-host"); await import("@parity/truapi-host/web");' + +dev: dev-bootstrap ## Start dotli host (:5173) + playground (:3000) together; open http://localhost:5173/localhost:3000. DEBUG=1 logs wire frames. @trap 'kill 0' EXIT; \ ( cd $(DOTLI) && bun run $(DOTLI_PREVIEW) ) & \ ( cd $(PLAYGROUND) && yarn dev ) & \ + ( until curl -fsS http://localhost:3000/ >/dev/null 2>&1; do sleep 1; done; curl -fsS http://localhost:3000/diagnostics >/dev/null 2>&1 || true ) & \ wait +e2e-dotli: ## Fully automated dotli + playground diagnosis e2e. Requires SIGNER_BOT_SVC_TOKEN unless E2E_DOTLI_SMOKE=1. + @SIGNER_BOT_SVC_TOKEN_ENV="$$SIGNER_BOT_SVC_TOKEN"; \ + SIGNER_BOT_BASE_URL_ENV="$$SIGNER_BOT_BASE_URL"; \ + SIGNER_BOT_NETWORK_ENV="$$SIGNER_BOT_NETWORK"; \ + set -a; \ + if [ -f .env ]; then . ./.env; fi; \ + set +a; \ + if [ -n "$$SIGNER_BOT_SVC_TOKEN_ENV" ]; then SIGNER_BOT_SVC_TOKEN="$$SIGNER_BOT_SVC_TOKEN_ENV"; export SIGNER_BOT_SVC_TOKEN; fi; \ + if [ -n "$$SIGNER_BOT_BASE_URL_ENV" ]; then SIGNER_BOT_BASE_URL="$$SIGNER_BOT_BASE_URL_ENV"; export SIGNER_BOT_BASE_URL; fi; \ + if [ -n "$$SIGNER_BOT_NETWORK_ENV" ]; then SIGNER_BOT_NETWORK="$$SIGNER_BOT_NETWORK_ENV"; export SIGNER_BOT_NETWORK; fi; \ + if [ "$$E2E_DOTLI_SMOKE" != "1" ]; then test -n "$$SIGNER_BOT_SVC_TOKEN" || (echo "Missing SIGNER_BOT_SVC_TOKEN. e2e-dotli requires signer-bot; without it a human phone scan is required."; exit 1); fi; \ + $(MAKE) dev-bootstrap; \ + cd $(PLAYGROUND) && bun tests/e2e/dotli-diagnosis.ts + matrix: ## Regenerate the host compatibility matrix from explorer/diagnosis-reports. cd $(EXPLORER) && npm run generate-matrix diff --git a/README.md b/README.md index a08d9483..a5c38f00 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ > The following is a prototype, reference implementation, and proof-of-concept. This open source code is provided for research, experimentation, and developer education only. This code has not been audited, is actively experimental, and may contain bugs, vulnerabilities, or incomplete features. Use at your own risk. -*The protocol that lets product webviews talk to their Polkadot host.* +_The protocol that lets product webviews talk to their Polkadot host._ [![License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](./LICENSE) [![CI](https://img.shields.io/github/actions/workflow/status/paritytech/truapi/ci.yml?branch=main&style=flat-square&label=ci)](https://github.com/paritytech/truapi/actions/workflows/ci.yml) @@ -57,14 +57,30 @@ rust/crates/ truapi/ Rust trait and type definitions (v01, v02) truapi-codegen/ rustdoc JSON to TypeScript client + Rust dispatcher truapi-macros/ #[wire(id = N)] proc-macro + truapi-platform/ Host syscall traits used by truapi-server (storage, navigation, consent, ...) + truapi-server/ Rust runtime that hosts implement: dispatcher, frames, SCALE, WASM surface js/packages/ - truapi/ @parity/truapi TypeScript client -playground/ Interactive Next.js playground (truapi-playground.dot) -hosts/dotli/ dotli host, vendored as a submodule -docs/ Design docs, RFCs, feature proposals -scripts/codegen.sh Regenerate the TS client from the Rust source + truapi/ @parity/truapi TypeScript client + truapi-host/ @parity/truapi-host: WASM-backed host runtime; entries `.` + (shared host types), `/web` (iframe + Web Worker), + `/worker-runtime` +playground/ Interactive Next.js playground (truapi-playground.dot) +hosts/dotli/ dotli host, vendored as a submodule +docs/ Design docs, RFCs, feature proposals +scripts/codegen.sh Regenerate the TS client from the Rust source ``` +### JS Host SDKs + +JS hosts integrate the Rust core through [`@parity/truapi-host`](js/packages/truapi-host), +a single package with tree-shakeable subpath entries: + +- `@parity/truapi-host` (the `.` entry) exposes shared host runtime types and generated callback contracts. +- `@parity/truapi-host/web` wires the WASM provider into a browser host: the iframe + MessageChannel handshake (`createIframeHost`) plus `createWebWorkerProvider`. +- `@parity/truapi-host/worker-runtime` is the Web Worker entrypoint so the WASM core can + run off the page main thread. + ## How it works 1. The protocol is defined as Rust traits in [`rust/crates/truapi/`](rust/crates/truapi/), with each method tagged `#[wire(id = N)]` for a stable byte-level dispatch table. Every method's doc comment must carry a ` ```ts ` example, which codegen extracts into the playground's EXAMPLE tab; the build fails if any method is missing one. @@ -80,9 +96,10 @@ Common tasks are wrapped in the top-level `Makefile`. Run `make help` for the fu ```bash make setup # submodules + JS dependencies -make build # Rust workspace + TypeScript client -make test # Rust + TypeScript client tests +make build # Rust workspace + TypeScript client + @parity/truapi-host +make test # Rust + TypeScript client + @parity/truapi-host tests make check # full suite: build, fmt, clippy, test, TS tests, playground build + lint +make wasm # rebuild truapi-server WASM artifacts under js/packages/truapi-host/dist/wasm/ ``` To run the playground locally: @@ -129,4 +146,3 @@ See [`CONTRIBUTING.md`](CONTRIBUTING.md) for issue reports, feature proposals, a ## License [MIT](./LICENSE) - diff --git a/deny.toml b/deny.toml index 4fdc03c6..9305a03f 100644 --- a/deny.toml +++ b/deny.toml @@ -5,6 +5,7 @@ allow = [ "MIT", "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", "BSD-2-Clause", "BSD-3-Clause", "CC0-1.0", @@ -13,3 +14,17 @@ allow = [ "Zlib", ] confidence-threshold = 0.8 + +# uniffi is MPL-2.0: file-level weak copyleft, consumed unmodified as a +# dependency, which MPL-2.0 permits without affecting the MIT outbound licence. +# Scoped per crate so MPL-2.0 stays disallowed everywhere else. +exceptions = [ + { name = "uniffi", allow = ["MPL-2.0"] }, + { name = "uniffi_bindgen", allow = ["MPL-2.0"] }, + { name = "uniffi_core", allow = ["MPL-2.0"] }, + { name = "uniffi_internal_macros", allow = ["MPL-2.0"] }, + { name = "uniffi_macros", allow = ["MPL-2.0"] }, + { name = "uniffi_meta", allow = ["MPL-2.0"] }, + { name = "uniffi_pipeline", allow = ["MPL-2.0"] }, + { name = "uniffi_udl", allow = ["MPL-2.0"] }, +] diff --git a/docs/RELEASE_PROCESS.md b/docs/RELEASE_PROCESS.md index adda268c..bc52db8b 100644 --- a/docs/RELEASE_PROCESS.md +++ b/docs/RELEASE_PROCESS.md @@ -1,9 +1,9 @@ -# Releasing `@parity/truapi` +# Releasing npm packages -The `@parity/truapi` npm package is published by +The `@parity/truapi` and `@parity/truapi-host` npm packages are published by [`paritytech/npm_publish_automation`](https://github.com/paritytech/npm_publish_automation). We never run `npm publish` locally or from a personal account; the -`Release` workflow in `.github/workflows/release.yml` packs the package +`Release` workflow in `.github/workflows/release.yml` packs the packages and dispatches the automation. Releases happen via a dedicated **release PR**. Nothing publishes @@ -31,11 +31,13 @@ npm run version-packages # consumes the changeset, bumps package.json + writ ``` The first command writes a markdown file under `.changeset/`; the second -consumes it, bumps `js/packages/truapi/package.json`, appends an entry to -`js/packages/truapi/CHANGELOG.md`, deletes the changeset file, and then -runs `scripts/sync-cargo-version.mjs` to bump -`rust/crates/truapi/Cargo.toml` to the same version. All three files -should appear in the resulting diff. +consumes it, bumps the selected package `package.json`, appends the package +`CHANGELOG.md`, deletes the changeset file, and then runs +`scripts/sync-cargo-version.mjs` to keep `rust/crates/truapi/Cargo.toml` +aligned with `js/packages/truapi/package.json`. A protocol release should +therefore include the `@parity/truapi` package, its changelog, and the Cargo +version. A host-runtime-only release can bump `@parity/truapi-host` without +changing the Rust crate version. ### 3. Open a release PR @@ -49,6 +51,7 @@ The PR title must start with `release:`. Convention: ``` release: @parity/truapi 0.1.1 +release: @parity/truapi-host 0.1.1 ``` ### 4. Get the PR reviewed and merged @@ -66,11 +69,14 @@ say); the tag-already-exists guard makes re-runs safe. On merge, CI runs as usual. When CI passes, the `Release` workflow: 1. Confirms the commit subject starts with `release:`. -2. Reads the new version from `js/packages/truapi/package.json` and - checks no `@parity/truapi@` tag exists yet (re-runs are - idempotent — they skip). -3. Builds the package, creates and pushes the tag, packs the tarball, - and dispatches to `npm_publish_automation`. +2. Reads package versions from `js/packages/truapi/package.json` and + `js/packages/truapi-host/package.json`. +3. Checks for `@parity/truapi@` and + `@parity/truapi-host@` tags. Packages whose tag already exists + are skipped, so re-runs are idempotent. +4. Builds generated sources and the host WASM bundle, creates and pushes tags + for unpublished packages, packs their tarballs, and dispatches to + `npm_publish_automation`. You can watch the dispatched run under [`paritytech/npm_publish_automation` Actions](https://github.com/paritytech/npm_publish_automation/actions). @@ -79,7 +85,7 @@ You can watch the dispatched run under - A feature PR that accidentally bumps `package.json` will **not** trigger a publish — only `release:` PRs do. -- A `release:` PR that forgets to bump the version will be skipped at +- A `release:` PR that forgets to bump package versions will be skipped at the tag-already-exists check, not silently re-publish over an existing version. - A `release:` PR with mismatched `js/packages/truapi/package.json` and diff --git a/docs/local-e2e-testing.md b/docs/local-e2e-testing.md index f646e2db..7ffde321 100644 --- a/docs/local-e2e-testing.md +++ b/docs/local-e2e-testing.md @@ -25,6 +25,21 @@ The chain below is also automated: The doc below is still the canonical narrative and the source of truth for failure modes — both the skills and CI cite it. +`make e2e-dotli` is the end-to-end dotli + playground diagnosis harness. It +starts the local dotli preview and playground, opens Chromium, signs out any +restored host session, signs in through the signer-bot SSO service, runs the +playground Diagnosis screen, and writes +`playground/test-results/e2e-dotli/diagnosis-report.md`. Full automation +requires `SIGNER_BOT_SVC_TOKEN`; `SIGNER_BOT_BASE_URL` and +`SIGNER_BOT_NETWORK` default to dotli CI's signer-bot service and +`paseo-next-v2`. Without the token, use +`E2E_DOTLI_SMOKE=1 make e2e-dotli` to verify the local stack, browser launch, +login click, TrUAPI debug logs, and QR/deeplink extraction without a phone. +In root CI, the job also needs `DOTLI_CHECKOUT_TOKEN` to read the private +dotli submodule. Without dotli access it reports a warning and skips the e2e +job; with dotli access but without `SIGNER_BOT_SVC_TOKEN`, it runs the smoke +path only. + The order matters: each layer assumes the layer below it builds clean. Skip a step only if you are certain the change cannot affect that layer. @@ -164,12 +179,19 @@ method from the UI. ```bash cd hosts/dotli bun run preview # → http://localhost:5173 -# or, for the TrUAPI debug panel: -bun run preview:debugger # = VITE_APP_DEBUG=true bun run preview ``` -`preview:debugger` is recommended whenever you're investigating a wire -issue — the debug panel logs every host↔product TrUAPI frame. +When investigating a wire issue, raise the Rust core's log level from the +host origin. The WASM worker bridge forwards core `tracing` output to the +browser console, mapping each level to the matching `console` method: + +```js +window.__truapi.setLogLevel("trace"); +``` + +`debug` and `trace` are emitted via `console.debug`, which Chrome hides +unless the console **Default levels ▾** dropdown includes **Verbose**; +`info`/`warn`/`error` always render. ### Start the playground dev server @@ -225,9 +247,9 @@ failing. Check: stale; redo step 4). If a method call hangs, the host either didn't receive the frame -(check dotli's debug panel or console) or didn't respond. The bridge -auto-responds to `host_handshake_request` only; everything else is on -the host implementation. +(check dotli's console with `truapi:logLevel` set to `debug`) or didn't respond. +The bridge auto-responds to `host_handshake_request` only; everything +else is on the host implementation. ## 7. Codegen tests diff --git a/docs/rfcs/0010-allowance.md b/docs/rfcs/0010-allowance.md index b8cd32b9..fe31710d 100644 --- a/docs/rfcs/0010-allowance.md +++ b/docs/rfcs/0010-allowance.md @@ -87,7 +87,7 @@ enum AllocatableResource { /// Allocate slot in SSS slot table StatementStoreAllowance, /// Allocate slot in Bulletin slot table - BulletInAllowance, + BulletinAllowance, /// Pre-claim PGAS into product account at `dest` to cover AH fees / storage deposits SmartContractAllowance { dest: DerivationIndex }, /// Grant auto-signing from product's own accounts. @@ -134,7 +134,7 @@ struct ResourceAllocationRequest { enum ApAllocatableResource { StatementStoreAllowance, - BulletInAllowance, + BulletinAllowance, SmartContractAllowance { dest: DerivationIndex }, AutoSigning, } @@ -162,7 +162,7 @@ enum ApAllocationOutcome { enum ApAllocatedResource { StatementStoreAllowance { slot_account_key: PrivateKey }, - BulletInAllowance { slot_account_key: PrivateKey }, + BulletinAllowance { slot_account_key: PrivateKey }, SmartContractAllowance, AutoSigning { /// Secret component of the soft-derivation path. @@ -252,7 +252,7 @@ sequenceDiagram participant B as Bulletin Chain P->>H: preimage_submit(chunk) - H->>A: request_resource_allocation(ProductId, [BulletInAllowance]) + H->>A: request_resource_allocation(ProductId, [BulletinAllowance]) A->>U: show authorization UI (single resource) U-->>A: approve A->>A: derive //allowance//bulletin//{productId} diff --git a/hosts/dotli b/hosts/dotli index 80bedceb..4812841e 160000 --- a/hosts/dotli +++ b/hosts/dotli @@ -1 +1 @@ -Subproject commit 80bedceb98bd6f305f5bf134c0225c203752ecac +Subproject commit 4812841ea9d727e96fa241b3340c0296558e6a8a 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..44e0cd0f --- /dev/null +++ b/js/packages/truapi-host/README.md @@ -0,0 +1,81 @@ +# @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. Release builds use +the workspace size-optimized Rust profile plus `wasm-opt -Oz`, validate that +debug/name/producers custom sections were stripped, and emit `.wasm.gz` and +`.wasm.br` sidecars for hosts that serve precompressed assets. + +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..22f0c698 --- /dev/null +++ b/js/packages/truapi-host/scripts/build-wasm.mjs @@ -0,0 +1,185 @@ +#!/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 { readFile, rm, writeFile } from "node:fs/promises"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { + brotliCompress, + brotliDecompress, + constants as zlibConstants, + gzip, + gunzip, +} from "node:zlib"; + +const execFileAsync = promisify(execFile); +const brotliCompressAsync = promisify(brotliCompress); +const brotliDecompressAsync = promisify(brotliDecompress); +const gzipAsync = promisify(gzip); +const gunzipAsync = promisify(gunzip); +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"; +const wasmFileName = "truapi_server_bg.wasm"; + +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; +} + +function formatBytes(bytes) { + if (bytes < 1024) return `${bytes} B`; + const kib = bytes / 1024; + if (kib < 1024) return `${kib.toFixed(1)} KiB`; + return `${(kib / 1024).toFixed(2)} MiB`; +} + +function readVarUint(bytes, cursor) { + let result = 0; + let shift = 0; + let position = cursor; + while (position < bytes.length) { + const byte = bytes[position]; + result += (byte & 0x7f) * 2 ** shift; + position += 1; + if ((byte & 0x80) === 0) { + return [result, position]; + } + shift += 7; + } + throw new Error("unterminated wasm varuint"); +} + +function readCustomSectionNames(bytes) { + if ( + bytes.length < 8 || + bytes[0] !== 0x00 || + bytes[1] !== 0x61 || + bytes[2] !== 0x73 || + bytes[3] !== 0x6d + ) { + throw new Error("generated file is not a wasm module"); + } + + const names = []; + let offset = 8; + while (offset < bytes.length) { + const sectionId = bytes[offset]; + offset += 1; + const [sectionSize, payloadStart] = readVarUint(bytes, offset); + const payloadEnd = payloadStart + sectionSize; + if (payloadEnd > bytes.length) { + throw new Error("wasm section extends past end of file"); + } + if (sectionId === 0) { + const [nameLength, nameStart] = readVarUint(bytes, payloadStart); + const nameEnd = nameStart + nameLength; + if (nameEnd > payloadEnd) { + throw new Error("wasm custom section name extends past section end"); + } + names.push(Buffer.from(bytes.subarray(nameStart, nameEnd)).toString("utf8")); + } + offset = payloadEnd; + } + return names; +} + +async function validateReleaseWasm(wasmPath) { + if (wasmProfile !== "release") return; + + const wasm = await readFile(wasmPath); + const customSections = readCustomSectionNames(wasm); + const forbidden = customSections.filter( + (name) => name === "name" || name === "producers" || name.startsWith(".debug"), + ); + if (forbidden.length > 0) { + throw new Error( + `release wasm retained debug/metadata custom sections: ${forbidden.join(", ")}`, + ); + } +} + +async function writeCompressedSidecars(wasmPath) { + if (wasmProfile !== "release") return; + + const wasm = await readFile(wasmPath); + const gzipBytes = await gzipAsync(wasm, { level: 9 }); + const brotliBytes = await brotliCompressAsync(wasm, { + params: { + [zlibConstants.BROTLI_PARAM_QUALITY]: 11, + }, + }); + + await writeFile(`${wasmPath}.gz`, gzipBytes); + await writeFile(`${wasmPath}.br`, brotliBytes); + + const [gzipRoundTrip, brotliRoundTrip] = await Promise.all([ + gunzipAsync(gzipBytes), + brotliDecompressAsync(brotliBytes), + ]); + if (!gzipRoundTrip.equals(wasm) || !brotliRoundTrip.equals(wasm)) { + throw new Error("compressed wasm sidecar round-trip validation failed"); + } + + process.stdout.write( + [ + `wasm size: ${formatBytes(wasm.length)}`, + `gzip: ${formatBytes(gzipBytes.length)}`, + `brotli: ${formatBytes(brotliBytes.length)}`, + ].join(" | ") + "\n", + ); +} + +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 }); + const wasmPath = resolve(outDir, wasmFileName); + await Promise.all([ + rm(`${wasmPath}.br`, { force: true }), + rm(`${wasmPath}.gz`, { force: true }), + ]); + await validateReleaseWasm(wasmPath); + await writeCompressedSidecars(wasmPath); +} + +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..aa4fd591 --- /dev/null +++ b/js/packages/truapi-host/src/host-callbacks-adapter.test.ts @@ -0,0 +1,468 @@ +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[] = []; + const dispose = raw.subscribeTheme?.( + (theme) => seen.push(ThemeVariant.dec(theme!)), + (error) => errors.push(error), + ); + + await settle(); + + expect(seen).toEqual(["Dark"]); + 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[] = []; + const dispose = raw.subscribeTheme?.( + (theme) => seen.push(ThemeVariant.dec(theme!)), + (error) => errors.push(error), + ); + + await settle(); + + expect(seen).toEqual(["Dark"]); + 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..39ae36a4 --- /dev/null +++ b/js/packages/truapi-host/src/web/worker-provider.test.ts @@ -0,0 +1,961 @@ +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(); + 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(worker.messages.at(-1)).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/playground/package.json b/playground/package.json index 78fef5f3..bc1116a4 100644 --- a/playground/package.json +++ b/playground/package.json @@ -10,7 +10,7 @@ "prebuild": "node ./scripts/bundle-rxjs-dts.mjs", "build": "next build", "prelint": "node ./scripts/bundle-rxjs-dts.mjs", - "lint": "next lint --dir src --dir test/generated/examples && tsc --noEmit && tsc -p tsconfig.examples.json", + "lint": "eslint src test/generated/examples && tsc --noEmit && tsc -p tsconfig.examples.json", "pretypecheck": "node ./scripts/bundle-rxjs-dts.mjs", "typecheck": "tsc --noEmit", "typecheck:examples": "tsc -p tsconfig.examples.json", @@ -41,6 +41,7 @@ "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "^15.5.4", + "jsqr": "^1.4.0", "typescript": "^6.0" }, "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" diff --git a/playground/playwright.config.ts b/playground/playwright.config.ts index b5f80bfc..9009ec7c 100644 --- a/playground/playwright.config.ts +++ b/playground/playwright.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ reporter: isCI ? [["github"], ["html", { open: "never" }]] : "list", use: { baseURL: "http://localhost:5173", + serviceWorkers: "block", trace: "retain-on-failure", screenshot: "only-on-failure", video: "retain-on-failure", @@ -23,11 +24,13 @@ export default defineConfig({ ], webServer: [ { - // dotli host iframe shell at :5173. `bun run preview` runs - // `turbo run build && bun scripts/preview-server.ts`, so a cold - // CI runner needs the long timeout. - command: "bun run preview", + // dotli host iframe shell at :5173. Localhost product proxy routes are + // debug-build-only, so mirror `make dev` and run the debug preview. + command: "bun run preview:debug", cwd: "../hosts/dotli", + env: { + VITE_NETWORKS: process.env.VITE_NETWORKS ?? "paseo-next-v2,previewnet", + }, url: "http://localhost:5173", reuseExistingServer: !isCI, timeout: 10 * 60 * 1000, diff --git a/playground/src/lib/auto-test.ts b/playground/src/lib/auto-test.ts index 617072d8..12d901a1 100644 --- a/playground/src/lib/auto-test.ts +++ b/playground/src/lib/auto-test.ts @@ -15,13 +15,16 @@ export interface TestEntry { const UNARY_TIMEOUT_MS = 10_000; const SIGNING_TIMEOUT_MS = 30_000; const SSO_TIMEOUT_MS = 60_000; +const LIVE_ALLOCATION_TIMEOUT_MS = 240_000; // Services skipped wholesale in the diagnosis until hosts wire them up. -const SKIPPED_SERVICES = new Set(["Coin Payment"]); -// Methods whose first call implicitly triggers a host permission/signing -// prompt, so they need the longer signing-class timeout to allow for the user -// to respond. `get_account_alias` and `Preimage/submit` prompt on first use. +const SKIPPED_SERVICES = new Set(["Chat", "Coin Payment", "Payment"]); +// Individual methods skipped while the host surface is intentionally deferred. +const SKIPPED_METHODS = new Set(["Account/create_account_proof"]); +// Methods that trigger a host permission/signing prompt, so they need the +// longer signing-class timeout to allow for the user to respond. const LONG_TIMEOUT_METHODS = new Set([ + "Account/get_account", "Account/get_account_alias", "Resource Allocation/request", "Signing/sign_payload", @@ -35,6 +38,13 @@ const LONG_TIMEOUT_METHODS = new Set([ const METHOD_TIMEOUT_MS = new Map([ ["Account/get_account_alias", SSO_TIMEOUT_MS], + ["Resource Allocation/request", LIVE_ALLOCATION_TIMEOUT_MS], + ["Preimage/lookup_subscribe", SSO_TIMEOUT_MS], + ["Preimage/submit", SSO_TIMEOUT_MS], + ["Signing/create_transaction", SSO_TIMEOUT_MS], + ["Statement Store/create_proof_authorized", LIVE_ALLOCATION_TIMEOUT_MS], + ["Statement Store/submit", LIVE_ALLOCATION_TIMEOUT_MS], + ["Statement Store/subscribe", LIVE_ALLOCATION_TIMEOUT_MS], ]); type RunOneOpts = { @@ -54,6 +64,10 @@ async function runOne({ onUpdate(id, { status: "skipped" }); return; } + if (SKIPPED_METHODS.has(id)) { + onUpdate(id, { status: "skipped" }); + return; + } if (!method.exampleSource) { onUpdate(id, { status: "fail", output: "no runnable example" }); return; diff --git a/playground/tests/e2e/dotli-diagnosis.ts b/playground/tests/e2e/dotli-diagnosis.ts new file mode 100644 index 00000000..3e9521d6 --- /dev/null +++ b/playground/tests/e2e/dotli-diagnosis.ts @@ -0,0 +1,760 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: AGPL-3.0-only + +import { + chromium, + type BrowserContext, + type Frame, + type Page, +} from "playwright"; +import { spawn, type ChildProcess } from "node:child_process"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { extractQrPayload } from "./dotli/helpers/extract-qr-payload"; +import { + disconnect, + generateUsername, + health, + pair, + type PairResult, +} from "./dotli/helpers/signer-bot"; + +const currentDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(currentDir, "../../.."); +const playgroundRoot = resolve(repoRoot, "playground"); +const dotliRoot = resolve(repoRoot, "hosts/dotli"); +const outputDir = resolve(playgroundRoot, "test-results/e2e-dotli"); +const screenshotsDir = resolve(outputDir, "screenshots"); + +const hostPort = process.env.E2E_DOTLI_HOST_PORT ?? process.env.PORT ?? "5173"; +const playgroundPort = process.env.E2E_DOTLI_PLAYGROUND_PORT ?? "3000"; +const headless = process.env.HEADED === "1" ? false : true; +const slowMo = process.env.SLOWMO ? Number(process.env.SLOWMO) : 0; +const smokeOnly = process.env.E2E_DOTLI_SMOKE === "1"; +const defaultBotBase = "https://signing-bot-dev.novasama-tech.org/"; +const defaultBotNetwork = "paseo-next-v2"; +const loginUserBadgeTimeoutMs = Number( + process.env.E2E_DOTLI_LOGIN_TIMEOUT_MS ?? "240000", +); + +const botToken = readEnv("SIGNER_BOT_SVC_TOKEN"); +const botBase = process.env.SIGNER_BOT_BASE_URL ?? defaultBotBase; +const botNetwork = process.env.SIGNER_BOT_NETWORK ?? defaultBotNetwork; + +const serverProcesses: ChildProcess[] = []; +const pageErrors: string[] = []; +const browserLogs: string[] = []; +const screenshots: string[] = []; +let screenshotSeq = 0; + +type PlaygroundE2E = { + waitForConnectionStatus?: ( + status: "disconnected" | "connecting" | "connected", + timeoutMs?: number, + ) => Promise; + startAccountConnectionStatusProbe?: unknown; +}; + +declare global { + interface Window { + __dotliE2eAuthStates?: unknown[]; + __truapiPlaygroundE2E?: PlaygroundE2E; + __TRUAPI_PLAYGROUND_E2E__?: boolean; + } +} + +function readEnv(name: string): string | undefined { + const value = process.env[name]; + if (!value && !smokeOnly) { + throw new Error( + `${name} is required for fully automated e2e-dotli. ` + + "This suite pairs through signer-bot; without it, a human phone scan is required.", + ); + } + return value; +} + +function requireBotEnv(): { + token: string; + base: string; + network: string; +} { + if (botToken === undefined) { + throw new Error( + "SIGNER_BOT_SVC_TOKEN is required outside E2E_DOTLI_SMOKE=1.", + ); + } + return { token: botToken, base: botBase, network: botNetwork }; +} + +function startServer( + label: string, + command: string, + args: string[], + cwd: string, + env: NodeJS.ProcessEnv = {}, +): ChildProcess { + const child = spawn(command, args, { + cwd, + env: { ...process.env, ...env }, + stdio: ["ignore", "pipe", "pipe"], + }); + serverProcesses.push(child); + + const prefix = `[${label}]`; + child.stdout?.on("data", (chunk: Buffer) => { + process.stdout.write( + chunk + .toString() + .split("\n") + .map((line) => (line.length > 0 ? `${prefix} ${line}` : line)) + .join("\n"), + ); + }); + child.stderr?.on("data", (chunk: Buffer) => { + process.stderr.write( + chunk + .toString() + .split("\n") + .map((line) => (line.length > 0 ? `${prefix} ${line}` : line)) + .join("\n"), + ); + }); + child.on("exit", (code, signal) => { + if (code !== null && code !== 0) { + console.error(`${prefix} exited with code ${code}`); + } else if (signal !== null && signal !== "SIGTERM") { + console.error(`${prefix} exited via ${signal}`); + } + }); + return child; +} + +async function waitForHttp(url: string, label: string): Promise { + const deadline = Date.now() + 120_000; + let last = ""; + while (Date.now() < deadline) { + for (const child of serverProcesses) { + if (child.exitCode !== null) { + throw new Error(`${label} failed to start; a server process exited`); + } + } + try { + const response = await fetch(url, { method: "GET" }); + if (response.ok) { + return; + } + last = `${response.status} ${response.statusText}`; + } catch (error) { + last = error instanceof Error ? error.message : String(error); + } + await sleep(1_000); + } + throw new Error(`${label} did not become ready at ${url}: ${last}`); +} + +async function sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function captureStep(page: Page, name: string): Promise { + const safeName = name.replace(/[^a-z0-9_-]+/gi, "-").toLowerCase(); + const filename = `${String(++screenshotSeq).padStart(2, "0")}-${safeName}.png`; + const path = resolve(screenshotsDir, filename); + mkdirSync(screenshotsDir, { recursive: true }); + await page + .screenshot({ path, fullPage: true }) + .then(() => { + screenshots.push(path); + console.log(`[e2e-dotli] screenshot: ${path}`); + }) + .catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + console.warn(`[e2e-dotli] screenshot failed (${name}): ${message}`); + }); +} + +async function assertPortsFree(): Promise { + await Promise.all([ + assertPortFree(Number(hostPort), "dotli preview"), + assertPortFree(Number(playgroundPort), "playground"), + ]); +} + +async function assertPortFree(port: number, label: string): Promise { + try { + await fetch(`http://127.0.0.1:${port}/`, { method: "GET" }); + } catch { + return; + } + throw new Error( + `${label} port ${port} is already serving HTTP. Stop the stale process or set ` + + `${label === "dotli preview" ? "E2E_DOTLI_HOST_PORT" : "E2E_DOTLI_PLAYGROUND_PORT"}.`, + ); +} + +async function startLocalStack(): Promise { + await assertPortsFree(); + startServer("dotli", "bun", ["run", "preview:debug"], dotliRoot, { + PORT: hostPort, + }); + startServer("playground", "yarn", ["dev"], playgroundRoot, { + PORT: playgroundPort, + }); + await Promise.all([ + waitForHttp(`http://localhost:${hostPort}/`, "dotli preview"), + waitForHttp(`http://localhost:${playgroundPort}/`, "playground"), + ]); +} + +async function signOutIfNeeded(page: Page): Promise { + const badge = page.locator("#auth-button .user-badge"); + if (!(await badge.isVisible({ timeout: 2_000 }).catch(() => false))) { + return; + } + console.log( + "[e2e-dotli] existing session found; signing out through host UI", + ); + await page.evaluate(() => { + document.querySelector("#auth-button")?.click(); + }); + await page.locator("#user-popover-disconnect").waitFor({ + state: "visible", + timeout: 5_000, + }); + await page.evaluate(() => { + document + .querySelector("#user-popover-disconnect") + ?.click(); + }); + await badge.waitFor({ state: "hidden", timeout: 20_000 }); +} + +async function openLoginQr(page: Page): Promise { + const auth = page.locator("#auth-button"); + await auth.waitFor({ state: "visible", timeout: 30_000 }); + await page.waitForFunction( + () => !document.querySelector("#auth-button")?.disabled, + null, + { timeout: 30_000 }, + ); + await auth.click(); + + const qr = page.locator("#auth-modal-qr canvas"); + await qr.waitFor({ state: "visible", timeout: 30_000 }); + await captureStep(page, "login-qr"); + return await extractQrPayload(page, "#auth-modal-qr canvas"); +} + +async function signInWithBot(page: Page): Promise { + const { token, base, network } = requireBotEnv(); + const handshake = await openLoginQr(page); + const username = generateUsername(); + console.log(`[e2e-dotli] pairing signer-bot user ${username}`); + const result = await pair(base, token, { + handshake, + username, + network, + }); + try { + await waitForSignedIn(page, result); + await captureStep(page, "signed-in"); + } catch (error) { + await disconnect(base, token, result.sessionId); + throw error; + } + return result; +} + +async function waitForSignedIn(page: Page, result: PairResult): Promise { + try { + const existingFailure = await latestLoginFailureReason(page); + if (existingFailure !== null) { + throw new Error(`Login failed: ${existingFailure}`); + } + await Promise.race([ + page + .locator("#auth-button .user-badge") + .waitFor({ state: "visible", timeout: loginUserBadgeTimeoutMs }), + page.evaluate( + () => + new Promise((_, reject) => { + const listener = (event: Event): void => { + const state = ( + event as CustomEvent< + { tag?: string; reason?: string } | undefined + > + ).detail; + if (state?.tag !== "LoginFailed") { + return; + } + window.removeEventListener("dotli:truapi-auth-state", listener); + reject(new Error(`Login failed: ${state.reason ?? "unknown"}`)); + }; + window.addEventListener("dotli:truapi-auth-state", listener); + }), + ), + ]); + } catch (error) { + await writeAuthDebug(page, { + stage: "post-pair-user-badge", + pairResult: redactedPairResult(result), + }); + throw error; + } +} + +async function latestLoginFailureReason(page: Page): Promise { + return await page.evaluate(() => { + const states = window.__dotliE2eAuthStates ?? []; + for (let i = states.length - 1; i >= 0; i--) { + const candidate = states[i] as { + detail?: { tag?: string; reason?: string }; + }; + if (candidate.detail?.tag === "LoginFailed") { + return candidate.detail.reason ?? "unknown"; + } + } + return null; + }); +} + +function redactedPairResult(result: PairResult): PairResult { + return { + sessionId: result.sessionId, + user: { + username: result.user.username, + network: result.user.network, + address: result.user.address, + publicKeyHex: result.user.publicKeyHex, + attested: result.user.attested, + }, + }; +} + +async function writeAuthDebug( + page: Page, + extra: Record, +): Promise { + const debug = await page.evaluate(() => { + const safeStorageValue = (key: string, value: string): string => { + if ( + key === "dotli:mode" || + key === "dotli:network" || + key === "dotli:chain-backend" || + key === "dotli:content-backend" || + key === "truapi:logLevel" || + key === "dotli:truapi-debug" + ) { + return value; + } + return "[redacted]"; + }; + const readStorage = (storage: Storage): Record => { + const values: Record = {}; + for (let i = 0; i < storage.length; i++) { + const key = storage.key(i); + if (key !== null && /dotli|truapi/i.test(key)) { + values[key] = safeStorageValue(key, storage.getItem(key) ?? ""); + } + } + return values; + }; + + const authButton = document.querySelector("#auth-button"); + const modal = document.querySelector("#auth-modal-backdrop"); + const modalReason = document.querySelector("#auth-modal-reason"); + return { + url: location.href, + authStates: window.__dotliE2eAuthStates ?? [], + authButtonHtml: authButton?.outerHTML ?? null, + authButtonText: authButton?.textContent ?? null, + modalClass: modal?.getAttribute("class") ?? null, + modalReasonText: modalReason?.textContent ?? null, + localStorage: readStorage(localStorage), + sessionStorage: readStorage(sessionStorage), + }; + }); + const metadataPath = resolve(outputDir, "auth-debug.json"); + writeFileSync( + metadataPath, + `${JSON.stringify({ ...debug, ...extra }, null, 2)}\n`, + ); + console.error(`[e2e-dotli] auth debug: ${metadataPath}`); +} + +function startHostModalClicker(page: Page): () => void { + let stopped = false; + void (async () => { + while (!stopped) { + await acceptVisibleHostModal(page); + await page.waitForTimeout(250).catch(() => {}); + } + })(); + return () => { + stopped = true; + }; +} + +async function drainHostModals(page: Page, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if ( + !(await page + .locator(".signing-modal-backdrop") + .isVisible() + .catch(() => false)) + ) { + return; + } + if (!(await acceptVisibleHostModal(page))) { + await page.waitForTimeout(250); + } + } +} + +async function acceptVisibleHostModal(page: Page): Promise { + const allowedLabels = new Set(["Allow", "Create", "Sign"]); + const buttons = page.locator(".signing-modal-backdrop button"); + const count = await buttons.count().catch(() => 0); + for (let index = 0; index < count; index++) { + const button = buttons.nth(index); + const visible = await button.isVisible({ timeout: 100 }).catch(() => false); + const enabled = await button.isEnabled({ timeout: 100 }).catch(() => false); + if (!visible || !enabled) { + continue; + } + const label = (await button.innerText().catch(() => "")).trim(); + if (!allowedLabels.has(label)) { + continue; + } + console.log(`[e2e-dotli] accepting host modal: ${label}`); + await button.click({ timeout: 2_000 }).catch(() => {}); + return true; + } + return false; +} + +async function findPlaygroundFrame(page: Page) { + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + const frame = page + .frames() + .find((candidate) => + candidate.url().startsWith(`http://localhost:${playgroundPort}/`), + ); + if (frame) { + const ready = await frame + .locator('[data-testid="diagnosis-entry"]') + .isVisible({ timeout: 500 }) + .catch(() => false); + if (ready) { + return frame; + } + } + await page.waitForTimeout(250); + } + throw new Error("playground iframe did not become ready"); +} + +async function waitForPlaygroundE2EHook(page: Page): Promise { + const frame = await findPlaygroundFrame(page); + await frame.waitForFunction(() => Boolean(window.__truapiPlaygroundE2E), { + timeout: 15_000, + }); + await frame.evaluate(async () => { + const hook = window.__truapiPlaygroundE2E; + if (!hook?.waitForConnectionStatus) { + throw new Error("playground e2e host connection hook is unavailable"); + } + await hook.waitForConnectionStatus("connected", 30_000); + }); +} + +async function assertHostSignOutAndReconnect(page: Page): Promise { + console.log("[e2e-dotli] validating host sign-out"); + await signOutIfNeeded(page); + await page + .locator("#auth-button .user-badge") + .waitFor({ state: "hidden", timeout: 20_000 }); + await captureStep(page, "signed-out"); + + console.log("[e2e-dotli] validating signer reconnect"); + return await signInWithBot(page); +} + +async function runDiagnosis(page: Page): Promise<{ + summary: string; + report: string; + copyReportClicked: boolean; +}> { + for (let attempt = 1; attempt <= 2; attempt++) { + try { + return await runDiagnosisOnce(page); + } catch (error) { + if (attempt === 2 || !isFrameDetachedError(error)) { + throw error; + } + console.warn( + "[e2e-dotli] playground iframe detached during diagnosis; retrying once", + ); + await captureStep(page, `diagnosis-frame-detached-attempt-${attempt}`); + await page.waitForTimeout(1_000); + } + } + throw new Error("diagnosis retry exhausted"); +} + +async function runDiagnosisOnce(page: Page): Promise<{ + summary: string; + report: string; + copyReportClicked: boolean; +}> { + const frame = await findPlaygroundFrame(page); + await captureStep(page, "diagnosis-ready"); + await frame.locator('[data-testid="diagnosis-entry"]').click(); + await frame.locator('[data-testid="diagnosis-run"]').click(); + await captureStep(page, "diagnosis-running"); + + await waitForDiagnosisReportReady(frame); + + const summary = await frame + .locator('[data-testid="diagnosis-summary"]') + .innerText({ timeout: 5_000 }); + await drainHostModals(page, 5_000); + await captureStep(page, "diagnosis-report-ready"); + const report = + (await frame + .locator('[data-testid="diagnosis-report-markdown"]') + .textContent({ timeout: 5_000 })) ?? ""; + if (report.trim().length === 0) { + throw new Error("diagnosis report markdown is empty"); + } + + await frame.locator('[data-testid="diagnosis-copy-report"]').click(); + + return { summary, report, copyReportClicked: true }; +} + +async function waitForDiagnosisReportReady(frame: Frame): Promise { + const deadline = Date.now() + 20 * 60_000; + let lastLogAt = 0; + while (Date.now() < deadline) { + const reportReady = await frame + .locator('[data-testid="diagnosis-copy-report"]') + .isVisible({ timeout: 1_000 }); + if (reportReady) { + return; + } + + const now = Date.now(); + if (now - lastLogAt >= 30_000) { + lastLogAt = now; + const progress = await frame.evaluate(() => { + const counts: Record = {}; + let running = "none"; + for (const row of document.querySelectorAll( + '[data-testid="diagnosis-row"]', + )) { + const status = row.dataset.status ?? "unknown"; + counts[status] = (counts[status] ?? 0) + 1; + if (status === "running") { + running = + row.querySelector(".diag__name")?.innerText ?? + "unknown"; + } + } + const parts = Object.entries(counts) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([status, count]) => `${status}=${count}`); + return `${parts.join(" ")} running=${running}`; + }); + console.log(`[e2e-dotli] diagnosis progress: ${progress}`); + } + + await sleep(5_000); + } + throw new Error("diagnosis did not finish within 20 minutes"); +} + +function isFrameDetachedError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return message.includes("Frame was detached"); +} + +async function main(): Promise { + mkdirSync(outputDir, { recursive: true }); + mkdirSync(screenshotsDir, { recursive: true }); + if (!smokeOnly) { + const { base, network } = requireBotEnv(); + console.log(`[e2e-dotli] bot=${base} network=${network}`); + const probe = await health(base); + if (!probe.ok) { + throw new Error(`signer-bot unavailable: ${probe.error ?? probe.status}`); + } + } else { + console.log("[e2e-dotli] smoke mode: validating local stack and QR only"); + } + + let browser: Awaited> | undefined; + let context: BrowserContext | undefined; + let pairResult: PairResult | undefined; + let page: Page | undefined; + try { + await startLocalStack(); + + const executablePath = existsSync("/usr/bin/chromium") + ? "/usr/bin/chromium" + : undefined; + browser = await chromium.launch({ + headless, + slowMo, + executablePath, + args: ["--no-sandbox"], + }); + context = await browser.newContext({ + serviceWorkers: "block", + permissions: ["camera", "clipboard-read", "clipboard-write"], + }); + page = await context.newPage(); + page.on("pageerror", (error) => { + const message = error.stack ?? error.message; + pageErrors.push(message); + console.error(`[browser:pageerror] ${message}`); + }); + page.on("console", (message) => { + const text = message.text(); + if ( + message.type() === "error" || + /\[truapi|\[dotli|\[dot\.li|statement.store|signing/i.test(text) + ) { + const line = `[browser:${message.type()}] ${text}`; + browserLogs.push(line); + console.log(line); + } + }); + + await page.addInitScript( + ({ playgroundPort }) => { + try { + const playgroundLabel = `localhost:${playgroundPort}`; + localStorage.setItem("dotli:mode", "gateway"); + localStorage.setItem("dotli:chain-backend", "rpc-gateway"); + localStorage.setItem("dotli:content-backend", "ipfs-gateway"); + localStorage.setItem( + `dotli:permissions:${playgroundLabel}`, + JSON.stringify({ Camera: "granted" }), + ); + localStorage.setItem("desktop-banner-dismissed", "1"); + sessionStorage.removeItem("dotli:truapi-debug"); + localStorage.setItem("truapi:logLevel", "debug"); + localStorage.setItem("truapi:playground:e2e", "1"); + window.__TRUAPI_PLAYGROUND_E2E__ = true; + window.__dotliE2eAuthStates = []; + window.addEventListener("dotli:truapi-auth-state", (event: Event) => { + window.__dotliE2eAuthStates?.push({ + timestamp: Date.now(), + detail: (event as CustomEvent).detail, + }); + }); + } catch { + /* ignore */ + } + }, + { playgroundPort }, + ); + + const params = new URLSearchParams({ + chainBackend: "rpc-gateway", + e2e: String(Date.now()), + network: botNetwork, + }); + const url = `http://localhost:${hostPort}/localhost:${playgroundPort}?${params.toString()}`; + await page.goto(url, { timeout: 60_000, waitUntil: "domcontentloaded" }); + await captureStep(page, "loaded"); + await signOutIfNeeded(page); + if (smokeOnly) { + const handshake = await openLoginQr(page); + const metadataPath = resolve(outputDir, "smoke-run.json"); + writeFileSync( + metadataPath, + `${JSON.stringify( + { + mode: "smoke", + handshakePrefix: handshake.slice(0, 32), + pageErrors, + browserLogs, + timestamp: new Date().toISOString(), + }, + null, + 2, + )}\n`, + ); + console.log(`[e2e-dotli] smoke complete: ${metadataPath}`); + if (pageErrors.length > 0) { + throw new Error(`browser page errors occurred: ${pageErrors.length}`); + } + return; + } + await waitForPlaygroundE2EHook(page); + pairResult = await signInWithBot(page); + const stopClicker = startHostModalClicker(page); + try { + const { summary, report, copyReportClicked } = await runDiagnosis(page); + const reportPath = resolve(outputDir, "diagnosis-report.md"); + writeFileSync(reportPath, report); + pairResult = await assertHostSignOutAndReconnect(page); + const metadataPath = resolve(outputDir, "diagnosis-run.json"); + writeFileSync( + metadataPath, + `${JSON.stringify( + { + summary, + reportPath, + copyReportClicked, + screenshots, + user: redactedPairResult(pairResult).user, + sessionLifecycle: "host-sign-out-reconnect", + pageErrors, + browserLogs, + timestamp: new Date().toISOString(), + }, + null, + 2, + )}\n`, + ); + console.log(`[e2e-dotli] diagnosis complete: ${summary}`); + console.log(`[e2e-dotli] report: ${reportPath}`); + if (pageErrors.length > 0) { + throw new Error(`browser page errors occurred: ${pageErrors.length}`); + } + } finally { + stopClicker(); + } + } catch (error) { + if (page) { + await captureStep(page, "failure"); + } + throw error; + } finally { + if (pairResult) { + const { token, base } = requireBotEnv(); + await disconnect(base, token, pairResult.sessionId); + } + await context?.close().catch(() => {}); + await browser?.close().catch(() => {}); + for (const child of serverProcesses) { + child.kill("SIGTERM"); + } + } +} + +main().catch((error: unknown) => { + const message = + error instanceof Error ? (error.stack ?? error.message) : String(error); + mkdirSync(dirname(resolve(outputDir, "failure.log")), { recursive: true }); + writeFileSync(resolve(outputDir, "failure.log"), `${message}\n`); + console.error(`[e2e-dotli] ${message}`); + process.exit(1); +}); diff --git a/playground/tests/e2e/dotli/helpers/extract-qr-payload.ts b/playground/tests/e2e/dotli/helpers/extract-qr-payload.ts new file mode 100644 index 00000000..fb94678d --- /dev/null +++ b/playground/tests/e2e/dotli/helpers/extract-qr-payload.ts @@ -0,0 +1,44 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: AGPL-3.0-only + +import type { Page } from "@playwright/test"; +import jsQR from "jsqr"; + +export async function extractQrPayload( + page: Page, + canvasSelector: string, + timeoutMs = 30_000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const embedded = await page + .locator(canvasSelector) + .getAttribute("data-qr-payload", { timeout: 250 }) + .catch(() => null); + if (embedded?.startsWith("polkadotapp://")) { + return embedded; + } + + const px = await page.evaluate((sel) => { + const canvas = document.querySelector(sel) as HTMLCanvasElement | null; + if (!canvas || canvas.width === 0) return null; + const ctx = canvas.getContext("2d"); + if (!ctx) return null; + const img = ctx.getImageData(0, 0, canvas.width, canvas.height); + return { + data: Array.from(img.data), + width: img.width, + height: img.height, + }; + }, canvasSelector); + + if (px) { + const code = jsQR(new Uint8ClampedArray(px.data), px.width, px.height); + if (code?.data?.startsWith("polkadotapp://")) { + return code.data; + } + } + await page.waitForTimeout(1_000); + } + throw new Error("Could not decode QR payload from canvas"); +} diff --git a/playground/tests/e2e/dotli/helpers/signer-bot.ts b/playground/tests/e2e/dotli/helpers/signer-bot.ts new file mode 100644 index 00000000..7560a4b7 --- /dev/null +++ b/playground/tests/e2e/dotli/helpers/signer-bot.ts @@ -0,0 +1,278 @@ +// Copyright 2026 Parity Technologies (UK) Ltd. +// SPDX-License-Identifier: AGPL-3.0-only + +import { setTimeout as sleep } from "node:timers/promises"; + +const TRANSIENT = new Set([502, 503, 504]); + +// Per-attempt request timeout. First-time pair can include user creation and +// People-chain attestation, so give the one-shot handshake room to finish +// instead of aborting and retrying the same QR payload. +const PAIR_REQUEST_TIMEOUT_MS = Number( + process.env.SIGNER_BOT_PAIR_TIMEOUT_MS ?? "120000", +); +const HEALTH_REQUEST_TIMEOUT_MS = 5_000; + +function shellQuote(value: string): string { + if (value.length === 0) return "''"; + return `'${value.replace(/'/g, "'\\''")}'`; +} + +function buildPairCurl(url: string, body: unknown): string { + return [ + `curl -sS -X POST ${shellQuote(url)}`, + '-H "Authorization: Bearer ${SIGNER_BOT_SVC_TOKEN}"', + "-H 'Content-Type: application/json'", + "-H 'Accept: application/json'", + `--data-raw ${shellQuote(JSON.stringify(body))}`, + ].join(" \\\n "); +} + +const SECRET_FIELD_NAMES = new Set([ + "mnemonic", + "privatekey", + "secret", + "secretphrase", + "secretseed", + "secreturi", + "seed", + "suri", +]); + +const QUOTED_JSON_FIELD = /"((?:\\.|[^"\\])+)"\s*:\s*"((?:\\.|[^"\\])*)"/g; + +function normalizeSecretFieldName(name: string): string { + return name.replace(/[-_]/g, "").toLowerCase(); +} + +function isSecretFieldName(name: string): boolean { + return SECRET_FIELD_NAMES.has(normalizeSecretFieldName(name)); +} + +export function redactSignerBotResponse(text: string): string { + try { + return JSON.stringify(JSON.parse(text), (key, value) => + isSecretFieldName(key) ? "[redacted]" : value, + ); + } catch { + return text.replace(QUOTED_JSON_FIELD, (field, name: string) => + isSecretFieldName(name) ? `"${name}":"[redacted]"` : field, + ); + } +} + +async function fetchWithTimeout( + url: string, + init: RequestInit, + timeoutMs: number, +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { ...init, signal: controller.signal }); + } finally { + clearTimeout(timer); + } +} + +interface TextResponse { + response: Response; + text: string; +} + +async function fetchTextWithTimeout( + url: string, + init: RequestInit, + timeoutMs: number, +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { ...init, signal: controller.signal }); + const text = await response.text(); + return { response, text }; + } finally { + clearTimeout(timer); + } +} + +async function fetchTextRetry( + url: string, + init: RequestInit, + attempts = 4, + timeoutMs = PAIR_REQUEST_TIMEOUT_MS, +): Promise { + let last: unknown = null; + for (let i = 1; i <= attempts; i++) { + try { + const result = await fetchTextWithTimeout(url, init, timeoutMs); + const { response, text } = result; + if (response.ok || !TRANSIENT.has(response.status) || i === attempts) { + return result; + } + console.warn( + `[bot] ${init.method ?? "GET"} ${url} response ${response.status} ${response.statusText} (attempt ${i}/${attempts}): ${redactSignerBotResponse(text)}`, + ); + } catch (e) { + last = e; + if (i === attempts) throw e; + console.warn( + `[bot] ${init.method ?? "GET"} ${url} threw "${(e as Error).message}" (attempt ${i}/${attempts})`, + ); + } + await sleep(1_000 * 2 ** (i - 1)); + } + throw last ?? new Error("fetchTextRetry exhausted"); +} + +/** + * Generate a per-run username for the Nova signing bot. + * + * Each test run gets its own throwaway user so network state (allowances, + * permissions, derived product accounts) doesn't leak between PRs. The + * format is `dotlitests` followed by 6 lowercase letters, a namespace of + * roughly 3·10^8 with no collisions in practice. It stays inside the bot's + * strictest username regex (^[a-z]+$) so it works for both regular + * `username` and `liteUsername` fields if we ever want one. + */ +export function generateUsername(): string { + const alphabet = "abcdefghijklmnopqrstuvwxyz"; + let suffix = ""; + for (let i = 0; i < 6; i++) { + suffix += alphabet[Math.floor(Math.random() * alphabet.length)]; + } + return `dotlitests${suffix}`; +} + +export interface PairResult { + sessionId: string; + user: { + username: string; + network: string; + address: string; + publicKeyHex: string; + attested?: boolean; + }; +} + +/** + * Pair the bot with a dot.li session via the QR-derived handshake deeplink. + * + * The Nova bot's `/api/pair` is one-shot: given a handshake, it + * (a) creates the user if `username` is new, (b) attests the account on + * People chain so it has Statement Store allowance, (c) completes the + * SSO handshake, (d) starts auto-signing future SignRequests for that + * session. No separate provisioning / poll step needed. + * + * `network` should match dot.li's default network (`paseo-next-v2` at time + * of writing). The bot's `/api/networks` endpoint lists supported IDs. + */ +export async function pair( + base: string, + svcToken: string, + args: { handshake: string; username: string; network: string }, +): Promise { + const url = `${base.replace(/\/$/, "")}/api/pair`; + console.log(`[bot] /api/pair curl:\n${buildPairCurl(url, args)}`); + const init: RequestInit = { + method: "POST", + headers: { + Authorization: `Bearer ${svcToken}`, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(args), + }; + let result: TextResponse; + try { + result = await fetchTextRetry(url, init); + } catch (e) { + console.error( + `[bot] /api/pair response unavailable: ${(e as Error).message}`, + ); + throw e; + } + const { response: r, text } = result; + console.log( + `[bot] /api/pair raw response ${r.status} ${r.statusText}: ${redactSignerBotResponse(text) || ""}`, + ); + if (!r.ok) { + throw new Error( + `pair ${r.status}: ${redactSignerBotResponse(text) || ""}`, + ); + } + return JSON.parse(text) as PairResult; +} + +/** + * Tear down a bot session at the end of a test worker. + * + * Best-effort. Failure to disconnect is non-fatal, the bot times + * sessions out anyway. + */ +export async function disconnect( + base: string, + svcToken: string, + sessionId: string, +): Promise { + await disconnectStrict(base, svcToken, sessionId).catch(() => {}); +} + +export async function disconnectStrict( + base: string, + svcToken: string, + sessionId: string, +): Promise { + const response = await fetchWithTimeout( + `${base.replace(/\/$/, "")}/api/disconnect`, + { + method: "POST", + headers: { + Authorization: `Bearer ${svcToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ sessionId }), + }, + PAIR_REQUEST_TIMEOUT_MS, + ); + if (!response.ok) { + throw new Error(`disconnect ${response.status}: ${await response.text()}`); + } + const body = (await response.json()) as { disconnected?: boolean }; + if (body.disconnected !== true) { + throw new Error(`disconnect did not find session ${sessionId}`); + } +} + +export interface BotHealth { + ok: boolean; + status?: string; + uptime?: number; + error?: string; +} + +/** + * Lightweight bot reachability probe. Used by globalSetup to fail-fast + * when the bot is unreachable, distinguishing "Nova is down" from + * "dot.li is broken" in CI output. No auth required. + */ +export async function health(base: string): Promise { + try { + const r = await fetchWithTimeout( + `${base.replace(/\/$/, "")}/api/health`, + { method: "GET", headers: { Accept: "application/json" } }, + HEALTH_REQUEST_TIMEOUT_MS, + ); + if (!r.ok) { + return { ok: false, error: `${r.status} ${r.statusText}` }; + } + const body = (await r.json()) as { status?: string; uptime?: number }; + return { + ok: body.status === "ok", + status: body.status, + uptime: body.uptime, + }; + } catch (e) { + return { ok: false, error: (e as Error).message }; + } +} diff --git a/playground/tests/e2e/helpers.ts b/playground/tests/e2e/helpers.ts index a5cc3801..c61550ef 100644 --- a/playground/tests/e2e/helpers.ts +++ b/playground/tests/e2e/helpers.ts @@ -4,11 +4,27 @@ import { expect, type FrameLocator, type Page } from "@playwright/test"; * Open the playground inside dotli's iframe shell and wait for it to mount. * * The dotli host parses `/localhost:` as a proxy directive and iframes - * `http://localhost:3000`. We hand back the FrameLocator scoped to that - * iframe so individual specs only need to know about playground selectors. + * `http://localhost:3000`. The `dotliProductId` query param is host-only; it + * makes local e2e use the same product id as the deployed playground examples. + * We hand back the FrameLocator scoped to that iframe so individual specs only + * need to know about playground selectors. */ export async function openPlaygroundInDotli(page: Page): Promise { - await page.goto("/localhost:3000"); + await page.addInitScript(() => { + localStorage.setItem("dotli:mode", "gateway"); + localStorage.setItem("dotli:chain-backend", "rpc"); + localStorage.setItem("dotli:content-backend", "ipfs-gateway"); + localStorage.setItem( + "dotli:permissions:localhost:3000", + JSON.stringify({ Camera: "granted" }), + ); + localStorage.setItem("desktop-banner-dismissed", "1"); + localStorage.setItem("truapi:playground:e2e", "1"); + ( + window as typeof window & { __TRUAPI_PLAYGROUND_E2E__?: boolean } + ).__TRUAPI_PLAYGROUND_E2E__ = true; + }); + await page.goto("/localhost:3000?dotliProductId=truapi-playground.dot"); // dotli renders an additional hidden iframe (host.localhost:5173?mode=direct) // alongside the proxied playground; scope to the playground src so the // FrameLocator is unique under Playwright strict mode. diff --git a/playground/yarn.lock b/playground/yarn.lock index 01d5a21a..6f4162c8 100644 --- a/playground/yarn.lock +++ b/playground/yarn.lock @@ -1920,6 +1920,11 @@ json5@^1.0.2: dependencies: minimist "^1.2.0" +jsqr@^1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/jsqr/-/jsqr-1.4.0.tgz#8efb8d0a7cc6863cb6d95116b9069123ce9eb2d1" + integrity sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A== + "jsx-ast-utils@^2.4.1 || ^3.0.0", jsx-ast-utils@^3.3.5: version "3.3.5" resolved "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.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..1ac2e496 100644 --- a/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts +++ b/rust/crates/truapi-codegen/tests/golden/host-callbacks-adapter.ts @@ -16,9 +16,7 @@ import { RemotePermissionResponse, ThemeVariant, } from "@parity/truapi"; -import type { - NotificationId, -} from "@parity/truapi"; +import type { GenericError, NotificationId } from "@parity/truapi"; import { AuthState, CoreStorageKey, @@ -29,13 +27,8 @@ import type { RequiredHostCallbacks, } from "./host-callbacks.js"; -import type { - ChainConnect, -} from "../runtime.js"; -import { - chainConnectAdapter, - driveResultStream, -} from "../adapter-support.js"; +import type { ChainConnect } from "../runtime.js"; +import { chainConnectAdapter, driveResultStream } from "../adapter-support.js"; export interface RawCallbacks { authStateChanged(state: Uint8Array): void; @@ -49,12 +42,22 @@ export interface RawCallbacks { cancelNotification(id: NotificationId): Promise; devicePermission(request: Uint8Array): Promise; remotePermission(request: Uint8Array): Promise; - submitPreimage(value: Uint8Array, bulletinAllowanceSigner: BulletinAllowanceSigner): Promise; - lookupPreimage(key: Uint8Array, sendItem: (item?: Uint8Array) => void): (() => void) | void; + submitPreimage( + value: Uint8Array, + bulletinAllowanceSigner: BulletinAllowanceSigner, + ): Promise; + 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 @@ -63,23 +66,66 @@ export function createWasmRawCallbacks( callbacks: RequiredHostCallbacks, ): RawCallbacks { return { - authStateChanged: async (state) => await callbacks.auth.authStateChanged(AuthState.dec(state)), + authStateChanged: async (state) => + await callbacks.auth.authStateChanged(AuthState.dec(state)), chainConnect: chainConnectAdapter(callbacks.chain), - readCoreStorage: async (key) => await callbacks.coreStorage.readCoreStorage(CoreStorageKey.dec(key)), - writeCoreStorage: async (key, value) => await callbacks.coreStorage.writeCoreStorage(CoreStorageKey.dec(key), value), - clearCoreStorage: async (key) => await callbacks.coreStorage.clearCoreStorage(CoreStorageKey.dec(key)), - featureSupported: async (request) => HostFeatureSupportedResponse.enc(await callbacks.features.featureSupported(HostFeatureSupportedRequest.dec(request))), + readCoreStorage: async (key) => + await callbacks.coreStorage.readCoreStorage(CoreStorageKey.dec(key)), + writeCoreStorage: async (key, value) => + await callbacks.coreStorage.writeCoreStorage( + CoreStorageKey.dec(key), + value, + ), + clearCoreStorage: async (key) => + await callbacks.coreStorage.clearCoreStorage(CoreStorageKey.dec(key)), + featureSupported: async (request) => + HostFeatureSupportedResponse.enc( + await callbacks.features.featureSupported( + HostFeatureSupportedRequest.dec(request), + ), + ), navigateTo: async (url) => await callbacks.navigation.navigateTo(url), - pushNotification: async (notification) => HostPushNotificationResponse.enc(await callbacks.notifications.pushNotification(HostPushNotificationRequest.dec(notification))), - cancelNotification: async (id) => await callbacks.notifications.cancelNotification(id), - 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), + pushNotification: async (notification) => + HostPushNotificationResponse.enc( + await callbacks.notifications.pushNotification( + HostPushNotificationRequest.dec(notification), + ), + ), + cancelNotification: async (id) => + await callbacks.notifications.cancelNotification(id), + 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, 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), + 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))), - confirmUserAction: async (review) => await callbacks.userConfirmation.confirmUserAction(UserConfirmationReview.dec(review)), + 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..38ead680 100644 --- a/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts +++ b/rust/crates/truapi-codegen/tests/golden/worker-callbacks.ts @@ -6,6 +6,18 @@ // 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"; + +export interface WorkerBulletinAllowanceSigner { + publicKey: Uint8Array; + signerId: number; +} + +import type { + ChainConnect, +} from "../runtime.js"; import type { BulletinAllowanceSigner } from "./host-callbacks.js"; @@ -50,6 +62,7 @@ export interface WorkerCallbackBridge { name: SubscriptionName, payload: Uint8Array | null, sendItem: (value: T) => void, + sendError: (error: GenericError) => void, ): () => void; chainConnect: ChainConnect; } @@ -91,10 +104,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 +127,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 +135,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/Cargo.toml b/rust/crates/truapi-server/Cargo.toml index 93ce86b5..4a2e9e72 100644 --- a/rust/crates/truapi-server/Cargo.toml +++ b/rust/crates/truapi-server/Cargo.toml @@ -8,6 +8,23 @@ license = "MIT" [lib] crate-type = ["rlib", "cdylib"] +[package.metadata.wasm-pack.profile.release] +wasm-opt = [ + "-Oz", + "--enable-bulk-memory", + "--enable-nontrapping-float-to-int", + "--enable-reference-types", + "--enable-sign-ext", + "--strip-debug", + "--strip-dwarf", + "--strip-producers", +] + +[package.metadata.wasm-pack.profile.release.wasm-bindgen] +debug-js-glue = false +demangle-name-section = false +dwarf-debug-info = false + [features] default = [] 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. diff --git a/rust/crates/truapi/src/api/account.rs b/rust/crates/truapi/src/api/account.rs index 83211328..1ccaac15 100644 --- a/rust/crates/truapi/src/api/account.rs +++ b/rust/crates/truapi/src/api/account.rs @@ -43,6 +43,15 @@ pub trait Account: Send + Sync { /// }); /// assert(result.isOk(), "getAccount failed:", result); /// console.log("account retrieved:", result.value); + /// + /// const otherProduct = await truapi.account.getAccount({ + /// productAccountId: { + /// dotNsIdentifier: "other-product.dot", + /// derivationIndex: 0, + /// }, + /// }); + /// assert(otherProduct.isOk(), "cross-product getAccount was denied or failed:", otherProduct); + /// console.log("other product account retrieved after approval:", otherProduct.value); /// ``` #[wire(request_id = 22)] async fn get_account( diff --git a/rust/crates/truapi/src/api/statement_store.rs b/rust/crates/truapi/src/api/statement_store.rs index 16cfc8cd..8a3a5f2b 100644 --- a/rust/crates/truapi/src/api/statement_store.rs +++ b/rust/crates/truapi/src/api/statement_store.rs @@ -25,14 +25,8 @@ pub trait StatementStore: Send + Sync { /// const expiry = BigInt(Math.floor(Date.now() / 1000) + 86400) << 32n; /// const statement: Statement = { expiry, topics: [topic] }; /// - /// const proofResult = await truapi.statementStore.createProof({ - /// productAccountId: { - /// dotNsIdentifier: "truapi-playground.dot", - /// derivationIndex: 0, - /// }, - /// statement, - /// }); - /// assert(proofResult.isOk(), "createProof failed:", proofResult); + /// const proofResult = await truapi.statementStore.createProofAuthorized(statement); + /// assert(proofResult.isOk(), "createProofAuthorized failed:", proofResult); /// /// statement.proof = proofResult.value.proof; /// console.log("submitting statement:", statement); @@ -68,7 +62,8 @@ pub trait StatementStore: Send + Sync { /// /// **Deprecated:** use [`create_proof_authorized`](Self::create_proof_authorized) /// instead, which uses a pre-allocated allowance account and does not - /// require a per-call signing prompt. + /// require a per-call signing prompt. Pairing hosts may reject this method + /// when their signing channel cannot sign statement proof payloads exactly. /// /// ```ts /// // Expiry packs a Unix-seconds timestamp in the high 32 bits; a day out @@ -84,8 +79,11 @@ pub trait StatementStore: Send + Sync { /// }, /// statement, /// }); - /// assert(result.isOk(), "createProof failed:", result); - /// console.log("proof created:", result.value); + /// if (result.isErr()) { + /// console.log("deprecated createProof unavailable:", result.error); + /// } else { + /// console.log("proof created:", result.value); + /// } /// ``` #[wire(request_id = 60)] async fn create_proof( @@ -136,14 +134,8 @@ pub trait StatementStore: Send + Sync { /// const expiry = BigInt(Math.floor(Date.now() / 1000) + 86400) << 32n; /// const statement = { expiry, topics: [topic] }; /// - /// const proofResult = await truapi.statementStore.createProof({ - /// productAccountId: { - /// dotNsIdentifier: "truapi-playground.dot", - /// derivationIndex: 0, - /// }, - /// statement, - /// }); - /// assert(proofResult.isOk(), "createProof failed:", proofResult); + /// const proofResult = await truapi.statementStore.createProofAuthorized(statement); + /// assert(proofResult.isOk(), "createProofAuthorized failed:", proofResult); /// /// const result = await truapi.statementStore.submit({ /// proof: proofResult.value.proof,