Something went wrong. Try again.
Mirror: A Node.js fetch shim using built-in Request, Response, and Headers (but without native fetch)
Something went wrong. Try again.
46 kB · 1270 lines
TypeScript
at main
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271// Source: https://github.com/remix-run/web-std-io/blob/7a8596e/packages/fetch/test/main.js
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import FormDataPolyfill from 'form-data';import { ReadableStream } from 'node:stream/web';import stream from 'node:stream';import vm from 'node:vm';
import TestServer from './utils/server.js';import { fetch } from '../fetch';
const { Uint8Array: VMUint8Array } = vm.runInNewContext('this');
async function streamToPromise<T>( stream: ReadableStream<T>, dataHandler: (data: T) => void) { for await (const chunk of stream) { dataHandler(chunk); }}
async function collectStream<T>(stream: ReadableStream<T>): Promise<T[]> { const chunks: T[] = []; for await (const chunk of stream) chunks.push(chunk); return chunks;}
describe(fetch, () => { const local = new TestServer(); let baseURL: string;
beforeEach(async () => { await local.start(); baseURL = `http://${local.hostname}:${local.port}/`; });
afterEach(async () => { await local.stop(); });
it('should reject with error if url is protocol relative', async () => { // [Type Error: Invalid URL] await expect(() => fetch('//example.com/') ).rejects.toThrowErrorMatchingInlineSnapshot(`[TypeError: Invalid URL]`); });
it('should reject with error if url is relative path', async () => { // [Type Error: Invalid URL] await expect(() => fetch('/some/path') ).rejects.toThrowErrorMatchingInlineSnapshot(`[TypeError: Invalid URL]`); });
it('should reject with error if protocol is unsupported', async () => { // URL scheme 'ftp' is not supported await expect( fetch('ftp://example.com/') ).rejects.toThrowErrorMatchingInlineSnapshot( `[TypeError: URL scheme "ftp:" is not supported.]` ); });
it('should reject with error on network failure', async () => { await expect(() => fetch('http://localhost:50000/')).rejects.toThrow(); }, 1_000);
it('should reject with error when connectTimeout is exceeded', async () => { await expect(() => fetch('http://10.255.255.1/', { connectTimeout: 100 }) ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: Request timed out]`); }, 5_000);
it('should resolve into response', async () => { const response = await fetch(new URL('hello', baseURL)); expect(response.url).toBe(`${baseURL}hello`); expect(response).toBeInstanceOf(Response); expect(response).toMatchObject({ headers: expect.any(Headers), body: expect.any(ReadableStream), bodyUsed: false, ok: true, status: 200, statusText: 'OK', }); });
it('should support https request', async () => { const response = await fetch('https://github.com/', { method: 'HEAD' }); expect(response.status).toBe(200); }, 5000);
describe('response methods', () => { it('should accept plain text response', async () => { const response = await fetch(new URL('plain', baseURL)); expect(response.headers.get('content-type')).toBe('text/plain'); const text = await response.text(); expect(response.bodyUsed).toBe(true); expect(text).toBe('text'); });
it('should accept html response (like plain text)', async () => { const response = await fetch(new URL('html', baseURL)); expect(response.headers.get('content-type')).toBe('text/html'); const text = await response.text(); expect(response.bodyUsed).toBe(true); expect(text).toBe('<html></html>'); });
it('should accept json response', async () => { const response = await fetch(new URL('json', baseURL)); expect(response.headers.get('content-type')).toBe('application/json'); const text = await response.json(); expect(response.bodyUsed).toBe(true); expect(text).toEqual({ name: 'value' }); }); });
describe('request headers', () => { it('should send request with custom headers', async () => { const response = await fetch(new URL('inspect', baseURL), { headers: { 'x-custom-header': 'abc' }, }); expect(await response.json()).toMatchObject({ headers: expect.objectContaining({ 'x-custom-header': 'abc' }), }); });
it('should send custom Content-Type with body', async () => { const response = await fetch(new URL('inspect', baseURL), { headers: { 'content-type': 'some/magic' }, body: 'test', }); expect(await response.json()).toMatchObject({ headers: expect.objectContaining({ 'content-type': 'some/magic' }), }); });
it('should prefer init headers when Request is passed', async () => { const request = new Request(new URL('inspect', baseURL), { headers: { 'x-custom-header': 'abc' }, }); const response = await fetch(request, { headers: { 'x-custom-header': 'def' }, }); expect(await response.json()).toMatchObject({ headers: expect.objectContaining({ 'x-custom-header': 'def' }), }); });
it('should send request with custom User-Agent', async () => { const response = await fetch(new URL('inspect', baseURL), { headers: { 'user-agent': 'faked' }, }); expect(await response.json()).toMatchObject({ headers: expect.objectContaining({ 'user-agent': 'faked' }), }); });
it('should set default Accept header', async () => { const response = await fetch(new URL('inspect', baseURL)); expect(await response.json()).toMatchObject({ headers: expect.objectContaining({ accept: '*/*' }), }); });
it('should send custom Accept header', async () => { const response = await fetch(new URL('inspect', baseURL), { headers: { accept: 'application/json' }, }); expect(await response.json()).toMatchObject({ headers: expect.objectContaining({ accept: 'application/json' }), }); });
it('should accept headers instance', async () => { const response = await fetch(new URL('inspect', baseURL), { headers: new Headers({ 'x-custom-header': 'abc' }), }); expect(await response.json()).toMatchObject({ headers: expect.objectContaining({ 'x-custom-header': 'abc' }), }); });
it('should accept custom "host" header', async () => { const response = await fetch(new URL('inspect', baseURL), { headers: { host: 'example.com' }, }); expect(await response.json()).toMatchObject({ headers: expect.objectContaining({ host: 'example.com' }), }); });
it('should accept custom "HoSt" header', async () => { const response = await fetch(new URL('inspect', baseURL), { headers: { HoSt: 'example.com' }, }); expect(await response.json()).toMatchObject({ headers: expect.objectContaining({ host: 'example.com' }), }); });
it('should preserve header casing when headers are passed as a plain object', async () => { const response = await fetch(new URL('inspect', baseURL), { headers: { 'X-Custom-Header': 'abc', Authorization: 'Bearer token', 'content-type': 'text/plain', }, }); const { rawHeaders } = (await response.json()) as any; expect(rawHeaders).toMatchObject({ 'X-Custom-Header': 'abc', Authorization: 'Bearer token', 'content-type': 'text/plain', }); });
it('should preserve header casing when headers are passed as an array of tuples', async () => { const response = await fetch(new URL('inspect', baseURL), { headers: [ ['X-Custom-Header', 'abc'], ['Authorization', 'Bearer token'], ], }); const { rawHeaders } = (await response.json()) as any; expect(rawHeaders).toMatchObject({ 'X-Custom-Header': 'abc', Authorization: 'Bearer token', }); });
it('should merge tuple headers with the same name into a combined value', async () => { const response = await fetch(new URL('inspect', baseURL), { headers: [ ['X-Custom-Header', 'abc'], ['X-Custom-Header', 'def'], ], }); const { headers } = (await response.json()) as any; expect(headers['x-custom-header']).toBe('abc, def'); });
it('should merge tuple headers with the same name but different casing into a combined value', async () => { const response = await fetch(new URL('inspect', baseURL), { headers: [ ['X-Custom-Header', 'abc'], ['X-custom-header', 'def'], ], }); const { headers } = (await response.json()) as any; expect(headers['x-custom-header']).toBe('abc, def'); });
it('should lowercase header names when headers are passed as a Headers instance', async () => { const response = await fetch(new URL('inspect', baseURL), { headers: new Headers({ 'X-Custom-Header': 'abc', Authorization: 'Bearer token', }), }); const { rawHeaders } = (await response.json()) as any; expect(rawHeaders).toMatchObject({ 'x-custom-header': 'abc', authorization: 'Bearer token', }); }); });
describe('redirects', () => { it.each([[301], [302], [303], [307], [308]])( 'should follow redirect code %d', async status => { const response = await fetch(new URL(`redirect/${status}`, baseURL)); expect(response.headers.get('X-Inspect')).toBe('inspect'); } );
it('should follow redirect chain', async () => { const response = await fetch(new URL('redirect/chain', baseURL)); expect(response.headers.get('X-Inspect')).toBe('inspect'); });
it.each([ ['POST', 301, 'GET'], ['PUT', 301, 'PUT'], ['POST', 302, 'GET'], ['PATCH', 302, 'PATCH'], ['PUT', 303, 'GET'], ['PATCH', 307, 'PATCH'], ])( 'should follow %s request redirect code %d with %s', async (inputMethod, code, outputMethod) => { const response = await fetch(new URL(`redirect/${code}`, baseURL), { method: inputMethod, body: 'a=1', }); expect(response.headers.get('X-Inspect')).toBe('inspect'); expect(response.url).toBe(`${baseURL}inspect`); expect(await response.json()).toMatchObject({ method: outputMethod, body: outputMethod === 'GET' ? '' : 'a=1', }); } );
it.each([[301], [302], [303]])( 'should remove body, Content-Length, and Content-Type headers on %d redirect that changes method to GET', async code => { const response = await fetch(new URL(`redirect/${code}`, baseURL), { method: 'POST', body: 'a=1', headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': '3', }, }); expect(response.url).toBe(`${baseURL}inspect`); const inspect: any = await response.json(); expect(inspect).toMatchObject({ method: 'GET', body: '', }); expect(inspect.headers).not.toHaveProperty('content-type'); expect(inspect.headers).not.toHaveProperty('content-length'); } );
it('should not follow non-GET redirect if body is a readable stream', async () => { await expect(() => fetch(new URL('redirect/307', baseURL), { method: 'POST', body: stream.Readable.from('tada'), }) ).rejects.toThrowErrorMatchingInlineSnapshot( `[Error: Cannot follow redirect with a streamed body]` ); });
it('should not follow non HTTP(s) redirect', async () => { await expect(() => fetch(new URL('redirect/301/file', baseURL)) ).rejects.toThrowErrorMatchingInlineSnapshot( `[Error: URL scheme must be a HTTP(S) scheme]` ); });
it('should support redirect mode, manual flag', async () => { const response = await fetch(new URL('redirect/301', baseURL), { redirect: 'manual', }); expect(response.status).toBe(301); expect(response.headers.get('location')).toBe(`${baseURL}inspect`); });
it('should support redirect mode, manual flag, broken Location header', async () => { const response = await fetch(new URL('redirect/bad-location', baseURL), { redirect: 'manual', }); expect(response.status).toBe(301); expect(response.headers.get('location')).toBe( `${baseURL}redirect/%C3%A2%C2%98%C2%83` ); });
it('should support redirect mode, error flag', async () => { await expect(() => fetch(new URL('redirect/301', baseURL), { redirect: 'error', }) ).rejects.toThrowErrorMatchingInlineSnapshot( `[Error: URI requested responds with a redirect, redirect mode is set to error]` ); });
it('should support redirect mode, manual flag when there is no redirect', async () => { const response = await fetch(new URL('hello', baseURL), { redirect: 'manual', }); expect(response.status).toBe(200); expect(response.headers.has('location')).toBe(false); });
it('should follow redirect code 301 and keep existing headers', async () => { const response = await fetch(new URL('inspect', baseURL), { headers: new Headers({ 'x-custom-header': 'abc' }), }); expect(await response.json()).toMatchObject({ headers: expect.objectContaining({ 'x-custom-header': 'abc', }), }); });
it('should treat broken redirect as ordinary response for redirect: "manual"', async () => { const response = await fetch(new URL('redirect/no-location', baseURL), { redirect: 'manual', }); expect(response.status).toBe(301); expect(response.headers.has('location')).toBe(false); });
it('should throw on broken redirects for redirect: "follow"', async () => { await expect(() => fetch(new URL('redirect/no-location', baseURL), { redirect: 'follow', }) ).rejects.toThrowErrorMatchingInlineSnapshot( `[Error: URI requested responds with an invalid redirect URL]` ); });
it('should throw a TypeError on an invalid redirect option', async () => { await expect(() => fetch(new URL('redirect/no-location', baseURL), { // @ts-ignore: Intentionally invalid redirect: 'foobar', }) ).rejects.toThrowErrorMatchingInlineSnapshot( `[TypeError: Request constructor: foobar is not an accepted type. Expected one of follow, manual, error.]` ); });
it('should set redirected property on response when redirect', async () => { const response = await fetch(new URL('redirect/301', baseURL)); expect(response.redirected).toBe(true); });
it('should not set redirected property on response without redirect', async () => { const response = await fetch(new URL('hello', baseURL)); expect(response.redirected).toBe(false); });
it('should follow redirect after empty chunked transfer-encoding', async () => { const response = await fetch(new URL('redirect/chunked', baseURL)); expect(response.status).toBe(200); expect(response.ok).toBe(true); }); });
describe('error handling', () => { it('should handle client-error response', async () => { const response = await fetch(new URL('error/400', baseURL)); expect(response.headers.get('content-type')).toBe('text/plain'); expect(response.status).toBe(400); expect(response.statusText).toBe('Bad Request'); expect(response.ok).toBe(false); expect(await response.text()).toBe('client error'); });
it('should handle server-error response', async () => { const response = await fetch(new URL('error/500', baseURL)); expect(response.headers.get('content-type')).toBe('text/plain'); expect(response.status).toBe(500); expect(response.statusText).toBe('Internal Server Error'); expect(response.ok).toBe(false); expect(await response.text()).toBe('server error'); });
it('should handle network-error response', async () => { await expect(() => fetch(new URL('error/reset', baseURL)) ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: socket hang up]`); });
it('should handle premature close properly', async () => { const response = await fetch(new URL('redirect/301/rn', baseURL)); expect(response.status).toBe(403); });
it('should handle network-error partial response', async () => { const response = await fetch(new URL('error/premature', baseURL)); expect(response.status).toBe(200); expect(response.ok).toBe(true); await expect(() => response.text() ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: aborted]`); });
it('should handle network-error in chunked response', async () => { const response = await fetch(new URL('error/premature/chunked', baseURL)); expect(response.status).toBe(200); expect(response.ok).toBe(true); await expect(() => collectStream(response.body!) ).rejects.toMatchInlineSnapshot(`[Error: aborted]`); });
it('should handle network-error in chunked response in consumeBody', async () => { const response = await fetch(new URL('error/premature/chunked', baseURL)); expect(response.status).toBe(200); expect(response.ok).toBe(true); await expect(() => response.text() ).rejects.toThrowErrorMatchingInlineSnapshot(`[Error: aborted]`); }); });
describe('responses', () => { it('should handle chunked response with more than 1 chunk in the final packet', async () => { const response = await fetch(new URL('chunked/multiple-ending', baseURL)); expect(response.ok).toBe(true); expect(await response.text()).toBe('foobar'); });
it('should handle chunked response with final chunk and EOM in separate packets', async () => { const response = await fetch(new URL('chunked/split-ending', baseURL)); expect(response.ok).toBe(true); expect(await response.text()).toBe('foobar'); });
it('should reject invalid json response', async () => { const response = await fetch(new URL('error/json', baseURL)); expect(response.headers.get('content-type')).toBe('application/json'); await expect(() => response.json()).rejects.toThrow(/Unexpected token/); });
it('should reject decoding body twice', async () => { const response = await fetch(new URL('plain', baseURL)); expect(response.headers.get('Content-Type')).toBe('text/plain'); await response.text(); expect(response.bodyUsed).toBe(true); await expect(() => response.text()).rejects.toThrow(/Body is unusable/); });
it('should handle response with no status text', async () => { const response = await fetch(new URL('no-status-text', baseURL)); expect(response.statusText).toBe(''); });
it('should allow piping response body as stream', async () => { const response = await fetch(new URL('hello', baseURL)); const onResult = vi.fn(data => { expect(Buffer.from(data).toString()).toBe('world'); }); await streamToPromise(response.body!, onResult); expect(onResult).toHaveBeenCalledOnce(); });
it('should allow cloning response body to two streams', async () => { const response = await fetch(new URL('hello', baseURL)); const clone = response.clone(); const onResult = vi.fn(data => { expect(Buffer.from(data).toString()).toBe('world'); }); await Promise.all([ streamToPromise(response.body!, onResult), streamToPromise(clone.body!, onResult), ]); expect(onResult).toHaveBeenCalledTimes(2); });
describe('no content', () => { it('should handle no content response', async () => { const response = await fetch(new URL('no-content', baseURL)); expect(response.status).toBe(204); expect(response.statusText).toBe('No Content'); expect(response.ok).toBe(true); expect(await response.text()).toBe(''); });
it('should reject when trying to parse no content response as json', async () => { const response = await fetch(new URL('no-content', baseURL)); expect(response.status).toBe(204); expect(response.statusText).toBe('No Content'); expect(response.ok).toBe(true); await expect(() => response.json() ).rejects.toThrowErrorMatchingInlineSnapshot( `[SyntaxError: Unexpected end of JSON input]` ); });
it('should handle no content response with gzip encoding', async () => { const response = await fetch(new URL('no-content/gzip', baseURL)); expect(response.status).toBe(204); expect(response.statusText).toBe('No Content'); expect(response.headers.get('Content-Encoding')).toBe('gzip'); expect(response.ok).toBe(true); expect(await response.text()).toBe(''); });
it('should handle 304 response', async () => { const response = await fetch(new URL('not-modified', baseURL)); expect(response.status).toBe(304); expect(response.statusText).toBe('Not Modified'); expect(response.ok).toBe(false); expect(await response.text()).toBe(''); });
it('should handle 304 response with gzip encoding', async () => { const response = await fetch(new URL('not-modified/gzip', baseURL)); expect(response.status).toBe(304); expect(response.statusText).toBe('Not Modified'); expect(response.headers.get('Content-Encoding')).toBe('gzip'); expect(response.ok).toBe(false); expect(await response.text()).toBe(''); }); }); });
describe('content encoding', () => { it('should decompress gzip response', async () => { const response = await fetch(new URL('gzip', baseURL)); expect(response.headers.get('content-type')).toBe('text/plain'); expect(response.headers.get('content-encoding')).toBe('gzip'); expect(await response.text()).toBe('hello world'); });
it('should decompress slightly invalid gzip response', async () => { const response = await fetch(new URL('gzip-truncated', baseURL)); expect(response.headers.get('content-type')).toBe('text/plain'); expect(response.headers.get('content-encoding')).toBe('gzip'); expect(await response.text()).toBe('hello world'); });
it('should make capitalised Content-Encoding lowercase', async () => { const response = await fetch(new URL('gzip-capital', baseURL)); expect(response.headers.get('content-type')).toBe('text/plain'); expect(response.headers.get('content-encoding')).toBe('gzip'); expect(await response.text()).toBe('hello world'); });
it('should decompress deflate response', async () => { const response = await fetch(new URL('deflate', baseURL)); expect(response.headers.get('content-type')).toBe('text/plain'); expect(response.headers.get('content-encoding')).toBe('deflate'); expect(await response.text()).toBe('hello world'); });
it('should decompress deflate raw response from old apache server', async () => { const response = await fetch(new URL('deflate-raw', baseURL)); expect(response.headers.get('content-type')).toBe('text/plain'); expect(response.headers.get('content-encoding')).toBe('deflate'); expect(await response.text()).toBe('hello world'); });
it('should decompress brotli response', async () => { const response = await fetch(new URL('brotli', baseURL)); expect(response.headers.get('content-type')).toBe('text/plain'); expect(response.headers.get('content-encoding')).toBe('br'); expect(await response.text()).toBe('hello world'); });
it('should skip decompression if unsupported', async () => { const response = await fetch(new URL('sdch', baseURL)); expect(response.headers.get('content-type')).toBe('text/plain'); expect(response.headers.get('content-encoding')).toBe('sdch'); expect(await response.text()).toBe('fake sdch string'); });
it('should reject if response compression is invalid', async () => { const response = await fetch( new URL('invalid-content-encoding', baseURL) ); expect(response.headers.get('content-type')).toBe('text/plain'); expect(response.headers.get('content-encoding')).toBe('gzip'); await expect(() => response.text() ).rejects.toThrowErrorMatchingInlineSnapshot( `[Error: incorrect header check]` ); });
it('should handle errors on invalid body stream even if it is not used', async () => { const response = await fetch( new URL('invalid-content-encoding', baseURL) ); expect(response.headers.get('content-type')).toBe('text/plain'); expect(response.headers.get('content-encoding')).toBe('gzip'); await new Promise(resolve => setTimeout(resolve, 20)); });
it('should reject when invalid body stream is used later', async () => { const response = await fetch( new URL('invalid-content-encoding', baseURL) ); expect(response.headers.get('content-type')).toBe('text/plain'); expect(response.headers.get('content-encoding')).toBe('gzip'); await new Promise(resolve => setTimeout(resolve, 20)); await expect(() => response.text() ).rejects.toThrowErrorMatchingInlineSnapshot( `[Error: incorrect header check]` ); }); });
describe('AbortController', () => { let controller: AbortController;
beforeEach(() => { controller = new AbortController(); });
it('should support request cancellation with signal', async () => { const response$ = fetch(new URL('timeout', baseURL), { method: 'POST', signal: controller.signal, headers: { 'Content-Type': 'application/json', body: JSON.stringify({ hello: 'world' }), }, }); setTimeout(() => controller.abort(), 100); await expect(response$).rejects.toThrowErrorMatchingInlineSnapshot( `[AbortError: This operation was aborted]` ); });
it('should support multiple request cancellation with signal', async () => { const fetches = [ fetch(new URL('timeout', baseURL), { signal: controller.signal }), fetch(new URL('timeout', baseURL), { method: 'POST', signal: controller.signal, headers: { 'Content-Type': 'application/json', body: JSON.stringify({ hello: 'world' }), }, }), ]; setTimeout(() => controller.abort(), 100); await expect(fetches[0]).rejects.toThrowErrorMatchingInlineSnapshot( `[AbortError: This operation was aborted]` ); await expect(fetches[1]).rejects.toThrowErrorMatchingInlineSnapshot( `[AbortError: This operation was aborted]` ); });
it('should reject immediately if signal has already been aborted', async () => { controller.abort(); await expect(() => { return fetch(new URL('timeout', baseURL), { signal: controller.signal, }); }).rejects.toThrowErrorMatchingInlineSnapshot( `[AbortError: This operation was aborted]` ); });
it('should allow redirects to be aborted', async () => { const request = new Request(new URL('redirect/slow', baseURL), { signal: controller.signal, }); setTimeout(() => controller.abort(), 20); await expect(() => fetch(request) ).rejects.toThrowErrorMatchingInlineSnapshot( `[AbortError: This operation was aborted]` ); });
it('should allow redirected response body to be aborted', async () => { const response = await fetch(new URL('redirect/slow-stream', baseURL), { signal: controller.signal, }); expect(response.headers.get('content-type')).toBe('text/plain'); const text$ = response.text(); controller.abort(); await expect(text$).rejects.toThrowErrorMatchingInlineSnapshot( `[AbortError: This operation was aborted]` ); });
it('should reject response body when aborted before stream completes', async () => { const response = await fetch(new URL('slow', baseURL), { signal: controller.signal, }); const text$ = response.text(); controller.abort(); await expect(text$).rejects.toThrowErrorMatchingInlineSnapshot( `[AbortError: This operation was aborted]` ); });
it('should reject response body methods immediately with AbortError when aborted before stream is disturbed', async () => { const response$ = fetch(new URL('slow', baseURL), { signal: controller.signal, }); controller.abort(); await expect(response$).rejects.toThrowErrorMatchingInlineSnapshot( `[AbortError: This operation was aborted]` ); });
it('should emit error event to response body with an AbortError when aborted before underlying stream is closed', async () => { const response = await fetch(new URL('slow', baseURL), { signal: controller.signal, }); const done$ = expect(() => response.arrayBuffer() ).rejects.toThrowErrorMatchingInlineSnapshot( `[AbortError: This operation was aborted]` ); controller.abort(); await done$; });
it('should cancel request body of type Stream with AbortError when aborted', async () => { const body = new stream.Readable({ objectMode: true }); body._read = () => {}; const response$ = fetch(new URL('slow', baseURL), { signal: controller.signal, method: 'POST', body, }); const bodyError$ = new Promise(resolve => { body.on('error', error => { expect(error).toMatchInlineSnapshot( `[AbortError: The operation was aborted]` ); resolve(null); }); }); controller.abort(); await bodyError$; await expect(response$).rejects.toMatchInlineSnapshot( `[AbortError: This operation was aborted]` ); });
it('should throw a TypeError if a signal is not of type AbortSignal or EventTarget', async () => { const url = new URL('inspect', baseURL); await Promise.all([ expect(() => fetch(url, { signal: {} as any }) ).rejects.toThrowErrorMatchingInlineSnapshot( `[TypeError: The "signal" argument must be an instance of AbortSignal. Received an instance of Object]` ), expect(() => fetch(url, { signal: Object.create(null) as any }) ).rejects.toThrowErrorMatchingInlineSnapshot( `[TypeError: The "signal" argument must be an instance of AbortSignal. Received [Object: null prototype] {}]` ), ]); });
it('should gracefully handle a nullish signal', async () => { const url = new URL('inspect', baseURL); await Promise.all([ expect(fetch(url, { signal: null })).resolves.toMatchObject({ ok: true, }), expect(fetch(url, { signal: undefined })).resolves.toMatchObject({ ok: true, }), ]); }); });
describe('request body', () => { it('should allow POST request with empty body', async () => { const response = await fetch(new URL('inspect', baseURL), { method: 'POST', }); const inspect = await response.json(); expect(inspect).not.toHaveProperty('headers.transfer-encoding'); expect(inspect).not.toHaveProperty('headers.content-type'); expect(inspect).toMatchObject({ method: 'POST', headers: { 'content-length': '0', }, }); });
it('should allow POST request with string body', async () => { const response = await fetch(new URL('inspect', baseURL), { method: 'POST', body: 'a=1', }); const inspect = await response.json(); expect(inspect).not.toHaveProperty('headers.transfer-encoding'); expect(inspect).toMatchObject({ method: 'POST', body: 'a=1', headers: { 'content-type': 'text/plain;charset=UTF-8', 'content-length': '3', }, }); });
it('should allow POST request with Buffer body', async () => { const response = await fetch(new URL('inspect', baseURL), { method: 'POST', body: Buffer.from('a=1', 'utf8'), }); const inspect = await response.json(); expect(inspect).not.toHaveProperty('headers.transfer-encoding'); expect(inspect).not.toHaveProperty('headers.content-type'); expect(inspect).toMatchObject({ method: 'POST', body: 'a=1', headers: { 'content-length': '3', }, }); });
it('should allow POST request with ArrayBuffer body', async () => { const response = await fetch(new URL('inspect', baseURL), { method: 'POST', body: new TextEncoder().encode('Hello, world!\n').buffer, }); const inspect = await response.json(); expect(inspect).not.toHaveProperty('headers.transfer-encoding'); expect(inspect).not.toHaveProperty('headers.content-type'); expect(inspect).toMatchObject({ method: 'POST', body: 'Hello, world!\n', headers: { 'content-length': '14', }, }); });
it('should allow POST request with ArrayBuffer body from a VM context', async () => { const response = await fetch(new URL('inspect', baseURL), { method: 'POST', body: new VMUint8Array(Buffer.from('Hello, world!\n')).buffer, }); const inspect = await response.json(); expect(inspect).not.toHaveProperty('headers.transfer-encoding'); expect(inspect).not.toHaveProperty('headers.content-type'); expect(inspect).toMatchObject({ method: 'POST', body: 'Hello, world!\n', headers: { 'content-length': '14', }, }); });
it('should allow POST request with ArrayBufferView (Uint8Array) body', async () => { const response = await fetch(new URL('inspect', baseURL), { method: 'POST', body: new TextEncoder().encode('Hello, world!\n'), }); const inspect = await response.json(); expect(inspect).not.toHaveProperty('headers.transfer-encoding'); expect(inspect).not.toHaveProperty('headers.content-type'); expect(inspect).toMatchObject({ method: 'POST', body: 'Hello, world!\n', headers: { 'content-length': '14', }, }); });
it('should allow POST request with ArrayBufferView (DataView) body', async () => { const response = await fetch(new URL('inspect', baseURL), { method: 'POST', body: new DataView(new TextEncoder().encode('Hello, world!\n').buffer), }); const inspect = await response.json(); expect(inspect).not.toHaveProperty('headers.transfer-encoding'); expect(inspect).not.toHaveProperty('headers.content-type'); expect(inspect).toMatchObject({ method: 'POST', body: 'Hello, world!\n', headers: { 'content-length': '14', }, }); });
it('should allow POST request with ArrayBufferView (Uint8Array) body from a VM context', async () => { const response = await fetch(new URL('inspect', baseURL), { method: 'POST', body: new VMUint8Array(new TextEncoder().encode('Hello, world!\n')), }); const inspect = await response.json(); expect(inspect).not.toHaveProperty('headers.transfer-encoding'); expect(inspect).not.toHaveProperty('headers.content-type'); expect(inspect).toMatchObject({ method: 'POST', body: 'Hello, world!\n', headers: { 'content-length': '14', }, }); });
it('should allow POST request with ArrayBufferView (Uint8Array, offset, length) body', async () => { const response = await fetch(new URL('inspect', baseURL), { method: 'POST', body: new TextEncoder().encode('Hello, world!\n').subarray(7, 13), }); const inspect = await response.json(); expect(inspect).not.toHaveProperty('headers.transfer-encoding'); expect(inspect).not.toHaveProperty('headers.content-type'); expect(inspect).toMatchObject({ method: 'POST', body: 'world!', headers: { 'content-length': '6', }, }); });
it('should allow POST request with blob body without type', async () => { const response = await fetch(new URL('inspect', baseURL), { method: 'POST', body: new Blob(['a=1']), }); const inspect = await response.json(); expect(inspect).not.toHaveProperty('headers.transfer-encoding'); expect(inspect).not.toHaveProperty('headers.content-type'); expect(inspect).toMatchObject({ method: 'POST', body: 'a=1', headers: { 'content-length': '3', }, }); });
it('should allow POST request with blob body with type', async () => { const response = await fetch(new URL('inspect', baseURL), { method: 'POST', body: new Blob(['a=1'], { type: 'text/plain;charset=utf-8', }), }); const inspect = await response.json(); expect(inspect).not.toHaveProperty('headers.transfer-encoding'); expect(inspect).toMatchObject({ method: 'POST', body: 'a=1', headers: { 'content-type': 'text/plain;charset=utf-8', 'content-length': '3', }, }); });
it('should preserve blob body on roundtrip', async () => { const body = new Blob(['a=1']); let response = await fetch(new URL('inspect', baseURL), { method: 'POST', body, }); expect(await response.json()).toMatchObject({ body: 'a=1' }); response = await fetch(new URL('inspect', baseURL), { method: 'POST', body: new Blob(['a=1']), }); expect(await response.json()).toMatchObject({ body: 'a=1' }); });
it('should allow POST request with readable stream as body', async () => { const response = await fetch(new URL('inspect', baseURL), { method: 'POST', body: stream.Readable.from('a=1'), }); const inspect = await response.json(); expect(inspect).not.toHaveProperty('headers.content-type'); expect(inspect).not.toHaveProperty('headers.content-length'); expect(inspect).toMatchObject({ method: 'POST', body: 'a=1', headers: { 'transfer-encoding': 'chunked', }, }); });
it('should allow POST request with FormData as body', async () => { const form = new FormData(); form.append('a', '1');
const response = await fetch(new URL('multipart', baseURL), { method: 'POST', body: form, }); const inspect = await response.json(); expect(inspect).not.toHaveProperty('headers.transfer-encoding'); expect(inspect).toMatchObject({ method: 'POST', body: 'a=1', headers: { 'content-type': expect.stringMatching( /^multipart\/form-data; boundary=/ ), 'content-length': '109', }, }); });
it('should allow POST request with form-data using stream as body', async () => { const form = new FormDataPolyfill(); form.append('my_field', stream.Readable.from('dummy'));
const response = await fetch(new URL('multipart', baseURL), { method: 'POST', body: form, }); const inspect = await response.json(); expect(inspect).not.toHaveProperty('headers.content-length'); expect(inspect).toMatchObject({ method: 'POST', body: 'my_field=undefined', headers: { 'transfer-encoding': 'chunked', 'content-type': expect.stringMatching( /^multipart\/form-data; boundary=/ ), }, }); });
it('should allow POST request with URLSearchParams as body', async () => { const params = new URLSearchParams(); params.set('key1', 'value1'); params.set('key2', 'value2');
const response = await fetch(new URL('multipart', baseURL), { method: 'POST', body: params, }); const inspect = await response.json(); expect(inspect).not.toHaveProperty('headers.transfer-encoding'); expect(inspect).toMatchObject({ method: 'POST', body: 'key1=value1key2=value2', headers: { 'content-type': 'application/x-www-form-urlencoded;charset=UTF-8', 'content-length': '23', }, }); });
it('should allow POST request with extended URLSearchParams as body', async () => { class CustomSearchParameters extends URLSearchParams {} const params = new CustomSearchParameters(); params.set('key1', 'value1'); params.set('key2', 'value2');
const response = await fetch(new URL('multipart', baseURL), { method: 'POST', body: params, }); const inspect = await response.json(); expect(inspect).not.toHaveProperty('headers.transfer-encoding'); expect(inspect).toMatchObject({ method: 'POST', body: 'key1=value1key2=value2', headers: { 'content-type': 'application/x-www-form-urlencoded;charset=UTF-8', 'content-length': '23', }, }); });
it('should allow POST request with invalid body', async () => { const response = await fetch(new URL('inspect', baseURL), { method: 'POST', body: { a: 1 } as any, }); const inspect = await response.json(); expect(inspect).not.toHaveProperty('headers.transfer-encoding'); expect(inspect).toMatchObject({ method: 'POST', body: '[object Object]', headers: { 'content-type': 'text/plain;charset=UTF-8', 'content-length': expect.any(String), }, }); });
it('should overwrite Content-Length if possible', async () => { const response = await fetch(new URL('inspect', baseURL), { method: 'POST', body: new Blob(['a=1']), headers: { 'Content-Length': '1000', }, }); const inspect = await response.json(); expect(inspect).not.toHaveProperty('headers.transfer-encoding'); expect(inspect).not.toHaveProperty('headers.content-type'); expect(inspect).toMatchObject({ method: 'POST', body: 'a=1', headers: { 'content-length': '3', }, }); });
it.each([['PUT'], ['DELETE'], ['PATCH']])( 'should allow %s request', async method => { const response = await fetch(new URL('inspect', baseURL), { method, body: 'a=1', }); const inspect = await response.json(); expect(inspect).toMatchObject({ method, body: 'a=1', }); } );
it('should allow HEAD requests', async () => { const response = await fetch(new URL('inspect', baseURL), { method: 'HEAD', }); expect(response.status).toBe(200); expect(await response.text()).toBe(''); });
it('should allow HEAD requests with Content-Encoding header', async () => { const response = await fetch(new URL('error/404', baseURL), { method: 'HEAD', }); expect(response.status).toBe(404); expect(response.headers.get('Content-Encoding')).toBe('gzip'); expect(await response.text()).toBe(''); });
it('should allow OPTIONS request', async () => { const response = await fetch(new URL('options', baseURL), { method: 'OPTIONS', }); expect(response.status).toBe(200); expect(response.headers.get('Allow')).toBe('GET, HEAD, OPTIONS'); expect(await response.text()).toBe('hello world'); });
it('should support fetch with Request instance', async () => { const request = new Request(new URL('hello', baseURL)); const response = await fetch(request); expect(response.url).toBe(request.url); expect(response.ok).toBe(true); expect(response.status).toBe(200); }); });
describe('request URL', () => { it('should keep `?` sign in URL when no params are given', async () => { const response = await fetch(new URL('question?', baseURL)); expect(response.url).toBe(`${baseURL}question?`); });
it('if params are given, do not modify anything', async () => { const response = await fetch(new URL('question?a=1', baseURL)); expect(response.url).toBe(`${baseURL}question?a=1`); });
it('should preserve the hash (#) symbol', async () => { const response = await fetch(new URL('question?#', baseURL)); expect(response.url).toBe(`${baseURL}question?#`); });
it('should encode URLs as UTF-8', async () => { const url = new URL('möbius', baseURL); const res = await fetch(url); expect(res.url).to.equal(`${baseURL}m%C3%B6bius`); }); });});