@statewalker/webrun-http-streams 0.1.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +361 -13
  2. package/dist/bytes.d.ts +43 -0
  3. package/dist/bytes.d.ts.map +1 -0
  4. package/dist/codec-default.d.ts +7 -0
  5. package/dist/codec-default.d.ts.map +1 -0
  6. package/dist/duplex-site-builder.d.ts +4 -1
  7. package/dist/duplex-site-builder.d.ts.map +1 -1
  8. package/dist/envelope.d.ts +10 -10
  9. package/dist/envelope.d.ts.map +1 -1
  10. package/dist/fetch.d.ts +3 -2
  11. package/dist/fetch.d.ts.map +1 -1
  12. package/dist/http-data.d.ts +11 -7
  13. package/dist/http-data.d.ts.map +1 -1
  14. package/dist/http-error.d.ts.map +1 -1
  15. package/dist/http-stubs.d.ts +12 -0
  16. package/dist/http-stubs.d.ts.map +1 -1
  17. package/dist/http1/chunked.d.ts +18 -0
  18. package/dist/http1/chunked.d.ts.map +1 -0
  19. package/dist/http1/decode.d.ts +5 -0
  20. package/dist/http1/decode.d.ts.map +1 -0
  21. package/dist/http1/encode.d.ts +20 -0
  22. package/dist/http1/encode.d.ts.map +1 -0
  23. package/dist/http1/errors.d.ts +9 -0
  24. package/dist/http1/errors.d.ts.map +1 -0
  25. package/dist/http1/headers.d.ts +78 -0
  26. package/dist/http1/headers.d.ts.map +1 -0
  27. package/dist/http1/index.d.ts +18 -0
  28. package/dist/http1/index.d.ts.map +1 -0
  29. package/dist/index.d.ts +6 -2
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +1040 -138
  32. package/dist/message.d.ts +50 -0
  33. package/dist/message.d.ts.map +1 -0
  34. package/dist/request-streams.d.ts +52 -0
  35. package/dist/request-streams.d.ts.map +1 -0
  36. package/dist/sniff.d.ts +18 -0
  37. package/dist/sniff.d.ts.map +1 -0
  38. package/package.json +13 -7
  39. package/src/bytes.ts +157 -0
  40. package/src/codec-default.ts +13 -0
  41. package/src/duplex-site-builder.ts +10 -2
  42. package/src/envelope.ts +43 -34
  43. package/src/fetch.ts +130 -12
  44. package/src/http-data.ts +160 -17
  45. package/src/http-stubs.ts +72 -18
  46. package/src/http1/chunked.ts +89 -0
  47. package/src/http1/decode.ts +208 -0
  48. package/src/http1/encode.ts +181 -0
  49. package/src/http1/errors.ts +8 -0
  50. package/src/http1/headers.ts +263 -0
  51. package/src/http1/index.ts +40 -0
  52. package/src/index.ts +18 -0
  53. package/src/message.ts +62 -0
  54. package/src/request-streams.ts +75 -0
  55. package/src/sniff.ts +68 -0
  56. package/LICENSE +0 -21
package/dist/index.js CHANGED
@@ -1,7 +1,145 @@
1
- import { fromReadableStream, toReadableStream } from "@statewalker/webrun-streams";
1
+ import { deserializeError, fromReadableStream, serializeError, toReadableStream } from "@statewalker/webrun-streams";
2
+ //#region src/bytes.ts
3
+ const CR = 13;
4
+ const LF = 10;
5
+ const EMPTY = /* @__PURE__ */ new Uint8Array(0);
6
+ var ByteStreamError = class extends Error {
7
+ name = "ByteStreamError";
8
+ };
9
+ function toAsyncIterator(input) {
10
+ const asyncIter = input[Symbol.asyncIterator];
11
+ if (asyncIter) return asyncIter.call(input);
12
+ const syncIter = input[Symbol.iterator]();
13
+ return { next() {
14
+ return Promise.resolve(syncIter.next());
15
+ } };
16
+ }
17
+ /**
18
+ * Discard an iterable we are contractually forbidden from consuming — a body
19
+ * skipped for HEAD/204, or one abandoned because the peer reported an error.
20
+ *
21
+ * The `.next()` is not optional: `.return()` on a generator still in suspended
22
+ * start is a no-op, so the body never runs and its `try/finally` never unwinds.
23
+ * Without it a wrapped ReadableStream or socket is never cancelled.
24
+ */
25
+ async function discard(source) {
26
+ if (source === void 0) return;
27
+ const it = toAsyncIterator(source);
28
+ try {
29
+ await it.next();
30
+ await it.return?.();
31
+ } catch {}
32
+ }
33
+ function concatChunks(parts, totalLen) {
34
+ if (parts.length === 1) {
35
+ const only = parts[0];
36
+ if (only !== void 0) return only;
37
+ }
38
+ const out = new Uint8Array(totalLen);
39
+ let off = 0;
40
+ for (const p of parts) {
41
+ out.set(p, off);
42
+ off += p.byteLength;
43
+ }
44
+ return out;
45
+ }
46
+ /**
47
+ * Pull-based reader over a byte source. Holds at most one pending buffer, and
48
+ * hands out `subarray` views rather than copies — a body never passes through
49
+ * an allocation here.
50
+ */
51
+ var ByteReader = class {
52
+ #iter;
53
+ #buf = EMPTY;
54
+ #done = false;
55
+ constructor(input) {
56
+ this.#iter = toAsyncIterator(input);
57
+ }
58
+ /** Bytes already pulled from the source but not yet consumed. */
59
+ bufferedLength() {
60
+ return this.#buf.byteLength;
61
+ }
62
+ /** Pull one more non-empty chunk. Returns false at end of stream. */
63
+ async #pull() {
64
+ if (this.#done) return false;
65
+ while (true) {
66
+ const next = await this.#iter.next();
67
+ if (next.done) {
68
+ this.#done = true;
69
+ return false;
70
+ }
71
+ const chunk = next.value;
72
+ if (chunk.byteLength === 0) continue;
73
+ this.#buf = this.#buf.byteLength === 0 ? chunk : concatChunks([this.#buf, chunk], this.#buf.byteLength + chunk.byteLength);
74
+ return true;
75
+ }
76
+ }
77
+ async peekByte() {
78
+ while (this.#buf.byteLength === 0) if (!await this.#pull()) return void 0;
79
+ return this.#buf[0];
80
+ }
81
+ /** Up to `max` bytes. `undefined` means end of stream. */
82
+ async readSome(max) {
83
+ while (this.#buf.byteLength === 0) if (!await this.#pull()) return void 0;
84
+ const take = Math.min(max, this.#buf.byteLength);
85
+ const out = this.#buf.subarray(0, take);
86
+ this.#buf = this.#buf.subarray(take);
87
+ return out;
88
+ }
89
+ /**
90
+ * One CRLF-terminated line, without the CRLF. A bare LF is rejected: real
91
+ * peers always send CRLF, and tolerating a bare LF is precisely the lenience
92
+ * that lets request smuggling through a proxy pair.
93
+ *
94
+ * The `maxBytes` bound is best-effort: it only rejects a line if the check
95
+ * happens to run before the line is fully buffered. A line already sitting in
96
+ * the buffer bypasses the check. Callers needing a hard per-line limit or
97
+ * aggregate bounds must keep their own running total.
98
+ */
99
+ async readLine(maxBytes) {
100
+ let searched = 0;
101
+ while (true) {
102
+ const idx = this.#buf.indexOf(LF, searched);
103
+ if (idx !== -1) {
104
+ if (idx === 0 || this.#buf[idx - 1] !== CR) throw new ByteStreamError("bare LF line terminator; CRLF required");
105
+ const line = this.#buf.subarray(0, idx - 1);
106
+ this.#buf = this.#buf.subarray(idx + 1);
107
+ return line;
108
+ }
109
+ searched = this.#buf.byteLength;
110
+ if (searched > maxBytes) throw new ByteStreamError(`line exceeds ${maxBytes} bytes without CRLF`);
111
+ if (!await this.#pull()) throw new ByteStreamError(`stream ended after ${searched} bytes without CRLF`);
112
+ }
113
+ }
114
+ /** Everything not yet consumed, lazily. */
115
+ async *rest() {
116
+ while (true) {
117
+ if (this.#buf.byteLength > 0) {
118
+ const out = this.#buf;
119
+ this.#buf = EMPTY;
120
+ yield out;
121
+ continue;
122
+ }
123
+ if (!await this.#pull()) return;
124
+ }
125
+ }
126
+ };
127
+ //#endregion
128
+ //#region src/http1/errors.ts
129
+ /**
130
+ * Raised for any byte sequence this codec refuses to interpret. Every case is
131
+ * a refusal to guess: HTTP/1.1 parsers that guess are how request smuggling
132
+ * works.
133
+ */
134
+ var HttpParseError = class extends Error {
135
+ name = "HttpParseError";
136
+ };
137
+ //#endregion
2
138
  //#region src/envelope.ts
3
139
  const NEWLINE = 10;
4
- const encoder = new TextEncoder();
140
+ /** Mirrors the HTTP/1.1 codec's default `maxHeaderBytes`. */
141
+ const MAX_ENVELOPE_BYTES = 65536;
142
+ const encoder$1 = new TextEncoder();
5
143
  const decoder = new TextDecoder();
6
144
  /**
7
145
  * Encode an HTTP envelope plus optional body as one continuous byte stream:
@@ -14,7 +152,7 @@ const decoder = new TextDecoder();
14
152
  * This is the same wire shape used by the legacy `webrun-http-port` package.
15
153
  */
16
154
  async function* encodeMessage(envelope, body) {
17
- yield encoder.encode(`${JSON.stringify(envelope)}\n`);
155
+ yield encoder$1.encode(`${JSON.stringify(envelope)}\n`);
18
156
  if (!body) return;
19
157
  for await (const chunk of body) if (chunk.byteLength > 0) yield chunk;
20
158
  }
@@ -30,13 +168,14 @@ async function decodeMessage(input) {
30
168
  let tail;
31
169
  while (true) {
32
170
  const next = await iter.next();
33
- if (next.done) throw new Error(`decodeMessage: stream ended after ${accumLen} bytes without delimiter (\\n)`);
171
+ if (next.done) throw new HttpParseError(`decodeMessage: stream ended after ${accumLen} bytes without delimiter (\\n)`);
34
172
  const chunk = next.value;
35
173
  if (chunk.byteLength === 0) continue;
36
174
  const nl = chunk.indexOf(NEWLINE);
37
175
  if (nl === -1) {
38
176
  accum.push(chunk);
39
177
  accumLen += chunk.byteLength;
178
+ if (accumLen > MAX_ENVELOPE_BYTES) throw new HttpParseError(`decodeMessage: envelope exceeds ${MAX_ENVELOPE_BYTES} bytes without delimiter (\\n)`);
40
179
  continue;
41
180
  }
42
181
  accum.push(chunk.subarray(0, nl));
@@ -49,7 +188,7 @@ async function decodeMessage(input) {
49
188
  try {
50
189
  envelope = JSON.parse(decoder.decode(before));
51
190
  } catch (err) {
52
- throw new Error(`decodeMessage: malformed envelope JSON at bytes 0..${before.byteLength}: ${err.message}`);
191
+ throw new HttpParseError(`decodeMessage: malformed envelope JSON at bytes 0..${before.byteLength}: ${err.message}`);
53
192
  }
54
193
  async function* body() {
55
194
  if (tail.byteLength > 0) yield tail;
@@ -64,26 +203,647 @@ async function decodeMessage(input) {
64
203
  body: body()
65
204
  };
66
205
  }
67
- function toAsyncIterator(input) {
68
- const asyncIter = input[Symbol.asyncIterator];
69
- if (asyncIter) return asyncIter.call(input);
70
- const syncIter = input[Symbol.iterator]();
71
- return { next() {
72
- return Promise.resolve(syncIter.next());
73
- } };
206
+ const OPEN_BRACE = 123;
207
+ /**
208
+ * The original wire format — `<JSON.stringify(envelope)>\n<body bytes…>` —
209
+ * expressed as a `MessageCodec`. Direction-agnostic: requests and responses
210
+ * serialise identically.
211
+ *
212
+ * Retained so a peer pair can be upgraded in either order; see ADR-0006.
213
+ */
214
+ const jsonEnvelopeCodec = {
215
+ name: "json-envelope",
216
+ sniff: (byte) => byte === OPEN_BRACE,
217
+ encodeRequest: (env, body) => encodeMessage(env, body),
218
+ encodeResponse: (env, body) => encodeMessage(env, body),
219
+ decodeRequest: (input) => decodeMessage(input),
220
+ decodeResponse: (input) => decodeMessage(input)
221
+ };
222
+ //#endregion
223
+ //#region src/http1/headers.ts
224
+ const TCHAR = /* @__PURE__ */ new Set("!#$%&'*+-.^_`|~0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ");
225
+ const SP = 32;
226
+ const HTAB = 9;
227
+ function isTokenChar(byte) {
228
+ return byte > SP && byte < 127 && TCHAR.has(String.fromCharCode(byte));
74
229
  }
75
- function concatChunks(parts, totalLen) {
76
- if (parts.length === 1) return parts[0];
77
- const out = new Uint8Array(totalLen);
78
- let off = 0;
79
- for (const p of parts) {
80
- out.set(p, off);
81
- off += p.byteLength;
230
+ function isToken(value) {
231
+ if (value.length === 0) return false;
232
+ for (const ch of value) if (!TCHAR.has(ch)) return false;
233
+ return true;
234
+ }
235
+ /**
236
+ * Header bytes are latin-1: byte-preserving, matching Node, and lossless
237
+ * across a decode/encode round-trip. Control characters are rejected
238
+ * separately by `assertValidHeaderValue`.
239
+ */
240
+ function decodeLatin1(bytes) {
241
+ let out = "";
242
+ for (const byte of bytes) out += String.fromCharCode(byte);
243
+ return out;
244
+ }
245
+ function encodeLatin1(text) {
246
+ const out = new Uint8Array(text.length);
247
+ for (let i = 0; i < text.length; i++) {
248
+ const code = text.charCodeAt(i);
249
+ if (code > 255) throw new HttpParseError(`not latin-1 encodable: ${JSON.stringify(text)}`);
250
+ out[i] = code;
251
+ }
252
+ return out;
253
+ }
254
+ const HOST_IP_LITERAL = /^\[[0-9A-Fa-f:.]+\](:\d{1,5})?$/;
255
+ const HOST_REG_NAME = /^[A-Za-z0-9._~-]+(:\d{1,5})?$/;
256
+ /**
257
+ * Validates a value that is about to become (or was read as) a `Host`
258
+ * header: untrusted input either way, so an unrecognised shape is a refusal,
259
+ * not a guess. Rejects a present-but-empty value, one carrying userinfo
260
+ * (`evil.com@good.com`), and — the reason this also runs on encode — one
261
+ * smuggling a CRLF-terminated line into the authority of a caller-supplied
262
+ * url. Never applied to trusted configuration (a codec's `opts.host`).
263
+ */
264
+ function assertValidHost(host) {
265
+ if (!HOST_IP_LITERAL.test(host) && !HOST_REG_NAME.test(host)) throw new HttpParseError(`invalid Host header: ${JSON.stringify(host)}`);
266
+ }
267
+ /**
268
+ * A request-target must be entirely visible ASCII (RFC 9110 VCHAR) — nothing
269
+ * `<= 0x20` (space and every control character, CR/LF included) and nothing
270
+ * `>= 0x7F` (DEL and beyond). Applied on encode (`splitTarget`, so a
271
+ * caller-supplied url can never inject a second request line) and on decode
272
+ * (so a relay that decodes then re-encodes can never put an embedded control
273
+ * character back on the wire).
274
+ */
275
+ function assertValidTarget(target) {
276
+ for (let i = 0; i < target.length; i++) {
277
+ const code = target.charCodeAt(i);
278
+ if (code <= SP || code >= 127) throw new HttpParseError(`invalid request-target: ${JSON.stringify(target)}`);
279
+ }
280
+ }
281
+ /**
282
+ * Shared control-character check behind both `assertValidHeaderValue` and
283
+ * `assertValidStatusText` — the two were previously checked by different,
284
+ * looser rules (statusText only rejected CR/LF), which is exactly the
285
+ * asymmetry class C1 and M5 are both instances of. `label` is prepended
286
+ * verbatim to each message, so callers keep their own wording.
287
+ */
288
+ function assertNoControlChars(label, value) {
289
+ for (let i = 0; i < value.length; i++) {
290
+ const code = value.charCodeAt(i);
291
+ if (code === 13 || code === 10) throw new HttpParseError(`${label} contains CR or LF`);
292
+ if (code < SP && code !== HTAB || code === 127) throw new HttpParseError(`${label} contains a control character`);
293
+ if (code > 255) throw new HttpParseError(`${label} is not latin-1 encodable`);
294
+ }
295
+ }
296
+ function assertValidHeaderValue(name, value) {
297
+ assertNoControlChars(`header "${name}" value`, value);
298
+ }
299
+ /** Same character class as a header value (M5) — CR/LF, every C0 control, and DEL. */
300
+ function assertValidStatusText(value) {
301
+ assertNoControlChars("statusText", value);
302
+ }
303
+ function encodeHeaderLines(headers) {
304
+ let out = "";
305
+ for (const [name, value] of headers) {
306
+ if (!isToken(name)) throw new HttpParseError(`invalid header name: ${JSON.stringify(name)}`);
307
+ assertValidHeaderValue(name, value);
308
+ out += `${name}: ${value}\r\n`;
82
309
  }
83
310
  return out;
84
311
  }
312
+ /**
313
+ * Read to the blank line. `alreadyUsed` is the byte count of the start line,
314
+ * so the bound covers the whole head section rather than the headers alone.
315
+ */
316
+ async function readHeaderSection(reader, maxHeaderBytes, alreadyUsed) {
317
+ const headers = [];
318
+ let used = alreadyUsed;
319
+ while (true) {
320
+ const remaining = maxHeaderBytes - used;
321
+ if (remaining <= 0) throw new HttpParseError(`head section exceeds ${maxHeaderBytes} bytes`);
322
+ const lineBytes = await reader.readLine(remaining);
323
+ used += lineBytes.byteLength + 2;
324
+ if (used > maxHeaderBytes) throw new HttpParseError(`head section exceeds ${maxHeaderBytes} bytes`);
325
+ if (lineBytes.byteLength === 0) return headers;
326
+ if (lineBytes[0] === SP || lineBytes[0] === HTAB) throw new HttpParseError("obs-fold header continuation is not accepted");
327
+ const line = decodeLatin1(lineBytes);
328
+ const colon = line.indexOf(":");
329
+ if (colon <= 0) throw new HttpParseError(`malformed header line: ${JSON.stringify(line)}`);
330
+ const name = line.slice(0, colon);
331
+ if (!isToken(name)) throw new HttpParseError(`invalid header name: ${JSON.stringify(name)}`);
332
+ const value = line.slice(colon + 1).replace(/^[ \t]+/, "").replace(/[ \t]+$/, "");
333
+ assertValidHeaderValue(name, value);
334
+ headers.push([name, value]);
335
+ }
336
+ }
337
+ function getAll(headers, name) {
338
+ const lower = name.toLowerCase();
339
+ return headers.filter(([k]) => k.toLowerCase() === lower).map(([, v]) => v);
340
+ }
341
+ function withoutHeaders(headers, names) {
342
+ const drop = new Set(names.map((n) => n.toLowerCase()));
343
+ return headers.filter(([k]) => !drop.has(k.toLowerCase()));
344
+ }
345
+ /**
346
+ * Parse a `Content-Length` field into a single validated value.
347
+ *
348
+ * RFC 9110 §8.6 permits the value to be a comma-separated list of identical
349
+ * numbers (a relay may have appended one), so the list is folded; differing
350
+ * values are a refusal, not a choice. Shared by `resolveFraming` on decode and
351
+ * by the encoder's declared-length check, which previously carried its own
352
+ * copy that did NOT split on commas — so decode accepted `5, 5` while encode
353
+ * rejected it, and a relay that decoded then re-encoded threw.
354
+ *
355
+ * Returns undefined when the field is absent.
356
+ */
357
+ function parseContentLength(values) {
358
+ if (values.length === 0) return void 0;
359
+ const unique = new Set(values.flatMap((v) => v.split(",").map((s) => s.trim())));
360
+ if (unique.size !== 1) throw new HttpParseError(`conflicting Content-Length values: ${[...unique].join(", ")}`);
361
+ const raw = [...unique][0];
362
+ if (!/^\d{1,15}$/.test(raw)) throw new HttpParseError(`invalid Content-Length: ${JSON.stringify(raw)}`);
363
+ return Number(raw);
364
+ }
365
+ /**
366
+ * Statuses that carry no body whatever the headers say (RFC 9110 §8.6): 1xx,
367
+ * 204 and 304. Defined once because the encoder must not frame a body for
368
+ * them and the decoder must not try to read one — two lists that agreed today
369
+ * and were free to drift tomorrow.
370
+ *
371
+ * A response to HEAD is also bodyless, but that depends on the request rather
372
+ * than the status, so callers test it separately.
373
+ */
374
+ function isBodylessStatus(status) {
375
+ return status < 200 || status === 204 || status === 304;
376
+ }
377
+ /**
378
+ * RFC 9112 §6.3, with every ambiguity turned into a refusal. In particular a
379
+ * message declaring both Content-Length and Transfer-Encoding is rejected
380
+ * rather than resolved — disagreeing on which one wins is request smuggling.
381
+ */
382
+ function resolveFraming(headers, version) {
383
+ const te = getAll(headers, "transfer-encoding");
384
+ const cl = getAll(headers, "content-length");
385
+ if (te.length > 0 && cl.length > 0) throw new HttpParseError("message declares both Content-Length and Transfer-Encoding; refusing (request smuggling)");
386
+ if (te.length > 0) {
387
+ const encodings = te.join(",").split(",").map((s) => s.trim().toLowerCase()).filter((s) => s !== "");
388
+ if (encodings.length !== 1 || encodings[0] !== "chunked") throw new HttpParseError(`unsupported Transfer-Encoding: ${JSON.stringify(te.join(", "))}`);
389
+ if (version === "HTTP/1.0") throw new HttpParseError("Transfer-Encoding is not valid in HTTP/1.0");
390
+ return { kind: "chunked" };
391
+ }
392
+ const length = parseContentLength(cl);
393
+ if (length !== void 0) return {
394
+ kind: "length",
395
+ length
396
+ };
397
+ return { kind: "none" };
398
+ }
399
+ //#endregion
400
+ //#region src/http1/chunked.ts
401
+ const CRLF = new Uint8Array([13, 10]);
402
+ const LAST_CHUNK = new Uint8Array([
403
+ 48,
404
+ 13,
405
+ 10,
406
+ 13,
407
+ 10
408
+ ]);
409
+ const encoder = new TextEncoder();
410
+ /**
411
+ * Chunk sizes are written in HEXADECIMAL — a 21-byte chunk is `15`. Writing
412
+ * them in decimal is the defect note 15 found in @libp2p/http, and it is the
413
+ * one that cannot be caught downstream: decimal digits are also valid hex, so
414
+ * a conforming parser silently reads the wrong length.
415
+ *
416
+ * Zero-length source chunks are skipped; a zero-sized chunk on the wire is the
417
+ * body terminator.
418
+ */
419
+ async function* encodeChunked(body) {
420
+ for await (const chunk of body) {
421
+ if (chunk.byteLength === 0) continue;
422
+ yield encoder.encode(`${chunk.byteLength.toString(16)}\r\n`);
423
+ yield chunk;
424
+ yield CRLF;
425
+ }
426
+ yield LAST_CHUNK;
427
+ }
428
+ /**
429
+ * Decode a chunked body, yielding data chunks as they arrive. Nothing is
430
+ * accumulated: a chunk larger than the transport's frame is yielded in pieces.
431
+ */
432
+ async function* decodeChunked(reader, maxLineBytes) {
433
+ while (true) {
434
+ const sizeLine = decodeLatin1(await reader.readLine(maxLineBytes));
435
+ const semicolon = sizeLine.indexOf(";");
436
+ const sizeText = semicolon === -1 ? sizeLine : sizeLine.slice(0, semicolon);
437
+ if (!/^[0-9a-fA-F]{1,32}$/.test(sizeText)) throw new HttpParseError(`invalid chunk size: ${JSON.stringify(sizeLine)}`);
438
+ const size = Number.parseInt(sizeText, 16);
439
+ if (!Number.isSafeInteger(size)) throw new HttpParseError(`chunk size is too large: ${JSON.stringify(sizeLine)}`);
440
+ if (size === 0) {
441
+ let used = 0;
442
+ while (true) {
443
+ const remaining = maxLineBytes - used;
444
+ if (remaining <= 0) throw new HttpParseError(`trailer section exceeds ${maxLineBytes} bytes`);
445
+ const lineBytes = await reader.readLine(remaining);
446
+ used += lineBytes.byteLength + 2;
447
+ if (used > maxLineBytes) throw new HttpParseError(`trailer section exceeds ${maxLineBytes} bytes`);
448
+ if (lineBytes.byteLength === 0) break;
449
+ }
450
+ return;
451
+ }
452
+ let remaining = size;
453
+ while (remaining > 0) {
454
+ const part = await reader.readSome(remaining);
455
+ if (part === void 0) throw new HttpParseError(`chunk truncated: ${remaining} of ${size} bytes missing`);
456
+ remaining -= part.byteLength;
457
+ yield part;
458
+ }
459
+ try {
460
+ if ((await reader.readLine(2)).byteLength !== 0) throw new HttpParseError("chunk data not terminated by CRLF");
461
+ } catch (err) {
462
+ if (err instanceof ByteStreamError) throw new HttpParseError("chunk data not terminated by CRLF");
463
+ throw err;
464
+ }
465
+ }
466
+ }
467
+ //#endregion
468
+ //#region src/http1/decode.ts
469
+ const VERSIONS = /* @__PURE__ */ new Set(["HTTP/1.1", "HTTP/1.0"]);
470
+ const ABSOLUTE_FORM = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//;
471
+ /**
472
+ * One message per Duplex call (ADR-0006), so bytes after a complete message
473
+ * are an error. Checked against what is ALREADY BUFFERED rather than by
474
+ * awaiting end-of-stream: a live socket from a keep-alive peer never reaches
475
+ * EOF, so awaiting one would hang instead of failing.
476
+ */
477
+ function assertNoBufferedBytes(reader) {
478
+ const extra = reader.bufferedLength();
479
+ if (extra > 0) throw new HttpParseError(`${extra} trailing bytes after a complete message`);
480
+ }
481
+ /**
482
+ * The public error contract (I2): everything leaving `decodeRequest` /
483
+ * `decodeResponse` is an `HttpParseError`, whether the refusal happened
484
+ * synchronously (a malformed start line or header) or lazily while the body
485
+ * is later drained. `ByteStreamError` — the `ByteReader`'s own class, never
486
+ * exported — is the one thing converted here, message and all preserved via
487
+ * `cause`. Anything else is a genuine failure of the underlying source (a
488
+ * dropped socket, say) and must reach the caller unchanged: blanket-catching
489
+ * would hide that distinction.
490
+ */
491
+ function toHttpParseError(err) {
492
+ if (err instanceof ByteStreamError) throw new HttpParseError(err.message, { cause: err });
493
+ throw err;
494
+ }
495
+ /** Applies `toHttpParseError` across the whole lifetime of a body generator. */
496
+ async function* convertBodyErrors(source) {
497
+ try {
498
+ yield* source;
499
+ } catch (err) {
500
+ toHttpParseError(err);
501
+ }
502
+ }
503
+ async function* readBody(reader, framing, opts, noneMeansEof) {
504
+ if (framing.kind === "chunked") {
505
+ yield* decodeChunked(reader, opts.maxHeaderBytes);
506
+ assertNoBufferedBytes(reader);
507
+ return;
508
+ }
509
+ if (framing.kind === "length") {
510
+ let remaining = framing.length;
511
+ while (remaining > 0) {
512
+ const part = await reader.readSome(remaining);
513
+ if (part === void 0) throw new HttpParseError(`body truncated: ${remaining} of ${framing.length} bytes missing`);
514
+ remaining -= part.byteLength;
515
+ yield part;
516
+ }
517
+ assertNoBufferedBytes(reader);
518
+ return;
519
+ }
520
+ if (noneMeansEof) {
521
+ yield* reader.rest();
522
+ return;
523
+ }
524
+ assertNoBufferedBytes(reader);
525
+ }
526
+ /**
527
+ * `readLine`'s bound is best-effort — it is skipped when the line is already
528
+ * buffered — so a very long start line can reach these messages intact. Since
529
+ * a refusal is now echoed back to the sender in a 400 body, quote only enough
530
+ * to diagnose rather than reflecting the whole thing.
531
+ */
532
+ function quoteLine(line) {
533
+ const MAX = 120;
534
+ return line.length <= MAX ? JSON.stringify(line) : `${JSON.stringify(line.slice(0, MAX))} (truncated from ${line.length} chars)`;
535
+ }
536
+ async function decodeRequest(input, opts) {
537
+ const reader = new ByteReader(input);
538
+ try {
539
+ const startBytes = await reader.readLine(opts.maxHeaderBytes);
540
+ const startLine = decodeLatin1(startBytes);
541
+ const parts = startLine.split(" ");
542
+ if (parts.length !== 3) throw new HttpParseError(`malformed request line: ${quoteLine(startLine)}`);
543
+ const [method, target, version] = parts;
544
+ if (!isToken(method)) throw new HttpParseError(`invalid method: ${JSON.stringify(method)}`);
545
+ if (!VERSIONS.has(version)) throw new HttpParseError(`unsupported HTTP version: ${JSON.stringify(version)}`);
546
+ if (target === "*") throw new HttpParseError("asterisk-form request target is not supported");
547
+ assertValidTarget(target);
548
+ const headers = await readHeaderSection(reader, opts.maxHeaderBytes, startBytes.byteLength + 2);
549
+ const hosts = getAll(headers, "host");
550
+ if (hosts.length > 1) throw new HttpParseError("multiple Host headers");
551
+ const host = hosts[0];
552
+ let url;
553
+ if (target.startsWith("/")) {
554
+ if (version === "HTTP/1.1" && host === void 0) throw new HttpParseError("HTTP/1.1 request has no Host header");
555
+ if (host !== void 0) assertValidHost(host);
556
+ url = `${opts.scheme}://${host ?? opts.host}${target}`;
557
+ } else if (ABSOLUTE_FORM.test(target)) url = target;
558
+ else throw new HttpParseError(`unsupported request target: ${JSON.stringify(target)}`);
559
+ try {
560
+ new URL(url);
561
+ } catch {
562
+ throw new HttpParseError(`decoded url is not a valid URL: ${JSON.stringify(url)}`);
563
+ }
564
+ return {
565
+ envelope: {
566
+ url,
567
+ method,
568
+ headers
569
+ },
570
+ body: convertBodyErrors(readBody(reader, resolveFraming(headers, version), opts, false))
571
+ };
572
+ } catch (err) {
573
+ toHttpParseError(err);
574
+ }
575
+ }
576
+ async function decodeResponse(input, opts, method) {
577
+ const reader = new ByteReader(input);
578
+ try {
579
+ const startBytes = await reader.readLine(opts.maxHeaderBytes);
580
+ const startLine = decodeLatin1(startBytes);
581
+ const firstSp = startLine.indexOf(" ");
582
+ if (firstSp === -1) throw new HttpParseError(`malformed status line: ${quoteLine(startLine)}`);
583
+ const version = startLine.slice(0, firstSp);
584
+ if (!VERSIONS.has(version)) throw new HttpParseError(`unsupported HTTP version: ${JSON.stringify(version)}`);
585
+ const afterVersion = startLine.slice(firstSp + 1);
586
+ const secondSp = afterVersion.indexOf(" ");
587
+ const codeText = secondSp === -1 ? afterVersion : afterVersion.slice(0, secondSp);
588
+ if (!/^\d{3}$/.test(codeText)) throw new HttpParseError(`invalid status code: ${JSON.stringify(codeText)}`);
589
+ const status = Number(codeText);
590
+ const statusText = secondSp === -1 ? "" : afterVersion.slice(secondSp + 1);
591
+ const headers = await readHeaderSection(reader, opts.maxHeaderBytes, startBytes.byteLength + 2);
592
+ const bodyless = isBodylessStatus(status) || method.toUpperCase() === "HEAD";
593
+ const framing = bodyless ? { kind: "none" } : resolveFraming(headers, version);
594
+ return {
595
+ envelope: {
596
+ status,
597
+ statusText,
598
+ headers
599
+ },
600
+ body: convertBodyErrors(readBody(reader, framing, opts, !bodyless))
601
+ };
602
+ } catch (err) {
603
+ toHttpParseError(err);
604
+ }
605
+ }
606
+ //#endregion
607
+ //#region src/http1/encode.ts
608
+ /** Headers the codec owns: a caller-supplied copy is dropped and re-derived. */
609
+ const REQUEST_OWNED = [
610
+ "host",
611
+ "connection",
612
+ "transfer-encoding"
613
+ ];
614
+ const RESPONSE_OWNED = ["connection", "transfer-encoding"];
615
+ /**
616
+ * Split a URL into an origin-form request target and an authority *without*
617
+ * going through `new URL()`. `URL` normalises percent-encoding and would
618
+ * re-serialise the target — which is the exact class of defect note 15 found
619
+ * in @libp2p/http, where rebuilding the request line silently dropped
620
+ * `url.search`. Here the target is a verbatim slice of the caller's string.
621
+ */
622
+ function splitTarget(url, opts) {
623
+ if (url.startsWith("/")) {
624
+ assertValidTarget(url);
625
+ return {
626
+ target: url,
627
+ authority: opts.host
628
+ };
629
+ }
630
+ const match = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\/([^/?#]*)([^#]*)/.exec(url);
631
+ if (!match) throw new HttpParseError(`cannot derive a request target from url: ${JSON.stringify(url)}`);
632
+ const rawAuthority = match[1];
633
+ if (rawAuthority === "") throw new HttpParseError(`url has no authority: ${JSON.stringify(url)}`);
634
+ const at = rawAuthority.lastIndexOf("@");
635
+ const authority = at === -1 ? rawAuthority : rawAuthority.slice(at + 1);
636
+ assertValidHost(authority);
637
+ const rawTarget = match[2];
638
+ const target = rawTarget === "" ? "/" : rawTarget.startsWith("/") ? rawTarget : `/${rawTarget}`;
639
+ assertValidTarget(target);
640
+ return {
641
+ target,
642
+ authority
643
+ };
644
+ }
645
+ async function* emitBody(body, declared) {
646
+ if (declared === void 0) {
647
+ yield* encodeChunked(body);
648
+ return;
649
+ }
650
+ let sent = 0;
651
+ for await (const chunk of body) {
652
+ if (chunk.byteLength === 0) continue;
653
+ sent += chunk.byteLength;
654
+ if (sent > declared) throw new HttpParseError(`body exceeds declared Content-Length ${declared} (${sent} bytes so far)`);
655
+ yield chunk;
656
+ }
657
+ if (sent !== declared) throw new HttpParseError(`body is ${sent} bytes but Content-Length declares ${declared}`);
658
+ }
659
+ async function* encodeRequest(env, body, opts) {
660
+ if (!isToken(env.method)) throw new HttpParseError(`invalid method: ${JSON.stringify(env.method)}`);
661
+ const { target, authority } = splitTarget(env.url, opts);
662
+ if (authority === "") throw new HttpParseError("no Host available: url has no authority and no host is configured");
663
+ const carried = withoutHeaders(env.headers, REQUEST_OWNED);
664
+ const declared = parseContentLength(getAll(carried, "content-length"));
665
+ let head = `${env.method} ${target} HTTP/1.1\r\n`;
666
+ head += `Host: ${authority}\r\n`;
667
+ head += encodeHeaderLines(carried);
668
+ head += "Connection: close\r\n";
669
+ if (body !== void 0 && declared === void 0) head += "Transfer-Encoding: chunked\r\n";
670
+ head += "\r\n";
671
+ yield encodeLatin1(head);
672
+ if (body === void 0) {
673
+ if (declared !== void 0 && declared !== 0) throw new HttpParseError(`body is 0 bytes but Content-Length declares ${declared}`);
674
+ return;
675
+ }
676
+ yield* emitBody(body, declared);
677
+ }
678
+ async function* encodeResponse(env, body, _opts, requestMethod) {
679
+ if (!Number.isInteger(env.status) || env.status < 100 || env.status > 599) throw new HttpParseError(`invalid status: ${env.status}`);
680
+ const reason = env.statusText ?? "";
681
+ assertValidStatusText(reason);
682
+ const bodylessStatus = isBodylessStatus(env.status);
683
+ const bodyless = bodylessStatus || requestMethod?.toUpperCase() === "HEAD";
684
+ const owned = bodylessStatus ? [...RESPONSE_OWNED, "content-length"] : RESPONSE_OWNED;
685
+ const carried = withoutHeaders(env.headers, owned);
686
+ const declared = parseContentLength(getAll(carried, "content-length"));
687
+ let head = `HTTP/1.1 ${env.status} ${reason}\r\n`;
688
+ head += encodeHeaderLines(carried);
689
+ head += "Connection: close\r\n";
690
+ if (!bodyless && body !== void 0 && declared === void 0) head += "Transfer-Encoding: chunked\r\n";
691
+ head += "\r\n";
692
+ yield encodeLatin1(head);
693
+ if (bodyless) {
694
+ await discard(body);
695
+ return;
696
+ }
697
+ if (body === void 0) {
698
+ if (declared !== void 0 && declared !== 0) throw new HttpParseError(`body is 0 bytes but Content-Length declares ${declared}`);
699
+ return;
700
+ }
701
+ yield* emitBody(body, declared);
702
+ }
703
+ //#endregion
704
+ //#region src/http1/index.ts
705
+ function newHttpCodec(options = {}) {
706
+ const opts = {
707
+ scheme: options.scheme ?? "http",
708
+ host: options.host ?? "localhost",
709
+ maxHeaderBytes: options.maxHeaderBytes ?? 65536
710
+ };
711
+ return {
712
+ name: "http/1.1",
713
+ sniff: (byte) => isTokenChar(byte),
714
+ encodeRequest: (env, body) => encodeRequest(env, body, opts),
715
+ encodeResponse: (env, body, o) => encodeResponse(env, body, opts, o?.method),
716
+ decodeRequest: (input) => decodeRequest(input, opts),
717
+ decodeResponse: (input, o) => decodeResponse(input, opts, o.method)
718
+ };
719
+ }
720
+ const httpCodec = newHttpCodec();
721
+ //#endregion
722
+ //#region src/sniff.ts
723
+ /**
724
+ * Dispatches on byte 0. The formats are self-identifying — a JSON envelope
725
+ * always begins `{`, which is not a token character and so can never begin an
726
+ * HTTP start-line — so no negotiation handshake, magic prefix, or version byte
727
+ * is needed.
728
+ *
729
+ * This is what makes a mixed-version peer pair safe: readers accept either
730
+ * format, so the two ends can be upgraded in any order.
731
+ */
732
+ function newSniffingCodec(options) {
733
+ const { write, accept } = options;
734
+ async function pick(input) {
735
+ const reader = new ByteReader(input);
736
+ const first = await reader.peekByte();
737
+ if (first === void 0) throw new HttpParseError("sniff: stream ended before any bytes arrived");
738
+ const codec = accept.find((c) => c.sniff(first));
739
+ if (!codec) throw new HttpParseError(`sniff: no accepted codec recognises a message starting with byte 0x${first.toString(16).padStart(2, "0")}`);
740
+ return {
741
+ codec,
742
+ input: reader.rest()
743
+ };
744
+ }
745
+ return {
746
+ name: `sniff(write=${write.name}; accept=${accept.map((c) => c.name).join(",")})`,
747
+ sniff: (byte) => accept.some((c) => c.sniff(byte)),
748
+ encodeRequest: (env, body) => write.encodeRequest(env, body),
749
+ encodeResponse: (env, body, o) => write.encodeResponse(env, body, o),
750
+ decodeRequest: async (input) => {
751
+ const picked = await pick(input);
752
+ try {
753
+ return {
754
+ ...await picked.codec.decodeRequest(picked.input),
755
+ codec: picked.codec
756
+ };
757
+ } catch (error) {
758
+ if (error !== null && typeof error === "object") error.codec = picked.codec;
759
+ throw error;
760
+ }
761
+ },
762
+ decodeResponse: async (input, o) => {
763
+ const picked = await pick(input);
764
+ return picked.codec.decodeResponse(picked.input, o);
765
+ }
766
+ };
767
+ }
768
+ //#endregion
769
+ //#region src/codec-default.ts
770
+ /**
771
+ * Writes HTTP/1.1; accepts HTTP/1.1 or the legacy JSON envelope. Used whenever
772
+ * no `codec` option is supplied.
773
+ */
774
+ const defaultCodec = newSniffingCodec({
775
+ write: httpCodec,
776
+ accept: [httpCodec, jsonEnvelopeCodec]
777
+ });
85
778
  //#endregion
86
779
  //#region src/http-data.ts
780
+ /** Carries the serialized JS error between two webrun peers. */
781
+ const PEER_ERROR_HEADER = "x-webrun-error";
782
+ /** Hard cap on the peer-error header (M3): see `encodeErrorResponse`. */
783
+ const MAX_DETAIL_CHARS = 4096;
784
+ /**
785
+ * JSON with every non-printable-ASCII character escaped, so the result is a
786
+ * legal latin-1 header value whatever the error message contained.
787
+ */
788
+ function asciiJson(value) {
789
+ return JSON.stringify(value).replace(/[^\x20-\x7E]/g, (ch) => `\\u${ch.charCodeAt(0).toString(16).padStart(4, "0")}`);
790
+ }
791
+ /**
792
+ * Decision 13: a real HTTP peer cannot receive a JavaScript exception, only a
793
+ * response. So an uncaught handler error becomes a conforming 500 whose body
794
+ * carries the message, with the serialized error in a namespaced header that
795
+ * another webrun peer re-throws from.
796
+ */
797
+ function encodeErrorResponse(codec, error, method, status = 500, statusText = "Internal Server Error") {
798
+ const serialized = serializeError(error);
799
+ let detail = asciiJson(serialized);
800
+ if (detail.length > MAX_DETAIL_CHARS) detail = asciiJson({ message: serialized.message });
801
+ if (detail.length > MAX_DETAIL_CHARS) detail = detail.slice(0, MAX_DETAIL_CHARS);
802
+ const message = serialized.message ?? statusText;
803
+ return codec.encodeResponse({
804
+ status,
805
+ statusText,
806
+ headers: [["Content-Type", "text/plain; charset=utf-8"], [PEER_ERROR_HEADER, detail]]
807
+ }, [new TextEncoder().encode(message)], { method });
808
+ }
809
+ /**
810
+ * `output` is the `Duplex` call's own generator — one logical call, per the
811
+ * `Duplex` contract in `@statewalker/webrun-streams`: "Consumer `.return()`
812
+ * on the output → producer's `finally` runs." On a mux transport
813
+ * (`emulateMux`), that `finally` is what frees the stream-table slot; skip it
814
+ * and every peer-error response leaks one slot, unboundedly, until the mux
815
+ * itself is exhausted (`maxStreams` reached, every further call rejected).
816
+ *
817
+ * Cancelling `output` here — not inside the codec's own decode logic — is
818
+ * deliberate. `codec.decodeResponse` already pulled from `output` to read the
819
+ * head before this function runs, so unlike the body case above there's no
820
+ * suspended-start no-op to worry about. And it's safe specifically *because*
821
+ * this is the client's own response-only generator: the request was already
822
+ * fully sent before we got here, so there is nothing left to write on it.
823
+ * The equivalent is NOT safe inside `src/http1/decode.ts`'s `ByteReader` —
824
+ * that code also runs when a caller wires `codec.decodeRequest`/
825
+ * `decodeResponse` directly onto a raw bidirectional socket (see
826
+ * `tests/http1-node-interop.test.ts`), where the *same* object is read from
827
+ * and then written back to (a server reads the request, then replies on the
828
+ * same socket); cancelling the read side there tears down the whole
829
+ * connection out from under the pending write. That was tried and reverted —
830
+ * see the Task 11 report for the "socket hang up" failure it caused.
831
+ */
832
+ async function throwIfPeerError(result, output) {
833
+ const found = result.envelope.headers.find(([k]) => k.toLowerCase() === PEER_ERROR_HEADER);
834
+ if (!found) return;
835
+ await discard(result.body);
836
+ try {
837
+ await output.return?.(void 0);
838
+ } catch {}
839
+ let payload;
840
+ try {
841
+ payload = JSON.parse(found[1]);
842
+ } catch {
843
+ payload = { message: found[1] };
844
+ }
845
+ throw deserializeError(payload);
846
+ }
87
847
  /**
88
848
  * Initiate an HTTP call over a `Duplex`. The caller's `call: Duplex` is
89
849
  * obtained from any `webrun-streams-*` adapter's `connect`. Returns the
@@ -92,24 +852,227 @@ function concatChunks(parts, totalLen) {
92
852
  * The call is one logical Duplex invocation; multiplexing of concurrent
93
853
  * calls is the adapter's concern (native or `emulateMux`).
94
854
  */
95
- async function httpFetch(call, env, body) {
96
- return decodeMessage(call(encodeMessage(env, body)));
855
+ async function httpFetch(call, env, body, options = {}) {
856
+ const codec = options.codec ?? defaultCodec;
857
+ const output = call(codec.encodeRequest(env, body));
858
+ const result = await codec.decodeResponse(output, { method: env.method });
859
+ await throwIfPeerError(result, output);
860
+ return result;
97
861
  }
98
862
  /**
99
863
  * Wrap an HTTP handler as a `Duplex` so it can be registered with any
100
- * `webrun-streams-*` adapter's `serve`. The duplex `split`s the input to
101
- * recover envelope + body, dispatches to the handler, and emits the response
102
- * via `encodeMessage`.
864
+ * `webrun-streams-*` adapter's `serve`.
103
865
  */
104
- function httpServe(handler) {
866
+ function httpServe(handler, options = {}) {
867
+ const codec = options.codec ?? defaultCodec;
105
868
  return async function* httpHandlerDuplex(input) {
106
- const { envelope: reqEnv, body: reqBody } = await decodeMessage(input);
107
- const result = await handler(reqEnv, reqBody);
108
- yield* encodeMessage(result.envelope, result.body);
869
+ let decoded;
870
+ try {
871
+ decoded = await codec.decodeRequest(input);
872
+ } catch (error) {
873
+ if (!(error instanceof HttpParseError)) throw error;
874
+ yield* encodeErrorResponse(error.codec ?? codec, error, "GET", 400, "Bad Request");
875
+ return;
876
+ }
877
+ const replyCodec = decoded.codec ?? codec;
878
+ let result;
879
+ try {
880
+ result = await handler(decoded.envelope, decoded.body);
881
+ } catch (error) {
882
+ yield* encodeErrorResponse(replyCodec, error, decoded.envelope.method);
883
+ return;
884
+ }
885
+ yield* replyCodec.encodeResponse(result.envelope, result.body, { method: decoded.envelope.method });
886
+ };
887
+ }
888
+ //#endregion
889
+ //#region src/request-streams.ts
890
+ /**
891
+ * The one place that knows a runtime may not implement request body streams,
892
+ * and the buffering both fallbacks need. `fetch.ts` and `http-stubs.ts` each
893
+ * carry a client and a server direction that must agree on the answer, so the
894
+ * predicate lives here rather than being written out four times.
895
+ */
896
+ /**
897
+ * Whether `Request.prototype` exposes a `body` accessor. Two call sites read
898
+ * that property, and two more depend on the constructor accepting a
899
+ * `ReadableStream` as `init.body`; this predicate answers for all four.
900
+ *
901
+ * What was verified, and all that is claimed here: Firefox (146 at the time of
902
+ * writing) has *neither*, and Chromium and Node have *both*. On Firefox it is
903
+ * not that `body` is `undefined` on the instance —
904
+ * `Object.getOwnPropertyDescriptor(Request.prototype, "body")` is `null`, the
905
+ * accessor is genuinely absent — and because a `ReadableStream` is then not a
906
+ * recognised `BodyInit`, the constructor falls through to the string branch and
907
+ * stores the literal text `[object ReadableStream]`.
908
+ *
909
+ * The two halves are NOT guaranteed to ship together, so do not read this as a
910
+ * test for "request streams" in general. Safari is the counterexample: it has
911
+ * had `Request.body` since 11.1 but only accepts a stream as `init.body` from
912
+ * Technology Preview 250, so a shipping Safari has the reader half without the
913
+ * upload half and this returns `true` there. That looks benign — WebKit appears
914
+ * to store the stream on the `Request` rather than stringify it, and its error
915
+ * comes from `fetch()`, which this package never calls on the objects it builds
916
+ * — but it is untested, and it is the case to look at first if a Safari report
917
+ * arrives. The sharper probe, if one is ever needed, is whether
918
+ * `new Request(url, {method:"POST", body:new ReadableStream(), duplex:"half"})`
919
+ * has a `content-type` of `text/plain;charset=UTF-8` (stringified) or `null`
920
+ * (stored); it is not used here because it costs a `Request` and a
921
+ * `ReadableStream` per call and agrees with the descriptor check on every
922
+ * runtime measured.
923
+ *
924
+ * A capability check, never a user-agent test: the question is what this
925
+ * runtime does, and the answer flips on its own the day Firefox ships request
926
+ * streams. Evaluated per call rather than cached at module load so that a test
927
+ * can install a `Request` without the capability and exercise the real branch
928
+ * under Node.
929
+ */
930
+ function supportsRequestStreams() {
931
+ return typeof Request === "function" && Object.getOwnPropertyDescriptor(Request.prototype, "body") != null;
932
+ }
933
+ /**
934
+ * Drain a body iterable into one contiguous buffer. Only the fallbacks need it.
935
+ * Allocates its own buffer rather than reusing `bytes.ts`'s `concatChunks`,
936
+ * which passes a single chunk straight through: that chunk is a view onto the
937
+ * decoder's own read buffer, and the result here is handed to `new Request` and
938
+ * outlives the decode. The allocation is also what makes it a
939
+ * `Uint8Array<ArrayBuffer>`, which `BodyInit` accepts and the looser
940
+ * `Uint8Array<ArrayBufferLike>` does not.
941
+ */
942
+ async function collectBytes(source) {
943
+ const parts = [];
944
+ let total = 0;
945
+ for await (const chunk of source) {
946
+ if (chunk.byteLength === 0) continue;
947
+ parts.push(chunk);
948
+ total += chunk.byteLength;
949
+ }
950
+ const out = new Uint8Array(total);
951
+ let offset = 0;
952
+ for (const part of parts) {
953
+ out.set(part, offset);
954
+ offset += part.byteLength;
955
+ }
956
+ return out;
957
+ }
958
+ //#endregion
959
+ //#region src/http-stubs.ts
960
+ const NULL_BODY_STATUSES = /* @__PURE__ */ new Set([
961
+ 101,
962
+ 103,
963
+ 204,
964
+ 205,
965
+ 304
966
+ ]);
967
+ const REQUEST_FIELDS = [
968
+ "url",
969
+ "method",
970
+ "mode",
971
+ "credentials",
972
+ "cache",
973
+ "redirect",
974
+ "referrer",
975
+ "referrerPolicy",
976
+ "integrity",
977
+ "keepalive"
978
+ ];
979
+ /** `SerializedHttpEnvelope.content` is never absent, only empty. */
980
+ async function* noBytes() {}
981
+ async function* oneChunk(chunk) {
982
+ yield chunk;
983
+ }
984
+ /**
985
+ * Returns an HTTP handler that serializes a Request, hands the envelope to
986
+ * `send` for transport, and deserializes the reply into a Response. Used on
987
+ * the caller side.
988
+ */
989
+ function newHttpClientStub(send) {
990
+ return async (requestOrPromise) => {
991
+ const request = await requestOrPromise;
992
+ const headers = [...request.headers].map(([k, v]) => [k, v]);
993
+ const options = {
994
+ url: request.url,
995
+ headers
996
+ };
997
+ for (const field of REQUEST_FIELDS) {
998
+ const val = request[field];
999
+ if (val !== void 0 && field !== "url") options[field] = val;
1000
+ }
1001
+ let content;
1002
+ if (request.body != null) content = fromReadableStream(request.body);
1003
+ else if (!supportsRequestStreams()) content = oneChunk(new Uint8Array(await request.arrayBuffer()));
1004
+ else content = noBytes();
1005
+ const result = await send({
1006
+ options,
1007
+ content
1008
+ });
1009
+ if (!result) return new Response(null, {
1010
+ status: 404,
1011
+ statusText: "Error 404: Not Found"
1012
+ });
1013
+ const responseOptions = result.options;
1014
+ const method = options.method;
1015
+ if (method === "HEAD" || method === "OPTIONS" || NULL_BODY_STATUSES.has(responseOptions.status)) {
1016
+ await discard(result.content);
1017
+ return new Response(null, responseOptions);
1018
+ }
1019
+ return new Response(toReadableStream(result.content[Symbol.asyncIterator]()), responseOptions);
1020
+ };
1021
+ }
1022
+ /**
1023
+ * Returns a server-side transport handler. It deserializes the incoming
1024
+ * request envelope, delegates to `handler`, and serializes the response.
1025
+ */
1026
+ function newHttpServerStub(handler) {
1027
+ return async (envelopeOrPromise) => {
1028
+ const { options, content } = await envelopeOrPromise;
1029
+ const { url, method, headers = [], ...rest } = options;
1030
+ const { mode: _mode, ...forwardable } = rest;
1031
+ const requestHeaders = new Headers();
1032
+ for (const [key, value] of headers) requestHeaders.append(key, value);
1033
+ const hasBody = method !== "GET" && method !== "HEAD" && method !== "OPTIONS";
1034
+ const requestInit = {
1035
+ ...forwardable,
1036
+ method,
1037
+ headers: requestHeaders
1038
+ };
1039
+ if (!hasBody) await discard(content);
1040
+ else if (supportsRequestStreams()) {
1041
+ requestInit.body = toReadableStream(content[Symbol.asyncIterator]());
1042
+ requestInit.duplex = "half";
1043
+ } else requestInit.body = await collectBytes(content);
1044
+ const response = await handler(new Request(url, requestInit));
1045
+ return {
1046
+ options: {
1047
+ status: response.status,
1048
+ statusText: response.statusText,
1049
+ headers: Object.fromEntries([...response.headers])
1050
+ },
1051
+ content: response.body ? fromReadableStream(response.body) : (async function* () {})()
1052
+ };
109
1053
  };
110
1054
  }
111
1055
  //#endregion
112
1056
  //#region src/fetch.ts
1057
+ /**
1058
+ * Connection-scoped headers. The codec surfaces them verbatim (decision 12),
1059
+ * but they are meaningless to a `Request`/`Response`, and re-emitting them
1060
+ * from a relay would corrupt its framing.
1061
+ */
1062
+ const HOP_BY_HOP = /* @__PURE__ */ new Set([
1063
+ "connection",
1064
+ "host",
1065
+ "keep-alive",
1066
+ "proxy-authenticate",
1067
+ "proxy-authorization",
1068
+ "te",
1069
+ "trailer",
1070
+ "transfer-encoding",
1071
+ "upgrade"
1072
+ ]);
1073
+ function forwardableHeaders(headers) {
1074
+ return headers.filter(([name]) => !HOP_BY_HOP.has(name.toLowerCase()));
1075
+ }
113
1076
  function headersToArray(headers) {
114
1077
  const out = [];
115
1078
  headers.forEach((value, key) => {
@@ -164,43 +1127,65 @@ function asyncIterableToReadable(iter) {
164
1127
  * the other side. The request's `signal` is plumbed into the body iteration —
165
1128
  * abort terminates the underlying call.
166
1129
  */
167
- async function fetchOverDuplex(call, request) {
1130
+ async function fetchOverDuplex(call, request, options = {}) {
168
1131
  if (request.signal?.aborted) throw abortReason(request.signal);
169
- const { envelope, body: respBody } = await httpFetch(call, {
1132
+ const env = {
170
1133
  url: request.url,
171
1134
  method: request.method,
172
1135
  headers: headersToArray(request.headers)
173
- }, request.body ? readableToAsyncIterable(request.body) : void 0);
174
- return new Response(asyncIterableToReadable(withAbort(respBody, request.signal)), {
1136
+ };
1137
+ let body;
1138
+ if (request.body != null) body = readableToAsyncIterable(request.body);
1139
+ else if (!supportsRequestStreams()) {
1140
+ const buffered = new Uint8Array(await request.arrayBuffer());
1141
+ if (buffered.byteLength > 0) body = [buffered];
1142
+ }
1143
+ const { envelope, body: respBody } = await httpFetch(call, env, body, options);
1144
+ const responseInit = {
175
1145
  status: envelope.status,
176
1146
  statusText: envelope.statusText,
177
- headers: envelope.headers
178
- });
1147
+ headers: forwardableHeaders(envelope.headers)
1148
+ };
1149
+ if (request.method === "HEAD" || request.method === "OPTIONS" || NULL_BODY_STATUSES.has(envelope.status)) {
1150
+ await respBody.return?.();
1151
+ return new Response(null, responseInit);
1152
+ }
1153
+ return new Response(asyncIterableToReadable(withAbort(respBody, request.signal)), responseInit);
179
1154
  }
180
1155
  /**
181
1156
  * Wrap a `(Request) => Promise<Response>` handler as a `Duplex` so it can be
182
1157
  * registered with any `webrun-streams-*` adapter's `serve`.
183
1158
  */
184
- function serveFetchOverDuplex(handler) {
1159
+ function serveFetchOverDuplex(handler, options = {}) {
185
1160
  return httpServe(async (env, body) => {
186
1161
  const reqInit = {
187
1162
  method: env.method,
188
- headers: env.headers
1163
+ headers: forwardableHeaders(env.headers)
189
1164
  };
190
1165
  if (env.method !== "GET" && env.method !== "HEAD") {
191
- reqInit.body = asyncIterableToReadable(body);
192
- reqInit.duplex = "half";
1166
+ if (supportsRequestStreams()) {
1167
+ reqInit.body = asyncIterableToReadable(body);
1168
+ reqInit.duplex = "half";
1169
+ } else reqInit.body = await collectBytes(body);
193
1170
  }
194
1171
  const response = await handler(new Request(env.url, reqInit));
1172
+ const respEnv = {
1173
+ status: response.status,
1174
+ statusText: response.statusText,
1175
+ headers: headersToArray(response.headers)
1176
+ };
1177
+ if (env.method === "HEAD" || env.method === "OPTIONS" || NULL_BODY_STATUSES.has(response.status)) {
1178
+ await response.body?.cancel();
1179
+ return {
1180
+ envelope: respEnv,
1181
+ body: void 0
1182
+ };
1183
+ }
195
1184
  return {
196
- envelope: {
197
- status: response.status,
198
- statusText: response.statusText,
199
- headers: headersToArray(response.headers)
200
- },
1185
+ envelope: respEnv,
201
1186
  body: response.body ? readableToAsyncIterable(response.body) : void 0
202
1187
  };
203
- });
1188
+ }, options);
204
1189
  }
205
1190
  async function* withAbort(iter, signal) {
206
1191
  if (!signal) {
@@ -228,7 +1213,7 @@ function abortReason(signal) {
228
1213
  * ```ts
229
1214
  * import { SiteBuilder } from "@statewalker/webrun-site-builder";
230
1215
  * import { DuplexSiteBuilder } from "@statewalker/webrun-http-streams";
231
- * import { serve } from "@statewalker/webrun-streams-port";
1216
+ * import { serve } from "@statewalker/webrun-rpc";
232
1217
  *
233
1218
  * const handler = new SiteBuilder()
234
1219
  * .setEndpoint("/api/time", () => new Response(new Date().toISOString()))
@@ -243,14 +1228,20 @@ function abortReason(signal) {
243
1228
  */
244
1229
  var DuplexSiteBuilder = class {
245
1230
  #handler;
1231
+ #codec;
246
1232
  setHandler(handler) {
247
1233
  this.#handler = handler;
248
1234
  return this;
249
1235
  }
1236
+ /** Pin the wire format. Defaults to HTTP/1.1 with legacy acceptance. */
1237
+ setCodec(codec) {
1238
+ this.#codec = codec;
1239
+ return this;
1240
+ }
250
1241
  async start(serve, params) {
251
1242
  if (!this.#handler) throw new Error("DuplexSiteBuilder.start: setHandler(handler) must be called before start()");
252
1243
  const handler = this.#handler;
253
- return serve(params, serveFetchOverDuplex(async (req) => handler(req)));
1244
+ return serve(params, serveFetchOverDuplex(async (req) => handler(req), { codec: this.#codec }));
254
1245
  }
255
1246
  };
256
1247
  //#endregion
@@ -278,10 +1269,11 @@ var HttpError = class HttpError extends Error {
278
1269
  }
279
1270
  static fromError(error) {
280
1271
  if (error instanceof HttpError) return error;
1272
+ const message = error instanceof Error ? error.message : String(error);
281
1273
  return new HttpError({
282
1274
  status: 500,
283
1275
  statusText: "Bad Request",
284
- message: error instanceof Error ? error.message : String(error)
1276
+ message
285
1277
  });
286
1278
  }
287
1279
  static errorResourceNotFound(options = {}) {
@@ -314,94 +1306,4 @@ var HttpError = class HttpError extends Error {
314
1306
  }
315
1307
  };
316
1308
  //#endregion
317
- //#region src/http-stubs.ts
318
- const NULL_BODY_STATUSES = new Set([
319
- 101,
320
- 103,
321
- 204,
322
- 205,
323
- 304
324
- ]);
325
- const REQUEST_FIELDS = [
326
- "url",
327
- "method",
328
- "mode",
329
- "credentials",
330
- "cache",
331
- "redirect",
332
- "referrer",
333
- "referrerPolicy",
334
- "integrity",
335
- "keepalive"
336
- ];
337
- /**
338
- * Returns an HTTP handler that serializes a Request, hands the envelope to
339
- * `send` for transport, and deserializes the reply into a Response. Used on
340
- * the caller side.
341
- */
342
- function newHttpClientStub(send) {
343
- return async (requestOrPromise) => {
344
- const request = await requestOrPromise;
345
- const headers = [...request.headers].map(([k, v]) => [k, v]);
346
- const options = {
347
- url: request.url,
348
- headers
349
- };
350
- for (const field of REQUEST_FIELDS) {
351
- const val = request[field];
352
- if (val !== void 0 && field !== "url") options[field] = val;
353
- }
354
- const result = await send({
355
- options,
356
- content: request.body ? fromReadableStream(request.body) : (async function* () {})()
357
- });
358
- if (!result) return new Response(null, {
359
- status: 404,
360
- statusText: "Error 404: Not Found"
361
- });
362
- const responseOptions = result.options;
363
- const method = options.method;
364
- if (method === "HEAD" || method === "OPTIONS" || NULL_BODY_STATUSES.has(responseOptions.status)) {
365
- await result.content.return?.();
366
- return new Response(null, responseOptions);
367
- }
368
- return new Response(toReadableStream(result.content[Symbol.asyncIterator]()), responseOptions);
369
- };
370
- }
371
- /**
372
- * Returns a server-side transport handler. It deserializes the incoming
373
- * request envelope, delegates to `handler`, and serializes the response.
374
- */
375
- function newHttpServerStub(handler) {
376
- return async (envelopeOrPromise) => {
377
- const { options, content } = await envelopeOrPromise;
378
- const { url, method, headers = [], ...rest } = options;
379
- const { mode: _mode, ...forwardable } = rest;
380
- const requestHeaders = new Headers();
381
- for (const [key, value] of headers) requestHeaders.append(key, value);
382
- const hasBody = method !== "GET" && method !== "HEAD" && method !== "OPTIONS";
383
- let body;
384
- if (hasBody) body = toReadableStream(content[Symbol.asyncIterator]());
385
- else await content.return?.();
386
- const requestInit = {
387
- ...forwardable,
388
- method,
389
- headers: requestHeaders
390
- };
391
- if (hasBody) {
392
- requestInit.body = body;
393
- requestInit.duplex = "half";
394
- }
395
- const response = await handler(new Request(url, requestInit));
396
- return {
397
- options: {
398
- status: response.status,
399
- statusText: response.statusText,
400
- headers: Object.fromEntries([...response.headers])
401
- },
402
- content: response.body ? fromReadableStream(response.body) : (async function* () {})()
403
- };
404
- };
405
- }
406
- //#endregion
407
- export { DuplexSiteBuilder, HttpError, decodeMessage, encodeMessage, fetchOverDuplex, httpFetch, httpServe, newHttpClientStub, newHttpServerStub, serveFetchOverDuplex };
1309
+ export { DuplexSiteBuilder, HttpError, HttpParseError, PEER_ERROR_HEADER, decodeMessage, defaultCodec, encodeMessage, fetchOverDuplex, httpCodec, httpFetch, httpServe, jsonEnvelopeCodec, newHttpClientStub, newHttpCodec, newHttpServerStub, newSniffingCodec, serveFetchOverDuplex };