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:
2026-11-09 04:14:56 +01:00
co-authored by Claude Opus 5.5
parent b410b92f9f
commit 96e392f556
79 changed files with 1334 additions and 184 deletions
+86 -37
View File
@@ -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.