@statewalker/webrun-http-streams 0.1.1 → 0.2.1

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 (55) hide show
  1. package/README.md +288 -11
  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 +3 -0
  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 +1039 -137
  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 +8 -6
  39. package/src/bytes.ts +157 -0
  40. package/src/codec-default.ts +13 -0
  41. package/src/duplex-site-builder.ts +9 -1
  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
@@ -0,0 +1,50 @@
1
+ /** Anything the codecs will read bytes from. */
2
+ export type ByteSource = AsyncIterable<Uint8Array> | Iterable<Uint8Array>;
3
+ export type RequestEnvelope = {
4
+ url: string;
5
+ method: string;
6
+ headers: [string, string][];
7
+ };
8
+ export type ResponseEnvelope = {
9
+ status: number;
10
+ statusText: string;
11
+ headers: [string, string][];
12
+ };
13
+ export type DecodedRequest = {
14
+ envelope: RequestEnvelope;
15
+ body: AsyncIterable<Uint8Array>;
16
+ /** The codec that actually read this message; set by the sniffing codec. */
17
+ codec?: MessageCodec;
18
+ };
19
+ export type DecodedResponse = {
20
+ envelope: ResponseEnvelope;
21
+ body: AsyncIterable<Uint8Array>;
22
+ };
23
+ /** Extra context a codec may need that the envelope does not carry. */
24
+ export type ResponseCodecOptions = {
25
+ /**
26
+ * Method of the request this response answers. Required by HTTP/1.1: a
27
+ * response to HEAD carries framing headers but no body, and no parser can
28
+ * know that from the response bytes alone.
29
+ */
30
+ method: string;
31
+ };
32
+ /**
33
+ * One wire format. Requests and responses are separate operations because
34
+ * HTTP/1.1 serialises them differently — a codec cannot be generic over the
35
+ * envelope the way the JSON format was.
36
+ */
37
+ export interface MessageCodec {
38
+ readonly name: string;
39
+ /**
40
+ * True if a message in this format may begin with `byte`. Used by the
41
+ * sniffing codec to dispatch without a negotiation handshake. Implementations
42
+ * must be mutually exclusive with every other codec they are paired with.
43
+ */
44
+ sniff(byte: number): boolean;
45
+ encodeRequest(env: RequestEnvelope, body?: ByteSource): AsyncGenerator<Uint8Array>;
46
+ encodeResponse(env: ResponseEnvelope, body?: ByteSource, options?: ResponseCodecOptions): AsyncGenerator<Uint8Array>;
47
+ decodeRequest(input: ByteSource): Promise<DecodedRequest>;
48
+ decodeResponse(input: ByteSource, options: ResponseCodecOptions): Promise<DecodedResponse>;
49
+ }
50
+ //# sourceMappingURL=message.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"message.d.ts","sourceRoot":"","sources":["../src/message.ts"],"names":[],"mappings":"AAAA,gDAAgD;AAChD,MAAM,MAAM,UAAU,GAAG,aAAa,CAAC,UAAU,CAAC,GAAG,QAAQ,CAAC,UAAU,CAAC,CAAC;AAE1E,MAAM,MAAM,eAAe,GAAG;IAC5B,GAAG,EAAE,MAAM,CAAC;IACZ,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,CAAC;CAC7B,CAAC;AAEF,MAAM,MAAM,gBAAgB,GAAG;IAC7B,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,CAAC,MAAM,EAAE,MAAM,CAAC,EAAE,CAAC;CAC7B,CAAC;AAEF,MAAM,MAAM,cAAc,GAAG;IAC3B,QAAQ,EAAE,eAAe,CAAC;IAC1B,IAAI,EAAE,aAAa,CAAC,UAAU,CAAC,CAAC;IAChC,4EAA4E;IAC5E,KAAK,CAAC,EAAE,YAAY,CAAC;CACtB,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG;IAC5B,QAAQ,EAAE,gBAAgB,CAAC;IAC3B,IAAI,EAAE,aAAa,CAAC,UAAU,CAAC,CAAC;CACjC,CAAC;AAEF,uEAAuE;AACvE,MAAM,MAAM,oBAAoB,GAAG;IACjC;;;;OAIG;IACH,MAAM,EAAE,MAAM,CAAC;CAChB,CAAC;AAEF;;;;GAIG;AACH,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAEtB;;;;OAIG;IACH,KAAK,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC;IAE7B,aAAa,CAAC,GAAG,EAAE,eAAe,EAAE,IAAI,CAAC,EAAE,UAAU,GAAG,cAAc,CAAC,UAAU,CAAC,CAAC;IACnF,cAAc,CACZ,GAAG,EAAE,gBAAgB,EACrB,IAAI,CAAC,EAAE,UAAU,EACjB,OAAO,CAAC,EAAE,oBAAoB,GAC7B,cAAc,CAAC,UAAU,CAAC,CAAC;IAE9B,aAAa,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAAC,cAAc,CAAC,CAAC;IAC1D,cAAc,CAAC,KAAK,EAAE,UAAU,EAAE,OAAO,EAAE,oBAAoB,GAAG,OAAO,CAAC,eAAe,CAAC,CAAC;CAC5F"}
@@ -0,0 +1,52 @@
1
+ /**
2
+ * The one place that knows a runtime may not implement request body streams,
3
+ * and the buffering both fallbacks need. `fetch.ts` and `http-stubs.ts` each
4
+ * carry a client and a server direction that must agree on the answer, so the
5
+ * predicate lives here rather than being written out four times.
6
+ */
7
+ /**
8
+ * Whether `Request.prototype` exposes a `body` accessor. Two call sites read
9
+ * that property, and two more depend on the constructor accepting a
10
+ * `ReadableStream` as `init.body`; this predicate answers for all four.
11
+ *
12
+ * What was verified, and all that is claimed here: Firefox (146 at the time of
13
+ * writing) has *neither*, and Chromium and Node have *both*. On Firefox it is
14
+ * not that `body` is `undefined` on the instance —
15
+ * `Object.getOwnPropertyDescriptor(Request.prototype, "body")` is `null`, the
16
+ * accessor is genuinely absent — and because a `ReadableStream` is then not a
17
+ * recognised `BodyInit`, the constructor falls through to the string branch and
18
+ * stores the literal text `[object ReadableStream]`.
19
+ *
20
+ * The two halves are NOT guaranteed to ship together, so do not read this as a
21
+ * test for "request streams" in general. Safari is the counterexample: it has
22
+ * had `Request.body` since 11.1 but only accepts a stream as `init.body` from
23
+ * Technology Preview 250, so a shipping Safari has the reader half without the
24
+ * upload half and this returns `true` there. That looks benign — WebKit appears
25
+ * to store the stream on the `Request` rather than stringify it, and its error
26
+ * comes from `fetch()`, which this package never calls on the objects it builds
27
+ * — but it is untested, and it is the case to look at first if a Safari report
28
+ * arrives. The sharper probe, if one is ever needed, is whether
29
+ * `new Request(url, {method:"POST", body:new ReadableStream(), duplex:"half"})`
30
+ * has a `content-type` of `text/plain;charset=UTF-8` (stringified) or `null`
31
+ * (stored); it is not used here because it costs a `Request` and a
32
+ * `ReadableStream` per call and agrees with the descriptor check on every
33
+ * runtime measured.
34
+ *
35
+ * A capability check, never a user-agent test: the question is what this
36
+ * runtime does, and the answer flips on its own the day Firefox ships request
37
+ * streams. Evaluated per call rather than cached at module load so that a test
38
+ * can install a `Request` without the capability and exercise the real branch
39
+ * under Node.
40
+ */
41
+ export declare function supportsRequestStreams(): boolean;
42
+ /**
43
+ * Drain a body iterable into one contiguous buffer. Only the fallbacks need it.
44
+ * Allocates its own buffer rather than reusing `bytes.ts`'s `concatChunks`,
45
+ * which passes a single chunk straight through: that chunk is a view onto the
46
+ * decoder's own read buffer, and the result here is handed to `new Request` and
47
+ * outlives the decode. The allocation is also what makes it a
48
+ * `Uint8Array<ArrayBuffer>`, which `BodyInit` accepts and the looser
49
+ * `Uint8Array<ArrayBufferLike>` does not.
50
+ */
51
+ export declare function collectBytes(source: AsyncIterable<Uint8Array>): Promise<Uint8Array<ArrayBuffer>>;
52
+ //# sourceMappingURL=request-streams.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"request-streams.d.ts","sourceRoot":"","sources":["../src/request-streams.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,wBAAgB,sBAAsB,IAAI,OAAO,CAKhD;AAED;;;;;;;;GAQG;AACH,wBAAsB,YAAY,CAChC,MAAM,EAAE,aAAa,CAAC,UAAU,CAAC,GAChC,OAAO,CAAC,UAAU,CAAC,WAAW,CAAC,CAAC,CAelC"}
@@ -0,0 +1,18 @@
1
+ import type { MessageCodec } from "./message.js";
2
+ export type SniffingCodecOptions = {
3
+ /** Codec used for everything this peer writes. */
4
+ write: MessageCodec;
5
+ /** Codecs this peer accepts on read, tried in order. */
6
+ accept: MessageCodec[];
7
+ };
8
+ /**
9
+ * Dispatches on byte 0. The formats are self-identifying — a JSON envelope
10
+ * always begins `{`, which is not a token character and so can never begin an
11
+ * HTTP start-line — so no negotiation handshake, magic prefix, or version byte
12
+ * is needed.
13
+ *
14
+ * This is what makes a mixed-version peer pair safe: readers accept either
15
+ * format, so the two ends can be upgraded in any order.
16
+ */
17
+ export declare function newSniffingCodec(options: SniffingCodecOptions): MessageCodec;
18
+ //# sourceMappingURL=sniff.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sniff.d.ts","sourceRoot":"","sources":["../src/sniff.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAc,YAAY,EAAE,MAAM,cAAc,CAAC;AAE7D,MAAM,MAAM,oBAAoB,GAAG;IACjC,kDAAkD;IAClD,KAAK,EAAE,YAAY,CAAC;IACpB,wDAAwD;IACxD,MAAM,EAAE,YAAY,EAAE,CAAC;CACxB,CAAC;AAEF;;;;;;;;GAQG;AACH,wBAAgB,gBAAgB,CAAC,OAAO,EAAE,oBAAoB,GAAG,YAAY,CA+C5E"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statewalker/webrun-http-streams",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "HTTP request/response over Duplex — merge of webrun-http + webrun-http-port",
@@ -22,14 +22,14 @@
22
22
  "src"
23
23
  ],
24
24
  "dependencies": {
25
- "@statewalker/webrun-streams": "0.1.0"
25
+ "@statewalker/webrun-streams": "0.1.1"
26
26
  },
27
27
  "devDependencies": {
28
- "@types/node": "^25.6.0",
28
+ "@types/node": "^26.2.0",
29
29
  "rimraf": "^6.1.3",
30
- "rolldown": "^1.0.0-rc.16",
31
- "typescript": "^6.0.3",
32
- "vitest": "^4.1.4"
30
+ "rolldown": "^1.2.4",
31
+ "typescript": "^7.0.2",
32
+ "vitest": "^4.1.10"
33
33
  },
34
34
  "sideEffects": false,
35
35
  "publishConfig": {
@@ -38,6 +38,8 @@
38
38
  "scripts": {
39
39
  "build": "rimraf dist && rolldown -c && tsc --emitDeclarationOnly --declaration",
40
40
  "test": "vitest run",
41
+ "typecheck": "tsc -p tsconfig.json --noEmit",
42
+ "typecheck:tests": "tsc -p tsconfig.tests.json",
41
43
  "lint": "biome check src tests"
42
44
  }
43
45
  }
package/src/bytes.ts ADDED
@@ -0,0 +1,157 @@
1
+ import type { ByteSource } from "./message.js";
2
+
3
+ const CR = 0x0d;
4
+ const LF = 0x0a;
5
+ const EMPTY = new Uint8Array(0);
6
+
7
+ export class ByteStreamError extends Error {
8
+ override readonly name = "ByteStreamError";
9
+ }
10
+
11
+ export function toAsyncIterator(input: ByteSource): AsyncIterator<Uint8Array> {
12
+ const asyncIter = (input as AsyncIterable<Uint8Array>)[Symbol.asyncIterator];
13
+ if (asyncIter) return asyncIter.call(input as AsyncIterable<Uint8Array>);
14
+ const syncIter = (input as Iterable<Uint8Array>)[Symbol.iterator]();
15
+ return {
16
+ next(): Promise<IteratorResult<Uint8Array>> {
17
+ return Promise.resolve(syncIter.next());
18
+ },
19
+ };
20
+ }
21
+
22
+ /**
23
+ * Discard an iterable we are contractually forbidden from consuming — a body
24
+ * skipped for HEAD/204, or one abandoned because the peer reported an error.
25
+ *
26
+ * The `.next()` is not optional: `.return()` on a generator still in suspended
27
+ * start is a no-op, so the body never runs and its `try/finally` never unwinds.
28
+ * Without it a wrapped ReadableStream or socket is never cancelled.
29
+ */
30
+ export async function discard(source: ByteSource | undefined): Promise<void> {
31
+ if (source === undefined) return;
32
+ const it = toAsyncIterator(source);
33
+ try {
34
+ await it.next();
35
+ await it.return?.();
36
+ } catch {
37
+ /* the source is being discarded; its failures are not ours to surface */
38
+ }
39
+ }
40
+
41
+ export function concatChunks(parts: Uint8Array[], totalLen: number): Uint8Array {
42
+ if (parts.length === 1) {
43
+ const only = parts[0];
44
+ // `parts.length === 1` guarantees index 0 exists; falls through to the
45
+ // general path below in the unreachable case where it somehow doesn't.
46
+ if (only !== undefined) return only;
47
+ }
48
+ const out = new Uint8Array(totalLen);
49
+ let off = 0;
50
+ for (const p of parts) {
51
+ out.set(p, off);
52
+ off += p.byteLength;
53
+ }
54
+ return out;
55
+ }
56
+
57
+ /**
58
+ * Pull-based reader over a byte source. Holds at most one pending buffer, and
59
+ * hands out `subarray` views rather than copies — a body never passes through
60
+ * an allocation here.
61
+ */
62
+ export class ByteReader {
63
+ #iter: AsyncIterator<Uint8Array>;
64
+ #buf: Uint8Array = EMPTY;
65
+ #done = false;
66
+
67
+ constructor(input: ByteSource) {
68
+ this.#iter = toAsyncIterator(input);
69
+ }
70
+
71
+ /** Bytes already pulled from the source but not yet consumed. */
72
+ bufferedLength(): number {
73
+ return this.#buf.byteLength;
74
+ }
75
+
76
+ /** Pull one more non-empty chunk. Returns false at end of stream. */
77
+ async #pull(): Promise<boolean> {
78
+ if (this.#done) return false;
79
+ while (true) {
80
+ const next = await this.#iter.next();
81
+ if (next.done) {
82
+ this.#done = true;
83
+ return false;
84
+ }
85
+ const chunk = next.value;
86
+ if (chunk.byteLength === 0) continue;
87
+ this.#buf =
88
+ this.#buf.byteLength === 0
89
+ ? chunk
90
+ : concatChunks([this.#buf, chunk], this.#buf.byteLength + chunk.byteLength);
91
+ return true;
92
+ }
93
+ }
94
+
95
+ async peekByte(): Promise<number | undefined> {
96
+ while (this.#buf.byteLength === 0) {
97
+ if (!(await this.#pull())) return undefined;
98
+ }
99
+ return this.#buf[0];
100
+ }
101
+
102
+ /** Up to `max` bytes. `undefined` means end of stream. */
103
+ async readSome(max: number): Promise<Uint8Array | undefined> {
104
+ while (this.#buf.byteLength === 0) {
105
+ if (!(await this.#pull())) return undefined;
106
+ }
107
+ const take = Math.min(max, this.#buf.byteLength);
108
+ const out = this.#buf.subarray(0, take);
109
+ this.#buf = this.#buf.subarray(take);
110
+ return out;
111
+ }
112
+
113
+ /**
114
+ * One CRLF-terminated line, without the CRLF. A bare LF is rejected: real
115
+ * peers always send CRLF, and tolerating a bare LF is precisely the lenience
116
+ * that lets request smuggling through a proxy pair.
117
+ *
118
+ * The `maxBytes` bound is best-effort: it only rejects a line if the check
119
+ * happens to run before the line is fully buffered. A line already sitting in
120
+ * the buffer bypasses the check. Callers needing a hard per-line limit or
121
+ * aggregate bounds must keep their own running total.
122
+ */
123
+ async readLine(maxBytes: number): Promise<Uint8Array> {
124
+ let searched = 0;
125
+ while (true) {
126
+ const idx = this.#buf.indexOf(LF, searched);
127
+ if (idx !== -1) {
128
+ if (idx === 0 || this.#buf[idx - 1] !== CR) {
129
+ throw new ByteStreamError("bare LF line terminator; CRLF required");
130
+ }
131
+ const line = this.#buf.subarray(0, idx - 1);
132
+ this.#buf = this.#buf.subarray(idx + 1);
133
+ return line;
134
+ }
135
+ searched = this.#buf.byteLength;
136
+ if (searched > maxBytes) {
137
+ throw new ByteStreamError(`line exceeds ${maxBytes} bytes without CRLF`);
138
+ }
139
+ if (!(await this.#pull())) {
140
+ throw new ByteStreamError(`stream ended after ${searched} bytes without CRLF`);
141
+ }
142
+ }
143
+ }
144
+
145
+ /** Everything not yet consumed, lazily. */
146
+ async *rest(): AsyncGenerator<Uint8Array> {
147
+ while (true) {
148
+ if (this.#buf.byteLength > 0) {
149
+ const out = this.#buf;
150
+ this.#buf = EMPTY;
151
+ yield out;
152
+ continue;
153
+ }
154
+ if (!(await this.#pull())) return;
155
+ }
156
+ }
157
+ }
@@ -0,0 +1,13 @@
1
+ import { jsonEnvelopeCodec } from "./envelope.js";
2
+ import { httpCodec } from "./http1/index.js";
3
+ import type { MessageCodec } from "./message.js";
4
+ import { newSniffingCodec } from "./sniff.js";
5
+
6
+ /**
7
+ * Writes HTTP/1.1; accepts HTTP/1.1 or the legacy JSON envelope. Used whenever
8
+ * no `codec` option is supplied.
9
+ */
10
+ export const defaultCodec: MessageCodec = newSniffingCodec({
11
+ write: httpCodec,
12
+ accept: [httpCodec, jsonEnvelopeCodec],
13
+ });
@@ -1,5 +1,6 @@
1
1
  import type { Serve } from "@statewalker/webrun-streams";
2
2
  import { serveFetchOverDuplex } from "./fetch.js";
3
+ import type { MessageCodec } from "./message.js";
3
4
 
4
5
  /**
5
6
  * Structural alias of `SiteHandler` from `@statewalker/webrun-site-builder`.
@@ -29,12 +30,19 @@ export type SiteHandler = (request: Request) => Promise<Response>;
29
30
  */
30
31
  export class DuplexSiteBuilder {
31
32
  #handler?: SiteHandler;
33
+ #codec?: MessageCodec;
32
34
 
33
35
  setHandler(handler: SiteHandler): this {
34
36
  this.#handler = handler;
35
37
  return this;
36
38
  }
37
39
 
40
+ /** Pin the wire format. Defaults to HTTP/1.1 with legacy acceptance. */
41
+ setCodec(codec: MessageCodec): this {
42
+ this.#codec = codec;
43
+ return this;
44
+ }
45
+
38
46
  async start<P>(serve: Serve<P>, params: P): Promise<() => Promise<void>> {
39
47
  if (!this.#handler) {
40
48
  throw new Error("DuplexSiteBuilder.start: setHandler(handler) must be called before start()");
@@ -42,7 +50,7 @@ export class DuplexSiteBuilder {
42
50
  const handler = this.#handler;
43
51
  return serve(
44
52
  params,
45
- serveFetchOverDuplex(async (req) => handler(req)),
53
+ serveFetchOverDuplex(async (req) => handler(req), { codec: this.#codec }),
46
54
  );
47
55
  }
48
56
  }
package/src/envelope.ts CHANGED
@@ -1,16 +1,20 @@
1
- export type RequestEnvelope = {
2
- url: string;
3
- method: string;
4
- headers: [string, string][];
5
- };
1
+ import { concatChunks, toAsyncIterator } from "./bytes.js";
2
+ import { HttpParseError } from "./http1/errors.js";
3
+ import type {
4
+ ByteSource,
5
+ DecodedRequest,
6
+ DecodedResponse,
7
+ MessageCodec,
8
+ RequestEnvelope,
9
+ ResponseEnvelope,
10
+ } from "./message.js";
6
11
 
7
- export type ResponseEnvelope = {
8
- status: number;
9
- statusText: string;
10
- headers: [string, string][];
11
- };
12
+ export type { RequestEnvelope, ResponseEnvelope } from "./message.js";
12
13
 
13
14
  const NEWLINE = 0x0a;
15
+
16
+ /** Mirrors the HTTP/1.1 codec's default `maxHeaderBytes`. */
17
+ const MAX_ENVELOPE_BYTES = 65536;
14
18
  const encoder = new TextEncoder();
15
19
  const decoder = new TextDecoder();
16
20
 
@@ -51,7 +55,7 @@ export async function decodeMessage<E>(
51
55
  while (true) {
52
56
  const next = await iter.next();
53
57
  if (next.done) {
54
- throw new Error(
58
+ throw new HttpParseError(
55
59
  `decodeMessage: stream ended after ${accumLen} bytes without delimiter (\\n)`,
56
60
  );
57
61
  }
@@ -61,6 +65,15 @@ export async function decodeMessage<E>(
61
65
  if (nl === -1) {
62
66
  accum.push(chunk);
63
67
  accumLen += chunk.byteLength;
68
+ // The envelope is one line, so a peer that never sends the delimiter is
69
+ // sending garbage — without a bound this loop buffers it all and then
70
+ // concatenates a second copy. The HTTP/1.1 path refuses the same flood
71
+ // via maxHeaderBytes; this is its counterpart.
72
+ if (accumLen > MAX_ENVELOPE_BYTES) {
73
+ throw new HttpParseError(
74
+ `decodeMessage: envelope exceeds ${MAX_ENVELOPE_BYTES} bytes without delimiter (\\n)`,
75
+ );
76
+ }
64
77
  continue;
65
78
  }
66
79
  accum.push(chunk.subarray(0, nl));
@@ -74,7 +87,7 @@ export async function decodeMessage<E>(
74
87
  try {
75
88
  envelope = JSON.parse(decoder.decode(before)) as E;
76
89
  } catch (err) {
77
- throw new Error(
90
+ throw new HttpParseError(
78
91
  `decodeMessage: malformed envelope JSON at bytes 0..${before.byteLength}: ${(err as Error).message}`,
79
92
  );
80
93
  }
@@ -91,26 +104,22 @@ export async function decodeMessage<E>(
91
104
  return { envelope, body: body() };
92
105
  }
93
106
 
94
- function toAsyncIterator(
95
- input: AsyncIterable<Uint8Array> | Iterable<Uint8Array>,
96
- ): AsyncIterator<Uint8Array> {
97
- const asyncIter = (input as AsyncIterable<Uint8Array>)[Symbol.asyncIterator];
98
- if (asyncIter) return asyncIter.call(input as AsyncIterable<Uint8Array>);
99
- const syncIter = (input as Iterable<Uint8Array>)[Symbol.iterator]();
100
- return {
101
- next(): Promise<IteratorResult<Uint8Array>> {
102
- return Promise.resolve(syncIter.next());
103
- },
104
- };
105
- }
107
+ const OPEN_BRACE = 0x7b;
106
108
 
107
- function concatChunks(parts: Uint8Array[], totalLen: number): Uint8Array {
108
- if (parts.length === 1) return parts[0];
109
- const out = new Uint8Array(totalLen);
110
- let off = 0;
111
- for (const p of parts) {
112
- out.set(p, off);
113
- off += p.byteLength;
114
- }
115
- return out;
116
- }
109
+ /**
110
+ * The original wire format — `<JSON.stringify(envelope)>\n<body bytes…>` —
111
+ * expressed as a `MessageCodec`. Direction-agnostic: requests and responses
112
+ * serialise identically.
113
+ *
114
+ * Retained so a peer pair can be upgraded in either order; see ADR-0006.
115
+ */
116
+ export const jsonEnvelopeCodec: MessageCodec = {
117
+ name: "json-envelope",
118
+ sniff: (byte: number): boolean => byte === OPEN_BRACE,
119
+ encodeRequest: (env: RequestEnvelope, body?: ByteSource) => encodeMessage(env, body),
120
+ encodeResponse: (env: ResponseEnvelope, body?: ByteSource) => encodeMessage(env, body),
121
+ decodeRequest: (input: ByteSource): Promise<DecodedRequest> =>
122
+ decodeMessage<RequestEnvelope>(input),
123
+ decodeResponse: (input: ByteSource): Promise<DecodedResponse> =>
124
+ decodeMessage<ResponseEnvelope>(input),
125
+ };
package/src/fetch.ts CHANGED
@@ -1,6 +1,30 @@
1
1
  import type { Duplex } from "@statewalker/webrun-streams";
2
2
  import type { RequestEnvelope, ResponseEnvelope } from "./envelope.js";
3
- import { httpFetch, httpServe } from "./http-data.js";
3
+ import { type HttpDataOptions, httpFetch, httpServe } from "./http-data.js";
4
+ import { NULL_BODY_STATUSES } from "./http-stubs.js";
5
+ import type { ByteSource } from "./message.js";
6
+ import { collectBytes, supportsRequestStreams } from "./request-streams.js";
7
+
8
+ /**
9
+ * Connection-scoped headers. The codec surfaces them verbatim (decision 12),
10
+ * but they are meaningless to a `Request`/`Response`, and re-emitting them
11
+ * from a relay would corrupt its framing.
12
+ */
13
+ const HOP_BY_HOP = new Set([
14
+ "connection",
15
+ "host",
16
+ "keep-alive",
17
+ "proxy-authenticate",
18
+ "proxy-authorization",
19
+ "te",
20
+ "trailer",
21
+ "transfer-encoding",
22
+ "upgrade",
23
+ ]);
24
+
25
+ function forwardableHeaders(headers: [string, string][]): [string, string][] {
26
+ return headers.filter(([name]) => !HOP_BY_HOP.has(name.toLowerCase()));
27
+ }
4
28
 
5
29
  function headersToArray(headers: Headers): [string, string][] {
6
30
  const out: [string, string][] = [];
@@ -67,35 +91,117 @@ function asyncIterableToReadable(iter: AsyncIterable<Uint8Array>): ReadableStrea
67
91
  * the other side. The request's `signal` is plumbed into the body iteration —
68
92
  * abort terminates the underlying call.
69
93
  */
70
- export async function fetchOverDuplex(call: Duplex, request: Request): Promise<Response> {
94
+ export async function fetchOverDuplex(
95
+ call: Duplex,
96
+ request: Request,
97
+ options: HttpDataOptions = {},
98
+ ): Promise<Response> {
71
99
  if (request.signal?.aborted) throw abortReason(request.signal);
72
100
  const env: RequestEnvelope = {
73
101
  url: request.url,
74
102
  method: request.method,
75
103
  headers: headersToArray(request.headers),
76
104
  };
77
- const body = request.body ? readableToAsyncIterable(request.body) : undefined;
78
- const { envelope, body: respBody } = await httpFetch(call, env, body);
79
- return new Response(asyncIterableToReadable(withAbort(respBody, request.signal)), {
105
+ let body: ByteSource | undefined;
106
+ if (request.body != null) {
107
+ // The streaming path stays first and unconditional: a runtime that has
108
+ // request streams never reaches the fallback, and never buffers an upload.
109
+ body = readableToAsyncIterable(request.body);
110
+ } else if (!supportsRequestStreams()) {
111
+ // Firefox. Without `request.body` there is nothing to stream from, so the
112
+ // only way to see the payload at all is to buffer the whole of it. Two
113
+ // consequences, both real, neither worth rediscovering:
114
+ //
115
+ // 1. Request streaming is lost outright on this path. A 1 GiB upload from
116
+ // a Firefox page is a 1 GiB allocation here, where Chromium would have
117
+ // streamed it chunk by chunk.
118
+ // 2. Buffering cannot distinguish an absent body from an empty one.
119
+ // Chromium's `new Request(url, { method: "POST", body: "" })` yields a
120
+ // non-null empty stream, so an empty body envelope goes on the wire
121
+ // (with the HTTP/1.1 codec, a chunked body whose only chunk is the
122
+ // terminator); here a zero-length `arrayBuffer()` is indistinguishable
123
+ // from no body and we send none. Both decode to the same empty body at
124
+ // the far end — `kind: "none"` framing on a request means zero bytes,
125
+ // not read-to-EOF — so only the framing differs, but it does differ.
126
+ const buffered = new Uint8Array(await request.arrayBuffer());
127
+ if (buffered.byteLength > 0) body = [buffered];
128
+ }
129
+ const { envelope, body: respBody } = await httpFetch(call, env, body, options);
130
+ const responseInit: ResponseInit = {
80
131
  status: envelope.status,
81
132
  statusText: envelope.statusText,
82
- headers: envelope.headers,
83
- });
133
+ headers: forwardableHeaders(envelope.headers),
134
+ };
135
+ // `new Response(body, { status })` throws for the null-body-status set, and
136
+ // HEAD/OPTIONS carry no body either, so return a bodyless Response and try to
137
+ // let go of the body we were handed.
138
+ //
139
+ // KNOWN GAP — this `.return()` does not actually release the producer, and
140
+ // `discard` would not fix it either. Do not read it as a working drain.
141
+ //
142
+ // `respBody` is *not* the generator the codec pulled from. `decodeResponse`
143
+ // pulls from its `input` (the `output` generator returned by `call(...)`) to
144
+ // read the head, and hands back a freshly constructed one —
145
+ // `convertBodyErrors(readBody(...))` in `http1/decode.ts`. Nobody has pulled
146
+ // from that, so it is in suspended start and `.return()` on it is a no-op,
147
+ // exactly as in the two sites `http-stubs.ts` fixed. Measured: the producer's
148
+ // `finally` does not run for 204, 205, 304, HEAD+200 or OPTIONS+200.
149
+ //
150
+ // `discard` does not help because the release has to reach further down.
151
+ // `ByteReader` never propagates `.return()` to its own `#iter`, so only
152
+ // `await output.return()` frees the underlying call — and `fetchOverDuplex`
153
+ // has no handle on `output`, since `httpFetch` returns only `{envelope,
154
+ // body}`. Closing this needs `httpFetch` to expose the call for cancellation;
155
+ // that is a design change, not a patch here.
156
+ //
157
+ // Worth knowing which shapes leak a *populated* body rather than an empty
158
+ // one: `decodeResponse`'s own bodyless test covers status < 200, 204, 304 and
159
+ // HEAD, so for 205 and for OPTIONS the decoder frames a real body that is
160
+ // then abandoned. `http-data.ts` (see `throwIfPeerError`) spells out the cost
161
+ // on a mux transport — one stream-table slot per call, until `maxStreams`.
162
+ if (
163
+ request.method === "HEAD" ||
164
+ request.method === "OPTIONS" ||
165
+ NULL_BODY_STATUSES.has(envelope.status)
166
+ ) {
167
+ const returnable = respBody as AsyncIterable<Uint8Array> & { return?: () => unknown };
168
+ await returnable.return?.();
169
+ return new Response(null, responseInit);
170
+ }
171
+ return new Response(asyncIterableToReadable(withAbort(respBody, request.signal)), responseInit);
84
172
  }
85
173
 
86
174
  /**
87
175
  * Wrap a `(Request) => Promise<Response>` handler as a `Duplex` so it can be
88
176
  * registered with any `webrun-streams-*` adapter's `serve`.
89
177
  */
90
- export function serveFetchOverDuplex(handler: (request: Request) => Promise<Response>): Duplex {
178
+ export function serveFetchOverDuplex(
179
+ handler: (request: Request) => Promise<Response>,
180
+ options: HttpDataOptions = {},
181
+ ): Duplex {
91
182
  return httpServe(async (env, body) => {
92
183
  const reqInit: RequestInit = {
93
184
  method: env.method,
94
- headers: env.headers,
185
+ headers: forwardableHeaders(env.headers),
95
186
  };
96
187
  if (env.method !== "GET" && env.method !== "HEAD") {
97
- reqInit.body = asyncIterableToReadable(body);
98
- (reqInit as RequestInit & { duplex?: string }).duplex = "half";
188
+ if (supportsRequestStreams()) {
189
+ reqInit.body = asyncIterableToReadable(body);
190
+ (reqInit as RequestInit & { duplex?: string }).duplex = "half";
191
+ } else {
192
+ // Firefox again, and this is the half that fails *silently*. The
193
+ // constructor does not reject a `ReadableStream` here, it stringifies
194
+ // it: the handler would read the literal text `[object ReadableStream]`
195
+ // and answer 200 with corrupt data, where the outbound direction at
196
+ // least fails loudly with a 500. Bytes it does accept, so drain first.
197
+ //
198
+ // Same cost as the outbound fallback: the whole upload is held in
199
+ // memory, and a handler that wanted to stream its request body cannot.
200
+ // Unlike outbound, an empty body is still passed through — the
201
+ // streaming branch above always sets `body` for these methods, and
202
+ // passing a zero-length `Uint8Array` keeps that true.
203
+ reqInit.body = await collectBytes(body);
204
+ }
99
205
  }
100
206
  const request = new Request(env.url, reqInit);
101
207
  const response = await handler(request);
@@ -104,11 +210,23 @@ export function serveFetchOverDuplex(handler: (request: Request) => Promise<Resp
104
210
  statusText: response.statusText,
105
211
  headers: headersToArray(response.headers),
106
212
  };
213
+ // Mirror the client direction: put no bytes on the wire for a
214
+ // null-body-status response or a HEAD/OPTIONS request, even if the
215
+ // handler's Response carries a body stream. Cancel it so the producer
216
+ // isn't left hanging.
217
+ if (
218
+ env.method === "HEAD" ||
219
+ env.method === "OPTIONS" ||
220
+ NULL_BODY_STATUSES.has(response.status)
221
+ ) {
222
+ await response.body?.cancel();
223
+ return { envelope: respEnv, body: undefined };
224
+ }
107
225
  return {
108
226
  envelope: respEnv,
109
227
  body: response.body ? readableToAsyncIterable(response.body) : undefined,
110
228
  };
111
- });
229
+ }, options);
112
230
  }
113
231
 
114
232
  async function* withAbort(