Compare commits

...

6 Commits

Author SHA1 Message Date
RiskyMH
6e1c787546 . 2025-08-06 22:12:13 +10:00
RiskyMH
73415e1154 not as sus 2025-08-06 22:09:56 +10:00
RiskyMH
7281dfd0ed cache less here
it does add some inefficiency, but that can be dealt with later
2025-08-06 20:12:35 +10:00
RiskyMH
9ce27b92ec fix many routes with same html bundle 2025-08-06 19:58:01 +10:00
RiskyMH
229facad76 fix which one is more likely to occure 2025-08-06 19:37:58 +10:00
RiskyMH
0587c598b7 support new Response(html) for custom headers/status in Bun.serve() routes 2025-08-06 19:34:58 +10:00
9 changed files with 449 additions and 0 deletions

View File

@@ -297,6 +297,9 @@ pub const Stdio = union(enum) {
},
.Blob, .WTFStringImpl, .InternalBlob => unreachable, // handled above.
.HTMLBundle => {
return globalThis.throwInvalidArguments("HTMLBundle cannot be used as stdin", .{});
},
.Locked => {
if (is_sync) {
return globalThis.throwInvalidArguments("ReadableStream cannot be used in sync mode", .{});

View File

@@ -198,6 +198,37 @@ pub const AnyRoute = union(enum) {
}
}
if (argument.as(jsc.WebCore.Response)) |response| {
if (response.body.value == .HTMLBundle) {
const html_bundle = response.body.value.HTMLBundle;
const needs_custom = response.init.headers != null or response.statusCode() != 200;
if (needs_custom) {
var route = HTMLBundle.Route.init(html_bundle);
if (response.init.headers) |headers| {
route.data.custom_headers = bun.http.Headers.from(headers, bun.default_allocator, .{}) catch bun.outOfMemory();
}
const status = response.statusCode();
if (status != 200) {
route.data.custom_status = status;
}
return .{ .html = route };
} else {
const entry = init_ctx.dedupe_html_bundle_map.getOrPut(html_bundle) catch bun.outOfMemory();
if (!entry.found_existing) {
entry.value_ptr.* = HTMLBundle.Route.init(html_bundle);
return .{ .html = entry.value_ptr.* };
} else {
return .{ .html = entry.value_ptr.dupeRef() };
}
}
}
}
if (try bundledHTMLManifestFromJS(argument, init_ctx)) |html_route| {
return html_route;
}

View File

@@ -69,11 +69,18 @@ pub const Route = struct {
method: bun.http.Method.Set,
} = .any,
/// Custom headers & status code to apply to the HTML response
custom_headers: ?bun.http.Headers = null,
custom_status: ?u16 = null,
pub fn memoryCost(this: *const Route) usize {
var cost: usize = 0;
cost += @sizeOf(Route);
cost += this.pending_responses.items.len * @sizeOf(PendingResponse);
cost += this.state.memoryCost();
if (this.custom_headers) |headers| {
cost += headers.memoryCost();
}
return cost;
}
@@ -92,6 +99,9 @@ pub const Route = struct {
this.pending_responses.deinit(bun.default_allocator);
this.bundle.deref();
this.state.deinit();
if (this.custom_headers) |*headers| {
headers.deinit();
}
bun.destroy(this);
}
@@ -415,6 +425,19 @@ pub const Route = struct {
const html_route: *StaticRoute = this_html_route orelse @panic("Internal assertion failure: HTML entry point not found in HTMLBundle.");
const html_route_clone = html_route.clone(globalThis) catch bun.outOfMemory();
if (this.custom_headers) |*custom_headers| {
const entries = custom_headers.entries.slice();
const names = entries.items(.name);
const values = entries.items(.value);
for (names, values) |name_ptr, value_ptr| {
html_route_clone.headers.append(custom_headers.asStr(name_ptr), custom_headers.asStr(value_ptr)) catch bun.outOfMemory();
}
}
if (this.custom_status) |status| {
html_route_clone.status_code = status;
}
this.state = .{ .html = html_route_clone };
if (!(server.reloadStaticRoutes() catch bun.outOfMemory())) {

View File

@@ -1439,6 +1439,12 @@ pub fn NewRequestContext(comptime ssl_enabled: bool, comptime debug_mode: bool,
resp.writeHeaderInt("content-length", 0);
this.endWithoutBody(this.shouldCloseConnection());
},
.HTMLBundle => {
// HTMLBundle cannot be used in HEAD response
this.renderMetadata();
resp.writeHeaderInt("content-length", 0);
this.endWithoutBody(this.shouldCloseConnection());
},
}
}

View File

@@ -1325,6 +1325,10 @@ pub fn writeFileInternal(globalThis: *jsc.JSGlobalObject, path_or_blob_: *PathOr
=> {
break :brk response.body.use();
},
.HTMLBundle => {
destination_blob.detach();
return globalThis.throwInvalidArguments("HTMLBundle cannot be written to a file", .{});
},
.Error => |*err_ref| {
destination_blob.detach();
_ = response.body.value.use();
@@ -1386,6 +1390,10 @@ pub fn writeFileInternal(globalThis: *jsc.JSGlobalObject, path_or_blob_: *PathOr
=> {
break :brk request.body.value.use();
},
.HTMLBundle => {
destination_blob.detach();
return globalThis.throwInvalidArguments("HTMLBundle cannot be written to a file", .{});
},
.Error => |*err_ref| {
destination_blob.detach();
_ = request.body.value.use();

View File

@@ -268,6 +268,7 @@ pub const Value = union(Tag) {
Empty,
Error: ValueError,
Null,
HTMLBundle: *bun.jsc.API.HTMLBundle,
// We may not have all the data yet
// So we can't know for sure if it's empty or not
@@ -280,6 +281,7 @@ pub const Value = union(Tag) {
.Blob => this.Blob.size == 0,
.WTFStringImpl => this.WTFStringImpl.length() == 0,
.Error, .Locked => false,
.HTMLBundle => false,
};
}
@@ -440,6 +442,7 @@ pub const Value = union(Tag) {
Empty,
Error,
Null,
HTMLBundle,
};
// pub const empty = Value{ .Empty = {} };
@@ -523,6 +526,9 @@ pub const Value = union(Tag) {
// TODO: handle error properly
return jsc.WebCore.ReadableStream.empty(globalThis);
},
.HTMLBundle => {
return globalThis.throwInvalidArguments("HTMLBundle cannot be used as a Response body", .{});
},
}
}
@@ -598,6 +604,13 @@ pub const Value = union(Tag) {
}
}
if (value.as(bun.jsc.API.HTMLBundle)) |html_bundle| {
html_bundle.ref();
return Body.Value{
.HTMLBundle = html_bundle,
};
}
value.ensureStillAlive();
if (try jsc.WebCore.ReadableStream.fromJS(value, globalThis)) |readable| {
@@ -858,6 +871,7 @@ pub const Value = union(Tag) {
},
// .InlineBlob => .{ .InlineBlob = this.InlineBlob },
.Locked => this.Locked.toAnyBlobAllowPromise() orelse AnyBlob{ .Blob = .{} },
.HTMLBundle => .{ .Blob = Blob.initEmpty(undefined) },
else => .{ .Blob = Blob.initEmpty(undefined) },
};
@@ -875,6 +889,7 @@ pub const Value = union(Tag) {
.WTFStringImpl => .{ .WTFStringImpl = this.WTFStringImpl },
// .InlineBlob => .{ .InlineBlob = this.InlineBlob },
.Locked => this.Locked.toAnyBlobAllowPromise() orelse AnyBlob{ .Blob = .{} },
.HTMLBundle => .{ .Blob = Blob.initEmpty(undefined) },
else => .{ .Blob = Blob.initEmpty(undefined) },
};
@@ -963,6 +978,11 @@ pub const Value = union(Tag) {
if (tag == .Error) {
this.Error.deinit();
}
if (tag == .HTMLBundle) {
this.HTMLBundle.deref();
this.* = Value{ .Null = {} };
}
}
pub fn tee(this: *Value, globalThis: *jsc.JSGlobalObject) bun.JSError!Value {
@@ -1093,6 +1113,10 @@ pub fn Mixin(comptime Type: type) type {
return handleBodyAlreadyUsed(globalObject);
}
if (value.* == .HTMLBundle) {
return globalObject.throwInvalidArguments("HTMLBundle cannot be used as a Response body", .{});
}
if (value.* == .Locked) {
if (value.Locked.action != .none or value.Locked.isDisturbed(Type, globalObject, callframe.this())) {
return handleBodyAlreadyUsed(globalObject);
@@ -1149,6 +1173,10 @@ pub fn Mixin(comptime Type: type) type {
return handleBodyAlreadyUsed(globalObject);
}
if (value.* == .HTMLBundle) {
return globalObject.throwInvalidArguments("HTMLBundle cannot be used as a Response body", .{});
}
if (value.* == .Locked) {
if (value.Locked.action != .none or value.Locked.isDisturbed(Type, globalObject, callframe.this())) {
return handleBodyAlreadyUsed(globalObject);
@@ -1176,6 +1204,10 @@ pub fn Mixin(comptime Type: type) type {
return handleBodyAlreadyUsed(globalObject);
}
if (value.* == .HTMLBundle) {
return globalObject.throwInvalidArguments("HTMLBundle cannot be used as a Response body", .{});
}
if (value.* == .Locked) {
if (value.Locked.action != .none or value.Locked.isDisturbed(Type, globalObject, callframe.this())) {
return handleBodyAlreadyUsed(globalObject);
@@ -1200,6 +1232,10 @@ pub fn Mixin(comptime Type: type) type {
return handleBodyAlreadyUsed(globalObject);
}
if (value.* == .HTMLBundle) {
return globalObject.throwInvalidArguments("HTMLBundle cannot be used as a Response body", .{});
}
if (value.* == .Locked) {
if (value.Locked.action != .none or value.Locked.isDisturbed(Type, globalObject, callframe.this())) {
return handleBodyAlreadyUsed(globalObject);
@@ -1222,6 +1258,10 @@ pub fn Mixin(comptime Type: type) type {
return handleBodyAlreadyUsed(globalObject);
}
if (value.* == .HTMLBundle) {
return globalObject.throwInvalidArguments("HTMLBundle cannot be used as a Response body", .{});
}
if (value.* == .Locked) {
if (value.Locked.action != .none or value.Locked.isDisturbed(Type, globalObject, callframe.this())) {
return handleBodyAlreadyUsed(globalObject);
@@ -1273,6 +1313,10 @@ pub fn Mixin(comptime Type: type) type {
return handleBodyAlreadyUsed(globalObject);
}
if (value.* == .HTMLBundle) {
return globalObject.throwInvalidArguments("HTMLBundle cannot be used as a Response body", .{});
}
if (value.* == .Locked) {
if (value.Locked.action != .none or
((this_value != .zero and value.Locked.isDisturbed(Type, globalObject, this_value)) or
@@ -1414,6 +1458,9 @@ pub const ValueBufferer = struct {
.Locked => {
try sink.bufferLockedBodyValue(value);
},
.HTMLBundle => {
return error.UnsupportedBodyType;
},
}
}

View File

@@ -105,6 +105,7 @@ pub export fn jsFunctionGetCompleteRequestOrResponseBodyValueAsArrayBuffer(globa
return any_blob.toArrayBufferTransfer(globalObject) catch return .zero;
},
.Error, .Locked => return .js_undefined,
.HTMLBundle => return .js_undefined,
}
}

View File

@@ -717,6 +717,13 @@ pub const WriteFileWaitFromLockedValueTask = struct {
value.Locked.onReceiveValue = thenWrap;
value.Locked.task = this;
},
.HTMLBundle => {
file_blob.detach();
_ = value.use();
this.promise.deinit();
bun.destroy(this);
promise.reject(globalThis, ZigString.init("HTMLBundle cannot be written to a file").toErrorInstance(globalThis));
},
}
}
};

View File

@@ -0,0 +1,323 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tempDirWithFiles } from "harness";
test("static route with new Response(html) and custom headers/status", async () => {
const dir = tempDirWithFiles("html-response-static", {
"index.html": /*html*/ `<!DOCTYPE html>
<html>
<head>
<title>Test Page</title>
<script type="module" src="./app.ts"></script>
</head>
<body>
<h1>Hello from HTMLBundle</h1>
</body>
</html>`,
"app.ts": /*ts*/ `console.log("App loaded");`,
"server.ts": /*ts*/ `
import html from "./index.html";
const server = Bun.serve({
port: 0,
development: false,
routes: {
"/": new Response(html, {
status: 201,
headers: {
"X-Custom": "custom-value",
"X-Test": "test-value"
}
})
}
});
console.log(server.port);
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "server.ts"],
env: bunEnv,
cwd: dir,
stdout: "pipe",
});
const reader = proc.stdout.getReader();
const { value } = await reader.read();
const port = parseInt(new TextDecoder().decode(value).trim());
const response = await fetch(`http://localhost:${port}/`);
expect(response.status).toBe(201);
expect(response.headers.get("X-Custom")).toBe("custom-value");
expect(response.headers.get("X-Test")).toBe("test-value");
expect(response.headers.get("Content-Type")).toBe("text/html;charset=utf-8");
const text = await response.text();
expect(text).toContain("Test Page");
expect(text).toContain("Hello from HTMLBundle");
expect(text).toMatch(/src="[^"]+\.js"/);
proc.kill();
});
// todo: add build support for this
test.each(["runtime" /*"build"*/])("many static routes with custom headers/status (%s)", async runtime => {
const dir = tempDirWithFiles("html-response-static", {
"index.html": /*html*/ `<!DOCTYPE html>
<html>
<head>
<title>Test Page</title>
<script type="module" src="./app.ts"></script>
</head>
<body>
<h1>Hello from HTMLBundle</h1>
</body>
</html>`,
"hello.html": /*html*/ `<!DOCTYPE html>
<html>
<head>
<title>Hello Page</title>
</head>
<body>
<h1>Hello from HTMLBundle</h1>
</body>
</html>`,
"app.ts": /*ts*/ `console.log("App loaded");`,
"server.ts": /*ts*/ `
import html from "./index.html";
import hello from "./hello.html";
const server = Bun.serve({
port: 0,
development: false,
routes: {
"/": new Response(html, {
status: 201,
headers: {
"X-Custom": "custom-value",
"X-Test": "test-value",
}
}),
"/home": new Response(html),
"/haha": new Response(html, {status: 400}),
"/index.html": html,
"/tea": {
GET: new Response(html, {status: 418}),
POST: () => new Response("Teapot!!!"),
},
"/hello": new Response(hello),
"/*": new Response(html, {
status: 404,
})
}
});
console.log(server.port);
`,
});
let proc: Bun.Subprocess<"pipe", "pipe", "pipe">;
if (runtime === "runtime") {
proc = Bun.spawn({
cmd: [bunExe(), "server.ts"],
env: bunEnv,
cwd: dir,
stdout: "pipe",
});
} else {
const buildProc = Bun.spawn({
cmd: [bunExe(), "build", "server.ts", "--outdir", "dist", "--target", "bun", "--splitting"],
env: bunEnv,
cwd: dir,
});
await buildProc.exited;
buildProc.kill();
proc = Bun.spawn({
cmd: [bunExe(), "server.js"],
env: bunEnv,
cwd: dir + "/dist",
stdout: "pipe",
});
}
const reader = proc.stdout.getReader();
const { value } = await reader.read();
const port = parseInt(new TextDecoder().decode(value).trim());
{
const response = await fetch(`http://localhost:${port}/`);
expect(response.status).toBe(201);
expect(response.headers.get("X-Custom")).toBe("custom-value");
expect(response.headers.get("X-Test")).toBe("test-value");
expect(response.headers.get("Content-Type")).toBe("text/html;charset=utf-8");
const text = await response.text();
expect(text).toContain("Test Page");
expect(text).toContain("Hello from HTMLBundle");
expect(text).toMatch(/src="[^"]+\.js"/);
}
{
const response = await fetch(`http://localhost:${port}/home`);
expect(response.status).toBe(200);
expect(response.headers.get("X-Custom")).not.toBe("custom-value");
expect(response.headers.get("X-Test")).not.toBe("test-value");
expect(response.headers.get("Content-Type")).toBe("text/html;charset=utf-8");
const text = await response.text();
expect(text).toContain("Test Page");
expect(text).toContain("Hello from HTMLBundle");
expect(text).toMatch(/src="[^"]+\.js"/);
}
{
const response = await fetch(`http://localhost:${port}/index.html`);
expect(response.status).toBe(200);
expect(response.headers.get("X-Custom")).not.toBe("custom-value");
expect(response.headers.get("X-Test")).not.toBe("test-value");
expect(response.headers.get("Content-Type")).toBe("text/html;charset=utf-8");
const text = await response.text();
expect(text).toContain("Test Page");
expect(text).toContain("Hello from HTMLBundle");
expect(text).toMatch(/src="[^"]+\.js"/);
}
{
const response = await fetch(`http://localhost:${port}/haha`);
expect(response.status).toBe(400);
expect(response.headers.get("X-Custom")).not.toBe("custom-value");
expect(response.headers.get("X-Test")).not.toBe("test-value");
expect(response.headers.get("Content-Type")).toBe("text/html;charset=utf-8");
const text = await response.text();
expect(text).toContain("Test Page");
expect(text).toContain("Hello from HTMLBundle");
expect(text).toMatch(/src="[^"]+\.js"/);
}
{
const response = await fetch(`http://localhost:${port}/tea`, {
method: "GET",
});
expect(response.status).toBe(418);
expect(response.headers.get("X-Custom")).not.toBe("custom-value");
expect(response.headers.get("X-Test")).not.toBe("test-value");
expect(response.headers.get("Content-Type")).toBe("text/html;charset=utf-8");
const text = await response.text();
expect(text).toContain("Test Page");
expect(text).toContain("Hello from HTMLBundle");
expect(text).toMatch(/src="[^"]+\.js"/);
}
{
const response = await fetch(`http://localhost:${port}/tea`, {
method: "POST",
});
expect(response.status).toBe(200);
expect(response.headers.get("X-Custom")).not.toBe("custom-value");
expect(response.headers.get("X-Test")).not.toBe("test-value");
expect(response.headers.get("Content-Type")).toBe("text/plain;charset=utf-8");
const text = await response.text();
expect(text).toBe("Teapot!!!");
}
{
const response = await fetch(`http://localhost:${port}/hello`);
expect(response.status).toBe(200);
expect(response.headers.get("X-Custom")).not.toBe("custom-value");
expect(response.headers.get("X-Test")).not.toBe("test-value");
expect(response.headers.get("Content-Type")).toBe("text/html;charset=utf-8");
const text = await response.text();
expect(text).toContain("Hello Page");
expect(text).toContain("Hello from HTMLBundle");
expect(text).toMatch(/src="[^"]+\.js"/);
}
{
const response = await fetch(`http://localhost:${port}/not-found`);
expect(response.status).toBe(404);
expect(response.headers.get("X-Custom")).not.toBe("custom-value");
expect(response.headers.get("X-Test")).not.toBe("test-value");
expect(response.headers.get("Content-Type")).toBe("text/html;charset=utf-8");
const text = await response.text();
expect(text).toContain("Test Page");
expect(text).toContain("Hello from HTMLBundle");
expect(text).toMatch(/src="[^"]+\.js"/);
}
proc.kill();
});
test("HTMLBundle in Response error conditions", async () => {
const dir = tempDirWithFiles("html-response-errors", {
"index.html": /*html*/ `<\!DOCTYPE html>
<html>
<head>
<title>Test Page</title>
</head>
<body>
<h1>Error Test</h1>
</body>
</html>`,
"test.ts": `
import html from "./index.html";
const response = new Response(html);
const tests = [
() => response.text(),
() => response.blob(),
() => response.json(),
() => response.arrayBuffer(),
() => response.formData(),
() => Bun.write("output.html", response.body),
() => Bun.spawn({
cmd: ["echo", "test"],
stdin: response.body
})
];
for (let i = 0; i < tests.length; i++) {
try {
const result = await tests[i]();
console.log(\`FAIL: Test \${i} should have thrown\`);
} catch (e) {
if (e.toString().includes("HTMLBundle")) {
console.log(\`PASS: Test \${i} threw as expected\`);
} else {
console.log(\`HALF PASS: Test \${i} should have thrown better error message\`);
}
}
}
`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test.ts"],
env: bunEnv,
cwd: dir,
stdout: "pipe",
});
expect(await proc.stdout.text()).toMatchInlineSnapshot(`
"PASS: Test 0 threw as expected
PASS: Test 1 threw as expected
PASS: Test 2 threw as expected
PASS: Test 3 threw as expected
PASS: Test 4 threw as expected
PASS: Test 5 threw as expected
PASS: Test 6 threw as expected
"
`);
});