|
| 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 | +} |
0 commit comments