@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
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(
|
package/src/http-data.ts
CHANGED
|
@@ -1,10 +1,23 @@
|
|
|
1
1
|
import type { Duplex } from "@statewalker/webrun-streams";
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
2
|
+
import { deserializeError, serializeError } from "@statewalker/webrun-streams";
|
|
3
|
+
import { discard } from "./bytes.js";
|
|
4
|
+
import { defaultCodec } from "./codec-default.js";
|
|
5
|
+
import { HttpParseError } from "./http1/errors.js";
|
|
6
|
+
import type {
|
|
7
|
+
ByteSource,
|
|
8
|
+
DecodedRequest,
|
|
9
|
+
MessageCodec,
|
|
10
|
+
RequestEnvelope,
|
|
11
|
+
ResponseEnvelope,
|
|
12
|
+
} from "./message.js";
|
|
13
|
+
|
|
14
|
+
/** Carries the serialized JS error between two webrun peers. */
|
|
15
|
+
export const PEER_ERROR_HEADER = "x-webrun-error";
|
|
16
|
+
|
|
17
|
+
export type HttpDataOptions = {
|
|
18
|
+
/** Wire format. Defaults to `defaultCodec`: writes HTTP/1.1, accepts either. */
|
|
19
|
+
codec?: MessageCodec;
|
|
20
|
+
};
|
|
8
21
|
|
|
9
22
|
export interface HttpFetchResult {
|
|
10
23
|
envelope: ResponseEnvelope;
|
|
@@ -13,7 +26,7 @@ export interface HttpFetchResult {
|
|
|
13
26
|
|
|
14
27
|
export interface HttpDataHandlerResult {
|
|
15
28
|
envelope: ResponseEnvelope;
|
|
16
|
-
body?:
|
|
29
|
+
body?: ByteSource;
|
|
17
30
|
}
|
|
18
31
|
|
|
19
32
|
/**
|
|
@@ -29,6 +42,100 @@ export type HttpDataHandler = (
|
|
|
29
42
|
body: AsyncIterable<Uint8Array>,
|
|
30
43
|
) => Promise<HttpDataHandlerResult>;
|
|
31
44
|
|
|
45
|
+
/** Hard cap on the peer-error header (M3): see `encodeErrorResponse`. */
|
|
46
|
+
const MAX_DETAIL_CHARS = 4096;
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* JSON with every non-printable-ASCII character escaped, so the result is a
|
|
50
|
+
* legal latin-1 header value whatever the error message contained.
|
|
51
|
+
*/
|
|
52
|
+
function asciiJson(value: unknown): string {
|
|
53
|
+
return JSON.stringify(value).replace(
|
|
54
|
+
/[^\x20-\x7E]/g,
|
|
55
|
+
(ch) => `\\u${ch.charCodeAt(0).toString(16).padStart(4, "0")}`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Decision 13: a real HTTP peer cannot receive a JavaScript exception, only a
|
|
61
|
+
* response. So an uncaught handler error becomes a conforming 500 whose body
|
|
62
|
+
* carries the message, with the serialized error in a namespaced header that
|
|
63
|
+
* another webrun peer re-throws from.
|
|
64
|
+
*/
|
|
65
|
+
function encodeErrorResponse(
|
|
66
|
+
codec: MessageCodec,
|
|
67
|
+
error: unknown,
|
|
68
|
+
method: string,
|
|
69
|
+
status = 500,
|
|
70
|
+
statusText = "Internal Server Error",
|
|
71
|
+
): AsyncGenerator<Uint8Array> {
|
|
72
|
+
const serialized = serializeError(error);
|
|
73
|
+
let detail = asciiJson(serialized);
|
|
74
|
+
if (detail.length > MAX_DETAIL_CHARS) detail = asciiJson({ message: serialized.message });
|
|
75
|
+
// The fallback above is itself unbounded — a >64 KiB handler message would
|
|
76
|
+
// still blow the head past maxHeaderBytes, and the client would then
|
|
77
|
+
// report "head section exceeds 65536 bytes" instead of the peer's actual
|
|
78
|
+
// error (M3). Truncate unconditionally as the last resort.
|
|
79
|
+
if (detail.length > MAX_DETAIL_CHARS) detail = detail.slice(0, MAX_DETAIL_CHARS);
|
|
80
|
+
const message = serialized.message ?? statusText;
|
|
81
|
+
return codec.encodeResponse(
|
|
82
|
+
{
|
|
83
|
+
status,
|
|
84
|
+
statusText,
|
|
85
|
+
headers: [
|
|
86
|
+
["Content-Type", "text/plain; charset=utf-8"],
|
|
87
|
+
[PEER_ERROR_HEADER, detail],
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
[new TextEncoder().encode(message)],
|
|
91
|
+
{ method },
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* `output` is the `Duplex` call's own generator — one logical call, per the
|
|
97
|
+
* `Duplex` contract in `@statewalker/webrun-streams`: "Consumer `.return()`
|
|
98
|
+
* on the output → producer's `finally` runs." On a mux transport
|
|
99
|
+
* (`emulateMux`), that `finally` is what frees the stream-table slot; skip it
|
|
100
|
+
* and every peer-error response leaks one slot, unboundedly, until the mux
|
|
101
|
+
* itself is exhausted (`maxStreams` reached, every further call rejected).
|
|
102
|
+
*
|
|
103
|
+
* Cancelling `output` here — not inside the codec's own decode logic — is
|
|
104
|
+
* deliberate. `codec.decodeResponse` already pulled from `output` to read the
|
|
105
|
+
* head before this function runs, so unlike the body case above there's no
|
|
106
|
+
* suspended-start no-op to worry about. And it's safe specifically *because*
|
|
107
|
+
* this is the client's own response-only generator: the request was already
|
|
108
|
+
* fully sent before we got here, so there is nothing left to write on it.
|
|
109
|
+
* The equivalent is NOT safe inside `src/http1/decode.ts`'s `ByteReader` —
|
|
110
|
+
* that code also runs when a caller wires `codec.decodeRequest`/
|
|
111
|
+
* `decodeResponse` directly onto a raw bidirectional socket (see
|
|
112
|
+
* `tests/http1-node-interop.test.ts`), where the *same* object is read from
|
|
113
|
+
* and then written back to (a server reads the request, then replies on the
|
|
114
|
+
* same socket); cancelling the read side there tears down the whole
|
|
115
|
+
* connection out from under the pending write. That was tried and reverted —
|
|
116
|
+
* see the Task 11 report for the "socket hang up" failure it caused.
|
|
117
|
+
*/
|
|
118
|
+
async function throwIfPeerError(
|
|
119
|
+
result: HttpFetchResult,
|
|
120
|
+
output: AsyncGenerator<Uint8Array>,
|
|
121
|
+
): Promise<void> {
|
|
122
|
+
const found = result.envelope.headers.find(([k]) => k.toLowerCase() === PEER_ERROR_HEADER);
|
|
123
|
+
if (!found) return;
|
|
124
|
+
await discard(result.body);
|
|
125
|
+
try {
|
|
126
|
+
await output.return?.(undefined);
|
|
127
|
+
} catch {
|
|
128
|
+
/* the call is being abandoned; a failing cancel must not mask the peer's error */
|
|
129
|
+
}
|
|
130
|
+
let payload: { message: string };
|
|
131
|
+
try {
|
|
132
|
+
payload = JSON.parse(found[1]) as { message: string };
|
|
133
|
+
} catch {
|
|
134
|
+
payload = { message: found[1] };
|
|
135
|
+
}
|
|
136
|
+
throw deserializeError(payload);
|
|
137
|
+
}
|
|
138
|
+
|
|
32
139
|
/**
|
|
33
140
|
* Initiate an HTTP call over a `Duplex`. The caller's `call: Duplex` is
|
|
34
141
|
* obtained from any `webrun-streams-*` adapter's `connect`. Returns the
|
|
@@ -40,22 +147,58 @@ export type HttpDataHandler = (
|
|
|
40
147
|
export async function httpFetch(
|
|
41
148
|
call: Duplex,
|
|
42
149
|
env: RequestEnvelope,
|
|
43
|
-
body?:
|
|
150
|
+
body?: ByteSource,
|
|
151
|
+
options: HttpDataOptions = {},
|
|
44
152
|
): Promise<HttpFetchResult> {
|
|
45
|
-
const
|
|
46
|
-
|
|
153
|
+
const codec = options.codec ?? defaultCodec;
|
|
154
|
+
const output = call(codec.encodeRequest(env, body));
|
|
155
|
+
const result = await codec.decodeResponse(output, { method: env.method });
|
|
156
|
+
await throwIfPeerError(result, output);
|
|
157
|
+
return result;
|
|
47
158
|
}
|
|
48
159
|
|
|
49
160
|
/**
|
|
50
161
|
* Wrap an HTTP handler as a `Duplex` so it can be registered with any
|
|
51
|
-
* `webrun-streams-*` adapter's `serve`.
|
|
52
|
-
* recover envelope + body, dispatches to the handler, and emits the response
|
|
53
|
-
* via `encodeMessage`.
|
|
162
|
+
* `webrun-streams-*` adapter's `serve`.
|
|
54
163
|
*/
|
|
55
|
-
export function httpServe(handler: HttpDataHandler): Duplex {
|
|
164
|
+
export function httpServe(handler: HttpDataHandler, options: HttpDataOptions = {}): Duplex {
|
|
165
|
+
const codec = options.codec ?? defaultCodec;
|
|
56
166
|
return async function* httpHandlerDuplex(input) {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
167
|
+
// A refusal to parse the request must still produce a response. Left
|
|
168
|
+
// uncaught, the exception propagates out of this generator and NOTHING is
|
|
169
|
+
// written — a real peer waits, then sees the connection end with no status
|
|
170
|
+
// at all, which is the one place the "a real peer cannot receive a
|
|
171
|
+
// JavaScript exception" rule was not applied.
|
|
172
|
+
//
|
|
173
|
+
// Only HttpParseError is converted: that is the codec's refusal type, so
|
|
174
|
+
// it means the bytes were malformed. A transport failure is not our 400 to
|
|
175
|
+
// send and must keep propagating.
|
|
176
|
+
let decoded: DecodedRequest;
|
|
177
|
+
try {
|
|
178
|
+
decoded = await codec.decodeRequest(input);
|
|
179
|
+
} catch (error) {
|
|
180
|
+
if (!(error instanceof HttpParseError)) throw error;
|
|
181
|
+
// Answer in the format the peer was speaking, when sniffing got far
|
|
182
|
+
// enough to know it; otherwise fall back to our write codec.
|
|
183
|
+
const refusalCodec = (error as { codec?: MessageCodec }).codec ?? codec;
|
|
184
|
+
// The method is unknowable — the request line may be what failed — so
|
|
185
|
+
// encode as if for GET, which permits a body and so lets the peer read
|
|
186
|
+
// why it was refused.
|
|
187
|
+
yield* encodeErrorResponse(refusalCodec, error, "GET", 400, "Bad Request");
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
// Reply in kind: answer with the codec that actually read the request, so a
|
|
191
|
+
// peer pinned to one format is never answered in the other.
|
|
192
|
+
const replyCodec = decoded.codec ?? codec;
|
|
193
|
+
let result: HttpDataHandlerResult;
|
|
194
|
+
try {
|
|
195
|
+
result = await handler(decoded.envelope, decoded.body);
|
|
196
|
+
} catch (error) {
|
|
197
|
+
yield* encodeErrorResponse(replyCodec, error, decoded.envelope.method);
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
yield* replyCodec.encodeResponse(result.envelope, result.body, {
|
|
201
|
+
method: decoded.envelope.method,
|
|
202
|
+
});
|
|
60
203
|
};
|
|
61
204
|
}
|
package/src/http-stubs.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { fromReadableStream, toReadableStream } from "@statewalker/webrun-streams";
|
|
2
|
+
import { discard } from "./bytes.js";
|
|
3
|
+
import { collectBytes, supportsRequestStreams } from "./request-streams.js";
|
|
2
4
|
|
|
3
5
|
export interface SerializedHttpRequest {
|
|
4
6
|
url: string;
|
|
@@ -23,14 +25,26 @@ export interface SerializedHttpResponse {
|
|
|
23
25
|
|
|
24
26
|
export interface SerializedHttpEnvelope<Options> {
|
|
25
27
|
options: Options;
|
|
28
|
+
/**
|
|
29
|
+
* The body bytes. Must be *productive*: it has to yield or finish on its own,
|
|
30
|
+
* because a body neither stub is allowed to read (a GET/HEAD/OPTIONS request,
|
|
31
|
+
* a null-body-status response) is released with `discard`, and `discard` must
|
|
32
|
+
* await one `.next()` before `.return()` — a bare `.return()` is a no-op on a
|
|
33
|
+
* generator in suspended start and would leak the producer. So a `content`
|
|
34
|
+
* that blocks forever without yielding makes the stub block with it, where an
|
|
35
|
+
* earlier version returned immediately and leaked instead. Every transport in
|
|
36
|
+
* this repo satisfies this; a `content` that waits on a peer that may never
|
|
37
|
+
* send should carry its own timeout.
|
|
38
|
+
*/
|
|
26
39
|
content: AsyncIterable<Uint8Array>;
|
|
27
40
|
}
|
|
28
41
|
|
|
29
42
|
export type HttpHandler = (request: Request) => Response | Promise<Response>;
|
|
30
43
|
|
|
31
44
|
// Statuses that MUST NOT carry a body — `new Response(body, { status })` throws
|
|
32
|
-
// for these (per the Fetch spec's "null body status" set).
|
|
33
|
-
|
|
45
|
+
// for these (per the Fetch spec's "null body status" set). Exported so
|
|
46
|
+
// `fetch.ts` mirrors this exact set rather than redefining it.
|
|
47
|
+
export const NULL_BODY_STATUSES = new Set([101, 103, 204, 205, 304]);
|
|
34
48
|
|
|
35
49
|
const REQUEST_FIELDS = [
|
|
36
50
|
"url",
|
|
@@ -45,6 +59,13 @@ const REQUEST_FIELDS = [
|
|
|
45
59
|
"keepalive",
|
|
46
60
|
] as const;
|
|
47
61
|
|
|
62
|
+
/** `SerializedHttpEnvelope.content` is never absent, only empty. */
|
|
63
|
+
async function* noBytes(): AsyncGenerator<Uint8Array> {}
|
|
64
|
+
|
|
65
|
+
async function* oneChunk(chunk: Uint8Array): AsyncGenerator<Uint8Array> {
|
|
66
|
+
yield chunk;
|
|
67
|
+
}
|
|
68
|
+
|
|
48
69
|
/**
|
|
49
70
|
* Returns an HTTP handler that serializes a Request, hands the envelope to
|
|
50
71
|
* `send` for transport, and deserializes the reply into a Response. Used on
|
|
@@ -63,9 +84,25 @@ export function newHttpClientStub(
|
|
|
63
84
|
const val = (request as unknown as Record<string, unknown>)[field];
|
|
64
85
|
if (val !== undefined && field !== "url") (options as Record<string, unknown>)[field] = val;
|
|
65
86
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
:
|
|
87
|
+
let content: AsyncIterable<Uint8Array>;
|
|
88
|
+
if (request.body != null) {
|
|
89
|
+
// The streaming path stays first and unconditional: a runtime that has
|
|
90
|
+
// request streams never reaches the fallback, and never buffers an upload.
|
|
91
|
+
content = fromReadableStream(request.body as ReadableStream<Uint8Array>);
|
|
92
|
+
} else if (!supportsRequestStreams()) {
|
|
93
|
+
// Firefox. Without `request.body` there is nothing to stream from, so the
|
|
94
|
+
// only way to see the payload at all is to buffer the whole of it, and
|
|
95
|
+
// request streaming is lost outright on this path — a 1 GiB upload from a
|
|
96
|
+
// Firefox page is a 1 GiB allocation here.
|
|
97
|
+
//
|
|
98
|
+
// The empty-versus-absent body divergence that `fetch.ts` has to live
|
|
99
|
+
// with does not arise on this transport: `SerializedHttpEnvelope` always
|
|
100
|
+
// carries a `content` iterable, so a zero-length body and no body are
|
|
101
|
+
// already the same thing here, whichever runtime produced it.
|
|
102
|
+
content = oneChunk(new Uint8Array(await request.arrayBuffer()));
|
|
103
|
+
} else {
|
|
104
|
+
content = noBytes();
|
|
105
|
+
}
|
|
69
106
|
|
|
70
107
|
const result = await send({ options, content });
|
|
71
108
|
|
|
@@ -77,14 +114,14 @@ export function newHttpClientStub(
|
|
|
77
114
|
const method = options.method;
|
|
78
115
|
// `new Response(body, ...)` throws for null-body statuses (204/205/304), and
|
|
79
116
|
// HEAD/OPTIONS carry no body either — drain the (empty) content stream and
|
|
80
|
-
// emit a bodyless response.
|
|
117
|
+
// emit a bodyless response. `discard` rather than a bare `.return()`: see
|
|
118
|
+
// the note at the matching site in `newHttpServerStub` below.
|
|
81
119
|
if (
|
|
82
120
|
method === "HEAD" ||
|
|
83
121
|
method === "OPTIONS" ||
|
|
84
122
|
NULL_BODY_STATUSES.has(responseOptions.status)
|
|
85
123
|
) {
|
|
86
|
-
|
|
87
|
-
await returnable.return?.();
|
|
124
|
+
await discard(result.content);
|
|
88
125
|
return new Response(null, responseOptions);
|
|
89
126
|
}
|
|
90
127
|
return new Response(toReadableStream(result.content[Symbol.asyncIterator]()), responseOptions);
|
|
@@ -111,22 +148,39 @@ export function newHttpServerStub(
|
|
|
111
148
|
for (const [key, value] of headers) requestHeaders.append(key, value);
|
|
112
149
|
|
|
113
150
|
const hasBody = method !== "GET" && method !== "HEAD" && method !== "OPTIONS";
|
|
114
|
-
let body: ReadableStream<Uint8Array> | undefined;
|
|
115
|
-
if (hasBody) {
|
|
116
|
-
body = toReadableStream(content[Symbol.asyncIterator]());
|
|
117
|
-
} else {
|
|
118
|
-
const returnable = content as AsyncIterable<Uint8Array> & { return?: () => unknown };
|
|
119
|
-
await returnable.return?.();
|
|
120
|
-
}
|
|
121
|
-
|
|
122
151
|
const requestInit: RequestInit & { duplex?: "half" } = {
|
|
123
152
|
...forwardable,
|
|
124
153
|
method,
|
|
125
154
|
headers: requestHeaders,
|
|
126
155
|
};
|
|
127
|
-
if (hasBody) {
|
|
128
|
-
|
|
156
|
+
if (!hasBody) {
|
|
157
|
+
// `discard`, not a bare `.return()`. GET/HEAD/OPTIONS arrive with a
|
|
158
|
+
// `content` iterable the transport built but nobody has pulled from yet,
|
|
159
|
+
// and `.return()` on an async generator still in *suspended start* is a
|
|
160
|
+
// no-op: the body never runs, its `try/finally` never unwinds, and
|
|
161
|
+
// whatever the producer holds open — a socket, a port subscription — is
|
|
162
|
+
// never released. `discard` calls `.next()` first for exactly this
|
|
163
|
+
// reason; see its comment in `bytes.ts`.
|
|
164
|
+
//
|
|
165
|
+
// `fetch.ts` has the same shape at its null-body-status branch and still
|
|
166
|
+
// uses the bare form — not because it is safe there (it is not; its
|
|
167
|
+
// producer's `finally` does not run either) but because `discard` cannot
|
|
168
|
+
// fix it: the release has to reach the `output` generator that
|
|
169
|
+
// `fetchOverDuplex` has no handle on. See the KNOWN GAP note there. Here
|
|
170
|
+
// the producer is reachable, so here it gets released.
|
|
171
|
+
await discard(content);
|
|
172
|
+
} else if (supportsRequestStreams()) {
|
|
173
|
+
requestInit.body = toReadableStream(content[Symbol.asyncIterator]());
|
|
129
174
|
requestInit.duplex = "half";
|
|
175
|
+
} else {
|
|
176
|
+
// Firefox again, and this is the half that fails *silently*. The
|
|
177
|
+
// constructor does not reject a `ReadableStream` here, it stringifies it:
|
|
178
|
+
// the handler would read the literal text `[object ReadableStream]` and
|
|
179
|
+
// answer 200 with corrupt data, where the client direction above at least
|
|
180
|
+
// sends an empty body a JSON endpoint rejects loudly. Bytes it does
|
|
181
|
+
// accept, so drain first — at the same cost as above, the whole upload in
|
|
182
|
+
// memory and no streaming left for the handler.
|
|
183
|
+
requestInit.body = await collectBytes(content);
|
|
130
184
|
}
|
|
131
185
|
|
|
132
186
|
const request = new Request(url, requestInit);
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import type { ByteReader } from "../bytes.js";
|
|
2
|
+
import { ByteStreamError } from "../bytes.js";
|
|
3
|
+
import type { ByteSource } from "../message.js";
|
|
4
|
+
import { HttpParseError } from "./errors.js";
|
|
5
|
+
import { decodeLatin1 } from "./headers.js";
|
|
6
|
+
|
|
7
|
+
const CRLF = new Uint8Array([0x0d, 0x0a]);
|
|
8
|
+
const LAST_CHUNK = new Uint8Array([0x30, 0x0d, 0x0a, 0x0d, 0x0a]); // "0\r\n\r\n"
|
|
9
|
+
const encoder = new TextEncoder();
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Chunk sizes are written in HEXADECIMAL — a 21-byte chunk is `15`. Writing
|
|
13
|
+
* them in decimal is the defect note 15 found in @libp2p/http, and it is the
|
|
14
|
+
* one that cannot be caught downstream: decimal digits are also valid hex, so
|
|
15
|
+
* a conforming parser silently reads the wrong length.
|
|
16
|
+
*
|
|
17
|
+
* Zero-length source chunks are skipped; a zero-sized chunk on the wire is the
|
|
18
|
+
* body terminator.
|
|
19
|
+
*/
|
|
20
|
+
export async function* encodeChunked(body: ByteSource): AsyncGenerator<Uint8Array> {
|
|
21
|
+
for await (const chunk of body) {
|
|
22
|
+
if (chunk.byteLength === 0) continue;
|
|
23
|
+
yield encoder.encode(`${chunk.byteLength.toString(16)}\r\n`);
|
|
24
|
+
yield chunk;
|
|
25
|
+
yield CRLF;
|
|
26
|
+
}
|
|
27
|
+
yield LAST_CHUNK;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Decode a chunked body, yielding data chunks as they arrive. Nothing is
|
|
32
|
+
* accumulated: a chunk larger than the transport's frame is yielded in pieces.
|
|
33
|
+
*/
|
|
34
|
+
export async function* decodeChunked(
|
|
35
|
+
reader: ByteReader,
|
|
36
|
+
maxLineBytes: number,
|
|
37
|
+
): AsyncGenerator<Uint8Array> {
|
|
38
|
+
while (true) {
|
|
39
|
+
const sizeLine = decodeLatin1(await reader.readLine(maxLineBytes));
|
|
40
|
+
const semicolon = sizeLine.indexOf(";"); // chunk extensions: accepted, ignored
|
|
41
|
+
const sizeText = semicolon === -1 ? sizeLine : sizeLine.slice(0, semicolon);
|
|
42
|
+
if (!/^[0-9a-fA-F]{1,32}$/.test(sizeText)) {
|
|
43
|
+
throw new HttpParseError(`invalid chunk size: ${JSON.stringify(sizeLine)}`);
|
|
44
|
+
}
|
|
45
|
+
const size = Number.parseInt(sizeText, 16);
|
|
46
|
+
if (!Number.isSafeInteger(size)) {
|
|
47
|
+
throw new HttpParseError(`chunk size is too large: ${JSON.stringify(sizeLine)}`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (size === 0) {
|
|
51
|
+
// Trailer section: legal input, read and discarded, never re-emitted.
|
|
52
|
+
let used = 0;
|
|
53
|
+
while (true) {
|
|
54
|
+
const remaining = maxLineBytes - used;
|
|
55
|
+
if (remaining <= 0) {
|
|
56
|
+
throw new HttpParseError(`trailer section exceeds ${maxLineBytes} bytes`);
|
|
57
|
+
}
|
|
58
|
+
const lineBytes = await reader.readLine(remaining);
|
|
59
|
+
used += lineBytes.byteLength + 2;
|
|
60
|
+
if (used > maxLineBytes) {
|
|
61
|
+
throw new HttpParseError(`trailer section exceeds ${maxLineBytes} bytes`);
|
|
62
|
+
}
|
|
63
|
+
if (lineBytes.byteLength === 0) break;
|
|
64
|
+
}
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let remaining = size;
|
|
69
|
+
while (remaining > 0) {
|
|
70
|
+
const part = await reader.readSome(remaining);
|
|
71
|
+
if (part === undefined) {
|
|
72
|
+
throw new HttpParseError(`chunk truncated: ${remaining} of ${size} bytes missing`);
|
|
73
|
+
}
|
|
74
|
+
remaining -= part.byteLength;
|
|
75
|
+
yield part;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
if ((await reader.readLine(2)).byteLength !== 0) {
|
|
80
|
+
throw new HttpParseError("chunk data not terminated by CRLF");
|
|
81
|
+
}
|
|
82
|
+
} catch (err) {
|
|
83
|
+
if (err instanceof ByteStreamError) {
|
|
84
|
+
throw new HttpParseError("chunk data not terminated by CRLF");
|
|
85
|
+
}
|
|
86
|
+
throw err;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|