Skip to content
Closed
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
80 changes: 41 additions & 39 deletions src/runtime/bake/bake_body.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Option<ZigStringSlice>> {
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,
Expand Down Expand Up @@ -176,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"))?;
Expand Down Expand Up @@ -232,26 +211,14 @@ 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(),
Err(e) => {
return Err(global
.throw_error(e.to_zig_err(), "while querying current working directory"));
}
}
};
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)?;
}

let root_z = arena_dupe_z(&arena, root);

Ok(UserOptions {
root: root_z,
root,
framework,
bundler_options,
allocations,
Expand All @@ -260,6 +227,41 @@ impl UserOptions {
}
}

/// Absolute, no trailing separator, resolved against the same directory as `Framework::resolve`.
fn resolve_root(
user_root: Option<ZigStringSlice>,
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::<paths::platform::Auto>(
top_level_dir,
&mut buf[..],
&[user_root.slice()],
Comment thread
coderabbitai[bot] marked this conversation as resolved.
) else {
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,
without_trailing_slash_windows_path(resolved),
))
}

/// Each string stores its allocator since some may hold reference counts to JSC
#[derive(Default)]
pub struct StringRefList {
Expand Down Expand Up @@ -334,7 +336,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"
Expand Down Expand Up @@ -856,7 +858,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!(
Expand All @@ -866,7 +868,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 {
Expand Down
110 changes: 110 additions & 0 deletions test/bake/app-options.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
// 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 working directory 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", () => {
// `root` defaults to the cwd; every other spelling below names that same directory.
const spellings: (string | undefined)[] = [undefined, "", ".", "./", "routes/..", "<cwd>/"];
test.concurrent.each(spellings)("routes are served when root is %p", async spelling => {
using dir = tempDir("bake-app-root", {
...appFiles,
"serve.ts": `
const { root } = JSON.parse(process.argv[2]);
const server = Bun.serve({
port: 0,
development: true,
app: { framework: ${framework}, root: root?.replace("<cwd>", process.cwd()) },
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", JSON.stringify({ root: 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.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": `
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',
// 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',
"serverComponents.serverRegisterClientReferenceExport: 123":
'The "serverRegisterClientReferenceExport" property must be of type string, got number',
});
expect(exitCode).toBe(0);
});
});
Loading