@parity/product-sdk-host 0.0.0-dev.312.0

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/src/worker.ts ADDED
@@ -0,0 +1,261 @@
1
+ // Copyright 2026 Parity Technologies (UK) Ltd.
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * Wrapper for calling this product's own background worker from its rendered
5
+ * surface (App, Widget, or Funding).
6
+ *
7
+ * A product ships two things: the web application the host renders, and a
8
+ * single background worker published at `worker.<product_id>.<tld>`. They run
9
+ * in different sandboxes and cannot reach each other directly. This module is
10
+ * the host-mediated path between them.
11
+ *
12
+ * **What crosses is data, never code.** `call(apiName, payload)` names an
13
+ * export the worker archive already declared; the host resolves it against the
14
+ * pinned, verified bundle. A page cannot hand the worker a function, a script,
15
+ * or an import path — that would let anything able to inject into the page
16
+ * (an XSS, a compromised dependency) run with worker authority, which is
17
+ * strictly wider than the page's own.
18
+ *
19
+ * **The page never names the product.** The host supplies the product identity
20
+ * from the surface it is rendering, so this call can only ever reach *your*
21
+ * worker.
22
+ *
23
+ * **Opt in on chain, not here.** The host only routes this call when the worker
24
+ * manifest declares the surface in `includes`. A worker published without it
25
+ * answers `unavailable`, exactly as a host with no worker support would.
26
+ *
27
+ * ```ts
28
+ * const worker = getWorkerManager();
29
+ * const { jobId } = await worker.call<{ jobId: string }>("startSettlement", {
30
+ * rail: "BANK",
31
+ * intentId,
32
+ * });
33
+ * ```
34
+ *
35
+ * @module
36
+ */
37
+
38
+ import { HostError, HostUnavailableError } from "./errors.js";
39
+
40
+ /**
41
+ * Why a worker call did not produce a result — the frozen error set the host
42
+ * runtime reports, surfaced verbatim so callers can branch rather than parse
43
+ * a message.
44
+ *
45
+ * - `unavailable` — no worker registered, the user disabled it, the manifest
46
+ * declares no ceiling for this surface, or the worker is in crash
47
+ * quarantine. This is the one worth handling: it is the normal answer on a
48
+ * host that does not run workers at all.
49
+ * - `denied` — the call passed the ceiling but failed a downstream
50
+ * authorization check.
51
+ * - `invalid` — the worker exports no such name, or the payload is malformed
52
+ * or over the host's size bound.
53
+ * - `timeout` — the call outlived its deadline and was revoked.
54
+ * - `crashed` — the worker threw or died handling the call.
55
+ * - `version` — the worker and host disagree on the protocol.
56
+ */
57
+ export type WorkerErrorTag =
58
+ | "unavailable"
59
+ | "denied"
60
+ | "invalid"
61
+ | "timeout"
62
+ | "crashed"
63
+ | "version";
64
+
65
+ /**
66
+ * A worker call that reached the host and came back without a result. Branch
67
+ * on {@link WorkerCallError.tag}; `unavailable` is the expected answer when the
68
+ * product ships no worker or the user has switched it off, so treat it as a
69
+ * capability check rather than a fault.
70
+ */
71
+ export class WorkerCallError extends HostError {
72
+ /** Which of the frozen failure modes this was. */
73
+ readonly tag: WorkerErrorTag;
74
+
75
+ constructor(tag: WorkerErrorTag, reason?: string) {
76
+ super(reason ? `worker call failed: ${tag} (${reason})` : `worker call failed: ${tag}`);
77
+ this.name = "WorkerCallError";
78
+ this.tag = tag;
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Handle for this product's background worker. Obtain one with
84
+ * {@link getWorkerManager}.
85
+ */
86
+ export interface WorkerManager {
87
+ /**
88
+ * Whether the host exposes the worker bridge at all. `false` outside a host
89
+ * container, and on hosts that predate the bridge. A `true` here does not
90
+ * promise the product *has* a worker — that surfaces as an `unavailable`
91
+ * {@link WorkerCallError} on the first call.
92
+ */
93
+ isAvailable(): boolean;
94
+
95
+ /**
96
+ * Invoke an export the worker archive declared.
97
+ *
98
+ * @param apiName - Export name, as published in the worker bundle.
99
+ * @param payload - JSON-serialisable arguments. Bounded by the host's
100
+ * payload ceiling; keep it to identifiers and parameters, not blobs.
101
+ * @param options - `deadlineMs` overrides the host default and is clamped
102
+ * to the host's own window.
103
+ * @throws {@link WorkerCallError} for every typed failure, and
104
+ * {@link HostUnavailableError} when there is no host bridge at all.
105
+ */
106
+ call<Result = unknown>(
107
+ apiName: string,
108
+ payload?: unknown,
109
+ options?: { deadlineMs?: number },
110
+ ): Promise<Result>;
111
+ }
112
+
113
+ /**
114
+ * The bridge the host installs on the page, mirroring the shape already used
115
+ * by the Pocket capability bridge so one transport serves both.
116
+ */
117
+ type WorkerBridge = (apiName: string, payloadJson: string, deadlineMs?: number) => Promise<string>;
118
+
119
+ const ERROR_TAGS: readonly WorkerErrorTag[] = [
120
+ "unavailable",
121
+ "denied",
122
+ "invalid",
123
+ "timeout",
124
+ "crashed",
125
+ "version",
126
+ ];
127
+
128
+ function readBridge(): WorkerBridge | null {
129
+ const host = (globalThis as { __polkadotHost?: { workerCall?: unknown } }).__polkadotHost;
130
+ const bridge = host?.workerCall;
131
+ return typeof bridge === "function" ? (bridge as WorkerBridge) : null;
132
+ }
133
+
134
+ function isErrorTag(value: unknown): value is WorkerErrorTag {
135
+ return typeof value === "string" && (ERROR_TAGS as readonly string[]).includes(value);
136
+ }
137
+
138
+ /**
139
+ * Turn the bridge's JSON answer into a result or a typed error.
140
+ *
141
+ * An unrecognised `error` value is reported as `unavailable` rather than
142
+ * thrown away: a host that grows a new failure mode should degrade the same
143
+ * way as a host with no worker support, not crash the page.
144
+ */
145
+ function parseAnswer<Result>(raw: string): Result {
146
+ let answer: unknown;
147
+ try {
148
+ answer = JSON.parse(raw);
149
+ } catch {
150
+ throw new WorkerCallError("invalid", "host answer was not JSON");
151
+ }
152
+ if (answer && typeof answer === "object" && "error" in answer) {
153
+ const { error, reason } = answer as { error: unknown; reason?: unknown };
154
+ throw new WorkerCallError(
155
+ isErrorTag(error) ? error : "unavailable",
156
+ typeof reason === "string" ? reason : undefined,
157
+ );
158
+ }
159
+ return answer as Result;
160
+ }
161
+
162
+ /**
163
+ * Get the handle for this product's background worker.
164
+ *
165
+ * Follows the singleton accessor pattern used by `getNotificationManager` and
166
+ * `getPaymentManager`: cheap to call repeatedly, no setup, resolves the bridge
167
+ * lazily so a page that never talks to its worker pays nothing.
168
+ */
169
+ export function getWorkerManager(): WorkerManager {
170
+ return {
171
+ isAvailable() {
172
+ return readBridge() !== null;
173
+ },
174
+ async call<Result = unknown>(
175
+ apiName: string,
176
+ payload?: unknown,
177
+ options?: { deadlineMs?: number },
178
+ ): Promise<Result> {
179
+ const bridge = readBridge();
180
+ if (!bridge) {
181
+ throw new HostUnavailableError("no host worker bridge on this page");
182
+ }
183
+ const raw = await bridge(apiName, JSON.stringify(payload ?? {}), options?.deadlineMs);
184
+ return parseAnswer<Result>(raw);
185
+ },
186
+ };
187
+ }
188
+
189
+ if (import.meta.vitest) {
190
+ const { test, expect, afterEach } = import.meta.vitest;
191
+
192
+ type Bridged = typeof globalThis & { __polkadotHost?: { workerCall?: unknown } };
193
+
194
+ const install = (workerCall: unknown) => {
195
+ (globalThis as Bridged).__polkadotHost = { workerCall };
196
+ };
197
+
198
+ afterEach(() => {
199
+ (globalThis as Bridged).__polkadotHost = undefined;
200
+ });
201
+
202
+ test("reports unavailable when the host installs no bridge", () => {
203
+ expect(getWorkerManager().isAvailable()).toBe(false);
204
+ });
205
+
206
+ test("calling without a bridge throws HostUnavailableError", async () => {
207
+ await expect(getWorkerManager().call("startSettlement")).rejects.toBeInstanceOf(
208
+ HostUnavailableError,
209
+ );
210
+ });
211
+
212
+ test("passes the api name and a JSON payload to the bridge", async () => {
213
+ const seen: unknown[] = [];
214
+ install((apiName: string, payloadJson: string, deadlineMs?: number) => {
215
+ seen.push([apiName, payloadJson, deadlineMs]);
216
+ return Promise.resolve('{"jobId":"j-1"}');
217
+ });
218
+ const result = await getWorkerManager().call<{ jobId: string }>(
219
+ "startSettlement",
220
+ { rail: "BANK" },
221
+ { deadlineMs: 5_000 },
222
+ );
223
+ expect(result).toEqual({ jobId: "j-1" });
224
+ expect(seen).toEqual([["startSettlement", '{"rail":"BANK"}', 5_000]]);
225
+ });
226
+
227
+ test("an omitted payload is sent as an empty object, not undefined", async () => {
228
+ let sent: string | undefined;
229
+ install((_apiName: string, payloadJson: string) => {
230
+ sent = payloadJson;
231
+ return Promise.resolve("null");
232
+ });
233
+ await getWorkerManager().call("status");
234
+ expect(sent).toBe("{}");
235
+ });
236
+
237
+ test("a typed host error surfaces as a WorkerCallError carrying the tag", async () => {
238
+ install(() => Promise.resolve('{"error":"unavailable"}'));
239
+ const error = await getWorkerManager()
240
+ .call("startSettlement")
241
+ .catch((thrown: unknown) => thrown);
242
+ expect(error).toBeInstanceOf(WorkerCallError);
243
+ expect((error as WorkerCallError).tag).toBe("unavailable");
244
+ });
245
+
246
+ test("an unrecognised error tag degrades to unavailable", async () => {
247
+ install(() => Promise.resolve('{"error":"someFutureFailure"}'));
248
+ const error = await getWorkerManager()
249
+ .call("startSettlement")
250
+ .catch((thrown: unknown) => thrown);
251
+ expect((error as WorkerCallError).tag).toBe("unavailable");
252
+ });
253
+
254
+ test("a non-JSON answer is invalid rather than a parse crash", async () => {
255
+ install(() => Promise.resolve("not json"));
256
+ const error = await getWorkerManager()
257
+ .call("status")
258
+ .catch((thrown: unknown) => thrown);
259
+ expect((error as WorkerCallError).tag).toBe("invalid");
260
+ });
261
+ }