'use strict'
const { tspl } = require('@matteo.collina/tspl')
const { test } = require('node:test')
const net = require('node:net')
const { Client, errors, fetch } = require('..')
const truncatedChunkedResponse = Buffer.from(
'HTTP/1.1 200 OK\r\n' +
'Transfer-Encoding: chunked\r\n' +
'Connection: close\r\n' +
'\r\n' +
'3\r\n' +
'hel\r\n'
)
function createTrackedServer (onConnection) {
const sockets = new Set()
const server = net.createServer(socket => {
sockets.add(socket)
socket.on('close', () => {
sockets.delete(socket)
})
onConnection(socket)
})
return {
server,
close () {
for (const socket of sockets) {
socket.destroy()
}
return new Promise((resolve, reject) => {
server.close(err => {
if (err != null) {
reject(err)
return
}
resolve()
})
})
}
}
}
function listen (server) {
return new Promise(resolve => server.listen(0, resolve))
}
test('https://github.com/mcollina/undici/issues/268', async (t) => {
const ctx = t
t = tspl(t, { plan: 2 })
const { server, close } = createTrackedServer(socket => {
socket.write('HTTP/1.1 200 OK\r\n')
socket.write('Transfer-Encoding: chunked\r\n\r\n')
setTimeout(() => {
socket.write('1\r\n')
socket.write('\n\r\n')
setTimeout(() => {
socket.write('1\r\n')
socket.write('\n\r\n')
}, 500)
}, 500)
})
await listen(server)
const client = new Client(`http://localhost:${server.address().port}`)
ctx.after(async () => {
client.destroy()
await close()
})
client.on('disconnect', () => {
if (!client.closed && !client.destroyed) {
t.fail('unexpected disconnect')
}
})
client.request({
method: 'GET',
path: '/nxt/_changes?feed=continuous&heartbeat=5000',
headersTimeout: 1e3
}, (err, data) => {
t.ifError(err)
data.body.resume()
setTimeout(() => {
t.ok(true, 'pass')
data.body.on('error', () => {})
}, 2e3)
})
await t.completed
})
test('parser fail', async (t) => {
const ctx = t
t = tspl(t, { plan: 2 })
const { server, close } = createTrackedServer(socket => {
socket.end('HTT/1.1 200 OK\r\n')
})
await listen(server)
const client = new Client(`http://localhost:${server.address().port}`)
ctx.after(async () => {
client.destroy()
await close()
})
client.request({
method: 'GET',
path: '/'
}, (err, data) => {
t.ok(err)
t.ok(err instanceof errors.HTTPParserError)
})
await t.completed
})
test('split header field', async (t) => {
const ctx = t
t = tspl(t, { plan: 2 })
const { server, close } = createTrackedServer(socket => {
socket.write('HTTP/1.1 200 OK\r\nA')
setTimeout(() => {
socket.end('SD: asd,asd\r\nContent-Length: 0\r\n\r\n')
}, 100)
})
await listen(server)
const client = new Client(`http://localhost:${server.address().port}`)
ctx.after(async () => {
client.destroy()
await close()
})
client.request({
method: 'GET',
path: '/'
}, (err, data) => {
t.ifError(err)
t.equal(data.headers.asd, 'asd,asd')
data.body.resume()
})
await t.completed
})
test('split header value', async (t) => {
const ctx = t
t = tspl(t, { plan: 2 })
const { server, close } = createTrackedServer(socket => {
socket.write('HTTP/1.1 200 OK\r\nASD: asd')
setTimeout(() => {
socket.end(',asd\r\nContent-Length: 0\r\n\r\n')
}, 100)
})
await listen(server)
const client = new Client(`http://localhost:${server.address().port}`)
ctx.after(async () => {
client.destroy()
await close()
})
client.request({
method: 'GET',
path: '/'
}, (err, data) => {
t.ifError(err)
t.equal(data.headers.asd, 'asd,asd')
data.body.resume()
})
await t.completed
})
test('refreshes wasm input view after reallocating parser buffer', async (t) => {
const ctx = t
t = tspl(t, { plan: 4 })
const smallBody = Buffer.from('ok')
const largeBody = Buffer.alloc(8192, 'a')
const responses = [
Buffer.concat([
Buffer.from(`HTTP/1.1 200 OK\r\nContent-Length: ${smallBody.length}\r\n\r\n`),
smallBody
]),
Buffer.concat([
Buffer.from(`HTTP/1.1 200 OK\r\nContent-Length: ${largeBody.length}\r\n\r\n`),
largeBody
])
]
const { server, close } = createTrackedServer(socket => {
let responsesSent = 0
socket.on('data', () => {
socket.write(responses[responsesSent++])
})
})
await listen(server)
const client = new Client(`http://localhost:${server.address().port}`)
ctx.after(async () => {
client.destroy()
await close()
})
function request () {
return new Promise((resolve, reject) => {
client.request({
method: 'GET',
path: '/'
}, (err, { statusCode, body } = {}) => {
if (err) {
reject(err)
return
}
const bufs = []
body.on('data', buf => {
bufs.push(buf)
})
body.on('end', () => {
resolve({
statusCode,
body: Buffer.concat(bufs)
})
})
body.on('error', reject)
})
})
}
const smallResponse = await request()
t.strictEqual(smallResponse.statusCode, 200)
t.strictEqual(smallResponse.body.toString(), smallBody.toString())
const largeResponse = await request()
t.strictEqual(largeResponse.statusCode, 200)
t.strictEqual(largeResponse.body.toString(), largeBody.toString())
})
test('truncated chunked responses terminated by EOF error the response body', async (t) => {
const ctx = t
t = tspl(t, { plan: 3 })
const server = net.createServer((socket) => {
socket.end(truncatedChunkedResponse)
})
ctx.after(() => server.close())
await new Promise(resolve => server.listen(0, resolve))
const client = new Client(`http://localhost:${server.address().port}`)
ctx.after(() => client.destroy())
client.request({
method: 'GET',
path: '/'
}, (err, { body } = {}) => {
t.ifError(err)
body
.on('end', () => {
t.fail('expected the truncated chunked body to fail')
})
.on('error', (err) => {
t.strictEqual(err.name, 'HTTPParserError')
t.strictEqual(err.message, 'Response does not match the HTTP/1.1 protocol (Invalid EOF state)')
})
.resume()
})
await t.completed
})
test('fetch rejects truncated chunked responses terminated by EOF', async (t) => {
const ctx = t
t = tspl(t, { plan: 3 })
const server = net.createServer((socket) => {
socket.end(truncatedChunkedResponse)
})
ctx.after(() => server.close())
await new Promise(resolve => server.listen(0, resolve))
const res = await fetch(`http://localhost:${server.address().port}`)
t.strictEqual(res.status, 200)
try {
await res.text()
t.fail('expected fetch to reject the truncated chunked body')
} catch (err) {
t.strictEqual(err.name, 'TypeError')
t.strictEqual(err.cause?.message, 'Response does not match the HTTP/1.1 protocol (Invalid EOF state)')
}
})
// https://github.com/nodejs/undici/issues/5360
// When the HTTP/1 parser is paused under backpressure (body not consumed)
// and the socket ends (FIN), parser.finish() must not assert(!this.paused).
// That assert throws an uncatchable AssertionError from the socket 'end' handler.
// The process should survive with a catchable error instead.
test('no crash when parser paused and socket ends (issue 5360)', async (t) => {
t = tspl(t, { plan: 2 })
const { spawnSync } = require('node:child_process')
const { join } = require('node:path')
const code = `
const { createServer } = require('node:net')
const { fetch } = require('./index')
const BODY = Buffer.alloc(64 * 1024, 0x61)
const server = createServer((sock) => {
sock.once('data', () => {
sock.write(
'HTTP/1.1 200 OK\\r\\n' +
'Content-Length: ' + BODY.length + '\\r\\n' +
'Connection: close\\r\\n\\r\\n'
)
sock.write(BODY)
sock.end()
})
})
server.listen(0, '127.0.0.1', async () => {
const { port } = server.address()
const res = await fetch('http://127.0.0.1:' + port + '/')
// Never read the body -> parser pauses under backpressure
await new Promise((r) => setTimeout(r, 1000))
console.log('no crash')
server.close()
})
`
const res = spawnSync(process.execPath, ['-e', code], {
cwd: join(__dirname, '..'),
timeout: 5000,
stdio: 'pipe'
})
const stderr = res.stderr.toString()
const stdout = res.stdout.toString()
// Must not contain AssertionError in stderr
t.ok(!stderr.includes('AssertionError'), 'stderr must not contain AssertionError')
// Must print 'no crash'
t.equal(stdout.trim(), 'no crash', 'must print no crash')
})