@statewalker/webrun-http-browser 0.6.5 → 0.7.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.
Files changed (92) hide show
  1. package/README.md +302 -52
  2. package/dist/core/data-calls.d.ts +17 -0
  3. package/dist/core/data-calls.d.ts.map +1 -1
  4. package/dist/core/service-worker-control.d.ts +3 -1
  5. package/dist/core/service-worker-control.d.ts.map +1 -1
  6. package/dist/index.d.ts +3 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +3906 -265
  9. package/dist/link/errors.d.ts +2 -0
  10. package/dist/link/errors.d.ts.map +1 -0
  11. package/dist/link/http-link.d.ts +58 -0
  12. package/dist/link/http-link.d.ts.map +1 -0
  13. package/dist/link/index.d.ts +4 -0
  14. package/dist/link/index.d.ts.map +1 -0
  15. package/dist/link/locks.d.ts +39 -0
  16. package/dist/link/locks.d.ts.map +1 -0
  17. package/dist/link/rendezvous.d.ts +21 -0
  18. package/dist/link/rendezvous.d.ts.map +1 -0
  19. package/dist/relay/index-sw.d.ts +12 -2
  20. package/dist/relay/index-sw.d.ts.map +1 -1
  21. package/dist/relay/index.d.ts +66 -8
  22. package/dist/relay/index.d.ts.map +1 -1
  23. package/dist/relay-sw.js +1160 -55
  24. package/dist/relay-worker.js +1161 -55
  25. package/dist/sw/handler-endpoint.d.ts +81 -0
  26. package/dist/sw/handler-endpoint.d.ts.map +1 -0
  27. package/dist/sw/handler-table.d.ts +53 -0
  28. package/dist/sw/handler-table.d.ts.map +1 -0
  29. package/dist/sw/http-sw-dispatcher.d.ts +162 -21
  30. package/dist/sw/http-sw-dispatcher.d.ts.map +1 -1
  31. package/dist/sw/index.d.ts +5 -0
  32. package/dist/sw/index.d.ts.map +1 -1
  33. package/dist/sw/protocol.d.ts +115 -0
  34. package/dist/sw/protocol.d.ts.map +1 -0
  35. package/dist/sw/sw-dispatcher.d.ts.map +1 -1
  36. package/dist/sw/sw-http-adapter.d.ts +199 -0
  37. package/dist/sw/sw-http-adapter.d.ts.map +1 -0
  38. package/dist/sw/sw-websocket.d.ts +16 -0
  39. package/dist/sw/sw-websocket.d.ts.map +1 -0
  40. package/dist/sw/worker-service.d.ts +45 -0
  41. package/dist/sw/worker-service.d.ts.map +1 -0
  42. package/dist/sw/worker-url.d.ts +9 -0
  43. package/dist/sw/worker-url.d.ts.map +1 -0
  44. package/dist/sw-worker.js +2075 -210
  45. package/dist/sw.d.ts +2 -0
  46. package/dist/sw.d.ts.map +1 -1
  47. package/dist/sw.js +3901 -531
  48. package/dist/worker.d.ts +10 -0
  49. package/dist/worker.d.ts.map +1 -0
  50. package/dist/worker.js +3507 -0
  51. package/dist/ws/client.d.ts +73 -0
  52. package/dist/ws/client.d.ts.map +1 -0
  53. package/dist/ws/frames.d.ts +68 -0
  54. package/dist/ws/frames.d.ts.map +1 -0
  55. package/dist/ws/index.d.ts +6 -0
  56. package/dist/ws/index.d.ts.map +1 -0
  57. package/dist/ws/server.d.ts +53 -0
  58. package/dist/ws/server.d.ts.map +1 -0
  59. package/dist/ws/socket-core.d.ts +91 -0
  60. package/dist/ws/socket-core.d.ts.map +1 -0
  61. package/dist/ws/upgrade.d.ts +93 -0
  62. package/dist/ws/upgrade.d.ts.map +1 -0
  63. package/package.json +10 -5
  64. package/src/core/data-calls.ts +52 -2
  65. package/src/core/service-worker-control.ts +7 -1
  66. package/src/index.ts +3 -0
  67. package/src/link/errors.ts +3 -0
  68. package/src/link/http-link.ts +222 -0
  69. package/src/link/index.ts +3 -0
  70. package/src/link/locks.ts +97 -0
  71. package/src/link/rendezvous.ts +60 -0
  72. package/src/relay/index-sw.ts +84 -12
  73. package/src/relay/index.ts +203 -27
  74. package/src/sw/handler-endpoint.ts +278 -0
  75. package/src/sw/handler-table.ts +130 -0
  76. package/src/sw/http-sw-dispatcher.ts +649 -90
  77. package/src/sw/index.ts +5 -0
  78. package/src/sw/protocol.ts +103 -0
  79. package/src/sw/sw-dispatcher.ts +1 -20
  80. package/src/sw/sw-http-adapter.ts +613 -0
  81. package/src/sw/sw-websocket.ts +43 -0
  82. package/src/sw/worker-service.ts +133 -0
  83. package/src/sw/worker-url.ts +19 -0
  84. package/src/sw-worker.ts +8 -3
  85. package/src/sw.ts +2 -0
  86. package/src/worker.ts +9 -0
  87. package/src/ws/client.ts +346 -0
  88. package/src/ws/frames.ts +119 -0
  89. package/src/ws/index.ts +11 -0
  90. package/src/ws/server.ts +132 -0
  91. package/src/ws/socket-core.ts +251 -0
  92. package/src/ws/upgrade.ts +200 -0
package/dist/sw.js CHANGED
@@ -281,6 +281,34 @@ async function sendIterator(send, it) {
281
281
  });
282
282
  }
283
283
  }
284
+ const DEFAULT_CHUNK_SIZE = 16384;
285
+ /**
286
+ * Curried `Duplex`-shaped transformer that splits incoming `Uint8Array`s into
287
+ * chunks no larger than `size` bytes. Empty input chunks are skipped; small
288
+ * input chunks pass through unchanged (zero-copy `subarray` views are used
289
+ * when splitting, so no allocation per chunk).
290
+ *
291
+ * Use to respect transport MTUs before reaching `channel.send`. Reassembly on
292
+ * the receive side is not needed — consumers iterate a continuous byte stream.
293
+ */
294
+ function toChunks(size = DEFAULT_CHUNK_SIZE) {
295
+ if (!(Number.isInteger(size) && size > 0)) throw new RangeError(`toChunks: size must be a positive integer, got ${size}`);
296
+ return async function* (input) {
297
+ for await (const block of input) {
298
+ if (block.byteLength === 0) continue;
299
+ if (block.byteLength <= size) {
300
+ yield block;
301
+ continue;
302
+ }
303
+ let offset = 0;
304
+ while (offset < block.byteLength) {
305
+ const end = Math.min(offset + size, block.byteLength);
306
+ yield block.subarray(offset, end);
307
+ offset = end;
308
+ }
309
+ }
310
+ };
311
+ }
284
312
  //#endregion
285
313
  //#region ../webrun-http-streams/dist/index.js
286
314
  const CR = 13;
@@ -1032,10 +1060,160 @@ function newSniffingCodec(options) {
1032
1060
  }
1033
1061
  };
1034
1062
  }
1035
- newSniffingCodec({
1063
+ /**
1064
+ * Writes HTTP/1.1; accepts HTTP/1.1 or the legacy JSON envelope. Used whenever
1065
+ * no `codec` option is supplied.
1066
+ */
1067
+ const defaultCodec = newSniffingCodec({
1036
1068
  write: httpCodec,
1037
1069
  accept: [httpCodec, jsonEnvelopeCodec]
1038
1070
  });
1071
+ /** Carries the serialized JS error between two webrun peers. */
1072
+ const PEER_ERROR_HEADER = "x-webrun-error";
1073
+ /** Hard cap on the peer-error header (M3): see `encodeErrorResponse`. */
1074
+ const MAX_DETAIL_CHARS = 4096;
1075
+ /**
1076
+ * JSON with every non-printable-ASCII character escaped, so the result is a
1077
+ * legal latin-1 header value whatever the error message contained.
1078
+ */
1079
+ function asciiJson(value) {
1080
+ return JSON.stringify(value).replace(/[^\x20-\x7E]/g, (ch) => `\\u${ch.charCodeAt(0).toString(16).padStart(4, "0")}`);
1081
+ }
1082
+ /**
1083
+ * Decision 13: a real HTTP peer cannot receive a JavaScript exception, only a
1084
+ * response. So an uncaught handler error becomes a conforming 500 whose body
1085
+ * carries the message, with the serialized error in a namespaced header that
1086
+ * another webrun peer re-throws from.
1087
+ */
1088
+ function encodeErrorResponse(codec, error, method, status = 500, statusText = "Internal Server Error") {
1089
+ const serialized = serializeError(error);
1090
+ let detail = asciiJson(serialized);
1091
+ if (detail.length > MAX_DETAIL_CHARS) detail = asciiJson({ message: serialized.message });
1092
+ if (detail.length > MAX_DETAIL_CHARS) detail = detail.slice(0, MAX_DETAIL_CHARS);
1093
+ const message = serialized.message ?? statusText;
1094
+ return codec.encodeResponse({
1095
+ status,
1096
+ statusText,
1097
+ headers: [["Content-Type", "text/plain; charset=utf-8"], [PEER_ERROR_HEADER, detail]]
1098
+ }, [new TextEncoder().encode(message)], { method });
1099
+ }
1100
+ /**
1101
+ * `output` is the `Duplex` call's own generator — one logical call, per the
1102
+ * `Duplex` contract in `@statewalker/webrun-streams`: "Consumer `.return()`
1103
+ * on the output → producer's `finally` runs." On a mux transport
1104
+ * (`emulateMux`), that `finally` is what frees the stream-table slot; skip it
1105
+ * and every peer-error response leaks one slot, unboundedly, until the mux
1106
+ * itself is exhausted (`maxStreams` reached, every further call rejected).
1107
+ *
1108
+ * Cancelling `output` here — not inside the codec's own decode logic — is
1109
+ * deliberate. `codec.decodeResponse` already pulled from `output` to read the
1110
+ * head before this function runs, so unlike the body case above there's no
1111
+ * suspended-start no-op to worry about. And it's safe specifically *because*
1112
+ * this is the client's own response-only generator: the request was already
1113
+ * fully sent before we got here, so there is nothing left to write on it.
1114
+ * The equivalent is NOT safe inside `src/http1/decode.ts`'s `ByteReader` —
1115
+ * that code also runs when a caller wires `codec.decodeRequest`/
1116
+ * `decodeResponse` directly onto a raw bidirectional socket (see
1117
+ * `tests/http1-node-interop.test.ts`), where the *same* object is read from
1118
+ * and then written back to (a server reads the request, then replies on the
1119
+ * same socket); cancelling the read side there tears down the whole
1120
+ * connection out from under the pending write. That was tried and reverted —
1121
+ * see the Task 11 report for the "socket hang up" failure it caused.
1122
+ */
1123
+ async function throwIfPeerError(result, output) {
1124
+ const found = result.envelope.headers.find(([k]) => k.toLowerCase() === PEER_ERROR_HEADER);
1125
+ if (!found) return;
1126
+ await discard(result.body);
1127
+ try {
1128
+ await output.return?.(void 0);
1129
+ } catch {}
1130
+ let payload;
1131
+ try {
1132
+ payload = JSON.parse(found[1]);
1133
+ } catch {
1134
+ payload = { message: found[1] };
1135
+ }
1136
+ throw deserializeError(payload);
1137
+ }
1138
+ /**
1139
+ * Initiate an HTTP call over a `Duplex`. The caller's `call: Duplex` is
1140
+ * obtained from any `webrun-streams-*` adapter's `connect`. Returns the
1141
+ * response envelope and an async iterable over the response body bytes.
1142
+ *
1143
+ * The call is one logical Duplex invocation; multiplexing of concurrent
1144
+ * calls is the adapter's concern (native or `emulateMux`).
1145
+ */
1146
+ async function httpFetch(call, env, body, options = {}) {
1147
+ const codec = options.codec ?? defaultCodec;
1148
+ const output = call(codec.encodeRequest(env, body));
1149
+ const decoded = await codec.decodeResponse(output, { method: env.method });
1150
+ const release = async () => {
1151
+ try {
1152
+ await output.return?.(void 0);
1153
+ } catch {}
1154
+ };
1155
+ const result = {
1156
+ envelope: decoded.envelope,
1157
+ body: releasingOnExit(decoded.body, release),
1158
+ release
1159
+ };
1160
+ await throwIfPeerError(result, output);
1161
+ return result;
1162
+ }
1163
+ /**
1164
+ * `body`, but leaving it before its end releases the call.
1165
+ *
1166
+ * The body the codec hands back is a generator of its own over a `ByteReader`,
1167
+ * and `.return()` on it never reaches the call's `output` (see
1168
+ * `throwIfPeerError` for why the codec must not do that itself). So a caller
1169
+ * that stopped reading — an aborted `fetch`, a cancelled response stream —
1170
+ * left the peer's producer parked forever, holding its handler open. Safe here
1171
+ * for the same reason it is safe in `throwIfPeerError`: `output` is this
1172
+ * call's own response-only generator, and the request was fully sent.
1173
+ */
1174
+ async function* releasingOnExit(body, release) {
1175
+ let completed = false;
1176
+ try {
1177
+ yield* body;
1178
+ completed = true;
1179
+ } finally {
1180
+ if (!completed) await release();
1181
+ }
1182
+ }
1183
+ /**
1184
+ * Wrap an HTTP handler as a `Duplex` so it can be registered with any
1185
+ * `webrun-streams-*` adapter's `serve`.
1186
+ */
1187
+ function httpServe(handler, options = {}) {
1188
+ const codec = options.codec ?? defaultCodec;
1189
+ return async function* httpHandlerDuplex(input) {
1190
+ let decoded;
1191
+ try {
1192
+ decoded = await codec.decodeRequest(input);
1193
+ } catch (error) {
1194
+ if (!(error instanceof HttpParseError)) throw error;
1195
+ yield* encodeErrorResponse(error.codec ?? codec, error, "GET", 400, "Bad Request");
1196
+ return;
1197
+ }
1198
+ const replyCodec = decoded.codec ?? codec;
1199
+ let result;
1200
+ try {
1201
+ result = await handler(decoded.envelope, decoded.body);
1202
+ } catch (error) {
1203
+ yield* encodeErrorResponse(replyCodec, error, decoded.envelope.method);
1204
+ return;
1205
+ }
1206
+ let completed = false;
1207
+ try {
1208
+ yield* replyCodec.encodeResponse(result.envelope, result.body, { method: decoded.envelope.method });
1209
+ completed = true;
1210
+ } finally {
1211
+ if (!completed) try {
1212
+ await result.release?.();
1213
+ } catch {}
1214
+ }
1215
+ };
1216
+ }
1039
1217
  /**
1040
1218
  * The one place that knows a runtime may not implement request body streams,
1041
1219
  * and the buffering both fallbacks need. `fetch.ts` and `http-stubs.ts` each
@@ -1167,580 +1345,3105 @@ function newHttpClientStub(send) {
1167
1345
  };
1168
1346
  }
1169
1347
  /**
1170
- * Returns a server-side transport handler. It deserializes the incoming
1171
- * request envelope, delegates to `handler`, and serializes the response.
1348
+ * Connection-scoped headers. The codec surfaces them verbatim (decision 12),
1349
+ * but they are meaningless to a `Request`/`Response`, and re-emitting them
1350
+ * from a relay would corrupt its framing.
1351
+ */
1352
+ const HOP_BY_HOP = /* @__PURE__ */ new Set([
1353
+ "connection",
1354
+ "host",
1355
+ "keep-alive",
1356
+ "proxy-authenticate",
1357
+ "proxy-authorization",
1358
+ "te",
1359
+ "trailer",
1360
+ "transfer-encoding",
1361
+ "upgrade"
1362
+ ]);
1363
+ function forwardableHeaders(headers) {
1364
+ return headers.filter(([name]) => !HOP_BY_HOP.has(name.toLowerCase()));
1365
+ }
1366
+ function headersToArray(headers) {
1367
+ const out = [];
1368
+ headers.forEach((value, key) => {
1369
+ out.push([key, value]);
1370
+ });
1371
+ return out;
1372
+ }
1373
+ async function* readableToAsyncIterable(stream) {
1374
+ if (!stream) return;
1375
+ const reader = stream.getReader();
1376
+ let consumed = false;
1377
+ try {
1378
+ while (true) {
1379
+ const { done, value } = await reader.read();
1380
+ if (done) {
1381
+ consumed = true;
1382
+ return;
1383
+ }
1384
+ if (value) yield value;
1385
+ }
1386
+ } finally {
1387
+ if (!consumed) try {
1388
+ await reader.cancel();
1389
+ } catch {}
1390
+ try {
1391
+ reader.releaseLock();
1392
+ } catch {}
1393
+ }
1394
+ }
1395
+ function asyncIterableToReadable(iter) {
1396
+ const it = iter[Symbol.asyncIterator]();
1397
+ return new ReadableStream({
1398
+ async pull(controller) {
1399
+ try {
1400
+ const { done, value } = await it.next();
1401
+ if (done) {
1402
+ controller.close();
1403
+ return;
1404
+ }
1405
+ controller.enqueue(value);
1406
+ } catch (err) {
1407
+ controller.error(err);
1408
+ }
1409
+ },
1410
+ async cancel() {
1411
+ await it.return?.();
1412
+ }
1413
+ });
1414
+ }
1415
+ /**
1416
+ * Run a `Request` through a `Duplex` call and reconstruct the `Response` on
1417
+ * the other side. The request's `signal` is plumbed into the body iteration —
1418
+ * abort terminates the underlying call.
1419
+ */
1420
+ async function fetchOverDuplex(call, request, options = {}) {
1421
+ if (request.signal?.aborted) throw abortReason$1(request.signal);
1422
+ const env = {
1423
+ url: request.url,
1424
+ method: request.method,
1425
+ headers: headersToArray(request.headers)
1426
+ };
1427
+ let body;
1428
+ if (request.body != null) body = readableToAsyncIterable(request.body);
1429
+ else if (!supportsRequestStreams()) {
1430
+ const buffered = new Uint8Array(await request.arrayBuffer());
1431
+ if (buffered.byteLength > 0) body = [buffered];
1432
+ }
1433
+ const { envelope, body: respBody, release } = await httpFetch(call, env, body, options);
1434
+ const responseInit = {
1435
+ status: envelope.status,
1436
+ statusText: envelope.statusText,
1437
+ headers: forwardableHeaders(envelope.headers)
1438
+ };
1439
+ if (request.method === "HEAD" || request.method === "OPTIONS" || NULL_BODY_STATUSES.has(envelope.status)) {
1440
+ await release?.();
1441
+ return new Response(null, responseInit);
1442
+ }
1443
+ return new Response(asyncIterableToReadable(withAbort(respBody, request.signal)), responseInit);
1444
+ }
1445
+ /**
1446
+ * Wrap a `(Request) => Promise<Response>` handler as a `Duplex` so it can be
1447
+ * registered with any `webrun-streams-*` adapter's `serve`.
1172
1448
  */
1173
- function newHttpServerStub(handler) {
1174
- return async (envelopeOrPromise) => {
1175
- const { options, content } = await envelopeOrPromise;
1176
- const { url, method, headers = [], ...rest } = options;
1177
- const { mode: _mode, ...forwardable } = rest;
1178
- const requestHeaders = new Headers();
1179
- for (const [key, value] of headers) requestHeaders.append(key, value);
1180
- const hasBody = method !== "GET" && method !== "HEAD" && method !== "OPTIONS";
1181
- const requestInit = {
1182
- ...forwardable,
1183
- method,
1184
- headers: requestHeaders
1449
+ function serveFetchOverDuplex(handler, options = {}) {
1450
+ return httpServe(async (env, body) => {
1451
+ const reqInit = {
1452
+ method: env.method,
1453
+ headers: forwardableHeaders(env.headers)
1454
+ };
1455
+ if (env.method !== "GET" && env.method !== "HEAD") {
1456
+ if (supportsRequestStreams()) {
1457
+ reqInit.body = asyncIterableToReadable(body);
1458
+ reqInit.duplex = "half";
1459
+ } else reqInit.body = await collectBytes(body);
1460
+ }
1461
+ const response = await handler(new Request(env.url, reqInit));
1462
+ const respEnv = {
1463
+ status: response.status,
1464
+ statusText: response.statusText,
1465
+ headers: headersToArray(response.headers)
1185
1466
  };
1186
- if (!hasBody) await discard(content);
1187
- else if (supportsRequestStreams()) {
1188
- requestInit.body = toReadableStream(content[Symbol.asyncIterator]());
1189
- requestInit.duplex = "half";
1190
- } else requestInit.body = await collectBytes(content);
1191
- const response = await handler(new Request(url, requestInit));
1467
+ if (env.method === "HEAD" || env.method === "OPTIONS" || NULL_BODY_STATUSES.has(response.status)) {
1468
+ await response.body?.cancel();
1469
+ return {
1470
+ envelope: respEnv,
1471
+ body: void 0
1472
+ };
1473
+ }
1192
1474
  return {
1193
- options: {
1194
- status: response.status,
1195
- statusText: response.statusText,
1196
- headers: Object.fromEntries([...response.headers])
1197
- },
1198
- content: response.body ? fromReadableStream(response.body) : (async function* () {})()
1475
+ envelope: respEnv,
1476
+ body: response.body ? readableToAsyncIterable(response.body) : void 0,
1477
+ release: () => response.body?.cancel()
1199
1478
  };
1200
- };
1479
+ }, options);
1480
+ }
1481
+ async function* withAbort(iter, signal) {
1482
+ if (!signal) {
1483
+ yield* iter;
1484
+ return;
1485
+ }
1486
+ for await (const chunk of iter) {
1487
+ if (signal.aborted) throw abortReason$1(signal);
1488
+ yield chunk;
1489
+ }
1490
+ }
1491
+ function abortReason$1(signal) {
1492
+ const reason = signal.reason;
1493
+ if (reason instanceof Error) return reason;
1494
+ const err = new Error(reason === void 0 ? "Aborted" : String(reason));
1495
+ err.name = "AbortError";
1496
+ return err;
1201
1497
  }
1202
1498
  //#endregion
1203
- //#region src/core/data-channels.ts
1204
- const MESSAGE_TYPE_REQUEST = "REQUEST";
1205
- const MESSAGE_TYPE_RESPONSE = "RESPONSE";
1206
- let __invocationCounter = 0;
1207
- function newInvokationChannel({ port, handler = () => {
1208
- throw new Error("Handler not implemented");
1209
- }, onError = console.error, newCallId = () => ++__invocationCounter }) {
1210
- const requests = {};
1211
- const listener = async (event) => {
1212
- const data = event.data ?? {};
1213
- if (data.type === MESSAGE_TYPE_REQUEST) try {
1214
- const result = await handler(data.request, ...event.ports);
1215
- const [response, ...transfers] = Array.isArray(result) ? result : result !== void 0 ? [result] : [];
1216
- port.postMessage({
1217
- type: MESSAGE_TYPE_RESPONSE,
1218
- callId: data.callId,
1219
- response
1220
- }, transfers);
1221
- } catch (error) {
1222
- port.postMessage({
1223
- type: MESSAGE_TYPE_RESPONSE,
1224
- callId: data.callId,
1225
- error: serializeError(error)
1226
- });
1227
- }
1228
- else if (data.type === MESSAGE_TYPE_RESPONSE) {
1229
- const pending = requests[data.callId];
1230
- delete requests[data.callId];
1231
- if (!pending) return;
1232
- if (data.error) pending.reject(deserializeError(data.error));
1233
- else pending.resolve(data.response);
1499
+ //#region ../webrun-rpc/dist/index.js
1500
+ const closeSignals = /* @__PURE__ */ new WeakMap();
1501
+ /**
1502
+ * Internal: register a port's close signal. Called once by `bindBytesToPort`.
1503
+ *
1504
+ * @internal
1505
+ */
1506
+ function setPortCloseSignal(port, signal) {
1507
+ closeSignals.set(port, signal);
1508
+ }
1509
+ /**
1510
+ * Return the AbortSignal that fires when `port`'s transport closes, or
1511
+ * `undefined` if `port` is not transport-backed (e.g., a raw
1512
+ * `MessageChannel().port1`).
1513
+ */
1514
+ function getPortCloseSignal(port) {
1515
+ return closeSignals.get(port);
1516
+ }
1517
+ /**
1518
+ * Pass as `timeout` to install no deadline at all. Used by the stream tier,
1519
+ * where the deadline belongs to the stream rather than to one chunk (spec D8):
1520
+ * a slow consumer is throttled, never failed.
1521
+ */
1522
+ const NO_TIMEOUT = Number.POSITIVE_INFINITY;
1523
+ /**
1524
+ * Asynchronous request/response over any `MessageTarget`.
1525
+ *
1526
+ * Sends `params` to the peer listening with `listenPort`, waits up to
1527
+ * `timeout` ms for a matching reply, and either resolves with the result or
1528
+ * rejects with the deserialised error.
1529
+ */
1530
+ function callPort(port, params, { timeout = 1e3, channelName = "", log = () => {}, newCallId = () => `call-${Date.now()}-${String(Math.random()).substring(2)}`, signal } = {}) {
1531
+ const callId = newCallId();
1532
+ log("[callPort]", {
1533
+ channelName,
1534
+ callId,
1535
+ params
1536
+ });
1537
+ const combinedSignal = combineSignals(signal, getPortCloseSignal(port));
1538
+ let timerId;
1539
+ let onMessage;
1540
+ let onAbort;
1541
+ const promise = new Promise((resolve, reject) => {
1542
+ if (combinedSignal?.aborted) {
1543
+ reject(abortReason(combinedSignal));
1544
+ return;
1234
1545
  }
1235
- };
1236
- const start = async () => {
1237
- try {
1238
- port.addEventListener("message", listener);
1239
- await port.start?.();
1240
- } catch (e) {
1241
- onError(e);
1546
+ if (Number.isFinite(timeout) && timeout > 0) timerId = setTimeout(() => reject(/* @__PURE__ */ new Error(`Call timeout. CallId: "${callId}".`)), timeout);
1547
+ onMessage = (event) => {
1548
+ const data = event.data;
1549
+ if (!data) return;
1550
+ if (data.channelName !== channelName) return;
1551
+ if (data.callId !== callId) return;
1552
+ if (data.type === "response:error") reject(deserializeError(data.error));
1553
+ else if (data.type === "response:result") resolve(data.result);
1554
+ };
1555
+ port.addEventListener("message", onMessage);
1556
+ if (combinedSignal) {
1557
+ onAbort = () => reject(abortReason(combinedSignal));
1558
+ combinedSignal.addEventListener("abort", onAbort, { once: true });
1242
1559
  }
1243
- };
1244
- const close = async () => {
1560
+ });
1561
+ promise.catch(() => {}).finally(() => {
1562
+ if (timerId !== void 0) clearTimeout(timerId);
1563
+ if (onMessage) port.removeEventListener("message", onMessage);
1564
+ if (onAbort && combinedSignal) combinedSignal.removeEventListener("abort", onAbort);
1565
+ });
1566
+ port.postMessage({
1567
+ type: "request",
1568
+ channelName,
1569
+ callId,
1570
+ params
1571
+ });
1572
+ return promise;
1573
+ }
1574
+ function abortReason(signal) {
1575
+ const reason = signal.reason;
1576
+ if (reason instanceof Error) return reason;
1577
+ const err = new Error(reason === void 0 ? "Aborted" : String(reason));
1578
+ err.name = "AbortError";
1579
+ return err;
1580
+ }
1581
+ function combineSignals(...signals) {
1582
+ const live = signals.filter((s) => s !== void 0);
1583
+ if (live.length === 0) return void 0;
1584
+ if (live.length === 1) return live[0];
1585
+ return AbortSignal.any(live);
1586
+ }
1587
+ /**
1588
+ * Installs `handler` as the server side of a `callPort` / `listenPort`
1589
+ * request/response pair on `port`.
1590
+ *
1591
+ * Returns a cleanup function that removes the listener.
1592
+ */
1593
+ function listenPort(port, handler, { channelName = "", log = () => {} } = {}) {
1594
+ const onMessage = async (event) => {
1595
+ const data = event.data;
1596
+ if (!data || data.channelName !== channelName || data.type !== "request") return;
1597
+ const { callId, params } = data;
1598
+ log("[listenPort]", {
1599
+ channelName,
1600
+ callId,
1601
+ params
1602
+ });
1603
+ let result;
1604
+ let error;
1605
+ let type;
1245
1606
  try {
1246
- port.removeEventListener("message", listener);
1247
- await port.close?.();
1607
+ result = await handler(params);
1608
+ type = "response:result";
1248
1609
  } catch (e) {
1249
- onError(e);
1610
+ error = serializeError(e);
1611
+ type = "response:error";
1250
1612
  }
1251
- };
1252
- const invoke = (request = {}, ...transfers) => {
1253
- const callId = newCallId();
1254
- return new Promise((resolve, reject) => {
1255
- try {
1256
- requests[callId] = {
1257
- resolve,
1258
- reject
1259
- };
1260
- port.postMessage({
1261
- type: MESSAGE_TYPE_REQUEST,
1262
- callId,
1263
- request
1264
- }, transfers);
1265
- } catch (error) {
1266
- delete requests[callId];
1267
- reject(error);
1268
- }
1613
+ port.postMessage({
1614
+ callId,
1615
+ channelName,
1616
+ type,
1617
+ result,
1618
+ error
1269
1619
  });
1270
1620
  };
1271
- return {
1272
- start,
1273
- close,
1274
- invoke
1275
- };
1621
+ port.addEventListener("message", onMessage);
1622
+ return () => port.removeEventListener("message", onMessage);
1276
1623
  }
1277
- async function* sendStream(communicationPort, input, params = {}) {
1278
- const messageChannel = new MessageChannel();
1279
- communicationPort.postMessage({
1280
- type: "START_CALL",
1281
- params
1282
- }, [messageChannel.port2]);
1283
- const channel = newStreamChannel(messageChannel.port1);
1284
- let drained = false;
1624
+ /**
1625
+ * Wraps an async iterable so that an `AbortSignal` firing causes the wrapper
1626
+ * to return cleanly, forwarding `return()` to the underlying iterator so the
1627
+ * producer (e.g., a user-supplied generator) sees its own `finally` blocks
1628
+ * run immediately rather than waiting for the next yield.
1629
+ */
1630
+ async function* throughAbort(input, signal) {
1631
+ const iter = input[Symbol.asyncIterator] ? input[Symbol.asyncIterator]() : input[Symbol.iterator]();
1632
+ const onAbort = () => {
1633
+ iter.return?.(void 0);
1634
+ };
1635
+ if (signal.aborted) {
1636
+ iter.return?.(void 0);
1637
+ return;
1638
+ }
1639
+ signal.addEventListener("abort", onAbort, { once: true });
1285
1640
  try {
1286
- await channel.start();
1287
- channel.sendAll(input);
1288
- yield* channel.recieveAll();
1289
- drained = true;
1641
+ while (true) {
1642
+ const r = await iter.next();
1643
+ if (r.done) return;
1644
+ if (signal.aborted) return;
1645
+ yield r.value;
1646
+ }
1290
1647
  } finally {
1291
- if (!drained) channel.cancel();
1292
- await channel.close();
1648
+ signal.removeEventListener("abort", onAbort);
1293
1649
  }
1294
1650
  }
1295
- function handleStreams(communicationPort, handler) {
1296
- const listener = async (event) => {
1297
- const { type, params } = event.data ?? {};
1298
- if (type !== "START_CALL") return;
1299
- const port = event.ports[0];
1300
- const channel = newStreamChannel(port);
1651
+ /**
1652
+ * The `type` of the out-of-band notice a side posts when it abandons a stream.
1653
+ *
1654
+ * Layer 1's `close` is not observable to layer 2 — a closed virtual port drops
1655
+ * its listeners silently and is indistinguishable from a working port nobody
1656
+ * is answering — so the peer would otherwise wait forever. Exported because
1657
+ * tests and adapters assert on it.
1658
+ */
1659
+ const STREAM_ABORT = "webrun-rpc:stream-abort";
1660
+ /** Caller's input travels on this channel; the handler listens on it. */
1661
+ const CHANNEL_IN = "in";
1662
+ /** The handler's output travels on this channel; the caller listens on it. */
1663
+ const CHANNEL_OUT = "out";
1664
+ /**
1665
+ * One port in, one `Duplex` out (spec D9).
1666
+ *
1667
+ * The returned `Duplex` runs a single stream on `port`: the caller's `input`
1668
+ * is sent chunk by chunk with `callPort`, and the handler's output arrives the
1669
+ * same way on the other channel. Within each direction the next chunk is never
1670
+ * sent until the previous one has been delivered *and* pulled past by the
1671
+ * consumer (spec D11) — the reply to a chunk call *is* the confirmation, and
1672
+ * `listenPort` withholds it until then.
1673
+ *
1674
+ * A stream port carries exactly one invocation. To make several calls, open
1675
+ * several ports: `mux.openPort({ kind: "stream" })` per call.
1676
+ */
1677
+ function duplexOverPort(port, options = {}) {
1678
+ return (input) => runCallerSide(port, input, options);
1679
+ }
1680
+ /**
1681
+ * Installs `handler` as the serving side of one stream on `port`. Returns an
1682
+ * idempotent teardown that abandons the stream and notifies the peer.
1683
+ */
1684
+ function serveDuplexOverPort(port, handler, options = {}) {
1685
+ const controller = new AbortController();
1686
+ const notice = installAbortNotice(port, controller);
1687
+ abortOnPortClose(port, controller);
1688
+ const clock = installStreamTimeout(controller, options.timeout);
1689
+ const inbound = receiveChunks(port, CHANNEL_IN, controller, clock.touch);
1690
+ let output;
1691
+ try {
1692
+ output = handler(inbound.stream);
1693
+ } catch (err) {
1694
+ const pump = sendChunks(port, CHANNEL_OUT, failing(err), options, controller.signal, clock.touch);
1695
+ pump.catch(() => {});
1696
+ disarmClockWhenBothSidesSettle(clock, pump, inbound.ended);
1697
+ return teardownOnce(controller, notice, clock, inbound, void 0);
1698
+ }
1699
+ const pump = sendChunks(port, CHANNEL_OUT, output, options, controller.signal, clock.touch);
1700
+ pump.catch(() => {});
1701
+ disarmClockWhenBothSidesSettle(clock, pump, inbound.ended);
1702
+ return teardownOnce(controller, notice, clock, inbound, output);
1703
+ }
1704
+ /**
1705
+ * A stream that completes normally has nobody left to call the returned
1706
+ * teardown — the caller only knows the stream ended, not that it must also
1707
+ * dispose the serve-side handle. Without this, the last `touch()`'s timer
1708
+ * stays armed for up to `timeout` ms after real completion, doing nothing
1709
+ * but holding the event loop open.
1710
+ *
1711
+ * Only disarms once BOTH `pump` (our output) and `inboundEnded` (the peer's
1712
+ * declared end of input, or an abort) have settled. Disarming on `pump`
1713
+ * alone is not safe: under half-close the handler can finish producing
1714
+ * output while the caller is still sending input, and a clock stopped then
1715
+ * would leave that still-open half with no deadline at all — worse than the
1716
+ * leak this fixes.
1717
+ */
1718
+ function disarmClockWhenBothSidesSettle(clock, pump, inboundEnded) {
1719
+ Promise.allSettled([pump, inboundEnded]).then(() => {
1720
+ clock.stop();
1721
+ });
1722
+ }
1723
+ async function* failing(err) {
1724
+ throw err;
1725
+ }
1726
+ function teardownOnce(controller, notice, clock, inbound, output) {
1727
+ let torn = false;
1728
+ return () => {
1729
+ if (torn) return;
1730
+ torn = true;
1731
+ if (!controller.signal.aborted) controller.abort(/* @__PURE__ */ new Error("webrun-rpc: stream torn down"));
1732
+ notice.post();
1733
+ notice.stop();
1734
+ clock.stop();
1735
+ inbound.stop();
1736
+ output?.return(void 0).catch(() => {});
1737
+ };
1738
+ }
1739
+ function runCallerSide(port, input, options) {
1740
+ const controller = new AbortController();
1741
+ const notice = installAbortNotice(port, controller);
1742
+ abortOnPortClose(port, controller);
1743
+ const clock = installStreamTimeout(controller, options.timeout);
1744
+ const inbound = receiveChunks(port, CHANNEL_OUT, controller, clock.touch);
1745
+ const pump = sendChunks(port, CHANNEL_IN, input, options, controller.signal, clock.touch);
1746
+ pump.catch(() => {});
1747
+ return (async function* () {
1301
1748
  try {
1302
- await channel.start();
1303
- const response = await handler(channel.recieveAll(), params ?? {});
1304
- await channel.sendAll(response);
1749
+ yield* inbound.stream;
1305
1750
  } finally {
1306
- await channel.close();
1751
+ if (!controller.signal.aborted) controller.abort(/* @__PURE__ */ new Error("webrun-rpc: the caller abandoned the stream"));
1752
+ notice.post();
1753
+ notice.stop();
1754
+ clock.stop();
1755
+ inbound.stop();
1756
+ await pump.catch(() => {});
1757
+ try {
1758
+ await port.close?.();
1759
+ } catch {}
1307
1760
  }
1308
- };
1309
- communicationPort.addEventListener("message", listener);
1310
- communicationPort.start?.();
1311
- return () => communicationPort.removeEventListener("message", listener);
1761
+ })();
1312
1762
  }
1313
- function newStreamChannel(port) {
1314
- let listeners = [];
1315
- let iterators = [];
1316
- const notifyAll = async (data) => {
1317
- for (const listener of listeners) await listener(data);
1763
+ /**
1764
+ * Listens for the peer's abort notice, and can post our own. The notice is a
1765
+ * plain message with no `channelName` and a `type` that is not `"request"`,
1766
+ * so neither `callPort` nor `listenPort` reacts to it, and it carries no
1767
+ * numeric `id`, so `structuredCodec` never mistakes it for a layer 1 envelope.
1768
+ */
1769
+ function installAbortNotice(port, controller) {
1770
+ const onMessage = (event) => {
1771
+ const data = event.data;
1772
+ if (!data || data.type !== "webrun-rpc:stream-abort") return;
1773
+ if (!controller.signal.aborted) controller.abort(/* @__PURE__ */ new Error("webrun-rpc: the peer abandoned the stream"));
1318
1774
  };
1319
- /**
1320
- * Release whatever we are sending. NOT awaited: `.return()` on an async
1321
- * generator parked awaiting its own source is queued behind that pending
1322
- * `next()`, so awaiting it here would block the message handler — and the
1323
- * chunk that would unblock it can only arrive through that same handler.
1324
- */
1325
- const cancelOutgoing = () => {
1326
- for (const it of [...iterators]) {
1327
- const iterable = it;
1328
- Promise.resolve(iterable.return?.()).catch(() => {});
1775
+ port.addEventListener("message", onMessage);
1776
+ let posted = false;
1777
+ return {
1778
+ post() {
1779
+ if (posted) return;
1780
+ posted = true;
1781
+ try {
1782
+ port.postMessage({ type: STREAM_ABORT });
1783
+ } catch {}
1784
+ },
1785
+ stop() {
1786
+ port.removeEventListener("message", onMessage);
1329
1787
  }
1330
1788
  };
1331
- const channel = newInvokationChannel({
1332
- port,
1333
- handler: (data) => {
1334
- const message = data;
1335
- if (message?.cancel) {
1336
- cancelOutgoing();
1337
- return;
1338
- }
1339
- return notifyAll(message);
1789
+ }
1790
+ /**
1791
+ * Aborts the stream when its port closes underneath it — the multiplexer was
1792
+ * closed, or the peer closed the port — for ports that report it (see
1793
+ * `getPortCloseSignal`). Without this a stream waiting for the peer's next
1794
+ * chunk would wait forever on a port that can no longer deliver one: the
1795
+ * outbound half's `callPort` already fails on the close signal, but nothing
1796
+ * failed the inbound half.
1797
+ */
1798
+ function abortOnPortClose(port, controller) {
1799
+ const closed = getPortCloseSignal(port);
1800
+ if (!closed) return;
1801
+ const onClose = () => {
1802
+ if (!controller.signal.aborted) controller.abort(closed.reason);
1803
+ };
1804
+ if (closed.aborted) {
1805
+ onClose();
1806
+ return;
1807
+ }
1808
+ closed.addEventListener("abort", onClose, { once: true });
1809
+ controller.signal.addEventListener("abort", () => closed.removeEventListener("abort", onClose), { once: true });
1810
+ }
1811
+ /**
1812
+ * The per-stream inactivity timeout (spec D8). Reset by any chunk in either
1813
+ * direction; elapsing aborts the stream. Unset, zero or non-finite installs no
1814
+ * timer at all, which is the default: a slow consumer is throttled, not failed.
1815
+ */
1816
+ function installStreamTimeout(controller, timeout) {
1817
+ if (timeout === void 0 || !Number.isFinite(timeout) || timeout <= 0) return {
1818
+ touch() {},
1819
+ stop() {}
1820
+ };
1821
+ let timer;
1822
+ let stopped = false;
1823
+ const arm = () => {
1824
+ timer = setTimeout(() => {
1825
+ if (!controller.signal.aborted) controller.abort(/* @__PURE__ */ new Error(`webrun-rpc: stream idle for ${timeout} ms`));
1826
+ }, timeout);
1827
+ };
1828
+ arm();
1829
+ return {
1830
+ touch() {
1831
+ if (stopped) return;
1832
+ if (timer !== void 0) clearTimeout(timer);
1833
+ arm();
1834
+ },
1835
+ stop() {
1836
+ stopped = true;
1837
+ if (timer !== void 0) clearTimeout(timer);
1838
+ timer = void 0;
1340
1839
  }
1840
+ };
1841
+ }
1842
+ /**
1843
+ * The receiving half of one direction.
1844
+ *
1845
+ * The `listenPort` listener is installed **eagerly**, not lazily inside
1846
+ * `recieveIterator`'s installer, because a handler that never drains its input
1847
+ * would otherwise leave the peer's chunk calls with nobody to answer them. If
1848
+ * the local consumer has not started iterating, an inbound chunk waits for it —
1849
+ * which is the correct backpressure, and different from having no listener.
1850
+ */
1851
+ function receiveChunks(port, channelName, controller, touch) {
1852
+ let deliver;
1853
+ const waiting = [];
1854
+ let finished = false;
1855
+ let outstanding = false;
1856
+ let poison;
1857
+ let resolveEnded = () => {};
1858
+ const ended = new Promise((resolve) => {
1859
+ resolveEnded = resolve;
1341
1860
  });
1342
- const start = () => channel.start();
1343
- const cancel = () => {
1344
- channel.invoke({ cancel: true }).catch(() => {});
1861
+ let aborted = false;
1862
+ let abortReason;
1863
+ const ready = () => deliver || finished ? Promise.resolve() : new Promise((r) => waiting.push(r));
1864
+ const wake = () => {
1865
+ for (const r of waiting.splice(0)) r();
1345
1866
  };
1346
- const close = async () => {
1347
- await notifyAll({ done: true });
1348
- for (const it of [...iterators]) await it.return?.();
1349
- await channel.close();
1867
+ const off = listenPort(port, async ({ done, value, error }) => {
1868
+ touch();
1869
+ if (poison) throw poison;
1870
+ if (outstanding) {
1871
+ poison = /* @__PURE__ */ new Error("webrun-rpc: peer sent a second unconfirmed chunk; the stream port is closed");
1872
+ controller.abort(poison);
1873
+ setTimeout(() => {
1874
+ try {
1875
+ port.close?.();
1876
+ } catch {}
1877
+ }, 0);
1878
+ throw poison;
1879
+ }
1880
+ outstanding = true;
1881
+ try {
1882
+ await ready();
1883
+ if (finished) throw poison ?? /* @__PURE__ */ new Error("webrun-rpc: the stream is closed");
1884
+ await deliver?.({
1885
+ done,
1886
+ value,
1887
+ error: error ? deserializeError(error) : void 0
1888
+ });
1889
+ } finally {
1890
+ outstanding = false;
1891
+ }
1892
+ if (done) resolveEnded();
1893
+ }, { channelName });
1894
+ const detach = () => {
1895
+ finished = true;
1896
+ wake();
1897
+ off();
1898
+ controller.signal.removeEventListener("abort", onAbort);
1899
+ resolveEnded();
1350
1900
  };
1351
- async function* recieveAll() {
1352
- yield* recieveIterator((deliver) => {
1353
- listeners.push(deliver);
1354
- return () => {
1355
- listeners = listeners.filter((l) => l !== deliver);
1356
- };
1901
+ const onAbort = () => {
1902
+ aborted = true;
1903
+ abortReason = controller.signal.reason;
1904
+ finished = true;
1905
+ wake();
1906
+ deliver?.({
1907
+ done: true,
1908
+ error: abortReason
1357
1909
  });
1358
- }
1359
- async function sendAll(it) {
1360
- await sendIterator((chunk) => void channel.invoke(chunk), (async function* () {
1361
- try {
1362
- iterators.push(it);
1363
- yield* it;
1364
- } finally {
1365
- iterators = iterators.filter((i) => i !== it);
1366
- }
1367
- })());
1368
- }
1910
+ resolveEnded();
1911
+ };
1912
+ controller.signal.addEventListener("abort", onAbort, { once: true });
1369
1913
  return {
1370
- start,
1371
- close,
1372
- cancel,
1373
- recieveAll,
1374
- sendAll
1914
+ stream: recieveIterator((d) => {
1915
+ if (aborted) {
1916
+ d({
1917
+ done: true,
1918
+ error: abortReason
1919
+ });
1920
+ return detach;
1921
+ }
1922
+ deliver = d;
1923
+ wake();
1924
+ return detach;
1925
+ }),
1926
+ stop: detach,
1927
+ ended
1375
1928
  };
1376
1929
  }
1377
- //#endregion
1378
- //#region src/http/http-send-recieve.ts
1379
- async function* httpToIterator(envelopeOrPromise) {
1380
- const { options, content } = await envelopeOrPromise;
1381
- yield new TextEncoder().encode(JSON.stringify(options));
1382
- yield* content;
1383
- }
1384
- async function httpFromIterator(iterable) {
1385
- const it = (await iterable)[Symbol.asyncIterator]();
1386
- const { done, value } = await it.next();
1387
- let options = {};
1388
- if (!done && value) {
1389
- const str = new TextDecoder().decode(value);
1390
- options = JSON.parse(str);
1930
+ /**
1931
+ * The sending half of one direction: one `callPort` per chunk, one call
1932
+ * outstanding at a time (spec D11/D12), with no per-chunk deadline (spec D8).
1933
+ */
1934
+ async function sendChunks(port, channelName, output, { maxMessageSize, log }, signal, touch) {
1935
+ const stream = throughAbort(maxMessageSize ? toChunks(maxMessageSize)(output) : output, signal);
1936
+ try {
1937
+ await sendIterator(async ({ done, value, error }) => {
1938
+ if (signal.aborted) return;
1939
+ const chunk = {
1940
+ done,
1941
+ value,
1942
+ error: error === void 0 ? void 0 : serializeError(error)
1943
+ };
1944
+ log?.("[duplexOverPort] send", {
1945
+ channelName,
1946
+ done,
1947
+ size: value?.byteLength
1948
+ });
1949
+ await callPort(port, chunk, {
1950
+ channelName,
1951
+ timeout: NO_TIMEOUT,
1952
+ signal
1953
+ });
1954
+ touch();
1955
+ }, stream);
1956
+ } catch (err) {
1957
+ if (signal.aborted) return;
1958
+ throw err;
1391
1959
  }
1960
+ }
1961
+ /**
1962
+ * One virtual port.
1963
+ *
1964
+ * `deliver` and `markClosed` are deliberately not on `port`: the consumer holds
1965
+ * only a `MessageTarget`, so it cannot forge inbound traffic or close the port
1966
+ * out from under the multiplexer's bookkeeping.
1967
+ */
1968
+ function newVirtualPort(send, requestClose) {
1969
+ const listeners = /* @__PURE__ */ new Set();
1970
+ let closed = false;
1971
+ const closing = new AbortController();
1972
+ const abort = (reason) => {
1973
+ if (closing.signal.aborted) return;
1974
+ closing.abort(reason ?? /* @__PURE__ */ new Error("webrun-rpc: the port was closed"));
1975
+ };
1976
+ const port = {
1977
+ addEventListener(_type, listener) {
1978
+ listeners.add(listener);
1979
+ },
1980
+ removeEventListener(_type, listener) {
1981
+ listeners.delete(listener);
1982
+ },
1983
+ postMessage(message, transfer) {
1984
+ if (closed) return;
1985
+ send(message, transfer);
1986
+ },
1987
+ close() {
1988
+ if (closed) return;
1989
+ closed = true;
1990
+ const notify = requestClose;
1991
+ listeners.clear();
1992
+ notify();
1993
+ abort();
1994
+ }
1995
+ };
1996
+ setPortCloseSignal(port, closing.signal);
1392
1997
  return {
1393
- options,
1394
- content: { [Symbol.asyncIterator]() {
1395
- return it;
1396
- } }
1998
+ port,
1999
+ deliver(payload) {
2000
+ if (closed) return;
2001
+ const event = new MessageEvent("message", { data: payload });
2002
+ for (const listener of [...listeners]) try {
2003
+ listener(event);
2004
+ } catch {}
2005
+ },
2006
+ markClosed(reason) {
2007
+ closed = true;
2008
+ listeners.clear();
2009
+ abort(reason);
2010
+ },
2011
+ isClosed() {
2012
+ return closed;
2013
+ }
1397
2014
  };
1398
2015
  }
1399
2016
  /**
1400
- * Serve an `HttpHandler` over a `MessageTarget`, using this package's own
1401
- * `handleStreams` transport.
1402
- *
1403
- * @deprecated Prefer the port stack in `@statewalker/webrun-rpc`: open a port
1404
- * (`multiplexPort` over one pipe, or `transferPortMux` where the platform can
1405
- * transfer a real `MessagePort`), turn it into a `Duplex` with
1406
- * `serveDuplexOverPort`, and serve HTTP on that with
1407
- * `httpServe(handler, options)` from `@statewalker/webrun-http-streams`.
1408
- *
1409
- * That path has backpressure; **this one does not.** `sendStream`'s chunk
1410
- * sender discards the promise it is given, so a fast producer over a slow
1411
- * consumer accumulates without bound. It also has no per-stream timeout and no
1412
- * chunking to a transport's message ceiling.
2017
+ * The message of the error that fails the calls still in flight on a
2018
+ * multiplexer when it is closed (`PortMux.close()`).
2019
+ */
2020
+ const MUX_CLOSED_MESSAGE = "webrun-rpc: the multiplexer was closed";
2021
+ /** Ceiling on concurrently open virtual ports. Bounds the id table only. */
2022
+ const DEFAULT_MAX_PORTS = 1024;
2023
+ /**
2024
+ * The default `PortMux`: emulates multiplexing over a single port.
1413
2025
  *
1414
- * Kept for existing ServiceWorker setups built on the `MessageTarget` surface.
2026
+ * A transport that already multiplexes natively supplies its own `PortMux`
2027
+ * instead — this implementation is for transports that offer one pipe.
1415
2028
  */
1416
- function handleHttpRequests(communicationPort, handler) {
1417
- const serverStub = newHttpServerStub(handler);
1418
- return handleStreams(communicationPort, async (it) => {
1419
- const envelope = await httpFromIterator(it);
1420
- return httpToIterator(await serverStub(envelope));
1421
- });
2029
+ function multiplexPort(port, options) {
2030
+ const { codec, onPort, side = "initiator", maxPorts = DEFAULT_MAX_PORTS, maxMessageSize } = options;
2031
+ const open = /* @__PURE__ */ new Map();
2032
+ let nextId = side === "initiator" ? 0 : 1;
2033
+ let muxClosed = false;
2034
+ const post = (envelope, transfer) => {
2035
+ if (muxClosed) return;
2036
+ try {
2037
+ codec.post(port, envelope, transfer);
2038
+ } catch {}
2039
+ };
2040
+ const attach = (id) => {
2041
+ let self;
2042
+ self = newVirtualPort((payload, transfer) => post({
2043
+ type: "message",
2044
+ id,
2045
+ payload
2046
+ }, transfer), (reason) => {
2047
+ post({
2048
+ type: "close",
2049
+ id,
2050
+ reason
2051
+ });
2052
+ open.delete(id);
2053
+ self.markClosed();
2054
+ });
2055
+ open.set(id, self);
2056
+ return self;
2057
+ };
2058
+ const handleEnvelope = (envelope) => {
2059
+ if (muxClosed) return;
2060
+ if (envelope.type === "open") {
2061
+ if (open.has(envelope.id)) return;
2062
+ if (open.size >= maxPorts) {
2063
+ post({
2064
+ type: "close",
2065
+ id: envelope.id,
2066
+ reason: "max-ports"
2067
+ });
2068
+ return;
2069
+ }
2070
+ const handle = attach(envelope.id);
2071
+ let accepted = false;
2072
+ if (onPort) try {
2073
+ accepted = onPort(handle.port, envelope.meta) !== false;
2074
+ } catch {
2075
+ accepted = false;
2076
+ }
2077
+ if (!accepted) {
2078
+ open.delete(envelope.id);
2079
+ handle.markClosed(/* @__PURE__ */ new Error("webrun-rpc: the port was rejected"));
2080
+ post({
2081
+ type: "close",
2082
+ id: envelope.id,
2083
+ reason: "rejected"
2084
+ });
2085
+ }
2086
+ return;
2087
+ }
2088
+ const handle = open.get(envelope.id);
2089
+ if (envelope.type === "message") {
2090
+ if (!handle) return;
2091
+ handle.deliver(envelope.payload);
2092
+ return;
2093
+ }
2094
+ if (!handle) return;
2095
+ open.delete(envelope.id);
2096
+ handle.markClosed(/* @__PURE__ */ new Error("webrun-rpc: the peer closed the port"));
2097
+ };
2098
+ const listener = (event) => {
2099
+ const envelope = codec.read(event);
2100
+ if (envelope) handleEnvelope(envelope);
2101
+ };
2102
+ port.addEventListener("message", listener);
2103
+ port.start?.();
2104
+ return {
2105
+ maxMessageSize,
2106
+ async openPort(meta) {
2107
+ if (muxClosed) throw new Error("webrun-rpc: the multiplexer is closed");
2108
+ if (open.size >= maxPorts) throw new RangeError(`webrun-rpc: maxPorts (${maxPorts}) reached`);
2109
+ while (open.has(nextId)) nextId += 2;
2110
+ const id = nextId;
2111
+ nextId += 2;
2112
+ const handle = attach(id);
2113
+ post({
2114
+ type: "open",
2115
+ id,
2116
+ meta
2117
+ });
2118
+ return handle.port;
2119
+ },
2120
+ async close() {
2121
+ if (muxClosed) return;
2122
+ for (const [id, handle] of [...open]) {
2123
+ post({
2124
+ type: "close",
2125
+ id
2126
+ });
2127
+ open.delete(id);
2128
+ handle.markClosed(/* @__PURE__ */ new Error(MUX_CLOSED_MESSAGE));
2129
+ }
2130
+ muxClosed = true;
2131
+ port.removeEventListener("message", listener);
2132
+ await port.close?.();
2133
+ }
2134
+ };
1422
2135
  }
1423
2136
  /**
1424
- * Ship a `Request` over a `MessageTarget` and await the `Response`, using this
1425
- * package's own `sendStream` transport.
2137
+ * Announced with every port this pair opens. Layer 1 never reads `meta` — it is
2138
+ * here so a peer running something else over the same multiplexer can tell a
2139
+ * stream port from whatever else it hands out, and so a packet capture says
2140
+ * what the port is for.
2141
+ */
2142
+ const STREAM_META = { kind: "stream" };
2143
+ /**
2144
+ * One port source in, one caller `Duplex` out.
1426
2145
  *
1427
- * @deprecated Prefer the port stack in `@statewalker/webrun-rpc`: open a port
1428
- * (`multiplexPort` over one pipe, or `transferPortMux` where the platform can
1429
- * transfer a real `MessagePort`), turn it into a `Duplex` with
1430
- * `duplexOverPort`, and drive HTTP over it with `httpFetch` from
1431
- * `@statewalker/webrun-http-streams`.
2146
+ * A call is a port: the mux allocates one, `duplexOverPort` runs the single
2147
+ * invocation on it, and closing the stream closes the port. No stream ids, no
2148
+ * framing and no credit accounting live here — the mux owns the first, and
2149
+ * `duplexOverPort` owns the one-chunk window that makes memory bounded.
2150
+ */
2151
+ const connect = async ({ mux: factory, timeout }) => {
2152
+ const mux = await factory();
2153
+ const streamOptions = {
2154
+ maxMessageSize: mux.maxMessageSize,
2155
+ timeout
2156
+ };
2157
+ const call = (input) => (async function* () {
2158
+ yield* duplexOverPort(await mux.openPort(STREAM_META), streamOptions)(input);
2159
+ })();
2160
+ return {
2161
+ call,
2162
+ async close() {
2163
+ await mux.close();
2164
+ }
2165
+ };
2166
+ };
2167
+ /**
2168
+ * One port source in, `handler` serving every call that arrives on it.
1432
2169
  *
1433
- * Same caveat as {@link handleHttpRequests}: the transport underneath this
1434
- * helper has no backpressure, no per-stream timeout, and no chunking to a
1435
- * transport's message ceiling. Kept for existing ServiceWorker setups.
2170
+ * Each inbound port is one invocation, so the factory's `onPort` is the accept
2171
+ * loop: it installs `handler` on the new port and nothing else. The returned
2172
+ * teardown abandons the streams still running *before* dropping the mux, so
2173
+ * the peer's callers reject with "the peer abandoned the stream" instead of
2174
+ * parking forever on a port that has silently gone inert — layer 1's close is
2175
+ * not observable to layer 2, which is why the notice has to be posted
2176
+ * deliberately.
1436
2177
  */
1437
- async function sendHttpRequest(communicationPort, request) {
1438
- return await newHttpClientStub(async (req) => {
1439
- return await httpFromIterator(sendStream(communicationPort, httpToIterator(req)));
1440
- })(request);
1441
- }
1442
- //#endregion
1443
- //#region ../../node_modules/.pnpm/idb-keyval@6.3.0/node_modules/idb-keyval/dist/index.js
1444
- function promisifyRequest(request) {
1445
- return new Promise((resolve, reject) => {
1446
- request.oncomplete = request.onsuccess = () => resolve(request.result);
1447
- request.onabort = request.onerror = () => reject(request.error);
2178
+ const serve = async ({ mux: factory, timeout }, handler) => {
2179
+ const live = /* @__PURE__ */ new Set();
2180
+ let streamOptions = { timeout };
2181
+ const mux = await factory((port) => {
2182
+ let off;
2183
+ let finished = false;
2184
+ const tracked = (input) => (async function* () {
2185
+ try {
2186
+ yield* handler(input);
2187
+ } finally {
2188
+ finished = true;
2189
+ if (off) live.delete(off);
2190
+ }
2191
+ })();
2192
+ off = serveDuplexOverPort(port, tracked, streamOptions);
2193
+ if (!finished) live.add(off);
1448
2194
  });
1449
- }
1450
- function createStore(dbName, storeName) {
1451
- let dbp;
1452
- const getDB = () => {
1453
- if (dbp) return dbp;
1454
- const request = indexedDB.open(dbName);
1455
- request.onupgradeneeded = () => request.result.createObjectStore(storeName);
1456
- dbp = promisifyRequest(request);
1457
- dbp.then((db) => {
1458
- db.onclose = () => dbp = void 0;
1459
- }, () => {
1460
- dbp = void 0;
1461
- });
1462
- return dbp;
2195
+ streamOptions = {
2196
+ maxMessageSize: mux.maxMessageSize,
2197
+ timeout
1463
2198
  };
1464
- return (txMode, callback) => getDB().then((db) => callback(db.transaction(storeName, txMode).objectStore(storeName)));
1465
- }
1466
- let defaultGetStoreFunc;
1467
- function defaultGetStore() {
1468
- if (!defaultGetStoreFunc) defaultGetStoreFunc = createStore("keyval-store", "keyval");
1469
- return defaultGetStoreFunc;
1470
- }
2199
+ let torn = false;
2200
+ return async () => {
2201
+ if (torn) return;
2202
+ torn = true;
2203
+ for (const off of [...live]) {
2204
+ live.delete(off);
2205
+ try {
2206
+ off();
2207
+ } catch {}
2208
+ }
2209
+ await mux.close();
2210
+ };
2211
+ };
1471
2212
  /**
1472
- * Get a value by its key.
2213
+ * Ports over ONE PIPE OF BYTES — a `MessagePort`, a worker, a WebSocket.
1473
2214
  *
1474
- * @param key
1475
- * @param customStore Method to get a custom store. Use with caution (see the docs).
2215
+ * There is no second port to be had, so `multiplexPort`'s id table invents
2216
+ * them. Use this when the transport gives you exactly one channel.
1476
2217
  */
1477
- function get(key, customStore = defaultGetStore()) {
1478
- return customStore("readonly", (store) => promisifyRequest(store.get(key)));
2218
+ function overPipe(pipe, options) {
2219
+ return (onPort) => multiplexPort(pipe, {
2220
+ ...options,
2221
+ onPort
2222
+ });
2223
+ }
2224
+ function isEnvelope(value) {
2225
+ if (typeof value !== "object" || value === null) return false;
2226
+ const candidate = value;
2227
+ if (typeof candidate.id !== "number") return false;
2228
+ if (!Number.isInteger(candidate.id) || candidate.id < 0) return false;
2229
+ return candidate.type === "open" || candidate.type === "message" || candidate.type === "close";
1479
2230
  }
1480
2231
  /**
1481
- * Set a value with a key.
2232
+ * For ports whose messages are structured values — a real `MessagePort`, a
2233
+ * worker, an iframe.
1482
2234
  *
1483
- * @param key
1484
- * @param value
1485
- * @param customStore Method to get a custom store. Use with caution (see the docs).
2235
+ * Envelopes are posted as-is, so nothing is encoded, `ArrayBuffer`s move
2236
+ * zero-copy through the transfer list, and structured clone does the work the
2237
+ * platform already does well. This is a performance choice only: layer 2 may
2238
+ * not send anything a byte codec could not also carry.
1486
2239
  */
1487
- function set(key, value, customStore = defaultGetStore()) {
1488
- return customStore("readwrite", (store) => {
1489
- store.put(value, key);
1490
- return promisifyRequest(store.transaction);
1491
- });
1492
- }
2240
+ const structuredCodec = {
2241
+ post(port, envelope, transfer) {
2242
+ if (transfer && transfer.length > 0) port.postMessage(envelope, transfer);
2243
+ else port.postMessage(envelope);
2244
+ },
2245
+ read(event) {
2246
+ return isEnvelope(event.data) ? event.data : void 0;
2247
+ }
2248
+ };
1493
2249
  //#endregion
1494
- //#region src/core/data-calls.ts
1495
- function callChannel(target, callType, params, ...transfers) {
1496
- return new Promise((resolve, reject) => {
1497
- const channel = new MessageChannel();
1498
- channel.port1.onmessage = (ev) => {
1499
- const { result, error } = ev.data;
1500
- if (error) reject(deserializeError(error));
1501
- else resolve(result);
1502
- };
1503
- target.postMessage({
1504
- type: callType,
1505
- params
1506
- }, [channel.port2, ...transfers]);
2250
+ //#region src/link/http-link.ts
2251
+ /**
2252
+ * The version of the HTTP-over-`MessagePort` protocol this package speaks,
2253
+ * sent in every hand-shake that sets up a link. Version 1 was this package's
2254
+ * own `handleStreams` / `sendStream` (no backpressure); version 2 is
2255
+ * webrun-rpc's `connect` / `serve` over `overPipe` with webrun-http-streams'
2256
+ * `fetchOverDuplex` / `serveFetchOverDuplex`: one port per link, calls
2257
+ * multiplexed on it, each call a stream with a one-chunk window in both
2258
+ * directions, and cancellation that reaches the handler.
2259
+ */
2260
+ const HTTP_LINK_PROTOCOL = "webrun-http-link/2";
2261
+ /**
2262
+ * Why a request failed when its link went away under it: the far side (the
2263
+ * handler, or the ServiceWorker) was lost. The request may or may not have
2264
+ * been applied.
2265
+ */
2266
+ var LinkLostError = class extends Error {
2267
+ constructor(message = "The link was lost while this request was in flight") {
2268
+ super(`${message}; it may or may not have been applied.`);
2269
+ this.name = "LinkLostError";
2270
+ }
2271
+ };
2272
+ /**
2273
+ * Posted on a link's port by the calling end when it closes the link, so the
2274
+ * answering end can release it: a `MessagePort` never reports that its other
2275
+ * end is gone.
2276
+ */
2277
+ const LINK_CLOSE = "webrun-http:link-close";
2278
+ /** Opens the calling end of a link on `port` (one port per link). */
2279
+ async function connectHttpLink(port, { streamTimeout } = {}) {
2280
+ const { call, close } = await connect({
2281
+ mux: overPipe(port, {
2282
+ codec: structuredCodec,
2283
+ side: "initiator"
2284
+ }),
2285
+ timeout: streamTimeout
1507
2286
  });
1508
- }
1509
- function handleChannelCalls(target, callType, handler) {
1510
- const listener = async (event) => {
1511
- if (!event.data || event.data.type !== callType) return;
1512
- const [port, ...transfers] = event.ports ?? [];
1513
- const response = {};
2287
+ return trackedLink((request) => fetchOverDuplex(call, request), async () => {
1514
2288
  try {
1515
- response.result = await handler(event, event.data.params, ...transfers);
1516
- } catch (error) {
1517
- response.error = serializeError(error);
1518
- }
1519
- port?.postMessage(response);
1520
- };
1521
- target.addEventListener("message", listener);
1522
- target.start?.();
1523
- return () => target.removeEventListener("message", listener);
2289
+ port.postMessage({ type: LINK_CLOSE });
2290
+ } catch {}
2291
+ await close();
2292
+ });
1524
2293
  }
1525
- //#endregion
1526
- //#region src/core/deadline.ts
1527
2294
  /**
1528
- * Settles like `promise` if it settles before `deadline` (a `Date.now()`
1529
- * value). Otherwise calls `onTimeout`: an `Error` it returns rejects, any
1530
- * other value resolves. Internal; not re-exported.
2295
+ * Serves `handler` on the answering end of a link. Resolves with a teardown
2296
+ * that abandons the requests still running (their handlers see the
2297
+ * cancellation) and releases the port.
1531
2298
  */
1532
- function withDeadline(deadline, promise, onTimeout) {
1533
- return new Promise((resolve, reject) => {
1534
- const timer = setTimeout(() => {
1535
- const outcome = onTimeout();
1536
- if (outcome instanceof Error) reject(outcome);
1537
- else resolve(outcome);
1538
- }, Math.max(0, deadline - Date.now()));
1539
- promise.then((value) => {
1540
- clearTimeout(timer);
1541
- resolve(value);
1542
- }, (error) => {
1543
- clearTimeout(timer);
1544
- reject(error);
1545
- });
1546
- });
2299
+ async function serveHttpLink(port, handler, { streamTimeout } = {}) {
2300
+ const teardown = await serve({
2301
+ mux: overPipe(port, {
2302
+ codec: structuredCodec,
2303
+ side: "responder"
2304
+ }),
2305
+ timeout: streamTimeout
2306
+ }, serveFetchOverDuplex(async (request) => await handler(request)));
2307
+ const onClose = (event) => {
2308
+ if (event.data?.type === LINK_CLOSE) close();
2309
+ };
2310
+ const close = async () => {
2311
+ port.removeEventListener("message", onClose);
2312
+ await teardown();
2313
+ };
2314
+ port.addEventListener("message", onClose);
2315
+ return close;
1547
2316
  }
1548
- //#endregion
1549
- //#region src/core/registry.ts
1550
- function newRegistry(onError = console.error) {
1551
- let counter = 0;
1552
- const registrations = {};
1553
- const register = (action) => {
1554
- const id = counter++;
1555
- const fn = Object.assign((skip) => {
2317
+ /**
2318
+ * Wraps a `send` so the link knows its requests in flight (for draining and
2319
+ * load-based routing) and can fail them all at once when it is closed.
2320
+ */
2321
+ function trackedLink(send, release) {
2322
+ let inflight = 0;
2323
+ let closed = false;
2324
+ let lostReason;
2325
+ const failers = /* @__PURE__ */ new Set();
2326
+ const idleWaiters = [];
2327
+ const done = () => {
2328
+ inflight--;
2329
+ if (inflight === 0) for (const resolve of idleWaiters.splice(0)) resolve();
2330
+ };
2331
+ return {
2332
+ get inflight() {
2333
+ return inflight;
2334
+ },
2335
+ get closed() {
2336
+ return closed;
2337
+ },
2338
+ whenIdle() {
2339
+ return inflight === 0 ? Promise.resolve() : new Promise((r) => idleWaiters.push(r));
2340
+ },
2341
+ async send(request) {
2342
+ if (closed) throw lostReason ?? new LinkLostError("The link is closed");
2343
+ inflight++;
2344
+ let finished = false;
2345
+ const finish = () => {
2346
+ if (finished) return;
2347
+ finished = true;
2348
+ failers.delete(fail);
2349
+ done();
2350
+ };
2351
+ let fail = () => {};
2352
+ const lost = new Promise((_, reject) => {
2353
+ fail = reject;
2354
+ });
2355
+ failers.add(fail);
2356
+ lost.catch(() => {});
2357
+ let response;
1556
2358
  try {
1557
- delete registrations[id];
1558
- return skip ? void 0 : action();
2359
+ response = await Promise.race([send(request), lost]);
1559
2360
  } catch (error) {
1560
- onError(error);
2361
+ finish();
2362
+ throw lostReason ?? error;
1561
2363
  }
1562
- }, { action });
1563
- registrations[id] = fn;
1564
- return fn;
1565
- };
1566
- const unregister = (action) => {
1567
- for (const r of Object.values(registrations)) if (r.action === action) r(true);
1568
- };
1569
- const clear = () => {
1570
- for (const r of Object.values(registrations)) r();
2364
+ if (!response.body) {
2365
+ finish();
2366
+ return response;
2367
+ }
2368
+ return new Response(guardBody(response.body, lost, () => lostReason, finish), {
2369
+ status: response.status,
2370
+ statusText: response.statusText,
2371
+ headers: response.headers
2372
+ });
2373
+ },
2374
+ async close(reason) {
2375
+ if (closed) return;
2376
+ closed = true;
2377
+ lostReason = reason ?? new LinkLostError("The link was closed");
2378
+ for (const fail of [...failers]) fail(lostReason);
2379
+ try {
2380
+ await release();
2381
+ } catch {}
2382
+ }
1571
2383
  };
1572
- return Object.assign([
1573
- register,
1574
- clear,
1575
- unregister
1576
- ], {
1577
- register,
1578
- clear,
1579
- unregister
2384
+ }
2385
+ /**
2386
+ * A response body that reports its end (done, cancelled or failed) and fails
2387
+ * with the link's reason when the link is lost mid-stream.
2388
+ */
2389
+ function guardBody(body, lost, reason, finish) {
2390
+ const reader = body.getReader();
2391
+ return new ReadableStream({
2392
+ async pull(controller) {
2393
+ try {
2394
+ const { done, value } = await Promise.race([reader.read(), lost]);
2395
+ if (done) {
2396
+ finish();
2397
+ controller.close();
2398
+ return;
2399
+ }
2400
+ controller.enqueue(value);
2401
+ } catch (error) {
2402
+ finish();
2403
+ reader.cancel().catch(() => {});
2404
+ controller.error(reason() ?? error);
2405
+ }
2406
+ },
2407
+ async cancel(why) {
2408
+ finish();
2409
+ await reader.cancel(why).catch(() => {});
2410
+ }
1580
2411
  });
1581
2412
  }
1582
2413
  //#endregion
1583
- //#region src/core/service-worker-control.ts
2414
+ //#region src/link/locks.ts
2415
+ function lockManager() {
2416
+ return globalThis.navigator?.locks;
2417
+ }
2418
+ function locksAvailable() {
2419
+ return lockManager() !== void 0;
2420
+ }
2421
+ /** A fresh lock name: `<prefix>:<uuid>`. */
2422
+ function newLockName(prefix) {
2423
+ return `${prefix}:${crypto.randomUUID()}`;
2424
+ }
1584
2425
  /**
1585
- * How long, by default, the page waits for its ServiceWorker to activate and
1586
- * to take control before giving up. Generous: a first install downloads and
1587
- * evaluates the worker script, which on a slow link takes seconds.
2426
+ * Takes the lock `name` and holds it until the returned release is called or
2427
+ * this context dies. Resolves once the lock is held; with no Web Locks,
2428
+ * resolves at once.
1588
2429
  */
1589
- const DEFAULT_SERVICE_WORKER_TIMEOUT = 3e4;
2430
+ async function holdLock(name) {
2431
+ const locks = lockManager();
2432
+ if (!locks) return () => {};
2433
+ let release = () => {};
2434
+ const released = new Promise((resolve) => {
2435
+ release = resolve;
2436
+ });
2437
+ await new Promise((held) => {
2438
+ locks.request(name, {}, () => {
2439
+ held();
2440
+ return released;
2441
+ }).catch(() => held());
2442
+ });
2443
+ return release;
2444
+ }
1590
2445
  /**
1591
- * How long the page waits for `controllerchange` once the worker has
1592
- * answered the `CLAIM` request. `clients.claim()` resolves only after the
1593
- * browser has queued that event, so this is a grace period for delivery, not
1594
- * a second budget.
2446
+ * Calls `onLost` once the lock `name` is granted to this context — that is,
2447
+ * once whoever holds it is gone — and releases it at once. Returns a function
2448
+ * that stops watching. With no Web Locks, never calls `onLost`.
1595
2449
  */
1596
- const CLAIM_GRACE_MS = 1e3;
1597
- /** Channel call a page sends to ask its ServiceWorker to `clients.claim()` it. */
1598
- const CLAIM_CALL = "CLAIM";
2450
+ function watchLock(name, onLost) {
2451
+ const locks = lockManager();
2452
+ if (!locks) return () => {};
2453
+ const controller = new AbortController();
2454
+ locks.request(name, { signal: controller.signal }, () => {
2455
+ if (!controller.signal.aborted) onLost();
2456
+ }).catch(() => {});
2457
+ return () => controller.abort();
2458
+ }
1599
2459
  /**
1600
- * Why a page could not get a working ServiceWorker. `reason` says which wait
1601
- * failed; check it (or `name`) rather than `instanceof`, because each of this
1602
- * package's bundles carries its own copy of this class.
2460
+ * The names of the locks held right now in this origin, or `undefined` where
2461
+ * that cannot be asked. A backup for `watchLock`: a grant can be late, a query
2462
+ * cannot.
1603
2463
  */
1604
- var ServiceWorkerControlError = class extends Error {
1605
- reason;
1606
- constructor(reason, message) {
1607
- super(message);
1608
- this.name = "ServiceWorkerControlError";
1609
- this.reason = reason;
2464
+ async function heldLockNames() {
2465
+ const locks = lockManager();
2466
+ if (!locks?.query) return void 0;
2467
+ try {
2468
+ const { held = [] } = await locks.query();
2469
+ return new Set(held.map((lock) => lock.name ?? ""));
2470
+ } catch {
2471
+ return;
1610
2472
  }
1611
- };
2473
+ }
2474
+ //#endregion
2475
+ //#region src/link/rendezvous.ts
1612
2476
  /**
1613
- * Resolves with the registration's worker once it is `activated`. Rejects
1614
- * with a `ServiceWorkerControlError` (`reason: "activation-timeout"`) if that
1615
- * takes longer than `timeout` — an install that throws or never finishes
1616
- * would otherwise leave the caller waiting forever.
2477
+ * Matches replies to requests by id, for the control messages that hand
2478
+ * WebSocket ports across: each waiting request has a deadline, and all of a
2479
+ * peer's waiting requests can be failed at once when the peer is lost.
1617
2480
  */
1618
- async function awaitActiveServiceWorker(registration, { timeout = DEFAULT_SERVICE_WORKER_TIMEOUT } = {}) {
1619
- return await withDeadline(Date.now() + timeout, waitForActivated(registration), () => {
1620
- const worker = registration.installing ?? registration.waiting ?? registration.active;
1621
- return new ServiceWorkerControlError("activation-timeout", `ServiceWorker ${scriptUrl(worker)} (scope ${registration.scope}) did not activate within ${timeout} ms` + (worker ? `; it is "${worker.state}"` : "") + ". Check the worker script for errors during install (DevTools → Application → Service Workers), or raise the `timeout` option.");
1622
- });
2481
+ var PortRendezvous = class {
2482
+ _prefix;
2483
+ _waiting = /* @__PURE__ */ new Map();
2484
+ _counter = 0;
2485
+ constructor(_prefix = "r") {
2486
+ this._prefix = _prefix;
2487
+ }
2488
+ /** A fresh id for a request. */
2489
+ newId() {
2490
+ return `${this._prefix}:${++this._counter}`;
2491
+ }
2492
+ get size() {
2493
+ return this._waiting.size;
2494
+ }
2495
+ /** Waits for the reply to `id`, failing after `timeoutMs`. */
2496
+ wait(id, timeoutMs, describe = "the reply") {
2497
+ return new Promise((resolve, reject) => {
2498
+ const timer = setTimeout(() => {
2499
+ this._waiting.delete(id);
2500
+ reject(/* @__PURE__ */ new Error(`No answer to ${describe} within ${timeoutMs} ms`));
2501
+ }, timeoutMs);
2502
+ this._waiting.set(id, {
2503
+ resolve,
2504
+ reject,
2505
+ timer
2506
+ });
2507
+ });
2508
+ }
2509
+ /** Delivers the reply to `id`. `false` when nobody waits for it (it timed out). */
2510
+ settle(id, value) {
2511
+ const waiting = this._waiting.get(id);
2512
+ if (!waiting) return false;
2513
+ this._waiting.delete(id);
2514
+ clearTimeout(waiting.timer);
2515
+ waiting.resolve(value);
2516
+ return true;
2517
+ }
2518
+ fail(id, error) {
2519
+ const waiting = this._waiting.get(id);
2520
+ if (!waiting) return;
2521
+ this._waiting.delete(id);
2522
+ clearTimeout(waiting.timer);
2523
+ waiting.reject(error);
2524
+ }
2525
+ failAll(error) {
2526
+ for (const id of [...this._waiting.keys()]) this.fail(id, error);
2527
+ }
2528
+ };
2529
+ //#endregion
2530
+ //#region src/link/errors.ts
2531
+ function errorMessage(error) {
2532
+ return error instanceof Error ? error.message : String(error);
1623
2533
  }
2534
+ //#endregion
2535
+ //#region src/ws/frames.ts
1624
2536
  /**
1625
- * Resolves with the ServiceWorker that controls this page, once `registration`
1626
- * has an activated worker and the page is controlled by it.
2537
+ * The wire protocol of a WebSocket carried over a `MessagePort`.
1627
2538
  *
1628
- * A page can stay uncontrolled while its worker is active, and then no
1629
- * `controllerchange` ever fires on its own:
1630
- * - a hard reload (Ctrl+Shift+R) bypasses the worker for that load, and the
1631
- * worker's `clients.claim()` already ran when it activated;
1632
- * - Firefox can leave a page loaded while the worker is running uncontrolled.
2539
+ * A `MessagePort` is a natural carrier for a WebSocket: messages arrive in
2540
+ * order, and strings and transferable `ArrayBuffer`s cross it as they are. The
2541
+ * protocol on top is these frames, the same in both directions:
1633
2542
  *
1634
- * So when the page is uncontrolled, this asks the active worker to claim it
1635
- * again (a `CLAIM` channel call — this package's workers answer it) and waits
1636
- * for `controllerchange`. Every wait is bounded by `timeout`. If control never
1637
- * comes it reloads once (`reloadIfUncontrolled`) or rejects with a
1638
- * `ServiceWorkerControlError` (`reason: "uncontrolled"`) that says what
1639
- * happened and what to do.
2543
+ * - `m` — one message. An `ArrayBuffer` is transferred, never copied.
2544
+ * - `c` — close. The side that closes first sends it; the other answers with
2545
+ * its own `c` (the echo), and both are then closed cleanly.
2546
+ * - `p` — ping, client to server, carrying the client's heartbeat period.
2547
+ * - `q` — pong, server to client.
2548
+ * - `a` — ack: the receiver consumed `n` more bytes of messages. This is the
2549
+ * flow control: what a side has sent and not yet seen acknowledged is its
2550
+ * `bufferedAmount`, and the server end can wait for it to fall
2551
+ * (`whenDrained`). Added in version 2; version 1 had no backpressure.
2552
+ *
2553
+ * HEARTBEAT. A `MessagePort` never reports that the other side died, so the
2554
+ * client pings every `hb` ms and closes with 1006 when pongs stop for `2·hb`;
2555
+ * the server closes with 1006 when pings stop for `3·hb`. That is how a socket
2556
+ * notices a closed tab or a terminated worker.
2557
+ *
2558
+ * Unknown frames are ignored, so a later version can add frames a peer of
2559
+ * this version skips.
1640
2560
  */
1641
- async function awaitServiceWorkerControl(registration, { timeout = DEFAULT_SERVICE_WORKER_TIMEOUT, reloadIfUncontrolled = false, container = navigator.serviceWorker } = {}) {
1642
- const deadline = Date.now() + timeout;
1643
- const controlled = waitForController(container);
1644
- try {
1645
- const active = await awaitActiveServiceWorker(registration, { timeout });
1646
- let controller = container.controller;
1647
- if (!controller) controller = await withDeadline(deadline, (async () => {
1648
- await Promise.race([callChannel(active, CLAIM_CALL, {}), controlled.promise]);
1649
- return await Promise.race([controlled.promise, delay(CLAIM_GRACE_MS, null)]);
1650
- })(), () => null);
1651
- if (controller) {
1652
- forgetReload(registration);
1653
- return controller;
2561
+ /** The version of this frame protocol. Sent in the upgrade hand-shake. */
2562
+ const WS_PORT_PROTOCOL_VERSION = 2;
2563
+ /** Default heartbeat period of the client, in ms. */
2564
+ const DEFAULT_HEARTBEAT_MS = 1e4;
2565
+ /** How long a side waits for the echo of its close frame, in ms. */
2566
+ const DEFAULT_CLOSE_TIMEOUT_MS = 5e3;
2567
+ const defaultTimers = {
2568
+ setTimeout: (fn, ms) => setTimeout(fn, ms),
2569
+ clearTimeout: (id) => clearTimeout(id),
2570
+ setInterval: (fn, ms) => setInterval(fn, ms),
2571
+ clearInterval: (id) => clearInterval(id)
2572
+ };
2573
+ /** Recovers a frame from a message's data, or `undefined` for anything else. */
2574
+ function readFrame(data) {
2575
+ if (typeof data !== "object" || data === null) return void 0;
2576
+ const frame = data;
2577
+ switch (frame.t) {
2578
+ case "m": return typeof frame.d === "string" || frame.d instanceof ArrayBuffer ? frame : void 0;
2579
+ case "c": return {
2580
+ t: "c",
2581
+ code: typeof frame.code === "number" ? frame.code : 1005,
2582
+ reason: typeof frame.reason === "string" ? frame.reason : ""
2583
+ };
2584
+ case "p": {
2585
+ const hb = frame.hb;
2586
+ return {
2587
+ t: "p",
2588
+ hb: typeof hb === "number" && hb > 0 ? hb : DEFAULT_HEARTBEAT_MS
2589
+ };
1654
2590
  }
1655
- if (reloadIfUncontrolled && markReload(registration)) {
1656
- location.reload();
1657
- return await new Promise(() => {});
2591
+ case "q": return { t: "q" };
2592
+ case "a": {
2593
+ const n = frame.n;
2594
+ return typeof n === "number" && n >= 0 ? {
2595
+ t: "a",
2596
+ n
2597
+ } : void 0;
1658
2598
  }
1659
- forgetReload(registration);
1660
- throw new ServiceWorkerControlError("uncontrolled", `This page is not controlled by its ServiceWorker ${scriptUrl(active)} (scope ${registration.scope}), although the worker is active, and the worker did not take control when asked (clients.claim()) within ${timeout} ms. This happens after a hard reload (Ctrl+Shift+R / Cmd+Shift+R), which bypasses ServiceWorkers for that load, and in Firefox for some pages opened while the worker was already running. Requests from this page would not reach the worker. Reload the page normally, pass \`reloadIfUncontrolled: true\` to do that automatically, check that the page is inside the worker's scope, and that the worker answers the "${CLAIM_CALL}" request (this package's workers do).`);
1661
- } finally {
1662
- controlled.cancel();
2599
+ default: return;
2600
+ }
2601
+ }
2602
+ /** Posts `frame`, transferring a message's `ArrayBuffer`. */
2603
+ function postFrame(port, frame) {
2604
+ if (frame.t === "m" && frame.d instanceof ArrayBuffer) port.postMessage(frame, [frame.d]);
2605
+ else port.postMessage(frame);
2606
+ }
2607
+ /** The size a message counts for in `bufferedAmount`: UTF-8 bytes, or the buffer's length. */
2608
+ function messageSize(data) {
2609
+ return typeof data === "string" ? utf8Length(data) : data.byteLength;
2610
+ }
2611
+ /** The UTF-8 length of `text`, without encoding it. */
2612
+ function utf8Length(text) {
2613
+ let length = 0;
2614
+ for (let i = 0; i < text.length; i++) {
2615
+ const code = text.charCodeAt(i);
2616
+ if (code < 128) length += 1;
2617
+ else if (code < 2048) length += 2;
2618
+ else if (code >= 55296 && code <= 56319 && i + 1 < text.length) {
2619
+ const next = text.charCodeAt(i + 1);
2620
+ if (next >= 56320 && next <= 57343) {
2621
+ length += 4;
2622
+ i++;
2623
+ } else length += 3;
2624
+ } else length += 3;
1663
2625
  }
2626
+ return length;
1664
2627
  }
1665
2628
  /**
1666
- * ServiceWorker side: answers the page's `CLAIM` request with
1667
- * `clients.claim()`, which takes over every uncontrolled client in scope.
1668
- * Returns a function that stops answering.
2629
+ * A `CloseEvent` for runtimes that have none: Node 22 has `Event` and
2630
+ * `MessageEvent` as globals, but not `CloseEvent`.
1669
2631
  */
1670
- function handleClaimRequests(self) {
1671
- return handleChannelCalls(self, CLAIM_CALL, (event) => {
1672
- const claimed = self.clients.claim().then(() => true);
1673
- event.waitUntil?.(claimed);
1674
- return claimed;
1675
- });
2632
+ var WsCloseEvent = class extends Event {
2633
+ code;
2634
+ reason;
2635
+ wasClean;
2636
+ constructor(type, init = {}) {
2637
+ super(type, init);
2638
+ this.code = init.code ?? 0;
2639
+ this.reason = init.reason ?? "";
2640
+ this.wasClean = init.wasClean ?? false;
2641
+ }
2642
+ };
2643
+ /** A `close` event: the platform's `CloseEvent` where there is one. */
2644
+ function newCloseEvent(init) {
2645
+ const Native = globalThis.CloseEvent;
2646
+ return Native ? new Native("close", init) : new WsCloseEvent("close", init);
1676
2647
  }
1677
- function waitForActivated(registration) {
1678
- return new Promise((resolve) => {
1679
- const watched = /* @__PURE__ */ new Set();
1680
- const check = () => {
1681
- const active = registration.active;
1682
- if (active?.state === "activated") {
1683
- registration.removeEventListener("updatefound", watch);
1684
- for (const worker of watched) worker.removeEventListener("statechange", check);
1685
- resolve(active);
1686
- return;
1687
- }
1688
- watch();
2648
+ /**
2649
+ * What both ends of a socket over a `MessagePort` share: the frames, the close
2650
+ * handshake, and the byte accounting behind `bufferedAmount`.
2651
+ *
2652
+ * Not exported from the package: the two ends are `createWebSocketClass`'s
2653
+ * class and `ServerWebSocket`.
2654
+ */
2655
+ var PortSocketCore = class extends EventTarget {
2656
+ _port;
2657
+ _state = 0;
2658
+ _timers;
2659
+ _closeTimeoutMs;
2660
+ /** Bytes sent and not yet acknowledged by the peer, plus those still being prepared. */
2661
+ _inFlight = 0;
2662
+ /** Bytes of messages "sent" after the socket started closing: counted, never sent. */
2663
+ _discarded = 0;
2664
+ _toAck = 0;
2665
+ _ackScheduled = false;
2666
+ _closeTimer;
2667
+ _drainWaiters = [];
2668
+ constructor(options = {}) {
2669
+ super();
2670
+ this._timers = options.timers ?? defaultTimers;
2671
+ this._closeTimeoutMs = options.closeTimeoutMs ?? 5e3;
2672
+ }
2673
+ get readyState() {
2674
+ return this._state;
2675
+ }
2676
+ /**
2677
+ * Bytes queued by `send()` that the peer has not consumed yet. A peer
2678
+ * acknowledges what it has dispatched, so this falls as the other side reads.
2679
+ */
2680
+ get bufferedAmount() {
2681
+ return this._inFlight + this._discarded;
2682
+ }
2683
+ /**
2684
+ * Resolves once `bufferedAmount` is at most `limit` (default 0), or the
2685
+ * socket is closed. The backpressure a producer applies:
2686
+ * `if (socket.bufferedAmount > HIGH) await socket.whenDrained(LOW)`.
2687
+ */
2688
+ whenDrained(limit = 0) {
2689
+ if (this._state === 3 || this._inFlight <= limit) return Promise.resolve();
2690
+ return new Promise((resolve) => this._drainWaiters.push({
2691
+ limit,
2692
+ resolve
2693
+ }));
2694
+ }
2695
+ _attach(port) {
2696
+ this._port = port;
2697
+ port.onmessage = (event) => {
2698
+ const frame = readFrame(event.data);
2699
+ if (frame) this._onFrame(frame);
1689
2700
  };
1690
- function watch() {
1691
- for (const worker of [
1692
- registration.installing,
1693
- registration.waiting,
1694
- registration.active
1695
- ]) {
1696
- if (!worker || watched.has(worker)) continue;
1697
- watched.add(worker);
1698
- worker.addEventListener("statechange", check);
1699
- }
2701
+ }
2702
+ _onFrame(frame) {
2703
+ switch (frame.t) {
2704
+ case "m":
2705
+ case "c":
2706
+ this._deliver(frame);
2707
+ return;
2708
+ case "a":
2709
+ this._inFlight = Math.max(0, this._inFlight - frame.n);
2710
+ this._wakeDrainWaiters();
2711
+ return;
2712
+ case "p":
2713
+ this._onPing(frame.hb);
2714
+ return;
2715
+ case "q":
2716
+ this._onPong();
2717
+ return;
1700
2718
  }
1701
- registration.addEventListener("updatefound", watch);
1702
- check();
1703
- });
1704
- }
1705
- function waitForController(container) {
1706
- let cancel = () => {};
1707
- return {
1708
- promise: new Promise((resolve) => {
1709
- const onChange = () => {
1710
- if (!container.controller) return;
2719
+ }
2720
+ /** A message or close frame the socket is ready to act on. */
2721
+ _deliver(frame) {
2722
+ if (frame.t === "c") {
2723
+ this._onCloseFrame(frame.code, frame.reason);
2724
+ return;
2725
+ }
2726
+ if (this._state !== 1) return;
2727
+ this._dispatchMessage(frame.d);
2728
+ this._consumed(messageSize(frame.d));
2729
+ }
2730
+ _onPing(_hb) {}
2731
+ _onPong() {}
2732
+ _onClosed() {}
2733
+ /** Acknowledge consumed bytes, coalescing the acks of one turn into one frame. */
2734
+ _consumed(bytes) {
2735
+ this._toAck += bytes;
2736
+ if (this._ackScheduled) return;
2737
+ this._ackScheduled = true;
2738
+ queueMicrotask(() => {
2739
+ this._ackScheduled = false;
2740
+ const n = this._toAck;
2741
+ this._toAck = 0;
2742
+ if (n > 0 && this._state !== 3) this._post({
2743
+ t: "a",
2744
+ n
2745
+ });
2746
+ });
2747
+ }
2748
+ /** Reserves `bytes` in `bufferedAmount` for a message still being prepared (a Blob). */
2749
+ _reserve(bytes) {
2750
+ this._inFlight += bytes;
2751
+ }
2752
+ /** Sends one message whose bytes were reserved with `_reserve` (or `reserved = false`). */
2753
+ _sendData(data, reserved = false) {
2754
+ const size = messageSize(data);
2755
+ if (this._state !== 1) {
2756
+ if (reserved) this._inFlight = Math.max(0, this._inFlight - size);
2757
+ this._discarded += size;
2758
+ return;
2759
+ }
2760
+ if (!reserved) this._inFlight += size;
2761
+ this._post({
2762
+ t: "m",
2763
+ d: data
2764
+ });
2765
+ }
2766
+ _post(frame) {
2767
+ try {
2768
+ if (this._port) postFrame(this._port, frame);
2769
+ } catch {}
2770
+ }
2771
+ /** Starts the close handshake. */
2772
+ _startClose(code, reason) {
2773
+ if (this._state === 2 || this._state === 3) return;
2774
+ this._state = 2;
2775
+ this._post({
2776
+ t: "c",
2777
+ code,
2778
+ reason
2779
+ });
2780
+ this._closeTimer = this._timers.setTimeout(() => this._finish(1006, "", false), this._closeTimeoutMs);
2781
+ }
2782
+ _onCloseFrame(code, reason) {
2783
+ if (this._state === 3) return;
2784
+ if (this._state === 2) {
2785
+ this._finish(code, reason, true);
2786
+ return;
2787
+ }
2788
+ this._state = 2;
2789
+ this._post({
2790
+ t: "c",
2791
+ code,
2792
+ reason
2793
+ });
2794
+ this._finish(code, reason, true);
2795
+ }
2796
+ /** The socket is closed: release the port and tell the listeners. */
2797
+ _finish(code, reason, wasClean, error = false) {
2798
+ if (this._state === 3) return;
2799
+ this._state = 3;
2800
+ if (this._closeTimer !== void 0) this._timers.clearTimeout(this._closeTimer);
2801
+ this._closeTimer = void 0;
2802
+ this._onClosed();
2803
+ if (this._port) {
2804
+ this._port.onmessage = null;
2805
+ try {
2806
+ this._port.close();
2807
+ } catch {}
2808
+ }
2809
+ this._wakeDrainWaiters();
2810
+ if (error) this.dispatchEvent(new Event("error"));
2811
+ this.dispatchEvent(newCloseEvent({
2812
+ code,
2813
+ reason,
2814
+ wasClean
2815
+ }));
2816
+ }
2817
+ _wakeDrainWaiters() {
2818
+ if (this._drainWaiters.length === 0) return;
2819
+ const closed = this._state === 3;
2820
+ this._drainWaiters = this._drainWaiters.filter(({ limit, resolve }) => {
2821
+ if (!closed && this._inFlight > limit) return true;
2822
+ resolve();
2823
+ return false;
2824
+ });
2825
+ }
2826
+ };
2827
+ /** Copies `data` into a fresh `ArrayBuffer`: `send()` must never detach the caller's buffer. */
2828
+ function toArrayBuffer(data) {
2829
+ if (data instanceof ArrayBuffer) return data.slice(0);
2830
+ const copy = new Uint8Array(data.byteLength);
2831
+ copy.set(new Uint8Array(data.buffer, data.byteOffset, data.byteLength));
2832
+ return copy.buffer;
2833
+ }
2834
+ function isBinary(data) {
2835
+ return data instanceof ArrayBuffer || ArrayBuffer.isView(data);
2836
+ }
2837
+ function isBlob(data) {
2838
+ return typeof Blob !== "undefined" && data instanceof Blob;
2839
+ }
2840
+ /** Valid close codes for an application to send: 1000, or 3000–4999. */
2841
+ function isApplicationCloseCode(code) {
2842
+ return code === 1e3 || code >= 3e3 && code <= 4999;
2843
+ }
2844
+ //#endregion
2845
+ //#region src/ws/server.ts
2846
+ /**
2847
+ * The server end of a socket over a `MessagePort`: what a handler gets from
2848
+ * `upgradeWebSocket` and talks to.
2849
+ *
2850
+ * - Events are delivered only after `accept()`; frames that arrive earlier are
2851
+ * queued. Pings are answered even before `accept()`, so a slow handler does
2852
+ * not look like a dead one.
2853
+ * - Messages arrive as strings or `ArrayBuffer`s.
2854
+ * - `bufferedAmount` counts what the client has not consumed yet, and
2855
+ * `whenDrained(limit)` waits for it to fall: the backpressure a producer
2856
+ * applies.
2857
+ * - When the client's pings stop for three heartbeat periods (a closed tab, a
2858
+ * terminated worker), the socket closes with 1006.
2859
+ */
2860
+ var ServerWebSocket = class extends PortSocketCore {
2861
+ static CONNECTING = 0;
2862
+ static OPEN = 1;
2863
+ static CLOSING = 2;
2864
+ static CLOSED = 3;
2865
+ url;
2866
+ protocol;
2867
+ _accepted = false;
2868
+ _queue = [];
2869
+ _heartbeatMs = DEFAULT_HEARTBEAT_MS;
2870
+ _lastPing = Date.now();
2871
+ _watch;
2872
+ constructor(port, options = {}) {
2873
+ super({
2874
+ timers: options.timers,
2875
+ closeTimeoutMs: options.closeTimeoutMs
2876
+ });
2877
+ this.url = options.url ?? "";
2878
+ this.protocol = options.protocol ?? "";
2879
+ this._attach(port);
2880
+ this._state = 1;
2881
+ this._watchHeartbeat();
2882
+ }
2883
+ /** Whether `accept()` was called. */
2884
+ get accepted() {
2885
+ return this._accepted;
2886
+ }
2887
+ /** Starts delivering events, the queued ones first. */
2888
+ accept() {
2889
+ if (this._accepted) return;
2890
+ this._accepted = true;
2891
+ for (const frame of this._queue.splice(0)) super._deliver(frame);
2892
+ }
2893
+ send(data) {
2894
+ if (!this._accepted) throw new DOMException("accept() the socket before sending on it.", "InvalidStateError");
2895
+ this._sendData(isBinary(data) ? toArrayBuffer(data) : String(data));
2896
+ }
2897
+ close(code = 1e3, reason = "") {
2898
+ if (!isSendableCloseCode(code)) throw new DOMException(`${code} is not a close code an endpoint may send.`, "InvalidAccessError");
2899
+ if (utf8Length(reason) > 123) throw new DOMException("The close reason is longer than 123 bytes.", "SyntaxError");
2900
+ this._startClose(code, reason);
2901
+ }
2902
+ _deliver(frame) {
2903
+ if (!this._accepted) {
2904
+ this._queue.push(frame);
2905
+ return;
2906
+ }
2907
+ super._deliver(frame);
2908
+ }
2909
+ _dispatchMessage(data) {
2910
+ this.dispatchEvent(new MessageEvent("message", { data }));
2911
+ }
2912
+ _onPing(hb) {
2913
+ this._post({ t: "q" });
2914
+ this._lastPing = Date.now();
2915
+ if (hb !== this._heartbeatMs) {
2916
+ this._heartbeatMs = hb;
2917
+ this._watchHeartbeat();
2918
+ }
2919
+ }
2920
+ _onClosed() {
2921
+ if (this._watch !== void 0) this._timers.clearInterval(this._watch);
2922
+ this._watch = void 0;
2923
+ }
2924
+ _watchHeartbeat() {
2925
+ if (this._watch !== void 0) this._timers.clearInterval(this._watch);
2926
+ this._watch = this._timers.setInterval(() => {
2927
+ if (Date.now() - this._lastPing > 3 * this._heartbeatMs) this._finish(1006, "", false, true);
2928
+ }, this._heartbeatMs);
2929
+ }
2930
+ };
2931
+ /** Codes an endpoint may put in a close frame (RFC 6455 §7.4). */
2932
+ function isSendableCloseCode(code) {
2933
+ if (!Number.isInteger(code)) return false;
2934
+ if (code >= 3e3 && code <= 4999) return true;
2935
+ return code >= 1e3 && code <= 1014 && code !== 1004 && code !== 1005 && code !== 1006;
2936
+ }
2937
+ //#endregion
2938
+ //#region src/ws/upgrade.ts
2939
+ /**
2940
+ * The upgrade, as an HTTP exchange: a `GET` with `Upgrade: websocket` goes to
2941
+ * an ordinary `(Request) => Response` handler, and a `101` answer carrying a
2942
+ * server socket means accepted. Anything else is a refusal, with its status.
2943
+ *
2944
+ * FORBIDDEN HEADERS. A browser drops `Upgrade`, `Connection` and every
2945
+ * `Sec-*` header from a `new Request()`. The upgrade request therefore carries
2946
+ * its headers as an own `headers` property, a standalone `Headers` that keeps
2947
+ * them, so handler code reads `request.headers.get("upgrade")` as usual.
2948
+ *
2949
+ * NO 101 IN A `Response`. The constructor refuses statuses below 200, so the
2950
+ * response `upgradeWebSocket` returns reports 101 through an own `status`
2951
+ * property; the port that goes back to the caller is found with `takePort`.
2952
+ */
2953
+ /** The request header carrying the client's version of the frame protocol. */
2954
+ const WS_VERSION_HEADER = "x-webrun-ws-version";
2955
+ /**
2956
+ * The `GET` a handler sees for `new WebSocket(url, protocols)`. A `ws:` or
2957
+ * `wss:` url is given as `http:` or `https:`, as a server sees it.
2958
+ */
2959
+ function newWebSocketUpgradeRequest(url, { protocols = [], headers } = {}) {
2960
+ const httpUrl = new URL(`${url}`);
2961
+ if (httpUrl.protocol === "ws:") httpUrl.protocol = "http:";
2962
+ else if (httpUrl.protocol === "wss:") httpUrl.protocol = "https:";
2963
+ const all = new Headers(headers);
2964
+ all.set("upgrade", "websocket");
2965
+ all.set("connection", "Upgrade");
2966
+ all.set("sec-websocket-version", "13");
2967
+ if (!all.has("sec-websocket-key")) all.set("sec-websocket-key", randomKey());
2968
+ if (protocols.length > 0) all.set("sec-websocket-protocol", protocols.join(", "));
2969
+ if (!all.has("x-webrun-ws-version")) all.set(WS_VERSION_HEADER, `2`);
2970
+ return withHeaders(new Request(httpUrl, {
2971
+ method: "GET",
2972
+ headers: all
2973
+ }), all);
2974
+ }
2975
+ /**
2976
+ * Re-attaches `headers` to `request` as its own `headers` property, keeping
2977
+ * the forbidden ones a browser dropped. Returns `request`.
2978
+ */
2979
+ function withHeaders(request, headers) {
2980
+ Object.defineProperty(request, "headers", {
2981
+ value: headers,
2982
+ configurable: true
2983
+ });
2984
+ return request;
2985
+ }
2986
+ /** Whether `request` asks for a WebSocket. */
2987
+ function isWebSocketUpgrade(request) {
2988
+ return request.headers.get("upgrade")?.toLowerCase() === "websocket";
2989
+ }
2990
+ /** The subprotocols `request` asks for, in order of preference. */
2991
+ function requestedProtocols(request) {
2992
+ const header = request.headers.get("sec-websocket-protocol");
2993
+ if (!header) return [];
2994
+ return header.split(",").map((protocol) => protocol.trim()).filter((protocol) => protocol !== "");
2995
+ }
2996
+ /**
2997
+ * Where a response from `upgradeWebSocket` keeps its port. A registered
2998
+ * symbol, not a module-level `WeakMap`: the handler and the host that takes
2999
+ * the port often run different bundles of this package (a page's adapter from
3000
+ * `dist/sw.js`, its handler code importing `dist/worker.js`), and each bundle
3001
+ * has its own copy of every module.
3002
+ */
3003
+ const UPGRADE = Symbol.for("webrun-http-browser:websocket-upgrade");
3004
+ /**
3005
+ * Accepts a WebSocket upgrade in a handler:
3006
+ *
3007
+ * ```ts
3008
+ * if (isWebSocketUpgrade(request)) {
3009
+ * const { socket, response } = upgradeWebSocket(request, { protocol: "v1" });
3010
+ * socket.accept();
3011
+ * socket.addEventListener("message", (event) => socket.send(event.data));
3012
+ * return response;
3013
+ * }
3014
+ * ```
3015
+ *
3016
+ * The host takes the client's port from the response with `takePort` and
3017
+ * hands it to the caller.
3018
+ */
3019
+ function upgradeWebSocket(request, options = {}) {
3020
+ const protocol = options.protocol ?? "";
3021
+ const channel = new MessageChannel();
3022
+ const socket = new ServerWebSocket(channel.port1, {
3023
+ ...options,
3024
+ url: request.url,
3025
+ protocol
3026
+ });
3027
+ const response = new Response(null, {
3028
+ status: 200,
3029
+ headers: protocol ? { "sec-websocket-protocol": protocol } : {}
3030
+ });
3031
+ Object.defineProperties(response, {
3032
+ status: {
3033
+ value: 101,
3034
+ configurable: true
3035
+ },
3036
+ statusText: {
3037
+ value: "Switching Protocols",
3038
+ configurable: true
3039
+ },
3040
+ webSocket: {
3041
+ value: socket,
3042
+ configurable: true
3043
+ },
3044
+ [UPGRADE]: {
3045
+ value: {
3046
+ port: channel.port2,
3047
+ protocol
3048
+ },
3049
+ configurable: true,
3050
+ writable: true
3051
+ }
3052
+ });
3053
+ return {
3054
+ socket,
3055
+ response
3056
+ };
3057
+ }
3058
+ /**
3059
+ * The port to hand to the caller, for a response from `upgradeWebSocket`.
3060
+ * Taken once: a second call returns `undefined`.
3061
+ */
3062
+ function takePort(response) {
3063
+ return takeUpgrade(response)?.port;
3064
+ }
3065
+ /** `takePort`, with the subprotocol the server picked. */
3066
+ function takeUpgrade(response) {
3067
+ const holder = response;
3068
+ const upgrade = holder[UPGRADE];
3069
+ if (upgrade) holder[UPGRADE] = void 0;
3070
+ return upgrade;
3071
+ }
3072
+ /**
3073
+ * Runs an upgrade through `handler`, as a host does when a caller asks for a
3074
+ * socket: builds the upgrade request, calls the handler, and either takes the
3075
+ * port of a `101` or reports the refusal. A subprotocol the caller did not ask
3076
+ * for is a refusal, as in the standard.
3077
+ */
3078
+ async function serveWebSocketUpgrade(handler, { url, protocols = [], headers }) {
3079
+ let response;
3080
+ try {
3081
+ response = await handler(newWebSocketUpgradeRequest(url, {
3082
+ protocols,
3083
+ headers
3084
+ }));
3085
+ } catch (error) {
3086
+ return {
3087
+ ok: false,
3088
+ status: 500,
3089
+ message: errorMessage(error)
3090
+ };
3091
+ }
3092
+ const upgrade = takeUpgrade(response);
3093
+ if (!upgrade || response.status !== 101) {
3094
+ upgrade?.port.close();
3095
+ try {
3096
+ await response.body?.cancel();
3097
+ } catch {}
3098
+ return {
3099
+ ok: false,
3100
+ status: response.status,
3101
+ message: `The server refused the upgrade: ${response.status} ${response.statusText}`.trim()
3102
+ };
3103
+ }
3104
+ if (upgrade.protocol !== "" && !protocols.includes(upgrade.protocol)) {
3105
+ upgrade.port.close();
3106
+ return {
3107
+ ok: false,
3108
+ status: 400,
3109
+ message: `The server picked the subprotocol '${upgrade.protocol}', which was not requested.`
3110
+ };
3111
+ }
3112
+ return {
3113
+ ok: true,
3114
+ port: upgrade.port,
3115
+ protocol: upgrade.protocol
3116
+ };
3117
+ }
3118
+ function randomKey() {
3119
+ const bytes = /* @__PURE__ */ new Uint8Array(16);
3120
+ crypto.getRandomValues(bytes);
3121
+ return btoa(String.fromCharCode(...bytes));
3122
+ }
3123
+ //#endregion
3124
+ //#region src/sw/handler-endpoint.ts
3125
+ /** The message calls through a lost ServiceWorker fail with. */
3126
+ const ROUTER_LOST_MESSAGE = "The ServiceWorker was lost while this call was in flight";
3127
+ /**
3128
+ * The handler's end of its links to the ServiceWorker, the same in a page and
3129
+ * in a Dedicated Worker.
3130
+ *
3131
+ * - Serves `handler` on each link's `down` port, and answers WebSocket
3132
+ * upgrades arriving on its `ctl` port by running them through `handler`.
3133
+ * - Sends requests through the router on `up` (`fetch`) and opens sockets to
3134
+ * other handlers over `ctl` (`connectWebSocket`).
3135
+ * - Watches the ServiceWorker's router lock. When the ServiceWorker dies, the
3136
+ * links made for it are dropped: calls in flight on them fail with
3137
+ * `LinkLostError` ("may or may not have been applied") instead of hanging,
3138
+ * and new calls wait for a link, asking for one (`requestRelink`) every
3139
+ * `relinkRetryMs` until it comes.
3140
+ * - While calls are in flight, calls `keepAlive` every `keepAliveMs`: port
3141
+ * messages are not events, so without it the browser may stop the
3142
+ * ServiceWorker in the middle of a long call.
3143
+ */
3144
+ var HandlerEndpoint = class {
3145
+ handler;
3146
+ options;
3147
+ _links = /* @__PURE__ */ new Set();
3148
+ _waiters = [];
3149
+ _sockets = new PortRendezvous("o");
3150
+ _keepAliveTimer;
3151
+ _closed = false;
3152
+ constructor(handler, options = {}) {
3153
+ this.handler = handler;
3154
+ this.options = options;
3155
+ }
3156
+ /** Whether any link is live. */
3157
+ get linked() {
3158
+ return this._links.size > 0;
3159
+ }
3160
+ /** Installs a fresh link made for the router holding `routerLock`. */
3161
+ async link(key, ports, routerLock) {
3162
+ if (this._closed) {
3163
+ for (const port of Object.values(ports)) port?.close();
3164
+ return;
3165
+ }
3166
+ const closeDown = await serveHttpLink(ports.down, this.handler, this.options);
3167
+ const up = ports.up ? await connectHttpLink(ports.up, this.options) : void 0;
3168
+ const link = {
3169
+ key,
3170
+ routerLock,
3171
+ ctl: ports.ctl,
3172
+ up,
3173
+ closeDown,
3174
+ stopWatch: () => {}
3175
+ };
3176
+ if (ports.ctl) ports.ctl.onmessage = (event) => this._onControl(link, event.data);
3177
+ if (routerLock) link.stopWatch = watchLock(routerLock, () => this._routerLost(routerLock));
3178
+ for (const old of [...this._links]) {
3179
+ if (old.key !== key) continue;
3180
+ if (old.routerLock !== routerLock) this._drop(old, new LinkLostError(ROUTER_LOST_MESSAGE));
3181
+ else if (!old.superseded) {
3182
+ old.superseded = true;
3183
+ setTimeout(() => void this._drop(old), this.options.supersededLinkMs ?? 3e4);
3184
+ }
3185
+ }
3186
+ this._links.add(link);
3187
+ for (const wake of this._waiters.splice(0)) wake();
3188
+ }
3189
+ /** Drops the links attached under `key` (all of them without a key). */
3190
+ async unlink(key, reason) {
3191
+ const dropped = [...this._links].filter((link) => key === void 0 || link.key === key);
3192
+ await Promise.all(dropped.map((link) => this._drop(link, reason)));
3193
+ }
3194
+ /** The routers this endpoint has live links for. */
3195
+ routerLocks() {
3196
+ return new Set([...this._links].map((link) => link.routerLock));
3197
+ }
3198
+ /**
3199
+ * Sends `request` through the ServiceWorker's router, as if the browser had
3200
+ * fetched it: it reaches the handler its url routes to.
3201
+ */
3202
+ async fetch(request) {
3203
+ const link = await this._linkWith("up");
3204
+ this._startKeepAlive();
3205
+ return await link.up.send(request);
3206
+ }
3207
+ /** Opens a socket to the handler `url` routes to, through the ServiceWorker. */
3208
+ async connectWebSocket(url, protocols = []) {
3209
+ const link = await this._linkWith("ctl");
3210
+ const id = this._sockets.newId();
3211
+ const answer = this._sockets.wait(id, this.options.wsConnectTimeoutMs ?? 15e3, `the socket request for ${url}`);
3212
+ link.ctl?.postMessage({
3213
+ t: "ws-open",
3214
+ id,
3215
+ url,
3216
+ protocols
3217
+ });
3218
+ return await answer;
3219
+ }
3220
+ async close() {
3221
+ this._closed = true;
3222
+ if (this._keepAliveTimer) clearInterval(this._keepAliveTimer);
3223
+ await this.unlink(void 0, new LinkLostError("The handler was closed"));
3224
+ for (const wake of this._waiters.splice(0)) wake();
3225
+ }
3226
+ async _drop(link, reason) {
3227
+ if (!this._links.delete(link)) return;
3228
+ link.stopWatch();
3229
+ await link.up?.close(reason);
3230
+ try {
3231
+ await link.closeDown();
3232
+ } catch {}
3233
+ if (link.ctl) {
3234
+ link.ctl.onmessage = null;
3235
+ link.ctl.close();
3236
+ }
3237
+ }
3238
+ _routerLost(routerLock) {
3239
+ const reason = new LinkLostError(ROUTER_LOST_MESSAGE);
3240
+ for (const link of [...this._links]) if (link.routerLock === routerLock) this._drop(link, reason);
3241
+ this._sockets.failAll(reason);
3242
+ }
3243
+ /** A live link with `port`, waiting (and asking) for one when there is none. */
3244
+ async _linkWith(port) {
3245
+ const find = () => [...this._links].filter((link) => !link.superseded).reverse().find((link) => port === "up" ? link.up && !link.up.closed : link.ctl);
3246
+ const found = find();
3247
+ if (found) return found;
3248
+ const retryMs = this.options.relinkRetryMs ?? 2e3;
3249
+ const deadline = Date.now() + (this.options.relinkTimeoutMs ?? 3e4);
3250
+ while (!this._closed) {
3251
+ this.options.requestRelink?.();
3252
+ await new Promise((resolve) => {
3253
+ const timer = setTimeout(resolve, Math.max(0, Math.min(retryMs, deadline - Date.now())));
3254
+ this._waiters.push(() => {
3255
+ clearTimeout(timer);
3256
+ resolve();
3257
+ });
3258
+ });
3259
+ const link = find();
3260
+ if (link) return link;
3261
+ if (Date.now() >= deadline) break;
3262
+ }
3263
+ throw new LinkLostError("No link to the ServiceWorker could be made for this call");
3264
+ }
3265
+ _startKeepAlive() {
3266
+ if (this._keepAliveTimer || !this.options.keepAlive) return;
3267
+ this._keepAliveTimer = setInterval(() => {
3268
+ if ([...this._links].reduce((sum, link) => sum + (link.up?.inflight ?? 0), 0) === 0) {
3269
+ clearInterval(this._keepAliveTimer);
3270
+ this._keepAliveTimer = void 0;
3271
+ return;
3272
+ }
3273
+ this.options.keepAlive?.();
3274
+ }, this.options.keepAliveMs ?? 2e4);
3275
+ }
3276
+ _onControl(link, message) {
3277
+ if (!message || typeof message !== "object") return;
3278
+ switch (message.t) {
3279
+ case "ws-connect":
3280
+ this._acceptSocket(link, message);
3281
+ return;
3282
+ case "ws-opened":
3283
+ if (!this._sockets.settle(message.id, {
3284
+ port: message.port,
3285
+ protocol: message.protocol
3286
+ })) message.port.close();
3287
+ return;
3288
+ case "ws-failed":
3289
+ this._sockets.fail(message.id, new Error(message.message));
3290
+ return;
3291
+ }
3292
+ }
3293
+ async _acceptSocket(link, { id, url, protocols, headers }) {
3294
+ const ctl = link.ctl;
3295
+ if (!ctl) return;
3296
+ try {
3297
+ const result = await serveWebSocketUpgrade(this.handler, {
3298
+ url,
3299
+ protocols,
3300
+ headers
3301
+ });
3302
+ if (result.ok) ctl.postMessage({
3303
+ t: "ws-accept",
3304
+ id,
3305
+ protocol: result.protocol,
3306
+ port: result.port
3307
+ }, [result.port]);
3308
+ else ctl.postMessage({
3309
+ t: "ws-refuse",
3310
+ id,
3311
+ status: result.status,
3312
+ message: result.message
3313
+ });
3314
+ } catch (error) {
3315
+ ctl.postMessage({
3316
+ t: "ws-refuse",
3317
+ id,
3318
+ status: 500,
3319
+ message: errorMessage(error)
3320
+ });
3321
+ }
3322
+ }
3323
+ };
3324
+ //#endregion
3325
+ //#region src/sw/handler-table.ts
3326
+ /**
3327
+ * The default route: the handler with the fewest requests in flight, the
3328
+ * oldest on a tie. With one handler per key — one tab — that is the first
3329
+ * live handler of the key, as before routing was pluggable.
3330
+ */
3331
+ const leastInflightRoute = (_request, _key, handlers) => {
3332
+ let best;
3333
+ for (const handler of handlers) if (best === void 0 || handler.inflight < best.inflight || handler.inflight === best.inflight && handler.attachedAt < best.attachedAt) best = handler;
3334
+ return best;
3335
+ };
3336
+ var HandlerTable = class {
3337
+ _entries = /* @__PURE__ */ new Map();
3338
+ _waiters = [];
3339
+ /** Adds `entry`. Returns the entry it replaces (same key and id), if any. */
3340
+ add(entry) {
3341
+ let slots = this._entries.get(entry.key);
3342
+ if (!slots) {
3343
+ slots = /* @__PURE__ */ new Map();
3344
+ this._entries.set(entry.key, slots);
3345
+ }
3346
+ const replaced = slots.get(entry.id);
3347
+ slots.set(entry.id, entry);
3348
+ if (!entry.draining) this._waiters = this._waiters.filter(({ key, resolve }) => {
3349
+ if (key !== entry.key) return true;
3350
+ resolve(true);
3351
+ return false;
3352
+ });
3353
+ return replaced;
3354
+ }
3355
+ /** Removes `entry` if it is still the one in its slot. */
3356
+ remove(entry) {
3357
+ const slots = this._entries.get(entry.key);
3358
+ if (slots?.get(entry.id) !== entry) return false;
3359
+ slots.delete(entry.id);
3360
+ if (slots.size === 0) this._entries.delete(entry.key);
3361
+ return true;
3362
+ }
3363
+ get(key, id) {
3364
+ return this._entries.get(key)?.get(id);
3365
+ }
3366
+ /** Every entry of `key`, draining or not. */
3367
+ all(key) {
3368
+ return [...this._entries.get(key)?.values() ?? []];
3369
+ }
3370
+ /** The entries of `key` that take new requests. */
3371
+ live(key) {
3372
+ return this.all(key).filter((entry) => !entry.draining);
3373
+ }
3374
+ entries() {
3375
+ return [...this._entries.values()].flatMap((slots) => [...slots.values()]);
3376
+ }
3377
+ get size() {
3378
+ let size = 0;
3379
+ for (const slots of this._entries.values()) size += slots.size;
3380
+ return size;
3381
+ }
3382
+ /**
3383
+ * Resolves `true` once a live entry for `key` is added (at once if there is
3384
+ * one, unless `next`), `false` after `ms`.
3385
+ */
3386
+ waitFor(key, ms, next = false) {
3387
+ if (!next && this.live(key).length > 0) return Promise.resolve(true);
3388
+ return new Promise((resolve) => {
3389
+ const waiter = {
3390
+ key,
3391
+ resolve: (found) => {
3392
+ clearTimeout(timer);
3393
+ resolve(found);
3394
+ }
3395
+ };
3396
+ const timer = setTimeout(() => {
3397
+ this._waiters = this._waiters.filter((w) => w !== waiter);
3398
+ resolve(false);
3399
+ }, ms);
3400
+ this._waiters.push(waiter);
3401
+ });
3402
+ }
3403
+ };
3404
+ //#endregion
3405
+ //#region ../../node_modules/.pnpm/idb-keyval@6.3.0/node_modules/idb-keyval/dist/index.js
3406
+ function promisifyRequest(request) {
3407
+ return new Promise((resolve, reject) => {
3408
+ request.oncomplete = request.onsuccess = () => resolve(request.result);
3409
+ request.onabort = request.onerror = () => reject(request.error);
3410
+ });
3411
+ }
3412
+ function createStore(dbName, storeName) {
3413
+ let dbp;
3414
+ const getDB = () => {
3415
+ if (dbp) return dbp;
3416
+ const request = indexedDB.open(dbName);
3417
+ request.onupgradeneeded = () => request.result.createObjectStore(storeName);
3418
+ dbp = promisifyRequest(request);
3419
+ dbp.then((db) => {
3420
+ db.onclose = () => dbp = void 0;
3421
+ }, () => {
3422
+ dbp = void 0;
3423
+ });
3424
+ return dbp;
3425
+ };
3426
+ return (txMode, callback) => getDB().then((db) => callback(db.transaction(storeName, txMode).objectStore(storeName)));
3427
+ }
3428
+ let defaultGetStoreFunc;
3429
+ function defaultGetStore() {
3430
+ if (!defaultGetStoreFunc) defaultGetStoreFunc = createStore("keyval-store", "keyval");
3431
+ return defaultGetStoreFunc;
3432
+ }
3433
+ /**
3434
+ * Get a value by its key.
3435
+ *
3436
+ * @param key
3437
+ * @param customStore Method to get a custom store. Use with caution (see the docs).
3438
+ */
3439
+ function get(key, customStore = defaultGetStore()) {
3440
+ return customStore("readonly", (store) => promisifyRequest(store.get(key)));
3441
+ }
3442
+ /**
3443
+ * Set a value with a key.
3444
+ *
3445
+ * @param key
3446
+ * @param value
3447
+ * @param customStore Method to get a custom store. Use with caution (see the docs).
3448
+ */
3449
+ function set(key, value, customStore = defaultGetStore()) {
3450
+ return customStore("readwrite", (store) => {
3451
+ store.put(value, key);
3452
+ return promisifyRequest(store.transaction);
3453
+ });
3454
+ }
3455
+ //#endregion
3456
+ //#region src/core/deadline.ts
3457
+ /**
3458
+ * Settles like `promise` if it settles before `deadline` (a `Date.now()`
3459
+ * value). Otherwise calls `onTimeout`: an `Error` it returns rejects, any
3460
+ * other value resolves. Internal; not re-exported.
3461
+ */
3462
+ function withDeadline(deadline, promise, onTimeout) {
3463
+ return new Promise((resolve, reject) => {
3464
+ const timer = setTimeout(() => {
3465
+ const outcome = onTimeout();
3466
+ if (outcome instanceof Error) reject(outcome);
3467
+ else resolve(outcome);
3468
+ }, Math.max(0, deadline - Date.now()));
3469
+ promise.then((value) => {
3470
+ clearTimeout(timer);
3471
+ resolve(value);
3472
+ }, (error) => {
3473
+ clearTimeout(timer);
3474
+ reject(error);
3475
+ });
3476
+ });
3477
+ }
3478
+ //#endregion
3479
+ //#region src/core/data-calls.ts
3480
+ const TRANSFER = Symbol.for("webrun-http-browser:transfer");
3481
+ /**
3482
+ * A channel call's result that carries transferables, a `MessagePort` say:
3483
+ * return `withTransfer({ port }, [port])` from a `handleChannelCalls` handler
3484
+ * and `callChannel` resolves with `{ port }`.
3485
+ */
3486
+ function withTransfer(value, transfer) {
3487
+ return {
3488
+ [TRANSFER]: true,
3489
+ value,
3490
+ transfer
3491
+ };
3492
+ }
3493
+ function isTransferResult(value) {
3494
+ return typeof value === "object" && value !== null && TRANSFER in value;
3495
+ }
3496
+ /** Splits a handler's result into the value to post and its transfer list. */
3497
+ function unwrapTransfer(result) {
3498
+ return isTransferResult(result) ? {
3499
+ value: result.value,
3500
+ transfer: result.transfer
3501
+ } : {
3502
+ value: result,
3503
+ transfer: []
3504
+ };
3505
+ }
3506
+ function callChannel(target, callType, params, ...transfers) {
3507
+ return new Promise((resolve, reject) => {
3508
+ const channel = new MessageChannel();
3509
+ channel.port1.onmessage = (ev) => {
3510
+ const { result, error } = ev.data;
3511
+ if (error) reject(deserializeError(error));
3512
+ else resolve(result);
3513
+ };
3514
+ target.postMessage({
3515
+ type: callType,
3516
+ params
3517
+ }, [channel.port2, ...transfers]);
3518
+ });
3519
+ }
3520
+ function handleChannelCalls(target, callType, handler) {
3521
+ const listener = async (event) => {
3522
+ if (!event.data || event.data.type !== callType) return;
3523
+ const [port, ...transfers] = event.ports ?? [];
3524
+ const response = {};
3525
+ let transfer = [];
3526
+ try {
3527
+ const result = await handler(event, event.data.params, ...transfers);
3528
+ ({value: response.result, transfer} = unwrapTransfer(result));
3529
+ } catch (error) {
3530
+ response.error = serializeError(error);
3531
+ }
3532
+ port?.postMessage(response, transfer);
3533
+ };
3534
+ target.addEventListener("message", listener);
3535
+ target.start?.();
3536
+ return () => target.removeEventListener("message", listener);
3537
+ }
3538
+ /**
3539
+ * `callChannel`, failing after `ms` instead of waiting for ever: a peer that
3540
+ * died (a ServiceWorker stopped mid-call) or that does not know `callType` (an
3541
+ * older version) never answers.
3542
+ */
3543
+ function callChannelWithin(ms, target, callType, params, ...transfers) {
3544
+ return withDeadline(Date.now() + ms, callChannel(target, callType, params, ...transfers), () => /* @__PURE__ */ new Error(`No answer to ${callType} within ${ms} ms`));
3545
+ }
3546
+ //#endregion
3547
+ //#region src/core/service-worker-control.ts
3548
+ /**
3549
+ * How long, by default, the page waits for its ServiceWorker to activate and
3550
+ * to take control before giving up. Generous: a first install downloads and
3551
+ * evaluates the worker script, which on a slow link takes seconds.
3552
+ */
3553
+ const DEFAULT_SERVICE_WORKER_TIMEOUT = 3e4;
3554
+ /**
3555
+ * How long the page waits for `controllerchange` once the worker has
3556
+ * answered the `CLAIM` request. `clients.claim()` resolves only after the
3557
+ * browser has queued that event, so this is a grace period for delivery, not
3558
+ * a second budget.
3559
+ */
3560
+ const CLAIM_GRACE_MS = 1e3;
3561
+ /** Channel call a page sends to ask its ServiceWorker to `clients.claim()` it. */
3562
+ const CLAIM_CALL = "CLAIM";
3563
+ /**
3564
+ * Why a page could not get a working ServiceWorker. `reason` says which wait
3565
+ * failed; check it (or `name`) rather than `instanceof`, because each of this
3566
+ * package's bundles carries its own copy of this class.
3567
+ */
3568
+ var ServiceWorkerControlError = class extends Error {
3569
+ reason;
3570
+ constructor(reason, message) {
3571
+ super(message);
3572
+ this.name = "ServiceWorkerControlError";
3573
+ this.reason = reason;
3574
+ }
3575
+ };
3576
+ /**
3577
+ * Resolves with the registration's worker once it is `activated`. Rejects
3578
+ * with a `ServiceWorkerControlError` (`reason: "activation-timeout"`) if that
3579
+ * takes longer than `timeout` — an install that throws or never finishes
3580
+ * would otherwise leave the caller waiting forever.
3581
+ */
3582
+ async function awaitActiveServiceWorker(registration, { timeout = DEFAULT_SERVICE_WORKER_TIMEOUT } = {}) {
3583
+ return await withDeadline(Date.now() + timeout, waitForActivated(registration), () => {
3584
+ const worker = registration.installing ?? registration.waiting ?? registration.active;
3585
+ return new ServiceWorkerControlError("activation-timeout", `ServiceWorker ${scriptUrl(worker)} (scope ${registration.scope}) did not activate within ${timeout} ms` + (worker ? `; it is "${worker.state}"` : "") + ". Check the worker script for errors during install (DevTools → Application → Service Workers), or raise the `timeout` option.");
3586
+ });
3587
+ }
3588
+ /**
3589
+ * Resolves with the ServiceWorker that controls this page, once `registration`
3590
+ * has an activated worker and the page is controlled by it.
3591
+ *
3592
+ * A page can stay uncontrolled while its worker is active, and then no
3593
+ * `controllerchange` ever fires on its own:
3594
+ * - a hard reload (Ctrl+Shift+R) bypasses the worker for that load, and the
3595
+ * worker's `clients.claim()` already ran when it activated;
3596
+ * - Firefox can leave a page loaded while the worker is running uncontrolled.
3597
+ *
3598
+ * So when the page is uncontrolled, this asks the active worker to claim it
3599
+ * again (a `CLAIM` channel call — this package's workers answer it) and waits
3600
+ * for `controllerchange`. Every wait is bounded by `timeout`. If control never
3601
+ * comes it reloads once (`reloadIfUncontrolled`) or rejects with a
3602
+ * `ServiceWorkerControlError` (`reason: "uncontrolled"`) that says what
3603
+ * happened and what to do.
3604
+ */
3605
+ async function awaitServiceWorkerControl(registration, { timeout = DEFAULT_SERVICE_WORKER_TIMEOUT, reloadIfUncontrolled = false, container = navigator.serviceWorker } = {}) {
3606
+ const deadline = Date.now() + timeout;
3607
+ const controlled = waitForController(container);
3608
+ try {
3609
+ const active = await awaitActiveServiceWorker(registration, { timeout });
3610
+ let controller = container.controller;
3611
+ if (!controller) controller = await withDeadline(deadline, (async () => {
3612
+ await Promise.race([callChannel(active, CLAIM_CALL, {}), controlled.promise]);
3613
+ return await Promise.race([controlled.promise, delay(CLAIM_GRACE_MS, null)]);
3614
+ })(), () => null);
3615
+ if (controller) {
3616
+ forgetReload(registration);
3617
+ return controller;
3618
+ }
3619
+ if (reloadIfUncontrolled && markReload(registration)) {
3620
+ location.reload();
3621
+ return await new Promise(() => {});
3622
+ }
3623
+ forgetReload(registration);
3624
+ throw new ServiceWorkerControlError("uncontrolled", `This page is not controlled by its ServiceWorker ${scriptUrl(active)} (scope ${registration.scope}), although the worker is active, and the worker did not take control when asked (clients.claim()) within ${timeout} ms. This happens after a hard reload (Ctrl+Shift+R / Cmd+Shift+R), which bypasses ServiceWorkers for that load, and in Firefox for some pages opened while the worker was already running. Requests from this page would not reach the worker. Reload the page normally, pass \`reloadIfUncontrolled: true\` to do that automatically, check that the page is inside the worker's scope, and that the worker answers the "${CLAIM_CALL}" request (this package's workers do).`);
3625
+ } finally {
3626
+ controlled.cancel();
3627
+ }
3628
+ }
3629
+ /**
3630
+ * ServiceWorker side: answers the page's `CLAIM` request with
3631
+ * `clients.claim()`, which takes over every uncontrolled client in scope.
3632
+ * Returns a function that stops answering.
3633
+ */
3634
+ function handleClaimRequests(self) {
3635
+ return handleChannelCalls(self, CLAIM_CALL, (event) => {
3636
+ const claimed = self.clients.claim().then(() => true);
3637
+ event.waitUntil?.(claimed);
3638
+ return claimed;
3639
+ });
3640
+ }
3641
+ function waitForActivated(registration) {
3642
+ return new Promise((resolve) => {
3643
+ const watched = /* @__PURE__ */ new Set();
3644
+ const check = () => {
3645
+ const active = registration.active;
3646
+ if (active?.state === "activated") {
3647
+ registration.removeEventListener("updatefound", watch);
3648
+ for (const worker of watched) worker.removeEventListener("statechange", check);
3649
+ resolve(active);
3650
+ return;
3651
+ }
3652
+ watch();
3653
+ };
3654
+ function watch() {
3655
+ for (const worker of [
3656
+ registration.installing,
3657
+ registration.waiting,
3658
+ registration.active
3659
+ ]) {
3660
+ if (!worker || watched.has(worker)) continue;
3661
+ watched.add(worker);
3662
+ worker.addEventListener("statechange", check);
3663
+ }
3664
+ }
3665
+ registration.addEventListener("updatefound", watch);
3666
+ check();
3667
+ });
3668
+ }
3669
+ function waitForController(container) {
3670
+ let cancel = () => {};
3671
+ return {
3672
+ promise: new Promise((resolve) => {
3673
+ const onChange = () => {
3674
+ if (!container.controller) return;
1711
3675
  cancel();
1712
3676
  resolve(container.controller);
1713
3677
  };
1714
- cancel = () => container.removeEventListener("controllerchange", onChange);
1715
- container.addEventListener("controllerchange", onChange);
1716
- }),
1717
- cancel
3678
+ cancel = () => container.removeEventListener("controllerchange", onChange);
3679
+ container.addEventListener("controllerchange", onChange);
3680
+ }),
3681
+ cancel
3682
+ };
3683
+ }
3684
+ function delay(ms, value) {
3685
+ return new Promise((resolve) => setTimeout(() => resolve(value), ms));
3686
+ }
3687
+ function scriptUrl(worker) {
3688
+ return worker?.scriptURL ? `"${worker.scriptURL}"` : "(no worker)";
3689
+ }
3690
+ function reloadKey(registration) {
3691
+ return `webrun-http-browser:reloaded-uncontrolled:${registration.scope}`;
3692
+ }
3693
+ /** Records the reload about to happen. `false` if one already happened, or if it cannot be recorded. */
3694
+ function markReload(registration) {
3695
+ try {
3696
+ const key = reloadKey(registration);
3697
+ if (sessionStorage.getItem(key)) return false;
3698
+ sessionStorage.setItem(key, "1");
3699
+ return true;
3700
+ } catch {
3701
+ return false;
3702
+ }
3703
+ }
3704
+ function forgetReload(registration) {
3705
+ try {
3706
+ sessionStorage.removeItem(reloadKey(registration));
3707
+ } catch {}
3708
+ }
3709
+ //#endregion
3710
+ //#region src/core/data-channels.ts
3711
+ const MESSAGE_TYPE_REQUEST = "REQUEST";
3712
+ const MESSAGE_TYPE_RESPONSE = "RESPONSE";
3713
+ let __invocationCounter = 0;
3714
+ function newInvokationChannel({ port, handler = () => {
3715
+ throw new Error("Handler not implemented");
3716
+ }, onError = console.error, newCallId = () => ++__invocationCounter }) {
3717
+ const requests = {};
3718
+ const listener = async (event) => {
3719
+ const data = event.data ?? {};
3720
+ if (data.type === MESSAGE_TYPE_REQUEST) try {
3721
+ const result = await handler(data.request, ...event.ports);
3722
+ const [response, ...transfers] = Array.isArray(result) ? result : result !== void 0 ? [result] : [];
3723
+ port.postMessage({
3724
+ type: MESSAGE_TYPE_RESPONSE,
3725
+ callId: data.callId,
3726
+ response
3727
+ }, transfers);
3728
+ } catch (error) {
3729
+ port.postMessage({
3730
+ type: MESSAGE_TYPE_RESPONSE,
3731
+ callId: data.callId,
3732
+ error: serializeError(error)
3733
+ });
3734
+ }
3735
+ else if (data.type === MESSAGE_TYPE_RESPONSE) {
3736
+ const pending = requests[data.callId];
3737
+ delete requests[data.callId];
3738
+ if (!pending) return;
3739
+ if (data.error) pending.reject(deserializeError(data.error));
3740
+ else pending.resolve(data.response);
3741
+ }
3742
+ };
3743
+ const start = async () => {
3744
+ try {
3745
+ port.addEventListener("message", listener);
3746
+ await port.start?.();
3747
+ } catch (e) {
3748
+ onError(e);
3749
+ }
3750
+ };
3751
+ const close = async () => {
3752
+ try {
3753
+ port.removeEventListener("message", listener);
3754
+ await port.close?.();
3755
+ } catch (e) {
3756
+ onError(e);
3757
+ }
3758
+ };
3759
+ const invoke = (request = {}, ...transfers) => {
3760
+ const callId = newCallId();
3761
+ return new Promise((resolve, reject) => {
3762
+ try {
3763
+ requests[callId] = {
3764
+ resolve,
3765
+ reject
3766
+ };
3767
+ port.postMessage({
3768
+ type: MESSAGE_TYPE_REQUEST,
3769
+ callId,
3770
+ request
3771
+ }, transfers);
3772
+ } catch (error) {
3773
+ delete requests[callId];
3774
+ reject(error);
3775
+ }
3776
+ });
3777
+ };
3778
+ return {
3779
+ start,
3780
+ close,
3781
+ invoke
3782
+ };
3783
+ }
3784
+ async function* sendStream(communicationPort, input, params = {}) {
3785
+ const messageChannel = new MessageChannel();
3786
+ communicationPort.postMessage({
3787
+ type: "START_CALL",
3788
+ params
3789
+ }, [messageChannel.port2]);
3790
+ const channel = newStreamChannel(messageChannel.port1);
3791
+ let drained = false;
3792
+ try {
3793
+ await channel.start();
3794
+ channel.sendAll(input);
3795
+ yield* channel.recieveAll();
3796
+ drained = true;
3797
+ } finally {
3798
+ if (!drained) channel.cancel();
3799
+ await channel.close();
3800
+ }
3801
+ }
3802
+ function newStreamChannel(port) {
3803
+ let listeners = [];
3804
+ let iterators = [];
3805
+ const notifyAll = async (data) => {
3806
+ for (const listener of listeners) await listener(data);
3807
+ };
3808
+ /**
3809
+ * Release whatever we are sending. NOT awaited: `.return()` on an async
3810
+ * generator parked awaiting its own source is queued behind that pending
3811
+ * `next()`, so awaiting it here would block the message handler — and the
3812
+ * chunk that would unblock it can only arrive through that same handler.
3813
+ */
3814
+ const cancelOutgoing = () => {
3815
+ for (const it of [...iterators]) {
3816
+ const iterable = it;
3817
+ Promise.resolve(iterable.return?.()).catch(() => {});
3818
+ }
3819
+ };
3820
+ const channel = newInvokationChannel({
3821
+ port,
3822
+ handler: (data) => {
3823
+ const message = data;
3824
+ if (message?.cancel) {
3825
+ cancelOutgoing();
3826
+ return;
3827
+ }
3828
+ return notifyAll(message);
3829
+ }
3830
+ });
3831
+ const start = () => channel.start();
3832
+ const cancel = () => {
3833
+ channel.invoke({ cancel: true }).catch(() => {});
3834
+ };
3835
+ const close = async () => {
3836
+ await notifyAll({ done: true });
3837
+ for (const it of [...iterators]) await it.return?.();
3838
+ await channel.close();
3839
+ };
3840
+ async function* recieveAll() {
3841
+ yield* recieveIterator((deliver) => {
3842
+ listeners.push(deliver);
3843
+ return () => {
3844
+ listeners = listeners.filter((l) => l !== deliver);
3845
+ };
3846
+ });
3847
+ }
3848
+ async function sendAll(it) {
3849
+ await sendIterator((chunk) => void channel.invoke(chunk), (async function* () {
3850
+ try {
3851
+ iterators.push(it);
3852
+ yield* it;
3853
+ } finally {
3854
+ iterators = iterators.filter((i) => i !== it);
3855
+ }
3856
+ })());
3857
+ }
3858
+ return {
3859
+ start,
3860
+ close,
3861
+ cancel,
3862
+ recieveAll,
3863
+ sendAll
1718
3864
  };
1719
3865
  }
1720
- function delay(ms, value) {
1721
- return new Promise((resolve) => setTimeout(() => resolve(value), ms));
3866
+ //#endregion
3867
+ //#region src/http/http-send-recieve.ts
3868
+ async function* httpToIterator(envelopeOrPromise) {
3869
+ const { options, content } = await envelopeOrPromise;
3870
+ yield new TextEncoder().encode(JSON.stringify(options));
3871
+ yield* content;
1722
3872
  }
1723
- function scriptUrl(worker) {
1724
- return worker?.scriptURL ? `"${worker.scriptURL}"` : "(no worker)";
3873
+ async function httpFromIterator(iterable) {
3874
+ const it = (await iterable)[Symbol.asyncIterator]();
3875
+ const { done, value } = await it.next();
3876
+ let options = {};
3877
+ if (!done && value) {
3878
+ const str = new TextDecoder().decode(value);
3879
+ options = JSON.parse(str);
3880
+ }
3881
+ return {
3882
+ options,
3883
+ content: { [Symbol.asyncIterator]() {
3884
+ return it;
3885
+ } }
3886
+ };
1725
3887
  }
1726
- function reloadKey(registration) {
1727
- return `webrun-http-browser:reloaded-uncontrolled:${registration.scope}`;
3888
+ /**
3889
+ * Ship a `Request` over a `MessageTarget` and await the `Response`, using this
3890
+ * package's own `sendStream` transport.
3891
+ *
3892
+ * @deprecated Prefer the port stack in `@statewalker/webrun-rpc`: open a port
3893
+ * (`multiplexPort` over one pipe, or `transferPortMux` where the platform can
3894
+ * transfer a real `MessagePort`), turn it into a `Duplex` with
3895
+ * `duplexOverPort`, and drive HTTP over it with `httpFetch` from
3896
+ * `@statewalker/webrun-http-streams`.
3897
+ *
3898
+ * Same caveat as {@link handleHttpRequests}: the transport underneath this
3899
+ * helper has no backpressure, no per-stream timeout, and no chunking to a
3900
+ * transport's message ceiling. Kept for existing ServiceWorker setups.
3901
+ */
3902
+ async function sendHttpRequest(communicationPort, request) {
3903
+ return await newHttpClientStub(async (req) => {
3904
+ return await httpFromIterator(sendStream(communicationPort, httpToIterator(req)));
3905
+ })(request);
1728
3906
  }
1729
- /** Records the reload about to happen. `false` if one already happened, or if it cannot be recorded. */
1730
- function markReload(registration) {
1731
- try {
1732
- const key = reloadKey(registration);
1733
- if (sessionStorage.getItem(key)) return false;
1734
- sessionStorage.setItem(key, "1");
1735
- return true;
1736
- } catch {
1737
- return false;
3907
+ //#endregion
3908
+ //#region src/sw/protocol.ts
3909
+ /**
3910
+ * The messages between pages, Dedicated Workers and the same-origin
3911
+ * ServiceWorker. Internal: the shapes may change between versions, which is
3912
+ * why every hand-shake carries `HTTP_LINK_PROTOCOL`.
3913
+ *
3914
+ * THREE PORTS PER HANDLER LINK. A page makes the channels and hands one end of
3915
+ * each to the handler (itself, or a worker) and the other to the
3916
+ * ServiceWorker; after that it carries nothing:
3917
+ *
3918
+ * - `down`: ServiceWorker → handler, the requests the handler serves;
3919
+ * - `ctl`: a raw port next to them, for what HTTP over a stream cannot carry,
3920
+ * transferred `MessagePort`s (WebSocket hand-over);
3921
+ * - `up` (workers only): handler → ServiceWorker, the requests the handler
3922
+ * sends through the router (to other handlers).
3923
+ */
3924
+ /** Page → ServiceWorker: attach a handler link. The name is the one version 1 used. */
3925
+ const ATTACH_CALL = "UPDATE_COMMUNICATION_PORT";
3926
+ /** Page → ServiceWorker: remove a handler link now. */
3927
+ const DETACH_CALL = "webrun-http:detach";
3928
+ /** Page → ServiceWorker: stop routing to a link, let its requests finish, then remove it. */
3929
+ const DRAIN_CALL = "webrun-http:drain";
3930
+ /** Page → ServiceWorker: open a WebSocket to a handler. */
3931
+ const WS_CONNECT_CALL = "webrun-http:ws-connect";
3932
+ /** ServiceWorker → pages: attach your links again (the ServiceWorker restarted). */
3933
+ const NEED_LINKS_CALL = "webrun-http:need-links";
3934
+ /**
3935
+ * Page → ServiceWorker, plain messages (no reply). A message to the
3936
+ * ServiceWorker object is an event: it starts a stopped worker and resets its
3937
+ * idle timer. Port messages do neither.
3938
+ */
3939
+ const WAKE_MESSAGE = "webrun-http:wake";
3940
+ const KEEPALIVE_MESSAGE = "webrun-http:keepalive";
3941
+ /** Page ↔ worker: what `attachWorker` and `serveHttpInWorker` exchange. */
3942
+ const WORKER_HELLO = "webrun-http:worker-hello";
3943
+ //#endregion
3944
+ //#region src/sw/http-sw-dispatcher.ts
3945
+ /** One attached handler link, as the ServiceWorker holds it. */
3946
+ var HandlerEntry = class {
3947
+ key;
3948
+ id;
3949
+ owner;
3950
+ meta;
3951
+ link;
3952
+ ctl;
3953
+ clientId;
3954
+ ownerLock;
3955
+ draining = false;
3956
+ attachedAt = Date.now();
3957
+ sockets;
3958
+ stopWatch = () => {};
3959
+ closeUp;
3960
+ constructor(key, id, owner, meta, link, ctl, clientId, ownerLock) {
3961
+ this.key = key;
3962
+ this.id = id;
3963
+ this.owner = owner;
3964
+ this.meta = meta;
3965
+ this.link = link;
3966
+ this.ctl = ctl;
3967
+ this.clientId = clientId;
3968
+ this.ownerLock = ownerLock;
3969
+ this.sockets = new PortRendezvous(`s:${id}`);
3970
+ }
3971
+ get inflight() {
3972
+ return this.link.inflight;
3973
+ }
3974
+ };
3975
+ /** The reason requests fail with when their handler dies under them. */
3976
+ const HANDLER_LOST_MESSAGE = "The handler was lost while this request was in flight";
3977
+ const TIMED_OUT = Symbol("timed-out");
3978
+ /**
3979
+ * The same-origin ServiceWorker: routes `fetch` events under its scope to
3980
+ * handler links that pages attach — handlers in the pages themselves
3981
+ * (`SwHttpAdapter.register`) or in Dedicated Workers
3982
+ * (`SwHttpAdapter.attachWorker` + `serveHttpInWorker`).
3983
+ *
3984
+ * A url `<scope>/<key>/...` belongs to the handlers of `key`. Of those, the
3985
+ * `route` option picks one per request. Requests and responses stream with
3986
+ * backpressure in both directions, and an aborted fetch reaches the handler
3987
+ * as a cancellation (webrun-rpc + webrun-http-streams).
3988
+ *
3989
+ * Urls of keys this worker never served go to the network untouched: the
3990
+ * worker does not call `respondWith` for them.
3991
+ *
3992
+ * RESTARTS. The browser stops an idle ServiceWorker, and every port it held
3993
+ * dies with it. Nothing is persisted but the set of keys ever served: the
3994
+ * first request after a restart asks every window to attach its links again
3995
+ * (`NEED_LINKS`) and waits for them.
3996
+ *
3997
+ * LIVENESS. Each handler holds an owner lock; when it is granted to this
3998
+ * worker, the handler is gone and its requests in flight fail at once (502).
3999
+ * This worker holds a router lock that handlers watch the same way.
4000
+ */
4001
+ var SwHttpDispatcher = class {
4002
+ self;
4003
+ log;
4004
+ options;
4005
+ table = new HandlerTable();
4006
+ /**
4007
+ * Keys that were served at some point on this worker (sticky, kept in
4008
+ * IndexedDB). Lets a fetch tell "a site url whose handler is gone → 404"
4009
+ * from "an unknown url under the scope → the network".
4010
+ */
4011
+ claimedKeys = /* @__PURE__ */ new Set();
4012
+ /** The Web Lock this worker holds while it lives; handlers watch it. */
4013
+ routerLock;
4014
+ _routerHeld = Promise.resolve();
4015
+ _restored = Promise.resolve();
4016
+ _restoreSettled = false;
4017
+ _cleanup = [];
4018
+ _livenessTimer;
4019
+ _relinkRounds = /* @__PURE__ */ new Map();
4020
+ /** Keys whose last handler was drained: until the time stored, requests wait for the next. */
4021
+ _vacant = /* @__PURE__ */ new Map();
4022
+ constructor(options) {
4023
+ this.options = options;
4024
+ this.self = options.self;
4025
+ this.log = options.log ?? (() => {});
4026
+ if (locksAvailable()) this.routerLock = newLockName("webrun-http-router");
4027
+ }
4028
+ get scope() {
4029
+ return `${new URL("./", this.self.location.href)}`;
4030
+ }
4031
+ /** The key `url` belongs to: its first path segment under the scope. */
4032
+ keyOf(url) {
4033
+ const href = `${url}`;
4034
+ const scope = this.scope;
4035
+ if (!href.startsWith(scope)) return void 0;
4036
+ const key = href.substring(scope.length).replace(/[?#].*$/, "").split("/")[0];
4037
+ return key ? decodeURIComponent(key) : void 0;
4038
+ }
4039
+ /** Whether `key` has ever been served on this worker. */
4040
+ isClaimedKey(key) {
4041
+ return this.claimedKeys.has(key);
4042
+ }
4043
+ /** Whether requests for `key` are this worker's to answer. */
4044
+ serves(key) {
4045
+ return this.claimedKeys.has(key) || this.table.all(key).length > 0;
4046
+ }
4047
+ start() {
4048
+ const self = this.self;
4049
+ if (this.routerLock) this._routerHeld = holdLock(this.routerLock).then(() => {});
4050
+ this._restored = this._restoreClaimedKeys();
4051
+ const latch = () => {
4052
+ this._restoreSettled = true;
4053
+ };
4054
+ this._restored.then(latch, latch);
4055
+ this._cleanup.push(handleClaimRequests(self), handleChannelCalls(self, ATTACH_CALL, (event, params, ...ports) => this._attach(event, params, ports)), handleChannelCalls(self, DETACH_CALL, (_event, params) => {
4056
+ const entry = this._find(params);
4057
+ if (!entry) return false;
4058
+ this._close(entry, new LinkLostError("The handler was detached"));
4059
+ return true;
4060
+ }), handleChannelCalls(self, DRAIN_CALL, async (_event, params) => {
4061
+ const entry = this._find(params);
4062
+ if (!entry) return false;
4063
+ this._vacant.set(entry.key, Date.now() + (this.options.attachTimeoutMs ?? 15e3));
4064
+ await this._retire(entry, params.ms ?? this.options.handoverMs ?? 5e3);
4065
+ return true;
4066
+ }), handleChannelCalls(self, WS_CONNECT_CALL, async (event, params) => {
4067
+ if (!this._isOwnClient(event)) throw new Error("Only this origin's pages may open sockets");
4068
+ const { url, protocols } = params;
4069
+ const connection = await this.connectWebSocket(url, protocols ?? []);
4070
+ return withTransfer(connection, [connection.port]);
4071
+ }));
4072
+ const onMessage = (event) => {
4073
+ const type = event.data?.type;
4074
+ if (type === "webrun-http:wake") event.waitUntil(this._askForLinks());
4075
+ else if (type === "webrun-http:keepalive") event.waitUntil(Promise.resolve());
4076
+ };
4077
+ self.addEventListener("message", onMessage);
4078
+ this._cleanup.push(() => self.removeEventListener("message", onMessage));
4079
+ const onInstall = (event) => {
4080
+ this.log("Skip waiting on install.");
4081
+ event.waitUntil(self.skipWaiting());
4082
+ };
4083
+ const onActivate = (event) => {
4084
+ this.log("Claim control over all clients.");
4085
+ event.waitUntil(self.clients.claim());
4086
+ };
4087
+ const onFetch = (event) => this._onFetch(event);
4088
+ self.addEventListener("install", onInstall);
4089
+ self.addEventListener("activate", onActivate);
4090
+ self.addEventListener("fetch", onFetch);
4091
+ this._cleanup.push(() => {
4092
+ self.removeEventListener("install", onInstall);
4093
+ self.removeEventListener("activate", onActivate);
4094
+ self.removeEventListener("fetch", onFetch);
4095
+ });
4096
+ }
4097
+ async stop() {
4098
+ for (const cleanup of this._cleanup.splice(0)) cleanup();
4099
+ if (this._livenessTimer) clearInterval(this._livenessTimer);
4100
+ this._livenessTimer = void 0;
4101
+ for (const entry of this.table.entries()) this._close(entry, new LinkLostError("The ServiceWorker dispatcher stopped"));
4102
+ }
4103
+ /**
4104
+ * Answers `request` as a fetch under the scope would be: through the
4105
+ * handler its key routes to. A url outside the scope, or of a key this
4106
+ * worker never served, goes to the network.
4107
+ */
4108
+ async dispatch(request) {
4109
+ const key = this.keyOf(request.url);
4110
+ if (key === void 0) return await fetch(request);
4111
+ await this._restored.catch(() => {});
4112
+ const entry = await this._acquire(request, key);
4113
+ if (entry === TIMED_OUT) return new Response(null, {
4114
+ status: 503,
4115
+ statusText: "Service Unavailable (no handler attached in time)"
4116
+ });
4117
+ if (!entry) {
4118
+ if (!this.serves(key)) return await fetch(request);
4119
+ return new Response(null, {
4120
+ status: 404,
4121
+ statusText: "Not Found (no active handler)"
4122
+ });
4123
+ }
4124
+ try {
4125
+ return await entry.link.send(request);
4126
+ } catch (error) {
4127
+ this.log("[dispatch] failed", key, error);
4128
+ return new Response(errorMessage(error), {
4129
+ status: 502,
4130
+ statusText: "Bad Gateway",
4131
+ headers: { "content-type": "text/plain; charset=utf-8" }
4132
+ });
4133
+ }
4134
+ }
4135
+ /**
4136
+ * Opens a socket to the handler `url` routes to: the handler accepts or
4137
+ * refuses it like an HTTP upgrade, and the socket's port comes back. After
4138
+ * that the socket runs between the caller and the handler directly; this
4139
+ * worker is not on its data path.
4140
+ */
4141
+ async connectWebSocket(url, protocols = [], headers) {
4142
+ const request = newWebSocketUpgradeRequest(url, {
4143
+ protocols,
4144
+ headers
4145
+ });
4146
+ const key = this.keyOf(request.url);
4147
+ if (key === void 0) throw new Error(`${url} is outside this ServiceWorker's scope`);
4148
+ await this._restored.catch(() => {});
4149
+ const entry = await this._acquire(request, key);
4150
+ if (!entry || entry === TIMED_OUT) throw new Error(`No handler serves ${url}`);
4151
+ if (!entry.ctl) throw new Error(`The handler of ${url} does not accept sockets`);
4152
+ const id = entry.sockets.newId();
4153
+ const answer = entry.sockets.wait(id, this.options.wsConnectTimeoutMs ?? 15e3, `the socket request for ${url}`);
4154
+ entry.ctl.postMessage({
4155
+ t: "ws-connect",
4156
+ id,
4157
+ url: request.url,
4158
+ protocols,
4159
+ headers
4160
+ });
4161
+ return await answer;
4162
+ }
4163
+ _onFetch(event) {
4164
+ const request = event.request;
4165
+ const key = this.keyOf(request.url);
4166
+ if (key === void 0) return;
4167
+ if (this._restoreSettled) {
4168
+ if (this.serves(key)) event.respondWith(this.dispatch(request));
4169
+ else if (request.mode === "navigate") event.respondWith(fetch(request));
4170
+ return;
4171
+ }
4172
+ event.respondWith((async () => {
4173
+ await this._restored.catch(() => {});
4174
+ if (!this.serves(key)) return await fetch(request);
4175
+ return await this.dispatch(request);
4176
+ })());
4177
+ }
4178
+ async _attach(event, params, ports) {
4179
+ const clientId = event.source?.id;
4180
+ const [down, ...rest] = ports;
4181
+ if (!down) throw new Error("An attach carries at least one port");
4182
+ this.log("[attach]", clientId, params);
4183
+ if (params.wire === void 0) {
4184
+ const link = trackedLink((request) => sendHttpRequest(down, request), () => down.close());
4185
+ const id = clientId ?? crypto.randomUUID();
4186
+ this._add(new HandlerEntry(params.key, id, id, void 0, link, void 0, clientId, void 0));
4187
+ return { ...params };
4188
+ }
4189
+ if (params.wire !== "webrun-http-link/2") {
4190
+ for (const port of ports) port.close();
4191
+ throw new Error(`This ServiceWorker speaks ${HTTP_LINK_PROTOCOL}, the page ${params.wire}. Reload the page so that both run the same version of @statewalker/webrun-http-browser.`);
4192
+ }
4193
+ const ctl = params.ctl ? rest.shift() : void 0;
4194
+ const up = params.up ? rest.shift() : void 0;
4195
+ const streamTimeout = this.options.streamTimeout;
4196
+ const link = await connectHttpLink(down, { streamTimeout });
4197
+ const entry = new HandlerEntry(params.key, params.id ?? clientId ?? crypto.randomUUID(), params.owner ?? crypto.randomUUID(), params.meta, link, ctl, clientId, params.ownerLock);
4198
+ if (up) entry.closeUp = await serveHttpLink(up, (request) => this.dispatch(request), { streamTimeout });
4199
+ if (ctl) ctl.onmessage = (message) => this._onControl(entry, message.data);
4200
+ if (params.ownerLock) entry.stopWatch = watchLock(params.ownerLock, () => {
4201
+ this.log("[owner lost]", entry.key, entry.id);
4202
+ this._close(entry, new LinkLostError(HANDLER_LOST_MESSAGE));
4203
+ });
4204
+ this._add(entry);
4205
+ await this._routerHeld;
4206
+ return {
4207
+ accepted: HTTP_LINK_PROTOCOL,
4208
+ routerLock: this.routerLock
4209
+ };
4210
+ }
4211
+ _add(entry) {
4212
+ this._vacant.delete(entry.key);
4213
+ const replaced = this.table.add(entry);
4214
+ if (replaced && replaced !== entry) this._retire(replaced, this.options.handoverMs ?? 5e3, false);
4215
+ this._claim(entry.key);
4216
+ if (entry.ownerLock) this._watchLiveness();
4217
+ }
4218
+ /**
4219
+ * The backup of the owner-lock watches: every `livenessCheckMs`, drops the
4220
+ * handlers whose owner lock is no longer held. Runs only while some handler
4221
+ * has an owner lock, and keeps nothing awake: a timer is not an event.
4222
+ */
4223
+ _watchLiveness() {
4224
+ const period = this.options.livenessCheckMs ?? 5e3;
4225
+ if (this._livenessTimer || period <= 0) return;
4226
+ this._livenessTimer = setInterval(async () => {
4227
+ const watched = this.table.entries().filter((entry) => entry.ownerLock);
4228
+ if (watched.length === 0) {
4229
+ clearInterval(this._livenessTimer);
4230
+ this._livenessTimer = void 0;
4231
+ return;
4232
+ }
4233
+ const held = await heldLockNames();
4234
+ if (!held) return;
4235
+ for (const entry of watched) if (entry.ownerLock && !held.has(entry.ownerLock) && this.table.remove(entry)) {
4236
+ this.log("[owner gone]", entry.key, entry.id);
4237
+ this._close(entry, new LinkLostError(HANDLER_LOST_MESSAGE), false);
4238
+ }
4239
+ }, period);
4240
+ }
4241
+ /** The entry a drain or detach names, if its owner token still matches. */
4242
+ _find({ key, id, owner }) {
4243
+ const entry = this.table.get(key, id);
4244
+ return entry && entry.owner === owner ? entry : void 0;
4245
+ }
4246
+ /**
4247
+ * Stops routing to `entry`, lets its requests in flight run for up to `ms`,
4248
+ * then closes it. Requests that arrive meanwhile wait for the next owner.
4249
+ */
4250
+ async _retire(entry, ms, inTable = true) {
4251
+ entry.draining = true;
4252
+ let timer;
4253
+ await Promise.race([entry.link.whenIdle(), new Promise((resolve) => {
4254
+ timer = setTimeout(resolve, ms);
4255
+ })]);
4256
+ clearTimeout(timer);
4257
+ this._close(entry, new LinkLostError("The handler was drained"), inTable);
4258
+ }
4259
+ _close(entry, reason, inTable = true) {
4260
+ if (inTable) this.table.remove(entry);
4261
+ entry.stopWatch();
4262
+ entry.sockets.failAll(reason);
4263
+ entry.link.close(reason);
4264
+ entry.closeUp?.().catch(() => {});
4265
+ if (entry.ctl) {
4266
+ entry.ctl.onmessage = null;
4267
+ entry.ctl.close();
4268
+ }
1738
4269
  }
4270
+ /**
4271
+ * The handler for `request`: picked by `route` among the live handlers of
4272
+ * its key, waiting for one when there is none.
4273
+ */
4274
+ async _acquire(request, key) {
4275
+ const picked = await this._pick(request, key);
4276
+ if (picked) return picked;
4277
+ if (this.table.all(key).length > 0) return await this.table.waitFor(key, this.options.attachTimeoutMs ?? 15e3, this.table.live(key).length > 0) ? await this._pick(request, key) ?? TIMED_OUT : TIMED_OUT;
4278
+ const vacantUntil = this._vacant.get(key) ?? 0;
4279
+ if (vacantUntil > Date.now()) return await this.table.waitFor(key, vacantUntil - Date.now()) ? await this._pick(request, key) ?? TIMED_OUT : TIMED_OUT;
4280
+ if (!this.serves(key)) return void 0;
4281
+ await this._askForLinks(key);
4282
+ return await this._pick(request, key);
4283
+ }
4284
+ async _pick(request, key) {
4285
+ let live = this.table.live(key);
4286
+ for (const entry of live) {
4287
+ if (entry.ownerLock || !entry.clientId) continue;
4288
+ if (!await this.self.clients.get(entry.clientId)) this._close(entry, new LinkLostError(HANDLER_LOST_MESSAGE));
4289
+ }
4290
+ live = this.table.live(key);
4291
+ if (live.length === 0) return void 0;
4292
+ return (this.options.route ?? leastInflightRoute)(request, key, live);
4293
+ }
4294
+ /**
4295
+ * Asks every window to attach its links again, and waits until a handler
4296
+ * for `key` is back, every window answered, or `relinkTimeoutMs` passed.
4297
+ * At most one round per key every 10 s: a key whose tab is simply gone
4298
+ * should not cost every request the wait.
4299
+ */
4300
+ _askForLinks(key = "") {
4301
+ const round = this._relinkRounds.get(key);
4302
+ if (round && Date.now() - round.at < 1e4) return round.done;
4303
+ const done = this._linkRound(key);
4304
+ this._relinkRounds.set(key, {
4305
+ at: Date.now(),
4306
+ done
4307
+ });
4308
+ return done;
4309
+ }
4310
+ async _linkRound(key) {
4311
+ const timeout = this.options.relinkTimeoutMs ?? 3e3;
4312
+ const windows = await this.self.clients.matchAll({
4313
+ type: "window",
4314
+ includeUncontrolled: true
4315
+ });
4316
+ this.log("[need-links]", key, windows.length);
4317
+ const params = { routerLock: this.routerLock };
4318
+ const pending = new Set(windows);
4319
+ const answered = Promise.allSettled(windows.map((client) => callChannel(client, NEED_LINKS_CALL, params).then(() => pending.delete(client))));
4320
+ let timer;
4321
+ const timedOut = new Promise((resolve) => {
4322
+ timer = setTimeout(resolve, timeout);
4323
+ });
4324
+ await Promise.race([answered, timedOut]);
4325
+ clearTimeout(timer);
4326
+ for (const client of pending) this._askLegacyPage(client);
4327
+ }
4328
+ /** Asks a version-1 page for a fresh port, as version 1's worker did. */
4329
+ _askLegacyPage(client) {
4330
+ const channel = new MessageChannel();
4331
+ callChannel(client, ATTACH_CALL, {}, channel.port2).then((info) => {
4332
+ if (!info?.key || info.wire !== void 0) return;
4333
+ const link = trackedLink((request) => sendHttpRequest(channel.port1, request), () => channel.port1.close());
4334
+ this._add(new HandlerEntry(info.key, client.id, client.id, void 0, link, void 0, client.id, void 0));
4335
+ }, () => channel.port1.close());
4336
+ }
4337
+ _onControl(entry, message) {
4338
+ if (!message || typeof message !== "object") return;
4339
+ switch (message.t) {
4340
+ case "ws-accept":
4341
+ if (!entry.sockets.settle(message.id, {
4342
+ port: message.port,
4343
+ protocol: message.protocol
4344
+ })) message.port.close();
4345
+ return;
4346
+ case "ws-refuse":
4347
+ entry.sockets.fail(message.id, new Error(message.message));
4348
+ return;
4349
+ case "ws-open":
4350
+ this.connectWebSocket(message.url, message.protocols).then(({ port, protocol }) => entry.ctl?.postMessage({
4351
+ t: "ws-opened",
4352
+ id: message.id,
4353
+ protocol,
4354
+ port
4355
+ }, [port]), (error) => entry.ctl?.postMessage({
4356
+ t: "ws-failed",
4357
+ id: message.id,
4358
+ message: errorMessage(error)
4359
+ }));
4360
+ return;
4361
+ }
4362
+ }
4363
+ _isOwnClient(event) {
4364
+ const source = event.source;
4365
+ if (!source?.url) return false;
4366
+ try {
4367
+ return new URL(source.url).origin === this.self.location.origin;
4368
+ } catch {
4369
+ return false;
4370
+ }
4371
+ }
4372
+ async _claim(key) {
4373
+ if (!key || this.claimedKeys.has(key)) return;
4374
+ this.claimedKeys.add(key);
4375
+ try {
4376
+ await set("claimedKeys", [...this.claimedKeys].sort());
4377
+ } catch (error) {
4378
+ this.log("[claimedKeys] not stored", error);
4379
+ }
4380
+ }
4381
+ async _restoreClaimedKeys() {
4382
+ const stored = await get("claimedKeys") ?? [];
4383
+ for (const key of stored) this.claimedKeys.add(key);
4384
+ }
4385
+ };
4386
+ /**
4387
+ * Starts the same-origin dispatcher in a ServiceWorker. Returns a function
4388
+ * that stops it.
4389
+ */
4390
+ function startHttpDispatcher(options) {
4391
+ const dispatcher = new SwHttpDispatcher(options);
4392
+ dispatcher.start();
4393
+ return () => {
4394
+ dispatcher.stop();
4395
+ };
4396
+ }
4397
+ //#endregion
4398
+ //#region src/core/registry.ts
4399
+ function newRegistry(onError = console.error) {
4400
+ let counter = 0;
4401
+ const registrations = {};
4402
+ const register = (action) => {
4403
+ const id = counter++;
4404
+ const fn = Object.assign((skip) => {
4405
+ try {
4406
+ delete registrations[id];
4407
+ return skip ? void 0 : action();
4408
+ } catch (error) {
4409
+ onError(error);
4410
+ }
4411
+ }, { action });
4412
+ registrations[id] = fn;
4413
+ return fn;
4414
+ };
4415
+ const unregister = (action) => {
4416
+ for (const r of Object.values(registrations)) if (r.action === action) r(true);
4417
+ };
4418
+ const clear = () => {
4419
+ for (const r of Object.values(registrations)) r();
4420
+ };
4421
+ return Object.assign([
4422
+ register,
4423
+ clear,
4424
+ unregister
4425
+ ], {
4426
+ register,
4427
+ clear,
4428
+ unregister
4429
+ });
1739
4430
  }
1740
- function forgetReload(registration) {
4431
+ //#endregion
4432
+ //#region src/sw/worker-url.ts
4433
+ /**
4434
+ * Resolve a `serviceWorkerUrl` option the way a page would: relative to the
4435
+ * current document. Absolute urls pass through untouched. A url that cannot
4436
+ * be resolved is reported with the option name and the offending value,
4437
+ * because the raw `TypeError: Failed to construct 'URL': Invalid URL` says
4438
+ * neither.
4439
+ */
4440
+ function resolveWorkerUrl(serviceWorkerUrl) {
4441
+ const base = globalThis.location?.href;
1741
4442
  try {
1742
- sessionStorage.removeItem(reloadKey(registration));
1743
- } catch {}
4443
+ return new URL(serviceWorkerUrl, base);
4444
+ } catch (error) {
4445
+ throw new Error(`Invalid serviceWorkerUrl: ${JSON.stringify(serviceWorkerUrl)}` + (base ? ` (relative to ${base})` : " (no document to resolve it against)"), { cause: error });
4446
+ }
1744
4447
  }
1745
4448
  //#endregion
1746
4449
  //#region src/sw/sw-dispatcher.ts
@@ -1980,43 +4683,361 @@ var SwPortDispatcher = class {
1980
4683
  return index;
1981
4684
  }
1982
4685
  };
4686
+ //#endregion
4687
+ //#region src/ws/client.ts
4688
+ /** RFC 7230 `token`: what a subprotocol name may be. */
4689
+ const TOKEN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
1983
4690
  /**
1984
- * Resolve a `serviceWorkerUrl` option the way a page would: relative to the
1985
- * current document. Absolute urls pass through untouched. A url that cannot
1986
- * be resolved is reported with the option name and the offending value,
1987
- * because the raw `TypeError: Failed to construct 'URL': Invalid URL` says
1988
- * neither.
4691
+ * A WHATWG-compatible `WebSocket` class whose transport is a `MessagePort`
4692
+ * from `connect`. Code written against the browser's `WebSocket` runs on it
4693
+ * unchanged:
4694
+ *
4695
+ * ```ts
4696
+ * const WS = createWebSocketClass(adapter.connectWebSocket);
4697
+ * const ws = new WS(`${baseUrl}chat`, ["v1"]);
4698
+ * ws.onmessage = (event) => console.log(event.data);
4699
+ * ```
4700
+ *
4701
+ * It validates as the standard does (url, scheme, fragment, subprotocol
4702
+ * tokens and duplicates, close codes and reasons), has `binaryType`, the `on*`
4703
+ * handlers next to `addEventListener`, and the close handshake.
4704
+ * `bufferedAmount` is real: the server acknowledges what it consumed.
4705
+ *
4706
+ * The real `globalThis.WebSocket` is left alone; install the class where you
4707
+ * want it.
1989
4708
  */
1990
- function resolveWorkerUrl(serviceWorkerUrl) {
1991
- const base = globalThis.location?.href;
4709
+ function createWebSocketClass(connect, options = {}) {
4710
+ const heartbeatMs = options.heartbeatMs ?? 1e4;
4711
+ class WebSocketOverPort extends PortSocketCore {
4712
+ static CONNECTING = 0;
4713
+ static OPEN = 1;
4714
+ static CLOSING = 2;
4715
+ static CLOSED = 3;
4716
+ CONNECTING = 0;
4717
+ OPEN = 1;
4718
+ CLOSING = 2;
4719
+ CLOSED = 3;
4720
+ url;
4721
+ extensions = "";
4722
+ _protocol = "";
4723
+ _binaryType = "blob";
4724
+ _handlers = {};
4725
+ _heartbeat;
4726
+ /** When the oldest ping still waiting for its pong was sent. */
4727
+ _unansweredSince;
4728
+ _onVisibility;
4729
+ /** Sends waiting for a Blob ahead of them to be read, so order is kept. */
4730
+ _chain;
4731
+ constructor(url, protocols = []) {
4732
+ super({
4733
+ timers: options.timers,
4734
+ closeTimeoutMs: options.closeTimeoutMs
4735
+ });
4736
+ this.url = parseUrl(url);
4737
+ const list = typeof protocols === "string" ? [protocols] : [...protocols];
4738
+ validateProtocols(list);
4739
+ for (const type of [
4740
+ "open",
4741
+ "message",
4742
+ "error",
4743
+ "close"
4744
+ ]) this.addEventListener(type, (event) => this._handlers[type]?.call(this, event));
4745
+ Promise.resolve().then(() => connect(this.url, list)).then((connection) => {
4746
+ try {
4747
+ this._opened(connection, list);
4748
+ } catch {
4749
+ this._failed();
4750
+ }
4751
+ }, () => this._failed());
4752
+ }
4753
+ get protocol() {
4754
+ return this._protocol;
4755
+ }
4756
+ get binaryType() {
4757
+ return this._binaryType;
4758
+ }
4759
+ set binaryType(value) {
4760
+ if (value === "blob" || value === "arraybuffer") this._binaryType = value;
4761
+ }
4762
+ get onopen() {
4763
+ return this._handlers.open ?? null;
4764
+ }
4765
+ set onopen(fn) {
4766
+ this._handlers.open = typeof fn === "function" ? fn : null;
4767
+ }
4768
+ get onmessage() {
4769
+ return this._handlers.message ?? null;
4770
+ }
4771
+ set onmessage(fn) {
4772
+ this._handlers.message = typeof fn === "function" ? fn : null;
4773
+ }
4774
+ get onerror() {
4775
+ return this._handlers.error ?? null;
4776
+ }
4777
+ set onerror(fn) {
4778
+ this._handlers.error = typeof fn === "function" ? fn : null;
4779
+ }
4780
+ get onclose() {
4781
+ return this._handlers.close ?? null;
4782
+ }
4783
+ set onclose(fn) {
4784
+ this._handlers.close = typeof fn === "function" ? fn : null;
4785
+ }
4786
+ send(data) {
4787
+ if (this._state === 0) throw new DOMException("Still in CONNECTING state.", "InvalidStateError");
4788
+ if (isBlob(data)) {
4789
+ const size = data.size;
4790
+ if (this._state !== 1) {
4791
+ this._sendData(new ArrayBuffer(size));
4792
+ return;
4793
+ }
4794
+ this._reserve(size);
4795
+ this._enqueue(async () => this._sendData(await data.arrayBuffer(), true));
4796
+ return;
4797
+ }
4798
+ const payload = isBinary(data) ? toArrayBuffer(data) : String(data);
4799
+ if (this._chain) this._enqueue(async () => this._sendData(payload));
4800
+ else this._sendData(payload);
4801
+ }
4802
+ close(code, reason) {
4803
+ if (code !== void 0 && !isApplicationCloseCode(code)) throw new DOMException(`The close code must be 1000 or in 3000-4999; ${code} is neither.`, "InvalidAccessError");
4804
+ const text = reason ?? "";
4805
+ if (utf8Length(text) > 123) throw new DOMException("The close reason is longer than 123 bytes.", "SyntaxError");
4806
+ if (this._state === 2 || this._state === 3) return;
4807
+ if (this._state === 0) {
4808
+ this._state = 2;
4809
+ return;
4810
+ }
4811
+ this._startClose(code ?? 1005, text);
4812
+ }
4813
+ _dispatchMessage(data) {
4814
+ const value = typeof data === "string" || this._binaryType === "arraybuffer" ? data : new Blob([data]);
4815
+ this.dispatchEvent(new MessageEvent("message", {
4816
+ data: value,
4817
+ origin: new URL(this.url).origin
4818
+ }));
4819
+ }
4820
+ _onPong() {
4821
+ this._unansweredSince = void 0;
4822
+ }
4823
+ _onClosed() {
4824
+ if (this._heartbeat !== void 0) this._timers.clearInterval(this._heartbeat);
4825
+ this._heartbeat = void 0;
4826
+ if (this._onVisibility) globalThis.document?.removeEventListener("visibilitychange", this._onVisibility);
4827
+ }
4828
+ _ping(hb = heartbeatMs) {
4829
+ this._unansweredSince ??= Date.now();
4830
+ this._post({
4831
+ t: "p",
4832
+ hb
4833
+ });
4834
+ }
4835
+ _opened(connection, requested) {
4836
+ const { port, protocol = "" } = connection ?? {};
4837
+ if (!port || typeof port.postMessage !== "function") {
4838
+ this._failed();
4839
+ return;
4840
+ }
4841
+ if (this._state !== 0) {
4842
+ try {
4843
+ port.postMessage({
4844
+ t: "c",
4845
+ code: 1006,
4846
+ reason: ""
4847
+ });
4848
+ port.close();
4849
+ } catch {}
4850
+ this._finish(1006, "", false, true);
4851
+ return;
4852
+ }
4853
+ if (protocol !== "" && !requested.includes(protocol)) {
4854
+ port.close();
4855
+ this._failed();
4856
+ return;
4857
+ }
4858
+ this._protocol = protocol;
4859
+ this._attach(port);
4860
+ this._state = 1;
4861
+ this._ping();
4862
+ this._heartbeat = this._timers.setInterval(() => {
4863
+ const since = this._unansweredSince;
4864
+ if (since !== void 0 && Date.now() - since > 2 * heartbeatMs) {
4865
+ this._finish(1006, "", false, true);
4866
+ return;
4867
+ }
4868
+ this._ping();
4869
+ }, heartbeatMs);
4870
+ const doc = globalThis.document;
4871
+ if (doc) {
4872
+ this._onVisibility = () => this._ping(doc.visibilityState === "hidden" ? Math.max(heartbeatMs, 7e4) : heartbeatMs);
4873
+ doc.addEventListener("visibilitychange", this._onVisibility);
4874
+ if (doc.visibilityState === "hidden") this._onVisibility();
4875
+ }
4876
+ this.dispatchEvent(new Event("open"));
4877
+ }
4878
+ _failed() {
4879
+ this._state = 2;
4880
+ this._finish(1006, "", false, true);
4881
+ }
4882
+ _enqueue(task) {
4883
+ const run = (this._chain ?? Promise.resolve()).then(task).catch(() => {});
4884
+ this._chain = run;
4885
+ run.then(() => {
4886
+ if (this._chain === run) this._chain = void 0;
4887
+ });
4888
+ }
4889
+ }
4890
+ return WebSocketOverPort;
4891
+ }
4892
+ function parseUrl(input) {
4893
+ let url;
1992
4894
  try {
1993
- return new URL(serviceWorkerUrl, base);
1994
- } catch (error) {
1995
- throw new Error(`Invalid serviceWorkerUrl: ${JSON.stringify(serviceWorkerUrl)}` + (base ? ` (relative to ${base})` : " (no document to resolve it against)"), { cause: error });
4895
+ url = new URL(`${input}`, globalThis.location?.href);
4896
+ } catch {
4897
+ throw new DOMException(`The URL '${input}' is invalid.`, "SyntaxError");
4898
+ }
4899
+ if (url.protocol === "http:") url.protocol = "ws:";
4900
+ else if (url.protocol === "https:") url.protocol = "wss:";
4901
+ if (url.protocol !== "ws:" && url.protocol !== "wss:") throw new DOMException(`The URL's scheme must be 'ws', 'wss', 'http' or 'https'; '${url.protocol.slice(0, -1)}' is not allowed.`, "SyntaxError");
4902
+ if (url.hash !== "" || `${input}`.includes("#")) throw new DOMException(`The URL contains a fragment identifier ('${url.hash}'). Fragment identifiers are not allowed in WebSocket URLs.`, "SyntaxError");
4903
+ return url.href;
4904
+ }
4905
+ function validateProtocols(protocols) {
4906
+ const seen = /* @__PURE__ */ new Set();
4907
+ for (const protocol of protocols) {
4908
+ if (!TOKEN.test(protocol)) throw new DOMException(`The subprotocol '${protocol}' is invalid.`, "SyntaxError");
4909
+ if (seen.has(protocol)) throw new DOMException(`The subprotocol '${protocol}' is duplicated.`, "SyntaxError");
4910
+ seen.add(protocol);
1996
4911
  }
1997
4912
  }
1998
4913
  //#endregion
1999
- //#region src/sw/http-sw-dispatcher.ts
2000
- var SwHttpAdapter = class extends SwPortHandler {
4914
+ //#region src/sw/sw-http-adapter.ts
4915
+ /**
4916
+ * The page side of the same-origin ServiceWorker: registers the worker, and
4917
+ * attaches handler links to it.
4918
+ *
4919
+ * - `register(prefix, handler)` serves a handler in this page, under
4920
+ * `<scope>/<key>/...`.
4921
+ * - `attachWorker(worker)` links a Dedicated Worker that runs
4922
+ * `serveHttpInWorker(handler)`: the ServiceWorker then talks to the worker
4923
+ * directly, and this page carries nothing.
4924
+ * - `connectWebSocket(url, protocols)` and `WebSocket` open sockets to
4925
+ * handlers, through the ServiceWorker or straight to a handler of this page.
4926
+ *
4927
+ * Links are attached again whenever the ServiceWorker asks (after a restart),
4928
+ * and requests in flight on a handler that dies fail at once.
4929
+ */
4930
+ var SwHttpAdapter = class {
4931
+ options;
2001
4932
  _handlers = /* @__PURE__ */ new Map();
2002
- _cleanupRequestChannel;
4933
+ _endpoint;
4934
+ _ownerLock = newLockName("webrun-http-owner");
4935
+ _id;
4936
+ _owner = crypto.randomUUID();
4937
+ _workers = /* @__PURE__ */ new Set();
4938
+ _releaseOwner;
4939
+ _serviceWorkerUrl;
4940
+ _serviceWorker;
4941
+ _registration;
4942
+ _registrationPromise;
4943
+ _cleanup = [];
4944
+ _attaching;
4945
+ _routerLock;
4946
+ _WebSocket;
2003
4947
  constructor(options) {
2004
- super({
2005
- bindPort: () => {},
2006
- ...options
2007
- });
4948
+ this.options = options;
4949
+ if (!this.key) throw new Error("Key is not defined.");
4950
+ if (!this.scope) throw new Error("Scope is not defined");
4951
+ this._id = options.id ?? `page:${crypto.randomUUID()}`;
4952
+ this._endpoint = new HandlerEndpoint((request) => this._handleHttpRequest(request), { streamTimeout: options.streamTimeout });
2008
4953
  }
2009
- async _setCommunicationPort(port) {
2010
- this._cleanupRequestChannel?.();
2011
- this._cleanupRequestChannel = handleHttpRequests(port, this._handleHttpRequest.bind(this));
4954
+ get key() {
4955
+ return this.options.key;
2012
4956
  }
2013
- async _handleHttpRequest(request) {
2014
- const requestUrl = request.url;
2015
- for (const [urlPrefix, handler] of this._handlers) if (requestUrl.indexOf(urlPrefix) === 0) return handler(request);
2016
- return new Response(null, {
2017
- status: 404,
2018
- statusText: "Error 404: Not found"
2019
- });
4957
+ get scope() {
4958
+ return this.options.scope ?? new URL("./", this.serviceWorkerUrl).pathname;
4959
+ }
4960
+ /**
4961
+ * The scope as a url on the worker's origin. Resolved against the worker's
4962
+ * own url when one is given, not against this module's: a bundle that is
4963
+ * not an ES module (an IIFE, Theia's frontend) has no `import.meta.url`, and
4964
+ * `new URL(scope, undefined)` threw `Invalid URL` there. Only without a
4965
+ * worker url does the module's location decide (its default `sw-worker.js`
4966
+ * sits next to it).
4967
+ */
4968
+ get rootUrl() {
4969
+ const base = this.options.serviceWorkerUrl ? this.serviceWorkerUrl : import.meta.url;
4970
+ return new URL(this.scope, base);
4971
+ }
4972
+ get serviceWorkerUrl() {
4973
+ if (!this._serviceWorkerUrl) {
4974
+ const url = this.options.serviceWorkerUrl ? resolveWorkerUrl(this.options.serviceWorkerUrl) : new URL("./sw-worker.js", this.rootUrl);
4975
+ this._serviceWorkerUrl = `${url}`;
4976
+ }
4977
+ return this._serviceWorkerUrl;
4978
+ }
4979
+ /**
4980
+ * Registers the worker and waits until it is activated and controls this
4981
+ * page, then attaches this page's handler link. The page must be controlled,
4982
+ * because only a controlled page's `fetch()` reaches the worker. When the
4983
+ * page loaded uncontrolled (a hard reload does that), the worker is asked to
4984
+ * claim it. Rejects with a `ServiceWorkerControlError` when that fails or
4985
+ * `timeout` passes (`reason` says which step); a later `start()` tries
4986
+ * again.
4987
+ */
4988
+ async start() {
4989
+ if (!this._registrationPromise) this._registrationPromise = (async () => {
4990
+ const registration = await navigator.serviceWorker.register(this.serviceWorkerUrl, { scope: this.scope });
4991
+ this._registration = registration;
4992
+ const stopListening = handleChannelCalls(navigator.serviceWorker, NEED_LINKS_CALL, async (_event, params) => {
4993
+ await this.relink(params?.routerLock);
4994
+ return true;
4995
+ });
4996
+ const onControllerChange = () => {
4997
+ const controller = navigator.serviceWorker.controller;
4998
+ if (controller) this._serviceWorker = controller;
4999
+ };
5000
+ navigator.serviceWorker.addEventListener("controllerchange", onControllerChange);
5001
+ this._cleanup.push(stopListening, () => navigator.serviceWorker.removeEventListener("controllerchange", onControllerChange));
5002
+ const timeout = this.options.timeout ?? 3e4;
5003
+ const deadline = Date.now() + timeout;
5004
+ try {
5005
+ this._serviceWorker = await awaitServiceWorkerControl(registration, {
5006
+ timeout,
5007
+ reloadIfUncontrolled: this.options.reloadIfUncontrolled
5008
+ });
5009
+ this._releaseOwner ??= await holdLock(this._ownerLock);
5010
+ await withDeadline(deadline, this._attachPage(), () => new ServiceWorkerControlError("unresponsive", `ServiceWorker "${this._serviceWorker?.scriptURL}" controls this page but did not answer the UPDATE_COMMUNICATION_PORT handshake within ${timeout} ms. Check that the worker script runs this package's same-origin dispatcher (it importScripts \`sw-worker.js\`, or calls \`startHttpDispatcher\`).`));
5011
+ } catch (error) {
5012
+ for (const cleanup of this._cleanup.splice(0)) cleanup();
5013
+ this._registration = void 0;
5014
+ this._registrationPromise = void 0;
5015
+ throw error;
5016
+ }
5017
+ })();
5018
+ return this._registrationPromise;
5019
+ }
5020
+ /** Detaches every link, and unregisters the worker. */
5021
+ async stop() {
5022
+ for (const cleanup of this._cleanup.splice(0)) cleanup();
5023
+ for (const worker of [...this._workers]) await worker.detach().catch(() => {});
5024
+ const sw = this._serviceWorker;
5025
+ if (sw) await callChannel(sw, DETACH_CALL, {
5026
+ key: this.key,
5027
+ id: this._id,
5028
+ owner: this._owner
5029
+ }).catch(() => {});
5030
+ await this._endpoint.unlink();
5031
+ this._releaseOwner?.();
5032
+ this._releaseOwner = void 0;
5033
+ const registration = this._registration;
5034
+ this._registration = void 0;
5035
+ this._registrationPromise = void 0;
5036
+ try {
5037
+ await registration?.unregister();
5038
+ } catch (error) {
5039
+ console.log("Service Worker unregistration failed: ", error);
5040
+ }
2020
5041
  }
2021
5042
  /**
2022
5043
  * Registers an HTTP handler on a prefix. Returns the resulting base URL and a
@@ -2035,44 +5056,393 @@ var SwHttpAdapter = class extends SwPortHandler {
2035
5056
  }
2036
5057
  };
2037
5058
  }
5059
+ /**
5060
+ * Links a Dedicated Worker that runs `serveHttpInWorker(handler)` as a
5061
+ * handler of `key`. The ServiceWorker sends it requests directly, without
5062
+ * this page relaying anything, so a busy or throttled page does not slow
5063
+ * them down.
5064
+ */
5065
+ async attachWorker(worker, options = {}) {
5066
+ await this.start();
5067
+ const controls = [];
5068
+ let retry;
5069
+ const { control, ownerLock } = await new Promise((resolve, reject) => {
5070
+ const timer = setTimeout(() => {
5071
+ clearInterval(retry);
5072
+ for (const port of controls) port.close();
5073
+ reject(/* @__PURE__ */ new Error("The worker did not answer within the timeout. Does it call serveHttpInWorker()?"));
5074
+ }, this.options.timeout ?? 3e4);
5075
+ const hello = () => {
5076
+ const channel = new MessageChannel();
5077
+ controls.push(channel.port1);
5078
+ channel.port1.onmessage = (event) => {
5079
+ const message = event.data;
5080
+ if (message?.type !== "ready") return;
5081
+ clearTimeout(timer);
5082
+ clearInterval(retry);
5083
+ for (const port of controls) if (port !== channel.port1) port.close();
5084
+ resolve({
5085
+ control: channel.port1,
5086
+ ownerLock: message.ownerLock
5087
+ });
5088
+ };
5089
+ worker.postMessage({ type: WORKER_HELLO }, [channel.port2]);
5090
+ };
5091
+ hello();
5092
+ retry = setInterval(hello, 500);
5093
+ });
5094
+ const key = options.key ?? this.key;
5095
+ const id = options.id ?? `worker:${crypto.randomUUID()}`;
5096
+ for (const old of [...this._workers]) if (old.key === key && old.id === id) old.retire();
5097
+ const link = new WorkerLinkImpl(this, control, {
5098
+ key,
5099
+ id,
5100
+ owner: crypto.randomUUID(),
5101
+ meta: options.meta,
5102
+ ownerLock
5103
+ });
5104
+ this._workers.add(link);
5105
+ await link.attach();
5106
+ return link;
5107
+ }
5108
+ /**
5109
+ * Opens the transport of a socket to the handler `url` routes to: a handler
5110
+ * of this page directly, any other through the ServiceWorker. Resolves once
5111
+ * the handler accepted. An arrow function, so it can be handed around:
5112
+ * `createWebSocketClass(adapter.connectWebSocket)`.
5113
+ */
5114
+ connectWebSocket = async (url, protocols = []) => {
5115
+ const httpUrl = new URL(`${url}`, globalThis.location?.href);
5116
+ if (httpUrl.protocol === "ws:") httpUrl.protocol = "http:";
5117
+ else if (httpUrl.protocol === "wss:") httpUrl.protocol = "https:";
5118
+ const local = this._findHandler(`${httpUrl}`);
5119
+ if (local) {
5120
+ const result = await serveWebSocketUpgrade(local, {
5121
+ url: `${httpUrl}`,
5122
+ protocols
5123
+ });
5124
+ if (!result.ok) throw new Error(result.message);
5125
+ return {
5126
+ port: result.port,
5127
+ protocol: result.protocol
5128
+ };
5129
+ }
5130
+ await this.start();
5131
+ return await callChannelWithin(this._callTimeout(), this._worker(), WS_CONNECT_CALL, {
5132
+ url: `${httpUrl}`,
5133
+ protocols
5134
+ });
5135
+ };
5136
+ /**
5137
+ * A WHATWG `WebSocket` class whose sockets reach this adapter's handlers:
5138
+ * `new adapter.WebSocket(`${baseUrl}chat`, ["v1"])`. The global
5139
+ * `WebSocket` is left alone.
5140
+ */
5141
+ get WebSocket() {
5142
+ this._WebSocket ??= createWebSocketClass(this.connectWebSocket, this.options.webSocket);
5143
+ return this._WebSocket;
5144
+ }
5145
+ /**
5146
+ * Attaches again every link not made for the router holding
5147
+ * `currentRouterLock` (every link, without it). The ServiceWorker asks for
5148
+ * this after a restart; a page may call it too.
5149
+ */
5150
+ async relink(currentRouterLock) {
5151
+ if (!this._registrationPromise) return;
5152
+ const stale = (routerLock) => currentRouterLock === void 0 || routerLock !== currentRouterLock;
5153
+ const jobs = [];
5154
+ if (stale(this._routerLock)) jobs.push(this._attachPage());
5155
+ for (const worker of this._workers) if (stale(worker.routerLock)) jobs.push(worker.attach());
5156
+ await Promise.allSettled(jobs);
5157
+ }
5158
+ /** @internal Posts a plain message to the ServiceWorker, which starts it if stopped. */
5159
+ _poke(type) {
5160
+ try {
5161
+ this._worker().postMessage({ type });
5162
+ } catch {}
5163
+ }
5164
+ /** @internal */
5165
+ _worker() {
5166
+ const worker = navigator.serviceWorker.controller ?? this._serviceWorker ?? this._registration?.active;
5167
+ if (!worker) throw new Error("The ServiceWorker is not running; call start() first");
5168
+ return worker;
5169
+ }
5170
+ /** @internal */
5171
+ _forget(link) {
5172
+ this._workers.delete(link);
5173
+ }
5174
+ /** @internal How long a call to the ServiceWorker may take. */
5175
+ _callTimeout() {
5176
+ return this.options.timeout ?? 3e4;
5177
+ }
5178
+ _attachPage() {
5179
+ this._attaching ??= (async () => {
5180
+ try {
5181
+ const down = new MessageChannel();
5182
+ const ctl = new MessageChannel();
5183
+ const params = {
5184
+ wire: HTTP_LINK_PROTOCOL,
5185
+ key: this.key,
5186
+ id: this._id,
5187
+ owner: this._owner,
5188
+ ownerLock: this._releaseOwner && locksAvailable() ? this._ownerLock : void 0,
5189
+ meta: this.options.meta,
5190
+ ctl: true
5191
+ };
5192
+ const reply = await callChannelWithin(this._callTimeout(), this._worker(), ATTACH_CALL, params, down.port1, ctl.port1);
5193
+ checkReply(reply, this._worker());
5194
+ await this._endpoint.link(this.key, {
5195
+ down: down.port2,
5196
+ ctl: ctl.port2
5197
+ }, reply.routerLock);
5198
+ this._routerLock = reply.routerLock;
5199
+ } finally {
5200
+ this._attaching = void 0;
5201
+ }
5202
+ })();
5203
+ return this._attaching;
5204
+ }
5205
+ _findHandler(url) {
5206
+ for (const [urlPrefix, handler] of this._handlers) if (url.indexOf(urlPrefix) === 0) return handler;
5207
+ }
5208
+ async _handleHttpRequest(request) {
5209
+ const handler = this._findHandler(request.url);
5210
+ if (handler) return await handler(request);
5211
+ return new Response(null, {
5212
+ status: 404,
5213
+ statusText: "Error 404: Not found"
5214
+ });
5215
+ }
2038
5216
  };
2039
- var SwHttpDispatcher = class extends SwPortDispatcher {
2040
- start() {
2041
- super.start();
2042
- this.self.addEventListener("fetch", this._handleFetchEvent.bind(this));
5217
+ var WorkerLinkImpl = class {
5218
+ _adapter;
5219
+ _control;
5220
+ key;
5221
+ id;
5222
+ owner;
5223
+ routerLock;
5224
+ _meta;
5225
+ _ownerLock;
5226
+ _attaching;
5227
+ _detached = false;
5228
+ constructor(_adapter, _control, init) {
5229
+ this._adapter = _adapter;
5230
+ this._control = _control;
5231
+ this.key = init.key;
5232
+ this.id = init.id;
5233
+ this.owner = init.owner;
5234
+ this._meta = init.meta;
5235
+ this._ownerLock = init.ownerLock;
5236
+ _control.onmessage = (event) => {
5237
+ const message = event.data;
5238
+ if (message?.type === "relink") {
5239
+ this._adapter._poke(WAKE_MESSAGE);
5240
+ this.attach().catch(() => {});
5241
+ } else if (message?.type === "keepalive") this._adapter._poke(KEEPALIVE_MESSAGE);
5242
+ };
2043
5243
  }
2044
- _handleFetchEvent(event) {
2045
- event.respondWith((async () => {
2046
- const request = event.request;
5244
+ /**
5245
+ * Forgets this link without telling the ServiceWorker: another attachment
5246
+ * took its slot, and the ServiceWorker retires it by itself.
5247
+ */
5248
+ retire() {
5249
+ this._detached = true;
5250
+ this._adapter._forget(this);
5251
+ this._control.onmessage = null;
5252
+ }
5253
+ /** Makes fresh channels and attaches them; the worker gets its ends. */
5254
+ attach() {
5255
+ if (this._detached) return Promise.resolve();
5256
+ this._attaching ??= (async () => {
2047
5257
  try {
2048
- const requestUrl = request.url;
2049
- const rootUrl = this.scope;
2050
- if (requestUrl.indexOf(rootUrl) === 0) {
2051
- const key = requestUrl.substring(rootUrl.length).replace(/^\/?([^/]+).*$/, "$1");
2052
- const channelInfo = await this.loadChannelInfo(key);
2053
- if (channelInfo?.port) return await sendHttpRequest(channelInfo.port, request);
2054
- if (this.isClaimedKey(key)) return new Response(null, {
2055
- status: 404,
2056
- statusText: "Not Found (no active handler)"
2057
- });
5258
+ if (this._ownerLock) {
5259
+ const held = await heldLockNames();
5260
+ if (held && !held.has(this._ownerLock)) {
5261
+ this.retire();
5262
+ return;
5263
+ }
2058
5264
  }
2059
- return await fetch(request);
2060
- } catch (error) {
2061
- console.error(error);
2062
- return new Response(null, {
2063
- status: 500,
2064
- statusText: "Error 500: Internal error"
2065
- });
5265
+ const down = new MessageChannel();
5266
+ const ctl = new MessageChannel();
5267
+ const up = new MessageChannel();
5268
+ const params = {
5269
+ wire: HTTP_LINK_PROTOCOL,
5270
+ key: this.key,
5271
+ id: this.id,
5272
+ owner: this.owner,
5273
+ ownerLock: this._ownerLock,
5274
+ meta: this._meta,
5275
+ ctl: true,
5276
+ up: true
5277
+ };
5278
+ const sw = this._adapter._worker();
5279
+ const reply = await callChannelWithin(this._adapter._callTimeout(), sw, ATTACH_CALL, params, down.port1, ctl.port1, up.port1);
5280
+ checkReply(reply, sw);
5281
+ this.routerLock = reply.routerLock;
5282
+ this._control.postMessage({
5283
+ type: "link",
5284
+ key: this.key,
5285
+ routerLock: reply.routerLock
5286
+ }, [
5287
+ down.port2,
5288
+ ctl.port2,
5289
+ up.port2
5290
+ ]);
5291
+ } finally {
5292
+ this._attaching = void 0;
2066
5293
  }
2067
- })());
5294
+ })();
5295
+ return this._attaching;
5296
+ }
5297
+ async drain({ ms } = {}) {
5298
+ if (this._detached) return;
5299
+ this._detached = true;
5300
+ this._adapter._forget(this);
5301
+ try {
5302
+ await callChannel(this._adapter._worker(), DRAIN_CALL, {
5303
+ key: this.key,
5304
+ id: this.id,
5305
+ owner: this.owner,
5306
+ ms
5307
+ });
5308
+ } finally {
5309
+ this._unlink();
5310
+ }
5311
+ }
5312
+ async detach() {
5313
+ if (this._detached) return;
5314
+ this._detached = true;
5315
+ this._adapter._forget(this);
5316
+ await callChannel(this._adapter._worker(), DETACH_CALL, {
5317
+ key: this.key,
5318
+ id: this.id,
5319
+ owner: this.owner
5320
+ }).catch(() => {});
5321
+ this._unlink();
5322
+ }
5323
+ _unlink() {
5324
+ this._control.postMessage({
5325
+ type: "unlink",
5326
+ key: this.key
5327
+ });
5328
+ this._control.onmessage = null;
2068
5329
  }
2069
5330
  };
2070
- function startHttpDispatcher(options) {
2071
- const dispatcher = new SwHttpDispatcher(options);
2072
- dispatcher.start();
2073
- return () => {
2074
- dispatcher.stop();
5331
+ function checkReply(reply, sw) {
5332
+ if (reply?.accepted === "webrun-http-link/2") return;
5333
+ throw new ServiceWorkerControlError("incompatible", `ServiceWorker "${sw.scriptURL}" answered the handshake but does not speak ${HTTP_LINK_PROTOCOL}: it runs an older version of @statewalker/webrun-http-browser. Reload the page once the updated worker is active.`);
5334
+ }
5335
+ //#endregion
5336
+ //#region src/sw/sw-websocket.ts
5337
+ /**
5338
+ * Opens the transport of a socket through the ServiceWorker controlling this
5339
+ * page, for a page that has no adapter of its own: content a handler serves
5340
+ * under the same-origin dispatcher, or under a relay. The ServiceWorker
5341
+ * routes `url` like a fetch, and the handler accepts or refuses the upgrade.
5342
+ */
5343
+ async function connectWebSocketThroughServiceWorker(url, protocols = [], container = navigator.serviceWorker) {
5344
+ const worker = container.controller;
5345
+ if (!worker) throw new Error("This page is not controlled by a ServiceWorker");
5346
+ const httpUrl = new URL(`${url}`, globalThis.location?.href);
5347
+ if (httpUrl.protocol === "ws:") httpUrl.protocol = "http:";
5348
+ else if (httpUrl.protocol === "wss:") httpUrl.protocol = "https:";
5349
+ return await callChannel(worker, WS_CONNECT_CALL, {
5350
+ url: `${httpUrl}`,
5351
+ protocols
5352
+ });
5353
+ }
5354
+ /**
5355
+ * A WHATWG `WebSocket` class over `connectWebSocketThroughServiceWorker`.
5356
+ * Install it where the page's code expects `WebSocket`, e.g.
5357
+ * `globalThis.WebSocket = serviceWorkerWebSocketClass()` in content that only
5358
+ * talks to its own handlers.
5359
+ */
5360
+ function serviceWorkerWebSocketClass(options) {
5361
+ return createWebSocketClass((url, protocols) => connectWebSocketThroughServiceWorker(url, protocols), options);
5362
+ }
5363
+ //#endregion
5364
+ //#region src/sw/worker-service.ts
5365
+ /**
5366
+ * Serves `handler` from a Dedicated Worker, on the links the page attaches
5367
+ * with `adapter.attachWorker(worker)`:
5368
+ *
5369
+ * ```ts
5370
+ * // worker.ts
5371
+ * const service = serveHttpInWorker(async (request) => new Response("hi"));
5372
+ * ```
5373
+ *
5374
+ * The worker holds an owner lock while it lives, so the ServiceWorker notices
5375
+ * at once when it is terminated. It watches the ServiceWorker's router lock,
5376
+ * and asks its page to attach it again when its links died and a call waits.
5377
+ * While calls through the router are in flight it asks its page every 20 s to
5378
+ * keep the ServiceWorker awake — only a message to the ServiceWorker object
5379
+ * counts as an event, and a worker cannot post one.
5380
+ */
5381
+ function serveHttpInWorker(handler, options = {}) {
5382
+ const scope = options.scope ?? globalThis;
5383
+ const ownerLock = newLockName("webrun-http-owner");
5384
+ const held = holdLock(ownerLock);
5385
+ const controls = /* @__PURE__ */ new Set();
5386
+ const tellPages = (message) => {
5387
+ for (const control of controls) control.postMessage(message);
5388
+ };
5389
+ const endpoint = new HandlerEndpoint(handler, {
5390
+ ...options,
5391
+ requestRelink: () => tellPages({ type: "relink" }),
5392
+ keepAlive: () => tellPages({ type: "keepalive" })
5393
+ });
5394
+ const onMessage = (event) => {
5395
+ const { data, ports } = event;
5396
+ if (data?.type !== "webrun-http:worker-hello") return;
5397
+ const control = ports?.[0];
5398
+ if (!control) return;
5399
+ controls.add(control);
5400
+ control.onmessage = (message) => {
5401
+ const msg = message.data;
5402
+ if (msg?.type === "link") {
5403
+ const [down, ctl, up] = message.ports;
5404
+ if (down) endpoint.link(msg.key, {
5405
+ down,
5406
+ ctl,
5407
+ up
5408
+ }, msg.routerLock);
5409
+ } else if (msg?.type === "unlink") endpoint.unlink(msg.key);
5410
+ };
5411
+ held.then(() => control.postMessage({
5412
+ type: "ready",
5413
+ ownerLock: locksAvailable() ? ownerLock : void 0
5414
+ }));
5415
+ };
5416
+ scope.addEventListener("message", onMessage);
5417
+ const baseUrl = () => globalThis.location?.href;
5418
+ const connectWebSocket = (url, protocols = []) => {
5419
+ const httpUrl = new URL(`${url}`, baseUrl());
5420
+ if (httpUrl.protocol === "ws:") httpUrl.protocol = "http:";
5421
+ else if (httpUrl.protocol === "wss:") httpUrl.protocol = "https:";
5422
+ return endpoint.connectWebSocket(`${httpUrl}`, protocols);
5423
+ };
5424
+ let WebSocketClass;
5425
+ return {
5426
+ fetch(input, init) {
5427
+ const request = input instanceof Request && init === void 0 ? input : new Request(input instanceof Request ? input : new URL(`${input}`, baseUrl()), init);
5428
+ return endpoint.fetch(request);
5429
+ },
5430
+ connectWebSocket,
5431
+ get WebSocket() {
5432
+ WebSocketClass ??= createWebSocketClass(connectWebSocket, options.webSocket);
5433
+ return WebSocketClass;
5434
+ },
5435
+ get linked() {
5436
+ return endpoint.linked;
5437
+ },
5438
+ async close() {
5439
+ scope.removeEventListener("message", onMessage);
5440
+ await endpoint.close();
5441
+ for (const control of controls) control.close();
5442
+ controls.clear();
5443
+ (await held)();
5444
+ }
2075
5445
  };
2076
5446
  }
2077
5447
  //#endregion
2078
- export { SwHttpAdapter, SwHttpDispatcher, SwPortDispatcher, SwPortHandler, startHttpDispatcher };
5448
+ export { DEFAULT_CLOSE_TIMEOUT_MS, DEFAULT_HEARTBEAT_MS, HANDLER_LOST_MESSAGE, HTTP_LINK_PROTOCOL, HandlerEndpoint, HandlerTable, LinkLostError, PortRendezvous, ROUTER_LOST_MESSAGE, ServerWebSocket, SwHttpAdapter, SwHttpDispatcher, SwPortDispatcher, SwPortHandler, WS_PORT_PROTOCOL_VERSION, WS_VERSION_HEADER, WsCloseEvent, connectHttpLink, connectWebSocketThroughServiceWorker, createWebSocketClass, holdLock, isWebSocketUpgrade, leastInflightRoute, locksAvailable, newLockName, newWebSocketUpgradeRequest, requestedProtocols, serveHttpInWorker, serveHttpLink, serveWebSocketUpgrade, serviceWorkerWebSocketClass, startHttpDispatcher, takePort, takeUpgrade, trackedLink, upgradeWebSocket, watchLock, withHeaders };