diff --git a/src/bun.js/api/server.zig b/src/bun.js/api/server.zig
index 81c3c5c9d0..5196110500 100644
--- a/src/bun.js/api/server.zig
+++ b/src/bun.js/api/server.zig
@@ -118,7 +118,91 @@ pub const AnyRoute = union(enum) {
}
}
- pub fn htmlRouteFromJS(argument: JSC.JSValue, init_ctx: *ServerInitContext) ?AnyRoute {
+ fn htmlManifestEntryFromJS(entry: JSC.JSValue, global: *JSC.JSGlobalObject, index_route: *?AnyRoute, index_route_path: []const u8, user_routes_to_build: *std.ArrayList(ServerConfig.StaticRouteEntry)) bun.JSError!void {
+ var path: JSC.Node.PathOrFileDescriptor = .{
+ .path = .{ .encoded_slice = try entry.getOptional(global, "path", ZigString.Slice) orelse return },
+ };
+ defer path.deinit();
+ const headers: ?*JSC.WebCore.FetchHeaders = if (try entry.getOptional(global, "headers", JSValue)) |headers_value|
+ JSC.WebCore.FetchHeaders.createFromJS(global, headers_value)
+ else
+ null;
+
+ defer {
+ if (headers) |h| h.deref();
+ }
+
+ if (global.hasException()) return error.JSError;
+
+ var blob = Blob.findOrCreateFileFromPath(
+ &path,
+ global,
+ true,
+ );
+
+ var path_segment = std.ArrayList(u8).init(bun.default_allocator);
+ errdefer path_segment.deinit();
+
+ var path_relative_to_cwd = bun.path.relativeNormalized(bun.fs.FileSystem.instance.top_level_dir, path.slice(), .posix, true);
+ if (strings.hasPrefix(path_relative_to_cwd, "./")) {
+ path_relative_to_cwd = path_relative_to_cwd[1..];
+ }
+
+ if (!strings.hasPrefix(path_relative_to_cwd, "/")) {
+ try path_segment.append('/');
+ }
+
+ try path_segment.appendSlice(path_relative_to_cwd);
+
+ const any_route: AnyRoute = if (blob.needsToReadFile())
+ .{
+ .file = FileRoute.initFromBlob(blob, .{
+ .headers = headers,
+ .server = null,
+ }),
+ }
+ else
+ .{
+ .static = StaticRoute.initFromAnyBlob(&.{ .Blob = blob }, .{
+ .headers = headers,
+ .server = null,
+ }),
+ };
+
+ if (index_route.* == null and strings.eql(path.slice(), index_route_path)) {
+ index_route.* = any_route;
+ } else {
+ var methods = HTTP.Method.Optional{
+ .method = .{},
+ };
+ methods.insert(.GET);
+ methods.insert(.HEAD);
+
+ try user_routes_to_build.append(.{
+ .path = path_segment.items,
+ .route = any_route,
+ .method = methods,
+ });
+ }
+ }
+
+ fn htmlManifestObjectFromJS(argument: JSC.JSValue, init_ctx: *ServerInitContext, global: *JSC.JSGlobalObject) bun.JSError!?AnyRoute {
+ const index: ZigString.Slice = try (argument.getOptional(global, "index", ZigString.Slice)) orelse return null;
+ defer index.deinit();
+
+ const files: JSValue = (try argument.getOwnArray(global, "files")) orelse return null;
+ var array_iter = files.arrayIterator(global);
+ var index_route: ?AnyRoute = null;
+ const index_route_ptr: *?AnyRoute = &index_route;
+
+ while (array_iter.next()) |entry| {
+ try htmlManifestEntryFromJS(entry, global, index_route_ptr, index.slice(), init_ctx.static_routes);
+ }
+
+ return index_route;
+ }
+
+ pub fn htmlRouteFromJS(argument: JSC.JSValue, init_ctx: *ServerInitContext, global: *JSC.JSGlobalObject) bun.JSError!?AnyRoute {
if (argument.as(HTMLBundle)) |html_bundle| {
const entry = init_ctx.dedupe_html_bundle_map.getOrPut(html_bundle) catch bun.outOfMemory();
if (!entry.found_existing) {
@@ -129,6 +213,10 @@ pub const AnyRoute = union(enum) {
}
}
+ if (argument.isObject()) {
+ return htmlManifestObjectFromJS(argument, init_ctx, global);
+ }
+
return null;
}
@@ -137,6 +225,7 @@ pub const AnyRoute = union(enum) {
dedupe_html_bundle_map: std.AutoHashMap(*HTMLBundle, bun.ptr.RefPtr(HTMLBundle.Route)),
js_string_allocations: bun.bake.StringRefList,
framework_router_list: std.ArrayList(bun.bake.Framework.FileSystemRouterType),
+ static_routes: *std.ArrayList(ServerConfig.StaticRouteEntry),
};
pub fn fromJS(
@@ -145,7 +234,7 @@ pub const AnyRoute = union(enum) {
argument: JSC.JSValue,
init_ctx: *ServerInitContext,
) bun.JSError!?AnyRoute {
- if (AnyRoute.htmlRouteFromJS(argument, init_ctx)) |html_route| {
+ if (try AnyRoute.htmlRouteFromJS(argument, init_ctx, global)) |html_route| {
return html_route;
}
diff --git a/src/bun.js/api/server/FileRoute.zig b/src/bun.js/api/server/FileRoute.zig
index 2dbdca9a88..7139d76cd1 100644
--- a/src/bun.js/api/server/FileRoute.zig
+++ b/src/bun.js/api/server/FileRoute.zig
@@ -12,6 +12,7 @@ has_content_length_header: bool,
pub const InitOptions = struct {
server: ?AnyServer,
status_code: u16 = 200,
+ headers: ?*JSC.WebCore.FetchHeaders = null,
};
pub fn lastModifiedDate(this: *const FileRoute) ?u64 {
@@ -34,13 +35,15 @@ pub fn lastModifiedDate(this: *const FileRoute) ?u64 {
}
pub fn initFromBlob(blob: Blob, opts: InitOptions) *FileRoute {
- const headers = Headers.from(null, bun.default_allocator, .{ .body = &.{ .Blob = blob } }) catch bun.outOfMemory();
+ const headers = Headers.from(opts.headers, bun.default_allocator, .{ .body = &.{ .Blob = blob } }) catch bun.outOfMemory();
return bun.new(FileRoute, .{
.ref_count = .init(),
.server = opts.server,
.blob = blob,
.headers = headers,
.status_code = opts.status_code,
+ .has_last_modified_header = headers.contains("last-modified"),
+ .has_content_length_header = headers.contains("content-length"),
});
}
diff --git a/src/bun.js/api/server/ServerConfig.zig b/src/bun.js/api/server/ServerConfig.zig
index 88a3effc7b..71252538da 100644
--- a/src/bun.js/api/server/ServerConfig.zig
+++ b/src/bun.js/api/server/ServerConfig.zig
@@ -511,6 +511,7 @@ pub fn fromJS(
.dedupe_html_bundle_map = .init(bun.default_allocator),
.framework_router_list = .init(bun.default_allocator),
.js_string_allocations = .empty,
+ .static_routes = &args.static_routes,
};
errdefer {
init_ctx.arena.deinit();
@@ -1067,7 +1068,7 @@ pub fn fromJS(
return;
}
-const UserRouteBuilder = struct {
+pub const UserRouteBuilder = struct {
route: ServerConfig.RouteDeclaration,
callback: JSC.Strong.Optional = .empty,
diff --git a/src/bun.js/api/server/StaticRoute.zig b/src/bun.js/api/server/StaticRoute.zig
index f1874d916f..616bb2bfec 100644
--- a/src/bun.js/api/server/StaticRoute.zig
+++ b/src/bun.js/api/server/StaticRoute.zig
@@ -22,11 +22,12 @@ pub const InitFromBytesOptions = struct {
server: ?AnyServer,
mime_type: ?*const bun.http.MimeType = null,
status_code: u16 = 200,
+ headers: ?*JSC.WebCore.FetchHeaders = null,
};
/// Ownership of `blob` is transferred to this function.
pub fn initFromAnyBlob(blob: *const AnyBlob, options: InitFromBytesOptions) *StaticRoute {
- var headers = Headers.from(null, bun.default_allocator, .{ .body = blob }) catch bun.outOfMemory();
+ var headers = Headers.from(options.headers, bun.default_allocator, .{ .body = blob }) catch bun.outOfMemory();
if (options.mime_type) |mime_type| {
if (headers.getContentType() == null) {
headers.append("Content-Type", mime_type.value) catch bun.outOfMemory();
diff --git a/src/bun.js/node/types.zig b/src/bun.js/node/types.zig
index a085793b45..64af20951e 100644
--- a/src/bun.js/node/types.zig
+++ b/src/bun.js/node/types.zig
@@ -871,6 +871,13 @@ pub const PathOrFileDescriptor = union(Tag) {
}
}
+ pub fn slice(this: *const PathOrFileDescriptor) []const u8 {
+ return switch (this.*) {
+ .path => this.path.slice(),
+ .fd => @panic("slice not supported for file descriptors"),
+ };
+ }
+
pub fn estimatedSize(this: *const PathOrFileDescriptor) usize {
return switch (this.*) {
.path => this.path.estimatedSize(),
diff --git a/src/http.zig b/src/http.zig
index 3084a08d67..90896af28f 100644
--- a/src/http.zig
+++ b/src/http.zig
@@ -4806,6 +4806,9 @@ pub const Headers = struct {
return null;
}
+ pub inline fn contains(this: *const Headers, name: []const u8) bool {
+ return this.get(name) != null;
+ }
pub fn append(this: *Headers, name: []const u8, value: []const u8) !void {
var offset: u32 = @truncate(this.buf.items.len);
diff --git a/test/js/bun/http/bun-serve-html-manifest.test.ts b/test/js/bun/http/bun-serve-html-manifest.test.ts
new file mode 100644
index 0000000000..0e7e2c2099
--- /dev/null
+++ b/test/js/bun/http/bun-serve-html-manifest.test.ts
@@ -0,0 +1,484 @@
+import { describe, expect, test, beforeAll, afterAll } from "bun:test";
+import { join } from "path";
+import { tempDirWithFiles } from "harness";
+import type { Server } from "bun";
+
+describe("serve html manifest", () => {
+ test.only("basic manifest object with index and files", async () => {
+ const dir = tempDirWithFiles("html-manifest-basic", {
+ "index.html": `
+
+
+ Manifest Test
+
+
+ Hello from Manifest
+
+`,
+ "about.html": `
+
+
+ About Page
+
+
+ About Us
+
+`,
+ "styles.css": `.container { color: blue; }`,
+ "script.js": `console.log("Hello from script");`,
+ });
+
+ // Create a manifest object that mimics what would be generated by the bundler
+ const manifest = {
+ index: join(dir, "index.html"),
+ files: [{ path: join(dir, "about.html") }, { path: join(dir, "styles.css") }, { path: join(dir, "script.js") }],
+ };
+
+ using server = Bun.serve({
+ port: 0,
+ routes: {
+ "/": manifest,
+ },
+ fetch(req) {
+ return new Response("Not found", { status: 404 });
+ },
+ });
+
+ // Test index route
+ const indexResponse = await fetch(server.url);
+ expect(indexResponse.status).toBe(200);
+ expect(indexResponse.headers.get("content-type")).toBe("text/html;charset=utf-8");
+ const indexHtml = await indexResponse.text();
+ expect(indexHtml).toContain("Hello from Manifest
");
+
+ // Test file routes
+ const aboutResponse = await fetch(`${server.url}about.html`);
+ expect(aboutResponse.status).toBe(200);
+ expect(aboutResponse.headers.get("content-type")).toBe("text/html;charset=utf-8");
+ const aboutHtml = await aboutResponse.text();
+ expect(aboutHtml).toContain("About Us
");
+
+ const cssResponse = await fetch(`${server.url}styles.css`);
+ expect(cssResponse.status).toBe(200);
+ expect(cssResponse.headers.get("content-type")).toBe("text/css;charset=utf-8");
+ const css = await cssResponse.text();
+ expect(css).toContain(".container { color: blue; }");
+
+ const jsResponse = await fetch(`${server.url}script.js`);
+ expect(jsResponse.status).toBe(200);
+ expect(jsResponse.headers.get("content-type")).toBe("text/javascript;charset=utf-8");
+ const js = await jsResponse.text();
+ expect(js).toContain('console.log("Hello from script");');
+ });
+
+ test("manifest with custom headers", async () => {
+ const dir = tempDirWithFiles("html-manifest-headers", {
+ "index.html": `Index`,
+ "cached.js": `console.log("cached");`,
+ });
+
+ const manifest = {
+ index: join(dir, "index.html"),
+ files: [
+ {
+ path: join(dir, "cached.js"),
+ headers: {
+ "Cache-Control": "public, max-age=3600",
+ "X-Custom-Header": "custom-value",
+ },
+ },
+ ],
+ };
+
+ using server = Bun.serve({
+ port: 0,
+ static: {
+ "/": manifest,
+ },
+ fetch(req) {
+ return new Response("Not found", { status: 404 });
+ },
+ });
+
+ const jsResponse = await fetch(`${server.url}cached.js`);
+ expect(jsResponse.status).toBe(200);
+ expect(jsResponse.headers.get("cache-control")).toBe("public, max-age=3600");
+ expect(jsResponse.headers.get("x-custom-header")).toBe("custom-value");
+ });
+
+ test("manifest with nested paths", async () => {
+ const dir = tempDirWithFiles("html-manifest-nested", {
+ "index.html": `Root`,
+ "assets/styles.css": `.nested { color: red; }`,
+ "assets/images/logo.png": Buffer.from("fake png data"),
+ "pages/about.html": `About`,
+ });
+
+ const manifest = {
+ index: join(dir, "index.html"),
+ files: [
+ { path: join(dir, "assets/styles.css") },
+ { path: join(dir, "assets/images/logo.png") },
+ { path: join(dir, "pages/about.html") },
+ ],
+ };
+
+ using server = Bun.serve({
+ port: 0,
+ static: {
+ "/": manifest,
+ },
+ fetch(req) {
+ return new Response("Not found", { status: 404 });
+ },
+ });
+
+ // Test nested paths are served correctly
+ const cssResponse = await fetch(`${server.url}assets/styles.css`);
+ expect(cssResponse.status).toBe(200);
+ const css = await cssResponse.text();
+ expect(css).toContain(".nested { color: red; }");
+
+ const pngResponse = await fetch(`${server.url}assets/images/logo.png`);
+ expect(pngResponse.status).toBe(200);
+ expect(pngResponse.headers.get("content-type")).toBe("image/png");
+
+ const aboutResponse = await fetch(`${server.url}pages/about.html`);
+ expect(aboutResponse.status).toBe(200);
+ const aboutHtml = await aboutResponse.text();
+ expect(aboutHtml).toContain("About");
+ });
+
+ test("manifest with multiple routes", async () => {
+ const dir = tempDirWithFiles("html-manifest-multiple", {
+ "home/index.html": `Home`,
+ "home/home.js": `console.log("home");`,
+ "admin/index.html": `Admin`,
+ "admin/admin.js": `console.log("admin");`,
+ });
+
+ const homeManifest = {
+ index: join(dir, "home/index.html"),
+ files: [{ path: join(dir, "home/home.js") }],
+ };
+
+ const adminManifest = {
+ index: join(dir, "admin/index.html"),
+ files: [{ path: join(dir, "admin/admin.js") }],
+ };
+
+ using server = Bun.serve({
+ port: 0,
+ static: {
+ "/": homeManifest,
+ "/admin": adminManifest,
+ },
+ fetch(req) {
+ return new Response("Not found", { status: 404 });
+ },
+ });
+
+ // Test home route
+ const homeResponse = await fetch(server.url);
+ expect(homeResponse.status).toBe(200);
+ const homeHtml = await homeResponse.text();
+ expect(homeHtml).toContain("Home");
+
+ const homeJsResponse = await fetch(`${server.url}home/home.js`);
+ expect(homeJsResponse.status).toBe(200);
+ const homeJs = await homeJsResponse.text();
+ expect(homeJs).toContain('console.log("home");');
+
+ // Test admin route
+ const adminResponse = await fetch(`${server.url}admin`);
+ expect(adminResponse.status).toBe(200);
+ const adminHtml = await adminResponse.text();
+ expect(adminHtml).toContain("Admin");
+
+ const adminJsResponse = await fetch(`${server.url}admin/admin.js`);
+ expect(adminJsResponse.status).toBe(200);
+ const adminJs = await adminJsResponse.text();
+ expect(adminJs).toContain('console.log("admin");');
+ });
+
+ test("manifest with large files", async () => {
+ const largeContent = "x".repeat(1024 * 1024); // 1MB
+ const dir = tempDirWithFiles("html-manifest-large", {
+ "index.html": `Index`,
+ "large.txt": largeContent,
+ });
+
+ const manifest = {
+ index: join(dir, "index.html"),
+ files: [{ path: join(dir, "large.txt") }],
+ };
+
+ using server = Bun.serve({
+ port: 0,
+ static: {
+ "/": manifest,
+ },
+ fetch(req) {
+ return new Response("Not found", { status: 404 });
+ },
+ });
+
+ const response = await fetch(`${server.url}large.txt`);
+ expect(response.status).toBe(200);
+ const text = await response.text();
+ expect(text.length).toBe(1024 * 1024);
+ expect(text).toBe(largeContent);
+ });
+
+ test("manifest with binary files", async () => {
+ const binaryData = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]); // JPEG header
+ const dir = tempDirWithFiles("html-manifest-binary", {
+ "index.html": `Index`,
+ "image.jpg": binaryData,
+ });
+
+ const manifest = {
+ index: join(dir, "index.html"),
+ files: [{ path: join(dir, "image.jpg") }],
+ };
+
+ using server = Bun.serve({
+ port: 0,
+ static: {
+ "/": manifest,
+ },
+ fetch(req) {
+ return new Response("Not found", { status: 404 });
+ },
+ });
+
+ const response = await fetch(`${server.url}image.jpg`);
+ expect(response.status).toBe(200);
+ expect(response.headers.get("content-type")).toBe("image/jpeg");
+ const buffer = await response.arrayBuffer();
+ expect(new Uint8Array(buffer)).toEqual(new Uint8Array(binaryData));
+ });
+
+ test("manifest handles HEAD requests", async () => {
+ const dir = tempDirWithFiles("html-manifest-head", {
+ "index.html": `Index`,
+ "file.txt": "Hello World",
+ });
+
+ const manifest = {
+ index: join(dir, "index.html"),
+ files: [{ path: join(dir, "file.txt") }],
+ };
+
+ using server = Bun.serve({
+ port: 0,
+ static: {
+ "/": manifest,
+ },
+ fetch(req) {
+ return new Response("Not found", { status: 404 });
+ },
+ });
+
+ // HEAD request for index
+ const indexHead = await fetch(server.url, { method: "HEAD" });
+ expect(indexHead.status).toBe(200);
+ expect(indexHead.headers.get("content-type")).toBe("text/html;charset=utf-8");
+ expect(await indexHead.text()).toBe("");
+
+ // HEAD request for file
+ const fileHead = await fetch(`${server.url}file.txt`, { method: "HEAD" });
+ expect(fileHead.status).toBe(200);
+ expect(fileHead.headers.get("content-type")).toBe("text/plain;charset=utf-8");
+ expect(await fileHead.text()).toBe("");
+ });
+
+ test("manifest with empty files array", async () => {
+ const dir = tempDirWithFiles("html-manifest-empty", {
+ "index.html": `Index Only`,
+ });
+
+ const manifest = {
+ index: join(dir, "index.html"),
+ files: [],
+ };
+
+ using server = Bun.serve({
+ port: 0,
+ static: {
+ "/": manifest,
+ },
+ fetch(req) {
+ return new Response("Not found", { status: 404 });
+ },
+ });
+
+ // Index should work
+ const indexResponse = await fetch(server.url);
+ expect(indexResponse.status).toBe(200);
+ const html = await indexResponse.text();
+ expect(html).toContain("Index Only");
+
+ // Other paths should return 404
+ const notFound = await fetch(`${server.url}nonexistent.js`);
+ expect(notFound.status).toBe(404);
+ });
+
+ test("manifest with wildcards and API routes", async () => {
+ const dir = tempDirWithFiles("html-manifest-wildcard", {
+ "index.html": `App`,
+ "app.js": `console.log("app");`,
+ });
+
+ const manifest = {
+ index: join(dir, "index.html"),
+ files: [{ path: join(dir, "app.js") }],
+ };
+
+ using server = Bun.serve({
+ port: 0,
+ static: {
+ "/*": manifest,
+ "/api/*": false,
+ },
+ fetch(req) {
+ const url = new URL(req.url);
+ if (url.pathname.startsWith("/api/")) {
+ return Response.json({ api: true, path: url.pathname });
+ }
+ return new Response("Not found", { status: 404 });
+ },
+ });
+
+ // Test HTML routes
+ for (const path of ["/", "/about", "/contact"]) {
+ const response = await fetch(`${server.url}${path}`);
+ expect(response.status).toBe(200);
+ const html = await response.text();
+ expect(html).toContain("App");
+ }
+
+ // Test static file
+ const jsResponse = await fetch(`${server.url}app.js`);
+ expect(jsResponse.status).toBe(200);
+ const js = await jsResponse.text();
+ expect(js).toContain('console.log("app");');
+
+ // Test API routes
+ const apiResponse = await fetch(`${server.url}api/users`);
+ expect(apiResponse.status).toBe(200);
+ const json = await apiResponse.json();
+ expect(json).toEqual({ api: true, path: "/api/users" });
+ });
+
+ test("manifest with development mode", async () => {
+ const dir = tempDirWithFiles("html-manifest-dev", {
+ "index.html": `Dev Mode`,
+ "app.js": `console.log("development");`,
+ });
+
+ const manifest = {
+ index: join(dir, "index.html"),
+ files: [{ path: join(dir, "app.js") }],
+ };
+
+ for (const development of [true, false]) {
+ using server = Bun.serve({
+ port: 0,
+ development,
+ static: {
+ "/": manifest,
+ },
+ fetch(req) {
+ return new Response("Not found", { status: 404 });
+ },
+ });
+
+ const response = await fetch(server.url);
+ expect(response.status).toBe(200);
+ const html = await response.text();
+ expect(html).toContain("Dev Mode");
+ }
+ });
+
+ test("manifest with relative paths converted to absolute", async () => {
+ const dir = tempDirWithFiles("html-manifest-relative", {
+ "public/index.html": `Public`,
+ "public/assets/style.css": `body { margin: 0; }`,
+ });
+
+ // Test that relative paths are handled correctly
+ const manifest = {
+ index: join(dir, "public/index.html"),
+ files: [{ path: join(dir, "public/assets/style.css") }],
+ };
+
+ using server = Bun.serve({
+ port: 0,
+ static: {
+ "/": manifest,
+ },
+ fetch(req) {
+ return new Response("Not found", { status: 404 });
+ },
+ });
+
+ const cssResponse = await fetch(`${server.url}public/assets/style.css`);
+ expect(cssResponse.status).toBe(200);
+ const css = await cssResponse.text();
+ expect(css).toContain("body { margin: 0; }");
+ });
+
+ test("manifest reload", async () => {
+ const dir = tempDirWithFiles("html-manifest-reload", {
+ "v1/index.html": `Version 1`,
+ "v2/index.html": `Version 2`,
+ });
+
+ const manifest1 = {
+ index: join(dir, "v1/index.html"),
+ files: [],
+ };
+
+ const manifest2 = {
+ index: join(dir, "v2/index.html"),
+ files: [],
+ };
+
+ const server = Bun.serve({
+ port: 0,
+ static: {
+ "/": manifest1,
+ },
+ fetch(req) {
+ return new Response("Not found", { status: 404 });
+ },
+ });
+
+ try {
+ // Test initial version
+ const response1 = await fetch(server.url);
+ expect(response1.status).toBe(200);
+ const html1 = await response1.text();
+ expect(html1).toContain("Version 1");
+
+ // Reload with new manifest
+ server.reload({
+ static: {
+ "/": manifest2,
+ },
+ fetch(req) {
+ return new Response("Not found", { status: 404 });
+ },
+ });
+
+ // Test updated version
+ const response2 = await fetch(server.url);
+ expect(response2.status).toBe(200);
+ const html2 = await response2.text();
+ expect(html2).toContain("Version 2");
+ } finally {
+ server.stop(true);
+ }
+ });
+});