import http from 'node:http'; import zlib from 'node:zlib'; import {once} from 'node:events'; import busboy from 'busboy'; export default class TestServer { constructor(hostname) { this.server = http.createServer(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); }); this.hostname = hostname || 'localhost'; } async start() { let host = this.hostname; if (host.startsWith('[')) { // If we're trying to listen on an IPv6 literal hostname, strip the // square brackets before binding to the IPv6 address host = host.slice(1, -1); } this.server.listen(0, host); return once(this.server, 'listening'); } async stop() { this.server.close(); return once(this.server, 'close'); } get port() { return this.server.address().port; } mockResponse(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.mockResponse()’.'); } } 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(''); } 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 === '/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 === '/empty/deflate') { res.statusCode = 200; res.setHeader('Content-Type', 'text/plain'); res.setHeader('Content-Encoding', 'deflate'); res.end(); } 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/301/invalid') { res.statusCode = 301; res.setHeader('Location', '//super:invalid:url%/'); res.end(); } if (p.startsWith('/redirect-to/3')) { res.statusCode = p.slice(13, 16); res.setHeader('Location', p.slice(17)); res.end(); } if (p === '/redirect/301/otherhost') { res.statusCode = 301; res.setHeader('Location', 'https://github.com/node-fetch'); 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 === '/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 === '/redirect/referrer-policy') { res.statusCode = 301; res.setHeader('Location', '/inspect'); res.setHeader('Referrer-Policy', 'foo unsafe-url bar'); res.end(); } if (p === '/redirect/referrer-policy/same-origin') { res.statusCode = 301; res.setHeader('Location', '/inspect'); res.setHeader('Referrer-Policy', 'foo unsafe-url same-origin bar'); res.end(); } if (p === '/redirect/chunked') { res.writeHead(301, { Location: '/inspect', 'Transfer-Encoding': 'chunked' }); setTimeout(() => res.end(), 10); } 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 === '/chunked/split-ending') { res.socket.write('HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\n\r\n'); res.socket.write('3\r\nfoo\r\n3\r\nbar\r\n'); setTimeout(() => { res.socket.write('0\r\n'); }, 10); setTimeout(() => { res.socket.end('\r\n'); }, 20); } if (p === '/chunked/multiple-ending') { res.socket.write('HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\n\r\n'); res.socket.write('3\r\nfoo\r\n3\r\nbar\r\n0\r\n\r\n'); } 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') { let body = ''; res.statusCode = 200; res.setHeader('Content-Type', 'application/json'); const bb = busboy({headers: request.headers}); bb.on('file', async (fieldName, file, info) => { body += `${fieldName}=${info.filename}`; // consume file data // eslint-disable-next-line no-empty, no-unused-vars for await (const c of file) {} }); bb.on('field', (fieldName, value) => { body += `${fieldName}=${value}`; }); bb.on('close', () => { res.end(JSON.stringify({ method: request.method, url: request.url, headers: request.headers, body })); }); request.pipe(bb); } if (p === '/m%C3%B6bius') { res.statusCode = 200; res.setHeader('Content-Type', 'text/plain'); res.end('ok'); } } }