@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.
- package/README.md +361 -13
- 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 +4 -1
- 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 +1040 -138
- 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 +13 -7
- package/src/bytes.ts +157 -0
- package/src/codec-default.ts +13 -0
- package/src/duplex-site-builder.ts +10 -2
- 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
- package/LICENSE +0 -21
|
@@ -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.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "HTTP request/response over Duplex — merge of webrun-http + webrun-http-port",
|
|
@@ -15,29 +15,35 @@
|
|
|
15
15
|
"url": "git@github.com:statewalker/webrun-wire.git"
|
|
16
16
|
},
|
|
17
17
|
"exports": {
|
|
18
|
-
".":
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"import": "./dist/index.js"
|
|
21
|
+
}
|
|
19
22
|
},
|
|
20
23
|
"files": [
|
|
21
24
|
"dist",
|
|
22
25
|
"src"
|
|
23
26
|
],
|
|
24
27
|
"dependencies": {
|
|
25
|
-
"@statewalker/webrun-streams": "0.
|
|
28
|
+
"@statewalker/webrun-streams": "0.2.0"
|
|
26
29
|
},
|
|
27
30
|
"devDependencies": {
|
|
28
|
-
"@types/node": "^
|
|
31
|
+
"@types/node": "^26.2.0",
|
|
29
32
|
"rimraf": "^6.1.3",
|
|
30
|
-
"rolldown": "^1.
|
|
31
|
-
"typescript": "^
|
|
32
|
-
"vitest": "^4.1.
|
|
33
|
+
"rolldown": "^1.2.4",
|
|
34
|
+
"typescript": "^7.0.2",
|
|
35
|
+
"vitest": "^4.1.10"
|
|
33
36
|
},
|
|
34
37
|
"sideEffects": false,
|
|
35
38
|
"publishConfig": {
|
|
36
39
|
"access": "public"
|
|
37
40
|
},
|
|
41
|
+
"types": "./dist/index.d.ts",
|
|
38
42
|
"scripts": {
|
|
39
43
|
"build": "rimraf dist && rolldown -c && tsc --emitDeclarationOnly --declaration",
|
|
40
44
|
"test": "vitest run",
|
|
45
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
46
|
+
"typecheck:tests": "tsc -p tsconfig.tests.json",
|
|
41
47
|
"lint": "biome check src tests"
|
|
42
48
|
}
|
|
43
49
|
}
|
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`.
|
|
@@ -14,7 +15,7 @@ export type SiteHandler = (request: Request) => Promise<Response>;
|
|
|
14
15
|
* ```ts
|
|
15
16
|
* import { SiteBuilder } from "@statewalker/webrun-site-builder";
|
|
16
17
|
* import { DuplexSiteBuilder } from "@statewalker/webrun-http-streams";
|
|
17
|
-
* import { serve } from "@statewalker/webrun-
|
|
18
|
+
* import { serve } from "@statewalker/webrun-rpc";
|
|
18
19
|
*
|
|
19
20
|
* const handler = new SiteBuilder()
|
|
20
21
|
* .setEndpoint("/api/time", () => new Response(new Date().toISOString()))
|
|
@@ -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
|
+
};
|