1.4.0: not_found, server-only response options, compression
- Server functions can return not_found: true to render the 404 page with status 404. - Page components and window.__PAGE_PROPS__ (and HMR updates) only get props, query, url and error. response_options, redirect, cache settings and res_transform stay on the server, so cookies and private headers no longer end up in the HTML. BunextPageProps is narrowed to match (breaking for code that read server-only fields from component props). - response_options headers go through a Headers object, so repeated headers such as several Set-Cookie survive. - Redirects: status_code wins over permanent; response_options headers and res_transform apply to redirects too. - Gzip for compressible text responses; compress: false to turn it off. - Files the router matches but never bundles (.server.ts, __root, excluded paths and directories) return 404 instead of looping through dev rebuild retries. - Missing public files return 404 rather than 403, so dotted page paths like /users/john.doe fall through to pages again. - importFresh detects rewrites within the filesystem's mtime granularity. - README: self-hosted React (not esm.sh), snake_case option names, static file serving, cache headers, the new options. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -28,6 +28,7 @@ The goal is a framework that is:
|
||||
- [Server Function](#server-function)
|
||||
- [Default Server Props](#default-server-props)
|
||||
- [Redirects from Server](#redirects-from-server)
|
||||
- [Not Found from Server](#not-found-from-server)
|
||||
- [Custom Response Options](#custom-response-options)
|
||||
- [SEO Metadata](#seo-metadata)
|
||||
- [Dynamic Metadata](#dynamic-metadata)
|
||||
@@ -314,7 +315,7 @@ const server: BunextPageServerFn<{
|
||||
// ctx.req — the raw Request object
|
||||
// ctx.url — the parsed URL
|
||||
// ctx.query — query string parameters
|
||||
// ctx.resTransform — optional response interceptor
|
||||
// ctx.res_transform — optional response interceptor
|
||||
|
||||
const username = "alice";
|
||||
const bio = "Software engineer";
|
||||
@@ -358,7 +359,11 @@ The server function receives a `ctx` object (type `BunxRouteParams`) with:
|
||||
| `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 |
|
||||
| `res_transform` | `(res: Response) => Promise<Response>\|Response` | Intercept and transform the final response |
|
||||
|
||||
### What the Component Receives
|
||||
|
||||
The page and root components receive `props`, `query`, `url` and `error` from the server function's result. The same object is serialized into the HTML as `window.__PAGE_PROPS__` for hydration, so treat everything in it as public. The rest of the result (`response_options`, `redirect`, `not_found`, `cache_page`, `cache_expiry`, `res_transform`) is server-only: it controls the response and never reaches the browser.
|
||||
|
||||
### Default Server Props
|
||||
|
||||
@@ -414,8 +419,8 @@ const server: BunextPageServerFn = async (ctx) => {
|
||||
return {
|
||||
redirect: {
|
||||
destination: "/login",
|
||||
permanent: false, // uses 302
|
||||
// status_code: 307 // override status code
|
||||
// permanent: true, // 301 instead of 302
|
||||
// status_code: 307, // any status; wins over `permanent`
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -426,7 +431,28 @@ const server: BunextPageServerFn = async (ctx) => {
|
||||
export default server;
|
||||
```
|
||||
|
||||
`permanent: true` sends a `301` redirect. Otherwise it sends `302`, or the value of `status_code` if provided.
|
||||
The status is `status_code` if set, otherwise `301` with `permanent: true`, otherwise `302`. Use `307`/`308` to keep the request method and body. Headers from `response_options` (e.g. a `Set-Cookie` after login) are sent with the redirect.
|
||||
|
||||
### Not Found from Server
|
||||
|
||||
Return `not_found: true` to answer with the 404 page and status `404`, exactly as if the route didn't exist. Use it for dynamic routes whose record doesn't exist:
|
||||
|
||||
```ts
|
||||
// src/pages/blog/[slug].server.ts
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
|
||||
const server: BunextPageServerFn = async (ctx) => {
|
||||
const post = await findPost(ctx.query?.slug);
|
||||
|
||||
if (!post) {
|
||||
return { not_found: true };
|
||||
}
|
||||
|
||||
return { props: { post } };
|
||||
};
|
||||
|
||||
export default server;
|
||||
```
|
||||
|
||||
### Custom Response Options
|
||||
|
||||
@@ -439,7 +465,7 @@ import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
const server: BunextPageServerFn = async (ctx) => {
|
||||
return {
|
||||
props: { message: "Created" },
|
||||
responseOptions: {
|
||||
response_options: {
|
||||
status: 201,
|
||||
headers: {
|
||||
"X-Custom-Header": "my-value",
|
||||
@@ -451,6 +477,20 @@ const server: BunextPageServerFn = async (ctx) => {
|
||||
export default server;
|
||||
```
|
||||
|
||||
`response_options` is server-only: it is never serialized into the page, so it's safe for cookies and other private headers. A plain headers object holds each name once; to send a header several times, such as multiple cookies, pass a `Headers` object or an array of pairs:
|
||||
|
||||
```ts
|
||||
return {
|
||||
props: {},
|
||||
response_options: {
|
||||
headers: [
|
||||
["Set-Cookie", "session=abc; HttpOnly; Secure; Path=/"],
|
||||
["Set-Cookie", "theme=dark; Path=/"],
|
||||
],
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### SEO Metadata
|
||||
|
||||
Export a `meta` object from the **page file** (not the server file) to inject SEO and Open Graph tags into the `<head>`:
|
||||
@@ -607,7 +647,7 @@ import type {
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
export const config: BunextServerRouteConfig = {
|
||||
maxRequestBodyMB: 50, // allow up to 50 MB
|
||||
max_request_body_mb: 50, // allow up to 50 MB
|
||||
};
|
||||
|
||||
export default async function handler(ctx: BunxRouteParams): Promise<Response> {
|
||||
@@ -863,16 +903,17 @@ Nothing is written to disk, so the same build can be deployed to production with
|
||||
|
||||
## Static Files
|
||||
|
||||
Place static files in the `public/` directory. They are served at the `/public/` URL path:
|
||||
Place static files in the `public/` directory. Every file is served under `/public/`, and files whose name has an extension are also served from the site root, like Next.js does:
|
||||
|
||||
```
|
||||
public/
|
||||
├── logo.png → http://localhost:7000/public/logo.png
|
||||
├── styles.css → http://localhost:7000/public/styles.css
|
||||
└── favicon.ico → http://localhost:7000/favicon.ico
|
||||
├── logo.png → /public/logo.png and /logo.png
|
||||
├── robots.txt → /public/robots.txt and /robots.txt
|
||||
├── images/hero.webp → /public/images/hero.webp and /images/hero.webp
|
||||
└── favicon.ico → /public/favicon.ico and /favicon.ico
|
||||
```
|
||||
|
||||
> Favicons are also served from `/public/` but matched directly at `/favicon.*`.
|
||||
A root path that looks like a file but has no match in `public/` falls through to the pages, so routes like `/users/john.doe` still work. Files get `Cache-Control: public, max-age=3600` in production.
|
||||
|
||||
---
|
||||
|
||||
@@ -920,8 +961,8 @@ const server: BunextPageServerFn = async (ctx) => {
|
||||
|
||||
return {
|
||||
props: { data },
|
||||
cachePage: true,
|
||||
cacheExpiry: 600, // 10 minutes
|
||||
cache_page: true,
|
||||
cache_expiry: 600, // 10 minutes
|
||||
};
|
||||
};
|
||||
|
||||
@@ -941,17 +982,17 @@ export default function ProductsPage({ props }: any) {
|
||||
}
|
||||
```
|
||||
|
||||
If both `module.config.cachePage` and `serverRes.cachePage` are set, `module.config` takes precedence.
|
||||
If both `config.cachePage` (page export) and `cache_page` (server return) are set, `config.cachePage` 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)
|
||||
1. `cacheExpiry` on the page `config` export, or `cache_expiry` in the `server` return (per-page, in seconds)
|
||||
2. `default_cache_expiry` 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.
|
||||
The cron job checks all cache entries every 30 seconds and deletes any whose age exceeds their expiry. Bundled assets (JS/CSS in `.bunext/public/`) are served with `Cache-Control: public, max-age=3600` in production, independent of the page HTML cache.
|
||||
|
||||
### Cache Behavior and Limitations
|
||||
|
||||
@@ -975,8 +1016,8 @@ const config: BunextConfig = {
|
||||
port: 3000, // default: 7000
|
||||
origin: "https://example.com",
|
||||
dist_dir: ".bunext", // directory for build artifacts
|
||||
assetsPrefix: "_bunext/static",
|
||||
globalVars: {
|
||||
assets_prefix: "_bunext/static",
|
||||
global_vars: {
|
||||
MY_API_URL: "https://api.example.com",
|
||||
},
|
||||
public_envs: {
|
||||
@@ -993,17 +1034,19 @@ export default config;
|
||||
| `port` | `number` | `7000` | HTTP server port |
|
||||
| `origin` | `string` | — | Canonical origin URL |
|
||||
| `dist_dir` | `string` | `.bunext` | Build artifact directory, relative to the project root — see [Dist Directory](#dist-directory) |
|
||||
| `assetsPrefix` | `string` | `_bunext/static` | URL prefix for static assets |
|
||||
| `globalVars` | `{ [k: string]: any }` | — | Variables injected globally at build time |
|
||||
| `assets_prefix` | `string` | `_bunext/static` | URL prefix for static assets |
|
||||
| `global_vars` | `{ [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 |
|
||||
| `default_cache_expiry` | `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) |
|
||||
| `server_options` | `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) |
|
||||
| `disable_seo` | `boolean` | `false` | Keep the whole site out of search engines (e.g. preview deployments) — see [Disabling SEO](#disabling-seo) |
|
||||
| `compress` | `boolean` | `true` | Gzip text responses (HTML, JS, CSS, JSON, SVG) for clients that accept it. Set `false` when a reverse proxy already compresses |
|
||||
| `pages_exclude_patterns` | `RegExp[]` | — | Files and directories under `src/pages` to leave out of routing and bundling, matched against absolute paths. Requests for them get the 404 page |
|
||||
| `minify_ssr_bundle` | `boolean` | `true` | Minify the SSR bundle. SSR output is served from local disk, not downloaded by browsers, so set `false` to cut build memory on constrained hosts. Never minified in dev |
|
||||
|
||||
### Middleware
|
||||
@@ -1083,14 +1126,14 @@ 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.
|
||||
Pass additional options to the underlying `Bun.serve()` call via `server_options`. 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: {
|
||||
server_options: {
|
||||
tls: {
|
||||
cert: Bun.file("./certs/cert.pem"),
|
||||
key: Bun.file("./certs/key.pem"),
|
||||
@@ -1226,7 +1269,7 @@ The bundler uses **ESBuild** with a virtual namespace plugin that generates in-m
|
||||
|
||||
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.
|
||||
React is loaded externally — `react`, `react-dom`, `react-dom/client`, and `react/jsx-runtime` are all marked as external in the ESBuild config. At startup Bunext bundles the project's own `react` and `react-dom` into `.bunext/public/vendor/` and injects a `<script type="importmap">` into every page that maps the React specifiers to those files, so nothing is loaded from a CDN. 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`.
|
||||
|
||||
@@ -1262,33 +1305,39 @@ Request
|
||||
│
|
||||
├── /api/* → API route handler
|
||||
│ Matches src/pages/api/<path>.ts
|
||||
│ Checks content-length against maxRequestBodyMB / 10 MB default
|
||||
│ Checks content-length against max_request_body_mb / 10 MB default
|
||||
│ Calls module.default(ctx) → returns Response directly
|
||||
│
|
||||
├── /public/* → Serve static file from public/
|
||||
│
|
||||
├── /favicon.* → Serve favicon from public/
|
||||
├── /<path>.<ext> → Serve public/<path>.<ext> if it exists,
|
||||
│ otherwise fall through to the pages
|
||||
│
|
||||
└── 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
|
||||
1. Match route via FileSystemRouter (404 for .server.ts, __root, excluded files)
|
||||
2. Find bundled artifact in BUNDLER_CTX_MAP
|
||||
3. Import page module (with cache-busting timestamp in dev)
|
||||
3. Import page module (re-imported when the file changes in dev)
|
||||
4. Import companion server module (<page>.server.ts/tsx) if it exists; run its exported function for server-side data
|
||||
redirect → redirect response; not_found → 404 page
|
||||
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
|
||||
6. renderToReadableStream(<html> document)
|
||||
7. Inject window.__PAGE_PROPS__ (client-safe props only), hydration <script>, CSS <link>
|
||||
8. If config.cachePage or cache_page → write to cache
|
||||
9. Return HTML response with response_options status/headers
|
||||
On error → render 404 or 500 error page
|
||||
|
||||
Every response from the branches above (not middleware short-circuits) is
|
||||
then gzipped if it is compressible text and the client accepts gzip,
|
||||
unless `compress: false`.
|
||||
```
|
||||
|
||||
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).
|
||||
- `window.__PAGE_PROPS__` — the client-safe part of the server function's result (`props`, `query`, `url`, `error`), read by `hydrateRoot` on the client.
|
||||
- A `<script type="importmap">` mapping React package specifiers to the self-hosted builds in `.bunext/public/vendor/` (development builds 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.
|
||||
|
||||
Reference in New Issue
Block a user