diff options
Diffstat (limited to 'test/js/web')
31 files changed, 5989 insertions, 0 deletions
diff --git a/test/js/web/abort/abort-signal-timeout.test.js b/test/js/web/abort/abort-signal-timeout.test.js new file mode 100644 index 000000000..7d741b2ad --- /dev/null +++ b/test/js/web/abort/abort-signal-timeout.test.js @@ -0,0 +1,12 @@ +import { expect, test } from "bun:test"; + +test.skip("AbortSignal.timeout", done => { + const abort = AbortSignal.timeout(10); + abort.addEventListener("abort", event => { + done(); + }); + + // AbortSignal.timeout doesn't keep the event loop / process alive + // so we set a no-op timeout + setTimeout(() => {}, 11); +}); diff --git a/test/js/web/console/console-log.expected.txt b/test/js/web/console/console-log.expected.txt new file mode 100644 index 000000000..97191c8be --- /dev/null +++ b/test/js/web/console/console-log.expected.txt @@ -0,0 +1,46 @@ +Hello World! +123 +-123 +123.567 +-123.567 +true +false +null +undefined +Symbol(Symbol Description) +2000-06-27T02:24:34.304Z +[ 123, 456, 789 ] +{ + name: "foo" +} +{ + a: 123, + b: 456, + c: 789 +} +{ + a: { + b: { + c: 123 + }, + bacon: true + }, + name: "bar" +} +Promise { <pending> } +[Function] +[Function: Foo] +{} +[Function: foooo] +/FooRegex/ +Is it a bug or a feature that formatting numbers like 123 is colored +String 123 should be 2nd word, 456 == 456 and percent s %s == What okay +{ + foo: { + name: "baz" + }, + bar: [Circular] +} am +[ + {}, {}, {}, {} +] diff --git a/test/js/web/console/console-log.js b/test/js/web/console/console-log.js new file mode 100644 index 000000000..e23a3e9cb --- /dev/null +++ b/test/js/web/console/console-log.js @@ -0,0 +1,54 @@ +console.log("Hello World!"); +console.log(123); +console.log(-123); +console.log(123.567); +console.log(-123.567); +console.log(true); +console.log(false); +console.log(null); +console.log(undefined); +console.log(Symbol("Symbol Description")); +console.log(new Date(Math.pow(2, 34) * 56)); +console.log([123, 456, 789]); +console.log({ name: "foo" }); +console.log({ a: 123, b: 456, c: 789 }); +console.log({ + a: { + b: { + c: 123, + }, + bacon: true, + }, + name: "bar", +}); + +console.log(new Promise(() => {})); + +class Foo {} + +console.log(() => {}); +console.log(Foo); +console.log(new Foo()); +console.log(function foooo() {}); + +console.log(/FooRegex/); + +console.error("uh oh"); +console.time("Check"); + +console.log("Is it a bug or a feature that formatting numbers like %d is colored", 123); +//console.log(globalThis); + +console.log("String %s should be 2nd word, 456 == %s and percent s %s == %s", "123", "456", "%s", "What", "okay"); + +const infinteLoop = { + foo: { + name: "baz", + }, + bar: {}, +}; + +infinteLoop.bar = infinteLoop; +console.log(infinteLoop, "am"); + +console.log(new Array(4).fill({})); diff --git a/test/js/web/console/console-log.test.ts b/test/js/web/console/console-log.test.ts new file mode 100644 index 000000000..98c8370de --- /dev/null +++ b/test/js/web/console/console-log.test.ts @@ -0,0 +1,20 @@ +import { file, spawn } from "bun"; +import { expect, it } from "bun:test"; +import { bunExe } from "harness"; + +it("should log to console correctly", async () => { + const { stdout, stderr, exited } = spawn({ + cmd: [bunExe(), import.meta.dir + "/console-log.js"], + stdin: null, + stdout: "pipe", + stderr: "pipe", + env: { + BUN_DEBUG_QUIET_LOGS: "1", + }, + }); + expect(await exited).toBe(0); + expect(await new Response(stderr).text()).toBe("uh oh\n"); + expect(await new Response(stdout).text()).toBe( + await new Response(file(import.meta.dir + "/console-log.expected.txt")).text(), + ); +}); diff --git a/test/js/web/crypto/web-crypto.test.ts b/test/js/web/crypto/web-crypto.test.ts new file mode 100644 index 000000000..250282b96 --- /dev/null +++ b/test/js/web/crypto/web-crypto.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "bun:test"; + +describe("Web Crypto", () => { + it("has globals", () => { + expect(crypto.subtle !== undefined).toBe(true); + expect(CryptoKey.name).toBe("CryptoKey"); + expect(SubtleCrypto.name).toBe("SubtleCrypto"); + }); + it("should encrypt and decrypt", async () => { + const key = await crypto.subtle.generateKey( + { + name: "AES-GCM", + length: 256, + }, + true, + ["encrypt", "decrypt"], + ); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const data = new TextEncoder().encode("Hello World!"); + const encrypted = await crypto.subtle.encrypt( + { + name: "AES-GCM", + iv, + }, + key, + data, + ); + const decrypted = await crypto.subtle.decrypt( + { + name: "AES-GCM", + iv, + }, + key, + encrypted, + ); + expect(new TextDecoder().decode(decrypted)).toBe("Hello World!"); + }); + + it("should verify and sign", async () => { + async function importKey(secret) { + return await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign", "verify"], + ); + } + + async function signResponse(message, secret) { + const key = await importKey(secret); + const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(message)); + + // Convert ArrayBuffer to Base64 + return btoa(String.fromCharCode(...new Uint8Array(signature))); + } + + async function verifySignature(message, signature, secret) { + const key = await importKey(secret); + + // Convert Base64 to Uint8Array + const sigBuf = Uint8Array.from(atob(signature), c => c.charCodeAt(0)); + + return await crypto.subtle.verify("HMAC", key, sigBuf, new TextEncoder().encode(message)); + } + + const msg = `hello world`; + const SECRET = "secret"; + const signature = await signResponse(msg, SECRET); + + const isSigValid = await verifySignature(msg, signature, SECRET); + expect(isSigValid).toBe(true); + }); +}); + +describe("Ed25519", () => { + describe("generateKey", () => { + it("should return CryptoKeys without namedCurve in algorithm field", async () => { + const { publicKey, privateKey } = (await crypto.subtle.generateKey("Ed25519", true, [ + "sign", + "verify", + ])) as CryptoKeyPair; + expect(publicKey.algorithm!.name).toBe("Ed25519"); + // @ts-ignore + expect(publicKey.algorithm!.namedCurve).toBe(undefined); + expect(privateKey.algorithm!.name).toBe("Ed25519"); + // @ts-ignore + expect(privateKey.algorithm!.namedCurve).toBe(undefined); + }); + }); +}); diff --git a/test/js/web/encoding/text-decoder.test.js b/test/js/web/encoding/text-decoder.test.js new file mode 100644 index 000000000..abd4c2a72 --- /dev/null +++ b/test/js/web/encoding/text-decoder.test.js @@ -0,0 +1,243 @@ +import { expect, it, describe } from "bun:test"; +import { gc as gcTrace, withoutAggressiveGC } from "harness"; + +const getByteLength = str => { + // returns the byte length of an utf8 string + var s = str.length; + for (var i = str.length - 1; i >= 0; i--) { + var code = str.charCodeAt(i); + if (code > 0x7f && code <= 0x7ff) s++; + else if (code > 0x7ff && code <= 0xffff) s += 2; + if (code >= 0xdc00 && code <= 0xdfff) i--; //trail surrogate + } + return s; +}; + +describe("TextDecoder", () => { + it("should not crash on empty text", () => { + const decoder = new TextDecoder(); + gcTrace(true); + const fixtures = [new Uint8Array(), new Uint8Array([]), new Buffer(0), new ArrayBuffer(0), new Uint16Array(0)]; + + for (let input of fixtures) { + expect(decoder.decode(input)).toBe(""); + } + + // Cause a de-opt + try { + decoder.decode([NaN, Symbol("s")]); + } catch (e) {} + + // DOMJIT test + for (let i = 0; i < 90000; i++) { + decoder.decode(fixtures[0]); + } + + gcTrace(true); + }); + it("should decode ascii text", () => { + const decoder = new TextDecoder("latin1"); + gcTrace(true); + expect(decoder.encoding).toBe("windows-1252"); + gcTrace(true); + expect(decoder.decode(new Uint8Array([0x41, 0x42, 0x43]))).toBe("ABC"); + gcTrace(true); + + // hit the SIMD code path + const result = [ + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, 72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33, + ]; + gcTrace(true); + expect(decoder.decode(Uint8Array.from(result))).toBe(String.fromCharCode(...result)); + gcTrace(true); + }); + + it("should decode unicode text", () => { + const decoder = new TextDecoder(); + gcTrace(true); + const inputBytes = [226, 157, 164, 239, 184, 143, 32, 82, 101, 100, 32, 72, 101, 97, 114, 116]; + for (var repeat = 1; repeat < 100; repeat++) { + var text = `❤️ Red Heart`.repeat(repeat); + + var bytes = Array.from({ length: repeat }, () => inputBytes).flat(); + var decoded = decoder.decode(Uint8Array.from(bytes)); + expect(decoder.encoding).toBe("utf-8"); + expect(decoded).toBe(text); + gcTrace(true); + } + }); + + describe("typedArrays", () => { + var text = `ABC DEF GHI JKL MNO PQR STU VWX YZ ABC DEF GHI JKL MNO PQR STU V`; + var bytes = new TextEncoder().encode(text); + var decoder = new TextDecoder(); + for (let TypedArray of [ + Uint8Array, + Uint16Array, + Uint32Array, + Int8Array, + Int16Array, + Int32Array, + Float32Array, + Float64Array, + DataView, + BigInt64Array, + BigUint64Array, + ]) { + it(`should decode ${TypedArray.name}`, () => { + const decoded = decoder.decode(new TypedArray(bytes.buffer)); + expect(decoded).toBe(text); + }); + } + + it("DOMJIT call", () => { + const array = new Uint8Array(bytes.buffer); + withoutAggressiveGC(() => { + for (let i = 0; i < 100_000; i++) { + const decoded = decoder.decode(array); + expect(decoded).toBe(text); + } + }); + }); + }); + + it("should decode unicode text with multiple consecutive emoji", () => { + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + gcTrace(true); + var text = `❤️❤️❤️❤️❤️❤️ Red Heart`; + + text += ` ✨ Sparkles 🔥 Fire 😀 😃 😄 😁 😆 😅 😂 🤣 🥲 ☺️ 😊 😇 🙂 🙃 😉 😌 😍 🥰 😘 😗 😙 😚 😋 😛 😝 😜 🤪 🤨 🧐 🤓 😎 🥸 🤩 🥳 😏 😒 😞 😔 😟 😕 🙁 ☹️ 😣 😖 😫 😩 🥺 😢 😭 😤 😠 😡 🤬 🤯 😳 🥵 🥶 😱 😨 😰`; + gcTrace(true); + expect(decoder.decode(encoder.encode(text))).toBe(text); + gcTrace(true); + const bytes = new Uint8Array(getByteLength(text) * 8); + gcTrace(true); + const amount = encoder.encodeInto(text, bytes); + gcTrace(true); + expect(decoder.decode(bytes.subarray(0, amount.written))).toBe(text); + gcTrace(true); + }); +}); + +it("truncated sequences", () => { + const assert_equals = (a, b) => expect(a).toBe(b); + + // Truncated sequences + assert_equals(new TextDecoder().decode(new Uint8Array([0xf0])), "\uFFFD"); + assert_equals(new TextDecoder().decode(new Uint8Array([0xf0, 0x9f])), "\uFFFD"); + assert_equals(new TextDecoder().decode(new Uint8Array([0xf0, 0x9f, 0x92])), "\uFFFD"); + + // Errors near end-of-queue + assert_equals(new TextDecoder().decode(new Uint8Array([0xf0, 0x9f, 0x41])), "\uFFFDA"); + assert_equals(new TextDecoder().decode(new Uint8Array([0xf0, 0x41, 0x42])), "\uFFFDAB"); + assert_equals(new TextDecoder().decode(new Uint8Array([0xf0, 0x41, 0xf0])), "\uFFFDA\uFFFD"); + assert_equals(new TextDecoder().decode(new Uint8Array([0xf0, 0x8f, 0x92])), "\uFFFD\uFFFD\uFFFD"); +}); diff --git a/test/js/web/encoding/text-encoder.test.js b/test/js/web/encoding/text-encoder.test.js new file mode 100644 index 000000000..3d271026d --- /dev/null +++ b/test/js/web/encoding/text-encoder.test.js @@ -0,0 +1,281 @@ +import { expect, it, describe } from "bun:test"; +import { gc as gcTrace, withoutAggressiveGC } from "harness"; + +const getByteLength = str => { + // returns the byte length of an utf8 string + var s = str.length; + for (var i = str.length - 1; i >= 0; i--) { + var code = str.charCodeAt(i); + if (code > 0x7f && code <= 0x7ff) s++; + else if (code > 0x7ff && code <= 0xffff) s += 2; + if (code >= 0xdc00 && code <= 0xdfff) i--; //trail surrogate + } + return s; +}; + +describe("TextEncoder", () => { + it("should encode latin1 text with non-ascii latin1 characters", () => { + var text = "H©ell©o Wor©ld!"; + + gcTrace(true); + const encoder = new TextEncoder(); + const encoded = encoder.encode(text); + gcTrace(true); + const into = new Uint8Array(100); + const out = encoder.encodeInto(text, into); + gcTrace(true); + expect(out.read).toBe(text.length); + + expect(encoded instanceof Uint8Array).toBe(true); + const result = [72, 194, 169, 101, 108, 108, 194, 169, 111, 32, 87, 111, 114, 194, 169, 108, 100, 33]; + for (let i = 0; i < result.length; i++) { + expect(encoded[i]).toBe(result[i]); + expect(into[i]).toBe(result[i]); + } + expect(encoded.length).toBe(result.length); + expect(out.written).toBe(result.length); + + const repeatCOunt = 16; + text = "H©ell©o Wor©ld!".repeat(repeatCOunt); + const byteLength = getByteLength(text); + const encoded2 = encoder.encode(text); + expect(encoded2.length).toBe(byteLength); + const into2 = new Uint8Array(byteLength); + const out2 = encoder.encodeInto(text, into2); + expect(out2.read).toBe(text.length); + expect(out2.written).toBe(byteLength); + expect(into2).toEqual(encoded2); + const repeatedResult = new Uint8Array(byteLength); + for (let i = 0; i < repeatCOunt; i++) { + repeatedResult.set(result, i * result.length); + } + expect(into2).toEqual(repeatedResult); + }); + + it("should encode latin1 text", async () => { + gcTrace(true); + const text = "Hello World!"; + const encoder = new TextEncoder(); + gcTrace(true); + const encoded = encoder.encode(text); + gcTrace(true); + expect(encoded instanceof Uint8Array).toBe(true); + expect(encoded.length).toBe(text.length); + gcTrace(true); + const result = [72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33]; + for (let i = 0; i < result.length; i++) { + expect(encoded[i]).toBe(result[i]); + } + + let t = [ + { + str: "\u009c\u0097", + expected: [194, 156, 194, 151], + }, + { + str: "世", + expected: [228, 184, 150], + }, + // Less than 0, out of range. + { + str: -1, + expected: [45, 49], + }, + // Greater than 0x10FFFF, out of range. + { + str: 0x110000, + expected: [49, 49, 49, 52, 49, 49, 50], + }, + // The Unicode replacement character. + { + str: "\uFFFD", + expected: [239, 191, 189], + }, + ]; + for (let { str, expected } of t) { + let utf8 = new TextEncoder().encode(str); + expect([...utf8]).toEqual(expected); + } + + expect([...new TextEncoder().encode(String.fromCodePoint(0))]).toEqual([0]); + + const fixture = new Uint8Array(await Bun.file(import.meta.dir + "/utf8-encoding-fixture.bin").arrayBuffer()); + const length = 0x110000; + let textEncoder = new TextEncoder(); + let textDecoder = new TextDecoder(); + let encodeOut = new Uint8Array(length * 4); + let encodeIntoOut = new Uint8Array(length * 4); + let encodeIntoBuffer = new Uint8Array(4); + let encodeDecodedOut = new Uint8Array(length * 4); + for (let i = 0, offset = 0; i < length; i++, offset += 4) { + const s = String.fromCodePoint(i); + const u = textEncoder.encode(s); + encodeOut.set(u, offset); + + textEncoder.encodeInto(s, encodeIntoBuffer); + encodeIntoOut.set(encodeIntoBuffer, offset); + + const decoded = textDecoder.decode(encodeIntoBuffer); + const encoded = textEncoder.encode(decoded); + encodeDecodedOut.set(encoded, offset); + } + + expect(encodeOut).toEqual(fixture); + expect(encodeIntoOut).toEqual(fixture); + expect(encodeOut).toEqual(encodeIntoOut); + expect(encodeDecodedOut).toEqual(encodeOut); + expect(encodeDecodedOut).toEqual(encodeIntoOut); + expect(encodeDecodedOut).toEqual(fixture); + + expect(() => textEncoder.encode(String.fromCodePoint(length + 1))).toThrow(); + }); + + it("should encode long latin1 text", async () => { + const text = "Hello World!".repeat(1000); + const encoder = new TextEncoder(); + gcTrace(true); + const encoded = encoder.encode(text); + gcTrace(true); + expect(encoded instanceof Uint8Array).toBe(true); + expect(encoded.length).toBe(text.length); + gcTrace(true); + const decoded = new TextDecoder().decode(encoded); + expect(decoded).toBe(text); + gcTrace(); + await new Promise(resolve => setTimeout(resolve, 1)); + gcTrace(); + expect(decoded).toBe(text); + }); + + it("should encode latin1 rope text", () => { + var text = "Hello"; + text += " "; + text += "World!"; + + gcTrace(true); + const encoder = new TextEncoder(); + const encoded = encoder.encode(text); + gcTrace(true); + const into = new Uint8Array(100); + const out = encoder.encodeInto(text, into); + gcTrace(true); + expect(out.read).toBe(text.length); + expect(out.written).toBe(encoded.length); + expect(encoded instanceof Uint8Array).toBe(true); + const result = [72, 101, 108, 108, 111, 32, 87, 111, 114, 108, 100, 33]; + for (let i = 0; i < result.length; i++) { + expect(encoded[i]).toBe(result[i]); + expect(encoded[i]).toBe(into[i]); + } + expect(encoded.length).toBe(getByteLength(text)); + }); + + it("should encode latin1 rope text with non-ascii latin1 characters", () => { + var text = "H©ell©o"; + text += " "; + text += "Wor©ld!"; + + gcTrace(true); + const encoder = new TextEncoder(); + const encoded = encoder.encode(text); + gcTrace(true); + const into = new Uint8Array(100); + const out = encoder.encodeInto(text, into); + gcTrace(true); + expect(out.read).toBe(text.length); + + expect(encoded instanceof Uint8Array).toBe(true); + const result = [72, 194, 169, 101, 108, 108, 194, 169, 111, 32, 87, 111, 114, 194, 169, 108, 100, 33]; + + for (let i = 0; i < result.length; i++) { + expect(encoded[i]).toBe(into[i]); + expect(encoded[i]).toBe(result[i]); + } + expect(encoded.length).toBe(result.length); + expect(out.written).toBe(encoded.length); + + withoutAggressiveGC(() => { + for (let i = 0; i < 10_000; i++) { + expect(encoder.encodeInto(text, into)).toEqual(out); + } + }); + }); + + it("should encode utf-16 text", () => { + var text = `❤️ Red Heart + ✨ Sparkles + 🔥 Fire + `; + var encoder = new TextEncoder(); + var decoder = new TextDecoder(); + gcTrace(true); + expect(decoder.decode(encoder.encode(text))).toBe(text); + gcTrace(true); + }); + + // this test is from a web platform test in WebKit + describe("should use a unicode replacement character for invalid surrogate pairs", () => { + var bad = [ + { + encoding: "utf-16le", + input: [0x00, 0xd8], + expected: "\uFFFD", + name: "lone surrogate lead", + }, + { + encoding: "utf-16le", + input: [0x00, 0xdc], + expected: "\uFFFD", + name: "lone surrogate trail", + }, + { + encoding: "utf-16le", + input: [0x00, 0xd8, 0x00, 0x00], + expected: "\uFFFD\u0000", + name: "unmatched surrogate lead", + }, + { + encoding: "utf-16le", + input: [0x00, 0xdc, 0x00, 0x00], + expected: "\uFFFD\u0000", + name: "unmatched surrogate trail", + }, + { + encoding: "utf-16le", + input: [0x00, 0xdc, 0x00, 0xd8], + expected: "\uFFFD\uFFFD", + name: "swapped surrogate pair", + }, + ]; + + bad.forEach(function (t) { + it(t.encoding + " - " + t.name, () => { + gcTrace(true); + expect(new TextDecoder(t.encoding).decode(new Uint8Array(t.input))).toBe(t.expected); + expect(new TextDecoder(t.encoding).decode(new Uint16Array(new Uint8Array(t.input).buffer))).toBe(t.expected); + gcTrace(true); + }); + // test(function () { + // assert_throws_js(TypeError, function () { + // new TextDecoder(t.encoding, { fatal: true }).decode( + // new Uint8Array(t.input) + // ); + // }); + // }, t.encoding + " - " + t.name + " (fatal flag set)"); + }); + }); + + it("should encode utf-16 rope text", () => { + gcTrace(true); + var textReal = `❤️ Red Heart ✨ Sparkles 🔥 Fire`; + + var a = textReal.split(""); + var text = ""; + for (let j of a) { + text += j; + } + + var encoder = new TextEncoder(); + expect(new TextDecoder().decode(encoder.encode(text))).toBe(textReal); + }); +}); diff --git a/test/js/web/encoding/utf8-encoding-fixture.bin b/test/js/web/encoding/utf8-encoding-fixture.bin Binary files differnew file mode 100644 index 000000000..1f9ecf34f --- /dev/null +++ b/test/js/web/encoding/utf8-encoding-fixture.bin diff --git a/test/js/web/fetch/body-mixin-errors.test.ts b/test/js/web/fetch/body-mixin-errors.test.ts new file mode 100644 index 000000000..f57bbc56c --- /dev/null +++ b/test/js/web/fetch/body-mixin-errors.test.ts @@ -0,0 +1,17 @@ +import { it, describe, expect } from "bun:test"; + +describe("body-mixin-errors", () => { + it("should fail when bodyUsed", async () => { + var res = new Response("a"); + expect(res.bodyUsed).toBe(false); + await res.text(); + expect(res.bodyUsed).toBe(true); + + try { + await res.text(); + throw new Error("should not get here"); + } catch (e: any) { + expect(e.message).toBe("Body already used"); + } + }); +}); diff --git a/test/js/web/fetch/body-stream.test.ts b/test/js/web/fetch/body-stream.test.ts new file mode 100644 index 000000000..1cd932ed9 --- /dev/null +++ b/test/js/web/fetch/body-stream.test.ts @@ -0,0 +1,451 @@ +// @ts-nocheck +import { file, gc, serve, ServeOptions } from "bun"; +import { afterAll, afterEach, describe, expect, it, test } from "bun:test"; +import { readFileSync } from "fs"; + +var port = 0; + +{ + const BodyMixin = [ + Request.prototype.arrayBuffer, + Request.prototype.blob, + Request.prototype.text, + Request.prototype.json, + ]; + const useRequestObjectValues = [true, false]; + + for (let RequestPrototypeMixin of BodyMixin) { + for (let useRequestObject of useRequestObjectValues) { + describe(`Request.prototoype.${RequestPrototypeMixin.name}() ${ + useRequestObject ? "fetch(req)" : "fetch(url)" + }`, () => { + const inputFixture = [ + [JSON.stringify("Hello World"), JSON.stringify("Hello World")], + [JSON.stringify("Hello World 123"), Buffer.from(JSON.stringify("Hello World 123")).buffer], + [JSON.stringify("Hello World 456"), Buffer.from(JSON.stringify("Hello World 456"))], + [ + JSON.stringify("EXTREMELY LONG VERY LONG STRING WOW SO LONG YOU WONT BELIEVE IT! ".repeat(100)), + Buffer.from( + JSON.stringify("EXTREMELY LONG VERY LONG STRING WOW SO LONG YOU WONT BELIEVE IT! ".repeat(100)), + ), + ], + [ + JSON.stringify("EXTREMELY LONG 🔥 UTF16 🔥 VERY LONG STRING WOW SO LONG YOU WONT BELIEVE IT! ".repeat(100)), + Buffer.from( + JSON.stringify( + "EXTREMELY LONG 🔥 UTF16 🔥 VERY LONG STRING WOW SO LONG YOU WONT BELIEVE IT! ".repeat(100), + ), + ), + ], + ]; + + for (const [name, input] of inputFixture) { + test(`${name.slice(0, Math.min(name.length ?? name.byteLength, 64))}`, async () => { + await runInServer( + { + async fetch(req) { + var result = await RequestPrototypeMixin.call(req); + if (RequestPrototypeMixin === Request.prototype.json) { + result = JSON.stringify(result); + } + if (typeof result === "string") { + expect(result.length).toBe(name.length); + expect(result).toBe(name); + } else if (result && result instanceof Blob) { + expect(result.size).toBe(new TextEncoder().encode(name).byteLength); + expect(await result.text()).toBe(name); + } else { + expect(result.byteLength).toBe(Buffer.from(input).byteLength); + expect(Bun.SHA1.hash(result, "base64")).toBe(Bun.SHA1.hash(input, "base64")); + } + return new Response(result, { + headers: req.headers, + }); + }, + }, + async url => { + var response; + + // once, then batch of 5 + + if (useRequestObject) { + response = await fetch( + new Request({ + body: input, + method: "POST", + url: url, + headers: { + "content-type": "text/plain", + }, + }), + ); + } else { + response = await fetch(url, { + body: input, + method: "POST", + headers: { + "content-type": "text/plain", + }, + }); + } + + expect(response.status).toBe(200); + expect(response.headers.get("content-length")).toBe(String(Buffer.from(input).byteLength)); + expect(response.headers.get("content-type")).toBe("text/plain"); + expect(await response.text()).toBe(name); + + var promises = new Array(5); + for (let i = 0; i < 5; i++) { + if (useRequestObject) { + promises[i] = await fetch( + new Request({ + body: input, + method: "POST", + url: url, + headers: { + "content-type": "text/plain", + "x-counter": i, + }, + }), + ); + } else { + promises[i] = await fetch(url, { + body: input, + method: "POST", + headers: { + "content-type": "text/plain", + "x-counter": i, + }, + }); + } + } + + const results = await Promise.all(promises); + for (let i = 0; i < 5; i++) { + const response = results[i]; + expect(response.status).toBe(200); + expect(response.headers.get("content-length")).toBe(String(Buffer.from(input).byteLength)); + expect(response.headers.get("content-type")).toBe("text/plain"); + expect(response.headers.get("x-counter")).toBe(String(i)); + expect(await response.text()).toBe(name); + } + }, + ); + }); + } + }); + } + } +} + +var existingServer; +async function runInServer(opts: ServeOptions, cb: (url: string) => void | Promise<void>) { + var server; + const handler = { + ...opts, + port: port++, + fetch(req) { + try { + return opts.fetch(req); + } catch (e) { + console.error(e.message); + console.log(e.stack); + throw e; + } + }, + error(err) { + console.log(err.message); + console.log(err.stack); + throw err; + }, + }; + + if (!existingServer) { + existingServer = server = Bun.serve(handler); + } else { + server = existingServer; + server.reload(handler); + } + + try { + await cb(`http://${server.hostname}:${server.port}`); + } catch (e) { + throw e; + } finally { + } +} + +afterAll(() => { + existingServer && existingServer.close(); + existingServer = null; +}); + +function fillRepeating(dstBuffer, start, end) { + let len = dstBuffer.length, + sLen = end - start, + p = sLen; + while (p < len) { + if (p + sLen > len) sLen = len - p; + dstBuffer.copyWithin(p, start, sLen); + p += sLen; + sLen <<= 1; + } +} + +function gc() { + Bun.gc(true); +} + +describe("reader", function () { + try { + // - empty + // - 1 byte + // - less than the InlineBlob limit + // - multiple chunks + // - backpressure + for (let inputLength of [0, 1, 2, 12, 95, 1024, 1024 * 1024, 1024 * 1024 * 2]) { + var bytes = new Uint8Array(inputLength); + { + const chunk = Math.min(bytes.length, 256); + for (var i = 0; i < chunk; i++) { + bytes[i] = 255 - i; + } + } + + if (bytes.length > 255) fillRepeating(bytes, 0, bytes.length); + + for (const huge_ of [ + bytes, + bytes.buffer, + new DataView(bytes.buffer), + new Int8Array(bytes), + new Blob([bytes]), + + new Uint16Array(bytes), + new Uint32Array(bytes), + new Float64Array(bytes), + + new Int16Array(bytes), + new Int32Array(bytes), + new Float32Array(bytes), + + // make sure we handle subarray() as expected when reading + // typed arrays from native code + new Int16Array(bytes).subarray(1), + new Int16Array(bytes).subarray(0, new Int16Array(bytes).byteLength - 1), + new Int32Array(bytes).subarray(1), + new Int32Array(bytes).subarray(0, new Int32Array(bytes).byteLength - 1), + new Float32Array(bytes).subarray(1), + new Float32Array(bytes).subarray(0, new Float32Array(bytes).byteLength - 1), + new Int16Array(bytes).subarray(0, 1), + new Int32Array(bytes).subarray(0, 1), + new Float32Array(bytes).subarray(0, 1), + ]) { + gc(); + const thisArray = huge_; + it(`works with ${thisArray.constructor.name}(${ + thisArray.byteLength ?? thisArray.size + }:${inputLength}) via req.body.getReader() in chunks`, async () => { + var huge = thisArray; + var called = false; + gc(); + + const expectedHash = + huge instanceof Blob + ? Bun.SHA1.hash(new Uint8Array(await huge.arrayBuffer()), "base64") + : Bun.SHA1.hash(huge, "base64"); + const expectedSize = huge instanceof Blob ? huge.size : huge.byteLength; + + const out = await runInServer( + { + async fetch(req) { + try { + expect(req.headers.get("x-custom")).toBe("hello"); + expect(req.headers.get("content-type")).toBe("text/plain"); + expect(req.headers.get("user-agent")).toBe(navigator.userAgent); + + gc(); + expect(req.headers.get("x-custom")).toBe("hello"); + expect(req.headers.get("content-type")).toBe("text/plain"); + expect(req.headers.get("user-agent")).toBe(navigator.userAgent); + + var reader = req.body.getReader(); + called = true; + var buffers = []; + while (true) { + var { done, value } = await reader.read(); + if (done) break; + buffers.push(value); + } + const out = new Blob(buffers); + gc(); + expect(out.size).toBe(expectedSize); + expect(Bun.SHA1.hash(await out.arrayBuffer(), "base64")).toBe(expectedHash); + expect(req.headers.get("x-custom")).toBe("hello"); + expect(req.headers.get("content-type")).toBe("text/plain"); + expect(req.headers.get("user-agent")).toBe(navigator.userAgent); + gc(); + return new Response(out, { + headers: req.headers, + }); + } catch (e) { + console.error(e); + throw e; + } + }, + }, + async url => { + gc(); + const response = await fetch(url, { + body: huge, + method: "POST", + headers: { + "content-type": "text/plain", + "x-custom": "hello", + "x-typed-array": thisArray.constructor.name, + }, + }); + huge = undefined; + expect(response.status).toBe(200); + const response_body = new Uint8Array(await response.arrayBuffer()); + + expect(response_body.byteLength).toBe(expectedSize); + expect(Bun.SHA1.hash(response_body, "base64")).toBe(expectedHash); + + gc(); + expect(response.headers.get("content-type")).toBe("text/plain"); + gc(); + }, + ); + expect(called).toBe(true); + gc(); + return out; + }); + + for (let isDirectStream of [true, false]) { + const positions = ["begin", "end"]; + const inner = thisArray => { + for (let position of positions) { + it(`streaming back ${thisArray.constructor.name}(${ + thisArray.byteLength ?? thisArray.size + }:${inputLength}) starting request.body.getReader() at ${position}`, async () => { + var huge = thisArray; + var called = false; + gc(); + + const expectedHash = + huge instanceof Blob + ? Bun.SHA1.hash(new Uint8Array(await huge.arrayBuffer()), "base64") + : Bun.SHA1.hash(huge, "base64"); + const expectedSize = huge instanceof Blob ? huge.size : huge.byteLength; + + const out = await runInServer( + { + async fetch(req) { + try { + var reader; + + if (position === "begin") { + reader = req.body.getReader(); + } + + if (position === "end") { + await 1; + reader = req.body.getReader(); + } + + expect(req.headers.get("x-custom")).toBe("hello"); + expect(req.headers.get("content-type")).toBe("text/plain"); + expect(req.headers.get("user-agent")).toBe(navigator.userAgent); + + gc(); + expect(req.headers.get("x-custom")).toBe("hello"); + expect(req.headers.get("content-type")).toBe("text/plain"); + expect(req.headers.get("user-agent")).toBe(navigator.userAgent); + + const direct = { + type: "direct", + async pull(controller) { + while (true) { + const { done, value } = await reader.read(); + if (done) { + called = true; + controller.end(); + + return; + } + controller.write(value); + } + }, + }; + + const web = { + async pull(controller) { + while (true) { + const { done, value } = await reader.read(); + if (done) { + called = true; + controller.close(); + return; + } + controller.enqueue(value); + } + }, + }; + + return new Response(new ReadableStream(isDirectStream ? direct : web), { + headers: req.headers, + }); + } catch (e) { + console.error(e); + throw e; + } + }, + }, + async url => { + gc(); + const response = await fetch(url, { + body: huge, + method: "POST", + headers: { + "content-type": "text/plain", + "x-custom": "hello", + "x-typed-array": thisArray.constructor.name, + }, + }); + huge = undefined; + expect(response.status).toBe(200); + const response_body = new Uint8Array(await response.arrayBuffer()); + + expect(response_body.byteLength).toBe(expectedSize); + expect(Bun.SHA1.hash(response_body, "base64")).toBe(expectedHash); + + gc(); + if (!response.headers.has("content-type")) { + console.error(Object.fromEntries(response.headers.entries())); + } + + expect(response.headers.get("content-type")).toBe("text/plain"); + gc(); + }, + ); + expect(called).toBe(true); + gc(); + return out; + }); + } + }; + + if (isDirectStream) { + describe(" direct stream", () => inner(thisArray)); + } else { + describe("default stream", () => inner(thisArray)); + } + } + } + } + } catch (e) { + console.error(e); + throw e; + } +}); diff --git a/test/js/web/fetch/fetch-gzip.test.ts b/test/js/web/fetch/fetch-gzip.test.ts new file mode 100644 index 000000000..01eedc54a --- /dev/null +++ b/test/js/web/fetch/fetch-gzip.test.ts @@ -0,0 +1,181 @@ +import { concatArrayBuffers } from "bun"; +import { it, describe, expect } from "bun:test"; +import fs from "fs"; +import { gc, gcTick } from "harness"; + +it("fetch() with a buffered gzip response works (one chunk)", async () => { + var server = Bun.serve({ + port: 6025, + + async fetch(req) { + gcTick(true); + return new Response(require("fs").readFileSync(import.meta.dir + "/fixture.html.gz"), { + headers: { + "Content-Encoding": "gzip", + "Content-Type": "text/html; charset=utf-8", + }, + }); + }, + }); + gcTick(true); + + const res = await fetch(`http://${server.hostname}:${server.port}`, { verbose: true }); + gcTick(true); + const arrayBuffer = await res.arrayBuffer(); + const clone = new Buffer(arrayBuffer); + gcTick(true); + await (async function () { + const second = new Buffer(await Bun.file(import.meta.dir + "/fixture.html").arrayBuffer()); + gcTick(true); + expect(second.equals(clone)).toBe(true); + })(); + gcTick(true); + server.stop(); +}); + +it("fetch() with a redirect that returns a buffered gzip response works (one chunk)", async () => { + var server = Bun.serve({ + port: 6020, + + async fetch(req) { + if (req.url.endsWith("/redirect")) + return new Response(await Bun.file(import.meta.dir + "/fixture.html.gz").arrayBuffer(), { + headers: { + "Content-Encoding": "gzip", + "Content-Type": "text/html; charset=utf-8", + }, + }); + + return Response.redirect("/redirect"); + }, + }); + + const res = await fetch(`http://${server.hostname}:${server.port}/hey`, { verbose: true }); + const arrayBuffer = await res.arrayBuffer(); + expect( + new Buffer(arrayBuffer).equals(new Buffer(await Bun.file(import.meta.dir + "/fixture.html").arrayBuffer())), + ).toBe(true); + server.stop(); +}); + +it("fetch() with a protocol-relative redirect that returns a buffered gzip response works (one chunk)", async () => { + const server = Bun.serve({ + port: 5018, + + async fetch(req, server) { + if (req.url.endsWith("/redirect")) + return new Response(await Bun.file(import.meta.dir + "/fixture.html.gz").arrayBuffer(), { + headers: { + "Content-Encoding": "gzip", + "Content-Type": "text/html; charset=utf-8", + }, + }); + + return Response.redirect(`://${server.hostname}:${server.port}/redirect`); + }, + }); + + const res = await fetch(`http://${server.hostname}:${server.port}/hey`, { verbose: true }); + expect(res.url).toBe(`http://${server.hostname}:${server.port}/redirect`); + expect(res.redirected).toBe(true); + expect(res.status).toBe(200); + const arrayBuffer = await res.arrayBuffer(); + expect( + new Buffer(arrayBuffer).equals(new Buffer(await Bun.file(import.meta.dir + "/fixture.html").arrayBuffer())), + ).toBe(true); + + server.stop(); +}); + +it("fetch() with a gzip response works (one chunk, streamed, with a delay", async () => { + var server = Bun.serve({ + port: 6081, + + fetch(req) { + return new Response( + new ReadableStream({ + type: "direct", + async pull(controller) { + await 2; + + const buffer = await Bun.file(import.meta.dir + "/fixture.html.gz").arrayBuffer(); + controller.write(buffer); + controller.close(); + }, + }), + { + headers: { + "Content-Encoding": "gzip", + "Content-Type": "text/html; charset=utf-8", + "Content-Length": "1", + }, + }, + ); + }, + }); + + const res = await fetch(`http://${server.hostname}:${server.port}`, {}); + const arrayBuffer = await res.arrayBuffer(); + expect( + new Buffer(arrayBuffer).equals(new Buffer(await Bun.file(import.meta.dir + "/fixture.html").arrayBuffer())), + ).toBe(true); + server.stop(); +}); + +it("fetch() with a gzip response works (multiple chunks, TCP server", async done => { + const compressed = await Bun.file(import.meta.dir + "/fixture.html.gz").arrayBuffer(); + var socketToClose; + const server = Bun.listen({ + port: 4024, + hostname: "0.0.0.0", + socket: { + async open(socket) { + socketToClose = socket; + + var corked: any[] = []; + var cork = true; + async function write(chunk) { + await new Promise<void>((resolve, reject) => { + if (cork) { + corked.push(chunk); + } + + if (!cork && corked.length) { + socket.write(corked.join("")); + corked.length = 0; + } + + if (!cork) { + socket.write(chunk); + } + + resolve(); + }); + } + await write("HTTP/1.1 200 OK\r\n"); + await write("Content-Encoding: gzip\r\n"); + await write("Content-Type: text/html; charset=utf-8\r\n"); + await write("Content-Length: " + compressed.byteLength + "\r\n"); + await write("X-WTF: " + "lol".repeat(1000) + "\r\n"); + await write("\r\n"); + for (var i = 100; i < compressed.byteLength; i += 100) { + cork = false; + await write(compressed.slice(i - 100, i)); + } + await write(compressed.slice(i - 100)); + socket.flush(); + }, + drain(socket) {}, + }, + }); + await 1; + + const res = await fetch(`http://${server.hostname}:${server.port}`, {}); + const arrayBuffer = await res.arrayBuffer(); + expect( + new Buffer(arrayBuffer).equals(new Buffer(await Bun.file(import.meta.dir + "/fixture.html").arrayBuffer())), + ).toBe(true); + socketToClose.end(); + server.stop(); + done(); +}); diff --git a/test/js/web/fetch/fetch.js.txt b/test/js/web/fetch/fetch.js.txt new file mode 100644 index 000000000..5a9b52fcf --- /dev/null +++ b/test/js/web/fetch/fetch.js.txt @@ -0,0 +1,46 @@ +<!doctype html> +<html> +<head> + <title>Example Domain</title> + + <meta charset="utf-8" /> + <meta http-equiv="Content-type" content="text/html; charset=utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <style type="text/css"> + body { + background-color: #f0f0f2; + margin: 0; + padding: 0; + font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif; + + } + div { + width: 600px; + margin: 5em auto; + padding: 2em; + background-color: #fdfdff; + border-radius: 0.5em; + box-shadow: 2px 3px 7px 2px rgba(0,0,0,0.02); + } + a:link, a:visited { + color: #38488f; + text-decoration: none; + } + @media (max-width: 700px) { + div { + margin: 0 auto; + width: auto; + } + } + </style> +</head> + +<body> +<div> + <h1>Example Domain</h1> + <p>This domain is for use in illustrative examples in documents. You may use this + domain in literature without prior coordination or asking for permission.</p> + <p><a href="https://www.iana.org/domains/example">More information...</a></p> +</div> +</body> +</html> diff --git a/test/js/web/fetch/fetch.test.ts b/test/js/web/fetch/fetch.test.ts new file mode 100644 index 000000000..1185dbd55 --- /dev/null +++ b/test/js/web/fetch/fetch.test.ts @@ -0,0 +1,935 @@ +import { serve, 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 } from "harness"; + +const tmp_dir = mkdtempSync(join(realpathSync(tmpdir()), "fetch.test")); + +const fixture = readFileSync(join(import.meta.dir, "fetch.js.txt"), "utf8"); + +let server; +function startServer({ fetch, ...options }) { + 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); + +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([ + ["set-cookie", "foo=bar"], + ["set-cookie", "bar=baz"], + ["x-bun", "abc, def"], + ]); + expect([...headers.values()]).toEqual(["foo=bar", "bar=baz", "abc, def"]); + }); + + 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([ + ["set-cookie", "foo=bar"], + ["set-cookie", "bar=baz"], + ["x-bun", "foo, bar"], + ]); + }); + + 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=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", "foo=baz"], + ["set-cookie", "bar=qat"], + ]); + }); +}); + +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" }, + ]; + for (let url of urls) { + gc(); + let name; + 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; + } + 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("provide body", async () => { + startServer({ + fetch(req) { + return new Response(req.body); + }, + host: "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("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, hasBlobFn?) { + 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(); + blobed.type = ""; + if (withGC) gc(); + expect(blobed.type).toBe(""); + if (withGC) gc(); + blobed.type = "application/json"; + if (withGC) gc(); + expect(blobed.type).toBe("application/json"); + if (withGC) gc(); + 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()); + const path = join(tmp_dir, `tmp-${count++}.bytes`); + writeFileSync(path, buffer); + const file = Bun.file(path); + expect(blob.size).toBe(file.size); + 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); + }); + + function forEachMethod(fn, skip?) { + const method = ["arrayBuffer", "text", "json"]; + 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])); + + var blobConstructorValues = [ + ["123", "456"], + ["123", 456], + ["123", "456", "789"], + ["123", 456, 789], + [1, 2, 3, 4, 5, 6, 7, 8, 9], + [Uint8Array.from([1, 2, 3, 4, 5, 6, 7, 9])], + [Uint8Array.from([1, 2, 3, 4]), "5678", 9], + [new Blob([Uint8Array.from([1, 2, 3, 4])]), "5678", 9], + [ + new Blob([ + new TextEncoder().encode( + "😀 😃 😄 😁 😆 😅 😂 🤣 🥲 ☺️ 😊 😇 🙂 🙃 😉 😌 😍 🥰 😘 😗 😙 😚 😋 😛 😝 😜 🤪 🤨 🧐 🤓 😎 🥸 🤩 🥳", + ), + ]), + ], + [ + new TextEncoder().encode( + "😀 😃 😄 😁 😆 😅 😂 🤣 🥲 ☺️ 😊 😇 🙂 🙃 😉 😌 😍 🥰 😘 😗 😙 😚 😋 😛 😝 😜 🤪 🤨 🧐 🤓 😎 🥸 🤩 🥳", + ), + ], + ]; + + var expected = [ + "123456", + "123456", + "123456789", + "123456789", + "123456789", + "\x01\x02\x03\x04\x05\x06\x07\t", + "\x01\x02\x03\x0456789", + "\x01\x02\x03\x0456789", + "😀 😃 😄 😁 😆 😅 😂 🤣 🥲 ☺️ 😊 😇 🙂 🙃 😉 😌 😍 🥰 😘 😗 😙 😚 😋 😛 😝 😜 🤪 🤨 🧐 🤓 😎 🥸 🤩 🥳", + "😀 😃 😄 😁 😆 😅 😂 🤣 🥲 ☺️ 😊 😇 🙂 🙃 😉 😌 😍 🥰 😘 😗 😙 😚 😋 😛 😝 😜 🤪 🤨 🧐 🤓 😎 🥸 🤩 🥳", + ]; + + it(`blobConstructorValues`, async () => { + for (let i = 0; i < blobConstructorValues.length; i++) { + var response = new Blob(blobConstructorValues[i]); + const res = await response.text(); + if (res !== expected[i]) { + throw new Error( + `Failed: ${expected[i].split("").map(a => a.charCodeAt(0))}, received: ${res + .split("") + .map(a => a.charCodeAt(0))}`, + ); + } + + expect(res).toBe(expected[i]); + } + }); + + for (let withGC of [false, true]) { + it(`Blob.slice() ${withGC ? " with gc" : ""}`, async () => { + var parts = ["hello", " ", "world"]; + if (withGC) gc(); + var str = parts.join(""); + if (withGC) gc(); + var combined = new Blob(parts); + if (withGC) gc(); + for (let part of parts) { + if (withGC) gc(); + expect(await combined.slice(str.indexOf(part), str.indexOf(part) + part.length).text()).toBe(part); + if (withGC) gc(); + } + if (withGC) gc(); + for (let part of parts) { + if (withGC) gc(); + expect(await combined.slice(str.indexOf(part), str.indexOf(part) + part.length).text()).toBe(part); + if (withGC) gc(); + } + }); + } +}); + +{ + const sample = new TextEncoder().encode("Hello World!"); + const typedArrays = [ + Uint8Array, + Uint8ClampedArray, + Int8Array, + Uint16Array, + Int16Array, + Uint32Array, + Int32Array, + Float32Array, + Float64Array, + ]; + const Constructors = [Blob, Response, Request]; + + for (let withGC of [false, true]) { + for (let TypedArray of typedArrays) { + for (let Constructor of Constructors) { + it(`${Constructor.name} arrayBuffer() with ${TypedArray.name}${withGC ? " with gc" : ""}`, async () => { + const data = new TypedArray(sample); + if (withGC) gc(); + const input = Constructor === Blob ? [data] : Constructor === Request ? { body: data } : data; + if (withGC) gc(); + const blob = new Constructor(input); + if (withGC) gc(); + const out = await blob.arrayBuffer(); + if (withGC) gc(); + expect(out instanceof ArrayBuffer).toBe(true); + if (withGC) gc(); + expect(out.byteLength).toBe(data.byteLength); + if (withGC) gc(); + }); + } + } + } +} + +describe("Response", () => { + describe("Response.json", () => { + it("works", async () => { + const inputs = ["hellooo", [[123], 456, 789], { hello: "world" }, { ok: "😉 😌 😍 🥰 😘 " }]; + for (let input of inputs) { + const output = JSON.stringify(input); + expect(await Response.json(input).text()).toBe(output); + } + // JSON.stringify() returns undefined + expect(await Response.json().text()).toBe(""); + // JSON.stringify("") returns '""' + expect(await Response.json("").text()).toBe('""'); + }); + it("sets the content-type header", () => { + let response = Response.json("hello"); + expect(response.type).toBe("basic"); + expect(response.headers.get("content-type")).toBe("application/json;charset=utf-8"); + expect(response.status).toBe(200); + }); + it("supports number status code", () => { + let response = Response.json("hello", 407); + expect(response.type).toBe("basic"); + expect(response.headers.get("content-type")).toBe("application/json;charset=utf-8"); + expect(response.status).toBe(407); + }); + + it("supports headers", () => { + var response = Response.json("hello", { + headers: { + "content-type": "potato", + "x-hello": "world", + }, + status: 408, + }); + + expect(response.headers.get("x-hello")).toBe("world"); + expect(response.status).toBe(408); + }); + }); + describe("Response.redirect", () => { + it("works", () => { + const inputs = [ + "http://example.com", + "http://example.com/", + "http://example.com/hello", + "http://example.com/hello/", + "http://example.com/hello/world", + "http://example.com/hello/world/", + ]; + for (let input of inputs) { + expect(Response.redirect(input).headers.get("Location")).toBe(input); + } + }); + + it("supports headers", () => { + var response = Response.redirect("https://example.com", { + headers: { + "content-type": "potato", + "x-hello": "world", + Location: "https://wrong.com", + }, + status: 408, + }); + expect(response.headers.get("x-hello")).toBe("world"); + expect(response.headers.get("Location")).toBe("https://example.com"); + expect(response.status).toBe(302); + expect(response.type).toBe("basic"); + expect(response.ok).toBe(false); + }); + }); + describe("Response.error", () => { + it("works", () => { + expect(Response.error().type).toBe("error"); + expect(Response.error().ok).toBe(false); + expect(Response.error().status).toBe(0); + }); + }); + it("clone", async () => { + gc(); + var body = new Response("<div>hello</div>", { + headers: { + "content-type": "text/html; charset=utf-8", + }, + }); + gc(); + var clone = body.clone(); + gc(); + body.headers.set("content-type", "text/plain"); + gc(); + expect(clone.headers.get("content-type")).toBe("text/html; charset=utf-8"); + gc(); + expect(body.headers.get("content-type")).toBe("text/plain"); + gc(); + expect(await clone.text()).toBe("<div>hello</div>"); + gc(); + }); + it("invalid json", async () => { + gc(); + var body = new Response("<div>hello</div>", { + headers: { + "content-type": "text/html; charset=utf-8", + }, + }); + try { + await body.json(); + expect(false).toBe(true); + } catch (exception) { + expect(exception instanceof SyntaxError).toBe(true); + } + }); + + testBlobInterface(data => new Response(data), true); +}); + +describe("Request", () => { + it("clone", async () => { + gc(); + var body = new Request("https://hello.com", { + headers: { + "content-type": "text/html; charset=utf-8", + }, + body: "<div>hello</div>", + }); + gc(); + expect(body.signal).toBeDefined(); + gc(); + expect(body.headers.get("content-type")).toBe("text/html; charset=utf-8"); + gc(); + var clone = body.clone(); + gc(); + expect(clone.signal).toBeDefined(); + gc(); + body.headers.set("content-type", "text/plain"); + gc(); + expect(clone.headers.get("content-type")).toBe("text/html; charset=utf-8"); + gc(); + expect(body.headers.get("content-type")).toBe("text/plain"); + gc(); + expect(await clone.text()).toBe("<div>hello</div>"); + }); + + it("signal", async () => { + gc(); + const controller = new AbortController(); + const req = new Request("https://hello.com", { signal: controller.signal }); + expect(req.signal.aborted).toBe(false); + gc(); + controller.abort(); + gc(); + expect(req.signal.aborted).toBe(true); + }); + + it("cloned signal", async () => { + gc(); + const controller = new AbortController(); + const req = new Request("https://hello.com", { signal: controller.signal }); + expect(req.signal.aborted).toBe(false); + gc(); + controller.abort(); + gc(); + expect(req.signal.aborted).toBe(true); + gc(); + const cloned = req.clone(); + expect(cloned.signal.aborted).toBe(true); + }); + + testBlobInterface(data => new Request("https://hello.com", { body: data }), true); +}); + +describe("Headers", () => { + it("writes", async () => { + var headers = new Headers({ + "content-type": "text/html; charset=utf-8", + }); + gc(); + expect(headers.get("content-type")).toBe("text/html; charset=utf-8"); + gc(); + headers.delete("content-type"); + gc(); + expect(headers.get("content-type")).toBe(null); + gc(); + headers.append("content-type", "text/plain"); + gc(); + expect(headers.get("content-type")).toBe("text/plain"); + gc(); + headers.append("content-type", "text/plain"); + gc(); + expect(headers.get("content-type")).toBe("text/plain, text/plain"); + gc(); + headers.set("content-type", "text/html; charset=utf-8"); + gc(); + expect(headers.get("content-type")).toBe("text/html; charset=utf-8"); + + headers.delete("content-type"); + gc(); + expect(headers.get("content-type")).toBe(null); + gc(); + }); +}); + +it("body nullable", async () => { + gc(); + { + const req = new Request("https://hello.com", { body: null }); + expect(req.body).toBeNull(); + } + gc(); + { + const req = new Request("https://hello.com", { body: undefined }); + expect(req.body).toBeNull(); + } + gc(); + { + const req = new Request("https://hello.com"); + expect(req.body).toBeNull(); + } + gc(); + { + const req = new Request("https://hello.com", { body: "" }); + expect(req.body).not.toBeNull(); + } +}); diff --git a/test/js/web/fetch/fetch_headers.test.js b/test/js/web/fetch/fetch_headers.test.js new file mode 100644 index 000000000..cd2786c08 --- /dev/null +++ b/test/js/web/fetch/fetch_headers.test.js @@ -0,0 +1,66 @@ +import { describe, it, expect, beforeAll, afterAll } from "bun:test"; +const port = 3009; +const url = `http://localhost:${port}`; +let server; + +describe("Headers", async () => { + // Start up a single server and reuse it between tests + beforeAll(() => { + server = Bun.serve({ + fetch(req) { + const hdr = req.headers.get("x-test"); + return new Response(hdr); + }, + port: port, + }); + }); + afterAll(() => { + server.stop(); + }); + + it("Headers should work", async () => { + expect(await fetchContent({ "x-test": "header 1" })).toBe("header 1"); + }); + + it("Header names must be valid", async () => { + expect(() => fetch(url, { headers: { "a\tb:c": "foo" } })).toThrow("Invalid header name: 'a\tb:c'"); + expect(() => fetch(url, { headers: { "❤️": "foo" } })).toThrow("Invalid header name: '❤️'"); + }); + + it("Header values must be valid", async () => { + expect(() => fetch(url, { headers: { "x-test": "\0" } })).toThrow("Header 'x-test' has invalid value: '\0'"); + expect(() => fetch(url, { headers: { "x-test": "❤️" } })).toThrow("Header 'x-test' has invalid value: '❤️'"); + }); + + it("repro 1602", async () => { + const origString = "😂1234".slice(3); + + var encoder = new TextEncoder(); + var decoder = new TextDecoder(); + const roundTripString = decoder.decode(encoder.encode(origString)); + + expect(roundTripString).toBe(origString); + + // This one will pass + expect(await fetchContent({ "x-test": roundTripString })).toBe(roundTripString); + // This would hang + expect(await fetchContent({ "x-test": origString })).toBe(origString); + }); + + describe("toJSON()", () => { + it("should provide lowercase header names", () => { + const headers1 = new Headers({ "X-Test": "yep", "Content-Type": "application/json" }); + expect(headers1.toJSON()).toEqual({ "x-test": "yep", "content-type": "application/json" }); + + const headers2 = new Headers(); + headers2.append("X-Test", "yep"); + headers2.append("Content-Type", "application/json"); + expect(headers2.toJSON()).toEqual({ "x-test": "yep", "content-type": "application/json" }); + }); + }); +}); + +async function fetchContent(headers) { + const res = await fetch(url, { headers: headers }, { verbose: true }); + return await res.text(); +} diff --git a/test/js/web/fetch/fixture.html b/test/js/web/fetch/fixture.html new file mode 100644 index 000000000..081040506 --- /dev/null +++ b/test/js/web/fetch/fixture.html @@ -0,0 +1,1428 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <meta property="og:title" content="Bun is a fast all-in-one JavaScript runtime" /> + <title>Bun is a fast all-in-one JavaScript runtime</title> + <meta + property="og:description" + content="Bundle, transpile, install and run JavaScript & TypeScript + projects – all in Bun. Bun is a new JavaScript runtime with + a native bundler, transpiler, task runner and npm client built-in." + /> + <meta name="og:locale" content="en_US" /> + <meta name="twitter:site" content="@jarredsumner" /> + <meta name="twitter:card" content="summary_large_image" /> + <meta property="og:image" content="https://bun.sh/share.png" /> + <meta + name="description" + content="Bundle, transpile, install and run JavaScript & TypeScript + projects – all in Bun. Bun is a new JavaScript runtime with + a native bundler, transpiler, task runner and npm client built-in." + /> + <meta name="theme-color" content="#fbf0df" /> + <link rel="manifest" href="manifest.json" /> + <link rel="icon" type="image/png" sizes="256x256" href="/logo-square.png" /> + <link rel="icon" type="image/png" sizes="32x32" href="/logo-square@32px.png" /> + <link rel="icon" type="image/png" sizes="16x16" href="/logo-square@16px.png" /> + <style> + :root { + --black: #0b0a08; + --blue: #00a6e1; + --orange: #f89b4b; + --orange-light: #d4d3d2; + --monospace-font: "Fira Code", "Hack", "Source Code Pro", "SF Mono", "Inconsolata", monospace; + --dark-border: rgba(200, 200, 25, 0.2); + --max-width: 1152px; + --system-font: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, + "Open Sans", "Helvetica Neue", sans-serif; + --horizontal-padding: 3rem; + --vertical-padding: 4rem; + --line-height: 1.4; + } + * { + box-sizing: border-box; + } + head, + body, + :root { + margin: 0 auto; + padding: 0; + font-family: var(--system-font); + } + body { + background-color: #fbf0df; + } + a { + color: inherit; + text-decoration: none; + transition: transform 0.1s linear; + } + a:visited { + color: inherit; + } + a:hover { + text-decoration: underline; + transform: scale(1.06); + transform-origin: middle center; + } + #header-wrap, + #pitch { + background-color: var(--black); + color: #fbf0df; + width: 100%; + } + #logo-link { + width: fit-content; + display: flex; + gap: 24px; + align-items: center; + } + main { + width: auto; + margin: 0 auto; + max-width: var(--max-width); + display: grid; + grid-template-columns: auto auto; + overflow-y: hidden; + } + main, + header, + #explain-section { + margin: 0 auto; + max-width: var(--max-width); + padding: 0 var(--horizontal-padding); + } + #cards-wrap, + #usecases, + main, + header { + padding: var(--vertical-padding) var(--horizontal-padding); + } + #pitch-content { + max-width: 600px; + } + .tagline { + margin-top: 0; + line-height: 1; + font-size: 36pt; + } + .subtitle { + font-size: 1.2rem; + } + .Navigation ul { + white-space: nowrap; + display: flex; + gap: 2rem; + list-style: none; + } + .NavText { + color: #fbf0df; + display: block; + font-weight: 500; + font-size: 1.2rem; + } + #HeaderInstallButton { + margin-left: 2.4rem; + } + #pitch main { + gap: 2rem; + } + #logo { + max-width: 70px; + margin: auto 0; + } + #logo-text { + max-width: 96px; + } + header { + display: grid; + grid-template-columns: auto max-content; + background-color: var(--black); + padding: 1.5rem 3rem; + align-items: center; + color: #fff; + } + #HeaderInstallButton:hover { + cursor: pointer; + transform: scale(1.06); + } + #HeaderInstallButton { + transition: transform 0.1s linear; + background: #00a6e1; + padding: 8px 16px; + border-radius: 100px; + color: #000; + font-weight: 500; + } + .InstallBox { + margin-top: 2rem; + background: #15140e; + padding: 24px; + border-radius: 24px; + user-select: none; + -webkit-user-select: none; + -webkit-user-drag: none; + -moz-user-select: none; + } + .InstallBox-label-heading { + font-size: 1.4rem; + margin-bottom: 1rem; + font-weight: 500; + } + .InstallBox-label-subtitle { + font-size: 0.9rem; + color: var(--orange-light); + } + #usecases-section { + background: linear-gradient(12deg, rgba(0, 0, 0, 0.7), rgba(0, 0, 0, 0.2)), + conic-gradient( + from 6.27deg at 46.95% 50.05%, + #ff8181 0deg, + #e5f067 75deg, + #6dd9ba 155.62deg, + #67f0ae 168.75deg, + #8b67f0 243.75deg, + #f067e2 300deg, + #e967e3 334.49deg, + #f06767 348.9deg, + #ff8181 360deg + ); + color: #fff; + font-family: var(--monospace-font); + contain: paint; + font-size: 24pt; + font-weight: 700; + } + #usecases-section { + padding: 0; + margin: 0; + } + #usecases { + padding-top: 1rem; + padding-bottom: 1rem; + } + #usecases-section h1 { + background: linear-gradient(90deg, #ff0000 0%, #faff00 50.52%, #0500ff 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + text-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25); + font-family: Helvetica; + margin: 0; + padding: 0; + } + .InstallBox-code-box { + background-color: #252420; + padding: 4px 16px; + position: relative; + border-radius: 8px; + text-align: center; + align-items: center; + border: 1px solid var(--orange); + margin-top: 1rem; + display: flex; + justify-content: space-between; + align-content: center; + white-space: nowrap; + margin-bottom: 1rem; + font-family: var(--monospace-font); + } + .InstallBox-curl { + user-select: all; + -webkit-user-select: text; + pointer-events: auto; + white-space: nowrap; + cursor: text; + display: inline-flex; + padding: 12px 8px; + gap: 2ch; + } + .InstallBox-curl:before { + display: block; + content: "$" / ""; + color: var(--orange); + pointer-events: none; + width: 1ch; + height: 1ch; + } + .InstallBox-view-source-link { + color: var(--orange-light); + } + .InstallBox-copy { + height: 100%; + display: flex; + align-items: center; + color: var(--orange-light); + transition: transform 0.05s linear; + transition-property: color, transform; + transform-origin: center center; + cursor: pointer; + background: transparent; + border: none; + font-size: inherit; + font-family: inherit; + } + .InstallBox-copy:hover { + color: var(--blue); + transform: scale(1.06); + } + .InstallBox-copy:active { + transform: scale(1.12); + } + .Tabs { + display: grid; + grid-template-columns: repeat(3, 1fr); + margin-left: auto; + margin-right: auto; + justify-content: center; + align-items: center; + width: min-content; + white-space: nowrap; + padding: 0; + } + .Tab { + width: min-content; + border: none; + background-color: transparent; + font-family: var(--monospace-font); + text-align: center; + border-bottom: 1px solid #ccc; + cursor: pointer; + padding: 16px; + color: inherit; + font-size: inherit; + } + .Tab:hover, + .Graphs--active-react .Tab[data-tab="react"], + .Graphs--active-sqlite .Tab[data-tab="sqlite"], + .Graphs--active-websocket .Tab[data-tab="websocket"] { + border-bottom-color: #7fffd4; + background-color: #82d8f71a; + border-right-color: #7fffd4; + border-left-color: #7fffd4; + } + .BarGraph { + padding: 24px; + display: flex; + flex-direction: column; + } + .BarGraph-heading { + font-weight: 500; + font-size: 1.5rem; + margin: 0; + } + .BarGraphList { + flex: 1; + position: relative; + list-style-type: none; + padding: 0; + } + .BarGraph, + .ActiveTab, + .Graphs { + height: auto; + } + .BarGraph-subheading { + font-size: 0.9rem; + color: #878686; + margin: 0; + } + .BarGraphList { + margin-top: 1rem; + display: grid; + grid-template-columns: repeat(var(--count), 1fr); + font-variant-numeric: tabular-nums; + font-family: var(--monospace-font); + justify-content: center; + align-items: flex-start; + height: 100%; + background-color: #080808; + } + .BarGraphKey { + display: grid; + text-align: center; + margin-top: 1rem; + grid-template-columns: repeat(var(--count), 1fr); + } + .BarGraphBar { + --primary: 70px; + --opposite: 100%; + } + .BarGraph, + .BarGraphBar-label, + .BarGraphItem { + --level: calc(var(--amount) / var(--max)); + --inverse: calc(1 / var(--level)); + } + .BarGraphBar { + margin: 0 auto; + width: var(--primary); + height: var(--opposite); + background-color: #5d5986; + position: relative; + height: calc(200px * var(--level)); + } + .BarGraphItem { + border-right: 1px dashed var(--dark-border); + border-top: 1px dashed var(--dark-border); + border-bottom: 1px dashed var(--dark-border); + min-height: 200px; + display: flex; + flex-direction: column; + justify-content: flex-end; + } + .BarGraphItem--deno { + border-right-color: transparent; + } + .BarGraph--vertical .BarGraphBar { + max-width: 90%; + } + .BarGraphBar-label { + color: #fff; + font-variant-numeric: tabular-nums; + font-family: var(--monospace-font); + width: 100%; + text-align: center; + position: relative; + display: flex; + justify-content: center; + } + .CardContent { + position: relative; + } + .BarGraph--vertical .BarGraphBar-label { + transform: scaleX(var(--inverse)); + bottom: 0; + right: 0; + } + .BarGraph--horizontal .BarGraphBar-label { + top: -22px; + } + .BarGraphItem--bun .BarGraphBar { + background-color: #f9f1e1; + box-shadow: inset 1px 1px 3px #ccc6bb; + background-image: url(data:image/svg+xml;base64,PHN2ZyBpZD0iQnVuIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA4MCA3MCI+PHRpdGxlPkJ1biBMb2dvPC90aXRsZT48cGF0aCBpZD0iU2hhZG93IiBkPSJNNzEuMDksMjAuNzRjLS4xNi0uMTctLjMzLS4zNC0uNS0uNXMtLjMzLS4zNC0uNS0uNS0uMzMtLjM0LS41LS41LS4zMy0uMzQtLjUtLjUtLjMzLS4zNC0uNS0uNS0uMzMtLjM0LS41LS41LS4zMy0uMzQtLjUtLjVBMjYuNDYsMjYuNDYsMCwwLDEsNzUuNSwzNS43YzAsMTYuNTctMTYuODIsMzAuMDUtMzcuNSwzMC4wNS0xMS41OCwwLTIxLjk0LTQuMjMtMjguODMtMTAuODZsLjUuNS41LjUuNS41LjUuNS41LjUuNS41LjUuNUMxOS41NSw2NS4zLDMwLjE0LDY5Ljc1LDQyLDY5Ljc1YzIwLjY4LDAsMzcuNS0xMy40OCwzNy41LTMwQzc5LjUsMzIuNjksNzYuNDYsMjYsNzEuMDksMjAuNzRaIi8+PGcgaWQ9IkJvZHkiPjxwYXRoIGlkPSJCYWNrZ3JvdW5kIiBkPSJNNzMsMzUuN2MwLDE1LjIxLTE1LjY3LDI3LjU0LTM1LDI3LjU0UzMsNTAuOTEsMywzNS43QzMsMjYuMjcsOSwxNy45NCwxOC4yMiwxM1MzMy4xOCwzLDM4LDNzOC45NCw0LjEzLDE5Ljc4LDEwQzY3LDE3Ljk0LDczLDI2LjI3LDczLDM1LjdaIiBzdHlsZT0iZmlsbDojZmJmMGRmIi8+PHBhdGggaWQ9IkJvdHRvbV9TaGFkb3ciIGRhdGEtbmFtZT0iQm90dG9tIFNoYWRvdyIgZD0iTTczLDM1LjdhMjEuNjcsMjEuNjcsMCwwLDAtLjgtNS43OGMtMi43MywzMy4zLTQzLjM1LDM0LjktNTkuMzIsMjQuOTRBNDAsNDAsMCwwLDAsMzgsNjMuMjRDNTcuMyw2My4yNCw3Myw1MC44OSw3MywzNS43WiIgc3R5bGU9ImZpbGw6I2Y2ZGVjZSIvPjxwYXRoIGlkPSJMaWdodF9TaGluZSIgZGF0YS1uYW1lPSJMaWdodCBTaGluZSIgZD0iTTI0LjUzLDExLjE3QzI5LDguNDksMzQuOTQsMy40Niw0MC43OCwzLjQ1QTkuMjksOS4yOSwwLDAsMCwzOCwzYy0yLjQyLDAtNSwxLjI1LTguMjUsMy4xMy0xLjEzLjY2LTIuMywxLjM5LTMuNTQsMi4xNS0yLjMzLDEuNDQtNSwzLjA3LTgsNC43QzguNjksMTguMTMsMywyNi42MiwzLDM1LjdjMCwuNCwwLC44LDAsMS4xOUM5LjA2LDE1LjQ4LDIwLjA3LDEzLjg1LDI0LjUzLDExLjE3WiIgc3R5bGU9ImZpbGw6I2ZmZmVmYyIvPjxwYXRoIGlkPSJUb3AiIGQ9Ik0zNS4xMiw1LjUzQTE2LjQxLDE2LjQxLDAsMCwxLDI5LjQ5LDE4Yy0uMjguMjUtLjA2LjczLjMuNTksMy4zNy0xLjMxLDcuOTItNS4yMyw2LTEzLjE0QzM1LjcxLDUsMzUuMTIsNS4xMiwzNS4xMiw1LjUzWm0yLjI3LDBBMTYuMjQsMTYuMjQsMCwwLDEsMzksMTljLS4xMi4zNS4zMS42NS41NS4zNkM0MS43NCwxNi41Niw0My42NSwxMSwzNy45Myw1LDM3LjY0LDQuNzQsMzcuMTksNS4xNCwzNy4zOSw1LjQ5Wm0yLjc2LS4xN0ExNi40MiwxNi40MiwwLDAsMSw0NywxNy4xMmEuMzMuMzMsMCwwLDAsLjY1LjExYy45Mi0zLjQ5LjQtOS40NC03LjE3LTEyLjUzQzQwLjA4LDQuNTQsMzkuODIsNS4wOCw0MC4xNSw1LjMyWk0yMS42OSwxNS43NmExNi45NCwxNi45NCwwLDAsMCwxMC40Ny05Yy4xOC0uMzYuNzUtLjIyLjY2LjE4LTEuNzMsOC03LjUyLDkuNjctMTEuMTIsOS40NUMyMS4zMiwxNi40LDIxLjMzLDE1Ljg3LDIxLjY5LDE1Ljc2WiIgc3R5bGU9ImZpbGw6I2NjYmVhNztmaWxsLXJ1bGU6ZXZlbm9kZCIvPjxwYXRoIGlkPSJPdXRsaW5lIiBkPSJNMzgsNjUuNzVDMTcuMzIsNjUuNzUuNSw1Mi4yNy41LDM1LjdjMC0xMCw2LjE4LTE5LjMzLDE2LjUzLTI0LjkyLDMtMS42LDUuNTctMy4yMSw3Ljg2LTQuNjIsMS4yNi0uNzgsMi40NS0xLjUxLDMuNi0yLjE5QzMyLDEuODksMzUsLjUsMzgsLjVzNS42MiwxLjIsOC45LDMuMTRjMSwuNTcsMiwxLjE5LDMuMDcsMS44NywyLjQ5LDEuNTQsNS4zLDMuMjgsOSw1LjI3QzY5LjMyLDE2LjM3LDc1LjUsMjUuNjksNzUuNSwzNS43LDc1LjUsNTIuMjcsNTguNjgsNjUuNzUsMzgsNjUuNzVaTTM4LDNjLTIuNDIsMC01LDEuMjUtOC4yNSwzLjEzLTEuMTMuNjYtMi4zLDEuMzktMy41NCwyLjE1LTIuMzMsMS40NC01LDMuMDctOCw0LjdDOC42OSwxOC4xMywzLDI2LjYyLDMsMzUuNywzLDUwLjg5LDE4LjcsNjMuMjUsMzgsNjMuMjVTNzMsNTAuODksNzMsMzUuN0M3MywyNi42Miw2Ny4zMSwxOC4xMyw1Ny43OCwxMyw1NCwxMSw1MS4wNSw5LjEyLDQ4LjY2LDcuNjRjLTEuMDktLjY3LTIuMDktMS4yOS0zLTEuODRDNDIuNjMsNCw0MC40MiwzLDM4LDNaIi8+PC9nPjxnIGlkPSJNb3V0aCI+PGcgaWQ9IkJhY2tncm91bmQtMiIgZGF0YS1uYW1lPSJCYWNrZ3JvdW5kIj48cGF0aCBkPSJNNDUuMDUsNDNhOC45Myw4LjkzLDAsMCwxLTIuOTIsNC43MSw2LjgxLDYuODEsMCwwLDEtNCwxLjg4QTYuODQsNi44NCwwLDAsMSwzNCw0Ny43MSw4LjkzLDguOTMsMCwwLDEsMzEuMTIsNDNhLjcyLjcyLDAsMCwxLC44LS44MUg0NC4yNkEuNzIuNzIsMCwwLDEsNDUuMDUsNDNaIiBzdHlsZT0iZmlsbDojYjcxNDIyIi8+PC9nPjxnIGlkPSJUb25ndWUiPjxwYXRoIGlkPSJCYWNrZ3JvdW5kLTMiIGRhdGEtbmFtZT0iQmFja2dyb3VuZCIgZD0iTTM0LDQ3Ljc5YTYuOTEsNi45MSwwLDAsMCw0LjEyLDEuOSw2LjkxLDYuOTEsMCwwLDAsNC4xMS0xLjksMTAuNjMsMTAuNjMsMCwwLDAsMS0xLjA3LDYuODMsNi44MywwLDAsMC00LjktMi4zMSw2LjE1LDYuMTUsMCwwLDAtNSwyLjc4QzMzLjU2LDQ3LjQsMzMuNzYsNDcuNiwzNCw0Ny43OVoiIHN0eWxlPSJmaWxsOiNmZjYxNjQiLz48cGF0aCBpZD0iT3V0bGluZS0yIiBkYXRhLW5hbWU9Ik91dGxpbmUiIGQ9Ik0zNC4xNiw0N2E1LjM2LDUuMzYsMCwwLDEsNC4xOS0yLjA4LDYsNiwwLDAsMSw0LDEuNjljLjIzLS4yNS40NS0uNTEuNjYtLjc3YTcsNywwLDAsMC00LjcxLTEuOTMsNi4zNiw2LjM2LDAsMCwwLTQuODksMi4zNkE5LjUzLDkuNTMsMCwwLDAsMzQuMTYsNDdaIi8+PC9nPjxwYXRoIGlkPSJPdXRsaW5lLTMiIGRhdGEtbmFtZT0iT3V0bGluZSIgZD0iTTM4LjA5LDUwLjE5YTcuNDIsNy40MiwwLDAsMS00LjQ1LTIsOS41Miw5LjUyLDAsMCwxLTMuMTEtNS4wNSwxLjIsMS4yLDAsMCwxLC4yNi0xLDEuNDEsMS40MSwwLDAsMSwxLjEzLS41MUg0NC4yNmExLjQ0LDEuNDQsMCwwLDEsMS4xMy41MSwxLjE5LDEuMTksMCwwLDEsLjI1LDFoMGE5LjUyLDkuNTIsMCwwLDEtMy4xMSw1LjA1QTcuNDIsNy40MiwwLDAsMSwzOC4wOSw1MC4xOVptLTYuMTctNy40Yy0uMTYsMC0uMi4wNy0uMjEuMDlhOC4yOSw4LjI5LDAsMCwwLDIuNzMsNC4zN0E2LjIzLDYuMjMsMCwwLDAsMzguMDksNDlhNi4yOCw2LjI4LDAsMCwwLDMuNjUtMS43Myw4LjMsOC4zLDAsMCwwLDIuNzItNC4zNy4yMS4yMSwwLDAsMC0uMi0uMDlaIi8+PC9nPjxnIGlkPSJGYWNlIj48ZWxsaXBzZSBpZD0iUmlnaHRfQmx1c2giIGRhdGEtbmFtZT0iUmlnaHQgQmx1c2giIGN4PSI1My4yMiIgY3k9IjQwLjE4IiByeD0iNS44NSIgcnk9IjMuNDQiIHN0eWxlPSJmaWxsOiNmZWJiZDAiLz48ZWxsaXBzZSBpZD0iTGVmdF9CbHVjaCIgZGF0YS1uYW1lPSJMZWZ0IEJsdWNoIiBjeD0iMjIuOTUiIGN5PSI0MC4xOCIgcng9IjUuODUiIHJ5PSIzLjQ0IiBzdHlsZT0iZmlsbDojZmViYmQwIi8+PHBhdGggaWQ9IkV5ZXMiIGQ9Ik0yNS43LDM4LjhhNS41MSw1LjUxLDAsMSwwLTUuNS01LjUxQTUuNTEsNS41MSwwLDAsMCwyNS43LDM4LjhabTI0Ljc3LDBBNS41MSw1LjUxLDAsMSwwLDQ1LDMzLjI5LDUuNSw1LjUsMCwwLDAsNTAuNDcsMzguOFoiIHN0eWxlPSJmaWxsLXJ1bGU6ZXZlbm9kZCIvPjxwYXRoIGlkPSJJcmlzIiBkPSJNMjQsMzMuNjRhMi4wNywyLjA3LDAsMSwwLTIuMDYtMi4wN0EyLjA3LDIuMDcsMCwwLDAsMjQsMzMuNjRabTI0Ljc3LDBhMi4wNywyLjA3LDAsMSwwLTIuMDYtMi4wN0EyLjA3LDIuMDcsMCwwLDAsNDguNzUsMzMuNjRaIiBzdHlsZT0iZmlsbDojZmZmO2ZpbGwtcnVsZTpldmVub2RkIi8+PC9nPjwvc3ZnPg==); + background-repeat: no-repeat; + background-size: 56px 48.8px; + background-position: 6px 20%; + } + .BarGraph--vertical .BarGraphItem--bun { + border-top-right-radius: 12px; + border-bottom-right-radius: 12px; + } + .BarGraph--horizontal .BarGraphItem--bun { + border-top-left-radius: 12px; + border-top-right-radius: 12px; + } + .BarGraph--vertical .BarGraphBar { + height: var(--primary); + width: var(--opposite); + transform: scaleX(var(--level)); + transform-origin: bottom left; + max-height: 40px; + margin-top: 1rem; + margin-bottom: 1rem; + } + .BarGraph--vertical .BarGraphList, + .BarGraph--vertical .BarGraphKey--vertical { + grid-template-columns: 1fr; + grid-template-rows: repeat(var(--count), 1fr); + } + .BarGraph--vertical .BarGraphList { + direction: rtl; + } + .BarGraphKeyItem-label { + color: #fff; + } + .BarGraphKeyItem-value { + color: #7a7a7a; + margin-top: 0.5rem; + } + .BarGraphKeyItem-viewSource { + margin-top: 0.5rem; + color: #7a7a7a; + text-transform: lowercase; + font-weight: thin; + font-size: 0.8rem; + } + .BarGraphKeyItem:hover { + text-decoration: none; + } + .BarGraphKeyItem:hover .BarGraphKeyItem-viewSource { + color: var(--orange-light); + } + .DemphasizedLabel { + text-transform: uppercase; + white-space: nowrap; + } + #frameworks { + display: flex; + } + .FrameworksGroup { + display: grid; + grid-template-rows: auto 40px; + gap: 0.5rem; + } + .DemphasizedLabel { + color: #7a7a7a; + font-weight: 300; + } + .FrameworksList { + display: grid; + grid-template-columns: repeat(2, 40px); + gap: 3.5rem; + align-items: center; + } + #cards { + display: grid; + } + #explain ul { + font-size: 1.2rem; + } + #explain li { + margin-bottom: 1rem; + line-height: var(--line-height); + } + .Tag { + --background: rgba(31, 31, 132, 0.15); + background-color: var(--background); + border-radius: 8px; + padding: 3px 8px; + color: #000; + text-decoration: none !important; + display: inline-block; + font-family: var(--monospace-font) !important; + } + .mono { + font-family: var(--monospace-font); + } + .Tag--Command { + --background: #111; + font-weight: medium; + color: #a3ff85; + } + .Tag--Command:before { + content: "\276f"/ ""; + color: #ffffff59; + margin-top: auto; + margin-bottom: auto; + margin-right: 1ch; + margin-left: 0.5ch; + display: inline-block; + transform: translateY(-1px); + } + .Tag--WebAPI { + --background: #29b6f6; + box-shadow: inset -1px -1px 3px #e7bb49; + } + .Tag--NodeJS { + --background: rgb(130, 172, 108); + } + .Tag--TypeScript { + --background: rgb(69, 119, 192); + color: #fff; + } + .Tag--React { + color: #82d8f7; + --background: #333; + } + .Tag--React:before { + color: #82d8f780; + content: "<" / ""; + } + .Tag--React:after { + color: #82d8f780; + content: ">" / ""; + } + .Tag--Bun { + --background: #e600e5; + color: #fff; + } + .mono { + font-family: var(--monospace-font); + border-radius: 6px; + color: #006713; + } + @media (min-width: 931px) { + .InstallBox--mobile { + display: none; + } + } + #explain { + max-width: 650px; + margin: 0 auto; + } + @media (max-width: 930px) { + header { + padding: 24px 16px; + } + .InstallBox--desktop { + display: none; + } + #logo { + width: 48px; + } + :root { + --max-width: 100%; + --horizontal-padding: 16px; + --vertical-padding: 2rem; + --line-height: 1.6; + } + main { + grid-template-columns: auto; + grid-template-rows: auto auto auto; + } + #explain li { + line-height: var(--line-height); + margin-bottom: 1.5rem; + } + ul { + padding: 0; + list-style: none; + } + .Tabs { + margin-left: 0; + } + .Graphs, + .BarGraph, + .BarGraphList { + max-width: auto; + } + .BarGraph { + padding: 24px 0; + } + #pitch-content { + max-width: auto; + } + #pitch main { + gap: 1rem; + } + .InstallBox { + margin-top: 0; + } + .tagline { + font-size: 32pt; + } + #logo-text, + #HeaderInstallButton { + display: none; + } + } + .InstallBox--mobile { + border-radius: 0; + } + @media (max-width: 599px) { + .InstallBox-copy { + display: none; + } + .InstallBox-code-box { + font-size: 0.8rem; + } + } + @media (max-width: 360px) { + .tagline { + font-size: 22pt; + } + } + #explain p { + line-height: var(--line-height); + font-size: 1.2rem; + } + #explain p a { + text-decoration: underline; + } + .Zig { + transform: translateY(15%); + } + .CodeBlock .shiki { + padding: 1rem; + border-radius: 8px; + font-family: var(--monospace-font); + font-size: 1rem; + } + .Identifier { + font-family: var(--monospace-font); + font-size: 1rem; + color: #50fa7b !important; + background-color: #282a36; + padding: 0.25rem; + border-radius: 8px; + text-decoration: none !important; + } + .PerformanceClaim { + text-decoration: dashed underline 2px #000 !important; + text-decoration-skip-ink: auto !important; + } + .BarGraph--react, + .BarGraph--websocket, + .BarGraph--sqlite { + display: none; + } + .Graphs--active-react .BarGraph--react, + .Graphs--active-websocket .BarGraph--websocket, + .Graphs--active-sqlite .BarGraph--sqlite { + display: block; + } + @media (min-width: 930px) { + .Graphs { + margin-left: auto; + } + .BarGraph-subheading, + .BarGraph-heading { + text-align: center; + } + .BarGraph-heading { + margin-bottom: 0.25rem; + } + .BarGraphKeyItem-label { + width: 130px; + } + } + @media (max-width: 929px) { + .InstallBox-code-box { + width: fit-content; + } + .CodeBlock .shiki { + padding: 24px 16px; + margin: calc(-1 * var(--horizontal-padding)); + width: calc(100vw - var(--horizontal-padding) - var(--horizontal-padding) -2px); + white-space: pre-wrap; + box-sizing: border-box; + border-radius: 0; + font-size: 0.8rem; + } + .logo-link { + gap: 0; + } + header { + grid-template-columns: min-content auto; + gap: 2rem; + } + .tagline, + .subtitle, + .BarGraph-heading, + .BarGraph-subheading { + padding: 0 var(--horizontal-padding); + } + main { + padding-left: 0; + padding-right: 0; + text-align: left; + } + .InstallBox { + padding: 24px 16px; + margin-bottom: -32px; + } + .tagline { + font-size: 30pt; + } + .Tag--Command { + display: block; + width: fit-content; + margin-bottom: 1rem; + } + .Tabs { + margin: 0; + gap: 0rem; + width: 100%; + border-top: 1px solid rgba(200, 200, 200, 0.1); + } + .Tab { + width: 100%; + border-bottom-color: #333; + } + #pitch-content { + max-width: 100%; + } + .Graphs--active-react .Tab[data-tab="react"], + .Graphs--active-sqlite .Tab[data-tab="sqlite"], + .Graphs--active-websocket .Tab[data-tab="websocket"] { + background-color: #6464641a; + } + } + #explain p > code { + white-space: pre; + padding: 1px 2px; + } + .Group { + display: block; + } + .Tag--Command { + display: block; + width: fit-content; + margin-bottom: 0.5rem; + padding: 8px 12px; + } + .Label-replace { + font-weight: 500; + } + .Label-text { + margin-top: 0.5rem; + margin-bottom: 1rem; + } + #batteries { + padding-left: 0; + } + .Group { + margin-bottom: 2rem; + } + .Group strong { + display: block; + } + .Built { + text-align: center; + margin-top: 4rem; + margin-bottom: 2rem; + color: #333; + } + img { + object-fit: contain; + } + .visually-hidden { + clip: rect(0 0 0 0); + clip-path: inset(50%); + height: 1px; + overflow: hidden; + position: absolute; + white-space: nowrap; + width: 1px; + } + </style> + </head> + <body> + <div id="header-wrap"> + <header> + <a href="/" id="logo-link" aria-label="home" + ><img + height="61px" + src="data:image/svg+xml;base64, PHN2ZyBpZD0iQnVuIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA4MCA3MCI+PHRpdGxlPkJ1biBMb2dvPC90aXRsZT48cGF0aCBpZD0iU2hhZG93IiBkPSJNNzEuMDksMjAuNzRjLS4xNi0uMTctLjMzLS4zNC0uNS0uNXMtLjMzLS4zNC0uNS0uNS0uMzMtLjM0LS41LS41LS4zMy0uMzQtLjUtLjUtLjMzLS4zNC0uNS0uNS0uMzMtLjM0LS41LS41LS4zMy0uMzQtLjUtLjVBMjYuNDYsMjYuNDYsMCwwLDEsNzUuNSwzNS43YzAsMTYuNTctMTYuODIsMzAuMDUtMzcuNSwzMC4wNS0xMS41OCwwLTIxLjk0LTQuMjMtMjguODMtMTAuODZsLjUuNS41LjUuNS41LjUuNS41LjUuNS41LjUuNUMxOS41NSw2NS4zLDMwLjE0LDY5Ljc1LDQyLDY5Ljc1YzIwLjY4LDAsMzcuNS0xMy40OCwzNy41LTMwQzc5LjUsMzIuNjksNzYuNDYsMjYsNzEuMDksMjAuNzRaIi8+PGcgaWQ9IkJvZHkiPjxwYXRoIGlkPSJCYWNrZ3JvdW5kIiBkPSJNNzMsMzUuN2MwLDE1LjIxLTE1LjY3LDI3LjU0LTM1LDI3LjU0UzMsNTAuOTEsMywzNS43QzMsMjYuMjcsOSwxNy45NCwxOC4yMiwxM1MzMy4xOCwzLDM4LDNzOC45NCw0LjEzLDE5Ljc4LDEwQzY3LDE3Ljk0LDczLDI2LjI3LDczLDM1LjdaIiBzdHlsZT0iZmlsbDojZmJmMGRmIi8+PHBhdGggaWQ9IkJvdHRvbV9TaGFkb3ciIGRhdGEtbmFtZT0iQm90dG9tIFNoYWRvdyIgZD0iTTczLDM1LjdhMjEuNjcsMjEuNjcsMCwwLDAtLjgtNS43OGMtMi43MywzMy4zLTQzLjM1LDM0LjktNTkuMzIsMjQuOTRBNDAsNDAsMCwwLDAsMzgsNjMuMjRDNTcuMyw2My4yNCw3Myw1MC44OSw3MywzNS43WiIgc3R5bGU9ImZpbGw6I2Y2ZGVjZSIvPjxwYXRoIGlkPSJMaWdodF9TaGluZSIgZGF0YS1uYW1lPSJMaWdodCBTaGluZSIgZD0iTTI0LjUzLDExLjE3QzI5LDguNDksMzQuOTQsMy40Niw0MC43OCwzLjQ1QTkuMjksOS4yOSwwLDAsMCwzOCwzYy0yLjQyLDAtNSwxLjI1LTguMjUsMy4xMy0xLjEzLjY2LTIuMywxLjM5LTMuNTQsMi4xNS0yLjMzLDEuNDQtNSwzLjA3LTgsNC43QzguNjksMTguMTMsMywyNi42MiwzLDM1LjdjMCwuNCwwLC44LDAsMS4xOUM5LjA2LDE1LjQ4LDIwLjA3LDEzLjg1LDI0LjUzLDExLjE3WiIgc3R5bGU9ImZpbGw6I2ZmZmVmYyIvPjxwYXRoIGlkPSJUb3AiIGQ9Ik0zNS4xMiw1LjUzQTE2LjQxLDE2LjQxLDAsMCwxLDI5LjQ5LDE4Yy0uMjguMjUtLjA2LjczLjMuNTksMy4zNy0xLjMxLDcuOTItNS4yMyw2LTEzLjE0QzM1LjcxLDUsMzUuMTIsNS4xMiwzNS4xMiw1LjUzWm0yLjI3LDBBMTYuMjQsMTYuMjQsMCwwLDEsMzksMTljLS4xMi4zNS4zMS42NS41NS4zNkM0MS43NCwxNi41Niw0My42NSwxMSwzNy45Myw1LDM3LjY0LDQuNzQsMzcuMTksNS4xNCwzNy4zOSw1LjQ5Wm0yLjc2LS4xN0ExNi40MiwxNi40MiwwLDAsMSw0NywxNy4xMmEuMzMuMzMsMCwwLDAsLjY1LjExYy45Mi0zLjQ5LjQtOS40NC03LjE3LTEyLjUzQzQwLjA4LDQuNTQsMzkuODIsNS4wOCw0MC4xNSw1LjMyWk0yMS42OSwxNS43NmExNi45NCwxNi45NCwwLDAsMCwxMC40Ny05Yy4xOC0uMzYuNzUtLjIyLjY2LjE4LTEuNzMsOC03LjUyLDkuNjctMTEuMTIsOS40NUMyMS4zMiwxNi40LDIxLjMzLDE1Ljg3LDIxLjY5LDE1Ljc2WiIgc3R5bGU9ImZpbGw6I2NjYmVhNztmaWxsLXJ1bGU6ZXZlbm9kZCIvPjxwYXRoIGlkPSJPdXRsaW5lIiBkPSJNMzgsNjUuNzVDMTcuMzIsNjUuNzUuNSw1Mi4yNy41LDM1LjdjMC0xMCw2LjE4LTE5LjMzLDE2LjUzLTI0LjkyLDMtMS42LDUuNTctMy4yMSw3Ljg2LTQuNjIsMS4yNi0uNzgsMi40NS0xLjUxLDMuNi0yLjE5QzMyLDEuODksMzUsLjUsMzgsLjVzNS42MiwxLjIsOC45LDMuMTRjMSwuNTcsMiwxLjE5LDMuMDcsMS44NywyLjQ5LDEuNTQsNS4zLDMuMjgsOSw1LjI3QzY5LjMyLDE2LjM3LDc1LjUsMjUuNjksNzUuNSwzNS43LDc1LjUsNTIuMjcsNTguNjgsNjUuNzUsMzgsNjUuNzVaTTM4LDNjLTIuNDIsMC01LDEuMjUtOC4yNSwzLjEzLTEuMTMuNjYtMi4zLDEuMzktMy41NCwyLjE1LTIuMzMsMS40NC01LDMuMDctOCw0LjdDOC42OSwxOC4xMywzLDI2LjYyLDMsMzUuNywzLDUwLjg5LDE4LjcsNjMuMjUsMzgsNjMuMjVTNzMsNTAuODksNzMsMzUuN0M3MywyNi42Miw2Ny4zMSwxOC4xMyw1Ny43OCwxMyw1NCwxMSw1MS4wNSw5LjEyLDQ4LjY2LDcuNjRjLTEuMDktLjY3LTIuMDktMS4yOS0zLTEuODRDNDIuNjMsNCw0MC40MiwzLDM4LDNaIi8+PC9nPjxnIGlkPSJNb3V0aCI+PGcgaWQ9IkJhY2tncm91bmQtMiIgZGF0YS1uYW1lPSJCYWNrZ3JvdW5kIj48cGF0aCBkPSJNNDUuMDUsNDNhOC45Myw4LjkzLDAsMCwxLTIuOTIsNC43MSw2LjgxLDYuODEsMCwwLDEtNCwxLjg4QTYuODQsNi44NCwwLDAsMSwzNCw0Ny43MSw4LjkzLDguOTMsMCwwLDEsMzEuMTIsNDNhLjcyLjcyLDAsMCwxLC44LS44MUg0NC4yNkEuNzIuNzIsMCwwLDEsNDUuMDUsNDNaIiBzdHlsZT0iZmlsbDojYjcxNDIyIi8+PC9nPjxnIGlkPSJUb25ndWUiPjxwYXRoIGlkPSJCYWNrZ3JvdW5kLTMiIGRhdGEtbmFtZT0iQmFja2dyb3VuZCIgZD0iTTM0LDQ3Ljc5YTYuOTEsNi45MSwwLDAsMCw0LjEyLDEuOSw2LjkxLDYuOTEsMCwwLDAsNC4xMS0xLjksMTAuNjMsMTAuNjMsMCwwLDAsMS0xLjA3LDYuODMsNi44MywwLDAsMC00LjktMi4zMSw2LjE1LDYuMTUsMCwwLDAtNSwyLjc4QzMzLjU2LDQ3LjQsMzMuNzYsNDcuNiwzNCw0Ny43OVoiIHN0eWxlPSJmaWxsOiNmZjYxNjQiLz48cGF0aCBpZD0iT3V0bGluZS0yIiBkYXRhLW5hbWU9Ik91dGxpbmUiIGQ9Ik0zNC4xNiw0N2E1LjM2LDUuMzYsMCwwLDEsNC4xOS0yLjA4LDYsNiwwLDAsMSw0LDEuNjljLjIzLS4yNS40NS0uNTEuNjYtLjc3YTcsNywwLDAsMC00LjcxLTEuOTMsNi4zNiw2LjM2LDAsMCwwLTQuODksMi4zNkE5LjUzLDkuNTMsMCwwLDAsMzQuMTYsNDdaIi8+PC9nPjxwYXRoIGlkPSJPdXRsaW5lLTMiIGRhdGEtbmFtZT0iT3V0bGluZSIgZD0iTTM4LjA5LDUwLjE5YTcuNDIsNy40MiwwLDAsMS00LjQ1LTIsOS41Miw5LjUyLDAsMCwxLTMuMTEtNS4wNSwxLjIsMS4yLDAsMCwxLC4yNi0xLDEuNDEsMS40MSwwLDAsMSwxLjEzLS41MUg0NC4yNmExLjQ0LDEuNDQsMCwwLDEsMS4xMy41MSwxLjE5LDEuMTksMCwwLDEsLjI1LDFoMGE5LjUyLDkuNTIsMCwwLDEtMy4xMSw1LjA1QTcuNDIsNy40MiwwLDAsMSwzOC4wOSw1MC4xOVptLTYuMTctNy40Yy0uMTYsMC0uMi4wNy0uMjEuMDlhOC4yOSw4LjI5LDAsMCwwLDIuNzMsNC4zN0E2LjIzLDYuMjMsMCwwLDAsMzguMDksNDlhNi4yOCw2LjI4LDAsMCwwLDMuNjUtMS43Myw4LjMsOC4zLDAsMCwwLDIuNzItNC4zNy4yMS4yMSwwLDAsMC0uMi0uMDlaIi8+PC9nPjxnIGlkPSJGYWNlIj48ZWxsaXBzZSBpZD0iUmlnaHRfQmx1c2giIGRhdGEtbmFtZT0iUmlnaHQgQmx1c2giIGN4PSI1My4yMiIgY3k9IjQwLjE4IiByeD0iNS44NSIgcnk9IjMuNDQiIHN0eWxlPSJmaWxsOiNmZWJiZDAiLz48ZWxsaXBzZSBpZD0iTGVmdF9CbHVjaCIgZGF0YS1uYW1lPSJMZWZ0IEJsdWNoIiBjeD0iMjIuOTUiIGN5PSI0MC4xOCIgcng9IjUuODUiIHJ5PSIzLjQ0IiBzdHlsZT0iZmlsbDojZmViYmQwIi8+PHBhdGggaWQ9IkV5ZXMiIGQ9Ik0yNS43LDM4LjhhNS41MSw1LjUxLDAsMSwwLTUuNS01LjUxQTUuNTEsNS41MSwwLDAsMCwyNS43LDM4LjhabTI0Ljc3LDBBNS41MSw1LjUxLDAsMSwwLDQ1LDMzLjI5LDUuNSw1LjUsMCwwLDAsNTAuNDcsMzguOFoiIHN0eWxlPSJmaWxsLXJ1bGU6ZXZlbm9kZCIvPjxwYXRoIGlkPSJJcmlzIiBkPSJNMjQsMzMuNjRhMi4wNywyLjA3LDAsMSwwLTIuMDYtMi4wN0EyLjA3LDIuMDcsMCwwLDAsMjQsMzMuNjRabTI0Ljc3LDBhMi4wNywyLjA3LDAsMSwwLTIuMDYtMi4wN0EyLjA3LDIuMDcsMCwwLDAsNDguNzUsMzMuNjRaIiBzdHlsZT0iZmlsbDojZmZmO2ZpbGwtcnVsZTpldmVub2RkIi8+PC9nPjwvc3ZnPg==" + alt="Bun logo" + id="logo" /><img + alt="Bun" + id="logo-text" + height="31.65px" + src="data:image/png;base64, iVBORw0KGgoAAAANSUhEUgAAALkAAAA9CAYAAADxjMiSAAAACXBIWXMAABYlAAAWJQFJUiTwAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAi1SURBVHgB7Z3vddpIEMBnBPhdPgUqiLgUYFJBSAXxVRC7gjgVxK4gdgVxKohdQUgFRxo4SAXgT753Ejs3s5JsQiQQsCsJaX/v+R+SkZBmZ2fnnxAKgGYTf/W1AKCL/JW2fxtgGv86x15/Dg7HHmDeHVlQRSC7IYBP/AWxkKKHL/R2osfX9N/RPqZOcsrvN0XEqVL0g/8e80AYuwHgyMMvQi6CHETCOfBADQi9F7zDgCLB7UL1GCOp7wTebafXH4HDkcKjkAfzyTf+MYBqCvNGRNvzt1FLwSVr+Ck4HDHLQk5QExDhxgm7I8GDGkIEpwuEb//NJqfgaDxayONFZa2QhS9r9M/B/eQjOBqNFvJ/D9QOzwXBRXg/+QyOxlJLc2UVMV9Yo38CRyNphJBrCM6D2WQIjsbRhgYhNjr/6IOjckhUnGM0Q/61yzEaH7zW88dtin6SRL93DAJqF6LWcOyNgCZA8GZT4Ii9MhIv0DGD5IJzRDcJiKWmI9ByKgLifOXGTHd1Z2aciy/bMCWqnHEeU4kjbPrcImgLPpaKPqO/Es1ORaLQoBb3CrypF0Wm53kDc+LwCD14j2xObhkhH7EJ+qXDP/Nc18YJOd+Um/Zz/yxr+wPf6DbCBEwek29Gu9t/AzsQzKd8X2gIBmBhOmv3+jdg+VibjqOPJV4vNiFhX6cHwkVbwfU67d4cmzyGtdIJOFJByNbY26EGWVtEiQSzf8bi9QITXj1+H46J/J2WBJhg3SaPwu04SqZNHlVzmdKSTMMkG1G+FNCJh/jaZHJXCl25IC4aao8FeH7a62KeSJCOAH0wiMhLyIIeziYyg9yubrcl5HM+8jW/+c2WwqRPUCKVHsJHW8Ie1DkuUAFaCMdprwdAVwjogx26hPCVZefVUa8/Xt5gx1xhAefFx8Wu2pJP8ibgBSL/OgYLYEOFXBX0uUU5rUbRRXHxeugdWAZlplgxXSprkz/jARIS/AUyKxiG1rxnq8YDYNPgNjlzBivvJTMzFEM3jFzFT8eWb5Zt4J0RQeeV+hcwTOfJzfYbTdXypvEit6dGtHjBMjZcDvwdgndlBGZxFUWF8ORhYRPiPRTN0sxReSFXa7TuLrBdaMXOd/xK4mF5iOzjARTPozavvJCbNh9aii7BYZ3Ew8JrnNLiEghKH7vyQm60IJqjnU32jydh+iKQ+yaalE0V6x6VzHNATx+78glasb/cCE6LZ/OwJmK4M+WninRloFVakwf3P40FhIjgg4tyNg+Wn0ElNXmcnfYRSJ2DCUhdHvVeXoGjbMSrNeWop/ZuEZD17hAs5MNKCblEqli434WSnUaGPjwLeKf38iLv7rLQrU3bgoqAUWrsZVoKbjibnPAOn2z50WUBbEfI2S8aziepCw7KcAlK/rJ05wJzEjYXE4U1+M02/xTnUjsMoDufRWm3o6x94oSqWxb2K7LgT5fBY0uTdyl7GvIh/WSMIV4UWWQ6G7w8RMBbBG/y3gMW9vNgPj02lTu/TK3yyWValMofKYpwAl4uinZo7kR2vF+1EnJF6q5tOEJaPnS4bfu2JLLZcQSGqZcmR+9TiDAJ5pOZ9HbUiUE2/L8F0rSEMST6AYaxZJPjLRHdZWzM7EvOYdguR6lew/65DvL+Em0bLlijBPeTm87zvgsEHQBKEujALHaEnEfj0YZC1nVI9K0FdGEiyZ4ij80Fe3tO8yyE4vI8R0mgBXOzkuaK5JEf9f48ZeE0pn1F2BcpVSMp+7k03BIJmyLkCVJCx2aPsaKJJUF3hREN4gAWnmg0HK8ru73sUiwbmoRctVGpVF7I48prsyYEwXnB2twHR2kchAsRLdjJIaQnfz2zE0Tqxu3eHCVwEEJuZbqPXJXpmyyYLEmViqN4Ki/ksQa0YVoMszZY8bCg935bE0kS/qvaSeGQOABNTmZyytPeOcOdSATGo24Q9QP5mnfnuAlr7v0d2Xjxt0r6hkWLF9F1KQVbFf3DYD5Z25xSP0tVnooRlY45r4wBdMTT9PSMHuxdMFumJov7bNtiEOfX3PKM8R2ern0XEd6GYka5kKtRrIT15Rk9HEYXIR2Rgi/bPB0gskPpFAqo8s4K8bcARiFY54SF2i1GC8Ba+VucMyLCeioCw5pL+7vlyQTSxnl53zgxS7T/EHQCVyG1OZkmiQxIHqRTt+irB0XWeGo/MYfpYVWGqYT1L5L6vnYHgjsoo71ZDF8iyeTsA+IxGGJVuSzzByugAmavUmjckyYSWuCtTReguFd6WbQIPpDhlIZ11Lk/ZNLVtlGZd5jjgUpxZXlZ12Ucn58tL0+j0EK+aJiQs5Y8y7UjwTWUAMXH7dSulK8cmmeukMpdYMsLFjEXClcAnbhdtZgQ6AR9b5ol5EQ/tmo0xEJGBgs3ch1zpSkpGax5bGpBSGOEXDRiG3AIW3LU61/ZqCDPYrUpKbtTp2CITZHtus4ajRByWWiyHf5qVw9CSHRWiACkmFLKLT73Rgv5sxo34mF/+LU8DXkfF5lcH2XpIV0JMhAzTCkn5HtSW02uNa900+q9NJLFKBVKocwGNjQ6292taBD9hkkPCzV0EVtHIdeNPll79ztrGk3ugmh0eb6oyeJqPdPwWiFrpinSw1LXheljWJ+1VL+lc0doiFEo+aDKteI+iNdpj502SWzanT7MJhf79IZJ2hnzTDPauLM8QMDDt/zT5z/8LXJq5lHpII6BFj85yjtdexi+fh7iW+JjQHSMrVJ9o8HIC2WUZp80hR2Q9IIFD3zA1jFFLfJ82CHlGKNMUsmVym5WpBvhs6BLp34PlC8H3fIC20Zu4Jht5btO9HjzUrSQfl68Vg5quOYaRc3n+YIrxW5MA+cbVxnpm588Rr2zpIlNNTyV3Pcgo+uZCFF8zLnt65/k4IcZ8rfUA/O3c9kp3U8XM0TtmX3UX6rLN1iyCLsy+pJtYIjlUSlCIsK9Tfquo9lYz2ld1TiYc+pJRqZrwezYl/8BpjOlthQ26tQAAAAASUVORK5CYII=" + /></a> + <nav class="Navigation"> + <ul> + <li> + <a class="NavText" href="https://github.com/oven-sh/bun#Reference">Docs</a> + </li> + <li> + <a class="NavText" href="https://bun.sh/discord">Discord</a> + </li> + <li> + <a class="NavText" href="https://github.com/oven-sh/bun">GitHub</a> + </li> + </ul> + </nav> + </header> + </div> + <div id="pitch"> + <main> + <div id="pitch-content"> + <h1 class="tagline">Bun is a fast all-in-one JavaScript runtime</h1> + <p class="subtitle"> + Bundle, transpile, install and run JavaScript & TypeScript projects — all in Bun. Bun is a new + JavaScript runtime with a native bundler, transpiler, task runner and npm client built-in. + </p> + <div class="InstallBox InstallBox--desktop"> + <div class="InstallBox-label"> + <div class="InstallBox-label-heading"> + Install Bun CLI + <!-- -->0.2.1<!-- --> + (beta) + </div> + <div class="InstallBox-label-subtitle"> + macOS x64 & Silicon, Linux x64, Windows Subsystem for Linux + </div> + </div> + <div class="InstallBox-code-box"> + <div class="InstallBox-curl">curl https://bun.sh/install | bash</div> + <button class="InstallBox-copy" aria-label="Copy installation script">copy</button> + </div> + <a class="InstallBox-view-source-link" target="_blank" href="https://bun.sh/install">Show script source</a> + </div> + </div> + <div class="Graphs Graphs--active-react"> + <div class="Tabs" role="tablist"> + <button + data-tab="react" + id="tab-react" + aria-controls="react-tab-content" + class="Tab" + role="tab" + aria-selected="true" + tabindex="0" + > + Bun.serve</button + ><button + data-tab="websocket" + id="tab-websocket" + aria-controls="websocket-tab-content" + class="Tab" + role="tab" + tabindex="-1" + > + WebSocket</button + ><button + data-tab="sqlite" + id="tab-sqlite" + aria-controls="sqlite-tab-content" + class="Tab" + role="tab" + tabindex="-1" + > + bun:sqlite + </button> + </div> + <div id="active-tab" class="ActiveTab"> + <div + role="tabpanel" + tabindex="0" + id="react-tab-content" + aria-labelledby="tab-react" + class="BarGraph BarGraph--react BarGraph--horizontal BarGraph--dark" + > + <h2 class="BarGraph-heading">Server-side rendering React</h2> + <p class="BarGraph-subheading">HTTP requests per second (Linux x64)</p> + <ul style="--count: 3" class="BarGraphList"> + <li class="BarGraphItem BarGraphItem--bun" style="--amount: 69845; --max: 87306.25"> + <div class="visually-hidden">bun: 69,845 requests per second</div> + <div style="--amount: 69845; --max: 87306.25" class="BarGraphBar" aria-hidden="true"> + <div style="--amount: 69845; --max: 87306.25" class="BarGraphBar-label">69,845</div> + </div> + </li> + <li class="BarGraphItem BarGraphItem--node" style="--amount: 16288; --max: 87306.25"> + <div class="visually-hidden">node: 16,288 requests per second</div> + <div style="--amount: 16288; --max: 87306.25" class="BarGraphBar" aria-hidden="true"> + <div style="--amount: 16288; --max: 87306.25" class="BarGraphBar-label">16,288</div> + </div> + </li> + <li class="BarGraphItem BarGraphItem--deno" style="--amount: 12926; --max: 87306.25"> + <div class="visually-hidden">deno: 12,926 requests per second</div> + <div style="--amount: 12926; --max: 87306.25" class="BarGraphBar" aria-hidden="true"> + <div style="--amount: 12926; --max: 87306.25" class="BarGraphBar-label">12,926</div> + </div> + </li> + </ul> + <div style="--count: 3" class="BarGraphKey"> + <a + href="https://github.com/oven-sh/bun/blob/b0a7f8df926e91d3b2f0b3b8833ddaf55073f30e/bench/react-hello-world/react-hello-world.jsx#L27" + target="_blank" + class="BarGraphKeyItem" + aria-label="bun benchmark source" + ><div class="BarGraphKeyItem-label">bun</div> + <div class="BarGraphKeyItem-value">v0.2.0</div> + <div class="BarGraphKeyItem-viewSource">View source</div></a + ><a + href="https://github.com/oven-sh/bun/blob/e55d6eed2bf9a5db30250fdd8b9be063dc949054/bench/react-hello-world/react-hello-world.node.jsx" + target="_blank" + class="BarGraphKeyItem" + aria-label="node benchmark source" + ><div class="BarGraphKeyItem-label">node</div> + <div class="BarGraphKeyItem-value">v18.1.0</div> + <div class="BarGraphKeyItem-viewSource">View source</div></a + ><a + href="https://github.com/oven-sh/bun/blob/af033c02c5fbaade201abfe332f376879d9e6885/bench/react-hello-world/react-hello-world.deno.jsx" + target="_blank" + class="BarGraphKeyItem" + aria-label="Deno.serve() benchmark source" + ><div class="BarGraphKeyItem-label">Deno.serve()</div> + <div class="BarGraphKeyItem-value">v1.26.0</div> + <div class="BarGraphKeyItem-viewSource">View source</div></a + > + </div> + </div> + <div + role="tabpanel" + tabindex="-1" + id="websocket-tab-content" + aria-labelledby="tab-websocket" + class="BarGraph BarGraph--websocket BarGraph--horizontal BarGraph--dark" + > + <h2 class="BarGraph-heading">WebSocket server chat</h2> + <p class="BarGraph-subheading">Messages sent per second (Linux x64, 16 clients)</p> + <ul style="--count: 3" class="BarGraphList"> + <li class="BarGraphItem BarGraphItem--bun" style="--amount: 737280; --max: 921600"> + <div class="visually-hidden">bun: 737,280 messages sent per second</div> + <div style="--amount: 737280; --max: 921600" class="BarGraphBar" aria-hidden="true"> + <div style="--amount: 737280; --max: 921600" class="BarGraphBar-label">737,280</div> + </div> + </li> + <li class="BarGraphItem BarGraphItem--node" style="--amount: 107457; --max: 921600"> + <div class="visually-hidden">node: 107,457 messages sent per second</div> + <div style="--amount: 107457; --max: 921600" class="BarGraphBar" aria-hidden="true"> + <div style="--amount: 107457; --max: 921600" class="BarGraphBar-label">107,457</div> + </div> + </li> + <li class="BarGraphItem BarGraphItem--deno" style="--amount: 82097; --max: 921600"> + <div class="visually-hidden">deno: 82,097 messages sent per second</div> + <div style="--amount: 82097; --max: 921600" class="BarGraphBar" aria-hidden="true"> + <div style="--amount: 82097; --max: 921600" class="BarGraphBar-label">82,097</div> + </div> + </li> + </ul> + <div style="--count: 3" class="BarGraphKey"> + <a + href="https://github.com/oven-sh/bun/blob/9c7eb75a9ac845d92bfdfd6cc574dc8f39bde293/bench/websocket-server/chat-server.bun.js#L1" + target="_blank" + class="BarGraphKeyItem" + aria-label="Bun.serve() benchmark source" + ><div class="BarGraphKeyItem-label">Bun.serve()</div> + <div class="BarGraphKeyItem-value">v0.2.1</div> + <div class="BarGraphKeyItem-viewSource">View source</div></a + ><a + href="https://github.com/oven-sh/bun/blob/9c7eb75a9ac845d92bfdfd6cc574dc8f39bde293/bench/websocket-server/chat-server.node.mjs#L1" + target="_blank" + class="BarGraphKeyItem" + aria-label="ws (Node.js) benchmark source" + ><div class="BarGraphKeyItem-label">ws (Node.js)</div> + <div class="BarGraphKeyItem-value">node v18.10.0</div> + <div class="BarGraphKeyItem-viewSource">View source</div></a + ><a + href="https://github.com/oven-sh/bun/blob/9c7eb75a9ac845d92bfdfd6cc574dc8f39bde293/bench/websocket-server/chat-server.deno.mjs#L1" + target="_blank" + class="BarGraphKeyItem" + aria-label="Deno.serve() benchmark source" + ><div class="BarGraphKeyItem-label">Deno.serve()</div> + <div class="BarGraphKeyItem-value">v1.26.2</div> + <div class="BarGraphKeyItem-viewSource">View source</div></a + > + </div> + </div> + <div + role="tabpanel" + tabindex="-1" + id="sqlite-tab-content" + aria-labelledby="tab-sqlite" + class="BarGraph--sqlite BarGraph BarGraph--horizontal BarGraph--dark" + > + <h2 class="BarGraph-heading">Load a huge table</h2> + <p class="BarGraph-subheading">Average queries per second</p> + <ul style="--count: 3" class="BarGraphList"> + <li class="BarGraphItem BarGraphItem--bun" style="--amount: 70.32; --max: 88"> + <div class="visually-hidden">bun: 70.32 queries per second</div> + <div style="--amount: 70.32; --max: 88" class="BarGraphBar" aria-hidden="true"> + <div style="--amount: 70.32; --max: 88" class="BarGraphBar-label">70.32</div> + </div> + </li> + <li class="BarGraphItem BarGraphItem--deno" style="--amount: 36.54; --max: 88"> + <div class="visually-hidden">deno: 36.54 queries per second</div> + <div style="--amount: 36.54; --max: 88" class="BarGraphBar" aria-hidden="true"> + <div style="--amount: 36.54; --max: 88" class="BarGraphBar-label">36.54</div> + </div> + </li> + <li class="BarGraphItem BarGraphItem--better-sqlite3" style="--amount: 23.28; --max: 88"> + <div class="visually-hidden">better-sqlite3: 23.28 queries per second</div> + <div style="--amount: 23.28; --max: 88" class="BarGraphBar" aria-hidden="true"> + <div style="--amount: 23.28; --max: 88" class="BarGraphBar-label">23.28</div> + </div> + </li> + </ul> + <div style="--count: 3" class="BarGraphKey"> + <a + href="https://github.com/oven-sh/bun/blob/b0a7f8df926e91d3b2f0b3b8833ddaf55073f30e/bench/sqlite/bun.js#L9" + target="_blank" + class="BarGraphKeyItem" + aria-label="bun:sqlite benchmark source" + ><div class="BarGraphKeyItem-label">bun:sqlite</div> + <div class="BarGraphKeyItem-value">v0.2.0</div> + <div class="BarGraphKeyItem-viewSource">View source</div></a + ><a + href="https://github.com/oven-sh/bun/blob/6223030360c121e272aad98c7d1c14a009c5fc1c/bench/sqlite/deno.js#L9" + target="_blank" + class="BarGraphKeyItem" + aria-label="deno (x/sqlite3) benchmark source" + ><div class="BarGraphKeyItem-label">deno (x/sqlite3)</div> + <div class="BarGraphKeyItem-value">v1.26.1</div> + <div class="BarGraphKeyItem-viewSource">View source</div></a + ><a + href="https://github.com/oven-sh/bun/blob/e55d6eed2bf9a5db30250fdd8b9be063dc949054/bench/sqlite/node.mjs" + target="_blank" + class="BarGraphKeyItem" + aria-label="better-sqlite3 benchmark source" + ><div class="BarGraphKeyItem-label">better-sqlite3</div> + <div class="BarGraphKeyItem-value">node v18.2.0</div> + <div class="BarGraphKeyItem-viewSource">View source</div></a + > + </div> + </div> + </div> + </div> + <div class="InstallBox InstallBox--mobile"> + <div class="InstallBox-label"> + <div class="InstallBox-label-heading"> + Install Bun CLI + <!-- -->0.2.1<!-- --> + (beta) + </div> + <div class="InstallBox-label-subtitle">macOS x64 & Silicon, Linux x64, Windows Subsystem for Linux</div> + </div> + <div class="InstallBox-code-box"> + <div class="InstallBox-curl">curl https://bun.sh/install | bash</div> + <button class="InstallBox-copy" aria-label="Copy installation script">copy</button> + </div> + <a class="InstallBox-view-source-link" target="_blank" href="https://bun.sh/install">Show script source</a> + </div> + </main> + </div> + <section id="explain-section"> + <div id="explain"> + <h2>Tell me more about Bun</h2> + <p> + Bun is a modern JavaScript runtime like Node or Deno. It was built from scratch to focus on three main things: + </p> + <ul> + <li>Start fast (it has the edge in mind).</li> + <li>New levels of performance (extending JavaScriptCore, the engine).</li> + <li>Being a great and complete tool (bundler, transpiler, package manager).</li> + </ul> + <p> + Bun is designed as a drop-in replacement for your current JavaScript & TypeScript apps or scripts — on + your local computer, server or on the edge. Bun natively implements hundreds of Node.js and Web APIs, + including ~90% of<!-- --> + <a href="https://nodejs.org/api/n-api.html" target="_blank">Node-API</a> + <!-- -->functions (native modules), fs, path, Buffer and more. + </p> + <p> + The goal of Bun is to run most of the world's JavaScript outside of browsers, bringing performance and + complexity enhancements to your future infrastructure, as well as developer productivity through better, + simpler tooling. + </p> + <h2>Batteries included</h2> + <ul id="batteries"> + <li> + Web APIs like<!-- --> + <a target="_blank" href="https://developer.mozilla.org/en-US/docs/Web/API/fetch" class="Tag Tag--WebAPI" + >fetch</a + >,<!-- --> + <a target="_blank" href="https://developer.mozilla.org/en-US/docs/Web/API/WebSocket" class="Tag Tag--WebAPI" + >WebSocket</a + >, and<!-- --> + <a + target="_blank" + href="https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream" + class="Tag Tag--WebAPI" + >ReadableStream</a + > + <!-- -->are built-in + </li> + <li> + <span target="_blank" class="Tag Tag--NodeJS">node_modules</span> + bun implements Node.js' module resolution algorithm, so you can use npm packages in Bun. ESM and + CommonJS are supported, but Bun internally uses ESM + </li> + <li> + In Bun, every file is transpiled.<!-- --> + <span target="_blank" class="Tag Tag--TypeScript">TypeScript</span> + & <span target="_blank" class="Tag Tag--React">JSX</span> just work + </li> + <li> + Bun supports <code>"paths"</code>, <code>"jsxImportSource"</code>and more from + <span target="_blank" class="Tag Tag--TypeScript">tsconfig.json</span> + files + </li> + <li> + <span target="_blank" class="Tag Tag--Bun">Bun.Transpiler</span> + Bun's JSX & TypeScript transpiler is available as an API in Bun + </li> + <li> + use the fastest system calls available with + <span target="_blank" class="Tag Tag--Bun">Bun.write</span> + <!-- -->to write, copy, pipe, send and clone files + </li> + <li> + Bun automatically loads environment variables from + <span target="_blank" class="Tag Tag--Bun">.env</span> + <!-- -->files. No more<!-- --> + <code class="mono">require("dotenv").config()</code> + </li> + <li> + Bun ships with a fast SQLite3 client built-in<!-- --> + <span target="_blank" class="Tag Tag--Bun">bun:sqlite</span> + </li> + <li> + <a target="_blank" href="https://github.com/oven-sh/bun/issues/158" class="Tag Tag--NodeJS">Node-API</a> + <!-- -->Bun implements most of<!-- --> + <a href="https://nodejs.org/api/n-api.html#node-api" target="_blank">Node-API (N-API)</a>. Many Node.js + native modules just work + </li> + <li> + <span target="_blank" class="Tag Tag--Bun">bun:ffi</span> call native code from JavaScript with Bun's + low-overhead foreign function interface + </li> + <li> + <span target="_blank" class="Tag Tag--NodeJS">node:fs</span> + <span target="_blank" class="Tag Tag--NodeJS">node:path</span> Bun natively supports a growing list of + Node.js core modules along with globals like Buffer and process + </li> + </ul> + <h2>How does Bun work?</h2> + <p> + Bun uses the<!-- --> + <a href="https://github.com/WebKit/WebKit/tree/main/Source/JavaScriptCore">JavaScriptCore</a> + <!-- -->engine, which tends<!-- --> + <a target="blank" href="https://twitter.com/jarredsumner/status/1499225725492076544">to start</a> + <!-- -->and perform a little faster than more traditional choices like V8. Bun is written in<!-- --> + <a href="https://ziglang.org/" + ><svg xmlns="http://www.w3.org/2000/svg" height="1.2rem" class="Zig" viewBox="0 0 400 140"> + <title>Zig</title> + <g fill="#F7A41D"> + <g> + <polygon points="46,22 28,44 19,30"></polygon> + <polygon + points="46,22 33,33 28,44 22,44 22,95 31,95 20,100 12,117 0,117 0,22" + shape-rendering="crispEdges" + ></polygon> + <polygon points="31,95 12,117 4,106"></polygon> + </g> + <g> + <polygon points="56,22 62,36 37,44"></polygon> + <polygon points="56,22 111,22 111,44 37,44 56,32" shape-rendering="crispEdges"></polygon> + <polygon points="116,95 97,117 90,104"></polygon> + <polygon points="116,95 100,104 97,117 42,117 42,95" shape-rendering="crispEdges"></polygon> + <polygon points="150,0 52,117 3,140 101,22"></polygon> + </g> + <g> + <polygon points="141,22 140,40 122,45"></polygon> + <polygon + points="153,22 153,117 106,117 120,105 125,95 131,95 131,45 122,45 132,36 141,22" + shape-rendering="crispEdges" + ></polygon> + <polygon points="125,95 130,110 106,117"></polygon> + </g> + </g> + <g fill="#121212"> + <g> + <polygon points="260,22 260,37 229,40 177,40 177,22" shape-rendering="crispEdges"></polygon> + <polygon points="260,37 207,99 207,103 176,103 229,40 229,37"></polygon> + <polygon points="261,99 261,117 176,117 176,103 206,99" shape-rendering="crispEdges"></polygon> + </g> + <rect x="272" y="22" shape-rendering="crispEdges" width="22" height="95"></rect> + <g> + <polygon points="394,67 394,106 376,106 376,81 360,70 346,67" shape-rendering="crispEdges"></polygon> + <polygon points="360,68 376,81 346,67"></polygon> + <path + d="M394,106c-10.2,7.3-24,12-37.7,12c-29,0-51.1-20.8-51.1-48.3c0-27.3,22.5-48.1,52-48.1 + c14.3,0,29.2,5.5,38.9,14l-13,15c-7.1-6.3-16.8-10-25.9-10c-17,0-30.2,12.9-30.2,29.5c0,16.8,13.3,29.6,30.3,29.6 + c5.7,0,12.8-2.3,19-5.5L394,106z" + ></path> + </g> + </g></svg></a + >, a low-level programming language with manual memory management.<br /><br />Most of Bun is written from + scratch including the JSX/TypeScript transpiler, npm client, bundler, SQLite client, HTTP client, WebSocket + client and more. + </p> + <h2>Why is Bun fast?</h2> + <p> + An enormous amount of time spent profiling, benchmarking and optimizing things. The answer is different for + every part of Bun, but one general theme:<!-- --> + <a href="https://ziglang.org/" + ><svg xmlns="http://www.w3.org/2000/svg" height="1.2rem" class="Zig" viewBox="0 0 400 140"> + <title>Zig</title> + <g fill="#F7A41D"> + <g> + <polygon points="46,22 28,44 19,30"></polygon> + <polygon + points="46,22 33,33 28,44 22,44 22,95 31,95 20,100 12,117 0,117 0,22" + shape-rendering="crispEdges" + ></polygon> + <polygon points="31,95 12,117 4,106"></polygon> + </g> + <g> + <polygon points="56,22 62,36 37,44"></polygon> + <polygon points="56,22 111,22 111,44 37,44 56,32" shape-rendering="crispEdges"></polygon> + <polygon points="116,95 97,117 90,104"></polygon> + <polygon points="116,95 100,104 97,117 42,117 42,95" shape-rendering="crispEdges"></polygon> + <polygon points="150,0 52,117 3,140 101,22"></polygon> + </g> + <g> + <polygon points="141,22 140,40 122,45"></polygon> + <polygon + points="153,22 153,117 106,117 120,105 125,95 131,95 131,45 122,45 132,36 141,22" + shape-rendering="crispEdges" + ></polygon> + <polygon points="125,95 130,110 106,117"></polygon> + </g> + </g> + <g fill="#121212"> + <g> + <polygon points="260,22 260,37 229,40 177,40 177,22" shape-rendering="crispEdges"></polygon> + <polygon points="260,37 207,99 207,103 176,103 229,40 229,37"></polygon> + <polygon points="261,99 261,117 176,117 176,103 206,99" shape-rendering="crispEdges"></polygon> + </g> + <rect x="272" y="22" shape-rendering="crispEdges" width="22" height="95"></rect> + <g> + <polygon points="394,67 394,106 376,106 376,81 360,70 346,67" shape-rendering="crispEdges"></polygon> + <polygon points="360,68 376,81 346,67"></polygon> + <path + d="M394,106c-10.2,7.3-24,12-37.7,12c-29,0-51.1-20.8-51.1-48.3c0-27.3,22.5-48.1,52-48.1 + c14.3,0,29.2,5.5,38.9,14l-13,15c-7.1-6.3-16.8-10-25.9-10c-17,0-30.2,12.9-30.2,29.5c0,16.8,13.3,29.6,30.3,29.6 + c5.7,0,12.8-2.3,19-5.5L394,106z" + ></path> + </g> + </g></svg></a + >'s low-level control over memory and lack of hidden control flow makes it much simpler to write fast + software.<!-- --> + <a href="https://github.com/sponsors/ziglang">Sponsor the Zig Software Foundation</a>. + </p> + <h2>Getting started</h2> + <p> + To install Bun, run this<!-- --> + <a target="_blank" href="https://bun.sh/install">install script</a> + <!-- -->in your terminal. It downloads Bun from GitHub. + </p> + <div class="CodeBlock"> + <pre + class="shiki" + style="background-color: #282a36" + ><code><span class="line"><span style="color: #F8F8F2">curl https://bun.sh/install </span><span style="color: #FF79C6">|</span><span style="color: #F8F8F2"> bash</span></span></code></pre> + </div> + <p> + <!-- -->Bun's HTTP server is built on web standards like<!-- --> + <a class="Identifier" href="https://developer.mozilla.org/en-US/docs/Web/API/Request">Request</a> + <!-- -->and<!-- --> + <a class="Identifier" href="https://developer.mozilla.org/en-US/docs/Web/API/Response">Response</a> + </p> + <div class="CodeBlock"> + <pre + class="shiki" + style="background-color: #282a36" + ><code><span class="line"><span style="color: #6272A4">// http.js</span></span> +<span class="line"><span style="color: #FF79C6">export</span><span style="color: #F8F8F2"> </span><span style="color: #FF79C6">default</span><span style="color: #F8F8F2"> {</span></span> +<span class="line"><span style="color: #F8F8F2"> port</span><span style="color: #FF79C6">:</span><span style="color: #F8F8F2"> </span><span style="color: #BD93F9">3000</span><span style="color: #F8F8F2">,</span></span> +<span class="line"><span style="color: #F8F8F2"> </span><span style="color: #50FA7B">fetch</span><span style="color: #F8F8F2">(</span><span style="color: #FFB86C; font-style: italic">request</span><span style="color: #F8F8F2">) {</span></span> +<span class="line"><span style="color: #F8F8F2"> </span><span style="color: #FF79C6">return</span><span style="color: #F8F8F2"> </span><span style="color: #FF79C6; font-weight: bold">new</span><span style="color: #F8F8F2"> </span><span style="color: #50FA7B">Response</span><span style="color: #F8F8F2">(</span><span style="color: #E9F284">"</span><span style="color: #F1FA8C">Welcome to Bun!</span><span style="color: #E9F284">"</span><span style="color: #F8F8F2">);</span></span> +<span class="line"><span style="color: #F8F8F2"> },</span></span> +<span class="line"><span style="color: #F8F8F2">};</span></span></code></pre> + </div> + <p>Run it with Bun:</p> + <div class="CodeBlock"> + <pre + class="shiki" + style="background-color: #282a36" + ><code><span class="line"><span style="color: #F8F8F2">bun run http.js</span></span></code></pre> + </div> + <p> + Then open<!-- --> + <a target="_blank" href="http://localhost:3000">http://localhost:3000</a> + <!-- -->in your browser.<br /><br />See<!-- --> + <a href="https://github.com/oven-sh/bun/tree/main/examples">more examples</a> + <!-- -->and check out<!-- --> + <a href="https://github.com/oven-sh/bun#Reference">the docs</a>. If you have any questions or want help, join<!-- --> + <a href="https://bun.sh/discord">Bun's Discord</a>. + </p> + <h2>Bun CLI</h2> + <div class="Group"> + <span target="_blank" class="Tag Tag--Command">bun run</span> + <p> + The same command for running JavaScript & TypeScript files with bun's JavaScript runtime also runs + package.json<!-- --> + <code class="mono">"scripts"</code>. + </p> + <strong + >Replace <code class="mono">npm run</code> with<!-- --> + <code class="mono">bun run</code> and save 160ms on every run.</strong + ><br /> + <div> + Bun runs package.json scripts<!-- --> + <a + href="https://twitter.com/jarredsumner/status/1454218996983623685" + target="_blank" + class="PerformanceClaim" + >30x faster than <code class="mono">npm run</code></a + > + </div> + </div> + <div class="Group"> + <span target="_blank" class="Tag Tag--Command">bun install</span> + <p> + <code classsName="mono">bun install</code> is an npm-compatible package manager. You probably will be + surprised by how much faster copying files can get. + </p> + <strong + >Replace <code class="mono">yarn</code> with<!-- --> + <code class="mono">bun install</code> and get 20x faster package installs.</strong + ><br /> + <div><code class="mono">bun install</code> uses the fastest system calls available to copy files.</div> + </div> + <div class="Group"> + <span target="_blank" class="Tag Tag--Command">bun wiptest</span> + <p>A Jest-like test runner for JavaScript & TypeScript projects built-in to Bun.</p> + <div class="Label"> + <a + href="https://twitter.com/jarredsumner/status/1542824445810642946" + target="_blank" + class="PerformanceClaim" + >You've never seen a JavaScript test runner this fast</a + > + <!-- -->(or incomplete). + </div> + </div> + <h2>What is the license?</h2> + <p>MIT License, excluding dependencies which have various licenses.</p> + <h2>How do I see the source code?</h2> + <p>Bun is on <a href="https://github.com/oven-sh/bun">GitHub</a>.</p> + </div> + </section> + <section id="explain-section"><div id="explain"></div></section> + <script> + [...document.querySelectorAll(".Tab")].map(el => { + el.addEventListener("click", function (e) { + var tab = e.srcElement.getAttribute("data-tab"); + [...document.querySelectorAll(".Tab")].map(el => { + var active = el.getAttribute("data-tab") === tab; + el.setAttribute("tabindex", active ? 0 : -1); + el.setAttribute("aria-selected", active); + }); + [...document.querySelectorAll(".BarGraph")].map(el => { + var active = el.id === tab + "-tab-content"; + el.setAttribute("tabindex", active ? 0 : -1); + }); + document.querySelector(".Graphs").setAttribute("class", "Graphs Graphs--active-" + tab); + }); + + el.addEventListener("keydown", e => { + var tabs = [...document.querySelectorAll(".Tab")]; + var activeTabEl = document.querySelector(".Tab[aria-selected='true']"); + var activeTabIndex = tabs.indexOf(activeTabEl); + if (e.key === "ArrowRight" || e.key === "ArrowDown") { + e.preventDefault(); + activeTabIndex = (activeTabIndex + 1) % tabs.length; + tabs[activeTabIndex].click(); + tabs[activeTabIndex].focus(); + } + if (e.key === "ArrowLeft" || e.key === "ArrowUp") { + e.preventDefault(); + activeTabIndex = (activeTabIndex + tabs.length - 1) % tabs.length; + tabs[activeTabIndex].click(); + tabs[activeTabIndex].focus(); + } + if (e.key === "Home") { + e.preventDefault(); + tabs[0].click(); + tabs[0].focus(); + } + if (e.key === "End") { + e.preventDefault(); + tabs[tabs.length - 1].click(); + tabs[tabs.length - 1].focus(); + } + }); + }); + + for (const el of document.querySelectorAll(".InstallBox-copy")) { + el.addEventListener("click", async e => { + await navigator.clipboard.writeText("curl https://bun.sh/install | bash"); + }); + } + </script> + <div class="Built"> + Built with Bun + <!-- -->0.2.1 + </div> + </body> +</html> diff --git a/test/js/web/fetch/fixture.html.gz b/test/js/web/fetch/fixture.html.gz Binary files differnew file mode 100644 index 000000000..0bb85d4cb --- /dev/null +++ b/test/js/web/fetch/fixture.html.gz diff --git a/test/js/web/html/FormData.test.ts b/test/js/web/html/FormData.test.ts new file mode 100644 index 000000000..9d0db4361 --- /dev/null +++ b/test/js/web/html/FormData.test.ts @@ -0,0 +1,410 @@ +import { afterAll, beforeAll, describe, expect, it, test } from "bun:test"; +import fs, { chmodSync, unlinkSync } from "fs"; +import { mkfifo } from "mkfifo"; +import { gc, withoutAggressiveGC } from "../../gc"; + +describe("FormData", () => { + it("should be able to append a string", () => { + const formData = new FormData(); + formData.append("foo", "bar"); + expect(formData.get("foo")).toBe("bar"); + expect(formData.getAll("foo")[0]).toBe("bar"); + }); + + it("should be able to append a Blob", async () => { + const formData = new FormData(); + formData.append("foo", new Blob(["bar"])); + expect(await formData.get("foo").text()).toBe("bar"); + expect(formData.getAll("foo")[0] instanceof Blob).toBe(true); + }); + + it("should be able to set a Blob", async () => { + const formData = new FormData(); + formData.set("foo", new Blob(["bar"])); + expect(await formData.get("foo").text()).toBe("bar"); + expect(formData.getAll("foo")[0] instanceof Blob).toBe(true); + }); + + it("should be able to set a string", async () => { + const formData = new FormData(); + formData.set("foo", "bar"); + expect(formData.get("foo")).toBe("bar"); + expect(formData.getAll("foo")[0]).toBe("bar"); + }); + + const multipartFormDataFixturesRawBody = [ + { + name: "simple", + body: '--foo\r\nContent-Disposition: form-data; name="foo"\r\n\r\nbar\r\n--foo--\r\n', + headers: { + "Content-Type": "multipart/form-data; boundary=foo", + }, + expected: { + foo: "bar", + }, + }, + { + name: "simple with trailing CRLF", + body: '--foo\r\nContent-Disposition: form-data; name="foo"\r\n\r\nbar\r\n--foo--\r\n\r\n', + headers: { + "Content-Type": "multipart/form-data; boundary=foo", + }, + expected: { + foo: "bar", + }, + }, + { + name: "simple with trailing CRLF and extra CRLF", + body: '--foo\r\nContent-Disposition: form-data; name="foo"\r\n\r\nbar\r\n--foo--\r\n\r\n\r\n', + headers: { + "Content-Type": "multipart/form-data; boundary=foo", + }, + expected: { + foo: "bar", + }, + }, + { + name: "advanced", + body: '--foo\r\nContent-Disposition: form-data; name="foo"\r\n\r\nbar\r\n--foo\r\nContent-Disposition: form-data; name="baz"\r\n\r\nqux\r\n--foo--\r\n', + headers: { + "Content-Type": "multipart/form-data; boundary=foo", + }, + expected: { + foo: "bar", + baz: "qux", + }, + }, + { + name: "advanced with multiple values", + body: '--foo\r\nContent-Disposition: form-data; name="foo"\r\n\r\nbar\r\n--foo\r\nContent-Disposition: form-data; name="foo"\r\n\r\nbaz\r\n--foo--\r\n', + headers: { + "Content-Type": "multipart/form-data; boundary=foo", + }, + expected: { + foo: ["bar", "baz"], + }, + }, + { + name: "advanced with multiple values and trailing CRLF", + body: '--foo\r\nContent-Disposition: form-data; name="foo"\r\n\r\nbar\r\n--foo\r\nContent-Disposition: form-data; name="foo"\r\n\r\nbaz\r\n--foo--\r\n\r\n', + headers: { + "Content-Type": "multipart/form-data; boundary=foo", + }, + expected: { + foo: ["bar", "baz"], + }, + }, + { + name: "extremely advanced", + body: '--foo\r\nContent-Disposition: form-data; name="foo"\r\n\r\nbar\r\n--foo\r\nContent-Disposition: form-data; name="baz"\r\n\r\nqux\r\n--foo\r\nContent-Disposition: form-data; name="foo"\r\n\r\nbaz\r\n--foo--\r\n', + headers: { + "Content-Type": "multipart/form-data; boundary=foo", + }, + expected: { + foo: ["bar", "baz"], + baz: "qux", + }, + }, + { + name: "with name and filename", + body: '--foo\r\nContent-Disposition: form-data; name="foo"; filename="bar"\r\n\r\nbaz\r\n--foo--\r\n', + headers: { + "Content-Type": "multipart/form-data; boundary=foo", + }, + expected: { + foo: new Blob(["baz"]), + }, + }, + { + name: "with name and filename and trailing CRLF", + body: '--foo\r\nContent-Disposition: form-data; name="foo"; filename="bar"\r\n\r\nbaz\r\n--foo--\r\n\r\n', + headers: { + "Content-Type": "multipart/form-data; boundary=foo", + }, + expected: { + foo: new Blob(["baz"]), + }, + }, + ]; + + for (const { name, body, headers, expected: expected_ } of multipartFormDataFixturesRawBody) { + const Class = [Response, Request] as const; + for (const C of Class) { + it(`should parse multipart/form-data (${name}) with ${C.name}`, async () => { + const response = C === Response ? new Response(body, { headers }) : new Request({ headers, body }); + const formData = await response.formData(); + expect(formData instanceof FormData).toBe(true); + const entry = {}; + const expected = Object.assign({}, expected_); + + for (const key of formData.keys()) { + const values = formData.getAll(key); + if (values.length > 1) { + entry[key] = values; + } else { + entry[key] = values[0]; + if (entry[key] instanceof Blob) { + expect(expected[key] instanceof Blob).toBe(true); + + entry[key] = await entry[key].text(); + expected[key] = await expected[key].text(); + } else { + expect(typeof entry[key]).toBe(typeof expected[key]); + expect(expected[key] instanceof Blob).toBe(false); + } + } + } + + expect(entry).toEqual(expected); + }); + + it(`should roundtrip multipart/form-data (${name}) with ${C.name}`, async () => { + const response = C === Response ? new Response(body, { headers }) : new Request({ headers, body }); + const formData = await response.formData(); + expect(formData instanceof FormData).toBe(true); + + const request = await new Response(formData).formData(); + expect(request instanceof FormData).toBe(true); + + const aKeys = Array.from(formData.keys()); + const bKeys = Array.from(request.keys()); + expect(aKeys).toEqual(bKeys); + + for (const key of aKeys) { + const aValues = formData.getAll(key); + const bValues = request.getAll(key); + for (let i = 0; i < aValues.length; i++) { + const a = aValues[i]; + const b = bValues[i]; + if (a instanceof Blob) { + expect(b instanceof Blob).toBe(true); + expect(await a.text()).toBe(await b.text()); + } else { + expect(a).toBe(b); + } + } + } + + // Test that it also works with Blob. + const c = await new Blob([body], { type: headers["Content-Type"] }).formData(); + expect(c instanceof FormData).toBe(true); + const cKeys = Array.from(c.keys()); + expect(cKeys).toEqual(bKeys); + for (const key of cKeys) { + const cValues = c.getAll(key); + const bValues = request.getAll(key); + for (let i = 0; i < cValues.length; i++) { + const c = cValues[i]; + const b = bValues[i]; + if (c instanceof Blob) { + expect(b instanceof Blob).toBe(true); + expect(await c.text()).toBe(await b.text()); + } else { + expect(c).toBe(b); + } + } + } + }); + } + } + + it("should throw on missing final boundary", async () => { + const response = new Response('-foo\r\nContent-Disposition: form-data; name="foo"\r\n\r\nbar\r\n', { + headers: { + "Content-Type": "multipart/form-data; boundary=foo", + }, + }); + try { + await response.formData(); + throw "should have thrown"; + } catch (e) { + expect(typeof e.message).toBe("string"); + } + }); + + it("should throw on bad boundary", async () => { + const response = new Response('foo\r\nContent-Disposition: form-data; name="foo"\r\n\r\nbar\r\n', { + headers: { + "Content-Type": "multipart/form-data; boundary=foo", + }, + }); + try { + await response.formData(); + throw "should have thrown"; + } catch (e) { + expect(typeof e.message).toBe("string"); + } + }); + + it("should throw on bad header", async () => { + const response = new Response('foo\r\nContent-Disposition: form-data; name"foo"\r\n\r\nbar\r\n', { + headers: { + "Content-Type": "multipart/form-data; boundary=foo", + }, + }); + try { + await response.formData(); + throw "should have thrown"; + } catch (e) { + expect(typeof e.message).toBe("string"); + } + }); + + it("file upload on HTTP server (receive)", async () => { + const server = Bun.serve({ + port: 0, + development: false, + async fetch(req) { + const formData = await req.formData(); + return new Response(formData.get("foo")); + }, + }); + + const reqBody = new Request(`http://${server.hostname}:${server.port}`, { + body: '--foo\r\nContent-Disposition: form-data; name="foo"; filename="bar"\r\n\r\nbaz\r\n--foo--\r\n\r\n', + headers: { + "Content-Type": "multipart/form-data; boundary=foo", + }, + method: "POST", + }); + + const res = await fetch(reqBody); + const body = await res.text(); + expect(body).toBe("baz"); + server.stop(true); + }); + + it("file send on HTTP server (receive)", async () => { + const server = Bun.serve({ + port: 0, + development: false, + async fetch(req) { + const formData = await req.formData(); + return new Response(formData); + }, + }); + + const reqBody = new Request(`http://${server.hostname}:${server.port}`, { + body: '--foo\r\nContent-Disposition: form-data; name="foo"; filename="bar"\r\n\r\nbaz\r\n--foo--\r\n\r\n', + headers: { + "Content-Type": "multipart/form-data; boundary=foo", + }, + method: "POST", + }); + + const res = await fetch(reqBody); + const body = await res.formData(); + expect(await (body.get("foo") as Blob).text()).toBe("baz"); + server.stop(true); + }); + + describe("Bun.file support", () => { + describe("roundtrip", () => { + const path = import.meta.dir + "/form-data-fixture.txt"; + for (const C of [Request, Response]) { + it(`with ${C.name}`, async () => { + await Bun.write(path, "foo!"); + const formData = new FormData(); + formData.append("foo", Bun.file(path)); + const response = C === Response ? new Response(formData) : new Request({ body: formData }); + expect(response.headers.get("content-type")?.startsWith("multipart/form-data;")).toBe(true); + + const formData2 = await response.formData(); + expect(formData2 instanceof FormData).toBe(true); + expect(formData2.get("foo") instanceof Blob).toBe(true); + expect(await (formData2.get("foo") as Blob).text()).toBe("foo!"); + }); + } + }); + + it("doesnt crash when file is missing", async () => { + const formData = new FormData(); + formData.append("foo", Bun.file("missing")); + expect(() => new Response(formData)).toThrow(); + }); + }); + + it("Bun.inspect", () => { + const formData = new FormData(); + formData.append("foo", "bar"); + formData.append("foo", new Blob(["bar"])); + formData.append("bar", "baz"); + formData.append("boop", Bun.file("missing")); + expect(Bun.inspect(formData).length > 0).toBe(true); + }); + + describe("URLEncoded", () => { + test("should parse URL encoded", async () => { + const response = new Response("foo=bar&baz=qux", { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }); + const formData = await response.formData(); + expect(formData instanceof FormData).toBe(true); + expect(formData.get("foo")).toBe("bar"); + expect(formData.get("baz")).toBe("qux"); + }); + + test("should parse URLSearchParams", async () => { + const searchParams = new URLSearchParams("foo=bar&baz=qux"); + const response = new Response(searchParams); + expect(response.headers.get("Content-Type")).toBe("application/x-www-form-urlencoded;charset=UTF-8"); + + expect(searchParams instanceof URLSearchParams).toBe(true); + expect(searchParams.get("foo")).toBe("bar"); + + const formData = await response.formData(); + expect(formData instanceof FormData).toBe(true); + expect(formData.get("foo")).toBe("bar"); + expect(formData.get("baz")).toBe("qux"); + }); + + test("should parse URL encoded with charset", async () => { + const response = new Response("foo=bar&baz=qux", { + headers: { + "Content-Type": "application/x-www-form-urlencoded; charset=utf-8", + }, + }); + const formData = await response.formData(); + expect(formData instanceof FormData).toBe(true); + expect(formData.get("foo")).toBe("bar"); + expect(formData.get("baz")).toBe("qux"); + }); + + test("should parse URL encoded with charset and space", async () => { + const response = new Response("foo=bar&baz=qux+quux", { + headers: { + "Content-Type": "application/x-www-form-urlencoded; charset=utf-8", + }, + }); + const formData = await response.formData(); + expect(formData instanceof FormData).toBe(true); + expect(formData.get("foo")).toBe("bar"); + expect(formData.get("baz")).toBe("qux quux"); + }); + + test("should parse URL encoded with charset and plus", async () => { + const response = new Response("foo=bar&baz=qux+quux", { + headers: { + "Content-Type": "application/x-www-form-urlencoded; charset=utf-8", + }, + }); + const formData = await response.formData(); + expect(formData instanceof FormData).toBe(true); + expect(formData.get("foo")).toBe("bar"); + expect(formData.get("baz")).toBe("qux quux"); + }); + + it("should handle multiple values", async () => { + const response = new Response("foo=bar&foo=baz", { + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + }); + const formData = await response.formData(); + expect(formData instanceof FormData).toBe(true); + expect(formData.getAll("foo")).toEqual(["bar", "baz"]); + }); + }); +}); diff --git a/test/js/web/html/form-data-fixture.txt b/test/js/web/html/form-data-fixture.txt new file mode 100644 index 000000000..a4d20dd78 --- /dev/null +++ b/test/js/web/html/form-data-fixture.txt @@ -0,0 +1 @@ +foo!
\ No newline at end of file diff --git a/test/js/web/streams/bun-streams-test-fifo.sh b/test/js/web/streams/bun-streams-test-fifo.sh new file mode 100644 index 000000000..57650ba1d --- /dev/null +++ b/test/js/web/streams/bun-streams-test-fifo.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash + +echoerr() { echo "$@" 1>&2; } + +echoerr "bun-streams-test-fifo.sh: starting" +echo -e "$FIFO_TEST" >>${@: -1} +echoerr "bun-streams-test-fifo.sh: ending" +exit 0 diff --git a/test/js/web/streams/fetch.js.txt b/test/js/web/streams/fetch.js.txt new file mode 100644 index 000000000..5a9b52fcf --- /dev/null +++ b/test/js/web/streams/fetch.js.txt @@ -0,0 +1,46 @@ +<!doctype html> +<html> +<head> + <title>Example Domain</title> + + <meta charset="utf-8" /> + <meta http-equiv="Content-type" content="text/html; charset=utf-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <style type="text/css"> + body { + background-color: #f0f0f2; + margin: 0; + padding: 0; + font-family: -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", "Open Sans", "Helvetica Neue", Helvetica, Arial, sans-serif; + + } + div { + width: 600px; + margin: 5em auto; + padding: 2em; + background-color: #fdfdff; + border-radius: 0.5em; + box-shadow: 2px 3px 7px 2px rgba(0,0,0,0.02); + } + a:link, a:visited { + color: #38488f; + text-decoration: none; + } + @media (max-width: 700px) { + div { + margin: 0 auto; + width: auto; + } + } + </style> +</head> + +<body> +<div> + <h1>Example Domain</h1> + <p>This domain is for use in illustrative examples in documents. You may use this + domain in literature without prior coordination or asking for permission.</p> + <p><a href="https://www.iana.org/domains/example">More information...</a></p> +</div> +</body> +</html> diff --git a/test/js/web/streams/streams.test.js b/test/js/web/streams/streams.test.js new file mode 100644 index 000000000..c4af85e4f --- /dev/null +++ b/test/js/web/streams/streams.test.js @@ -0,0 +1,630 @@ +import { file, readableStreamToArrayBuffer, readableStreamToArray, readableStreamToText } from "bun"; +import { expect, it, beforeEach, afterEach, describe } from "bun:test"; +import { mkfifo } from "mkfifo"; +import { realpathSync, unlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "os"; +import { gc } from "harness"; + +beforeEach(() => gc()); +afterEach(() => gc()); + +describe("WritableStream", () => { + it("works", async () => { + try { + var chunks = []; + var writable = new WritableStream({ + write(chunk, controller) { + chunks.push(chunk); + }, + close(er) { + console.log("closed"); + console.log(er); + }, + abort(reason) { + console.log("aborted!"); + console.log(reason); + }, + }); + + var writer = writable.getWriter(); + + writer.write(new Uint8Array([1, 2, 3])); + + writer.write(new Uint8Array([4, 5, 6])); + + await writer.close(); + + expect(JSON.stringify(Array.from(Buffer.concat(chunks)))).toBe(JSON.stringify([1, 2, 3, 4, 5, 6])); + } catch (e) { + console.log(e); + console.log(e.stack); + throw e; + } + }); + + it("pipeTo", async () => { + const rs = new ReadableStream({ + start(controller) { + controller.enqueue("hello world"); + controller.close(); + }, + }); + + let received; + const ws = new WritableStream({ + write(chunk, controller) { + received = chunk; + }, + }); + await rs.pipeTo(ws); + expect(received).toBe("hello world"); + }); +}); + +describe("ReadableStream.prototype.tee", () => { + it("class", () => { + const [a, b] = new ReadableStream().tee(); + expect(a instanceof ReadableStream).toBe(true); + expect(b instanceof ReadableStream).toBe(true); + }); + + describe("default stream", () => { + it("works", async () => { + var [a, b] = new ReadableStream({ + start(controller) { + controller.enqueue("a"); + controller.enqueue("b"); + controller.enqueue("c"); + controller.close(); + }, + }).tee(); + + expect(await readableStreamToText(a)).toBe("abc"); + expect(await readableStreamToText(b)).toBe("abc"); + }); + }); + + describe("direct stream", () => { + it("works", async () => { + try { + var [a, b] = new ReadableStream({ + pull(controller) { + controller.write("a"); + controller.write("b"); + controller.write("c"); + controller.close(); + }, + type: "direct", + }).tee(); + + expect(await readableStreamToText(a)).toBe("abc"); + expect(await readableStreamToText(b)).toBe("abc"); + } catch (e) { + console.log(e.message); + console.log(e.stack); + throw e; + } + }); + }); +}); + +it("ReadableStream.prototype[Symbol.asyncIterator]", async () => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue("hello"); + controller.enqueue("world"); + controller.close(); + }, + cancel(reason) {}, + }); + + const chunks = []; + try { + for await (const chunk of stream) { + chunks.push(chunk); + } + } catch (e) { + console.log(e.message); + console.log(e.stack); + } + + expect(chunks.join("")).toBe("helloworld"); +}); + +it("ReadableStream.prototype[Symbol.asyncIterator] pull", async () => { + const stream = new ReadableStream({ + pull(controller) { + controller.enqueue("hello"); + controller.enqueue("world"); + controller.close(); + }, + cancel(reason) {}, + }); + + const chunks = []; + for await (const chunk of stream) { + chunks.push(chunk); + } + expect(chunks.join("")).toBe("helloworld"); +}); + +it("ReadableStream.prototype[Symbol.asyncIterator] direct", async () => { + const stream = new ReadableStream({ + pull(controller) { + controller.write("hello"); + controller.write("world"); + controller.close(); + }, + type: "direct", + cancel(reason) {}, + }); + + const chunks = []; + try { + for await (const chunk of stream) { + chunks.push(chunk); + } + } catch (e) { + console.log(e.message); + console.log(e.stack); + } + + expect(Buffer.concat(chunks).toString()).toBe("helloworld"); +}); + +it("ReadableStream.prototype.values() cancel", async () => { + var cancelled = false; + const stream = new ReadableStream({ + pull(controller) { + controller.enqueue("hello"); + controller.enqueue("world"); + }, + cancel(reason) { + cancelled = true; + }, + }); + + for await (const chunk of stream.values({ preventCancel: false })) { + break; + } + expect(cancelled).toBe(true); +}); + +it("ReadableStream.prototype.values() preventCancel", async () => { + var cancelled = false; + const stream = new ReadableStream({ + pull(controller) { + controller.enqueue("hello"); + controller.enqueue("world"); + }, + cancel(reason) { + cancelled = true; + }, + }); + + for await (const chunk of stream.values({ preventCancel: true })) { + break; + } + expect(cancelled).toBe(false); +}); + +it("ReadableStream.prototype.values", async () => { + const stream = new ReadableStream({ + start(controller) { + controller.enqueue("hello"); + controller.enqueue("world"); + controller.close(); + }, + }); + + const chunks = []; + for await (const chunk of stream.values()) { + chunks.push(chunk); + } + expect(chunks.join("")).toBe("helloworld"); +}); + +it("Bun.file() read text from pipe", async () => { + try { + unlinkSync("/tmp/fifo"); + } catch (e) {} + + console.log("here"); + mkfifo("/tmp/fifo", 0o666); + + // 65k so its less than the max on linux + const large = "HELLO!".repeat((((1024 * 65) / "HELLO!".length) | 0) + 1); + + const chunks = []; + + const proc = Bun.spawn({ + cmd: ["bash", join(import.meta.dir + "/", "bun-streams-test-fifo.sh"), "/tmp/fifo"], + stderr: "inherit", + stdout: null, + stdin: null, + env: { + FIFO_TEST: large, + }, + }); + const exited = proc.exited; + proc.ref(); + + const prom = (async function () { + while (chunks.length === 0) { + var out = Bun.file("/tmp/fifo").stream(); + for await (const chunk of out) { + chunks.push(chunk); + } + } + return Buffer.concat(chunks).toString(); + })(); + + const [status, output] = await Promise.all([exited, prom]); + expect(output.length).toBe(large.length + 1); + expect(output).toBe(large + "\n"); + expect(status).toBe(0); +}); + +it("exists globally", () => { + expect(typeof ReadableStream).toBe("function"); + expect(typeof ReadableStreamBYOBReader).toBe("function"); + expect(typeof ReadableStreamBYOBRequest).toBe("function"); + expect(typeof ReadableStreamDefaultController).toBe("function"); + expect(typeof ReadableStreamDefaultReader).toBe("function"); + expect(typeof TransformStream).toBe("function"); + expect(typeof TransformStreamDefaultController).toBe("function"); + expect(typeof WritableStream).toBe("function"); + expect(typeof WritableStreamDefaultController).toBe("function"); + expect(typeof WritableStreamDefaultWriter).toBe("function"); + expect(typeof ByteLengthQueuingStrategy).toBe("function"); + expect(typeof CountQueuingStrategy).toBe("function"); +}); + +it("new Response(stream).body", async () => { + var stream = new ReadableStream({ + pull(controller) { + controller.enqueue("hello"); + controller.enqueue("world"); + controller.close(); + }, + cancel() {}, + }); + var response = new Response(stream); + expect(response.body).toBe(stream); + expect(await response.text()).toBe("helloworld"); +}); + +it("new Request({body: stream}).body", async () => { + var stream = new ReadableStream({ + pull(controller) { + controller.enqueue("hello"); + controller.enqueue("world"); + controller.close(); + }, + cancel() {}, + }); + var response = new Request({ body: stream }); + expect(response.body).toBe(stream); + expect(await response.text()).toBe("helloworld"); +}); + +it("ReadableStream (readMany)", async () => { + var stream = new ReadableStream({ + pull(controller) { + controller.enqueue("hello"); + controller.enqueue("world"); + controller.close(); + }, + cancel() {}, + }); + var reader = stream.getReader(); + const chunk = await reader.readMany(); + expect(chunk.value.join("")).toBe("helloworld"); + expect((await reader.read()).done).toBe(true); +}); + +it("ReadableStream (direct)", async () => { + var stream = new ReadableStream({ + pull(controller) { + controller.write("hello"); + controller.write("world"); + controller.close(); + }, + cancel() {}, + type: "direct", + }); + var reader = stream.getReader(); + const chunk = await reader.read(); + expect(chunk.value.join("")).toBe(Buffer.from("helloworld").join("")); + expect((await reader.read()).done).toBe(true); + expect((await reader.read()).done).toBe(true); +}); + +it("ReadableStream (bytes)", async () => { + var stream = new ReadableStream({ + start(controller) { + controller.enqueue(Buffer.from("abdefgh")); + }, + pull(controller) {}, + cancel() {}, + type: "bytes", + }); + const chunks = []; + const chunk = await stream.getReader().read(); + chunks.push(chunk.value); + expect(chunks[0].join("")).toBe(Buffer.from("abdefgh").join("")); +}); + +it("ReadableStream (default)", async () => { + var stream = new ReadableStream({ + start(controller) { + controller.enqueue(Buffer.from("abdefgh")); + controller.close(); + }, + pull(controller) {}, + cancel() {}, + }); + const chunks = []; + const chunk = await stream.getReader().read(); + chunks.push(chunk.value); + expect(chunks[0].join("")).toBe(Buffer.from("abdefgh").join("")); +}); + +it("readableStreamToArray", async () => { + var queue = [Buffer.from("abdefgh")]; + var stream = new ReadableStream({ + pull(controller) { + var chunk = queue.shift(); + if (chunk) { + controller.enqueue(chunk); + } else { + controller.close(); + } + }, + cancel() {}, + type: "bytes", + }); + + const chunks = await readableStreamToArray(stream); + + expect(chunks[0].join("")).toBe(Buffer.from("abdefgh").join("")); +}); + +it("readableStreamToArrayBuffer (bytes)", async () => { + var queue = [Buffer.from("abdefgh")]; + var stream = new ReadableStream({ + pull(controller) { + var chunk = queue.shift(); + if (chunk) { + controller.enqueue(chunk); + } else { + controller.close(); + } + }, + cancel() {}, + type: "bytes", + }); + const buffer = await readableStreamToArrayBuffer(stream); + expect(new TextDecoder().decode(new Uint8Array(buffer))).toBe("abdefgh"); +}); + +it("readableStreamToArrayBuffer (default)", async () => { + var queue = [Buffer.from("abdefgh")]; + var stream = new ReadableStream({ + pull(controller) { + var chunk = queue.shift(); + if (chunk) { + controller.enqueue(chunk); + } else { + controller.close(); + } + }, + cancel() {}, + }); + + const buffer = await readableStreamToArrayBuffer(stream); + expect(new TextDecoder().decode(new Uint8Array(buffer))).toBe("abdefgh"); +}); + +it("ReadableStream for Blob", async () => { + var blob = new Blob(["abdefgh", "ijklmnop"]); + expect(await blob.text()).toBe("abdefghijklmnop"); + var stream; + try { + stream = blob.stream(); + stream = blob.stream(); + } catch (e) { + console.error(e); + console.error(e.stack); + } + const chunks = []; + var reader; + reader = stream.getReader(); + + while (true) { + var chunk; + try { + chunk = await reader.read(); + } catch (e) { + console.error(e); + console.error(e.stack); + } + if (chunk.done) break; + chunks.push(new TextDecoder().decode(chunk.value)); + } + expect(chunks.join("")).toBe(new TextDecoder().decode(Buffer.from("abdefghijklmnop"))); +}); + +it("ReadableStream for File", async () => { + var blob = file(import.meta.dir + "/fetch.js.txt"); + var stream = blob.stream(); + const chunks = []; + var reader = stream.getReader(); + stream = undefined; + while (true) { + const chunk = await reader.read(); + if (chunk.done) break; + chunks.push(chunk.value); + } + reader = undefined; + const output = new Uint8Array(await blob.arrayBuffer()).join(""); + const input = chunks.map(a => a.join("")).join(""); + expect(output).toBe(input); +}); + +it("ReadableStream for File errors", async () => { + try { + var blob = file(import.meta.dir + "/fetch.js.txt.notfound"); + blob.stream().getReader(); + throw new Error("should not reach here"); + } catch (e) { + expect(e.code).toBe("ENOENT"); + expect(e.syscall).toBe("open"); + } +}); + +it("ReadableStream for empty blob closes immediately", async () => { + var blob = new Blob([]); + var stream = blob.stream(); + const chunks = []; + var reader = stream.getReader(); + while (true) { + const chunk = await reader.read(); + if (chunk.done) break; + chunks.push(chunk.value); + } + + expect(chunks.length).toBe(0); +}); + +it("ReadableStream for empty file closes immediately", async () => { + writeFileSync("/tmp/bun-empty-file-123456", ""); + var blob = file("/tmp/bun-empty-file-123456"); + var stream; + try { + stream = blob.stream(); + } catch (e) { + console.error(e.stack); + } + const chunks = []; + var reader = stream.getReader(); + while (true) { + const chunk = await reader.read(); + if (chunk.done) break; + chunks.push(chunk.value); + } + + expect(chunks.length).toBe(0); +}); + +it("new Response(stream).arrayBuffer() (bytes)", async () => { + var queue = [Buffer.from("abdefgh")]; + var stream = new ReadableStream({ + pull(controller) { + var chunk = queue.shift(); + if (chunk) { + controller.enqueue(chunk); + } else { + controller.close(); + } + }, + cancel() {}, + type: "bytes", + }); + const buffer = await new Response(stream).arrayBuffer(); + expect(new TextDecoder().decode(new Uint8Array(buffer))).toBe("abdefgh"); +}); + +it("new Response(stream).arrayBuffer() (default)", async () => { + var queue = [Buffer.from("abdefgh")]; + var stream = new ReadableStream({ + pull(controller) { + var chunk = queue.shift(); + if (chunk) { + controller.enqueue(chunk); + } else { + controller.close(); + } + }, + cancel() {}, + }); + const buffer = await new Response(stream).arrayBuffer(); + expect(new TextDecoder().decode(new Uint8Array(buffer))).toBe("abdefgh"); +}); + +it("new Response(stream).text() (default)", async () => { + var queue = [Buffer.from("abdefgh")]; + var stream = new ReadableStream({ + pull(controller) { + var chunk = queue.shift(); + if (chunk) { + controller.enqueue(chunk); + } else { + controller.close(); + } + }, + cancel() {}, + }); + const text = await new Response(stream).text(); + expect(text).toBe("abdefgh"); +}); + +it("new Response(stream).json() (default)", async () => { + var queue = [Buffer.from(JSON.stringify({ hello: true }))]; + var stream = new ReadableStream({ + pull(controller) { + var chunk = queue.shift(); + if (chunk) { + controller.enqueue(chunk); + } else { + controller.close(); + } + }, + cancel() {}, + }); + const json = await new Response(stream).json(); + expect(json.hello).toBe(true); +}); + +it("new Response(stream).blob() (default)", async () => { + var queue = [Buffer.from(JSON.stringify({ hello: true }))]; + var stream = new ReadableStream({ + pull(controller) { + var chunk = queue.shift(); + if (chunk) { + controller.enqueue(chunk); + } else { + controller.close(); + } + }, + cancel() {}, + }); + const response = new Response(stream); + const blob = await response.blob(); + expect(await blob.text()).toBe('{"hello":true}'); +}); + +it("Blob.stream() -> new Response(stream).text()", async () => { + var blob = new Blob(["abdefgh"]); + var stream = blob.stream(); + const text = await new Response(stream).text(); + expect(text).toBe("abdefgh"); +}); + +it("Bun.file().stream() read text from large file", async () => { + const hugely = "HELLO!".repeat(1024 * 1024 * 10); + const tmpfile = join(realpathSync(tmpdir()), "bun-streams-test.txt"); + writeFileSync(tmpfile, hugely); + try { + const chunks = []; + for await (const chunk of Bun.file(tmpfile).stream()) { + chunks.push(chunk); + } + const output = Buffer.concat(chunks).toString(); + expect(output).toHaveLength(hugely.length); + expect(output).toBe(hugely); + } finally { + unlinkSync(tmpfile); + } +}); diff --git a/test/js/web/timers/microtask.test.js b/test/js/web/timers/microtask.test.js new file mode 100644 index 000000000..f41159cfa --- /dev/null +++ b/test/js/web/timers/microtask.test.js @@ -0,0 +1,74 @@ +import { it } from "bun:test"; + +it("queueMicrotask", async () => { + // You can verify this test is correct by copy pasting this into a browser's console and checking it doesn't throw an error. + var run = 0; + + await new Promise((resolve, reject) => { + queueMicrotask(() => { + if (run++ != 0) { + reject(new Error("Microtask execution order is wrong: " + run)); + } + queueMicrotask(() => { + if (run++ != 3) { + reject(new Error("Microtask execution order is wrong: " + run)); + } + }); + }); + queueMicrotask(() => { + if (run++ != 1) { + reject(new Error("Microtask execution order is wrong: " + run)); + } + queueMicrotask(() => { + if (run++ != 4) { + reject(new Error("Microtask execution order is wrong: " + run)); + } + + queueMicrotask(() => { + if (run++ != 6) { + reject(new Error("Microtask execution order is wrong: " + run)); + } + }); + }); + }); + queueMicrotask(() => { + if (run++ != 2) { + reject(new Error("Microtask execution order is wrong: " + run)); + } + queueMicrotask(() => { + if (run++ != 5) { + reject(new Error("Microtask execution order is wrong: " + run)); + } + + queueMicrotask(() => { + if (run++ != 7) { + reject(new Error("Microtask execution order is wrong: " + run)); + } + resolve(true); + }); + }); + }); + }); + + { + var passed = false; + try { + queueMicrotask(1234); + } catch (exception) { + passed = exception instanceof TypeError; + } + + if (!passed) throw new Error("queueMicrotask should throw a TypeError if the argument is not a function"); + } + + { + var passed = false; + try { + queueMicrotask(); + } catch (exception) { + passed = exception instanceof TypeError; + } + + if (!passed) throw new Error("queueMicrotask should throw a TypeError if the argument is empty"); + } +}); diff --git a/test/js/web/timers/performance.test.js b/test/js/web/timers/performance.test.js new file mode 100644 index 000000000..dd50c4dc6 --- /dev/null +++ b/test/js/web/timers/performance.test.js @@ -0,0 +1,22 @@ +import { expect, it } from "bun:test"; + +it("performance.now() should be monotonic", () => { + const first = performance.now(); + const second = performance.now(); + const third = performance.now(); + const fourth = performance.now(); + const fifth = performance.now(); + const sixth = performance.now(); + expect(first < second).toBe(true); + expect(second < third).toBe(true); + expect(third < fourth).toBe(true); + expect(fourth < fifth).toBe(true); + expect(fifth < sixth).toBe(true); + expect(Bun.nanoseconds() > 0).toBe(true); + expect(Bun.nanoseconds() > sixth).toBe(true); + expect(typeof Bun.nanoseconds() === "number").toBe(true); +}); + +it("performance.timeOrigin + performance.now() should be similar to Date.now()", () => { + expect(Math.abs(performance.timeOrigin + performance.now() - Date.now()) < 1000).toBe(true); +}); diff --git a/test/js/web/timers/setImmediate.test.js b/test/js/web/timers/setImmediate.test.js new file mode 100644 index 000000000..9cd6fa1c9 --- /dev/null +++ b/test/js/web/timers/setImmediate.test.js @@ -0,0 +1,47 @@ +import { it, expect } from "bun:test"; + +it("setImmediate", async () => { + var lastID = -1; + const result = await new Promise((resolve, reject) => { + var numbers = []; + + for (let i = 0; i < 10; i++) { + const id = setImmediate((...args) => { + numbers.push(i); + if (i === 9) { + resolve(numbers); + } + try { + expect(args.length).toBe(1); + expect(args[0]).toBe(i); + } catch (err) { + reject(err); + } + }, i); + expect(id > lastID).toBe(true); + lastID = id; + } + }); + + for (let j = 0; j < result.length; j++) { + expect(result[j]).toBe(j); + } + expect(result.length).toBe(10); +}); + +it("clearImmediate", async () => { + var called = false; + const id = setImmediate(() => { + called = true; + expect(false).toBe(true); + }); + clearImmediate(id); + + // assert it doesn't crash if you call clearImmediate twice + clearImmediate(id); + + await new Promise((resolve, reject) => { + setImmediate(resolve); + }); + expect(called).toBe(false); +}); diff --git a/test/js/web/timers/setInterval.test.js b/test/js/web/timers/setInterval.test.js new file mode 100644 index 000000000..7b03afba5 --- /dev/null +++ b/test/js/web/timers/setInterval.test.js @@ -0,0 +1,61 @@ +import { it, expect } from "bun:test"; + +it("setInterval", async () => { + var counter = 0; + var start; + const result = await new Promise((resolve, reject) => { + start = performance.now(); + + var id = setInterval( + (...args) => { + counter++; + if (counter === 10) { + resolve(counter); + clearInterval(id); + } + try { + expect(args).toStrictEqual(["foo"]); + } catch (err) { + reject(err); + clearInterval(id); + } + }, + 1, + "foo", + ); + }); + + expect(result).toBe(10); + expect(performance.now() - start >= 10).toBe(true); +}); + +it("clearInterval", async () => { + var called = false; + const id = setInterval(() => { + called = true; + expect(false).toBe(true); + }, 1); + clearInterval(id); + await new Promise((resolve, reject) => { + setInterval(() => { + resolve(); + }, 10); + }); + expect(called).toBe(false); +}); + +it("async setInterval", async () => { + var remaining = 5; + await new Promise((resolve, reject) => { + queueMicrotask(() => { + var id = setInterval(async () => { + await 1; + remaining--; + if (remaining === 0) { + clearInterval(id); + resolve(); + } + }, 1); + }); + }); +}); diff --git a/test/js/web/timers/setTimeout.test.js b/test/js/web/timers/setTimeout.test.js new file mode 100644 index 000000000..88472adc7 --- /dev/null +++ b/test/js/web/timers/setTimeout.test.js @@ -0,0 +1,173 @@ +import { it, expect } from "bun:test"; + +it("setTimeout", async () => { + var lastID = -1; + const result = await new Promise((resolve, reject) => { + var numbers = []; + + for (let i = 0; i < 10; i++) { + const id = setTimeout( + (...args) => { + numbers.push(i); + if (i === 9) { + resolve(numbers); + } + try { + expect(args).toStrictEqual(["foo"]); + } catch (err) { + reject(err); + } + }, + i, + "foo", + ); + expect(+id > lastID).toBe(true); + lastID = id; + } + }); + + for (let j = 0; j < result.length; j++) { + expect(result[j]).toBe(j); + } + expect(result.length).toBe(10); +}); + +it("clearTimeout", async () => { + var called = false; + + // as object + { + const id = setTimeout(() => { + called = true; + expect(false).toBe(true); + }, 0); + clearTimeout(id); + + // assert it doesn't crash if you call clearTimeout twice + clearTimeout(id); + } + + // as number + { + const id = setTimeout(() => { + called = true; + expect(false).toBe(true); + }, 0); + clearTimeout(+id); + + // assert it doesn't crash if you call clearTimeout twice + clearTimeout(+id); + } + + await new Promise((resolve, reject) => { + setTimeout(resolve, 10); + }); + expect(called).toBe(false); +}); + +it("setTimeout(() => {}, 0)", async () => { + var called = false; + setTimeout(() => { + called = true; + }, 0); + await new Promise((resolve, reject) => { + setTimeout(() => { + resolve(); + }, 10); + }); + expect(called).toBe(true); + var ranFirst = -1; + setTimeout(() => { + if (ranFirst === -1) ranFirst = 1; + }, 1); + setTimeout(() => { + if (ranFirst === -1) ranFirst = 0; + }, 0); + + await new Promise((resolve, reject) => { + setTimeout(() => { + resolve(); + }, 10); + }); + expect(ranFirst).toBe(0); + + ranFirst = -1; + + const id = setTimeout(() => { + ranFirst = 0; + }, 0); + clearTimeout(id); + await new Promise((resolve, reject) => { + setTimeout(() => { + resolve(); + }, 10); + }); + expect(ranFirst).toBe(-1); +}); + +it("Bun.sleep", async () => { + var sleeps = 0; + await Bun.sleep(0); + const start = performance.now(); + sleeps++; + await Bun.sleep(1); + sleeps++; + await Bun.sleep(2); + sleeps++; + const end = performance.now(); + expect((end - start) * 1000).toBeGreaterThanOrEqual(3); + + expect(sleeps).toBe(3); +}); + +it("Bun.sleep propagates exceptions", async () => { + try { + await Bun.sleep(1).then(a => { + throw new Error("TestPassed"); + }); + throw "Should not reach here"; + } catch (err) { + expect(err.message).toBe("TestPassed"); + } +}); + +it("Bun.sleep works with a Date object", async () => { + var ten_ms = new Date(); + ten_ms.setMilliseconds(ten_ms.getMilliseconds() + 12); + const now = performance.now(); + await Bun.sleep(ten_ms); + expect(performance.now() - now).toBeGreaterThanOrEqual(10); +}); + +it("node.js timers/promises setTimeout propagates exceptions", async () => { + const { setTimeout } = require("timers/promises"); + try { + await setTimeout(1).then(a => { + throw new Error("TestPassed"); + }); + throw "Should not reach here"; + } catch (err) { + expect(err.message).toBe("TestPassed"); + } +}); + +it.skip("order of setTimeouts", done => { + var nums = []; + var maybeDone = cb => { + return () => { + cb(); + if (nums.length === 4) { + try { + expect(nums).toEqual([1, 2, 3, 4]); + done(); + } catch (e) { + done(e); + } + } + }; + }; + setTimeout(maybeDone(() => nums.push(2))); + setTimeout(maybeDone(() => nums.push(3), 0)); + setTimeout(maybeDone(() => nums.push(4), 1)); + Promise.resolve().then(maybeDone(() => nums.push(1))); +}); diff --git a/test/js/web/url/url.test.ts b/test/js/web/url/url.test.ts new file mode 100644 index 000000000..19e10b262 --- /dev/null +++ b/test/js/web/url/url.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect } from "bun:test"; + +describe("url", () => { + it("prints", () => { + expect(Bun.inspect(new URL("https://example.com"))).toBe(`URL { + href: "https://example.com/", + origin: "https://example.com", + protocol: "https:", + username: "", + password: "", + host: "example.com", + hostname: "example.com", + port: "", + pathname: "/", + hash: "", + search: "", + searchParams: URLSearchParams { + append: [Function: append], + delete: [Function: delete], + get: [Function: get], + getAll: [Function: getAll], + has: [Function: has], + set: [Function: set], + sort: [Function: sort], + entries: [Function: entries], + keys: [Function: keys], + values: [Function: values], + forEach: [Function: forEach], + toString: [Function: toString], + [Symbol(Symbol.iterator)]: [Function: entries] + }, + toJSON: [Function: toJSON], + toString: [Function: toString] +}`); + + expect( + Bun.inspect( + new URL("https://github.com/oven-sh/bun/issues/135?hello%20i%20have%20spaces%20thank%20you%20good%20night"), + ), + ).toBe(`URL { + href: "https://github.com/oven-sh/bun/issues/135?hello%20i%20have%20spaces%20thank%20you%20good%20night", + origin: "https://github.com", + protocol: "https:", + username: "", + password: "", + host: "github.com", + hostname: "github.com", + port: "", + pathname: "/oven-sh/bun/issues/135", + hash: "", + search: "?hello%20i%20have%20spaces%20thank%20you%20good%20night", + searchParams: URLSearchParams { + append: [Function: append], + delete: [Function: delete], + get: [Function: get], + getAll: [Function: getAll], + has: [Function: has], + set: [Function: set], + sort: [Function: sort], + entries: [Function: entries], + keys: [Function: keys], + values: [Function: values], + forEach: [Function: forEach], + toString: [Function: toString], + [Symbol(Symbol.iterator)]: [Function: entries] + }, + toJSON: [Function: toJSON], + toString: [Function: toString] +}`); + }); + it("works", () => { + const inputs = [ + [ + "https://username:password@api.foo.bar.com:9999/baz/okay/i/123?ran=out&of=things#to-use-as-a-placeholder", + { + hash: "#to-use-as-a-placeholder", + host: "api.foo.bar.com:9999", + hostname: "api.foo.bar.com", + href: "https://username:password@api.foo.bar.com:9999/baz/okay/i/123?ran=out&of=things#to-use-as-a-placeholder", + origin: "https://api.foo.bar.com:9999", + password: "password", + pathname: "/baz/okay/i/123", + port: "9999", + protocol: "https:", + search: "?ran=out&of=things", + username: "username", + }, + ], + [ + "https://url.spec.whatwg.org/#url-serializing", + { + hash: "#url-serializing", + host: "url.spec.whatwg.org", + hostname: "url.spec.whatwg.org", + href: "https://url.spec.whatwg.org/#url-serializing", + origin: "https://url.spec.whatwg.org", + password: "", + pathname: "/", + port: "", + protocol: "https:", + search: "", + username: "", + }, + ], + [ + "https://url.spec.whatwg.org#url-serializing", + { + hash: "#url-serializing", + host: "url.spec.whatwg.org", + hostname: "url.spec.whatwg.org", + href: "https://url.spec.whatwg.org/#url-serializing", + origin: "https://url.spec.whatwg.org", + password: "", + pathname: "/", + port: "", + protocol: "https:", + search: "", + username: "", + }, + ], + ] as const; + + for (let [url, values] of inputs) { + const result = new URL(url); + expect(result.hash).toBe(values.hash); + expect(result.host).toBe(values.host); + expect(result.hostname).toBe(values.hostname); + expect(result.href).toBe(values.href); + expect(result.password).toBe(values.password); + expect(result.pathname).toBe(values.pathname); + expect(result.port).toBe(values.port); + expect(result.protocol).toBe(values.protocol); + expect(result.search).toBe(values.search); + expect(result.username).toBe(values.username); + } + }); +}); diff --git a/test/js/web/util/atob.test.js b/test/js/web/util/atob.test.js new file mode 100644 index 000000000..4945829e1 --- /dev/null +++ b/test/js/web/util/atob.test.js @@ -0,0 +1,77 @@ +import { expect, it } from "bun:test"; + +function expectInvalidCharacters(val) { + try { + atob(val); + throw new Error("Expected error"); + } catch (error) { + expect(error.message).toBe("The string contains invalid characters."); + } +} + +it("atob", () => { + expect(atob("YQ==")).toBe("a"); + expect(atob("YWI=")).toBe("ab"); + expect(atob("YWJj")).toBe("abc"); + expect(atob("YWJjZA==")).toBe("abcd"); + expect(atob("YWJjZGU=")).toBe("abcde"); + expect(atob("YWJjZGVm")).toBe("abcdef"); + expect(atob("zzzz")).toBe("Ï<ó"); + expect(atob("")).toBe(""); + expect(atob(null)).toBe("ée"); + expect(atob("6ek=")).toBe("éé"); + expect(atob("6ek")).toBe("éé"); + expect(atob("gIE=")).toBe(""); + expect(atob("zz")).toBe("Ï"); + expect(atob("zzz")).toBe("Ï<"); + expect(atob("zzz=")).toBe("Ï<"); + expect(atob(" YQ==")).toBe("a"); + expect(atob("YQ==\u000a")).toBe("a"); + + try { + atob(); + } catch (error) { + expect(error.name).toBe("TypeError"); + } + expectInvalidCharacters(undefined); + expectInvalidCharacters(" abcd==="); + expectInvalidCharacters("abcd=== "); + expectInvalidCharacters("abcd ==="); + expectInvalidCharacters("тест"); + expectInvalidCharacters("z"); + expectInvalidCharacters("zzz=="); + expectInvalidCharacters("zzz==="); + expectInvalidCharacters("zzz===="); + expectInvalidCharacters("zzz====="); + expectInvalidCharacters("zzzzz"); + expectInvalidCharacters("z=zz"); + expectInvalidCharacters("="); + expectInvalidCharacters("=="); + expectInvalidCharacters("==="); + expectInvalidCharacters("===="); + expectInvalidCharacters("====="); +}); + +it("btoa", () => { + expect(btoa("a")).toBe("YQ=="); + expect(btoa("ab")).toBe("YWI="); + expect(btoa("abc")).toBe("YWJj"); + expect(btoa("abcd")).toBe("YWJjZA=="); + expect(btoa("abcde")).toBe("YWJjZGU="); + expect(btoa("abcdef")).toBe("YWJjZGVm"); + expect(typeof btoa).toBe("function"); + try { + btoa(); + throw new Error("Expected error"); + } catch (error) { + expect(error.name).toBe("TypeError"); + } + var window = "[object Window]"; + expect(btoa("")).toBe(""); + expect(btoa(null)).toBe("bnVsbA=="); + expect(btoa(undefined)).toBe("dW5kZWZpbmVk"); + expect(btoa(window)).toBe("W29iamVjdCBXaW5kb3dd"); + expect(btoa("éé")).toBe("6ek="); + expect(btoa("\u0080\u0081")).toBe("gIE="); + expect(btoa(Bun)).toBe(btoa("[object Bun]")); +}); diff --git a/test/js/web/web-globals.test.js b/test/js/web/web-globals.test.js new file mode 100644 index 000000000..b7a243190 --- /dev/null +++ b/test/js/web/web-globals.test.js @@ -0,0 +1,156 @@ +import { unsafe } from "bun"; +import { expect, it, test } from "bun:test"; +import { withoutAggressiveGC } from "harness"; + +test("exists", () => { + expect(typeof URL !== "undefined").toBe(true); + expect(typeof URLSearchParams !== "undefined").toBe(true); + expect(typeof DOMException !== "undefined").toBe(true); + expect(typeof Event !== "undefined").toBe(true); + expect(typeof EventTarget !== "undefined").toBe(true); + expect(typeof AbortController !== "undefined").toBe(true); + expect(typeof AbortSignal !== "undefined").toBe(true); + expect(typeof CustomEvent !== "undefined").toBe(true); + expect(typeof Headers !== "undefined").toBe(true); + expect(typeof ErrorEvent !== "undefined").toBe(true); + expect(typeof CloseEvent !== "undefined").toBe(true); + expect(typeof MessageEvent !== "undefined").toBe(true); + expect(typeof TextEncoder !== "undefined").toBe(true); + expect(typeof WebSocket !== "undefined").toBe(true); + expect(typeof Blob !== "undefined").toBe(true); + expect(typeof FormData !== "undefined").toBe(true); +}); + +test("CloseEvent", () => { + var event = new CloseEvent("close", { reason: "world" }); + expect(event.type).toBe("close"); + const target = new EventTarget(); + var called = false; + target.addEventListener("close", ({ type, reason }) => { + expect(type).toBe("close"); + expect(reason).toBe("world"); + called = true; + }); + target.dispatchEvent(event); + expect(called).toBe(true); +}); + +test("MessageEvent", () => { + var event = new MessageEvent("message", { data: "world" }); + expect(event.type).toBe("message"); + const target = new EventTarget(); + var called = false; + target.addEventListener("message", ({ type, data }) => { + expect(type).toBe("message"); + expect(data).toBe("world"); + called = true; + }); + target.dispatchEvent(event); + expect(called).toBe(true); +}); + +it("crypto.getRandomValues", () => { + var foo = new Uint8Array(32); + + // run it once buffered and unbuffered + { + var array = crypto.getRandomValues(foo); + expect(array).toBe(foo); + expect(array.reduce((sum, a) => (sum += a === 0), 0) != foo.length).toBe(true); + } + + // disable it for this block because it tends to get stuck here running the GC forever + withoutAggressiveGC(() => { + // run it again to check that the fast path works + for (var i = 0; i < 9000; i++) { + var array = crypto.getRandomValues(foo); + expect(array).toBe(foo); + } + }); + + // run it on a large input + expect(!!crypto.getRandomValues(new Uint8Array(8096)).find(a => a > 0)).toBe(true); + + { + // any additional input into getRandomValues() makes it unbuffered + var array = crypto.getRandomValues(foo, "unbuffered"); + expect(array).toBe(foo); + expect(array.reduce((sum, a) => (sum += a === 0), 0) != foo.length).toBe(true); + } +}); + +// not actually a web global +it("crypto.timingSafeEqual", () => { + const crypto = import.meta.require("node:crypto"); + var uuidStr = crypto.randomUUID(); + expect(uuidStr.length).toBe(36); + expect(uuidStr[8]).toBe("-"); + expect(uuidStr[13]).toBe("-"); + expect(uuidStr[18]).toBe("-"); + expect(uuidStr[23]).toBe("-"); + const uuid = Buffer.from(uuidStr); + + expect(crypto.timingSafeEqual(uuid, uuid)).toBe(true); + expect(crypto.timingSafeEqual(uuid, uuid.slice())).toBe(true); + try { + crypto.timingSafeEqual(uuid, uuid.slice(1)); + expect(false).toBe(true); + } catch (e) {} + + try { + crypto.timingSafeEqual(uuid, uuid.slice(0, uuid.length - 2)); + expect(false).toBe(true); + } catch (e) { + expect(e.message).toBe("Input buffers must have the same length"); + } + + try { + expect(crypto.timingSafeEqual(uuid, crypto.randomUUID())).toBe(false); + expect(false).toBe(true); + } catch (e) { + expect(e.name).toBe("TypeError"); + } + + var shorter = uuid.slice(0, 1); + for (let i = 0; i < 9000; i++) { + if (!crypto.timingSafeEqual(shorter, shorter)) throw new Error("fail"); + } +}); + +it("crypto.randomUUID", () => { + var uuid = crypto.randomUUID(); + expect(uuid.length).toBe(36); + expect(uuid[8]).toBe("-"); + expect(uuid[13]).toBe("-"); + expect(uuid[18]).toBe("-"); + expect(uuid[23]).toBe("-"); + + withoutAggressiveGC(() => { + // check that the fast path works + for (let i = 0; i < 9000; i++) { + var uuid2 = crypto.randomUUID(); + expect(uuid2.length).toBe(36); + expect(uuid2[8]).toBe("-"); + expect(uuid2[13]).toBe("-"); + expect(uuid2[18]).toBe("-"); + expect(uuid2[23]).toBe("-"); + } + }); +}); + +it("URL.prototype.origin", () => { + const url = new URL("https://html.spec.whatwg.org/"); + const { origin, host, hostname } = url; + + expect(hostname).toBe("html.spec.whatwg.org"); + expect(host).toBe("html.spec.whatwg.org"); + expect(origin).toBe("https://html.spec.whatwg.org"); +}); + +test("navigator", () => { + expect(globalThis.navigator !== undefined).toBe(true); + const version = process.versions.bun; + const userAgent = `Bun/${version}`; + expect(navigator.hardwareConcurrency > 0).toBe(true); + expect(navigator.userAgent).toBe(userAgent); +}); diff --git a/test/js/web/websocket/websocket-subprocess.ts b/test/js/web/websocket/websocket-subprocess.ts new file mode 100644 index 000000000..fd25b7fd5 --- /dev/null +++ b/test/js/web/websocket/websocket-subprocess.ts @@ -0,0 +1,13 @@ +const host = process.argv[2]; + +const ws = new WebSocket(host); + +ws.onmessage = message => { + if (message.data === "hello websocket") { + ws.send("hello"); + } else if (message.data === "timeout") { + setTimeout(() => { + ws.send("close"); + }, 300); + } +}; diff --git a/test/js/web/websocket/websocket.test.js b/test/js/web/websocket/websocket.test.js new file mode 100644 index 000000000..f0f29c1c3 --- /dev/null +++ b/test/js/web/websocket/websocket.test.js @@ -0,0 +1,263 @@ +import { describe, it, expect } from "bun:test"; +import { unsafe, spawn, readableStreamToText } from "bun"; +import { bunExe, bunEnv, gc } from "harness"; + +const TEST_WEBSOCKET_HOST = process.env.TEST_WEBSOCKET_HOST || "wss://ws.postman-echo.com/raw"; + +describe("WebSocket", () => { + it("should connect", async () => { + const ws = new WebSocket(TEST_WEBSOCKET_HOST); + await new Promise((resolve, reject) => { + ws.onopen = resolve; + ws.onerror = reject; + }); + var closed = new Promise((resolve, reject) => { + ws.onclose = resolve; + }); + ws.close(); + await closed; + }); + + it("should connect over https", async () => { + const ws = new WebSocket(TEST_WEBSOCKET_HOST.replaceAll("wss:", "https:")); + await new Promise((resolve, reject) => { + ws.onopen = resolve; + ws.onerror = reject; + }); + var closed = new Promise((resolve, reject) => { + ws.onclose = resolve; + }); + ws.close(); + await closed; + }); + + it("supports headers", done => { + const server = Bun.serve({ + port: 8024, + fetch(req, server) { + expect(req.headers.get("X-Hello")).toBe("World"); + expect(req.headers.get("content-type")).toBe("lolwut"); + server.stop(); + done(); + return new Response(); + }, + websocket: { + open(ws) { + ws.close(); + }, + }, + }); + const ws = new WebSocket(`ws://${server.hostname}:${server.port}`, { + headers: { + "X-Hello": "World", + "content-type": "lolwut", + }, + }); + }); + + it("should connect over http", done => { + const server = Bun.serve({ + port: 8025, + fetch(req, server) { + server.stop(); + done(); + return new Response(); + }, + websocket: { + open(ws) { + ws.close(); + }, + }, + }); + const ws = new WebSocket(`http://${server.hostname}:${server.port}`, {}); + }); + + it("should send and receive messages", async () => { + const ws = new WebSocket(TEST_WEBSOCKET_HOST); + await new Promise((resolve, reject) => { + ws.onopen = resolve; + ws.onerror = reject; + ws.onclose = () => { + reject("WebSocket closed"); + }; + }); + const count = 10; + + // 10 messages in burst + var promise = new Promise((resolve, reject) => { + var remain = count; + ws.onmessage = event => { + gc(true); + expect(event.data).toBe("Hello World!"); + remain--; + + if (remain <= 0) { + ws.onmessage = () => {}; + resolve(); + } + }; + ws.onerror = reject; + }); + + for (let i = 0; i < count; i++) { + ws.send("Hello World!"); + gc(true); + } + + await promise; + var echo = 0; + + // 10 messages one at a time + function waitForEcho() { + return new Promise((resolve, reject) => { + gc(true); + const msg = `Hello World! ${echo++}`; + ws.onmessage = event => { + expect(event.data).toBe(msg); + resolve(); + }; + ws.onerror = reject; + ws.onclose = reject; + ws.send(msg); + gc(true); + }); + } + gc(true); + for (let i = 0; i < count; i++) await waitForEcho(); + ws.onclose = () => {}; + ws.onerror = () => {}; + ws.close(); + gc(true); + }); +}); + +describe("websocket in subprocess", () => { + var port = 8765; + it("should exit", async () => { + let messageReceived = false; + const server = Bun.serve({ + port: port++, + fetch(req, server) { + if (server.upgrade(req)) { + return; + } + + return new Response("http response"); + }, + websocket: { + open(ws) { + ws.send("hello websocket"); + }, + message(ws) { + messageReceived = true; + ws.close(); + }, + close(ws) {}, + }, + }); + const subprocess = Bun.spawn({ + cmd: [bunExe(), import.meta.dir + "/websocket-subprocess.ts", `http://${server.hostname}:${server.port}`], + stderr: "pipe", + stdin: "pipe", + stdout: "pipe", + env: bunEnv, + }); + + expect(await subprocess.exited).toBe(0); + expect(messageReceived).toBe(true); + server.stop(true); + }); + + it("should exit after killed", async () => { + const subprocess = Bun.spawn({ + cmd: [bunExe(), import.meta.dir + "/websocket-subprocess.ts", TEST_WEBSOCKET_HOST], + stderr: "pipe", + stdin: "pipe", + stdout: "pipe", + env: bunEnv, + }); + + subprocess.kill(); + + expect(await subprocess.exited).toBe("SIGHUP"); + }); + + it("should exit with invalid url", async () => { + const subprocess = Bun.spawn({ + cmd: [bunExe(), import.meta.dir + "/websocket-subprocess.ts", "invalid url"], + stderr: "pipe", + stdin: "pipe", + stdout: "pipe", + env: bunEnv, + }); + + expect(await subprocess.exited).toBe(1); + }); + + it("should exit after timeout", async () => { + let messageReceived = false; + let start = 0; + const server = Bun.serve({ + port: port++, + fetch(req, server) { + if (server.upgrade(req)) { + return; + } + + return new Response("http response"); + }, + websocket: { + open(ws) { + start = performance.now(); + ws.send("timeout"); + }, + message(ws, message) { + messageReceived = true; + expect(performance.now() - start >= 300).toBe(true); + ws.close(); + }, + close(ws) {}, + }, + }); + const subprocess = Bun.spawn({ + cmd: [bunExe(), import.meta.dir + "/websocket-subprocess.ts", `http://${server.hostname}:${server.port}`], + stderr: "pipe", + stdin: "pipe", + stdout: "pipe", + env: bunEnv, + }); + + expect(await subprocess.exited).toBe(0); + expect(messageReceived).toBe(true); + server.stop(true); + }); + + it("should exit after server stop and 0 messages", async () => { + const server = Bun.serve({ + port: port++, + fetch(req, server) { + if (server.upgrade(req)) { + return; + } + + return new Response("http response"); + }, + websocket: { + open(ws) {}, + message(ws, message) {}, + close(ws) {}, + }, + }); + + const subprocess = Bun.spawn({ + cmd: [bunExe(), import.meta.dir + "/websocket-subprocess.ts", `http://${server.hostname}:${server.port}`], + stderr: "pipe", + stdin: "pipe", + stdout: "pipe", + env: bunEnv, + }); + + server.stop(true); + expect(await subprocess.exited).toBe(0); + }); +}); |