Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
54 changes: 30 additions & 24 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 @@ -232,26 +219,45 @@ 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.
Comment thread
robobun marked this conversation as resolved.
Outdated
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::<paths::platform::Auto>(
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))
Comment thread
robobun marked this conversation as resolved.
Outdated
}
}
};

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 Down Expand Up @@ -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"
Expand Down Expand Up @@ -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!(
Expand All @@ -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 {
Expand Down
108 changes: 108 additions & 0 deletions test/bake/app-options.test.ts
Original file line number Diff line number Diff line change
@@ -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/..", "<cwd>/"])("routes are served when root is %j", async spelling => {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
using dir = tempDir("bake-app-root", {
...appFiles,
"serve.ts": `
const root = process.argv[2].replace("<cwd>", 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 () => {
Comment thread
robobun marked this conversation as resolved.
Outdated
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);
});
});
Loading