Skip to content

Commit 9fbaaf4

Browse files
committed
refactor(truapi): iframe MessagePort handshake and SCALE codec additions
Switch the in-iframe client to the host-transferred MessagePort handshake (truapi-ready / truapi-init) used by the WASM host runtime, and extend the SCALE primitives (Struct, _void, str, hex helpers) consumed by the generated codecs. Pulls @noble/hashes in as a hex-codec dependency.
1 parent 96af0a0 commit 9fbaaf4

19 files changed

Lines changed: 510 additions & 67 deletions

js/packages/truapi/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@
7979
"typescript": "^6.0"
8080
},
8181
"dependencies": {
82+
"@noble/hashes": "^2.2.0",
8283
"neverthrow": "^8.2.0",
8384
"scale-ts": "^1.6.1"
8485
}

js/packages/truapi/src/sandbox.ts

Lines changed: 97 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@
1111
*/
1212

1313
import {
14-
createIframeProvider,
1514
createMessagePortProvider,
1615
type WireProvider,
1716
} from "./transport.js";
@@ -62,10 +61,7 @@ export function isCorrectEnvironment(): boolean {
6261
}
6362

6463
/**
65-
* Origin used as the `targetOrigin` for outbound `postMessage` frames. Frames
66-
* carry signed payloads and account ids, so this fails closed: when no concrete
67-
* origin can be pinned it returns `null` (rather than falling back to `"*"`) and
68-
* provider construction throws.
64+
* Origin used as the `targetOrigin` for iframe bootstrap messages.
6965
*/
7066
function resolveHostOrigin(): string | null {
7167
if (typeof document !== "undefined" && document.referrer) {
@@ -80,7 +76,8 @@ function resolveHostOrigin(): string | null {
8076
return null;
8177
}
8278

83-
const WEBVIEW_PORT_TIMEOUT_MS = 20_000;
79+
const HOST_PORT_TIMEOUT_MS = 20_000;
80+
let iframePortPromise: Promise<MessagePort> | null = null;
8481

8582
/**
8683
* Resolve the host-injected `MessagePort`, polling `window.__HOST_API_PORT__`
@@ -93,7 +90,7 @@ const WEBVIEW_PORT_TIMEOUT_MS = 20_000;
9390
*/
9491
async function waitForWebviewPort(
9592
signal?: AbortSignal,
96-
timeoutMs = WEBVIEW_PORT_TIMEOUT_MS,
93+
timeoutMs = HOST_PORT_TIMEOUT_MS,
9794
): Promise<MessagePort> {
9895
const start = Date.now();
9996
while (Date.now() - start < timeoutMs) {
@@ -107,18 +104,93 @@ async function waitForWebviewPort(
107104
);
108105
}
109106

107+
/**
108+
* Resolve the iframe `MessagePort` transferred by `createIframeHost`.
109+
*/
110+
function waitForIframePort(
111+
signal?: AbortSignal,
112+
timeoutMs = HOST_PORT_TIMEOUT_MS,
113+
): Promise<MessagePort> {
114+
const existing = hostWindow()?.__HOST_API_PORT__;
115+
if (existing) return Promise.resolve(existing);
116+
if (iframePortPromise) return iframePortPromise;
117+
118+
iframePortPromise = new Promise<MessagePort>((resolve, reject) => {
119+
const win = hostWindow();
120+
if (!win) {
121+
reject(new Error("window is unavailable"));
122+
return;
123+
}
124+
125+
const hostOrigin = resolveHostOrigin();
126+
let done = false;
127+
const cleanup = (): void => {
128+
win.removeEventListener("message", onMessage);
129+
signal?.removeEventListener("abort", onAbort);
130+
clearTimeout(timer);
131+
};
132+
const finish = (result: MessagePort | Error): void => {
133+
if (done) return;
134+
done = true;
135+
cleanup();
136+
if (result instanceof Error) {
137+
reject(result);
138+
} else {
139+
win.__HOST_API_PORT__ = result;
140+
resolve(result);
141+
}
142+
};
143+
const onAbort = (): void => {
144+
finish(new Error("waitForIframePort aborted"));
145+
};
146+
const onMessage = (event: MessageEvent): void => {
147+
if (event.source !== win.parent) return;
148+
if (
149+
hostOrigin !== null &&
150+
event.origin !== hostOrigin &&
151+
event.origin !== "null"
152+
) {
153+
return;
154+
}
155+
if (event.data?.type !== "truapi-init") return;
156+
const [port] = event.ports;
157+
if (!port) {
158+
finish(new Error("truapi-init did not include a MessagePort"));
159+
return;
160+
}
161+
finish(port);
162+
};
163+
const timer = setTimeout(() => {
164+
finish(
165+
new Error(`Timed out waiting for iframe MessagePort (${timeoutMs}ms)`),
166+
);
167+
}, timeoutMs);
168+
169+
win.addEventListener("message", onMessage);
170+
signal?.addEventListener("abort", onAbort, { once: true });
171+
win.parent.postMessage({ type: "truapi-ready" }, hostOrigin ?? "*");
172+
}).catch((error: unknown) => {
173+
iframePortPromise = null;
174+
throw error;
175+
});
176+
177+
return iframePortPromise;
178+
}
179+
110180
/** Build the {@link WireProvider} matching the detected environment (iframe or webview). */
111181
function createSandboxProvider(): WireProvider {
182+
const portController = new AbortController();
112183
if (isIframe()) {
113-
const hostOrigin = resolveHostOrigin();
114-
if (!hostOrigin) {
115-
throw new Error(
116-
"TrUAPI iframe provider could not resolve the host origin from document.referrer / ancestorOrigins.",
117-
);
118-
}
119-
return createIframeProvider({ target: window.parent, hostOrigin });
184+
const provider = createMessagePortProvider(
185+
waitForIframePort(portController.signal),
186+
);
187+
const baseDispose = provider.dispose;
188+
provider.dispose = () => {
189+
portController.abort();
190+
baseDispose?.();
191+
};
192+
return provider;
120193
}
121-
const portController = new AbortController();
122194
const provider = createMessagePortProvider(
123195
waitForWebviewPort(portController.signal),
124196
);
@@ -166,14 +238,21 @@ export function getClientSync(): TrUApiClient | null {
166238
export function subscribeConnectionStatus(
167239
callback: (status: ConnectionStatus) => void,
168240
): () => void {
169-
statusListeners.add(callback);
170-
callback(status);
241+
let emitted = false;
242+
const listener = (next: ConnectionStatus) => {
243+
emitted = true;
244+
callback(next);
245+
};
246+
statusListeners.add(listener);
171247

172248
if (status === "disconnected") {
173249
setStatus(getClientSync() ? "connected" : "disconnected");
174250
}
251+
if (!emitted) {
252+
callback(status);
253+
}
175254

176255
return () => {
177-
statusListeners.delete(callback);
256+
statusListeners.delete(listener);
178257
};
179258
}

js/packages/truapi/src/scale.ts

Lines changed: 58 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -8,17 +8,25 @@
88
import {
99
Bytes,
1010
Enum,
11+
Struct,
12+
_void,
1113
createCodec,
1214
createDecoder,
1315
enhanceCodec,
16+
str as scaleStr,
1417
u8,
1518
type Codec,
1619
} from "scale-ts";
20+
import {
21+
bytesToHex as encodeHex,
22+
hexToBytes as decodeHex,
23+
} from "@noble/hashes/utils.js";
1724

1825
export type { Codec };
1926
export type { ResultPayload } from "scale-ts";
2027

2128
export {
29+
Bytes,
2230
Enum,
2331
Option,
2432
Result,
@@ -59,7 +67,9 @@ export const OptionBool: Codec<boolean | undefined> = enhanceCodec(
5967
case 2:
6068
return false;
6169
default:
62-
throw new Error(`Unknown OptionBool byte: ${byte}. Expected 0, 1, or 2.`);
70+
throw new Error(
71+
`Unknown OptionBool byte: ${byte}. Expected 0, 1, or 2.`,
72+
);
6373
}
6474
},
6575
);
@@ -79,22 +89,12 @@ export function toHexString(value: string): HexString {
7989

8090
/** Encode a byte array as a lower-case hex string with a `0x` prefix. */
8191
export function bytesToHex(bytes: Uint8Array): HexString {
82-
let hex = "0x";
83-
for (let i = 0; i < bytes.length; i++) {
84-
hex += bytes[i]!.toString(16).padStart(2, "0");
85-
}
86-
return hex as HexString;
92+
return `0x${encodeHex(bytes)}`;
8793
}
8894

8995
/** Decode a hex string into a byte array. Tolerates a missing `0x` prefix. */
9096
export function hexToBytes(hex: string): Uint8Array {
91-
const start = hex.startsWith("0x") ? 2 : 0;
92-
const length = (hex.length - start) >> 1;
93-
const bytes = new Uint8Array(length);
94-
for (let i = 0; i < length; i++) {
95-
bytes[i] = parseInt(hex.substring(start + i * 2, start + i * 2 + 2), 16);
96-
}
97-
return bytes;
97+
return decodeHex(hex.startsWith("0x") ? hex.slice(2) : hex);
9898
}
9999

100100
/**
@@ -123,6 +123,51 @@ export function TaggedUnion<O extends TaggedUnionCodecs>(
123123
return Enum(inner) as unknown as Codec<TaggedUnionValue<O>>;
124124
}
125125

126+
/**
127+
* Wire codec for Rust `CallError<D>`, projected to the public domain error `D`.
128+
*
129+
* Generated TypeScript APIs expose only the domain error union in
130+
* `ResultAsync<Ok, D>`. The Rust host still wraps that value in
131+
* `CallError::Domain` on the wire so framework errors can share the response
132+
* channel. Encoding always emits `Domain`; decoding returns the inner domain
133+
* value and throws for framework-level failures that have no public `D` shape.
134+
*/
135+
export function CallError<D>(domain: Codec<D>): Codec<D> {
136+
type WireCallError =
137+
| { tag: "Domain"; value: D }
138+
| { tag: "Denied"; value?: undefined }
139+
| { tag: "Unsupported"; value?: undefined }
140+
| { tag: "MalformedFrame"; value: { reason: string } }
141+
| { tag: "HostFailure"; value: { reason: string } };
142+
143+
const wire = Enum({
144+
Domain: domain,
145+
Denied: _void,
146+
Unsupported: _void,
147+
MalformedFrame: Struct({ reason: scaleStr }),
148+
HostFailure: Struct({ reason: scaleStr }),
149+
}) as unknown as Codec<WireCallError>;
150+
151+
return enhanceCodec(
152+
wire,
153+
(value: D): WireCallError => ({ tag: "Domain", value }),
154+
(value: WireCallError): D => {
155+
switch (value.tag) {
156+
case "Domain":
157+
return value.value;
158+
case "Denied":
159+
throw new Error("Host denied the request");
160+
case "Unsupported":
161+
throw new Error("Host does not support this request");
162+
case "MalformedFrame":
163+
throw new Error(`Malformed request frame: ${value.value.reason}`);
164+
case "HostFailure":
165+
throw new Error(`Host failure: ${value.value.reason}`);
166+
}
167+
},
168+
);
169+
}
170+
126171
type TaggedUnionCodecs = {
127172
[Sym: symbol]: never;
128173
[Num: number]: never;

js/packages/truapi/src/transport.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,10 @@ export interface WireProvider {
279279

280280
/**
281281
* Register a callback for provider-level close or failure events.
282+
*
283+
* Providers keep a terminal close reason. The callback fires at most once
284+
* for an active subscription, and fires immediately when registered after
285+
* the provider has already closed.
282286
**/
283287
subscribeClose?(callback: (error: Error) => void): () => void;
284288

package-lock.json

Lines changed: 13 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

playground/CLAUDE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
77
An interactive explorer for the TrUAPI, the Host API surface exposed to products running inside the Polkadot Desktop Browser webview. The app must be opened from within a Host environment. It talks to the host over iframe `postMessage` frames or the native webview `window.__HOST_API_PORT__` MessagePort.
88

99
To develop locally, run `yarn dev` and open the app via `https://dot.li/localhost:3000` inside the Desktop Host.
10+
For host-backed diagnosis/e2e runs, signer-bot settings may live in the
11+
repo-root `.env`; if they are not present in the current worktree environment,
12+
load or copy them from that file before treating signer-bot as unavailable.
1013

1114
## Commands
1215

playground/src/app/page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
runDiagnosis,
2424
runSingleTest,
2525
} from "@/src/lib/auto-test";
26+
import { installE2EHooks } from "@/src/lib/e2e-hooks";
2627
import packageJson from "../../package.json";
2728

2829
const VERSION_LABEL = `v${packageJson.version}`;
@@ -188,6 +189,7 @@ export default function PlaygroundPage() {
188189

189190
useEffect(() => {
190191
try {
192+
installE2EHooks();
191193
return subscribeConnectionStatus(setStatus);
192194
} catch {
193195
setStatus("disconnected");

0 commit comments

Comments
 (0)