Add Caching
This commit is contained in:
parent
35930857fd
commit
32e914fdc1
32
README.md
32
README.md
@ -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.
|
||||
|
||||
@ -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
5
src/data/app-data.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export const AppData = {
|
||||
DefaultCacheExpiryTimeSeconds: 60 * 60,
|
||||
DefaultCronInterval: 30000,
|
||||
BunextStaticFilesCacheExpiry: 60 * 60 * 24 * 7,
|
||||
} as const;
|
||||
@ -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
26
src/functions/cache/get-cache.ts
vendored
Normal 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
12
src/functions/cache/grab-cache-names.ts
vendored
Normal 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
24
src/functions/cache/trim-all-cache.ts
vendored
Normal 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
60
src/functions/cache/trim-cache-key.ts
vendored
Normal 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
62
src/functions/cache/write-cache.ts
vendored
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
>)[];
|
||||
|
||||
9
src/functions/server/cron.tsx
Normal file
9
src/functions/server/cron.tsx
Normal 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"]);
|
||||
}
|
||||
}
|
||||
@ -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.")) {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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()) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user