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.
Files changed (96) hide show
  1. package/LICENSE +28 -0
  2. package/README.md +617 -0
  3. package/README.zh-CN.md +470 -0
  4. package/package.json +74 -0
  5. package/src/client/cookies.js +429 -0
  6. package/src/client/decode.js +346 -0
  7. package/src/client/redirect.js +249 -0
  8. package/src/client.js +704 -0
  9. package/src/errors.js +181 -0
  10. package/src/http1/chunked.js +289 -0
  11. package/src/http1/index.js +10 -0
  12. package/src/http1/request.js +143 -0
  13. package/src/http1/response.js +493 -0
  14. package/src/http2/connection.js +1170 -0
  15. package/src/http2/constants.js +129 -0
  16. package/src/http2/frames.js +291 -0
  17. package/src/http2/hpack.js +420 -0
  18. package/src/http2/huffman.js +203 -0
  19. package/src/http2/index.js +21 -0
  20. package/src/index.js +46 -0
  21. package/src/pool.js +256 -0
  22. package/src/proxy/direct.js +62 -0
  23. package/src/proxy/http-connect.js +206 -0
  24. package/src/proxy/index.js +197 -0
  25. package/src/proxy/socks5.js +344 -0
  26. package/src/tls/aead.js +263 -0
  27. package/src/tls/connect.js +407 -0
  28. package/src/tls/constants.js +334 -0
  29. package/src/tls/extensions.js +376 -0
  30. package/src/tls/handshake-messages.js +901 -0
  31. package/src/tls/handshake.js +568 -0
  32. package/src/tls/handshake12.js +507 -0
  33. package/src/tls/index.js +44 -0
  34. package/src/tls/keyschedule.js +473 -0
  35. package/src/tls/record.js +872 -0
  36. package/src/tls/tickets.js +145 -0
  37. package/src/tls/transcript.js +101 -0
  38. package/src/tls/wire.js +224 -0
  39. package/src/transport.js +296 -0
  40. package/src/trust/der.js +551 -0
  41. package/src/trust/index.js +375 -0
  42. package/src/trust/name.js +235 -0
  43. package/src/trust/ocsp.js +759 -0
  44. package/src/trust/path.js +595 -0
  45. package/src/trust/roots.js +454 -0
  46. package/src/trust/x509.js +902 -0
  47. package/src/util/bytes.js +470 -0
  48. package/src/util/deadline.js +266 -0
  49. package/src/warmup-fixture.js +85 -0
  50. package/src/warmup.js +243 -0
  51. package/types/client/cookies.d.ts +159 -0
  52. package/types/client/decode.d.ts +54 -0
  53. package/types/client/redirect.d.ts +96 -0
  54. package/types/client.d.ts +323 -0
  55. package/types/errors.d.ts +141 -0
  56. package/types/http1/chunked.d.ts +48 -0
  57. package/types/http1/index.d.ts +3 -0
  58. package/types/http1/request.d.ts +44 -0
  59. package/types/http1/response.d.ts +183 -0
  60. package/types/http2/connection.d.ts +282 -0
  61. package/types/http2/constants.d.ts +95 -0
  62. package/types/http2/frames.d.ts +116 -0
  63. package/types/http2/hpack.d.ts +99 -0
  64. package/types/http2/huffman.d.ts +21 -0
  65. package/types/http2/index.d.ts +5 -0
  66. package/types/index.d.ts +17 -0
  67. package/types/pool.d.ts +135 -0
  68. package/types/proxy/direct.d.ts +26 -0
  69. package/types/proxy/http-connect.d.ts +37 -0
  70. package/types/proxy/index.d.ts +62 -0
  71. package/types/proxy/socks5.d.ts +47 -0
  72. package/types/tls/aead.d.ts +67 -0
  73. package/types/tls/connect.d.ts +280 -0
  74. package/types/tls/constants.d.ts +275 -0
  75. package/types/tls/extensions.d.ts +195 -0
  76. package/types/tls/handshake-messages.d.ts +430 -0
  77. package/types/tls/handshake.d.ts +90 -0
  78. package/types/tls/handshake12.d.ts +35 -0
  79. package/types/tls/index.d.ts +9 -0
  80. package/types/tls/keyschedule.d.ts +272 -0
  81. package/types/tls/record.d.ts +361 -0
  82. package/types/tls/tickets.d.ts +66 -0
  83. package/types/tls/transcript.d.ts +52 -0
  84. package/types/tls/wire.d.ts +106 -0
  85. package/types/transport.d.ts +222 -0
  86. package/types/trust/der.d.ts +239 -0
  87. package/types/trust/index.d.ts +194 -0
  88. package/types/trust/name.d.ts +33 -0
  89. package/types/trust/ocsp.d.ts +138 -0
  90. package/types/trust/path.d.ts +139 -0
  91. package/types/trust/roots.d.ts +36 -0
  92. package/types/trust/x509.d.ts +401 -0
  93. package/types/util/bytes.d.ts +183 -0
  94. package/types/util/deadline.d.ts +133 -0
  95. package/types/warmup-fixture.d.ts +11 -0
  96. 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
+ }