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});