Compare commits

...

1 Commits

Author SHA1 Message Date
Claude Bot
bfb0c54866 Add public/static folder serving to Bun's dev server
Implements static file serving for the Bun dev server, allowing files
from configured directories (default: "public") to be served directly.

Changes:
- Add `static_dirs` field to Framework struct in bake.zig
- Parse `staticDirs` option from framework configuration
- Resolve static_dirs to absolute paths in Framework.resolve()
- Enable "public" directory by default for React framework
- Implement static file serving in DevServer.onRequest()
  - Static files are checked before framework routes
  - Files are served with correct MIME types
  - Supports nested directories and various file types
- Add comprehensive tests for static file serving

The implementation prioritizes static files over framework routes,
similar to Next.js behavior. If a file exists in a static directory,
it will be served instead of matching a dynamic route.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-10 22:06:31 +00:00
3 changed files with 183 additions and 3 deletions

View File

@@ -246,7 +246,7 @@ const BuildConfigSubset = struct {
pub const Framework = struct {
is_built_in_react: bool,
file_system_router_types: []FileSystemRouterType,
// static_routers: [][]const u8,
static_dirs: [][]const u8 = &.{},
server_components: ?ServerComponents = null,
react_fast_refresh: ?ReactFastRefresh = null,
built_in_modules: bun.StringArrayHashMapUnmanaged(BuiltInModule) = .{},
@@ -276,7 +276,7 @@ pub const Framework = struct {
.allow_layouts = true,
},
}),
// .static_routers = try arena.dupe([]const u8, &.{"public"}),
.static_dirs = try arena.dupe([]const u8, &.{"public"}),
.built_in_modules = bun.StringArrayHashMapUnmanaged(BuiltInModule).init(arena, &.{
"bun-framework-react/client.tsx",
"bun-framework-react/server.tsx",
@@ -404,6 +404,15 @@ pub const Framework = struct {
f.resolveHelper(client, &fsr.entry_server, &had_errors, "server side entrypoint");
}
// Resolve static_dirs to absolute paths
if (clone.static_dirs.len > 0) {
const resolved_static_dirs = try arena.alloc([]const u8, clone.static_dirs.len);
for (clone.static_dirs, 0..) |dir, i| {
resolved_static_dirs[i] = try arena.dupe(u8, bun.path.joinAbs(server.fs.top_level_dir, .auto, dir));
}
clone.static_dirs = resolved_static_dirs;
}
if (had_errors) return error.ModuleNotFound;
return clone;
@@ -645,9 +654,24 @@ pub const Framework = struct {
};
errdefer for (file_system_router_types) |*fsr| fsr.style.deinit();
const static_dirs: [][]const u8 = brk: {
const array = try opts.getArray(global, "staticDirs") orelse
break :brk &.{};
const len = try array.getLength(global);
const dirs = try arena.alloc([]const u8, len);
var it = try array.arrayIterator(global);
var i: usize = 0;
while (try it.next()) |array_item| : (i += 1) {
dirs[i] = refs.track(try array_item.toSlice(global, arena));
}
break :brk dirs;
};
const framework: Framework = .{
.is_built_in_react = false,
.file_system_router_types = file_system_router_types,
.static_dirs = static_dirs,
.react_fast_refresh = react_fast_refresh,
.server_components = server_components,
.built_in_modules = built_in_modules,

View File

@@ -3124,8 +3124,62 @@ pub fn routeBundlePtr(dev: *DevServer, idx: RouteBundle.Index) *RouteBundle {
}
fn onRequest(dev: *DevServer, req: *Request, resp: anytype) void {
const url_path = req.url();
// Try to serve static files first if static directories are configured
if (dev.framework.static_dirs.len > 0) {
// Remove leading slash from URL path for joining
const clean_path = if (url_path.len > 0 and url_path[0] == '/')
url_path[1..]
else
url_path;
for (dev.framework.static_dirs) |static_dir| {
const file_path = bun.path.joinAbsString(
static_dir,
&.{clean_path},
.auto,
);
// Try to open and serve the file
const file = std.fs.openFileAbsolute(file_path, .{}) catch continue;
defer file.close();
const stat = file.stat() catch continue;
if (stat.kind != .file) continue;
// Allocate and read file contents
const contents = dev.allocator().alloc(u8, @intCast(stat.size)) catch {
resp.writeStatus("500 Internal Server Error");
resp.end("Out of memory", false);
return;
};
errdefer dev.allocator().free(contents);
_ = file.readAll(contents) catch {
dev.allocator().free(contents);
continue;
};
// Determine MIME type from file extension
const ext = std.fs.path.extension(file_path);
const mime_type = if (ext.len > 0 and ext[0] == '.')
MimeType.byExtension(ext[1..])
else
MimeType.byExtension(ext);
// Create a temporary static route and serve it
const blob = AnyBlob.fromOwnedSlice(dev.allocator(), contents);
StaticRoute.sendBlobThenDeinit(AnyResponse.init(resp), &blob, .{
.mime_type = &mime_type,
.server = dev.server,
});
return;
}
}
var params: FrameworkRouter.MatchedParams = undefined;
if (dev.router.matchSlow(req.url(), &params)) |route_index| {
if (dev.router.matchSlow(url_path, &params)) |route_index| {
var ctx = RequestEnsureRouteBundledCtx{
.dev = dev,
.req = .{ .req = req },

View File

@@ -0,0 +1,102 @@
import { expect } from "bun:test";
import { devTest } from "../bake-harness";
devTest("serve static files from public directory", {
framework: "react",
files: {
"pages/index.tsx": `
export default function Home() {
return <h1>Hello World</h1>;
}
`,
"public/robots.txt": `
User-agent: *
Disallow: /admin
`,
"public/favicon.ico": Buffer.from([0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x10, 0x10]),
"public/test.json": `{"hello": "world"}`,
},
async test(dev) {
// Test serving robots.txt
const robotsResponse = await dev.fetch("/robots.txt");
expect(robotsResponse.status).toBe(200);
expect(robotsResponse.headers.get("content-type")).toContain("text/plain");
const robotsText = await robotsResponse.text();
expect(robotsText).toContain("User-agent: *");
expect(robotsText).toContain("Disallow: /admin");
// Test serving favicon.ico (binary file)
const faviconResponse = await dev.fetch("/favicon.ico");
expect(faviconResponse.status).toBe(200);
const faviconBuffer = await faviconResponse.arrayBuffer();
expect(faviconBuffer.byteLength).toBe(8);
// Test serving JSON file
const jsonResponse = await dev.fetch("/test.json");
expect(jsonResponse.status).toBe(200);
expect(jsonResponse.headers.get("content-type")).toContain("application/json");
const jsonData = await jsonResponse.json();
expect(jsonData.hello).toBe("world");
// Test that non-existent files return 404
const notFoundResponse = await dev.fetch("/does-not-exist.txt");
expect(notFoundResponse.status).toBe(404);
// Test that the React page still works
const pageResponse = await dev.fetch("/");
expect(pageResponse.status).toBe(200);
const html = await pageResponse.text();
expect(html).toContain("Hello World");
},
});
devTest("static files take precedence over routes", {
framework: "react",
files: {
"pages/test.txt.tsx": `
export default function TestPage() {
return <div>This is a route</div>;
}
`,
"public/test.txt": `This is a static file`,
},
async test(dev) {
const response = await dev.fetch("/test.txt");
expect(response.status).toBe(200);
const text = await response.text();
expect(text).toBe("This is a static file");
expect(text).not.toContain("This is a route");
},
});
devTest("serve nested static files", {
framework: "react",
files: {
"pages/index.tsx": `
export default function Home() {
return <h1>Home</h1>;
}
`,
"public/assets/styles.css": `
body { background: red; }
`,
"public/images/logo.svg": `
<svg xmlns="http://www.w3.org/2000/svg"><circle r="50"/></svg>
`,
},
async test(dev) {
// Test nested CSS file
const cssResponse = await dev.fetch("/assets/styles.css");
expect(cssResponse.status).toBe(200);
expect(cssResponse.headers.get("content-type")).toContain("text/css");
const css = await cssResponse.text();
expect(css).toContain("background: red");
// Test nested SVG file
const svgResponse = await dev.fetch("/images/logo.svg");
expect(svgResponse.status).toBe(200);
expect(svgResponse.headers.get("content-type")).toContain("image/svg");
const svg = await svgResponse.text();
expect(svg).toContain("<circle");
},
});