Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/runtime/bake/bake.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down
44 changes: 42 additions & 2 deletions src/runtime/bake/production/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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::<platform::Auto>(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 =
Expand Down Expand Up @@ -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 {
Comment thread
robobun marked this conversation as resolved.
Resolved
Global::crash();
}

Expand Down
186 changes: 174 additions & 12 deletions test/bake/dev/production.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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({
Expand All @@ -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 () => {
Expand Down
Loading