Hedronite · Frontend Dev · TypeScript · 2026-10-27

Shipping a Vite React app with Bun Bun.serve routes, bun test, and one executable

Serve a Vite build and its JSON API from Bun.serve with honest 404s and the right caching, pin installs with a frozen bun.lock, test handlers and a real browser, and ship it all as one executable.

Lesson Class: Dev (TypeScript runtime · Bun)
Tested with: bun 1.4.4 (macOS) and 1.4.2 (Linux) · vite 8.3.5 · react 19.3.1 · typescript 7.0.2 · Chrome 156 · macOS + Linux
Lab: booklog · bun test (7 tests) · curl transcripts · Bun.WebView browser check · bun build --compile binary run from an empty folder
Source: Bun docs (server, routing, bun run, bunfig, install, lockfile, env, test, WebView, executables) · Programming TypeScript ch. 3 · JavaScript: The Definitive Guide §15.10.4 · Web Development with Node and Express, ch. 1 and 17
Exact Beats Wildcard
Bun.serve picks exact, then parameter, then wildcard, then /* routes, so the API, the hashed files and the app shell each get their own honest answer.
Freeze the Install
bun.lock records every version; bun install --frozen-lockfile turns drift into a failed build instead of a different app.
One File Out
bun build --compile --asset ./dist folds the runtime, the server and the built front end into a single executable.
Name every file you serve, and let everything else be a clear 404.

Versions: Bun 1.4.4 (macOS arm64) and 1.4.2 (Linux x64), Vite 8.3.5, @vitejs/plugin-react 6.1.2, React 19.3.1, TypeScript 7.0.2, @types/bun 1.4.4; the Linux browser check drove Google Chrome 156. Bun.WebView is marked experimental in the docs.

The problem

Booklog is a small reading-list app built with Vite and React. It is a single-page application (SPA): the server sends one HTML page and JavaScript in the browser draws everything else. From the user's perspective there are many pages, such as / and /books/2. The only difference from a classic site is whether each page is shipped from the server or rendered in the browser.

vite build produces a folder of static files, but static files are not a deployment. In production Booklog needs a server that does five things:

  1. Answer a JSON API under /api.
  2. Serve Vite's hashed files with caching headers that are correct.
  3. Send the app shell (the small index.html that starts the app) for client-side routes like /books/2, including on a fresh load or a refresh, when the browser asks the server for a path the server has no file for.
  4. Keep real 404s for missing files and unknown API paths. Otherwise a typo in a script name returns a web page with status 200 and hides the mistake.
  5. Ship as something easy to copy to a machine.

You could assemble that from Express, a test framework, a TypeScript runner, and a packaging tool. Booklog does it with one tool, Bun, plus Vite for the front-end build.

The idea in plain words

Bun is a JavaScript runtime (a program that executes your code, like Node) that also acts as a package manager, test runner, and bundler (a tool that combines source files into fewer output files). One tool therefore covers installing packages and recording them in bun.lock, running the server with Bun.serve, testing with bun test, and shipping with bun build --compile. Vite still builds the front end. Bun serves what Vite built, answers the API, tests both handlers and browser behavior, and can fold the runtime, the server code, and the built files into one executable file.

Concept A: Bun.serve as the shell for a Vite build

Definition

Bun.serve starts an HTTP server. Its routes option (needs Bun v1.2.3 or later) maps URL patterns to responses. A route value can be a static Response, a handler function, or an object with one handler per HTTP method, such as { GET: ... }. A wildcard is a pattern segment (*) that matches whatever remains of the path. A catch-all is the wildcard route /*, which matches any path nothing else matched.

Why

An SPA needs two kinds of answers from one server. Paths under /api/* go to your code, and everything else goes to static files. Ethan Brown's routing table for a Node/Express deployment draws the same split: /api/* goes to your server and /* goes to a static file store. Bun's route matching lets you write both halves in one object, and it picks the winner by specificity, so you do not depend on the order you wrote the entries. Bun checks, in this order: exact routes (), parameter routes (), wildcard routes (), then the global catch-all (/*).

Worked example

First, the data. It lives in its own file so the server and any future code share one list.

export type Book = { id: number; title: string; author: string };

export const books: Book[] = [
  { id: 1, title: "Programming TypeScript", author: "Boris Cherny" },
  { id: 2, title: "JavaScript: The Definitive Guide", author: "David Flanagan" },
  { id: 3, title: "Learning React", author: "Alex Banks and Eve Porcello" },
];

This file is plain TypeScript with no Bun-specific code, which keeps it trivial to test. The Book type is exported so handlers can use it. The server file imports books and returns it as JSON. A real app would read a database here, but a fixed array keeps every curl result on this page reproducible.

Now the server itself:

import { readdirSync } from "node:fs";
import { join } from "node:path";
import { books } from "./books";

export type Options = {
  distDir: string; // the folder `vite build` wrote
  appName: string;
  port?: number; // undefined: $BUN_PORT, $PORT, $NODE_PORT, else 3000
};

// One exact route per file in dist/assets. Vite fixes the hashed names
// at build time, and this also works inside a compiled executable,
// where a { dir } route cannot open the embedded folder.
function assetRoutes(distDir: string): Record<string, Response> {
  const routes: Record<string, Response> = {};
  for (const name of readdirSync(join(distDir, "assets"))) {
    routes[`/assets/${name}`] = new Response(
      Bun.file(join(distDir, "assets", name)),
      // A new build gets a new hash, so the old name never changes.
      { headers: { "cache-control": "public, max-age=31536000, immutable" } },
    );
  }
  return routes;
}

export function startServer({ distDir, appName, port }: Options) {
  const indexHtml = join(distDir, "index.html");

  return Bun.serve({
    port,
    hostname: "127.0.0.1",
    routes: {
      // 1. Exact API routes
      "/api/info": () => Response.json({ name: appName, bun: Bun.version }),
      "/api/books": { GET: () => Response.json(books) },
      // 2. Parameter route
      "/api/books/:id": {
        GET: (req) => {
          const book = books.find((b) => b.id === Number(req.params.id));
          return book
            ? Response.json(book)
            : Response.json({ error: "no such book" }, { status: 404 });
        },
      },
      // 3. Wildcards: unknown API paths stay JSON 404s
      "/api/*": Response.json({ error: "no such endpoint" }, { status: 404 }),
      // Vite's hashed bundles, then a real 404 for anything else there
      ...assetRoutes(distDir),
      "/assets/*": new Response("Not Found", { status: 404 }),
      // 4. Catch-all: client-side routes get the app shell
      "/*": (req) => {
        const last = new URL(req.url).pathname.split("/").pop() ?? "";
        if (last.includes(".")) {
          return new Response("Not Found", { status: 404 });
        }
        return new Response(Bun.file(indexHtml), {
          headers: {
            "content-type": "text/html;charset=utf-8",
            // Revalidate the shell so a deploy's new hashes are picked up.
            "cache-control": "no-cache",
          },
        });
      },
    },
    error(err) {
      console.error(err);
      return Response.json({ error: "internal" }, { status: 500 });
    },
  });
}

Read the routes object top to bottom, because it shows all four precedence levels. /api/info and /api/books are exact. /api/books/:id is a parameter route, and req.params.id holds the matched text. /api/* is a wildcard that catches every other API path. The entries from assetRoutes are exact, so they take priority over the /assets/* wildcard that follows. /* catches the rest.

Two kinds of values appear. Response.json(...) and new Response("Not Found", ...) are static responses: Bun caches them for the life of the server object, so they cost almost nothing per request. The /api/books entry uses { GET: ... }, a per-method handler. The lab sent a POST to that route and got {"error":"no such endpoint"} with status 404. The route has no POST handler, so the request fell through to /api/* and received the JSON 404. Nothing in that path produced a 405 (Method Not Allowed) response, so if you want one, add a POST handler that returns it.

new Response(Bun.file(path)) is a file route. Bun reads the file from disk on each request, uses the file's modification time for Last-Modified, and answers If-Modified-Since with 304 Not Modified when nothing changed. The lab captured this:

$ curl -sI http://127.0.0.1:8787/assets/index-BLGFL_JS.js
HTTP/1.1 200 OK
Date: Tue, 27 Oct 2026 20:32:16 GMT
Cache-Control: public, max-age=31536000, immutable
Content-Type: text/javascript;charset=utf-8
last-modified: Tue, 27 Oct 2026 20:32:15 GMT
content-length: 220507

$ curl -s -o /dev/null -w '%{http_code}\n' -H 'If-Modified-Since: Tue, 27 Oct 2026 20:32:15 GMT' http://127.0.0.1:8787/assets/index-BLGFL_JS.js
304
$ curl -sI http://127.0.0.1:8787/books/2
HTTP/1.1 200 OK
Content-Type: text/html;charset=utf-8
Cache-Control: no-cache
content-length: 241

The first request returned our Cache-Control header, a Content-Type of JavaScript chosen from the file name, and a last-modified header that Bun added on its own. The second request echoed that date back in If-Modified-Since and got 304 with no body. The third shows the shell for /books/2 with no-cache.

The two Cache-Control choices follow from the cache busting idea in Brown's Express book. A browser may keep a file for a long time, so when you change an asset, give it a new name. Vite puts a content hash in each file name (index-BLGFL_JS.js), meaning a string computed from the file's contents, so a new build produces a new name and the old name never changes. That makes max-age=31536000, immutable safe for those files. The shell has a fixed name, index.html, and it is the file that points at the current hashed names. Marking it no-cache makes browsers revalidate it so a new deploy is picked up. If you cached it for a year, users would keep loading the old shell, which points at old files.

The /* handler holds the rule that keeps 404s honest. It takes the last path segment, and if the segment contains a dot, it treats the request as a file request and returns 404. Otherwise it returns the shell. /books/2 ends in 2, so it gets HTML. /favicon.ico ends in favicon.ico, so it gets 404. The cost of the rule is that a client-side route whose last segment contains a dot, such as /books/1.5, would get a 404 as well.

The error function replaces Bun's default behavior. Per the docs, when a handler throws in development mode and no error handler returns a response, Bun shows a built-in page with the stack trace and source code. That is useful on your laptop but not something to show users. Ours logs the error and returns a JSON 500. The lab never triggered it, so you will not see it in the output.

Finally, main.ts starts the server and passes no port.

import { join } from "node:path";
import { startServer } from "./server/app";

// Next to this file when run with `bun main.ts`; inside the
// executable's embedded file system after `bun build --compile`.
const distDir = join(import.meta.dir, "dist");

if (!(await Bun.file(join(distDir, "index.html")).exists())) {
  console.error(`no ${distDir}/index.html: run \`bun run build\` first`);
  process.exit(1);
}

const server = startServer({
  distDir,
  appName: process.env.APP_NAME ?? "Booklog",
});

console.log(
  `booklog on ${server.url} (dist: ${distDir}, standalone: ${Bun.isStandaloneExecutable})`,
);

process.on("SIGTERM", async () => {
  await server.stop();
  process.exit(0);
});

import.meta.dir is the folder holding main.ts, so dist is found next to it no matter where you start the process from. The guard exits early with a fix-it message when the build is missing (see the pitfall "Starting before building"). The SIGTERM handler, which is the signal kill sends by default, calls server.stop(). By default stop() lets in-flight requests finish, and its promise resolves once every connection has closed, so a deploy does not cut a response in half. Bun.isStandaloneExecutable is false here and true in the compiled binary, which the log line prints.

With no port, Bun uses $BUN_PORT, then $PORT, then $NODE_PORT, and finally 3000. Bun also reads .env automatically, so this file in the project folder sets the port and the name:

# Loaded automatically by `bun main.ts` and by the compiled executable.
PORT=8787
APP_NAME=Booklog (from .env)

The start line in the log below shows port 8787 and the second response shows the name Booklog (from .env). Neither value is set anywhere in the code. Here is the macOS run from run-lab.sh (Bun 1.4.4). The server's start line is shortened to ... because it prints the full local path of the dist folder:

$ bun main.ts &
booklog on http://127.0.0.1:8787/ (dist: .../dist, standalone: false)
/api/info                    200 application/json;charset=utf-8  {"name":"Booklog (from .env)","bun":"1.4.4"}
/api/books/2                 200 application/json;charset=utf-8  {"id":2,"title":"JavaScript: The Definitive Guide","author":
/api/books/99                404 application/json;charset=utf-8  {"error":"no such book"}
/api/nope                    404 application/json;charset=utf-8  {"error":"no such endpoint"}
/books/2                     200 text/html;charset=utf-8  <!doctype html> <html lang="en">   <head>     <meta charset=
/assets/index-BLGFL_JS.js    200 text/javascript;charset=utf-8  var e=(e,t)=>()=>(t||(e((t={exports:{}}).exports,t,t.exports
/assets/missing.js           404 text/plain;charset=utf-8  Not Found
/favicon.ico                 404 text/plain;charset=utf-8  Not Found
POST /api/books              {"error":"no such endpoint"} 404

Every requirement from the problem statement shows up here. The API paths return JSON, and the unknown ones return JSON 404s. The client-side route /books/2 returns HTML. The hashed file returns JavaScript. The missing script and the favicon return plain 404s instead of HTML.

Common mistake

Expecting a 405 for a wrong method. If a route lists only GET, a POST does not stop at that route in this setup. It falls to the next matching route, here /api/*, and you get that route's answer. Spot it by sending a POST with curl -X POST and reading the body.

Concept B: scripts, bunfig.toml and a frozen bun.lock

Definition

A package.json script is a named command. bun run <script> runs it. bunfig.toml is Bun's own configuration file, and it is optional: Bun reads package.json and tsconfig.json where it can, and bunfig.toml holds only Bun-specific settings. A lockfile records the exact version of every installed package. Bun writes bun.lock, a text file, which should be committed to git.

Why

Scripts give every machine the same entry points, so "how do I build this?" has one answer: a name in package.json. The lockfile gives every machine the same dependencies. Without a lockfile, ^8.3.5 can resolve to different versions next month, and CI would then test something other than what you tested locally.

Worked example

{
  "name": "booklog",
  "private": true,
  "type": "module",
  "scripts": {
    "dev": "vite",
    "dev:api": "bun --watch main.ts",
    "build": "vite build",
    "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.server.json",
    "start": "bun main.ts",
    "compile": "bun build --compile main.ts --asset ./dist --outfile booklog"
  },
  "dependencies": {
    "react": "19.3.1",
    "react-dom": "19.3.1"
  },
  "devDependencies": {
    "@types/bun": "1.4.4",
    "@types/react": "19.3.0",
    "@types/react-dom": "19.3.0",
    "@vitejs/plugin-react": "6.1.2",
    "typescript": "7.0.2",
    "vite": "8.3.5"
  }
}

Every version is written without a caret, which is what install.exact does for new packages (see below). dev starts Vite's development server. dev:api runs the Bun server in watch mode so edits to server files take effect without a manual restart. build runs Vite for production. typecheck runs the TypeScript compiler twice, once per config file. start runs the server, and compile makes the executable. Note the names: build here means Vite, not Bun's bundler.

Bun lets you shorten bun run build to bun build, but if a built-in bun command has the same name, the built-in wins. bun build is Bun's bundler, so it asks for an entry file. The lab log shows the difference:

$ bun build
bun build v1.4.2 (744846f84)
error: Missing entrypoints. What would you like to bundle?

Usage:
  $ bun build <entrypoint> [...<entrypoints>] [...flags]  

To see full documentation:
  $ bun build --help
exit=1

$ bun run build
$ vite build
vite v8.3.5 building client environment for production...
transforming...
...

The first command failed because Bun treated build as its own command and found no entry file. The second used the explicit run, which always means "run the package script", and Vite started. Prefer bun run <script> in docs and CI for that reason.

One more detail: the Vite command file starts with a line saying it should run under Node. head -1 node_modules/vite/bin/vite.js printed #!/usr/bin/env node. That first line is a shebang, a marker telling the system which program should run the file. By default Bun respects it, so bun run build runs Vite under Node. The --bun flag overrides that, and bun run --bun build ran Vite on Bun's runtime. In the lab both paths printed the same dist lines (index-BLGFL_JS.js 220.50 kB). Use the default unless you have a reason to change it.

Next, the Bun settings file:

[install]
# Write "19.3.1", not "^19.3.1", when you `bun add` a package.
exact = true

[test]
# Only look for *.test.ts files under server/.
root = "./server"

install.exact defaults to false, in which case Bun writes caret ranges such as ^2.4.1. With true, bun add writes the exact version. The lab tried it in a scratch copy:

# scratch copy of package.json + bunfig.toml + bun.lock
$ bun add --lockfile-only hono
bun add v1.4.2 (744846f84)
Resolving dependencies
Resolved, downloaded and extracted [2]
Saved lockfile
Saved bun.lock (75 packages) [62.00ms]
$ grep hono package.json
    "hono": "4.13.14",

The grep line shows "hono": "4.13.14" with no caret, so the setting took effect. --lockfile-only updated bun.lock without installing files. The test.root setting defaults to . and tells bun test where to start looking, so here it searches only server/.

Now the lockfile in CI. Bun does not turn on --frozen-lockfile by itself, so pass the flag or use bun ci. With the flag, Bun installs exactly what the lockfile says and exits with an error if package.json disagrees. The passing case from the macOS run:

$ bun install --frozen-lockfile
[0.07ms] ".env"
bun install v1.4.4 (38afee6c8)

Checked 30 installs across 74 packages (no changes) [15.00ms]
exit=0

"no changes" means the lockfile and package.json agreed. The first line, [0.07ms] ".env", is Bun reporting that it loaded .env before the install started. The lockfile on both machines was written once by Bun 1.4.2 on Linux, and Bun 1.4.4 on macOS accepted it unchanged. Not every edit breaks the freeze. Edits that keep each package at its locked version, such as changing ^1.0.0 to >=1.0.0, moving a dependency to another group, or editing scripts, still pass. Changing a version to something the lockfile does not hold fails (see the pitfall "Frozen lockfile rejects a version edit").

The project has two TypeScript configs because it has two environments:

{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2022", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "moduleResolution": "bundler",
    "jsx": "react-jsx",
    "strict": true,
    "isolatedModules": true,
    "noEmit": true,
    "skipLibCheck": true,
    "types": ["vite/client"]
  },
  "include": ["src"]
}

This is the browser config. It covers only src, includes the DOM library (so document, history, and location type-check), and loads Vite's client types. jsx: "react-jsx" lets you write JSX without importing React in every file, and noEmit means tsc only checks, because Vite produces the output.

{
  "compilerOptions": {
    "target": "ESNext",
    "lib": ["ESNext"],
    "module": "Preserve",
    "moduleResolution": "bundler",
    "strict": true,
    "noEmit": true,
    "skipLibCheck": true,
    "types": ["bun"]
  },
  "include": ["main.ts", "server", "smoke.ts"]
}

This is the server config. It covers main.ts, server/ and the browser check smoke.ts (which runs on Bun, not in the page), has no DOM library, and loads the Bun types, which is where Bun.serve and Bun.file come from. Splitting them means the browser code cannot call Bun.file without a type error, and the server code cannot reach for document. The typecheck script runs tsc once per file.

Vite's config is short:

import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  server: {
    // In development, Vite serves the UI on :5173 and forwards /api
    // to the Bun server started with `bun run dev:api`.
    proxy: { "/api": "http://127.0.0.1:8787" },
  },
});

The React plugin teaches Vite to handle JSX. The proxy line matters only in development: the browser talks to Vite on port 5173, and Vite forwards /api requests to the Bun server on 8787. The React code then calls fetch("/api/books") with a relative URL in both dev and production. With two terminals running (bun run dev:api and bun run dev), the lab got the following:

# terminal 1: bun run dev:api    terminal 2: bun run dev
$ curl -s http://localhost:5173/api/info
{"name":"Booklog (from .env)","bun":"1.4.2"}
$ curl -s http://localhost:5173/books/1 | head -5
<!doctype html>
<html lang="en">
  <head>
    <script type="module">import { injectIntoGlobalHook } from "/@react-refresh";

The first request went to Vite's port but the JSON came from the Bun server, so the proxy works. The second shows Vite answering a client-side route with the shell (plus the hot-reload code it injects in development). In production, our /* route plays that part.

The front end itself has three files. index.html is the app shell:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>Booklog</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

It holds an empty div and one script tag. When Vite builds, it replaces the script tag with one in the <head> that points at the hashed file, <script type="module" crossorigin src="/assets/index-BLGFL_JS.js"></script>. Before the script runs, the page is empty. That is why dist/index.html is only 0.24 kB in the build output.

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";

createRoot(document.getElementById("root")!).render(
  <StrictMode>
    <App />
  </StrictMode>,
);

main.tsx finds the root element and renders App into it. The ! tells TypeScript the element exists, which is true because index.html defines it. StrictMode wraps the app in React's extra development checks.

import { useEffect, useState } from "react";

type Book = { id: number; title: string; author: string };
type Info = { name: string; bun: string };

// Client-side routing with the History API: "/" is the list,
// "/books/:id" is one book. The server never sees these clicks.
function usePath(): [string, (to: string) => void] {
  const [path, setPath] = useState(location.pathname);
  useEffect(() => {
    const onPop = () => setPath(location.pathname);
    addEventListener("popstate", onPop);
    return () => removeEventListener("popstate", onPop);
  }, []);
  const go = (to: string) => {
    history.pushState(null, "", to);
    setPath(to);
  };
  return [path, go];
}

export function App() {
  const [path, go] = usePath();
  const [books, setBooks] = useState<Book[]>([]);
  const [info, setInfo] = useState<Info | null>(null);

  useEffect(() => {
    fetch("/api/books").then((r) => r.json()).then(setBooks);
    fetch("/api/info").then((r) => r.json()).then(setInfo);
  }, []);

  const match = path.match(/^\/books\/(\d+)$/);
  const book = match ? books.find((b) => b.id === Number(match[1])) : undefined;

  return (
    <main>
      <h1>{info ? info.name : "Booklog"}</h1>
      {match ? (
        <section>
          <p id="detail">{book ? `${book.title} by ${book.author}` : "Loading…"}</p>
          <button onClick={() => go("/")}>Back</button>
        </section>
      ) : (
        <ul>
          {books.map((b) => (
            <li key={b.id}>
              <a
                href={`/books/${b.id}`}
                onClick={(e) => {
                  e.preventDefault();
                  go(`/books/${b.id}`);
                }}
              >
                {b.title}
              </a>
            </li>
          ))}
        </ul>
      )}
      <footer>{info ? `served by Bun ${info.bun}` : ""}</footer>
    </main>
  );
}

A client-side route is a URL path handled by JavaScript in the browser, without asking the server for a new page. The usePath hook implements it with the History API. As David Flanagan describes it, when a web app enters a new state it calls history.pushState() to add that state to the browser history. If the user then clicks Back, the browser fires a popstate event. Applications can also save a URL with each state, which matters if you want users to bookmark and share links to the app's internal states. In the code, go calls pushState and updates React state, and the popstate listener catches Back and Forward. Each link has a real href, so a copied link or a refresh asks the server for /books/2 itself. Our /* route answers that with the shell, then the script reads location.pathname and shows the right view. Without the server fallback, those fresh loads would fail even though in-app clicks worked.

Common mistake

Running bun build when you meant bun run build, and then wondering why it asks for entry points. Spot it by the words Missing entrypoints (see the pitfall "bun build instead of bun run build").

Concept C: testing the handlers with bun test, then a real browser

Definition

bun test is Bun's built-in, Jest-compatible test runner. You import test, expect, describe, beforeAll, and afterAll from bun:test. It searches the working directory recursively for files matching *.test.ts, *_test.ts, *.spec.ts, or *_spec.ts (and the JavaScript variants); our test.root narrows the search to server/. Bun.WebView is a headless browser (one with no visible window) built into the runtime. It is marked experimental in the docs and may change in future releases.

Why

Handler tests are fast and need no browser, so run them on every change. But they cannot tell you whether the built React app actually works against the server, so a short browser check covers that gap.

Worked example

import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { startServer } from "./app";

// A fake dist/ so the tests do not depend on `vite build`.
const dist = mkdtempSync(join(tmpdir(), "booklog-dist-"));
mkdirSync(join(dist, "assets"));
writeFileSync(join(dist, "index.html"), '<div id="root"></div>');
writeFileSync(join(dist, "assets", "index-abc123.js"), "console.log(1)");

let server: ReturnType<typeof startServer>;
const get = (path: string, init?: RequestInit) =>
  fetch(new URL(path, server.url), init);

beforeAll(() => {
  server = startServer({ distDir: dist, appName: "Test shelf", port: 0 });
});
afterAll(async () => {
  await server.stop();
  rmSync(dist, { recursive: true });
});

describe("API", () => {
  test("lists books as JSON", async () => {
    const res = await get("/api/books");
    expect(res.status).toBe(200);
    expect(res.headers.get("content-type")).toContain("application/json");
    const list = (await res.json()) as unknown[];
    expect(list.length).toBe(3);
  });

  test("route parameter picks one book", async () => {
    const res = await get("/api/books/2");
    expect(await res.json()).toMatchObject({ author: "David Flanagan" });
  });

  test("unknown book and unknown endpoint are JSON 404s", async () => {
    expect((await get("/api/books/99")).status).toBe(404);
    const res = await get("/api/nope");
    expect(res.status).toBe(404);
    expect(await res.json()).toEqual({ error: "no such endpoint" });
  });
});

describe("SPA shell", () => {
  test("client-side route falls back to index.html", async () => {
    const res = await get("/books/2");
    expect(res.status).toBe(200);
    expect(res.headers.get("cache-control")).toBe("no-cache");
    expect(await res.text()).toContain('<div id="root">');
  });

  test("hashed asset is served with a JavaScript type", async () => {
    const res = await get("/assets/index-abc123.js");
    expect(res.status).toBe(200);
    expect(res.headers.get("content-type")).toContain("javascript");
    expect(res.headers.get("cache-control")).toContain("immutable");
  });

  test("missing files are 404, not the app shell", async () => {
    expect((await get("/assets/missing.js")).status).toBe(404);
    expect((await get("/favicon.ico")).status).toBe(404);
  });

  test("a dot-dot asset path is a 404", async () => {
    const res = await get("/assets/%2e%2e/index.html");
    expect(res.status).toBe(404);
  });
});

The test file builds a fake dist/ folder in a temporary directory, with one HTML file and one asset. Tests therefore pass on a fresh checkout where nobody has run vite build, and they test the server's behavior rather than Vite's output. Setting port: 0 asks for a random free port, so tests never collide with a running dev server. Because the port is random, the tests read server.url. That value is a URL whose href ends with /, so ${server.url}/users would produce http://localhost:3000//users with two slashes. Resolve paths against it instead, as the get helper does with new URL(path, server.url). afterAll awaits server.stop() and removes the temp folder, so nothing is left listening or on disk.

The assertions mirror the problem statement: JSON content type, a route parameter, JSON 404s, the shell with no-cache, an immutable JavaScript asset, and plain 404s for missing files. The macOS result:

$ bun test
bun test v1.4.4 (38afee6c8)

server/app.test.ts:
(pass) API > lists books as JSON [0.97ms]
(pass) API > route parameter picks one book [0.24ms]
(pass) API > unknown book and unknown endpoint are JSON 404s [0.28ms]
(pass) SPA shell > client-side route falls back to index.html [0.21ms]
(pass) SPA shell > hashed asset is served with a JavaScript type [0.17ms]
(pass) SPA shell > missing files are 404, not the app shell [0.21ms]
(pass) SPA shell > a dot-dot asset path is a 404 [0.11ms]

 7 pass
 0 fail
 16 expect() calls
Ran 7 tests across 1 file. [10.00ms]
exit=0

All seven tests ran in about ten milliseconds, because no browser or build is involved. The Linux run (1.4.2) printed the same seven passes and 16 expect() calls.

The last test deserves care. The fetch call builds its URL with new URL, which rewrites %2e%2e to a plain path before the request is sent. In a Bun one-liner, new URL("/assets/%2e%2e/index.html","http://x/").pathname printed /index.html. So that test actually requests /index.html and passes because of the dot rule in the /* handler. To send the raw path, the lab used curl --path-as-is against bun main.ts (Linux, 1.4.2):

/assets/%2e%2e/index.html -> 404 Not Found
/assets/../../etc/passwd -> 404 Not Found
/index.html -> 404 Not Found

All three came back as 404. This fits how the server is built: only the exact file names found at startup are routes, and the request path is never joined to a disk path.

For the browser check, smoke.ts drives a real page:

// Browser check of a running Booklog server.
// Usage: bun smoke.ts http://127.0.0.1:8787/
// Bun.WebView is experimental: WebKit on macOS, installed Chrome elsewhere.
export {}; // a module, so top-level await type-checks
const base = process.argv[2] ?? "http://127.0.0.1:8787/";
// view.url only changes on real navigations, so read the History API path.
const path = () => view.evaluate("location.pathname");
const text = (sel: string) =>
  view.evaluate(`document.querySelector(${JSON.stringify(sel)})?.textContent`);
// The page renders before /api/books and /api/info answer, so poll an
// expression until it is truthy instead of reading the page too early.
async function until(expr: string, ms = 5000) {
  const end = Date.now() + ms;
  while (!(await view.evaluate(`Boolean(${expr})`))) {
    if (Date.now() > end) throw new Error(`timed out waiting for ${expr}`);
    await Bun.sleep(50);
  }
}

await using view = new Bun.WebView({ width: 800, height: 600 });

// 1. Home page: the list arrives from /api/books after first render.
await view.navigate(base);
await view.click("li:nth-child(2) a"); // waits until the link is actionable
console.log("after click:", await path(), "|", await text("#detail"));

// 2. Deep link: a fresh load of /books/3 must reach the server's fallback.
await view.navigate(new URL("/books/3", base).href);
await until(`document.querySelector("#detail")?.textContent.includes(" by ")`);
await until(`document.querySelector("footer")?.textContent`);
await view.click("button"); // "Back" to the list
console.log("deep link then Back:", await path(), "|", await text("li"));
console.log("footer:", await text("footer"));

The export {} line looks odd but is needed: TypeScript only allows top-level await in a module, and a file with no import or export is treated as a plain script (see the pitfall "TS1375 on top-level await in a script file"). The until helper polls an expression in the page every 50 ms until it is true, or throws after five seconds. On macOS, Bun.WebView uses the system's WKWebView, so nothing needs installing. On Linux it drives an installed Chrome, Chromium, Edge, or Brave over the Chrome DevTools Protocol. await using closes the view automatically when the script ends. navigate resolves when the page's load event fires. click with a CSS selector waits until the element is actionable: it exists, has a non-zero size, sits in the viewport, has stopped moving for two frames, and is the topmost element. The default timeout is 30000 milliseconds. That wait covers the first click, because the book list only appears after the /api/books response arrives. It does not cover the deep link: on /books/3 the Back button exists at once, while the detail still reads Loading…, so the click succeeds before any data has arrived. That is why the script calls until on the detail text and the footer before clicking. evaluate runs a JavaScript expression in the page and awaits it if it returns a promise. Clicking a link calls pushState, which changes the address without a real navigation, so view.url would stay stale. The script reads location.pathname through evaluate instead.

The first half proves in-app routing. The second half loads /books/3 as a fresh page, which only works if the server's /* fallback sends the shell, and then clicks Back. The macOS run (Bun 1.4.4):

$ bun smoke.ts http://127.0.0.1:8787/
after click: /books/2 | JavaScript: The Definitive Guide by David Flanagan
deep link then Back: / | Programming TypeScript
footer: served by Bun 1.4.4
exit=0

The path changed to /books/2, the detail text is the second book, Back returned to /, and the footer shows the Bun version that served the API. The Linux run (Bun 1.4.2, driving Chrome) printed the same three lines with served by Bun 1.4.2. An earlier version of the script, without until, passed on macOS but printed deep link then Back: / | undefined and an empty footer on Linux. It had clicked Back and read the page before the two fetches finished (see the pitfall "The browser check reads the page too early").

Common mistake

Building a test URL with a template string, such as ${server.url}/api/books. You get a double slash and the route may not match as you expect. Spot it by printing the URL. Use new URL(path, server.url).

Putting it together: one executable

Definition

A standalone executable is a single file you can run without installing Bun on the target machine. bun build --compile makes one from a TypeScript or JavaScript file. Bun combines every imported file and package into the executable and adds a copy of the Bun runtime. An embedded file system is the read-only set of files stored inside the executable, which your code reads with normal file calls.

Why

Copying one file is easier than copying a source tree, a node_modules folder, and a matching runtime version. The catch is that Booklog also needs dist/, the folder Vite built, and it is not imported by any code. The --asset flag handles that. It embeds a file or directory tree in the executable under its original relative path. At runtime the embedded files live under import.meta.dir and are reachable through node:fs (existsSync, statSync, readdirSync, readFileSync) and Bun.file(). Bun embeds only regular files, and it skips symlinks and empty subdirectories.

Worked example

The command is the compile script from package.json:

$ bun run compile
$ bun build --compile main.ts --asset ./dist --outfile booklog
   [2ms]  bundle  3 modules
 [163ms] compile  booklog
exit=0
-rwxr-xr-x@ 1 evanpincham  staff  89374562 Oct 27 16:40 booklog

Bun combined three modules (main.ts, server/app.ts, server/books.ts) in 2 ms and spent 163 ms writing booklog. The file is 89,374,562 bytes on macOS arm64, and the Linux x64 build is 81,536,480 bytes. The files are large because a copy of the runtime is inside.

The embedded files get an internal path starting with /$bunfs/. That is why main.ts prints dist: /$bunfs/root/dist, standalone: true when it runs from the binary. run-lab.sh ties the whole pipeline together:

#!/usr/bin/env bash
# Full Booklog pipeline. Usage: bash run-lab.sh   (needs bun and curl;
# the browser check needs Chrome on Linux, nothing extra on macOS)
set -u
cd "$(dirname "$0")"
step() { echo; echo "\$ $*"; "$@"; echo "exit=$?"; }
get() { curl -s -o /tmp/booklog-body -w "%{http_code} %{content_type}" "$1"; echo "  $(head -c 60 /tmp/booklog-body | tr "\n" " ")"; }
# Poll until the server answers (up to 10 s) instead of guessing with sleep.
wait_up() { for _ in $(seq 100); do curl -s -o /dev/null "$1" && return 0; sleep 0.1; done; echo "not up: $1"; }

bun --version
step bun install --frozen-lockfile
step bun test
step bun run typecheck
step bun run build

echo; echo "\$ bun main.ts &"
bun main.ts & SERVER=$!
wait_up http://127.0.0.1:8787/api/info
ASSET=$(cd dist/assets && ls *.js)
for p in /api/info /api/books/2 /api/books/99 /api/nope /books/2 "/assets/$ASSET" /assets/missing.js /favicon.ico; do
  printf '%-28s ' "$p"; get "http://127.0.0.1:8787$p"
done
printf '%-28s ' "POST /api/books"; curl -s -X POST -w " %{http_code}\n" http://127.0.0.1:8787/api/books
step bun smoke.ts http://127.0.0.1:8787/
kill "$SERVER"; wait "$SERVER" 2>/dev/null

step bun run compile
ls -l booklog
DEPLOY=$(mktemp -d)
cp booklog "$DEPLOY/"
echo; echo "\$ (cd \$DEPLOY && ./booklog &)   # empty folder: no dist/, no .env"
(cd "$DEPLOY" && exec ./booklog) & BIN=$!
wait_up http://127.0.0.1:3000/api/info
for p in /api/info /books/3 "/assets/$ASSET" /assets/missing.js; do
  printf '%-28s ' "$p"; get "http://127.0.0.1:3000$p"
done
kill "$BIN"; wait "$BIN" 2>/dev/null
rm -rf "$DEPLOY"

The step helper prints each command, runs it, and prints its exit code, so a failure is visible without set -e stopping the script early. The get helper prints the status, content type, and the first 60 characters of the body. The wait_up helper polls /api/info every 0.1 s, for up to 10 s, until the server answers; an earlier sleep 1 was not always long enough (see the pitfall "The server is not listening yet"). The script runs the checks in the order a CI job would: frozen install, tests, typecheck, build. It then starts the server in the background, probes it, runs the browser check, and kills the server. The final part compiles the binary, copies it to a fresh temporary folder, and runs it there. The temp folder has no dist/ and no .env, which proves the binary needs neither. The macOS output (the server no longer reads .env, so the port falls back to 3000):

$ (cd $DEPLOY && ./booklog &)   # empty folder: no dist/, no .env
booklog on http://127.0.0.1:3000/ (dist: /$bunfs/root/dist, standalone: true)
/api/info                    200 application/json;charset=utf-8  {"name":"Booklog","bun":"1.4.4"}
/books/3                     200 text/html;charset=utf-8  <!doctype html> <html lang="en">   <head>     <meta charset=
/assets/index-BLGFL_JS.js    200 text/javascript;charset=utf-8  var e=(e,t)=>()=>(t||(e((t={exports:{}}).exports,t,t.exports
/assets/missing.js           404 text/plain;charset=utf-8  Not Found

Every behavior from the source-tree run is still there, now from inside the executable: JSON from /api/info, the shell for /books/3, a hashed asset with a JavaScript type, and a 404 for a missing asset. The name is plain Booklog and the port is 3000 because no .env was present.

Tsconfig and package.json loading is disabled in compiled executables, but .env and bunfig.toml loading stays enabled, because those files often hold runtime configuration that varies per deployment. So a .env in the folder where you run the binary changes its behavior. The lab put one in a deploy folder, then rebuilt with the flag that turns this off:

# a deploy folder that happens to contain a .env
$ cat .env
PORT=9100
APP_NAME=Booklog (deploy .env)
$ ./booklog &
booklog on http://127.0.0.1:9100/ (dist: /$bunfs/root/dist, standalone: true)
$ curl -s http://127.0.0.1:9100/api/info
{"name":"Booklog (deploy .env)","bun":"1.4.2"}

# rebuilt with: bun build --compile --no-compile-autoload-dotenv main.ts --asset ./dist --outfile booklog
$ ./booklog &
booklog on http://127.0.0.1:3000/ (dist: /$bunfs/root/dist, standalone: true)
$ curl -s http://127.0.0.1:3000/api/info
{"name":"Booklog","bun":"1.4.2"}

With the default build, the stray .env moved the port to 9100 and renamed the app. With --no-compile-autoload-dotenv, the same folder gave port 3000 and the plain name. The docs say a future version of Bun may disable .env and bunfig.toml loading by default, so do not build a deploy plan that depends on either behavior.

Common mistake

Running the binary from a folder that happens to contain a .env and not noticing the port and name changed. Spot it by reading the first line the server prints (see the pitfall "A .env in the deploy folder changes the binary").

Pitfalls

Directory route fails in the compiled binary

Symptom:

ENOENT: no such file or directory, open '/$bunfs/root/dist/assets'
    path: "/$bunfs/root/dist/assets",
 syscall: "open",
   errno: -2,
    code: "ENOENT"

This appeared on Bun 1.4.2 (Linux x64) and on 1.4.4 (macOS arm64). The earlier version of server/app.ts served assets with this route line: "/assets/*": { dir: join(distDir, "assets") },. Under bun main.ts it worked. The compiled binary died at startup.

Cause: Directory routes ({ dir }, with a path ending in /*) open a real directory. The docs describe openat2 on Linux. Embedded files live in the executable's internal file system, which readdirSync and Bun.file can read, but the directory route could not open it in these versions.

Fix: Enumerate dist/assets at startup into exact file routes, as assetRoutes in server/app.ts does, and add an /assets/* route that returns 404 for everything else.

bun build instead of bun run build

Symptom:

error: Missing entrypoints. What would you like to bundle?

Cause: bun build is Bun's built-in bundler. A built-in command beats a package script with the same name.

Fix: Use bun run build to run the Vite script.

Frozen lockfile rejects a version edit

Symptom:

error: lockfile had changes, but lockfile is frozen
note: try re-running without --frozen-lockfile and commit the updated lockfile

The lab edited "vite": "8.3.5" to "8.3.4" in package.json and left bun.lock alone.

Cause: The new version is not what the lockfile holds, so the frozen install refuses to guess.

Fix: Run bun install without the flag, then commit both package.json and the updated bun.lock.

TS2571 on res.json()

Symptom:

server/app.test.ts(30,12): error TS2571: Object is of type 'unknown'.

The line was (await res.json()).length.

Cause: Bun's types return unknown from Response.json(). As Boris Cherny explains, unknown represents any value, but TypeScript will not let you use it until you refine it by checking what it is.

Fix: Narrow or assert the type before using it. The test uses an assertion:

const list = (await res.json()) as unknown[];

A .env in the deploy folder changes the binary

Symptom:

booklog on http://127.0.0.1:9100/ (dist: /$bunfs/root/dist, standalone: true)
{"name":"Booklog (deploy .env)","bun":"1.4.2"}

Cause: Compiled executables still auto-load .env and bunfig.toml from the folder you run them in.

Fix: Keep unrelated .env files out of the deploy folder, or compile with --no-compile-autoload-dotenv.

EADDRINUSE on a second copy

Symptom:

error: Failed to start server. Is port 8787 in use?
 syscall: "listen",
   errno: 0,
    code: "EADDRINUSE"

Cause: A first copy is still listening on 8787 (set by .env), and a second process asked for the same port.

Fix: Stop the first copy before starting another, or give the second copy a different PORT in the .env it reads.

Starting before building

Symptom:

$ bun main.ts   # in a fresh checkout before any `bun run build`
no /tmp/booklog/dist/index.html: run `bun run build` first
exit=1

Cause: dist/ does not exist yet, because nobody has run vite build.

Fix: Run bun run build. Our check in main.ts is deliberate: failing at startup with the fix in the message is better than a server that starts and then returns confusing 404s or a bare file-system error, because you would debug the wrong layer.

The browser check reads the page too early

Symptom: on Linux (Bun 1.4.2, Chrome), an earlier smoke.ts without the until helper exited 0 but printed:

$ bun smoke.ts http://127.0.0.1:8787/
after click: /books/2 | JavaScript: The Definitive Guide by David Flanagan
deep link then Back: / | undefined
footer: 
exit=0

The same script printed the full text on macOS, so the bug hid behind a faster run.

Cause: click waits for the Back button to be actionable, and on a fresh load of /books/3 the button is there before /api/books and /api/info have answered. The script clicked Back and read the list and footer while React state was still empty.

Fix: wait for the data, not the element. until polls the detail text and the footer before the click. A check that prints undefined and still exits 0 is also a reason to assert with an exit code rather than only print, once the check grows.

TS1375 on top-level await in a script file

Symptom: after smoke.ts was added to tsconfig.server.json, bun run typecheck failed (first lines shown):

$ tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.server.json
smoke.ts(19,1): error TS2853: 'await using' statements are only allowed at the top level of a file when that file is a module, but this file has no imports or exports. Consider adding an empty 'export {}' to make this file a module.
smoke.ts(22,1): error TS1375: 'await' expressions are only allowed at the top level of a file when that file is a module, but this file has no imports or exports. Consider adding an empty 'export {}' to make this file a module.
smoke.ts(23,1): error TS1375: 'await' expressions are only allowed at the top level of a file when that file is a module, but this file has no imports or exports. Consider adding an empty 'export {}' to make this file a module.

bun smoke.ts itself ran fine; only tsc objected.

Cause: smoke.ts had no import or export, so TypeScript treated it as a script, and top-level await (including await using) is only allowed in modules.

Fix: add export {}; near the top, as the final smoke.ts does. The error message suggests exactly this.

The server is not listening yet

Symptom: one macOS run of the pipeline, when run-lab.sh still used sleep 1 before probing the freshly started binary:

$ (cd $DEPLOY && ./booklog &)   # empty folder: no dist/, no .env
/api/info                    000   Not Found
/books/3                     booklog on http://127.0.0.1:3000/ (dist: /$bunfs/root/dist, standalone: true)

Status 000 is curl reporting that no HTTP response arrived, and the server's start line appears only after the first probe. The Not Found text is stale: get printed the body file left over from the previous request.

Cause: a fixed sleep guesses how long startup takes. On that run the compiled binary was not listening one second after launch; the runs before and after were fast enough.

Fix: poll until the server answers, as wait_up in run-lab.sh does. It prints not up: and the URL if ten seconds pass without an answer, so a slow start is visible in the log instead of showing up as confusing probe results.

Run it yourself

Create the files from this lesson. Then run bun install once (without the frozen flag) to write bun.lock, and commit it. From there, follow the order in run-lab.sh:

  1. bun --version
  2. bun install --frozen-lockfile
  3. bun test
  4. bun run typecheck
  5. bun run build
  6. bun main.ts (port 8787 from .env), then probe it with curl
  7. bun smoke.ts http://127.0.0.1:8787/ (on Linux this needs Chrome installed)
  8. bun run compile, copy booklog to an empty folder, and run it there (port 3000)

Or run all of it with bash run-lab.sh.

For development, use two terminals. In the first, run bun run dev:api. In the second, run bun run dev, then open http://localhost:5173. The API server uses the same main.ts, which exits if dist/index.html is missing, so run bun run build once before starting it.

Check yourself

**In what order does Bun.serve pick among exact, parameter, wildcard, and catch-all routes, and where did our POST to /api/books end up?**

It prefers exact routes, then parameter routes, then wildcard routes, then the global catch-all /*. The /api/books route had only a GET handler, so the POST fell through to the /api/* wildcard and received the JSON 404 {"error":"no such endpoint"}. Bun did not send a 405.

**Why does the server give hashed asset files immutable caching but mark index.html as no-cache?**

The hash in a file name changes whenever the content changes, so a given name never changes and can be cached for a year. index.html keeps the same name but points at the current hashed names. Revalidating it means a new deploy reaches users.

**What is the difference between bun build and bun run build in this project?**

bun build is Bun's built-in bundler and fails with Missing entrypoints when given no file. bun run build runs the package script, which is vite build. A built-in command beats a script with the same name, so the explicit run is needed.

**Why does the test file use port: 0 and new URL(path, server.url)?**

port: 0 selects a random free port, so tests never collide with another server. server.url ends with a slash, so a template string such as ${server.url}/users yields a double slash. Resolving the path against the URL avoids that.

What did the compiled binary contain, and what could still change its behavior at runtime?

It contains a copy of the Bun runtime, the combined server code, and the embedded dist/ folder under /$bunfs/root/dist. A .env or bunfig.toml in the folder where you run it is still auto-loaded, which can change the port and name. Compile with --no-compile-autoload-dotenv to stop that for .env.

Sources

Bun documentation:

Books: