Skip to content

Commit 78695b8

Browse files
authored
Merge branch 'main' into blocking-default
2 parents c54f654 + 2e79b62 commit 78695b8

File tree

14 files changed

+288
-317
lines changed

14 files changed

+288
-317
lines changed

build/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
FROM node:22-alpine3.19@sha256:83b4d7bcfc3d4a40faac3e73a59bc3b0f4b3cc72b9a19e036d340746ebfeaecb
1+
FROM node:22-alpine3.19@sha256:f1b43157ce277feaed97088f4d1bbf6b209148d49d98cea592e0af6637657baf
22

33
ARG UID=1000
44
ARG GID=1000

docs/docs/api/Dispatcher.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -527,6 +527,7 @@ try {
527527
console.log('headers', headers)
528528
body.setEncoding('utf8')
529529
body.on('data', console.log)
530+
body.on('error', console.error)
530531
body.on('end', () => {
531532
console.log('trailers', trailers)
532533
})
@@ -630,6 +631,25 @@ try {
630631
}
631632
```
632633

634+
#### Example 3 - Conditionally reading the body
635+
636+
Remember to fully consume the body even in the case when it is not read.
637+
638+
```js
639+
const { body, statusCode } = await client.request({
640+
path: '/',
641+
method: 'GET'
642+
})
643+
644+
if (statusCode === 200) {
645+
return await body.arrayBuffer()
646+
}
647+
648+
await body.dump()
649+
650+
return null
651+
```
652+
633653
### `Dispatcher.stream(options, factory[, callback])`
634654

635655
A faster version of `Dispatcher.request`. This method expects the second argument `factory` to return a [`stream.Writable`](https://nodejs.org/api/stream.html#stream_class_stream_writable) stream which the response will be written to. This improves performance by avoiding creating an intermediate [`stream.Readable`](https://nodejs.org/api/stream.html#stream_readable_streams) stream when the user expects to directly pipe the response body to a [`stream.Writable`](https://nodejs.org/api/stream.html#stream_class_stream_writable) stream.

lib/api/api-request.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ class RequestHandler extends AsyncResource {
6565
this.removeAbortListener = util.addAbortListener(signal, () => {
6666
this.reason = signal.reason ?? new RequestAbortedError()
6767
if (this.res) {
68-
util.destroy(this.res, this.reason)
68+
util.destroy(this.res.on('error', noop), this.reason)
6969
} else if (this.abort) {
7070
this.abort(this.reason)
7171
}

lib/handler/cache-handler.js

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,6 @@ class CacheHandler extends DecoratorHandler {
1616
*/
1717
#store
1818

19-
/**
20-
* @type {import('../../types/cache-interceptor.d.ts').default.CacheMethods}
21-
*/
22-
#methods
23-
2419
/**
2520
* @type {import('../../types/dispatcher.d.ts').default.RequestOptions}
2621
*/
@@ -42,14 +37,13 @@ class CacheHandler extends DecoratorHandler {
4237
* @param {import('../../types/dispatcher.d.ts').default.DispatchHandlers} handler
4338
*/
4439
constructor (opts, requestOptions, handler) {
45-
const { store, methods } = opts
40+
const { store } = opts
4641

4742
super(handler)
4843

4944
this.#store = store
5045
this.#requestOptions = requestOptions
5146
this.#handler = handler
52-
this.#methods = methods
5347
}
5448

5549
/**
@@ -75,7 +69,7 @@ class CacheHandler extends DecoratorHandler {
7569
)
7670

7771
if (
78-
!this.#methods.includes(this.#requestOptions.method) &&
72+
!util.safeHTTPMethods.includes(this.#requestOptions.method) &&
7973
statusCode >= 200 &&
8074
statusCode <= 399
8175
) {

lib/interceptor/cache.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,11 @@ module.exports = (opts = {}) => {
3030
methods
3131
}
3232

33+
const safeMethodsToNotCache = util.safeHTTPMethods.filter(method => methods.includes(method) === false)
34+
3335
return dispatch => {
3436
return (opts, handler) => {
35-
if (!opts.origin || !methods.includes(opts.method)) {
37+
if (!opts.origin || safeMethodsToNotCache.includes(opts.method)) {
3638
// Not a method we want to cache or we don't have the origin, skip
3739
return dispatch(opts, handler)
3840
}

lib/web/fetch/body.js

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -364,12 +364,8 @@ function bodyMixinMethods (instance, getInternalState) {
364364
switch (mimeType.essence) {
365365
case 'multipart/form-data': {
366366
// 1. ... [long step]
367-
const parsed = multipartFormDataParser(value, mimeType)
368-
369367
// 2. If that fails for some reason, then throw a TypeError.
370-
if (parsed === 'failure') {
371-
throw new TypeError('Failed to parse body as FormData.')
372-
}
368+
const parsed = multipartFormDataParser(value, mimeType)
373369

374370
// 3. Return a new FormData object, appending each entry,
375371
// resulting from the parsing operation, to its entry list.

lib/web/fetch/formdata-parser.js

Lines changed: 70 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ const { File: NodeFile } = require('node:buffer')
1111
const File = globalThis.File ?? NodeFile
1212

1313
const formDataNameBuffer = Buffer.from('form-data; name="')
14-
const filenameBuffer = Buffer.from('; filename')
14+
const filenameBuffer = Buffer.from('filename')
1515
const dd = Buffer.from('--')
1616
const ddcrlf = Buffer.from('--\r\n')
1717

@@ -75,7 +75,7 @@ function multipartFormDataParser (input, mimeType) {
7575
// Otherwise, let boundary be the result of UTF-8 decoding mimeType’s
7676
// parameters["boundary"].
7777
if (boundaryString === undefined) {
78-
return 'failure'
78+
throw parsingError('missing boundary in content-type header')
7979
}
8080

8181
const boundary = Buffer.from(`--${boundaryString}`, 'utf8')
@@ -111,7 +111,7 @@ function multipartFormDataParser (input, mimeType) {
111111
if (input.subarray(position.position, position.position + boundary.length).equals(boundary)) {
112112
position.position += boundary.length
113113
} else {
114-
return 'failure'
114+
throw parsingError('expected a value starting with -- and the boundary')
115115
}
116116

117117
// 5.2. If position points to the sequence of bytes 0x2D 0x2D 0x0D 0x0A
@@ -127,7 +127,7 @@ function multipartFormDataParser (input, mimeType) {
127127
// 5.3. If position does not point to a sequence of bytes starting with 0x0D
128128
// 0x0A (CR LF), return failure.
129129
if (input[position.position] !== 0x0d || input[position.position + 1] !== 0x0a) {
130-
return 'failure'
130+
throw parsingError('expected CRLF')
131131
}
132132

133133
// 5.4. Advance position by 2. (This skips past the newline.)
@@ -138,10 +138,6 @@ function multipartFormDataParser (input, mimeType) {
138138
// is not failure. Otherwise, return failure.
139139
const result = parseMultipartFormDataHeaders(input, position)
140140

141-
if (result === 'failure') {
142-
return 'failure'
143-
}
144-
145141
let { name, filename, contentType, encoding } = result
146142

147143
// 5.6. Advance position by 2. (This skips past the empty line that marks
@@ -157,7 +153,7 @@ function multipartFormDataParser (input, mimeType) {
157153
const boundaryIndex = input.indexOf(boundary.subarray(2), position.position)
158154

159155
if (boundaryIndex === -1) {
160-
return 'failure'
156+
throw parsingError('expected boundary after body')
161157
}
162158

163159
body = input.subarray(position.position, boundaryIndex - 4)
@@ -174,7 +170,7 @@ function multipartFormDataParser (input, mimeType) {
174170
// 5.9. If position does not point to a sequence of bytes starting with
175171
// 0x0D 0x0A (CR LF), return failure. Otherwise, advance position by 2.
176172
if (input[position.position] !== 0x0d || input[position.position + 1] !== 0x0a) {
177-
return 'failure'
173+
throw parsingError('expected CRLF')
178174
} else {
179175
position.position += 2
180176
}
@@ -230,7 +226,7 @@ function parseMultipartFormDataHeaders (input, position) {
230226
if (input[position.position] === 0x0d && input[position.position + 1] === 0x0a) {
231227
// 2.1.1. If name is null, return failure.
232228
if (name === null) {
233-
return 'failure'
229+
throw parsingError('header name is null')
234230
}
235231

236232
// 2.1.2. Return name, filename and contentType.
@@ -250,12 +246,12 @@ function parseMultipartFormDataHeaders (input, position) {
250246

251247
// 2.4. If header name does not match the field-name token production, return failure.
252248
if (!HTTP_TOKEN_CODEPOINTS.test(headerName.toString())) {
253-
return 'failure'
249+
throw parsingError('header name does not match the field-name token production')
254250
}
255251

256252
// 2.5. If the byte at position is not 0x3A (:), return failure.
257253
if (input[position.position] !== 0x3a) {
258-
return 'failure'
254+
throw parsingError('expected :')
259255
}
260256

261257
// 2.6. Advance position by 1.
@@ -278,7 +274,7 @@ function parseMultipartFormDataHeaders (input, position) {
278274
// 2. If position does not point to a sequence of bytes starting with
279275
// `form-data; name="`, return failure.
280276
if (!bufferStartsWith(input, formDataNameBuffer, position)) {
281-
return 'failure'
277+
throw parsingError('expected form-data; name=" for content-disposition header')
282278
}
283279

284280
// 3. Advance position so it points at the byte after the next 0x22 (")
@@ -290,34 +286,61 @@ function parseMultipartFormDataHeaders (input, position) {
290286
// failure.
291287
name = parseMultipartFormDataName(input, position)
292288

293-
if (name === null) {
294-
return 'failure'
295-
}
296-
297289
// 5. If position points to a sequence of bytes starting with `; filename="`:
298-
if (bufferStartsWith(input, filenameBuffer, position)) {
299-
// Note: undici also handles filename*
300-
let check = position.position + filenameBuffer.length
301-
302-
if (input[check] === 0x2a) {
303-
position.position += 1
304-
check += 1
305-
}
306-
307-
if (input[check] !== 0x3d || input[check + 1] !== 0x22) { // ="
308-
return 'failure'
309-
}
310-
311-
// 1. Advance position so it points at the byte after the next 0x22 (") byte
312-
// (the one in the sequence of bytes matched above).
313-
position.position += 12
314-
315-
// 2. Set filename to the result of parsing a multipart/form-data name given
316-
// input and position, if the result is not failure. Otherwise, return failure.
317-
filename = parseMultipartFormDataName(input, position)
318-
319-
if (filename === null) {
320-
return 'failure'
290+
if (input[position.position] === 0x3b /* ; */ && input[position.position + 1] === 0x20 /* ' ' */) {
291+
const at = { position: position.position + 2 }
292+
293+
if (bufferStartsWith(input, filenameBuffer, at)) {
294+
if (input[at.position + 8] === 0x2a /* '*' */) {
295+
at.position += 10 // skip past filename*=
296+
297+
// Remove leading http tab and spaces. See RFC for examples.
298+
// https://datatracker.ietf.org/doc/html/rfc6266#section-5
299+
collectASequenceOfBytes(
300+
(char) => char === 0x20 || char === 0x09,
301+
input,
302+
at
303+
)
304+
305+
const headerValue = collectASequenceOfBytes(
306+
(char) => char !== 0x20 && char !== 0x0d && char !== 0x0a, // ' ' or CRLF
307+
input,
308+
at
309+
)
310+
311+
if (
312+
(headerValue[0] !== 0x75 && headerValue[0] !== 0x55) || // u or U
313+
(headerValue[1] !== 0x74 && headerValue[1] !== 0x54) || // t or T
314+
(headerValue[2] !== 0x66 && headerValue[2] !== 0x46) || // f or F
315+
headerValue[3] !== 0x2d || // -
316+
headerValue[4] !== 0x38 // 8
317+
) {
318+
throw parsingError('unknown encoding, expected utf-8\'\'')
319+
}
320+
321+
// skip utf-8''
322+
filename = decodeURIComponent(new TextDecoder().decode(headerValue.subarray(7)))
323+
324+
position.position = at.position
325+
} else {
326+
// 1. Advance position so it points at the byte after the next 0x22 (") byte
327+
// (the one in the sequence of bytes matched above).
328+
position.position += 11
329+
330+
// Remove leading http tab and spaces. See RFC for examples.
331+
// https://datatracker.ietf.org/doc/html/rfc6266#section-5
332+
collectASequenceOfBytes(
333+
(char) => char === 0x20 || char === 0x09,
334+
input,
335+
position
336+
)
337+
338+
position.position++ // skip past " after removing whitespace
339+
340+
// 2. Set filename to the result of parsing a multipart/form-data name given
341+
// input and position, if the result is not failure. Otherwise, return failure.
342+
filename = parseMultipartFormDataName(input, position)
343+
}
321344
}
322345
}
323346

@@ -367,7 +390,7 @@ function parseMultipartFormDataHeaders (input, position) {
367390
// 2.9. If position does not point to a sequence of bytes starting with 0x0D 0x0A
368391
// (CR LF), return failure. Otherwise, advance position by 2 (past the newline).
369392
if (input[position.position] !== 0x0d && input[position.position + 1] !== 0x0a) {
370-
return 'failure'
393+
throw parsingError('expected CRLF')
371394
} else {
372395
position.position += 2
373396
}
@@ -393,7 +416,7 @@ function parseMultipartFormDataName (input, position) {
393416

394417
// 3. If the byte at position is not 0x22 ("), return failure. Otherwise, advance position by 1.
395418
if (input[position.position] !== 0x22) {
396-
return null // name could be 'failure'
419+
throw parsingError('expected "')
397420
} else {
398421
position.position++
399422
}
@@ -468,6 +491,10 @@ function bufferStartsWith (buffer, start, position) {
468491
return true
469492
}
470493

494+
function parsingError (cause) {
495+
return new TypeError('Failed to parse body as FormData.', { cause: new TypeError(cause) })
496+
}
497+
471498
module.exports = {
472499
multipartFormDataParser,
473500
validateBoundary

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "undici",
3-
"version": "7.0.0-alpha.2",
3+
"version": "7.0.0-alpha.3",
44
"description": "An HTTP/1.1 client, written from scratch for Node.js",
55
"homepage": "https://undici.nodejs.org",
66
"bugs": {

test/busboy/issue-3760.js

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
'use strict'
2+
3+
const { test } = require('node:test')
4+
const assert = require('node:assert')
5+
const { Response } = require('../..')
6+
7+
// https://github.com/nodejs/undici/issues/3760
8+
test('filename* parameter is parsed properly', async (t) => {
9+
const response = new Response([
10+
'--83d82e0d-9ced-44c0-ac79-4e66a827415b\r\n' +
11+
'Content-Type: text/plain\r\n' +
12+
'Content-Disposition: form-data; name="file"; filename*=UTF-8\'\'%e2%82%ac%20rates\r\n' +
13+
'\r\n' +
14+
'testabc\r\n' +
15+
'--83d82e0d-9ced-44c0-ac79-4e66a827415b--\r\n' +
16+
'\r\n'
17+
].join(''), {
18+
headers: {
19+
'content-type': 'multipart/form-data; boundary="83d82e0d-9ced-44c0-ac79-4e66a827415b"'
20+
}
21+
})
22+
23+
const fd = await response.formData()
24+
assert.deepEqual(fd.get('file').name, '€ rates')
25+
})
26+
27+
test('whitespace after filename[*]= is ignored', async () => {
28+
for (const response of [
29+
new Response([
30+
'--83d82e0d-9ced-44c0-ac79-4e66a827415b\r\n' +
31+
'Content-Type: text/plain\r\n' +
32+
'Content-Disposition: form-data; name="file"; filename*= utf-8\'\'hello\r\n' +
33+
'\r\n' +
34+
'testabc\r\n' +
35+
'--83d82e0d-9ced-44c0-ac79-4e66a827415b--\r\n' +
36+
'\r\n'
37+
].join(''), {
38+
headers: {
39+
'content-type': 'multipart/form-data; boundary="83d82e0d-9ced-44c0-ac79-4e66a827415b"'
40+
}
41+
}),
42+
new Response([
43+
'--83d82e0d-9ced-44c0-ac79-4e66a827415b\r\n' +
44+
'Content-Type: text/plain\r\n' +
45+
'Content-Disposition: form-data; name="file"; filename= "hello"\r\n' +
46+
'\r\n' +
47+
'testabc\r\n' +
48+
'--83d82e0d-9ced-44c0-ac79-4e66a827415b--\r\n' +
49+
'\r\n'
50+
].join(''), {
51+
headers: {
52+
'content-type': 'multipart/form-data; boundary="83d82e0d-9ced-44c0-ac79-4e66a827415b"'
53+
}
54+
})
55+
]) {
56+
const fd = await response.formData()
57+
assert.deepEqual(fd.get('file').name, 'hello')
58+
}
59+
})

0 commit comments

Comments
 (0)