Skip to content

Commit 130789d

Browse files
authored
feat(truapi): add @parity/truapi/sandbox bootstrap entry point (#234)
* feat(truapi): add @parity/truapi/sandbox bootstrap entry point * refactor(truapi): drop unused disposeClient from sandbox * refactor(truapi): drop getClientOrThrow from sandbox * refactor(truapi): drop proactive handshake from sandbox * docs(truapi): add cleanup TODO to sandbox waitForWebviewPort
1 parent 7897736 commit 130789d

9 files changed

Lines changed: 253 additions & 192 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@parity/truapi": minor
3+
---
4+
5+
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.

js/packages/truapi/README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,38 @@ sub.unsubscribe();
6868
- **TrUAPI transport** that handles request, response, subscription, and handshake framing.
6969
- **Generated domain clients and types** produced from the Rust API contract.
7070
- **SCALE codec helpers** used by the generated code, also re-exported for direct use.
71+
- **Sandbox bootstrap** (`@parity/truapi/sandbox`) that detects the host environment, builds the
72+
matching provider, and exposes a cached client — see below.
73+
74+
## Sandbox bootstrap
75+
76+
`@parity/truapi/sandbox` wires up a client for browser-embedded hosts: it detects whether the app
77+
runs inside a TrUAPI host (iframe or webview), builds the matching provider, and caches the
78+
resulting client. Use it instead of assembling `createTransport` / `createClient` by hand.
79+
80+
```ts
81+
import {
82+
getClientSync,
83+
isCorrectEnvironment,
84+
subscribeConnectionStatus,
85+
} from "@parity/truapi/sandbox";
86+
87+
const client = getClientSync(); // null outside a host container
88+
if (client) {
89+
// …make host calls
90+
}
91+
92+
// Or drive UI off connection status:
93+
const unsubscribe = subscribeConnectionStatus((status) => {
94+
// "disconnected" | "connected"
95+
});
96+
```
97+
98+
| Export | Purpose |
99+
| ------------------------------------------- | ----------------------------------------------- |
100+
| `isCorrectEnvironment(): boolean` | Synchronous host-environment detection. |
101+
| `getClientSync(): TrUApiClient \| null` | Cached client; `null` outside a host container. |
102+
| `subscribeConnectionStatus(cb): () => void` | Connected / disconnected status listener. |
71103

72104
## Wire format
73105

js/packages/truapi/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@
2727
"types": "./dist/index.d.ts",
2828
"import": "./dist/index.js"
2929
},
30+
"./sandbox": {
31+
"types": "./dist/sandbox.d.ts",
32+
"import": "./dist/sandbox.js"
33+
},
3034
"./scale": {
3135
"types": "./dist/scale.d.ts",
3236
"import": "./dist/scale.js"

js/packages/truapi/src/sandbox.ts

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
/**
2+
* Sandbox bootstrap for browser-embedded hosts.
3+
*
4+
* Detects whether the app runs inside a TrUAPI host (iframe or webview), builds
5+
* the matching {@link Provider}, and exposes a lazily-created, cached
6+
* {@link TrUApiClient} via {@link getClientSync} so embedders don't
7+
* re-implement the wiring. {@link subscribeConnectionStatus} surfaces a
8+
* connected / disconnected signal over the same cached client.
9+
*
10+
* @module
11+
*/
12+
13+
import {
14+
createIframeProvider,
15+
createMessagePortProvider,
16+
type Provider,
17+
} from "./transport.js";
18+
import { createTransport } from "./client.js";
19+
import { createClient, type TrUApiClient } from "./generated/index.js";
20+
21+
/**
22+
* Connection lifecycle state. {@link subscribeConnectionStatus} emits
23+
* `"connected"` / `"disconnected"`; `"connecting"` is reserved for consumers
24+
* that want to render an indeterminate state before the first status is known.
25+
*/
26+
export type ConnectionStatus = "disconnected" | "connecting" | "connected";
27+
28+
declare global {
29+
interface Window {
30+
/** Set by webview hosts (Polkadot Desktop / Mobile) to mark the embedding. */
31+
__HOST_WEBVIEW_MARK__?: boolean;
32+
/** Injected by webview hosts to carry the host-side `MessagePort`. */
33+
__HOST_API_PORT__?: MessagePort;
34+
}
35+
}
36+
37+
function hostWindow(): Window | null {
38+
return typeof window === "undefined" ? null : window;
39+
}
40+
41+
function isIframe(): boolean {
42+
try {
43+
return window !== window.top;
44+
} catch {
45+
// A cross-origin parent throws on access, which itself means we're embedded.
46+
return true;
47+
}
48+
}
49+
50+
/**
51+
* Detect whether the app is running inside a TrUAPI host container: an iframe
52+
* (including a cross-origin parent), a marked webview, or a window carrying an
53+
* injected host message port. Synchronous, so it can gate hot paths.
54+
*/
55+
export function isCorrectEnvironment(): boolean {
56+
const win = hostWindow();
57+
if (!win) return false;
58+
if (isIframe()) return true;
59+
if (win.__HOST_WEBVIEW_MARK__ === true) return true;
60+
if (win.__HOST_API_PORT__ != null) return true;
61+
return false;
62+
}
63+
64+
/**
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.
69+
*/
70+
function resolveHostOrigin(): string | null {
71+
if (typeof document !== "undefined" && document.referrer) {
72+
try {
73+
return new URL(document.referrer).origin;
74+
} catch {
75+
// Fall through to ancestorOrigins.
76+
}
77+
}
78+
const ancestors = window.location?.ancestorOrigins;
79+
if (ancestors && ancestors.length > 0) return ancestors[0] ?? null;
80+
return null;
81+
}
82+
83+
const WEBVIEW_PORT_TIMEOUT_MS = 20_000;
84+
85+
/**
86+
* Resolve the host-injected `MessagePort`, polling `window.__HOST_API_PORT__`
87+
* until it appears or the timeout elapses. Rejects on timeout or abort.
88+
*
89+
* TODO(cleanup): this polling is defensive against the port not being injected
90+
* yet. Once hosts guarantee the iframe / `__HOST_API_PORT__` is wired before any
91+
* product code runs, read `window.__HOST_API_PORT__` directly and drop the
92+
* poll loop + timeout.
93+
*/
94+
async function waitForWebviewPort(
95+
signal?: AbortSignal,
96+
timeoutMs = WEBVIEW_PORT_TIMEOUT_MS,
97+
): Promise<MessagePort> {
98+
const start = Date.now();
99+
while (Date.now() - start < timeoutMs) {
100+
if (signal?.aborted) throw new Error("waitForWebviewPort aborted");
101+
const port = hostWindow()?.__HOST_API_PORT__;
102+
if (port) return port;
103+
await new Promise((resolve) => setTimeout(resolve, 50));
104+
}
105+
throw new Error(
106+
`Timed out waiting for window.__HOST_API_PORT__ (${timeoutMs}ms)`,
107+
);
108+
}
109+
110+
/** Build the {@link Provider} matching the detected environment (iframe or webview). */
111+
function createSandboxProvider(): Provider {
112+
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 });
120+
}
121+
const portController = new AbortController();
122+
const provider = createMessagePortProvider(
123+
waitForWebviewPort(portController.signal),
124+
);
125+
const baseDispose = provider.dispose;
126+
provider.dispose = () => {
127+
portController.abort();
128+
baseDispose?.();
129+
};
130+
return provider;
131+
}
132+
133+
let cachedClient: TrUApiClient | null = null;
134+
let status: ConnectionStatus = "disconnected";
135+
const statusListeners = new Set<(status: ConnectionStatus) => void>();
136+
137+
function setStatus(next: ConnectionStatus): void {
138+
if (status === next) return;
139+
status = next;
140+
for (const listener of statusListeners) listener(next);
141+
}
142+
143+
/**
144+
* Build (or return the cached) {@link TrUApiClient}. Returns `null` outside a
145+
* host container or if the provider can't be built.
146+
*/
147+
export function getClientSync(): TrUApiClient | null {
148+
if (cachedClient) return cachedClient;
149+
if (!isCorrectEnvironment()) return null;
150+
try {
151+
const provider = createSandboxProvider();
152+
cachedClient = createClient(createTransport(provider));
153+
provider.subscribeClose?.(() => setStatus("disconnected"));
154+
return cachedClient;
155+
} catch {
156+
return null;
157+
}
158+
}
159+
160+
/**
161+
* Subscribe to connection-status changes. The callback fires immediately with
162+
* the current status and on every transition. Status is `"connected"` once the
163+
* client is built inside a host container, and `"disconnected"` otherwise (or
164+
* when the provider reports the pipe closed). Returns an unsubscribe function.
165+
*/
166+
export function subscribeConnectionStatus(
167+
callback: (status: ConnectionStatus) => void,
168+
): () => void {
169+
statusListeners.add(callback);
170+
callback(status);
171+
172+
if (status === "disconnected") {
173+
setStatus(getClientSync() ? "connected" : "disconnected");
174+
}
175+
176+
return () => {
177+
statusListeners.delete(callback);
178+
};
179+
}

playground/src/app/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
import {
1111
subscribeConnectionStatus,
1212
type ConnectionStatus,
13-
} from "@/src/lib/transport";
13+
} from "@parity/truapi/sandbox";
1414
import { ServiceTable } from "@/src/components/ServiceTable";
1515
import { MethodView } from "@/src/components/MethodView";
1616
import { DiagnosisView } from "@/src/components/DiagnosisView";

playground/src/components/DiagnosisView.tsx

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
renderReportMarkdown,
77
reportIssueUrl,
88
} from "@/src/lib/diagnosis-report";
9-
import { getClient } from "@/src/lib/transport";
9+
import { getClientSync } from "@parity/truapi/sandbox";
1010

1111
const STATUS_LABEL: Record<TestStatus, string> = {
1212
idle: "queued",
@@ -95,11 +95,8 @@ export function DiagnosisView({
9595
const report = renderReportMarkdown(services, testResults);
9696
void navigator.clipboard?.writeText(report).catch(() => {});
9797
const url = reportIssueUrl(report, detectHostMode());
98-
try {
99-
void getClient().system.navigateTo({ url });
100-
} catch {
101-
/* no host connection */
102-
}
98+
// No-op outside a host container; navigation is best-effort.
99+
void getClientSync()?.system.navigateTo({ url });
103100
};
104101

105102
return (
@@ -123,11 +120,12 @@ export function DiagnosisView({
123120
<span className="panel__label">About</span>
124121
</div>
125122
<p className="panel__desc">
126-
Runs every TrUAPI method against the connected host to build a coverage
127-
report — which methods work, which fail, and which aren&apos;t wired
128-
yet. Methods run one at a time, in order; those that need your approval
129-
(signing, permission and resource requests) wait on your response
130-
before the run continues. When it finishes, copy the report below.
123+
Runs every TrUAPI method against the connected host to build a
124+
coverage report — which methods work, which fail, and which
125+
aren&apos;t wired yet. Methods run one at a time, in order; those that
126+
need your approval (signing, permission and resource requests) wait on
127+
your response before the run continues. When it finishes, copy the
128+
report below.
131129
</p>
132130
<p className="diag__callout">
133131
Before you start: make sure you are <strong>logged in</strong>, and

playground/src/components/MethodView.tsx

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { useState, useEffect, useRef, useCallback } from "react";
44
import { stringify } from "@/src/lib/host-api-bridge";
55
import { ExampleEditor } from "@/src/components/ExampleEditor";
66
import { runExample, type LogEntry } from "@/src/lib/example-runner";
7-
import { getClient } from "@/src/lib/transport";
7+
import { getClientSync } from "@parity/truapi/sandbox";
88
import { methodTestId, revealInRail, serviceTestId } from "@/src/lib/rail";
99
import { services } from "@/src/lib/services";
1010
import type { MethodInfo, ServiceInfo } from "@/src/lib/services";
@@ -121,14 +121,22 @@ export function MethodView({
121121
setLogs([]);
122122
setTab("output");
123123
try {
124-
const run = await runExample({ source, client: getClient(), onLog });
124+
const client = getClientSync();
125+
if (!client) {
126+
throw new Error(
127+
"App must be opened inside a TrUAPI host (iframe or webview).",
128+
);
129+
}
130+
const run = await runExample({ source, client, onLog });
125131
cancelRunRef.current = run.cancel;
126132
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
127133
const abortPromise = new Promise<never>((_, reject) => {
128134
callAbortRef.current = (reason: string) => reject(new Error(reason));
129135
timeoutHandle = setTimeout(
130136
() =>
131-
reject(new Error(`Call timed out after ${CALL_TIMEOUT_MS / 1000}s`)),
137+
reject(
138+
new Error(`Call timed out after ${CALL_TIMEOUT_MS / 1000}s`),
139+
),
132140
CALL_TIMEOUT_MS,
133141
);
134142
});
@@ -368,7 +376,9 @@ export function MethodView({
368376
<span className="console__entry-i">
369377
{String(i + 1).padStart(2, "0")}
370378
</span>
371-
<span className="console__entry-body">{entry.text}</span>
379+
<span className="console__entry-body">
380+
{entry.text}
381+
</span>
372382
</div>
373383
))}
374384
</div>

playground/src/lib/auto-test.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,5 @@
1-
import {
2-
runExample,
3-
type LogEntry,
4-
type RunResult,
5-
} from "./example-runner";
6-
import { getClient } from "./transport";
1+
import { runExample, type LogEntry, type RunResult } from "./example-runner";
2+
import { getClientSync } from "@parity/truapi/sandbox";
73
import type { MethodInfo, ServiceInfo } from "./services";
84

95
export const DIAGNOSIS_ID = "__diagnosis__";
@@ -72,7 +68,13 @@ async function runOne({
7268
// output, captured into `logs` for the report but with no bearing on status.
7369
let run: RunResult | undefined;
7470
try {
75-
run = await runExample({ source, client: getClient(), onLog });
71+
const client = getClientSync();
72+
if (!client) {
73+
throw new Error(
74+
"App must be opened inside a TrUAPI host (iframe or webview).",
75+
);
76+
}
77+
run = await runExample({ source, client, onLog });
7678
await Promise.race([
7779
run.promise,
7880
new Promise<never>((_, reject) =>

0 commit comments

Comments
 (0)