Mirror: A Node.js fetch shim using built-in Request, Response, and Headers (but without native fetch)
1// Source: https://github.com/remix-run/web-std-io/blob/7a8596e/packages/fetch/test/main.js
2
3import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
4
5import FormDataPolyfill from 'form-data';
6import { ReadableStream } from 'node:stream/web';
7import stream from 'node:stream';
8import vm from 'node:vm';
9
10import TestServer from './utils/server.js';
11import { fetch } from '../fetch';
12
13const { Uint8Array: VMUint8Array } = vm.runInNewContext('this');
14
15async function streamToPromise<T>(
16 stream: ReadableStream<T>,
17 dataHandler: (data: T) => void
18) {
19 for await (const chunk of stream) {
20 dataHandler(chunk);
21 }
22}
23
24async function collectStream<T>(stream: ReadableStream<T>): Promise<T[]> {
25 const chunks: T[] = [];
26 for await (const chunk of stream) chunks.push(chunk);
27 return chunks;
28}
29
30describe(fetch, () => {
31 const local = new TestServer();
32 let baseURL: string;
33
34 beforeEach(async () => {
35 await local.start();
36 baseURL = `http://${local.hostname}:${local.port}/`;
37 });
38
39 afterEach(async () => {
40 await local.stop();
41 });
42
43 it('should reject with error if url is protocol relative', async () => {
44 // [Type Error: Invalid URL]
45 await expect(() =>
46 fetch('//example.com/')
47 ).rejects.toThrowErrorMatchingInlineSnapshot(`[TypeError: Invalid URL]`);
48 });
49
50 it('should reject with error if url is relative path', async () => {
51 // [Type Error: Invalid URL]
52 await expect(() =>
53 fetch('/some/path')
54 ).rejects.toThrowErrorMatchingInlineSnapshot(`[TypeError: Invalid URL]`);
55 });
56
57 it('should reject with error if protocol is unsupported', async () => {
58 // URL scheme 'ftp' is not supported
59 await expect(
60 fetch('ftp://example.com/')
61 ).rejects.toThrowErrorMatchingInlineSnapshot(
62 `[TypeError: URL scheme "ftp:" is not supported.]`
63 );
64 });
65
66 it('should reject with error on network failure', async () => {
67 await expect(() => fetch('http://localhost:50000/')).rejects.toThrow();
68 }, 1_000);
69
70 it('should resolve into response', async () => {
71 const response = await fetch(new URL('hello', baseURL));
72 expect(response.url).toBe(`${baseURL}hello`);
73 expect(response).toBeInstanceOf(Response);
74 expect(response).toMatchObject({
75 headers: expect.any(Headers),
76 body: expect.any(ReadableStream),
77 bodyUsed: false,
78 ok: true,
79 status: 200,
80 statusText: 'OK',
81 });
82 });
83
84 it('should support https request', async () => {
85 const response = await fetch('https://github.com/', { method: 'HEAD' });
86 expect(response.status).toBe(200);
87 }, 5000);
88
89 describe('response methods', () => {
90 it('should accept plain text response', async () => {
91 const response = await fetch(new URL('plain', baseURL));
92 expect(response.headers.get('content-type')).toBe('text/plain');
93 const text = await response.text();
94 expect(response.bodyUsed).toBe(true);
95 expect(text).toBe('text');
96 });
97
98 it('should accept html response (like plain text)', async () => {
99 const response = await fetch(new URL('html', baseURL));
100 expect(response.headers.get('content-type')).toBe('text/html');
101 const text = await response.text();
102 expect(response.bodyUsed).toBe(true);
103 expect(text).toBe('<html></html>');
104 });
105
106 it('should accept json response', async () => {
107 const response = await fetch(new URL('json', baseURL));
108 expect(response.headers.get('content-type')).toBe('application/json');
109 const text = await response.json();
110 expect(response.bodyUsed).toBe(true);
111 expect(text).toEqual({ name: 'value' });
112 });
113 });
114
115 describe('request headers', () => {
116 it('should send request with custom headers', async () => {
117 const response = await fetch(new URL('inspect', baseURL), {
118 headers: { 'x-custom-header': 'abc' },
119 });
120 expect(await response.json()).toMatchObject({
121 headers: expect.objectContaining({ 'x-custom-header': 'abc' }),
122 });
123 });
124
125 it('should prefer init headers when Request is passed', async () => {
126 const request = new Request(new URL('inspect', baseURL), {
127 headers: { 'x-custom-header': 'abc' },
128 });
129 const response = await fetch(request, {
130 headers: { 'x-custom-header': 'def' },
131 });
132 expect(await response.json()).toMatchObject({
133 headers: expect.objectContaining({ 'x-custom-header': 'def' }),
134 });
135 });
136
137 it('should send request with custom User-Agent', async () => {
138 const response = await fetch(new URL('inspect', baseURL), {
139 headers: { 'user-agent': 'faked' },
140 });
141 expect(await response.json()).toMatchObject({
142 headers: expect.objectContaining({ 'user-agent': 'faked' }),
143 });
144 });
145
146 it('should set default Accept header', async () => {
147 const response = await fetch(new URL('inspect', baseURL));
148 expect(await response.json()).toMatchObject({
149 headers: expect.objectContaining({ accept: '*/*' }),
150 });
151 });
152
153 it('should send custom Accept header', async () => {
154 const response = await fetch(new URL('inspect', baseURL), {
155 headers: { accept: 'application/json' },
156 });
157 expect(await response.json()).toMatchObject({
158 headers: expect.objectContaining({ accept: 'application/json' }),
159 });
160 });
161
162 it('should accept headers instance', async () => {
163 const response = await fetch(new URL('inspect', baseURL), {
164 headers: new Headers({ 'x-custom-header': 'abc' }),
165 });
166 expect(await response.json()).toMatchObject({
167 headers: expect.objectContaining({ 'x-custom-header': 'abc' }),
168 });
169 });
170
171 it('should accept custom "host" header', async () => {
172 const response = await fetch(new URL('inspect', baseURL), {
173 headers: { host: 'example.com' },
174 });
175 expect(await response.json()).toMatchObject({
176 headers: expect.objectContaining({ host: 'example.com' }),
177 });
178 });
179
180 it('should accept custom "HoSt" header', async () => {
181 const response = await fetch(new URL('inspect', baseURL), {
182 headers: { HoSt: 'example.com' },
183 });
184 expect(await response.json()).toMatchObject({
185 headers: expect.objectContaining({ host: 'example.com' }),
186 });
187 });
188 });
189
190 describe('redirects', () => {
191 it.each([[301], [302], [303], [307], [308]])(
192 'should follow redirect code %d',
193 async status => {
194 const response = await fetch(new URL(`redirect/${status}`, baseURL));
195 expect(response.headers.get('X-Inspect')).toBe('inspect');
196 }
197 );
198
199 it('should follow redirect chain', async () => {
200 const response = await fetch(new URL('redirect/chain', baseURL));
201 expect(response.headers.get('X-Inspect')).toBe('inspect');
202 });
203
204 it.each([
205 ['POST', 301, 'GET'],
206 ['PUT', 301, 'PUT'],
207 ['POST', 302, 'GET'],
208 ['PATCH', 302, 'PATCH'],
209 ['PUT', 303, 'GET'],
210 ['PATCH', 307, 'PATCH'],
211 ])(
212 'should follow %s request redirect code %d with %s',
213 async (inputMethod, code, outputMethod) => {
214 const response = await fetch(new URL(`redirect/${code}`, baseURL), {
215 method: inputMethod,
216 body: 'a=1',
217 });
218 expect(response.headers.get('X-Inspect')).toBe('inspect');
219 expect(response.url).toBe(`${baseURL}inspect`);
220 expect(await response.json()).toMatchObject({
221 method: outputMethod,
222 body: outputMethod === 'GET' ? '' : 'a=1',
223 });
224 }
225 );
226
227 it('should not follow non-GET redirect if body is a readable stream', async () => {
228 await expect(() =>
229 fetch(new URL('redirect/307', baseURL), {
230 method: 'POST',
231 body: stream.Readable.from('tada'),
232 })
233 ).rejects.toThrowErrorMatchingInlineSnapshot(
234 `[Error: Cannot follow redirect with a streamed body]`
235 );
236 });
237
238 it('should not follow non HTTP(s) redirect', async () => {
239 await expect(() =>
240 fetch(new URL('redirect/301/file', baseURL))
241 ).rejects.toThrowErrorMatchingInlineSnapshot(
242 `[Error: URL scheme must be a HTTP(S) scheme]`
243 );
244 });
245
246 it('should support redirect mode, manual flag', async () => {
247 const response = await fetch(new URL('redirect/301', baseURL), {
248 redirect: 'manual',
249 });
250 expect(response.status).toBe(301);
251 expect(response.headers.get('location')).toBe(`${baseURL}inspect`);
252 });
253
254 it('should support redirect mode, manual flag, broken Location header', async () => {
255 const response = await fetch(new URL('redirect/bad-location', baseURL), {
256 redirect: 'manual',
257 });
258 expect(response.status).toBe(301);
259 expect(response.headers.get('location')).toBe(
260 `${baseURL}redirect/%C3%A2%C2%98%C2%83`
261 );
262 });
263
264 it('should support redirect mode, error flag', async () => {
265 await expect(() =>
266 fetch(new URL('redirect/301', baseURL), {
267 redirect: 'error',
268 })
269 ).rejects.toThrowErrorMatchingInlineSnapshot(
270 `[Error: URI requested responds with a redirect, redirect mode is set to error]`
271 );
272 });
273
274 it('should support redirect mode, manual flag when there is no redirect', async () => {
275 const response = await fetch(new URL('hello', baseURL), {
276 redirect: 'manual',
277 });
278 expect(response.status).toBe(200);
279 expect(response.headers.has('location')).toBe(false);
280 });
281
282 it('should follow redirect code 301 and keep existing headers', async () => {
283 const response = await fetch(new URL('inspect', baseURL), {
284 headers: new Headers({ 'x-custom-header': 'abc' }),
285 });
286 expect(await response.json()).toMatchObject({
287 headers: expect.objectContaining({
288 'x-custom-header': 'abc',
289 }),
290 });
291 });
292
293 it.each([['follow'], ['manual']] as const)(
294 'should treat broken redirect as ordinary response (%s)',
295 async redirect => {
296 const response = await fetch(new URL('redirect/no-location', baseURL), {
297 redirect,
298 });
299 expect(response.status).toBe(301);
300 expect(response.headers.has('location')).toBe(false);
301 }
302 );
303
304 it('should throw a TypeError on an invalid redirect option', async () => {
305 await expect(() =>
306 fetch(new URL('redirect/no-location', baseURL), {
307 // @ts-ignore: Intentionally invalid
308 redirect: 'foobar',
309 })
310 ).rejects.toThrowErrorMatchingInlineSnapshot(
311 `[TypeError: Request constructor: foobar is not an accepted type. Expected one of follow, manual, error.]`
312 );
313 });
314
315 it('should set redirected property on response when redirect', async () => {
316 const response = await fetch(new URL('redirect/301', baseURL));
317 expect(response.redirected).toBe(true);
318 });
319
320 it('should not set redirected property on response without redirect', async () => {
321 const response = await fetch(new URL('hello', baseURL));
322 expect(response.redirected).toBe(false);
323 });
324
325 it('should follow redirect after empty chunked transfer-encoding', async () => {
326 const response = await fetch(new URL('redirect/chunked', baseURL));
327 expect(response.status).toBe(200);
328 expect(response.ok).toBe(true);
329 });
330 });
331
332 describe('error handling', () => {
333 it('should handle client-error response', async () => {
334 const response = await fetch(new URL('error/400', baseURL));
335 expect(response.headers.get('content-type')).toBe('text/plain');
336 expect(response.status).toBe(400);
337 expect(response.statusText).toBe('Bad Request');
338 expect(response.ok).toBe(false);
339 expect(await response.text()).toBe('client error');
340 });
341
342 it('should handle server-error response', async () => {
343 const response = await fetch(new URL('error/500', baseURL));
344 expect(response.headers.get('content-type')).toBe('text/plain');
345 expect(response.status).toBe(500);
346 expect(response.statusText).toBe('Internal Server Error');
347 expect(response.ok).toBe(false);
348 expect(await response.text()).toBe('server error');
349 });
350
351 it('should handle network-error response', async () => {
352 await expect(() =>
353 fetch(new URL('error/reset', baseURL))
354 ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: socket hang up]`);
355 });
356
357 it('should handle premature close properly', async () => {
358 const response = await fetch(new URL('redirect/301/rn', baseURL));
359 expect(response.status).toBe(403);
360 });
361
362 it('should handle network-error partial response', async () => {
363 const response = await fetch(new URL('error/premature', baseURL));
364 expect(response.status).toBe(200);
365 expect(response.ok).toBe(true);
366 await expect(() =>
367 response.text()
368 ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: aborted]`);
369 });
370
371 it('should handle network-error in chunked response', async () => {
372 const response = await fetch(new URL('error/premature/chunked', baseURL));
373 expect(response.status).toBe(200);
374 expect(response.ok).toBe(true);
375 await expect(() =>
376 collectStream(response.body!)
377 ).rejects.toMatchInlineSnapshot(`[Error: aborted]`);
378 });
379
380 it('should handle network-error in chunked response in consumeBody', async () => {
381 const response = await fetch(new URL('error/premature/chunked', baseURL));
382 expect(response.status).toBe(200);
383 expect(response.ok).toBe(true);
384 await expect(() =>
385 response.text()
386 ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: aborted]`);
387 });
388 });
389
390 describe('responses', () => {
391 it('should handle chunked response with more than 1 chunk in the final packet', async () => {
392 const response = await fetch(new URL('chunked/multiple-ending', baseURL));
393 expect(response.ok).toBe(true);
394 expect(await response.text()).toBe('foobar');
395 });
396
397 it('should handle chunked response with final chunk and EOM in separate packets', async () => {
398 const response = await fetch(new URL('chunked/split-ending', baseURL));
399 expect(response.ok).toBe(true);
400 expect(await response.text()).toBe('foobar');
401 });
402
403 it('should reject invalid json response', async () => {
404 const response = await fetch(new URL('error/json', baseURL));
405 expect(response.headers.get('content-type')).toBe('application/json');
406 await expect(() => response.json()).rejects.toThrow(/Unexpected token/);
407 });
408
409 it('should reject decoding body twice', async () => {
410 const response = await fetch(new URL('plain', baseURL));
411 expect(response.headers.get('Content-Type')).toBe('text/plain');
412 await response.text();
413 expect(response.bodyUsed).toBe(true);
414 await expect(() => response.text()).rejects.toThrow(/Body is unusable/);
415 });
416
417 it('should handle response with no status text', async () => {
418 const response = await fetch(new URL('no-status-text', baseURL));
419 expect(response.statusText).toBe('');
420 });
421
422 it('should allow piping response body as stream', async () => {
423 const response = await fetch(new URL('hello', baseURL));
424 const onResult = vi.fn(data => {
425 expect(Buffer.from(data).toString()).toBe('world');
426 });
427 await streamToPromise(response.body!, onResult);
428 expect(onResult).toHaveBeenCalledOnce();
429 });
430
431 it('should allow cloning response body to two streams', async () => {
432 const response = await fetch(new URL('hello', baseURL));
433 const clone = response.clone();
434 const onResult = vi.fn(data => {
435 expect(Buffer.from(data).toString()).toBe('world');
436 });
437 await Promise.all([
438 streamToPromise(response.body!, onResult),
439 streamToPromise(clone.body!, onResult),
440 ]);
441 expect(onResult).toHaveBeenCalledTimes(2);
442 });
443
444 describe('no content', () => {
445 it('should handle no content response', async () => {
446 const response = await fetch(new URL('no-content', baseURL));
447 expect(response.status).toBe(204);
448 expect(response.statusText).toBe('No Content');
449 expect(response.ok).toBe(true);
450 expect(await response.text()).toBe('');
451 });
452
453 it('should reject when trying to parse no content response as json', async () => {
454 const response = await fetch(new URL('no-content', baseURL));
455 expect(response.status).toBe(204);
456 expect(response.statusText).toBe('No Content');
457 expect(response.ok).toBe(true);
458 await expect(() =>
459 response.json()
460 ).rejects.toThrowErrorMatchingInlineSnapshot(
461 `[SyntaxError: Unexpected end of JSON input]`
462 );
463 });
464
465 it('should handle no content response with gzip encoding', async () => {
466 const response = await fetch(new URL('no-content/gzip', baseURL));
467 expect(response.status).toBe(204);
468 expect(response.statusText).toBe('No Content');
469 expect(response.headers.get('Content-Encoding')).toBe('gzip');
470 expect(response.ok).toBe(true);
471 expect(await response.text()).toBe('');
472 });
473
474 it('should handle 304 response', async () => {
475 const response = await fetch(new URL('not-modified', baseURL));
476 expect(response.status).toBe(304);
477 expect(response.statusText).toBe('Not Modified');
478 expect(response.ok).toBe(false);
479 expect(await response.text()).toBe('');
480 });
481
482 it('should handle 304 response with gzip encoding', async () => {
483 const response = await fetch(new URL('not-modified/gzip', baseURL));
484 expect(response.status).toBe(304);
485 expect(response.statusText).toBe('Not Modified');
486 expect(response.headers.get('Content-Encoding')).toBe('gzip');
487 expect(response.ok).toBe(false);
488 expect(await response.text()).toBe('');
489 });
490 });
491 });
492
493 describe('content encoding', () => {
494 it('should decompress gzip response', async () => {
495 const response = await fetch(new URL('gzip', baseURL));
496 expect(response.headers.get('content-type')).toBe('text/plain');
497 expect(response.headers.get('content-encoding')).toBe('gzip');
498 expect(await response.text()).toBe('hello world');
499 });
500
501 it('should decompress slightly invalid gzip response', async () => {
502 const response = await fetch(new URL('gzip-truncated', baseURL));
503 expect(response.headers.get('content-type')).toBe('text/plain');
504 expect(response.headers.get('content-encoding')).toBe('gzip');
505 expect(await response.text()).toBe('hello world');
506 });
507
508 it('should make capitalised Content-Encoding lowercase', async () => {
509 const response = await fetch(new URL('gzip-capital', baseURL));
510 expect(response.headers.get('content-type')).toBe('text/plain');
511 expect(response.headers.get('content-encoding')).toBe('gzip');
512 expect(await response.text()).toBe('hello world');
513 });
514
515 it('should decompress deflate response', async () => {
516 const response = await fetch(new URL('deflate', baseURL));
517 expect(response.headers.get('content-type')).toBe('text/plain');
518 expect(response.headers.get('content-encoding')).toBe('deflate');
519 expect(await response.text()).toBe('hello world');
520 });
521
522 it('should decompress deflate raw response from old apache server', async () => {
523 const response = await fetch(new URL('deflate-raw', baseURL));
524 expect(response.headers.get('content-type')).toBe('text/plain');
525 expect(response.headers.get('content-encoding')).toBe('deflate');
526 expect(await response.text()).toBe('hello world');
527 });
528
529 it('should decompress brotli response', async () => {
530 const response = await fetch(new URL('brotli', baseURL));
531 expect(response.headers.get('content-type')).toBe('text/plain');
532 expect(response.headers.get('content-encoding')).toBe('br');
533 expect(await response.text()).toBe('hello world');
534 });
535
536 it('should skip decompression if unsupported', async () => {
537 const response = await fetch(new URL('sdch', baseURL));
538 expect(response.headers.get('content-type')).toBe('text/plain');
539 expect(response.headers.get('content-encoding')).toBe('sdch');
540 expect(await response.text()).toBe('fake sdch string');
541 });
542
543 it('should reject if response compression is invalid', async () => {
544 const response = await fetch(
545 new URL('invalid-content-encoding', baseURL)
546 );
547 expect(response.headers.get('content-type')).toBe('text/plain');
548 expect(response.headers.get('content-encoding')).toBe('gzip');
549 await expect(() =>
550 response.text()
551 ).rejects.toThrowErrorMatchingInlineSnapshot(
552 `[Error: incorrect header check]`
553 );
554 });
555
556 it('should handle errors on invalid body stream even if it is not used', async () => {
557 const response = await fetch(
558 new URL('invalid-content-encoding', baseURL)
559 );
560 expect(response.headers.get('content-type')).toBe('text/plain');
561 expect(response.headers.get('content-encoding')).toBe('gzip');
562 await new Promise(resolve => setTimeout(resolve, 20));
563 });
564
565 it('should reject when invalid body stream is used later', async () => {
566 const response = await fetch(
567 new URL('invalid-content-encoding', baseURL)
568 );
569 expect(response.headers.get('content-type')).toBe('text/plain');
570 expect(response.headers.get('content-encoding')).toBe('gzip');
571 await new Promise(resolve => setTimeout(resolve, 20));
572 await expect(() =>
573 response.text()
574 ).rejects.toThrowErrorMatchingInlineSnapshot(
575 `[Error: incorrect header check]`
576 );
577 });
578 });
579
580 describe('AbortController', () => {
581 let controller: AbortController;
582
583 beforeEach(() => {
584 controller = new AbortController();
585 });
586
587 it('should support request cancellation with signal', async () => {
588 const response$ = fetch(new URL('timeout', baseURL), {
589 method: 'POST',
590 signal: controller.signal,
591 headers: {
592 'Content-Type': 'application/json',
593 body: JSON.stringify({ hello: 'world' }),
594 },
595 });
596 setTimeout(() => controller.abort(), 100);
597 await expect(response$).rejects.toThrowErrorMatchingInlineSnapshot(
598 `[AbortError: The operation was aborted]`
599 );
600 });
601
602 it('should support multiple request cancellation with signal', async () => {
603 const fetches = [
604 fetch(new URL('timeout', baseURL), { signal: controller.signal }),
605 fetch(new URL('timeout', baseURL), {
606 method: 'POST',
607 signal: controller.signal,
608 headers: {
609 'Content-Type': 'application/json',
610 body: JSON.stringify({ hello: 'world' }),
611 },
612 }),
613 ];
614 setTimeout(() => controller.abort(), 100);
615 await expect(fetches[0]).rejects.toThrowErrorMatchingInlineSnapshot(
616 `[AbortError: The operation was aborted]`
617 );
618 await expect(fetches[1]).rejects.toThrowErrorMatchingInlineSnapshot(
619 `[AbortError: The operation was aborted]`
620 );
621 });
622
623 it('should reject immediately if signal has already been aborted', async () => {
624 controller.abort();
625 await expect(() => {
626 return fetch(new URL('timeout', baseURL), {
627 signal: controller.signal,
628 });
629 }).rejects.toThrowErrorMatchingInlineSnapshot(
630 `[AbortError: The operation was aborted]`
631 );
632 });
633
634 it('should allow redirects to be aborted', async () => {
635 const request = new Request(new URL('redirect/slow', baseURL), {
636 signal: controller.signal,
637 });
638 setTimeout(() => controller.abort(), 20);
639 await expect(() =>
640 fetch(request)
641 ).rejects.toThrowErrorMatchingInlineSnapshot(
642 `[AbortError: The operation was aborted]`
643 );
644 });
645
646 it('should allow redirected response body to be aborted', async () => {
647 const response = await fetch(new URL('redirect/slow-stream', baseURL), {
648 signal: controller.signal,
649 });
650 expect(response.headers.get('content-type')).toBe('text/plain');
651 const text$ = response.text();
652 controller.abort();
653 await expect(text$).rejects.toThrowErrorMatchingInlineSnapshot(
654 `[AbortError: This operation was aborted]`
655 );
656 });
657
658 it('should reject response body when aborted before stream completes', async () => {
659 const response = await fetch(new URL('slow', baseURL), {
660 signal: controller.signal,
661 });
662 const text$ = response.text();
663 controller.abort();
664 await expect(text$).rejects.toThrowErrorMatchingInlineSnapshot(
665 `[AbortError: This operation was aborted]`
666 );
667 });
668
669 it('should reject response body methods immediately with AbortError when aborted before stream is disturbed', async () => {
670 const response$ = fetch(new URL('slow', baseURL), {
671 signal: controller.signal,
672 });
673 controller.abort();
674 await expect(response$).rejects.toThrowErrorMatchingInlineSnapshot(
675 `[AbortError: The operation was aborted]`
676 );
677 });
678
679 it('should emit error event to response body with an AbortError when aborted before underlying stream is closed', async () => {
680 const response = await fetch(new URL('slow', baseURL), {
681 signal: controller.signal,
682 });
683 const done$ = expect(() =>
684 response.arrayBuffer()
685 ).rejects.toThrowErrorMatchingInlineSnapshot(
686 `[AbortError: This operation was aborted]`
687 );
688 controller.abort();
689 await done$;
690 });
691
692 it('should cancel request body of type Stream with AbortError when aborted', async () => {
693 const body = new stream.Readable({ objectMode: true });
694 body._read = () => {};
695 const response$ = fetch(new URL('slow', baseURL), {
696 signal: controller.signal,
697 method: 'POST',
698 body,
699 });
700 const bodyError$ = new Promise(resolve => {
701 body.on('error', error => {
702 expect(error).toMatchInlineSnapshot(
703 `[AbortError: The operation was aborted]`
704 );
705 resolve(null);
706 });
707 });
708 controller.abort();
709 await bodyError$;
710 await expect(response$).rejects.toMatchInlineSnapshot(
711 `[AbortError: The operation was aborted]`
712 );
713 });
714
715 it('should throw a TypeError if a signal is not of type AbortSignal or EventTarget', async () => {
716 const url = new URL('inspect', baseURL);
717 await Promise.all([
718 expect(() =>
719 fetch(url, { signal: {} as any })
720 ).rejects.toThrowErrorMatchingInlineSnapshot(
721 `[TypeError: The "signal" argument must be an instance of AbortSignal. Received an instance of Object]`
722 ),
723 expect(() =>
724 fetch(url, { signal: Object.create(null) as any })
725 ).rejects.toThrowErrorMatchingInlineSnapshot(
726 `[TypeError: The "signal" argument must be an instance of AbortSignal. Received [Object: null prototype] {}]`
727 ),
728 ]);
729 });
730
731 it('should gracefully handle a nullish signal', async () => {
732 const url = new URL('inspect', baseURL);
733 await Promise.all([
734 expect(fetch(url, { signal: null })).resolves.toMatchObject({
735 ok: true,
736 }),
737 expect(fetch(url, { signal: undefined })).resolves.toMatchObject({
738 ok: true,
739 }),
740 ]);
741 });
742 });
743
744 describe('request body', () => {
745 it('should allow POST request with empty body', async () => {
746 const response = await fetch(new URL('inspect', baseURL), {
747 method: 'POST',
748 });
749 const inspect = await response.json();
750 expect(inspect).not.toHaveProperty('headers.transfer-encoding');
751 expect(inspect).not.toHaveProperty('headers.content-type');
752 expect(inspect).toMatchObject({
753 method: 'POST',
754 headers: {
755 'content-length': '0',
756 },
757 });
758 });
759
760 it('should allow POST request with string body', async () => {
761 const response = await fetch(new URL('inspect', baseURL), {
762 method: 'POST',
763 body: 'a=1',
764 });
765 const inspect = await response.json();
766 expect(inspect).not.toHaveProperty('headers.transfer-encoding');
767 expect(inspect).toMatchObject({
768 method: 'POST',
769 body: 'a=1',
770 headers: {
771 'content-type': 'text/plain;charset=UTF-8',
772 'content-length': '3',
773 },
774 });
775 });
776
777 it('should allow POST request with Buffer body', async () => {
778 const response = await fetch(new URL('inspect', baseURL), {
779 method: 'POST',
780 body: Buffer.from('a=1', 'utf8'),
781 });
782 const inspect = await response.json();
783 expect(inspect).not.toHaveProperty('headers.transfer-encoding');
784 expect(inspect).not.toHaveProperty('headers.content-type');
785 expect(inspect).toMatchObject({
786 method: 'POST',
787 body: 'a=1',
788 headers: {
789 'content-length': '3',
790 },
791 });
792 });
793
794 it('should allow POST request with ArrayBuffer body', async () => {
795 const response = await fetch(new URL('inspect', baseURL), {
796 method: 'POST',
797 body: new TextEncoder().encode('Hello, world!\n').buffer,
798 });
799 const inspect = await response.json();
800 expect(inspect).not.toHaveProperty('headers.transfer-encoding');
801 expect(inspect).not.toHaveProperty('headers.content-type');
802 expect(inspect).toMatchObject({
803 method: 'POST',
804 body: 'Hello, world!\n',
805 headers: {
806 'content-length': '14',
807 },
808 });
809 });
810
811 it('should allow POST request with ArrayBuffer body from a VM context', async () => {
812 const response = await fetch(new URL('inspect', baseURL), {
813 method: 'POST',
814 body: new VMUint8Array(Buffer.from('Hello, world!\n')).buffer,
815 });
816 const inspect = await response.json();
817 expect(inspect).not.toHaveProperty('headers.transfer-encoding');
818 expect(inspect).not.toHaveProperty('headers.content-type');
819 expect(inspect).toMatchObject({
820 method: 'POST',
821 body: 'Hello, world!\n',
822 headers: {
823 'content-length': '14',
824 },
825 });
826 });
827
828 it('should allow POST request with ArrayBufferView (Uint8Array) body', async () => {
829 const response = await fetch(new URL('inspect', baseURL), {
830 method: 'POST',
831 body: new TextEncoder().encode('Hello, world!\n'),
832 });
833 const inspect = await response.json();
834 expect(inspect).not.toHaveProperty('headers.transfer-encoding');
835 expect(inspect).not.toHaveProperty('headers.content-type');
836 expect(inspect).toMatchObject({
837 method: 'POST',
838 body: 'Hello, world!\n',
839 headers: {
840 'content-length': '14',
841 },
842 });
843 });
844
845 it('should allow POST request with ArrayBufferView (DataView) body', async () => {
846 const response = await fetch(new URL('inspect', baseURL), {
847 method: 'POST',
848 body: new DataView(new TextEncoder().encode('Hello, world!\n').buffer),
849 });
850 const inspect = await response.json();
851 expect(inspect).not.toHaveProperty('headers.transfer-encoding');
852 expect(inspect).not.toHaveProperty('headers.content-type');
853 expect(inspect).toMatchObject({
854 method: 'POST',
855 body: 'Hello, world!\n',
856 headers: {
857 'content-length': '14',
858 },
859 });
860 });
861
862 it('should allow POST request with ArrayBufferView (Uint8Array) body from a VM context', async () => {
863 const response = await fetch(new URL('inspect', baseURL), {
864 method: 'POST',
865 body: new VMUint8Array(new TextEncoder().encode('Hello, world!\n')),
866 });
867 const inspect = await response.json();
868 expect(inspect).not.toHaveProperty('headers.transfer-encoding');
869 expect(inspect).not.toHaveProperty('headers.content-type');
870 expect(inspect).toMatchObject({
871 method: 'POST',
872 body: 'Hello, world!\n',
873 headers: {
874 'content-length': '14',
875 },
876 });
877 });
878
879 it('should allow POST request with ArrayBufferView (Uint8Array, offset, length) body', async () => {
880 const response = await fetch(new URL('inspect', baseURL), {
881 method: 'POST',
882 body: new TextEncoder().encode('Hello, world!\n').subarray(7, 13),
883 });
884 const inspect = await response.json();
885 expect(inspect).not.toHaveProperty('headers.transfer-encoding');
886 expect(inspect).not.toHaveProperty('headers.content-type');
887 expect(inspect).toMatchObject({
888 method: 'POST',
889 body: 'world!',
890 headers: {
891 'content-length': '6',
892 },
893 });
894 });
895
896 it('should allow POST request with blob body without type', async () => {
897 const response = await fetch(new URL('inspect', baseURL), {
898 method: 'POST',
899 body: new Blob(['a=1']),
900 });
901 const inspect = await response.json();
902 expect(inspect).not.toHaveProperty('headers.transfer-encoding');
903 expect(inspect).not.toHaveProperty('headers.content-type');
904 expect(inspect).toMatchObject({
905 method: 'POST',
906 body: 'a=1',
907 headers: {
908 'content-length': '3',
909 },
910 });
911 });
912
913 it('should allow POST request with blob body with type', async () => {
914 const response = await fetch(new URL('inspect', baseURL), {
915 method: 'POST',
916 body: new Blob(['a=1'], {
917 type: 'text/plain;charset=utf-8',
918 }),
919 });
920 const inspect = await response.json();
921 expect(inspect).not.toHaveProperty('headers.transfer-encoding');
922 expect(inspect).toMatchObject({
923 method: 'POST',
924 body: 'a=1',
925 headers: {
926 'content-type': 'text/plain;charset=utf-8',
927 'content-length': '3',
928 },
929 });
930 });
931
932 it('should preserve blob body on roundtrip', async () => {
933 const body = new Blob(['a=1']);
934 let response = await fetch(new URL('inspect', baseURL), {
935 method: 'POST',
936 body,
937 });
938 expect(await response.json()).toMatchObject({ body: 'a=1' });
939 response = await fetch(new URL('inspect', baseURL), {
940 method: 'POST',
941 body: new Blob(['a=1']),
942 });
943 expect(await response.json()).toMatchObject({ body: 'a=1' });
944 });
945
946 it('should allow POST request with readable stream as body', async () => {
947 const response = await fetch(new URL('inspect', baseURL), {
948 method: 'POST',
949 body: stream.Readable.from('a=1'),
950 });
951 const inspect = await response.json();
952 expect(inspect).not.toHaveProperty('headers.content-type');
953 expect(inspect).not.toHaveProperty('headers.content-length');
954 expect(inspect).toMatchObject({
955 method: 'POST',
956 body: 'a=1',
957 headers: {
958 'transfer-encoding': 'chunked',
959 },
960 });
961 });
962
963 it('should allow POST request with FormData as body', async () => {
964 const form = new FormData();
965 form.append('a', '1');
966
967 const response = await fetch(new URL('multipart', baseURL), {
968 method: 'POST',
969 body: form,
970 });
971 const inspect = await response.json();
972 expect(inspect).not.toHaveProperty('headers.transfer-encoding');
973 expect(inspect).toMatchObject({
974 method: 'POST',
975 body: 'a=1',
976 headers: {
977 'content-type': expect.stringMatching(
978 /^multipart\/form-data; boundary=/
979 ),
980 'content-length': '109',
981 },
982 });
983 });
984
985 it('should allow POST request with form-data using stream as body', async () => {
986 const form = new FormDataPolyfill();
987 form.append('my_field', stream.Readable.from('dummy'));
988
989 const response = await fetch(new URL('multipart', baseURL), {
990 method: 'POST',
991 body: form,
992 });
993 const inspect = await response.json();
994 expect(inspect).not.toHaveProperty('headers.content-length');
995 expect(inspect).toMatchObject({
996 method: 'POST',
997 body: 'my_field=undefined',
998 headers: {
999 'transfer-encoding': 'chunked',
1000 'content-type': expect.stringMatching(
1001 /^multipart\/form-data; boundary=/
1002 ),
1003 },
1004 });
1005 });
1006
1007 it('should allow POST request with URLSearchParams as body', async () => {
1008 const params = new URLSearchParams();
1009 params.set('key1', 'value1');
1010 params.set('key2', 'value2');
1011
1012 const response = await fetch(new URL('multipart', baseURL), {
1013 method: 'POST',
1014 body: params,
1015 });
1016 const inspect = await response.json();
1017 expect(inspect).not.toHaveProperty('headers.transfer-encoding');
1018 expect(inspect).toMatchObject({
1019 method: 'POST',
1020 body: 'key1=value1key2=value2',
1021 headers: {
1022 'content-type': 'application/x-www-form-urlencoded;charset=UTF-8',
1023 'content-length': '23',
1024 },
1025 });
1026 });
1027
1028 it('should allow POST request with extended URLSearchParams as body', async () => {
1029 class CustomSearchParameters extends URLSearchParams {}
1030 const params = new CustomSearchParameters();
1031 params.set('key1', 'value1');
1032 params.set('key2', 'value2');
1033
1034 const response = await fetch(new URL('multipart', baseURL), {
1035 method: 'POST',
1036 body: params,
1037 });
1038 const inspect = await response.json();
1039 expect(inspect).not.toHaveProperty('headers.transfer-encoding');
1040 expect(inspect).toMatchObject({
1041 method: 'POST',
1042 body: 'key1=value1key2=value2',
1043 headers: {
1044 'content-type': 'application/x-www-form-urlencoded;charset=UTF-8',
1045 'content-length': '23',
1046 },
1047 });
1048 });
1049
1050 it('should allow POST request with invalid body', async () => {
1051 const response = await fetch(new URL('inspect', baseURL), {
1052 method: 'POST',
1053 body: { a: 1 } as any,
1054 });
1055 const inspect = await response.json();
1056 expect(inspect).not.toHaveProperty('headers.transfer-encoding');
1057 expect(inspect).toMatchObject({
1058 method: 'POST',
1059 body: '[object Object]',
1060 headers: {
1061 'content-type': 'text/plain;charset=UTF-8',
1062 'content-length': expect.any(String),
1063 },
1064 });
1065 });
1066
1067 it('should overwrite Content-Length if possible', async () => {
1068 const response = await fetch(new URL('inspect', baseURL), {
1069 method: 'POST',
1070 body: new Blob(['a=1']),
1071 headers: {
1072 'Content-Length': '1000',
1073 },
1074 });
1075 const inspect = await response.json();
1076 expect(inspect).not.toHaveProperty('headers.transfer-encoding');
1077 expect(inspect).not.toHaveProperty('headers.content-type');
1078 expect(inspect).toMatchObject({
1079 method: 'POST',
1080 body: 'a=1',
1081 headers: {
1082 'content-length': '3',
1083 },
1084 });
1085 });
1086
1087 it.each([['PUT'], ['DELETE'], ['PATCH']])(
1088 'should allow %s request',
1089 async method => {
1090 const response = await fetch(new URL('inspect', baseURL), {
1091 method,
1092 body: 'a=1',
1093 });
1094 const inspect = await response.json();
1095 expect(inspect).toMatchObject({
1096 method,
1097 body: 'a=1',
1098 });
1099 }
1100 );
1101
1102 it('should allow HEAD requests', async () => {
1103 const response = await fetch(new URL('inspect', baseURL), {
1104 method: 'HEAD',
1105 });
1106 expect(response.status).toBe(200);
1107 expect(await response.text()).toBe('');
1108 });
1109
1110 it('should allow HEAD requests with Content-Encoding header', async () => {
1111 const response = await fetch(new URL('error/404', baseURL), {
1112 method: 'HEAD',
1113 });
1114 expect(response.status).toBe(404);
1115 expect(response.headers.get('Content-Encoding')).toBe('gzip');
1116 expect(await response.text()).toBe('');
1117 });
1118
1119 it('should allow OPTIONS request', async () => {
1120 const response = await fetch(new URL('options', baseURL), {
1121 method: 'OPTIONS',
1122 });
1123 expect(response.status).toBe(200);
1124 expect(response.headers.get('Allow')).toBe('GET, HEAD, OPTIONS');
1125 expect(await response.text()).toBe('hello world');
1126 });
1127
1128 it('should support fetch with Request instance', async () => {
1129 const request = new Request(new URL('hello', baseURL));
1130 const response = await fetch(request);
1131 expect(response.url).toBe(request.url);
1132 expect(response.ok).toBe(true);
1133 expect(response.status).toBe(200);
1134 });
1135 });
1136
1137 describe('request URL', () => {
1138 it('should keep `?` sign in URL when no params are given', async () => {
1139 const response = await fetch(new URL('question?', baseURL));
1140 expect(response.url).toBe(`${baseURL}question?`);
1141 });
1142
1143 it('if params are given, do not modify anything', async () => {
1144 const response = await fetch(new URL('question?a=1', baseURL));
1145 expect(response.url).toBe(`${baseURL}question?a=1`);
1146 });
1147
1148 it('should preserve the hash (#) symbol', async () => {
1149 const response = await fetch(new URL('question?#', baseURL));
1150 expect(response.url).toBe(`${baseURL}question?#`);
1151 });
1152
1153 it('should encode URLs as UTF-8', async () => {
1154 const url = new URL('möbius', baseURL);
1155 const res = await fetch(url);
1156 expect(res.url).to.equal(`${baseURL}m%C3%B6bius`);
1157 });
1158 });
1159});