@statewalker/webrun-http-browser 0.4.2 → 0.6.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.
@@ -0,0 +1,1899 @@
1
+ //#region ../webrun-streams/dist/index.js
2
+ function serializeError(error) {
3
+ if (error instanceof Error) {
4
+ const out = {
5
+ message: error.message,
6
+ stack: error.stack
7
+ };
8
+ const bag = error;
9
+ for (const key of Object.keys(bag)) out[key] = bag[key];
10
+ return out;
11
+ }
12
+ if (typeof error === "object" && error !== null) {
13
+ const bag = error;
14
+ return {
15
+ message: String(bag.message ?? error),
16
+ ...bag
17
+ };
18
+ }
19
+ return { message: String(error) };
20
+ }
21
+ function deserializeError(error) {
22
+ const payload = typeof error === "string" ? { message: error } : error;
23
+ return Object.assign(new Error(payload.message), payload);
24
+ }
25
+ new TextEncoder();
26
+ new TextDecoder();
27
+ /**
28
+ * The newAsyncGenerator function creates async generators from callback-based initialization
29
+ * functions, providing a bridge between imperative event handling and declarative async
30
+ * iteration patterns.
31
+ *
32
+ * The initialization function receives two callback functions that control the generator's
33
+ * behavior. The `next` function yields values to consumers, while the `done` function
34
+ * signals completion or error conditions. The initialization function can return a cleanup
35
+ * function that will be called when the generator is closed or an error occurs.
36
+ *
37
+ * The generator manages an internal queue of values and completion signals, ensuring that
38
+ * producers can yield values without overwhelming consumers. Proper backpressure is implemented
39
+ * by having the `next` and `done` functions return promises that resolve only after the
40
+ * consumer has processed the values. This prevents memory leaks and ensures that producers
41
+ * are aware of whether their values were successfully handled.
42
+ * The generator also handles cleanup by draining any remaining items in the queue and notifying
43
+ * producers that their values were not processed if the generator is closed early. This ensures
44
+ * that resources are properly managed and that no memory leaks occur.
45
+ *
46
+ * Example usage:
47
+ * ```typescript
48
+ * const asyncGen = newAsyncGenerator<number>((next, done) => {
49
+ * let count = 0;
50
+ * const interval = setInterval(() => {
51
+ * if (count < 5) {
52
+ * next(count++);
53
+ * } else {
54
+ * done();
55
+ * clearInterval(interval);
56
+ * }
57
+ * }, 1000);
58
+ * return () => clearInterval(interval); // Cleanup function
59
+ * });
60
+ * (async () => {
61
+ * for await (const num of asyncGen) {
62
+ * console.log(num); // Logs numbers 0 to 4 at 1 second intervals
63
+ * }
64
+ * console.log("Completed");
65
+ * })();
66
+ * ```
67
+ * @template T The type of values yielded by the generator
68
+ * @template E The type of errors that can be thrown; defaults to Error
69
+ * @param init Initialization function that sets up the generator behavior with next/done callbacks;
70
+ * The first parameter - `next` function - is used to yield values to consumers;
71
+ * It returns a Promise<boolean> indicating whether the value was successfully handled;
72
+ * The second parameter - `done` function - is used to signal completion or error;
73
+ * It returns a Promise<boolean> indicating whether the completion was successfully handled;
74
+ * The initialization function can optionally return a cleanup function that will be called
75
+ * when the generator is closed or an error occurs;
76
+ * @param skipValues If true, only the most recent value is kept in the queue,
77
+ * skipping intermediate values (not consumed values are considered skipped);
78
+ * This is useful for scenarios where only the latest value matters, such as UI updates.
79
+ * Defaults to false, meaning all values are queued and processed in order.
80
+ * @returns AsyncGenerator that properly manages backpressure and resource cleanup
81
+ */
82
+ async function* newAsyncGenerator(init, skipValues = false) {
83
+ let head;
84
+ let tail;
85
+ /** Flag to prevent new values from being queued after generator closes */
86
+ let closed = false;
87
+ /** Wake-up function to notify the generator loop of new items */
88
+ let wakeUp;
89
+ /**
90
+ * Drains the internal queue, notifying all pending producers that their values
91
+ * were not processed due to generator closure. This prevents memory leaks and
92
+ * ensures proper backpressure signaling.
93
+ */
94
+ const drainQueue = () => {
95
+ for (; head; head = head.next) {
96
+ closed = closed || head.done;
97
+ head.resolve(false);
98
+ }
99
+ tail = void 0;
100
+ };
101
+ /**
102
+ * Enqueues a value or completion signal with a promise that resolves when the item
103
+ * is processed. This enables backpressure by allowing producers to know when their
104
+ * values have been consumed.
105
+ */
106
+ const enqueue = (params) => {
107
+ if (skipValues) drainQueue();
108
+ return !closed ? new Promise((resolve) => {
109
+ const next = {
110
+ ...params,
111
+ next: void 0,
112
+ resolve
113
+ };
114
+ if (tail) tail.next = next;
115
+ tail = next;
116
+ if (!head) head = tail;
117
+ wakeUp?.();
118
+ }) : Promise.resolve(false);
119
+ };
120
+ /**
121
+ * Producer function to yield a value to consumers. Returns a promise that resolves
122
+ * to true if the value was successfully processed, false if the generator is closed
123
+ * or the value was skipped due to backpressure.
124
+ */
125
+ const next = (value) => enqueue({
126
+ done: false,
127
+ value
128
+ });
129
+ /**
130
+ * Producer function to signal completion or error. Optional error parameter will
131
+ * cause the generator to throw that error to consumers.
132
+ */
133
+ const done = (error) => enqueue({
134
+ done: true,
135
+ error
136
+ });
137
+ const unsubscribe = init(next, done);
138
+ try {
139
+ while (!closed) {
140
+ if (!head) {
141
+ await new Promise((resolve) => {
142
+ wakeUp = resolve;
143
+ }).then(() => {
144
+ wakeUp = void 0;
145
+ });
146
+ continue;
147
+ }
148
+ const slot = head;
149
+ head = head.next;
150
+ if (tail === slot) tail = head;
151
+ try {
152
+ if (slot.done) {
153
+ closed = true;
154
+ if (slot.error !== void 0) throw slot.error;
155
+ break;
156
+ }
157
+ yield slot.value;
158
+ } finally {
159
+ slot.resolve(true);
160
+ }
161
+ }
162
+ } finally {
163
+ /**
164
+ * Cleanup phase - ensures proper resource management and notification of any
165
+ * remaining producers. This runs whether the generator completes normally,
166
+ * encounters an error, or is closed early by the consumer.
167
+ */
168
+ closed = true;
169
+ wakeUp?.();
170
+ if (typeof unsubscribe === "function") await unsubscribe();
171
+ /**
172
+ * Drain any remaining items in the queue and notify their producers that
173
+ * the values were not processed. This prevents memory leaks and ensures
174
+ * proper backpressure signaling.
175
+ */
176
+ drainQueue();
177
+ }
178
+ }
179
+ new TextEncoder();
180
+ /**
181
+ * The iterator ↔ `ReadableStream` boundary.
182
+ *
183
+ * Both adapters must carry CANCELLATION, not just data: a response body leaves
184
+ * a handler as a `ReadableStream`, crosses a transport as an iterator, and
185
+ * becomes a `ReadableStream` again at the caller — so when the caller walks
186
+ * away, the only path back to the handler's producer runs through both of
187
+ * these functions. Teardown that stops at an adapter leaves a producer running
188
+ * for ever.
189
+ */
190
+ function toReadableStream(it) {
191
+ return new ReadableStream({
192
+ /**
193
+ * One chunk per pull. An earlier version drained the whole iterator inside
194
+ * a single `pull`, which defeated the stream's own backpressure (every
195
+ * chunk was enqueued as fast as the producer could make them, however slow
196
+ * the reader was) and left no point between chunks at which a cancellation
197
+ * could take effect.
198
+ */
199
+ async pull(controller) {
200
+ try {
201
+ const slot = await it.next();
202
+ if (!slot || slot.done) {
203
+ controller.close();
204
+ return;
205
+ }
206
+ controller.enqueue(await slot.value);
207
+ } catch (error) {
208
+ controller.error(error);
209
+ }
210
+ },
211
+ /**
212
+ * Release the source. NOT awaited: `.return()` on an async generator that
213
+ * is parked awaiting its own source is queued behind that pending
214
+ * `next()`, so awaiting it here would hang `reader.cancel()` on exactly
215
+ * the producers that most need cancelling.
216
+ */
217
+ cancel(reason) {
218
+ Promise.resolve(it.return?.(reason)).catch(() => {});
219
+ }
220
+ });
221
+ }
222
+ async function* fromReadableStream(stream) {
223
+ const reader = stream.getReader();
224
+ let drained = false;
225
+ try {
226
+ while (true) {
227
+ const { done, value } = await reader.read();
228
+ if (done) {
229
+ drained = true;
230
+ break;
231
+ }
232
+ if (value !== void 0) yield value;
233
+ }
234
+ } finally {
235
+ if (drained) reader.releaseLock();
236
+ else await reader.cancel().catch(() => {});
237
+ }
238
+ }
239
+ /**
240
+ * Inverse of {@link sendIterator}: turns a sequence of `{done, value, error}`
241
+ * chunks (delivered to the supplied callback by `installer`) into an async
242
+ * generator.
243
+ *
244
+ * The `installer` is given a `deliver` function. It should call `deliver`
245
+ * once for each incoming chunk and may return a cleanup callback that
246
+ * runs when the consumer stops iterating.
247
+ */
248
+ function recieveIterator(installer) {
249
+ return newAsyncGenerator((next, done) => {
250
+ const cleanup = installer(async (chunk = { done: true }) => {
251
+ const { done: isDone = true, value, error } = chunk;
252
+ if (error) return await done(error);
253
+ if (isDone) return await done();
254
+ return await next(value);
255
+ });
256
+ if (cleanup) return async () => {
257
+ await cleanup();
258
+ };
259
+ });
260
+ }
261
+ /**
262
+ * Drain an async iterator into a sink that consumes one chunk at a time.
263
+ *
264
+ * Each yielded value becomes `{ done: false, value }`. Completion emits
265
+ * `{ done: true }`. If the iterator throws, the error is caught and the
266
+ * final `done` chunk carries it so the peer can rethrow on its side.
267
+ */
268
+ async function sendIterator(send, it) {
269
+ let error;
270
+ try {
271
+ for await (const value of it) await send({
272
+ done: false,
273
+ value
274
+ });
275
+ } catch (err) {
276
+ error = err;
277
+ } finally {
278
+ await send({
279
+ done: true,
280
+ error
281
+ });
282
+ }
283
+ }
284
+ //#endregion
285
+ //#region ../webrun-http-streams/dist/index.js
286
+ const CR = 13;
287
+ const LF = 10;
288
+ const EMPTY = /* @__PURE__ */ new Uint8Array(0);
289
+ var ByteStreamError = class extends Error {
290
+ name = "ByteStreamError";
291
+ };
292
+ function toAsyncIterator(input) {
293
+ const asyncIter = input[Symbol.asyncIterator];
294
+ if (asyncIter) return asyncIter.call(input);
295
+ const syncIter = input[Symbol.iterator]();
296
+ return { next() {
297
+ return Promise.resolve(syncIter.next());
298
+ } };
299
+ }
300
+ /**
301
+ * Discard an iterable we are contractually forbidden from consuming — a body
302
+ * skipped for HEAD/204, or one abandoned because the peer reported an error.
303
+ *
304
+ * The `.next()` is not optional: `.return()` on a generator still in suspended
305
+ * start is a no-op, so the body never runs and its `try/finally` never unwinds.
306
+ * Without it a wrapped ReadableStream or socket is never cancelled.
307
+ */
308
+ async function discard(source) {
309
+ if (source === void 0) return;
310
+ const it = toAsyncIterator(source);
311
+ try {
312
+ await it.next();
313
+ await it.return?.();
314
+ } catch {}
315
+ }
316
+ function concatChunks(parts, totalLen) {
317
+ if (parts.length === 1) {
318
+ const only = parts[0];
319
+ if (only !== void 0) return only;
320
+ }
321
+ const out = new Uint8Array(totalLen);
322
+ let off = 0;
323
+ for (const p of parts) {
324
+ out.set(p, off);
325
+ off += p.byteLength;
326
+ }
327
+ return out;
328
+ }
329
+ /**
330
+ * Pull-based reader over a byte source. Holds at most one pending buffer, and
331
+ * hands out `subarray` views rather than copies — a body never passes through
332
+ * an allocation here.
333
+ */
334
+ var ByteReader = class {
335
+ #iter;
336
+ #buf = EMPTY;
337
+ #done = false;
338
+ constructor(input) {
339
+ this.#iter = toAsyncIterator(input);
340
+ }
341
+ /** Bytes already pulled from the source but not yet consumed. */
342
+ bufferedLength() {
343
+ return this.#buf.byteLength;
344
+ }
345
+ /** Pull one more non-empty chunk. Returns false at end of stream. */
346
+ async #pull() {
347
+ if (this.#done) return false;
348
+ while (true) {
349
+ const next = await this.#iter.next();
350
+ if (next.done) {
351
+ this.#done = true;
352
+ return false;
353
+ }
354
+ const chunk = next.value;
355
+ if (chunk.byteLength === 0) continue;
356
+ this.#buf = this.#buf.byteLength === 0 ? chunk : concatChunks([this.#buf, chunk], this.#buf.byteLength + chunk.byteLength);
357
+ return true;
358
+ }
359
+ }
360
+ async peekByte() {
361
+ while (this.#buf.byteLength === 0) if (!await this.#pull()) return void 0;
362
+ return this.#buf[0];
363
+ }
364
+ /** Up to `max` bytes. `undefined` means end of stream. */
365
+ async readSome(max) {
366
+ while (this.#buf.byteLength === 0) if (!await this.#pull()) return void 0;
367
+ const take = Math.min(max, this.#buf.byteLength);
368
+ const out = this.#buf.subarray(0, take);
369
+ this.#buf = this.#buf.subarray(take);
370
+ return out;
371
+ }
372
+ /**
373
+ * One CRLF-terminated line, without the CRLF. A bare LF is rejected: real
374
+ * peers always send CRLF, and tolerating a bare LF is precisely the lenience
375
+ * that lets request smuggling through a proxy pair.
376
+ *
377
+ * The `maxBytes` bound is best-effort: it only rejects a line if the check
378
+ * happens to run before the line is fully buffered. A line already sitting in
379
+ * the buffer bypasses the check. Callers needing a hard per-line limit or
380
+ * aggregate bounds must keep their own running total.
381
+ */
382
+ async readLine(maxBytes) {
383
+ let searched = 0;
384
+ while (true) {
385
+ const idx = this.#buf.indexOf(LF, searched);
386
+ if (idx !== -1) {
387
+ if (idx === 0 || this.#buf[idx - 1] !== CR) throw new ByteStreamError("bare LF line terminator; CRLF required");
388
+ const line = this.#buf.subarray(0, idx - 1);
389
+ this.#buf = this.#buf.subarray(idx + 1);
390
+ return line;
391
+ }
392
+ searched = this.#buf.byteLength;
393
+ if (searched > maxBytes) throw new ByteStreamError(`line exceeds ${maxBytes} bytes without CRLF`);
394
+ if (!await this.#pull()) throw new ByteStreamError(`stream ended after ${searched} bytes without CRLF`);
395
+ }
396
+ }
397
+ /** Everything not yet consumed, lazily. */
398
+ async *rest() {
399
+ while (true) {
400
+ if (this.#buf.byteLength > 0) {
401
+ const out = this.#buf;
402
+ this.#buf = EMPTY;
403
+ yield out;
404
+ continue;
405
+ }
406
+ if (!await this.#pull()) return;
407
+ }
408
+ }
409
+ };
410
+ /**
411
+ * Raised for any byte sequence this codec refuses to interpret. Every case is
412
+ * a refusal to guess: HTTP/1.1 parsers that guess are how request smuggling
413
+ * works.
414
+ */
415
+ var HttpParseError = class extends Error {
416
+ name = "HttpParseError";
417
+ };
418
+ const NEWLINE = 10;
419
+ /** Mirrors the HTTP/1.1 codec's default `maxHeaderBytes`. */
420
+ const MAX_ENVELOPE_BYTES = 65536;
421
+ const encoder$1 = new TextEncoder();
422
+ const decoder = new TextDecoder();
423
+ /**
424
+ * Encode an HTTP envelope plus optional body as one continuous byte stream:
425
+ *
426
+ * <JSON.stringify(envelope)>\n<body bytes...>
427
+ *
428
+ * `JSON.stringify` with default whitespace never emits a literal `\n`, so the
429
+ * first `0x0a` byte unambiguously terminates the envelope.
430
+ *
431
+ * This is the same wire shape used by the legacy `webrun-http-port` package.
432
+ */
433
+ async function* encodeMessage(envelope, body) {
434
+ yield encoder$1.encode(`${JSON.stringify(envelope)}\n`);
435
+ if (!body) return;
436
+ for await (const chunk of body) if (chunk.byteLength > 0) yield chunk;
437
+ }
438
+ /**
439
+ * Consume an envelope-then-body byte stream. Returns the parsed envelope and
440
+ * an async iterable over the remaining body bytes.
441
+ */
442
+ async function decodeMessage(input) {
443
+ const iter = toAsyncIterator(input);
444
+ const accum = [];
445
+ let accumLen = 0;
446
+ let before;
447
+ let tail;
448
+ while (true) {
449
+ const next = await iter.next();
450
+ if (next.done) throw new HttpParseError(`decodeMessage: stream ended after ${accumLen} bytes without delimiter (\\n)`);
451
+ const chunk = next.value;
452
+ if (chunk.byteLength === 0) continue;
453
+ const nl = chunk.indexOf(NEWLINE);
454
+ if (nl === -1) {
455
+ accum.push(chunk);
456
+ accumLen += chunk.byteLength;
457
+ if (accumLen > MAX_ENVELOPE_BYTES) throw new HttpParseError(`decodeMessage: envelope exceeds ${MAX_ENVELOPE_BYTES} bytes without delimiter (\\n)`);
458
+ continue;
459
+ }
460
+ accum.push(chunk.subarray(0, nl));
461
+ accumLen += nl;
462
+ before = concatChunks(accum, accumLen);
463
+ tail = chunk.subarray(nl + 1);
464
+ break;
465
+ }
466
+ let envelope;
467
+ try {
468
+ envelope = JSON.parse(decoder.decode(before));
469
+ } catch (err) {
470
+ throw new HttpParseError(`decodeMessage: malformed envelope JSON at bytes 0..${before.byteLength}: ${err.message}`);
471
+ }
472
+ async function* body() {
473
+ if (tail.byteLength > 0) yield tail;
474
+ while (true) {
475
+ const next = await iter.next();
476
+ if (next.done) return;
477
+ if (next.value.byteLength > 0) yield next.value;
478
+ }
479
+ }
480
+ return {
481
+ envelope,
482
+ body: body()
483
+ };
484
+ }
485
+ const OPEN_BRACE = 123;
486
+ /**
487
+ * The original wire format — `<JSON.stringify(envelope)>\n<body bytes…>` —
488
+ * expressed as a `MessageCodec`. Direction-agnostic: requests and responses
489
+ * serialise identically.
490
+ *
491
+ * Retained so a peer pair can be upgraded in either order; see ADR-0006.
492
+ */
493
+ const jsonEnvelopeCodec = {
494
+ name: "json-envelope",
495
+ sniff: (byte) => byte === OPEN_BRACE,
496
+ encodeRequest: (env, body) => encodeMessage(env, body),
497
+ encodeResponse: (env, body) => encodeMessage(env, body),
498
+ decodeRequest: (input) => decodeMessage(input),
499
+ decodeResponse: (input) => decodeMessage(input)
500
+ };
501
+ const TCHAR = /* @__PURE__ */ new Set("!#$%&'*+-.^_`|~0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ");
502
+ const SP = 32;
503
+ const HTAB = 9;
504
+ function isTokenChar(byte) {
505
+ return byte > SP && byte < 127 && TCHAR.has(String.fromCharCode(byte));
506
+ }
507
+ function isToken(value) {
508
+ if (value.length === 0) return false;
509
+ for (const ch of value) if (!TCHAR.has(ch)) return false;
510
+ return true;
511
+ }
512
+ /**
513
+ * Header bytes are latin-1: byte-preserving, matching Node, and lossless
514
+ * across a decode/encode round-trip. Control characters are rejected
515
+ * separately by `assertValidHeaderValue`.
516
+ */
517
+ function decodeLatin1(bytes) {
518
+ let out = "";
519
+ for (const byte of bytes) out += String.fromCharCode(byte);
520
+ return out;
521
+ }
522
+ function encodeLatin1(text) {
523
+ const out = new Uint8Array(text.length);
524
+ for (let i = 0; i < text.length; i++) {
525
+ const code = text.charCodeAt(i);
526
+ if (code > 255) throw new HttpParseError(`not latin-1 encodable: ${JSON.stringify(text)}`);
527
+ out[i] = code;
528
+ }
529
+ return out;
530
+ }
531
+ const HOST_IP_LITERAL = /^\[[0-9A-Fa-f:.]+\](:\d{1,5})?$/;
532
+ const HOST_REG_NAME = /^[A-Za-z0-9._~-]+(:\d{1,5})?$/;
533
+ /**
534
+ * Validates a value that is about to become (or was read as) a `Host`
535
+ * header: untrusted input either way, so an unrecognised shape is a refusal,
536
+ * not a guess. Rejects a present-but-empty value, one carrying userinfo
537
+ * (`evil.com@good.com`), and — the reason this also runs on encode — one
538
+ * smuggling a CRLF-terminated line into the authority of a caller-supplied
539
+ * url. Never applied to trusted configuration (a codec's `opts.host`).
540
+ */
541
+ function assertValidHost(host) {
542
+ if (!HOST_IP_LITERAL.test(host) && !HOST_REG_NAME.test(host)) throw new HttpParseError(`invalid Host header: ${JSON.stringify(host)}`);
543
+ }
544
+ /**
545
+ * A request-target must be entirely visible ASCII (RFC 9110 VCHAR) — nothing
546
+ * `<= 0x20` (space and every control character, CR/LF included) and nothing
547
+ * `>= 0x7F` (DEL and beyond). Applied on encode (`splitTarget`, so a
548
+ * caller-supplied url can never inject a second request line) and on decode
549
+ * (so a relay that decodes then re-encodes can never put an embedded control
550
+ * character back on the wire).
551
+ */
552
+ function assertValidTarget(target) {
553
+ for (let i = 0; i < target.length; i++) {
554
+ const code = target.charCodeAt(i);
555
+ if (code <= SP || code >= 127) throw new HttpParseError(`invalid request-target: ${JSON.stringify(target)}`);
556
+ }
557
+ }
558
+ /**
559
+ * Shared control-character check behind both `assertValidHeaderValue` and
560
+ * `assertValidStatusText` — the two were previously checked by different,
561
+ * looser rules (statusText only rejected CR/LF), which is exactly the
562
+ * asymmetry class C1 and M5 are both instances of. `label` is prepended
563
+ * verbatim to each message, so callers keep their own wording.
564
+ */
565
+ function assertNoControlChars(label, value) {
566
+ for (let i = 0; i < value.length; i++) {
567
+ const code = value.charCodeAt(i);
568
+ if (code === 13 || code === 10) throw new HttpParseError(`${label} contains CR or LF`);
569
+ if (code < SP && code !== HTAB || code === 127) throw new HttpParseError(`${label} contains a control character`);
570
+ if (code > 255) throw new HttpParseError(`${label} is not latin-1 encodable`);
571
+ }
572
+ }
573
+ function assertValidHeaderValue(name, value) {
574
+ assertNoControlChars(`header "${name}" value`, value);
575
+ }
576
+ /** Same character class as a header value (M5) — CR/LF, every C0 control, and DEL. */
577
+ function assertValidStatusText(value) {
578
+ assertNoControlChars("statusText", value);
579
+ }
580
+ function encodeHeaderLines(headers) {
581
+ let out = "";
582
+ for (const [name, value] of headers) {
583
+ if (!isToken(name)) throw new HttpParseError(`invalid header name: ${JSON.stringify(name)}`);
584
+ assertValidHeaderValue(name, value);
585
+ out += `${name}: ${value}\r\n`;
586
+ }
587
+ return out;
588
+ }
589
+ /**
590
+ * Read to the blank line. `alreadyUsed` is the byte count of the start line,
591
+ * so the bound covers the whole head section rather than the headers alone.
592
+ */
593
+ async function readHeaderSection(reader, maxHeaderBytes, alreadyUsed) {
594
+ const headers = [];
595
+ let used = alreadyUsed;
596
+ while (true) {
597
+ const remaining = maxHeaderBytes - used;
598
+ if (remaining <= 0) throw new HttpParseError(`head section exceeds ${maxHeaderBytes} bytes`);
599
+ const lineBytes = await reader.readLine(remaining);
600
+ used += lineBytes.byteLength + 2;
601
+ if (used > maxHeaderBytes) throw new HttpParseError(`head section exceeds ${maxHeaderBytes} bytes`);
602
+ if (lineBytes.byteLength === 0) return headers;
603
+ if (lineBytes[0] === SP || lineBytes[0] === HTAB) throw new HttpParseError("obs-fold header continuation is not accepted");
604
+ const line = decodeLatin1(lineBytes);
605
+ const colon = line.indexOf(":");
606
+ if (colon <= 0) throw new HttpParseError(`malformed header line: ${JSON.stringify(line)}`);
607
+ const name = line.slice(0, colon);
608
+ if (!isToken(name)) throw new HttpParseError(`invalid header name: ${JSON.stringify(name)}`);
609
+ const value = line.slice(colon + 1).replace(/^[ \t]+/, "").replace(/[ \t]+$/, "");
610
+ assertValidHeaderValue(name, value);
611
+ headers.push([name, value]);
612
+ }
613
+ }
614
+ function getAll(headers, name) {
615
+ const lower = name.toLowerCase();
616
+ return headers.filter(([k]) => k.toLowerCase() === lower).map(([, v]) => v);
617
+ }
618
+ function withoutHeaders(headers, names) {
619
+ const drop = new Set(names.map((n) => n.toLowerCase()));
620
+ return headers.filter(([k]) => !drop.has(k.toLowerCase()));
621
+ }
622
+ /**
623
+ * Parse a `Content-Length` field into a single validated value.
624
+ *
625
+ * RFC 9110 §8.6 permits the value to be a comma-separated list of identical
626
+ * numbers (a relay may have appended one), so the list is folded; differing
627
+ * values are a refusal, not a choice. Shared by `resolveFraming` on decode and
628
+ * by the encoder's declared-length check, which previously carried its own
629
+ * copy that did NOT split on commas — so decode accepted `5, 5` while encode
630
+ * rejected it, and a relay that decoded then re-encoded threw.
631
+ *
632
+ * Returns undefined when the field is absent.
633
+ */
634
+ function parseContentLength(values) {
635
+ if (values.length === 0) return void 0;
636
+ const unique = new Set(values.flatMap((v) => v.split(",").map((s) => s.trim())));
637
+ if (unique.size !== 1) throw new HttpParseError(`conflicting Content-Length values: ${[...unique].join(", ")}`);
638
+ const raw = [...unique][0];
639
+ if (!/^\d{1,15}$/.test(raw)) throw new HttpParseError(`invalid Content-Length: ${JSON.stringify(raw)}`);
640
+ return Number(raw);
641
+ }
642
+ /**
643
+ * Statuses that carry no body whatever the headers say (RFC 9110 §8.6): 1xx,
644
+ * 204 and 304. Defined once because the encoder must not frame a body for
645
+ * them and the decoder must not try to read one — two lists that agreed today
646
+ * and were free to drift tomorrow.
647
+ *
648
+ * A response to HEAD is also bodyless, but that depends on the request rather
649
+ * than the status, so callers test it separately.
650
+ */
651
+ function isBodylessStatus(status) {
652
+ return status < 200 || status === 204 || status === 304;
653
+ }
654
+ /**
655
+ * RFC 9112 §6.3, with every ambiguity turned into a refusal. In particular a
656
+ * message declaring both Content-Length and Transfer-Encoding is rejected
657
+ * rather than resolved — disagreeing on which one wins is request smuggling.
658
+ */
659
+ function resolveFraming(headers, version) {
660
+ const te = getAll(headers, "transfer-encoding");
661
+ const cl = getAll(headers, "content-length");
662
+ if (te.length > 0 && cl.length > 0) throw new HttpParseError("message declares both Content-Length and Transfer-Encoding; refusing (request smuggling)");
663
+ if (te.length > 0) {
664
+ const encodings = te.join(",").split(",").map((s) => s.trim().toLowerCase()).filter((s) => s !== "");
665
+ if (encodings.length !== 1 || encodings[0] !== "chunked") throw new HttpParseError(`unsupported Transfer-Encoding: ${JSON.stringify(te.join(", "))}`);
666
+ if (version === "HTTP/1.0") throw new HttpParseError("Transfer-Encoding is not valid in HTTP/1.0");
667
+ return { kind: "chunked" };
668
+ }
669
+ const length = parseContentLength(cl);
670
+ if (length !== void 0) return {
671
+ kind: "length",
672
+ length
673
+ };
674
+ return { kind: "none" };
675
+ }
676
+ const CRLF = new Uint8Array([13, 10]);
677
+ const LAST_CHUNK = new Uint8Array([
678
+ 48,
679
+ 13,
680
+ 10,
681
+ 13,
682
+ 10
683
+ ]);
684
+ const encoder = new TextEncoder();
685
+ /**
686
+ * Chunk sizes are written in HEXADECIMAL — a 21-byte chunk is `15`. Writing
687
+ * them in decimal is the defect note 15 found in @libp2p/http, and it is the
688
+ * one that cannot be caught downstream: decimal digits are also valid hex, so
689
+ * a conforming parser silently reads the wrong length.
690
+ *
691
+ * Zero-length source chunks are skipped; a zero-sized chunk on the wire is the
692
+ * body terminator.
693
+ */
694
+ async function* encodeChunked(body) {
695
+ for await (const chunk of body) {
696
+ if (chunk.byteLength === 0) continue;
697
+ yield encoder.encode(`${chunk.byteLength.toString(16)}\r\n`);
698
+ yield chunk;
699
+ yield CRLF;
700
+ }
701
+ yield LAST_CHUNK;
702
+ }
703
+ /**
704
+ * Decode a chunked body, yielding data chunks as they arrive. Nothing is
705
+ * accumulated: a chunk larger than the transport's frame is yielded in pieces.
706
+ */
707
+ async function* decodeChunked(reader, maxLineBytes) {
708
+ while (true) {
709
+ const sizeLine = decodeLatin1(await reader.readLine(maxLineBytes));
710
+ const semicolon = sizeLine.indexOf(";");
711
+ const sizeText = semicolon === -1 ? sizeLine : sizeLine.slice(0, semicolon);
712
+ if (!/^[0-9a-fA-F]{1,32}$/.test(sizeText)) throw new HttpParseError(`invalid chunk size: ${JSON.stringify(sizeLine)}`);
713
+ const size = Number.parseInt(sizeText, 16);
714
+ if (!Number.isSafeInteger(size)) throw new HttpParseError(`chunk size is too large: ${JSON.stringify(sizeLine)}`);
715
+ if (size === 0) {
716
+ let used = 0;
717
+ while (true) {
718
+ const remaining = maxLineBytes - used;
719
+ if (remaining <= 0) throw new HttpParseError(`trailer section exceeds ${maxLineBytes} bytes`);
720
+ const lineBytes = await reader.readLine(remaining);
721
+ used += lineBytes.byteLength + 2;
722
+ if (used > maxLineBytes) throw new HttpParseError(`trailer section exceeds ${maxLineBytes} bytes`);
723
+ if (lineBytes.byteLength === 0) break;
724
+ }
725
+ return;
726
+ }
727
+ let remaining = size;
728
+ while (remaining > 0) {
729
+ const part = await reader.readSome(remaining);
730
+ if (part === void 0) throw new HttpParseError(`chunk truncated: ${remaining} of ${size} bytes missing`);
731
+ remaining -= part.byteLength;
732
+ yield part;
733
+ }
734
+ try {
735
+ if ((await reader.readLine(2)).byteLength !== 0) throw new HttpParseError("chunk data not terminated by CRLF");
736
+ } catch (err) {
737
+ if (err instanceof ByteStreamError) throw new HttpParseError("chunk data not terminated by CRLF");
738
+ throw err;
739
+ }
740
+ }
741
+ }
742
+ const VERSIONS = /* @__PURE__ */ new Set(["HTTP/1.1", "HTTP/1.0"]);
743
+ const ABSOLUTE_FORM = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//;
744
+ /**
745
+ * One message per Duplex call (ADR-0006), so bytes after a complete message
746
+ * are an error. Checked against what is ALREADY BUFFERED rather than by
747
+ * awaiting end-of-stream: a live socket from a keep-alive peer never reaches
748
+ * EOF, so awaiting one would hang instead of failing.
749
+ */
750
+ function assertNoBufferedBytes(reader) {
751
+ const extra = reader.bufferedLength();
752
+ if (extra > 0) throw new HttpParseError(`${extra} trailing bytes after a complete message`);
753
+ }
754
+ /**
755
+ * The public error contract (I2): everything leaving `decodeRequest` /
756
+ * `decodeResponse` is an `HttpParseError`, whether the refusal happened
757
+ * synchronously (a malformed start line or header) or lazily while the body
758
+ * is later drained. `ByteStreamError` — the `ByteReader`'s own class, never
759
+ * exported — is the one thing converted here, message and all preserved via
760
+ * `cause`. Anything else is a genuine failure of the underlying source (a
761
+ * dropped socket, say) and must reach the caller unchanged: blanket-catching
762
+ * would hide that distinction.
763
+ */
764
+ function toHttpParseError(err) {
765
+ if (err instanceof ByteStreamError) throw new HttpParseError(err.message, { cause: err });
766
+ throw err;
767
+ }
768
+ /** Applies `toHttpParseError` across the whole lifetime of a body generator. */
769
+ async function* convertBodyErrors(source) {
770
+ try {
771
+ yield* source;
772
+ } catch (err) {
773
+ toHttpParseError(err);
774
+ }
775
+ }
776
+ async function* readBody(reader, framing, opts, noneMeansEof) {
777
+ if (framing.kind === "chunked") {
778
+ yield* decodeChunked(reader, opts.maxHeaderBytes);
779
+ assertNoBufferedBytes(reader);
780
+ return;
781
+ }
782
+ if (framing.kind === "length") {
783
+ let remaining = framing.length;
784
+ while (remaining > 0) {
785
+ const part = await reader.readSome(remaining);
786
+ if (part === void 0) throw new HttpParseError(`body truncated: ${remaining} of ${framing.length} bytes missing`);
787
+ remaining -= part.byteLength;
788
+ yield part;
789
+ }
790
+ assertNoBufferedBytes(reader);
791
+ return;
792
+ }
793
+ if (noneMeansEof) {
794
+ yield* reader.rest();
795
+ return;
796
+ }
797
+ assertNoBufferedBytes(reader);
798
+ }
799
+ /**
800
+ * `readLine`'s bound is best-effort — it is skipped when the line is already
801
+ * buffered — so a very long start line can reach these messages intact. Since
802
+ * a refusal is now echoed back to the sender in a 400 body, quote only enough
803
+ * to diagnose rather than reflecting the whole thing.
804
+ */
805
+ function quoteLine(line) {
806
+ const MAX = 120;
807
+ return line.length <= MAX ? JSON.stringify(line) : `${JSON.stringify(line.slice(0, MAX))} (truncated from ${line.length} chars)`;
808
+ }
809
+ async function decodeRequest(input, opts) {
810
+ const reader = new ByteReader(input);
811
+ try {
812
+ const startBytes = await reader.readLine(opts.maxHeaderBytes);
813
+ const startLine = decodeLatin1(startBytes);
814
+ const parts = startLine.split(" ");
815
+ if (parts.length !== 3) throw new HttpParseError(`malformed request line: ${quoteLine(startLine)}`);
816
+ const [method, target, version] = parts;
817
+ if (!isToken(method)) throw new HttpParseError(`invalid method: ${JSON.stringify(method)}`);
818
+ if (!VERSIONS.has(version)) throw new HttpParseError(`unsupported HTTP version: ${JSON.stringify(version)}`);
819
+ if (target === "*") throw new HttpParseError("asterisk-form request target is not supported");
820
+ assertValidTarget(target);
821
+ const headers = await readHeaderSection(reader, opts.maxHeaderBytes, startBytes.byteLength + 2);
822
+ const hosts = getAll(headers, "host");
823
+ if (hosts.length > 1) throw new HttpParseError("multiple Host headers");
824
+ const host = hosts[0];
825
+ let url;
826
+ if (target.startsWith("/")) {
827
+ if (version === "HTTP/1.1" && host === void 0) throw new HttpParseError("HTTP/1.1 request has no Host header");
828
+ if (host !== void 0) assertValidHost(host);
829
+ url = `${opts.scheme}://${host ?? opts.host}${target}`;
830
+ } else if (ABSOLUTE_FORM.test(target)) url = target;
831
+ else throw new HttpParseError(`unsupported request target: ${JSON.stringify(target)}`);
832
+ try {
833
+ new URL(url);
834
+ } catch {
835
+ throw new HttpParseError(`decoded url is not a valid URL: ${JSON.stringify(url)}`);
836
+ }
837
+ return {
838
+ envelope: {
839
+ url,
840
+ method,
841
+ headers
842
+ },
843
+ body: convertBodyErrors(readBody(reader, resolveFraming(headers, version), opts, false))
844
+ };
845
+ } catch (err) {
846
+ toHttpParseError(err);
847
+ }
848
+ }
849
+ async function decodeResponse(input, opts, method) {
850
+ const reader = new ByteReader(input);
851
+ try {
852
+ const startBytes = await reader.readLine(opts.maxHeaderBytes);
853
+ const startLine = decodeLatin1(startBytes);
854
+ const firstSp = startLine.indexOf(" ");
855
+ if (firstSp === -1) throw new HttpParseError(`malformed status line: ${quoteLine(startLine)}`);
856
+ const version = startLine.slice(0, firstSp);
857
+ if (!VERSIONS.has(version)) throw new HttpParseError(`unsupported HTTP version: ${JSON.stringify(version)}`);
858
+ const afterVersion = startLine.slice(firstSp + 1);
859
+ const secondSp = afterVersion.indexOf(" ");
860
+ const codeText = secondSp === -1 ? afterVersion : afterVersion.slice(0, secondSp);
861
+ if (!/^\d{3}$/.test(codeText)) throw new HttpParseError(`invalid status code: ${JSON.stringify(codeText)}`);
862
+ const status = Number(codeText);
863
+ const statusText = secondSp === -1 ? "" : afterVersion.slice(secondSp + 1);
864
+ const headers = await readHeaderSection(reader, opts.maxHeaderBytes, startBytes.byteLength + 2);
865
+ const bodyless = isBodylessStatus(status) || method.toUpperCase() === "HEAD";
866
+ const framing = bodyless ? { kind: "none" } : resolveFraming(headers, version);
867
+ return {
868
+ envelope: {
869
+ status,
870
+ statusText,
871
+ headers
872
+ },
873
+ body: convertBodyErrors(readBody(reader, framing, opts, !bodyless))
874
+ };
875
+ } catch (err) {
876
+ toHttpParseError(err);
877
+ }
878
+ }
879
+ /** Headers the codec owns: a caller-supplied copy is dropped and re-derived. */
880
+ const REQUEST_OWNED = [
881
+ "host",
882
+ "connection",
883
+ "transfer-encoding"
884
+ ];
885
+ const RESPONSE_OWNED = ["connection", "transfer-encoding"];
886
+ /**
887
+ * Split a URL into an origin-form request target and an authority *without*
888
+ * going through `new URL()`. `URL` normalises percent-encoding and would
889
+ * re-serialise the target — which is the exact class of defect note 15 found
890
+ * in @libp2p/http, where rebuilding the request line silently dropped
891
+ * `url.search`. Here the target is a verbatim slice of the caller's string.
892
+ */
893
+ function splitTarget(url, opts) {
894
+ if (url.startsWith("/")) {
895
+ assertValidTarget(url);
896
+ return {
897
+ target: url,
898
+ authority: opts.host
899
+ };
900
+ }
901
+ const match = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/([^/?#]*)([^#]*)/.exec(url);
902
+ if (!match) throw new HttpParseError(`cannot derive a request target from url: ${JSON.stringify(url)}`);
903
+ const rawAuthority = match[1];
904
+ if (rawAuthority === "") throw new HttpParseError(`url has no authority: ${JSON.stringify(url)}`);
905
+ const at = rawAuthority.lastIndexOf("@");
906
+ const authority = at === -1 ? rawAuthority : rawAuthority.slice(at + 1);
907
+ assertValidHost(authority);
908
+ const rawTarget = match[2];
909
+ const target = rawTarget === "" ? "/" : rawTarget.startsWith("/") ? rawTarget : `/${rawTarget}`;
910
+ assertValidTarget(target);
911
+ return {
912
+ target,
913
+ authority
914
+ };
915
+ }
916
+ async function* emitBody(body, declared) {
917
+ if (declared === void 0) {
918
+ yield* encodeChunked(body);
919
+ return;
920
+ }
921
+ let sent = 0;
922
+ for await (const chunk of body) {
923
+ if (chunk.byteLength === 0) continue;
924
+ sent += chunk.byteLength;
925
+ if (sent > declared) throw new HttpParseError(`body exceeds declared Content-Length ${declared} (${sent} bytes so far)`);
926
+ yield chunk;
927
+ }
928
+ if (sent !== declared) throw new HttpParseError(`body is ${sent} bytes but Content-Length declares ${declared}`);
929
+ }
930
+ async function* encodeRequest(env, body, opts) {
931
+ if (!isToken(env.method)) throw new HttpParseError(`invalid method: ${JSON.stringify(env.method)}`);
932
+ const { target, authority } = splitTarget(env.url, opts);
933
+ if (authority === "") throw new HttpParseError("no Host available: url has no authority and no host is configured");
934
+ const carried = withoutHeaders(env.headers, REQUEST_OWNED);
935
+ const declared = parseContentLength(getAll(carried, "content-length"));
936
+ let head = `${env.method} ${target} HTTP/1.1\r\n`;
937
+ head += `Host: ${authority}\r\n`;
938
+ head += encodeHeaderLines(carried);
939
+ head += "Connection: close\r\n";
940
+ if (body !== void 0 && declared === void 0) head += "Transfer-Encoding: chunked\r\n";
941
+ head += "\r\n";
942
+ yield encodeLatin1(head);
943
+ if (body === void 0) {
944
+ if (declared !== void 0 && declared !== 0) throw new HttpParseError(`body is 0 bytes but Content-Length declares ${declared}`);
945
+ return;
946
+ }
947
+ yield* emitBody(body, declared);
948
+ }
949
+ async function* encodeResponse(env, body, _opts, requestMethod) {
950
+ if (!Number.isInteger(env.status) || env.status < 100 || env.status > 599) throw new HttpParseError(`invalid status: ${env.status}`);
951
+ const reason = env.statusText ?? "";
952
+ assertValidStatusText(reason);
953
+ const bodylessStatus = isBodylessStatus(env.status);
954
+ const bodyless = bodylessStatus || requestMethod?.toUpperCase() === "HEAD";
955
+ const owned = bodylessStatus ? [...RESPONSE_OWNED, "content-length"] : RESPONSE_OWNED;
956
+ const carried = withoutHeaders(env.headers, owned);
957
+ const declared = parseContentLength(getAll(carried, "content-length"));
958
+ let head = `HTTP/1.1 ${env.status} ${reason}\r\n`;
959
+ head += encodeHeaderLines(carried);
960
+ head += "Connection: close\r\n";
961
+ if (!bodyless && body !== void 0 && declared === void 0) head += "Transfer-Encoding: chunked\r\n";
962
+ head += "\r\n";
963
+ yield encodeLatin1(head);
964
+ if (bodyless) {
965
+ await discard(body);
966
+ return;
967
+ }
968
+ if (body === void 0) {
969
+ if (declared !== void 0 && declared !== 0) throw new HttpParseError(`body is 0 bytes but Content-Length declares ${declared}`);
970
+ return;
971
+ }
972
+ yield* emitBody(body, declared);
973
+ }
974
+ function newHttpCodec(options = {}) {
975
+ const opts = {
976
+ scheme: options.scheme ?? "http",
977
+ host: options.host ?? "localhost",
978
+ maxHeaderBytes: options.maxHeaderBytes ?? 65536
979
+ };
980
+ return {
981
+ name: "http/1.1",
982
+ sniff: (byte) => isTokenChar(byte),
983
+ encodeRequest: (env, body) => encodeRequest(env, body, opts),
984
+ encodeResponse: (env, body, o) => encodeResponse(env, body, opts, o?.method),
985
+ decodeRequest: (input) => decodeRequest(input, opts),
986
+ decodeResponse: (input, o) => decodeResponse(input, opts, o.method)
987
+ };
988
+ }
989
+ const httpCodec = newHttpCodec();
990
+ /**
991
+ * Dispatches on byte 0. The formats are self-identifying — a JSON envelope
992
+ * always begins `{`, which is not a token character and so can never begin an
993
+ * HTTP start-line — so no negotiation handshake, magic prefix, or version byte
994
+ * is needed.
995
+ *
996
+ * This is what makes a mixed-version peer pair safe: readers accept either
997
+ * format, so the two ends can be upgraded in any order.
998
+ */
999
+ function newSniffingCodec(options) {
1000
+ const { write, accept } = options;
1001
+ async function pick(input) {
1002
+ const reader = new ByteReader(input);
1003
+ const first = await reader.peekByte();
1004
+ if (first === void 0) throw new HttpParseError("sniff: stream ended before any bytes arrived");
1005
+ const codec = accept.find((c) => c.sniff(first));
1006
+ if (!codec) throw new HttpParseError(`sniff: no accepted codec recognises a message starting with byte 0x${first.toString(16).padStart(2, "0")}`);
1007
+ return {
1008
+ codec,
1009
+ input: reader.rest()
1010
+ };
1011
+ }
1012
+ return {
1013
+ name: `sniff(write=${write.name}; accept=${accept.map((c) => c.name).join(",")})`,
1014
+ sniff: (byte) => accept.some((c) => c.sniff(byte)),
1015
+ encodeRequest: (env, body) => write.encodeRequest(env, body),
1016
+ encodeResponse: (env, body, o) => write.encodeResponse(env, body, o),
1017
+ decodeRequest: async (input) => {
1018
+ const picked = await pick(input);
1019
+ try {
1020
+ return {
1021
+ ...await picked.codec.decodeRequest(picked.input),
1022
+ codec: picked.codec
1023
+ };
1024
+ } catch (error) {
1025
+ if (error !== null && typeof error === "object") error.codec = picked.codec;
1026
+ throw error;
1027
+ }
1028
+ },
1029
+ decodeResponse: async (input, o) => {
1030
+ const picked = await pick(input);
1031
+ return picked.codec.decodeResponse(picked.input, o);
1032
+ }
1033
+ };
1034
+ }
1035
+ newSniffingCodec({
1036
+ write: httpCodec,
1037
+ accept: [httpCodec, jsonEnvelopeCodec]
1038
+ });
1039
+ /**
1040
+ * The one place that knows a runtime may not implement request body streams,
1041
+ * and the buffering both fallbacks need. `fetch.ts` and `http-stubs.ts` each
1042
+ * carry a client and a server direction that must agree on the answer, so the
1043
+ * predicate lives here rather than being written out four times.
1044
+ */
1045
+ /**
1046
+ * Whether `Request.prototype` exposes a `body` accessor. Two call sites read
1047
+ * that property, and two more depend on the constructor accepting a
1048
+ * `ReadableStream` as `init.body`; this predicate answers for all four.
1049
+ *
1050
+ * What was verified, and all that is claimed here: Firefox (146 at the time of
1051
+ * writing) has *neither*, and Chromium and Node have *both*. On Firefox it is
1052
+ * not that `body` is `undefined` on the instance —
1053
+ * `Object.getOwnPropertyDescriptor(Request.prototype, "body")` is `null`, the
1054
+ * accessor is genuinely absent — and because a `ReadableStream` is then not a
1055
+ * recognised `BodyInit`, the constructor falls through to the string branch and
1056
+ * stores the literal text `[object ReadableStream]`.
1057
+ *
1058
+ * The two halves are NOT guaranteed to ship together, so do not read this as a
1059
+ * test for "request streams" in general. Safari is the counterexample: it has
1060
+ * had `Request.body` since 11.1 but only accepts a stream as `init.body` from
1061
+ * Technology Preview 250, so a shipping Safari has the reader half without the
1062
+ * upload half and this returns `true` there. That looks benign — WebKit appears
1063
+ * to store the stream on the `Request` rather than stringify it, and its error
1064
+ * comes from `fetch()`, which this package never calls on the objects it builds
1065
+ * — but it is untested, and it is the case to look at first if a Safari report
1066
+ * arrives. The sharper probe, if one is ever needed, is whether
1067
+ * `new Request(url, {method:"POST", body:new ReadableStream(), duplex:"half"})`
1068
+ * has a `content-type` of `text/plain;charset=UTF-8` (stringified) or `null`
1069
+ * (stored); it is not used here because it costs a `Request` and a
1070
+ * `ReadableStream` per call and agrees with the descriptor check on every
1071
+ * runtime measured.
1072
+ *
1073
+ * A capability check, never a user-agent test: the question is what this
1074
+ * runtime does, and the answer flips on its own the day Firefox ships request
1075
+ * streams. Evaluated per call rather than cached at module load so that a test
1076
+ * can install a `Request` without the capability and exercise the real branch
1077
+ * under Node.
1078
+ */
1079
+ function supportsRequestStreams() {
1080
+ return typeof Request === "function" && Object.getOwnPropertyDescriptor(Request.prototype, "body") != null;
1081
+ }
1082
+ const NULL_BODY_STATUSES = /* @__PURE__ */ new Set([
1083
+ 101,
1084
+ 103,
1085
+ 204,
1086
+ 205,
1087
+ 304
1088
+ ]);
1089
+ const REQUEST_FIELDS = [
1090
+ "url",
1091
+ "method",
1092
+ "mode",
1093
+ "credentials",
1094
+ "cache",
1095
+ "redirect",
1096
+ "referrer",
1097
+ "referrerPolicy",
1098
+ "integrity",
1099
+ "keepalive"
1100
+ ];
1101
+ /** `SerializedHttpEnvelope.content` is never absent, only empty. */
1102
+ async function* noBytes() {}
1103
+ async function* oneChunk(chunk) {
1104
+ yield chunk;
1105
+ }
1106
+ /**
1107
+ * Returns an HTTP handler that serializes a Request, hands the envelope to
1108
+ * `send` for transport, and deserializes the reply into a Response. Used on
1109
+ * the caller side.
1110
+ */
1111
+ function newHttpClientStub(send) {
1112
+ return async (requestOrPromise) => {
1113
+ const request = await requestOrPromise;
1114
+ const headers = [...request.headers].map(([k, v]) => [k, v]);
1115
+ const options = {
1116
+ url: request.url,
1117
+ headers
1118
+ };
1119
+ for (const field of REQUEST_FIELDS) {
1120
+ const val = request[field];
1121
+ if (val !== void 0 && field !== "url") options[field] = val;
1122
+ }
1123
+ let content;
1124
+ if (request.body != null) content = fromReadableStream(request.body);
1125
+ else if (!supportsRequestStreams()) content = oneChunk(new Uint8Array(await request.arrayBuffer()));
1126
+ else content = noBytes();
1127
+ const result = await send({
1128
+ options,
1129
+ content
1130
+ });
1131
+ if (!result) return new Response(null, {
1132
+ status: 404,
1133
+ statusText: "Error 404: Not Found"
1134
+ });
1135
+ const responseOptions = result.options;
1136
+ const method = options.method;
1137
+ if (method === "HEAD" || method === "OPTIONS" || NULL_BODY_STATUSES.has(responseOptions.status)) {
1138
+ await discard(result.content);
1139
+ return new Response(null, responseOptions);
1140
+ }
1141
+ return new Response(toReadableStream(result.content[Symbol.asyncIterator]()), responseOptions);
1142
+ };
1143
+ }
1144
+ var HttpError = class HttpError extends Error {
1145
+ status;
1146
+ statusText;
1147
+ constructor(options = {}) {
1148
+ super(options.message ?? options.statusText ?? "HTTP Error");
1149
+ this.status = options.status;
1150
+ this.statusText = options.statusText;
1151
+ }
1152
+ getResponseOptions(options = {}) {
1153
+ return {
1154
+ ...this.toJson(),
1155
+ ...options
1156
+ };
1157
+ }
1158
+ toJson() {
1159
+ return {
1160
+ status: this.status,
1161
+ statusText: this.statusText,
1162
+ message: this.message
1163
+ };
1164
+ }
1165
+ static fromError(error) {
1166
+ if (error instanceof HttpError) return error;
1167
+ const message = error instanceof Error ? error.message : String(error);
1168
+ return new HttpError({
1169
+ status: 500,
1170
+ statusText: "Bad Request",
1171
+ message
1172
+ });
1173
+ }
1174
+ static errorResourceNotFound(options = {}) {
1175
+ return new HttpError({
1176
+ status: 404,
1177
+ statusText: "Error 404: Resource not found",
1178
+ ...options
1179
+ });
1180
+ }
1181
+ static errorForbidden(options = {}) {
1182
+ return new HttpError({
1183
+ status: 403,
1184
+ statusText: "Error 403: Forbidden",
1185
+ ...options
1186
+ });
1187
+ }
1188
+ static errorResourceGone(options = {}) {
1189
+ return new HttpError({
1190
+ status: 410,
1191
+ statusText: "Error 410: Resource Gone",
1192
+ ...options
1193
+ });
1194
+ }
1195
+ static errorInternalError(options = {}) {
1196
+ return new HttpError({
1197
+ ...options,
1198
+ status: 500,
1199
+ statusText: "Error 500: Internal error"
1200
+ });
1201
+ }
1202
+ };
1203
+ //#endregion
1204
+ //#region ../../node_modules/.pnpm/idb-keyval@6.3.0/node_modules/idb-keyval/dist/index.js
1205
+ function promisifyRequest(request) {
1206
+ return new Promise((resolve, reject) => {
1207
+ request.oncomplete = request.onsuccess = () => resolve(request.result);
1208
+ request.onabort = request.onerror = () => reject(request.error);
1209
+ });
1210
+ }
1211
+ function createStore(dbName, storeName) {
1212
+ let dbp;
1213
+ const getDB = () => {
1214
+ if (dbp) return dbp;
1215
+ const request = indexedDB.open(dbName);
1216
+ request.onupgradeneeded = () => request.result.createObjectStore(storeName);
1217
+ dbp = promisifyRequest(request);
1218
+ dbp.then((db) => {
1219
+ db.onclose = () => dbp = void 0;
1220
+ }, () => {
1221
+ dbp = void 0;
1222
+ });
1223
+ return dbp;
1224
+ };
1225
+ return (txMode, callback) => getDB().then((db) => callback(db.transaction(storeName, txMode).objectStore(storeName)));
1226
+ }
1227
+ let defaultGetStoreFunc;
1228
+ function defaultGetStore() {
1229
+ if (!defaultGetStoreFunc) defaultGetStoreFunc = createStore("keyval-store", "keyval");
1230
+ return defaultGetStoreFunc;
1231
+ }
1232
+ /**
1233
+ * Get a value by its key.
1234
+ *
1235
+ * @param key
1236
+ * @param customStore Method to get a custom store. Use with caution (see the docs).
1237
+ */
1238
+ function get(key, customStore = defaultGetStore()) {
1239
+ return customStore("readonly", (store) => promisifyRequest(store.get(key)));
1240
+ }
1241
+ /**
1242
+ * Set a value with a key.
1243
+ *
1244
+ * @param key
1245
+ * @param value
1246
+ * @param customStore Method to get a custom store. Use with caution (see the docs).
1247
+ */
1248
+ function set(key, value, customStore = defaultGetStore()) {
1249
+ return customStore("readwrite", (store) => {
1250
+ store.put(value, key);
1251
+ return promisifyRequest(store.transaction);
1252
+ });
1253
+ }
1254
+ //#endregion
1255
+ //#region src/core/data-calls.ts
1256
+ function callChannel(target, callType, params, ...transfers) {
1257
+ return new Promise((resolve, reject) => {
1258
+ const channel = new MessageChannel();
1259
+ channel.port1.onmessage = (ev) => {
1260
+ const { result, error } = ev.data;
1261
+ if (error) reject(deserializeError(error));
1262
+ else resolve(result);
1263
+ };
1264
+ target.postMessage({
1265
+ type: callType,
1266
+ params
1267
+ }, [channel.port2, ...transfers]);
1268
+ });
1269
+ }
1270
+ function handleChannelCalls(target, callType, handler) {
1271
+ const listener = async (event) => {
1272
+ if (!event.data || event.data.type !== callType) return;
1273
+ const [port, ...transfers] = event.ports ?? [];
1274
+ const response = {};
1275
+ try {
1276
+ response.result = await handler(event, event.data.params, ...transfers);
1277
+ } catch (error) {
1278
+ response.error = serializeError(error);
1279
+ }
1280
+ port?.postMessage(response);
1281
+ };
1282
+ target.addEventListener("message", listener);
1283
+ target.start?.();
1284
+ return () => target.removeEventListener("message", listener);
1285
+ }
1286
+ //#endregion
1287
+ //#region src/core/registry.ts
1288
+ function newRegistry(onError = console.error) {
1289
+ let counter = 0;
1290
+ const registrations = {};
1291
+ const register = (action) => {
1292
+ const id = counter++;
1293
+ const fn = Object.assign((skip) => {
1294
+ try {
1295
+ delete registrations[id];
1296
+ return skip ? void 0 : action();
1297
+ } catch (error) {
1298
+ onError(error);
1299
+ }
1300
+ }, { action });
1301
+ registrations[id] = fn;
1302
+ return fn;
1303
+ };
1304
+ const unregister = (action) => {
1305
+ for (const r of Object.values(registrations)) if (r.action === action) r(true);
1306
+ };
1307
+ const clear = () => {
1308
+ for (const r of Object.values(registrations)) r();
1309
+ };
1310
+ return Object.assign([
1311
+ register,
1312
+ clear,
1313
+ unregister
1314
+ ], {
1315
+ register,
1316
+ clear,
1317
+ unregister
1318
+ });
1319
+ }
1320
+ //#endregion
1321
+ //#region src/core/service-worker-control.ts
1322
+ /** Channel call a page sends to ask its ServiceWorker to `clients.claim()` it. */
1323
+ const CLAIM_CALL = "CLAIM";
1324
+ /**
1325
+ * ServiceWorker side: answers the page's `CLAIM` request with
1326
+ * `clients.claim()`, which takes over every uncontrolled client in scope.
1327
+ * Returns a function that stops answering.
1328
+ */
1329
+ function handleClaimRequests(self) {
1330
+ return handleChannelCalls(self, CLAIM_CALL, (event) => {
1331
+ const claimed = self.clients.claim().then(() => true);
1332
+ event.waitUntil?.(claimed);
1333
+ return claimed;
1334
+ });
1335
+ }
1336
+ //#endregion
1337
+ //#region src/core/data-channels.ts
1338
+ const MESSAGE_TYPE_REQUEST = "REQUEST";
1339
+ const MESSAGE_TYPE_RESPONSE = "RESPONSE";
1340
+ let __invocationCounter = 0;
1341
+ function newInvokationChannel({ port, handler = () => {
1342
+ throw new Error("Handler not implemented");
1343
+ }, onError = console.error, newCallId = () => ++__invocationCounter }) {
1344
+ const requests = {};
1345
+ const listener = async (event) => {
1346
+ const data = event.data ?? {};
1347
+ if (data.type === MESSAGE_TYPE_REQUEST) try {
1348
+ const result = await handler(data.request, ...event.ports);
1349
+ const [response, ...transfers] = Array.isArray(result) ? result : result !== void 0 ? [result] : [];
1350
+ port.postMessage({
1351
+ type: MESSAGE_TYPE_RESPONSE,
1352
+ callId: data.callId,
1353
+ response
1354
+ }, transfers);
1355
+ } catch (error) {
1356
+ port.postMessage({
1357
+ type: MESSAGE_TYPE_RESPONSE,
1358
+ callId: data.callId,
1359
+ error: serializeError(error)
1360
+ });
1361
+ }
1362
+ else if (data.type === MESSAGE_TYPE_RESPONSE) {
1363
+ const pending = requests[data.callId];
1364
+ delete requests[data.callId];
1365
+ if (!pending) return;
1366
+ if (data.error) pending.reject(deserializeError(data.error));
1367
+ else pending.resolve(data.response);
1368
+ }
1369
+ };
1370
+ const start = async () => {
1371
+ try {
1372
+ port.addEventListener("message", listener);
1373
+ await port.start?.();
1374
+ } catch (e) {
1375
+ onError(e);
1376
+ }
1377
+ };
1378
+ const close = async () => {
1379
+ try {
1380
+ port.removeEventListener("message", listener);
1381
+ await port.close?.();
1382
+ } catch (e) {
1383
+ onError(e);
1384
+ }
1385
+ };
1386
+ const invoke = (request = {}, ...transfers) => {
1387
+ const callId = newCallId();
1388
+ return new Promise((resolve, reject) => {
1389
+ try {
1390
+ requests[callId] = {
1391
+ resolve,
1392
+ reject
1393
+ };
1394
+ port.postMessage({
1395
+ type: MESSAGE_TYPE_REQUEST,
1396
+ callId,
1397
+ request
1398
+ }, transfers);
1399
+ } catch (error) {
1400
+ delete requests[callId];
1401
+ reject(error);
1402
+ }
1403
+ });
1404
+ };
1405
+ return {
1406
+ start,
1407
+ close,
1408
+ invoke
1409
+ };
1410
+ }
1411
+ async function* sendStream(communicationPort, input, params = {}) {
1412
+ const messageChannel = new MessageChannel();
1413
+ communicationPort.postMessage({
1414
+ type: "START_CALL",
1415
+ params
1416
+ }, [messageChannel.port2]);
1417
+ const channel = newStreamChannel(messageChannel.port1);
1418
+ let drained = false;
1419
+ try {
1420
+ await channel.start();
1421
+ channel.sendAll(input);
1422
+ yield* channel.recieveAll();
1423
+ drained = true;
1424
+ } finally {
1425
+ if (!drained) channel.cancel();
1426
+ await channel.close();
1427
+ }
1428
+ }
1429
+ function newStreamChannel(port) {
1430
+ let listeners = [];
1431
+ let iterators = [];
1432
+ const notifyAll = async (data) => {
1433
+ for (const listener of listeners) await listener(data);
1434
+ };
1435
+ /**
1436
+ * Release whatever we are sending. NOT awaited: `.return()` on an async
1437
+ * generator parked awaiting its own source is queued behind that pending
1438
+ * `next()`, so awaiting it here would block the message handler — and the
1439
+ * chunk that would unblock it can only arrive through that same handler.
1440
+ */
1441
+ const cancelOutgoing = () => {
1442
+ for (const it of [...iterators]) {
1443
+ const iterable = it;
1444
+ Promise.resolve(iterable.return?.()).catch(() => {});
1445
+ }
1446
+ };
1447
+ const channel = newInvokationChannel({
1448
+ port,
1449
+ handler: (data) => {
1450
+ const message = data;
1451
+ if (message?.cancel) {
1452
+ cancelOutgoing();
1453
+ return;
1454
+ }
1455
+ return notifyAll(message);
1456
+ }
1457
+ });
1458
+ const start = () => channel.start();
1459
+ const cancel = () => {
1460
+ channel.invoke({ cancel: true }).catch(() => {});
1461
+ };
1462
+ const close = async () => {
1463
+ await notifyAll({ done: true });
1464
+ for (const it of [...iterators]) await it.return?.();
1465
+ await channel.close();
1466
+ };
1467
+ async function* recieveAll() {
1468
+ yield* recieveIterator((deliver) => {
1469
+ listeners.push(deliver);
1470
+ return () => {
1471
+ listeners = listeners.filter((l) => l !== deliver);
1472
+ };
1473
+ });
1474
+ }
1475
+ async function sendAll(it) {
1476
+ await sendIterator((chunk) => void channel.invoke(chunk), (async function* () {
1477
+ try {
1478
+ iterators.push(it);
1479
+ yield* it;
1480
+ } finally {
1481
+ iterators = iterators.filter((i) => i !== it);
1482
+ }
1483
+ })());
1484
+ }
1485
+ return {
1486
+ start,
1487
+ close,
1488
+ cancel,
1489
+ recieveAll,
1490
+ sendAll
1491
+ };
1492
+ }
1493
+ //#endregion
1494
+ //#region src/http/http-send-recieve.ts
1495
+ async function* httpToIterator(envelopeOrPromise) {
1496
+ const { options, content } = await envelopeOrPromise;
1497
+ yield new TextEncoder().encode(JSON.stringify(options));
1498
+ yield* content;
1499
+ }
1500
+ async function httpFromIterator(iterable) {
1501
+ const it = (await iterable)[Symbol.asyncIterator]();
1502
+ const { done, value } = await it.next();
1503
+ let options = {};
1504
+ if (!done && value) {
1505
+ const str = new TextDecoder().decode(value);
1506
+ options = JSON.parse(str);
1507
+ }
1508
+ return {
1509
+ options,
1510
+ content: { [Symbol.asyncIterator]() {
1511
+ return it;
1512
+ } }
1513
+ };
1514
+ }
1515
+ /**
1516
+ * Ship a `Request` over a `MessageTarget` and await the `Response`, using this
1517
+ * package's own `sendStream` transport.
1518
+ *
1519
+ * @deprecated Prefer the port stack in `@statewalker/webrun-rpc`: open a port
1520
+ * (`multiplexPort` over one pipe, or `transferPortMux` where the platform can
1521
+ * transfer a real `MessagePort`), turn it into a `Duplex` with
1522
+ * `duplexOverPort`, and drive HTTP over it with `httpFetch` from
1523
+ * `@statewalker/webrun-http-streams`.
1524
+ *
1525
+ * Same caveat as {@link handleHttpRequests}: the transport underneath this
1526
+ * helper has no backpressure, no per-stream timeout, and no chunking to a
1527
+ * transport's message ceiling. Kept for existing ServiceWorker setups.
1528
+ */
1529
+ async function sendHttpRequest(communicationPort, request) {
1530
+ return await newHttpClientStub(async (req) => {
1531
+ return await httpFromIterator(sendStream(communicationPort, httpToIterator(req)));
1532
+ })(request);
1533
+ }
1534
+ //#endregion
1535
+ //#region src/relay/mount-table.ts
1536
+ /** `/` stays `/`; `/peers` and `/peers/` both become `/peers/`. */
1537
+ function normalise(path) {
1538
+ if (path === "" || path === "/") return "/";
1539
+ const withSlash = path.startsWith("/") ? path : `/${path}`;
1540
+ return withSlash.endsWith("/") ? withSlash : `${withSlash}/`;
1541
+ }
1542
+ /** Does `prefix` own `pathname`? `/peers/` owns `/peers/`, `/peers` and `/peers/x`. */
1543
+ function owns(prefix, pathname) {
1544
+ if (prefix === "/") return true;
1545
+ if (pathname.startsWith(prefix)) return true;
1546
+ return `${pathname}/` === prefix;
1547
+ }
1548
+ function newMountTable(options = {}) {
1549
+ const entries = /* @__PURE__ */ new Map();
1550
+ let seq = 0;
1551
+ return {
1552
+ set(key, spec) {
1553
+ entries.set(key, {
1554
+ key,
1555
+ prefix: spec.path == null ? "" : normalise(spec.path),
1556
+ match: spec.match,
1557
+ seq: seq++
1558
+ });
1559
+ },
1560
+ remove(key) {
1561
+ entries.delete(key);
1562
+ },
1563
+ excludes(url) {
1564
+ return options.exclude?.(url) === true;
1565
+ },
1566
+ find(url) {
1567
+ if (options.exclude?.(url) === true) return void 0;
1568
+ let best;
1569
+ for (const entry of entries.values()) {
1570
+ if (entry.prefix === "" || !owns(entry.prefix, url.pathname)) continue;
1571
+ if (best == null || entry.prefix.length > best.prefix.length) best = entry;
1572
+ }
1573
+ if (best != null) return best.key;
1574
+ const predicates = [...entries.values()].filter((entry) => entry.match != null).sort((a, b) => a.seq - b.seq);
1575
+ for (const entry of predicates) if (entry.match?.(url) === true) return entry.key;
1576
+ }
1577
+ };
1578
+ }
1579
+ //#endregion
1580
+ //#region src/relay/split-service-url.ts
1581
+ /**
1582
+ * Splits a URL of the form `<base>/<separator><key>/<path>` into parts.
1583
+ * Example: `https://host/~FS/a/b` → `{ baseUrl: "https://host/~FS/", key: "FS", path: "a/b" }`.
1584
+ */
1585
+ function splitServiceUrl(url, separator = "~") {
1586
+ const str = `${url}`;
1587
+ const empty = {
1588
+ url: str,
1589
+ key: "",
1590
+ baseUrl: "",
1591
+ path: ""
1592
+ };
1593
+ const hashIdx = str.indexOf("#");
1594
+ const queryIdx = str.indexOf("?");
1595
+ let strippedEnd = str.length;
1596
+ if (hashIdx >= 0) strippedEnd = Math.min(strippedEnd, hashIdx);
1597
+ if (queryIdx >= 0) strippedEnd = Math.min(strippedEnd, queryIdx);
1598
+ const stripped = str.slice(0, strippedEnd);
1599
+ let prefixEnd = 0;
1600
+ const schemeMatch = stripped.match(/^[a-zA-Z][a-zA-Z0-9+\-.]*:\/\//);
1601
+ if (schemeMatch) {
1602
+ prefixEnd = schemeMatch[0].length;
1603
+ const slashIdx = stripped.indexOf("/", prefixEnd);
1604
+ if (slashIdx >= 0) prefixEnd = slashIdx;
1605
+ else return empty;
1606
+ } else if (stripped.startsWith("//")) {
1607
+ prefixEnd = 2;
1608
+ const slashIdx = stripped.indexOf("/", prefixEnd);
1609
+ if (slashIdx >= 0) prefixEnd = slashIdx;
1610
+ else return empty;
1611
+ }
1612
+ const prefix = stripped.slice(0, prefixEnd);
1613
+ const pathPart = stripped.slice(prefixEnd);
1614
+ let keyStart;
1615
+ let rooted = false;
1616
+ if (prefix === "") {
1617
+ if (pathPart.startsWith(`/${separator}`)) {
1618
+ rooted = true;
1619
+ keyStart = separator.length + 1;
1620
+ } else if (pathPart.startsWith(separator)) keyStart = separator.length;
1621
+ else return empty;
1622
+ } else {
1623
+ if (!pathPart.startsWith(`/${separator}`)) return empty;
1624
+ keyStart = separator.length + 1;
1625
+ }
1626
+ const rest = pathPart.slice(keyStart);
1627
+ const slash = rest.indexOf("/");
1628
+ const key = slash < 0 ? rest : rest.slice(0, slash);
1629
+ if (key === "") return empty;
1630
+ return {
1631
+ url: str,
1632
+ key,
1633
+ baseUrl: `${prefix === "" ? rooted ? "/" : "" : `${prefix}/`}${separator}${key}${slash < 0 ? "" : "/"}`,
1634
+ path: slash < 0 ? "" : rest.slice(slash + 1)
1635
+ };
1636
+ }
1637
+ //#endregion
1638
+ //#region src/relay/index-sw.ts
1639
+ /**
1640
+ * One stored registry entry, whatever shape it is on disk.
1641
+ *
1642
+ * BEFORE MOUNTS THE VALUE WAS A BARE CLIENT ID. A browser that ran the earlier
1643
+ * worker still holds that shape, and reading it as an object would drop the id
1644
+ * and quietly unregister every service the visitor had.
1645
+ */
1646
+ function readStoredEntry(value) {
1647
+ if (typeof value === "string") return { clientId: value };
1648
+ if (typeof value !== "object" || value === null) return void 0;
1649
+ const { clientId, path } = value;
1650
+ if (typeof clientId !== "string" || clientId === "") return void 0;
1651
+ return typeof path === "string" ? {
1652
+ clientId,
1653
+ path
1654
+ } : { clientId };
1655
+ }
1656
+ /**
1657
+ * Which service, if any, should answer `url`.
1658
+ *
1659
+ * `undefined` means NOT THE RELAY'S, and the caller must not call
1660
+ * `respondWith`: the request then goes to the network, which is how a host
1661
+ * keeps serving its own files from its own origin. Answering 404 here instead
1662
+ * would make a root mount fatal.
1663
+ */
1664
+ function resolveServiceKey(url, table, selfOrigin) {
1665
+ if (url.origin !== selfOrigin) return void 0;
1666
+ if (table.excludes(url)) return void 0;
1667
+ const mounted = table.find(url);
1668
+ if (mounted != null) return mounted;
1669
+ const { key } = splitServiceUrl(url);
1670
+ return key === "" ? void 0 : key;
1671
+ }
1672
+ /**
1673
+ * Waits for the mount table to be restored from the registry before routing
1674
+ * `url` — but a restore failure must never wedge every fetch. `restored`
1675
+ * rejecting (blocked storage, quota, private-mode edge cases) would otherwise
1676
+ * propagate straight to `respondWith` on every request, including ones that
1677
+ * should reach the network, which breaks the one rule this file exists to
1678
+ * uphold. So: log the failure and route with whatever the in-memory table
1679
+ * already holds — possibly empty, never fatal.
1680
+ */
1681
+ async function resolveAfterRestore(restored, url, table, selfOrigin) {
1682
+ try {
1683
+ await restored;
1684
+ } catch (error) {
1685
+ console.error("[relay] failed to restore mounts from the registry", error);
1686
+ }
1687
+ return resolveServiceKey(url, table, selfOrigin);
1688
+ }
1689
+ /**
1690
+ * What REGISTER does to the mount table: set it when `path` is given, or
1691
+ * remove any earlier mount when it is not. A path-less re-registration
1692
+ * reverts a service to `/~<key>/` addressing, and a stale prefix left behind
1693
+ * would keep routing requests to a mount that no longer exists.
1694
+ *
1695
+ * A REGISTRATION ONLY TOUCHES WHAT A REGISTRATION MADE. `hostKeys` names the
1696
+ * mounts the host declared itself, in `options.mounts`. Those are the host's
1697
+ * build-time decision and a page may not undo it: the documented static flow
1698
+ * -- host declares `{ key: "app", path: "/" }`, page calls `initHttpService(h,
1699
+ * { key: "app", port })` with no path -- would otherwise have its very first
1700
+ * registration delete the host's own mount and leave the origin unmounted. A
1701
+ * path-ful REGISTER naming a host key is ignored for the same reason (it would
1702
+ * replace a `match` predicate with a prefix of the page's choosing).
1703
+ */
1704
+ function applyRegisteredMount(table, key, path, hostKeys = /* @__PURE__ */ new Set()) {
1705
+ if (hostKeys.has(key)) return;
1706
+ if (path != null) table.set(key, { path });
1707
+ else table.remove(key);
1708
+ }
1709
+ /**
1710
+ * What UNREGISTER does to the mount table: drop the mount a registration
1711
+ * made. A host-declared mount stays, for the reason `applyRegisteredMount`
1712
+ * gives -- a page tearing down its service must not take the host's table
1713
+ * with it.
1714
+ */
1715
+ function removeRegisteredMount(table, key, hostKeys = /* @__PURE__ */ new Set()) {
1716
+ if (hostKeys.has(key)) return;
1717
+ table.remove(key);
1718
+ }
1719
+ /**
1720
+ * May `candidateId` take the key?
1721
+ *
1722
+ * `last-wins` is what the relay has always done and stays the default. With
1723
+ * `first-wins`, a LIVE holder keeps its key: on an origin whose name is
1724
+ * guessable, a second page proves nothing by existing. A holder that reloaded
1725
+ * is no longer live, so a host's own re-registration is never blocked.
1726
+ */
1727
+ function mayRegister(args) {
1728
+ if (args.takeover === "last-wins") return true;
1729
+ if (args.current == null || !args.isCurrentLive) return true;
1730
+ return args.current.clientId === args.candidateId;
1731
+ }
1732
+ /**
1733
+ * Boots the relay ServiceWorker: routes fetches shaped `<origin>/~<key>/…` to
1734
+ * the client that registered `key`, and exposes REGISTER/UNREGISTER/CONNECT
1735
+ * channel calls used by the page-side relay client.
1736
+ */
1737
+ function startRelayServiceWorker(self, options = {}) {
1738
+ const [register, clear] = newRegistry();
1739
+ const mounts = newMountTable({ exclude: options.exclude });
1740
+ const hostKeys = /* @__PURE__ */ new Set();
1741
+ for (const { key, ...spec } of options.mounts ?? []) {
1742
+ mounts.set(key, spec);
1743
+ hostKeys.add(key);
1744
+ }
1745
+ const takeover = options.takeover ?? "last-wins";
1746
+ if (typeof self.skipWaiting === "function") self.addEventListener("install", (e) => {
1747
+ e.waitUntil(self.skipWaiting());
1748
+ });
1749
+ if (self.clients && typeof self.clients.claim === "function") self.addEventListener("activate", (e) => {
1750
+ e.waitUntil(self.clients.claim());
1751
+ });
1752
+ const clientsRegistry = newClientsRegistry({ self });
1753
+ const restored = clientsRegistry.restoreMounts(mounts, hostKeys);
1754
+ let restoreSettled = false;
1755
+ const latch = () => {
1756
+ restoreSettled = true;
1757
+ };
1758
+ restored.then(latch, latch);
1759
+ register(handleClaimRequests(self));
1760
+ register(handleChannelCalls(self, "REGISTER", async (event, data) => {
1761
+ const source = event.source;
1762
+ if (!source) return false;
1763
+ const { key, path } = data;
1764
+ if (options.canRegister != null && !await options.canRegister(source, key)) throw new Error(`this client may not register "${key}"`);
1765
+ if (takeover === "first-wins") {
1766
+ const current = await clientsRegistry.getMount(key);
1767
+ const isCurrentLive = current != null && await clientsRegistry.getClient(key) != null;
1768
+ if (!mayRegister({
1769
+ current,
1770
+ candidateId: source.id,
1771
+ isCurrentLive,
1772
+ takeover
1773
+ })) throw new Error(`"${key}" is already served by another client`);
1774
+ }
1775
+ const added = await clientsRegistry.addClient(key, source, path);
1776
+ applyRegisteredMount(mounts, key, path, hostKeys);
1777
+ return added;
1778
+ }));
1779
+ register(handleChannelCalls(self, "UNREGISTER", async (_event, data) => {
1780
+ const { key } = data;
1781
+ removeRegisteredMount(mounts, key, hostKeys);
1782
+ return await clientsRegistry.removeClient(key);
1783
+ }));
1784
+ register(handleChannelCalls(self, "CONNECT", async (_event, data, port) => {
1785
+ const { key } = data;
1786
+ const client = await clientsRegistry.getClient(key);
1787
+ if (!client) throw new Error(`Target client was not found. Target key: "${key}".`);
1788
+ return await callChannel(client, "CONNECT", data, port);
1789
+ }));
1790
+ /** Answers `request` as the service registered under `key`. */
1791
+ async function serve(key, request, url) {
1792
+ const params = splitServiceUrl(url);
1793
+ try {
1794
+ const channel = new MessageChannel();
1795
+ const client = await clientsRegistry.getClient(key);
1796
+ if (!client) throw HttpError.errorResourceGone(params);
1797
+ if (!await callChannel(client, "CONNECT", {
1798
+ type: "http",
1799
+ key
1800
+ }, channel.port2)) throw HttpError.errorForbidden(params);
1801
+ const response = await sendHttpRequest(channel.port1, request);
1802
+ return options.decorateResponse?.(response, request) ?? response;
1803
+ } catch (error) {
1804
+ const httpError = HttpError.fromError(error);
1805
+ const errorOptions = httpError.getResponseOptions(params);
1806
+ const errorResponse = new Response(JSON.stringify(errorOptions), {
1807
+ status: httpError.status ?? 500,
1808
+ statusText: httpError.statusText ?? "Internal Error",
1809
+ headers: { "Content-Type": "application/json" }
1810
+ });
1811
+ return options.decorateResponse?.(errorResponse, request) ?? errorResponse;
1812
+ }
1813
+ }
1814
+ const fetchListener = (event) => {
1815
+ const request = event.request;
1816
+ const url = new URL(request.url);
1817
+ if (restoreSettled) {
1818
+ const key = resolveServiceKey(url, mounts, self.location.origin);
1819
+ if (key == null) return;
1820
+ event.respondWith(serve(key, request, url));
1821
+ return;
1822
+ }
1823
+ event.respondWith((async () => {
1824
+ const key = await resolveAfterRestore(restored, url, mounts, self.location.origin);
1825
+ if (key == null) return await fetch(request);
1826
+ return await serve(key, request, url);
1827
+ })());
1828
+ };
1829
+ self.addEventListener("fetch", fetchListener);
1830
+ register(() => self.removeEventListener("fetch", fetchListener));
1831
+ return clear;
1832
+ }
1833
+ function newClientsRegistry({ self, key = "clientsIds" }) {
1834
+ let _index;
1835
+ async function loadClientsIndex() {
1836
+ if (!_index) {
1837
+ const entries = await get(key) ?? [];
1838
+ _index = {};
1839
+ for (const [clientKey, value] of entries) {
1840
+ const entry = readStoredEntry(value);
1841
+ if (entry) _index[clientKey] = entry;
1842
+ }
1843
+ }
1844
+ return _index;
1845
+ }
1846
+ async function storeClientsIndex() {
1847
+ const index = await loadClientsIndex();
1848
+ await set(key, Object.entries(index));
1849
+ return index;
1850
+ }
1851
+ async function addClient(clientKey, client, path) {
1852
+ const index = await loadClientsIndex();
1853
+ const current = index[clientKey];
1854
+ if (current?.clientId === client.id && current.path === path) return false;
1855
+ index[clientKey] = path == null ? { clientId: client.id } : {
1856
+ clientId: client.id,
1857
+ path
1858
+ };
1859
+ await storeClientsIndex();
1860
+ return true;
1861
+ }
1862
+ async function removeClient(clientKey) {
1863
+ const index = await loadClientsIndex();
1864
+ if (!(clientKey in index)) return false;
1865
+ delete index[clientKey];
1866
+ await storeClientsIndex();
1867
+ return true;
1868
+ }
1869
+ async function getMount(clientKey) {
1870
+ return (await loadClientsIndex())[clientKey];
1871
+ }
1872
+ async function restoreMounts(table, hostKeys = /* @__PURE__ */ new Set()) {
1873
+ const index = await loadClientsIndex();
1874
+ for (const [clientKey, entry] of Object.entries(index)) {
1875
+ if (hostKeys.has(clientKey)) continue;
1876
+ if (entry.path != null) table.set(clientKey, { path: entry.path });
1877
+ }
1878
+ }
1879
+ async function getClient(clientKey) {
1880
+ const index = await loadClientsIndex();
1881
+ const entry = index[clientKey];
1882
+ if (!entry) return void 0;
1883
+ const client = await self.clients.get(entry.clientId);
1884
+ if (!client) {
1885
+ delete index[clientKey];
1886
+ await storeClientsIndex();
1887
+ }
1888
+ return client ?? void 0;
1889
+ }
1890
+ return {
1891
+ getClient,
1892
+ getMount,
1893
+ addClient,
1894
+ removeClient,
1895
+ restoreMounts
1896
+ };
1897
+ }
1898
+ //#endregion
1899
+ export { startRelayServiceWorker };