Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,24 @@ Using [benchmark-http2.js](https://github.com/nodejs/undici/blob/main/benchmarks

Node.js includes a built-in `fetch()` implementation powered by undici starting from Node.js v18. However, there are important differences between using the built-in fetch and installing undici as a separate module.

### WebAssembly requirement

Undici's HTTP/1 parser requires WebAssembly. There is no alternative parser when
WebAssembly is unavailable, for example in Node.js configurations where
`--jitless` disables it. The existence of `fetch` does not imply that it can make
HTTP/1 requests in that environment.

When the HTTP/1 parser is needed without WebAssembly, Client and dispatcher
requests fail with an `Error` whose code is `ERR_WEBASSEMBLY_NOT_SUPPORTED`.
`fetch` rejects with a `TypeError` carrying that error in `error.cause`. Importing
Undici, accessing `fetch`, and using `Headers`, `FormData`, `Request`, or `Response`
do not themselves require the HTTP/1 parser.

The error is defined locally in Undici and does not depend on Node.js providing
that error code. For built-in fetch, this behavior depends on the bundled Undici
version. Node.js receives upstream fixes through its normal vendored Undici
update, rather than changes to the generated bundle alone.

### Built-in Fetch (Node.js v18+)

Node.js's built-in fetch is powered by a bundled version of undici:
Expand Down
31 changes: 30 additions & 1 deletion docs/docs/api/Errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ if (err instanceof errors.ConnectTimeoutError) {
}
```

All errors, except [`HTTPParserError`][], extend [`UndiciError`][]. Each error
All errors, except [`HTTPParserError`][] and `WebAssemblyNotSupportedError`, extend [`UndiciError`][]. Each error
carries a stable `code` string (for example `UND_ERR_CONNECT_TIMEOUT`) and a
`name`.

Expand Down Expand Up @@ -52,6 +52,35 @@ on a well-known symbol rather than the prototype chain.
* `name` {string} Always `'UndiciError'`.
* `code` {string} Always `'UND_ERR'`.

## Class: `WebAssemblyNotSupportedError`

* Extends: {Error}

WebAssembly is unavailable when Undici initializes its HTTP/1 parser. Undici does
not provide an alternative parser for environments without WebAssembly, including
Node.js configurations where `--jitless` disables it. The check is performed when
the parser is needed, not when importing Undici or accessing `fetch`.

* `name` {string} Always `'Error'`.
* `code` {string} Always `'ERR_WEBASSEMBLY_NOT_SUPPORTED'`.
* `message` {string} By default `'WebAssembly is not supported in this environment, but is required for HTTP/1 parsing'`.

Client and dispatcher requests report this error directly. `fetch` rejects with
a `TypeError` whose `cause` is this error:

```js
try {
await fetch('http://localhost:3000')
} catch (error) {
if (error.cause?.code === 'ERR_WEBASSEMBLY_NOT_SUPPORTED') {
// HTTP/1 parsing requires WebAssembly in this environment.
}
}
```

`Headers`, `FormData`, `Request`, `Response`, and other operations that do not need
the HTTP/1 parser remain usable without WebAssembly.

## Class: `ConnectTimeoutError`

<!-- YAML
Expand Down
17 changes: 17 additions & 0 deletions lib/core/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,22 @@ class BalancedPoolMissingUpstreamError extends UndiciError {
}
}

const kWebAssemblyNotSupportedError = Symbol.for('undici.error.ERR_WEBASSEMBLY_NOT_SUPPORTED')
class WebAssemblyNotSupportedError extends Error {
constructor (message) {
super(message || 'WebAssembly is not supported in this environment, but is required for HTTP/1 parsing')
this.code = 'ERR_WEBASSEMBLY_NOT_SUPPORTED'
}

static [Symbol.hasInstance] (instance) {
return instance && instance[kWebAssemblyNotSupportedError] === true
}

get [kWebAssemblyNotSupportedError] () {
return true
}
}

const kHTTPParserError = Symbol.for('undici.error.UND_ERR_HTTP_PARSER')
class HTTPParserError extends Error {
constructor (message, code, data) {
Expand Down Expand Up @@ -487,6 +503,7 @@ class MessageSizeExceededError extends UndiciError {

module.exports = {
AbortError,
WebAssemblyNotSupportedError,
HTTPParserError,
UndiciError,
HeadersTimeoutError,
Expand Down
6 changes: 6 additions & 0 deletions lib/dispatcher/client-h1.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const {
InformationalError,
BodyTimeoutError,
HTTPParserError,
WebAssemblyNotSupportedError,
ResponseExceededMaxSizeError
} = require('../core/errors.js')
const {
Expand Down Expand Up @@ -68,6 +69,11 @@ const kTypeOfService = Symbol('kTypeOfService')
let extractBody

function lazyllhttp () {
// Check only when HTTP/1 needs the parser, so other APIs remain usable without WASM.
if (typeof WebAssembly === 'undefined') {
throw new WebAssemblyNotSupportedError()
}

const llhttpWasmData = process.env.JEST_WORKER_ID ? require('../llhttp/llhttp-wasm.js') : undefined

let mod
Expand Down
19 changes: 9 additions & 10 deletions lib/dispatcher/parser-h1.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,15 +60,6 @@ if (process.env.UNDICI_NO_WASM_SIMD === '1') {
useWasmSIMD = true
}

if (useWasmSIMD) {
try {
miloPackage = require('../milo/src/simd/index.js')
} catch {}
}

miloPackage = miloPackage || require('../milo/src/no-simd/index.js')
const { setup } = miloPackage

const USE_NATIVE_TIMER = 0
const USE_FAST_TIMER = 1
const TIMEOUT_HEADERS = 2 | USE_FAST_TIMER
Expand Down Expand Up @@ -110,7 +101,15 @@ function getMilo () {
return miloInstance
}

miloInstance = setup()
// Milo modules compile WASM on load; defer loading until the parser is needed.
if (useWasmSIMD) {
try {
miloPackage = require('../milo/src/simd/index.js')
} catch {}
}

miloPackage = miloPackage || require('../milo/src/no-simd/index.js')
miloInstance = miloPackage.setup()
return miloInstance
}

Expand Down
31 changes: 31 additions & 0 deletions test/client-wasm-unavailable.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
'use strict'

const assert = require('node:assert/strict')
const { spawnSync } = require('node:child_process')
const { join } = require('node:path')
const { test } = require('node:test')

// Each process starts with an empty parser cache and must exit without open sockets.
for (const mode of ['deleted', 'undefined', 'jitless', 'normal', 'no-simd', 'fallback', 'milo', 'milo-unavailable']) {
test(`HTTP/1 WebAssembly support: ${mode}`, (t) => {
const args = mode === 'jitless' ? ['--jitless'] : []
const result = spawnSync(process.execPath, [
...args,
join(__dirname, 'fixtures/wasm-support.js'),
mode
], {
env: { ...process.env, UNDICI_NO_WASM_SIMD: '0', UNDICI_USE_MILO: '0' },
timeout: 10000,
encoding: 'utf8'
})

assert.ifError(result.error)
assert.equal(result.status, 0, result.stderr)
assert.equal(result.signal, null)
if (result.stdout.trim() === 'WebAssembly available under --jitless') {
t.skip('--jitless does not disable WebAssembly in this runtime')
return
}
assert.equal(result.stdout.trim(), 'ok')
})
}
1 change: 1 addition & 0 deletions test/errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const createScenario = (ErrorClass, defaultMessage, name, code) => ({
})

const scenarios = [
createScenario(errors.WebAssemblyNotSupportedError, 'WebAssembly is not supported in this environment, but is required for HTTP/1 parsing', 'Error', 'ERR_WEBASSEMBLY_NOT_SUPPORTED'),
createScenario(errors.UndiciError, '', 'UndiciError', 'UND_ERR'),
createScenario(errors.ConnectTimeoutError, 'Connect Timeout Error', 'ConnectTimeoutError', 'UND_ERR_CONNECT_TIMEOUT'),
createScenario(errors.HeadersTimeoutError, 'Headers Timeout Error', 'HeadersTimeoutError', 'UND_ERR_HEADERS_TIMEOUT'),
Expand Down
138 changes: 138 additions & 0 deletions test/fixtures/wasm-support.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
'use strict'

const assert = require('node:assert/strict')
const { once } = require('node:events')
const { createServer } = require('node:http')

const mode = process.argv[2]
if (mode === 'deleted') {
delete globalThis.WebAssembly
} else if (mode === 'undefined') {
globalThis.WebAssembly = undefined
} else if (mode === 'no-simd') {
process.env.UNDICI_NO_WASM_SIMD = '1'
} else if (mode === 'milo') {
process.env.UNDICI_USE_MILO = '1'
} else if (mode === 'milo-unavailable') {
process.env.UNDICI_USE_MILO = '1'
delete globalThis.WebAssembly
}

let compilations = 0
if (mode === 'fallback') {
const Module = WebAssembly.Module
// Reject the SIMD attempt while allowing the generic module to compile.
WebAssembly.Module = function (bytes) {
if (++compilations === 1) {
throw new WebAssembly.CompileError('SIMD unavailable for this test')
}
return new Module(bytes)
}
}

function checkError (error) {
assert.ok(error instanceof Error)
assert.ok(!(error instanceof TypeError))
assert.equal(error.name, 'Error')
assert.equal(error.code, 'ERR_WEBASSEMBLY_NOT_SUPPORTED')
assert.equal(error.message, 'WebAssembly is not supported in this environment, but is required for HTTP/1 parsing')
assert.equal(error.cause, undefined)
return true
}

async function checkIndependentAPIs (undici) {
assert.equal(typeof undici.fetch, 'function')
const headers = new undici.Headers({ 'x-test': 'value' })
assert.equal(headers.get('x-test'), 'value')
const form = new undici.FormData()
form.append('field', 'value')
assert.equal(form.get('field'), 'value')
const request = new undici.Request('http://localhost', { method: 'POST', body: form })
assert.equal((await request.formData()).get('field'), 'value')
const response = new undici.Response('usable')
assert.equal(await response.text(), 'usable')
// A fetch operation without the HTTP/1 parser remains possible.
assert.equal(await (await undici.fetch('data:text/plain,usable')).text(), 'usable')
}

async function main () {
if (mode === 'jitless' && typeof WebAssembly !== 'undefined') {
console.log('WebAssembly available under --jitless')
return
}

// Import after removing WASM to catch accidental eager compilation.
const undici = require('../..')
const nodeFetch = require('../../index-fetch')
await checkIndependentAPIs(undici)
await checkIndependentAPIs(nodeFetch)

const server = createServer((req, res) => res.end('ok'))
server.listen(0, '127.0.0.1')
await once(server, 'listening')
const origin = `http://127.0.0.1:${server.address().port}`
const client = new undici.Client(origin)
const agent = new undici.Agent()

try {
if (typeof WebAssembly === 'undefined') {
// Reusing the client must reject again rather than leave its queue stalled.
for (let i = 0; i < 2; i++) {
await assert.rejects(client.request({ path: '/', method: 'GET' }), checkError)
}
await assert.rejects(new Promise((resolve, reject) => {
client.dispatch({ path: '/', method: 'GET' }, {
onRequestStart () {},
onResponseStart () { reject(new Error('Unexpected response')) },
onResponseData () {},
onResponseEnd () { resolve() },
onResponseError (controller, error) { reject(error) }
})
}), checkError)
await assert.rejects(undici.request(origin, { dispatcher: agent }), checkError)
for (const { fetch } of [undici, nodeFetch]) {
await assert.rejects(fetch(origin, { dispatcher: agent }), error => {
assert.ok(error instanceof TypeError)
assert.equal(error.message, 'fetch failed')
return checkError(error.cause)
})
}
await checkIndependentAPIs(undici)
// Node's native HTTP parser is independent of Undici's WASM parser.
const { get } = require('node:http')
await new Promise((resolve, reject) => {
get(origin, res => {
res.resume()
res.on('end', resolve)
res.on('error', reject)
}).on('error', reject)
})
} else {
const response = await client.request({ path: '/', method: 'GET' })
assert.equal(response.statusCode, 200)
assert.equal(await response.body.text(), 'ok')
const fetched = await undici.fetch(origin, { dispatcher: agent })
assert.equal(fetched.status, 200)
assert.equal(await fetched.text(), 'ok')
if (mode === 'fallback') {
assert.equal(compilations, 2)
}
}
} finally {
await client.destroy()
await agent.destroy()
await new Promise((resolve, reject) => server.close(error => {
if (error) {
reject(error)
} else {
resolve()
}
}))
}
console.log('ok')
}

main().catch(error => {
console.error(error)
process.exitCode = 1
})
5 changes: 5 additions & 0 deletions test/types/errors.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ expectAssignable<errors.UndiciError>(new errors.UndiciError())
expectAssignable<string>(new errors.UndiciError().name)
expectAssignable<string>(new errors.UndiciError().code)

expectAssignable<Error>(new errors.WebAssemblyNotSupportedError())
expectAssignable<errors.WebAssemblyNotSupportedError>(new errors.WebAssemblyNotSupportedError())
expectAssignable<'Error'>(new errors.WebAssemblyNotSupportedError().name)
expectAssignable<'ERR_WEBASSEMBLY_NOT_SUPPORTED'>(new errors.WebAssemblyNotSupportedError().code)

expectAssignable<errors.UndiciError>(new errors.ConnectTimeoutError())
expectAssignable<errors.ConnectTimeoutError>(new errors.ConnectTimeoutError())
expectAssignable<'ConnectTimeoutError'>(new errors.ConnectTimeoutError().name)
Expand Down
7 changes: 7 additions & 0 deletions types/errors.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,13 @@ declare namespace Errors {
code: string
}

/** WebAssembly is unavailable when initializing the HTTP/1 parser. */
export class WebAssemblyNotSupportedError extends Error {
constructor (message?: string)
name: 'Error'
code: 'ERR_WEBASSEMBLY_NOT_SUPPORTED'
}

/** The response exceed the length allowed. */
export class ResponseExceededMaxSizeError extends UndiciError {
name: 'ResponseExceededMaxSizeError'
Expand Down
Loading