Update bundler logic

This commit is contained in:
Benjamin Toby 2026-03-24 21:37:41 +01:00
parent f6c7f6b78c
commit 336fa812a5
8 changed files with 123 additions and 17 deletions

View File

@ -1,6 +1,6 @@
# Bunext # Bunext
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. A server-rendering framework for React, built entirely 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.
## Philosophy ## 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: The goal is a framework that is:
- Fast — Bun's runtime speed and Bun.build's bundling make the full dev loop snappy - Fast — Bun's runtime speed and ESBuild's bundling make the full dev loop snappy
- Transparent — the entire request pipeline is readable and debugable - 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 - Standard — server functions and API handlers use native Web APIs (`Request`, `Response`, `URL`) with no custom wrappers
@ -924,7 +924,7 @@ Running `bunext dev`:
1. Loads `bunext.config.ts` and sets `development: true`. 1. Loads `bunext.config.ts` and sets `development: true`.
2. Initializes directories (`.bunext/`, `public/pages/`). 2. Initializes directories (`.bunext/`, `public/pages/`).
3. Creates a `Bun.FileSystemRouter` pointed at `src/pages/`. 3. Creates a `Bun.FileSystemRouter` pointed at `src/pages/`.
4. Starts `Bun.build` in **watch mode** — it will automatically rebuild when file content changes. 4. Creates an ESBuild context and performs the initial build. File-change rebuilds are triggered manually by the FS watcher.
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. 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. 6. Waits for the first successful bundle.
7. Starts `Bun.serve()`. 7. Starts `Bun.serve()`.
@ -934,7 +934,7 @@ Running `bunext dev`:
Running `bunext build`: Running `bunext build`:
1. Sets `NODE_ENV=production`. 1. Sets `NODE_ENV=production`.
2. Runs `Bun.build` once with minification enabled. 2. Runs ESBuild once with minification enabled.
3. Writes all bundled artifacts to `.bunext/public/pages/` and the artifact map to `.bunext/public/pages/map.json`. 3. Writes all bundled artifacts to `.bunext/public/pages/` and the artifact map to `.bunext/public/pages/map.json`.
4. Exits. 4. Exits.
@ -947,11 +947,13 @@ Running `bunext start`:
### Bundler ### Bundler
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. The bundler uses **ESBuild** with a virtual namespace plugin that generates in-memory hydration entry points for each page — no temporary files are written to disk. Each virtual 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. Tailwind CSS is processed via a dedicated ESBuild plugin.
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. In development, an `esbuild.context()` is created once and rebuilt incrementally whenever the FS watcher detects a file change. In production, a single `esbuild.build()` call runs with minification enabled.
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`. React is loaded externally — `react`, `react-dom`, `react-dom/client`, and `react/jsx-runtime` are all marked as external in the ESBuild 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.
After each build, ESBuild'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. Output files are named `[hash].[ext]` so filenames change when content changes, enabling cache-busting.

View File

@ -0,0 +1,81 @@
import * as esbuild from "esbuild";
import grabAllPages from "../../utils/grab-all-pages";
import grabDirNames from "../../utils/grab-dir-names";
import isDevelopment from "../../utils/is-development";
import tailwindEsbuildPlugin from "../server/web-pages/tailwind-esbuild-plugin";
import grabClientHydrationScript from "./grab-client-hydration-script";
import type { PageFiles } from "../../types";
import path from "path";
import esbuildCTXArtifactTracker from "./plugins/esbuild-ctx-artifact-tracker";
const { HYDRATION_DST_DIR, BUNX_HYDRATION_SRC_DIR } = grabDirNames();
type Params = {
post_build_fn?: (params: { artifacts: any[] }) => Promise<void> | void;
};
export default async function allPagesESBuildContextBundlerFiles(
params?: Params,
) {
const pages = grabAllPages({ exclude_api: true });
global.PAGE_FILES = pages;
const dev = isDevelopment();
const entryToPage = new Map<string, PageFiles & { tsx: string }>();
for (const page of pages) {
const tsx = await grabClientHydrationScript({
page_local_path: page.local_path,
});
if (!tsx) continue;
const entryFile = path.join(
BUNX_HYDRATION_SRC_DIR,
`${page.url_path}.tsx`,
);
await Bun.write(entryFile, tsx, { createPath: true });
entryToPage.set(entryFile, { ...page, tsx });
}
const entryPoints = [...entryToPage.keys()];
const ctx = await esbuild.context({
entryPoints,
outdir: HYDRATION_DST_DIR,
bundle: true,
minify: !dev,
format: "esm",
target: "es2020",
platform: "browser",
define: {
"process.env.NODE_ENV": JSON.stringify(
dev ? "development" : "production",
),
},
entryNames: "[dir]/[hash]",
metafile: true,
plugins: [
tailwindEsbuildPlugin,
esbuildCTXArtifactTracker({
entryToPage,
post_build_fn: params?.post_build_fn,
}),
],
jsx: "automatic",
splitting: true,
logLevel: "silent",
external: [
"react",
"react-dom",
"react-dom/client",
"react/jsx-runtime",
],
});
await ctx.rebuild();
global.BUNDLER_CTX = ctx;
}

View File

@ -16,6 +16,8 @@ type Params = {
}; };
export default async function allPagesESBuildContextBundler(params?: Params) { export default async function allPagesESBuildContextBundler(params?: Params) {
// return await allPagesESBuildContextBundlerFiles(params);
const pages = grabAllPages({ exclude_api: true }); const pages = grabAllPages({ exclude_api: true });
global.PAGE_FILES = pages; global.PAGE_FILES = pages;
@ -39,9 +41,11 @@ export default async function allPagesESBuildContextBundler(params?: Params) {
entryToPage.set(entryFile, { ...page, tsx }); entryToPage.set(entryFile, { ...page, tsx });
} }
const entryPoints = [...entryToPage.keys()]; const entryPoints = [...entryToPage.keys()].map(
(e) => `hydration-virtual:${e}`,
);
const ctx = await esbuild.context({ global.BUNDLER_CTX = await esbuild.context({
entryPoints, entryPoints,
outdir: HYDRATION_DST_DIR, outdir: HYDRATION_DST_DIR,
bundle: true, bundle: true,
@ -69,6 +73,7 @@ export default async function allPagesESBuildContextBundler(params?: Params) {
jsx: "automatic", jsx: "automatic",
splitting: true, splitting: true,
logLevel: "silent", logLevel: "silent",
// logLevel: "silent",
// logLevel: dev ? "error" : "silent", // logLevel: dev ? "error" : "silent",
external: [ external: [
"react", "react",
@ -78,7 +83,5 @@ export default async function allPagesESBuildContextBundler(params?: Params) {
], ],
}); });
await ctx.rebuild(); await global.BUNDLER_CTX.rebuild();
global.BUNDLER_CTX = ctx;
} }

View File

@ -2,6 +2,7 @@ import path from "path";
import * as esbuild from "esbuild"; import * as esbuild from "esbuild";
import type { BundlerCTXMap, PageFiles } from "../../types"; import type { BundlerCTXMap, PageFiles } from "../../types";
import grabDirNames from "../../utils/grab-dir-names"; import grabDirNames from "../../utils/grab-dir-names";
import { log } from "../../utils/log";
const { ROOT_DIR } = grabDirNames(); const { ROOT_DIR } = grabDirNames();
@ -26,7 +27,14 @@ export default function grabArtifactsFromBundledResults({
) )
.filter(([, meta]) => meta.entryPoint) .filter(([, meta]) => meta.entryPoint)
.map(([outputPath, meta]) => { .map(([outputPath, meta]) => {
const entrypoint = path.join(ROOT_DIR, meta.entryPoint || ""); const entrypoint = meta.entryPoint?.match(/^hydration-virtual:/)
? meta.entryPoint?.replace(/^hydration-virtual:/, "")
: meta.entryPoint
? path.join(ROOT_DIR, meta.entryPoint)
: "";
// const entrypoint = path.join(ROOT_DIR, meta.entryPoint || "");
// console.log("entrypoint", entrypoint);
const target_page = entryToPage.get(entrypoint); const target_page = entryToPage.get(entrypoint);
@ -42,7 +50,7 @@ export default function grabArtifactsFromBundledResults({
type: outputPath.endsWith(".css") type: outputPath.endsWith(".css")
? "text/css" ? "text/css"
: "text/javascript", : "text/javascript",
entrypoint, entrypoint: meta.entryPoint,
css_path: meta.cssBundle, css_path: meta.cssBundle,
file_name, file_name,
local_path, local_path,

View File

@ -31,7 +31,7 @@ export default function esbuildCTXArtifactTracker({
if (build_starts == MAX_BUILD_STARTS) { if (build_starts == MAX_BUILD_STARTS) {
const error_msg = `Build Failed. Please check all your components and imports.`; const error_msg = `Build Failed. Please check all your components and imports.`;
log.error(error_msg); log.error(error_msg);
global.BUNDLER_CTX?.cancel(); global.RECOMPILING = false;
} }
}); });
@ -52,6 +52,8 @@ export default function esbuildCTXArtifactTracker({
entryToPage, entryToPage,
}); });
// console.log("artifacts", artifacts);
if (artifacts?.[0] && artifacts.length > 0) { if (artifacts?.[0] && artifacts.length > 0) {
for (let i = 0; i < artifacts.length; i++) { for (let i = 0; i < artifacts.length; i++) {
const artifact = artifacts[i]; const artifact = artifacts[i];

View File

@ -1,6 +1,7 @@
import type { Plugin } from "esbuild"; import type { Plugin } from "esbuild";
import path from "path"; import path from "path";
import type { PageFiles } from "../../../types"; import type { PageFiles } from "../../../types";
import { log } from "../../../utils/log";
type Params = { type Params = {
entryToPage: Map< entryToPage: Map<

View File

@ -52,6 +52,8 @@ export default async function watcherEsbuildCTX() {
if (global.RECOMPILING) return; if (global.RECOMPILING) return;
global.RECOMPILING = true; global.RECOMPILING = true;
log.info(`Rebuilding CTX ...`);
await global.BUNDLER_CTX?.rebuild(); await global.BUNDLER_CTX?.rebuild();
if (filename.match(/(404|500)\.tsx?/)) { if (filename.match(/(404|500)\.tsx?/)) {
@ -105,12 +107,18 @@ async function fullRebuild(params?: { msg?: string }) {
log.watch(msg); log.watch(msg);
} }
log.info(`Reloading Router ...`);
global.ROUTER.reload(); global.ROUTER.reload();
log.info(`Disposing Bundler CTX ...`);
await global.BUNDLER_CTX?.dispose(); await global.BUNDLER_CTX?.dispose();
global.BUNDLER_CTX = undefined; global.BUNDLER_CTX = undefined;
await allPagesESBuildContextBundler({ global.BUNDLER_CTX_MAP = {};
log.info(`Rebuilding Modules ...`);
allPagesESBuildContextBundler({
post_build_fn: serverPostBuildFn, post_build_fn: serverPostBuildFn,
}); });
} catch (error: any) { } catch (error: any) {
@ -120,6 +128,7 @@ async function fullRebuild(params?: { msg?: string }) {
} }
if (global.PAGES_SRC_WATCHER) { if (global.PAGES_SRC_WATCHER) {
log.info(`Restarting watcher ...`);
global.PAGES_SRC_WATCHER.close(); global.PAGES_SRC_WATCHER.close();
watcherEsbuildCTX(); watcherEsbuildCTX();
} }

View File

@ -41,7 +41,7 @@ export default async function (params?: Params) {
script += ` try {\n`; script += ` try {\n`;
script += ` document.getElementById("__bunext_error_overlay")?.remove();\n`; script += ` document.getElementById("__bunext_error_overlay")?.remove();\n`;
script += ` const data = JSON.parse(event.data);\n`; script += ` const data = JSON.parse(event.data);\n`;
script += ` console.log("data", data);\n`; // script += ` console.log("data", data);\n`;
script += ` if (data.reload) {\n`; script += ` if (data.reload) {\n`;
script += ` console.log(\`Root Changes Detected. Reloading Page ...\`);\n`; script += ` console.log(\`Root Changes Detected. Reloading Page ...\`);\n`;