Bugfix Caching

This commit is contained in:
Benjamin Toby 2026-03-17 22:00:11 +01:00
parent 32e914fdc1
commit f6db3ab866
3 changed files with 94 additions and 7 deletions

View File

@ -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 |
|---------------------------|----------------------------------------------|
| `<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 "bunext/src/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 "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 <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.
- **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 <script>, CSS <link>
8. Return HTML response
8. If module.config.cachePage or serverRes.cachePage → write to cache
9. Return HTML response
On error → render 404 or 500 error page
```

View File

@ -4,7 +4,7 @@ type Params = {
};
export default function grabCacheNames({ key, paradigm = "html" }: Params) {
const parsed_key = key.replace(/\//g, "-");
const parsed_key = encodeURIComponent(key);
const cache_name = `${parsed_key}.res.${paradigm}`;
const cache_meta_name = `${parsed_key}.meta.json`;

View File

@ -12,7 +12,7 @@ export default async function trimAllCache() {
const cached_item = cached_items[i];
if (!cached_item.endsWith(`.meta.json`)) continue;
const cache_key = cached_item.replace(/\.meta\.json/, "");
const cache_key = decodeURIComponent(cached_item.replace(/\.meta\.json/, ""));
const trim_key = await trimCacheKey({
key: cache_key,