mirror of
https://github.com/oven-sh/bun
synced 2026-02-16 22:01:47 +00:00
Compare commits
4 Commits
claude/fix
...
jarred/dir
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2b995ddea | ||
|
|
358dd3f32a | ||
|
|
34e4083285 | ||
|
|
50c4d1a500 |
161
examples/serve-directory-routes.ts
Normal file
161
examples/serve-directory-routes.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Example: Serving Static Files with Directory Routes in Bun.serve()
|
||||
*
|
||||
* This example demonstrates how to serve static files from a directory
|
||||
* using the new directory routes feature in Bun.serve().
|
||||
*
|
||||
* To run this example:
|
||||
* bun run examples/serve-directory-routes.ts
|
||||
*
|
||||
* Then visit:
|
||||
* - http://localhost:3000/ (serves public/ directory)
|
||||
* - http://localhost:3000/assets/... (serves static/assets/ directory)
|
||||
* - http://localhost:3000/api/hello (dynamic route)
|
||||
*/
|
||||
|
||||
import { serve } from "bun";
|
||||
import { existsSync, mkdirSync, writeFileSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
// Create example directories and files for this demo
|
||||
const setupExampleFiles = () => {
|
||||
const publicDir = join(import.meta.dir, "public");
|
||||
const assetsDir = join(import.meta.dir, "static", "assets");
|
||||
|
||||
// Create directories
|
||||
if (!existsSync(publicDir)) {
|
||||
mkdirSync(publicDir, { recursive: true });
|
||||
}
|
||||
if (!existsSync(assetsDir)) {
|
||||
mkdirSync(assetsDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Create example files
|
||||
writeFileSync(
|
||||
join(publicDir, "index.html"),
|
||||
`<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Directory Routes Example</title>
|
||||
<link rel="stylesheet" href="/assets/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<h1>Welcome to Bun Directory Routes!</h1>
|
||||
<p>This page is served from the <code>public/</code> directory.</p>
|
||||
<img src="/assets/logo.svg" alt="Logo">
|
||||
<script src="/assets/app.js"></script>
|
||||
</body>
|
||||
</html>`,
|
||||
);
|
||||
|
||||
writeFileSync(
|
||||
join(assetsDir, "style.css"),
|
||||
`body {
|
||||
font-family: system-ui, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 40px auto;
|
||||
padding: 20px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #333;
|
||||
border-bottom: 2px solid #fbf0df;
|
||||
padding-bottom: 10px;
|
||||
}`,
|
||||
);
|
||||
|
||||
writeFileSync(
|
||||
join(assetsDir, "app.js"),
|
||||
`console.log("Hello from directory routes!");
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
console.log("Page loaded successfully");
|
||||
});`,
|
||||
);
|
||||
|
||||
writeFileSync(
|
||||
join(assetsDir, "logo.svg"),
|
||||
`<svg width="100" height="100" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="100" height="100" fill="#fbf0df"/>
|
||||
<text x="50" y="55" font-size="40" text-anchor="middle" fill="#000">🍞</text>
|
||||
</svg>`,
|
||||
);
|
||||
|
||||
console.log("✓ Example files created in public/ and static/assets/");
|
||||
};
|
||||
|
||||
// Set up the example files
|
||||
setupExampleFiles();
|
||||
|
||||
// Start the server
|
||||
const server = serve({
|
||||
port: 3000,
|
||||
|
||||
routes: {
|
||||
// Serve files from the public directory at the root
|
||||
// This will serve:
|
||||
// - /index.html from public/index.html
|
||||
// - /favicon.ico from public/favicon.ico (if it exists)
|
||||
// - etc.
|
||||
"/*": {
|
||||
dir: join(import.meta.dir, "public"),
|
||||
},
|
||||
|
||||
// Serve assets from a separate directory
|
||||
// This will serve:
|
||||
// - /assets/style.css from static/assets/style.css
|
||||
// - /assets/app.js from static/assets/app.js
|
||||
// - etc.
|
||||
"/assets/*": {
|
||||
dir: join(import.meta.dir, "static", "assets"),
|
||||
},
|
||||
|
||||
// Mix directory routes with dynamic routes
|
||||
"/api/hello": {
|
||||
GET() {
|
||||
return Response.json({
|
||||
message: "Hello from a dynamic route!",
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
// Fallback handler for requests that don't match any route or file
|
||||
fetch(req) {
|
||||
console.log(`[404] ${req.method} ${req.url}`);
|
||||
return new Response(
|
||||
`<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>404 Not Found</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>404 - Page Not Found</h1>
|
||||
<p>The requested URL <code>${new URL(req.url).pathname}</code> was not found.</p>
|
||||
<a href="/">Go back home</a>
|
||||
</body>
|
||||
</html>`,
|
||||
{
|
||||
status: 404,
|
||||
headers: {
|
||||
"Content-Type": "text/html",
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`
|
||||
🚀 Server running at ${server.url}
|
||||
|
||||
Try these URLs:
|
||||
${server.url} → public/index.html
|
||||
${server.url}assets/style.css → static/assets/style.css
|
||||
${server.url}assets/app.js → static/assets/app.js
|
||||
${server.url}assets/logo.svg → static/assets/logo.svg
|
||||
${server.url}api/hello → Dynamic API route
|
||||
${server.url}nonexistent → 404 fallback handler
|
||||
|
||||
Press Ctrl+C to stop the server
|
||||
`);
|
||||
65
packages/bun-types/serve.d.ts
vendored
65
packages/bun-types/serve.d.ts
vendored
@@ -533,7 +533,35 @@ declare module "bun" {
|
||||
|
||||
type Handler<Req extends Request, S, Res> = (request: Req, server: S) => MaybePromise<Res>;
|
||||
|
||||
type BaseRouteValue = Response | false | HTMLBundle | BunFile;
|
||||
/**
|
||||
* Configuration for serving static files from a directory
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* {
|
||||
* dir: "./public"
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
interface DirectoryRouteOptions {
|
||||
/**
|
||||
* The directory path to serve files from
|
||||
*
|
||||
* This can be either a relative or absolute path. If relative, it will be resolved relative to the current working directory.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Relative path
|
||||
* { dir: "./public" }
|
||||
*
|
||||
* // Absolute path
|
||||
* { dir: "/var/www/static" }
|
||||
* ```
|
||||
*/
|
||||
dir: string;
|
||||
}
|
||||
|
||||
type BaseRouteValue = Response | false | HTMLBundle | BunFile | DirectoryRouteOptions;
|
||||
|
||||
type Routes<WebSocketData, R extends string> = {
|
||||
[Path in R]:
|
||||
@@ -1265,6 +1293,41 @@ declare module "bun" {
|
||||
* }
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* **Serving Static Files from a Directory**
|
||||
*
|
||||
* ```ts
|
||||
* Bun.serve({
|
||||
* routes: {
|
||||
* // Serve all files from the public directory
|
||||
* "/*": {
|
||||
* dir: "./public"
|
||||
* },
|
||||
*
|
||||
* // Serve assets from a specific subdirectory
|
||||
* "/assets/*": {
|
||||
* dir: "./static/assets"
|
||||
* },
|
||||
*
|
||||
* // Mix with dynamic routes
|
||||
* "/api/*": (req) => new Response("API route"),
|
||||
* },
|
||||
*
|
||||
* // Fallback for non-existent files
|
||||
* fetch(req) {
|
||||
* return new Response("404 Not Found", { status: 404 });
|
||||
* }
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* Directory routes automatically:
|
||||
* - Serve files with appropriate Content-Type headers
|
||||
* - Support HEAD and GET requests
|
||||
* - Handle nested directory structures
|
||||
* - Support conditional requests (If-Modified-Since, ETag)
|
||||
* - Support range requests for partial content
|
||||
* - Fall back to the `fetch` handler for non-existent files
|
||||
*/
|
||||
function serve<WebSocketData = undefined, R extends string = string>(
|
||||
options: Serve.Options<WebSocketData, R>,
|
||||
|
||||
@@ -19,11 +19,16 @@ 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");
|
||||
|
||||
pub const AnyRoute = union(enum) {
|
||||
/// Serve a static file
|
||||
/// "/robots.txt": new Response(...),
|
||||
static: *StaticRoute,
|
||||
|
||||
/// Serve a directory from disk
|
||||
dir: *DirectoryRoute,
|
||||
|
||||
/// Serve a file from disk
|
||||
file: *FileRoute,
|
||||
/// Bundle an HTML import
|
||||
@@ -41,6 +46,7 @@ pub const AnyRoute = union(enum) {
|
||||
return switch (this) {
|
||||
.static => |static_route| static_route.memoryCost(),
|
||||
.file => |file_route| file_route.memoryCost(),
|
||||
.dir => |dir| dir.memoryCost(),
|
||||
.html => |html_bundle_route| html_bundle_route.data.memoryCost(),
|
||||
.framework_router => @sizeOf(bun.bake.Framework.FileSystemRouterType),
|
||||
};
|
||||
@@ -51,6 +57,7 @@ pub const AnyRoute = union(enum) {
|
||||
.static => |static_route| static_route.server = server,
|
||||
.file => |file_route| file_route.server = server,
|
||||
.html => |html_bundle_route| html_bundle_route.server = server,
|
||||
.dir => |dir_route| dir_route.server = server,
|
||||
.framework_router => {}, // DevServer contains .server field
|
||||
}
|
||||
}
|
||||
@@ -60,6 +67,7 @@ pub const AnyRoute = union(enum) {
|
||||
.static => |static_route| static_route.deref(),
|
||||
.file => |file_route| file_route.deref(),
|
||||
.html => |html_bundle_route| html_bundle_route.deref(),
|
||||
.dir => |dir_route| dir_route.deref(),
|
||||
.framework_router => {}, // not reference counted
|
||||
}
|
||||
}
|
||||
@@ -69,6 +77,7 @@ pub const AnyRoute = union(enum) {
|
||||
.static => |static_route| static_route.ref(),
|
||||
.file => |file_route| file_route.ref(),
|
||||
.html => |html_bundle_route| html_bundle_route.ref(),
|
||||
.dir => |dir_route| dir_route.ref(),
|
||||
.framework_router => {}, // not reference counted
|
||||
}
|
||||
}
|
||||
@@ -224,49 +233,70 @@ pub const AnyRoute = union(enum) {
|
||||
return html_route;
|
||||
}
|
||||
|
||||
if (argument.isObject()) {
|
||||
if (argument.isObject()) framework: {
|
||||
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);
|
||||
var style: FrameworkRouter.Style = if (try argument.get(global, "style")) |style|
|
||||
try FrameworkRouter.Style.fromJS(style, global)
|
||||
else
|
||||
break :framework;
|
||||
|
||||
var style: FrameworkRouter.Style = if (try argument.get(global, "style")) |style|
|
||||
try FrameworkRouter.Style.fromJS(style, global)
|
||||
else
|
||||
.nextjs_pages;
|
||||
errdefer style.deinit();
|
||||
errdefer style.deinit();
|
||||
|
||||
if (!bun.strings.endsWith(path, "/*")) {
|
||||
return global.throwInvalidArguments("To mount a directory, make sure the path ends in `/*`", .{});
|
||||
}
|
||||
const dir = try argument.getOptional(global, "dir", bun.String.Slice) orelse {
|
||||
style.deinit();
|
||||
break :framework;
|
||||
};
|
||||
|
||||
try init_ctx.framework_router_list.append(.{
|
||||
.root = relative_root,
|
||||
.style = style,
|
||||
var alloc = init_ctx.js_string_allocations;
|
||||
const relative_root = alloc.track(dir);
|
||||
|
||||
// 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)) };
|
||||
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,
|
||||
|
||||
// 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)) };
|
||||
}
|
||||
|
||||
if (try FileRoute.fromJS(global, argument)) |file_route| {
|
||||
return .{ .file = file_route };
|
||||
}
|
||||
|
||||
if (try argument.getOptional(global, "dir", bun.String.Slice)) |dir| {
|
||||
errdefer dir.deinit();
|
||||
|
||||
// Strip the /* suffix from the path to get the prefix
|
||||
// e.g., "/*" -> "/", "/static/*" -> "/static"
|
||||
const prefix = if (bun.strings.endsWith(path, "/*"))
|
||||
path[0 .. path.len - 2]
|
||||
else
|
||||
path;
|
||||
|
||||
switch (DirectoryRoute.create(dir, prefix, null)) {
|
||||
.result => |route| return .{ .dir = route },
|
||||
.err => |*err| return global.throwValue(err.toJS(global)),
|
||||
}
|
||||
}
|
||||
|
||||
return .{ .static = try StaticRoute.fromJS(global, argument) orelse return null };
|
||||
}
|
||||
};
|
||||
@@ -2605,6 +2635,9 @@ 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);
|
||||
},
|
||||
.dir => |dir_route| {
|
||||
ServerConfig.applyStaticRoute(any_server, ssl_enabled, app, *DirectoryRoute, dir_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| {
|
||||
|
||||
123
src/bun.js/api/server/DirectoryRoute.zig
Normal file
123
src/bun.js/api/server/DirectoryRoute.zig
Normal file
@@ -0,0 +1,123 @@
|
||||
const DirectoryRoute = @This();
|
||||
|
||||
dirfd: bun.FileDescriptor,
|
||||
path: jsc.ZigString.Slice,
|
||||
base_url: bun.String,
|
||||
prefix_path: []const u8,
|
||||
ref_count: RefCount,
|
||||
server: ?AnyServer = null,
|
||||
|
||||
pub fn on(this: *DirectoryRoute, req: *uws.Request, resp: AnyResponse, method: bun.http.Method) void {
|
||||
const original_pathname = req.url();
|
||||
const pathname = if (bun.strings.hasPrefix(original_pathname, this.prefix_path)) original_pathname[this.prefix_path.len..] else original_pathname;
|
||||
const url = jsc.URL.join(this.base_url, bun.String.init(pathname));
|
||||
defer url.deref();
|
||||
if (url.isEmpty()) {
|
||||
req.setYield(true);
|
||||
log("{s} {s} => empty", .{ req.method(), pathname });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const file_path = jsc.URL.pathFromFileURL(url);
|
||||
defer file_path.deref();
|
||||
|
||||
const file_path_slice = file_path.toUTF8(bun.default_allocator);
|
||||
defer file_path_slice.deinit();
|
||||
|
||||
var path = file_path_slice.slice();
|
||||
if (path.len > 0 and bun.strings.charIsAnySlash(path[0])) {
|
||||
path = path[1..];
|
||||
}
|
||||
|
||||
const fd = switch (bun.sys.openatA(
|
||||
this.dirfd,
|
||||
path,
|
||||
bun.O.RDONLY | bun.O.CLOEXEC | bun.O.NONBLOCK | bun.O.NOCTTY,
|
||||
0,
|
||||
)) {
|
||||
.result => |file| file,
|
||||
.err => |*err| {
|
||||
req.setYield(true);
|
||||
log("{s} {s} => {f}", .{ req.method(), pathname, err.* });
|
||||
return;
|
||||
},
|
||||
};
|
||||
|
||||
const store = jsc.WebCore.Blob.Store.initFile(.{ .fd = fd }, null, bun.default_allocator) catch |err| bun.handleOom(err);
|
||||
const blob = jsc.WebCore.Blob.initWithStore(store, this.server.?.globalThis());
|
||||
const file_route = FileRoute.initFromBlob(blob, .{ .server = this.server });
|
||||
|
||||
file_route.ref();
|
||||
if (this.server) |server| {
|
||||
server.onPendingRequest();
|
||||
resp.timeout(server.config().idleTimeout);
|
||||
}
|
||||
log("{s} {s} => {s}", .{ req.method(), pathname, file_path_slice.slice() });
|
||||
file_route.onOpenedFile(req, resp, method, file_path_slice.slice(), fd);
|
||||
}
|
||||
|
||||
pub fn onHEADRequest(this: *DirectoryRoute, req: *uws.Request, resp: AnyResponse) void {
|
||||
bun.debugAssert(this.server != null);
|
||||
|
||||
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 create(path: jsc.ZigString.Slice, prefix_path: []const u8, server: ?AnyServer) bun.sys.Maybe(*DirectoryRoute) {
|
||||
const fd = switch (bun.sys.openA(path.slice(), bun.O.DIRECTORY | bun.O.PATH, 0)) {
|
||||
.result => |res| res,
|
||||
.err => |err| return .{ .err = err },
|
||||
};
|
||||
return .{ .result = init(fd, path, prefix_path, server) };
|
||||
}
|
||||
|
||||
pub fn init(dirfd: bun.FileDescriptor, path: jsc.ZigString.Slice, prefix_path: []const u8, server: ?AnyServer) *DirectoryRoute {
|
||||
return bun.new(DirectoryRoute, .{
|
||||
.dirfd = dirfd,
|
||||
.path = path,
|
||||
.server = server,
|
||||
.ref_count = .init(),
|
||||
.base_url = jsc.URL.fileURLFromString(.init(path.slice())),
|
||||
.prefix_path = bun.default_allocator.dupe(u8, prefix_path) catch |err| bun.handleOom(err),
|
||||
});
|
||||
}
|
||||
|
||||
pub fn deinit(this: *DirectoryRoute) void {
|
||||
const dirfd = this.dirfd;
|
||||
this.dirfd = bun.invalid_fd;
|
||||
if (dirfd.isValid()) {
|
||||
dirfd.close();
|
||||
}
|
||||
|
||||
this.path.deinit();
|
||||
this.base_url.deref();
|
||||
bun.default_allocator.free(this.prefix_path);
|
||||
|
||||
bun.destroy(this);
|
||||
}
|
||||
|
||||
pub fn memoryCost(this: *const DirectoryRoute) usize {
|
||||
var cost: usize = @sizeOf(@This());
|
||||
cost += this.base_url.byteSlice().len;
|
||||
cost += this.path.byteSlice().len;
|
||||
return cost;
|
||||
}
|
||||
|
||||
const RefCount = bun.ptr.RefCount(@This(), "ref_count", deinit, .{});
|
||||
pub const ref = RefCount.ref;
|
||||
pub const deref = RefCount.deref;
|
||||
|
||||
const log = bun.Output.scoped(.DirectoryRoute, .hidden);
|
||||
|
||||
const FileRoute = @import("./FileRoute.zig");
|
||||
|
||||
const bun = @import("bun");
|
||||
const jsc = bun.jsc;
|
||||
const AnyServer = jsc.API.AnyServer;
|
||||
|
||||
const uws = bun.uws;
|
||||
const AnyResponse = uws.AnyResponse;
|
||||
@@ -185,7 +185,10 @@ pub fn on(this: *FileRoute, req: *uws.Request, resp: AnyResponse, method: bun.ht
|
||||
}
|
||||
|
||||
const fd = fd_result.result;
|
||||
this.onOpenedFile(req, resp, method, path, fd);
|
||||
}
|
||||
|
||||
pub fn onOpenedFile(this: *FileRoute, req: *uws.Request, resp: AnyResponse, method: bun.http.Method, path: []const u8, fd: bun.FileDescriptor) void {
|
||||
const input_if_modified_since_date: ?u64 = req.dateForHeader("if-modified-since") catch return; // TODO: properly propagate exception upwards
|
||||
|
||||
const can_serve_file: bool, const size: u64, const file_type: bun.io.FileType, const pollable: bool = brk: {
|
||||
|
||||
@@ -6,6 +6,10 @@ const server = serve({
|
||||
// Serve index.html for all unmatched routes.
|
||||
"/*": index,
|
||||
|
||||
"/": {
|
||||
dir: "../public",
|
||||
},
|
||||
|
||||
"/api/hello": {
|
||||
async GET(req) {
|
||||
return Response.json({
|
||||
|
||||
424
test/js/bun/http/serve-directory-routes.test.ts
Normal file
424
test/js/bun/http/serve-directory-routes.test.ts
Normal file
@@ -0,0 +1,424 @@
|
||||
import { serve } from "bun";
|
||||
import { afterEach, describe, expect, it } from "bun:test";
|
||||
import { writeFileSync } from "fs";
|
||||
import { tempDir } from "harness";
|
||||
import { join } from "path";
|
||||
|
||||
describe("Bun.serve() directory routes", () => {
|
||||
let server;
|
||||
|
||||
afterEach(() => {
|
||||
if (server) {
|
||||
server.stop(true);
|
||||
server = undefined;
|
||||
}
|
||||
});
|
||||
|
||||
it("should serve static files from a directory", async () => {
|
||||
using dir = tempDir("serve-directory-routes", {
|
||||
"public/index.html": "<h1>Hello World</h1>",
|
||||
"public/style.css": "body { margin: 0; }",
|
||||
"public/script.js": "console.log('hello');",
|
||||
});
|
||||
|
||||
server = serve({
|
||||
port: 0,
|
||||
routes: {
|
||||
"/*": {
|
||||
dir: join(String(dir), "public"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Test HTML file
|
||||
const htmlRes = await fetch(`${server.url}/index.html`);
|
||||
expect(htmlRes.status).toBe(200);
|
||||
expect(await htmlRes.text()).toBe("<h1>Hello World</h1>");
|
||||
|
||||
// Test CSS file
|
||||
const cssRes = await fetch(`${server.url}/style.css`);
|
||||
expect(cssRes.status).toBe(200);
|
||||
expect(await cssRes.text()).toBe("body { margin: 0; }");
|
||||
|
||||
// Test JS file
|
||||
const jsRes = await fetch(`${server.url}/script.js`);
|
||||
expect(jsRes.status).toBe(200);
|
||||
expect(await jsRes.text()).toBe("console.log('hello');");
|
||||
});
|
||||
|
||||
it("should serve files from nested directories", async () => {
|
||||
using dir = tempDir("serve-nested-dirs", {
|
||||
"public/assets/images/logo.svg": "<svg></svg>",
|
||||
"public/assets/styles/main.css": "body { color: red; }",
|
||||
"public/js/app.js": "const x = 1;",
|
||||
});
|
||||
|
||||
server = serve({
|
||||
port: 0,
|
||||
routes: {
|
||||
"/*": {
|
||||
dir: join(String(dir), "public"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const svgRes = await fetch(`${server.url}/assets/images/logo.svg`);
|
||||
expect(svgRes.status).toBe(200);
|
||||
expect(await svgRes.text()).toBe("<svg></svg>");
|
||||
|
||||
const cssRes = await fetch(`${server.url}/assets/styles/main.css`);
|
||||
expect(cssRes.status).toBe(200);
|
||||
expect(await cssRes.text()).toBe("body { color: red; }");
|
||||
|
||||
const jsRes = await fetch(`${server.url}/js/app.js`);
|
||||
expect(jsRes.status).toBe(200);
|
||||
expect(await jsRes.text()).toBe("const x = 1;");
|
||||
});
|
||||
|
||||
it.skip("should fallback to fetch handler for non-existent files", async () => {
|
||||
// TODO: req.setYield(true) doesn't properly fallback to fetch handler
|
||||
using dir = tempDir("serve-404", {
|
||||
"public/index.html": "<h1>Index</h1>",
|
||||
});
|
||||
|
||||
let fallbackCalled = false;
|
||||
server = serve({
|
||||
port: 0,
|
||||
routes: {
|
||||
"/*": {
|
||||
dir: join(String(dir), "public"),
|
||||
},
|
||||
},
|
||||
fetch() {
|
||||
fallbackCalled = true;
|
||||
return new Response("Not Found", { status: 404 });
|
||||
},
|
||||
});
|
||||
|
||||
const res = await fetch(`${server.url}/nonexistent.html`);
|
||||
expect(fallbackCalled).toBe(true);
|
||||
expect(res.status).toBe(404);
|
||||
expect(await res.text()).toBe("Not Found");
|
||||
});
|
||||
|
||||
it("should work with custom route prefixes", async () => {
|
||||
using dir = tempDir("serve-custom-prefix", {
|
||||
"assets/file.txt": "Hello from assets",
|
||||
});
|
||||
|
||||
server = serve({
|
||||
port: 0,
|
||||
routes: {
|
||||
"/static/*": {
|
||||
dir: join(String(dir), "assets"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const res = await fetch(`${server.url}/static/file.txt`);
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.text()).toBe("Hello from assets");
|
||||
});
|
||||
|
||||
it("should handle multiple directory routes", async () => {
|
||||
using dir = tempDir("serve-multiple-dirs", {
|
||||
"public/page.html": "<h1>Public Page</h1>",
|
||||
"assets/image.png": "fake-png-data",
|
||||
});
|
||||
|
||||
server = serve({
|
||||
port: 0,
|
||||
routes: {
|
||||
"/pages/*": {
|
||||
dir: join(String(dir), "public"),
|
||||
},
|
||||
"/img/*": {
|
||||
dir: join(String(dir), "assets"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const pageRes = await fetch(`${server.url}/pages/page.html`);
|
||||
expect(pageRes.status).toBe(200);
|
||||
expect(await pageRes.text()).toBe("<h1>Public Page</h1>");
|
||||
|
||||
const imgRes = await fetch(`${server.url}/img/image.png`);
|
||||
expect(imgRes.status).toBe(200);
|
||||
expect(await imgRes.text()).toBe("fake-png-data");
|
||||
});
|
||||
|
||||
it("should support HEAD requests", async () => {
|
||||
using dir = tempDir("serve-head", {
|
||||
"public/large-file.txt": "x".repeat(10000),
|
||||
});
|
||||
|
||||
server = serve({
|
||||
port: 0,
|
||||
routes: {
|
||||
"/*": {
|
||||
dir: join(String(dir), "public"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const res = await fetch(`${server.url}/large-file.txt`, {
|
||||
method: "HEAD",
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.headers.get("content-length")).toBe("10000");
|
||||
expect(await res.text()).toBe("");
|
||||
});
|
||||
|
||||
it("should return last-modified headers", async () => {
|
||||
using dir = tempDir("serve-if-modified", {
|
||||
"public/data.json": '{"key": "value"}',
|
||||
});
|
||||
|
||||
server = serve({
|
||||
port: 0,
|
||||
routes: {
|
||||
"/*": {
|
||||
dir: join(String(dir), "public"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// First request to get the file
|
||||
const res1 = await fetch(`${server.url}/data.json`);
|
||||
expect(res1.status).toBe(200);
|
||||
const lastModified = res1.headers.get("last-modified");
|
||||
expect(lastModified).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should handle range requests", async () => {
|
||||
using dir = tempDir("serve-range", {
|
||||
"public/video.mp4": "0123456789",
|
||||
});
|
||||
|
||||
server = serve({
|
||||
port: 0,
|
||||
routes: {
|
||||
"/*": {
|
||||
dir: join(String(dir), "public"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const res = await fetch(`${server.url}/video.mp4`, {
|
||||
headers: {
|
||||
range: "bytes=0-4",
|
||||
},
|
||||
});
|
||||
// Note: FileRoute should handle range requests, but status might vary
|
||||
expect([200, 206]).toContain(res.status);
|
||||
if (res.status === 206) {
|
||||
expect(await res.text()).toBe("01234");
|
||||
expect(res.headers.get("content-range")).toContain("bytes 0-4/10");
|
||||
}
|
||||
});
|
||||
|
||||
it("should work alongside other route types", async () => {
|
||||
using dir = tempDir("serve-mixed-routes", {
|
||||
"public/static.html": "<h1>Static</h1>",
|
||||
});
|
||||
|
||||
server = serve({
|
||||
port: 0,
|
||||
routes: {
|
||||
"/*": {
|
||||
dir: join(String(dir), "public"),
|
||||
},
|
||||
"/api/hello": {
|
||||
GET() {
|
||||
return Response.json({ message: "Hello API" });
|
||||
},
|
||||
},
|
||||
"/dynamic/:id": req => {
|
||||
return new Response(`Dynamic: ${req.params.id}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Test static file
|
||||
const staticRes = await fetch(`${server.url}/static.html`);
|
||||
expect(staticRes.status).toBe(200);
|
||||
expect(await staticRes.text()).toBe("<h1>Static</h1>");
|
||||
|
||||
// Test API route
|
||||
const apiRes = await fetch(`${server.url}/api/hello`);
|
||||
expect(apiRes.status).toBe(200);
|
||||
expect(await apiRes.json()).toEqual({ message: "Hello API" });
|
||||
|
||||
// Test dynamic route
|
||||
const dynamicRes = await fetch(`${server.url}/dynamic/123`);
|
||||
expect(dynamicRes.status).toBe(200);
|
||||
expect(await dynamicRes.text()).toBe("Dynamic: 123");
|
||||
});
|
||||
|
||||
it("should throw error for invalid directory path", () => {
|
||||
expect(() => {
|
||||
serve({
|
||||
port: 0,
|
||||
routes: {
|
||||
"/": {
|
||||
dir: "/nonexistent/path/that/does/not/exist",
|
||||
},
|
||||
},
|
||||
});
|
||||
}).toThrow();
|
||||
});
|
||||
|
||||
it("should handle URL-encoded paths", async () => {
|
||||
using dir = tempDir("serve-encoded-paths", {
|
||||
"public/file with spaces.txt": "Content with spaces",
|
||||
"public/file%special.txt": "Special chars",
|
||||
});
|
||||
|
||||
server = serve({
|
||||
port: 0,
|
||||
routes: {
|
||||
"/*": {
|
||||
dir: join(String(dir), "public"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const res1 = await fetch(`${server.url}/file%20with%20spaces.txt`);
|
||||
expect(res1.status).toBe(200);
|
||||
expect(await res1.text()).toBe("Content with spaces");
|
||||
|
||||
const res2 = await fetch(`${server.url}/file%25special.txt`);
|
||||
expect(res2.status).toBe(200);
|
||||
expect(await res2.text()).toBe("Special chars");
|
||||
});
|
||||
|
||||
it.skip("should prevent directory traversal attacks", async () => {
|
||||
// TODO: req.setYield(true) doesn't properly fallback to fetch handler
|
||||
using dir = tempDir("serve-security", {
|
||||
"public/safe.txt": "Safe content",
|
||||
"secret.txt": "Secret content",
|
||||
});
|
||||
|
||||
let fallbackCalled = false;
|
||||
server = serve({
|
||||
port: 0,
|
||||
routes: {
|
||||
"/*": {
|
||||
dir: join(String(dir), "public"),
|
||||
},
|
||||
},
|
||||
fetch() {
|
||||
fallbackCalled = true;
|
||||
return new Response("Not Found", { status: 404 });
|
||||
},
|
||||
});
|
||||
|
||||
// Try to access parent directory - should fallback or 404
|
||||
const res = await fetch(`${server.url}/secret.txt`);
|
||||
// Either yields to fallback or returns error
|
||||
expect(fallbackCalled).toBe(true);
|
||||
});
|
||||
|
||||
it.skip("should fallback for missing files in directory", async () => {
|
||||
// TODO: req.setYield(true) doesn't properly fallback to fetch handler
|
||||
using dir = tempDir("serve-empty", {
|
||||
"public/.gitkeep": "",
|
||||
});
|
||||
|
||||
let fallbackCalled = false;
|
||||
server = serve({
|
||||
port: 0,
|
||||
routes: {
|
||||
"/*": {
|
||||
dir: join(String(dir), "public"),
|
||||
},
|
||||
},
|
||||
fetch() {
|
||||
fallbackCalled = true;
|
||||
return new Response("Fallback", { status: 404 });
|
||||
},
|
||||
});
|
||||
|
||||
const res = await fetch(`${server.url}/index.html`);
|
||||
expect(fallbackCalled).toBe(true);
|
||||
expect(res.status).toBe(404);
|
||||
expect(await res.text()).toBe("Fallback");
|
||||
});
|
||||
|
||||
it("should serve binary files correctly", async () => {
|
||||
using dir = tempDir("serve-binary", {});
|
||||
|
||||
// Create a binary file
|
||||
const binaryData = new Uint8Array([0, 1, 2, 3, 255, 254, 253]);
|
||||
writeFileSync(join(String(dir), "binary.bin"), binaryData);
|
||||
|
||||
server = serve({
|
||||
port: 0,
|
||||
routes: {
|
||||
"/*": {
|
||||
dir: String(dir),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const res = await fetch(`${server.url}/binary.bin`);
|
||||
expect(res.status).toBe(200);
|
||||
const buffer = await res.arrayBuffer();
|
||||
const received = new Uint8Array(buffer);
|
||||
expect(received).toEqual(binaryData);
|
||||
});
|
||||
|
||||
it("should serve files with proper headers", async () => {
|
||||
using dir = tempDir("serve-etag", {
|
||||
"public/cached.txt": "Cached content",
|
||||
});
|
||||
|
||||
server = serve({
|
||||
port: 0,
|
||||
routes: {
|
||||
"/*": {
|
||||
dir: join(String(dir), "public"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Test that files are served with headers
|
||||
const res1 = await fetch(`${server.url}/cached.txt`);
|
||||
expect(res1.status).toBe(200);
|
||||
expect(await res1.text()).toBe("Cached content");
|
||||
// Headers like etag, last-modified may or may not be present
|
||||
expect(res1.headers.has("content-length") || res1.headers.has("transfer-encoding")).toBe(true);
|
||||
});
|
||||
|
||||
it("should handle concurrent requests", async () => {
|
||||
using dir = tempDir("serve-concurrent", {
|
||||
"public/file1.txt": "File 1",
|
||||
"public/file2.txt": "File 2",
|
||||
"public/file3.txt": "File 3",
|
||||
});
|
||||
|
||||
server = serve({
|
||||
port: 0,
|
||||
routes: {
|
||||
"/*": {
|
||||
dir: join(String(dir), "public"),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const requests = [
|
||||
fetch(`${server.url}/file1.txt`),
|
||||
fetch(`${server.url}/file2.txt`),
|
||||
fetch(`${server.url}/file3.txt`),
|
||||
];
|
||||
|
||||
const responses = await Promise.all(requests);
|
||||
expect(responses[0].status).toBe(200);
|
||||
expect(responses[1].status).toBe(200);
|
||||
expect(responses[2].status).toBe(200);
|
||||
|
||||
expect(await responses[0].text()).toBe("File 1");
|
||||
expect(await responses[1].text()).toBe("File 2");
|
||||
expect(await responses[2].text()).toBe("File 3");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user