@coordination-network/user-app-link 0.0.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/dist/web.cjs ADDED
@@ -0,0 +1,560 @@
1
+ 'use strict';
2
+
3
+ var zod = require('zod');
4
+
5
+ /** A link-protocol failure, discriminated by the same `code` a wire `{ ok: false }` response carries. */
6
+ class LinkError extends Error {
7
+ constructor(code, message) {
8
+ super(message ?? code);
9
+ this.name = "LinkError";
10
+ this.code = code;
11
+ }
12
+ }
13
+ /**
14
+ * Thrown by decodeRequest/decodeResponse when a URL can't be decoded into a well-formed v1
15
+ * envelope - the only failure this package ever throws rather than turning into an `ok: false`
16
+ * response, because without a valid `id`/`callback` there is nowhere to send that response.
17
+ */
18
+ class LinkDecodeError extends LinkError {
19
+ constructor(message) {
20
+ super("INVALID_REQUEST", message);
21
+ this.name = "LinkDecodeError";
22
+ }
23
+ }
24
+
25
+ /** Protocol v1 types for the user-app <-> network-member-app deep link. */
26
+ /** Where the user app's device key lives in the platform's key hierarchy. */
27
+ const LINK_ERROR_CODES = [
28
+ "USER_DENIED",
29
+ /** Declined at the share step, after the proof was built and the person saw what it reveals. */
30
+ "CONSENT_DENIED",
31
+ "UNSUPPORTED_OP",
32
+ "INVALID_REQUEST",
33
+ "NOT_ONBOARDED",
34
+ "NO_CREDENTIAL",
35
+ /** A credential for this programme and country is held, but its issuer has revoked it. */
36
+ "CREDENTIAL_REVOKED",
37
+ "PROOF_FAILED",
38
+ "ISSUER_UNREACHABLE",
39
+ "VERIFIER_UNREACHABLE",
40
+ "INTERNAL"
41
+ ];
42
+
43
+ /**
44
+ * A response must stay on-device, in a custom app scheme: `http(s)` would let it leave the device
45
+ * entirely, and Android additionally resolves `intent`/`file`/`content`/`javascript`/`data` to
46
+ * behavior beyond opening another app (launching an arbitrary component, reading a local file, or
47
+ * running script), none of which this protocol's `callback` is meant to reach.
48
+ *
49
+ * The browser schemes map straight onto `http(s)://host/path` - `googlechrome://evil/cb` fetches
50
+ * `http://evil/cb` - so they defeat the rule exactly as `https` would. This list cannot be
51
+ * exhaustive: any installed app may register a scheme that proxies to the network, and nothing in
52
+ * the string says so. Closing it properly needs the signed response envelope and a consent screen
53
+ * showing the resolved callback, both already tracked as follow-ups.
54
+ */
55
+ const DENIED_CALLBACK_SCHEMES = [
56
+ "http",
57
+ "https",
58
+ "intent",
59
+ "file",
60
+ "content",
61
+ "javascript",
62
+ "data",
63
+ "googlechrome",
64
+ "googlechromes",
65
+ "x-safari-http",
66
+ "x-safari-https",
67
+ "microsoft-edge-http",
68
+ "microsoft-edge-https",
69
+ "firefox",
70
+ "ftp",
71
+ "ws",
72
+ "wss"
73
+ ];
74
+ /**
75
+ * These fields name server endpoints the app itself fetches or posts to, arriving from an
76
+ * unauthenticated deep-link caller - an allowlist has no unknown-scheme bypass to worry about.
77
+ * TODO: production should require https only; that belongs in configuration, not this schema.
78
+ */
79
+ const ALLOWED_ENDPOINT_URL_SCHEMES = ["http", "https"];
80
+ /** `encodeResponse` always appends its own `?payload=` to `callback`, so a `?`/`#` already in it would bury or hide that parameter and leave the client waiting until timeout instead of ever decoding a response. */
81
+ const callbackSchema = zod.z
82
+ .string()
83
+ .regex(/^[a-z][a-z0-9+.-]*:\/\/[^\s?#]+$/i, "must be an absolute URL with no query string or fragment")
84
+ .refine((value) => !DENIED_CALLBACK_SCHEMES.includes(value.slice(0, value.indexOf(":")).toLowerCase()), {
85
+ message: `scheme must not be one of: ${DENIED_CALLBACK_SCHEMES.join(", ")}`
86
+ });
87
+ /** No `z.string().url()`: it relies on the global `URL` class, which Hermes/React Native lack without a polyfill. */
88
+ const urlLikeSchema = zod.z
89
+ .string()
90
+ .regex(/^[a-z][a-z0-9+.-]*:\/\/\S+$/i, "must be an absolute URL")
91
+ .refine((value) => ALLOWED_ENDPOINT_URL_SCHEMES.includes(value.slice(0, value.indexOf(":")).toLowerCase()), {
92
+ message: `scheme must be one of: ${ALLOWED_ENDPOINT_URL_SCHEMES.join(", ")}`
93
+ });
94
+ const hexOfLength = (byteLength) => zod.z
95
+ .string()
96
+ .regex(new RegExp(`^0x[0-9a-fA-F]{${byteLength * 2}}$`), `must be ${byteLength}-byte 0x-prefixed hex`)
97
+ .transform((value) => value.toLowerCase());
98
+ const hex20Schema = hexOfLength(20);
99
+ const hex31Schema = hexOfLength(31);
100
+ const hex32Schema = hexOfLength(32);
101
+ const hex65Schema = hexOfLength(65);
102
+ const hex66Schema = hexOfLength(66);
103
+ const securityLevelSchema = zod.z.enum(["STRONGBOX", "TRUSTED_ENVIRONMENT", "SOFTWARE", "UNKNOWN"]);
104
+ /**
105
+ * `name` and `programme` are both rendered verbatim, on their own line and interpolated into a
106
+ * caption sentence the wallet wrote, so an unbounded or unclean value is a caller reshaping
107
+ * content it doesn't author: past the limit its text crowds the wallet's own closing sentence out
108
+ * of the three-line clip, and a control, formatting, or line/paragraph-separator character can
109
+ * hide text or relayout the line, as a bidi override does.
110
+ */
111
+ const sentenceSafeTextSchema = (maxLength) => zod.z
112
+ .string()
113
+ .min(1)
114
+ .max(maxLength)
115
+ .regex(/^[^\p{Cc}\p{Cf}\p{Zl}\p{Zp}]*$/u, "must not contain control, formatting or line-break characters");
116
+ const callerSchema = zod.z
117
+ .object({
118
+ name: sentenceSafeTextSchema(64),
119
+ scheme: zod.z.string().min(1)
120
+ })
121
+ .strict();
122
+ const approvalScopeSchema = zod.z
123
+ .object({ programme: sentenceSafeTextSchema(64), country: zod.z.string().regex(/^[A-Z]{3}$/, "ISO 3166-1 alpha-3") })
124
+ .strict();
125
+ const registrationSignParamsSchema = zod.z
126
+ .object({ chainId: zod.z.number().int().positive(), verifyingContract: hex20Schema })
127
+ .strict();
128
+ const signedRegistrationRequestSchema = zod.z
129
+ .object({
130
+ version: zod.z.literal("v1"),
131
+ networkId: hex32Schema,
132
+ schemeId: zod.z.literal(1),
133
+ metaStealthAddress: hex66Schema,
134
+ chainId: zod.z.number().int().positive(),
135
+ verifyingContract: hex20Schema,
136
+ signature: hex65Schema
137
+ })
138
+ .strict();
139
+ zod.z
140
+ .object({ request: signedRegistrationRequestSchema, securityLevel: securityLevelSchema })
141
+ .strict();
142
+ /** Scheme, host and port, as `urlLikeSchema` guarantees them: everything before the path. No
143
+ * `new URL` here either, for the same Hermes reason. */
144
+ function originOf(url) {
145
+ const pathStart = url.indexOf("/", url.indexOf("://") + 3);
146
+ return (pathStart === -1 ? url : url.slice(0, pathStart)).toLowerCase();
147
+ }
148
+ /** The four URLs are one server's, so they must say so. The wallet stores `revocationsUrl` for the
149
+ * credential's life and shows it once, at issuance, as the issuer's server: split origins would
150
+ * let a caller drive a genuine issuance while pointing every later revocation read at itself. */
151
+ const sameOriginEndpoints = ["credentialsUrl", "issuersUrl", "revocationsUrl"];
152
+ const credentialIssueParamsSchema = zod.z
153
+ .object({
154
+ issuerNetworkId: hex32Schema,
155
+ approvalScope: approvalScopeSchema,
156
+ challengeUrl: urlLikeSchema,
157
+ credentialsUrl: urlLikeSchema,
158
+ issuersUrl: urlLikeSchema,
159
+ revocationsUrl: urlLikeSchema
160
+ })
161
+ .strict()
162
+ .superRefine((params, ctx) => {
163
+ const origin = originOf(params.challengeUrl);
164
+ for (const field of sameOriginEndpoints) {
165
+ if (originOf(params[field]) !== origin) {
166
+ ctx.addIssue({
167
+ code: "custom",
168
+ path: [field],
169
+ message: `${field} must be on the same origin as challengeUrl (${origin})`
170
+ });
171
+ }
172
+ }
173
+ });
174
+ zod.z.discriminatedUnion("status", [
175
+ zod.z.object({ status: zod.z.literal("stored"), credentialId: zod.z.string().min(1) }).strict(),
176
+ zod.z.object({ status: zod.z.literal("alreadyHeld"), credentialId: zod.z.string().min(1) }).strict(),
177
+ zod.z.object({ status: zod.z.literal("refused"), code: zod.z.string().min(1), message: zod.z.string() }).strict()
178
+ ]);
179
+ const presentationCreateParamsSchema = zod.z
180
+ .object({
181
+ requestId: zod.z.string().min(1).max(128),
182
+ challenge: hex31Schema,
183
+ audience: zod.z.string().min(1),
184
+ currentTime: zod.z.number().int().nonnegative(),
185
+ programme: sentenceSafeTextSchema(64),
186
+ country: zod.z.string().regex(/^[A-Z]{3}$/, "ISO 3166-1 alpha-3"),
187
+ issuersUrl: urlLikeSchema,
188
+ revocationsUrl: urlLikeSchema,
189
+ responseUrl: urlLikeSchema
190
+ })
191
+ .strict()
192
+ .superRefine((params, ctx) => {
193
+ // The verifier serves all three, and it is the party that must accept the roots the proof
194
+ // carries. A split origin would let it send the wallet to read somebody else's tree.
195
+ const origin = originOf(params.responseUrl);
196
+ for (const field of ["issuersUrl", "revocationsUrl"]) {
197
+ if (originOf(params[field]) !== origin) {
198
+ ctx.addIssue({
199
+ code: "custom",
200
+ path: [field],
201
+ message: `${field} must be on the same origin as responseUrl (${origin})`
202
+ });
203
+ }
204
+ }
205
+ });
206
+ zod.z.object({ verificationId: zod.z.string().min(1) }).strict();
207
+ /** Envelope only: `op`/`params` stay unvalidated because a request that fails those checks still needs a usable `id`/`callback` to answer with an `ok: false` response. */
208
+ const linkRequestEnvelopeSchema = zod.z
209
+ .object({
210
+ id: zod.z.string().min(1),
211
+ version: zod.z.literal("v1"),
212
+ caller: callerSchema,
213
+ callback: callbackSchema,
214
+ op: zod.z.string().min(1),
215
+ params: zod.z.unknown()
216
+ })
217
+ .strict();
218
+ const requestEnvelopeFields = {
219
+ id: zod.z.string().min(1),
220
+ version: zod.z.literal("v1"),
221
+ caller: callerSchema,
222
+ callback: callbackSchema
223
+ };
224
+ /** Written out per op rather than mapped from `LINK_OPS`: `z.discriminatedUnion` needs each branch's literal `op` at the type level, which a `z.ZodTypeAny`-typed loop erases. */
225
+ zod.z.discriminatedUnion("op", [
226
+ zod.z
227
+ .object({ ...requestEnvelopeFields, op: zod.z.literal("registration.sign"), params: registrationSignParamsSchema })
228
+ .strict(),
229
+ zod.z
230
+ .object({ ...requestEnvelopeFields, op: zod.z.literal("credential.issue"), params: credentialIssueParamsSchema })
231
+ .strict(),
232
+ zod.z
233
+ .object({
234
+ ...requestEnvelopeFields,
235
+ op: zod.z.literal("presentation.create"),
236
+ params: presentationCreateParamsSchema
237
+ })
238
+ .strict()
239
+ ]);
240
+ const linkErrorCodeSchema = zod.z.enum(LINK_ERROR_CODES);
241
+ zod.z.discriminatedUnion("ok", [
242
+ zod.z.object({ id: zod.z.string().min(1), version: zod.z.literal("v1"), ok: zod.z.literal(true), result: zod.z.unknown() }).strict(),
243
+ zod.z
244
+ .object({
245
+ id: zod.z.string().min(1),
246
+ version: zod.z.literal("v1"),
247
+ ok: zod.z.literal(false),
248
+ code: linkErrorCodeSchema,
249
+ message: zod.z.string().optional()
250
+ })
251
+ .strict()
252
+ ]);
253
+
254
+ function base64UrlDecode(value) {
255
+ const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
256
+ const padded = base64.padEnd(base64.length + ((4 - (base64.length % 4)) % 4), "=");
257
+ const binary = globalThis.atob(padded);
258
+ const bytes = new Uint8Array(binary.length);
259
+ for (let i = 0; i < binary.length; i += 1) {
260
+ bytes[i] = binary.charCodeAt(i);
261
+ }
262
+ return bytes;
263
+ }
264
+ function decodePayload(payload) {
265
+ try {
266
+ return JSON.parse(new TextDecoder().decode(base64UrlDecode(payload)));
267
+ }
268
+ catch {
269
+ throw new LinkDecodeError("payload is not valid base64url-encoded JSON");
270
+ }
271
+ }
272
+ /**
273
+ * Reads a query parameter by hand rather than via the `URL`/`URLSearchParams` globals, which
274
+ * Hermes (React Native's JS engine) doesn't provide without a polyfill.
275
+ */
276
+ function getQueryParam(url, key) {
277
+ const queryIndex = url.indexOf("?");
278
+ if (queryIndex === -1) {
279
+ return null;
280
+ }
281
+ for (const pair of url.slice(queryIndex + 1).split("&")) {
282
+ const [rawKey, rawValue = ""] = pair.split("=");
283
+ if (decodeURIComponent(rawKey) === key) {
284
+ return decodeURIComponent(rawValue);
285
+ }
286
+ }
287
+ return null;
288
+ }
289
+ function decodeRequest(url) {
290
+ const payload = getQueryParam(url, "payload");
291
+ if (payload === null) {
292
+ throw new LinkDecodeError("link URL is missing the payload query parameter");
293
+ }
294
+ const parsed = linkRequestEnvelopeSchema.safeParse(decodePayload(payload));
295
+ if (!parsed.success) {
296
+ throw new LinkDecodeError(`malformed request payload: ${parsed.error.message}`);
297
+ }
298
+ return parsed.data;
299
+ }
300
+
301
+ const DEFAULT_CLOSE_POLL_INTERVAL_MS = 100;
302
+ const DEFAULT_HANDSHAKE_TIMEOUT_MS = 5000;
303
+ const READY_MESSAGE = "coordination-network:user-app-ready:v1";
304
+ const HELLO_MESSAGE = "coordination-network:member-app-hello:v1";
305
+ function normalizedOrigin(value, option) {
306
+ let origin;
307
+ try {
308
+ origin = new URL(value).origin;
309
+ }
310
+ catch {
311
+ throw new Error(`${option} must be an absolute browser URL`);
312
+ }
313
+ if (origin === "null") {
314
+ throw new Error(`${option} must have an http or https origin`);
315
+ }
316
+ return origin;
317
+ }
318
+ /**
319
+ * Supplies the two transport functions consumed by `createLinkClient`. The protocol request stays
320
+ * unchanged; only its query string is carried to the browser user app instead of a mobile scheme.
321
+ */
322
+ function createWebLinkTransport(options) {
323
+ const host = options.window ?? window;
324
+ const userAppOrigin = normalizedOrigin(options.userAppOrigin, "userAppOrigin");
325
+ const popupUrl = new URL(options.userAppUrl);
326
+ if (popupUrl.origin !== userAppOrigin) {
327
+ throw new Error("userAppUrl must belong to userAppOrigin");
328
+ }
329
+ let popup = null;
330
+ let listener = null;
331
+ let closeTimer = null;
332
+ let finishOpen = null;
333
+ let failOpen = null;
334
+ let closedAt = null;
335
+ function clearCloseTimer() {
336
+ if (closeTimer !== null) {
337
+ clearInterval(closeTimer);
338
+ closeTimer = null;
339
+ }
340
+ }
341
+ function handleMessage(event) {
342
+ if (!popup || event.source !== popup || event.origin !== userAppOrigin) {
343
+ return;
344
+ }
345
+ if (event.data === READY_MESSAGE) {
346
+ popup.postMessage(HELLO_MESSAGE, userAppOrigin);
347
+ return;
348
+ }
349
+ if (typeof event.data !== "string")
350
+ return;
351
+ const resolve = finishOpen;
352
+ finishOpen = null;
353
+ failOpen = null;
354
+ clearCloseTimer();
355
+ resolve?.();
356
+ listener?.(event.data);
357
+ }
358
+ return {
359
+ prepare() {
360
+ if (popup && !popup.closed)
361
+ return;
362
+ popup = host.open(popupUrl.toString(), options.popupName ?? "coordination-user-app", options.popupFeatures);
363
+ },
364
+ close() {
365
+ popup?.close();
366
+ popup = null;
367
+ const fail = failOpen;
368
+ finishOpen = null;
369
+ failOpen = null;
370
+ clearCloseTimer();
371
+ fail?.(new Error("user app popup closed before responding"));
372
+ },
373
+ onUrl(nextListener) {
374
+ listener = nextListener;
375
+ host.addEventListener("message", handleMessage);
376
+ return () => {
377
+ host.removeEventListener("message", handleMessage);
378
+ listener = null;
379
+ clearCloseTimer();
380
+ finishOpen?.();
381
+ finishOpen = null;
382
+ failOpen = null;
383
+ };
384
+ },
385
+ openUrl(requestUrl) {
386
+ const queryIndex = requestUrl.indexOf("?");
387
+ if (queryIndex === -1) {
388
+ return Promise.reject(new Error("browser link request is missing its encoded query"));
389
+ }
390
+ const destination = new URL(popupUrl.toString());
391
+ destination.search = requestUrl.slice(queryIndex);
392
+ if (popup && !popup.closed) {
393
+ popup.location.href = destination.toString();
394
+ }
395
+ else {
396
+ popup = host.open(destination.toString(), options.popupName ?? "coordination-user-app", options.popupFeatures);
397
+ }
398
+ if (!popup) {
399
+ return Promise.reject(new Error("user app popup was blocked"));
400
+ }
401
+ closedAt = null;
402
+ return new Promise((resolve, reject) => {
403
+ finishOpen = resolve;
404
+ failOpen = reject;
405
+ closeTimer = setInterval(() => {
406
+ if (!popup?.closed)
407
+ return;
408
+ closedAt ?? (closedAt = Date.now());
409
+ if (Date.now() - closedAt < (options.closeGracePeriodMs ?? 250))
410
+ return;
411
+ const fail = failOpen;
412
+ finishOpen = null;
413
+ failOpen = null;
414
+ clearCloseTimer();
415
+ fail?.(new Error("user app popup closed before responding"));
416
+ }, options.closePollIntervalMs ?? DEFAULT_CLOSE_POLL_INTERVAL_MS);
417
+ });
418
+ }
419
+ };
420
+ }
421
+ function authenticateMemberApp(host, recipient, allowedMemberOrigins, timeoutMs) {
422
+ return new Promise((resolve, reject) => {
423
+ const cleanup = () => {
424
+ clearTimeout(timer);
425
+ host.removeEventListener("message", handleMessage);
426
+ };
427
+ const handleMessage = (event) => {
428
+ if (event.source !== recipient || event.data !== HELLO_MESSAGE || !allowedMemberOrigins.has(event.origin))
429
+ return;
430
+ cleanup();
431
+ resolve(event.origin);
432
+ };
433
+ const timer = setTimeout(() => {
434
+ cleanup();
435
+ reject(new Error("member app origin handshake timed out"));
436
+ }, timeoutMs);
437
+ host.addEventListener("message", handleMessage);
438
+ recipient.postMessage(READY_MESSAGE, "*");
439
+ });
440
+ }
441
+ /** Authenticates the opener, dispatches the request, and posts the response to its verified origin. */
442
+ async function respondToWebLinkRequest(options) {
443
+ const host = options.window ?? window;
444
+ const recipient = host.opener ?? (host.parent !== host ? host.parent : null);
445
+ if (!recipient)
446
+ throw new Error("user app was not opened or embedded by a member app");
447
+ try {
448
+ const allowedMemberOrigins = new Set(options.allowedMemberOrigins.map((origin) => normalizedOrigin(origin, "allowedMemberOrigins")));
449
+ if (allowedMemberOrigins.size === 0)
450
+ throw new Error("allowedMemberOrigins must not be empty");
451
+ const memberAppOrigin = await authenticateMemberApp(host, recipient, allowedMemberOrigins, options.handshakeTimeoutMs ?? DEFAULT_HANDSHAKE_TIMEOUT_MS);
452
+ const request = decodeRequest(host.location.href);
453
+ if (normalizedOrigin(request.caller.scheme, "caller.scheme") !== memberAppOrigin) {
454
+ throw new Error("request caller does not match the authenticated member app origin");
455
+ }
456
+ const response = await options.handler.handle(host.location.href);
457
+ recipient.postMessage(response, memberAppOrigin);
458
+ // Give the queued postMessage a task to reach the opener before closing the popup.
459
+ await new Promise((resolve) => setTimeout(resolve, 0));
460
+ }
461
+ finally {
462
+ if (host.opener && (options.closeAfterResponding ?? true))
463
+ host.close();
464
+ }
465
+ }
466
+ /** Carries the browser protocol through an in-page iframe, avoiding popup-blocker behavior. */
467
+ function createEmbeddedWebLinkTransport(options) {
468
+ const host = options.window ?? window;
469
+ const userAppOrigin = normalizedOrigin(options.userAppOrigin, "userAppOrigin");
470
+ const userAppUrl = new URL(options.userAppUrl);
471
+ if (userAppUrl.origin !== userAppOrigin)
472
+ throw new Error("userAppUrl must belong to userAppOrigin");
473
+ let listener = null;
474
+ const handleMessage = (event) => {
475
+ if (event.origin !== userAppOrigin || event.source !== options.frame.contentWindow)
476
+ return;
477
+ if (event.data === READY_MESSAGE) {
478
+ options.frame.contentWindow?.postMessage(HELLO_MESSAGE, userAppOrigin);
479
+ return;
480
+ }
481
+ if (typeof event.data !== "string")
482
+ return;
483
+ listener?.(event.data);
484
+ };
485
+ return {
486
+ prepare() { },
487
+ close() {
488
+ options.frame.src = "about:blank";
489
+ },
490
+ onUrl(nextListener) {
491
+ listener = nextListener;
492
+ host.addEventListener("message", handleMessage);
493
+ return () => {
494
+ listener = null;
495
+ host.removeEventListener("message", handleMessage);
496
+ };
497
+ },
498
+ async openUrl(requestUrl) {
499
+ const queryIndex = requestUrl.indexOf("?");
500
+ if (queryIndex === -1)
501
+ throw new Error("browser link request is missing its encoded query");
502
+ const destination = new URL(userAppUrl);
503
+ destination.search = requestUrl.slice(queryIndex);
504
+ options.frame.src = destination.toString();
505
+ }
506
+ };
507
+ }
508
+ /** Carries unchanged link URLs through the local demo relay between independently opened pages. */
509
+ function createRelayWebLinkTransport(options) {
510
+ const fetcher = options.fetcher ?? fetch;
511
+ const relayUrl = new URL(options.relayUrl).origin;
512
+ let after = 0;
513
+ return {
514
+ async openUrl(url) {
515
+ const response = await fetcher(`${relayUrl}/v1/requests`, {
516
+ method: "POST",
517
+ headers: { "content-type": "application/json" },
518
+ body: JSON.stringify({ sender: options.sender, url })
519
+ });
520
+ if (!response.ok)
521
+ throw new Error("wallet request could not be delivered");
522
+ },
523
+ onUrl(listener) {
524
+ let active = true;
525
+ let polling = false;
526
+ const poll = async () => {
527
+ if (!active || polling)
528
+ return;
529
+ polling = true;
530
+ try {
531
+ const response = await fetcher(`${relayUrl}/v1/responses?sender=${encodeURIComponent(options.sender)}&after=${after}`);
532
+ if (!response.ok)
533
+ return;
534
+ const messages = (await response.json());
535
+ for (const message of messages) {
536
+ after = Math.max(after, message.seq);
537
+ listener(message.url);
538
+ }
539
+ }
540
+ catch {
541
+ // The next poll retries while the local demo stack starts or restarts.
542
+ }
543
+ finally {
544
+ polling = false;
545
+ }
546
+ };
547
+ void poll();
548
+ const timer = setInterval(() => void poll(), options.pollIntervalMs ?? 500);
549
+ return () => {
550
+ active = false;
551
+ clearInterval(timer);
552
+ };
553
+ }
554
+ };
555
+ }
556
+
557
+ exports.createEmbeddedWebLinkTransport = createEmbeddedWebLinkTransport;
558
+ exports.createRelayWebLinkTransport = createRelayWebLinkTransport;
559
+ exports.createWebLinkTransport = createWebLinkTransport;
560
+ exports.respondToWebLinkRequest = respondToWebLinkRequest;