From 96e392f556fe8bf8a92fc34203d90d0c2f03ea8d Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Mon, 9 Nov 2026 04:14:56 +0100 Subject: [PATCH] 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 --- README.md | 123 ++++++++++++------ dist/commands/build/index.js | 4 +- dist/commands/dev/dev-spawn.js | 3 +- dist/commands/dev/index.js | 4 +- dist/commands/dist-dir-option.d.ts | 2 + dist/commands/dist-dir-option.js | 3 + dist/commands/index.js | 17 ++- dist/commands/sitemap/index.js | 2 + dist/commands/start/index.js | 4 +- .../all-pages-esbuild-context-bundler.js | 2 +- dist/functions/bundler/api-routes-bundler.js | 2 +- .../bundler/api-routes-context-bundler.js | 2 - .../bundler/bun-react-modules-bundler.js | 5 +- .../bundler/pages-ssr-context-bundler.js | 3 +- dist/functions/bundler/plugins/chunk-react.js | 6 +- .../plugins/esbuild-ctx-artifact-tracker.js | 2 +- .../plugins/ssr-ctx-artifact-tracker.js | 2 +- .../bundler/react-modules-bundler.js | 6 +- dist/functions/bunext-init.js | 9 +- dist/functions/cleanup-logs-dir.js | 2 +- dist/functions/server/bunext-req-handler.js | 7 +- dist/functions/server/cleanup-artifacts.js | 3 +- .../server/handle-bunext-public-assets.js | 6 +- dist/functions/server/handle-files.js | 11 +- dist/functions/server/handle-public.js | 13 +- dist/functions/server/server-post-build-fn.js | 3 +- dist/functions/server/watcher-esbuild-ctx.js | 47 +++++-- .../server/web-pages/generate-web-html.js | 9 +- ...web-page-response-from-component-return.js | 29 ++--- .../server/web-pages/grab-page-component.js | 18 +-- .../server/web-pages/grab-page-modules.js | 10 +- .../web-pages/grab-tsx-string-module.js | 4 +- dist/functions/setup-dist-dir.d.ts | 17 +++ dist/functions/setup-dist-dir.js | 34 +++++ dist/functions/write-error-file.js | 2 +- dist/types/index.d.ts | 38 +++++- dist/utils/compress-response.d.ts | 7 + dist/utils/compress-response.js | 45 +++++++ dist/utils/grab-bunext-public-url-path.d.ts | 7 + dist/utils/grab-bunext-public-url-path.js | 15 +++ dist/utils/grab-client-page-props.d.ts | 11 ++ dist/utils/grab-client-page-props.js | 23 ++++ dist/utils/grab-dir-names.d.ts | 7 + dist/utils/grab-dir-names.js | 21 ++- dist/utils/grab-dist-dir.d.ts | 21 +++ dist/utils/grab-dist-dir.js | 51 ++++++++ dist/utils/grab-redirect-response.d.ts | 13 ++ dist/utils/grab-redirect-response.js | 21 +++ dist/utils/import-fresh.js | 14 +- dist/utils/is-routable-page.d.ts | 10 ++ dist/utils/is-routable-page.js | 39 ++++++ dist/utils/not-found-error.d.ts | 9 ++ dist/utils/not-found-error.js | 12 ++ dist/utils/page-path-transform.js | 3 +- dist/utils/resolve-public-file.d.ts | 15 +++ dist/utils/resolve-public-file.js | 31 +++++ dist/utils/write-logs.js | 2 +- package.json | 2 +- src/__tests__/e2e/e2e.test.ts | 121 ++++++++++++++++- src/__tests__/utils/compress-response.test.ts | 63 +++++++++ .../utils/grab-client-page-props.test.ts | 28 ++++ src/__tests__/utils/is-routable-page.test.ts | 44 +++++++ .../utils/resolve-public-file.test.ts | 61 +++++++++ src/functions/server/bunext-req-handler.ts | 5 + src/functions/server/handle-files.ts | 12 +- src/functions/server/handle-public.ts | 17 ++- src/functions/server/server-post-build-fn.ts | 3 +- .../server/web-pages/generate-web-html.tsx | 10 +- ...eb-page-response-from-component-return.tsx | 36 +++-- .../server/web-pages/grab-page-component.tsx | 18 +-- .../server/web-pages/grab-page-modules.tsx | 14 +- src/types/index.ts | 29 ++++- src/utils/compress-response.ts | 50 +++++++ src/utils/grab-client-page-props.ts | 25 ++++ src/utils/grab-redirect-response.ts | 36 +++++ src/utils/import-fresh.ts | 16 ++- src/utils/is-routable-page.ts | 42 ++++++ src/utils/not-found-error.ts | 13 ++ src/utils/resolve-public-file.ts | 42 ++++++ 79 files changed, 1334 insertions(+), 184 deletions(-) create mode 100644 dist/commands/dist-dir-option.d.ts create mode 100644 dist/commands/dist-dir-option.js create mode 100644 dist/functions/setup-dist-dir.d.ts create mode 100644 dist/functions/setup-dist-dir.js create mode 100644 dist/utils/compress-response.d.ts create mode 100644 dist/utils/compress-response.js create mode 100644 dist/utils/grab-bunext-public-url-path.d.ts create mode 100644 dist/utils/grab-bunext-public-url-path.js create mode 100644 dist/utils/grab-client-page-props.d.ts create mode 100644 dist/utils/grab-client-page-props.js create mode 100644 dist/utils/grab-dist-dir.d.ts create mode 100644 dist/utils/grab-dist-dir.js create mode 100644 dist/utils/grab-redirect-response.d.ts create mode 100644 dist/utils/grab-redirect-response.js create mode 100644 dist/utils/is-routable-page.d.ts create mode 100644 dist/utils/is-routable-page.js create mode 100644 dist/utils/not-found-error.d.ts create mode 100644 dist/utils/not-found-error.js create mode 100644 dist/utils/resolve-public-file.d.ts create mode 100644 dist/utils/resolve-public-file.js create mode 100644 src/__tests__/utils/compress-response.test.ts create mode 100644 src/__tests__/utils/grab-client-page-props.test.ts create mode 100644 src/__tests__/utils/is-routable-page.test.ts create mode 100644 src/__tests__/utils/resolve-public-file.test.ts create mode 100644 src/utils/compress-response.ts create mode 100644 src/utils/grab-client-page-props.ts create mode 100644 src/utils/grab-redirect-response.ts create mode 100644 src/utils/is-routable-page.ts create mode 100644 src/utils/not-found-error.ts create mode 100644 src/utils/resolve-public-file.ts diff --git a/README.md b/README.md index 0ca6aaa..de629d1 100644 --- a/README.md +++ b/README.md @@ -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` | Intercept and transform the final response | +| `res_transform` | `(res: Response) => Promise\|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 ``: @@ -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 { @@ -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` | — | 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` | — | 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 `