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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ dist/
*.tgz
.DS_Store
test/test-product-bundle.js
test/test-product-truapi-bundle.js
test/test-results/
test-results/

Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
# Changelog

## 0.11.0

### Added

- **`@parity/truapi` 0.4 product support (MessagePort handoff).** Products built on `@parity/truapi` ≥ 0.4 boot through `@parity/truapi/sandbox`: the iframe posts `{ type: "truapi-ready" }` to the parent window and waits (20s) for a `{ type: "truapi-init" }` answer carrying a transferred `MessagePort`, then runs all protocol traffic over that port — it never listens on direct window postMessage. The host page now answers that handshake with a fresh port pair on every product page load and routes wire frames to whichever channel the product opened. Products on the 0.3 bootstrap (`@novasamatech/host-api-wrapper`) continue to use the direct window postMessage channel; both kinds connect to the same container, and `waitForConnection()` and all handlers work unchanged. Wire frames are identical on both channels, so no codec changes were needed.

### Internal

- `src/browser/truapi-port-handoff.ts`: `createDualChannelIframeProvider({ iframe, url })` builds on the container's `createIframeProvider`, answering `truapi-ready` and swapping the port pair per page load (device-permission and deep-link reloads each re-handshake).
- `test/truapi-product.spec.ts` + `test/test-product-truapi.ts`: integration coverage with a real `@parity/truapi@0.4` product bundle — asserts `getConnectionStatus()` turns `connected` and serves a localStorage roundtrip and product-account fetch over the port. `@parity/truapi` added as a devDependency.

## 0.10.0

### Breaking changes
Expand Down
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,12 +207,18 @@ Playwright test
→ registers handlers: accounts, signing, chain RPC, localStorage

Product (in iframe)
→ host-api-wrapper detects iframe parent
→ injects window.injectedWeb3.spektr
→ truapi ≥ 0.4 (@parity/truapi/sandbox): posts truapi-ready, host answers
truapi-init with a transferred MessagePort — all frames flow over the port
→ truapi 0.3 (host-api-wrapper): exchanges frames directly over window
postMessage; injects window.injectedWeb3.spektr
→ gets accounts (Alice/Bob with real sr25519 public keys)
→ signing requests → host auto-signs with dev keypair → returns signature
```

Both channels carry the same wire frames and feed the same container, so every
handler, log, and control knob behaves identically for either product
generation.

The browser bundle (~780KB minified) includes `@novasamatech/host-container`, `@polkadot/keyring`, `@polkadot/types`, and WASM crypto. It's pre-built and inlined — consumers have zero build-time dependencies.

## API reference
Expand Down
34 changes: 34 additions & 0 deletions forum-post.md
Original file line number Diff line number Diff line change
Expand Up @@ -668,3 +668,37 @@ The config type is renamed. The shape is identical (`id`, `name`, `genesisHash`,
4. Optionally, add the extra networks your product connects to so mid-session chain switches resolve.

---

# host-api-test-sdk 0.11.0

## `@parity/truapi` 0.4 products connect out of the box

Products that upgraded to `@parity/truapi` 0.4 (including everything built on
recent `@parity/product-sdk`) change how the iframe channel is opened: instead
of exchanging frames directly over window postMessage, the product posts
`{ type: "truapi-ready" }` and expects the host to answer with
`{ type: "truapi-init" }` carrying a transferred `MessagePort`. Against older
test-sdk releases, that handshake went unanswered — the product waited 20
seconds for a port that never arrived and `waitForConnection()` timed out.

The test host now answers the handshake and serves all traffic over the
transferred port. No test changes are needed:

```ts
const bobFixture = createTestHostFixture({
productUrl: "http://localhost:5260",
accounts: ["bob"],
networks: [PASEO_ASSET_HUB],
});
// waitForConnection() now resolves for truapi-0.4 products too
```

Products on the 0.3 bootstrap (`@novasamatech/host-api-wrapper`) are
unaffected — the direct window postMessage channel is still served, and both
kinds of product talk to the same container with the same handlers, logs, and
permission model.

## What you need to do

1. Upgrade to `0.11.0`.
2. Nothing else — both product generations connect without configuration.
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@parity/host-api-test-sdk",
"version": "0.10.0",
"version": "0.11.0",
"description": "Lightweight test host for Spektr product E2E testing — embeds dapps with auto-signing dev accounts, no Docker needed",
"license": "MIT",
"repository": {
Expand Down Expand Up @@ -47,6 +47,7 @@
"devDependencies": {
"@novasamatech/host-api-wrapper": "^0.8.8",
"@novasamatech/host-container": "^0.8.8",
"@parity/truapi": "^0.4.1",
"@polkadot/keyring": "^14.0.0",
"@polkadot/types": "^16.0.0",
"@polkadot/util": "^14.0.0",
Expand Down
12 changes: 12 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 5 additions & 2 deletions src/browser/host-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ import {
import type { Container } from "@novasamatech/host-container";
import {
createContainer,
createIframeProvider,
deriveProductEntropy,
} from "@novasamatech/host-container";
import { Keyring } from "@polkadot/keyring";
Expand All @@ -41,6 +40,7 @@ import {
import { ResultAsync } from "neverthrow";
import { getWsProvider } from "polkadot-api/ws";

import { createDualChannelIframeProvider } from "./truapi-port-handoff.js";
import type {
ChatBot,
ChatMessageLogEntry,
Expand Down Expand Up @@ -353,7 +353,10 @@ function setupContainer(
paymentCounter = 0;
themeSubscribers.clear();

const provider = createIframeProvider({ iframe, url: config.productUrl });
const provider = createDualChannelIframeProvider({
iframe,
url: config.productUrl,
});
const container = createContainer(provider);

// Derive keypairs for all requested accounts
Expand Down
88 changes: 88 additions & 0 deletions src/browser/truapi-port-handoff.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**
* Product iframe provider serving both TrUAPI channel generations.
*
* Products embed one of two bootstraps, distinguished by how they open the
* frame channel:
*
* - `@novasamatech/host-api-wrapper` (truapi 0.3): exchanges raw Uint8Array
* frames directly with the parent window — `createIframeProvider`'s native
* channel.
* - `@parity/truapi/sandbox` (truapi 0.4): posts `{ type: "truapi-ready" }`
* to the parent and waits for `{ type: "truapi-init" }` carrying a
* transferred MessagePort, then runs all traffic over that port. It never
* listens on window postMessage.
*
* Wire frames are identical on both channels, so this provider wraps
* `createIframeProvider`, answers the ready ping with a fresh port pair, and
* routes frames to whichever channel the product opened — presenting a single
* Provider to the container.
*
* The provider's lifetime is one container generation: `setAccounts()`
* disposes the container (and this provider with it) and builds a fresh pair,
* so a handed-off port never outlives the product generation it serves.
*/

import type { Provider } from '@novasamatech/host-api';
import { createIframeProvider } from '@novasamatech/host-container';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we move all the @novasamatech/* related code in a legacy file, to make it easier to clean up in a follow up PR if we think we do need the dual mode for now

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As the newest version of truapi containing the core was just released and it is just now landing on product-sdk. I think it is better to first add this patch and then cleanup novasama deps as to not introduce any breaking changes


export function createDualChannelIframeProvider(options: {
iframe: HTMLIFrameElement;
url: string;
}): Provider {
const { iframe, url } = options;
const inner = createIframeProvider({ iframe, url });
const productOrigin = new URL(url, window.location.href).origin;
const subscribers = new Set<(message: Uint8Array) => void>();
let port: MessagePort | null = null;

const deliver = (message: Uint8Array): void => {
for (const subscriber of subscribers) subscriber(message);
};

const unsubscribeInner = inner.subscribe(deliver);

const onWindowMessage = (event: MessageEvent): void => {
if (event.source !== iframe.contentWindow) return;
if (event.origin !== productOrigin) return;
if ((event.data as { type?: unknown } | null)?.type !== 'truapi-ready') return;

// The product sends one ready ping per page load; each load needs its own
// port pair (device-permission reloads, deep-link reloads).
port?.close();
const channel = new MessageChannel();
port = channel.port1;
port.onmessage = (e: MessageEvent) => {
if (e.data instanceof Uint8Array) deliver(e.data);
};
iframe.contentWindow?.postMessage({ type: 'truapi-init' }, productOrigin, [channel.port2]);
};
window.addEventListener('message', onWindowMessage);

return {
logger: inner.logger,
isCorrectEnvironment: () => inner.isCorrectEnvironment(),
postMessage(message: Uint8Array): void {
// A product that completed the handoff listens only on the port; one
// that did not listens only on window postMessage.
if (port) {
port.postMessage(message);
} else {
inner.postMessage(message);
}
},
subscribe(callback: (message: Uint8Array) => void): () => void {
subscribers.add(callback);
return () => {
subscribers.delete(callback);
};
},
dispose(): void {
window.removeEventListener('message', onWindowMessage);
port?.close();
port = null;
subscribers.clear();
unsubscribeInner();
inner.dispose();
},
};
}
30 changes: 19 additions & 11 deletions test/build-test-product.mjs
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
import { build } from 'esbuild';

await build({
entryPoints: ['test/test-product.ts'],
bundle: true,
format: 'iife',
platform: 'browser',
target: 'es2022',
outfile: 'test/test-product-bundle.js',
sourcemap: false,
conditions: ['browser'],
});
const products = [
{ entry: 'test/test-product.ts', outfile: 'test/test-product-bundle.js' },
{ entry: 'test/test-product-truapi.ts', outfile: 'test/test-product-truapi-bundle.js' },
];

console.log('Test product bundle built: test/test-product-bundle.js');
await Promise.all(
products.map(async ({ entry, outfile }) => {
await build({
entryPoints: [entry],
bundle: true,
format: 'iife',
platform: 'browser',
target: 'es2022',
outfile,
sourcemap: false,
conditions: ['browser'],
});
console.log(`Test product bundle built: ${outfile}`);
}),
);
51 changes: 3 additions & 48 deletions test/integration.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,59 +9,14 @@
*/

import { test, expect } from '@playwright/test';
import { createServer } from 'node:http';
import { readFileSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Keyring } from '@polkadot/keyring';
import { cryptoWaitReady, sr25519Verify } from '@polkadot/util-crypto';
import { compactFromU8a, hexToU8a, u8aToHex } from '@polkadot/util';
import { createTestHostServer } from '../dist/index.js';

const __dirname = dirname(fileURLToPath(import.meta.url));

// ── Test product server ─────────────────────────────────────────────

async function serveTestProduct(): Promise<{ url: string; close: () => Promise<void> }> {
const html = readFileSync(join(__dirname, 'test-product.html'), 'utf-8');
const bundle = readFileSync(join(__dirname, 'test-product-bundle.js'), 'utf-8');

const server = createServer((req, res) => {
if (req.url?.endsWith('.js')) {
res.writeHead(200, { 'Content-Type': 'application/javascript' });
res.end(bundle);
} else {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(html);
}
});

const url = await new Promise<string>((resolve, reject) => {
server.on('error', reject);
server.listen(0, '127.0.0.1', () => {
const addr = server.address();
if (!addr || typeof addr === 'string') return reject(new Error('no address'));
resolve(`http://127.0.0.1:${addr.port}`);
});
});

return {
url,
close: () => new Promise<void>((resolve, reject) => {
server.close((err) => (err ? reject(err) : resolve()));
}),
};
}
import { loadHost, serveProduct } from './support';

// ── Helpers ─────────────────────────────────────────────────────────

/** Load the test host and wait for the product to connect. */
async function loadHost(page: import('@playwright/test').Page, hostUrl: string) {
await page.goto(hostUrl);
await page.waitForFunction(() => !!window.__TEST_HOST__, { timeout: 15_000 });
return page.frameLocator('#product-frame');
}

/** Get the product iframe as a Frame (supports evaluate, unlike FrameLocator). */
function getProductFrame(page: import('@playwright/test').Page, productUrl: string) {
const frame = page.frames().find(f => f.url().startsWith(productUrl));
Expand Down Expand Up @@ -95,13 +50,13 @@ async function getRootPublicKeys(page: import('@playwright/test').Page, hostUrl:

// ── Setup ───────────────────────────────────────────────────────────

let productServer: Awaited<ReturnType<typeof serveTestProduct>>;
let productServer: Awaited<ReturnType<typeof serveProduct>>;
let keyring: Keyring;

test.beforeAll(async () => {
await cryptoWaitReady();
keyring = new Keyring({ type: 'sr25519', ss58Format: 42 });
productServer = await serveTestProduct();
productServer = await serveProduct('test-product.html', 'test-product-bundle.js');
});

test.afterAll(async () => {
Expand Down
Loading
Loading