bunext/README.md
2026-03-17 21:47:12 +01:00

643 lines
21 KiB
Markdown

# Bunext
A Next.js-style full-stack meta-framework built on [Bun](https://bun.sh) and React 19. Bunext provides server-side rendering, file-system based routing, Hot Module Replacement (HMR), and client-side hydration — using ESBuild to bundle client assets and Bun's native HTTP server (`Bun.serve`) to handle requests.
---
## 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)
- [Pages](#pages)
- [Basic Page](#basic-page)
- [Server Function](#server-function)
- [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)
- [Static Files](#static-files)
- [Configuration](#configuration)
- [Middleware](#middleware)
- [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 19 and react-dom 19 (peer dependencies)
---
## Installation
Install Bunext as a dependency in your project:
```bash
bun add bunext
```
Install required peer dependencies:
```bash
bun add react react-dom
bun add -d typescript @types/react @types/react-dom
```
---
## 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. Start the development server:
```bash
bunext dev
```
4. 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 `public/pages/`. |
| `bunext start` | Start the production server using pre-built artifacts. |
```bash
# Development
bunext dev
# Production build
bunext build
# Production server (must run build first)
bunext start
```
> **Note:** `bunext start` will exit with an error if `public/pages/map.json` does not exist. Always run `bunext 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
│ ├── index.tsx # Route: /
│ ├── about.tsx # Route: /about
│ ├── 404.tsx # Optional: custom 404 page
│ ├── 500.tsx # Optional: custom 500 page
│ ├── blog/
│ │ ├── index.tsx # Route: /blog
│ │ └── [slug].tsx # Route: /blog/:slug (dynamic)
│ └── api/
│ └── users.ts # API route: /api/users
├── public/ # Static files and bundler output
│ └── pages/ # Generated by bundler (do not edit manually)
│ └── map.json # Artifact map used by production server
├── 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.
---
## 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
Export a `server` function to run server-side logic before rendering. The return value's `props` field is spread into the page component as props, and `query` carries route query parameters.
```tsx
// src/pages/profile.tsx
import type { BunextPageServerFn } from "bunext/src/types";
type Props = {
props?: { username: string; bio: string };
query?: Record<string, string>;
};
export 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 function ProfilePage({ props }: Props) {
return (
<div>
<h1>{props?.username}</h1>
<p>{props?.bio}</p>
</div>
);
}
```
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 |
### Redirects from Server
Return a `redirect` object from the `server` function to redirect the client:
```tsx
export 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: {} };
};
```
`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:
```tsx
export const server: BunextPageServerFn = async (ctx) => {
return {
props: { message: "Created" },
responseOptions: {
status: 201,
headers: {
"X-Custom-Header": "my-value",
},
},
};
};
```
### SEO Metadata
Export a `meta` object to inject SEO and Open Graph tags into the `<head>`:
```tsx
import type { BunextPageModuleMeta } from "bunext/src/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:
```tsx
import type { BunextPageModuleMetaFn } from "bunext/src/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 "bunext/src/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:
```tsx
// src/pages/__root.tsx
import type { PropsWithChildren } from "react";
export default function RootLayout({
children,
props,
}: PropsWithChildren<any>) {
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 "bunext/src/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 "bunext/src/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 "bunext/src/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.
---
## 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.*`.
---
## Configuration
Create a `bunext.config.ts` file in your project root to configure Bunext:
```ts
// bunext.config.ts
import type { BunextConfig } from "bunext/src/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",
},
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 |
| `development` | `boolean` | — | Overridden to `true` by `bunext dev` automatically |
| `middleware` | `(params: BunextConfigMiddlewareParams) => Response \| undefined \| Promise<...>` | — | Global middleware — see [Middleware](#middleware) |
### 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 "bunext/src/types";
const config: BunextConfig = {
middleware: async ({ req, url, server }) => {
// 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 });
}
// Add CORS headers to all API responses
if (url.pathname.startsWith("/api/")) {
// Let the request proceed, but transform the response via resTransform on individual routes
}
},
```
---
## Environment Variables
| Variable | Description |
| -------- | ------------------------------------------------------- |
| `PORT` | Override the server port (takes precedence over config) |
---
## 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. Starts the ESBuild bundler in **watch mode** — it will automatically rebuild when file content changes.
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 (not in watch mode) with minification enabled.
3. Writes all bundled artifacts to `public/pages/` and the artifact map to `public/pages/map.json`.
4. Exits.
### Production Server
Running `bunext start`:
1. Reads `public/pages/map.json` to load the pre-built artifact map.
2. Starts `Bun.serve()` without any bundler or file watcher.
### Bundler
The bundler (`allPagesBundler`) uses ESBuild with three custom plugins:
- **`tailwindcss` plugin** — Processes any `.css` files through PostCSS + Tailwind CSS before bundling.
- **`virtual-entrypoints` plugin** — Generates an in-memory client hydration entry point for each page. Each 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.
- **`artifact-tracker` plugin** — After each build, collects all output file paths, content hashes, and source entrypoints into a `BundlerCTXMap[]`. This map is stored in `global.BUNDLER_CTX_MAP` and written to `public/pages/map.json`.
Output files are named `[dir]/[name]/[hash]` 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 event through the SSE stream and the client reloads the page automatically.
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)
├── /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
1. Match route via FileSystemRouter
2. Find bundled artifact in BUNDLER_CTX_MAP
3. Import page module (with cache-busting timestamp in dev)
4. Run module.server(ctx) 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. 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="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.