@statewalker/webrun-http-browser 0.3.4 → 0.5.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/dist/sw.js CHANGED
@@ -1,30 +1,4 @@
1
- //#region ../webrun-http-streams/src/bytes.ts
2
- function toAsyncIterator(input) {
3
- const asyncIter = input[Symbol.asyncIterator];
4
- if (asyncIter) return asyncIter.call(input);
5
- const syncIter = input[Symbol.iterator]();
6
- return { next() {
7
- return Promise.resolve(syncIter.next());
8
- } };
9
- }
10
- /**
11
- * Discard an iterable we are contractually forbidden from consuming — a body
12
- * skipped for HEAD/204, or one abandoned because the peer reported an error.
13
- *
14
- * The `.next()` is not optional: `.return()` on a generator still in suspended
15
- * start is a no-op, so the body never runs and its `try/finally` never unwinds.
16
- * Without it a wrapped ReadableStream or socket is never cancelled.
17
- */
18
- async function discard(source) {
19
- if (source === void 0) return;
20
- const it = toAsyncIterator(source);
21
- try {
22
- await it.next();
23
- await it.return?.();
24
- } catch {}
25
- }
26
- //#endregion
27
- //#region ../webrun-streams/src/errors.ts
1
+ //#region ../webrun-streams/dist/index.js
28
2
  function serializeError(error) {
29
3
  if (error instanceof Error) {
30
4
  const out = {
@@ -48,8 +22,8 @@ function deserializeError(error) {
48
22
  const payload = typeof error === "string" ? { message: error } : error;
49
23
  return Object.assign(new Error(payload.message), payload);
50
24
  }
51
- //#endregion
52
- //#region ../webrun-streams/src/new-async-generator.ts
25
+ new TextEncoder();
26
+ new TextDecoder();
53
27
  /**
54
28
  * The newAsyncGenerator function creates async generators from callback-based initialization
55
29
  * functions, providing a bridge between imperative event handling and declarative async
@@ -202,36 +176,66 @@ async function* newAsyncGenerator(init, skipValues = false) {
202
176
  drainQueue();
203
177
  }
204
178
  }
205
- //#endregion
206
- //#region ../webrun-streams/src/readable-streams.ts
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
+ */
207
190
  function toReadableStream(it) {
208
- return new ReadableStream({ async pull(controller) {
209
- let handled = false;
210
- try {
211
- while (true) {
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 {
212
201
  const slot = await it.next();
213
- if (!slot || slot.done) break;
214
- const value = await slot.value;
215
- controller.enqueue(value);
202
+ if (!slot || slot.done) {
203
+ controller.close();
204
+ return;
205
+ }
206
+ controller.enqueue(await slot.value);
207
+ } catch (error) {
208
+ controller.error(error);
216
209
  }
217
- } catch (error) {
218
- handled = true;
219
- controller.error(error);
220
- } finally {
221
- if (!handled) controller.close();
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(() => {});
222
219
  }
223
- } });
220
+ });
224
221
  }
225
222
  async function* fromReadableStream(stream) {
226
223
  const reader = stream.getReader();
227
- while (true) {
228
- const { done, value } = await reader.read();
229
- if (done) break;
230
- if (value !== void 0) yield value;
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(() => {});
231
237
  }
232
238
  }
233
- //#endregion
234
- //#region ../webrun-streams/src/recieve-iterator.ts
235
239
  /**
236
240
  * Inverse of {@link sendIterator}: turns a sequence of `{done, value, error}`
237
241
  * chunks (delivered to the supplied callback by `installer`) into an async
@@ -254,8 +258,6 @@ function recieveIterator(installer) {
254
258
  };
255
259
  });
256
260
  }
257
- //#endregion
258
- //#region ../webrun-streams/src/send-iterator.ts
259
261
  /**
260
262
  * Drain an async iterator into a sink that consumes one chunk at a time.
261
263
  *
@@ -280,7 +282,760 @@ async function sendIterator(send, it) {
280
282
  }
281
283
  }
282
284
  //#endregion
283
- //#region ../webrun-http-streams/src/request-streams.ts
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
+ });
284
1039
  /**
285
1040
  * The one place that knows a runtime may not implement request body streams,
286
1041
  * and the buffering both fallbacks need. `fetch.ts` and `http-stubs.ts` each
@@ -349,8 +1104,6 @@ async function collectBytes(source) {
349
1104
  }
350
1105
  return out;
351
1106
  }
352
- //#endregion
353
- //#region ../webrun-http-streams/src/http-stubs.ts
354
1107
  const NULL_BODY_STATUSES = /* @__PURE__ */ new Set([
355
1108
  101,
356
1109
  103,
@@ -528,11 +1281,14 @@ async function* sendStream(communicationPort, input, params = {}) {
528
1281
  params
529
1282
  }, [messageChannel.port2]);
530
1283
  const channel = newStreamChannel(messageChannel.port1);
1284
+ let drained = false;
531
1285
  try {
532
1286
  await channel.start();
533
1287
  channel.sendAll(input);
534
1288
  yield* channel.recieveAll();
1289
+ drained = true;
535
1290
  } finally {
1291
+ if (!drained) channel.cancel();
536
1292
  await channel.close();
537
1293
  }
538
1294
  }
@@ -560,11 +1316,33 @@ function newStreamChannel(port) {
560
1316
  const notifyAll = async (data) => {
561
1317
  for (const listener of listeners) await listener(data);
562
1318
  };
1319
+ /**
1320
+ * Release whatever we are sending. NOT awaited: `.return()` on an async
1321
+ * generator parked awaiting its own source is queued behind that pending
1322
+ * `next()`, so awaiting it here would block the message handler — and the
1323
+ * chunk that would unblock it can only arrive through that same handler.
1324
+ */
1325
+ const cancelOutgoing = () => {
1326
+ for (const it of [...iterators]) {
1327
+ const iterable = it;
1328
+ Promise.resolve(iterable.return?.()).catch(() => {});
1329
+ }
1330
+ };
563
1331
  const channel = newInvokationChannel({
564
1332
  port,
565
- handler: (data) => notifyAll(data)
1333
+ handler: (data) => {
1334
+ const message = data;
1335
+ if (message?.cancel) {
1336
+ cancelOutgoing();
1337
+ return;
1338
+ }
1339
+ return notifyAll(message);
1340
+ }
566
1341
  });
567
1342
  const start = () => channel.start();
1343
+ const cancel = () => {
1344
+ channel.invoke({ cancel: true }).catch(() => {});
1345
+ };
568
1346
  const close = async () => {
569
1347
  await notifyAll({ done: true });
570
1348
  for (const it of [...iterators]) await it.return?.();
@@ -591,6 +1369,7 @@ function newStreamChannel(port) {
591
1369
  return {
592
1370
  start,
593
1371
  close,
1372
+ cancel,
594
1373
  recieveAll,
595
1374
  sendAll
596
1375
  };
@@ -618,13 +1397,21 @@ async function httpFromIterator(iterable) {
618
1397
  };
619
1398
  }
620
1399
  /**
621
- * @deprecated For new code, prefer the `MessagePort`-based stack from
622
- * `@statewalker/webrun-http-port`. Once a `MessagePort` is established between
623
- * page and worker, `httpServe(port, handler)` provides equivalent semantics
624
- * with `callBidi` multiplexing, full-duplex streaming, and `AbortSignal`.
625
- * This helper remains for existing ServiceWorker setups that still consume the
626
- * `MessageTarget` surface; it will be reimplemented on top of
627
- * `webrun-http-port` in a follow-up release.
1400
+ * Serve an `HttpHandler` over a `MessageTarget`, using this package's own
1401
+ * `handleStreams` transport.
1402
+ *
1403
+ * @deprecated Prefer the port stack in `@statewalker/webrun-rpc`: open a port
1404
+ * (`multiplexPort` over one pipe, or `transferPortMux` where the platform can
1405
+ * transfer a real `MessagePort`), turn it into a `Duplex` with
1406
+ * `serveDuplexOverPort`, and serve HTTP on that with
1407
+ * `httpServe(handler, options)` from `@statewalker/webrun-http-streams`.
1408
+ *
1409
+ * That path has backpressure; **this one does not.** `sendStream`'s chunk
1410
+ * sender discards the promise it is given, so a fast producer over a slow
1411
+ * consumer accumulates without bound. It also has no per-stream timeout and no
1412
+ * chunking to a transport's message ceiling.
1413
+ *
1414
+ * Kept for existing ServiceWorker setups built on the `MessageTarget` surface.
628
1415
  */
629
1416
  function handleHttpRequests(communicationPort, handler) {
630
1417
  const serverStub = newHttpServerStub(handler);
@@ -634,13 +1421,18 @@ function handleHttpRequests(communicationPort, handler) {
634
1421
  });
635
1422
  }
636
1423
  /**
637
- * @deprecated For new code, prefer the `MessagePort`-based stack from
638
- * `@statewalker/webrun-http-port/fetch`. Once the page and SW share a
639
- * `MessagePort`, `fetchOverPort(port, request)` provides the same
640
- * `Request → Response` semantics with multiplexing via `callBidi`, JSONL
641
- * envelope framing, and native `AbortSignal` support. This helper remains for
642
- * existing ServiceWorker setups; it will be reimplemented on top of
643
- * `webrun-http-port` in a follow-up release.
1424
+ * Ship a `Request` over a `MessageTarget` and await the `Response`, using this
1425
+ * package's own `sendStream` transport.
1426
+ *
1427
+ * @deprecated Prefer the port stack in `@statewalker/webrun-rpc`: open a port
1428
+ * (`multiplexPort` over one pipe, or `transferPortMux` where the platform can
1429
+ * transfer a real `MessagePort`), turn it into a `Duplex` with
1430
+ * `duplexOverPort`, and drive HTTP over it with `httpFetch` from
1431
+ * `@statewalker/webrun-http-streams`.
1432
+ *
1433
+ * Same caveat as {@link handleHttpRequests}: the transport underneath this
1434
+ * helper has no backpressure, no per-stream timeout, and no chunking to a
1435
+ * transport's message ceiling. Kept for existing ServiceWorker setups.
644
1436
  */
645
1437
  async function sendHttpRequest(communicationPort, request) {
646
1438
  return await newHttpClientStub(async (req) => {
@@ -731,6 +1523,29 @@ function handleChannelCalls(target, callType, handler) {
731
1523
  return () => target.removeEventListener("message", listener);
732
1524
  }
733
1525
  //#endregion
1526
+ //#region src/core/deadline.ts
1527
+ /**
1528
+ * Settles like `promise` if it settles before `deadline` (a `Date.now()`
1529
+ * value). Otherwise calls `onTimeout`: an `Error` it returns rejects, any
1530
+ * other value resolves. Internal; not re-exported.
1531
+ */
1532
+ function withDeadline(deadline, promise, onTimeout) {
1533
+ return new Promise((resolve, reject) => {
1534
+ const timer = setTimeout(() => {
1535
+ const outcome = onTimeout();
1536
+ if (outcome instanceof Error) reject(outcome);
1537
+ else resolve(outcome);
1538
+ }, Math.max(0, deadline - Date.now()));
1539
+ promise.then((value) => {
1540
+ clearTimeout(timer);
1541
+ resolve(value);
1542
+ }, (error) => {
1543
+ clearTimeout(timer);
1544
+ reject(error);
1545
+ });
1546
+ });
1547
+ }
1548
+ //#endregion
734
1549
  //#region src/core/registry.ts
735
1550
  function newRegistry(onError = console.error) {
736
1551
  let counter = 0;
@@ -765,6 +1580,169 @@ function newRegistry(onError = console.error) {
765
1580
  });
766
1581
  }
767
1582
  //#endregion
1583
+ //#region src/core/service-worker-control.ts
1584
+ /**
1585
+ * How long, by default, the page waits for its ServiceWorker to activate and
1586
+ * to take control before giving up. Generous: a first install downloads and
1587
+ * evaluates the worker script, which on a slow link takes seconds.
1588
+ */
1589
+ const DEFAULT_SERVICE_WORKER_TIMEOUT = 3e4;
1590
+ /**
1591
+ * How long the page waits for `controllerchange` once the worker has
1592
+ * answered the `CLAIM` request. `clients.claim()` resolves only after the
1593
+ * browser has queued that event, so this is a grace period for delivery, not
1594
+ * a second budget.
1595
+ */
1596
+ const CLAIM_GRACE_MS = 1e3;
1597
+ /** Channel call a page sends to ask its ServiceWorker to `clients.claim()` it. */
1598
+ const CLAIM_CALL = "CLAIM";
1599
+ /**
1600
+ * Why a page could not get a working ServiceWorker. `reason` says which wait
1601
+ * failed; check it (or `name`) rather than `instanceof`, because each of this
1602
+ * package's bundles carries its own copy of this class.
1603
+ */
1604
+ var ServiceWorkerControlError = class extends Error {
1605
+ reason;
1606
+ constructor(reason, message) {
1607
+ super(message);
1608
+ this.name = "ServiceWorkerControlError";
1609
+ this.reason = reason;
1610
+ }
1611
+ };
1612
+ /**
1613
+ * Resolves with the registration's worker once it is `activated`. Rejects
1614
+ * with a `ServiceWorkerControlError` (`reason: "activation-timeout"`) if that
1615
+ * takes longer than `timeout` — an install that throws or never finishes
1616
+ * would otherwise leave the caller waiting forever.
1617
+ */
1618
+ async function awaitActiveServiceWorker(registration, { timeout = DEFAULT_SERVICE_WORKER_TIMEOUT } = {}) {
1619
+ return await withDeadline(Date.now() + timeout, waitForActivated(registration), () => {
1620
+ const worker = registration.installing ?? registration.waiting ?? registration.active;
1621
+ return new ServiceWorkerControlError("activation-timeout", `ServiceWorker ${scriptUrl(worker)} (scope ${registration.scope}) did not activate within ${timeout} ms` + (worker ? `; it is "${worker.state}"` : "") + ". Check the worker script for errors during install (DevTools → Application → Service Workers), or raise the `timeout` option.");
1622
+ });
1623
+ }
1624
+ /**
1625
+ * Resolves with the ServiceWorker that controls this page, once `registration`
1626
+ * has an activated worker and the page is controlled by it.
1627
+ *
1628
+ * A page can stay uncontrolled while its worker is active, and then no
1629
+ * `controllerchange` ever fires on its own:
1630
+ * - a hard reload (Ctrl+Shift+R) bypasses the worker for that load, and the
1631
+ * worker's `clients.claim()` already ran when it activated;
1632
+ * - Firefox can leave a page loaded while the worker is running uncontrolled.
1633
+ *
1634
+ * So when the page is uncontrolled, this asks the active worker to claim it
1635
+ * again (a `CLAIM` channel call — this package's workers answer it) and waits
1636
+ * for `controllerchange`. Every wait is bounded by `timeout`. If control never
1637
+ * comes it reloads once (`reloadIfUncontrolled`) or rejects with a
1638
+ * `ServiceWorkerControlError` (`reason: "uncontrolled"`) that says what
1639
+ * happened and what to do.
1640
+ */
1641
+ async function awaitServiceWorkerControl(registration, { timeout = DEFAULT_SERVICE_WORKER_TIMEOUT, reloadIfUncontrolled = false, container = navigator.serviceWorker } = {}) {
1642
+ const deadline = Date.now() + timeout;
1643
+ const controlled = waitForController(container);
1644
+ try {
1645
+ const active = await awaitActiveServiceWorker(registration, { timeout });
1646
+ let controller = container.controller;
1647
+ if (!controller) controller = await withDeadline(deadline, (async () => {
1648
+ await Promise.race([callChannel(active, CLAIM_CALL, {}), controlled.promise]);
1649
+ return await Promise.race([controlled.promise, delay(CLAIM_GRACE_MS, null)]);
1650
+ })(), () => null);
1651
+ if (controller) {
1652
+ forgetReload(registration);
1653
+ return controller;
1654
+ }
1655
+ if (reloadIfUncontrolled && markReload(registration)) {
1656
+ location.reload();
1657
+ return await new Promise(() => {});
1658
+ }
1659
+ forgetReload(registration);
1660
+ throw new ServiceWorkerControlError("uncontrolled", `This page is not controlled by its ServiceWorker ${scriptUrl(active)} (scope ${registration.scope}), although the worker is active, and the worker did not take control when asked (clients.claim()) within ${timeout} ms. This happens after a hard reload (Ctrl+Shift+R / Cmd+Shift+R), which bypasses ServiceWorkers for that load, and in Firefox for some pages opened while the worker was already running. Requests from this page would not reach the worker. Reload the page normally, pass \`reloadIfUncontrolled: true\` to do that automatically, check that the page is inside the worker's scope, and that the worker answers the "${CLAIM_CALL}" request (this package's workers do).`);
1661
+ } finally {
1662
+ controlled.cancel();
1663
+ }
1664
+ }
1665
+ /**
1666
+ * ServiceWorker side: answers the page's `CLAIM` request with
1667
+ * `clients.claim()`, which takes over every uncontrolled client in scope.
1668
+ * Returns a function that stops answering.
1669
+ */
1670
+ function handleClaimRequests(self) {
1671
+ return handleChannelCalls(self, CLAIM_CALL, (event) => {
1672
+ const claimed = self.clients.claim().then(() => true);
1673
+ event.waitUntil?.(claimed);
1674
+ return claimed;
1675
+ });
1676
+ }
1677
+ function waitForActivated(registration) {
1678
+ return new Promise((resolve) => {
1679
+ const watched = /* @__PURE__ */ new Set();
1680
+ const check = () => {
1681
+ const active = registration.active;
1682
+ if (active?.state === "activated") {
1683
+ registration.removeEventListener("updatefound", watch);
1684
+ for (const worker of watched) worker.removeEventListener("statechange", check);
1685
+ resolve(active);
1686
+ return;
1687
+ }
1688
+ watch();
1689
+ };
1690
+ function watch() {
1691
+ for (const worker of [
1692
+ registration.installing,
1693
+ registration.waiting,
1694
+ registration.active
1695
+ ]) {
1696
+ if (!worker || watched.has(worker)) continue;
1697
+ watched.add(worker);
1698
+ worker.addEventListener("statechange", check);
1699
+ }
1700
+ }
1701
+ registration.addEventListener("updatefound", watch);
1702
+ check();
1703
+ });
1704
+ }
1705
+ function waitForController(container) {
1706
+ let cancel = () => {};
1707
+ return {
1708
+ promise: new Promise((resolve) => {
1709
+ const onChange = () => {
1710
+ if (!container.controller) return;
1711
+ cancel();
1712
+ resolve(container.controller);
1713
+ };
1714
+ cancel = () => container.removeEventListener("controllerchange", onChange);
1715
+ container.addEventListener("controllerchange", onChange);
1716
+ }),
1717
+ cancel
1718
+ };
1719
+ }
1720
+ function delay(ms, value) {
1721
+ return new Promise((resolve) => setTimeout(() => resolve(value), ms));
1722
+ }
1723
+ function scriptUrl(worker) {
1724
+ return worker?.scriptURL ? `"${worker.scriptURL}"` : "(no worker)";
1725
+ }
1726
+ function reloadKey(registration) {
1727
+ return `webrun-http-browser:reloaded-uncontrolled:${registration.scope}`;
1728
+ }
1729
+ /** Records the reload about to happen. `false` if one already happened, or if it cannot be recorded. */
1730
+ function markReload(registration) {
1731
+ try {
1732
+ const key = reloadKey(registration);
1733
+ if (sessionStorage.getItem(key)) return false;
1734
+ sessionStorage.setItem(key, "1");
1735
+ return true;
1736
+ } catch {
1737
+ return false;
1738
+ }
1739
+ }
1740
+ function forgetReload(registration) {
1741
+ try {
1742
+ sessionStorage.removeItem(reloadKey(registration));
1743
+ } catch {}
1744
+ }
1745
+ //#endregion
768
1746
  //#region src/sw/sw-dispatcher.ts
769
1747
  /**
770
1748
  * Page-side counterpart: registers a ServiceWorker and sets up a
@@ -792,7 +1770,7 @@ var SwPortHandler = class {
792
1770
  }
793
1771
  get serviceWorkerUrl() {
794
1772
  if (!this._serviceWorkerUrl) {
795
- const url = this.options.serviceWorkerUrl ? new URL(this.options.serviceWorkerUrl) : new URL("./index-sw.js", this.rootUrl);
1773
+ const url = this.options.serviceWorkerUrl ? resolveWorkerUrl(this.options.serviceWorkerUrl) : new URL("./index-sw.js", this.rootUrl);
796
1774
  this._serviceWorkerUrl = `${url}`;
797
1775
  }
798
1776
  return this._serviceWorkerUrl;
@@ -813,6 +1791,15 @@ var SwPortHandler = class {
813
1791
  _getRegistrationInfo() {
814
1792
  return { key: this.key };
815
1793
  }
1794
+ /**
1795
+ * Registers the worker and waits until it is activated and controls this
1796
+ * page, then opens the port to it. The page must be controlled, because
1797
+ * only a controlled page's `fetch()` reaches the worker. When the page
1798
+ * loaded uncontrolled (a hard reload does that), the worker is asked to
1799
+ * claim it. Rejects with a `ServiceWorkerControlError` when that fails or
1800
+ * `timeout` passes (`reason` says which step); a later `start()` tries
1801
+ * again.
1802
+ */
816
1803
  async start() {
817
1804
  if (!this._registrationPromise) this._registrationPromise = (async () => {
818
1805
  const [register, cleanup] = newRegistry();
@@ -822,13 +1809,25 @@ var SwPortHandler = class {
822
1809
  });
823
1810
  const registration = await navigator.serviceWorker.register(this.serviceWorkerUrl, { scope: this.scope });
824
1811
  register(() => registration.unregister());
825
- register(handleChannelCalls(navigator.serviceWorker, "UPDATE_COMMUNICATION_PORT", async (_event, _params, port) => {
1812
+ const stopListening = handleChannelCalls(navigator.serviceWorker, "UPDATE_COMMUNICATION_PORT", async (_event, _params, port) => {
826
1813
  await this._setCommunicationPort(port);
827
1814
  return this._getRegistrationInfo();
828
- }));
829
- this._serviceWorker = await getServiceWorkerController();
830
- await awaitServiceWorkerActivation(this._serviceWorker);
831
- await this._updateCommunicationChannel();
1815
+ });
1816
+ register(stopListening);
1817
+ const timeout = this.options.timeout ?? 3e4;
1818
+ const deadline = Date.now() + timeout;
1819
+ try {
1820
+ this._serviceWorker = await awaitServiceWorkerControl(registration, {
1821
+ timeout,
1822
+ reloadIfUncontrolled: this.options.reloadIfUncontrolled
1823
+ });
1824
+ await withDeadline(deadline, this._updateCommunicationChannel(), () => new ServiceWorkerControlError("unresponsive", `ServiceWorker "${this._serviceWorker?.scriptURL}" controls this page but did not answer the UPDATE_COMMUNICATION_PORT handshake within ${timeout} ms. Check that the worker script runs this package's same-origin dispatcher (it importScripts \`sw-worker.js\`, or calls \`startHttpDispatcher\`).`));
1825
+ } catch (error) {
1826
+ stopListening();
1827
+ this._cleanupRegistrations = void 0;
1828
+ this._registrationPromise = void 0;
1829
+ throw error;
1830
+ }
832
1831
  })();
833
1832
  return this._registrationPromise;
834
1833
  }
@@ -842,35 +1841,6 @@ var SwPortHandler = class {
842
1841
  }
843
1842
  }
844
1843
  };
845
- function getServiceWorkerController() {
846
- return new Promise((resolve) => {
847
- const container = navigator.serviceWorker;
848
- if (container.controller) {
849
- resolve(container.controller);
850
- return;
851
- }
852
- const onChange = () => {
853
- if (!container.controller) return;
854
- resolve(container.controller);
855
- container.removeEventListener("controllerchange", onChange);
856
- };
857
- container.addEventListener("controllerchange", onChange);
858
- });
859
- }
860
- function awaitServiceWorkerActivation(worker) {
861
- return new Promise((resolve) => {
862
- if (worker.state === "activated") {
863
- resolve();
864
- return;
865
- }
866
- const onStateChange = () => {
867
- if (worker.state !== "activated") return;
868
- worker.removeEventListener("statechange", onStateChange);
869
- resolve();
870
- };
871
- worker.addEventListener("statechange", onStateChange);
872
- });
873
- }
874
1844
  /**
875
1845
  * ServiceWorker-side counterpart: maintains an index of connected clients keyed by
876
1846
  * clientId, persisted in IndexedDB so it can reclaim ports across SW restarts.
@@ -918,7 +1888,8 @@ var SwPortDispatcher = class {
918
1888
  return this.claimedKeys.has(key);
919
1889
  }
920
1890
  start() {
921
- this._cleanup = handleChannelCalls(this.self, "UPDATE_COMMUNICATION_PORT", async (event, channelInfo, port) => {
1891
+ const stopClaims = handleClaimRequests(this.self);
1892
+ const stopPortUpdates = handleChannelCalls(this.self, "UPDATE_COMMUNICATION_PORT", async (event, channelInfo, port) => {
922
1893
  const clientId = event.source?.id;
923
1894
  this.log("[UPDATE_COMMUNICATION_PORT]", clientId, channelInfo);
924
1895
  await this._updateChannelInfo({
@@ -928,6 +1899,10 @@ var SwPortDispatcher = class {
928
1899
  });
929
1900
  return { ...channelInfo };
930
1901
  });
1902
+ this._cleanup = () => {
1903
+ stopClaims();
1904
+ stopPortUpdates();
1905
+ };
931
1906
  this.self.addEventListener("install", (event) => {
932
1907
  this.log("Skip waiting on install.", event);
933
1908
  this.self.skipWaiting();
@@ -993,6 +1968,21 @@ var SwPortDispatcher = class {
993
1968
  return index;
994
1969
  }
995
1970
  };
1971
+ /**
1972
+ * Resolve a `serviceWorkerUrl` option the way a page would: relative to the
1973
+ * current document. Absolute urls pass through untouched. A url that cannot
1974
+ * be resolved is reported with the option name and the offending value,
1975
+ * because the raw `TypeError: Failed to construct 'URL': Invalid URL` says
1976
+ * neither.
1977
+ */
1978
+ function resolveWorkerUrl(serviceWorkerUrl) {
1979
+ const base = globalThis.location?.href;
1980
+ try {
1981
+ return new URL(serviceWorkerUrl, base);
1982
+ } catch (error) {
1983
+ throw new Error(`Invalid serviceWorkerUrl: ${JSON.stringify(serviceWorkerUrl)}` + (base ? ` (relative to ${base})` : " (no document to resolve it against)"), { cause: error });
1984
+ }
1985
+ }
996
1986
  //#endregion
997
1987
  //#region src/sw/http-sw-dispatcher.ts
998
1988
  var SwHttpAdapter = class extends SwPortHandler {