- bunext build records when each page's client bundle hash last changed
in .bunext/page-lastmod.json (sitemap.lastmod_file to relocate). The
hash covers the page's whole import graph, so component edits re-date
every page that uses them. The sitemap uses these dates as lastmod
- Add sitemap.default_lastmod
- BREAKING: use_file_lastmod now defaults to false (file mtimes are reset
by checkouts); it is a fallback after the build record
- Warn when a page or __root.tsx is skipped because its default export
is not at the start of a line (e.g. export { Page as default })
- Replace leaking module mocks in tests (log, grab-root-file-path) with
spies and fixtures
Co-Authored-By: Claude Opus 5.5 <[email protected]>
1258 lines
52 KiB
Markdown
1258 lines
52 KiB
Markdown
# Bunext
|
|
|
|
A server-rendering framework for React, built entirely on [Bun](https://bun.sh). Bunext handles file-system routing, SSR, HMR, and client hydration — using ESBuild to bundle client assets and `Bun.serve` as the HTTP server.
|
|
|
|
## Philosophy
|
|
|
|
Bunext is focused on **server-side rendering and processing**. Every page is rendered on the server on every request. The framework deliberately does not implement client-side navigation, SPA routing, or client-side state management — those concerns belong in client-side libraries and are orthogonal to what Bunext is solving.
|
|
|
|
The goal is a framework that is:
|
|
|
|
- Fast — Bun's runtime speed and ESBuild's bundling make the full dev loop snappy
|
|
- Transparent — the entire request pipeline is readable and debugable
|
|
- Standard — server functions and API handlers use native Web APIs (`Request`, `Response`, `URL`) with no custom wrappers
|
|
|
|
---
|
|
|
|
## Table of Contents
|
|
|
|
- [Requirements](#requirements)
|
|
- [Installation](#installation)
|
|
- [Quick Start](#quick-start)
|
|
- [CLI Commands](#cli-commands)
|
|
- [Project Structure](#project-structure)
|
|
- [File-System Routing](#file-system-routing)
|
|
- [Non-Routed Directories](#non-routed-directories)
|
|
- [Pages](#pages)
|
|
- [Basic Page](#basic-page)
|
|
- [Server Function](#server-function)
|
|
- [Default Server Props](#default-server-props)
|
|
- [Redirects from Server](#redirects-from-server)
|
|
- [Custom Response Options](#custom-response-options)
|
|
- [SEO Metadata](#seo-metadata)
|
|
- [Dynamic Metadata](#dynamic-metadata)
|
|
- [Custom Head Content](#custom-head-content)
|
|
- [Root Layout](#root-layout)
|
|
- [API Routes](#api-routes)
|
|
- [Route Config (Body Size Limit)](#route-config-body-size-limit)
|
|
- [Error Pages](#error-pages)
|
|
- [Sitemap](#sitemap)
|
|
- [Page `sitemap` Export](#page-sitemap-export)
|
|
- [`bunext sitemap` Command](#bunext-sitemap-command)
|
|
- [Last-Modified Dates](#last-modified-dates)
|
|
- [Large Sitemaps](#large-sitemaps)
|
|
- [Generating at Runtime](#generating-at-runtime)
|
|
- [robots.txt](#robotstxt)
|
|
- [Static Files](#static-files)
|
|
- [Caching](#caching)
|
|
- [Enabling Cache Per Page](#enabling-cache-per-page)
|
|
- [Dynamic Cache Control from Server Function](#dynamic-cache-control-from-server-function)
|
|
- [Cache Expiry](#cache-expiry)
|
|
- [Cache Behavior and Limitations](#cache-behavior-and-limitations)
|
|
- [Configuration](#configuration)
|
|
- [Middleware](#middleware)
|
|
- [WebSocket](#websocket)
|
|
- [Server Options](#server-options)
|
|
- [Custom Server](#custom-server)
|
|
- [Environment Variables](#environment-variables)
|
|
- [How It Works](#how-it-works)
|
|
- [Development Server](#development-server)
|
|
- [Production Build](#production-build)
|
|
- [Bundler](#bundler)
|
|
- [Hot Module Replacement](#hot-module-replacement)
|
|
- [Request Pipeline](#request-pipeline)
|
|
|
|
---
|
|
|
|
## Requirements
|
|
|
|
- [Bun](https://bun.sh) v1.0 or later
|
|
- TypeScript 5.0+
|
|
|
|
> **React is managed by Bunext.** You do not need to install `react` or `react-dom` — Bunext enforces its own pinned React version and removes any user-installed copies at startup to prevent version conflicts. Installing this package is all you need.
|
|
|
|
---
|
|
|
|
## Installation
|
|
|
|
### From the Moduletrace registry (recommended)
|
|
|
|
Configure the `@moduletrace` scope to point at the registry — pick one:
|
|
|
|
**`bunfig.toml`** (Bun-native):
|
|
|
|
```toml
|
|
[install.scopes]
|
|
"@moduletrace" = { registry = "https://git.tben.me/api/packages/moduletrace/npm/" }
|
|
```
|
|
|
|
**`.npmrc`** (works with npm, bun, and most tools):
|
|
|
|
```ini
|
|
@moduletrace:registry=https://git.tben.me/api/packages/moduletrace/npm/
|
|
```
|
|
|
|
Then install:
|
|
|
|
```bash
|
|
bun add @moduletrace/bunext
|
|
```
|
|
|
|
Or globally:
|
|
|
|
```bash
|
|
bun add -g @moduletrace/bunext
|
|
```
|
|
|
|
### From GitHub (alternative)
|
|
|
|
```bash
|
|
bun add github:moduletrace/bunext
|
|
```
|
|
|
|
---
|
|
|
|
## Quick Start
|
|
|
|
1. Create a minimal project layout:
|
|
|
|
```
|
|
my-app/
|
|
├── src/
|
|
│ └── pages/
|
|
│ └── index.tsx
|
|
├── bunext.config.ts # optional
|
|
└── package.json
|
|
```
|
|
|
|
2. Create your first page (`src/pages/index.tsx`):
|
|
|
|
```tsx
|
|
export default function HomePage() {
|
|
return <h1>Hello from Bunext!</h1>;
|
|
}
|
|
```
|
|
|
|
3. Add scripts to your `package.json`:
|
|
|
|
```json
|
|
{
|
|
"scripts": {
|
|
"dev": "bunx bunext dev",
|
|
"build": "bunx bunext build",
|
|
"start": "bunx bunext start"
|
|
}
|
|
}
|
|
```
|
|
|
|
4. Start the development server:
|
|
|
|
```bash
|
|
bun run dev
|
|
```
|
|
|
|
5. Open `http://localhost:7000` in your browser.
|
|
|
|
---
|
|
|
|
## CLI Commands
|
|
|
|
| Command | Description |
|
|
| -------------- | ------------------------------------------------------------------------------ |
|
|
| `bunext dev` | Start the development server with HMR and file watching. |
|
|
| `bunext build` | Bundle all pages for production. Outputs artifacts to `.bunext/public/pages/` using paths relative to the project root. |
|
|
| `bunext start` | Start the production server using pre-built artifacts. |
|
|
| `bunext sitemap` | Generate `public/sitemap.xml` and `public/robots.txt` from your pages, using the `sitemap` and `robots` options in `bunext.config.ts`. See [Sitemap](#sitemap). |
|
|
|
|
### Running the CLI
|
|
|
|
Bunext exposes a `bunext` binary. How you invoke it depends on how the package is installed:
|
|
|
|
**Local install (recommended)** — add scripts to `package.json` and run them with `bun run`:
|
|
|
|
```json
|
|
{
|
|
"scripts": {
|
|
"dev": "bunx bunext dev",
|
|
"build": "bunx bunext build",
|
|
"start": "bunx bunext start"
|
|
}
|
|
}
|
|
```
|
|
|
|
```bash
|
|
bun run dev
|
|
bun run build
|
|
bun run start
|
|
```
|
|
|
|
**Global install** — install once and use `bunext` from anywhere:
|
|
|
|
```bash
|
|
bun add -g @moduletrace/bunext
|
|
bunext dev
|
|
bunext build
|
|
bunext start
|
|
```
|
|
|
|
> **Note:** `bunext start` will exit with an error if `.bunext/public/pages/map.json` does not exist. Always run `bunext build` (or `bun run build`) before `bunext start`.
|
|
|
|
---
|
|
|
|
## Project Structure
|
|
|
|
A typical Bunext project has the following layout:
|
|
|
|
```
|
|
my-app/
|
|
├── src/
|
|
│ └── pages/ # File-system routes (pages and API handlers)
|
|
│ ├── __root.tsx # Optional: root layout wrapper for all pages
|
|
│ ├── __root.server.ts # Optional: root-level server logic
|
|
│ ├── index.tsx # Route: /
|
|
│ ├── index.server.ts # Optional: server logic for index route
|
|
│ ├── about.tsx # Route: /about
|
|
│ ├── 404.tsx # Optional: custom 404 page
|
|
│ ├── 500.tsx # Optional: custom 500 page
|
|
│ ├── blog/
|
|
│ │ ├── index.tsx # Route: /blog
|
|
│ │ ├── index.server.ts # Server logic for /blog
|
|
│ │ └── [slug].tsx # Route: /blog/:slug (dynamic)
|
|
│ └── api/
|
|
│ └── users.ts # API route: /api/users
|
|
├── public/ # Static files served at /public/*
|
|
├── .bunext/ # Internal build artifacts (do not edit manually)
|
|
│ └── public/
|
|
│ ├── pages/ # Generated by bundler
|
|
│ │ └── map.json # Artifact map used by production server
|
|
│ └── cache/ # File-based HTML cache (production only)
|
|
├── bunext.config.ts # Optional configuration
|
|
├── tsconfig.json
|
|
└── package.json
|
|
```
|
|
|
|
---
|
|
|
|
## File-System Routing
|
|
|
|
Bunext uses `Bun.FileSystemRouter` with Next.js-style routing. Pages live in `src/pages/` and are automatically mapped to URL routes:
|
|
|
|
| File path | URL path |
|
|
| -------------------------------- | ------------- |
|
|
| `src/pages/index.tsx` | `/` |
|
|
| `src/pages/about.tsx` | `/about` |
|
|
| `src/pages/blog/index.tsx` | `/blog` |
|
|
| `src/pages/blog/[slug].tsx` | `/blog/:slug` |
|
|
| `src/pages/users/[id]/index.tsx` | `/users/:id` |
|
|
| `src/pages/api/users.ts` | `/api/users` |
|
|
|
|
Dynamic route parameters (e.g. `[slug]`) are available in the `server` function via `ctx.req.url` or from the `query` field in the server response.
|
|
|
|
### Non-Routed Directories
|
|
|
|
Directories whose name contains `--` or a parenthesis (`(` or `)`) are completely ignored by the router. Use this to co-locate helper components, utilities, or shared logic directly inside `src/pages/` alongside the routes that use them, without them becoming routes.
|
|
|
|
| Naming pattern | Effect |
|
|
| --------------- | -------------------- |
|
|
| `(components)/` | Ignored — not routed |
|
|
| `--utils--/` | Ignored — not routed |
|
|
| `--lib/` | Ignored — not routed |
|
|
|
|
```
|
|
src/pages/
|
|
├── blog/
|
|
│ ├── (components)/ # Not a route — co-location directory
|
|
│ │ ├── PostCard.tsx # Used by index.tsx and [slug].tsx
|
|
│ │ └── PostList.tsx
|
|
│ ├── index.tsx # Route: /blog
|
|
│ └── [slug].tsx # Route: /blog/:slug
|
|
└── index.tsx # Route: /
|
|
```
|
|
|
|
---
|
|
|
|
## Pages
|
|
|
|
### Basic Page
|
|
|
|
A page file must export a default React component. The component receives server-side props automatically.
|
|
|
|
```tsx
|
|
// src/pages/index.tsx
|
|
export default function HomePage() {
|
|
return <h1>Hello, World!</h1>;
|
|
}
|
|
```
|
|
|
|
### Server Function
|
|
|
|
Server logic lives in a companion **`.server.ts`** (or `.server.tsx`) file alongside the page. The framework looks for `<page>.server.ts` or `<page>.server.tsx` next to the page file and loads it separately on the server — it is never bundled into the client JS.
|
|
|
|
The server file exports the server function as either `export default` or `export const server`. The return value's `props` field is spread into the page component as props, and `query` carries route query parameters.
|
|
|
|
```ts
|
|
// src/pages/profile.server.ts
|
|
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
|
|
|
const server: BunextPageServerFn<{
|
|
username: string;
|
|
bio: string;
|
|
}> = async (ctx) => {
|
|
// ctx.req — the raw Request object
|
|
// ctx.url — the parsed URL
|
|
// ctx.query — query string parameters
|
|
// ctx.resTransform — optional response interceptor
|
|
|
|
const username = "alice";
|
|
const bio = "Software engineer";
|
|
|
|
return {
|
|
props: { username, bio },
|
|
};
|
|
};
|
|
|
|
export default server;
|
|
```
|
|
|
|
```tsx
|
|
// src/pages/profile.tsx (client-only exports — bundled to the browser)
|
|
type Props = {
|
|
props?: { username: string; bio: string };
|
|
query?: Record<string, string>;
|
|
url?: URL;
|
|
};
|
|
|
|
export default function ProfilePage({ props, url }: Props) {
|
|
return (
|
|
<div>
|
|
<h1>{props?.username}</h1>
|
|
<p>{props?.bio}</p>
|
|
<p>Current path: {url?.pathname}</p>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
> Server files can also export a `sitemap` function to list a page's URLs in the sitemap — see [Page `sitemap` Export](#page-sitemap-export).
|
|
|
|
> **Why separate files?** Bundling server code (Bun APIs, database clients, `fs`, secrets) into the same file as a React component causes TypeScript compilation errors because the bundler processes the page file for the browser. The `.server.ts` companion file is loaded only by the server at request time and is never included in the client bundle.
|
|
|
|
The server function receives a `ctx` object (type `BunxRouteParams`) with:
|
|
|
|
| Field | Type | Description |
|
|
| -------------- | ------------------------------------------------ | ------------------------------------------ |
|
|
| `req` | `Request` | Raw Bun/Web Request object |
|
|
| `url` | `URL` | Parsed URL |
|
|
| `body` | `any` | Parsed request body |
|
|
| `query` | `any` | Query string parameters |
|
|
| `resTransform` | `(res: Response) => Promise<Response>\|Response` | Intercept and transform the final response |
|
|
|
|
### Default Server Props
|
|
|
|
Every page component automatically receives a `url` prop — a copy of the request URL object — even if no `server` function is exported. This means you can always read URL data (pathname, search params, origin, etc.) directly from component props without writing a `server` function.
|
|
|
|
```tsx
|
|
// src/pages/index.tsx
|
|
import type { BunextPageModuleServerReturnURLObject } from "@moduletrace/bunext/types";
|
|
|
|
type Props = {
|
|
url?: BunextPageModuleServerReturnURLObject;
|
|
};
|
|
|
|
export default function HomePage({ url }: Props) {
|
|
return (
|
|
<div>
|
|
<p>Visiting: {url?.pathname}</p>
|
|
<p>Origin: {url?.origin}</p>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
The `url` prop exposes the following fields from the standard Web `URL` interface:
|
|
|
|
| Field | Type | Example |
|
|
| -------------- | ----------------- | -------------------------------- |
|
|
| `href` | `string` | `"https://example.com/blog?q=1"` |
|
|
| `origin` | `string` | `"https://example.com"` |
|
|
| `protocol` | `string` | `"https:"` |
|
|
| `host` | `string` | `"example.com"` |
|
|
| `hostname` | `string` | `"example.com"` |
|
|
| `port` | `string` | `""` |
|
|
| `pathname` | `string` | `"/blog"` |
|
|
| `search` | `string` | `"?q=1"` |
|
|
| `searchParams` | `URLSearchParams` | `URLSearchParams { q: "1" }` |
|
|
| `hash` | `string` | `""` |
|
|
| `username` | `string` | `""` |
|
|
| `password` | `string` | `""` |
|
|
|
|
### Redirects from Server
|
|
|
|
Return a `redirect` object from the server function to redirect the client:
|
|
|
|
```ts
|
|
// src/pages/dashboard.server.ts
|
|
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
|
|
|
const server: BunextPageServerFn = async (ctx) => {
|
|
const isLoggedIn = false; // check auth
|
|
|
|
if (!isLoggedIn) {
|
|
return {
|
|
redirect: {
|
|
destination: "/login",
|
|
permanent: false, // uses 302
|
|
// status_code: 307 // override status code
|
|
},
|
|
};
|
|
}
|
|
|
|
return { props: {} };
|
|
};
|
|
|
|
export default server;
|
|
```
|
|
|
|
`permanent: true` sends a `301` redirect. Otherwise it sends `302`, or the value of `status_code` if provided.
|
|
|
|
### Custom Response Options
|
|
|
|
Control status codes, headers, and other response options from the server function:
|
|
|
|
```ts
|
|
// src/pages/submit.server.ts
|
|
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
|
|
|
const server: BunextPageServerFn = async (ctx) => {
|
|
return {
|
|
props: { message: "Created" },
|
|
responseOptions: {
|
|
status: 201,
|
|
headers: {
|
|
"X-Custom-Header": "my-value",
|
|
},
|
|
},
|
|
};
|
|
};
|
|
|
|
export default server;
|
|
```
|
|
|
|
### SEO Metadata
|
|
|
|
Export a `meta` object from the **page file** (not the server file) to inject SEO and Open Graph tags into the `<head>`:
|
|
|
|
```tsx
|
|
import type { BunextPageModuleMeta } from "@moduletrace/bunext/types";
|
|
|
|
export const meta: BunextPageModuleMeta = {
|
|
title: "My Page Title",
|
|
description: "A description for search engines.",
|
|
keywords: ["bun", "react", "ssr"],
|
|
author: "Alice",
|
|
robots: "index, follow",
|
|
canonical: "https://example.com/about",
|
|
themeColor: "#ffffff",
|
|
og: {
|
|
title: "My Page Title",
|
|
description: "Shared on social media.",
|
|
image: "https://example.com/og-image.png",
|
|
url: "https://example.com/about",
|
|
type: "website",
|
|
siteName: "My Site",
|
|
locale: "en_US",
|
|
},
|
|
twitter: {
|
|
card: "summary_large_image",
|
|
title: "My Page Title",
|
|
description: "Shared on Twitter.",
|
|
image: "https://example.com/twitter-image.png",
|
|
site: "@mysite",
|
|
creator: "@alice",
|
|
},
|
|
};
|
|
|
|
export default function AboutPage() {
|
|
return <p>About us</p>;
|
|
}
|
|
```
|
|
|
|
### Dynamic Metadata
|
|
|
|
`meta` can also be an async function that receives the request context and server response. Like `meta`, it is exported from the **page file**:
|
|
|
|
```tsx
|
|
import type { BunextPageModuleMetaFn } from "@moduletrace/bunext/types";
|
|
|
|
export const meta: BunextPageModuleMetaFn = async ({ ctx, serverRes }) => {
|
|
return {
|
|
title: `Post: ${serverRes?.props?.title ?? "Untitled"}`,
|
|
description: serverRes?.props?.excerpt,
|
|
};
|
|
};
|
|
```
|
|
|
|
### Custom Head Content
|
|
|
|
Export a `Head` functional component to inject arbitrary HTML into `<head>`. It receives the server response and request context:
|
|
|
|
```tsx
|
|
import type { BunextPageHeadFCProps } from "@moduletrace/bunext/types";
|
|
|
|
export function Head({ serverRes, ctx }: BunextPageHeadFCProps) {
|
|
return (
|
|
<>
|
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
<link rel="icon" href="/public/favicon.ico" />
|
|
</>
|
|
);
|
|
}
|
|
|
|
export default function Page() {
|
|
return <main>Content</main>;
|
|
}
|
|
```
|
|
|
|
### Root Layout
|
|
|
|
Create `src/pages/__root.tsx` to wrap every page in a shared layout. The root component receives `children` (the current page component) along with all server props.
|
|
|
|
If the root layout also needs server-side logic, place it in `src/pages/__root.server.ts` (or `.server.tsx`) — the same `.server.*` convention used by regular pages:
|
|
|
|
```ts
|
|
// src/pages/__root.server.ts
|
|
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
|
|
|
const server: BunextPageServerFn = async (ctx) => {
|
|
// e.g. fetch navigation links, check auth
|
|
return { props: { navLinks: ["/", "/about"] } };
|
|
};
|
|
|
|
export default server;
|
|
```
|
|
|
|
```tsx
|
|
// src/pages/__root.tsx
|
|
import type { BunextRootComponentProps } from "@moduletrace/bunext/types";
|
|
|
|
export default function RootLayout({
|
|
children,
|
|
props,
|
|
}: BunextRootComponentProps) {
|
|
return (
|
|
<>
|
|
<header>My App</header>
|
|
<main>{children}</main>
|
|
<footer>© 2025</footer>
|
|
</>
|
|
);
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## API Routes
|
|
|
|
Create files under `src/pages/api/` to define API endpoints. The default export receives a `BunxRouteParams` object and must return a standard `Response`.
|
|
|
|
```ts
|
|
// src/pages/api/hello.ts
|
|
import type { BunxRouteParams } from "@moduletrace/bunext/types";
|
|
|
|
export default async function handler(ctx: BunxRouteParams): Promise<Response> {
|
|
return Response.json({ message: "Hello from the API" });
|
|
}
|
|
```
|
|
|
|
API routes are matched at `/api/<filename>`. Because the handler returns a plain `Response`, you control the status code, headers, and body format entirely:
|
|
|
|
```ts
|
|
// src/pages/api/users.ts
|
|
import type { BunxRouteParams } from "@moduletrace/bunext/types";
|
|
|
|
export default async function handler(ctx: BunxRouteParams): Promise<Response> {
|
|
if (ctx.req.method !== "GET") {
|
|
return Response.json({ error: "Method not allowed" }, { status: 405 });
|
|
}
|
|
|
|
const users = [{ id: 1, name: "Alice" }];
|
|
|
|
return Response.json({ success: true, data: users });
|
|
}
|
|
```
|
|
|
|
The `ctx` parameter has the same shape as the page `server` function context — see the [Server Function](#server-function) section for the full field reference. The `ctx.server` field additionally exposes the Bun `Server` instance.
|
|
|
|
### Route Config (Body Size Limit)
|
|
|
|
Export a `config` object to override the per-route request body limit (default: 10 MB):
|
|
|
|
```ts
|
|
import type {
|
|
BunextServerRouteConfig,
|
|
BunxRouteParams,
|
|
} from "@moduletrace/bunext/types";
|
|
|
|
export const config: BunextServerRouteConfig = {
|
|
maxRequestBodyMB: 50, // allow up to 50 MB
|
|
};
|
|
|
|
export default async function handler(ctx: BunxRouteParams): Promise<Response> {
|
|
// handle large uploads
|
|
return Response.json({ success: true });
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Error Pages
|
|
|
|
Bunext serves built-in fallback error pages, but you can override them by creating pages in `src/pages/`:
|
|
|
|
| File | Triggered when |
|
|
| ------------------- | --------------------------------------- |
|
|
| `src/pages/404.tsx` | No matching route is found |
|
|
| `src/pages/500.tsx` | An unhandled error occurs during render |
|
|
|
|
The error message is passed as `children` to the component:
|
|
|
|
```tsx
|
|
// src/pages/404.tsx
|
|
import type { PropsWithChildren } from "react";
|
|
|
|
export default function NotFoundPage({ children }: PropsWithChildren) {
|
|
return (
|
|
<div>
|
|
<h1>404 — Page Not Found</h1>
|
|
<p>{children}</p>
|
|
</div>
|
|
);
|
|
}
|
|
```
|
|
|
|
If no custom error pages exist, Bunext uses built-in preset components.
|
|
|
|
---
|
|
|
|
## Sitemap
|
|
|
|
Bunext builds a sitemap from every static page in `src/pages/`. API routes, `404`/`500` pages and ignored directories are left out. Dynamic routes (`[slug]`) cannot be discovered from the file system, so they are only included when their server file exports a `sitemap` function (see below), or when you pass their URLs through `additional_paths`.
|
|
|
|
### Page `sitemap` Export
|
|
|
|
A page's `.server.ts` file can export a `sitemap` function returning that page's entries. This is the way to list URLs for a dynamic route:
|
|
|
|
```ts
|
|
// src/pages/blog/[slug].server.ts
|
|
import type { BunextPageServerFn, BunextPageSitemapFn } from "@moduletrace/bunext/types";
|
|
|
|
const server: BunextPageServerFn = async (ctx) => {
|
|
// ...
|
|
};
|
|
|
|
export default server;
|
|
|
|
export const sitemap: BunextPageSitemapFn = async ({ route }) => {
|
|
// route === "/blog/[slug]"
|
|
const posts = await getPublishedPosts();
|
|
|
|
return posts.map((post) => ({
|
|
path: `/blog/${post.slug}`,
|
|
lastmod: post.updated_at,
|
|
priority: 0.7,
|
|
}));
|
|
};
|
|
```
|
|
|
|
- It can be sync or async, and returns strings or `BunextSitemapEntry` objects, like `additional_paths`.
|
|
- On a static page, the returned entries **replace** the page's default entry, so you can set its `priority`/`changefreq`, or return `[]` to leave the page out.
|
|
- `exclude` and the `default_*` options still apply to the returned entries.
|
|
- Generating a sitemap imports every page's server file, so their top-level code runs. If a `sitemap` function throws, generation fails with the file name in the error.
|
|
|
|
### `bunext sitemap` Command
|
|
|
|
Add a `sitemap` key to `bunext.config.ts`, then run `bunext sitemap`. By default the file is written to `public/sitemap.xml`, which is served at `/sitemap.xml`. Run it after `bunext build` in your deploy step to keep it current.
|
|
|
|
```ts
|
|
// bunext.config.ts
|
|
import type { BunextConfig } from "@moduletrace/bunext/types";
|
|
|
|
const config: BunextConfig = {
|
|
origin: "https://example.com",
|
|
sitemap: {
|
|
exclude: ["/login", "/admin/*"],
|
|
default_changefreq: "weekly",
|
|
// A function (sync or async) lets you load dynamic routes
|
|
additional_paths: async () => {
|
|
const posts = await getPublishedPosts();
|
|
return posts.map((post) => ({
|
|
path: `/blog/${post.slug}`,
|
|
lastmod: post.updated_at,
|
|
}));
|
|
},
|
|
},
|
|
};
|
|
|
|
export default config;
|
|
```
|
|
|
|
```bash
|
|
bunext sitemap # writes public/sitemap.xml and public/robots.txt
|
|
bunext sitemap -o public/sitemaps/main.xml # custom sitemap output path
|
|
bunext sitemap --origin https://staging.example.com
|
|
bunext sitemap --no-robots # sitemap only
|
|
```
|
|
|
|
#### Last-Modified Dates
|
|
|
|
`lastmod` comes from the build pipeline. On every `bunext build`, Bunext compares each page's client bundle hash with the previous build and records the build date for pages whose bundle changed in `.bunext/page-lastmod.json`. The hash covers the page's whole import graph, so editing a shared component re-dates every page that uses it, while untouched pages keep their date. Editing `__root.tsx` changes every page.
|
|
|
|
Each URL's `lastmod` is the first of:
|
|
|
|
1. The entry's own `lastmod` (from `additional_paths` or a page's `sitemap` export)
|
|
2. The page's date from the build record (also used for entries returned by that page's `sitemap` export)
|
|
3. The page file's modification time, if `use_file_lastmod: true`
|
|
4. `default_lastmod`
|
|
|
|
With no date, `<lastmod>` is left out. Run `bunext build` before `bunext sitemap`; the command warns when there is no record.
|
|
|
|
- **The record must persist between builds.** Without the previous record, every page is dated with the current build. Building on the server you deploy to keeps `.bunext/` between builds. For CI builds from a fresh checkout, set `sitemap.lastmod_file` to a path you cache between runs or commit (it is sorted JSON for small diffs).
|
|
- **Server data is not covered.** Content loaded in a server function (e.g. from a database) does not change the bundle, so set `lastmod` on those entries in the page's `sitemap` export.
|
|
- Upgrading esbuild or changing build options can change every hash once, re-dating all pages.
|
|
|
|
#### Large Sitemaps
|
|
|
|
A sitemap file may hold at most 50,000 URLs and 50MB. When the sitemap has more than `chunk_size` URLs (default 10,000), `bunext sitemap` splits it into chunk files and makes `sitemap.xml` a sitemap index pointing to them:
|
|
|
|
```
|
|
public/
|
|
├── sitemap.xml ← <sitemapindex> referencing the chunks
|
|
└── sitemap-chunks/
|
|
├── sitemap-1.xml ← up to chunk_size URLs each
|
|
├── sitemap-2.xml
|
|
└── sitemap-3.xml
|
|
```
|
|
|
|
Each index entry's `lastmod` is the newest `lastmod` in its chunk. Chunk files left over from a previous, larger run are removed; other files in the chunks directory are left alone. If the sitemap shrinks back under `chunk_size`, `sitemap.xml` returns to a plain `<urlset>`.
|
|
|
|
| Config option | Type | Description |
|
|
| ------------- | -------- | --------------------------------------------------------------------------------------------------- |
|
|
| `chunk_size` | `number` | Maximum URLs per file. Default `10000`, capped at `50000`. Chunks are also split at 50MB. |
|
|
| `chunks_dir` | `string` | Chunk directory relative to the project root. Must be inside `public/`. Default `public/sitemap-chunks`. |
|
|
|
|
`--output` and `--origin` override `sitemap.output` and `sitemap.origin` from the config. `--origin` also applies to robots.txt. The command also writes a `robots.txt` — see [robots.txt](#robotstxt).
|
|
|
|
### Generating at Runtime
|
|
|
|
`bunextGenerateSitemap()` resolves to the same XML as a single `<urlset>` (it does not chunk, and logs a warning above 50,000 URLs — use `bunext sitemap` for large sites), so you can serve it from a route instead of a file:
|
|
|
|
```ts
|
|
// src/pages/api/sitemap.ts
|
|
import bunext from "@moduletrace/bunext";
|
|
|
|
export default async function handler(req: Request) {
|
|
const xml = await bunext.bunextGenerateSitemap({
|
|
exclude: ["/login", "/admin/*", /^\/drafts/],
|
|
additional_paths: async () => {
|
|
const posts = await getPublishedPosts();
|
|
return posts.map((post) => ({
|
|
path: `/blog/${post.slug}`,
|
|
lastmod: post.updated_at,
|
|
}));
|
|
},
|
|
default_changefreq: "weekly",
|
|
});
|
|
|
|
return new Response(xml, {
|
|
headers: { "Content-Type": "application/xml" },
|
|
});
|
|
}
|
|
```
|
|
|
|
| Option | Type | Description |
|
|
| -------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------- |
|
|
| `origin` | `string` | Base URL for each `<loc>`. Defaults to `config.origin`, then `http://localhost:<port>`. |
|
|
| `exclude` | `(string \| RegExp)[]` | Exact paths, subtrees ending in `/*` (`/admin/*` also excludes `/admin`), or regexes tested on the path. |
|
|
| `additional_paths` | `entries \| () => entries \| Promise<entries>` | Extra paths or entries (`path`, `lastmod`, `changefreq`, `priority`), or a sync/async function returning them, e.g. for dynamic routes. Each entry is a `string` or `BunextSitemapEntry`. |
|
|
| `default_lastmod` | `Date \| string` | `lastmod` for entries with no other date. See [Last-Modified Dates](#last-modified-dates). |
|
|
| `lastmod_file` | `string` | Page date record written by `bunext build`. Default `.bunext/page-lastmod.json`. |
|
|
| `use_file_lastmod` | `boolean` | Fall back to each page file's modification time. Default `false`. |
|
|
| `trailing_slash` | `boolean` | Append `/` to every non-root path. Default `false`. |
|
|
| `default_changefreq` | `BunextSitemapChangeFreq` | `changefreq` for entries that do not set one. |
|
|
| `default_priority` | `number` | `priority` (0.0 to 1.0) for entries that do not set one. |
|
|
|
|
The `sitemap` config key takes the same options, plus `output` (the file path relative to the project root, default `public/sitemap.xml`) and the [chunking options](#large-sitemaps).
|
|
|
|
### robots.txt
|
|
|
|
`bunext sitemap` also writes `public/robots.txt`, served at `/robots.txt`. With no `robots` config it allows every crawler and points to the generated sitemap:
|
|
|
|
```
|
|
# Generated by bunext
|
|
|
|
User-agent: *
|
|
Allow: /
|
|
|
|
Sitemap: https://example.com/sitemap.xml
|
|
```
|
|
|
|
Configure it with the `robots` key:
|
|
|
|
```ts
|
|
// bunext.config.ts
|
|
const config: BunextConfig = {
|
|
origin: "https://example.com",
|
|
robots: {
|
|
rules: [
|
|
{ user_agent: "*", disallow: ["/admin", "/api/"] },
|
|
{ user_agent: ["GPTBot", "CCBot"], disallow: ["/"] },
|
|
],
|
|
// Block everything on staging
|
|
disallow_all: process.env.STAGING === "true",
|
|
},
|
|
};
|
|
```
|
|
|
|
| Option | Type | Description |
|
|
| -------------- | -------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
|
| `rules` | `BunextRobotsRule[]` | Groups of `user_agent` (string or array), `allow`, `disallow` and `crawl_delay`. Default: allow everything for `*`. |
|
|
| `sitemaps` | `string[]` | Extra `Sitemap:` URLs or paths. The generated sitemap is added automatically when it is inside `public/`. |
|
|
| `disallow_all` | `boolean` | Block every crawler from the whole site. `rules` and `sitemaps` are ignored. |
|
|
| `origin` | `string` | Base URL for relative `sitemaps` paths. Defaults to `--origin`, then `sitemap.origin`, then `config.origin`. |
|
|
| `output` | `string` | Output file relative to the project root. Default `public/robots.txt`. |
|
|
|
|
- **Existing files are protected.** Generated files start with `# Generated by bunext`. Without a `robots` config, a `robots.txt` that lacks this line (one you wrote yourself) is left alone and a warning is shown. Setting `robots` in the config overwrites it.
|
|
- **Opting out.** Set `robots: false`, or pass `--no-robots`, to skip robots.txt entirely.
|
|
- **Sitemap `exclude` does not add `Disallow` lines.** Leaving a page out of the sitemap and blocking crawlers from it are different things; add `disallow` rules explicitly. Remember robots.txt is public, so `Disallow` paths are visible to anyone.
|
|
|
|
To serve it from a route instead, `bunext.bunextGenerateRobotsTxt()` takes the same options except `output` and returns the text.
|
|
|
|
---
|
|
|
|
## Static Files
|
|
|
|
Place static files in the `public/` directory. They are served at the `/public/` URL path:
|
|
|
|
```
|
|
public/
|
|
├── logo.png → http://localhost:7000/public/logo.png
|
|
├── styles.css → http://localhost:7000/public/styles.css
|
|
└── favicon.ico → http://localhost:7000/favicon.ico
|
|
```
|
|
|
|
> Favicons are also served from `/public/` but matched directly at `/favicon.*`.
|
|
|
|
---
|
|
|
|
## Caching
|
|
|
|
Bunext includes a file-based HTML cache for production. Caching is **disabled in development** — every request renders fresh. In production, a cron job runs every 30 seconds to delete expired cache entries.
|
|
|
|
Cache files are stored in `.bunext/public/cache/`. Each cached page produces two files:
|
|
|
|
| File | Contents |
|
|
| ----------------- | ---------------------------------------------- |
|
|
| `<key>.res.html` | The cached HTML response body |
|
|
| `<key>.meta.json` | Metadata: creation timestamp, expiry, paradigm |
|
|
|
|
The cache is **cold on first request**: the first visitor triggers a full server render and writes the cache. Every subsequent request within the expiry window receives the cached HTML directly, bypassing the server function, component render, and bundler lookup. A cache hit is indicated by the response header `X-Bunext-Cache: HIT`.
|
|
|
|
### Enabling Cache Per Page
|
|
|
|
Export a `config` object from a page file to opt that page into caching:
|
|
|
|
```tsx
|
|
// src/pages/products.tsx
|
|
import type { BunextRouteConfig } from "@moduletrace/bunext/types";
|
|
|
|
export const config: BunextRouteConfig = {
|
|
cachePage: true,
|
|
cacheExpiry: 300, // seconds — optional, overrides the global default
|
|
};
|
|
|
|
export default function ProductsPage() {
|
|
return <h1>Products</h1>;
|
|
}
|
|
```
|
|
|
|
### Dynamic Cache Control from Server Function
|
|
|
|
Cache settings can also be returned from the server function, which lets you conditionally enable caching based on request data:
|
|
|
|
```ts
|
|
// src/pages/products.server.ts
|
|
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
|
|
|
const server: BunextPageServerFn = async (ctx) => {
|
|
const data = await fetchProducts();
|
|
|
|
return {
|
|
props: { data },
|
|
cachePage: true,
|
|
cacheExpiry: 600, // 10 minutes
|
|
};
|
|
};
|
|
|
|
export default server;
|
|
```
|
|
|
|
```tsx
|
|
// src/pages/products.tsx
|
|
export default function ProductsPage({ props }: any) {
|
|
return (
|
|
<ul>
|
|
{props.data.map((p: any) => (
|
|
<li key={p.id}>{p.name}</li>
|
|
))}
|
|
</ul>
|
|
);
|
|
}
|
|
```
|
|
|
|
If both `module.config.cachePage` and `serverRes.cachePage` are set, `module.config` takes precedence.
|
|
|
|
### Cache Expiry
|
|
|
|
Expiry resolution order (first truthy value wins):
|
|
|
|
1. `cacheExpiry` on the page `config` export or `server` return (per-page, in seconds)
|
|
2. `defaultCacheExpiry` in `bunext.config.ts` (global default, in seconds)
|
|
3. Built-in default: **3600 seconds (1 hour)**
|
|
|
|
The cron job checks all cache entries every 30 seconds and deletes any whose age exceeds their expiry. Static bundled assets (JS/CSS in `.bunext/public/`) receive a separate HTTP `Cache-Control: public, max-age=604800` header (7 days) via the browser cache — this is independent of the page HTML cache.
|
|
|
|
### Cache Behavior and Limitations
|
|
|
|
- **Production only.** Caching never activates in development (`bunext dev`).
|
|
- **Cold start required.** The cache is populated on the first request; there is no pre-warming step.
|
|
- **Immutable within the expiry window.** Once a page is cached, `writeCache` skips all subsequent write attempts for that key until the cron job deletes the expired entry. There is no manual invalidation API.
|
|
- **Cache is not cleared on rebuild.** Deploying a new build does not automatically flush `.bunext/public/cache/`. Stale HTML files referencing old JS bundles can be served until they expire. Clear the cache directory as part of your deploy process if needed.
|
|
- **No key collision.** Cache keys are generated via `encodeURIComponent()` on the URL path. `/foo/bar` encodes to `%2Ffoo%2Fbar` and `/foo-bar` to `%2Ffoo-bar` — distinct filenames with no collision risk.
|
|
|
|
---
|
|
|
|
## Configuration
|
|
|
|
Create a `bunext.config.ts` file in your project root to configure Bunext:
|
|
|
|
```ts
|
|
// bunext.config.ts
|
|
import type { BunextConfig } from "@moduletrace/bunext/types";
|
|
|
|
const config: BunextConfig = {
|
|
port: 3000, // default: 7000
|
|
origin: "https://example.com",
|
|
distDir: ".bunext", // directory for internal build artifacts
|
|
assetsPrefix: "_bunext/static",
|
|
globalVars: {
|
|
MY_API_URL: "https://api.example.com",
|
|
},
|
|
public_envs: {
|
|
BUNEXT_PUBLIC_APP_NAME: "My App",
|
|
},
|
|
development: false, // forced by the CLI; set manually if needed
|
|
};
|
|
|
|
export default config;
|
|
```
|
|
|
|
| Option | Type | Default | Description |
|
|
| -------------------- | --------------------------------------------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------- |
|
|
| `port` | `number` | `7000` | HTTP server port |
|
|
| `origin` | `string` | — | Canonical origin URL |
|
|
| `distDir` | `string` | `.bunext` | Internal artifact directory |
|
|
| `assetsPrefix` | `string` | `_bunext/static` | URL prefix for static assets |
|
|
| `globalVars` | `{ [k: string]: any }` | — | Variables injected globally at build time |
|
|
| `public_envs` | `Record<string, string>` | — | Public env vars exposed to the client via `window.process.env` (see [Environment Variables](#environment-variables)) |
|
|
| `development` | `boolean` | — | Overridden to `true` by `bunext dev` automatically |
|
|
| `defaultCacheExpiry` | `number` | `3600` | Global page cache expiry in seconds |
|
|
| `middleware` | `(params: BunextConfigMiddlewareParams) => Response \| undefined \| Promise<...>` | — | Global middleware — see [Middleware](#middleware) |
|
|
| `websocket` | `WebSocketHandler<any>` | — | Bun WebSocket handler — see [WebSocket](#websocket) |
|
|
| `serverOptions` | `ServeOptions` | — | Extra options passed to `Bun.serve()` (excluding `fetch`) — see [Server Options](#server-options) |
|
|
| `sitemap` | `BunextSitemapConfig` | — | Options for `bunext sitemap` — see [Sitemap](#sitemap) |
|
|
| `robots` | `BunextRobotsConfig \| false` | — | robots.txt options for `bunext sitemap`, or `false` to skip it — see [robots.txt](#robotstxt) |
|
|
|
|
### Middleware
|
|
|
|
Middleware runs on every request before any routing. Define it in `bunext.config.ts` via the `middleware` field. The function receives `{ req, url, server }` (type `BunextConfigMiddlewareParams`).
|
|
|
|
- If it returns a `Response`, that response is sent to the client immediately and no further routing occurs.
|
|
- If it returns `undefined` (or nothing), the request proceeds normally through the router.
|
|
|
|
```ts
|
|
// bunext.config.ts
|
|
import type {
|
|
BunextConfig,
|
|
BunextConfigMiddlewareParams,
|
|
} from "@moduletrace/bunext/types";
|
|
|
|
const config: BunextConfig = {
|
|
middleware: async ({ req, url }) => {
|
|
// Example: protect all /dashboard/* routes
|
|
if (url.pathname.startsWith("/dashboard")) {
|
|
const token = req.headers.get("authorization");
|
|
|
|
if (!token) {
|
|
return Response.redirect("/login", 302);
|
|
}
|
|
}
|
|
|
|
// Return undefined to continue to the normal request pipeline
|
|
return undefined;
|
|
},
|
|
};
|
|
|
|
export default config;
|
|
```
|
|
|
|
The middleware can return any valid `Response`, including redirects, JSON, or HTML:
|
|
|
|
```ts
|
|
middleware: async ({ req, url }) => {
|
|
// Block a specific path
|
|
if (url.pathname === "/maintenance") {
|
|
return new Response("Down for maintenance", { status: 503 });
|
|
}
|
|
|
|
// For API routes, return undefined to continue — the handler controls its own Response directly
|
|
},
|
|
```
|
|
|
|
### WebSocket
|
|
|
|
Add a `websocket` field to `bunext.config.ts` to handle WebSocket connections. The value is passed directly to `Bun.serve()` as the `websocket` option and accepts the full [`WebSocketHandler`](https://bun.sh/docs/api/websockets) interface.
|
|
|
|
Define the handler in its own file and import it into the config:
|
|
|
|
```ts
|
|
// websocket.ts
|
|
import type { WebSocketHandler } from "bun";
|
|
|
|
export const BunextWebsocket: WebSocketHandler<any> = {
|
|
message(ws, message) {
|
|
console.log(`WS Message => ${message}`);
|
|
},
|
|
};
|
|
```
|
|
|
|
```ts
|
|
// bunext.config.ts
|
|
import type { BunextConfig } from "@moduletrace/bunext/types";
|
|
import { BunextWebsocket } from "./websocket";
|
|
|
|
const config: BunextConfig = {
|
|
websocket: BunextWebsocket,
|
|
};
|
|
|
|
export default config;
|
|
```
|
|
|
|
### Server Options
|
|
|
|
Pass additional options to the underlying `Bun.serve()` call via `serverOptions`. All standard [`ServeOptions`](https://bun.sh/docs/api/http) fields are accepted except `fetch`, which Bunext manages internally.
|
|
|
|
```ts
|
|
// bunext.config.ts
|
|
import type { BunextConfig } from "@moduletrace/bunext/types";
|
|
|
|
const config: BunextConfig = {
|
|
serverOptions: {
|
|
tls: {
|
|
cert: Bun.file("./certs/cert.pem"),
|
|
key: Bun.file("./certs/key.pem"),
|
|
},
|
|
maxRequestBodySize: 64 * 1024 * 1024, // 64 MB
|
|
error(err) {
|
|
console.error("Server error:", err);
|
|
return new Response("Internal Server Error", { status: 500 });
|
|
},
|
|
},
|
|
};
|
|
|
|
export default config;
|
|
```
|
|
|
|
---
|
|
|
|
## Custom Server
|
|
|
|
For full control over the `Bun.serve()` instance — custom WebSocket upgrade logic, multi-protocol handling, or integrating Bunext alongside other handlers — you can skip `bunext dev` / `bunext start` and run your own server using Bunext's exported primitives.
|
|
|
|
```ts
|
|
// server.ts
|
|
import bunext from "@moduletrace/bunext";
|
|
|
|
const development = process.env.NODE_ENV === "development";
|
|
const port = process.env.PORT || 3700;
|
|
|
|
await bunext.bunextInit();
|
|
|
|
const server = Bun.serve({
|
|
routes: {
|
|
"/*": {
|
|
async GET(req) {
|
|
return await bunext.bunextRequestHandler({ req });
|
|
},
|
|
},
|
|
},
|
|
development,
|
|
port,
|
|
});
|
|
|
|
bunext.bunextLog.info(`Server running on http://localhost:${server.port} ...`);
|
|
```
|
|
|
|
| Export | Type | Description |
|
|
| ------------------------------- | ------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
| `bunextInit()` | `() => Promise<void>` | Initializes config, router, and bundler. Must be called before handling requests. |
|
|
| `bunextRequestHandler({ req })` | `(params: { req: Request }) => Promise<Response>` | The main Bunext request dispatcher — middleware, routing, SSR, static files. Only `req` is needed; the server instance is managed internally. |
|
|
| `bunextLog` | Logger | Framework logger (`info`, `error`, `success`, `server`, `watch`). |
|
|
|
|
Run the custom server directly with Bun:
|
|
|
|
```bash
|
|
bun run server.ts
|
|
```
|
|
|
|
> **Note:** When using a custom server, HMR and file watching are still driven by `bunextInit()`. Pass `development: true` in your `Bun.serve()` call to enable them.
|
|
|
|
---
|
|
|
|
## Environment Variables
|
|
|
|
| Variable | Description |
|
|
| ---------------------- | ------------------------------------------------------------------------------------------------ |
|
|
| `PORT` | Override the server port (takes precedence over config) |
|
|
| `BUNEXT_PUBLIC_*` | Any env var prefixed with `BUNEXT_PUBLIC_` is exposed to the client via `window.process.env` |
|
|
|
|
### Public Environment Variables
|
|
|
|
Variables prefixed with `BUNEXT_PUBLIC_` are automatically injected into every page as `window.process.env`. You can also define public envs in config via `public_envs` (config values override env vars of the same name):
|
|
|
|
```bash
|
|
# .env
|
|
BUNEXT_PUBLIC_API_URL=https://api.example.com
|
|
```
|
|
|
|
```ts
|
|
// bunext.config.ts
|
|
const config: BunextConfig = {
|
|
public_envs: {
|
|
BUNEXT_PUBLIC_APP_NAME: "My App",
|
|
},
|
|
};
|
|
```
|
|
|
|
```tsx
|
|
// Client component — available after hydration
|
|
const apiUrl = window.process.env.BUNEXT_PUBLIC_API_URL;
|
|
```
|
|
|
|
`window.process.env` always includes `NODE_ENV` (`"development"` or `"production"`).
|
|
|
|
---
|
|
|
|
## How It Works
|
|
|
|
### Development Server
|
|
|
|
Running `bunext dev`:
|
|
|
|
1. Loads `bunext.config.ts` and sets `development: true`.
|
|
2. Initializes directories (`.bunext/`, `public/pages/`).
|
|
3. Creates a `Bun.FileSystemRouter` pointed at `src/pages/`.
|
|
4. Creates an ESBuild context and performs the initial build. File-change rebuilds are triggered manually by the FS watcher.
|
|
5. Starts a file-system watcher on `src/` — when a file is created or deleted (a "rename" event), it triggers a full bundler rebuild to update the entry points.
|
|
6. Waits for the first successful bundle.
|
|
7. Starts `Bun.serve()`.
|
|
|
|
### Production Build
|
|
|
|
Running `bunext build`:
|
|
|
|
1. Sets `NODE_ENV=production`.
|
|
2. Runs ESBuild once with minification enabled.
|
|
3. Writes all bundled artifacts to `.bunext/public/pages/` plus the artifact maps `.bunext/public/pages/map.json` and `.bunext/public/pages/ssr-bundler-ctx-map.json`.
|
|
4. Artifact maps record paths relative to the project root so they resolve at request time regardless of where the build ran.
|
|
5. Exits.
|
|
|
|
### Production Server
|
|
|
|
Running `bunext start`:
|
|
|
|
1. Reads `.bunext/public/pages/map.json` to load the pre-built artifact map.
|
|
2. Starts `Bun.serve()` without any bundler or file watcher.
|
|
|
|
### Bundler
|
|
|
|
The bundler uses **ESBuild** with a virtual namespace plugin that generates in-memory hydration entry points for each page — no temporary files are written to disk. Each virtual entry imports the page component and calls `hydrateRoot()` against the server-rendered DOM node. If `src/pages/__root.tsx` exists, the page is wrapped in the root layout. Tailwind CSS is processed via a dedicated ESBuild plugin.
|
|
|
|
In development, an `esbuild.context()` is created once and rebuilt incrementally whenever the FS watcher detects a file change. In production, a single `esbuild.build()` call runs with minification enabled.
|
|
|
|
React is loaded externally — `react`, `react-dom`, `react-dom/client`, and `react/jsx-runtime` are all marked as external in the ESBuild config. The correct React version is resolved from the framework's own `node_modules` at startup and injected into every HTML page via a `<script type="importmap">` pointing at `esm.sh`. This guarantees a single shared React instance across all page bundles and HMR updates regardless of project size.
|
|
|
|
After each build, ESBuild's metafile is used to map each output file back to its source page, producing a `BundlerCTXMap[]`. This map is stored in `global.BUNDLER_CTX_MAP` and written to `.bunext/public/pages/map.json`.
|
|
|
|
Output files are named `[hash].[ext]` so filenames change when content changes, enabling cache-busting.
|
|
|
|
### Hot Module Replacement
|
|
|
|
In development, Bunext injects a script into every HTML page that opens a **Server-Sent Events (SSE)** connection to `/__hmr`. When a rebuild completes and the bundle hash for a page changes, the server pushes an `update` event through the stream containing the new artifact metadata. The client then performs a **true in-place HMR update** — no full page reload:
|
|
|
|
1. If the page has a CSS bundle, the old `<link rel="stylesheet">` is replaced with a new one pointing to the updated CSS file (cache-busted with `?t=<timestamp>`).
|
|
2. The existing client hydration `<script>` (identified by `id="bunext-client-hydration-script"`) is removed from the DOM.
|
|
3. A new `<script type="module">` is injected pointing to the freshly rebuilt JS bundle (also cache-busted).
|
|
4. The new bundle calls `window.__BUNEXT_RERENDER__(NewComponent)` if the root is already mounted, otherwise falls back to a fresh `hydrateRoot`.
|
|
|
|
The endpoint `/__bunext_client_hmr__?href=<page-url>` handles on-demand HMR bundle generation: it re-bundles the target page's component on every request so the freshly injected script always reflects the latest source.
|
|
|
|
The SSE controller for each connected client is tracked in `global.HMR_CONTROLLERS`, keyed by the page URL and its bundled artifact map entry. On disconnect, the controller is removed from the list.
|
|
|
|
### Request Pipeline
|
|
|
|
Every incoming request is handled by `Bun.serve()` and routed as follows:
|
|
|
|
```
|
|
Request
|
|
│
|
|
├── config.middleware({ req, url, server })
|
|
│ Returns Response? → short-circuit, send response immediately
|
|
│ Returns undefined → continue
|
|
│
|
|
├── GET /__hmr → Open SSE stream for HMR (dev only)
|
|
│
|
|
├── GET /__bunext_client_hmr__?href= → On-demand HMR bundle for the given page URL (dev only)
|
|
│
|
|
├── /api/* → API route handler
|
|
│ Matches src/pages/api/<path>.ts
|
|
│ Checks content-length against maxRequestBodyMB / 10 MB default
|
|
│ Calls module.default(ctx) → returns Response directly
|
|
│
|
|
├── /public/* → Serve static file from public/
|
|
│
|
|
├── /favicon.* → Serve favicon from public/
|
|
│
|
|
└── Everything else → Server-side render a page
|
|
[Production only] Check .bunext/public/cache/ for key = pathname + search
|
|
Cache HIT → return cached HTML with X-Bunext-Cache: HIT header
|
|
Cache MISS → continue ↓
|
|
1. Match route via FileSystemRouter
|
|
2. Find bundled artifact in BUNDLER_CTX_MAP
|
|
3. Import page module (with cache-busting timestamp in dev)
|
|
4. Import companion server module (<page>.server.ts/tsx) if it exists; run its exported function for server-side data
|
|
5. Resolve meta (static object or async function)
|
|
6. renderToString(component) → inject into HTML template
|
|
7. Inject window.__PAGE_PROPS__, hydration <script>, CSS <link>
|
|
8. If module.config.cachePage or serverRes.cachePage → write to cache
|
|
9. Return HTML response
|
|
On error → render 404 or 500 error page
|
|
```
|
|
|
|
Server-rendered HTML includes:
|
|
|
|
- `window.__PAGE_PROPS__` — the serialized server function return value, read by `hydrateRoot` on the client.
|
|
- A `<script type="importmap">` mapping React package specifiers to the esm.sh CDN (uses the `?dev` build in development).
|
|
- A `<script type="module" async>` tag pointing to the page's bundled client script.
|
|
- A `<link rel="stylesheet">` tag if the bundler emitted a CSS file for the page.
|
|
- In development: the HMR client script.
|