@statewalker/webrun-http-streams 0.1.0 → 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.
- package/README.md +288 -11
- package/dist/bytes.d.ts +43 -0
- package/dist/bytes.d.ts.map +1 -0
- package/dist/codec-default.d.ts +7 -0
- package/dist/codec-default.d.ts.map +1 -0
- package/dist/duplex-site-builder.d.ts +3 -0
- package/dist/duplex-site-builder.d.ts.map +1 -1
- package/dist/envelope.d.ts +10 -10
- package/dist/envelope.d.ts.map +1 -1
- package/dist/fetch.d.ts +3 -2
- package/dist/fetch.d.ts.map +1 -1
- package/dist/http-data.d.ts +11 -7
- package/dist/http-data.d.ts.map +1 -1
- package/dist/http-error.d.ts.map +1 -1
- package/dist/http-stubs.d.ts +12 -0
- package/dist/http-stubs.d.ts.map +1 -1
- package/dist/http1/chunked.d.ts +18 -0
- package/dist/http1/chunked.d.ts.map +1 -0
- package/dist/http1/decode.d.ts +5 -0
- package/dist/http1/decode.d.ts.map +1 -0
- package/dist/http1/encode.d.ts +20 -0
- package/dist/http1/encode.d.ts.map +1 -0
- package/dist/http1/errors.d.ts +9 -0
- package/dist/http1/errors.d.ts.map +1 -0
- package/dist/http1/headers.d.ts +78 -0
- package/dist/http1/headers.d.ts.map +1 -0
- package/dist/http1/index.d.ts +18 -0
- package/dist/http1/index.d.ts.map +1 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1039 -137
- package/dist/message.d.ts +50 -0
- package/dist/message.d.ts.map +1 -0
- package/dist/request-streams.d.ts +52 -0
- package/dist/request-streams.d.ts.map +1 -0
- package/dist/sniff.d.ts +18 -0
- package/dist/sniff.d.ts.map +1 -0
- package/package.json +8 -6
- package/src/bytes.ts +157 -0
- package/src/codec-default.ts +13 -0
- package/src/duplex-site-builder.ts +9 -1
- package/src/envelope.ts +43 -34
- package/src/fetch.ts +130 -12
- package/src/http-data.ts +160 -17
- package/src/http-stubs.ts +72 -18
- package/src/http1/chunked.ts +89 -0
- package/src/http1/decode.ts +208 -0
- package/src/http1/encode.ts +181 -0
- package/src/http1/errors.ts +8 -0
- package/src/http1/headers.ts +263 -0
- package/src/http1/index.ts +40 -0
- package/src/index.ts +18 -0
- package/src/message.ts +62 -0
- package/src/request-streams.ts +75 -0
- 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"}
|
package/dist/sniff.d.ts
ADDED
|
@@ -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
|
|
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.
|
|
25
|
+
"@statewalker/webrun-streams": "0.1.1"
|
|
26
26
|
},
|
|
27
27
|
"devDependencies": {
|
|
28
|
-
"@types/node": "^
|
|
28
|
+
"@types/node": "^26.2.0",
|
|
29
29
|
"rimraf": "^6.1.3",
|
|
30
|
-
"rolldown": "^1.
|
|
31
|
-
"typescript": "^
|
|
32
|
-
"vitest": "^4.1.
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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(
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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(
|
|
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
|
-
|
|
98
|
-
|
|
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(
|