Repository navigation
add bun bake: serve app/pages and app/server.ts #45682
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
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 4871341
add bun bake: serve app/pages and app/server.ts with the dev server
alii 5b4faf1
[autofix.ci] apply automated fixes
autofix-ci[bot] 145b616
bun bake: type check with --check, take --flag value, reject clashing…
alii 1bd28b5
bun bake: encode page urls, and let a trailing --check turn --hot int…
alii bbde9d6
bun bake: encode page urls as browsers do, check routes and websocket…
alii f0cd83e
bun bake: name ] in the page path error and docs
alii 2c75e85
bun bake: follow symlinks in app/pages
alii a8d4359
load what a built-in entry point imports as the imports of a main module
alii b79abff
bun bake: run with no entry module instead of a placeholder main path
alii caa5c95
make the vm's main optional, so bun bake has no entry module rather t…
alii 0c21b43
document bun bake --watch and --hot
alii ea4a624
point the restart note in the bun bake docs at --watch and --hot
alii 313d9ee
bun bake: --check after a --hot that follows bake also runs as --watch
alii de4b528
bun bake: keep --no-clear-screen and --watch-kill-signal given before…
alii 4a3b69f
document that Bun.main is empty when no script launched bun
alii 1e973fb
move the bun bake docs to their own pr
alii 819e52d
bun bake: one server across --hot reloads, no empty host, apply watch…
alii 45fa4f6
bun bake: pass the app/server.ts export to Bun.serve as bun app.ts do…
alii f90d995
bun bake: define its options on a frozen export, and keep a unix socket
alii File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. | ||
|
|
||
| ## 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. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 })]; | ||
|
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}".`); | ||
|
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++) { | ||
|
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); | ||
|
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; | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.