@waler/sdk 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Waler
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,203 @@
1
+ # @waler/sdk
2
+
3
+ The SDK a Waler app embeds. A Waler app is a web page that a Waler shell loads in
4
+ an iframe; this package is how that page talks to the shell — session, context,
5
+ tokens, navigation, device capabilities and events, over one bridge that behaves
6
+ the same wherever the shell runs.
7
+
8
+ TypeScript, ESM, **no runtime dependencies**. Works with React, Vue, Svelte or
9
+ plain HTML — the SDK has no opinion about your framework.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ npm install @waler/sdk
15
+ ```
16
+
17
+ ## Quickstart
18
+
19
+ ```ts
20
+ import { createSdk } from '@waler/sdk'
21
+
22
+ const Waler = createSdk({
23
+ // Your app's registered id.
24
+ appId: 'my-app',
25
+ // The shell origins allowed to host this surface. A security boundary, not
26
+ // configuration — see "Origins" below.
27
+ allowedOrigins: ['https://tenant-one.example.com'],
28
+ })
29
+
30
+ // Resolves once the shell has answered. From here on, `context` is filled in
31
+ // and `capabilities` is populated.
32
+ const { api, context, theme, locale } = await Waler.ready
33
+
34
+ console.log(context.tenantId, context.userId, context.activeAudience)
35
+ ```
36
+
37
+ `ready` rejects rather than resolving half-way. If it resolves, you have a live
38
+ bridge and a real context; you never have to null-check what it returned.
39
+
40
+ ## Calling the platform API
41
+
42
+ The app never hardcodes the API address. The shell hands it over in the
43
+ handshake, because the same app runs inside many shells on different domains,
44
+ and the same deploy serves dev, staging and production.
45
+
46
+ ```ts
47
+ const { api } = await Waler.ready
48
+ const { token } = await Waler.getToken()
49
+
50
+ const response = await fetch(`${api.baseUrl}/v1/some-resource`, {
51
+ headers: { Authorization: `Bearer ${token}` },
52
+ })
53
+ ```
54
+
55
+ `api.issuer` is there so your own backend can validate that token by discovering
56
+ the JWKS from it, instead of shipping a key in its code.
57
+
58
+ ## Feature detection, never version comparison
59
+
60
+ Your app is inside several shells at once, each on a different version, while the
61
+ app itself is always on its latest deploy. So ask what the shell _can do_, never
62
+ what version it is.
63
+
64
+ ```ts
65
+ if (Waler.capabilities.has('scanBarcode')) {
66
+ const { value } = await Waler.scanBarcode()
67
+ fillField(value)
68
+ } else {
69
+ showManualEntryField()
70
+ }
71
+ ```
72
+
73
+ `ready` returns a `shellVersion`. It is for telemetry. Gating a feature on it is
74
+ a bug that only shows up in the tenants that have not updated.
75
+
76
+ Capability-gated methods — `notify`, `download`, `share`, `scanBarcode`,
77
+ `getGeolocation` — reject with `UNSUPPORTED` if you call them without checking.
78
+
79
+ ## Origins
80
+
81
+ `allowedOrigins` is the trust boundary, and the only thing standing between your
82
+ surface and a page that embedded it without permission.
83
+
84
+ - These are the **only** origins the SDK will send to, each addressed explicitly.
85
+ - Messages from any other origin are dropped silently, before anything reads them.
86
+ - **There is no wildcard.** `'*'` and `'https://*.example.com'` are both refused —
87
+ the first is not an origin, the second matches nothing and would leave you with
88
+ an allowlist you believe covers every tenant and that in fact covers none.
89
+ - An allowlist with nothing usable in it fails immediately with
90
+ `INVALID_ARGUMENT`, rather than hanging until the handshake times out.
91
+
92
+ List the shell origins of the tenants your app is installed in:
93
+
94
+ ```ts
95
+ createSdk({
96
+ appId: 'my-app',
97
+ allowedOrigins: ['https://tenant-one.example.com', 'https://tenant-two.example.com'],
98
+ })
99
+ ```
100
+
101
+ ## Events
102
+
103
+ ```ts
104
+ const off = Waler.on('contextchange', (context) => {
105
+ // The shell resolved a different context, without reloading the surface.
106
+ render(context)
107
+ })
108
+
109
+ // Call `off()` when your view goes away.
110
+ ```
111
+
112
+ | Event | When |
113
+ | ------------------ | ---------------------------------------------------------------- |
114
+ | `contextchange` | the shell resolved a different context |
115
+ | `audiencechange` | the user switched to another population |
116
+ | `commandupdate` | a write this app started has a result |
117
+ | `themechange` | the tenant's theme changed, or the system toggled dark mode |
118
+ | `localechange` | language or timezone changed |
119
+ | `visibilitychange` | the surface left or came back — **pause polling when invisible** |
120
+ | `tokenexpiring` | call `getToken()` before it fails |
121
+
122
+ Two things to assume about `commandupdate`, because writes are asynchronous:
123
+ **it may never arrive** (rebuild state when your view opens), and **it may arrive
124
+ twice** (handle it idempotently, keyed on `commandId`). Without both, a
125
+ "processing…" spinner can run forever with no error anywhere.
126
+
127
+ ## Errors
128
+
129
+ Everything rejects with an `SdkError` carrying a `code`:
130
+
131
+ ```ts
132
+ import type { SdkError } from '@waler/sdk'
133
+
134
+ try {
135
+ await Waler.scanBarcode()
136
+ } catch (error) {
137
+ const { code } = error as SdkError
138
+ if (code === 'UNSUPPORTED') showManualEntryField()
139
+ else if (code === 'CANCELLED') return
140
+ else throw error
141
+ }
142
+ ```
143
+
144
+ | Code | Meaning |
145
+ | ------------------ | -------------------------------------------------------------- |
146
+ | `UNSUPPORTED` | this shell does not offer the method or capability — fall back |
147
+ | `FORBIDDEN` | not granted in the manifest, or denied |
148
+ | `CANCELLED` | the user backed out |
149
+ | `TIMEOUT` | the shell did not answer in time |
150
+ | `INVALID_ARGUMENT` | bad input, including an unusable `allowedOrigins` |
151
+ | `NOT_IN_SHELL` | running outside the shell |
152
+ | `INTERNAL` | everything else |
153
+
154
+ Treat the list as open: a newer shell may send a code this version has never
155
+ heard of, and it reaches you unchanged rather than flattened. Always keep an
156
+ `else`.
157
+
158
+ The stack on these errors points at **your** `await`, not at the SDK's internals.
159
+
160
+ ## Running without a shell
161
+
162
+ Open your app directly in a browser tab and the SDK falls back to a null
163
+ transport: everything rejects with `NOT_IN_SHELL`. That is a designed path, not
164
+ a failure — it is how you develop and test without standing up a shell.
165
+
166
+ ```ts
167
+ import type { SdkError } from '@waler/sdk'
168
+
169
+ try {
170
+ const { context } = await Waler.ready
171
+ render(context)
172
+ } catch (error) {
173
+ if ((error as SdkError).code === 'NOT_IN_SHELL') render(mockContext)
174
+ else throw error
175
+ }
176
+ ```
177
+
178
+ ## Surface
179
+
180
+ | Member | |
181
+ | -------------------------- | ----------------------------------------------------------- |
182
+ | `ready` | `Promise<ReadyPayload>` — resolves only over a live bridge |
183
+ | `context` | current `ShellContext`, or `null` before `ready` resolves |
184
+ | `capabilities` | `.has(name)` and `.list()` |
185
+ | `getToken(request?)` | an audience-scoped token |
186
+ | `openApp(key, params?)` | opens another surface, resolves with what it returned |
187
+ | `navigate(path)` | tells the shell your current route, for deep links and back |
188
+ | `close(result?)` | closes this surface, resolving whoever opened it |
189
+ | `setHeader(state)` | title, badge, or hide the native header |
190
+ | `on(event, handler)` | returns an unsubscribe function |
191
+ | `invoke(method, payload?)` | escape hatch for methods newer than this SDK |
192
+
193
+ `navigate()` and `setHeader()` are explicit on purpose: the SDK does not patch
194
+ `history.pushState` or watch `<title>` to infer them, because it will not rewrite
195
+ globals it does not own inside your app.
196
+
197
+ Everything is typed. `ReadyPayload`, `ShellContext`, `SdkError`, `Capability`,
198
+ `SdkEventMap` and the rest are exported from the package root.
199
+
200
+ ## Requirements
201
+
202
+ Any browser the shell supports. Node 20+ if you import it in a build step.
203
+ Published as ESM only, with no runtime dependencies.
@@ -0,0 +1,23 @@
1
+ import type { BridgeTransport } from '../types.ts';
2
+ /**
3
+ * Everything this transport needs from the browser, injected rather than reached
4
+ * for — no ambient global and no `MessageEvent` in any signature, so the same
5
+ * interface is implementable off the web and testable without a browser.
6
+ */
7
+ export interface FrameMessage {
8
+ readonly origin: string;
9
+ readonly data: unknown;
10
+ readonly source: unknown;
11
+ }
12
+ export interface FrameHost {
13
+ readonly parent: {
14
+ postMessage(message: unknown, targetOrigin: string): void;
15
+ };
16
+ addEventListener(type: 'message', listener: (event: FrameMessage) => void): void;
17
+ removeEventListener(type: 'message', listener: (event: FrameMessage) => void): void;
18
+ }
19
+ export interface IframeTransportOptions {
20
+ readonly host: FrameHost;
21
+ readonly allowedOrigins: readonly string[];
22
+ }
23
+ export declare function createIframeTransport(options: IframeTransportOptions): BridgeTransport;
@@ -0,0 +1,103 @@
1
+ import { decode, encode } from "../protocol/wire.js";
2
+ import { createRejectingTransport } from "./rejecting.js";
3
+ /**
4
+ * Normalises each entry to the exact string `event.origin` will carry, so a
5
+ * trailing slash or a capital cannot produce an allowlist that matches nothing.
6
+ *
7
+ * The wildcard check is NOT redundant with the parse. `'*'` is not a URL and
8
+ * throws, but `'https://*.example.com'` parses and hands the wildcard back as an
9
+ * origin — matching no real origin, which is worse than a hole: an allowlist the
10
+ * developer believes is broad and that in fact covers nobody.
11
+ */
12
+ function serialisedOrigins(entries) {
13
+ const origins = new Set();
14
+ for (const entry of entries) {
15
+ let url;
16
+ try {
17
+ url = new URL(entry);
18
+ }
19
+ catch {
20
+ continue;
21
+ }
22
+ if (url.protocol !== 'https:' && url.protocol !== 'http:')
23
+ continue;
24
+ if (url.hostname.includes('*'))
25
+ continue;
26
+ origins.add(url.origin);
27
+ }
28
+ return [...origins];
29
+ }
30
+ export function createIframeTransport(options) {
31
+ const allowed = serialisedOrigins(options.allowedOrigins);
32
+ // Fail closed and say what is wrong, instead of a handshake that times out
33
+ // against a shell that was never at fault.
34
+ if (allowed.length === 0) {
35
+ return createRejectingTransport('web', 'INVALID_ARGUMENT', 'createSdk({ allowedOrigins }) has no usable origin. Each entry must be an absolute origin, such as "https://acme.waler.app"; wildcards are not accepted.');
36
+ }
37
+ const { host } = options;
38
+ const handlers = new Set();
39
+ // Fixed by the first accepted message. Before the handshake any allowed origin
40
+ // could be the host; after it, exactly one is, and the rest stop being
41
+ // accepted or posted to.
42
+ let shellOrigin = null;
43
+ const listener = (event) => {
44
+ // GATE 1 — the trust boundary. Discarded in silence, before anything
45
+ // downstream can hold state about it: no answer, no error, no entry in the
46
+ // pending map. An unlisted sender learns nothing, not even that it was
47
+ // refused.
48
+ if (!allowed.includes(event.origin))
49
+ return;
50
+ if (shellOrigin !== null && event.origin !== shellOrigin)
51
+ return;
52
+ // GATE 2 — additional, never a substitute. `event.source` says which window
53
+ // sent the message, not which origin it is from; a sibling frame on an
54
+ // allowed origin clears gate 1 and has no business speaking for the shell.
55
+ if (event.source !== host.parent)
56
+ return;
57
+ // GATE 3 — the prefix, read off the raw value, before any parsing.
58
+ const envelope = decode(event.data);
59
+ if (envelope === null)
60
+ return;
61
+ shellOrigin ??= event.origin;
62
+ for (const handler of handlers)
63
+ handler(envelope);
64
+ };
65
+ let attached = false;
66
+ const attach = () => {
67
+ if (attached)
68
+ return;
69
+ host.addEventListener('message', listener);
70
+ attached = true;
71
+ };
72
+ const detach = () => {
73
+ if (!attached)
74
+ return;
75
+ host.removeEventListener('message', listener);
76
+ attached = false;
77
+ };
78
+ return {
79
+ platform: 'web',
80
+ connected: true,
81
+ /**
82
+ * One explicit `targetOrigin` per send, never `'*'`. Until the shell answers
83
+ * the SDK does not know which allowed origin hosts it, so the handshake goes
84
+ * to each under its own target — the browser delivers only the copy that
85
+ * matches and drops the rest. After the answer there is one target.
86
+ */
87
+ send(envelope) {
88
+ const wire = encode(envelope);
89
+ const targets = shellOrigin === null ? allowed : [shellOrigin];
90
+ for (const target of targets)
91
+ host.parent.postMessage(wire, target);
92
+ },
93
+ onMessage(handler) {
94
+ handlers.add(handler);
95
+ attach();
96
+ return () => {
97
+ handlers.delete(handler);
98
+ if (handlers.size === 0)
99
+ detach();
100
+ };
101
+ },
102
+ };
103
+ }
@@ -0,0 +1,6 @@
1
+ import type { BridgeTransport } from '../types.ts';
2
+ /**
3
+ * The transport used when the app is not inside a shell. Everything rejects with
4
+ * NOT_IN_SHELL, which an app can branch on to fall back to mocks.
5
+ */
6
+ export declare function createNullTransport(): BridgeTransport;
@@ -0,0 +1,8 @@
1
+ import { createRejectingTransport } from "./rejecting.js";
2
+ /**
3
+ * The transport used when the app is not inside a shell. Everything rejects with
4
+ * NOT_IN_SHELL, which an app can branch on to fall back to mocks.
5
+ */
6
+ export function createNullTransport() {
7
+ return createRejectingTransport('standalone', 'NOT_IN_SHELL', 'The app is running outside the Waler shell.');
8
+ }
@@ -0,0 +1,11 @@
1
+ import type { BridgeTransport, Platform, SdkErrorCode } from '../types.ts';
2
+ /**
3
+ * Answers every request with the same error, and never with silence — a hung
4
+ * promise has no stack, no log and no timeout. It answers through the same
5
+ * envelope path as a real transport, which is why nothing above it needs a
6
+ * branch for it.
7
+ *
8
+ * Deferred to a microtask: a synchronous answer would resolve a promise from
9
+ * inside the `send()` that created it, and no real transport does that.
10
+ */
11
+ export declare function createRejectingTransport(platform: Platform, code: SdkErrorCode, message: string): BridgeTransport;
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Answers every request with the same error, and never with silence — a hung
3
+ * promise has no stack, no log and no timeout. It answers through the same
4
+ * envelope path as a real transport, which is why nothing above it needs a
5
+ * branch for it.
6
+ *
7
+ * Deferred to a microtask: a synchronous answer would resolve a promise from
8
+ * inside the `send()` that created it, and no real transport does that.
9
+ */
10
+ export function createRejectingTransport(platform, code, message) {
11
+ const handlers = new Set();
12
+ return {
13
+ platform,
14
+ connected: false,
15
+ send(envelope) {
16
+ if (envelope.kind !== 'request')
17
+ return;
18
+ const answer = {
19
+ v: 1,
20
+ id: envelope.id,
21
+ correlationId: envelope.correlationId,
22
+ kind: 'error',
23
+ ...(envelope.method === undefined ? {} : { method: envelope.method }),
24
+ error: { code, message },
25
+ };
26
+ queueMicrotask(() => {
27
+ for (const handler of handlers)
28
+ handler(answer);
29
+ });
30
+ },
31
+ onMessage(handler) {
32
+ handlers.add(handler);
33
+ return () => handlers.delete(handler);
34
+ },
35
+ };
36
+ }
@@ -0,0 +1,8 @@
1
+ import type { BridgeTransport, WalerSdkOptions } from '../types.ts';
2
+ /**
3
+ * ORDER MATTERS. On a mobile device a surface is still inside a frame, so
4
+ * `detectFrameHost()` answers yes there too — a native check belongs above this
5
+ * one, never below, or the device silently gets the web bridge and loses its
6
+ * native capabilities with nothing failing.
7
+ */
8
+ export declare function selectTransport(options: WalerSdkOptions): BridgeTransport;
@@ -0,0 +1,16 @@
1
+ import { detectFrameHost } from "../browser.js";
2
+ import { createIframeTransport } from "./iframe.js";
3
+ import { createNullTransport } from "./null.js";
4
+ /**
5
+ * ORDER MATTERS. On a mobile device a surface is still inside a frame, so
6
+ * `detectFrameHost()` answers yes there too — a native check belongs above this
7
+ * one, never below, or the device silently gets the web bridge and loses its
8
+ * native capabilities with nothing failing.
9
+ */
10
+ export function selectTransport(options) {
11
+ const host = detectFrameHost();
12
+ if (host !== null) {
13
+ return createIframeTransport({ host, allowedOrigins: options.allowedOrigins });
14
+ }
15
+ return createNullTransport();
16
+ }
@@ -0,0 +1,12 @@
1
+ import type { FrameHost } from './bridge/iframe.ts';
2
+ /**
3
+ * The one file that reads an ambient browser global; eslint blocks them
4
+ * everywhere else, which is what keeps the transports portable.
5
+ */
6
+ /** `null` when not framed, which is what selects the null transport. */
7
+ export declare function detectFrameHost(): FrameHost | null;
8
+ /**
9
+ * Sent in the handshake so the shell knows which surface is speaking. It is NOT a
10
+ * trust signal — a page can claim any URL. `event.origin` is what is checked.
11
+ */
12
+ export declare function surfaceUrl(): string;
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The one file that reads an ambient browser global; eslint blocks them
3
+ * everywhere else, which is what keeps the transports portable.
4
+ */
5
+ /** `null` when not framed, which is what selects the null transport. */
6
+ export function detectFrameHost() {
7
+ if (typeof window === 'undefined')
8
+ return null;
9
+ if (window.parent === window)
10
+ return null;
11
+ return window;
12
+ }
13
+ /**
14
+ * Sent in the handshake so the shell knows which surface is speaking. It is NOT a
15
+ * trust signal — a page can claim any URL. `event.origin` is what is checked.
16
+ */
17
+ export function surfaceUrl() {
18
+ if (typeof window === 'undefined')
19
+ return '';
20
+ return window.location.href;
21
+ }
@@ -0,0 +1,10 @@
1
+ import type { Capability, CapabilitySet } from '../types.ts';
2
+ export interface MutableCapabilitySet {
3
+ readonly view: CapabilitySet;
4
+ replace(capabilities: readonly Capability[]): void;
5
+ }
6
+ /**
7
+ * Empty until the handshake fills it. `has()` answering false before then is the
8
+ * safe reading: an app that checks too early takes its fallback path.
9
+ */
10
+ export declare function createCapabilitySet(): MutableCapabilitySet;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Empty until the handshake fills it. `has()` answering false before then is the
3
+ * safe reading: an app that checks too early takes its fallback path.
4
+ */
5
+ export function createCapabilitySet() {
6
+ let capabilities = [];
7
+ return {
8
+ view: {
9
+ has: (capability) => capabilities.includes(capability),
10
+ list: () => capabilities,
11
+ },
12
+ replace(next) {
13
+ capabilities = [...next];
14
+ },
15
+ };
16
+ }
@@ -0,0 +1,6 @@
1
+ import type { SdkEventMap, Unsubscribe } from '../types.ts';
2
+ export interface Emitter {
3
+ on<K extends keyof SdkEventMap>(event: K, handler: (payload: SdkEventMap[K]) => void): Unsubscribe;
4
+ emit(event: string, payload: unknown): void;
5
+ }
6
+ export declare function createEmitter(): Emitter;
@@ -0,0 +1,36 @@
1
+ export function createEmitter() {
2
+ const handlers = new Map();
3
+ return {
4
+ on(event, handler) {
5
+ const set = handlers.get(event) ?? new Set();
6
+ handlers.set(event, set);
7
+ set.add(handler);
8
+ return () => {
9
+ set.delete(handler);
10
+ };
11
+ },
12
+ /**
13
+ * An unknown event name finds no handler and stops here, so a newer shell
14
+ * cannot break an older app.
15
+ *
16
+ * A handler that throws takes neither the others nor the message listener
17
+ * with it. Rethrowing from a microtask puts the error in front of the page's
18
+ * own reporting instead of blaming the bridge.
19
+ */
20
+ emit(event, payload) {
21
+ const set = handlers.get(event);
22
+ if (set === undefined)
23
+ return;
24
+ for (const handler of [...set]) {
25
+ try {
26
+ handler(payload);
27
+ }
28
+ catch (error) {
29
+ queueMicrotask(() => {
30
+ throw error;
31
+ });
32
+ }
33
+ }
34
+ },
35
+ };
36
+ }
@@ -0,0 +1,11 @@
1
+ import type { ReadyPayload, ShellContext } from '../types.ts';
2
+ export declare function isShellContext(value: unknown): value is ShellContext;
3
+ /**
4
+ * What makes `ready` resolving mean something. Without this check a shell that
5
+ * answered `{}` would leave `context` null right after `await ready` — the
6
+ * intermittent null the guarantee exists to prevent.
7
+ *
8
+ * Shape only, never meaning: `capabilities` may hold names this package has
9
+ * never heard of and `platform` may gain values. Neither is an error.
10
+ */
11
+ export declare function isReadyPayload(value: unknown): value is ReadyPayload;
@@ -0,0 +1,50 @@
1
+ const isFilledString = (value) => typeof value === 'string' && value.length > 0;
2
+ const asRecord = (value) => typeof value === 'object' && value !== null ? value : null;
3
+ export function isShellContext(value) {
4
+ const context = asRecord(value);
5
+ if (context === null)
6
+ return false;
7
+ if (!isFilledString(context['tenantId']))
8
+ return false;
9
+ if (!isFilledString(context['userId']))
10
+ return false;
11
+ if (!isFilledString(context['activeAudience']))
12
+ return false;
13
+ const audiences = context['audiences'];
14
+ // Never empty: the shell guarantees `activeAudience` belongs to `audiences`,
15
+ // and an empty list is a context no surface can render under.
16
+ return Array.isArray(audiences) && audiences.length > 0 && audiences.every(isFilledString);
17
+ }
18
+ /**
19
+ * What makes `ready` resolving mean something. Without this check a shell that
20
+ * answered `{}` would leave `context` null right after `await ready` — the
21
+ * intermittent null the guarantee exists to prevent.
22
+ *
23
+ * Shape only, never meaning: `capabilities` may hold names this package has
24
+ * never heard of and `platform` may gain values. Neither is an error.
25
+ */
26
+ export function isReadyPayload(value) {
27
+ const payload = asRecord(value);
28
+ if (payload === null)
29
+ return false;
30
+ if (!isFilledString(payload['shellVersion']))
31
+ return false;
32
+ if (!isFilledString(payload['platform']))
33
+ return false;
34
+ if (!isFilledString(payload['locale']))
35
+ return false;
36
+ if (!isFilledString(payload['timeZone']))
37
+ return false;
38
+ const api = asRecord(payload['api']);
39
+ // The one field an app has no way to recover on its own, and a surface that
40
+ // resolved without it would fail later, at the first request, far from here.
41
+ if (api === null || !isFilledString(api['baseUrl']) || !isFilledString(api['issuer'])) {
42
+ return false;
43
+ }
44
+ const capabilities = payload['capabilities'];
45
+ if (!Array.isArray(capabilities) || !capabilities.every(isFilledString))
46
+ return false;
47
+ if (asRecord(payload['theme']) === null)
48
+ return false;
49
+ return isShellContext(payload['context']);
50
+ }
@@ -0,0 +1,20 @@
1
+ import type { BridgeTransport, Unsubscribe } from '../types.ts';
2
+ export type EventHandler = (event: string, payload: unknown) => void;
3
+ export interface Rpc {
4
+ request<T>(method: string, payload?: unknown): Promise<T>;
5
+ /** Fire and forget: `navigate`, `close` and `setHeader` return void by contract. */
6
+ notify(method: string, payload?: unknown): void;
7
+ onEvent(handler: EventHandler): Unsubscribe;
8
+ }
9
+ export interface RpcConfig {
10
+ /** Overridable so a test can prove the TIMEOUT path without waiting ten seconds for it. */
11
+ readonly timeoutFor?: (method: string) => number;
12
+ }
13
+ /**
14
+ * One map of in-flight requests, keyed by envelope id, above every transport —
15
+ * which is why the timeout and the call-site stack live here and not in one.
16
+ *
17
+ * Nothing here knows what a window or an origin is: a message that reaches this
18
+ * point already cleared the transport's gates.
19
+ */
20
+ export declare function createRpc(transport: BridgeTransport, config?: RpcConfig): Rpc;