Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
3b1329e
move the html entry's server startup into internal/serve_cli
alii Oct 17, 2026
4871341
add bun bake: serve app/pages and app/server.ts with the dev server
alii Oct 17, 2026
5b4faf1
[autofix.ci] apply automated fixes
autofix-ci[bot] Oct 17, 2026
145b616
bun bake: type check with --check, take --flag value, reject clashing…
alii Oct 17, 2026
1bd28b5
bun bake: encode page urls, and let a trailing --check turn --hot int…
alii Oct 17, 2026
bbde9d6
bun bake: encode page urls as browsers do, check routes and websocket…
alii Oct 17, 2026
f0cd83e
bun bake: name ] in the page path error and docs
alii Oct 17, 2026
2c75e85
bun bake: follow symlinks in app/pages
alii Oct 17, 2026
a8d4359
load what a built-in entry point imports as the imports of a main module
alii Oct 17, 2026
b79abff
bun bake: run with no entry module instead of a placeholder main path
alii Oct 17, 2026
caa5c95
make the vm's main optional, so bun bake has no entry module rather t…
alii Oct 17, 2026
0c21b43
document bun bake --watch and --hot
alii Oct 17, 2026
ea4a624
point the restart note in the bun bake docs at --watch and --hot
alii Oct 17, 2026
313d9ee
bun bake: --check after a --hot that follows bake also runs as --watch
alii Oct 17, 2026
de4b528
bun bake: keep --no-clear-screen and --watch-kill-signal given before…
alii Oct 17, 2026
4a3b69f
document that Bun.main is empty when no script launched bun
alii Oct 17, 2026
1e973fb
move the bun bake docs to their own pr
alii Oct 17, 2026
819e52d
bun bake: one server across --hot reloads, no empty host, apply watch…
alii Oct 17, 2026
45fa4f6
bun bake: pass the app/server.ts export to Bun.serve as bun app.ts do…
alii Oct 18, 2026
f90d995
bun bake: define its options on a frozen export, and keep a unix socket
alii Oct 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 79 additions & 0 deletions docs/bundler/bake.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
---
title: "bun bake"
description: "Serve an app from the pages in app/pages and an optional app/server.ts, with hot reloading"
---

<Warning>
`bun bake` is experimental. It is on in canary builds. In other builds, set the environment variable
`BUN_FEATURE_FLAG_EXPERIMENTAL_BAKE=1`.
</Warning>

`bun bake` serves the app in the working directory with Bun's dev server. It reads two places:

- `app/pages/`: the HTML pages.
- `app/server.ts`: your server code. This file is optional.

```txt File Tree icon="folder-tree"
app/
pages/
index.html → /
about.html → /about
docs/
index.html → /docs
install.html → /docs/install
server.ts
```

```bash terminal icon="terminal"
bun bake
```

Each page is bundled with its scripts and stylesheets, the same as an [HTML import](/bundler/fullstack). Edits to pages reload in the browser. After you change `app/server.ts`, restart `bun bake`.

`bun bake` always runs the dev server, also with `NODE_ENV=production`. It is not for production.

## Pages

Every `.html` file in `app/pages/` is a page. Its path is its URL:

- `index.html` is the URL of its folder: `app/pages/docs/index.html` is `/docs`.
- Any other file drops the `.html`: `app/pages/docs/install.html` is `/docs/install`.

A page cannot have `[`, `:`, `*`, `?`, `#` or `%` in its path, because its URL must be fixed. Other characters are allowed: `app/pages/über.html` is `/über`, and Bun serves it at `/%C3%BCber`, the URL that browsers request.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

## Server code

The default export of `app/server.ts` can have three options of [`Bun.serve()`](/runtime/http/server):

- `routes`: more routes. A route cannot have the URL of a page.
- `fetch`: handles each request that no page and no route matched.
- `websocket`: the WebSocket handlers.

```ts title="app/server.ts" icon="/icons/typescript.svg"
export default {
routes: {
"/api/hello": () => Response.json({ message: "Hello" }),
},
fetch(req) {
return new Response("Not found", { status: 404 });
},
};
```

`bun bake` starts the server. Do not call `Bun.serve()` in `app/server.ts`. Without a `fetch`, a request that no page and no route matched gets a 404.

## Options

| Flag | Description |
| ---------------------------------- | -------------------------------------------------------------------------- |
| `--port <NUM>` | The port. The default is `$PORT`, or 3000. |
| `--host <STR>`, `--hostname <STR>` | The hostname. The default is `localhost`. `host:3000` also sets the port. |
| `--console` | Print the browser's console logs. |
| `--no-console` | Do not print the browser's console logs. |
| `--check` | Type check the project first. With type errors, `bun bake` does not start. |

A flag and its value can also be one argument: `--port=3000`. `bun --check bake` is the same as `bun bake --check`. With `--watch`, `bun --check --watch bake` type checks again after each change, and starts the server when there are no errors.

## A `bake` script or file of your own

When `package.json` has a `bake` script, `bun bake` runs that script. A file named `bake.ts` (or `bake.js`, and so on) also takes precedence. To start the dev server in that project, rename the script or the file.
2 changes: 1 addition & 1 deletion docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@
{
"group": "Development Server",
"icon": "monitor",
"pages": ["/bundler/fullstack", "/bundler/hot-reloading"]
"pages": ["/bundler/fullstack", "/bundler/bake", "/bundler/hot-reloading"]
},
{
"group": "Asset Processing",
Expand Down
206 changes: 206 additions & 0 deletions src/js/internal/bake.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
// This is the file that loads for `bun bake`, when the project has no `bake` script or file of its own.
// It serves the app in the working directory: the pages in `app/pages/` and the server in `app/server.*`.
import type { HTMLBundle, Serve } from "bun";
const initial = performance.now();
const argv = process.argv;

// `import` cannot be used in this file and only Bun builtin modules can be used.
const path = require("node:path");
const {
defaultServeFlags,
parseServeFlag,
serveOnFreePort,
printBannerAndHandleShortcuts,
} = require("internal/serve_cli");

// `app/server.ts` runs before the code that checks its export, and can change these on the prototypes.
const ObjectKeys = Object.keys;

const help = `
Bun v${Bun.version} (bake)

Usage:
bun bake [options]

Serves the app in the working directory, with hot reloading:

app/pages/**/*.html Pages. The path is the URL: app/pages/docs/index.html is /docs.
app/server.ts Optional. Its default export may have "routes", "fetch" and
"websocket", as in Bun.serve(). "fetch" gets the requests that
no page and no route matched.

Options:

--port <NUM>
--host <STR>, --hostname <STR>
--console # print console logs from browser
--no-console # don't print console logs from browser
--check # type check the project first, and do not serve it if it has errors
`;

type Page = {
/** The URL path, without the leading `/`, as the file names it. */
route: string;
/** The `Bun.serve` route: `/` and `route` with each segment percent-encoded, as browsers send it. */
url: string;
file: string;
bundle: HTMLBundle;
};

type AppServer = {
routes?: Record<string, unknown>;
fetch?: (req: Request, server: unknown) => unknown;
websocket?: unknown;
};

/** `file` relative to the working directory, with `/` on every platform, for messages. */
function display(file: string): string {
return path.relative(process.cwd(), file).replaceAll("\\", "/");
}

/** The URL path of the page at `file`, relative to `app/pages/`, without the leading `/`. */
function routeOfPage(file: string): string {
let route = file.replaceAll("\\", "/").slice(0, -".html".length);
if (route === "index") return "";
if (route.endsWith("/index")) route = route.slice(0, -"/index".length);
return route;
}

async function loadPages(pagesDir: string): Promise<Page[]> {
let files: string[];
try {
files = [...new Bun.Glob("**/*.html").scanSync({ cwd: pagesDir, onlyFiles: true })];
Comment thread
alii marked this conversation as resolved.
Resolved
} catch (error: any) {
if (error?.code === "ENOENT") return [];
throw error;
}
files.sort((a, b) => a.localeCompare(b));

const pages: Page[] = [];
const fileOfRoute: Record<string, string> = { __proto__: null } as any;
for (const relative of files) {
const file = path.join(pagesDir, relative);
// `Bun.serve` routes read `:` as a parameter and `*` as a wildcard. `[` is the parameter syntax of page files.
// In a URL, `?` and `#` end the path and `%` starts an escape.
if (/[[\]:*?#%]/.test(relative)) {
throw new Error(
`"${display(file)}": a page cannot have "[", ":", "*", "?", "#" or "%" in its path. Its URL must be fixed.`,
);
}
const route = routeOfPage(relative);
const other = fileOfRoute[route];
if (other !== undefined) {
throw new Error(`"${display(other)}" and "${display(file)}" are both the page "/${route}".`);
Comment thread
alii marked this conversation as resolved.
Resolved
}
fileOfRoute[route] = file;
// Encoded as browsers send it: non-ASCII characters and spaces are, `&` and `=` are not.
const url = new URL("/" + route, "http://localhost").pathname;
pages.push({ route, url, file, bundle: (await import(file)).default });
}
return pages;
}

async function loadAppServer(cwd: string): Promise<{ file: string; server: AppServer } | undefined> {
let file: string;
try {
file = Bun.resolveSync("./app/server", cwd);
} catch (error: any) {
if (error?.code === "ERR_MODULE_NOT_FOUND") return undefined;
throw error;
}

const server = (await import(file)).default;
const name = display(file);
if (server === null || typeof server !== "object") {
throw new TypeError(`"${name}" must have a default export with "routes", "fetch" or "websocket".`);
}
const keys = ObjectKeys(server);
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
if (key !== "routes" && key !== "fetch" && key !== "websocket") {
throw new TypeError(
`"${name}": the default export has "${key}", but "bun bake" only uses "routes", "fetch", "websocket".`,
);
}
}
const fetch = server.fetch;
if (fetch !== undefined && !$isCallable(fetch)) {
throw $ERR_INVALID_ARG_TYPE("fetch", "function", fetch);
}
const { routes, websocket } = server;
if (routes !== undefined && (routes === null || typeof routes !== "object")) {
throw $ERR_INVALID_ARG_TYPE("routes", "object", routes);
}
if (websocket !== undefined && (websocket === null || typeof websocket !== "object")) {
throw $ERR_INVALID_ARG_TYPE("websocket", "object", websocket);
}
return { file, server: { routes, fetch, websocket } };
}

async function start() {
const flags = defaultServeFlags();
// argv[1] is the entry point `bun bake` boots with.
for (let i = 2, argvLength = argv.length; i < argvLength;) {
const arg = argv[i];
if (arg === "--help" || arg === "-h") {
console.log(help);
process.exit(0);
}
const used = parseServeFlag(argv, i, flags);
if (used === 0) {
throw new Error(`Unknown argument "${arg}". Run "bun bake --help" for the usage.`);
}
i += used;
}

const cwd = process.cwd();
const pages = await loadPages(path.join(cwd, "app", "pages"));
const app = await loadAppServer(cwd);
if (pages.length === 0 && app === undefined) {
throw new Error(`Nothing to serve in "${cwd}": there are no pages in app/pages/ and no app/server.ts.`);
}

const routes: Record<string, unknown> = {};
const pageOfRoute: Record<string, Page> = { __proto__: null } as any;
for (let i = 0; i < pages.length; i++) {
Comment thread
alii marked this conversation as resolved.
Resolved
const page = pages[i];
routes[page.url] = page.bundle;
pageOfRoute[page.url] = page;
}
const appRoutes = app?.server.routes;
if (appRoutes !== undefined) {
const names = ObjectKeys(appRoutes);
Comment thread
alii marked this conversation as resolved.
Resolved
for (let i = 0; i < names.length; i++) {
const route = names[i];
const page = pageOfRoute[route];
if (page !== undefined) {
throw new Error(`The route "${route}" in "${display(app!.file)}" is also the page "${display(page.file)}".`);
}
routes[route] = appRoutes[route];
}
}

const fetch = app?.server.fetch ?? (() => new Response("Not found", { status: 404 }));
const server = serveOnFreePort(
port =>
({
routes,
fetch,
websocket: app?.server.websocket,
development: { console: flags.console, hmr: undefined },
hostname: flags.hostname,
// `undefined` uses the default port via existing port detection code.
port,
}) as Serve.Options<undefined>,
flags.port,
);
const elapsed = (performance.now() - initial).toFixed(2);

const listed: { route: string; file: string }[] = [];
for (let i = 0; i < pages.length; i++) {
listed[i] = { route: pages[i].route, file: pages[i].file };
}
printBannerAndHandleShortcuts({ server, elapsed, pathname: "/", routes: listed });
}

export default start;
Loading
Loading