Skip to content

Commit

Permalink
Merge branch 'main' into blocking-default
Browse files Browse the repository at this point in the history
  • Loading branch information
metcoder95 authored Oct 27, 2024
2 parents c54f654 + 2e79b62 commit 78695b8
Show file tree
Hide file tree
Showing 14 changed files with 288 additions and 317 deletions.
2 changes: 1 addition & 1 deletion build/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM node:22-alpine3.19@sha256:83b4d7bcfc3d4a40faac3e73a59bc3b0f4b3cc72b9a19e036d340746ebfeaecb
FROM node:22-alpine3.19@sha256:f1b43157ce277feaed97088f4d1bbf6b209148d49d98cea592e0af6637657baf

ARG UID=1000
ARG GID=1000
Expand Down
20 changes: 20 additions & 0 deletions docs/docs/api/Dispatcher.md
Original file line number Diff line number Diff line change
Expand Up @@ -527,6 +527,7 @@ try {
console.log('headers', headers)
body.setEncoding('utf8')
body.on('data', console.log)
body.on('error', console.error)
body.on('end', () => {
console.log('trailers', trailers)
})
Expand Down Expand Up @@ -630,6 +631,25 @@ try {
}
```

#### Example 3 - Conditionally reading the body

Remember to fully consume the body even in the case when it is not read.

```js
const { body, statusCode } = await client.request({
path: '/',
method: 'GET'
})

if (statusCode === 200) {
return await body.arrayBuffer()
}

await body.dump()

return null
```

### `Dispatcher.stream(options, factory[, callback])`

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.
Expand Down
2 changes: 1 addition & 1 deletion lib/api/api-request.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ class RequestHandler extends AsyncResource {
this.removeAbortListener = util.addAbortListener(signal, () => {
this.reason = signal.reason ?? new RequestAbortedError()
if (this.res) {
util.destroy(this.res, this.reason)
util.destroy(this.res.on('error', noop), this.reason)
} else if (this.abort) {
this.abort(this.reason)
}
Expand Down
10 changes: 2 additions & 8 deletions lib/handler/cache-handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,6 @@ class CacheHandler extends DecoratorHandler {
*/
#store

/**
* @type {import('../../types/cache-interceptor.d.ts').default.CacheMethods}
*/
#methods

/**
* @type {import('../../types/dispatcher.d.ts').default.RequestOptions}
*/
Expand All @@ -42,14 +37,13 @@ class CacheHandler extends DecoratorHandler {
* @param {import('../../types/dispatcher.d.ts').default.DispatchHandlers} handler
*/
constructor (opts, requestOptions, handler) {
const { store, methods } = opts
const { store } = opts

super(handler)

this.#store = store
this.#requestOptions = requestOptions
this.#handler = handler
this.#methods = methods
}

/**
Expand All @@ -75,7 +69,7 @@ class CacheHandler extends DecoratorHandler {
)

if (
!this.#methods.includes(this.#requestOptions.method) &&
!util.safeHTTPMethods.includes(this.#requestOptions.method) &&
statusCode >= 200 &&
statusCode <= 399
) {
Expand Down
4 changes: 3 additions & 1 deletion lib/interceptor/cache.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,11 @@ module.exports = (opts = {}) => {
methods
}

const safeMethodsToNotCache = util.safeHTTPMethods.filter(method => methods.includes(method) === false)

return dispatch => {
return (opts, handler) => {
if (!opts.origin || !methods.includes(opts.method)) {
if (!opts.origin || safeMethodsToNotCache.includes(opts.method)) {
// Not a method we want to cache or we don't have the origin, skip
return dispatch(opts, handler)
}
Expand Down
6 changes: 1 addition & 5 deletions lib/web/fetch/body.js
Original file line number Diff line number Diff line change
Expand Up @@ -364,12 +364,8 @@ function bodyMixinMethods (instance, getInternalState) {
switch (mimeType.essence) {
case 'multipart/form-data': {
// 1. ... [long step]
const parsed = multipartFormDataParser(value, mimeType)

// 2. If that fails for some reason, then throw a TypeError.
if (parsed === 'failure') {
throw new TypeError('Failed to parse body as FormData.')
}
const parsed = multipartFormDataParser(value, mimeType)

// 3. Return a new FormData object, appending each entry,
// resulting from the parsing operation, to its entry list.
Expand Down
113 changes: 70 additions & 43 deletions lib/web/fetch/formdata-parser.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const { File: NodeFile } = require('node:buffer')
const File = globalThis.File ?? NodeFile

const formDataNameBuffer = Buffer.from('form-data; name="')
const filenameBuffer = Buffer.from('; filename')
const filenameBuffer = Buffer.from('filename')
const dd = Buffer.from('--')
const ddcrlf = Buffer.from('--\r\n')

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

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

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

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

if (result === 'failure') {
return 'failure'
}

let { name, filename, contentType, encoding } = result

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

if (boundaryIndex === -1) {
return 'failure'
throw parsingError('expected boundary after body')
}

body = input.subarray(position.position, boundaryIndex - 4)
Expand All @@ -174,7 +170,7 @@ function multipartFormDataParser (input, mimeType) {
// 5.9. If position does not point to a sequence of bytes starting with
// 0x0D 0x0A (CR LF), return failure. Otherwise, advance position by 2.
if (input[position.position] !== 0x0d || input[position.position + 1] !== 0x0a) {
return 'failure'
throw parsingError('expected CRLF')
} else {
position.position += 2
}
Expand Down Expand Up @@ -230,7 +226,7 @@ function parseMultipartFormDataHeaders (input, position) {
if (input[position.position] === 0x0d && input[position.position + 1] === 0x0a) {
// 2.1.1. If name is null, return failure.
if (name === null) {
return 'failure'
throw parsingError('header name is null')
}

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

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

// 2.5. If the byte at position is not 0x3A (:), return failure.
if (input[position.position] !== 0x3a) {
return 'failure'
throw parsingError('expected :')
}

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

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

if (name === null) {
return 'failure'
}

// 5. If position points to a sequence of bytes starting with `; filename="`:
if (bufferStartsWith(input, filenameBuffer, position)) {
// Note: undici also handles filename*
let check = position.position + filenameBuffer.length

if (input[check] === 0x2a) {
position.position += 1
check += 1
}

if (input[check] !== 0x3d || input[check + 1] !== 0x22) { // ="
return 'failure'
}

// 1. Advance position so it points at the byte after the next 0x22 (") byte
// (the one in the sequence of bytes matched above).
position.position += 12

// 2. Set filename to the result of parsing a multipart/form-data name given
// input and position, if the result is not failure. Otherwise, return failure.
filename = parseMultipartFormDataName(input, position)

if (filename === null) {
return 'failure'
if (input[position.position] === 0x3b /* ; */ && input[position.position + 1] === 0x20 /* ' ' */) {
const at = { position: position.position + 2 }

if (bufferStartsWith(input, filenameBuffer, at)) {
if (input[at.position + 8] === 0x2a /* '*' */) {
at.position += 10 // skip past filename*=

// Remove leading http tab and spaces. See RFC for examples.
// https://datatracker.ietf.org/doc/html/rfc6266#section-5
collectASequenceOfBytes(
(char) => char === 0x20 || char === 0x09,
input,
at
)

const headerValue = collectASequenceOfBytes(
(char) => char !== 0x20 && char !== 0x0d && char !== 0x0a, // ' ' or CRLF
input,
at
)

if (
(headerValue[0] !== 0x75 && headerValue[0] !== 0x55) || // u or U
(headerValue[1] !== 0x74 && headerValue[1] !== 0x54) || // t or T
(headerValue[2] !== 0x66 && headerValue[2] !== 0x46) || // f or F
headerValue[3] !== 0x2d || // -
headerValue[4] !== 0x38 // 8
) {
throw parsingError('unknown encoding, expected utf-8\'\'')
}

// skip utf-8''
filename = decodeURIComponent(new TextDecoder().decode(headerValue.subarray(7)))

position.position = at.position
} else {
// 1. Advance position so it points at the byte after the next 0x22 (") byte
// (the one in the sequence of bytes matched above).
position.position += 11

// Remove leading http tab and spaces. See RFC for examples.
// https://datatracker.ietf.org/doc/html/rfc6266#section-5
collectASequenceOfBytes(
(char) => char === 0x20 || char === 0x09,
input,
position
)

position.position++ // skip past " after removing whitespace

// 2. Set filename to the result of parsing a multipart/form-data name given
// input and position, if the result is not failure. Otherwise, return failure.
filename = parseMultipartFormDataName(input, position)
}
}
}

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

// 3. If the byte at position is not 0x22 ("), return failure. Otherwise, advance position by 1.
if (input[position.position] !== 0x22) {
return null // name could be 'failure'
throw parsingError('expected "')
} else {
position.position++
}
Expand Down Expand Up @@ -468,6 +491,10 @@ function bufferStartsWith (buffer, start, position) {
return true
}

function parsingError (cause) {
return new TypeError('Failed to parse body as FormData.', { cause: new TypeError(cause) })
}

module.exports = {
multipartFormDataParser,
validateBoundary
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "undici",
"version": "7.0.0-alpha.2",
"version": "7.0.0-alpha.3",
"description": "An HTTP/1.1 client, written from scratch for Node.js",
"homepage": "https://undici.nodejs.org",
"bugs": {
Expand Down
59 changes: 59 additions & 0 deletions test/busboy/issue-3760.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
'use strict'

const { test } = require('node:test')
const assert = require('node:assert')
const { Response } = require('../..')

// https://github.com/nodejs/undici/issues/3760
test('filename* parameter is parsed properly', async (t) => {
const response = new Response([
'--83d82e0d-9ced-44c0-ac79-4e66a827415b\r\n' +
'Content-Type: text/plain\r\n' +
'Content-Disposition: form-data; name="file"; filename*=UTF-8\'\'%e2%82%ac%20rates\r\n' +
'\r\n' +
'testabc\r\n' +
'--83d82e0d-9ced-44c0-ac79-4e66a827415b--\r\n' +
'\r\n'
].join(''), {
headers: {
'content-type': 'multipart/form-data; boundary="83d82e0d-9ced-44c0-ac79-4e66a827415b"'
}
})

const fd = await response.formData()
assert.deepEqual(fd.get('file').name, '€ rates')
})

test('whitespace after filename[*]= is ignored', async () => {
for (const response of [
new Response([
'--83d82e0d-9ced-44c0-ac79-4e66a827415b\r\n' +
'Content-Type: text/plain\r\n' +
'Content-Disposition: form-data; name="file"; filename*= utf-8\'\'hello\r\n' +
'\r\n' +
'testabc\r\n' +
'--83d82e0d-9ced-44c0-ac79-4e66a827415b--\r\n' +
'\r\n'
].join(''), {
headers: {
'content-type': 'multipart/form-data; boundary="83d82e0d-9ced-44c0-ac79-4e66a827415b"'
}
}),
new Response([
'--83d82e0d-9ced-44c0-ac79-4e66a827415b\r\n' +
'Content-Type: text/plain\r\n' +
'Content-Disposition: form-data; name="file"; filename= "hello"\r\n' +
'\r\n' +
'testabc\r\n' +
'--83d82e0d-9ced-44c0-ac79-4e66a827415b--\r\n' +
'\r\n'
].join(''), {
headers: {
'content-type': 'multipart/form-data; boundary="83d82e0d-9ced-44c0-ac79-4e66a827415b"'
}
})
]) {
const fd = await response.formData()
assert.deepEqual(fd.get('file').name, 'hello')
}
})
Loading

0 comments on commit 78695b8

Please sign in to comment.