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