diff --git a/src/runtime/bake/bake.d.ts b/src/runtime/bake/bake.d.ts index 0a0149fc9621..886dbfcc64c1 100644 --- a/src/runtime/bake/bake.d.ts +++ b/src/runtime/bake/bake.d.ts @@ -245,6 +245,7 @@ declare module "bun" { interface FrameworkFileSystemRouterType { /** * Relative to project root. For example: `src/pages`. + * `bun build --app` fails if it cannot read this directory, so list only roots that exist. */ root: string; /** diff --git a/src/runtime/bake/production/mod.rs b/src/runtime/bake/production/mod.rs index c0c4cb799cb5..d34fb7b2bc7e 100644 --- a/src/runtime/bake/production/mod.rs +++ b/src/runtime/bake/production/mod.rs @@ -284,6 +284,42 @@ fn write_sourcemap_to_disk( Ok(()) } +/// An empty listing is also what the resolver keeps for a root that it can enter but cannot read. +fn router_root_is_listed(info: &resolver::DirInfo, abs_root: &[u8]) -> bool { + let has_entries = { + // Other threads rewrite the cached listing in place under this lock. + let _entries_lock = resolver::fs::FileSystem::get() + .fs + .entries_mutex + .lock_guard(); + info.get_entries_const() + .is_some_and(|entries| !entries.data.is_empty()) + }; + has_entries + || bun_sys::open_dir_absolute(abs_root) + .map(bun_sys::Dir::from_fd) + .is_ok() +} + +#[cold] +fn report_unreadable_router_root(framework: &bake::Framework, index: usize, abs_root: &[u8]) { + let root = bun_core::fmt::quote(abs_root); + match bun_sys::open_dir_absolute(abs_root).map(bun_sys::Dir::from_fd) { + Err(err) => Output::err(err.name(), "Cannot read the router root {}", (root,)), + // The directory opens, so the resolver has another reason to give no listing. + Ok(_) => Output::err_generic("The resolver cannot load the router root {}", (root,)), + } + if framework.is_built_in_react { + bun_core::note!( + "The \"react\" framework reads its pages ({}) from this directory", + bun_core::fmt::fmt_slice(&framework.file_system_router_types[index].extensions, ", ") + ); + } else { + bun_core::note!("This directory is 'fileSystemRouterTypes[{}].root'", index); + } + Output::flush(); +} + fn build_with_vm(ctx: Context, cwd: &[u8], pt: &mut PerThread) -> crate::Result<()> { // `pt.vm` is the live per-thread VM's BackRef set in `build_command`; // `as_ptr()` is `Copy` and does not borrow `pt`. @@ -552,14 +588,18 @@ fn build_with_vm(ctx: Context, cwd: &[u8], pt: &mut PerThread) -> crate::Result< owned_paths: Vec::new(), }; - for fsr in &framework.file_system_router_types { + let mut unreadable_roots: usize = 0; + for (index, fsr) in framework.file_system_router_types.iter().enumerate() { let mut buf = bun_paths::path_buffer_pool::get(); let joined_root = resolve_path::join_abs_string_buf::(cwd, &mut buf[..], &[&fsr.root]); let Some(entry) = server_transpiler .resolver .read_dir_info_ignore_error(joined_root) + .filter(|info| router_root_is_listed(info, joined_root)) else { + report_unreadable_router_root(framework, index, joined_root); + unreadable_roots += 1; continue; }; let server_file = @@ -602,7 +642,7 @@ fn build_with_vm(ctx: Context, cwd: &[u8], pt: &mut PerThread) -> crate::Result< &mut server_transpiler.resolver, router::InsertionContext::wrap(&mut route_scan), )?; - if route_scan.invalid_routes > 0 { + if route_scan.invalid_routes > 0 || unreadable_roots > 0 { Global::crash(); } diff --git a/test/bake/dev/production.test.ts b/test/bake/dev/production.test.ts index 6771c0f96261..b6769788d36d 100644 --- a/test/bake/dev/production.test.ts +++ b/test/bake/dev/production.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { existsSync } from "fs"; -import { bunEnv, bunExe, isWindows, tempDir } from "harness"; +import { chmodSync, existsSync } from "fs"; +import { bunEnv, bunExe, isWindows, tempDir, unprivilegedSpawnOptions, type DirectoryTree } from "harness"; import path from "path"; import { minimalFramework, tempDirWithBakeDeps } from "../bake-harness"; @@ -505,7 +505,7 @@ export default function () { expect(exitCode).toBe(1); }); - test("handles build with no pages directory without crashing", async () => { + test("fails the build when the react framework has no pages directory", async () => { const dir = await tempDirWithBakeDeps("bake-production-no-pages", { "app.ts": `export default { app: { framework: "react" } };`, "package.json": JSON.stringify({ @@ -518,18 +518,180 @@ export default function () { }), }); - // Run the build command - should not crash even with no pages - const { exitCode, stderr } = await Bun.$`${bunExe()} build --app ./app.ts`.cwd(dir).throws(false); + const { exitCode, stderr } = await Bun.$`${bunExe()} build --app ./app.ts`.cwd(dir).env(bunEnv).throws(false); - // The build should complete successfully (or fail gracefully, not crash) - // We're testing that it doesn't crash with the StringBuilder assertion - expect(exitCode).toBeDefined(); + const lines = normalizePath(stderr.toString()).split(/\r?\n/); + expect(lines.slice(lines.indexOf("Bundling routes") + 1)).toEqual([ + `ENOENT: Cannot read the router root "${normalizePath(path.join(dir, "pages"))}"`, + `note: The "react" framework reads its pages (.tsx, .jsx) from this directory`, + "", + ]); + expect(existsSync(path.join(dir, "dist"))).toBe(false); + expect(exitCode).toBe(1); + }); - // If it fails, it should be a graceful failure, not a crash - if (exitCode !== 0) { - expect(stderr.toString()).not.toContain("reached unreachable code"); - expect(stderr.toString()).not.toContain("assert(this.cap > 0)"); + // A router type reads its routes from `root`. A build that skips a root has pages missing and exit code 0. + describe("router roots that cannot be read", () => { + const router = { root: "routes", style: "nextjs-pages", serverEntryPoint: "./server.ts" }; + // The framework can prerender every page of these fixtures, so only a root stops a build. + // `config` is the source text of the router types. `setup` runs in the build, before the roots are read. + const fixture = (config: string, files: DirectoryTree, setup = "") => ({ + "app.ts": `${setup} + export default { app: { framework: { fileSystemRouterTypes: ${config} } } }; + `, + "server.ts": ` + export function render(req, meta) { + return new Response(String(meta.pageModule.default())); + } + export async function prerender(meta) { + return { files: { "/index.html": String(meta.pageModule.default()) } }; + } + `, + ...files, + }); + const roots = (...roots: string[]) => JSON.stringify(roots.map(root => ({ ...router, root }))); + const page = (name: string) => `export default () => ${JSON.stringify(name)};`; + // The two lines of a report: the error with the absolute root, and the option that names the root. + const report = (error: string, root: string, index: number) => [ + `${error} the router root "${normalizePath(root)}"`, + `note: This directory is 'fileSystemRouterTypes[${index}].root'`, + ]; + + async function build(dir: string, spawnOptions: { uid?: number; gid?: number } = {}) { + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", "--app", "./app.ts"], + env: bunEnv, + cwd: dir, + stdout: "pipe", + stderr: "pipe", + ...spawnOptions, + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const lines = normalizePath(stderr).split(/\r?\n/); + const dist = path.join(dir, "dist"); + const result = { + // The build reads the roots after this line. Its reports follow, or the line of the render step. + afterConfig: lines.slice(lines.indexOf("Bundling routes") + 1).filter(line => line !== ""), + stdout, + pages: existsSync(dist) ? Array.from(new Bun.Glob("**/*.html").scanSync(dist), normalizePath).sort() : null, + exitCode, + }; + return { result, stderr }; } + const failed = (...afterConfig: string[]) => ({ afterConfig, stdout: "", pages: null, exitCode: 1 }); + + test.concurrent.each([ + ["that does not exist", "ENOENT", { "routes/index.ts": page("index") }], + ["that is a file", "ENOTDIR", { "route": "not a directory" }], + ])("fails the build for a root %s", async (_, errno, files) => { + using dir = tempDir("bake-production-unreadable-root", fixture(roots("route"), files)); + const { result, stderr } = await build(String(dir)); + expect(result, stderr).toEqual(failed(...report(`${errno}: Cannot read`, path.join(String(dir), "route"), 0))); + }); + + test.concurrent("fails the build when one of two roots does not exist", async () => { + using dir = tempDir( + "bake-production-one-root-missing", + fixture(roots("src/pages", "routes"), { "routes/index.ts": page("index") }), + ); + const { result, stderr } = await build(String(dir)); + expect(result, stderr).toEqual(failed(...report("ENOENT: Cannot read", path.join(String(dir), "src/pages"), 0))); + }); + + test.concurrent("reports every root and every route before it fails the build", async () => { + // The project is one directory down, so the root "../no-root" is outside the project and inside `dir`. + using dir = tempDir("bake-production-unreadable-roots", { + app: fixture(roots("missing", "routes", "../no-root"), { + "routes/index.ts": page("index"), + "routes/[oops.ts": page("oops"), + }), + }); + const app = path.join(String(dir), "app"); + const { result, stderr } = await build(app); + expect(result, stderr).toEqual( + failed( + ...report("ENOENT: Cannot read", path.join(app, "missing"), 0), + ...report("ENOENT: Cannot read", path.join(String(dir), "no-root"), 2), + `error: "routes/[oops.ts" is not a valid route`, + ` -------`, + ` Missing "]" to match this route parameter`, + ), + ); + }); + + // The resolver looks up at most 256 directories that it has not seen, so it has no listing for + // a root that is 257 directories below the project. Windows: not run, the path is longer than MAX_PATH. + test.concurrent.skipIf(isWindows)( + "fails the build for a root that opens, when the resolver cannot load it", + async () => { + const root = Array(257).fill("a").join("/"); + using dir = tempDir( + "bake-production-unloadable-root", + fixture(roots(root), { [`${root}/index.ts`]: page("index") }), + ); + const { result, stderr } = await build(String(dir)); + expect(result, stderr).toEqual( + failed(...report("error: The resolver cannot load", path.join(String(dir), root), 0)), + ); + }, + ); + + test.concurrent("leaves the files of an earlier build as they are", async () => { + using dir = tempDir( + "bake-production-unreadable-root-old-dist", + fixture(roots("route"), { "dist/index.html": "the earlier build" }), + ); + const { result, stderr } = await build(String(dir)); + expect(result, stderr).toEqual({ + ...failed(...report("ENOENT: Cannot read", path.join(String(dir), "route"), 0)), + pages: ["index.html"], + }); + expect(await Bun.file(path.join(String(dir), "dist", "index.html")).text()).toBe("the earlier build"); + }); + + // POSIX only: `unprivilegedSpawnOptions` runs the build as a user that the mode bits apply to. + // Not concurrent: it can change the modes of the directories above the test's own. + // The build can enter a root of mode 0o311. It finds the entry point below that root, and the + // resolver then has an empty listing for the root. + test.skipIf(isWindows).each([ + ["that it has no permission to read", 0o000, "./server.ts"], + ["that it can enter but cannot read", 0o311, "./routes/_lib/server.ts"], + ])("fails the build for a root %s", async (_, mode, serverEntryPoint) => { + using dir = tempDir( + "bake-production-root-no-permission", + fixture(JSON.stringify([{ ...router, serverEntryPoint }]), { + "routes/index.ts": page("index"), + "routes/_lib/server.ts": `export * from "../../server.ts";`, + }), + ); + const root = path.join(String(dir), "routes"); + using unprivileged = unprivilegedSpawnOptions(String(dir)); + chmodSync(root, mode); + using restoreMode = { [Symbol.dispose]: () => chmodSync(root, 0o755) }; + const { result, stderr } = await build(String(dir), unprivileged); + expect(result, stderr).toEqual(failed(...report("EACCES: Cannot read", root, 0))); + }); + + // Builds to the end, which uses every core, so it runs alone. + // The root "empty" exists and has no files. The build has no routes from it, and that is valid. + test("builds a framework that lists only the roots that exist", async () => { + const setup = `import { existsSync } from "node:fs";`; + using dir = tempDir( + "bake-production-optional-roots", + fixture( + `${roots("src/pages", "routes", "empty")}.filter(router => existsSync(router.root))`, + { "routes/index.ts": page("index"), "empty": {} }, + setup, + ), + ); + const { result, stderr } = await build(String(dir)); + expect(result, stderr).toEqual({ + afterConfig: ["Rendering routes"], + stdout: "done\n", + pages: ["index.html"], + exitCode: 0, + }); + }); }); test("client-side component with default import should work", async () => {