From 84cffc375f5e900fb764145ef29fc053029a1bad Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 18:22:27 +0000 Subject: [PATCH 1/7] bake: resolve app.root against the cwd and require it to be a string UserOptions::from_js stored the user's root verbatim, but DevServer and FrameworkRouter require an absolute path without a trailing separator: a relative root tripped debug assertions and, in release builds, produced route patterns that never matched. The root is now joined against the cwd (the same directory it defaults to), stripped of a trailing separator, and rejected when it does not fit in a path buffer. The local get_optional_slice shim coerced non-strings with toString; JSValue::get_optional_slice throws ERR_INVALID_ARG_TYPE instead, so use it for root, plugin names and the server components strings. --- src/runtime/bake/bake_body.rs | 54 +++++++++-------- test/bake/app-options.test.ts | 108 ++++++++++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 24 deletions(-) create mode 100644 test/bake/app-options.test.ts diff --git a/src/runtime/bake/bake_body.rs b/src/runtime/bake/bake_body.rs index d51f80223766..f82fe7155318 100644 --- a/src/runtime/bake/bake_body.rs +++ b/src/runtime/bake/bake_body.rs @@ -31,19 +31,6 @@ use super::{dev_server, framework_router}; // FrameworkRouter` are already provided by the parent `mod.rs` (lines 349/369); // re-exporting here triggers E0365 because `bake_body` is a private module. -/// Local shim until `bun_jsc` grows a typed `get_optional`. -/// Returns `None` for missing/null/undefined. -fn get_optional_slice( - target: JSValue, - global: &JSGlobalObject, - property: &[u8], -) -> JsResult> { - match target.get(global, property)? { - Some(v) if !v.is_undefined_or_null() => Ok(Some(v.to_slice(global)?)), - _ => Ok(None), - } -} - /// `JSValue.getBooleanStrict` — local shim. fn get_boolean_strict( target: JSValue, @@ -232,15 +219,36 @@ impl UserOptions { &arena, )?; - let root: &[u8] = if let Some(slice) = get_optional_slice(config, global, b"root")? { - allocations.track(slice) - } else { - match bun_sys::getcwd_alloc() { - Ok(z) => arena_dupe_z(&arena, z.as_bytes()).as_bytes(), + // DevServer and FrameworkRouter strip `root` off absolute file paths to + // form module IDs and route patterns, so it must be absolute and must + // not end in a separator. + let root: &'static ZStr = { + let cwd = match bun_sys::getcwd_alloc() { + Ok(cwd) => cwd, Err(e) => { return Err(global .throw_error(e.to_zig_err(), "while querying current working directory")); } + }; + match config.get_optional_slice(global, b"root")? { + None => arena_dupe_z(&arena, cwd.as_bytes()), + Some(user_root) => { + use bun_paths::resolve_path::join_abs_string_buf_checked; + use bun_paths::string_paths::without_trailing_slash_windows_path; + + let mut buf = paths::path_buffer_pool::get(); + let Some(resolved) = join_abs_string_buf_checked::( + cwd.as_bytes(), + &mut buf[..], + &[user_root.slice()], + ) else { + return Err(global.throw_invalid_arguments(format_args!( + "'{}.root' is too long", + API_NAME + ))); + }; + arena_dupe_z(&arena, without_trailing_slash_windows_path(resolved)) + } } }; @@ -248,10 +256,8 @@ impl UserOptions { bundler_options.parse_plugin_array(plugin_array, global)?; } - let root_z = arena_dupe_z(&arena, root); - Ok(UserOptions { - root: root_z, + root, framework, bundler_options, allocations, @@ -334,7 +340,7 @@ impl SplitBundlerOptions { ); } - if let Some(slice) = get_optional_slice(plugin_config, global, b"name")? { + if let Some(slice) = plugin_config.get_optional_slice(global, b"name")? { if slice.slice().is_empty() { return Err(global.throw_invalid_arguments(format_args!( "Expected plugin to have a non-empty name" @@ -856,7 +862,7 @@ impl Framework { ))); }, server_runtime_import: refs.track( - match get_optional_slice(sc, global, b"serverRuntimeImportSource")? { + match sc.get_optional_slice(global, b"serverRuntimeImportSource")? { Some(s) => s, None => { return Err(global.throw_invalid_arguments(format_args!( @@ -866,7 +872,7 @@ impl Framework { }, ), server_register_client_reference: if let Some(slice) = - get_optional_slice(sc, global, b"serverRegisterClientReferenceExport")? + sc.get_optional_slice(global, b"serverRegisterClientReferenceExport")? { refs.track(slice) } else { diff --git a/test/bake/app-options.test.ts b/test/bake/app-options.test.ts new file mode 100644 index 000000000000..eb802a1f8f72 --- /dev/null +++ b/test/bake/app-options.test.ts @@ -0,0 +1,108 @@ +// Parsing of the `app` option of `Bun.serve({ app })` (src/runtime/bake/bake_body.rs, +// UserOptions::from_js). The dev server strips `app.root` off absolute file paths to build +// route patterns and module IDs, so the value the user passes has to be resolved against +// the cwd and normalized before it gets there. +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; + +const appFiles = { + "server.ts": `export function render(req, meta) { return meta.pageModule.default(); }`, + "routes/index.ts": `export default () => new Response("index route");`, +}; + +const framework = `{ + fileSystemRouterTypes: [{ root: "routes", style: "nextjs-pages", serverEntryPoint: "./server.ts" }], +}`; + +describe("app.root", () => { + // Every spelling below names the cwd, which is also what `root` defaults to. + test.concurrent.each([".", "./", "routes/..", "/"])("routes are served when root is %j", async spelling => { + using dir = tempDir("bake-app-root", { + ...appFiles, + "serve.ts": ` + const root = process.argv[2].replace("", process.cwd()); + const server = Bun.serve({ + port: 0, + development: true, + app: { framework: ${framework}, root }, + fetch: () => new Response("fallback"), + }); + const res = await fetch(server.url); + console.log(JSON.stringify({ status: res.status, body: await res.text() })); + await server.stop(true); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "serve.ts", spelling], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + const line = stdout.split("\n").find(l => l.startsWith("{")); + expect(line, stderr).toBeDefined(); + expect(JSON.parse(line!)).toEqual({ status: 200, body: "index route" }); + expect(exitCode).toBe(0); + }); + + test("values that cannot be used as a root are rejected while parsing the options", async () => { + using dir = tempDir("bake-app-root-invalid", { + ...appFiles, + "check.ts": ` + const framework = ${framework}; + const serverComponents = extra => ({ + framework: { ...framework, serverComponents: { separateSSRGraph: false, ...extra } }, + }); + const apps = { + "root: null": { framework, root: null }, + "root: 123": { framework, root: 123 }, + "root: 100k chars": { framework, root: Buffer.alloc(100_000, "a").toString() }, + // The same string check applies to the other optional string options. + "plugins[0].name: 123": { framework, plugins: [{ name: 123, setup() {} }] }, + "serverComponents.serverRuntimeImportSource: 123": serverComponents({ serverRuntimeImportSource: 123 }), + "serverComponents.serverRegisterClientReferenceExport: 123": serverComponents({ + serverRuntimeImportSource: "./server.ts", + serverRegisterClientReferenceExport: 123, + }), + }; + + const results = {}; + for (const [name, app] of Object.entries(apps)) { + try { + const server = Bun.serve({ port: 0, development: true, app, fetch: () => new Response("") }); + server.stop(true); + results[name] = "accepted"; + } catch (e) { + results[name] = e.message; + } + } + console.log(JSON.stringify(results)); + `, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "check.ts"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + "root: null": "accepted", + "root: 123": 'The "root" property must be of type string, got number', + "root: 100k chars": "'app.root' is too long", + "plugins[0].name: 123": 'The "name" property must be of type string, got number', + "serverComponents.serverRuntimeImportSource: 123": + 'The "serverRuntimeImportSource" property must be of type string, got number', + "serverComponents.serverRegisterClientReferenceExport: 123": + 'The "serverRegisterClientReferenceExport" property must be of type string, got number', + }); + expect(exitCode).toBe(0); + }); +}); From f2b23649eb6a618e97f7416f41af9fe59e1aed5f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 19:13:24 +0000 Subject: [PATCH 2/7] ci: retrigger From 6806c54431ee71bc87c8ef5b59dcff10336c8300 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 19:14:42 +0000 Subject: [PATCH 3/7] bake: shorten the root invariant comment --- src/runtime/bake/bake_body.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/runtime/bake/bake_body.rs b/src/runtime/bake/bake_body.rs index f82fe7155318..aa6ca1de19ea 100644 --- a/src/runtime/bake/bake_body.rs +++ b/src/runtime/bake/bake_body.rs @@ -219,9 +219,8 @@ impl UserOptions { &arena, )?; - // DevServer and FrameworkRouter strip `root` off absolute file paths to - // form module IDs and route patterns, so it must be absolute and must - // not end in a separator. + // Absolute with no trailing separator: `DevServer::relative_path` and + // `FrameworkRouter` strip it off file paths as a prefix. let root: &'static ZStr = { let cwd = match bun_sys::getcwd_alloc() { Ok(cwd) => cwd, From 0fd82fb99447047a4c32eab07346ed595fbf76b8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 19:16:05 +0000 Subject: [PATCH 4/7] bake: drop the root comment, the consumers assert the invariant --- src/runtime/bake/bake_body.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/runtime/bake/bake_body.rs b/src/runtime/bake/bake_body.rs index aa6ca1de19ea..90400a900aa7 100644 --- a/src/runtime/bake/bake_body.rs +++ b/src/runtime/bake/bake_body.rs @@ -219,8 +219,6 @@ impl UserOptions { &arena, )?; - // Absolute with no trailing separator: `DevServer::relative_path` and - // `FrameworkRouter` strip it off file paths as a prefix. let root: &'static ZStr = { let cwd = match bun_sys::getcwd_alloc() { Ok(cwd) => cwd, From c3f6ba591eb6800cfacdd91158632170833948e2 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:56:34 +0000 Subject: [PATCH 5/7] bake: resolve app.root against top_level_dir, the base the framework paths use Both arms of UserOptions::from_js now go through resolve_root: the default is the resolver's top_level_dir (which Framework::resolve also joins the framework's own paths against) and a user supplied root is resolved against that same directory, so the two can no longer be derived from different bases. This also drops the getcwd calls and their error paths. --- src/runtime/bake/bake_body.rs | 74 +++++++++++++++++------------------ 1 file changed, 36 insertions(+), 38 deletions(-) diff --git a/src/runtime/bake/bake_body.rs b/src/runtime/bake/bake_body.rs index 90400a900aa7..382e49ff49a4 100644 --- a/src/runtime/bake/bake_body.rs +++ b/src/runtime/bake/bake_body.rs @@ -163,15 +163,7 @@ impl UserOptions { let utf8_string = bunstr.to_utf8(); if strings::eql(utf8_string.slice(), b"react") { - let root = match bun_sys::getcwd_alloc() { - Ok(z) => arena_dupe_z(&arena, z.as_bytes()), - Err(e) => { - return Err(global.throw_error( - e.to_zig_err(), - "while querying current working directory", - )); - } - }; + let root = resolve_root(None, global, &arena)?; let framework = Framework::react(&arena) .map_err(|e| throw_core_error(global, e, "Framework::react"))?; @@ -219,35 +211,7 @@ impl UserOptions { &arena, )?; - let root: &'static ZStr = { - let cwd = match bun_sys::getcwd_alloc() { - Ok(cwd) => cwd, - Err(e) => { - return Err(global - .throw_error(e.to_zig_err(), "while querying current working directory")); - } - }; - match config.get_optional_slice(global, b"root")? { - None => arena_dupe_z(&arena, cwd.as_bytes()), - Some(user_root) => { - use bun_paths::resolve_path::join_abs_string_buf_checked; - use bun_paths::string_paths::without_trailing_slash_windows_path; - - let mut buf = paths::path_buffer_pool::get(); - let Some(resolved) = join_abs_string_buf_checked::( - cwd.as_bytes(), - &mut buf[..], - &[user_root.slice()], - ) else { - return Err(global.throw_invalid_arguments(format_args!( - "'{}.root' is too long", - API_NAME - ))); - }; - arena_dupe_z(&arena, without_trailing_slash_windows_path(resolved)) - } - } - }; + let root = resolve_root(config.get_optional_slice(global, b"root")?, global, &arena)?; if let Some(plugin_array) = config.get(global, "plugins")? { bundler_options.parse_plugin_array(plugin_array, global)?; @@ -263,6 +227,40 @@ impl UserOptions { } } +/// `app.root` defaults to, and a user supplied value is resolved against, the +/// directory `Framework::resolve` resolves the framework's own paths against. +/// DevServer strips the result off absolute file paths, so it never ends in a +/// separator. +fn resolve_root( + user_root: Option, + global: &JSGlobalObject, + arena: &Arena, +) -> JsResult<&'static ZStr> { + use bun_paths::resolve_path::join_abs_string_buf_checked; + use bun_paths::string_paths::without_trailing_slash_windows_path; + + let top_level_dir = bun_resolver::fs::FileSystem::get().top_level_dir; + let Some(user_root) = user_root else { + return Ok(arena_dupe_z( + arena, + without_trailing_slash_windows_path(top_level_dir), + )); + }; + + let mut buf = paths::path_buffer_pool::get(); + let Some(resolved) = join_abs_string_buf_checked::( + top_level_dir, + &mut buf[..], + &[user_root.slice()], + ) else { + return Err(global.throw_invalid_arguments(format_args!("'{}.root' is too long", API_NAME))); + }; + Ok(arena_dupe_z( + arena, + without_trailing_slash_windows_path(resolved), + )) +} + /// Each string stores its allocator since some may hold reference counts to JSC #[derive(Default)] pub struct StringRefList { From 858f564a61917438ca2d1bc97d63818a7c32d6da Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:59:05 +0000 Subject: [PATCH 6/7] bake: one line doc on resolve_root --- src/runtime/bake/bake_body.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/runtime/bake/bake_body.rs b/src/runtime/bake/bake_body.rs index 382e49ff49a4..72bc5b0dbbf9 100644 --- a/src/runtime/bake/bake_body.rs +++ b/src/runtime/bake/bake_body.rs @@ -227,10 +227,7 @@ impl UserOptions { } } -/// `app.root` defaults to, and a user supplied value is resolved against, the -/// directory `Framework::resolve` resolves the framework's own paths against. -/// DevServer strips the result off absolute file paths, so it never ends in a -/// separator. +/// Absolute, no trailing separator, resolved against the same directory as `Framework::resolve`. fn resolve_root( user_root: Option, global: &JSGlobalObject, From 6b8450ee7a1795cb6988bdbbc609eb1bd99f59e8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 16 Aug 2026 00:11:48 +0000 Subject: [PATCH 7/7] bake: name the path limit in the app.root error; cover unset and empty roots The rejection message now states the byte limit that was exceeded. The test serves the unset and empty spellings of the root as well, and the option test runs concurrently with the spellings. --- src/runtime/bake/bake_body.rs | 6 +++++- test/bake/app-options.test.ts | 18 ++++++++++-------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/src/runtime/bake/bake_body.rs b/src/runtime/bake/bake_body.rs index 72bc5b0dbbf9..91d1ccf9c109 100644 --- a/src/runtime/bake/bake_body.rs +++ b/src/runtime/bake/bake_body.rs @@ -250,7 +250,11 @@ fn resolve_root( &mut buf[..], &[user_root.slice()], ) else { - return Err(global.throw_invalid_arguments(format_args!("'{}.root' is too long", API_NAME))); + return Err(global.throw_invalid_arguments(format_args!( + "'{}.root' resolves to a path longer than {} bytes", + API_NAME, + paths::MAX_PATH_BYTES + ))); }; Ok(arena_dupe_z( arena, diff --git a/test/bake/app-options.test.ts b/test/bake/app-options.test.ts index eb802a1f8f72..7f2656d7fe47 100644 --- a/test/bake/app-options.test.ts +++ b/test/bake/app-options.test.ts @@ -1,7 +1,7 @@ // Parsing of the `app` option of `Bun.serve({ app })` (src/runtime/bake/bake_body.rs, // UserOptions::from_js). The dev server strips `app.root` off absolute file paths to build // route patterns and module IDs, so the value the user passes has to be resolved against -// the cwd and normalized before it gets there. +// the working directory and normalized before it gets there. import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, tempDir } from "harness"; @@ -15,16 +15,17 @@ const framework = `{ }`; describe("app.root", () => { - // Every spelling below names the cwd, which is also what `root` defaults to. - test.concurrent.each([".", "./", "routes/..", "/"])("routes are served when root is %j", async spelling => { + // `root` defaults to the cwd; every other spelling below names that same directory. + const spellings: (string | undefined)[] = [undefined, "", ".", "./", "routes/..", "/"]; + test.concurrent.each(spellings)("routes are served when root is %p", async spelling => { using dir = tempDir("bake-app-root", { ...appFiles, "serve.ts": ` - const root = process.argv[2].replace("", process.cwd()); + const { root } = JSON.parse(process.argv[2]); const server = Bun.serve({ port: 0, development: true, - app: { framework: ${framework}, root }, + app: { framework: ${framework}, root: root?.replace("", process.cwd()) }, fetch: () => new Response("fallback"), }); const res = await fetch(server.url); @@ -34,7 +35,7 @@ describe("app.root", () => { }); await using proc = Bun.spawn({ - cmd: [bunExe(), "serve.ts", spelling], + cmd: [bunExe(), "serve.ts", JSON.stringify({ root: spelling })], env: bunEnv, cwd: String(dir), stdout: "pipe", @@ -48,7 +49,7 @@ describe("app.root", () => { expect(exitCode).toBe(0); }); - test("values that cannot be used as a root are rejected while parsing the options", async () => { + test.concurrent("values that cannot be used as a root are rejected while parsing the options", async () => { using dir = tempDir("bake-app-root-invalid", { ...appFiles, "check.ts": ` @@ -96,7 +97,8 @@ describe("app.root", () => { expect(JSON.parse(stdout)).toEqual({ "root: null": "accepted", "root: 123": 'The "root" property must be of type string, got number', - "root: 100k chars": "'app.root' is too long", + // The limit is the platform's path buffer size (1024 on macOS, 4096 on Linux, larger on Windows). + "root: 100k chars": expect.stringMatching(/^'app\.root' resolves to a path longer than \d+ bytes$/), "plugins[0].name: 123": 'The "name" property must be of type string, got number', "serverComponents.serverRuntimeImportSource: 123": 'The "serverRuntimeImportSource" property must be of type string, got number',