Mirror: A Node.js fetch shim using built-in Request, Response, and Headers (but without native fetch)
1// See: https://github.com/remix-run/web-std-io/blob/7a8596e/packages/fetch/test/utils/server.js
2
3import http from 'http';
4import zlib from 'zlib';
5import Busboy from 'busboy';
6import { once } from 'events';
7
8export default class TestServer {
9 constructor() {
10 this.server = http.createServer(this.router);
11 // Node 8 default keepalive timeout is 5000ms
12 // make it shorter here as we want to close server quickly at the end of tests
13 this.server.keepAliveTimeout = 1000;
14 this.server.on('error', err => {
15 console.log(err.stack);
16 });
17 this.server.on('connection', socket => {
18 socket.setTimeout(1500);
19 });
20 }
21
22 async start() {
23 this.server.listen(0, 'localhost');
24 return once(this.server, 'listening');
25 }
26
27 async stop() {
28 this.server.close();
29 return once(this.server, 'close');
30 }
31
32 get port() {
33 return this.server.address().port;
34 }
35
36 get hostname() {
37 return 'localhost';
38 }
39
40 mockResponse(responseHandler) {
41 this.server.nextResponseHandler = responseHandler;
42 return `http://${this.hostname}:${this.port}/mocked`;
43 }
44
45 mock(handler) {
46 this.server.nextResponseHandler = handler;
47 return `http://${this.hostname}:${this.port}/mocked`;
48 }
49
50 router(request, res) {
51 const p = request.url;
52
53 if (p === '/mocked') {
54 if (this.nextResponseHandler) {
55 this.nextResponseHandler(request, res);
56 this.nextResponseHandler = undefined;
57 } else {
58 throw new Error("No mocked response. Use 'TestServer.mockResponse()'.");
59 }
60 }
61
62 if (p === '/hello') {
63 res.statusCode = 200;
64 res.setHeader('Content-Type', 'text/plain');
65 res.end('world');
66 }
67
68 if (p.includes('question')) {
69 res.statusCode = 200;
70 res.setHeader('Content-Type', 'text/plain');
71 res.end('ok');
72 }
73
74 if (p === '/plain') {
75 res.statusCode = 200;
76 res.setHeader('Content-Type', 'text/plain');
77 res.end('text');
78 }
79
80 if (p === '/no-status-text') {
81 res.writeHead(200, '', {}).end();
82 }
83
84 if (p === '/options') {
85 res.statusCode = 200;
86 res.setHeader('Allow', 'GET, HEAD, OPTIONS');
87 res.end('hello world');
88 }
89
90 if (p === '/html') {
91 res.statusCode = 200;
92 res.setHeader('Content-Type', 'text/html');
93 res.end('<html></html>');
94 }
95
96 if (p === '/json') {
97 res.statusCode = 200;
98 res.setHeader('Content-Type', 'application/json');
99 res.end(
100 JSON.stringify({
101 name: 'value',
102 })
103 );
104 }
105
106 if (p === '/gzip') {
107 res.statusCode = 200;
108 res.setHeader('Content-Type', 'text/plain');
109 res.setHeader('Content-Encoding', 'gzip');
110 zlib.gzip('hello world', (err, buffer) => {
111 if (err) {
112 throw err;
113 }
114
115 res.end(buffer);
116 });
117 }
118
119 if (p === '/gzip-truncated') {
120 res.statusCode = 200;
121 res.setHeader('Content-Type', 'text/plain');
122 res.setHeader('Content-Encoding', 'gzip');
123 zlib.gzip('hello world', (err, buffer) => {
124 if (err) {
125 throw err;
126 }
127
128 // Truncate the CRC checksum and size check at the end of the stream
129 res.end(buffer.slice(0, -8));
130 });
131 }
132
133 if (p === '/gzip-capital') {
134 res.statusCode = 200;
135 res.setHeader('Content-Type', 'text/plain');
136 res.setHeader('Content-Encoding', 'GZip');
137 zlib.gzip('hello world', (err, buffer) => {
138 if (err) {
139 throw err;
140 }
141
142 res.end(buffer);
143 });
144 }
145
146 if (p === '/deflate') {
147 res.statusCode = 200;
148 res.setHeader('Content-Type', 'text/plain');
149 res.setHeader('Content-Encoding', 'deflate');
150 zlib.deflate('hello world', (err, buffer) => {
151 if (err) {
152 throw err;
153 }
154
155 res.end(buffer);
156 });
157 }
158
159 if (p === '/brotli') {
160 res.statusCode = 200;
161 res.setHeader('Content-Type', 'text/plain');
162 if (typeof zlib.createBrotliDecompress === 'function') {
163 res.setHeader('Content-Encoding', 'br');
164 zlib.brotliCompress('hello world', (err, buffer) => {
165 if (err) {
166 throw err;
167 }
168
169 res.end(buffer);
170 });
171 }
172 }
173
174 if (p === '/deflate-raw') {
175 res.statusCode = 200;
176 res.setHeader('Content-Type', 'text/plain');
177 res.setHeader('Content-Encoding', 'deflate');
178 zlib.deflateRaw('hello world', (err, buffer) => {
179 if (err) {
180 throw err;
181 }
182
183 res.end(buffer);
184 });
185 }
186
187 if (p === '/sdch') {
188 res.statusCode = 200;
189 res.setHeader('Content-Type', 'text/plain');
190 res.setHeader('Content-Encoding', 'sdch');
191 res.end('fake sdch string');
192 }
193
194 if (p === '/invalid-content-encoding') {
195 res.statusCode = 200;
196 res.setHeader('Content-Type', 'text/plain');
197 res.setHeader('Content-Encoding', 'gzip');
198 res.end('fake gzip string');
199 }
200
201 if (p === '/timeout') {
202 setTimeout(() => {
203 res.statusCode = 200;
204 res.setHeader('Content-Type', 'text/plain');
205 res.end('text');
206 }, 1000);
207 }
208
209 if (p === '/slow') {
210 res.statusCode = 200;
211 res.setHeader('Content-Type', 'text/plain');
212 res.write('test');
213 setTimeout(() => {
214 res.end('test');
215 }, 1000);
216 }
217
218 if (p === '/cookie') {
219 res.statusCode = 200;
220 res.setHeader('Set-Cookie', ['a=1', 'b=1']);
221 res.end('cookie');
222 }
223
224 if (p === '/size/chunk') {
225 res.statusCode = 200;
226 res.setHeader('Content-Type', 'text/plain');
227 setTimeout(() => {
228 res.write('test');
229 }, 10);
230 setTimeout(() => {
231 res.end('test');
232 }, 20);
233 }
234
235 if (p === '/size/long') {
236 res.statusCode = 200;
237 res.setHeader('Content-Type', 'text/plain');
238 res.end('testtest');
239 }
240
241 if (p === '/redirect/301') {
242 res.statusCode = 301;
243 res.setHeader('Location', '/inspect');
244 res.end();
245 }
246
247 if (p === '/redirect/301/file') {
248 res.statusCode = 301;
249 res.setHeader('Location', 'file://inspect');
250 res.end();
251 }
252
253 if (p === '/redirect/301/rn') {
254 res.statusCode = 301;
255 res.setHeader('Location', '/403');
256 res.write('301 Permanently moved.\r\n');
257 res.end();
258 }
259
260 if (p === '/403') {
261 res.statusCode = 403;
262 res.write('403 Forbidden');
263 res.end();
264 }
265
266 if (p === '/redirect/302') {
267 res.statusCode = 302;
268 res.setHeader('Location', '/inspect');
269 res.end();
270 }
271
272 if (p === '/redirect/303') {
273 res.statusCode = 303;
274 res.setHeader('Location', '/inspect');
275 res.end();
276 }
277
278 if (p === '/redirect/307') {
279 res.statusCode = 307;
280 res.setHeader('Location', '/inspect');
281 res.end();
282 }
283
284 if (p === '/redirect/308') {
285 res.statusCode = 308;
286 res.setHeader('Location', '/inspect');
287 res.end();
288 }
289
290 if (p === '/redirect/chain') {
291 res.statusCode = 301;
292 res.setHeader('Location', '/redirect/301');
293 res.end();
294 }
295
296 if (p === '/redirect/no-location') {
297 res.statusCode = 301;
298 res.end();
299 }
300
301 if (p === '/redirect/slow') {
302 res.statusCode = 301;
303 res.setHeader('Location', '/redirect/301');
304 setTimeout(() => {
305 res.end();
306 }, 1000);
307 }
308
309 if (p === '/redirect/slow-chain') {
310 res.statusCode = 301;
311 res.setHeader('Location', '/redirect/slow');
312 setTimeout(() => {
313 res.end();
314 }, 10);
315 }
316
317 if (p === '/redirect/slow-stream') {
318 res.statusCode = 301;
319 res.setHeader('Location', '/slow');
320 res.end();
321 }
322
323 if (p === '/redirect/bad-location') {
324 res.socket.write('HTTP/1.1 301\r\nLocation: ☃\r\nContent-Length: 0\r\n');
325 res.socket.end('\r\n');
326 }
327
328 if (p === '/redirect/chunked') {
329 res.writeHead(301, {
330 Location: '/inspect',
331 'Transfer-Encoding': 'chunked',
332 });
333 setTimeout(() => res.end(), 10);
334 }
335
336 if (p === '/error/400') {
337 res.statusCode = 400;
338 res.setHeader('Content-Type', 'text/plain');
339 res.end('client error');
340 }
341
342 if (p === '/error/404') {
343 res.statusCode = 404;
344 res.setHeader('Content-Encoding', 'gzip');
345 res.end();
346 }
347
348 if (p === '/error/500') {
349 res.statusCode = 500;
350 res.setHeader('Content-Type', 'text/plain');
351 res.end('server error');
352 }
353
354 if (p === '/error/reset') {
355 res.destroy();
356 }
357
358 if (p === '/error/premature') {
359 res.writeHead(200, { 'content-length': 50 });
360 res.write('foo');
361 setTimeout(() => {
362 res.destroy();
363 }, 100);
364 }
365
366 if (p === '/error/premature/chunked') {
367 res.writeHead(200, {
368 'Content-Type': 'application/json',
369 'Transfer-Encoding': 'chunked',
370 });
371
372 res.write(`${JSON.stringify({ data: 'hi' })}\n`);
373
374 setTimeout(() => {
375 res.write(`${JSON.stringify({ data: 'bye' })}\n`);
376 }, 50);
377
378 setTimeout(() => {
379 res.destroy();
380 }, 100);
381 }
382
383 if (p === '/chunked/split-ending') {
384 res.socket.write('HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\n\r\n');
385 res.socket.write('3\r\nfoo\r\n3\r\nbar\r\n');
386
387 setTimeout(() => {
388 res.socket.write('0\r\n');
389 }, 10);
390
391 setTimeout(() => {
392 res.socket.end('\r\n');
393 }, 20);
394 }
395
396 if (p === '/chunked/multiple-ending') {
397 res.socket.write('HTTP/1.1 200\r\nTransfer-Encoding: chunked\r\n\r\n');
398 res.socket.write('3\r\nfoo\r\n3\r\nbar\r\n0\r\n\r\n');
399 res.end();
400 }
401
402 if (p === '/error/json') {
403 res.statusCode = 200;
404 res.setHeader('Content-Type', 'application/json');
405 res.end('invalid json');
406 }
407
408 if (p === '/no-content') {
409 res.statusCode = 204;
410 res.end();
411 }
412
413 if (p === '/no-content/gzip') {
414 res.statusCode = 204;
415 res.setHeader('Content-Encoding', 'gzip');
416 res.end();
417 }
418
419 if (p === '/no-content/brotli') {
420 res.statusCode = 204;
421 res.setHeader('Content-Encoding', 'br');
422 res.end();
423 }
424
425 if (p === '/not-modified') {
426 res.statusCode = 304;
427 res.end();
428 }
429
430 if (p === '/not-modified/gzip') {
431 res.statusCode = 304;
432 res.setHeader('Content-Encoding', 'gzip');
433 res.end();
434 }
435
436 if (p === '/inspect') {
437 res.statusCode = 200;
438 res.setHeader('Content-Type', 'application/json');
439 res.setHeader('X-Inspect', 'inspect');
440 let body = '';
441 request.on('data', c => {
442 body += c;
443 });
444 request.on('end', () => {
445 res.end(
446 JSON.stringify({
447 inspect: true,
448 method: request.method,
449 url: request.url,
450 headers: request.headers,
451 body,
452 })
453 );
454 });
455 }
456
457 if (p === '/multipart') {
458 res.statusCode = 200;
459 res.setHeader('Content-Type', 'application/json');
460 const busboy = new Busboy({ headers: request.headers });
461 let body = '';
462 busboy.on('file', async (fieldName, file, fileName) => {
463 body += `${fieldName}=${fileName}`;
464 // consume file data
465 // eslint-disable-next-line no-empty, no-unused-vars
466 for await (const c of file) {
467 }
468 });
469
470 busboy.on('field', (fieldName, value) => {
471 body += `${fieldName}=${value}`;
472 });
473 busboy.on('finish', () => {
474 res.end(
475 JSON.stringify({
476 method: request.method,
477 url: request.url,
478 headers: request.headers,
479 body,
480 })
481 );
482 });
483 request.pipe(busboy);
484 }
485
486 if (p === '/m%C3%B6bius') {
487 res.statusCode = 200;
488 res.setHeader('Content-Type', 'text/plain');
489 res.end('ok');
490 }
491 }
492}