'use strict'
const http = require('node:http')
const zlib = require('node:zlib')
const { once } = require('node:events')
const Busboy = require('@fastify/busboy')
module.exports = class TestServer {
constructor () {
this.server = http.createServer({ joinDuplicateHeaders: true }, this.router)
// Node 8 default keepalive timeout is 5000ms
// make it shorter here as we want to close server quickly at the end of tests
this.server.keepAliveTimeout = 1000
this.server.on('error', err => {
console.log(err.stack)
})
this.server.on('connection', socket => {
socket.setTimeout(1500)
})
}
async start () {
this.server.listen(0, 'localhost')
return once(this.server, 'listening')
}
async stop () {
// Destroy all active connections before closing
// This prevents 'close' from hanging on platforms like Windows
this.server.closeAllConnections()
this.server.close()
return once(this.server, 'close')
}
get port () {
return this.server.address().port
}
get hostname () {
return 'localhost'
}
mockState (responseHandler) {
this.server.nextResponseHandler = responseHandler
return `http://${this.hostname}:${this.port}/mocked`
}
router (request, res) {
const p = request.url
if (p === '/mocked') {
if (this.nextResponseHandler) {
this.nextResponseHandler(res)
this.nextResponseHandler = undefined
} else {
throw new Error('No mocked response. Use ’TestServer.mockState()’.')
}
}
if (p === '/hello') {
res.statusCode = 200
res.setHeader('Content-Type', 'text/plain')
res.end('world')
}
if (p.includes('question')) {
res.statusCode = 200
res.setHeader('Content-Type', 'text/plain')
res.end('ok')
}
if (p === '/plain') {
res.statusCode = 200
res.setHeader('Content-Type', 'text/plain')
res.end('text')
}
if (p === '/no-status-text') {
res.writeHead(200, '', {}).end()
}
if (p === '/options') {
res.statusCode = 200
res.setHeader('Allow', 'GET, HEAD, OPTIONS')
res.end('hello world')
}
if (p === '/html') {
res.statusCode = 200
res.setHeader('Content-Type', 'text/html')
res.end('<html></html>')
}
if (p === '/json') {
res.statusCode = 200
res.setHeader('Content-Type', 'application/json')
res.end(JSON.stringify({
name: 'value'
}))
}
if (p === '/gzip') {
res.statusCode = 200
res.setHeader('Content-Type', 'text/plain')
res.setHeader('Content-Encoding', 'gzip')
zlib.gzip('hello world', (err, buffer) => {
if (err) {
throw err
}
res.end(buffer)
})
}
if (p === '/gzip-truncated') {
res.statusCode = 200
res.setHeader('Content-Type', 'text/plain')
res.setHeader('Content-Encoding', 'gzip')
zlib.gzip('hello world', (err, buffer) => {
if (err) {
throw err
}
// Truncate the CRC checksum and size check at the end of the stream
res.end(buffer.slice(0, -8))
})
}
if (p === '/gzip-capital') {
res.statusCode = 200
res.setHeader('Content-Type', 'text/plain')
res.setHeader('Content-Encoding', 'GZip')
zlib.gzip('hello world', (err, buffer) => {
if (err) {
throw err
}
res.end(buffer)
})
}
if (p === '/deflate') {
res.statusCode = 200
res.setHeader('Content-Type', 'text/plain')
res.setHeader('Content-Encoding', 'deflate')
zlib.deflate('hello world', (err, buffer) => {
if (err) {
throw err
}
res.end(buffer)
})
}
if (p === '/brotli') {
res.statusCode = 200
res.setHeader('Content-Type', 'text/plain')
if (typeof zlib.createBrotliDecompress === 'function') {
res.setHeader('Content-Encoding', 'br')
zlib.brotliCompress('hello world', (err, buffer) => {
if (err) {
throw err
}
res.end(buffer)
})
}
}
if (p === '/multiunsupported') {
res.statusCode = 200
res.setHeader('Content-Type', 'text/plain')
if (typeof zlib.createBrotliDecompress === 'function') {
res.setHeader('Content-Encoding', 'br,asd,br')
res.end('multiunsupported')
}
}
if (p === '/multisupported') {
res.statusCode = 200
res.setHeader('Content-Type', 'text/plain')
if (typeof zlib.createBrotliDecompress === 'function') {
res.setHeader('Content-Encoding', 'br,br')
zlib.brotliCompress('hello world', (err, buffer) => {
if (err) {
throw err
}
zlib.brotliCompress(buffer, (err, buffer) => {
if (err) {
throw err
}
res.end(buffer)
})
})
}
}
if (p === '/deflate-raw') {
res.statusCode = 200
res.setHeader('Content-Type', 'text/plain')
res.setHeader('Content-Encoding', 'deflate')
zlib.deflateRaw('hello world', (err, buffer) => {
if (err) {
throw err
}
res.end(buffer)
})
}
if (p === '/sdch') {
res.statusCode = 200
res.setHeader('Content-Type', 'text/plain')
res.setHeader('Content-Encoding', 'sdch')
res.end('fake sdch string')
}
if (p === '/invalid-content-encoding') {
res.statusCode = 200
res.setHeader('Content-Type', 'text/plain')
res.setHeader('Content-Encoding', 'gzip')
res.end('fake gzip string')
}
if (p === '/timeout') {
setTimeout(() => {
res.statusCode = 200
res.setHeader('Content-Type', 'text/plain')
res.end('text')
}, 1000)
}
if (p === '/slow') {
res.statusCode = 200
res.setHeader('Content-Type', 'text/plain')
res.write('test')
setTimeout(() => {
res.end('test')
}, 1000)
}
if (p === '/cookie') {
res.statusCode = 200
res.setHeader('Set-Cookie', ['a=1', 'b=1'])
res.end('cookie')
}
if (p === '/size/chunk') {
res.statusCode = 200
res.setHeader('Content-Type', 'text/plain')
setTimeout(() => {
res.write('test')
}, 10)
setTimeout(() => {
res.end('test')
}, 20)
}
if (p === '/size/long') {
res.statusCode = 200
res.setHeader('Content-Type', 'text/plain')
res.end('testtest')
}
if (p === '/redirect/301') {
res.statusCode = 301
res.setHeader('Location', '/inspect')
res.end()
}
if (p === '/redirect/302') {
res.statusCode = 302
res.setHeader('Location', '/inspect')
res.end()
}
if (p === '/redirect/303') {
res.statusCode = 303
res.setHeader('Location', '/inspect')
res.end()
}
if (p === '/redirect/307') {
res.statusCode = 307
res.setHeader('Location', '/inspect')
res.end()
}
if (p === '/redirect/308') {
res.statusCode = 308
res.setHeader('Location', '/inspect')
res.end()
}
if (p === '/redirect/chain') {
res.statusCode = 301
res.setHeader('Location', '/redirect/301')
res.end()
}
if (p.startsWith('/redirect/chain/')) {
const count = parseInt(p.split('/').pop()) - 1
res.statusCode = 301
res.setHeader('Location', count ? `/redirect/chain/${count}` : '/redirect/301')
res.end()
}
if (p === '/redirect/no-location') {
res.statusCode = 301
res.end()
}
if (p === '/redirect/slow') {
res.statusCode = 301
res.setHeader('Location', '/redirect/301')
setTimeout(() => {
res.end()
}, 1000)
}
if (p === '/redirect/slow-chain') {
res.statusCode = 301
res.setHeader('Location', '/redirect/slow')
setTimeout(() => {
res.end()
}, 10)
}
if (p === '/redirect/slow-stream') {
res.statusCode = 301
res.setHeader('Location', '/slow')
res.end()
}
if (p === '/redirect/bad-location') {
res.socket.write('HTTP/1.1 301\r\nLocation: ☃\r\nContent-Length: 0\r\n')
res.socket.end('\r\n')
}
if (p === '/error/400') {
res.statusCode = 400
res.setHeader('Content-Type', 'text/plain')
res.end('client error')
}
if (p === '/error/404') {
res.statusCode = 404
res.setHeader('Content-Encoding', 'gzip')
res.end()
}
if (p === '/error/500') {
res.statusCode = 500
res.setHeader('Content-Type', 'text/plain')
res.end('server error')
}
if (p === '/error/reset') {
res.destroy()
}
if (p === '/error/premature') {
res.writeHead(200, { 'content-length': 50 })
res.write('foo')
setTimeout(() => {
res.destroy()
}, 100)
}
if (p === '/error/premature/chunked') {
res.writeHead(200, {
'Content-Type': 'application/json',
'Transfer-Encoding': 'chunked'
})
res.write(`${JSON.stringify({ data: 'hi' })}\n`)
setTimeout(() => {
res.write(`${JSON.stringify({ data: 'bye' })}\n`)
}, 200)
setTimeout(() => {
res.destroy()
}, 400)
}
if (p === '/error/json') {
res.statusCode = 200
res.setHeader('Content-Type', 'application/json')
res.end('invalid json')
}
if (p === '/no-content') {
res.statusCode = 204
res.end()
}
if (p === '/no-content/gzip') {
res.statusCode = 204
res.setHeader('Content-Encoding', 'gzip')
res.end()
}
if (p === '/no-content/brotli') {
res.statusCode = 204
res.setHeader('Content-Encoding', 'br')
res.end()
}
if (p === '/not-modified') {
res.statusCode = 304
res.end()
}
if (p === '/not-modified/gzip') {
res.statusCode = 304
res.setHeader('Content-Encoding', 'gzip')
res.end()
}
if (p === '/inspect') {
res.statusCode = 200
res.setHeader('Content-Type', 'application/json')
let body = ''
request.on('data', c => {
body += c
})
request.on('end', () => {
res.end(JSON.stringify({
method: request.method,
url: request.url,
headers: request.headers,
body
}))
})
}
if (p === '/multipart') {
res.statusCode = 200
res.setHeader('Content-Type', 'application/json')
const busboy = new Busboy({ headers: request.headers })
let body = ''
busboy.on('file', async (fieldName, file, fileName) => {
body += `${fieldName}=${fileName}`
// consume file data
// eslint-disable-next-line no-empty, no-unused-vars
for await (const c of file) {}
})
busboy.on('field', (fieldName, value) => {
body += `${fieldName}=${value}`
})
busboy.on('finish', () => {
res.end(JSON.stringify({
method: request.method,
url: request.url,
headers: request.headers,
body
}))
})
request.pipe(busboy)
}
if (p === '/m%C3%B6bius') {
res.statusCode = 200
res.setHeader('Content-Type', 'text/plain')
res.end('ok')
}
}
}