import { AnyFunction, serve, ServeOptions, Server, sleep } from "bun"; import { afterAll, afterEach, beforeAll, describe, expect, it, beforeEach } from "bun:test"; import { chmodSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "fs"; import { mkfifo } from "mkfifo"; import { tmpdir } from "os"; import { join } from "path"; import { gc, withoutAggressiveGC, gcTick } from "harness"; const tmp_dir = mkdtempSync(join(realpathSync(tmpdir()), "fetch.test")); const fixture = readFileSync(join(import.meta.dir, "fetch.js.txt"), "utf8"); let server: Server; function startServer({ fetch, ...options }: ServeOptions) { server = serve({ ...options, fetch, port: 0, }); } afterEach(() => { server?.stop?.(true); }); afterAll(() => { rmSync(tmp_dir, { force: true, recursive: true }); }); const payload = new Uint8Array(1024 * 1024 * 2); crypto.getRandomValues(payload); it("new Request(invalid url) throws", () => { expect(() => new Request("http")).toThrow(); expect(() => new Request("")).toThrow(); expect(() => new Request("http://[::1")).toThrow(); expect(() => new Request("https://[::1")).toThrow(); expect(() => new Request("!")).toThrow(); }); describe("fetch data urls", () => { it("basic", async () => { var url = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAYAAACNbyblAAAAHElEQVQI12P4//8/w38GIAXDIBKE0DHxgljNBAAO9TXL0Y4OHwAAAABJRU5ErkJggg=="; var res = await fetch(url); expect(res.status).toBe(200); expect(res.statusText).toBe("OK"); expect(res.ok).toBe(true); var blob = await res.blob(); expect(blob.size).toBe(85); expect(blob.type).toBe("image/png"); }); it("percent encoded", async () => { var url = "data:text/plain;base64,SGVsbG8sIFdvcmxkIQ%3D%3D"; var res = await fetch(url); expect(res.status).toBe(200); expect(res.statusText).toBe("OK"); expect(res.ok).toBe(true); var blob = await res.blob(); expect(blob.size).toBe(13); expect(blob.type).toBe("text/plain;charset=utf-8"); expect(blob.text()).resolves.toBe("Hello, World!"); }); it("percent encoded (invalid)", async () => { var url = "data:text/plain;base64,SGVsbG8sIFdvcmxkIQ%3D%3"; expect(async () => { await fetch(url); }).toThrow("failed to fetch the data URL"); }); it("plain text", async () => { var url = "data:,Hello%2C%20World!"; var res = await fetch(url); expect(res.status).toBe(200); expect(res.statusText).toBe("OK"); expect(res.ok).toBe(true); var blob = await res.blob(); expect(blob.size).toBe(13); expect(blob.type).toBe("text/plain;charset=utf-8"); expect(blob.text()).resolves.toBe("Hello, World!"); url = "data:,helloworld!"; res = await fetch(url); expect(res.status).toBe(200); expect(res.statusText).toBe("OK"); expect(res.ok).toBe(true); blob = await res.blob(); expect(blob.size).toBe(11); expect(blob.type).toBe("text/plain;charset=utf-8"); expect(blob.text()).resolves.toBe("helloworld!"); }); it("unstrict parsing of invalid URL characters", async () => { var url = "data:application/json,{%7B%7D}"; var res = await fetch(url); expect(res.status).toBe(200); expect(res.statusText).toBe("OK"); expect(res.ok).toBe(true); var blob = await res.blob(); expect(blob.size).toBe(4); expect(blob.type).toBe("application/json;charset=utf-8"); expect(blob.text()).resolves.toBe("{{}}"); }); it("unstrict parsing of double percent characters", async () => { var url = "data:application/json,{%%7B%7D%%}%%"; var res = await fetch(url); expect(res.status).toBe(200); expect(res.statusText).toBe("OK"); expect(res.ok).toBe(true); var blob = await res.blob(); expect(blob.size).toBe(9); expect(blob.type).toBe("application/json;charset=utf-8"); expect(blob.text()).resolves.toBe("{%{}%%}%%"); }); it("data url (invalid)", async () => { var url = "data:Hello%2C%20World!"; expect(async () => { await fetch(url); }).toThrow("failed to fetch the data URL"); }); it("emoji", async () => { var url = "data:,π"; var res = await fetch(url); expect(res.status).toBe(200); expect(res.statusText).toBe("OK"); expect(res.ok).toBe(true); var blob = await res.blob(); expect(blob.size).toBe(4); expect(blob.type).toBe("text/plain;charset=utf-8"); expect(blob.text()).resolves.toBe("π"); }); it("should work with Request", async () => { var req = new Request("data:,Hello%2C%20World!"); var res = await fetch(req); expect(res.status).toBe(200); expect(res.statusText).toBe("OK"); expect(res.ok).toBe(true); var blob = await res.blob(); expect(blob.size).toBe(13); expect(blob.type).toBe("text/plain;charset=utf-8"); expect(blob.text()).resolves.toBe("Hello, World!"); req = new Request("data:,π"); res = await fetch(req); expect(res.status).toBe(200); expect(res.statusText).toBe("OK"); expect(res.ok).toBe(true); blob = await res.blob(); expect(blob.size).toBe(4); expect(blob.type).toBe("text/plain;charset=utf-8"); expect(blob.text()).resolves.toBe("π"); }); it("should work with Request (invalid)", async () => { var req = new Request("data:Hello%2C%20World!"); expect(async () => { await fetch(req); }).toThrow("failed to fetch the data URL"); req = new Request("data:Hello%345632"); expect(async () => { await fetch(req); }).toThrow("failed to fetch the data URL"); }); }); describe("AbortSignal", () => { beforeEach(() => { startServer({ async fetch(request) { if (request.url.endsWith("/nodelay")) { return new Response("Hello"); } if (request.url.endsWith("/stream")) { const reader = request.body!.getReader(); const body = new ReadableStream({ async pull(controller) { if (!reader) controller.close(); const { done, value } = await reader.read(); // When no more data needs to be consumed, close the stream if (done) { controller.close(); return; } // Enqueue the next data chunk into our target stream controller.enqueue(value); }, }); return new Response(body); } if (request.method.toUpperCase() === "POST") { const body = await request.text(); return new Response(body); } await sleep(15); return new Response("Hello"); }, }); }); afterEach(() => { server?.stop?.(true); }); it("AbortError", async () => { const controller = new AbortController(); const signal = controller.signal; expect(async () => { async function manualAbort() { await sleep(1); controller.abort(); } await Promise.all([ fetch(`http://127.0.0.1:${server.port}`, { signal: signal }).then(res => res.text()), manualAbort(), ]); }).toThrow(new DOMException("The operation was aborted.")); }); it("AbortAfterFinish", async () => { const controller = new AbortController(); const signal = controller.signal; await fetch(`http://127.0.0.1:${server.port}/nodelay`, { signal: signal }).then(async res => expect(await res.text()).toBe("Hello"), ); controller.abort(); }); it("AbortErrorWithReason", async () => { const controller = new AbortController(); const signal = controller.signal; expect(async () => { async function manualAbort() { await sleep(10); controller.abort(new Error("My Reason")); } await Promise.all([ fetch(`http://127.0.0.1:${server.port}`, { signal: signal }).then(res => res.text()), manualAbort(), ]); }).toThrow("My Reason"); }); it("AbortErrorEventListener", async () => { const controller = new AbortController(); const signal = controller.signal; signal.addEventListener("abort", ev => { const target = ev.currentTarget!; expect(target).toBeDefined(); expect(target.aborted).toBe(true); expect(target.reason).toBeDefined(); expect(target.reason!.name).toBe("AbortError"); }); expect(async () => { async function manualAbort() { await sleep(10); controller.abort(); } await Promise.all([ fetch(`http://127.0.0.1:${server.port}`, { signal: signal }).then(res => res.text()), manualAbort(), ]); }).toThrow(new DOMException("The operation was aborted.")); }); it("AbortErrorWhileUploading", async () => { const controller = new AbortController(); expect(async () => { await fetch(`http://localhost:${server.port}`, { method: "POST", body: new ReadableStream({ pull(event_controller) { event_controller.enqueue(new Uint8Array([1, 2, 3, 4])); //this will abort immediately should abort before connected controller.abort(); }, }), signal: controller.signal, }); }).toThrow(new DOMException("The operation was aborted.")); }); it("TimeoutError", async () => { const signal = AbortSignal.timeout(10); try { await fetch(`http://127.0.0.1:${server.port}`, { signal: signal }).then(res => res.text()); expect(() => {}).toThrow(); } catch (ex: any) { expect(ex.name).toBe("TimeoutError"); } }); it("Request", async () => { const controller = new AbortController(); const signal = controller.signal; async function manualAbort() { await sleep(10); controller.abort(); } try { const request = new Request(`http://127.0.0.1:${server.port}`, { signal }); await Promise.all([fetch(request).then(res => res.text()), manualAbort()]); expect(() => {}).toThrow(); } catch (ex: any) { expect(ex.name).toBe("AbortError"); } }); }); describe("Headers", () => { it(".toJSON", () => { const headers = new Headers({ "content-length": "123", "content-type": "text/plain", "x-another-custom-header": "Hello World", "x-custom-header": "Hello World", }); expect(JSON.stringify(headers.toJSON(), null, 2)).toBe( JSON.stringify(Object.fromEntries(headers.entries()), null, 2), ); }); it(".getSetCookie() with object", () => { const headers = new Headers({ "content-length": "123", "content-type": "text/plain", "x-another-custom-header": "Hello World", "x-custom-header": "Hello World", "Set-Cookie": "foo=bar; Path=/; HttpOnly", }); expect(headers.count).toBe(5); expect(headers.getAll("set-cookie")).toEqual(["foo=bar; Path=/; HttpOnly"]); }); it(".getSetCookie() with array", () => { const headers = new Headers([ ["content-length", "123"], ["content-type", "text/plain"], ["x-another-custom-header", "Hello World"], ["x-custom-header", "Hello World"], ["Set-Cookie", "foo=bar; Path=/; HttpOnly"], ["Set-Cookie", "foo2=bar2; Path=/; HttpOnly"], ]); expect(headers.count).toBe(6); expect(headers.getAll("set-cookie")).toEqual(["foo=bar; Path=/; HttpOnly", "foo2=bar2; Path=/; HttpOnly"]); }); it("Set-Cookies init", () => { const headers = new Headers([ ["Set-Cookie", "foo=bar"], ["Set-Cookie", "bar=baz"], ["X-bun", "abc"], ["X-bun", "def"], ]); const actual = [...headers]; expect(actual).toEqual([ ["x-bun", "abc, def"], ["set-cookie", "foo=bar"], ["set-cookie", "bar=baz"], ]); expect([...headers.values()]).toEqual(["abc, def", "foo=bar", "bar=baz"]); }); it("Headers append multiple", () => { const headers = new Headers([ ["Set-Cookie", "foo=bar"], ["X-bun", "foo"], ]); headers.append("Set-Cookie", "bar=baz"); headers.append("x-bun", "bar"); const actual = [...headers]; // we do not preserve the order // which is kind of bad expect(actual).toEqual([ ["x-bun", "foo, bar"], ["set-cookie", "foo=bar"], ["set-cookie", "bar=baz"], ]); }); it("append duplicate set cookie key", () => { const headers = new Headers([["Set-Cookie", "foo=bar"]]); headers.append("set-Cookie", "foo=baz"); headers.append("Set-cookie", "baz=bar"); const actual = [...headers]; expect(actual).toEqual([ ["set-cookie", "foo=bar"], ["set-cookie", "foo=baz"], ["set-cookie", "baz=bar"], ]); }); it("set duplicate cookie key", () => { const headers = new Headers([["Set-Cookie", "foo=bar"]]); headers.set("set-Cookie", "foo=baz"); headers.set("set-cookie", "bar=qat"); const actual = [...headers]; expect(actual).toEqual([["set-cookie", "bar=qat"]]); }); it("should include set-cookie headers in array", () => { const headers = new Headers(); headers.append("Set-Cookie", "foo=bar"); headers.append("Content-Type", "text/plain"); const actual = [...headers]; expect(actual).toEqual([ ["content-type", "text/plain"], ["set-cookie", "foo=bar"], ]); }); }); describe("fetch", () => { const urls = [ "https://example.com", "http://example.com", new URL("https://example.com"), new Request({ url: "https://example.com" }), { toString: () => "https://example.com" } as string, ]; for (let url of urls) { gc(); let name: string; if (url instanceof URL) { name = "URL: " + url; } else if (url instanceof Request) { name = "Request: " + url.url; } else if (url.hasOwnProperty("toString")) { name = "Object: " + url.toString(); } else { name = url as string; } it(name, async () => { gc(); const response = await fetch(url, { verbose: true }); gc(); const text = await response.text(); gc(); expect(fixture).toBe(text); }); } it('redirect: "manual"', async () => { startServer({ fetch(req) { return new Response(null, { status: 302, headers: { Location: "https://example.com", }, }); }, }); const response = await fetch(`http://${server.hostname}:${server.port}`, { redirect: "manual", }); expect(response.status).toBe(302); expect(response.headers.get("location")).toBe("https://example.com"); expect(response.redirected).toBe(true); }); it('redirect: "follow"', async () => { startServer({ fetch(req) { return new Response(null, { status: 302, headers: { Location: "https://example.com", }, }); }, }); const response = await fetch(`http://${server.hostname}:${server.port}`, { redirect: "follow", }); expect(response.status).toBe(200); expect(response.headers.get("location")).toBe(null); expect(response.redirected).toBe(true); }); it('redirect: "error" #2819', async () => { startServer({ fetch(req) { return new Response(null, { status: 302, headers: { Location: "https://example.com", }, }); }, }); try { const response = await fetch(`http://${server.hostname}:${server.port}`, { redirect: "error", }); expect(response).toBeUndefined(); } catch (err: any) { expect(err.code).toBe("UnexpectedRedirect"); } }); it("provide body", async () => { startServer({ fetch(req) { return new Response(req.body); }, hostname: "localhost", }); // POST with body const url = `http://${server.hostname}:${server.port}`; const response = await fetch(url, { method: "POST", body: "buntastic" }); expect(response.status).toBe(200); expect(await response.text()).toBe("buntastic"); }); ["GET", "HEAD", "OPTIONS"].forEach(method => it(`fail on ${method} with body`, async () => { const url = `http://${server.hostname}:${server.port}`; expect(async () => { await fetch(url, { body: "buntastic" }); }).toThrow("fetch() request with GET/HEAD/OPTIONS method cannot have body."); }), ); it("content length is inferred", async () => { startServer({ fetch(req) { return new Response(req.headers.get("content-length")); }, hostname: "localhost", }); // POST with body const url = `http://${server.hostname}:${server.port}`; const response = await fetch(url, { method: "POST", body: "buntastic" }); expect(response.status).toBe(200); expect(await response.text()).toBe("9"); const response2 = await fetch(url, { method: "POST", body: "" }); expect(response2.status).toBe(200); expect(await response2.text()).toBe("0"); }); it("should work with ipv6 localhost", async () => { const server = Bun.serve({ port: 0, fetch(req) { return new Response("Pass!"); }, }); let res = await fetch(`http://[::1]:${server.port}`); expect(await res.text()).toBe("Pass!"); res = await fetch(`http://[::]:${server.port}/`); expect(await res.text()).toBe("Pass!"); res = await fetch(`http://[0:0:0:0:0:0:0:1]:${server.port}/`); expect(await res.text()).toBe("Pass!"); res = await fetch(`http://[0000:0000:0000:0000:0000:0000:0000:0001]:${server.port}/`); expect(await res.text()).toBe("Pass!"); server.stop(); }); }); it("simultaneous HTTPS fetch", async () => { const urls = ["https://example.com", "https://www.example.com"]; for (let batch = 0; batch < 4; batch++) { const promises = new Array(20); for (let i = 0; i < 20; i++) { promises[i] = fetch(urls[i % 2]); } const result = await Promise.all(promises); expect(result.length).toBe(20); for (let i = 0; i < 20; i++) { expect(result[i].status).toBe(200); expect(await result[i].text()).toBe(fixture); } } }); it("website with tlsextname", async () => { // irony await fetch("https://bun.sh", { method: "HEAD" }); }); function testBlobInterface(blobbyConstructor: { (..._: any[]): any }, hasBlobFn?: boolean) { for (let withGC of [false, true]) { for (let jsonObject of [ { hello: true }, { hello: "π π π π π π π π€£ π₯² βΊοΈ π π π π π π π π₯° π π π π π π π π π€ͺ π€¨ π§ π€ π π₯Έ π€© π₯³", }, ]) { it(`${jsonObject.hello === true ? "latin1" : "utf16"} json${withGC ? " (with gc) " : ""}`, async () => { if (withGC) gc(); var response = blobbyConstructor(JSON.stringify(jsonObject)); if (withGC) gc(); expect(JSON.stringify(await response.json())).toBe(JSON.stringify(jsonObject)); if (withGC) gc(); }); it(`${jsonObject.hello === true ? "latin1" : "utf16"} arrayBuffer -> json${ withGC ? " (with gc) " : "" }`, async () => { if (withGC) gc(); var response = blobbyConstructor(new TextEncoder().encode(JSON.stringify(jsonObject))); if (withGC) gc(); expect(JSON.stringify(await response.json())).toBe(JSON.stringify(jsonObject)); if (withGC) gc(); }); it(`${jsonObject.hello === true ? "latin1" : "utf16"} arrayBuffer -> invalid json${ withGC ? " (with gc) " : "" }`, async () => { if (withGC) gc(); var response = blobbyConstructor( new TextEncoder().encode(JSON.stringify(jsonObject) + " NOW WE ARE INVALID JSON"), ); if (withGC) gc(); var failed = false; try { await response.json(); } catch (e) { failed = true; } expect(failed).toBe(true); if (withGC) gc(); }); it(`${jsonObject.hello === true ? "latin1" : "utf16"} text${withGC ? " (with gc) " : ""}`, async () => { if (withGC) gc(); var response = blobbyConstructor(JSON.stringify(jsonObject)); if (withGC) gc(); expect(await response.text()).toBe(JSON.stringify(jsonObject)); if (withGC) gc(); }); it(`${jsonObject.hello === true ? "latin1" : "utf16"} arrayBuffer -> text${ withGC ? " (with gc) " : "" }`, async () => { if (withGC) gc(); var response = blobbyConstructor(new TextEncoder().encode(JSON.stringify(jsonObject))); if (withGC) gc(); expect(await response.text()).toBe(JSON.stringify(jsonObject)); if (withGC) gc(); }); it(`${jsonObject.hello === true ? "latin1" : "utf16"} arrayBuffer${withGC ? " (with gc) " : ""}`, async () => { if (withGC) gc(); var response = blobbyConstructor(JSON.stringify(jsonObject)); if (withGC) gc(); const bytes = new TextEncoder().encode(JSON.stringify(jsonObject)); if (withGC) gc(); const compare = new Uint8Array(await response.arrayBuffer()); if (withGC) gc(); withoutAggressiveGC(() => { for (let i = 0; i < compare.length; i++) { if (withGC) gc(); expect(compare[i]).toBe(bytes[i]); if (withGC) gc(); } }); if (withGC) gc(); }); it(`${jsonObject.hello === true ? "latin1" : "utf16"} arrayBuffer -> arrayBuffer${ withGC ? " (with gc) " : "" }`, async () => { if (withGC) gc(); var response = blobbyConstructor(new TextEncoder().encode(JSON.stringify(jsonObject))); if (withGC) gc(); const bytes = new TextEncoder().encode(JSON.stringify(jsonObject)); if (withGC) gc(); const compare = new Uint8Array(await response.arrayBuffer()); if (withGC) gc(); withoutAggressiveGC(() => { for (let i = 0; i < compare.length; i++) { if (withGC) gc(); expect(compare[i]).toBe(bytes[i]); if (withGC) gc(); } }); if (withGC) gc(); }); hasBlobFn && it(`${jsonObject.hello === true ? "latin1" : "utf16"} blob${withGC ? " (with gc) " : ""}`, async () => { if (withGC) gc(); const text = JSON.stringify(jsonObject); var response = blobbyConstructor(text); if (withGC) gc(); const size = new TextEncoder().encode(text).byteLength; if (withGC) gc(); const blobed = await response.blob(); if (withGC) gc(); expect(blobed instanceof Blob).toBe(true); if (withGC) gc(); expect(blobed.size).toBe(size); if (withGC) gc(); expect(blobed.type).toBe("text/plain;charset=utf-8"); const out = await blobed.text(); expect(out).toBe(text); if (withGC) gc(); await new Promise(resolve => setTimeout(resolve, 1)); if (withGC) gc(); expect(out).toBe(text); const first = await blobed.arrayBuffer(); const initial = first[0]; first[0] = 254; const second = await blobed.arrayBuffer(); expect(second[0]).toBe(initial); expect(first[0]).toBe(254); }); } } } describe("Bun.file", () => { let count = 0; testBlobInterface(data => { const blob = new Blob([data]); const buffer = Bun.peek(blob.arrayBuffer()) as ArrayBuffer; const path = join(tmp_dir, `tmp-${count++}.bytes`); writeFileSync(path, buffer); const file = Bun.file(path); expect(blob.size).toBe(file.size); expect(file.lastModified).toBeGreaterThan(0); return file; }); it("size is Infinity on a fifo", () => { const path = join(tmp_dir, "test-fifo"); mkfifo(path); const { size } = Bun.file(path); expect(size).toBe(Infinity); }); const method = ["arrayBuffer", "text", "json"] as const; function forEachMethod(fn: (m: (typeof method)[number]) => any, skip?: AnyFunction) { for (const m of method) { (skip ? it.skip : it)(m, fn(m)); } } describe("bad permissions throws", () => { const path = join(tmp_dir, "my-new-file"); beforeAll(async () => { await Bun.write(path, "hey"); chmodSync(path, 0o000); }); forEachMethod( m => () => { const file = Bun.file(path); expect(async () => await file[m]()).toThrow("Permission denied"); }, () => { try { readFileSync(path); } catch { return false; } return true; }, ); }); describe("non-existent file throws", () => { const path = join(tmp_dir, "does-not-exist"); forEachMethod(m => async () => { const file = Bun.file(path); expect(async () => await file[m]()).toThrow("No such file or directory"); }); }); }); describe("Blob", () => { testBlobInterface(data => new Blob([data])); it("should have expected content type", async () => { var response = new Response("