@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,40 @@
|
|
|
1
|
+
import type { MessageCodec } from "../message.js";
|
|
2
|
+
import { decodeRequest, decodeResponse } from "./decode.js";
|
|
3
|
+
import { encodeRequest, encodeResponse, type ResolvedHttpCodecOptions } from "./encode.js";
|
|
4
|
+
import { isTokenChar } from "./headers.js";
|
|
5
|
+
|
|
6
|
+
export type HttpCodecOptions = {
|
|
7
|
+
/**
|
|
8
|
+
* Scheme used to rebuild an absolute url on decode. HTTP/1.1 origin-form
|
|
9
|
+
* carries no scheme, so this is configuration, not wire data — a peer
|
|
10
|
+
* configured `http` rebuilds an `https` url as `http`. Accepted cost of
|
|
11
|
+
* decision 6; see ADR-0006.
|
|
12
|
+
*/
|
|
13
|
+
scheme?: "http" | "https";
|
|
14
|
+
/** Authority used when a url carries none. Also fills the mandatory Host header. */
|
|
15
|
+
host?: string;
|
|
16
|
+
/** Bound on the whole head section, start line included. Default 65536. */
|
|
17
|
+
maxHeaderBytes?: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function newHttpCodec(options: HttpCodecOptions = {}): MessageCodec {
|
|
21
|
+
const opts: ResolvedHttpCodecOptions = {
|
|
22
|
+
scheme: options.scheme ?? "http",
|
|
23
|
+
host: options.host ?? "localhost",
|
|
24
|
+
maxHeaderBytes: options.maxHeaderBytes ?? 65536,
|
|
25
|
+
};
|
|
26
|
+
return {
|
|
27
|
+
name: "http/1.1",
|
|
28
|
+
// A request starts with a method token, a response with "HTTP/1.1" — both
|
|
29
|
+
// begin with a token character. "{" is not a token character, so this is
|
|
30
|
+
// disjoint from jsonEnvelopeCodec by construction, not by convention.
|
|
31
|
+
sniff: (byte: number): boolean => isTokenChar(byte),
|
|
32
|
+
encodeRequest: (env, body) => encodeRequest(env, body, opts),
|
|
33
|
+
encodeResponse: (env, body, o) => encodeResponse(env, body, opts, o?.method),
|
|
34
|
+
decodeRequest: (input) => decodeRequest(input, opts),
|
|
35
|
+
decodeResponse: (input, o) => decodeResponse(input, opts, o.method),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export const httpCodec: MessageCodec = newHttpCodec();
|
|
40
|
+
export { HttpParseError } from "./errors.js";
|
package/src/index.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
export { defaultCodec } from "./codec-default.js";
|
|
1
2
|
export { DuplexSiteBuilder, type SiteHandler } from "./duplex-site-builder.js";
|
|
2
3
|
export {
|
|
3
4
|
decodeMessage,
|
|
4
5
|
encodeMessage,
|
|
6
|
+
jsonEnvelopeCodec,
|
|
5
7
|
type RequestEnvelope,
|
|
6
8
|
type ResponseEnvelope,
|
|
7
9
|
} from "./envelope.js";
|
|
@@ -9,9 +11,11 @@ export { fetchOverDuplex, serveFetchOverDuplex } from "./fetch.js";
|
|
|
9
11
|
export {
|
|
10
12
|
type HttpDataHandler,
|
|
11
13
|
type HttpDataHandlerResult,
|
|
14
|
+
type HttpDataOptions,
|
|
12
15
|
type HttpFetchResult,
|
|
13
16
|
httpFetch,
|
|
14
17
|
httpServe,
|
|
18
|
+
PEER_ERROR_HEADER,
|
|
15
19
|
} from "./http-data.js";
|
|
16
20
|
export { HttpError, type HttpErrorOptions } from "./http-error.js";
|
|
17
21
|
export {
|
|
@@ -22,3 +26,17 @@ export {
|
|
|
22
26
|
type SerializedHttpRequest,
|
|
23
27
|
type SerializedHttpResponse,
|
|
24
28
|
} from "./http-stubs.js";
|
|
29
|
+
export {
|
|
30
|
+
type HttpCodecOptions,
|
|
31
|
+
HttpParseError,
|
|
32
|
+
httpCodec,
|
|
33
|
+
newHttpCodec,
|
|
34
|
+
} from "./http1/index.js";
|
|
35
|
+
export type {
|
|
36
|
+
ByteSource,
|
|
37
|
+
DecodedRequest,
|
|
38
|
+
DecodedResponse,
|
|
39
|
+
MessageCodec,
|
|
40
|
+
ResponseCodecOptions,
|
|
41
|
+
} from "./message.js";
|
|
42
|
+
export { newSniffingCodec, type SniffingCodecOptions } from "./sniff.js";
|
package/src/message.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** Anything the codecs will read bytes from. */
|
|
2
|
+
export type ByteSource = AsyncIterable<Uint8Array> | Iterable<Uint8Array>;
|
|
3
|
+
|
|
4
|
+
export type RequestEnvelope = {
|
|
5
|
+
url: string;
|
|
6
|
+
method: string;
|
|
7
|
+
headers: [string, string][];
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export type ResponseEnvelope = {
|
|
11
|
+
status: number;
|
|
12
|
+
statusText: string;
|
|
13
|
+
headers: [string, string][];
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type DecodedRequest = {
|
|
17
|
+
envelope: RequestEnvelope;
|
|
18
|
+
body: AsyncIterable<Uint8Array>;
|
|
19
|
+
/** The codec that actually read this message; set by the sniffing codec. */
|
|
20
|
+
codec?: MessageCodec;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type DecodedResponse = {
|
|
24
|
+
envelope: ResponseEnvelope;
|
|
25
|
+
body: AsyncIterable<Uint8Array>;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Extra context a codec may need that the envelope does not carry. */
|
|
29
|
+
export type ResponseCodecOptions = {
|
|
30
|
+
/**
|
|
31
|
+
* Method of the request this response answers. Required by HTTP/1.1: a
|
|
32
|
+
* response to HEAD carries framing headers but no body, and no parser can
|
|
33
|
+
* know that from the response bytes alone.
|
|
34
|
+
*/
|
|
35
|
+
method: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* One wire format. Requests and responses are separate operations because
|
|
40
|
+
* HTTP/1.1 serialises them differently — a codec cannot be generic over the
|
|
41
|
+
* envelope the way the JSON format was.
|
|
42
|
+
*/
|
|
43
|
+
export interface MessageCodec {
|
|
44
|
+
readonly name: string;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* True if a message in this format may begin with `byte`. Used by the
|
|
48
|
+
* sniffing codec to dispatch without a negotiation handshake. Implementations
|
|
49
|
+
* must be mutually exclusive with every other codec they are paired with.
|
|
50
|
+
*/
|
|
51
|
+
sniff(byte: number): boolean;
|
|
52
|
+
|
|
53
|
+
encodeRequest(env: RequestEnvelope, body?: ByteSource): AsyncGenerator<Uint8Array>;
|
|
54
|
+
encodeResponse(
|
|
55
|
+
env: ResponseEnvelope,
|
|
56
|
+
body?: ByteSource,
|
|
57
|
+
options?: ResponseCodecOptions,
|
|
58
|
+
): AsyncGenerator<Uint8Array>;
|
|
59
|
+
|
|
60
|
+
decodeRequest(input: ByteSource): Promise<DecodedRequest>;
|
|
61
|
+
decodeResponse(input: ByteSource, options: ResponseCodecOptions): Promise<DecodedResponse>;
|
|
62
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
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
|
+
/**
|
|
9
|
+
* Whether `Request.prototype` exposes a `body` accessor. Two call sites read
|
|
10
|
+
* that property, and two more depend on the constructor accepting a
|
|
11
|
+
* `ReadableStream` as `init.body`; this predicate answers for all four.
|
|
12
|
+
*
|
|
13
|
+
* What was verified, and all that is claimed here: Firefox (146 at the time of
|
|
14
|
+
* writing) has *neither*, and Chromium and Node have *both*. On Firefox it is
|
|
15
|
+
* not that `body` is `undefined` on the instance —
|
|
16
|
+
* `Object.getOwnPropertyDescriptor(Request.prototype, "body")` is `null`, the
|
|
17
|
+
* accessor is genuinely absent — and because a `ReadableStream` is then not a
|
|
18
|
+
* recognised `BodyInit`, the constructor falls through to the string branch and
|
|
19
|
+
* stores the literal text `[object ReadableStream]`.
|
|
20
|
+
*
|
|
21
|
+
* The two halves are NOT guaranteed to ship together, so do not read this as a
|
|
22
|
+
* test for "request streams" in general. Safari is the counterexample: it has
|
|
23
|
+
* had `Request.body` since 11.1 but only accepts a stream as `init.body` from
|
|
24
|
+
* Technology Preview 250, so a shipping Safari has the reader half without the
|
|
25
|
+
* upload half and this returns `true` there. That looks benign — WebKit appears
|
|
26
|
+
* to store the stream on the `Request` rather than stringify it, and its error
|
|
27
|
+
* comes from `fetch()`, which this package never calls on the objects it builds
|
|
28
|
+
* — but it is untested, and it is the case to look at first if a Safari report
|
|
29
|
+
* arrives. The sharper probe, if one is ever needed, is whether
|
|
30
|
+
* `new Request(url, {method:"POST", body:new ReadableStream(), duplex:"half"})`
|
|
31
|
+
* has a `content-type` of `text/plain;charset=UTF-8` (stringified) or `null`
|
|
32
|
+
* (stored); it is not used here because it costs a `Request` and a
|
|
33
|
+
* `ReadableStream` per call and agrees with the descriptor check on every
|
|
34
|
+
* runtime measured.
|
|
35
|
+
*
|
|
36
|
+
* A capability check, never a user-agent test: the question is what this
|
|
37
|
+
* runtime does, and the answer flips on its own the day Firefox ships request
|
|
38
|
+
* streams. Evaluated per call rather than cached at module load so that a test
|
|
39
|
+
* can install a `Request` without the capability and exercise the real branch
|
|
40
|
+
* under Node.
|
|
41
|
+
*/
|
|
42
|
+
export function supportsRequestStreams(): boolean {
|
|
43
|
+
return (
|
|
44
|
+
typeof Request === "function" &&
|
|
45
|
+
Object.getOwnPropertyDescriptor(Request.prototype, "body") != null
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Drain a body iterable into one contiguous buffer. Only the fallbacks need it.
|
|
51
|
+
* Allocates its own buffer rather than reusing `bytes.ts`'s `concatChunks`,
|
|
52
|
+
* which passes a single chunk straight through: that chunk is a view onto the
|
|
53
|
+
* decoder's own read buffer, and the result here is handed to `new Request` and
|
|
54
|
+
* outlives the decode. The allocation is also what makes it a
|
|
55
|
+
* `Uint8Array<ArrayBuffer>`, which `BodyInit` accepts and the looser
|
|
56
|
+
* `Uint8Array<ArrayBufferLike>` does not.
|
|
57
|
+
*/
|
|
58
|
+
export async function collectBytes(
|
|
59
|
+
source: AsyncIterable<Uint8Array>,
|
|
60
|
+
): Promise<Uint8Array<ArrayBuffer>> {
|
|
61
|
+
const parts: Uint8Array[] = [];
|
|
62
|
+
let total = 0;
|
|
63
|
+
for await (const chunk of source) {
|
|
64
|
+
if (chunk.byteLength === 0) continue;
|
|
65
|
+
parts.push(chunk);
|
|
66
|
+
total += chunk.byteLength;
|
|
67
|
+
}
|
|
68
|
+
const out = new Uint8Array(total);
|
|
69
|
+
let offset = 0;
|
|
70
|
+
for (const part of parts) {
|
|
71
|
+
out.set(part, offset);
|
|
72
|
+
offset += part.byteLength;
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
package/src/sniff.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { ByteReader } from "./bytes.js";
|
|
2
|
+
import { HttpParseError } from "./http1/errors.js";
|
|
3
|
+
import type { ByteSource, MessageCodec } from "./message.js";
|
|
4
|
+
|
|
5
|
+
export type SniffingCodecOptions = {
|
|
6
|
+
/** Codec used for everything this peer writes. */
|
|
7
|
+
write: MessageCodec;
|
|
8
|
+
/** Codecs this peer accepts on read, tried in order. */
|
|
9
|
+
accept: MessageCodec[];
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Dispatches on byte 0. The formats are self-identifying — a JSON envelope
|
|
14
|
+
* always begins `{`, which is not a token character and so can never begin an
|
|
15
|
+
* HTTP start-line — so no negotiation handshake, magic prefix, or version byte
|
|
16
|
+
* is needed.
|
|
17
|
+
*
|
|
18
|
+
* This is what makes a mixed-version peer pair safe: readers accept either
|
|
19
|
+
* format, so the two ends can be upgraded in any order.
|
|
20
|
+
*/
|
|
21
|
+
export function newSniffingCodec(options: SniffingCodecOptions): MessageCodec {
|
|
22
|
+
const { write, accept } = options;
|
|
23
|
+
|
|
24
|
+
async function pick(input: ByteSource): Promise<{ codec: MessageCodec; input: ByteSource }> {
|
|
25
|
+
const reader = new ByteReader(input);
|
|
26
|
+
const first = await reader.peekByte();
|
|
27
|
+
if (first === undefined) {
|
|
28
|
+
throw new HttpParseError("sniff: stream ended before any bytes arrived");
|
|
29
|
+
}
|
|
30
|
+
const codec = accept.find((c) => c.sniff(first));
|
|
31
|
+
if (!codec) {
|
|
32
|
+
throw new HttpParseError(
|
|
33
|
+
`sniff: no accepted codec recognises a message starting with byte 0x${first
|
|
34
|
+
.toString(16)
|
|
35
|
+
.padStart(2, "0")}`,
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
return { codec, input: reader.rest() };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
name: `sniff(write=${write.name}; accept=${accept.map((c) => c.name).join(",")})`,
|
|
43
|
+
sniff: (byte) => accept.some((c) => c.sniff(byte)),
|
|
44
|
+
encodeRequest: (env, body) => write.encodeRequest(env, body),
|
|
45
|
+
encodeResponse: (env, body, o) => write.encodeResponse(env, body, o),
|
|
46
|
+
decodeRequest: async (input) => {
|
|
47
|
+
const picked = await pick(input);
|
|
48
|
+
try {
|
|
49
|
+
const decoded = await picked.codec.decodeRequest(picked.input);
|
|
50
|
+
return { ...decoded, codec: picked.codec };
|
|
51
|
+
} catch (error) {
|
|
52
|
+
// Sniffing already told us which format the peer is speaking. Carry
|
|
53
|
+
// that on the error so a refusal can be answered in the peer's own
|
|
54
|
+
// format — otherwise a peer pinned to the legacy envelope receives its
|
|
55
|
+
// error response as HTTP/1.1, the same mismatch reply-in-kind avoids
|
|
56
|
+
// on the success path.
|
|
57
|
+
if (error !== null && typeof error === "object") {
|
|
58
|
+
(error as { codec?: MessageCodec }).codec = picked.codec;
|
|
59
|
+
}
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
decodeResponse: async (input, o) => {
|
|
64
|
+
const picked = await pick(input);
|
|
65
|
+
return picked.codec.decodeResponse(picked.input, o);
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
package/LICENSE
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2022-2026 statewalker
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|