This commit is contained in:
2026-03-23 07:42:00 +01:00
parent 567fb4f746
commit 4b8b610e32
43 changed files with 635 additions and 169 deletions
+24 -21
View File
@@ -1,6 +1,6 @@
# Bunext
A server-rendering framework for React, built on [Bun](https://bun.sh). Bunext handles file-system routing, SSR, HMR, and client hydration — using ESBuild to bundle client assets and `Bun.serve` as the HTTP server.
A server-rendering framework for React, built entirely on [Bun](https://bun.sh). Bunext handles file-system routing, SSR, HMR, and client hydration — using `Bun.build` to bundle client assets and `Bun.serve` as the HTTP server.
## Philosophy
@@ -8,7 +8,7 @@ Bunext is focused on **server-side rendering and processing**. Every page is ren
The goal is a framework that is:
- Fast — Bun's runtime speed and ESBuild's bundling make the full dev loop snappy
- Fast — Bun's runtime speed and Bun.build's bundling make the full dev loop snappy
- Transparent — the entire request pipeline is readable and debugable
- Standard — server functions and API handlers use native Web APIs (`Request`, `Response`, `URL`) with no custom wrappers
@@ -61,7 +61,8 @@ The goal is a framework that is:
- [Bun](https://bun.sh) v1.0 or later
- TypeScript 5.0+
- React 19 and react-dom 19 (peer dependencies)
> **React is managed by Bunext.** You do not need to install `react` or `react-dom` — Bunext enforces its own pinned React version and removes any user-installed copies at startup to prevent version conflicts. Installing this package is all you need.
---
@@ -152,7 +153,7 @@ bun run dev
| Command | Description |
| -------------- | ---------------------------------------------------------------------- |
| `bunext dev` | Start the development server with HMR and file watching. |
| `bunext build` | Bundle all pages for production. Outputs artifacts to `public/pages/`. |
| `bunext build` | Bundle all pages for production. Outputs artifacts to `.bunext/public/pages/`. |
| `bunext start` | Start the production server using pre-built artifacts. |
### Running the CLI
@@ -186,7 +187,7 @@ bunext build
bunext start
```
> **Note:** `bunext start` will exit with an error if `public/pages/map.json` does not exist. Always run `bunext build` (or `bun run build`) before `bunext start`.
> **Note:** `bunext start` will exit with an error if `.bunext/public/pages/map.json` does not exist. Always run `bunext build` (or `bun run build`) before `bunext start`.
---
@@ -208,9 +209,10 @@ my-app/
│ │ └── [slug].tsx # Route: /blog/:slug (dynamic)
│ └── api/
│ └── users.ts # API route: /api/users
├── public/ # Static files and bundler output
│ └── __bunext/
├── pages/ # Generated by bundler (do not edit manually)
├── public/ # Static files served at /public/*
── .bunext/ # Internal build artifacts (do not edit manually)
└── public/
│ ├── pages/ # Generated by bundler
│ │ └── map.json # Artifact map used by production server
│ └── cache/ # File-based HTML cache (production only)
├── bunext.config.ts # Optional configuration
@@ -605,7 +607,7 @@ public/
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:
Cache files are stored in `.bunext/public/cache/`. Each cached page produces two files:
| File | Contents |
| ----------------- | ---------------------------------------------- |
@@ -670,14 +672,14 @@ Expiry resolution order (first truthy value wins):
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.
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.
### 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.
- **Cache is not cleared on rebuild.** Deploying a new build does not automatically flush `.bunext/public/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.
- **No key collision.** Cache keys are generated via `encodeURIComponent()` on the URL path. `/foo/bar` encodes to `%2Ffoo%2Fbar` and `/foo-bar` to `%2Ffoo-bar` — distinct filenames with no collision risk.
---
@@ -880,7 +882,7 @@ Running `bunext dev`:
1. Loads `bunext.config.ts` and sets `development: true`.
2. Initializes directories (`.bunext/`, `public/pages/`).
3. Creates a `Bun.FileSystemRouter` pointed at `src/pages/`.
4. Starts the ESBuild bundler in **watch mode** — it will automatically rebuild when file content changes.
4. Starts `Bun.build` in **watch mode** — it will automatically rebuild when file content changes.
5. Starts a file-system watcher on `src/` — when a file is created or deleted (a "rename" event), it triggers a full bundler rebuild to update the entry points.
6. Waits for the first successful bundle.
7. Starts `Bun.serve()`.
@@ -890,26 +892,26 @@ Running `bunext dev`:
Running `bunext build`:
1. Sets `NODE_ENV=production`.
2. Runs ESBuild once (not in watch mode) with minification enabled.
3. Writes all bundled artifacts to `public/pages/` and the artifact map to `public/pages/map.json`.
2. Runs `Bun.build` once with minification enabled.
3. Writes all bundled artifacts to `.bunext/public/pages/` and the artifact map to `.bunext/public/pages/map.json`.
4. Exits.
### Production Server
Running `bunext start`:
1. Reads `public/pages/map.json` to load the pre-built artifact map.
1. Reads `.bunext/public/pages/map.json` to load the pre-built artifact map.
2. Starts `Bun.serve()` without any bundler or file watcher.
### Bundler
The bundler (`allPagesBundler`) uses ESBuild with three custom plugins:
The bundler uses `Bun.build` with the `bun-plugin-tailwind` plugin. For each page, a client hydration entry point is generated and written as a real temporary file under `.bunext/hydration-src/`. Each entry imports the page component and calls `hydrateRoot()` against the server-rendered DOM node. If `src/pages/__root.tsx` exists, the page is wrapped in the root layout.
- **`tailwindcss` plugin** — Processes any `.css` files through PostCSS + Tailwind CSS before bundling.
- **`virtual-entrypoints` plugin** — Generates an in-memory client hydration entry point for each page. Each entry imports the page component and calls `hydrateRoot()` against the server-rendered DOM node. If `src/pages/__root.tsx` exists, the page is wrapped in the root layout.
- **`artifact-tracker` plugin** — After each build, collects all output file paths, content hashes, and source entrypoints into a `BundlerCTXMap[]`. This map is stored in `global.BUNDLER_CTX_MAP` and written to `public/pages/map.json`.
React is loaded externally — `react`, `react-dom`, `react-dom/client`, and `react/jsx-runtime` are all marked as external in the `Bun.build` config. The correct React version is resolved from the framework's own `node_modules` at startup and injected into every HTML page via a `<script type="importmap">` pointing at `esm.sh`. This guarantees a single shared React instance across all page bundles and HMR updates regardless of project size.
Output files are named `[dir]/[name]/[hash]` so filenames change when content changes, enabling cache-busting.
After each build, output metadata from `Bun.build`'s metafile is used to map each output file back to its source page, producing a `BundlerCTXMap[]`. This map is stored in `global.BUNDLER_CTX_MAP` and written to `.bunext/public/pages/map.json`.
Output files are named `[hash].[ext]` so filenames change when content changes, enabling cache-busting.
### Hot Module Replacement
@@ -949,7 +951,7 @@ Request
├── /favicon.* → Serve favicon from public/
└── Everything else → Server-side render a page
[Production only] Check public/__bunext/cache/ for key = pathname + search
[Production only] Check .bunext/public/cache/ for key = pathname + search
Cache HIT → return cached HTML with X-Bunext-Cache: HIT header
Cache MISS → continue ↓
1. Match route via FileSystemRouter
@@ -967,6 +969,7 @@ Request
Server-rendered HTML includes:
- `window.__PAGE_PROPS__` — the serialized server function return value, read by `hydrateRoot` on the client.
- A `<script type="importmap">` mapping React package specifiers to the esm.sh CDN (uses the `?dev` build in development).
- 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.