1.4.1: nested pages dirs, CommonJS require of react in client bundles

- Page URL paths are now relative to src/pages. The old greedy
  `.*/pages/` cut mapped src/pages/admin/pages/index.tsx to /index, so
  every page under a nested `pages` directory collided with the home page
  and was never bundled. SSR also tells API routes by URL path now.
- CommonJS `require("react…")` in client bundles (for example
  use-sync-external-store/shim, pulled in by MUI) threw "Dynamic require
  of "react" is not supported" in the browser. Those requires now go
  through a small bundled ESM module that imports the external.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-11-09 08:37:44 +01:00
co-authored by Claude Opus 5.5
parent 96e392f556
commit 33145bea9e
11 changed files with 246 additions and 29 deletions
@@ -1,3 +1,4 @@
import * as esbuild from "esbuild";
import type { BundlerCTXMap } from "../../types";
type Params = {
post_build_fn?: (params: {
@@ -17,4 +18,13 @@ type Params = {
*/
export declare function isHydrationEntryPage(local_path: string): boolean;
export default function allPagesESBuildContextBundler(params?: Params): Promise<void>;
/**
* CommonJS `require("react")` (e.g. `use-sync-external-store/shim`, pulled
* in by MUI) for a module that is external in ESM output becomes esbuild's
* `__require`, which throws "Dynamic require of "react" is not supported" in
* the browser. Give those requires a small bundled ESM module that imports
* the external instead; esbuild wires `require()` to it.
*/
export declare function externalRequireShim(specifier: string): string;
export declare function forceExternalReact(): esbuild.Plugin;
export {};
+27 -1
View File
@@ -128,18 +128,44 @@ export default async function allPagesESBuildContextBundler(params) {
], (line) => log.error(line));
}
}
function forceExternalReact() {
const ExternalRequireNamespace = "bunext-external-require";
/**
* CommonJS `require("react")` (e.g. `use-sync-external-store/shim`, pulled
* in by MUI) for a module that is external in ESM output becomes esbuild's
* `__require`, which throws "Dynamic require of "react" is not supported" in
* the browser. Give those requires a small bundled ESM module that imports
* the external instead; esbuild wires `require()` to it.
*/
export function externalRequireShim(specifier) {
const spec = JSON.stringify(specifier);
// A namespace import never fails to link, even when the vendor module has
// no default export (jsx-runtime); fall back to the namespace for
// `require(...).default` (Babel interop).
return [
`import * as ns from ${spec};`,
`export * from ${spec};`,
`export default ns.default ?? ns;`,
].join("\n");
}
export function forceExternalReact() {
return {
name: "force-external-react",
setup(build) {
build.onResolve({ filter: /^react(-dom)?(\/.*)?$/ }, (args) => {
if (args.pluginData?.externalReact)
return null;
if (args.kind === "require-call") {
return { path: args.path, namespace: ExternalRequireNamespace };
}
return {
path: args.path,
external: true,
};
});
build.onLoad({ filter: /.*/, namespace: ExternalRequireNamespace }, (args) => ({
contents: externalRequireShim(args.path),
loader: "js",
}));
},
};
}
+1 -1
View File
@@ -46,7 +46,7 @@ async function computeEntryToPage() {
}
catch (error) { }
for (const page of pages) {
if (page.local_path.match(/\/pages\/api\//) ||
if (page.url_path.startsWith("/api/") ||
page.local_path.match(/\.server\.tsx?$/)) {
const ts = await Bun.file(page.local_path).text();
if (ts.match(/(export default)|(export \w+ handler)|(export \w+ server)/)) {
+9
View File
@@ -5,4 +5,13 @@ type Params = {
include_server?: boolean;
};
export default function grabAllPages(params?: Params): PageFiles[];
/**
* The URL path is the file's path relative to the pages root, so a nested
* directory that happens to be called `pages` (e.g. `src/pages/admin/pages/
* index.tsx` -> `/admin/pages/index`) is kept, not treated as the root.
*/
export declare function grabPageFileObject({ file_path, pages_root, }: {
file_path: string;
pages_root: string;
}): PageFiles | undefined;
export {};
+17 -11
View File
@@ -17,13 +17,9 @@ export default function grabAllPages(params) {
}
return pages;
}
function grabPageDirRecursively({ page_dir, include_server, }) {
function grabPageDirRecursively({ page_dir, pages_root = page_dir, include_server, }) {
const pages = readdirSync(page_dir);
const pages_files = [];
const root_pages_file = grabPageFileObject({ file_path: `` });
if (root_pages_file) {
pages_files.push(root_pages_file);
}
for (let i = 0; i < pages.length; i++) {
const page = pages[i];
const page_name = page.split("/").pop();
@@ -56,6 +52,7 @@ function grabPageDirRecursively({ page_dir, include_server, }) {
continue;
const new_page_files = grabPageDirRecursively({
page_dir: full_page_path,
pages_root,
include_server,
});
pages_files.push(...new_page_files);
@@ -63,6 +60,7 @@ function grabPageDirRecursively({ page_dir, include_server, }) {
else if (page.match(/\.(ts|js)x?$/)) {
const pages_file = grabPageFileObject({
file_path: full_page_path,
pages_root,
});
if (pages_file) {
pages_files.push(pages_file);
@@ -77,14 +75,22 @@ function grabPageDirRecursively({ page_dir, include_server, }) {
return 0;
});
}
function grabPageFileObject({ file_path, }) {
let url_path = file_path
.replace(/.*\/pages\//, "/")
?.replace(/\.(ts|js)x?$/, "");
let file_name = url_path.split("/").pop();
/**
* The URL path is the file's path relative to the pages root, so a nested
* directory that happens to be called `pages` (e.g. `src/pages/admin/pages/
* index.tsx` -> `/admin/pages/index`) is kept, not treated as the root.
*/
export function grabPageFileObject({ file_path, pages_root, }) {
const relative_path = path
.relative(pages_root, file_path)
.split(path.sep)
.join("/");
if (!relative_path || relative_path.startsWith("../"))
return;
const url_path = "/" + relative_path.replace(/\.(ts|js)x?$/, "");
const file_name = url_path.split("/").pop();
if (!file_name)
return;
// const transformed_path = pagePathTransform({ page_path: file_path });
return {
local_path: file_path,
url_path,