tunnelfetch 1.0.0
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/LICENSE +28 -0
- package/README.md +617 -0
- package/README.zh-CN.md +470 -0
- package/package.json +74 -0
- package/src/client/cookies.js +429 -0
- package/src/client/decode.js +346 -0
- package/src/client/redirect.js +249 -0
- package/src/client.js +704 -0
- package/src/errors.js +181 -0
- package/src/http1/chunked.js +289 -0
- package/src/http1/index.js +10 -0
- package/src/http1/request.js +143 -0
- package/src/http1/response.js +493 -0
- package/src/http2/connection.js +1170 -0
- package/src/http2/constants.js +129 -0
- package/src/http2/frames.js +291 -0
- package/src/http2/hpack.js +420 -0
- package/src/http2/huffman.js +203 -0
- package/src/http2/index.js +21 -0
- package/src/index.js +46 -0
- package/src/pool.js +256 -0
- package/src/proxy/direct.js +62 -0
- package/src/proxy/http-connect.js +206 -0
- package/src/proxy/index.js +197 -0
- package/src/proxy/socks5.js +344 -0
- package/src/tls/aead.js +263 -0
- package/src/tls/connect.js +407 -0
- package/src/tls/constants.js +334 -0
- package/src/tls/extensions.js +376 -0
- package/src/tls/handshake-messages.js +901 -0
- package/src/tls/handshake.js +568 -0
- package/src/tls/handshake12.js +507 -0
- package/src/tls/index.js +44 -0
- package/src/tls/keyschedule.js +473 -0
- package/src/tls/record.js +872 -0
- package/src/tls/tickets.js +145 -0
- package/src/tls/transcript.js +101 -0
- package/src/tls/wire.js +224 -0
- package/src/transport.js +296 -0
- package/src/trust/der.js +551 -0
- package/src/trust/index.js +375 -0
- package/src/trust/name.js +235 -0
- package/src/trust/ocsp.js +759 -0
- package/src/trust/path.js +595 -0
- package/src/trust/roots.js +454 -0
- package/src/trust/x509.js +902 -0
- package/src/util/bytes.js +470 -0
- package/src/util/deadline.js +266 -0
- package/src/warmup-fixture.js +85 -0
- package/src/warmup.js +243 -0
- package/types/client/cookies.d.ts +159 -0
- package/types/client/decode.d.ts +54 -0
- package/types/client/redirect.d.ts +96 -0
- package/types/client.d.ts +323 -0
- package/types/errors.d.ts +141 -0
- package/types/http1/chunked.d.ts +48 -0
- package/types/http1/index.d.ts +3 -0
- package/types/http1/request.d.ts +44 -0
- package/types/http1/response.d.ts +183 -0
- package/types/http2/connection.d.ts +282 -0
- package/types/http2/constants.d.ts +95 -0
- package/types/http2/frames.d.ts +116 -0
- package/types/http2/hpack.d.ts +99 -0
- package/types/http2/huffman.d.ts +21 -0
- package/types/http2/index.d.ts +5 -0
- package/types/index.d.ts +17 -0
- package/types/pool.d.ts +135 -0
- package/types/proxy/direct.d.ts +26 -0
- package/types/proxy/http-connect.d.ts +37 -0
- package/types/proxy/index.d.ts +62 -0
- package/types/proxy/socks5.d.ts +47 -0
- package/types/tls/aead.d.ts +67 -0
- package/types/tls/connect.d.ts +280 -0
- package/types/tls/constants.d.ts +275 -0
- package/types/tls/extensions.d.ts +195 -0
- package/types/tls/handshake-messages.d.ts +430 -0
- package/types/tls/handshake.d.ts +90 -0
- package/types/tls/handshake12.d.ts +35 -0
- package/types/tls/index.d.ts +9 -0
- package/types/tls/keyschedule.d.ts +272 -0
- package/types/tls/record.d.ts +361 -0
- package/types/tls/tickets.d.ts +66 -0
- package/types/tls/transcript.d.ts +52 -0
- package/types/tls/wire.d.ts +106 -0
- package/types/transport.d.ts +222 -0
- package/types/trust/der.d.ts +239 -0
- package/types/trust/index.d.ts +194 -0
- package/types/trust/name.d.ts +33 -0
- package/types/trust/ocsp.d.ts +138 -0
- package/types/trust/path.d.ts +139 -0
- package/types/trust/roots.d.ts +36 -0
- package/types/trust/x509.d.ts +401 -0
- package/types/util/bytes.d.ts +183 -0
- package/types/util/deadline.d.ts +133 -0
- package/types/warmup-fixture.d.ts +11 -0
- package/types/warmup.d.ts +45 -0
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
// HTTP/1.1 response head parsing and body framing (RFC 9112 §4, §6.3).
|
|
2
|
+
//
|
|
3
|
+
// Framing is the part of an HTTP client where a bug is not a crash but a desynchronisation:
|
|
4
|
+
// read one byte too many and the next response's status line is gone; read one too few and this
|
|
5
|
+
// response's tail becomes the next response's head, attributing a payload to the wrong request.
|
|
6
|
+
// Everything here therefore fails closed. A message whose length is ambiguous (TE and CL both
|
|
7
|
+
// present, disagreeing duplicate CLs, a transfer coding we cannot decode) is an error, never a
|
|
8
|
+
// judgement call — RFC 9112 documents every one of these as a request-smuggling vector.
|
|
9
|
+
|
|
10
|
+
import { HttpError, LimitError, codes } from '../errors.js';
|
|
11
|
+
import { latin1, utf8 } from '../util/bytes.js';
|
|
12
|
+
import { decodeChunked } from './chunked.js';
|
|
13
|
+
|
|
14
|
+
const LF = utf8('\n');
|
|
15
|
+
|
|
16
|
+
// RFC 9110 token, for header field names. Duplicated in request.js/chunked.js rather than
|
|
17
|
+
// shared: it is one line, and a shared module would create an import cycle with chunked.js.
|
|
18
|
+
const TOKEN_RE = /^[A-Za-z0-9!#$%&'*+\-.^_`|~]+$/;
|
|
19
|
+
|
|
20
|
+
// status-line: HTTP-version SP status-code [ SP [ reason-phrase ] ].
|
|
21
|
+
// Exactly one space between fields, exactly three digits, and the reason phrase (with its
|
|
22
|
+
// leading space) may be absent entirely — `HTTP/1.1 200\r\n` is a legal status line.
|
|
23
|
+
const STATUS_LINE_RE = /^HTTP\/(1\.[01]) ([0-9]{3})(?: (.*))?$/;
|
|
24
|
+
|
|
25
|
+
// reason-phrase and field-value content: HTAB / SP / VCHAR / obs-text.
|
|
26
|
+
// Excludes NUL, CR, LF and every other control byte. Header values are opaque octets in the
|
|
27
|
+
// 0x80-0xFF range (decoded as latin1, never UTF-8), but control bytes have no place in them.
|
|
28
|
+
const FIELD_VALUE_RE = /^[\t\x20-\x7e\x80-\xff]*$/;
|
|
29
|
+
|
|
30
|
+
/** See deferred() in chunked.js: settle functions exposed, rejection pre-observed. */
|
|
31
|
+
function deferred() {
|
|
32
|
+
let resolve, reject;
|
|
33
|
+
const promise = new Promise((res, rej) => {
|
|
34
|
+
resolve = res;
|
|
35
|
+
reject = rej;
|
|
36
|
+
});
|
|
37
|
+
promise.catch(() => {});
|
|
38
|
+
return { promise, resolve, reject };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Read one CRLF-terminated line of the head. `budget.left` is shared across every line of
|
|
43
|
+
* every head (including 1xx heads), so the total is bounded no matter how the peer shapes it.
|
|
44
|
+
* The LF is the search needle so a bare-LF line is a named error instead of a stuck scan.
|
|
45
|
+
*/
|
|
46
|
+
async function readHeadLine(reader, budget, what, code) {
|
|
47
|
+
const raw = await reader.readUntil(LF, budget.left, what); // throws LIMIT_HEADER over budget
|
|
48
|
+
budget.left -= raw.byteLength;
|
|
49
|
+
const line = latin1(raw);
|
|
50
|
+
if (line.length < 2 || !line.endsWith('\r\n')) {
|
|
51
|
+
throw new HttpError(code, `${what}: line ended with bare LF, expected CRLF`, { line });
|
|
52
|
+
}
|
|
53
|
+
return line.slice(0, -2);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Parse one head (status line + header fields + blank line) off the reader. */
|
|
57
|
+
async function readOneHead(reader, budget) {
|
|
58
|
+
const statusLine = await readHeadLine(reader, budget, 'status line', codes.HTTP_STATUS_LINE);
|
|
59
|
+
const m = STATUS_LINE_RE.exec(statusLine);
|
|
60
|
+
if (!m) {
|
|
61
|
+
throw new HttpError(
|
|
62
|
+
codes.HTTP_STATUS_LINE,
|
|
63
|
+
`malformed status line ${JSON.stringify(statusLine)}`,
|
|
64
|
+
{ line: statusLine },
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
const httpVersion = m[1];
|
|
68
|
+
const status = Number(m[2]);
|
|
69
|
+
const statusText = m[3] ?? '';
|
|
70
|
+
if (status < 100) {
|
|
71
|
+
// Grammatically three digits, semantically nothing: 0xx fits no status class and any
|
|
72
|
+
// behaviour we picked for it would be invented.
|
|
73
|
+
throw new HttpError(codes.HTTP_STATUS_LINE, `status code ${m[2]} is not a valid status`, {
|
|
74
|
+
status: m[2],
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
if (!FIELD_VALUE_RE.test(statusText)) {
|
|
78
|
+
throw new HttpError(codes.HTTP_STATUS_LINE, 'reason phrase contains a control byte', {
|
|
79
|
+
line: statusLine,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const headers = new Headers();
|
|
84
|
+
const setCookie = [];
|
|
85
|
+
for (;;) {
|
|
86
|
+
const line = await readHeadLine(reader, budget, 'header field', codes.HTTP_HEADER);
|
|
87
|
+
if (line === '') break;
|
|
88
|
+
if (line[0] === ' ' || line[0] === '\t') {
|
|
89
|
+
// obs-fold. Deprecated by RFC 9112, and a folded continuation is a smuggling vector:
|
|
90
|
+
// two parsers that disagree on folding disagree on what the header said.
|
|
91
|
+
throw new HttpError(
|
|
92
|
+
codes.HTTP_HEADER,
|
|
93
|
+
'header line starts with whitespace (obs-fold is rejected)',
|
|
94
|
+
{ line },
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
const colon = line.indexOf(':');
|
|
98
|
+
if (colon <= 0) {
|
|
99
|
+
throw new HttpError(codes.HTTP_HEADER, `header line ${JSON.stringify(line)} has no name`, {
|
|
100
|
+
line,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
const name = line.slice(0, colon);
|
|
104
|
+
// Also rejects `Name : value` — whitespace before the colon makes name-based routing
|
|
105
|
+
// ambiguous, which is why RFC 9112 §5.1 forbids it. Space is not a tchar.
|
|
106
|
+
if (!TOKEN_RE.test(name)) {
|
|
107
|
+
throw new HttpError(
|
|
108
|
+
codes.HTTP_HEADER,
|
|
109
|
+
`header name ${JSON.stringify(name)} is not an RFC 9110 token`,
|
|
110
|
+
{ name },
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
const value = line.slice(colon + 1).replace(/^[ \t]+|[ \t]+$/g, '');
|
|
114
|
+
if (!FIELD_VALUE_RE.test(value)) {
|
|
115
|
+
throw new HttpError(codes.HTTP_HEADER, `header ${name} value contains a control byte`, {
|
|
116
|
+
name,
|
|
117
|
+
value,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
// Headers folds duplicates with ", ", which is correct for every list-valued field but
|
|
121
|
+
// destroys Set-Cookie (its values contain commas in Expires dates). Keep the raw values
|
|
122
|
+
// separately or no cookie jar can ever be built on top of this parser.
|
|
123
|
+
if (name.toLowerCase() === 'set-cookie') setCookie.push(value);
|
|
124
|
+
headers.append(name, value);
|
|
125
|
+
}
|
|
126
|
+
return { httpVersion, status, statusText, headers, setCookie };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* One skipped 1xx head. Kept because Early Hints (103) carry Link headers a caller may want;
|
|
131
|
+
* everything else about a 1xx is noise by definition.
|
|
132
|
+
* @typedef {object} InformationalHead
|
|
133
|
+
* @property {'1.0' | '1.1'} httpVersion
|
|
134
|
+
* @property {number} status
|
|
135
|
+
* @property {string} statusText
|
|
136
|
+
* @property {Headers} headers
|
|
137
|
+
*/
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The parsed response head. `setCookie` repeats the raw Set-Cookie values because the Headers
|
|
141
|
+
* class folds duplicates with ", ", which destroys cookie dates — no jar can be built from the
|
|
142
|
+
* folded form.
|
|
143
|
+
* @typedef {object} ResponseHead
|
|
144
|
+
* @property {'1.0' | '1.1'} httpVersion only versions the status-line grammar admits
|
|
145
|
+
* @property {number} status
|
|
146
|
+
* @property {string} statusText may be empty; `HTTP/1.1 200` is a legal status line
|
|
147
|
+
* @property {Headers} headers
|
|
148
|
+
* @property {string[]} setCookie one entry per Set-Cookie header, unfolded
|
|
149
|
+
* @property {InformationalHead[]} informational 1xx heads skipped before the real response
|
|
150
|
+
*/
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* @typedef {object} ReadHeadOptions
|
|
154
|
+
* @property {number} [maxHeaderBytes] budget for the ENTIRE head phase, default 65536
|
|
155
|
+
*/
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Read the response head: status line and header fields, plus any preceding informational
|
|
159
|
+
* (1xx) responses, which are legal noise before the real response (100 Continue, 103 Early
|
|
160
|
+
* Hints). They are skipped — 1xx never has a body — and returned in `informational` so a
|
|
161
|
+
* caller can surface Early Hints. 101 is fatal: this client never offers an upgrade, so a
|
|
162
|
+
* peer switching protocols means the bytes that follow are not HTTP and cannot be framed.
|
|
163
|
+
* Malformed heads throw HttpError; an oversized head throws LimitError.
|
|
164
|
+
*
|
|
165
|
+
* `maxHeaderBytes` bounds the ENTIRE head phase, informational heads included; a per-head
|
|
166
|
+
* budget would let a peer stream 1xx responses forever.
|
|
167
|
+
*
|
|
168
|
+
* @param {import('../util/bytes.js').ByteReader} reader
|
|
169
|
+
* @param {ReadHeadOptions} [opts]
|
|
170
|
+
* @returns {Promise<ResponseHead>}
|
|
171
|
+
*/
|
|
172
|
+
export async function readResponseHead(reader, { maxHeaderBytes = 65536 } = {}) {
|
|
173
|
+
const budget = { left: maxHeaderBytes };
|
|
174
|
+
const informational = [];
|
|
175
|
+
for (;;) {
|
|
176
|
+
const head = await readOneHead(reader, budget);
|
|
177
|
+
if (head.status >= 100 && head.status <= 199) {
|
|
178
|
+
if (head.status === 101) {
|
|
179
|
+
throw new HttpError(
|
|
180
|
+
codes.HTTP_UPGRADE_UNEXPECTED,
|
|
181
|
+
'101 Switching Protocols received, but no upgrade was offered',
|
|
182
|
+
{ statusText: head.statusText },
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
informational.push({
|
|
186
|
+
httpVersion: head.httpVersion,
|
|
187
|
+
status: head.status,
|
|
188
|
+
statusText: head.statusText,
|
|
189
|
+
headers: head.headers,
|
|
190
|
+
});
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
return { ...head, informational };
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** Split a folded list-valued header into trimmed elements, dropping empty ones (RFC 9110
|
|
198
|
+
* tells recipients to tolerate a reasonable number of empty list elements). */
|
|
199
|
+
function listElements(value) {
|
|
200
|
+
return value
|
|
201
|
+
.split(',')
|
|
202
|
+
.map((s) => s.trim())
|
|
203
|
+
.filter((s) => s !== '');
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* How a response body is delimited. The four kinds are exhaustive: RFC 9112 §6.3 admits no
|
|
208
|
+
* fifth, and everything ambiguous throws before a kind is chosen.
|
|
209
|
+
* @typedef {'none' | 'content-length' | 'chunked' | 'until-close'} FramingKind
|
|
210
|
+
*/
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* The framing decision. `length` is present exactly when `kind` is 'content-length'.
|
|
214
|
+
* @typedef {object} Framing
|
|
215
|
+
* @property {FramingKind} kind
|
|
216
|
+
* @property {number} [length] declared byte count, content-length framing only
|
|
217
|
+
* @property {boolean} keepAliveEligible whether the socket MAY be reused after the body ends
|
|
218
|
+
* as framed; see bodyFraming for why this is the load-bearing bit
|
|
219
|
+
*/
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Decide how the response body is delimited, per RFC 9112 §6.3, in its order. Ambiguous
|
|
223
|
+
* framing (TE and CL together, disagreeing duplicate CLs, an undecodable transfer coding)
|
|
224
|
+
* throws HttpError with HTTP_FRAMING_AMBIGUOUS — every one of those is a smuggling vector.
|
|
225
|
+
*
|
|
226
|
+
* `keepAliveEligible` is the load-bearing bit: it is true only when the body has a determinate
|
|
227
|
+
* end (`none`, `content-length`, `chunked`). A connection pool must never reuse a socket after
|
|
228
|
+
* an `until-close` body — with no marked end, "the body" is just "whatever arrived", and the
|
|
229
|
+
* next request on that socket would read the previous response's tail as its own head. That is
|
|
230
|
+
* the response-to-the-wrong-request bug, and this flag is the only thing standing between the
|
|
231
|
+
* pool and it. (A 2xx CONNECT reply is also ineligible: the socket is a tunnel now, not HTTP.)
|
|
232
|
+
*
|
|
233
|
+
* @param {{ status: number, method?: string, headers: Headers }} res the head fields framing
|
|
234
|
+
* depends on; a full ResponseHead satisfies it
|
|
235
|
+
* @returns {Framing}
|
|
236
|
+
*/
|
|
237
|
+
export function bodyFraming({ status, method, headers }) {
|
|
238
|
+
// 1. Messages that never have a body, regardless of any framing headers present: a HEAD
|
|
239
|
+
// response's Content-Length describes the GET-equivalent body that is not sent.
|
|
240
|
+
if (method === 'HEAD' || (status >= 100 && status <= 199) || status === 204 || status === 304) {
|
|
241
|
+
return { kind: 'none', keepAliveEligible: true };
|
|
242
|
+
}
|
|
243
|
+
// 2. A 2xx reply to CONNECT: the connection becomes an opaque tunnel immediately after the
|
|
244
|
+
// header block. No body, and no further HTTP on this socket.
|
|
245
|
+
if (method === 'CONNECT' && status >= 200 && status <= 299) {
|
|
246
|
+
return { kind: 'none', keepAliveEligible: false };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const te = headers.get('transfer-encoding');
|
|
250
|
+
const cl = headers.get('content-length');
|
|
251
|
+
|
|
252
|
+
if (te !== null) {
|
|
253
|
+
// 4 (checked first because it is reachable only when TE is present): both TE and CL is
|
|
254
|
+
// the canonical smuggling probe — two parsers that each pick a different winner split the
|
|
255
|
+
// stream at different offsets. Neither wins here.
|
|
256
|
+
if (cl !== null) {
|
|
257
|
+
throw new HttpError(
|
|
258
|
+
codes.HTTP_FRAMING_AMBIGUOUS,
|
|
259
|
+
`both Transfer-Encoding (${JSON.stringify(te)}) and Content-Length ` +
|
|
260
|
+
`(${JSON.stringify(cl)}) are present; the message length is ambiguous`,
|
|
261
|
+
{ transferEncoding: te, contentLength: cl },
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
// 3. Transfer-Encoding decides. Coding names are case-insensitive. `identity` is a no-op
|
|
265
|
+
// we accept; any other non-chunked coding (gzip, deflate, ...) would make the payload
|
|
266
|
+
// undecodable by this layer, and delivering still-coded bytes as if they were the body
|
|
267
|
+
// is a silent corruption, so it is refused by name.
|
|
268
|
+
const codings = listElements(te).map((s) => s.toLowerCase());
|
|
269
|
+
if (codings.length === 0) {
|
|
270
|
+
throw new HttpError(
|
|
271
|
+
codes.HTTP_FRAMING_AMBIGUOUS,
|
|
272
|
+
`Transfer-Encoding ${JSON.stringify(te)} names no transfer coding`,
|
|
273
|
+
{ transferEncoding: te },
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
for (const coding of codings) {
|
|
277
|
+
if (coding !== 'chunked' && coding !== 'identity') {
|
|
278
|
+
throw new HttpError(
|
|
279
|
+
codes.HTTP_FRAMING_AMBIGUOUS,
|
|
280
|
+
`transfer coding ${JSON.stringify(coding)} is not supported (only chunked/identity)`,
|
|
281
|
+
{ coding, transferEncoding: te },
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (codings[codings.length - 1] === 'chunked') {
|
|
286
|
+
return { kind: 'chunked', keepAliveEligible: true };
|
|
287
|
+
}
|
|
288
|
+
// TE present but the final coding is not chunked: RFC 9112 §6.3 item 4 — read to close.
|
|
289
|
+
// The end of the body is only ever signalled by EOF, so the socket cannot be reused.
|
|
290
|
+
return { kind: 'until-close', keepAliveEligible: false };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (cl !== null) {
|
|
294
|
+
// 5. Headers folded any duplicates to "5, 5". Identical repeats are tolerated (proxies
|
|
295
|
+
// do this); any disagreement or non-digit is fatal, because a length we are unsure of
|
|
296
|
+
// is a boundary we are unsure of.
|
|
297
|
+
const values = cl.split(',').map((s) => s.trim());
|
|
298
|
+
for (const v of values) {
|
|
299
|
+
if (!/^[0-9]+$/.test(v)) {
|
|
300
|
+
throw new HttpError(
|
|
301
|
+
codes.HTTP_FRAMING_AMBIGUOUS,
|
|
302
|
+
`Content-Length ${JSON.stringify(cl)} is not a non-negative integer`,
|
|
303
|
+
{ contentLength: cl },
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
if (v !== values[0]) {
|
|
307
|
+
throw new HttpError(
|
|
308
|
+
codes.HTTP_FRAMING_AMBIGUOUS,
|
|
309
|
+
`multiple Content-Length values disagree: ${JSON.stringify(cl)}`,
|
|
310
|
+
{ contentLength: cl },
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
const length = Number(values[0]);
|
|
315
|
+
if (!Number.isSafeInteger(length)) {
|
|
316
|
+
throw new HttpError(
|
|
317
|
+
codes.HTTP_FRAMING_AMBIGUOUS,
|
|
318
|
+
`Content-Length ${values[0]} overflows a safe integer`,
|
|
319
|
+
{ contentLength: cl },
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
// 6. A determinate length: the pool can hand the socket out again after exactly N bytes.
|
|
323
|
+
return { kind: 'content-length', length, keepAliveEligible: true };
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// 7. No framing information at all: the body is everything until the peer closes, which
|
|
327
|
+
// means EOF is data, not an error — and the socket is spent.
|
|
328
|
+
return { kind: 'until-close', keepAliveEligible: false };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* A response body stream plus the completion contract a connection pool needs. The two extra
|
|
333
|
+
* properties are documented on readResponseBody, which is the only producer.
|
|
334
|
+
* @typedef {ReadableStream<Uint8Array> & { completed: Promise<boolean>,
|
|
335
|
+
* trailers: Promise<Headers | null> }} BodyStream
|
|
336
|
+
*/
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* @typedef {object} ReadBodyOptions
|
|
340
|
+
* @property {number} [maxBytes] fail-closed cap on total payload bytes, default unlimited
|
|
341
|
+
*/
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Stream the response body according to `framing`.
|
|
345
|
+
*
|
|
346
|
+
* The returned ReadableStream<Uint8Array> carries two extra properties — the completion
|
|
347
|
+
* contract a connection pool needs:
|
|
348
|
+
*
|
|
349
|
+
* - `completed`: Promise<boolean>. Resolves `true` only when the body ended exactly as framed
|
|
350
|
+
* (the reader is positioned at the first byte after the body). Resolves `false` if the
|
|
351
|
+
* consumer cancelled early (position unknown). Rejects with the stream's error on a protocol
|
|
352
|
+
* violation (truncation, over-limit). The pool must await `completed === true` AND require
|
|
353
|
+
* `framing.keepAliveEligible` before reusing the socket; anything else and the next request
|
|
354
|
+
* reads this response's tail. Note it settles as the body is CONSUMED — an unread stream
|
|
355
|
+
* settles nothing (except for bodies that are complete at creation: `none` and length 0).
|
|
356
|
+
* - `trailers`: Promise<Headers|null>. Chunked trailers, or null for other framings / cancel.
|
|
357
|
+
*
|
|
358
|
+
* Bytes are streamed through, never buffered whole; `maxBytes` bounds the total.
|
|
359
|
+
*
|
|
360
|
+
* @param {import('../util/bytes.js').ByteReader} reader
|
|
361
|
+
* @param {Framing} framing
|
|
362
|
+
* @param {ReadBodyOptions} [opts]
|
|
363
|
+
* @returns {BodyStream}
|
|
364
|
+
*/
|
|
365
|
+
export function readResponseBody(reader, framing, { maxBytes = Infinity } = {}) {
|
|
366
|
+
if (framing.kind === 'none') {
|
|
367
|
+
// The reader is deliberately untouched: for HEAD/204/304 the very next bytes are the next
|
|
368
|
+
// response, and consuming even one of them would desynchronise the connection.
|
|
369
|
+
const stream = new ReadableStream({
|
|
370
|
+
start(c) {
|
|
371
|
+
c.close();
|
|
372
|
+
},
|
|
373
|
+
});
|
|
374
|
+
return Object.assign(stream, {
|
|
375
|
+
completed: Promise.resolve(true),
|
|
376
|
+
trailers: Promise.resolve(null),
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (framing.kind === 'chunked') {
|
|
381
|
+
const { stream, trailers } = decodeChunked(reader, { maxBytes });
|
|
382
|
+
// Trailers settle exactly when the terminal CRLF has been consumed, so their settlement
|
|
383
|
+
// IS the completion signal: Headers -> framed end reached, null -> cancelled.
|
|
384
|
+
const completed = trailers.then((t) => t !== null);
|
|
385
|
+
completed.catch(() => {});
|
|
386
|
+
return Object.assign(stream, { completed, trailers });
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
if (framing.kind === 'content-length') {
|
|
390
|
+
const length = framing.length;
|
|
391
|
+
if (!Number.isSafeInteger(length) || length < 0) {
|
|
392
|
+
throw new HttpError(codes.CONFIG_INVALID, `content-length framing with length ${length}`);
|
|
393
|
+
}
|
|
394
|
+
if (length > maxBytes) {
|
|
395
|
+
// Rejected before the first byte is read: the declared size already breaks the limit,
|
|
396
|
+
// and downloading maxBytes of it first would only delay the same answer.
|
|
397
|
+
throw new LimitError(
|
|
398
|
+
codes.LIMIT_BODY,
|
|
399
|
+
`declared Content-Length ${length} exceeds the ${maxBytes} byte limit`,
|
|
400
|
+
{ length, limit: maxBytes },
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
const done = deferred();
|
|
404
|
+
let remaining = length;
|
|
405
|
+
const stream = new ReadableStream({
|
|
406
|
+
async pull(c) {
|
|
407
|
+
if (remaining === 0) {
|
|
408
|
+
c.close();
|
|
409
|
+
done.resolve(true);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
let chunk;
|
|
413
|
+
try {
|
|
414
|
+
// Capped at `remaining`: bytes after the body (a pipelined next response) must stay
|
|
415
|
+
// in the reader. Over-reading here is the pipelining-corruption bug.
|
|
416
|
+
chunk = await reader.readSome(Math.min(remaining, 65536));
|
|
417
|
+
} catch (e) {
|
|
418
|
+
done.reject(e);
|
|
419
|
+
c.error(e);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
if (chunk === null) {
|
|
423
|
+
// The peer closed early. A short body silently returned would be indistinguishable
|
|
424
|
+
// from a complete one — truncation must be loud.
|
|
425
|
+
const err = new HttpError(
|
|
426
|
+
codes.HTTP_BODY_TRUNCATED,
|
|
427
|
+
`body ended after ${length - remaining} of the ${length} bytes declared by ` +
|
|
428
|
+
'Content-Length',
|
|
429
|
+
{ declared: length, got: length - remaining },
|
|
430
|
+
);
|
|
431
|
+
done.reject(err);
|
|
432
|
+
c.error(err);
|
|
433
|
+
return;
|
|
434
|
+
}
|
|
435
|
+
remaining -= chunk.byteLength;
|
|
436
|
+
c.enqueue(chunk);
|
|
437
|
+
// Close on the same pull that delivers the last byte, so a consumer that stops
|
|
438
|
+
// reading at exactly `length` bytes still lets `completed` settle.
|
|
439
|
+
if (remaining === 0) {
|
|
440
|
+
c.close();
|
|
441
|
+
done.resolve(true);
|
|
442
|
+
}
|
|
443
|
+
},
|
|
444
|
+
cancel() {
|
|
445
|
+
done.resolve(false);
|
|
446
|
+
},
|
|
447
|
+
});
|
|
448
|
+
if (length === 0) done.resolve(true); // complete at creation; no pull required
|
|
449
|
+
return Object.assign(stream, { completed: done.promise, trailers: Promise.resolve(null) });
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (framing.kind === 'until-close') {
|
|
453
|
+
const done = deferred();
|
|
454
|
+
let total = 0;
|
|
455
|
+
const stream = new ReadableStream({
|
|
456
|
+
async pull(c) {
|
|
457
|
+
let chunk;
|
|
458
|
+
try {
|
|
459
|
+
chunk = await reader.readSome(65536);
|
|
460
|
+
} catch (e) {
|
|
461
|
+
done.reject(e);
|
|
462
|
+
c.error(e);
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
if (chunk === null) {
|
|
466
|
+
// EOF is the framing here, so it completes the body rather than truncating it.
|
|
467
|
+
// (`completed` resolves true, but keepAliveEligible is false: the socket is spent.)
|
|
468
|
+
c.close();
|
|
469
|
+
done.resolve(true);
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
total += chunk.byteLength;
|
|
473
|
+
if (total > maxBytes) {
|
|
474
|
+
const err = new LimitError(
|
|
475
|
+
codes.LIMIT_BODY,
|
|
476
|
+
`body reached ${total} bytes, over the ${maxBytes} byte limit`,
|
|
477
|
+
{ limit: maxBytes },
|
|
478
|
+
);
|
|
479
|
+
done.reject(err);
|
|
480
|
+
c.error(err);
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
c.enqueue(chunk);
|
|
484
|
+
},
|
|
485
|
+
cancel() {
|
|
486
|
+
done.resolve(false);
|
|
487
|
+
},
|
|
488
|
+
});
|
|
489
|
+
return Object.assign(stream, { completed: done.promise, trailers: Promise.resolve(null) });
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
throw new HttpError(codes.CONFIG_INVALID, `unknown framing kind ${JSON.stringify(framing.kind)}`);
|
|
493
|
+
}
|