From f6db3ab866030d9f991263369d611c9dab100a53 Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Tue, 17 Mar 2026 22:00:11 +0100 Subject: [PATCH] Bugfix Caching --- README.md | 97 +++++++++++++++++++++++-- src/functions/cache/grab-cache-names.ts | 2 +- src/functions/cache/trim-all-cache.ts | 2 +- 3 files changed, 94 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 40ec0c7..7772878 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,11 @@ A Next.js-style full-stack meta-framework built on [Bun](https://bun.sh) and Rea - [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) - [Environment Variables](#environment-variables) @@ -135,8 +140,10 @@ my-app/ │ └── 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/ +│ ├── 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 @@ -463,6 +470,81 @@ public/ --- +## 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 | +|---------------------------|----------------------------------------------| +| `.res.html` | The cached HTML response body | +| `.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 "bunext/src/types"; + +export const config: BunextRouteConfig = { + cachePage: true, + cacheExpiry: 300, // seconds — optional, overrides the global default +}; + +export default function ProductsPage() { + return

Products

; +} +``` + +### 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 "bunext/src/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
    {props.data.map((p: any) =>
  • {p.name}
  • )}
; +} +``` + +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. +- **Key collision with dashes.** Cache keys are derived by replacing every `/` in the URL path with `-`. This means `/foo/bar` and `/foo-bar` produce the same cache filename and will share a cache entry. Avoid enabling `cachePage` on routes where a nested path and a dash-separated path could collide. + +--- + ## Configuration Create a `bunext.config.ts` file in your project root to configure Bunext: @@ -492,8 +574,9 @@ export default config; | `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) | +| `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) | ### Middleware @@ -623,6 +706,9 @@ Request ├── /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) @@ -630,7 +716,8 @@ Request 5. Resolve meta (static object or async function) 6. renderToString(component) → inject into HTML template 7. Inject window.__PAGE_PROPS__, hydration