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
70 changes: 41 additions & 29 deletions src/runtime/bake/FrameworkRouter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ pub type OpaqueFileId = bun_core::GenericIndex<u32, OpaqueFileIdMarker>;
pub type OpaqueFileIdOptional = Option<OpaqueFileId>;

pub struct FrameworkRouter {
/// Absolute path to root directory of the router.
/// Absolute path to the project root. Only used to label files in error
/// messages; each `Type.abs_root` is independent of it and may point
/// anywhere on disk (for example, a sibling package in a monorepo).
pub root: Box<[u8]>,
pub types: Box<[Type]>,
pub routes: Vec<Route>,
Expand Down Expand Up @@ -185,7 +187,7 @@ impl FrameworkRouter {

for (type_index, ty) in types.iter_mut().enumerate() {
ty.abs_root = strings::paths::without_trailing_slash_windows_path(&ty.abs_root).into();
debug_assert!(strings::has_prefix(&ty.abs_root, root));
debug_assert!(paths::is_absolute(&ty.abs_root));

routes.push(Route {
part: Part::Text(b""),
Expand Down Expand Up @@ -1610,32 +1612,26 @@ impl FrameworkRouter {
}
}

let t = &self.types[t_index.get() as usize];

// The route pattern is the file's path relative to this
// type's `abs_root`, which may be outside `self.root`.
// The scan never leaves `abs_root`, so no ".." appears.
let mut rel_path_buf = PathBuffer::uninit();
let full_rel_path_len = {
let full_rel_path = paths::resolve_path::relative_normalized_buf::<
paths::platform::Auto,
true,
>(
&mut rel_path_buf[1..],
&self.root,
fs_ref.abs(&[file.dir, file.base()]),
);
full_rel_path.len()
};
let rel_path_len = 1 + paths::resolve_path::relative_normalized_buf::<
paths::platform::Auto,
true,
>(
&mut rel_path_buf[1..],
&t.abs_root,
fs_ref.abs(&[file.dir, file.base()]),
)
.len();
rel_path_buf[0] = b'/';
paths::resolve_path::platform_to_posix_in_place(
&mut rel_path_buf[0..full_rel_path_len],
&mut rel_path_buf[0..rel_path_len],
);

let t = &self.types[t_index.get() as usize];
let abs_root_len = t.abs_root.len();
let root_len = self.root.len();
let full_rel_path = &rel_path_buf[1..1 + full_rel_path_len];
let rel_path: &[u8] = if abs_root_len == root_len {
&rel_path_buf[0..full_rel_path_len + 1]
} else {
&full_rel_path[abs_root_len - root_len - 1..]
};
let rel_path: &[u8] = &rel_path_buf[0..rel_path_len];

let mut log = TinyLog::empty();
// The arena is reset at the end of every arm via
Expand All @@ -1647,9 +1643,9 @@ impl FrameworkRouter {
.parse(rel_path, ext, &mut log, t.allow_layouts, arena_state);
let parsed = match parse_result {
Err(_) => {
log.cursor_at +=
u32::try_from(abs_root_len - root_len).expect("int cast");
ctx.on_router_syntax_error(full_rel_path, log)?;
// `log.cursor_at` indexes into the parsed path,
// so report the error against that exact string.
ctx.on_router_syntax_error(rel_path, log)?;
arena_state.reset_retain_with_limit(8 * 1024 * 1024);
continue 'outer;
}
Expand Down Expand Up @@ -1682,7 +1678,7 @@ impl FrameworkRouter {

if param_count > 64 {
log.write(format_args!("Pattern cannot have more than 64 param"));
ctx.on_router_syntax_error(full_rel_path, log)?;
ctx.on_router_syntax_error(rel_path, log)?;
arena_state.reset_retain_with_limit(8 * 1024 * 1024);
continue 'outer;
}
Expand Down Expand Up @@ -1747,8 +1743,24 @@ impl FrameworkRouter {
Ok(()) => {}
Err(InsertError::OutOfMemory) => return Err(AllocError),
Err(InsertError::RouteCollision) => {
// Handlers print the other colliding file
// relative to the project root, so label this
// one the same way (it may contain "..").
let mut display_buf = paths::path_buffer_pool::get();
let display_len = paths::resolve_path::relative_normalized_buf::<
paths::platform::Auto,
true,
>(
&mut display_buf[..],
&self.root,
fs_ref.abs(&[file.dir, file.base()]),
)
.len();
paths::resolve_path::platform_to_posix_in_place(
&mut display_buf[0..display_len],
);
ctx.on_router_collision_error(
full_rel_path,
&display_buf[0..display_len],
out_colliding_file_id,
file_kind,
)?;
Expand Down
119 changes: 118 additions & 1 deletion test/bake/framework-router.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { frameworkRouterInternals } from "bun:internal-for-testing";
import { describe, expect, test } from "bun:test";
import { tempDirWithFiles } from "harness";
import { bunEnv, bunExe, tempDir, tempDirWithFiles } from "harness";
import path from "path";

const { parseRoutePattern, FrameworkRouter } = frameworkRouterInternals;
Expand Down Expand Up @@ -78,6 +78,123 @@ describe("pattern parse", () => {
testApp.isNull("/route/_layout.tsx");
});

// A `fileSystemRouterTypes[].root` may resolve outside of the project root,
// e.g. `../web/pages` from a sibling package in a monorepo. Route patterns are
// derived from the path relative to that root, so this must not depend on the
// router root being inside the project root.
describe("router root outside of the project root", () => {
const serveFixture = (root: string) => ({
"apps/api/server-entry.ts": `
export function render(req, meta) {
return meta.pageModule.default(req, meta);
}
`,
"apps/api/serve.ts": `
const server = Bun.serve({
port: 0,
development: true,
app: {
framework: {
fileSystemRouterTypes: [
{
root: ${JSON.stringify(root)},
style: "nextjs-pages",
serverEntryPoint: "./server-entry.ts",
},
],
},
},
});
for (const pathname of ["/", "/blog/hello-world"]) {
const res = await fetch(new URL(pathname, server.url));
console.log(pathname, res.status, (await res.text()).trim());
}
server.stop(true);
process.exit(0);
`,
});

const run = async (dir: string) => {
await using proc = Bun.spawn({
cmd: [bunExe(), "serve.ts"],
env: bunEnv,
cwd: path.join(dir, "apps", "api"),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
return { stdout, stderr, exitCode };
};

test.concurrent("sibling directory", async () => {
using dir = tempDir("fsr-sibling-root", {
...serveFixture("../web/pages"),
"apps/web/pages/index.ts": `
export default function (req, meta) {
return new Response("sibling index");
}
`,
"apps/web/pages/blog/[slug].ts": `
export default function (req, meta) {
return new Response("slug:" + meta.params.slug);
}
`,
});
const { stdout, stderr, exitCode } = await run(String(dir));
expect(stdout, `stderr: ${stderr}`).toBe("/ 200 sibling index\n/blog/hello-world 200 slug:hello-world\n");
expect(exitCode).toBe(0);
});

test.concurrent("parent directory", async () => {
using dir = tempDir("fsr-parent-root", {
...serveFixture(".."),
"apps/index.ts": `
export default function (req, meta) {
return new Response("parent index");
}
`,
"apps/blog/[slug].ts": `
export default function (req, meta) {
return new Response("slug:" + meta.params.slug);
}
`,
});
const { stdout, stderr, exitCode } = await run(String(dir));
expect(stdout, `stderr: ${stderr}`).toBe("/ 200 parent index\n/blog/hello-world 200 slug:hello-world\n");
expect(exitCode).toBe(0);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

// Route errors are non-fatal and labeled: an invalid pattern is reported
// against the path it was parsed from (relative to the router root), and a
// collision lists both files relative to the project root.
test.concurrent("route errors in a sibling directory root", async () => {
using dir = tempDir("fsr-sibling-root-errors", {
...serveFixture("../web/pages"),
"apps/web/pages/index.ts": `
export default function (req, meta) {
return new Response("sibling index");
}
`,
"apps/web/pages/blog/[slug].ts": `
export default function (req, meta) {
return new Response("slug:" + meta.params.slug);
}
`,
"apps/web/pages/[oops.ts": `export default function () {}`,
"apps/web/pages/about.ts": `export default function () {}`,
"apps/web/pages/about/index.ts": `export default function () {}`,
});
const { stdout, stderr, exitCode } = await run(String(dir));
expect(stdout, `stderr: ${stderr}`).toBe("/ 200 sibling index\n/blog/hello-world 200 slug:hello-world\n");
expect(stderr).toContain('"/[oops.ts" is not a valid route');
expect(stderr).toContain('Missing "]" to match this route parameter');
expect(stderr).toContain("Multiple pages matching the same route pattern is ambiguous");
expect(stderr).toContain(" - ../web/pages/about.ts");
expect(stderr).toContain(" - ../web/pages/about/index.ts");
expect(exitCode).toBe(0);
});
});

test("discovers from filesystem paths", () => {
const dir = tempDirWithFiles("fsr", {
"hello.tsx": "1",
Expand Down
Loading