From 4b3a4dbc77c6ad8618bc9c0642c9cb27a610d305 Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Wed, 8 Apr 2026 05:39:11 +0100 Subject: [PATCH] Bugfix. Extract react imports from main bundler to vendor static files --- README.md | 10 +- bun.lock | 12 +- .../all-pages-esbuild-context-bundler.js | 35 ++++-- .../bundler/bun-react-modules-bundler.d.ts | 1 + .../bundler/bun-react-modules-bundler.js | 74 +++++++++++ .../bundler/grab-client-hydration-script.js | 1 + .../bundler/plugins/chunk-react.d.ts | 2 + dist/functions/bundler/plugins/chunk-react.js | 97 +++++++++++++++ .../bundler/plugins/react-alias.d.ts | 3 + dist/functions/bundler/plugins/react-alias.js | 22 ++++ .../bundler/plugins/virtual-files-plugin.js | 12 ++ .../bundler/react-modules-bundler.d.ts | 1 + .../bundler/react-modules-bundler.js | 77 ++++++++++++ dist/functions/bunext-init.d.ts | 4 + dist/functions/bunext-init.js | 4 + .../server/handle-bunext-public-assets.js | 50 ++------ dist/functions/server/handle-public.d.ts | 8 +- dist/functions/server/handle-public.js | 14 ++- .../server/web-pages/generate-web-html.js | 37 +----- .../server/web-pages/grab-file-path-module.js | 2 +- .../grab-web-page-hydration-script.js | 10 ++ dist/utils/grab-dir-names.d.ts | 2 + dist/utils/grab-dir-names.js | 3 + package.json | 68 +++++----- .../all-pages-esbuild-context-bundler.ts | 36 ++++-- .../bundler/bun-react-modules-bundler.ts | 86 +++++++++++++ .../bundler/grab-client-hydration-script.tsx | 2 + src/functions/bundler/plugins/chunk-react.ts | 117 ++++++++++++++++++ src/functions/bundler/plugins/react-alias.ts | 30 +++++ .../bundler/plugins/virtual-files-plugin.ts | 16 +++ .../bundler/react-modules-bundler.ts | 89 +++++++++++++ src/functions/bunext-init.ts | 6 + .../server/handle-bunext-public-assets.ts | 54 ++------ src/functions/server/handle-public.ts | 21 +++- .../server/web-pages/generate-web-html.tsx | 55 +------- .../web-pages/grab-file-path-module.tsx | 2 +- .../grab-web-page-hydration-script.tsx | 12 ++ src/utils/grab-dir-names.ts | 3 + 38 files changed, 825 insertions(+), 253 deletions(-) create mode 100644 dist/functions/bundler/bun-react-modules-bundler.d.ts create mode 100644 dist/functions/bundler/bun-react-modules-bundler.js create mode 100644 dist/functions/bundler/plugins/chunk-react.d.ts create mode 100644 dist/functions/bundler/plugins/chunk-react.js create mode 100644 dist/functions/bundler/plugins/react-alias.d.ts create mode 100644 dist/functions/bundler/plugins/react-alias.js create mode 100644 dist/functions/bundler/react-modules-bundler.d.ts create mode 100644 dist/functions/bundler/react-modules-bundler.js create mode 100644 src/functions/bundler/bun-react-modules-bundler.ts create mode 100644 src/functions/bundler/plugins/chunk-react.ts create mode 100644 src/functions/bundler/plugins/react-alias.ts create mode 100644 src/functions/bundler/react-modules-bundler.ts diff --git a/README.md b/README.md index a6f686f..c4b097c 100644 --- a/README.md +++ b/README.md @@ -61,8 +61,8 @@ The goal is a framework that is: - [Bun](https://bun.sh) v1.0 or later - TypeScript 5.0+ -- react 18.0+ -- react-dom 18.0+ + +> **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. --- @@ -103,12 +103,6 @@ bun add -g @moduletrace/bunext bun add github:moduletrace/bunext ``` -### Install react and react-dom - -```bash -bun add react react-dom -``` - --- ## Quick Start diff --git a/bun.lock b/bun.lock index 7260917..1ce009f 100644 --- a/bun.lock +++ b/bun.lock @@ -19,6 +19,8 @@ "micromatch": "^4.0.8", "ora": "^9.0.0", "postcss": "^8.5.8", + "react": "^19.2.4", + "react-dom": "^19.2.4", "tailwindcss": "^4.2.2", "typescript": "^5.0.0", }, @@ -28,10 +30,6 @@ "@types/micromatch": "^4.0.10", "happy-dom": "^20.8.4", }, - "peerDependencies": { - "react": "^19", - "react-dom": "^19", - }, }, }, "packages": { @@ -287,15 +285,15 @@ "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], - "react": ["react@19.0.0", "", {}, "sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ=="], + "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], - "react-dom": ["react-dom@19.0.0", "", { "dependencies": { "scheduler": "^0.25.0" }, "peerDependencies": { "react": "^19.0.0" } }, "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ=="], + "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], - "scheduler": ["scheduler@0.25.0", "", {}, "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], diff --git a/dist/functions/bundler/all-pages-esbuild-context-bundler.js b/dist/functions/bundler/all-pages-esbuild-context-bundler.js index dfa659c..119d6f8 100644 --- a/dist/functions/bundler/all-pages-esbuild-context-bundler.js +++ b/dist/functions/bundler/all-pages-esbuild-context-bundler.js @@ -9,7 +9,6 @@ import virtualFilesPlugin from "./plugins/virtual-files-plugin"; import esbuildCTXArtifactTracker from "./plugins/esbuild-ctx-artifact-tracker"; const { HYDRATION_DST_DIR, BUNX_HYDRATION_SRC_DIR } = grabDirNames(); export default async function allPagesESBuildContextBundler(params) { - // return await allPagesESBuildContextBundlerFiles(params); const pages = grabAllPages({ exclude_api: true }); global.PAGE_FILES = pages; const dev = isDevelopment(); @@ -39,6 +38,7 @@ export default async function allPagesESBuildContextBundler(params) { entryNames: "[dir]/[hash]", metafile: true, plugins: [ + forceExternalReact(), tailwindEsbuildPlugin, virtualFilesPlugin({ entryToPage, @@ -51,17 +51,28 @@ export default async function allPagesESBuildContextBundler(params) { jsx: "automatic", splitting: true, treeShaking: true, - logLevel: "silent", - // logLevel: "silent", - // logLevel: dev ? "error" : "silent", - // external: [ - // "react", - // "react-dom", - // "react-dom/client", - // "react/jsx-runtime", - // "react/jsx-dev-runtime", - // ], - // jsxDev: dev, + external: [ + "react", + "react-dom", + "react-dom/client", + "react/jsx-runtime", + "react/jsx-dev-runtime", + ], }); await global.BUNDLER_CTX.rebuild(); } +function forceExternalReact() { + return { + name: "force-external-react", + setup(build) { + build.onResolve({ filter: /^react(-dom)?(\/.*)?$/ }, (args) => { + if (args.pluginData?.externalReact) + return null; + return { + path: args.path, + external: true, + }; + }); + }, + }; +} diff --git a/dist/functions/bundler/bun-react-modules-bundler.d.ts b/dist/functions/bundler/bun-react-modules-bundler.d.ts new file mode 100644 index 0000000..61e9ae6 --- /dev/null +++ b/dist/functions/bundler/bun-react-modules-bundler.d.ts @@ -0,0 +1 @@ +export default function bunReactModulesBundler(): Promise; diff --git a/dist/functions/bundler/bun-react-modules-bundler.js b/dist/functions/bundler/bun-react-modules-bundler.js new file mode 100644 index 0000000..d7953b9 --- /dev/null +++ b/dist/functions/bundler/bun-react-modules-bundler.js @@ -0,0 +1,74 @@ +import grabDirNames from "../../utils/grab-dir-names"; +import isDevelopment from "../../utils/is-development"; +import path from "path"; +import { rmSync, mkdirSync, writeFileSync } from "fs"; +const { BUNEXT_VENDOR_DIR, BUNX_CWD_DIR } = grabDirNames(); +const VENDOR_ENTRIES = { + react: ` + import React from "react"; + export const { + Children, Component, Fragment, Profiler, PureComponent, StrictMode, + Suspense, cloneElement, createContext, createElement, createRef, + forwardRef, isValidElement, lazy, memo, startTransition, + useCallback, useContext, useDebugValue, useDeferredValue, useEffect, + useId, useImperativeHandle, useInsertionEffect, useLayoutEffect, + useMemo, useReducer, useRef, useState, useSyncExternalStore, + useTransition, version, use, cache, act, + } = React; + export default React; + `, + "react-dom": ` + import ReactDOM from "react-dom"; + export const { + createPortal, flushSync, version, + } = ReactDOM; + export default ReactDOM; + `, + "react-dom_client": ` + import ReactDOMClient from "react-dom/client"; + export const { createRoot, hydrateRoot } = ReactDOMClient; + export default ReactDOMClient; + `, + "react_jsx-runtime": ` + import JSXRuntime from "react/jsx-runtime"; + export const { jsx, jsxs, Fragment } = JSXRuntime; + `, + "react_jsx-dev-runtime": ` + import JSXDevRuntime from "react/jsx-dev-runtime"; + export const { jsxDEV, Fragment } = JSXDevRuntime; + `, +}; +export default async function bunReactModulesBundler() { + const dev = isDevelopment(); + rmSync(BUNEXT_VENDOR_DIR, { force: true, recursive: true }); + const tmpDir = path.join(BUNEXT_VENDOR_DIR, "_tmp"); + mkdirSync(tmpDir, { recursive: true }); + const entrypoints = []; + for (const [name, contents] of Object.entries(VENDOR_ENTRIES)) { + const file = path.join(tmpDir, `${name}.mjs`); + writeFileSync(file, contents); + entrypoints.push(file); + } + await Bun.build({ + entrypoints, + outdir: BUNEXT_VENDOR_DIR, + splitting: true, + format: "esm", + target: "browser", + minify: !dev, + define: { + "process.env.NODE_ENV": JSON.stringify(dev ? "development" : "production"), + }, + }); + rmSync(tmpDir, { force: true, recursive: true }); + const PUBLIC_ROOT = BUNEXT_VENDOR_DIR.replace(BUNX_CWD_DIR, "/.bunext"); + global.REACT_IMPORTS_MAP = { + imports: { + react: `${PUBLIC_ROOT}/react.js`, + "react-dom": `${PUBLIC_ROOT}/react-dom.js`, + "react-dom/client": `${PUBLIC_ROOT}/react-dom_client.js`, + "react/jsx-runtime": `${PUBLIC_ROOT}/react_jsx-runtime.js`, + "react/jsx-dev-runtime": `${PUBLIC_ROOT}/react_jsx-dev-runtime.js`, + }, + }; +} diff --git a/dist/functions/bundler/grab-client-hydration-script.js b/dist/functions/bundler/grab-client-hydration-script.js index dd8addb..c15b846 100644 --- a/dist/functions/bundler/grab-client-hydration-script.js +++ b/dist/functions/bundler/grab-client-hydration-script.js @@ -28,6 +28,7 @@ export default async function grabClientHydrationScript({ page_local_path, }) { } let txt = ``; txt += `import { hydrateRoot } from "${ROOT_DIR}/node_modules/react-dom/client.js";\n`; + // txt += `import react from "${ROOT_DIR}/node_modules/react/index.js";\n`; if (root_file_path) { txt += `import Root from "${root_file_path}";\n`; } diff --git a/dist/functions/bundler/plugins/chunk-react.d.ts b/dist/functions/bundler/plugins/chunk-react.d.ts new file mode 100644 index 0000000..3602c26 --- /dev/null +++ b/dist/functions/bundler/plugins/chunk-react.d.ts @@ -0,0 +1,2 @@ +import * as esbuild from "esbuild"; +export default function reactVendorChunkPlugin(): esbuild.Plugin; diff --git a/dist/functions/bundler/plugins/chunk-react.js b/dist/functions/bundler/plugins/chunk-react.js new file mode 100644 index 0000000..436803a --- /dev/null +++ b/dist/functions/bundler/plugins/chunk-react.js @@ -0,0 +1,97 @@ +// plugins/react-vendor-chunk-plugin.ts +import * as esbuild from "esbuild"; +import path from "path"; +import grabDirNames from "../../../utils/grab-dir-names"; +const { BUNEXT_VENDOR_DIR } = grabDirNames(); +const REACT_MODULES = new Set([ + "react", + "react-dom", + "react-dom/client", + "react/jsx-runtime", + "react/jsx-dev-runtime", +]); +const VENDOR_BASE = "/.bunext/public/vendor"; +const REACT_ENTRIES = { + react: ` + import React from "react"; + export const { + Children, Component, Fragment, Profiler, PureComponent, StrictMode, + Suspense, cloneElement, createContext, createElement, createFactory, + createRef, forwardRef, isValidElement, lazy, memo, startTransition, + useCallback, useContext, useDebugValue, useDeferredValue, useEffect, + useId, useImperativeHandle, useInsertionEffect, useLayoutEffect, + useMemo, useReducer, useRef, useState, useSyncExternalStore, + useTransition, version, + } = React; + export default React; + `, + "react-dom": ` + import ReactDOM from "react-dom"; + export const { + createPortal, flushSync, findDOMNode, hydrate, render, + unmountComponentAtNode, version, + } = ReactDOM; + export default ReactDOM; + `, + "react-dom/client": ` + import ReactDOMClient from "react-dom/client"; + export const { createRoot, hydrateRoot } = ReactDOMClient; + export default ReactDOMClient; + `, + "react/jsx-runtime": ` + import JSXRuntime from "react/jsx-runtime"; + export const { jsx, jsxs, Fragment } = JSXRuntime; + export default JSXRuntime; + `, + "react/jsx-dev-runtime": ` + import JSXDevRuntime from "react/jsx-dev-runtime"; + export const { jsxDEV, Fragment } = JSXDevRuntime; + export default JSXDevRuntime; + `, +}; +// Map bare specifier -> browser path +function vendorPath(specifier) { + const filename = specifier.replace(/\//g, "_") + ".js"; + return `${VENDOR_BASE}/${filename}`; +} +function vendorOutfile(specifier) { + const filename = specifier.replace(/\//g, "_") + ".js"; + return path.join(BUNEXT_VENDOR_DIR, filename); +} +export default function reactVendorChunkPlugin() { + let vendorReady; + return { + name: "react-vendor-chunk", + setup(build) { + vendorReady ??= buildAllVendorChunks(build.initialOptions); + build.onResolve({ filter: /^react(-dom)?(\/.*)?$/ }, async (args) => { + const bare = args.path.replace(/\/index(\.m?js)?$/, ""); + if (!(bare in REACT_ENTRIES)) + return; + await vendorReady; + return { + path: vendorPath(bare), + external: true, + }; + }); + }, + }; +} +async function buildAllVendorChunks(parentOptions) { + await Promise.all(Object.entries(REACT_ENTRIES).map(([specifier, contents]) => esbuild.build({ + stdin: { + contents, + resolveDir: process.cwd(), + loader: "tsx", + }, + outfile: vendorOutfile(specifier), + bundle: true, + minify: parentOptions.minify, + format: "esm", + target: parentOptions.target, + platform: "browser", + define: parentOptions.define, + mainFields: ["module", "main"], + conditions: ["import", "default"], + }))); +} diff --git a/dist/functions/bundler/plugins/react-alias.d.ts b/dist/functions/bundler/plugins/react-alias.d.ts new file mode 100644 index 0000000..cb4449c --- /dev/null +++ b/dist/functions/bundler/plugins/react-alias.d.ts @@ -0,0 +1,3 @@ +import type { Plugin } from "esbuild"; +declare const reactAliasPlugin: Plugin; +export default reactAliasPlugin; diff --git a/dist/functions/bundler/plugins/react-alias.js b/dist/functions/bundler/plugins/react-alias.js new file mode 100644 index 0000000..d8b57df --- /dev/null +++ b/dist/functions/bundler/plugins/react-alias.js @@ -0,0 +1,22 @@ +import path from "path"; +import grabDirNames from "../../../utils/grab-dir-names"; +const { ROOT_DIR } = grabDirNames(); +const reactAliasPlugin = { + name: "react-alias", + setup(build) { + const reactPath = path.join(ROOT_DIR, "node_modules"); + build.onResolve({ filter: /^react$/ }, () => ({ + path: path.join(reactPath, "react", "index.js"), + })); + build.onResolve({ filter: /^react-dom$/ }, () => ({ + path: path.join(reactPath, "react-dom", "index.js"), + })); + build.onResolve({ filter: /^react\/jsx-runtime$/ }, () => ({ + path: path.join(reactPath, "react", "jsx-runtime.js"), + })); + build.onResolve({ filter: /^react\/jsx-dev-runtime$/ }, () => ({ + path: path.join(reactPath, "react", "jsx-dev-runtime.js"), + })); + }, +}; +export default reactAliasPlugin; diff --git a/dist/functions/bundler/plugins/virtual-files-plugin.js b/dist/functions/bundler/plugins/virtual-files-plugin.js index bea6ddc..f9b159b 100644 --- a/dist/functions/bundler/plugins/virtual-files-plugin.js +++ b/dist/functions/bundler/plugins/virtual-files-plugin.js @@ -11,6 +11,18 @@ export default function virtualFilesPlugin({ entryToPage }) { namespace: "hydration-virtual", }; }); + build.onResolve({ filter: /node_modules\/react(-dom)?/ }, (args) => ({ + path: args.path.includes("react-dom") + ? args.path.includes("client") + ? "react-dom/client" + : "react-dom" + : args.path.includes("jsx-dev") + ? "react/jsx-dev-runtime" + : args.path.includes("jsx") + ? "react/jsx-runtime" + : "react", + external: true, + })); build.onLoad({ filter: /.*/, namespace: "hydration-virtual" }, (args) => { const target = entryToPage.get(args.path); if (!target?.tsx) diff --git a/dist/functions/bundler/react-modules-bundler.d.ts b/dist/functions/bundler/react-modules-bundler.d.ts new file mode 100644 index 0000000..09a2eb0 --- /dev/null +++ b/dist/functions/bundler/react-modules-bundler.d.ts @@ -0,0 +1 @@ +export default function reactModulesBundler(): Promise; diff --git a/dist/functions/bundler/react-modules-bundler.js b/dist/functions/bundler/react-modules-bundler.js new file mode 100644 index 0000000..997c6ad --- /dev/null +++ b/dist/functions/bundler/react-modules-bundler.js @@ -0,0 +1,77 @@ +import * as esbuild from "esbuild"; +import grabDirNames from "../../utils/grab-dir-names"; +import isDevelopment from "../../utils/is-development"; +import path from "path"; +import { rmSync, mkdirSync, writeFileSync } from "fs"; +const { BUNEXT_VENDOR_DIR, BUNX_CWD_DIR, ROOT_DIR } = grabDirNames(); +const VENDOR_ENTRIES = { + react: ` + import React from "react"; + export const { + Children, Component, Fragment, Profiler, PureComponent, StrictMode, + Suspense, cloneElement, createContext, createElement, createRef, + forwardRef, isValidElement, lazy, memo, startTransition, + useCallback, useContext, useDebugValue, useDeferredValue, useEffect, + useId, useImperativeHandle, useInsertionEffect, useLayoutEffect, + useMemo, useReducer, useRef, useState, useSyncExternalStore, + useTransition, version, use, cache, act, + } = React; + export default React; + `, + "react-dom": ` + import ReactDOM from "react-dom"; + export const { + createPortal, flushSync, version, + } = ReactDOM; + export default ReactDOM; + `, + "react-dom_client": ` + import ReactDOMClient from "react-dom/client"; + export const { createRoot, hydrateRoot } = ReactDOMClient; + export default ReactDOMClient; + `, + "react_jsx-runtime": ` + import JSXRuntime from "react/jsx-runtime"; + export const { jsx, jsxs, Fragment } = JSXRuntime; + `, + "react_jsx-dev-runtime": ` + import JSXDevRuntime from "react/jsx-dev-runtime"; + export const { jsxDEV, Fragment } = JSXDevRuntime; + `, +}; +export default async function reactModulesBundler() { + const dev = isDevelopment(); + rmSync(BUNEXT_VENDOR_DIR, { force: true, recursive: true }); + const tmpDir = path.join(BUNEXT_VENDOR_DIR, "_tmp"); + mkdirSync(tmpDir, { recursive: true }); + const entrypoints = {}; + for (const [name, contents] of Object.entries(VENDOR_ENTRIES)) { + const file = path.join(tmpDir, `${name}.mjs`); + writeFileSync(file, contents); + entrypoints[name] = file; + } + await esbuild.build({ + entryPoints: entrypoints, + outdir: BUNEXT_VENDOR_DIR, + bundle: true, + splitting: true, + format: "esm", + platform: "browser", + target: "es2020", + minify: !dev, + define: { + "process.env.NODE_ENV": JSON.stringify(dev ? "development" : "production"), + }, + }); + rmSync(tmpDir, { force: true, recursive: true }); + const PUBLIC_ROOT = BUNEXT_VENDOR_DIR.replace(BUNX_CWD_DIR, "/.bunext"); + global.REACT_IMPORTS_MAP = { + imports: { + react: `${PUBLIC_ROOT}/react.js`, + "react-dom": `${PUBLIC_ROOT}/react-dom.js`, + "react-dom/client": `${PUBLIC_ROOT}/react-dom_client.js`, + "react/jsx-runtime": `${PUBLIC_ROOT}/react_jsx-runtime.js`, + "react/jsx-dev-runtime": `${PUBLIC_ROOT}/react_jsx-dev-runtime.js`, + }, + }; +} diff --git a/dist/functions/bunext-init.d.ts b/dist/functions/bunext-init.d.ts index 76c3d5a..09e21d4 100644 --- a/dist/functions/bunext-init.d.ts +++ b/dist/functions/bunext-init.d.ts @@ -25,5 +25,9 @@ declare global { var SKIPPED_BROWSER_MODULES: Set; var BUNDLER_CTX: BuildContext | undefined; var DIR_NAMES: ReturnType; + var REACT_IMPORTS_MAP: { + imports: Record; + }; + var REACT_DOM_SERVER: any; } export default function bunextInit(): Promise; diff --git a/dist/functions/bunext-init.js b/dist/functions/bunext-init.js index ebde354..504cf0c 100644 --- a/dist/functions/bunext-init.js +++ b/dist/functions/bunext-init.js @@ -7,6 +7,7 @@ import cron from "./server/cron"; import watcherEsbuildCTX from "./server/watcher-esbuild-ctx"; import allPagesESBuildContextBundler from "./bundler/all-pages-esbuild-context-bundler"; import serverPostBuildFn from "./server/server-post-build-fn"; +import reactModulesBundler from "./bundler/react-modules-bundler"; const dirNames = grabDirNames(); const { PAGES_DIR } = dirNames; export default async function bunextInit() { @@ -16,7 +17,10 @@ export default async function bunextInit() { global.PAGE_FILES = []; global.SKIPPED_BROWSER_MODULES = new Set(); global.DIR_NAMES = dirNames; + global.REACT_IMPORTS_MAP = { imports: {} }; await init(); + // await bunReactModulesBundler(); + await reactModulesBundler(); log.banner(); const router = new Bun.FileSystemRouter({ style: "nextjs", diff --git a/dist/functions/server/handle-bunext-public-assets.js b/dist/functions/server/handle-bunext-public-assets.js index f749bde..8b2b348 100644 --- a/dist/functions/server/handle-bunext-public-assets.js +++ b/dist/functions/server/handle-bunext-public-assets.js @@ -1,54 +1,22 @@ import grabDirNames from "../../utils/grab-dir-names"; import path from "path"; import isDevelopment from "../../utils/is-development"; -import { existsSync } from "fs"; import { readFileResponse } from "./handle-public"; -const { HYDRATION_DST_DIR } = grabDirNames(); +const { BUNEXT_PUBLIC_DIR } = grabDirNames(); export default async function ({ req }) { try { const is_dev = isDevelopment(); const url = new URL(req.url); - // switch (url.pathname) { - // case "/.bunext/react": - // return readFileResponse({ - // file_path: is_dev - // ? global.DIR_NAMES.REACT_DEVELOPMENT_MODULE - // : global.DIR_NAMES.REACT_PRODUCTION_MODULE, - // }); - // case "/.bunext/react-dom": - // return readFileResponse({ - // file_path: is_dev - // ? global.DIR_NAMES.REACT_DOM_DEVELOPMENT_MODULE - // : global.DIR_NAMES.REACT_DOM_PRODUCTION_MODULE, - // }); - // case "/.bunext/react-dom-client": - // return readFileResponse({ - // file_path: is_dev - // ? global.DIR_NAMES.REACT_DOM_CLIENT_DEVELOPMENT_MODULE - // : global.DIR_NAMES.REACT_DOM_CLIENT_PRODUCTION_MODULE, - // }); - // case "/.bunext/react-jsx-runtime": - // return readFileResponse({ - // file_path: is_dev - // ? global.DIR_NAMES.REACT_JSX_RUNTIME_DEVELOPMENT_MODULE - // : global.DIR_NAMES.REACT_JSX_RUNTIME_PRODUCTION_MODULE, - // }); - // case "/.bunext/react-jsx-dev-runtime": - // return readFileResponse({ - // file_path: is_dev - // ? global.DIR_NAMES - // .REACT_JSX_DEVELOPMENT_RUNTIME_DEVELOPMENT_MODULE - // : global.DIR_NAMES - // .REACT_JSX_DEVELOPMENT_RUNTIME_PRODUCTION_MODULE, - // }); - // default: - // break; - // } - const file_path = path.join(HYDRATION_DST_DIR, url.pathname.replace(/\/\.bunext\/public\/pages\//, "")); - if (!file_path.startsWith(HYDRATION_DST_DIR + path.sep)) { + const file_path = path.join(BUNEXT_PUBLIC_DIR, url.pathname.replace(/\/\.bunext\/public\//, "")); + if (!file_path.startsWith(BUNEXT_PUBLIC_DIR + path.sep)) { return new Response("Forbidden", { status: 403 }); } - return readFileResponse({ file_path }); + return readFileResponse({ + file_path, + cache: url.pathname.includes("/vendor/") + ? { duration: 3600 } + : undefined, + }); } catch (error) { return new Response(`File Not Found`, { diff --git a/dist/functions/server/handle-public.d.ts b/dist/functions/server/handle-public.d.ts index 7071a78..923540d 100644 --- a/dist/functions/server/handle-public.d.ts +++ b/dist/functions/server/handle-public.d.ts @@ -2,7 +2,11 @@ type Params = { req: Request; }; export default function ({ req }: Params): Promise; -export declare function readFileResponse({ file_path }: { +type FileResponse = { file_path: string; -}): Response; + cache?: { + duration?: "infinite" | number; + }; +}; +export declare function readFileResponse({ file_path, cache }: FileResponse): Response; export {}; diff --git a/dist/functions/server/handle-public.js b/dist/functions/server/handle-public.js index 2f03ff9..41639d3 100644 --- a/dist/functions/server/handle-public.js +++ b/dist/functions/server/handle-public.js @@ -19,13 +19,21 @@ export default async function ({ req }) { }); } } -export function readFileResponse({ file_path }) { +export function readFileResponse({ file_path, cache }) { if (!existsSync(file_path)) { return new Response(`Public File Doesn't Exist`, { status: 404, }); } const file = Bun.file(file_path); - // let res_opts: ResponseInit = {}; - return new Response(file); + const headers = new Headers(); + if (cache?.duration == "infinite" || (cache && !cache.duration)) { + headers.set("Cache-Control", "public, max-age=31536000, immutable"); + } + else if (cache?.duration) { + headers.set("Cache-Control", `public, max-age=${cache.duration}`); + } + return new Response(file, { + headers, + }); } diff --git a/dist/functions/server/web-pages/generate-web-html.js b/dist/functions/server/web-pages/generate-web-html.js index 56cc493..719f4d7 100644 --- a/dist/functions/server/web-pages/generate-web-html.js +++ b/dist/functions/server/web-pages/generate-web-html.js @@ -6,16 +6,9 @@ import grabWebPageHydrationScript from "./grab-web-page-hydration-script"; import grabWebMetaHTML from "./grab-web-meta-html"; import { log } from "../../../utils/log"; import { AppData } from "../../../data/app-data"; -import { readFileSync } from "fs"; -import path from "path"; import _ from "lodash"; import grabDirNames from "../../../utils/grab-dir-names"; const { ROOT_DIR } = grabDirNames(); -let _reactVersion = "19"; -try { - _reactVersion = JSON.parse(readFileSync(path.join(process.cwd(), "node_modules/react/package.json"), "utf-8")).version; -} -catch { } export default async function genWebHTML({ component, pageProps, bundledMap, module, routeParams, debug, root_module, }) { const { ClientRootElementIDName, ClientWindowPagePropsName } = grabContants(); const { renderToReadableStream } = await import(`${ROOT_DIR}/node_modules/react-dom/server.js`); @@ -46,47 +39,23 @@ export default async function genWebHTML({ component, pageProps, bundledMap, mod const Head = module?.Head; const RootHead = root_module?.Head; const dev = isDevelopment(); - const devSuffix = dev ? "?dev" : ""; - // const browser_imports: Record = { - // react: `/.bunext/react`, - // "react-dom": `/.bunext/react-dom`, - // "react-dom/client": `/.bunext/react-dom-client`, - // "react/jsx-runtime": `/.bunext/react-jsx-runtime`, - // "react/jsx-dev-runtime": `/.bunext/react-jsx-dev-runtime`, - // }; - // const browser_imports: Record = { - // react: `https://esm.sh/react@${_reactVersion}`, - // "react-dom": `https://esm.sh/react-dom@${_reactVersion}`, - // "react-dom/client": `https://esm.sh/react-dom@${_reactVersion}/client`, - // "react/jsx-runtime": `https://esm.sh/react@${_reactVersion}/jsx-runtime`, - // "react/jsx-dev-runtime": `https://esm.sh/react@${_reactVersion}/jsx-dev-runtime`, - // }; - // if (dev) { - // browser_imports["react/jsx-dev-runtime"] = - // `https://esm.sh/react@${_reactVersion}/jsx-dev-runtime`; - // } - // const importMap = JSON.stringify({ - // imports: browser_imports, - // }); const final_meta = _.merge(root_meta, page_meta); let final_component = (_jsxs("html", { ...html_props, children: [_jsxs("head", { children: [_jsx("meta", { charSet: "utf-8", "data-bunext-head": true }), _jsx("meta", { name: "viewport", content: "width=device-width, initial-scale=1.0", "data-bunext-head": true }), final_meta ? grabWebMetaHTML({ meta: final_meta }) : null, bundledMap?.css_path ? (_jsx("link", { rel: "stylesheet", href: `/${bundledMap.css_path}`, "data-bunext-head": true })) : null, _jsx("script", { dangerouslySetInnerHTML: { __html: `window.${ClientWindowPagePropsName} = ${serializedProps}`, - }, "data-bunext-head": true }), RootHead ? (_jsx(RootHead, { serverRes: pageProps, ctx: routeParams })) : null, Head ? _jsx(Head, { serverRes: pageProps, ctx: routeParams }) : null, bundledMap?.path ? (_jsx(_Fragment, { children: _jsx("script", { src: `/${bundledMap.path}`, type: "module", id: AppData["BunextClientHydrationScriptID"], defer: true, "data-bunext-head": true }) })) : null, is_dev ? (_jsx("script", { defer: true, dangerouslySetInnerHTML: { + }, "data-bunext-head": true }), RootHead ? (_jsx(RootHead, { serverRes: pageProps, ctx: routeParams })) : null, Head ? _jsx(Head, { serverRes: pageProps, ctx: routeParams }) : null, bundledMap?.path ? (_jsxs(_Fragment, { children: [_jsx("script", { type: "importmap", dangerouslySetInnerHTML: { + __html: JSON.stringify(global.REACT_IMPORTS_MAP), + }, defer: true, "data-bunext-head": true }), _jsx("script", { src: `/${bundledMap.path}`, type: "module", id: AppData["BunextClientHydrationScriptID"], defer: true, "data-bunext-head": true })] })) : null, is_dev ? (_jsx("script", { defer: true, dangerouslySetInnerHTML: { __html: page_hydration_script, }, "data-bunext-head": true })) : null] }), _jsx("body", { children: _jsx("div", { id: ClientRootElementIDName, suppressHydrationWarning: !dev, children: component }) })] })); let html = `\n`; const stream = await renderToReadableStream(final_component, { onError(error) { - // This is where you "omit" or handle the errors - // You can log it silently or ignore it if (error.message.includes('unique "key" prop')) return; console.error(error); }, }); - // 2. Convert the Web Stream to a String (Bun-optimized) const htmlBody = await new Response(stream).text(); html += htmlBody; - // html += renderToString(final_component); return html; } diff --git a/dist/functions/server/web-pages/grab-file-path-module.js b/dist/functions/server/web-pages/grab-file-path-module.js index 87ce051..891cc12 100644 --- a/dist/functions/server/web-pages/grab-file-path-module.js +++ b/dist/functions/server/web-pages/grab-file-path-module.js @@ -14,7 +14,7 @@ export default async function grabFilePathModule({ file_path, out_file, }) { format: "esm", target: "es2020", platform: "node", - external: ["react", "react-dom"], + // external: ["react", "react-dom"], minify: true, define: { "process.env.NODE_ENV": JSON.stringify(dev ? "development" : "production"), diff --git a/dist/functions/server/web-pages/grab-web-page-hydration-script.js b/dist/functions/server/web-pages/grab-web-page-hydration-script.js index 9af6df9..7c03494 100644 --- a/dist/functions/server/web-pages/grab-web-page-hydration-script.js +++ b/dist/functions/server/web-pages/grab-web-page-hydration-script.js @@ -75,6 +75,16 @@ export default async function (params) { script += ` } else if (oldCSSLink) {\n`; script += ` oldCSSLink.remove();\n`; script += ` }\n`; + // script += ` const newScriptPath = \`/\${data.target_map.path}?t=\${Date.now()}\`;\n\n`; + // script += ` try {\n`; + // script += ` const mod = await import(newScriptPath);\n`; + // script += ` if (typeof mod.default === "function" || typeof window.__BUNEXT_RERENDER__ === "function") {\n`; + // script += ` window.__BUNEXT_RERENDER__?.();\n`; + // script += ` }\n`; + // script += ` } catch (importErr) {\n`; + // script += ` console.error("HMR import failed, reloading:", importErr.message);\n`; + // script += ` window.location.reload();\n`; + // script += ` }\n`; script += ` const newScriptPath = \`/\${data.target_map.path}?t=\${Date.now()}\`;\n\n`; script += ` const oldScript = document.getElementById("${AppData["BunextClientHydrationScriptID"]}");\n`; script += ` if (oldScript) {\n`; diff --git a/dist/utils/grab-dir-names.d.ts b/dist/utils/grab-dir-names.d.ts index 86029c6..71f53de 100644 --- a/dist/utils/grab-dir-names.d.ts +++ b/dist/utils/grab-dir-names.d.ts @@ -21,4 +21,6 @@ export default function grabDirNames(): { BUNX_CWD_MODULE_CACHE_DIR: string; BUNX_CWD_PAGES_REWRITE_DIR: string; HYDRATION_DST_DIR_MAP_JSON_FILE_NAME: string; + BUNEXT_VENDOR_DIR: string; + BUNEXT_PUBLIC_DIR: string; }; diff --git a/dist/utils/grab-dir-names.js b/dist/utils/grab-dir-names.js index d223996..6c2c092 100644 --- a/dist/utils/grab-dir-names.js +++ b/dist/utils/grab-dir-names.js @@ -13,6 +13,7 @@ export default function grabDirNames() { const BUNX_HYDRATION_SRC_DIR = path.resolve(BUNX_CWD_DIR, "client", "hydration-src"); const BUNEXT_PUBLIC_DIR = path.join(BUNX_CWD_DIR, "public"); const HYDRATION_DST_DIR = path.join(BUNEXT_PUBLIC_DIR, "pages"); + const BUNEXT_VENDOR_DIR = path.join(BUNEXT_PUBLIC_DIR, "vendor"); const BUNEXT_CACHE_DIR = path.join(BUNEXT_PUBLIC_DIR, "cache"); const HYDRATION_DST_DIR_MAP_JSON_FILE_NAME = "map.json"; const HYDRATION_DST_DIR_MAP_JSON_FILE = path.join(HYDRATION_DST_DIR, HYDRATION_DST_DIR_MAP_JSON_FILE_NAME); @@ -104,6 +105,8 @@ export default function grabDirNames() { BUNX_CWD_MODULE_CACHE_DIR, BUNX_CWD_PAGES_REWRITE_DIR, HYDRATION_DST_DIR_MAP_JSON_FILE_NAME, + BUNEXT_VENDOR_DIR, + BUNEXT_PUBLIC_DIR, // NODE_MODULES_DIR, // REACT_MODULE_DIR, // REACT_DOM_MODULE_DIR, diff --git a/package.json b/package.json index 6e9ab51..2077ff7 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,34 @@ { "name": "@moduletrace/bunext", - "module": "index.ts", - "type": "module", - "version": "1.0.55", + "version": "1.0.56", "main": "dist/index.js", - "types": "dist/index.d.ts", + "module": "index.ts", + "dependencies": { + "@tailwindcss/postcss": "^4.2.2", + "@types/bun": "latest", + "@types/node": "^24.10.0", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "bun-plugin-tailwind": "^0.1.2", + "chalk": "^5.6.2", + "commander": "^14.0.2", + "esbuild": "^0.27.4", + "lightningcss-wasm": "^1.32.0", + "lodash": "^4.17.23", + "micromatch": "^4.0.8", + "ora": "^9.0.0", + "postcss": "^8.5.8", + "react": "^19.2.4", + "react-dom": "^19.2.4", + "tailwindcss": "^4.2.2", + "typescript": "^5.0.0" + }, + "devDependencies": { + "@testing-library/dom": "^10.4.1", + "@types/lodash": "^4.17.24", + "@types/micromatch": "^4.0.10", + "happy-dom": "^20.8.4" + }, "exports": { ".": { "types": "./dist/index.d.ts", @@ -25,6 +49,9 @@ "README.md", "package.json" ], + "publishConfig": { + "registry": "https://npm.pkg.github.com" + }, "scripts": { "dev": "tsc --watch", "git:push": "tsc --noEmit && tsc && git add . && git commit -m 'Update init. Remove uneccessary directores creation' && git push", @@ -32,35 +59,6 @@ "build": "tsc", "test": "bun test --max-concurrency=1" }, - "devDependencies": { - "@testing-library/dom": "^10.4.1", - "@types/lodash": "^4.17.24", - "@types/micromatch": "^4.0.10", - "happy-dom": "^20.8.4" - }, - "peerDependencies": { - "react": "^19", - "react-dom": "^19" - }, - "publishConfig": { - "registry": "https://npm.pkg.github.com" - }, - "dependencies": { - "typescript": "^5.0.0", - "@tailwindcss/postcss": "^4.2.2", - "@types/bun": "latest", - "@types/node": "^24.10.0", - "@types/react": "^19.2.2", - "@types/react-dom": "^19.2.2", - "bun-plugin-tailwind": "^0.1.2", - "chalk": "^5.6.2", - "commander": "^14.0.2", - "esbuild": "^0.27.4", - "lightningcss-wasm": "^1.32.0", - "lodash": "^4.17.23", - "micromatch": "^4.0.8", - "ora": "^9.0.0", - "postcss": "^8.5.8", - "tailwindcss": "^4.2.2" - } + "type": "module", + "types": "dist/index.d.ts" } diff --git a/src/functions/bundler/all-pages-esbuild-context-bundler.ts b/src/functions/bundler/all-pages-esbuild-context-bundler.ts index 2996274..39803ea 100644 --- a/src/functions/bundler/all-pages-esbuild-context-bundler.ts +++ b/src/functions/bundler/all-pages-esbuild-context-bundler.ts @@ -16,8 +16,6 @@ type Params = { }; export default async function allPagesESBuildContextBundler(params?: Params) { - // return await allPagesESBuildContextBundlerFiles(params); - const pages = grabAllPages({ exclude_api: true }); global.PAGE_FILES = pages; @@ -62,6 +60,7 @@ export default async function allPagesESBuildContextBundler(params?: Params) { entryNames: "[dir]/[hash]", metafile: true, plugins: [ + forceExternalReact(), tailwindEsbuildPlugin, virtualFilesPlugin({ entryToPage, @@ -74,18 +73,29 @@ export default async function allPagesESBuildContextBundler(params?: Params) { jsx: "automatic", splitting: true, treeShaking: true, - logLevel: "silent", - // logLevel: "silent", - // logLevel: dev ? "error" : "silent", - // external: [ - // "react", - // "react-dom", - // "react-dom/client", - // "react/jsx-runtime", - // "react/jsx-dev-runtime", - // ], - // jsxDev: dev, + external: [ + "react", + "react-dom", + "react-dom/client", + "react/jsx-runtime", + "react/jsx-dev-runtime", + ], }); await global.BUNDLER_CTX.rebuild(); } + +function forceExternalReact(): esbuild.Plugin { + return { + name: "force-external-react", + setup(build) { + build.onResolve({ filter: /^react(-dom)?(\/.*)?$/ }, (args) => { + if (args.pluginData?.externalReact) return null; + return { + path: args.path, + external: true, + }; + }); + }, + }; +} diff --git a/src/functions/bundler/bun-react-modules-bundler.ts b/src/functions/bundler/bun-react-modules-bundler.ts new file mode 100644 index 0000000..7f7a0bd --- /dev/null +++ b/src/functions/bundler/bun-react-modules-bundler.ts @@ -0,0 +1,86 @@ +import grabDirNames from "../../utils/grab-dir-names"; +import isDevelopment from "../../utils/is-development"; +import path from "path"; +import { rmSync, mkdirSync, writeFileSync } from "fs"; + +const { BUNEXT_VENDOR_DIR, BUNX_CWD_DIR } = grabDirNames(); + +const VENDOR_ENTRIES: Record = { + react: ` + import React from "react"; + export const { + Children, Component, Fragment, Profiler, PureComponent, StrictMode, + Suspense, cloneElement, createContext, createElement, createRef, + forwardRef, isValidElement, lazy, memo, startTransition, + useCallback, useContext, useDebugValue, useDeferredValue, useEffect, + useId, useImperativeHandle, useInsertionEffect, useLayoutEffect, + useMemo, useReducer, useRef, useState, useSyncExternalStore, + useTransition, version, use, cache, act, + } = React; + export default React; + `, + "react-dom": ` + import ReactDOM from "react-dom"; + export const { + createPortal, flushSync, version, + } = ReactDOM; + export default ReactDOM; + `, + "react-dom_client": ` + import ReactDOMClient from "react-dom/client"; + export const { createRoot, hydrateRoot } = ReactDOMClient; + export default ReactDOMClient; + `, + "react_jsx-runtime": ` + import JSXRuntime from "react/jsx-runtime"; + export const { jsx, jsxs, Fragment } = JSXRuntime; + `, + "react_jsx-dev-runtime": ` + import JSXDevRuntime from "react/jsx-dev-runtime"; + export const { jsxDEV, Fragment } = JSXDevRuntime; + `, +}; + +export default async function bunReactModulesBundler() { + const dev = isDevelopment(); + + rmSync(BUNEXT_VENDOR_DIR, { force: true, recursive: true }); + + const tmpDir = path.join(BUNEXT_VENDOR_DIR, "_tmp"); + mkdirSync(tmpDir, { recursive: true }); + + const entrypoints: string[] = []; + for (const [name, contents] of Object.entries(VENDOR_ENTRIES)) { + const file = path.join(tmpDir, `${name}.mjs`); + writeFileSync(file, contents); + entrypoints.push(file); + } + + await Bun.build({ + entrypoints, + outdir: BUNEXT_VENDOR_DIR, + splitting: true, + format: "esm", + target: "browser", + minify: !dev, + define: { + "process.env.NODE_ENV": JSON.stringify( + dev ? "development" : "production", + ), + }, + }); + + rmSync(tmpDir, { force: true, recursive: true }); + + const PUBLIC_ROOT = BUNEXT_VENDOR_DIR.replace(BUNX_CWD_DIR, "/.bunext"); + + global.REACT_IMPORTS_MAP = { + imports: { + react: `${PUBLIC_ROOT}/react.js`, + "react-dom": `${PUBLIC_ROOT}/react-dom.js`, + "react-dom/client": `${PUBLIC_ROOT}/react-dom_client.js`, + "react/jsx-runtime": `${PUBLIC_ROOT}/react_jsx-runtime.js`, + "react/jsx-dev-runtime": `${PUBLIC_ROOT}/react_jsx-dev-runtime.js`, + }, + }; +} diff --git a/src/functions/bundler/grab-client-hydration-script.tsx b/src/functions/bundler/grab-client-hydration-script.tsx index 71262fc..1577580 100644 --- a/src/functions/bundler/grab-client-hydration-script.tsx +++ b/src/functions/bundler/grab-client-hydration-script.tsx @@ -50,6 +50,8 @@ export default async function grabClientHydrationScript({ let txt = ``; txt += `import { hydrateRoot } from "${ROOT_DIR}/node_modules/react-dom/client.js";\n`; + // txt += `import react from "${ROOT_DIR}/node_modules/react/index.js";\n`; + if (root_file_path) { txt += `import Root from "${root_file_path}";\n`; } diff --git a/src/functions/bundler/plugins/chunk-react.ts b/src/functions/bundler/plugins/chunk-react.ts new file mode 100644 index 0000000..df27213 --- /dev/null +++ b/src/functions/bundler/plugins/chunk-react.ts @@ -0,0 +1,117 @@ +// plugins/react-vendor-chunk-plugin.ts +import * as esbuild from "esbuild"; +import path from "path"; +import grabDirNames from "../../../utils/grab-dir-names"; + +const { BUNEXT_VENDOR_DIR } = grabDirNames(); + +const REACT_MODULES = new Set([ + "react", + "react-dom", + "react-dom/client", + "react/jsx-runtime", + "react/jsx-dev-runtime", +]); + +const VENDOR_BASE = "/.bunext/public/vendor"; + +const REACT_ENTRIES: Record = { + react: ` + import React from "react"; + export const { + Children, Component, Fragment, Profiler, PureComponent, StrictMode, + Suspense, cloneElement, createContext, createElement, createFactory, + createRef, forwardRef, isValidElement, lazy, memo, startTransition, + useCallback, useContext, useDebugValue, useDeferredValue, useEffect, + useId, useImperativeHandle, useInsertionEffect, useLayoutEffect, + useMemo, useReducer, useRef, useState, useSyncExternalStore, + useTransition, version, + } = React; + export default React; + `, + "react-dom": ` + import ReactDOM from "react-dom"; + export const { + createPortal, flushSync, findDOMNode, hydrate, render, + unmountComponentAtNode, version, + } = ReactDOM; + export default ReactDOM; + `, + "react-dom/client": ` + import ReactDOMClient from "react-dom/client"; + export const { createRoot, hydrateRoot } = ReactDOMClient; + export default ReactDOMClient; + `, + "react/jsx-runtime": ` + import JSXRuntime from "react/jsx-runtime"; + export const { jsx, jsxs, Fragment } = JSXRuntime; + export default JSXRuntime; + `, + "react/jsx-dev-runtime": ` + import JSXDevRuntime from "react/jsx-dev-runtime"; + export const { jsxDEV, Fragment } = JSXDevRuntime; + export default JSXDevRuntime; + `, +}; + +// Map bare specifier -> browser path +function vendorPath(specifier: string): string { + const filename = specifier.replace(/\//g, "_") + ".js"; + return `${VENDOR_BASE}/${filename}`; +} + +function vendorOutfile(specifier: string): string { + const filename = specifier.replace(/\//g, "_") + ".js"; + return path.join(BUNEXT_VENDOR_DIR, filename); +} + +export default function reactVendorChunkPlugin(): esbuild.Plugin { + let vendorReady: Promise; + + return { + name: "react-vendor-chunk", + setup(build) { + vendorReady ??= buildAllVendorChunks(build.initialOptions); + + build.onResolve( + { filter: /^react(-dom)?(\/.*)?$/ }, + async (args) => { + const bare = args.path.replace(/\/index(\.m?js)?$/, ""); + if (!(bare in REACT_ENTRIES)) return; + + await vendorReady; + + return { + path: vendorPath(bare), + external: true, + }; + }, + ); + }, + }; +} + +async function buildAllVendorChunks( + parentOptions: esbuild.BuildOptions, +): Promise { + await Promise.all( + Object.entries(REACT_ENTRIES).map(([specifier, contents]) => + esbuild.build({ + stdin: { + contents, + resolveDir: process.cwd(), + loader: "tsx", + }, + outfile: vendorOutfile(specifier), + bundle: true, + minify: parentOptions.minify, + format: "esm", + target: parentOptions.target as string, + platform: "browser", + define: parentOptions.define, + mainFields: ["module", "main"], + conditions: ["import", "default"], + }), + ), + ); +} diff --git a/src/functions/bundler/plugins/react-alias.ts b/src/functions/bundler/plugins/react-alias.ts new file mode 100644 index 0000000..75104de --- /dev/null +++ b/src/functions/bundler/plugins/react-alias.ts @@ -0,0 +1,30 @@ +import type { Plugin } from "esbuild"; +import path from "path"; +import grabDirNames from "../../../utils/grab-dir-names"; + +const { ROOT_DIR } = grabDirNames(); + +const reactAliasPlugin: Plugin = { + name: "react-alias", + setup(build) { + const reactPath = path.join(ROOT_DIR, "node_modules"); + + build.onResolve({ filter: /^react$/ }, () => ({ + path: path.join(reactPath, "react", "index.js"), + })); + + build.onResolve({ filter: /^react-dom$/ }, () => ({ + path: path.join(reactPath, "react-dom", "index.js"), + })); + + build.onResolve({ filter: /^react\/jsx-runtime$/ }, () => ({ + path: path.join(reactPath, "react", "jsx-runtime.js"), + })); + + build.onResolve({ filter: /^react\/jsx-dev-runtime$/ }, () => ({ + path: path.join(reactPath, "react", "jsx-dev-runtime.js"), + })); + }, +}; + +export default reactAliasPlugin; diff --git a/src/functions/bundler/plugins/virtual-files-plugin.ts b/src/functions/bundler/plugins/virtual-files-plugin.ts index 3a935e7..81e99ea 100644 --- a/src/functions/bundler/plugins/virtual-files-plugin.ts +++ b/src/functions/bundler/plugins/virtual-files-plugin.ts @@ -24,6 +24,22 @@ export default function virtualFilesPlugin({ entryToPage }: Params) { }; }); + build.onResolve( + { filter: /node_modules\/react(-dom)?/ }, + (args) => ({ + path: args.path.includes("react-dom") + ? args.path.includes("client") + ? "react-dom/client" + : "react-dom" + : args.path.includes("jsx-dev") + ? "react/jsx-dev-runtime" + : args.path.includes("jsx") + ? "react/jsx-runtime" + : "react", + external: true, + }), + ); + build.onLoad( { filter: /.*/, namespace: "hydration-virtual" }, (args) => { diff --git a/src/functions/bundler/react-modules-bundler.ts b/src/functions/bundler/react-modules-bundler.ts new file mode 100644 index 0000000..e1c5a89 --- /dev/null +++ b/src/functions/bundler/react-modules-bundler.ts @@ -0,0 +1,89 @@ +import * as esbuild from "esbuild"; +import grabDirNames from "../../utils/grab-dir-names"; +import isDevelopment from "../../utils/is-development"; +import path from "path"; +import { rmSync, mkdirSync, writeFileSync } from "fs"; + +const { BUNEXT_VENDOR_DIR, BUNX_CWD_DIR, ROOT_DIR } = grabDirNames(); + +const VENDOR_ENTRIES: Record = { + react: ` + import React from "react"; + export const { + Children, Component, Fragment, Profiler, PureComponent, StrictMode, + Suspense, cloneElement, createContext, createElement, createRef, + forwardRef, isValidElement, lazy, memo, startTransition, + useCallback, useContext, useDebugValue, useDeferredValue, useEffect, + useId, useImperativeHandle, useInsertionEffect, useLayoutEffect, + useMemo, useReducer, useRef, useState, useSyncExternalStore, + useTransition, version, use, cache, act, + } = React; + export default React; + `, + "react-dom": ` + import ReactDOM from "react-dom"; + export const { + createPortal, flushSync, version, + } = ReactDOM; + export default ReactDOM; + `, + "react-dom_client": ` + import ReactDOMClient from "react-dom/client"; + export const { createRoot, hydrateRoot } = ReactDOMClient; + export default ReactDOMClient; + `, + "react_jsx-runtime": ` + import JSXRuntime from "react/jsx-runtime"; + export const { jsx, jsxs, Fragment } = JSXRuntime; + `, + "react_jsx-dev-runtime": ` + import JSXDevRuntime from "react/jsx-dev-runtime"; + export const { jsxDEV, Fragment } = JSXDevRuntime; + `, +}; + +export default async function reactModulesBundler() { + const dev = isDevelopment(); + + rmSync(BUNEXT_VENDOR_DIR, { force: true, recursive: true }); + + const tmpDir = path.join(BUNEXT_VENDOR_DIR, "_tmp"); + mkdirSync(tmpDir, { recursive: true }); + + const entrypoints: Record = {}; + for (const [name, contents] of Object.entries(VENDOR_ENTRIES)) { + const file = path.join(tmpDir, `${name}.mjs`); + writeFileSync(file, contents); + entrypoints[name] = file; + } + + await esbuild.build({ + entryPoints: entrypoints, + outdir: BUNEXT_VENDOR_DIR, + bundle: true, + splitting: true, + format: "esm", + platform: "browser", + target: "es2020", + minify: !dev, + define: { + "process.env.NODE_ENV": JSON.stringify( + dev ? "development" : "production", + ), + }, + }); + + rmSync(tmpDir, { force: true, recursive: true }); + + const PUBLIC_ROOT = BUNEXT_VENDOR_DIR.replace(BUNX_CWD_DIR, "/.bunext"); + + global.REACT_IMPORTS_MAP = { + imports: { + react: `${PUBLIC_ROOT}/react.js`, + "react-dom": `${PUBLIC_ROOT}/react-dom.js`, + "react-dom/client": `${PUBLIC_ROOT}/react-dom_client.js`, + "react/jsx-runtime": `${PUBLIC_ROOT}/react_jsx-runtime.js`, + "react/jsx-dev-runtime": `${PUBLIC_ROOT}/react_jsx-dev-runtime.js`, + }, + }; +} diff --git a/src/functions/bunext-init.ts b/src/functions/bunext-init.ts index 23747d3..b96945c 100644 --- a/src/functions/bunext-init.ts +++ b/src/functions/bunext-init.ts @@ -15,6 +15,7 @@ import type { BuildContext } from "esbuild"; import watcherEsbuildCTX from "./server/watcher-esbuild-ctx"; import allPagesESBuildContextBundler from "./bundler/all-pages-esbuild-context-bundler"; import serverPostBuildFn from "./server/server-post-build-fn"; +import reactModulesBundler from "./bundler/react-modules-bundler"; /** * # Declare Global Variables @@ -36,6 +37,8 @@ declare global { var SKIPPED_BROWSER_MODULES: Set; var BUNDLER_CTX: BuildContext | undefined; var DIR_NAMES: ReturnType; + var REACT_IMPORTS_MAP: { imports: Record }; + var REACT_DOM_SERVER: any; } const dirNames = grabDirNames(); @@ -48,8 +51,11 @@ export default async function bunextInit() { global.PAGE_FILES = []; global.SKIPPED_BROWSER_MODULES = new Set(); global.DIR_NAMES = dirNames; + global.REACT_IMPORTS_MAP = { imports: {} }; await init(); + // await bunReactModulesBundler(); + await reactModulesBundler(); log.banner(); const router = new Bun.FileSystemRouter({ diff --git a/src/functions/server/handle-bunext-public-assets.ts b/src/functions/server/handle-bunext-public-assets.ts index 83f3fd0..c9d5bef 100644 --- a/src/functions/server/handle-bunext-public-assets.ts +++ b/src/functions/server/handle-bunext-public-assets.ts @@ -1,10 +1,9 @@ import grabDirNames from "../../utils/grab-dir-names"; import path from "path"; import isDevelopment from "../../utils/is-development"; -import { existsSync } from "fs"; import { readFileResponse } from "./handle-public"; -const { HYDRATION_DST_DIR } = grabDirNames(); +const { BUNEXT_PUBLIC_DIR } = grabDirNames(); type Params = { req: Request; @@ -15,54 +14,21 @@ export default async function ({ req }: Params): Promise { const is_dev = isDevelopment(); const url = new URL(req.url); - // switch (url.pathname) { - // case "/.bunext/react": - // return readFileResponse({ - // file_path: is_dev - // ? global.DIR_NAMES.REACT_DEVELOPMENT_MODULE - // : global.DIR_NAMES.REACT_PRODUCTION_MODULE, - // }); - // case "/.bunext/react-dom": - // return readFileResponse({ - // file_path: is_dev - // ? global.DIR_NAMES.REACT_DOM_DEVELOPMENT_MODULE - // : global.DIR_NAMES.REACT_DOM_PRODUCTION_MODULE, - // }); - // case "/.bunext/react-dom-client": - // return readFileResponse({ - // file_path: is_dev - // ? global.DIR_NAMES.REACT_DOM_CLIENT_DEVELOPMENT_MODULE - // : global.DIR_NAMES.REACT_DOM_CLIENT_PRODUCTION_MODULE, - // }); - // case "/.bunext/react-jsx-runtime": - // return readFileResponse({ - // file_path: is_dev - // ? global.DIR_NAMES.REACT_JSX_RUNTIME_DEVELOPMENT_MODULE - // : global.DIR_NAMES.REACT_JSX_RUNTIME_PRODUCTION_MODULE, - // }); - // case "/.bunext/react-jsx-dev-runtime": - // return readFileResponse({ - // file_path: is_dev - // ? global.DIR_NAMES - // .REACT_JSX_DEVELOPMENT_RUNTIME_DEVELOPMENT_MODULE - // : global.DIR_NAMES - // .REACT_JSX_DEVELOPMENT_RUNTIME_PRODUCTION_MODULE, - // }); - - // default: - // break; - // } - const file_path = path.join( - HYDRATION_DST_DIR, - url.pathname.replace(/\/\.bunext\/public\/pages\//, ""), + BUNEXT_PUBLIC_DIR, + url.pathname.replace(/\/\.bunext\/public\//, ""), ); - if (!file_path.startsWith(HYDRATION_DST_DIR + path.sep)) { + if (!file_path.startsWith(BUNEXT_PUBLIC_DIR + path.sep)) { return new Response("Forbidden", { status: 403 }); } - return readFileResponse({ file_path }); + return readFileResponse({ + file_path, + cache: url.pathname.includes("/vendor/") + ? { duration: 3600 } + : undefined, + }); } catch (error) { return new Response(`File Not Found`, { status: 404, diff --git a/src/functions/server/handle-public.ts b/src/functions/server/handle-public.ts index c345d33..75a6208 100644 --- a/src/functions/server/handle-public.ts +++ b/src/functions/server/handle-public.ts @@ -31,7 +31,14 @@ export default async function ({ req }: Params): Promise { } } -export function readFileResponse({ file_path }: { file_path: string }) { +type FileResponse = { + file_path: string; + cache?: { + duration?: "infinite" | number; + }; +}; + +export function readFileResponse({ file_path, cache }: FileResponse) { if (!existsSync(file_path)) { return new Response(`Public File Doesn't Exist`, { status: 404, @@ -40,7 +47,15 @@ export function readFileResponse({ file_path }: { file_path: string }) { const file = Bun.file(file_path); - // let res_opts: ResponseInit = {}; + const headers = new Headers(); - return new Response(file); + if (cache?.duration == "infinite" || (cache && !cache.duration)) { + headers.set("Cache-Control", "public, max-age=31536000, immutable"); + } else if (cache?.duration) { + headers.set("Cache-Control", `public, max-age=${cache.duration}`); + } + + return new Response(file, { + headers, + }); } diff --git a/src/functions/server/web-pages/generate-web-html.tsx b/src/functions/server/web-pages/generate-web-html.tsx index 1277c36..4bccc1d 100644 --- a/src/functions/server/web-pages/generate-web-html.tsx +++ b/src/functions/server/web-pages/generate-web-html.tsx @@ -6,23 +6,11 @@ import grabWebPageHydrationScript from "./grab-web-page-hydration-script"; import grabWebMetaHTML from "./grab-web-meta-html"; import { log } from "../../../utils/log"; import { AppData } from "../../../data/app-data"; -import { readFileSync } from "fs"; -import path from "path"; import _ from "lodash"; import grabDirNames from "../../../utils/grab-dir-names"; const { ROOT_DIR } = grabDirNames(); -let _reactVersion = "19"; -try { - _reactVersion = JSON.parse( - readFileSync( - path.join(process.cwd(), "node_modules/react/package.json"), - "utf-8", - ), - ).version; -} catch {} - export default async function genWebHTML({ component, pageProps, @@ -75,32 +63,6 @@ export default async function genWebHTML({ const RootHead = root_module?.Head; const dev = isDevelopment(); - const devSuffix = dev ? "?dev" : ""; - - // const browser_imports: Record = { - // react: `/.bunext/react`, - // "react-dom": `/.bunext/react-dom`, - // "react-dom/client": `/.bunext/react-dom-client`, - // "react/jsx-runtime": `/.bunext/react-jsx-runtime`, - // "react/jsx-dev-runtime": `/.bunext/react-jsx-dev-runtime`, - // }; - - // const browser_imports: Record = { - // react: `https://esm.sh/react@${_reactVersion}`, - // "react-dom": `https://esm.sh/react-dom@${_reactVersion}`, - // "react-dom/client": `https://esm.sh/react-dom@${_reactVersion}/client`, - // "react/jsx-runtime": `https://esm.sh/react@${_reactVersion}/jsx-runtime`, - // "react/jsx-dev-runtime": `https://esm.sh/react@${_reactVersion}/jsx-dev-runtime`, - // }; - - // if (dev) { - // browser_imports["react/jsx-dev-runtime"] = - // `https://esm.sh/react@${_reactVersion}/jsx-dev-runtime`; - // } - - // const importMap = JSON.stringify({ - // imports: browser_imports, - // }); const final_meta = _.merge(root_meta, page_meta); @@ -116,8 +78,6 @@ export default async function genWebHTML({ {final_meta ? grabWebMetaHTML({ meta: final_meta }) : null} - {/* */} - {bundledMap?.css_path ? ( - {/*