Compare commits

...

9 Commits

Author SHA1 Message Date
Jarred Sumner
f1f95ab7bf Merge branch 'main' into claude/directory 2025-07-21 13:14:57 -07:00
Claude Bot
9f9566c279 DirectoryRoute wildcard routing implementation - partial solution
- Implemented /* pattern registration for DirectoryRoute
- Root path (/) functionality confirmed working
- Sub-path routing still needs investigation of uWS routing mechanics
- 4/15 comprehensive tests pass (all root path scenarios)
- 11/15 tests fail on sub-path access (404 errors)

This establishes foundation for DirectoryRoute but requires deeper
understanding of uWS pattern matching for complete sub-path support.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-20 07:35:05 +00:00
Claude Bot
5384862b5b Implement proper wildcard routing for DirectoryRoute
- Register DirectoryRoute with /* pattern to handle sub-paths
- Root path (/) becomes /* to handle all paths
- Other paths become path/* to handle sub-paths under that directory
- This follows the same pattern as other /* handlers in the codebase

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-20 03:27:24 +00:00
Claude Bot
5cbcd05c45 Improve DirectoryRoute wildcard pattern registration
- Use string literal for /* pattern instead of unnecessary allocation
- Split the conditional for better memory management
- This should fix compilation issues and improve performance

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-20 03:25:04 +00:00
Claude Bot
fb9e1af73d Fix DirectoryRoute path matching with wildcard pattern
- Register DirectoryRoute with /* pattern to match all sub-paths
- This should fix 404 errors when accessing files in directory routes
- For root path /, register as /* to catch all paths
- For other paths, register as path/* to match sub-paths

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-20 03:06:44 +00:00
Claude Bot
e72e94a92e Fix PathOrFileDescriptor construction in DirectoryRoute
- Changed from slice_with_underlying_string to proper PathLike.string construction
- Use bun.PathString.init() following established codebase pattern
- This should resolve DirectoryRoute compilation issues

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-20 02:26:16 +00:00
Claude Bot
2003f97f03 Fix DirectoryRoute globalThis access
- Fixed server.vm().global access to use server.globalThis() method
- This should resolve compilation issues with DirectoryRoute

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-20 02:13:11 +00:00
Claude Bot
3a4ec715be Merge remote-tracking branch 'origin/main' into claude/directory 2025-07-20 01:56:49 +00:00
Jarred Sumner
a210ef439e Initial broken DirectoryRoute implementation 2025-07-16 20:14:05 -07:00
7 changed files with 778 additions and 33 deletions

View File

@@ -79,6 +79,7 @@ src/bun.js/api/JSBundler.zig
src/bun.js/api/JSTranspiler.zig
src/bun.js/api/server.zig
src/bun.js/api/server/AnyRequestContext.zig
src/bun.js/api/server/DirectoryRoute.zig
src/bun.js/api/server/FileRoute.zig
src/bun.js/api/server/HTMLBundle.zig
src/bun.js/api/server/HTTPStatusText.zig

View File

@@ -62,6 +62,7 @@ pub fn writeStatus(comptime ssl: bool, resp_ptr: ?*uws.NewApp(ssl).Response, sta
// TODO: rename to StaticBlobRoute? the html bundle is sometimes a static route
pub const StaticRoute = @import("./server/StaticRoute.zig");
pub const FileRoute = @import("./server/FileRoute.zig");
pub const DirectoryRoute = @import("./server/DirectoryRoute.zig");
const HTMLBundle = JSC.API.HTMLBundle;
@@ -71,6 +72,9 @@ pub const AnyRoute = union(enum) {
static: *StaticRoute,
/// Serve a file from disk
file: *FileRoute,
/// Serve files from a directory
/// "/assets": { dir: "./public" },
directory: *DirectoryRoute,
/// Bundle an HTML import
/// import html from "./index.html";
/// "/": html,
@@ -86,6 +90,7 @@ pub const AnyRoute = union(enum) {
return switch (this) {
.static => |static_route| static_route.memoryCost(),
.file => |file_route| file_route.memoryCost(),
.directory => |directory_route| directory_route.memoryCost(),
.html => |html_bundle_route| html_bundle_route.data.memoryCost(),
.framework_router => @sizeOf(bun.bake.Framework.FileSystemRouterType),
};
@@ -95,6 +100,7 @@ pub const AnyRoute = union(enum) {
switch (this) {
.static => |static_route| static_route.server = server,
.file => |file_route| file_route.server = server,
.directory => |directory_route| directory_route.server = server,
.html => |html_bundle_route| html_bundle_route.server = server,
.framework_router => {}, // DevServer contains .server field
}
@@ -104,6 +110,7 @@ pub const AnyRoute = union(enum) {
switch (this) {
.static => |static_route| static_route.deref(),
.file => |file_route| file_route.deref(),
.directory => |directory_route| directory_route.deref(),
.html => |html_bundle_route| html_bundle_route.deref(),
.framework_router => {}, // not reference counted
}
@@ -113,6 +120,7 @@ pub const AnyRoute = union(enum) {
switch (this) {
.static => |static_route| static_route.ref(),
.file => |file_route| file_route.ref(),
.directory => |directory_route| directory_route.ref(),
.html => |html_bundle_route| html_bundle_route.ref(),
.framework_router => {}, // not reference counted
}
@@ -270,42 +278,52 @@ pub const AnyRoute = union(enum) {
}
if (argument.isObject()) {
const FrameworkRouter = bun.bake.FrameworkRouter;
if (try argument.getOptional(global, "dir", bun.String.Slice)) |dir| {
var alloc = init_ctx.js_string_allocations;
const relative_root = alloc.track(dir);
if (try argument.get(global, "dir")) |dir_value| {
const dir_slice = try dir_value.toSlice(global, bun.default_allocator);
defer dir_slice.deinit();
// Check if this is a framework router (has a "style" property) or a simple directory route
if (try argument.get(global, "style")) |style| {
// Framework router
const FrameworkRouter = bun.bake.FrameworkRouter;
var alloc = init_ctx.js_string_allocations;
const relative_root = alloc.track(dir_slice);
var style: FrameworkRouter.Style = if (try argument.get(global, "style")) |style|
try FrameworkRouter.Style.fromJS(style, global)
else
.nextjs_pages;
errdefer style.deinit();
var style_parsed: FrameworkRouter.Style = try FrameworkRouter.Style.fromJS(style, global);
errdefer style_parsed.deinit();
if (!bun.strings.endsWith(path, "/*")) {
return global.throwInvalidArguments("To mount a directory, make sure the path ends in `/*`", .{});
if (!bun.strings.endsWith(path, "/*")) {
return global.throwInvalidArguments("To mount a directory, make sure the path ends in `/*`", .{});
}
try init_ctx.framework_router_list.append(.{
.root = relative_root,
.style = style_parsed,
// trim the /*
.prefix = if (path.len == 2) "/" else path[0 .. path.len - 2],
// TODO: customizable framework option.
.entry_client = "bun-framework-react/client.tsx",
.entry_server = "bun-framework-react/server.tsx",
.ignore_underscores = true,
.ignore_dirs = &.{ "node_modules", ".git" },
.extensions = &.{ ".tsx", ".jsx" },
.allow_layouts = true,
});
const limit = std.math.maxInt(@typeInfo(FrameworkRouter.Type.Index).@"enum".tag_type);
if (init_ctx.framework_router_list.items.len > limit) {
return global.throwInvalidArguments("Too many framework routers. Maximum is {d}.", .{limit});
}
return .{ .framework_router = .init(@intCast(init_ctx.framework_router_list.items.len - 1)) };
} else {
// Simple directory route
const directory_route = DirectoryRoute.init(dir_slice.slice()) catch |err| {
return global.throwInvalidArguments("Failed to open directory {s}: {s}", .{ dir_slice.slice(), @errorName(err) });
};
return .{ .directory = directory_route };
}
try init_ctx.framework_router_list.append(.{
.root = relative_root,
.style = style,
// trim the /*
.prefix = if (path.len == 2) "/" else path[0 .. path.len - 2],
// TODO: customizable framework option.
.entry_client = "bun-framework-react/client.tsx",
.entry_server = "bun-framework-react/server.tsx",
.ignore_underscores = true,
.ignore_dirs = &.{ "node_modules", ".git" },
.extensions = &.{ ".tsx", ".jsx" },
.allow_layouts = true,
});
const limit = std.math.maxInt(@typeInfo(FrameworkRouter.Type.Index).@"enum".tag_type);
if (init_ctx.framework_router_list.items.len > limit) {
return global.throwInvalidArguments("Too many framework routers. Maximum is {d}.", .{limit});
}
return .{ .framework_router = .init(@intCast(init_ctx.framework_router_list.items.len - 1)) };
}
}
@@ -2627,6 +2645,20 @@ pub fn NewServer(protocol_enum: enum { http, https }, development_kind: enum { d
.file => |file_route| {
ServerConfig.applyStaticRoute(any_server, ssl_enabled, app, *FileRoute, file_route, entry.path, entry.method);
},
.directory => |directory_route| {
// Directory routes need to handle the exact path AND all sub-paths
if (std.mem.eql(u8, entry.path, "/")) {
// For root path, register "*" first then "/" to handle all paths and root
ServerConfig.applyStaticRoute(any_server, ssl_enabled, app, *DirectoryRoute, directory_route, "*", entry.method);
ServerConfig.applyStaticRoute(any_server, ssl_enabled, app, *DirectoryRoute, directory_route, "/", entry.method);
} else {
// For other paths, register wildcard first then exact path
const dir_path = std.fmt.allocPrint(bun.default_allocator, "{s}/*", .{entry.path}) catch bun.outOfMemory();
defer bun.default_allocator.free(dir_path);
ServerConfig.applyStaticRoute(any_server, ssl_enabled, app, *DirectoryRoute, directory_route, dir_path, entry.method);
ServerConfig.applyStaticRoute(any_server, ssl_enabled, app, *DirectoryRoute, directory_route, entry.path, entry.method);
}
},
.html => |html_bundle_route| {
ServerConfig.applyStaticRoute(any_server, ssl_enabled, app, *HTMLBundle.Route, html_bundle_route.data, entry.path, entry.method);
if (dev_server) |dev| {

View File

@@ -0,0 +1,287 @@
const DirectoryRoute = @This();
ref_count: RefCount,
server: ?AnyServer = null,
directory_path: []const u8,
directory_fd: bun.FileDescriptor,
pub fn init(directory_path: []const u8) !*DirectoryRoute {
const path_duped = bun.default_allocator.dupe(u8, directory_path) catch bun.outOfMemory();
errdefer bun.default_allocator.free(path_duped);
const fd = switch (bun.sys.openA(path_duped, bun.O.DIRECTORY | bun.O.RDONLY | bun.O.CLOEXEC, 0)) {
.result => |fd| fd,
.err => {
bun.default_allocator.free(path_duped);
return error.AccessDenied;
},
};
return bun.new(DirectoryRoute, .{
.ref_count = .init(),
.directory_path = path_duped,
.directory_fd = fd,
});
}
pub fn deinit(this: *DirectoryRoute) void {
this.directory_fd.close();
bun.default_allocator.free(this.directory_path);
bun.destroy(this);
}
pub fn memoryCost(this: *const DirectoryRoute) usize {
return @sizeOf(DirectoryRoute) + this.directory_path.len;
}
pub fn fromJS(globalThis: *JSC.JSGlobalObject, argument: JSC.JSValue) bun.JSError!?*DirectoryRoute {
if (argument.isObject()) {
if (try argument.get(globalThis, "dir")) |dir_value| {
const dir_slice = try dir_value.toSlice(globalThis, bun.default_allocator);
defer dir_slice.deinit();
return DirectoryRoute.init(dir_slice.slice()) catch |err| {
return globalThis.throwInvalidArguments("Failed to open directory {s}: {s}", .{ dir_slice.slice(), @errorName(err) });
};
}
}
return null;
}
pub fn onHEADRequest(this: *DirectoryRoute, req: *uws.Request, resp: AnyResponse) void {
this.on(req, resp, .HEAD);
}
pub fn onRequest(this: *DirectoryRoute, req: *uws.Request, resp: AnyResponse) void {
this.on(req, resp, bun.http.Method.find(req.method()) orelse .GET);
}
pub fn on(this: *DirectoryRoute, req: *uws.Request, resp: AnyResponse, method: bun.http.Method) void {
bun.debugAssert(this.server != null);
this.ref();
if (this.server) |server| {
server.onPendingRequest();
resp.timeout(server.config().idleTimeout);
}
const url = req.url();
// Always log for debugging
std.debug.print("DirectoryRoute: URL={s}, directory={s}\n", .{ url, this.directory_path });
// Try to resolve the file path
const file_path = this.resolveFilePath(url) catch {
if (bun.Environment.enable_logs) {
bun.Output.scoped(.DirectoryRoute, false)("Failed to resolve file path for URL: {s}", .{url});
}
req.setYield(true);
this.deref();
return;
};
defer bun.default_allocator.free(file_path);
if (bun.Environment.enable_logs) {
bun.Output.scoped(.DirectoryRoute, false)("Resolved file path: {s}", .{file_path});
}
// Try to open the file using openat
const open_flags = bun.O.RDONLY | bun.O.CLOEXEC | bun.O.NONBLOCK;
const fd_result = bun.sys.openatA(this.directory_fd, file_path, open_flags, 0);
if (fd_result == .err) {
if (bun.Environment.enable_logs) {
bun.Output.scoped(.DirectoryRoute, false)("Failed to open file: {s}, error: {s}", .{ file_path, @tagName(fd_result.err.getErrno()) });
}
// Try with .html extension
if (this.tryWithHtmlExtension(file_path)) |html_path| {
defer bun.default_allocator.free(html_path);
if (bun.Environment.enable_logs) {
bun.Output.scoped(.DirectoryRoute, false)("Trying with .html extension: {s}", .{html_path});
}
const html_fd_result = bun.sys.openatA(this.directory_fd, html_path, open_flags, 0);
if (html_fd_result == .result) {
if (bun.Environment.enable_logs) {
bun.Output.scoped(.DirectoryRoute, false)("Found file with .html extension: {s}", .{html_path});
}
this.serveFile(req, resp, method, html_fd_result.result, html_path);
return;
}
}
// Try index.html or index.htm for directories
if (this.tryIndexFiles(file_path)) |index_path| {
defer bun.default_allocator.free(index_path);
if (bun.Environment.enable_logs) {
bun.Output.scoped(.DirectoryRoute, false)("Trying index file: {s}", .{index_path});
}
const index_fd_result = bun.sys.openatA(this.directory_fd, index_path, open_flags, 0);
if (index_fd_result == .result) {
if (bun.Environment.enable_logs) {
bun.Output.scoped(.DirectoryRoute, false)("Found index file: {s}", .{index_path});
}
this.serveFile(req, resp, method, index_fd_result.result, index_path);
return;
}
}
// File not found, yield to next handler
if (bun.Environment.enable_logs) {
bun.Output.scoped(.DirectoryRoute, false)("File not found, yielding to next handler", .{});
}
req.setYield(true);
this.deref();
return;
}
const fd = fd_result.result;
// Check if it's a directory
const stat = switch (bun.sys.fstat(fd)) {
.result => |s| s,
.err => {
bun.Async.Closer.close(fd, if (bun.Environment.isWindows) bun.windows.libuv.Loop.get());
req.setYield(true);
this.deref();
return;
},
};
if (bun.S.ISDIR(@intCast(stat.mode))) {
bun.Async.Closer.close(fd, if (bun.Environment.isWindows) bun.windows.libuv.Loop.get());
// Try index.html or index.htm for directories
if (this.tryIndexFiles(file_path)) |index_path| {
defer bun.default_allocator.free(index_path);
const index_fd_result = bun.sys.openatA(this.directory_fd, index_path, open_flags, 0);
if (index_fd_result == .result) {
this.serveFile(req, resp, method, index_fd_result.result, index_path);
return;
}
}
req.setYield(true);
this.deref();
return;
}
this.serveFile(req, resp, method, fd, file_path);
}
fn resolveFilePath(this: *DirectoryRoute, url: []const u8) ![]const u8 {
_ = this;
// Remove leading slash if present
const clean_url = if (url.len > 0 and url[0] == '/') url[1..] else url;
// Basic path traversal protection - reject paths containing ".."
if (std.mem.indexOf(u8, clean_url, "..") != null) {
return error.InvalidPath;
}
// If empty path, serve index
if (clean_url.len == 0) {
return bun.default_allocator.dupe(u8, ".");
}
return bun.default_allocator.dupe(u8, clean_url);
}
fn tryWithHtmlExtension(this: *DirectoryRoute, file_path: []const u8) ?[]const u8 {
_ = this;
// Don't add .html if path already has an extension
if (std.mem.lastIndexOfScalar(u8, file_path, '.') != null) {
return null;
}
return std.fmt.allocPrint(bun.default_allocator, "{s}.html", .{file_path}) catch null;
}
fn tryIndexFiles(this: *DirectoryRoute, file_path: []const u8) ?[]const u8 {
_ = this;
// Don't add index.html if path already ends with it
if (std.mem.endsWith(u8, file_path, "/index.html")) {
return null;
}
const base_path = if (std.mem.eql(u8, file_path, ".")) "" else file_path;
// Try index.html first
const index_html = if (base_path.len == 0)
bun.default_allocator.dupe(u8, "index.html") catch null
else
std.fmt.allocPrint(bun.default_allocator, "{s}/index.html", .{base_path}) catch null;
if (index_html) |path| {
return path;
}
// Try index.htm as fallback
if (base_path.len == 0) {
return bun.default_allocator.dupe(u8, "index.htm") catch null;
} else {
return std.fmt.allocPrint(bun.default_allocator, "{s}/index.htm", .{base_path}) catch null;
}
}
fn serveFile(this: *DirectoryRoute, req: *uws.Request, resp: AnyResponse, method: bun.http.Method, fd: bun.FileDescriptor, file_path: []const u8) void {
// Close the file descriptor since we'll let FileRoute open it with a path
bun.Async.Closer.close(fd, if (bun.Environment.isWindows) bun.windows.libuv.Loop.get());
// Create full path by combining directory path with file path
const full_path = std.fmt.allocPrint(bun.default_allocator, "{s}/{s}", .{ this.directory_path, file_path }) catch {
req.setYield(true);
this.deref();
return;
};
defer bun.default_allocator.free(full_path);
// Create a PathOrFileDescriptor from the full path
const path_or_fd = JSC.Node.PathOrFileDescriptor{
.path = JSC.Node.PathLike{
.string = bun.PathString.init(full_path),
},
};
// Create a blob from the file path
const store = Blob.Store.initFile(path_or_fd, null, bun.default_allocator) catch {
req.setYield(true);
this.deref();
return;
};
const blob = Blob.initWithStore(store, this.server.?.globalThis());
// Create a FileRoute to handle the actual file serving
const file_route = FileRoute.initFromBlob(blob, .{
.server = this.server,
.status_code = 200,
.headers = null,
});
// Let the FileRoute handle the request
file_route.on(req, resp, method);
// FileRoute will handle its own cleanup, so we just need to deref ourselves
this.deref();
}
const RefCount = bun.ptr.RefCount(@This(), "ref_count", deinit, .{});
pub const ref = RefCount.ref;
pub const deref = RefCount.deref;
const std = @import("std");
const bun = @import("bun");
const JSC = bun.JSC;
const uws = bun.uws;
const AnyServer = JSC.API.AnyServer;
const Blob = JSC.WebCore.Blob;
const FileRoute = @import("./FileRoute.zig");
const AnyResponse = uws.AnyResponse;
const strings = bun.strings;

View File

@@ -0,0 +1,63 @@
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
import { bunEnv, bunExe, tempDirWithFiles, isWindows } from "harness";
import { join } from "path";
describe("DirectoryRoute Simple", () => {
let testDir: string;
beforeAll(() => {
testDir = tempDirWithFiles("directory-route-simple-test", {
"index.html": "<html><body>Index Page</body></html>",
"about.html": "<html><body>About Page</body></html>",
});
console.log("Test directory:", testDir);
});
it("should recognize dir routes", () => {
console.log("Testing directory route creation...");
try {
const server = Bun.serve({
port: 0,
routes: {
"/": { dir: testDir },
},
});
console.log("Server created successfully on port:", server.port);
console.log("Test directory exists:", Bun.file(join(testDir, "index.html")).size > 0);
server.stop();
expect(true).toBe(true); // If we get here, route creation worked
} catch (error) {
console.error("Error creating server:", error);
throw error;
}
});
it("should test basic file access", async () => {
const server = Bun.serve({
port: 0,
routes: {
"/": { dir: testDir },
},
});
console.log("Server started on port:", server.port);
try {
const response = await fetch(`http://localhost:${server.port}/`);
console.log("Response status:", response.status);
console.log("Response headers:", Object.fromEntries(response.headers.entries()));
if (response.status !== 200) {
const text = await response.text();
console.log("Response body:", text);
}
expect(response.status).toBe(200);
} finally {
server.stop();
}
});
});

View File

@@ -0,0 +1,91 @@
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
import { bunEnv, bunExe, tempDirWithFiles, isWindows } from "harness";
import { join } from "path";
describe("DirectoryRoute Subpath", () => {
let testDir: string;
beforeAll(() => {
testDir = tempDirWithFiles("directory-route-subpath-test", {
"index.html": "<html><body>Root Index</body></html>",
"about.html": "<html><body>About Page</body></html>",
"subdir/index.html": "<html><body>Subdir Index</body></html>",
"subdir/page.html": "<html><body>Subdir Page</body></html>",
});
console.log("Test directory:", testDir);
});
it("should handle root directory route", async () => {
const server = Bun.serve({
port: 0,
routes: {
"/": { dir: testDir },
},
});
console.log("Server started on port:", server.port);
try {
// Test root path
const rootResponse = await fetch(`http://localhost:${server.port}/`);
expect(rootResponse.status).toBe(200);
const rootText = await rootResponse.text();
expect(rootText).toContain("Root Index");
// Test sub-file
const aboutResponse = await fetch(`http://localhost:${server.port}/about.html`);
expect(aboutResponse.status).toBe(200);
const aboutText = await aboutResponse.text();
expect(aboutText).toContain("About Page");
// Test sub-directory
const subdirResponse = await fetch(`http://localhost:${server.port}/subdir/`);
expect(subdirResponse.status).toBe(200);
const subdirText = await subdirResponse.text();
expect(subdirText).toContain("Subdir Index");
// Test sub-directory file
const subdirPageResponse = await fetch(`http://localhost:${server.port}/subdir/page.html`);
expect(subdirPageResponse.status).toBe(200);
const subdirPageText = await subdirPageResponse.text();
expect(subdirPageText).toContain("Subdir Page");
} finally {
server.stop();
}
});
it("should handle non-root directory route", async () => {
const server = Bun.serve({
port: 0,
routes: {
"/files": { dir: testDir },
},
});
console.log("Server started on port:", server.port);
try {
// Test exact path match
const exactResponse = await fetch(`http://localhost:${server.port}/files`);
expect(exactResponse.status).toBe(200);
const exactText = await exactResponse.text();
expect(exactText).toContain("Root Index");
// Test sub-file
const fileResponse = await fetch(`http://localhost:${server.port}/files/about.html`);
expect(fileResponse.status).toBe(200);
const fileText = await fileResponse.text();
expect(fileText).toContain("About Page");
// Test sub-directory
const subdirResponse = await fetch(`http://localhost:${server.port}/files/subdir/`);
expect(subdirResponse.status).toBe(200);
const subdirText = await subdirResponse.text();
expect(subdirText).toContain("Subdir Index");
} finally {
server.stop();
}
});
});

View File

@@ -0,0 +1,46 @@
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
import { bunEnv, bunExe, tempDirWithFiles, isWindows } from "harness";
import { join } from "path";
describe("DirectoryRoute Wildcard Debug", () => {
let testDir: string;
beforeAll(() => {
testDir = tempDirWithFiles("directory-route-wildcard-debug", {
"index.html": "<html><body>Root Index</body></html>",
"test.html": "<html><body>Test Page</body></html>",
});
console.log("Test directory:", testDir);
});
it("should debug wildcard behavior", async () => {
const server = Bun.serve({
port: 0,
routes: {
"/": { dir: testDir },
},
});
console.log("Server started on port:", server.port);
try {
// Test root path (this should work)
console.log("Testing /");
const rootResponse = await fetch(`http://localhost:${server.port}/`);
console.log("/ status:", rootResponse.status);
// Test simple file (this should work with wildcard)
console.log("Testing /test.html");
const testResponse = await fetch(`http://localhost:${server.port}/test.html`);
console.log("/test.html status:", testResponse.status);
if (testResponse.status !== 200) {
const text = await testResponse.text();
console.log("/test.html response body:", text);
}
} finally {
server.stop();
}
});
});

View File

@@ -0,0 +1,225 @@
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
import { bunEnv, bunExe, tempDirWithFiles, isWindows } from "harness";
import { join } from "path";
describe("DirectoryRoute", () => {
let testDir: string;
let server: Server;
let port: number;
beforeAll(() => {
testDir = tempDirWithFiles("directory-route-test", {
"index.html": "<html><body>Index Page</body></html>",
"index.htm": "<html><body>Index Htm Page</body></html>",
"about.html": "<html><body>About Page</body></html>",
"page.html": "<html><body>Page Content</body></html>",
"script.js": "console.log('Hello from script');",
"styles.css": "body { color: red; }",
"robots.txt": "User-agent: *\nDisallow: /",
"api/data.json": '{"message": "Hello from API"}',
"assets/image.png": "fake-png-data",
"nested/deep/file.txt": "Deep nested file content",
"nested/index.html": "<html><body>Nested Index</body></html>",
});
});
afterAll(() => {
server?.stop();
});
it("should serve index.html for root path", async () => {
server = Bun.serve({
port: 0,
routes: {
"/": { dir: testDir },
},
});
port = server.port;
const response = await fetch(`http://localhost:${port}/`);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("text/html");
const text = await response.text();
expect(text).toBe("<html><body>Index Page</body></html>");
});
it("should serve index.html when accessing directory", async () => {
const response = await fetch(`http://localhost:${port}/nested/`);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("text/html");
const text = await response.text();
expect(text).toBe("<html><body>Nested Index</body></html>");
});
it("should serve direct file access", async () => {
const response = await fetch(`http://localhost:${port}/about.html`);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("text/html");
const text = await response.text();
expect(text).toBe("<html><body>About Page</body></html>");
});
it("should serve files with correct content-type", async () => {
const jsResponse = await fetch(`http://localhost:${port}/script.js`);
expect(jsResponse.status).toBe(200);
expect(jsResponse.headers.get("content-type")).toContain("text/javascript");
const cssResponse = await fetch(`http://localhost:${port}/styles.css`);
expect(cssResponse.status).toBe(200);
expect(cssResponse.headers.get("content-type")).toContain("text/css");
const txtResponse = await fetch(`http://localhost:${port}/robots.txt`);
expect(txtResponse.status).toBe(200);
expect(txtResponse.headers.get("content-type")).toContain("text/plain");
});
it("should try .html extension fallback", async () => {
const response = await fetch(`http://localhost:${port}/page`);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("text/html");
const text = await response.text();
expect(text).toBe("<html><body>Page Content</body></html>");
});
it("should serve nested files", async () => {
const response = await fetch(`http://localhost:${port}/api/data.json`);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("application/json");
const text = await response.text();
expect(text).toBe('{"message": "Hello from API"}');
});
it("should serve deeply nested files", async () => {
const response = await fetch(`http://localhost:${port}/nested/deep/file.txt`);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("text/plain");
const text = await response.text();
expect(text).toBe("Deep nested file content");
});
it("should return 404 for non-existent files", async () => {
const response = await fetch(`http://localhost:${port}/nonexistent.html`);
expect(response.status).toBe(404);
});
it("should handle HEAD requests", async () => {
const response = await fetch(`http://localhost:${port}/about.html`, {
method: "HEAD",
});
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("text/html");
const text = await response.text();
expect(text).toBe(""); // HEAD should not return body
});
it("should have proper path traversal protection", async () => {
const response = await fetch(`http://localhost:${port}/../../../etc/passwd`);
expect(response.status).toBe(404);
});
it("should handle multiple directory routes", async () => {
server?.stop();
const assetsDir = tempDirWithFiles("assets-test", {
"logo.png": "fake-logo-data",
"favicon.ico": "fake-favicon-data",
});
server = Bun.serve({
port: 0,
routes: {
"/": { dir: testDir },
"/assets": { dir: assetsDir },
},
});
port = server.port;
const mainResponse = await fetch(`http://localhost:${port}/about.html`);
expect(mainResponse.status).toBe(200);
const mainText = await mainResponse.text();
expect(mainText).toBe("<html><body>About Page</body></html>");
const assetsResponse = await fetch(`http://localhost:${port}/assets/logo.png`);
expect(assetsResponse.status).toBe(200);
const assetsText = await assetsResponse.text();
expect(assetsText).toBe("fake-logo-data");
});
it("should support combined routes (directory + other route types)", async () => {
server?.stop();
server = Bun.serve({
port: 0,
routes: {
"/": { dir: testDir },
"/api/hello": () => new Response("Hello from API"),
},
});
port = server.port;
// Test directory route
const fileResponse = await fetch(`http://localhost:${port}/about.html`);
expect(fileResponse.status).toBe(200);
const fileText = await fileResponse.text();
expect(fileText).toBe("<html><body>About Page</body></html>");
// Test function route
const apiResponse = await fetch(`http://localhost:${port}/api/hello`);
expect(apiResponse.status).toBe(200);
const apiText = await apiResponse.text();
expect(apiText).toBe("Hello from API");
});
it("should fall back to index.htm if index.html doesn't exist", async () => {
const onlyHtmDir = tempDirWithFiles("only-htm-test", {
"index.htm": "<html><body>Only HTM Index</body></html>",
"page.html": "<html><body>Page Content</body></html>",
});
server?.stop();
server = Bun.serve({
port: 0,
routes: {
"/": { dir: onlyHtmDir },
},
});
port = server.port;
const response = await fetch(`http://localhost:${port}/`);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toContain("text/html");
const text = await response.text();
expect(text).toBe("<html><body>Only HTM Index</body></html>");
});
it("should handle directory access without trailing slash", async () => {
const response = await fetch(`http://localhost:${port}/nested`, {
redirect: "manual",
});
// Should try to find index.html in nested directory
expect(response.status).toBe(200);
const text = await response.text();
expect(text).toBe("<html><body>Only HTM Index</body></html>"); // From the previous test setup
});
it("should yield to next handler when file not found", async () => {
server?.stop();
server = Bun.serve({
port: 0,
routes: {
"/": { dir: testDir },
},
fetch: (req) => {
return new Response("Fallback handler", { status: 404 });
},
});
port = server.port;
const response = await fetch(`http://localhost:${port}/nonexistent.html`);
expect(response.status).toBe(404);
const text = await response.text();
expect(text).toBe("Fallback handler");
});
});