@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.
- package/README.md +302 -52
- package/dist/core/data-calls.d.ts +17 -0
- package/dist/core/data-calls.d.ts.map +1 -1
- package/dist/core/service-worker-control.d.ts +3 -1
- package/dist/core/service-worker-control.d.ts.map +1 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3906 -265
- package/dist/link/errors.d.ts +2 -0
- package/dist/link/errors.d.ts.map +1 -0
- package/dist/link/http-link.d.ts +58 -0
- package/dist/link/http-link.d.ts.map +1 -0
- package/dist/link/index.d.ts +4 -0
- package/dist/link/index.d.ts.map +1 -0
- package/dist/link/locks.d.ts +39 -0
- package/dist/link/locks.d.ts.map +1 -0
- package/dist/link/rendezvous.d.ts +21 -0
- package/dist/link/rendezvous.d.ts.map +1 -0
- package/dist/relay/index-sw.d.ts +12 -2
- package/dist/relay/index-sw.d.ts.map +1 -1
- package/dist/relay/index.d.ts +66 -8
- package/dist/relay/index.d.ts.map +1 -1
- package/dist/relay-sw.js +1160 -55
- package/dist/relay-worker.js +1161 -55
- package/dist/sw/handler-endpoint.d.ts +81 -0
- package/dist/sw/handler-endpoint.d.ts.map +1 -0
- package/dist/sw/handler-table.d.ts +53 -0
- package/dist/sw/handler-table.d.ts.map +1 -0
- package/dist/sw/http-sw-dispatcher.d.ts +162 -21
- package/dist/sw/http-sw-dispatcher.d.ts.map +1 -1
- package/dist/sw/index.d.ts +5 -0
- package/dist/sw/index.d.ts.map +1 -1
- package/dist/sw/protocol.d.ts +115 -0
- package/dist/sw/protocol.d.ts.map +1 -0
- package/dist/sw/sw-dispatcher.d.ts.map +1 -1
- package/dist/sw/sw-http-adapter.d.ts +199 -0
- package/dist/sw/sw-http-adapter.d.ts.map +1 -0
- package/dist/sw/sw-websocket.d.ts +16 -0
- package/dist/sw/sw-websocket.d.ts.map +1 -0
- package/dist/sw/worker-service.d.ts +45 -0
- package/dist/sw/worker-service.d.ts.map +1 -0
- package/dist/sw/worker-url.d.ts +9 -0
- package/dist/sw/worker-url.d.ts.map +1 -0
- package/dist/sw-worker.js +2075 -210
- package/dist/sw.d.ts +2 -0
- package/dist/sw.d.ts.map +1 -1
- package/dist/sw.js +3901 -531
- package/dist/worker.d.ts +10 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +3507 -0
- package/dist/ws/client.d.ts +73 -0
- package/dist/ws/client.d.ts.map +1 -0
- package/dist/ws/frames.d.ts +68 -0
- package/dist/ws/frames.d.ts.map +1 -0
- package/dist/ws/index.d.ts +6 -0
- package/dist/ws/index.d.ts.map +1 -0
- package/dist/ws/server.d.ts +53 -0
- package/dist/ws/server.d.ts.map +1 -0
- package/dist/ws/socket-core.d.ts +91 -0
- package/dist/ws/socket-core.d.ts.map +1 -0
- package/dist/ws/upgrade.d.ts +93 -0
- package/dist/ws/upgrade.d.ts.map +1 -0
- package/package.json +10 -5
- package/src/core/data-calls.ts +52 -2
- package/src/core/service-worker-control.ts +7 -1
- package/src/index.ts +3 -0
- package/src/link/errors.ts +3 -0
- package/src/link/http-link.ts +222 -0
- package/src/link/index.ts +3 -0
- package/src/link/locks.ts +97 -0
- package/src/link/rendezvous.ts +60 -0
- package/src/relay/index-sw.ts +84 -12
- package/src/relay/index.ts +203 -27
- package/src/sw/handler-endpoint.ts +278 -0
- package/src/sw/handler-table.ts +130 -0
- package/src/sw/http-sw-dispatcher.ts +649 -90
- package/src/sw/index.ts +5 -0
- package/src/sw/protocol.ts +103 -0
- package/src/sw/sw-dispatcher.ts +1 -20
- package/src/sw/sw-http-adapter.ts +613 -0
- package/src/sw/sw-websocket.ts +43 -0
- package/src/sw/worker-service.ts +133 -0
- package/src/sw/worker-url.ts +19 -0
- package/src/sw-worker.ts +8 -3
- package/src/sw.ts +2 -0
- package/src/worker.ts +9 -0
- package/src/ws/client.ts +346 -0
- package/src/ws/frames.ts +119 -0
- package/src/ws/index.ts +11 -0
- package/src/ws/server.ts +132 -0
- package/src/ws/socket-core.ts +251 -0
- package/src/ws/upgrade.ts +200 -0
package/dist/sw-worker.js
CHANGED
|
@@ -1,4 +1,55 @@
|
|
|
1
1
|
(function() {
|
|
2
|
+
//#region ../../node_modules/.pnpm/idb-keyval@6.3.0/node_modules/idb-keyval/dist/index.js
|
|
3
|
+
function promisifyRequest(request) {
|
|
4
|
+
return new Promise((resolve, reject) => {
|
|
5
|
+
request.oncomplete = request.onsuccess = () => resolve(request.result);
|
|
6
|
+
request.onabort = request.onerror = () => reject(request.error);
|
|
7
|
+
});
|
|
8
|
+
}
|
|
9
|
+
function createStore(dbName, storeName) {
|
|
10
|
+
let dbp;
|
|
11
|
+
const getDB = () => {
|
|
12
|
+
if (dbp) return dbp;
|
|
13
|
+
const request = indexedDB.open(dbName);
|
|
14
|
+
request.onupgradeneeded = () => request.result.createObjectStore(storeName);
|
|
15
|
+
dbp = promisifyRequest(request);
|
|
16
|
+
dbp.then((db) => {
|
|
17
|
+
db.onclose = () => dbp = void 0;
|
|
18
|
+
}, () => {
|
|
19
|
+
dbp = void 0;
|
|
20
|
+
});
|
|
21
|
+
return dbp;
|
|
22
|
+
};
|
|
23
|
+
return (txMode, callback) => getDB().then((db) => callback(db.transaction(storeName, txMode).objectStore(storeName)));
|
|
24
|
+
}
|
|
25
|
+
let defaultGetStoreFunc;
|
|
26
|
+
function defaultGetStore() {
|
|
27
|
+
if (!defaultGetStoreFunc) defaultGetStoreFunc = createStore("keyval-store", "keyval");
|
|
28
|
+
return defaultGetStoreFunc;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Get a value by its key.
|
|
32
|
+
*
|
|
33
|
+
* @param key
|
|
34
|
+
* @param customStore Method to get a custom store. Use with caution (see the docs).
|
|
35
|
+
*/
|
|
36
|
+
function get(key, customStore = defaultGetStore()) {
|
|
37
|
+
return customStore("readonly", (store) => promisifyRequest(store.get(key)));
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Set a value with a key.
|
|
41
|
+
*
|
|
42
|
+
* @param key
|
|
43
|
+
* @param value
|
|
44
|
+
* @param customStore Method to get a custom store. Use with caution (see the docs).
|
|
45
|
+
*/
|
|
46
|
+
function set(key, value, customStore = defaultGetStore()) {
|
|
47
|
+
return customStore("readwrite", (store) => {
|
|
48
|
+
store.put(value, key);
|
|
49
|
+
return promisifyRequest(store.transaction);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
//#endregion
|
|
2
53
|
//#region ../webrun-streams/dist/index.js
|
|
3
54
|
function serializeError(error) {
|
|
4
55
|
if (error instanceof Error) {
|
|
@@ -282,6 +333,110 @@
|
|
|
282
333
|
});
|
|
283
334
|
}
|
|
284
335
|
}
|
|
336
|
+
const DEFAULT_CHUNK_SIZE = 16384;
|
|
337
|
+
/**
|
|
338
|
+
* Curried `Duplex`-shaped transformer that splits incoming `Uint8Array`s into
|
|
339
|
+
* chunks no larger than `size` bytes. Empty input chunks are skipped; small
|
|
340
|
+
* input chunks pass through unchanged (zero-copy `subarray` views are used
|
|
341
|
+
* when splitting, so no allocation per chunk).
|
|
342
|
+
*
|
|
343
|
+
* Use to respect transport MTUs before reaching `channel.send`. Reassembly on
|
|
344
|
+
* the receive side is not needed — consumers iterate a continuous byte stream.
|
|
345
|
+
*/
|
|
346
|
+
function toChunks(size = DEFAULT_CHUNK_SIZE) {
|
|
347
|
+
if (!(Number.isInteger(size) && size > 0)) throw new RangeError(`toChunks: size must be a positive integer, got ${size}`);
|
|
348
|
+
return async function* (input) {
|
|
349
|
+
for await (const block of input) {
|
|
350
|
+
if (block.byteLength === 0) continue;
|
|
351
|
+
if (block.byteLength <= size) {
|
|
352
|
+
yield block;
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
let offset = 0;
|
|
356
|
+
while (offset < block.byteLength) {
|
|
357
|
+
const end = Math.min(offset + size, block.byteLength);
|
|
358
|
+
yield block.subarray(offset, end);
|
|
359
|
+
offset = end;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
//#endregion
|
|
365
|
+
//#region src/core/data-calls.ts
|
|
366
|
+
const TRANSFER = Symbol.for("webrun-http-browser:transfer");
|
|
367
|
+
/**
|
|
368
|
+
* A channel call's result that carries transferables, a `MessagePort` say:
|
|
369
|
+
* return `withTransfer({ port }, [port])` from a `handleChannelCalls` handler
|
|
370
|
+
* and `callChannel` resolves with `{ port }`.
|
|
371
|
+
*/
|
|
372
|
+
function withTransfer(value, transfer) {
|
|
373
|
+
return {
|
|
374
|
+
[TRANSFER]: true,
|
|
375
|
+
value,
|
|
376
|
+
transfer
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
function isTransferResult(value) {
|
|
380
|
+
return typeof value === "object" && value !== null && TRANSFER in value;
|
|
381
|
+
}
|
|
382
|
+
/** Splits a handler's result into the value to post and its transfer list. */
|
|
383
|
+
function unwrapTransfer(result) {
|
|
384
|
+
return isTransferResult(result) ? {
|
|
385
|
+
value: result.value,
|
|
386
|
+
transfer: result.transfer
|
|
387
|
+
} : {
|
|
388
|
+
value: result,
|
|
389
|
+
transfer: []
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
function callChannel(target, callType, params, ...transfers) {
|
|
393
|
+
return new Promise((resolve, reject) => {
|
|
394
|
+
const channel = new MessageChannel();
|
|
395
|
+
channel.port1.onmessage = (ev) => {
|
|
396
|
+
const { result, error } = ev.data;
|
|
397
|
+
if (error) reject(deserializeError(error));
|
|
398
|
+
else resolve(result);
|
|
399
|
+
};
|
|
400
|
+
target.postMessage({
|
|
401
|
+
type: callType,
|
|
402
|
+
params
|
|
403
|
+
}, [channel.port2, ...transfers]);
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
function handleChannelCalls(target, callType, handler) {
|
|
407
|
+
const listener = async (event) => {
|
|
408
|
+
if (!event.data || event.data.type !== callType) return;
|
|
409
|
+
const [port, ...transfers] = event.ports ?? [];
|
|
410
|
+
const response = {};
|
|
411
|
+
let transfer = [];
|
|
412
|
+
try {
|
|
413
|
+
const result = await handler(event, event.data.params, ...transfers);
|
|
414
|
+
({value: response.result, transfer} = unwrapTransfer(result));
|
|
415
|
+
} catch (error) {
|
|
416
|
+
response.error = serializeError(error);
|
|
417
|
+
}
|
|
418
|
+
port?.postMessage(response, transfer);
|
|
419
|
+
};
|
|
420
|
+
target.addEventListener("message", listener);
|
|
421
|
+
target.start?.();
|
|
422
|
+
return () => target.removeEventListener("message", listener);
|
|
423
|
+
}
|
|
424
|
+
//#endregion
|
|
425
|
+
//#region src/core/service-worker-control.ts
|
|
426
|
+
/** Channel call a page sends to ask its ServiceWorker to `clients.claim()` it. */
|
|
427
|
+
const CLAIM_CALL = "CLAIM";
|
|
428
|
+
/**
|
|
429
|
+
* ServiceWorker side: answers the page's `CLAIM` request with
|
|
430
|
+
* `clients.claim()`, which takes over every uncontrolled client in scope.
|
|
431
|
+
* Returns a function that stops answering.
|
|
432
|
+
*/
|
|
433
|
+
function handleClaimRequests(self) {
|
|
434
|
+
return handleChannelCalls(self, CLAIM_CALL, (event) => {
|
|
435
|
+
const claimed = self.clients.claim().then(() => true);
|
|
436
|
+
event.waitUntil?.(claimed);
|
|
437
|
+
return claimed;
|
|
438
|
+
});
|
|
439
|
+
}
|
|
285
440
|
//#endregion
|
|
286
441
|
//#region ../webrun-http-streams/dist/index.js
|
|
287
442
|
const CR = 13;
|
|
@@ -1033,10 +1188,160 @@
|
|
|
1033
1188
|
}
|
|
1034
1189
|
};
|
|
1035
1190
|
}
|
|
1036
|
-
|
|
1191
|
+
/**
|
|
1192
|
+
* Writes HTTP/1.1; accepts HTTP/1.1 or the legacy JSON envelope. Used whenever
|
|
1193
|
+
* no `codec` option is supplied.
|
|
1194
|
+
*/
|
|
1195
|
+
const defaultCodec = newSniffingCodec({
|
|
1037
1196
|
write: httpCodec,
|
|
1038
1197
|
accept: [httpCodec, jsonEnvelopeCodec]
|
|
1039
1198
|
});
|
|
1199
|
+
/** Carries the serialized JS error between two webrun peers. */
|
|
1200
|
+
const PEER_ERROR_HEADER = "x-webrun-error";
|
|
1201
|
+
/** Hard cap on the peer-error header (M3): see `encodeErrorResponse`. */
|
|
1202
|
+
const MAX_DETAIL_CHARS = 4096;
|
|
1203
|
+
/**
|
|
1204
|
+
* JSON with every non-printable-ASCII character escaped, so the result is a
|
|
1205
|
+
* legal latin-1 header value whatever the error message contained.
|
|
1206
|
+
*/
|
|
1207
|
+
function asciiJson(value) {
|
|
1208
|
+
return JSON.stringify(value).replace(/[^\x20-\x7E]/g, (ch) => `\\u${ch.charCodeAt(0).toString(16).padStart(4, "0")}`);
|
|
1209
|
+
}
|
|
1210
|
+
/**
|
|
1211
|
+
* Decision 13: a real HTTP peer cannot receive a JavaScript exception, only a
|
|
1212
|
+
* response. So an uncaught handler error becomes a conforming 500 whose body
|
|
1213
|
+
* carries the message, with the serialized error in a namespaced header that
|
|
1214
|
+
* another webrun peer re-throws from.
|
|
1215
|
+
*/
|
|
1216
|
+
function encodeErrorResponse(codec, error, method, status = 500, statusText = "Internal Server Error") {
|
|
1217
|
+
const serialized = serializeError(error);
|
|
1218
|
+
let detail = asciiJson(serialized);
|
|
1219
|
+
if (detail.length > MAX_DETAIL_CHARS) detail = asciiJson({ message: serialized.message });
|
|
1220
|
+
if (detail.length > MAX_DETAIL_CHARS) detail = detail.slice(0, MAX_DETAIL_CHARS);
|
|
1221
|
+
const message = serialized.message ?? statusText;
|
|
1222
|
+
return codec.encodeResponse({
|
|
1223
|
+
status,
|
|
1224
|
+
statusText,
|
|
1225
|
+
headers: [["Content-Type", "text/plain; charset=utf-8"], [PEER_ERROR_HEADER, detail]]
|
|
1226
|
+
}, [new TextEncoder().encode(message)], { method });
|
|
1227
|
+
}
|
|
1228
|
+
/**
|
|
1229
|
+
* `output` is the `Duplex` call's own generator — one logical call, per the
|
|
1230
|
+
* `Duplex` contract in `@statewalker/webrun-streams`: "Consumer `.return()`
|
|
1231
|
+
* on the output → producer's `finally` runs." On a mux transport
|
|
1232
|
+
* (`emulateMux`), that `finally` is what frees the stream-table slot; skip it
|
|
1233
|
+
* and every peer-error response leaks one slot, unboundedly, until the mux
|
|
1234
|
+
* itself is exhausted (`maxStreams` reached, every further call rejected).
|
|
1235
|
+
*
|
|
1236
|
+
* Cancelling `output` here — not inside the codec's own decode logic — is
|
|
1237
|
+
* deliberate. `codec.decodeResponse` already pulled from `output` to read the
|
|
1238
|
+
* head before this function runs, so unlike the body case above there's no
|
|
1239
|
+
* suspended-start no-op to worry about. And it's safe specifically *because*
|
|
1240
|
+
* this is the client's own response-only generator: the request was already
|
|
1241
|
+
* fully sent before we got here, so there is nothing left to write on it.
|
|
1242
|
+
* The equivalent is NOT safe inside `src/http1/decode.ts`'s `ByteReader` —
|
|
1243
|
+
* that code also runs when a caller wires `codec.decodeRequest`/
|
|
1244
|
+
* `decodeResponse` directly onto a raw bidirectional socket (see
|
|
1245
|
+
* `tests/http1-node-interop.test.ts`), where the *same* object is read from
|
|
1246
|
+
* and then written back to (a server reads the request, then replies on the
|
|
1247
|
+
* same socket); cancelling the read side there tears down the whole
|
|
1248
|
+
* connection out from under the pending write. That was tried and reverted —
|
|
1249
|
+
* see the Task 11 report for the "socket hang up" failure it caused.
|
|
1250
|
+
*/
|
|
1251
|
+
async function throwIfPeerError(result, output) {
|
|
1252
|
+
const found = result.envelope.headers.find(([k]) => k.toLowerCase() === PEER_ERROR_HEADER);
|
|
1253
|
+
if (!found) return;
|
|
1254
|
+
await discard(result.body);
|
|
1255
|
+
try {
|
|
1256
|
+
await output.return?.(void 0);
|
|
1257
|
+
} catch {}
|
|
1258
|
+
let payload;
|
|
1259
|
+
try {
|
|
1260
|
+
payload = JSON.parse(found[1]);
|
|
1261
|
+
} catch {
|
|
1262
|
+
payload = { message: found[1] };
|
|
1263
|
+
}
|
|
1264
|
+
throw deserializeError(payload);
|
|
1265
|
+
}
|
|
1266
|
+
/**
|
|
1267
|
+
* Initiate an HTTP call over a `Duplex`. The caller's `call: Duplex` is
|
|
1268
|
+
* obtained from any `webrun-streams-*` adapter's `connect`. Returns the
|
|
1269
|
+
* response envelope and an async iterable over the response body bytes.
|
|
1270
|
+
*
|
|
1271
|
+
* The call is one logical Duplex invocation; multiplexing of concurrent
|
|
1272
|
+
* calls is the adapter's concern (native or `emulateMux`).
|
|
1273
|
+
*/
|
|
1274
|
+
async function httpFetch(call, env, body, options = {}) {
|
|
1275
|
+
const codec = options.codec ?? defaultCodec;
|
|
1276
|
+
const output = call(codec.encodeRequest(env, body));
|
|
1277
|
+
const decoded = await codec.decodeResponse(output, { method: env.method });
|
|
1278
|
+
const release = async () => {
|
|
1279
|
+
try {
|
|
1280
|
+
await output.return?.(void 0);
|
|
1281
|
+
} catch {}
|
|
1282
|
+
};
|
|
1283
|
+
const result = {
|
|
1284
|
+
envelope: decoded.envelope,
|
|
1285
|
+
body: releasingOnExit(decoded.body, release),
|
|
1286
|
+
release
|
|
1287
|
+
};
|
|
1288
|
+
await throwIfPeerError(result, output);
|
|
1289
|
+
return result;
|
|
1290
|
+
}
|
|
1291
|
+
/**
|
|
1292
|
+
* `body`, but leaving it before its end releases the call.
|
|
1293
|
+
*
|
|
1294
|
+
* The body the codec hands back is a generator of its own over a `ByteReader`,
|
|
1295
|
+
* and `.return()` on it never reaches the call's `output` (see
|
|
1296
|
+
* `throwIfPeerError` for why the codec must not do that itself). So a caller
|
|
1297
|
+
* that stopped reading — an aborted `fetch`, a cancelled response stream —
|
|
1298
|
+
* left the peer's producer parked forever, holding its handler open. Safe here
|
|
1299
|
+
* for the same reason it is safe in `throwIfPeerError`: `output` is this
|
|
1300
|
+
* call's own response-only generator, and the request was fully sent.
|
|
1301
|
+
*/
|
|
1302
|
+
async function* releasingOnExit(body, release) {
|
|
1303
|
+
let completed = false;
|
|
1304
|
+
try {
|
|
1305
|
+
yield* body;
|
|
1306
|
+
completed = true;
|
|
1307
|
+
} finally {
|
|
1308
|
+
if (!completed) await release();
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
/**
|
|
1312
|
+
* Wrap an HTTP handler as a `Duplex` so it can be registered with any
|
|
1313
|
+
* `webrun-streams-*` adapter's `serve`.
|
|
1314
|
+
*/
|
|
1315
|
+
function httpServe(handler, options = {}) {
|
|
1316
|
+
const codec = options.codec ?? defaultCodec;
|
|
1317
|
+
return async function* httpHandlerDuplex(input) {
|
|
1318
|
+
let decoded;
|
|
1319
|
+
try {
|
|
1320
|
+
decoded = await codec.decodeRequest(input);
|
|
1321
|
+
} catch (error) {
|
|
1322
|
+
if (!(error instanceof HttpParseError)) throw error;
|
|
1323
|
+
yield* encodeErrorResponse(error.codec ?? codec, error, "GET", 400, "Bad Request");
|
|
1324
|
+
return;
|
|
1325
|
+
}
|
|
1326
|
+
const replyCodec = decoded.codec ?? codec;
|
|
1327
|
+
let result;
|
|
1328
|
+
try {
|
|
1329
|
+
result = await handler(decoded.envelope, decoded.body);
|
|
1330
|
+
} catch (error) {
|
|
1331
|
+
yield* encodeErrorResponse(replyCodec, error, decoded.envelope.method);
|
|
1332
|
+
return;
|
|
1333
|
+
}
|
|
1334
|
+
let completed = false;
|
|
1335
|
+
try {
|
|
1336
|
+
yield* replyCodec.encodeResponse(result.envelope, result.body, { method: decoded.envelope.method });
|
|
1337
|
+
completed = true;
|
|
1338
|
+
} finally {
|
|
1339
|
+
if (!completed) try {
|
|
1340
|
+
await result.release?.();
|
|
1341
|
+
} catch {}
|
|
1342
|
+
}
|
|
1343
|
+
};
|
|
1344
|
+
}
|
|
1040
1345
|
/**
|
|
1041
1346
|
* The one place that knows a runtime may not implement request body streams,
|
|
1042
1347
|
* and the buffering both fallbacks need. `fetch.ts` and `http-stubs.ts` each
|
|
@@ -1080,6 +1385,31 @@
|
|
|
1080
1385
|
function supportsRequestStreams() {
|
|
1081
1386
|
return typeof Request === "function" && Object.getOwnPropertyDescriptor(Request.prototype, "body") != null;
|
|
1082
1387
|
}
|
|
1388
|
+
/**
|
|
1389
|
+
* Drain a body iterable into one contiguous buffer. Only the fallbacks need it.
|
|
1390
|
+
* Allocates its own buffer rather than reusing `bytes.ts`'s `concatChunks`,
|
|
1391
|
+
* which passes a single chunk straight through: that chunk is a view onto the
|
|
1392
|
+
* decoder's own read buffer, and the result here is handed to `new Request` and
|
|
1393
|
+
* outlives the decode. The allocation is also what makes it a
|
|
1394
|
+
* `Uint8Array<ArrayBuffer>`, which `BodyInit` accepts and the looser
|
|
1395
|
+
* `Uint8Array<ArrayBufferLike>` does not.
|
|
1396
|
+
*/
|
|
1397
|
+
async function collectBytes(source) {
|
|
1398
|
+
const parts = [];
|
|
1399
|
+
let total = 0;
|
|
1400
|
+
for await (const chunk of source) {
|
|
1401
|
+
if (chunk.byteLength === 0) continue;
|
|
1402
|
+
parts.push(chunk);
|
|
1403
|
+
total += chunk.byteLength;
|
|
1404
|
+
}
|
|
1405
|
+
const out = new Uint8Array(total);
|
|
1406
|
+
let offset = 0;
|
|
1407
|
+
for (const part of parts) {
|
|
1408
|
+
out.set(part, offset);
|
|
1409
|
+
offset += part.byteLength;
|
|
1410
|
+
}
|
|
1411
|
+
return out;
|
|
1412
|
+
}
|
|
1083
1413
|
const NULL_BODY_STATUSES = /* @__PURE__ */ new Set([
|
|
1084
1414
|
101,
|
|
1085
1415
|
103,
|
|
@@ -1142,6 +1472,157 @@
|
|
|
1142
1472
|
return new Response(toReadableStream(result.content[Symbol.asyncIterator]()), responseOptions);
|
|
1143
1473
|
};
|
|
1144
1474
|
}
|
|
1475
|
+
/**
|
|
1476
|
+
* Connection-scoped headers. The codec surfaces them verbatim (decision 12),
|
|
1477
|
+
* but they are meaningless to a `Request`/`Response`, and re-emitting them
|
|
1478
|
+
* from a relay would corrupt its framing.
|
|
1479
|
+
*/
|
|
1480
|
+
const HOP_BY_HOP = /* @__PURE__ */ new Set([
|
|
1481
|
+
"connection",
|
|
1482
|
+
"host",
|
|
1483
|
+
"keep-alive",
|
|
1484
|
+
"proxy-authenticate",
|
|
1485
|
+
"proxy-authorization",
|
|
1486
|
+
"te",
|
|
1487
|
+
"trailer",
|
|
1488
|
+
"transfer-encoding",
|
|
1489
|
+
"upgrade"
|
|
1490
|
+
]);
|
|
1491
|
+
function forwardableHeaders(headers) {
|
|
1492
|
+
return headers.filter(([name]) => !HOP_BY_HOP.has(name.toLowerCase()));
|
|
1493
|
+
}
|
|
1494
|
+
function headersToArray(headers) {
|
|
1495
|
+
const out = [];
|
|
1496
|
+
headers.forEach((value, key) => {
|
|
1497
|
+
out.push([key, value]);
|
|
1498
|
+
});
|
|
1499
|
+
return out;
|
|
1500
|
+
}
|
|
1501
|
+
async function* readableToAsyncIterable(stream) {
|
|
1502
|
+
if (!stream) return;
|
|
1503
|
+
const reader = stream.getReader();
|
|
1504
|
+
let consumed = false;
|
|
1505
|
+
try {
|
|
1506
|
+
while (true) {
|
|
1507
|
+
const { done, value } = await reader.read();
|
|
1508
|
+
if (done) {
|
|
1509
|
+
consumed = true;
|
|
1510
|
+
return;
|
|
1511
|
+
}
|
|
1512
|
+
if (value) yield value;
|
|
1513
|
+
}
|
|
1514
|
+
} finally {
|
|
1515
|
+
if (!consumed) try {
|
|
1516
|
+
await reader.cancel();
|
|
1517
|
+
} catch {}
|
|
1518
|
+
try {
|
|
1519
|
+
reader.releaseLock();
|
|
1520
|
+
} catch {}
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
function asyncIterableToReadable(iter) {
|
|
1524
|
+
const it = iter[Symbol.asyncIterator]();
|
|
1525
|
+
return new ReadableStream({
|
|
1526
|
+
async pull(controller) {
|
|
1527
|
+
try {
|
|
1528
|
+
const { done, value } = await it.next();
|
|
1529
|
+
if (done) {
|
|
1530
|
+
controller.close();
|
|
1531
|
+
return;
|
|
1532
|
+
}
|
|
1533
|
+
controller.enqueue(value);
|
|
1534
|
+
} catch (err) {
|
|
1535
|
+
controller.error(err);
|
|
1536
|
+
}
|
|
1537
|
+
},
|
|
1538
|
+
async cancel() {
|
|
1539
|
+
await it.return?.();
|
|
1540
|
+
}
|
|
1541
|
+
});
|
|
1542
|
+
}
|
|
1543
|
+
/**
|
|
1544
|
+
* Run a `Request` through a `Duplex` call and reconstruct the `Response` on
|
|
1545
|
+
* the other side. The request's `signal` is plumbed into the body iteration —
|
|
1546
|
+
* abort terminates the underlying call.
|
|
1547
|
+
*/
|
|
1548
|
+
async function fetchOverDuplex(call, request, options = {}) {
|
|
1549
|
+
if (request.signal?.aborted) throw abortReason$1(request.signal);
|
|
1550
|
+
const env = {
|
|
1551
|
+
url: request.url,
|
|
1552
|
+
method: request.method,
|
|
1553
|
+
headers: headersToArray(request.headers)
|
|
1554
|
+
};
|
|
1555
|
+
let body;
|
|
1556
|
+
if (request.body != null) body = readableToAsyncIterable(request.body);
|
|
1557
|
+
else if (!supportsRequestStreams()) {
|
|
1558
|
+
const buffered = new Uint8Array(await request.arrayBuffer());
|
|
1559
|
+
if (buffered.byteLength > 0) body = [buffered];
|
|
1560
|
+
}
|
|
1561
|
+
const { envelope, body: respBody, release } = await httpFetch(call, env, body, options);
|
|
1562
|
+
const responseInit = {
|
|
1563
|
+
status: envelope.status,
|
|
1564
|
+
statusText: envelope.statusText,
|
|
1565
|
+
headers: forwardableHeaders(envelope.headers)
|
|
1566
|
+
};
|
|
1567
|
+
if (request.method === "HEAD" || request.method === "OPTIONS" || NULL_BODY_STATUSES.has(envelope.status)) {
|
|
1568
|
+
await release?.();
|
|
1569
|
+
return new Response(null, responseInit);
|
|
1570
|
+
}
|
|
1571
|
+
return new Response(asyncIterableToReadable(withAbort(respBody, request.signal)), responseInit);
|
|
1572
|
+
}
|
|
1573
|
+
/**
|
|
1574
|
+
* Wrap a `(Request) => Promise<Response>` handler as a `Duplex` so it can be
|
|
1575
|
+
* registered with any `webrun-streams-*` adapter's `serve`.
|
|
1576
|
+
*/
|
|
1577
|
+
function serveFetchOverDuplex(handler, options = {}) {
|
|
1578
|
+
return httpServe(async (env, body) => {
|
|
1579
|
+
const reqInit = {
|
|
1580
|
+
method: env.method,
|
|
1581
|
+
headers: forwardableHeaders(env.headers)
|
|
1582
|
+
};
|
|
1583
|
+
if (env.method !== "GET" && env.method !== "HEAD") {
|
|
1584
|
+
if (supportsRequestStreams()) {
|
|
1585
|
+
reqInit.body = asyncIterableToReadable(body);
|
|
1586
|
+
reqInit.duplex = "half";
|
|
1587
|
+
} else reqInit.body = await collectBytes(body);
|
|
1588
|
+
}
|
|
1589
|
+
const response = await handler(new Request(env.url, reqInit));
|
|
1590
|
+
const respEnv = {
|
|
1591
|
+
status: response.status,
|
|
1592
|
+
statusText: response.statusText,
|
|
1593
|
+
headers: headersToArray(response.headers)
|
|
1594
|
+
};
|
|
1595
|
+
if (env.method === "HEAD" || env.method === "OPTIONS" || NULL_BODY_STATUSES.has(response.status)) {
|
|
1596
|
+
await response.body?.cancel();
|
|
1597
|
+
return {
|
|
1598
|
+
envelope: respEnv,
|
|
1599
|
+
body: void 0
|
|
1600
|
+
};
|
|
1601
|
+
}
|
|
1602
|
+
return {
|
|
1603
|
+
envelope: respEnv,
|
|
1604
|
+
body: response.body ? readableToAsyncIterable(response.body) : void 0,
|
|
1605
|
+
release: () => response.body?.cancel()
|
|
1606
|
+
};
|
|
1607
|
+
}, options);
|
|
1608
|
+
}
|
|
1609
|
+
async function* withAbort(iter, signal) {
|
|
1610
|
+
if (!signal) {
|
|
1611
|
+
yield* iter;
|
|
1612
|
+
return;
|
|
1613
|
+
}
|
|
1614
|
+
for await (const chunk of iter) {
|
|
1615
|
+
if (signal.aborted) throw abortReason$1(signal);
|
|
1616
|
+
yield chunk;
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
function abortReason$1(signal) {
|
|
1620
|
+
const reason = signal.reason;
|
|
1621
|
+
if (reason instanceof Error) return reason;
|
|
1622
|
+
const err = new Error(reason === void 0 ? "Aborted" : String(reason));
|
|
1623
|
+
err.name = "AbortError";
|
|
1624
|
+
return err;
|
|
1625
|
+
}
|
|
1145
1626
|
//#endregion
|
|
1146
1627
|
//#region src/core/data-channels.ts
|
|
1147
1628
|
const MESSAGE_TYPE_REQUEST = "REQUEST";
|
|
@@ -1341,266 +1822,1649 @@
|
|
|
1341
1822
|
})(request);
|
|
1342
1823
|
}
|
|
1343
1824
|
//#endregion
|
|
1344
|
-
//#region
|
|
1345
|
-
function
|
|
1346
|
-
return
|
|
1347
|
-
request.oncomplete = request.onsuccess = () => resolve(request.result);
|
|
1348
|
-
request.onabort = request.onerror = () => reject(request.error);
|
|
1349
|
-
});
|
|
1350
|
-
}
|
|
1351
|
-
function createStore(dbName, storeName) {
|
|
1352
|
-
let dbp;
|
|
1353
|
-
const getDB = () => {
|
|
1354
|
-
if (dbp) return dbp;
|
|
1355
|
-
const request = indexedDB.open(dbName);
|
|
1356
|
-
request.onupgradeneeded = () => request.result.createObjectStore(storeName);
|
|
1357
|
-
dbp = promisifyRequest(request);
|
|
1358
|
-
dbp.then((db) => {
|
|
1359
|
-
db.onclose = () => dbp = void 0;
|
|
1360
|
-
}, () => {
|
|
1361
|
-
dbp = void 0;
|
|
1362
|
-
});
|
|
1363
|
-
return dbp;
|
|
1364
|
-
};
|
|
1365
|
-
return (txMode, callback) => getDB().then((db) => callback(db.transaction(storeName, txMode).objectStore(storeName)));
|
|
1366
|
-
}
|
|
1367
|
-
let defaultGetStoreFunc;
|
|
1368
|
-
function defaultGetStore() {
|
|
1369
|
-
if (!defaultGetStoreFunc) defaultGetStoreFunc = createStore("keyval-store", "keyval");
|
|
1370
|
-
return defaultGetStoreFunc;
|
|
1825
|
+
//#region src/link/errors.ts
|
|
1826
|
+
function errorMessage(error) {
|
|
1827
|
+
return error instanceof Error ? error.message : String(error);
|
|
1371
1828
|
}
|
|
1829
|
+
//#endregion
|
|
1830
|
+
//#region ../webrun-rpc/dist/index.js
|
|
1831
|
+
const closeSignals = /* @__PURE__ */ new WeakMap();
|
|
1372
1832
|
/**
|
|
1373
|
-
*
|
|
1833
|
+
* Internal: register a port's close signal. Called once by `bindBytesToPort`.
|
|
1374
1834
|
*
|
|
1375
|
-
* @
|
|
1376
|
-
* @param customStore Method to get a custom store. Use with caution (see the docs).
|
|
1835
|
+
* @internal
|
|
1377
1836
|
*/
|
|
1378
|
-
function
|
|
1379
|
-
|
|
1837
|
+
function setPortCloseSignal(port, signal) {
|
|
1838
|
+
closeSignals.set(port, signal);
|
|
1380
1839
|
}
|
|
1381
1840
|
/**
|
|
1382
|
-
*
|
|
1383
|
-
*
|
|
1384
|
-
*
|
|
1385
|
-
* @param value
|
|
1386
|
-
* @param customStore Method to get a custom store. Use with caution (see the docs).
|
|
1841
|
+
* Return the AbortSignal that fires when `port`'s transport closes, or
|
|
1842
|
+
* `undefined` if `port` is not transport-backed (e.g., a raw
|
|
1843
|
+
* `MessageChannel().port1`).
|
|
1387
1844
|
*/
|
|
1388
|
-
function
|
|
1389
|
-
return
|
|
1390
|
-
store.put(value, key);
|
|
1391
|
-
return promisifyRequest(store.transaction);
|
|
1392
|
-
});
|
|
1845
|
+
function getPortCloseSignal(port) {
|
|
1846
|
+
return closeSignals.get(port);
|
|
1393
1847
|
}
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1848
|
+
/**
|
|
1849
|
+
* Pass as `timeout` to install no deadline at all. Used by the stream tier,
|
|
1850
|
+
* where the deadline belongs to the stream rather than to one chunk (spec D8):
|
|
1851
|
+
* a slow consumer is throttled, never failed.
|
|
1852
|
+
*/
|
|
1853
|
+
const NO_TIMEOUT = Number.POSITIVE_INFINITY;
|
|
1854
|
+
/**
|
|
1855
|
+
* Asynchronous request/response over any `MessageTarget`.
|
|
1856
|
+
*
|
|
1857
|
+
* Sends `params` to the peer listening with `listenPort`, waits up to
|
|
1858
|
+
* `timeout` ms for a matching reply, and either resolves with the result or
|
|
1859
|
+
* rejects with the deserialised error.
|
|
1860
|
+
*/
|
|
1861
|
+
function callPort(port, params, { timeout = 1e3, channelName = "", log = () => {}, newCallId = () => `call-${Date.now()}-${String(Math.random()).substring(2)}`, signal } = {}) {
|
|
1862
|
+
const callId = newCallId();
|
|
1863
|
+
log("[callPort]", {
|
|
1864
|
+
channelName,
|
|
1865
|
+
callId,
|
|
1866
|
+
params
|
|
1867
|
+
});
|
|
1868
|
+
const combinedSignal = combineSignals(signal, getPortCloseSignal(port));
|
|
1869
|
+
let timerId;
|
|
1870
|
+
let onMessage;
|
|
1871
|
+
let onAbort;
|
|
1872
|
+
const promise = new Promise((resolve, reject) => {
|
|
1873
|
+
if (combinedSignal?.aborted) {
|
|
1874
|
+
reject(abortReason(combinedSignal));
|
|
1875
|
+
return;
|
|
1876
|
+
}
|
|
1877
|
+
if (Number.isFinite(timeout) && timeout > 0) timerId = setTimeout(() => reject(/* @__PURE__ */ new Error(`Call timeout. CallId: "${callId}".`)), timeout);
|
|
1878
|
+
onMessage = (event) => {
|
|
1879
|
+
const data = event.data;
|
|
1880
|
+
if (!data) return;
|
|
1881
|
+
if (data.channelName !== channelName) return;
|
|
1882
|
+
if (data.callId !== callId) return;
|
|
1883
|
+
if (data.type === "response:error") reject(deserializeError(data.error));
|
|
1884
|
+
else if (data.type === "response:result") resolve(data.result);
|
|
1403
1885
|
};
|
|
1404
|
-
|
|
1405
|
-
|
|
1886
|
+
port.addEventListener("message", onMessage);
|
|
1887
|
+
if (combinedSignal) {
|
|
1888
|
+
onAbort = () => reject(abortReason(combinedSignal));
|
|
1889
|
+
combinedSignal.addEventListener("abort", onAbort, { once: true });
|
|
1890
|
+
}
|
|
1891
|
+
});
|
|
1892
|
+
promise.catch(() => {}).finally(() => {
|
|
1893
|
+
if (timerId !== void 0) clearTimeout(timerId);
|
|
1894
|
+
if (onMessage) port.removeEventListener("message", onMessage);
|
|
1895
|
+
if (onAbort && combinedSignal) combinedSignal.removeEventListener("abort", onAbort);
|
|
1896
|
+
});
|
|
1897
|
+
port.postMessage({
|
|
1898
|
+
type: "request",
|
|
1899
|
+
channelName,
|
|
1900
|
+
callId,
|
|
1901
|
+
params
|
|
1902
|
+
});
|
|
1903
|
+
return promise;
|
|
1904
|
+
}
|
|
1905
|
+
function abortReason(signal) {
|
|
1906
|
+
const reason = signal.reason;
|
|
1907
|
+
if (reason instanceof Error) return reason;
|
|
1908
|
+
const err = new Error(reason === void 0 ? "Aborted" : String(reason));
|
|
1909
|
+
err.name = "AbortError";
|
|
1910
|
+
return err;
|
|
1911
|
+
}
|
|
1912
|
+
function combineSignals(...signals) {
|
|
1913
|
+
const live = signals.filter((s) => s !== void 0);
|
|
1914
|
+
if (live.length === 0) return void 0;
|
|
1915
|
+
if (live.length === 1) return live[0];
|
|
1916
|
+
return AbortSignal.any(live);
|
|
1917
|
+
}
|
|
1918
|
+
/**
|
|
1919
|
+
* Installs `handler` as the server side of a `callPort` / `listenPort`
|
|
1920
|
+
* request/response pair on `port`.
|
|
1921
|
+
*
|
|
1922
|
+
* Returns a cleanup function that removes the listener.
|
|
1923
|
+
*/
|
|
1924
|
+
function listenPort(port, handler, { channelName = "", log = () => {} } = {}) {
|
|
1925
|
+
const onMessage = async (event) => {
|
|
1926
|
+
const data = event.data;
|
|
1927
|
+
if (!data || data.channelName !== channelName || data.type !== "request") return;
|
|
1928
|
+
const { callId, params } = data;
|
|
1929
|
+
log("[listenPort]", {
|
|
1930
|
+
channelName,
|
|
1931
|
+
callId,
|
|
1406
1932
|
params
|
|
1407
|
-
}
|
|
1933
|
+
});
|
|
1934
|
+
let result;
|
|
1935
|
+
let error;
|
|
1936
|
+
let type;
|
|
1937
|
+
try {
|
|
1938
|
+
result = await handler(params);
|
|
1939
|
+
type = "response:result";
|
|
1940
|
+
} catch (e) {
|
|
1941
|
+
error = serializeError(e);
|
|
1942
|
+
type = "response:error";
|
|
1943
|
+
}
|
|
1944
|
+
port.postMessage({
|
|
1945
|
+
callId,
|
|
1946
|
+
channelName,
|
|
1947
|
+
type,
|
|
1948
|
+
result,
|
|
1949
|
+
error
|
|
1950
|
+
});
|
|
1951
|
+
};
|
|
1952
|
+
port.addEventListener("message", onMessage);
|
|
1953
|
+
return () => port.removeEventListener("message", onMessage);
|
|
1954
|
+
}
|
|
1955
|
+
/**
|
|
1956
|
+
* Wraps an async iterable so that an `AbortSignal` firing causes the wrapper
|
|
1957
|
+
* to return cleanly, forwarding `return()` to the underlying iterator so the
|
|
1958
|
+
* producer (e.g., a user-supplied generator) sees its own `finally` blocks
|
|
1959
|
+
* run immediately rather than waiting for the next yield.
|
|
1960
|
+
*/
|
|
1961
|
+
async function* throughAbort(input, signal) {
|
|
1962
|
+
const iter = input[Symbol.asyncIterator] ? input[Symbol.asyncIterator]() : input[Symbol.iterator]();
|
|
1963
|
+
const onAbort = () => {
|
|
1964
|
+
iter.return?.(void 0);
|
|
1965
|
+
};
|
|
1966
|
+
if (signal.aborted) {
|
|
1967
|
+
iter.return?.(void 0);
|
|
1968
|
+
return;
|
|
1969
|
+
}
|
|
1970
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1971
|
+
try {
|
|
1972
|
+
while (true) {
|
|
1973
|
+
const r = await iter.next();
|
|
1974
|
+
if (r.done) return;
|
|
1975
|
+
if (signal.aborted) return;
|
|
1976
|
+
yield r.value;
|
|
1977
|
+
}
|
|
1978
|
+
} finally {
|
|
1979
|
+
signal.removeEventListener("abort", onAbort);
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
/**
|
|
1983
|
+
* The `type` of the out-of-band notice a side posts when it abandons a stream.
|
|
1984
|
+
*
|
|
1985
|
+
* Layer 1's `close` is not observable to layer 2 — a closed virtual port drops
|
|
1986
|
+
* its listeners silently and is indistinguishable from a working port nobody
|
|
1987
|
+
* is answering — so the peer would otherwise wait forever. Exported because
|
|
1988
|
+
* tests and adapters assert on it.
|
|
1989
|
+
*/
|
|
1990
|
+
const STREAM_ABORT = "webrun-rpc:stream-abort";
|
|
1991
|
+
/** Caller's input travels on this channel; the handler listens on it. */
|
|
1992
|
+
const CHANNEL_IN = "in";
|
|
1993
|
+
/** The handler's output travels on this channel; the caller listens on it. */
|
|
1994
|
+
const CHANNEL_OUT = "out";
|
|
1995
|
+
/**
|
|
1996
|
+
* One port in, one `Duplex` out (spec D9).
|
|
1997
|
+
*
|
|
1998
|
+
* The returned `Duplex` runs a single stream on `port`: the caller's `input`
|
|
1999
|
+
* is sent chunk by chunk with `callPort`, and the handler's output arrives the
|
|
2000
|
+
* same way on the other channel. Within each direction the next chunk is never
|
|
2001
|
+
* sent until the previous one has been delivered *and* pulled past by the
|
|
2002
|
+
* consumer (spec D11) — the reply to a chunk call *is* the confirmation, and
|
|
2003
|
+
* `listenPort` withholds it until then.
|
|
2004
|
+
*
|
|
2005
|
+
* A stream port carries exactly one invocation. To make several calls, open
|
|
2006
|
+
* several ports: `mux.openPort({ kind: "stream" })` per call.
|
|
2007
|
+
*/
|
|
2008
|
+
function duplexOverPort(port, options = {}) {
|
|
2009
|
+
return (input) => runCallerSide(port, input, options);
|
|
2010
|
+
}
|
|
2011
|
+
/**
|
|
2012
|
+
* Installs `handler` as the serving side of one stream on `port`. Returns an
|
|
2013
|
+
* idempotent teardown that abandons the stream and notifies the peer.
|
|
2014
|
+
*/
|
|
2015
|
+
function serveDuplexOverPort(port, handler, options = {}) {
|
|
2016
|
+
const controller = new AbortController();
|
|
2017
|
+
const notice = installAbortNotice(port, controller);
|
|
2018
|
+
abortOnPortClose(port, controller);
|
|
2019
|
+
const clock = installStreamTimeout(controller, options.timeout);
|
|
2020
|
+
const inbound = receiveChunks(port, CHANNEL_IN, controller, clock.touch);
|
|
2021
|
+
let output;
|
|
2022
|
+
try {
|
|
2023
|
+
output = handler(inbound.stream);
|
|
2024
|
+
} catch (err) {
|
|
2025
|
+
const pump = sendChunks(port, CHANNEL_OUT, failing(err), options, controller.signal, clock.touch);
|
|
2026
|
+
pump.catch(() => {});
|
|
2027
|
+
disarmClockWhenBothSidesSettle(clock, pump, inbound.ended);
|
|
2028
|
+
return teardownOnce(controller, notice, clock, inbound, void 0);
|
|
2029
|
+
}
|
|
2030
|
+
const pump = sendChunks(port, CHANNEL_OUT, output, options, controller.signal, clock.touch);
|
|
2031
|
+
pump.catch(() => {});
|
|
2032
|
+
disarmClockWhenBothSidesSettle(clock, pump, inbound.ended);
|
|
2033
|
+
return teardownOnce(controller, notice, clock, inbound, output);
|
|
2034
|
+
}
|
|
2035
|
+
/**
|
|
2036
|
+
* A stream that completes normally has nobody left to call the returned
|
|
2037
|
+
* teardown — the caller only knows the stream ended, not that it must also
|
|
2038
|
+
* dispose the serve-side handle. Without this, the last `touch()`'s timer
|
|
2039
|
+
* stays armed for up to `timeout` ms after real completion, doing nothing
|
|
2040
|
+
* but holding the event loop open.
|
|
2041
|
+
*
|
|
2042
|
+
* Only disarms once BOTH `pump` (our output) and `inboundEnded` (the peer's
|
|
2043
|
+
* declared end of input, or an abort) have settled. Disarming on `pump`
|
|
2044
|
+
* alone is not safe: under half-close the handler can finish producing
|
|
2045
|
+
* output while the caller is still sending input, and a clock stopped then
|
|
2046
|
+
* would leave that still-open half with no deadline at all — worse than the
|
|
2047
|
+
* leak this fixes.
|
|
2048
|
+
*/
|
|
2049
|
+
function disarmClockWhenBothSidesSettle(clock, pump, inboundEnded) {
|
|
2050
|
+
Promise.allSettled([pump, inboundEnded]).then(() => {
|
|
2051
|
+
clock.stop();
|
|
1408
2052
|
});
|
|
1409
2053
|
}
|
|
1410
|
-
function
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
2054
|
+
async function* failing(err) {
|
|
2055
|
+
throw err;
|
|
2056
|
+
}
|
|
2057
|
+
function teardownOnce(controller, notice, clock, inbound, output) {
|
|
2058
|
+
let torn = false;
|
|
2059
|
+
return () => {
|
|
2060
|
+
if (torn) return;
|
|
2061
|
+
torn = true;
|
|
2062
|
+
if (!controller.signal.aborted) controller.abort(/* @__PURE__ */ new Error("webrun-rpc: stream torn down"));
|
|
2063
|
+
notice.post();
|
|
2064
|
+
notice.stop();
|
|
2065
|
+
clock.stop();
|
|
2066
|
+
inbound.stop();
|
|
2067
|
+
output?.return(void 0).catch(() => {});
|
|
2068
|
+
};
|
|
2069
|
+
}
|
|
2070
|
+
function runCallerSide(port, input, options) {
|
|
2071
|
+
const controller = new AbortController();
|
|
2072
|
+
const notice = installAbortNotice(port, controller);
|
|
2073
|
+
abortOnPortClose(port, controller);
|
|
2074
|
+
const clock = installStreamTimeout(controller, options.timeout);
|
|
2075
|
+
const inbound = receiveChunks(port, CHANNEL_OUT, controller, clock.touch);
|
|
2076
|
+
const pump = sendChunks(port, CHANNEL_IN, input, options, controller.signal, clock.touch);
|
|
2077
|
+
pump.catch(() => {});
|
|
2078
|
+
return (async function* () {
|
|
1415
2079
|
try {
|
|
1416
|
-
|
|
1417
|
-
}
|
|
1418
|
-
|
|
2080
|
+
yield* inbound.stream;
|
|
2081
|
+
} finally {
|
|
2082
|
+
if (!controller.signal.aborted) controller.abort(/* @__PURE__ */ new Error("webrun-rpc: the caller abandoned the stream"));
|
|
2083
|
+
notice.post();
|
|
2084
|
+
notice.stop();
|
|
2085
|
+
clock.stop();
|
|
2086
|
+
inbound.stop();
|
|
2087
|
+
await pump.catch(() => {});
|
|
2088
|
+
try {
|
|
2089
|
+
await port.close?.();
|
|
2090
|
+
} catch {}
|
|
2091
|
+
}
|
|
2092
|
+
})();
|
|
2093
|
+
}
|
|
2094
|
+
/**
|
|
2095
|
+
* Listens for the peer's abort notice, and can post our own. The notice is a
|
|
2096
|
+
* plain message with no `channelName` and a `type` that is not `"request"`,
|
|
2097
|
+
* so neither `callPort` nor `listenPort` reacts to it, and it carries no
|
|
2098
|
+
* numeric `id`, so `structuredCodec` never mistakes it for a layer 1 envelope.
|
|
2099
|
+
*/
|
|
2100
|
+
function installAbortNotice(port, controller) {
|
|
2101
|
+
const onMessage = (event) => {
|
|
2102
|
+
const data = event.data;
|
|
2103
|
+
if (!data || data.type !== "webrun-rpc:stream-abort") return;
|
|
2104
|
+
if (!controller.signal.aborted) controller.abort(/* @__PURE__ */ new Error("webrun-rpc: the peer abandoned the stream"));
|
|
2105
|
+
};
|
|
2106
|
+
port.addEventListener("message", onMessage);
|
|
2107
|
+
let posted = false;
|
|
2108
|
+
return {
|
|
2109
|
+
post() {
|
|
2110
|
+
if (posted) return;
|
|
2111
|
+
posted = true;
|
|
2112
|
+
try {
|
|
2113
|
+
port.postMessage({ type: STREAM_ABORT });
|
|
2114
|
+
} catch {}
|
|
2115
|
+
},
|
|
2116
|
+
stop() {
|
|
2117
|
+
port.removeEventListener("message", onMessage);
|
|
1419
2118
|
}
|
|
1420
|
-
port?.postMessage(response);
|
|
1421
2119
|
};
|
|
1422
|
-
target.addEventListener("message", listener);
|
|
1423
|
-
target.start?.();
|
|
1424
|
-
return () => target.removeEventListener("message", listener);
|
|
1425
2120
|
}
|
|
2121
|
+
/**
|
|
2122
|
+
* Aborts the stream when its port closes underneath it — the multiplexer was
|
|
2123
|
+
* closed, or the peer closed the port — for ports that report it (see
|
|
2124
|
+
* `getPortCloseSignal`). Without this a stream waiting for the peer's next
|
|
2125
|
+
* chunk would wait forever on a port that can no longer deliver one: the
|
|
2126
|
+
* outbound half's `callPort` already fails on the close signal, but nothing
|
|
2127
|
+
* failed the inbound half.
|
|
2128
|
+
*/
|
|
2129
|
+
function abortOnPortClose(port, controller) {
|
|
2130
|
+
const closed = getPortCloseSignal(port);
|
|
2131
|
+
if (!closed) return;
|
|
2132
|
+
const onClose = () => {
|
|
2133
|
+
if (!controller.signal.aborted) controller.abort(closed.reason);
|
|
2134
|
+
};
|
|
2135
|
+
if (closed.aborted) {
|
|
2136
|
+
onClose();
|
|
2137
|
+
return;
|
|
2138
|
+
}
|
|
2139
|
+
closed.addEventListener("abort", onClose, { once: true });
|
|
2140
|
+
controller.signal.addEventListener("abort", () => closed.removeEventListener("abort", onClose), { once: true });
|
|
2141
|
+
}
|
|
2142
|
+
/**
|
|
2143
|
+
* The per-stream inactivity timeout (spec D8). Reset by any chunk in either
|
|
2144
|
+
* direction; elapsing aborts the stream. Unset, zero or non-finite installs no
|
|
2145
|
+
* timer at all, which is the default: a slow consumer is throttled, not failed.
|
|
2146
|
+
*/
|
|
2147
|
+
function installStreamTimeout(controller, timeout) {
|
|
2148
|
+
if (timeout === void 0 || !Number.isFinite(timeout) || timeout <= 0) return {
|
|
2149
|
+
touch() {},
|
|
2150
|
+
stop() {}
|
|
2151
|
+
};
|
|
2152
|
+
let timer;
|
|
2153
|
+
let stopped = false;
|
|
2154
|
+
const arm = () => {
|
|
2155
|
+
timer = setTimeout(() => {
|
|
2156
|
+
if (!controller.signal.aborted) controller.abort(/* @__PURE__ */ new Error(`webrun-rpc: stream idle for ${timeout} ms`));
|
|
2157
|
+
}, timeout);
|
|
2158
|
+
};
|
|
2159
|
+
arm();
|
|
2160
|
+
return {
|
|
2161
|
+
touch() {
|
|
2162
|
+
if (stopped) return;
|
|
2163
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
2164
|
+
arm();
|
|
2165
|
+
},
|
|
2166
|
+
stop() {
|
|
2167
|
+
stopped = true;
|
|
2168
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
2169
|
+
timer = void 0;
|
|
2170
|
+
}
|
|
2171
|
+
};
|
|
2172
|
+
}
|
|
2173
|
+
/**
|
|
2174
|
+
* The receiving half of one direction.
|
|
2175
|
+
*
|
|
2176
|
+
* The `listenPort` listener is installed **eagerly**, not lazily inside
|
|
2177
|
+
* `recieveIterator`'s installer, because a handler that never drains its input
|
|
2178
|
+
* would otherwise leave the peer's chunk calls with nobody to answer them. If
|
|
2179
|
+
* the local consumer has not started iterating, an inbound chunk waits for it —
|
|
2180
|
+
* which is the correct backpressure, and different from having no listener.
|
|
2181
|
+
*/
|
|
2182
|
+
function receiveChunks(port, channelName, controller, touch) {
|
|
2183
|
+
let deliver;
|
|
2184
|
+
const waiting = [];
|
|
2185
|
+
let finished = false;
|
|
2186
|
+
let outstanding = false;
|
|
2187
|
+
let poison;
|
|
2188
|
+
let resolveEnded = () => {};
|
|
2189
|
+
const ended = new Promise((resolve) => {
|
|
2190
|
+
resolveEnded = resolve;
|
|
2191
|
+
});
|
|
2192
|
+
let aborted = false;
|
|
2193
|
+
let abortReason;
|
|
2194
|
+
const ready = () => deliver || finished ? Promise.resolve() : new Promise((r) => waiting.push(r));
|
|
2195
|
+
const wake = () => {
|
|
2196
|
+
for (const r of waiting.splice(0)) r();
|
|
2197
|
+
};
|
|
2198
|
+
const off = listenPort(port, async ({ done, value, error }) => {
|
|
2199
|
+
touch();
|
|
2200
|
+
if (poison) throw poison;
|
|
2201
|
+
if (outstanding) {
|
|
2202
|
+
poison = /* @__PURE__ */ new Error("webrun-rpc: peer sent a second unconfirmed chunk; the stream port is closed");
|
|
2203
|
+
controller.abort(poison);
|
|
2204
|
+
setTimeout(() => {
|
|
2205
|
+
try {
|
|
2206
|
+
port.close?.();
|
|
2207
|
+
} catch {}
|
|
2208
|
+
}, 0);
|
|
2209
|
+
throw poison;
|
|
2210
|
+
}
|
|
2211
|
+
outstanding = true;
|
|
2212
|
+
try {
|
|
2213
|
+
await ready();
|
|
2214
|
+
if (finished) throw poison ?? /* @__PURE__ */ new Error("webrun-rpc: the stream is closed");
|
|
2215
|
+
await deliver?.({
|
|
2216
|
+
done,
|
|
2217
|
+
value,
|
|
2218
|
+
error: error ? deserializeError(error) : void 0
|
|
2219
|
+
});
|
|
2220
|
+
} finally {
|
|
2221
|
+
outstanding = false;
|
|
2222
|
+
}
|
|
2223
|
+
if (done) resolveEnded();
|
|
2224
|
+
}, { channelName });
|
|
2225
|
+
const detach = () => {
|
|
2226
|
+
finished = true;
|
|
2227
|
+
wake();
|
|
2228
|
+
off();
|
|
2229
|
+
controller.signal.removeEventListener("abort", onAbort);
|
|
2230
|
+
resolveEnded();
|
|
2231
|
+
};
|
|
2232
|
+
const onAbort = () => {
|
|
2233
|
+
aborted = true;
|
|
2234
|
+
abortReason = controller.signal.reason;
|
|
2235
|
+
finished = true;
|
|
2236
|
+
wake();
|
|
2237
|
+
deliver?.({
|
|
2238
|
+
done: true,
|
|
2239
|
+
error: abortReason
|
|
2240
|
+
});
|
|
2241
|
+
resolveEnded();
|
|
2242
|
+
};
|
|
2243
|
+
controller.signal.addEventListener("abort", onAbort, { once: true });
|
|
2244
|
+
return {
|
|
2245
|
+
stream: recieveIterator((d) => {
|
|
2246
|
+
if (aborted) {
|
|
2247
|
+
d({
|
|
2248
|
+
done: true,
|
|
2249
|
+
error: abortReason
|
|
2250
|
+
});
|
|
2251
|
+
return detach;
|
|
2252
|
+
}
|
|
2253
|
+
deliver = d;
|
|
2254
|
+
wake();
|
|
2255
|
+
return detach;
|
|
2256
|
+
}),
|
|
2257
|
+
stop: detach,
|
|
2258
|
+
ended
|
|
2259
|
+
};
|
|
2260
|
+
}
|
|
2261
|
+
/**
|
|
2262
|
+
* The sending half of one direction: one `callPort` per chunk, one call
|
|
2263
|
+
* outstanding at a time (spec D11/D12), with no per-chunk deadline (spec D8).
|
|
2264
|
+
*/
|
|
2265
|
+
async function sendChunks(port, channelName, output, { maxMessageSize, log }, signal, touch) {
|
|
2266
|
+
const stream = throughAbort(maxMessageSize ? toChunks(maxMessageSize)(output) : output, signal);
|
|
2267
|
+
try {
|
|
2268
|
+
await sendIterator(async ({ done, value, error }) => {
|
|
2269
|
+
if (signal.aborted) return;
|
|
2270
|
+
const chunk = {
|
|
2271
|
+
done,
|
|
2272
|
+
value,
|
|
2273
|
+
error: error === void 0 ? void 0 : serializeError(error)
|
|
2274
|
+
};
|
|
2275
|
+
log?.("[duplexOverPort] send", {
|
|
2276
|
+
channelName,
|
|
2277
|
+
done,
|
|
2278
|
+
size: value?.byteLength
|
|
2279
|
+
});
|
|
2280
|
+
await callPort(port, chunk, {
|
|
2281
|
+
channelName,
|
|
2282
|
+
timeout: NO_TIMEOUT,
|
|
2283
|
+
signal
|
|
2284
|
+
});
|
|
2285
|
+
touch();
|
|
2286
|
+
}, stream);
|
|
2287
|
+
} catch (err) {
|
|
2288
|
+
if (signal.aborted) return;
|
|
2289
|
+
throw err;
|
|
2290
|
+
}
|
|
2291
|
+
}
|
|
2292
|
+
/**
|
|
2293
|
+
* One virtual port.
|
|
2294
|
+
*
|
|
2295
|
+
* `deliver` and `markClosed` are deliberately not on `port`: the consumer holds
|
|
2296
|
+
* only a `MessageTarget`, so it cannot forge inbound traffic or close the port
|
|
2297
|
+
* out from under the multiplexer's bookkeeping.
|
|
2298
|
+
*/
|
|
2299
|
+
function newVirtualPort(send, requestClose) {
|
|
2300
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
2301
|
+
let closed = false;
|
|
2302
|
+
const closing = new AbortController();
|
|
2303
|
+
const abort = (reason) => {
|
|
2304
|
+
if (closing.signal.aborted) return;
|
|
2305
|
+
closing.abort(reason ?? /* @__PURE__ */ new Error("webrun-rpc: the port was closed"));
|
|
2306
|
+
};
|
|
2307
|
+
const port = {
|
|
2308
|
+
addEventListener(_type, listener) {
|
|
2309
|
+
listeners.add(listener);
|
|
2310
|
+
},
|
|
2311
|
+
removeEventListener(_type, listener) {
|
|
2312
|
+
listeners.delete(listener);
|
|
2313
|
+
},
|
|
2314
|
+
postMessage(message, transfer) {
|
|
2315
|
+
if (closed) return;
|
|
2316
|
+
send(message, transfer);
|
|
2317
|
+
},
|
|
2318
|
+
close() {
|
|
2319
|
+
if (closed) return;
|
|
2320
|
+
closed = true;
|
|
2321
|
+
const notify = requestClose;
|
|
2322
|
+
listeners.clear();
|
|
2323
|
+
notify();
|
|
2324
|
+
abort();
|
|
2325
|
+
}
|
|
2326
|
+
};
|
|
2327
|
+
setPortCloseSignal(port, closing.signal);
|
|
2328
|
+
return {
|
|
2329
|
+
port,
|
|
2330
|
+
deliver(payload) {
|
|
2331
|
+
if (closed) return;
|
|
2332
|
+
const event = new MessageEvent("message", { data: payload });
|
|
2333
|
+
for (const listener of [...listeners]) try {
|
|
2334
|
+
listener(event);
|
|
2335
|
+
} catch {}
|
|
2336
|
+
},
|
|
2337
|
+
markClosed(reason) {
|
|
2338
|
+
closed = true;
|
|
2339
|
+
listeners.clear();
|
|
2340
|
+
abort(reason);
|
|
2341
|
+
},
|
|
2342
|
+
isClosed() {
|
|
2343
|
+
return closed;
|
|
2344
|
+
}
|
|
2345
|
+
};
|
|
2346
|
+
}
|
|
2347
|
+
/**
|
|
2348
|
+
* The message of the error that fails the calls still in flight on a
|
|
2349
|
+
* multiplexer when it is closed (`PortMux.close()`).
|
|
2350
|
+
*/
|
|
2351
|
+
const MUX_CLOSED_MESSAGE = "webrun-rpc: the multiplexer was closed";
|
|
2352
|
+
/** Ceiling on concurrently open virtual ports. Bounds the id table only. */
|
|
2353
|
+
const DEFAULT_MAX_PORTS = 1024;
|
|
2354
|
+
/**
|
|
2355
|
+
* The default `PortMux`: emulates multiplexing over a single port.
|
|
2356
|
+
*
|
|
2357
|
+
* A transport that already multiplexes natively supplies its own `PortMux`
|
|
2358
|
+
* instead — this implementation is for transports that offer one pipe.
|
|
2359
|
+
*/
|
|
2360
|
+
function multiplexPort(port, options) {
|
|
2361
|
+
const { codec, onPort, side = "initiator", maxPorts = DEFAULT_MAX_PORTS, maxMessageSize } = options;
|
|
2362
|
+
const open = /* @__PURE__ */ new Map();
|
|
2363
|
+
let nextId = side === "initiator" ? 0 : 1;
|
|
2364
|
+
let muxClosed = false;
|
|
2365
|
+
const post = (envelope, transfer) => {
|
|
2366
|
+
if (muxClosed) return;
|
|
2367
|
+
try {
|
|
2368
|
+
codec.post(port, envelope, transfer);
|
|
2369
|
+
} catch {}
|
|
2370
|
+
};
|
|
2371
|
+
const attach = (id) => {
|
|
2372
|
+
let self;
|
|
2373
|
+
self = newVirtualPort((payload, transfer) => post({
|
|
2374
|
+
type: "message",
|
|
2375
|
+
id,
|
|
2376
|
+
payload
|
|
2377
|
+
}, transfer), (reason) => {
|
|
2378
|
+
post({
|
|
2379
|
+
type: "close",
|
|
2380
|
+
id,
|
|
2381
|
+
reason
|
|
2382
|
+
});
|
|
2383
|
+
open.delete(id);
|
|
2384
|
+
self.markClosed();
|
|
2385
|
+
});
|
|
2386
|
+
open.set(id, self);
|
|
2387
|
+
return self;
|
|
2388
|
+
};
|
|
2389
|
+
const handleEnvelope = (envelope) => {
|
|
2390
|
+
if (muxClosed) return;
|
|
2391
|
+
if (envelope.type === "open") {
|
|
2392
|
+
if (open.has(envelope.id)) return;
|
|
2393
|
+
if (open.size >= maxPorts) {
|
|
2394
|
+
post({
|
|
2395
|
+
type: "close",
|
|
2396
|
+
id: envelope.id,
|
|
2397
|
+
reason: "max-ports"
|
|
2398
|
+
});
|
|
2399
|
+
return;
|
|
2400
|
+
}
|
|
2401
|
+
const handle = attach(envelope.id);
|
|
2402
|
+
let accepted = false;
|
|
2403
|
+
if (onPort) try {
|
|
2404
|
+
accepted = onPort(handle.port, envelope.meta) !== false;
|
|
2405
|
+
} catch {
|
|
2406
|
+
accepted = false;
|
|
2407
|
+
}
|
|
2408
|
+
if (!accepted) {
|
|
2409
|
+
open.delete(envelope.id);
|
|
2410
|
+
handle.markClosed(/* @__PURE__ */ new Error("webrun-rpc: the port was rejected"));
|
|
2411
|
+
post({
|
|
2412
|
+
type: "close",
|
|
2413
|
+
id: envelope.id,
|
|
2414
|
+
reason: "rejected"
|
|
2415
|
+
});
|
|
2416
|
+
}
|
|
2417
|
+
return;
|
|
2418
|
+
}
|
|
2419
|
+
const handle = open.get(envelope.id);
|
|
2420
|
+
if (envelope.type === "message") {
|
|
2421
|
+
if (!handle) return;
|
|
2422
|
+
handle.deliver(envelope.payload);
|
|
2423
|
+
return;
|
|
2424
|
+
}
|
|
2425
|
+
if (!handle) return;
|
|
2426
|
+
open.delete(envelope.id);
|
|
2427
|
+
handle.markClosed(/* @__PURE__ */ new Error("webrun-rpc: the peer closed the port"));
|
|
2428
|
+
};
|
|
2429
|
+
const listener = (event) => {
|
|
2430
|
+
const envelope = codec.read(event);
|
|
2431
|
+
if (envelope) handleEnvelope(envelope);
|
|
2432
|
+
};
|
|
2433
|
+
port.addEventListener("message", listener);
|
|
2434
|
+
port.start?.();
|
|
2435
|
+
return {
|
|
2436
|
+
maxMessageSize,
|
|
2437
|
+
async openPort(meta) {
|
|
2438
|
+
if (muxClosed) throw new Error("webrun-rpc: the multiplexer is closed");
|
|
2439
|
+
if (open.size >= maxPorts) throw new RangeError(`webrun-rpc: maxPorts (${maxPorts}) reached`);
|
|
2440
|
+
while (open.has(nextId)) nextId += 2;
|
|
2441
|
+
const id = nextId;
|
|
2442
|
+
nextId += 2;
|
|
2443
|
+
const handle = attach(id);
|
|
2444
|
+
post({
|
|
2445
|
+
type: "open",
|
|
2446
|
+
id,
|
|
2447
|
+
meta
|
|
2448
|
+
});
|
|
2449
|
+
return handle.port;
|
|
2450
|
+
},
|
|
2451
|
+
async close() {
|
|
2452
|
+
if (muxClosed) return;
|
|
2453
|
+
for (const [id, handle] of [...open]) {
|
|
2454
|
+
post({
|
|
2455
|
+
type: "close",
|
|
2456
|
+
id
|
|
2457
|
+
});
|
|
2458
|
+
open.delete(id);
|
|
2459
|
+
handle.markClosed(/* @__PURE__ */ new Error(MUX_CLOSED_MESSAGE));
|
|
2460
|
+
}
|
|
2461
|
+
muxClosed = true;
|
|
2462
|
+
port.removeEventListener("message", listener);
|
|
2463
|
+
await port.close?.();
|
|
2464
|
+
}
|
|
2465
|
+
};
|
|
2466
|
+
}
|
|
2467
|
+
/**
|
|
2468
|
+
* Announced with every port this pair opens. Layer 1 never reads `meta` — it is
|
|
2469
|
+
* here so a peer running something else over the same multiplexer can tell a
|
|
2470
|
+
* stream port from whatever else it hands out, and so a packet capture says
|
|
2471
|
+
* what the port is for.
|
|
2472
|
+
*/
|
|
2473
|
+
const STREAM_META = { kind: "stream" };
|
|
2474
|
+
/**
|
|
2475
|
+
* One port source in, one caller `Duplex` out.
|
|
2476
|
+
*
|
|
2477
|
+
* A call is a port: the mux allocates one, `duplexOverPort` runs the single
|
|
2478
|
+
* invocation on it, and closing the stream closes the port. No stream ids, no
|
|
2479
|
+
* framing and no credit accounting live here — the mux owns the first, and
|
|
2480
|
+
* `duplexOverPort` owns the one-chunk window that makes memory bounded.
|
|
2481
|
+
*/
|
|
2482
|
+
const connect = async ({ mux: factory, timeout }) => {
|
|
2483
|
+
const mux = await factory();
|
|
2484
|
+
const streamOptions = {
|
|
2485
|
+
maxMessageSize: mux.maxMessageSize,
|
|
2486
|
+
timeout
|
|
2487
|
+
};
|
|
2488
|
+
const call = (input) => (async function* () {
|
|
2489
|
+
yield* duplexOverPort(await mux.openPort(STREAM_META), streamOptions)(input);
|
|
2490
|
+
})();
|
|
2491
|
+
return {
|
|
2492
|
+
call,
|
|
2493
|
+
async close() {
|
|
2494
|
+
await mux.close();
|
|
2495
|
+
}
|
|
2496
|
+
};
|
|
2497
|
+
};
|
|
2498
|
+
/**
|
|
2499
|
+
* One port source in, `handler` serving every call that arrives on it.
|
|
2500
|
+
*
|
|
2501
|
+
* Each inbound port is one invocation, so the factory's `onPort` is the accept
|
|
2502
|
+
* loop: it installs `handler` on the new port and nothing else. The returned
|
|
2503
|
+
* teardown abandons the streams still running *before* dropping the mux, so
|
|
2504
|
+
* the peer's callers reject with "the peer abandoned the stream" instead of
|
|
2505
|
+
* parking forever on a port that has silently gone inert — layer 1's close is
|
|
2506
|
+
* not observable to layer 2, which is why the notice has to be posted
|
|
2507
|
+
* deliberately.
|
|
2508
|
+
*/
|
|
2509
|
+
const serve = async ({ mux: factory, timeout }, handler) => {
|
|
2510
|
+
const live = /* @__PURE__ */ new Set();
|
|
2511
|
+
let streamOptions = { timeout };
|
|
2512
|
+
const mux = await factory((port) => {
|
|
2513
|
+
let off;
|
|
2514
|
+
let finished = false;
|
|
2515
|
+
const tracked = (input) => (async function* () {
|
|
2516
|
+
try {
|
|
2517
|
+
yield* handler(input);
|
|
2518
|
+
} finally {
|
|
2519
|
+
finished = true;
|
|
2520
|
+
if (off) live.delete(off);
|
|
2521
|
+
}
|
|
2522
|
+
})();
|
|
2523
|
+
off = serveDuplexOverPort(port, tracked, streamOptions);
|
|
2524
|
+
if (!finished) live.add(off);
|
|
2525
|
+
});
|
|
2526
|
+
streamOptions = {
|
|
2527
|
+
maxMessageSize: mux.maxMessageSize,
|
|
2528
|
+
timeout
|
|
2529
|
+
};
|
|
2530
|
+
let torn = false;
|
|
2531
|
+
return async () => {
|
|
2532
|
+
if (torn) return;
|
|
2533
|
+
torn = true;
|
|
2534
|
+
for (const off of [...live]) {
|
|
2535
|
+
live.delete(off);
|
|
2536
|
+
try {
|
|
2537
|
+
off();
|
|
2538
|
+
} catch {}
|
|
2539
|
+
}
|
|
2540
|
+
await mux.close();
|
|
2541
|
+
};
|
|
2542
|
+
};
|
|
2543
|
+
/**
|
|
2544
|
+
* Ports over ONE PIPE OF BYTES — a `MessagePort`, a worker, a WebSocket.
|
|
2545
|
+
*
|
|
2546
|
+
* There is no second port to be had, so `multiplexPort`'s id table invents
|
|
2547
|
+
* them. Use this when the transport gives you exactly one channel.
|
|
2548
|
+
*/
|
|
2549
|
+
function overPipe(pipe, options) {
|
|
2550
|
+
return (onPort) => multiplexPort(pipe, {
|
|
2551
|
+
...options,
|
|
2552
|
+
onPort
|
|
2553
|
+
});
|
|
2554
|
+
}
|
|
2555
|
+
function isEnvelope(value) {
|
|
2556
|
+
if (typeof value !== "object" || value === null) return false;
|
|
2557
|
+
const candidate = value;
|
|
2558
|
+
if (typeof candidate.id !== "number") return false;
|
|
2559
|
+
if (!Number.isInteger(candidate.id) || candidate.id < 0) return false;
|
|
2560
|
+
return candidate.type === "open" || candidate.type === "message" || candidate.type === "close";
|
|
2561
|
+
}
|
|
2562
|
+
/**
|
|
2563
|
+
* For ports whose messages are structured values — a real `MessagePort`, a
|
|
2564
|
+
* worker, an iframe.
|
|
2565
|
+
*
|
|
2566
|
+
* Envelopes are posted as-is, so nothing is encoded, `ArrayBuffer`s move
|
|
2567
|
+
* zero-copy through the transfer list, and structured clone does the work the
|
|
2568
|
+
* platform already does well. This is a performance choice only: layer 2 may
|
|
2569
|
+
* not send anything a byte codec could not also carry.
|
|
2570
|
+
*/
|
|
2571
|
+
const structuredCodec = {
|
|
2572
|
+
post(port, envelope, transfer) {
|
|
2573
|
+
if (transfer && transfer.length > 0) port.postMessage(envelope, transfer);
|
|
2574
|
+
else port.postMessage(envelope);
|
|
2575
|
+
},
|
|
2576
|
+
read(event) {
|
|
2577
|
+
return isEnvelope(event.data) ? event.data : void 0;
|
|
2578
|
+
}
|
|
2579
|
+
};
|
|
1426
2580
|
//#endregion
|
|
1427
|
-
//#region src/
|
|
1428
|
-
/** Channel call a page sends to ask its ServiceWorker to `clients.claim()` it. */
|
|
1429
|
-
const CLAIM_CALL = "CLAIM";
|
|
2581
|
+
//#region src/link/http-link.ts
|
|
1430
2582
|
/**
|
|
1431
|
-
*
|
|
1432
|
-
*
|
|
1433
|
-
*
|
|
2583
|
+
* The version of the HTTP-over-`MessagePort` protocol this package speaks,
|
|
2584
|
+
* sent in every hand-shake that sets up a link. Version 1 was this package's
|
|
2585
|
+
* own `handleStreams` / `sendStream` (no backpressure); version 2 is
|
|
2586
|
+
* webrun-rpc's `connect` / `serve` over `overPipe` with webrun-http-streams'
|
|
2587
|
+
* `fetchOverDuplex` / `serveFetchOverDuplex`: one port per link, calls
|
|
2588
|
+
* multiplexed on it, each call a stream with a one-chunk window in both
|
|
2589
|
+
* directions, and cancellation that reaches the handler.
|
|
1434
2590
|
*/
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
2591
|
+
const HTTP_LINK_PROTOCOL = "webrun-http-link/2";
|
|
2592
|
+
/**
|
|
2593
|
+
* Why a request failed when its link went away under it: the far side (the
|
|
2594
|
+
* handler, or the ServiceWorker) was lost. The request may or may not have
|
|
2595
|
+
* been applied.
|
|
2596
|
+
*/
|
|
2597
|
+
var LinkLostError = class extends Error {
|
|
2598
|
+
constructor(message = "The link was lost while this request was in flight") {
|
|
2599
|
+
super(`${message}; it may or may not have been applied.`);
|
|
2600
|
+
this.name = "LinkLostError";
|
|
2601
|
+
}
|
|
2602
|
+
};
|
|
2603
|
+
/**
|
|
2604
|
+
* Posted on a link's port by the calling end when it closes the link, so the
|
|
2605
|
+
* answering end can release it: a `MessagePort` never reports that its other
|
|
2606
|
+
* end is gone.
|
|
2607
|
+
*/
|
|
2608
|
+
const LINK_CLOSE = "webrun-http:link-close";
|
|
2609
|
+
/** Opens the calling end of a link on `port` (one port per link). */
|
|
2610
|
+
async function connectHttpLink(port, { streamTimeout } = {}) {
|
|
2611
|
+
const { call, close } = await connect({
|
|
2612
|
+
mux: overPipe(port, {
|
|
2613
|
+
codec: structuredCodec,
|
|
2614
|
+
side: "initiator"
|
|
2615
|
+
}),
|
|
2616
|
+
timeout: streamTimeout
|
|
2617
|
+
});
|
|
2618
|
+
return trackedLink((request) => fetchOverDuplex(call, request), async () => {
|
|
2619
|
+
try {
|
|
2620
|
+
port.postMessage({ type: LINK_CLOSE });
|
|
2621
|
+
} catch {}
|
|
2622
|
+
await close();
|
|
2623
|
+
});
|
|
2624
|
+
}
|
|
2625
|
+
/**
|
|
2626
|
+
* Serves `handler` on the answering end of a link. Resolves with a teardown
|
|
2627
|
+
* that abandons the requests still running (their handlers see the
|
|
2628
|
+
* cancellation) and releases the port.
|
|
2629
|
+
*/
|
|
2630
|
+
async function serveHttpLink(port, handler, { streamTimeout } = {}) {
|
|
2631
|
+
const teardown = await serve({
|
|
2632
|
+
mux: overPipe(port, {
|
|
2633
|
+
codec: structuredCodec,
|
|
2634
|
+
side: "responder"
|
|
2635
|
+
}),
|
|
2636
|
+
timeout: streamTimeout
|
|
2637
|
+
}, serveFetchOverDuplex(async (request) => await handler(request)));
|
|
2638
|
+
const onClose = (event) => {
|
|
2639
|
+
if (event.data?.type === LINK_CLOSE) close();
|
|
2640
|
+
};
|
|
2641
|
+
const close = async () => {
|
|
2642
|
+
port.removeEventListener("message", onClose);
|
|
2643
|
+
await teardown();
|
|
2644
|
+
};
|
|
2645
|
+
port.addEventListener("message", onClose);
|
|
2646
|
+
return close;
|
|
2647
|
+
}
|
|
2648
|
+
/**
|
|
2649
|
+
* Wraps a `send` so the link knows its requests in flight (for draining and
|
|
2650
|
+
* load-based routing) and can fail them all at once when it is closed.
|
|
2651
|
+
*/
|
|
2652
|
+
function trackedLink(send, release) {
|
|
2653
|
+
let inflight = 0;
|
|
2654
|
+
let closed = false;
|
|
2655
|
+
let lostReason;
|
|
2656
|
+
const failers = /* @__PURE__ */ new Set();
|
|
2657
|
+
const idleWaiters = [];
|
|
2658
|
+
const done = () => {
|
|
2659
|
+
inflight--;
|
|
2660
|
+
if (inflight === 0) for (const resolve of idleWaiters.splice(0)) resolve();
|
|
2661
|
+
};
|
|
2662
|
+
return {
|
|
2663
|
+
get inflight() {
|
|
2664
|
+
return inflight;
|
|
2665
|
+
},
|
|
2666
|
+
get closed() {
|
|
2667
|
+
return closed;
|
|
2668
|
+
},
|
|
2669
|
+
whenIdle() {
|
|
2670
|
+
return inflight === 0 ? Promise.resolve() : new Promise((r) => idleWaiters.push(r));
|
|
2671
|
+
},
|
|
2672
|
+
async send(request) {
|
|
2673
|
+
if (closed) throw lostReason ?? new LinkLostError("The link is closed");
|
|
2674
|
+
inflight++;
|
|
2675
|
+
let finished = false;
|
|
2676
|
+
const finish = () => {
|
|
2677
|
+
if (finished) return;
|
|
2678
|
+
finished = true;
|
|
2679
|
+
failers.delete(fail);
|
|
2680
|
+
done();
|
|
2681
|
+
};
|
|
2682
|
+
let fail = () => {};
|
|
2683
|
+
const lost = new Promise((_, reject) => {
|
|
2684
|
+
fail = reject;
|
|
2685
|
+
});
|
|
2686
|
+
failers.add(fail);
|
|
2687
|
+
lost.catch(() => {});
|
|
2688
|
+
let response;
|
|
2689
|
+
try {
|
|
2690
|
+
response = await Promise.race([send(request), lost]);
|
|
2691
|
+
} catch (error) {
|
|
2692
|
+
finish();
|
|
2693
|
+
throw lostReason ?? error;
|
|
2694
|
+
}
|
|
2695
|
+
if (!response.body) {
|
|
2696
|
+
finish();
|
|
2697
|
+
return response;
|
|
2698
|
+
}
|
|
2699
|
+
return new Response(guardBody(response.body, lost, () => lostReason, finish), {
|
|
2700
|
+
status: response.status,
|
|
2701
|
+
statusText: response.statusText,
|
|
2702
|
+
headers: response.headers
|
|
2703
|
+
});
|
|
2704
|
+
},
|
|
2705
|
+
async close(reason) {
|
|
2706
|
+
if (closed) return;
|
|
2707
|
+
closed = true;
|
|
2708
|
+
lostReason = reason ?? new LinkLostError("The link was closed");
|
|
2709
|
+
for (const fail of [...failers]) fail(lostReason);
|
|
2710
|
+
try {
|
|
2711
|
+
await release();
|
|
2712
|
+
} catch {}
|
|
2713
|
+
}
|
|
2714
|
+
};
|
|
2715
|
+
}
|
|
2716
|
+
/**
|
|
2717
|
+
* A response body that reports its end (done, cancelled or failed) and fails
|
|
2718
|
+
* with the link's reason when the link is lost mid-stream.
|
|
2719
|
+
*/
|
|
2720
|
+
function guardBody(body, lost, reason, finish) {
|
|
2721
|
+
const reader = body.getReader();
|
|
2722
|
+
return new ReadableStream({
|
|
2723
|
+
async pull(controller) {
|
|
2724
|
+
try {
|
|
2725
|
+
const { done, value } = await Promise.race([reader.read(), lost]);
|
|
2726
|
+
if (done) {
|
|
2727
|
+
finish();
|
|
2728
|
+
controller.close();
|
|
2729
|
+
return;
|
|
2730
|
+
}
|
|
2731
|
+
controller.enqueue(value);
|
|
2732
|
+
} catch (error) {
|
|
2733
|
+
finish();
|
|
2734
|
+
reader.cancel().catch(() => {});
|
|
2735
|
+
controller.error(reason() ?? error);
|
|
2736
|
+
}
|
|
2737
|
+
},
|
|
2738
|
+
async cancel(why) {
|
|
2739
|
+
finish();
|
|
2740
|
+
await reader.cancel(why).catch(() => {});
|
|
2741
|
+
}
|
|
1440
2742
|
});
|
|
1441
2743
|
}
|
|
1442
2744
|
//#endregion
|
|
1443
|
-
//#region src/
|
|
2745
|
+
//#region src/link/locks.ts
|
|
2746
|
+
function lockManager() {
|
|
2747
|
+
return globalThis.navigator?.locks;
|
|
2748
|
+
}
|
|
2749
|
+
function locksAvailable() {
|
|
2750
|
+
return lockManager() !== void 0;
|
|
2751
|
+
}
|
|
2752
|
+
/** A fresh lock name: `<prefix>:<uuid>`. */
|
|
2753
|
+
function newLockName(prefix) {
|
|
2754
|
+
return `${prefix}:${crypto.randomUUID()}`;
|
|
2755
|
+
}
|
|
2756
|
+
/**
|
|
2757
|
+
* Takes the lock `name` and holds it until the returned release is called or
|
|
2758
|
+
* this context dies. Resolves once the lock is held; with no Web Locks,
|
|
2759
|
+
* resolves at once.
|
|
2760
|
+
*/
|
|
2761
|
+
async function holdLock(name) {
|
|
2762
|
+
const locks = lockManager();
|
|
2763
|
+
if (!locks) return () => {};
|
|
2764
|
+
let release = () => {};
|
|
2765
|
+
const released = new Promise((resolve) => {
|
|
2766
|
+
release = resolve;
|
|
2767
|
+
});
|
|
2768
|
+
await new Promise((held) => {
|
|
2769
|
+
locks.request(name, {}, () => {
|
|
2770
|
+
held();
|
|
2771
|
+
return released;
|
|
2772
|
+
}).catch(() => held());
|
|
2773
|
+
});
|
|
2774
|
+
return release;
|
|
2775
|
+
}
|
|
2776
|
+
/**
|
|
2777
|
+
* Calls `onLost` once the lock `name` is granted to this context — that is,
|
|
2778
|
+
* once whoever holds it is gone — and releases it at once. Returns a function
|
|
2779
|
+
* that stops watching. With no Web Locks, never calls `onLost`.
|
|
2780
|
+
*/
|
|
2781
|
+
function watchLock(name, onLost) {
|
|
2782
|
+
const locks = lockManager();
|
|
2783
|
+
if (!locks) return () => {};
|
|
2784
|
+
const controller = new AbortController();
|
|
2785
|
+
locks.request(name, { signal: controller.signal }, () => {
|
|
2786
|
+
if (!controller.signal.aborted) onLost();
|
|
2787
|
+
}).catch(() => {});
|
|
2788
|
+
return () => controller.abort();
|
|
2789
|
+
}
|
|
2790
|
+
/**
|
|
2791
|
+
* The names of the locks held right now in this origin, or `undefined` where
|
|
2792
|
+
* that cannot be asked. A backup for `watchLock`: a grant can be late, a query
|
|
2793
|
+
* cannot.
|
|
2794
|
+
*/
|
|
2795
|
+
async function heldLockNames() {
|
|
2796
|
+
const locks = lockManager();
|
|
2797
|
+
if (!locks?.query) return void 0;
|
|
2798
|
+
try {
|
|
2799
|
+
const { held = [] } = await locks.query();
|
|
2800
|
+
return new Set(held.map((lock) => lock.name ?? ""));
|
|
2801
|
+
} catch {
|
|
2802
|
+
return;
|
|
2803
|
+
}
|
|
2804
|
+
}
|
|
2805
|
+
//#endregion
|
|
2806
|
+
//#region src/link/rendezvous.ts
|
|
2807
|
+
/**
|
|
2808
|
+
* Matches replies to requests by id, for the control messages that hand
|
|
2809
|
+
* WebSocket ports across: each waiting request has a deadline, and all of a
|
|
2810
|
+
* peer's waiting requests can be failed at once when the peer is lost.
|
|
2811
|
+
*/
|
|
2812
|
+
var PortRendezvous = class {
|
|
2813
|
+
_prefix;
|
|
2814
|
+
_waiting = /* @__PURE__ */ new Map();
|
|
2815
|
+
_counter = 0;
|
|
2816
|
+
constructor(_prefix = "r") {
|
|
2817
|
+
this._prefix = _prefix;
|
|
2818
|
+
}
|
|
2819
|
+
/** A fresh id for a request. */
|
|
2820
|
+
newId() {
|
|
2821
|
+
return `${this._prefix}:${++this._counter}`;
|
|
2822
|
+
}
|
|
2823
|
+
get size() {
|
|
2824
|
+
return this._waiting.size;
|
|
2825
|
+
}
|
|
2826
|
+
/** Waits for the reply to `id`, failing after `timeoutMs`. */
|
|
2827
|
+
wait(id, timeoutMs, describe = "the reply") {
|
|
2828
|
+
return new Promise((resolve, reject) => {
|
|
2829
|
+
const timer = setTimeout(() => {
|
|
2830
|
+
this._waiting.delete(id);
|
|
2831
|
+
reject(/* @__PURE__ */ new Error(`No answer to ${describe} within ${timeoutMs} ms`));
|
|
2832
|
+
}, timeoutMs);
|
|
2833
|
+
this._waiting.set(id, {
|
|
2834
|
+
resolve,
|
|
2835
|
+
reject,
|
|
2836
|
+
timer
|
|
2837
|
+
});
|
|
2838
|
+
});
|
|
2839
|
+
}
|
|
2840
|
+
/** Delivers the reply to `id`. `false` when nobody waits for it (it timed out). */
|
|
2841
|
+
settle(id, value) {
|
|
2842
|
+
const waiting = this._waiting.get(id);
|
|
2843
|
+
if (!waiting) return false;
|
|
2844
|
+
this._waiting.delete(id);
|
|
2845
|
+
clearTimeout(waiting.timer);
|
|
2846
|
+
waiting.resolve(value);
|
|
2847
|
+
return true;
|
|
2848
|
+
}
|
|
2849
|
+
fail(id, error) {
|
|
2850
|
+
const waiting = this._waiting.get(id);
|
|
2851
|
+
if (!waiting) return;
|
|
2852
|
+
this._waiting.delete(id);
|
|
2853
|
+
clearTimeout(waiting.timer);
|
|
2854
|
+
waiting.reject(error);
|
|
2855
|
+
}
|
|
2856
|
+
failAll(error) {
|
|
2857
|
+
for (const id of [...this._waiting.keys()]) this.fail(id, error);
|
|
2858
|
+
}
|
|
2859
|
+
};
|
|
2860
|
+
//#endregion
|
|
2861
|
+
//#region src/ws/upgrade.ts
|
|
2862
|
+
/**
|
|
2863
|
+
* The upgrade, as an HTTP exchange: a `GET` with `Upgrade: websocket` goes to
|
|
2864
|
+
* an ordinary `(Request) => Response` handler, and a `101` answer carrying a
|
|
2865
|
+
* server socket means accepted. Anything else is a refusal, with its status.
|
|
2866
|
+
*
|
|
2867
|
+
* FORBIDDEN HEADERS. A browser drops `Upgrade`, `Connection` and every
|
|
2868
|
+
* `Sec-*` header from a `new Request()`. The upgrade request therefore carries
|
|
2869
|
+
* its headers as an own `headers` property, a standalone `Headers` that keeps
|
|
2870
|
+
* them, so handler code reads `request.headers.get("upgrade")` as usual.
|
|
2871
|
+
*
|
|
2872
|
+
* NO 101 IN A `Response`. The constructor refuses statuses below 200, so the
|
|
2873
|
+
* response `upgradeWebSocket` returns reports 101 through an own `status`
|
|
2874
|
+
* property; the port that goes back to the caller is found with `takePort`.
|
|
2875
|
+
*/
|
|
2876
|
+
/** The request header carrying the client's version of the frame protocol. */
|
|
2877
|
+
const WS_VERSION_HEADER = "x-webrun-ws-version";
|
|
2878
|
+
/**
|
|
2879
|
+
* The `GET` a handler sees for `new WebSocket(url, protocols)`. A `ws:` or
|
|
2880
|
+
* `wss:` url is given as `http:` or `https:`, as a server sees it.
|
|
2881
|
+
*/
|
|
2882
|
+
function newWebSocketUpgradeRequest(url, { protocols = [], headers } = {}) {
|
|
2883
|
+
const httpUrl = new URL(`${url}`);
|
|
2884
|
+
if (httpUrl.protocol === "ws:") httpUrl.protocol = "http:";
|
|
2885
|
+
else if (httpUrl.protocol === "wss:") httpUrl.protocol = "https:";
|
|
2886
|
+
const all = new Headers(headers);
|
|
2887
|
+
all.set("upgrade", "websocket");
|
|
2888
|
+
all.set("connection", "Upgrade");
|
|
2889
|
+
all.set("sec-websocket-version", "13");
|
|
2890
|
+
if (!all.has("sec-websocket-key")) all.set("sec-websocket-key", randomKey());
|
|
2891
|
+
if (protocols.length > 0) all.set("sec-websocket-protocol", protocols.join(", "));
|
|
2892
|
+
if (!all.has("x-webrun-ws-version")) all.set(WS_VERSION_HEADER, `2`);
|
|
2893
|
+
return withHeaders(new Request(httpUrl, {
|
|
2894
|
+
method: "GET",
|
|
2895
|
+
headers: all
|
|
2896
|
+
}), all);
|
|
2897
|
+
}
|
|
2898
|
+
/**
|
|
2899
|
+
* Re-attaches `headers` to `request` as its own `headers` property, keeping
|
|
2900
|
+
* the forbidden ones a browser dropped. Returns `request`.
|
|
2901
|
+
*/
|
|
2902
|
+
function withHeaders(request, headers) {
|
|
2903
|
+
Object.defineProperty(request, "headers", {
|
|
2904
|
+
value: headers,
|
|
2905
|
+
configurable: true
|
|
2906
|
+
});
|
|
2907
|
+
return request;
|
|
2908
|
+
}
|
|
2909
|
+
function randomKey() {
|
|
2910
|
+
const bytes = /* @__PURE__ */ new Uint8Array(16);
|
|
2911
|
+
crypto.getRandomValues(bytes);
|
|
2912
|
+
return btoa(String.fromCharCode(...bytes));
|
|
2913
|
+
}
|
|
2914
|
+
//#endregion
|
|
2915
|
+
//#region src/sw/handler-table.ts
|
|
1444
2916
|
/**
|
|
1445
|
-
*
|
|
1446
|
-
*
|
|
2917
|
+
* The default route: the handler with the fewest requests in flight, the
|
|
2918
|
+
* oldest on a tie. With one handler per key — one tab — that is the first
|
|
2919
|
+
* live handler of the key, as before routing was pluggable.
|
|
1447
2920
|
*/
|
|
1448
|
-
|
|
2921
|
+
const leastInflightRoute = (_request, _key, handlers) => {
|
|
2922
|
+
let best;
|
|
2923
|
+
for (const handler of handlers) if (best === void 0 || handler.inflight < best.inflight || handler.inflight === best.inflight && handler.attachedAt < best.attachedAt) best = handler;
|
|
2924
|
+
return best;
|
|
2925
|
+
};
|
|
2926
|
+
var HandlerTable = class {
|
|
2927
|
+
_entries = /* @__PURE__ */ new Map();
|
|
2928
|
+
_waiters = [];
|
|
2929
|
+
/** Adds `entry`. Returns the entry it replaces (same key and id), if any. */
|
|
2930
|
+
add(entry) {
|
|
2931
|
+
let slots = this._entries.get(entry.key);
|
|
2932
|
+
if (!slots) {
|
|
2933
|
+
slots = /* @__PURE__ */ new Map();
|
|
2934
|
+
this._entries.set(entry.key, slots);
|
|
2935
|
+
}
|
|
2936
|
+
const replaced = slots.get(entry.id);
|
|
2937
|
+
slots.set(entry.id, entry);
|
|
2938
|
+
if (!entry.draining) this._waiters = this._waiters.filter(({ key, resolve }) => {
|
|
2939
|
+
if (key !== entry.key) return true;
|
|
2940
|
+
resolve(true);
|
|
2941
|
+
return false;
|
|
2942
|
+
});
|
|
2943
|
+
return replaced;
|
|
2944
|
+
}
|
|
2945
|
+
/** Removes `entry` if it is still the one in its slot. */
|
|
2946
|
+
remove(entry) {
|
|
2947
|
+
const slots = this._entries.get(entry.key);
|
|
2948
|
+
if (slots?.get(entry.id) !== entry) return false;
|
|
2949
|
+
slots.delete(entry.id);
|
|
2950
|
+
if (slots.size === 0) this._entries.delete(entry.key);
|
|
2951
|
+
return true;
|
|
2952
|
+
}
|
|
2953
|
+
get(key, id) {
|
|
2954
|
+
return this._entries.get(key)?.get(id);
|
|
2955
|
+
}
|
|
2956
|
+
/** Every entry of `key`, draining or not. */
|
|
2957
|
+
all(key) {
|
|
2958
|
+
return [...this._entries.get(key)?.values() ?? []];
|
|
2959
|
+
}
|
|
2960
|
+
/** The entries of `key` that take new requests. */
|
|
2961
|
+
live(key) {
|
|
2962
|
+
return this.all(key).filter((entry) => !entry.draining);
|
|
2963
|
+
}
|
|
2964
|
+
entries() {
|
|
2965
|
+
return [...this._entries.values()].flatMap((slots) => [...slots.values()]);
|
|
2966
|
+
}
|
|
2967
|
+
get size() {
|
|
2968
|
+
let size = 0;
|
|
2969
|
+
for (const slots of this._entries.values()) size += slots.size;
|
|
2970
|
+
return size;
|
|
2971
|
+
}
|
|
2972
|
+
/**
|
|
2973
|
+
* Resolves `true` once a live entry for `key` is added (at once if there is
|
|
2974
|
+
* one, unless `next`), `false` after `ms`.
|
|
2975
|
+
*/
|
|
2976
|
+
waitFor(key, ms, next = false) {
|
|
2977
|
+
if (!next && this.live(key).length > 0) return Promise.resolve(true);
|
|
2978
|
+
return new Promise((resolve) => {
|
|
2979
|
+
const waiter = {
|
|
2980
|
+
key,
|
|
2981
|
+
resolve: (found) => {
|
|
2982
|
+
clearTimeout(timer);
|
|
2983
|
+
resolve(found);
|
|
2984
|
+
}
|
|
2985
|
+
};
|
|
2986
|
+
const timer = setTimeout(() => {
|
|
2987
|
+
this._waiters = this._waiters.filter((w) => w !== waiter);
|
|
2988
|
+
resolve(false);
|
|
2989
|
+
}, ms);
|
|
2990
|
+
this._waiters.push(waiter);
|
|
2991
|
+
});
|
|
2992
|
+
}
|
|
2993
|
+
};
|
|
2994
|
+
//#endregion
|
|
2995
|
+
//#region src/sw/protocol.ts
|
|
2996
|
+
/**
|
|
2997
|
+
* The messages between pages, Dedicated Workers and the same-origin
|
|
2998
|
+
* ServiceWorker. Internal: the shapes may change between versions, which is
|
|
2999
|
+
* why every hand-shake carries `HTTP_LINK_PROTOCOL`.
|
|
3000
|
+
*
|
|
3001
|
+
* THREE PORTS PER HANDLER LINK. A page makes the channels and hands one end of
|
|
3002
|
+
* each to the handler (itself, or a worker) and the other to the
|
|
3003
|
+
* ServiceWorker; after that it carries nothing:
|
|
3004
|
+
*
|
|
3005
|
+
* - `down`: ServiceWorker → handler, the requests the handler serves;
|
|
3006
|
+
* - `ctl`: a raw port next to them, for what HTTP over a stream cannot carry,
|
|
3007
|
+
* transferred `MessagePort`s (WebSocket hand-over);
|
|
3008
|
+
* - `up` (workers only): handler → ServiceWorker, the requests the handler
|
|
3009
|
+
* sends through the router (to other handlers).
|
|
3010
|
+
*/
|
|
3011
|
+
/** Page → ServiceWorker: attach a handler link. The name is the one version 1 used. */
|
|
3012
|
+
const ATTACH_CALL = "UPDATE_COMMUNICATION_PORT";
|
|
3013
|
+
/** Page → ServiceWorker: remove a handler link now. */
|
|
3014
|
+
const DETACH_CALL = "webrun-http:detach";
|
|
3015
|
+
/** Page → ServiceWorker: stop routing to a link, let its requests finish, then remove it. */
|
|
3016
|
+
const DRAIN_CALL = "webrun-http:drain";
|
|
3017
|
+
/** Page → ServiceWorker: open a WebSocket to a handler. */
|
|
3018
|
+
const WS_CONNECT_CALL = "webrun-http:ws-connect";
|
|
3019
|
+
/** ServiceWorker → pages: attach your links again (the ServiceWorker restarted). */
|
|
3020
|
+
const NEED_LINKS_CALL = "webrun-http:need-links";
|
|
3021
|
+
//#endregion
|
|
3022
|
+
//#region src/sw/http-sw-dispatcher.ts
|
|
3023
|
+
/** One attached handler link, as the ServiceWorker holds it. */
|
|
3024
|
+
var HandlerEntry = class {
|
|
3025
|
+
key;
|
|
3026
|
+
id;
|
|
3027
|
+
owner;
|
|
3028
|
+
meta;
|
|
3029
|
+
link;
|
|
3030
|
+
ctl;
|
|
3031
|
+
clientId;
|
|
3032
|
+
ownerLock;
|
|
3033
|
+
draining = false;
|
|
3034
|
+
attachedAt = Date.now();
|
|
3035
|
+
sockets;
|
|
3036
|
+
stopWatch = () => {};
|
|
3037
|
+
closeUp;
|
|
3038
|
+
constructor(key, id, owner, meta, link, ctl, clientId, ownerLock) {
|
|
3039
|
+
this.key = key;
|
|
3040
|
+
this.id = id;
|
|
3041
|
+
this.owner = owner;
|
|
3042
|
+
this.meta = meta;
|
|
3043
|
+
this.link = link;
|
|
3044
|
+
this.ctl = ctl;
|
|
3045
|
+
this.clientId = clientId;
|
|
3046
|
+
this.ownerLock = ownerLock;
|
|
3047
|
+
this.sockets = new PortRendezvous(`s:${id}`);
|
|
3048
|
+
}
|
|
3049
|
+
get inflight() {
|
|
3050
|
+
return this.link.inflight;
|
|
3051
|
+
}
|
|
3052
|
+
};
|
|
3053
|
+
/** The reason requests fail with when their handler dies under them. */
|
|
3054
|
+
const HANDLER_LOST_MESSAGE = "The handler was lost while this request was in flight";
|
|
3055
|
+
const TIMED_OUT = Symbol("timed-out");
|
|
3056
|
+
/**
|
|
3057
|
+
* The same-origin ServiceWorker: routes `fetch` events under its scope to
|
|
3058
|
+
* handler links that pages attach — handlers in the pages themselves
|
|
3059
|
+
* (`SwHttpAdapter.register`) or in Dedicated Workers
|
|
3060
|
+
* (`SwHttpAdapter.attachWorker` + `serveHttpInWorker`).
|
|
3061
|
+
*
|
|
3062
|
+
* A url `<scope>/<key>/...` belongs to the handlers of `key`. Of those, the
|
|
3063
|
+
* `route` option picks one per request. Requests and responses stream with
|
|
3064
|
+
* backpressure in both directions, and an aborted fetch reaches the handler
|
|
3065
|
+
* as a cancellation (webrun-rpc + webrun-http-streams).
|
|
3066
|
+
*
|
|
3067
|
+
* Urls of keys this worker never served go to the network untouched: the
|
|
3068
|
+
* worker does not call `respondWith` for them.
|
|
3069
|
+
*
|
|
3070
|
+
* RESTARTS. The browser stops an idle ServiceWorker, and every port it held
|
|
3071
|
+
* dies with it. Nothing is persisted but the set of keys ever served: the
|
|
3072
|
+
* first request after a restart asks every window to attach its links again
|
|
3073
|
+
* (`NEED_LINKS`) and waits for them.
|
|
3074
|
+
*
|
|
3075
|
+
* LIVENESS. Each handler holds an owner lock; when it is granted to this
|
|
3076
|
+
* worker, the handler is gone and its requests in flight fail at once (502).
|
|
3077
|
+
* This worker holds a router lock that handlers watch the same way.
|
|
3078
|
+
*/
|
|
3079
|
+
var SwHttpDispatcher = class {
|
|
1449
3080
|
self;
|
|
1450
3081
|
log;
|
|
1451
|
-
|
|
3082
|
+
options;
|
|
3083
|
+
table = new HandlerTable();
|
|
1452
3084
|
/**
|
|
1453
|
-
*
|
|
1454
|
-
*
|
|
1455
|
-
*
|
|
1456
|
-
* "unknown URL under scope → pass through to the network".
|
|
3085
|
+
* Keys that were served at some point on this worker (sticky, kept in
|
|
3086
|
+
* IndexedDB). Lets a fetch tell "a site url whose handler is gone → 404"
|
|
3087
|
+
* from "an unknown url under the scope → the network".
|
|
1457
3088
|
*/
|
|
1458
3089
|
claimedKeys = /* @__PURE__ */ new Set();
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
3090
|
+
/** The Web Lock this worker holds while it lives; handlers watch it. */
|
|
3091
|
+
routerLock;
|
|
3092
|
+
_routerHeld = Promise.resolve();
|
|
3093
|
+
_restored = Promise.resolve();
|
|
3094
|
+
_restoreSettled = false;
|
|
3095
|
+
_cleanup = [];
|
|
3096
|
+
_livenessTimer;
|
|
3097
|
+
_relinkRounds = /* @__PURE__ */ new Map();
|
|
3098
|
+
/** Keys whose last handler was drained: until the time stored, requests wait for the next. */
|
|
3099
|
+
_vacant = /* @__PURE__ */ new Map();
|
|
3100
|
+
constructor(options) {
|
|
3101
|
+
this.options = options;
|
|
3102
|
+
this.self = options.self;
|
|
3103
|
+
this.log = options.log ?? (() => {});
|
|
3104
|
+
if (locksAvailable()) this.routerLock = newLockName("webrun-http-router");
|
|
1464
3105
|
}
|
|
1465
3106
|
get scope() {
|
|
1466
3107
|
return `${new URL("./", this.self.location.href)}`;
|
|
1467
3108
|
}
|
|
1468
|
-
/**
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
for (const channelInfo of this.handlersIndex.values()) {
|
|
1476
|
-
if (channelInfo.key !== key) continue;
|
|
1477
|
-
const clientId = channelInfo.clientId;
|
|
1478
|
-
if (!clientId) continue;
|
|
1479
|
-
if (!await this.self.clients.get(clientId)) {
|
|
1480
|
-
this.handlersIndex.delete(clientId);
|
|
1481
|
-
continue;
|
|
1482
|
-
}
|
|
1483
|
-
return channelInfo;
|
|
1484
|
-
}
|
|
3109
|
+
/** The key `url` belongs to: its first path segment under the scope. */
|
|
3110
|
+
keyOf(url) {
|
|
3111
|
+
const href = `${url}`;
|
|
3112
|
+
const scope = this.scope;
|
|
3113
|
+
if (!href.startsWith(scope)) return void 0;
|
|
3114
|
+
const key = href.substring(scope.length).replace(/[?#].*$/, "").split("/")[0];
|
|
3115
|
+
return key ? decodeURIComponent(key) : void 0;
|
|
1485
3116
|
}
|
|
1486
|
-
/** Whether `key` has ever been
|
|
3117
|
+
/** Whether `key` has ever been served on this worker. */
|
|
1487
3118
|
isClaimedKey(key) {
|
|
1488
3119
|
return this.claimedKeys.has(key);
|
|
1489
3120
|
}
|
|
3121
|
+
/** Whether requests for `key` are this worker's to answer. */
|
|
3122
|
+
serves(key) {
|
|
3123
|
+
return this.claimedKeys.has(key) || this.table.all(key).length > 0;
|
|
3124
|
+
}
|
|
1490
3125
|
start() {
|
|
1491
|
-
const
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
...channelInfo,
|
|
1497
|
-
port,
|
|
1498
|
-
clientId
|
|
1499
|
-
});
|
|
1500
|
-
return { ...channelInfo };
|
|
1501
|
-
});
|
|
1502
|
-
this._cleanup = () => {
|
|
1503
|
-
stopClaims();
|
|
1504
|
-
stopPortUpdates();
|
|
3126
|
+
const self = this.self;
|
|
3127
|
+
if (this.routerLock) this._routerHeld = holdLock(this.routerLock).then(() => {});
|
|
3128
|
+
this._restored = this._restoreClaimedKeys();
|
|
3129
|
+
const latch = () => {
|
|
3130
|
+
this._restoreSettled = true;
|
|
1505
3131
|
};
|
|
1506
|
-
this.
|
|
1507
|
-
|
|
1508
|
-
this.
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
3132
|
+
this._restored.then(latch, latch);
|
|
3133
|
+
this._cleanup.push(handleClaimRequests(self), handleChannelCalls(self, ATTACH_CALL, (event, params, ...ports) => this._attach(event, params, ports)), handleChannelCalls(self, DETACH_CALL, (_event, params) => {
|
|
3134
|
+
const entry = this._find(params);
|
|
3135
|
+
if (!entry) return false;
|
|
3136
|
+
this._close(entry, new LinkLostError("The handler was detached"));
|
|
3137
|
+
return true;
|
|
3138
|
+
}), handleChannelCalls(self, DRAIN_CALL, async (_event, params) => {
|
|
3139
|
+
const entry = this._find(params);
|
|
3140
|
+
if (!entry) return false;
|
|
3141
|
+
this._vacant.set(entry.key, Date.now() + (this.options.attachTimeoutMs ?? 15e3));
|
|
3142
|
+
await this._retire(entry, params.ms ?? this.options.handoverMs ?? 5e3);
|
|
3143
|
+
return true;
|
|
3144
|
+
}), handleChannelCalls(self, WS_CONNECT_CALL, async (event, params) => {
|
|
3145
|
+
if (!this._isOwnClient(event)) throw new Error("Only this origin's pages may open sockets");
|
|
3146
|
+
const { url, protocols } = params;
|
|
3147
|
+
const connection = await this.connectWebSocket(url, protocols ?? []);
|
|
3148
|
+
return withTransfer(connection, [connection.port]);
|
|
3149
|
+
}));
|
|
3150
|
+
const onMessage = (event) => {
|
|
3151
|
+
const type = event.data?.type;
|
|
3152
|
+
if (type === "webrun-http:wake") event.waitUntil(this._askForLinks());
|
|
3153
|
+
else if (type === "webrun-http:keepalive") event.waitUntil(Promise.resolve());
|
|
3154
|
+
};
|
|
3155
|
+
self.addEventListener("message", onMessage);
|
|
3156
|
+
this._cleanup.push(() => self.removeEventListener("message", onMessage));
|
|
3157
|
+
const onInstall = (event) => {
|
|
3158
|
+
this.log("Skip waiting on install.");
|
|
3159
|
+
event.waitUntil(self.skipWaiting());
|
|
3160
|
+
};
|
|
3161
|
+
const onActivate = (event) => {
|
|
3162
|
+
this.log("Claim control over all clients.");
|
|
3163
|
+
event.waitUntil(self.clients.claim());
|
|
3164
|
+
};
|
|
3165
|
+
const onFetch = (event) => this._onFetch(event);
|
|
3166
|
+
self.addEventListener("install", onInstall);
|
|
3167
|
+
self.addEventListener("activate", onActivate);
|
|
3168
|
+
self.addEventListener("fetch", onFetch);
|
|
3169
|
+
this._cleanup.push(() => {
|
|
3170
|
+
self.removeEventListener("install", onInstall);
|
|
3171
|
+
self.removeEventListener("activate", onActivate);
|
|
3172
|
+
self.removeEventListener("fetch", onFetch);
|
|
1516
3173
|
});
|
|
1517
3174
|
}
|
|
1518
3175
|
async stop() {
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
3176
|
+
for (const cleanup of this._cleanup.splice(0)) cleanup();
|
|
3177
|
+
if (this._livenessTimer) clearInterval(this._livenessTimer);
|
|
3178
|
+
this._livenessTimer = void 0;
|
|
3179
|
+
for (const entry of this.table.entries()) this._close(entry, new LinkLostError("The ServiceWorker dispatcher stopped"));
|
|
1523
3180
|
}
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
const
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
3181
|
+
/**
|
|
3182
|
+
* Answers `request` as a fetch under the scope would be: through the
|
|
3183
|
+
* handler its key routes to. A url outside the scope, or of a key this
|
|
3184
|
+
* worker never served, goes to the network.
|
|
3185
|
+
*/
|
|
3186
|
+
async dispatch(request) {
|
|
3187
|
+
const key = this.keyOf(request.url);
|
|
3188
|
+
if (key === void 0) return await fetch(request);
|
|
3189
|
+
await this._restored.catch(() => {});
|
|
3190
|
+
const entry = await this._acquire(request, key);
|
|
3191
|
+
if (entry === TIMED_OUT) return new Response(null, {
|
|
3192
|
+
status: 503,
|
|
3193
|
+
statusText: "Service Unavailable (no handler attached in time)"
|
|
3194
|
+
});
|
|
3195
|
+
if (!entry) {
|
|
3196
|
+
if (!this.serves(key)) return await fetch(request);
|
|
3197
|
+
return new Response(null, {
|
|
3198
|
+
status: 404,
|
|
3199
|
+
statusText: "Not Found (no active handler)"
|
|
3200
|
+
});
|
|
3201
|
+
}
|
|
3202
|
+
try {
|
|
3203
|
+
return await entry.link.send(request);
|
|
3204
|
+
} catch (error) {
|
|
3205
|
+
this.log("[dispatch] failed", key, error);
|
|
3206
|
+
return new Response(errorMessage(error), {
|
|
3207
|
+
status: 502,
|
|
3208
|
+
statusText: "Bad Gateway",
|
|
3209
|
+
headers: { "content-type": "text/plain; charset=utf-8" }
|
|
1539
3210
|
});
|
|
1540
3211
|
}
|
|
1541
3212
|
}
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
3213
|
+
/**
|
|
3214
|
+
* Opens a socket to the handler `url` routes to: the handler accepts or
|
|
3215
|
+
* refuses it like an HTTP upgrade, and the socket's port comes back. After
|
|
3216
|
+
* that the socket runs between the caller and the handler directly; this
|
|
3217
|
+
* worker is not on its data path.
|
|
3218
|
+
*/
|
|
3219
|
+
async connectWebSocket(url, protocols = [], headers) {
|
|
3220
|
+
const request = newWebSocketUpgradeRequest(url, {
|
|
3221
|
+
protocols,
|
|
3222
|
+
headers
|
|
3223
|
+
});
|
|
3224
|
+
const key = this.keyOf(request.url);
|
|
3225
|
+
if (key === void 0) throw new Error(`${url} is outside this ServiceWorker's scope`);
|
|
3226
|
+
await this._restored.catch(() => {});
|
|
3227
|
+
const entry = await this._acquire(request, key);
|
|
3228
|
+
if (!entry || entry === TIMED_OUT) throw new Error(`No handler serves ${url}`);
|
|
3229
|
+
if (!entry.ctl) throw new Error(`The handler of ${url} does not accept sockets`);
|
|
3230
|
+
const id = entry.sockets.newId();
|
|
3231
|
+
const answer = entry.sockets.wait(id, this.options.wsConnectTimeoutMs ?? 15e3, `the socket request for ${url}`);
|
|
3232
|
+
entry.ctl.postMessage({
|
|
3233
|
+
t: "ws-connect",
|
|
3234
|
+
id,
|
|
3235
|
+
url: request.url,
|
|
3236
|
+
protocols,
|
|
3237
|
+
headers
|
|
3238
|
+
});
|
|
3239
|
+
return await answer;
|
|
3240
|
+
}
|
|
3241
|
+
_onFetch(event) {
|
|
3242
|
+
const request = event.request;
|
|
3243
|
+
const key = this.keyOf(request.url);
|
|
3244
|
+
if (key === void 0) return;
|
|
3245
|
+
if (this._restoreSettled) {
|
|
3246
|
+
if (this.serves(key)) event.respondWith(this.dispatch(request));
|
|
3247
|
+
else if (request.mode === "navigate") event.respondWith(fetch(request));
|
|
3248
|
+
return;
|
|
3249
|
+
}
|
|
3250
|
+
event.respondWith((async () => {
|
|
3251
|
+
await this._restored.catch(() => {});
|
|
3252
|
+
if (!this.serves(key)) return await fetch(request);
|
|
3253
|
+
return await this.dispatch(request);
|
|
3254
|
+
})());
|
|
3255
|
+
}
|
|
3256
|
+
async _attach(event, params, ports) {
|
|
3257
|
+
const clientId = event.source?.id;
|
|
3258
|
+
const [down, ...rest] = ports;
|
|
3259
|
+
if (!down) throw new Error("An attach carries at least one port");
|
|
3260
|
+
this.log("[attach]", clientId, params);
|
|
3261
|
+
if (params.wire === void 0) {
|
|
3262
|
+
const link = trackedLink((request) => sendHttpRequest(down, request), () => down.close());
|
|
3263
|
+
const id = clientId ?? crypto.randomUUID();
|
|
3264
|
+
this._add(new HandlerEntry(params.key, id, id, void 0, link, void 0, clientId, void 0));
|
|
3265
|
+
return { ...params };
|
|
1549
3266
|
}
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
const storedKeys = await get("claimedKeys") ?? [];
|
|
1554
|
-
for (const key of storedKeys) this.claimedKeys.add(key);
|
|
1555
|
-
const stored = await get("clientIds") ?? [];
|
|
1556
|
-
return await this._updateClientIds(new Set(stored));
|
|
1557
|
-
}
|
|
1558
|
-
async _updateClientIds(clientIds) {
|
|
1559
|
-
const ids = new Set(clientIds);
|
|
1560
|
-
for (const clientId of this.handlersIndex.keys()) ids.add(clientId);
|
|
1561
|
-
const index = /* @__PURE__ */ new Map();
|
|
1562
|
-
for (const clientId of ids) {
|
|
1563
|
-
const client = await this.self.clients.get(clientId);
|
|
1564
|
-
if (client) index.set(clientId, client);
|
|
1565
|
-
else this.handlersIndex.delete(clientId);
|
|
3267
|
+
if (params.wire !== "webrun-http-link/2") {
|
|
3268
|
+
for (const port of ports) port.close();
|
|
3269
|
+
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.`);
|
|
1566
3270
|
}
|
|
1567
|
-
|
|
1568
|
-
|
|
3271
|
+
const ctl = params.ctl ? rest.shift() : void 0;
|
|
3272
|
+
const up = params.up ? rest.shift() : void 0;
|
|
3273
|
+
const streamTimeout = this.options.streamTimeout;
|
|
3274
|
+
const link = await connectHttpLink(down, { streamTimeout });
|
|
3275
|
+
const entry = new HandlerEntry(params.key, params.id ?? clientId ?? crypto.randomUUID(), params.owner ?? crypto.randomUUID(), params.meta, link, ctl, clientId, params.ownerLock);
|
|
3276
|
+
if (up) entry.closeUp = await serveHttpLink(up, (request) => this.dispatch(request), { streamTimeout });
|
|
3277
|
+
if (ctl) ctl.onmessage = (message) => this._onControl(entry, message.data);
|
|
3278
|
+
if (params.ownerLock) entry.stopWatch = watchLock(params.ownerLock, () => {
|
|
3279
|
+
this.log("[owner lost]", entry.key, entry.id);
|
|
3280
|
+
this._close(entry, new LinkLostError(HANDLER_LOST_MESSAGE));
|
|
3281
|
+
});
|
|
3282
|
+
this._add(entry);
|
|
3283
|
+
await this._routerHeld;
|
|
3284
|
+
return {
|
|
3285
|
+
accepted: HTTP_LINK_PROTOCOL,
|
|
3286
|
+
routerLock: this.routerLock
|
|
3287
|
+
};
|
|
1569
3288
|
}
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
this.self.addEventListener("fetch", this._handleFetchEvent.bind(this));
|
|
3289
|
+
_add(entry) {
|
|
3290
|
+
this._vacant.delete(entry.key);
|
|
3291
|
+
const replaced = this.table.add(entry);
|
|
3292
|
+
if (replaced && replaced !== entry) this._retire(replaced, this.options.handoverMs ?? 5e3, false);
|
|
3293
|
+
this._claim(entry.key);
|
|
3294
|
+
if (entry.ownerLock) this._watchLiveness();
|
|
1577
3295
|
}
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
}
|
|
1593
|
-
return await fetch(request);
|
|
1594
|
-
} catch (error) {
|
|
1595
|
-
console.error(error);
|
|
1596
|
-
return new Response(null, {
|
|
1597
|
-
status: 500,
|
|
1598
|
-
statusText: "Error 500: Internal error"
|
|
1599
|
-
});
|
|
3296
|
+
/**
|
|
3297
|
+
* The backup of the owner-lock watches: every `livenessCheckMs`, drops the
|
|
3298
|
+
* handlers whose owner lock is no longer held. Runs only while some handler
|
|
3299
|
+
* has an owner lock, and keeps nothing awake: a timer is not an event.
|
|
3300
|
+
*/
|
|
3301
|
+
_watchLiveness() {
|
|
3302
|
+
const period = this.options.livenessCheckMs ?? 5e3;
|
|
3303
|
+
if (this._livenessTimer || period <= 0) return;
|
|
3304
|
+
this._livenessTimer = setInterval(async () => {
|
|
3305
|
+
const watched = this.table.entries().filter((entry) => entry.ownerLock);
|
|
3306
|
+
if (watched.length === 0) {
|
|
3307
|
+
clearInterval(this._livenessTimer);
|
|
3308
|
+
this._livenessTimer = void 0;
|
|
3309
|
+
return;
|
|
1600
3310
|
}
|
|
1601
|
-
|
|
3311
|
+
const held = await heldLockNames();
|
|
3312
|
+
if (!held) return;
|
|
3313
|
+
for (const entry of watched) if (entry.ownerLock && !held.has(entry.ownerLock) && this.table.remove(entry)) {
|
|
3314
|
+
this.log("[owner gone]", entry.key, entry.id);
|
|
3315
|
+
this._close(entry, new LinkLostError(HANDLER_LOST_MESSAGE), false);
|
|
3316
|
+
}
|
|
3317
|
+
}, period);
|
|
3318
|
+
}
|
|
3319
|
+
/** The entry a drain or detach names, if its owner token still matches. */
|
|
3320
|
+
_find({ key, id, owner }) {
|
|
3321
|
+
const entry = this.table.get(key, id);
|
|
3322
|
+
return entry && entry.owner === owner ? entry : void 0;
|
|
3323
|
+
}
|
|
3324
|
+
/**
|
|
3325
|
+
* Stops routing to `entry`, lets its requests in flight run for up to `ms`,
|
|
3326
|
+
* then closes it. Requests that arrive meanwhile wait for the next owner.
|
|
3327
|
+
*/
|
|
3328
|
+
async _retire(entry, ms, inTable = true) {
|
|
3329
|
+
entry.draining = true;
|
|
3330
|
+
let timer;
|
|
3331
|
+
await Promise.race([entry.link.whenIdle(), new Promise((resolve) => {
|
|
3332
|
+
timer = setTimeout(resolve, ms);
|
|
3333
|
+
})]);
|
|
3334
|
+
clearTimeout(timer);
|
|
3335
|
+
this._close(entry, new LinkLostError("The handler was drained"), inTable);
|
|
3336
|
+
}
|
|
3337
|
+
_close(entry, reason, inTable = true) {
|
|
3338
|
+
if (inTable) this.table.remove(entry);
|
|
3339
|
+
entry.stopWatch();
|
|
3340
|
+
entry.sockets.failAll(reason);
|
|
3341
|
+
entry.link.close(reason);
|
|
3342
|
+
entry.closeUp?.().catch(() => {});
|
|
3343
|
+
if (entry.ctl) {
|
|
3344
|
+
entry.ctl.onmessage = null;
|
|
3345
|
+
entry.ctl.close();
|
|
3346
|
+
}
|
|
3347
|
+
}
|
|
3348
|
+
/**
|
|
3349
|
+
* The handler for `request`: picked by `route` among the live handlers of
|
|
3350
|
+
* its key, waiting for one when there is none.
|
|
3351
|
+
*/
|
|
3352
|
+
async _acquire(request, key) {
|
|
3353
|
+
const picked = await this._pick(request, key);
|
|
3354
|
+
if (picked) return picked;
|
|
3355
|
+
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;
|
|
3356
|
+
const vacantUntil = this._vacant.get(key) ?? 0;
|
|
3357
|
+
if (vacantUntil > Date.now()) return await this.table.waitFor(key, vacantUntil - Date.now()) ? await this._pick(request, key) ?? TIMED_OUT : TIMED_OUT;
|
|
3358
|
+
if (!this.serves(key)) return void 0;
|
|
3359
|
+
await this._askForLinks(key);
|
|
3360
|
+
return await this._pick(request, key);
|
|
3361
|
+
}
|
|
3362
|
+
async _pick(request, key) {
|
|
3363
|
+
let live = this.table.live(key);
|
|
3364
|
+
for (const entry of live) {
|
|
3365
|
+
if (entry.ownerLock || !entry.clientId) continue;
|
|
3366
|
+
if (!await this.self.clients.get(entry.clientId)) this._close(entry, new LinkLostError(HANDLER_LOST_MESSAGE));
|
|
3367
|
+
}
|
|
3368
|
+
live = this.table.live(key);
|
|
3369
|
+
if (live.length === 0) return void 0;
|
|
3370
|
+
return (this.options.route ?? leastInflightRoute)(request, key, live);
|
|
3371
|
+
}
|
|
3372
|
+
/**
|
|
3373
|
+
* Asks every window to attach its links again, and waits until a handler
|
|
3374
|
+
* for `key` is back, every window answered, or `relinkTimeoutMs` passed.
|
|
3375
|
+
* At most one round per key every 10 s: a key whose tab is simply gone
|
|
3376
|
+
* should not cost every request the wait.
|
|
3377
|
+
*/
|
|
3378
|
+
_askForLinks(key = "") {
|
|
3379
|
+
const round = this._relinkRounds.get(key);
|
|
3380
|
+
if (round && Date.now() - round.at < 1e4) return round.done;
|
|
3381
|
+
const done = this._linkRound(key);
|
|
3382
|
+
this._relinkRounds.set(key, {
|
|
3383
|
+
at: Date.now(),
|
|
3384
|
+
done
|
|
3385
|
+
});
|
|
3386
|
+
return done;
|
|
3387
|
+
}
|
|
3388
|
+
async _linkRound(key) {
|
|
3389
|
+
const timeout = this.options.relinkTimeoutMs ?? 3e3;
|
|
3390
|
+
const windows = await this.self.clients.matchAll({
|
|
3391
|
+
type: "window",
|
|
3392
|
+
includeUncontrolled: true
|
|
3393
|
+
});
|
|
3394
|
+
this.log("[need-links]", key, windows.length);
|
|
3395
|
+
const params = { routerLock: this.routerLock };
|
|
3396
|
+
const pending = new Set(windows);
|
|
3397
|
+
const answered = Promise.allSettled(windows.map((client) => callChannel(client, NEED_LINKS_CALL, params).then(() => pending.delete(client))));
|
|
3398
|
+
let timer;
|
|
3399
|
+
const timedOut = new Promise((resolve) => {
|
|
3400
|
+
timer = setTimeout(resolve, timeout);
|
|
3401
|
+
});
|
|
3402
|
+
await Promise.race([answered, timedOut]);
|
|
3403
|
+
clearTimeout(timer);
|
|
3404
|
+
for (const client of pending) this._askLegacyPage(client);
|
|
3405
|
+
}
|
|
3406
|
+
/** Asks a version-1 page for a fresh port, as version 1's worker did. */
|
|
3407
|
+
_askLegacyPage(client) {
|
|
3408
|
+
const channel = new MessageChannel();
|
|
3409
|
+
callChannel(client, ATTACH_CALL, {}, channel.port2).then((info) => {
|
|
3410
|
+
if (!info?.key || info.wire !== void 0) return;
|
|
3411
|
+
const link = trackedLink((request) => sendHttpRequest(channel.port1, request), () => channel.port1.close());
|
|
3412
|
+
this._add(new HandlerEntry(info.key, client.id, client.id, void 0, link, void 0, client.id, void 0));
|
|
3413
|
+
}, () => channel.port1.close());
|
|
3414
|
+
}
|
|
3415
|
+
_onControl(entry, message) {
|
|
3416
|
+
if (!message || typeof message !== "object") return;
|
|
3417
|
+
switch (message.t) {
|
|
3418
|
+
case "ws-accept":
|
|
3419
|
+
if (!entry.sockets.settle(message.id, {
|
|
3420
|
+
port: message.port,
|
|
3421
|
+
protocol: message.protocol
|
|
3422
|
+
})) message.port.close();
|
|
3423
|
+
return;
|
|
3424
|
+
case "ws-refuse":
|
|
3425
|
+
entry.sockets.fail(message.id, new Error(message.message));
|
|
3426
|
+
return;
|
|
3427
|
+
case "ws-open":
|
|
3428
|
+
this.connectWebSocket(message.url, message.protocols).then(({ port, protocol }) => entry.ctl?.postMessage({
|
|
3429
|
+
t: "ws-opened",
|
|
3430
|
+
id: message.id,
|
|
3431
|
+
protocol,
|
|
3432
|
+
port
|
|
3433
|
+
}, [port]), (error) => entry.ctl?.postMessage({
|
|
3434
|
+
t: "ws-failed",
|
|
3435
|
+
id: message.id,
|
|
3436
|
+
message: errorMessage(error)
|
|
3437
|
+
}));
|
|
3438
|
+
return;
|
|
3439
|
+
}
|
|
3440
|
+
}
|
|
3441
|
+
_isOwnClient(event) {
|
|
3442
|
+
const source = event.source;
|
|
3443
|
+
if (!source?.url) return false;
|
|
3444
|
+
try {
|
|
3445
|
+
return new URL(source.url).origin === this.self.location.origin;
|
|
3446
|
+
} catch {
|
|
3447
|
+
return false;
|
|
3448
|
+
}
|
|
3449
|
+
}
|
|
3450
|
+
async _claim(key) {
|
|
3451
|
+
if (!key || this.claimedKeys.has(key)) return;
|
|
3452
|
+
this.claimedKeys.add(key);
|
|
3453
|
+
try {
|
|
3454
|
+
await set("claimedKeys", [...this.claimedKeys].sort());
|
|
3455
|
+
} catch (error) {
|
|
3456
|
+
this.log("[claimedKeys] not stored", error);
|
|
3457
|
+
}
|
|
3458
|
+
}
|
|
3459
|
+
async _restoreClaimedKeys() {
|
|
3460
|
+
const stored = await get("claimedKeys") ?? [];
|
|
3461
|
+
for (const key of stored) this.claimedKeys.add(key);
|
|
1602
3462
|
}
|
|
1603
3463
|
};
|
|
3464
|
+
/**
|
|
3465
|
+
* Starts the same-origin dispatcher in a ServiceWorker. Returns a function
|
|
3466
|
+
* that stops it.
|
|
3467
|
+
*/
|
|
1604
3468
|
function startHttpDispatcher(options) {
|
|
1605
3469
|
const dispatcher = new SwHttpDispatcher(options);
|
|
1606
3470
|
dispatcher.start();
|
|
@@ -1611,8 +3475,9 @@
|
|
|
1611
3475
|
//#endregion
|
|
1612
3476
|
//#region src/sw-worker.ts
|
|
1613
3477
|
startHttpDispatcher({
|
|
1614
|
-
|
|
1615
|
-
|
|
3478
|
+
log: console.log,
|
|
3479
|
+
...self.HTTP_DISPATCHER_OPTIONS,
|
|
3480
|
+
self
|
|
1616
3481
|
});
|
|
1617
3482
|
//#endregion
|
|
1618
3483
|
})();
|