950 lines
35 KiB
Markdown
950 lines
35 KiB
Markdown
# Bunext
|
|
|
|
A server-rendering framework for React, built 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)
|
|
- [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 19 and react-dom 19 (peer dependencies)
|
|
|
|
---
|
|
|
|
## Installation
|
|
|
|
Install Bunext directly from GitHub:
|
|
|
|
```bash
|
|
bun add github:moduletrace/bunext
|
|
```
|
|
|
|
Install globally:
|
|
|
|
```bash
|
|
bun add -g 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 `public/pages/`. |
|
|
| `bunext start` | Start the production server using pre-built artifacts. |
|
|
|
|
### 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 github:moduletrace/bunext
|
|
bunext dev
|
|
bunext build
|
|
bunext start
|
|
```
|
|
|
|
> **Note:** `bunext start` will exit with an error if `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
|
|
│ ├── 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
|
|
│ └── __bunext/
|
|
│ ├── pages/ # Generated by bundler (do not edit manually)
|
|
│ │ └── 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
|
|
|
|
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 "@moduletrace/bunext/types";
|
|
|
|
type Props = {
|
|
props?: { username: string; bio: string };
|
|
query?: Record<string, string>;
|
|
url?: URL;
|
|
};
|
|
|
|
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, url }: Props) {
|
|
return (
|
|
<div>
|
|
<h1>{props?.username}</h1>
|
|
<p>{props?.bio}</p>
|
|
<p>Current path: {url?.pathname}</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 |
|
|
|
|
### 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:
|
|
|
|
```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 "@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:
|
|
|
|
```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:
|
|
|
|
```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 "@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.
|
|
|
|
---
|
|
|
|
## 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 `public/__bunext/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:
|
|
|
|
```tsx
|
|
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
|
|
|
export const server: BunextPageServerFn = async (ctx) => {
|
|
const data = await fetchProducts();
|
|
|
|
return {
|
|
props: { data },
|
|
cachePage: true,
|
|
cacheExpiry: 600, // 10 minutes
|
|
};
|
|
};
|
|
|
|
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 `public/__bunext/`) 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 `public/__bunext/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",
|
|
},
|
|
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 |
|
|
| `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) |
|
|
|
|
### 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) |
|
|
|
|
---
|
|
|
|
## 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 `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 public/__bunext/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. 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. 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="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.
|