Compare commits

...

1 Commits

Author SHA1 Message Date
Jarred Sumner
4a1905255e WIP 2025-06-12 10:37:36 +02:00
7 changed files with 593 additions and 5 deletions

View File

@@ -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;
}

View File

@@ -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"),
});
}

View File

@@ -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,

View File

@@ -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();

View File

@@ -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(),

View File

@@ -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);

View File

@@ -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": `<!DOCTYPE html>
<html>
<head>
<title>Manifest Test</title>
</head>
<body>
<h1>Hello from Manifest</h1>
</body>
</html>`,
"about.html": `<!DOCTYPE html>
<html>
<head>
<title>About Page</title>
</head>
<body>
<h1>About Us</h1>
</body>
</html>`,
"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("<h1>Hello from Manifest</h1>");
// 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("<h1>About Us</h1>");
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": `<!DOCTYPE html><html><body>Index</body></html>`,
"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": `<!DOCTYPE html><html><body>Root</body></html>`,
"assets/styles.css": `.nested { color: red; }`,
"assets/images/logo.png": Buffer.from("fake png data"),
"pages/about.html": `<!DOCTYPE html><html><body>About</body></html>`,
});
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": `<!DOCTYPE html><html><body>Home</body></html>`,
"home/home.js": `console.log("home");`,
"admin/index.html": `<!DOCTYPE html><html><body>Admin</body></html>`,
"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": `<!DOCTYPE html><html><body>Index</body></html>`,
"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": `<!DOCTYPE html><html><body>Index</body></html>`,
"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": `<!DOCTYPE html><html><body>Index</body></html>`,
"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": `<!DOCTYPE html><html><body>Index Only</body></html>`,
});
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": `<!DOCTYPE html><html><body>App</body></html>`,
"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": `<!DOCTYPE html><html><body>Dev Mode</body></html>`,
"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": `<!DOCTYPE html><html><body>Public</body></html>`,
"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": `<!DOCTYPE html><html><body>Version 1</body></html>`,
"v2/index.html": `<!DOCTYPE html><html><body>Version 2</body></html>`,
});
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);
}
});
});