import type { Server, Subprocess } from "bun"; import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, tempDirWithFiles } from "harness"; import { join } from "path"; function replaceHash(html: string) { return html .trim() .split("\n") .map(a => a.trim()) .filter(a => a.length > 0) .join("\n") .trim() .replace(/chunk-[a-z0-9]+\.css/g, "chunk-HASH.css") .replace(/chunk-[a-z0-9]+\.js/g, "chunk-HASH.js"); } function extractHash(html: string, file_kind: "css" | "js") { const re = file_kind === "css" ? /chunk-([a-z0-9]+)\.css/ : /chunk-([a-z0-9]+)\.js/; return html.match(re)?.[1]; } test("serve html", async () => { const dir = tempDirWithFiles("html-css-js", { "dashboard.html": /*html*/ ` Dashboard

Dashboard

This is a separate route to test multiple pages work



Back to Home
`, "dashboard.js": /*js*/ ` import './script.js'; // Additional dashboard-specific code could go here console.log("How...dashing?") `, "index.html": /*html*/ ` Bun HTML Import Test

Hello from Bun!

`, "script.js": /*js*/ ` let count = 0; const button = document.getElementById('counter'); button.addEventListener('click', () => { count++; button.textContent = \`Click me: \${count}\`; }); `, "styles.css": /*css*/ ` .container { max-width: 800px; margin: 2rem auto; text-align: center; font-family: system-ui, sans-serif; } button { padding: 0.5rem 1rem; font-size: 1.25rem; border-radius: 0.25rem; border: 2px solid #000; background: #fff; cursor: pointer; transition: all 0.2s; } button:hover { background: #000; color: #fff; } `, }); const { subprocess: subprocess1, port, hostname, } = await waitForServer(dir, { "/": join(dir, "index.html"), "/dashboard": join(dir, "dashboard.html"), }); await using subprocess = subprocess1; { const html = await (await fetch(`http://${hostname}:${port}/`)).text(); const trimmed = html .trim() .split("\n") .map(a => a.trim()) .filter(a => a.length > 0) .join("\n") .trim() .replace(/chunk-[a-z0-9]+\.css/g, "chunk-HASH.css") .replace(/chunk-[a-z0-9]+\.js/g, "chunk-HASH.js"); expect(trimmed).toMatchInlineSnapshot(` " Bun HTML Import Test

Hello from Bun!

" `); } { const html = await (await fetch(`http://${hostname}:${port}/dashboard`)).text(); const jsSrc = new URL( html.match(/

Dashboard

This is a separate route to test multiple pages work



Back to Home
" `); const response = await fetch(jsSrc!); const js = await response.text(); expect( js .replace(/# debugId=[a-z0-9A-Z]+/g, "# debugId=") .replace(/# sourceMappingURL=[^"]+/g, "# sourceMappingURL="), ).toMatchInlineSnapshot(` "// script.js var count = 0; var button = document.getElementById("counter"); button.addEventListener("click", () => { count++; button.textContent = \`Click me: \${count}\`; }); // dashboard.js console.log("How...dashing?"); //# debugId= //# sourceMappingURL=" `); const sourceMapURL = js.match(/# sourceMappingURL=([^"]+)/)?.[1]; if (!sourceMapURL) { throw new Error("No source map URL found"); } const sourceMap = await (await fetch(new URL(sourceMapURL, "http://" + hostname + ":" + port))).json(); sourceMap.sourcesContent = sourceMap.sourcesContent.map(a => a.trim()); expect(JSON.stringify(sourceMap, null, 2)).toMatchInlineSnapshot(` "{ "version": 3, "sources": [ "script.js", "dashboard.js" ], "sourcesContent": [ "let count = 0;\\n const button = document.getElementById('counter');\\n button.addEventListener('click', () => {\\n count++;\\n button.textContent = \`Click me: \${count}\`;\\n });", "import './script.js';\\n // Additional dashboard-specific code could go here\\n console.log(\\"How...dashing?\\")" ], "mappings": ";AACM,IAAI,QAAQ;AACZ,IAAM,SAAS,SAAS,eAAe,SAAS;AAChD,OAAO,iBAAiB,SAAS,MAAM;AAAA,EACrC;AAAA,EACA,OAAO,cAAc,aAAa;AAAA,CACnC;;;ACHD,QAAQ,IAAI,gBAAgB;", "debugId": "0B3DD451DC3D66B564756E2164756E21", "names": [] }" `); const headers = response.headers.toJSON(); headers.date = ""; headers.sourcemap = headers.sourcemap.replace(/chunk-[a-z0-9]+\.js.map/g, "chunk-HASH.js.map"); expect(headers).toMatchInlineSnapshot(` { "content-length": "316", "content-type": "text/javascript;charset=utf-8", "date": "", "etag": "42b631804ef51c7e", "sourcemap": "/chunk-HASH.js.map", } `); } { const css = await (await fetch(cssSrc!)).text(); /* the order of the properties may change because we made add more handlers to DeclarationHandler which changes the order in which they are flushed, but semantically it should be the same */ expect(css).toMatchInlineSnapshot(` "/* styles.css */ .container { text-align: center; max-width: 800px; margin: 2rem auto; font-family: system-ui, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Noto Sans, Ubuntu, Cantarell, Helvetica Neue, sans-serif; } button { cursor: pointer; background: #fff; border: 2px solid #000; border-radius: .25rem; padding: .5rem 1rem; transition: all .2s; font-size: 1.25rem; } button:hover { color: #fff; background: #000; } " `); } expect(await (await fetch(`http://${hostname}:${port}/a-different-url`)).text()).toMatchInlineSnapshot( `"Hello World"`, ); subprocess.kill(); }); describe("serve plugins", () => { /** * Test with basic plugin which appends " OOGA BOOGA" to text file. */ test("basic plugin", async () => { const dir = await tempDirWithFiles("bun-serve-html-txt", { "bunfig.toml": /* toml */ ` [serve.static] plugins = ["./plugin.ts"] `, "index.html": /* html */ `
`, "styles.css": /* css */ ` .text-file-content { content: url("./example.txt"); display: block; white-space: pre; font-family: monospace; } `, "example.txt": "LMAO", "plugin.ts": /* ts */ ` import type { BunPlugin } from "bun"; const p: BunPlugin = { name: "my-plugin", setup(build) { build.onLoad({ filter: /\\.txt$/ }, async ({ path }) => { const text = await Bun.file(path).text(); return { loader: "text", contents: text + " OOGA BOOGA", }; }); }, }; export default p; `, }); const { subprocess: subprocess1, port, hostname, } = await waitForServer(dir, { "/": join(dir, "index.html"), }); await using subprocess = subprocess1; const response = await fetch(`http://${hostname}:${port}/`); expect(response.status).toBe(200); expect(response.headers.get("content-type")).toBe("text/html;charset=utf-8"); const rawHtml = await response.text(); const html = replaceHash(rawHtml); expect(html).toMatchInlineSnapshot(` "
" `); const hash = extractHash(rawHtml, "css"); console.log("HASH", hash); const cssResponse = await fetch(`http://${hostname}:${port}/chunk-${hash}.css`); expect(cssResponse.status).toBe(200); const css = await cssResponse.text(); // the base64 encoding of "LMAO OOGA BOOGA" expect(css).toMatchInlineSnapshot(` "/* styles.css */ .text-file-content { content: url("data:text/plain;base64,TE1BTyBPT0dBIEJPT0dB"); display: block; white-space: pre; font-family: monospace; } " `); }); test("serve html with failing plugin", async () => { const dir = tempDirWithFiles("html-css-js-failing-plugin", { "bunfig.toml": /* toml */ ` [serve.static] plugins = ["./plugin.ts"] `, "index.html": /*html*/ ` Bun HTML Import Test

Hello from Bun!

`, "styles.css": /*css*/ ` .container { max-width: 800px; margin: 2rem auto; text-align: center; } `, "plugin.ts": /*ts*/ ` const p = { name: "failing-plugin", setup(build) { build.onLoad({ filter: /\.css$/ }, async () => { throw new Error("Plugin failed intentionally"); }); }, }; export default p; `, }); const { subprocess: subprocess1, port, hostname, } = await waitForServer(dir, { "/": join(dir, "index.html"), }); await using subprocess = subprocess1; const response = await fetch(`http://${hostname}:${port}/`); expect(response.status).toBe(500); // try again const response2 = await fetch(`http://${hostname}:${port}/`); expect(response2.status).toBe(500); }); test("empty plugin array", async () => { const dir = tempDirWithFiles("html-css-js-empty-plugins", { "index.html": /*html*/ ` Empty Plugins Test

Hello from Bun!

`, "styles.css": /*css*/ ` .container { max-width: 800px; margin: 2rem auto; text-align: center; } `, "script.js": /*js*/ ` const button = document.getElementById('counter'); let count = 0; button.onclick = () => { count++; button.textContent = \`Click me: \${count}\`; }; `, "bunfig.toml": ` [serve.static] plugins = []`, }); const { subprocess: subprocess1, port, hostname, } = await waitForServer(dir, { "/": join(dir, "index.html"), }); await using subprocess = subprocess1; const response = await fetch(`http://${hostname}:${port}/`); expect(response.status).toBe(200); expect(response.headers.get("content-type")).toContain("text/html"); const text = await response.text(); expect(text).toContain("Empty Plugins Test"); }); test("concurrent requests to multiple routes during plugin load", async () => { // Helper function to generate HTML files const createHtmlFile = (title: string, jsFile: string) => /*html*/ ` ${title}

${title}

`; const dir = tempDirWithFiles("html-css-js-concurrent-plugins", { "index.html": createHtmlFile("Home Page", "index.js"), "about.html": createHtmlFile("About Page", "about.js"), "contact.html": createHtmlFile("Contact Page", "contact.js"), "products.html": createHtmlFile("Products Page", "products.js"), "services.html": createHtmlFile("Services Page", "services.js"), "blog.html": createHtmlFile("Blog Page", "blog.js"), "team.html": createHtmlFile("Team Page", "team.js"), "careers.html": createHtmlFile("Careers Page", "careers.js"), "faq.html": createHtmlFile("FAQ Page", "faq.js"), "ooga.html": createHtmlFile("Ooga Page", "ooga.js"), "index.js": "console.log('home page')", "about.js": "console.log('about page')", "contact.js": "console.log('contact page')", "products.js": "console.log('products page')", "services.js": "console.log('services page')", "blog.js": "console.log('blog page')", "team.js": "console.log('team page')", "careers.js": "console.log('careers page')", "faq.js": "console.log('faq page')", "ooga.js": "console.log('ooga page')", "bunfig.toml": `[serve.static] plugins = ["./plugin.js"]`, "plugin.js": ` export default { name: "test-plugin", setup(build) { // Add a small delay to simulate plugin initialization console.log("plugin setup"); return new Promise(resolve => setTimeout(resolve, 1000)); } }`, }); console.log("Waiting for server"); const { subprocess: subprocess1, port, hostname, } = await waitForServer(dir, { "/": join(dir, "index.html"), "/about": join(dir, "about.html"), "/contact": join(dir, "contact.html"), "/products": join(dir, "products.html"), "/services": join(dir, "services.html"), "/blog": join(dir, "blog.html"), "/team": join(dir, "team.html"), "/careers": join(dir, "careers.html"), "/faq": join(dir, "faq.html"), "/ooga": join(dir, "ooga.html"), }); console.log("done waiting for server"); await using subprocess = subprocess1; // Make concurrent requests to all routes while plugins are loading const responses = await Promise.all([ fetch(`http://${hostname}:${port}/`), fetch(`http://${hostname}:${port}/about`), fetch(`http://${hostname}:${port}/contact`), fetch(`http://${hostname}:${port}/products`), fetch(`http://${hostname}:${port}/services`), fetch(`http://${hostname}:${port}/blog`), fetch(`http://${hostname}:${port}/team`), fetch(`http://${hostname}:${port}/careers`), fetch(`http://${hostname}:${port}/faq`), ]); // All requests should succeed for (const response of responses) { expect(response.status).toBe(200); } // Verify content of all pages const texts = await Promise.all(responses.map(r => r.text())); const pages = ["Home", "About", "Contact", "Products", "Services", "Blog", "Team", "Careers", "FAQ"]; texts.forEach((text, i) => { expect(text).toContain(`${pages[i]} Page`); }); // Make another request and verify it's fast (plugins already loaded) const startTime = performance.now(); const secondHomeResponse = await fetch(`http://${hostname}:${port}/ooga`); const duration = performance.now() - startTime; expect(secondHomeResponse.status).toBe(200); expect(duration).toBeLessThan(500); // Should be much faster than initial plugin load subprocess.kill(); }); }); async function waitForServer( dir: string, entryPoints: Record, ): Promise<{ subprocess: Subprocess; port: number; hostname: string; }> { console.log("waitForServer", dir, entryPoints); let defer = Promise.withResolvers<{ subprocess: Subprocess; port: number; hostname: string; }>(); const process = Bun.spawn({ cmd: [bunExe(), join(import.meta.dir, "bun-serve-static-fixture.js")], env: { ...bunEnv, NODE_ENV: undefined, }, cwd: dir, stdio: ["inherit", "inherit", "inherit"], ipc(message, subprocess) { subprocess.send({ files: entryPoints, }); defer.resolve({ subprocess, port: message.port, hostname: message.hostname, }); }, }); return defer.promise; } test("serve html error handling", async () => { const dir = tempDirWithFiles("bun-serve-html-error-handling", { "index.html": /*html*/ ` Error Page

Error Page

`, "error.js": /*js*/ ` throw new Error("Error on purpose"); `, }); async function getServers() { const path = join(dir, "index.html"); const { default: html } = await import(path); let servers: Server[] = []; for (let i = 0; i < 10; i++) { servers.push( Bun.serve({ port: 0, static: { "/": html, }, development: true, fetch(req) { return new Response("Not found", { status: 404 }); }, }), ); } delete require.cache[path]; return servers; } { let servers = await getServers(); Bun.gc(); await Bun.sleep(1); for (const server of servers) { await server.stop(true); } servers = []; Bun.gc(); } Bun.gc(true); }); test("wildcard static routes", async () => { const dir = tempDirWithFiles("bun-serve-html-error-handling", { "index.html": /*html*/ ` Error Page

Error Page

`, "error.js": /*js*/ ` throw new Error("Error on purpose"); `, }); const { default: html } = await import(join(dir, "index.html")); for (let development of [true, false]) { using server = Bun.serve({ port: 0, static: { "/*": html, }, development, fetch(req) { return new Response("Not found", { status: 404 }); }, }); for (let url of [server.url, new URL("/potato", server.url)]) { const response = await fetch(url); expect(response.status).toBe(200); expect(response.headers.get("content-type")).toContain("text/html"); const text = await response.text(); expect(text).toContain("Error Page"); } } }); test("serve html with JSX runtime in development mode", async () => { const dir = join(import.meta.dir, "jsx-runtime"); const { default: html } = await import(join(dir, "index.html")); using server = Bun.serve({ port: 0, development: true, static: { "/": html, }, fetch(req) { return new Response("Not found", { status: 404 }); }, }); const response = await fetch(server.url); expect(response.status).toBe(200); const htmlText = await response.text(); const jsSrc = htmlText.match(/