Add Caching

This commit is contained in:
Benjamin Toby 2026-03-17 21:47:12 +01:00
parent 35930857fd
commit 32e914fdc1
17 changed files with 298 additions and 18 deletions

View File

@ -26,7 +26,7 @@ A Next.js-style full-stack meta-framework built on [Bun](https://bun.sh) and Rea
- [Error Pages](#error-pages)
- [Static Files](#static-files)
- [Configuration](#configuration)
- [Middleware](#middleware)
- [Middleware](#middleware)
- [Environment Variables](#environment-variables)
- [How It Works](#how-it-works)
- [Development Server](#development-server)
@ -402,7 +402,10 @@ The `ctx` parameter has the same shape as the page `server` function context —
Export a `config` object to override the per-route request body limit (default: 10 MB):
```ts
import type { BunextServerRouteConfig, BunxRouteParams } from "bunext/src/types";
import type {
BunextServerRouteConfig,
BunxRouteParams,
} from "bunext/src/types";
export const config: BunextServerRouteConfig = {
maxRequestBodyMB: 50, // allow up to 50 MB
@ -482,15 +485,15 @@ const config: BunextConfig = {
export default config;
```
| Option | Type | Default | Description |
| -------------- | -------------------------------------------------------------------- | ---------------- | -------------------------------------------------- |
| `port` | `number` | `7000` | HTTP server port |
| `origin` | `string` | — | Canonical origin URL |
| `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) |
| Option | Type | Default | Description |
| -------------- | --------------------------------------------------------------------------------- | ---------------- | -------------------------------------------------- |
| `port` | `number` | `7000` | HTTP server port |
| `origin` | `string` | — | Canonical origin URL |
| `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) |
### Middleware
@ -501,7 +504,10 @@ Middleware runs on every request before any routing. Define it in `bunext.config
```ts
// bunext.config.ts
import type { BunextConfig, BunextConfigMiddlewareParams } from "bunext/src/types";
import type {
BunextConfig,
BunextConfigMiddlewareParams,
} from "bunext/src/types";
const config: BunextConfig = {
middleware: async ({ req, url, server }) => {
@ -631,6 +637,6 @@ Request
Server-rendered HTML includes:
- `window.__PAGE_PROPS__` — the serialized server function return value, read by `hydrateRoot` on the client.
- A `<script type="module" defer>` tag pointing to the page's bundled client script.
- 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.

View File

@ -2,6 +2,7 @@
"name": "bunext",
"module": "index.ts",
"type": "module",
"version": "1.0.0",
"bin": {
"bunext": "dist/index.js"
},

5
src/data/app-data.ts Normal file
View File

@ -0,0 +1,5 @@
export const AppData = {
DefaultCacheExpiryTimeSeconds: 60 * 60,
DefaultCronInterval: 30000,
BunextStaticFilesCacheExpiry: 60 * 60 * 24 * 7,
} as const;

View File

@ -165,7 +165,7 @@ export default async function allPagesBundler(params?: Params) {
entryPoints: Object.keys(virtualEntries).map((k) => `virtual:${k}`),
outdir: HYDRATION_DST_DIR,
bundle: true,
minify: !dev,
minify: true,
format: "esm",
target: "es2020",
platform: "browser",
@ -178,6 +178,7 @@ export default async function allPagesBundler(params?: Params) {
metafile: true,
plugins: [tailwindPlugin, virtualPlugin, artifactTracker],
jsx: "automatic",
splitting: true,
});
await ctx.rebuild();

26
src/functions/cache/get-cache.ts vendored Normal file
View File

@ -0,0 +1,26 @@
import { readFileSync } from "fs";
import grabDirNames from "../../utils/grab-dir-names";
import grabCacheNames from "./grab-cache-names";
import path from "path";
type Params = {
key: string;
paradigm?: "html" | "json";
};
export default function getCache({ key, paradigm }: Params) {
try {
const { BUNEXT_CACHE_DIR } = grabDirNames();
const { cache_name } = grabCacheNames({ key, paradigm });
const content = readFileSync(
path.join(BUNEXT_CACHE_DIR, cache_name),
"utf-8",
);
return content;
} catch (error) {
return undefined;
}
}

12
src/functions/cache/grab-cache-names.ts vendored Normal file
View File

@ -0,0 +1,12 @@
type Params = {
key: string;
paradigm?: "html" | "json";
};
export default function grabCacheNames({ key, paradigm = "html" }: Params) {
const parsed_key = key.replace(/\//g, "-");
const cache_name = `${parsed_key}.res.${paradigm}`;
const cache_meta_name = `${parsed_key}.meta.json`;
return { cache_name, cache_meta_name };
}

24
src/functions/cache/trim-all-cache.ts vendored Normal file
View File

@ -0,0 +1,24 @@
import { readdirSync } from "fs";
import grabDirNames from "../../utils/grab-dir-names";
import trimCacheKey from "./trim-cache-key";
export default async function trimAllCache() {
try {
const { BUNEXT_CACHE_DIR } = grabDirNames();
const cached_items = readdirSync(BUNEXT_CACHE_DIR);
for (let i = 0; i < cached_items.length; i++) {
const cached_item = cached_items[i];
if (!cached_item.endsWith(`.meta.json`)) continue;
const cache_key = cached_item.replace(/\.meta\.json/, "");
const trim_key = await trimCacheKey({
key: cache_key,
});
}
} catch (error) {
return undefined;
}
}

60
src/functions/cache/trim-cache-key.ts vendored Normal file
View File

@ -0,0 +1,60 @@
import { readFileSync, unlinkSync } from "fs";
import grabDirNames from "../../utils/grab-dir-names";
import grabCacheNames from "./grab-cache-names";
import path from "path";
import type { APIResponseObject, BunextCacheFileMeta } from "../../types";
import { AppData } from "../../data/app-data";
type Params = {
key: string;
};
export default async function trimCacheKey({
key,
}: Params): Promise<APIResponseObject> {
try {
const { BUNEXT_CACHE_DIR } = grabDirNames();
const { cache_name, cache_meta_name } = grabCacheNames({
key,
});
const config = global.CONFIG;
const default_expiry_time_seconds =
config.defaultCacheExpiry ||
AppData["DefaultCacheExpiryTimeSeconds"];
const default_expiry_time_milliseconds =
default_expiry_time_seconds * 1000;
const cache_content_path = path.join(BUNEXT_CACHE_DIR, cache_name);
const cache_meta_path = path.join(BUNEXT_CACHE_DIR, cache_meta_name);
const cache_meta: BunextCacheFileMeta = JSON.parse(
readFileSync(cache_meta_path, "utf-8"),
);
const expiry_milliseconds = cache_meta.expiry_seconds
? cache_meta.expiry_seconds * 1000
: default_expiry_time_milliseconds;
if (Date.now() - cache_meta.date_created < expiry_milliseconds) {
return {
success: false,
msg: `Cache has not expired yet`,
};
}
unlinkSync(cache_content_path);
unlinkSync(cache_meta_path);
return {
success: true,
};
} catch (error: any) {
return {
success: false,
msg: `Trim cache key ERROR: ${error.message}`,
};
}
}

62
src/functions/cache/write-cache.ts vendored Normal file
View File

@ -0,0 +1,62 @@
import { existsSync, writeFileSync } from "fs";
import grabDirNames from "../../utils/grab-dir-names";
import grabCacheNames from "./grab-cache-names";
import type { APIResponseObject, BunextCacheFileMeta } from "../../types";
import path from "path";
type Params = {
key: string;
value: string;
paradigm?: "html" | "json";
expiry_seconds?: number;
};
export default async function writeCache({
key,
value,
paradigm = "html",
expiry_seconds,
}: Params): Promise<APIResponseObject> {
try {
const { BUNEXT_CACHE_DIR } = grabDirNames();
const { cache_meta_name, cache_name } = grabCacheNames({
key,
paradigm,
});
const target_path = path.join(BUNEXT_CACHE_DIR, cache_name);
if (existsSync(target_path)) {
return {
success: false,
msg: `Cache entry already exists`,
};
}
writeFileSync(path.join(target_path), value);
const cache_file_meta: BunextCacheFileMeta = {
date_created: Date.now(),
paradigm,
};
if (expiry_seconds) {
cache_file_meta.expiry_seconds = expiry_seconds;
}
writeFileSync(
path.join(BUNEXT_CACHE_DIR, cache_meta_name),
JSON.stringify(cache_file_meta),
);
return {
success: true,
};
} catch (error: any) {
return {
success: false,
msg: error.message,
};
}
}

View File

@ -1,9 +1,12 @@
import { existsSync, mkdirSync, statSync, writeFileSync } from "fs";
import grabDirNames from "../utils/grab-dir-names";
import { execSync } from "child_process";
export default async function () {
const dirNames = grabDirNames();
execSync(`rm -rf ${dirNames.BUNEXT_CACHE_DIR}`);
const keys = Object.keys(dirNames) as (keyof ReturnType<
typeof grabDirNames
>)[];

View File

@ -0,0 +1,9 @@
import { AppData } from "../../data/app-data";
import trimAllCache from "../cache/trim-all-cache";
export default async function cron() {
while (true) {
await trimAllCache();
await Bun.sleep(AppData["DefaultCronInterval"]);
}
}

View File

@ -6,6 +6,7 @@ import handleWebPages from "./web-pages/handle-web-pages";
import handleRoutes from "./handle-routes";
import isDevelopment from "../../utils/is-development";
import grabConstants from "../../utils/grab-constants";
import { AppData } from "../../data/app-data";
type Params = {
dev?: boolean;
@ -15,6 +16,8 @@ export default async function (params?: Params): Promise<ServeOptions> {
const port = grabAppPort();
const { PUBLIC_DIR } = grabDirNames();
const is_dev = isDevelopment();
return {
async fetch(req, server) {
try {
@ -34,7 +37,7 @@ export default async function (params?: Params): Promise<ServeOptions> {
}
}
if (url.pathname === "/__hmr" && isDevelopment()) {
if (url.pathname === "/__hmr" && is_dev) {
const referer_url = new URL(
req.headers.get("referer") || "",
);
@ -95,7 +98,15 @@ export default async function (params?: Params): Promise<ServeOptions> {
),
);
return new Response(file);
let res_opts: ResponseInit = {};
if (!is_dev && url.pathname.match(/__bunext/)) {
res_opts.headers = {
"Cache-Control": `public, max-age=${AppData["BunextStaticFilesCacheExpiry"]}, must-revalidate`,
};
}
return new Response(file, res_opts);
}
if (url.pathname.startsWith("/favicon.")) {

View File

@ -8,6 +8,7 @@ import grabDirNames from "../../utils/grab-dir-names";
import EJSON from "../../utils/ejson";
import { readFileSync } from "fs";
import type { BundlerCTXMap } from "../../types";
import cron from "./cron";
const { HYDRATION_DST_DIR_MAP_JSON_FILE } = grabDirNames();
@ -36,6 +37,7 @@ export default async function startServer(params?: Params) {
}
global.BUNDLER_CTX_MAP = artifacts;
global.IS_FIRST_BUNDLE_READY = true;
cron();
}
let bundle_ready_retries = 0;

View File

@ -46,7 +46,7 @@ export default async function genWebHTML({
}</script>\n`;
if (bundledMap?.path) {
html += ` <script src="/${bundledMap.path}" type="module" defer></script>\n`;
html += ` <script src="/${bundledMap.path}" type="module" async></script>\n`;
}
if (isDevelopment()) {

View File

@ -1,5 +1,7 @@
import type { GrabPageComponentRes } from "../../../types";
import isDevelopment from "../../../utils/is-development";
import getCache from "../../cache/get-cache";
import writeCache from "../../cache/write-cache";
import genWebHTML from "./generate-web-html";
import grabPageComponent from "./grab-page-component";
import grabPageErrorComponent from "./grab-page-error-component";
@ -12,6 +14,24 @@ export default async function handleWebPages({
req,
}: Params): Promise<Response> {
try {
if (!isDevelopment()) {
const url = new URL(req.url);
const key = url.pathname + (url.search || "");
const existing_cache = getCache({ key, paradigm: "html" });
if (existing_cache) {
const res_opts: ResponseInit = {
headers: {
"Content-Type": "text/html",
"X-Bunext-Cache": "HIT",
},
};
return new Response(existing_cache, res_opts);
}
}
const componentRes = await grabPageComponent({ req });
return await generateRes(componentRes);
} catch (error: any) {
@ -65,6 +85,20 @@ async function generateRes({
};
}
const cache_page =
module.config?.cachePage || serverRes?.cachePage || false;
const expiry_seconds = module.config?.cacheExpiry || serverRes?.cacheExpiry;
if (cache_page && routeParams?.url) {
const key = routeParams.url.pathname + (routeParams.url.search || "");
writeCache({
key,
value: html,
paradigm: "html",
expiry_seconds,
});
}
const res = new Response(html, res_opts);
if (routeParams?.resTransform) {

View File

@ -51,6 +51,7 @@ export type BunextConfig = {
middleware?: (
params: BunextConfigMiddlewareParams,
) => Promise<Response | undefined> | Response | undefined;
defaultCacheExpiry?: number;
};
export type BunextConfigMiddlewareParams = {
@ -157,6 +158,7 @@ export type BunextPageModule = {
server?: BunextPageServerFn;
meta?: BunextPageModuleMeta | BunextPageModuleMetaFn;
Head?: FC<BunextPageHeadFCProps>;
config?: BunextRouteConfig;
};
export type BunextPageModuleMetaFn = (params: {
@ -197,6 +199,14 @@ export type BunextPageServerFn<
ctx: Omit<BunxRouteParams, "body">,
) => Promise<BunextPageModuleServerReturn<T>>;
export type BunextRouteConfig = {
cachePage?: boolean;
/**
* Expiry time of the cache in seconds
*/
cacheExpiry?: number;
};
export type BunextPageModuleServerReturn<
T extends { [k: string]: any } = { [k: string]: any },
Q extends { [k: string]: any } = { [k: string]: any },
@ -205,6 +215,11 @@ export type BunextPageModuleServerReturn<
query?: Q;
redirect?: BunextPageModuleServerRedirect;
responseOptions?: ResponseInit;
cachePage?: boolean;
/**
* Expiry time of the cache in seconds
*/
cacheExpiry?: number;
};
export type BunextPageModuleServerRedirect = {
@ -250,3 +265,9 @@ export type GlobalHMRControllerObject = {
page_url: string;
target_map?: BundlerCTXMap;
};
export type BunextCacheFileMeta = {
date_created: number;
paradigm: "html" | "json";
expiry_seconds?: number;
};

View File

@ -6,7 +6,9 @@ export default function grabDirNames() {
const PAGES_DIR = path.join(SRC_DIR, "pages");
const API_DIR = path.join(PAGES_DIR, "api");
const PUBLIC_DIR = path.join(ROOT_DIR, "public");
const HYDRATION_DST_DIR = path.join(PUBLIC_DIR, "pages");
const BUNEXT_PUBLIC_DIR = path.join(PUBLIC_DIR, "__bunext");
const HYDRATION_DST_DIR = path.join(BUNEXT_PUBLIC_DIR, "pages");
const BUNEXT_CACHE_DIR = path.join(BUNEXT_PUBLIC_DIR, "cache");
const HYDRATION_DST_DIR_MAP_JSON_FILE = path.join(
HYDRATION_DST_DIR,
"map.json",
@ -54,5 +56,6 @@ export default function grabDirNames() {
BUNX_ROOT_404_PRESET_COMPONENT,
BUNX_ROOT_404_FILE_NAME,
HYDRATION_DST_DIR_MAP_JSON_FILE,
BUNEXT_CACHE_DIR,
};
}