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); + } + }); +});