mirror of
https://github.com/oven-sh/bun
synced 2026-02-10 10:58:56 +00:00
### What does this PR do?
Fixes a crash related to the dev server overwriting the uws user context
pointer when setting abort callback.
Adds support for `return new Response(<jsx />, { ... })` and `return
Response.render(...)` and `return Response.redirect(...)`:
- Created a `SSRResponse` class to handle this (see
`JSBakeResponse.{h,cpp}`)
- `SSRResponse` is designed to "fake" being a React component
- This is done in JSBakeResponse::create inside of
src/bun.js/bindings/JSBakeResponse.cpp
- And `src/js/builtins/BakeSSRResponse.ts` defines a `wrapComponent`
function which wraps
the passed in component (when doing `new Response(<jsx />, ...)`). It
does
this to throw an error (in redirect()/render() case) or return the
component.
- Created a `BakeAdditionsToGlobal` struct which contains some
properties
needed for this
- Added some of the properties we need to fake to BunBuiltinNames.h
(e.g.
`$$typeof`), the rationale behind this is that we couldn't use
`structure->addPropertyTransition` because JSBakeResponse is not a final
JSObject.
- When bake and server-side, bundler rewrites `Response ->
Bun.SSRResponse` (see `src/ast/P.zig` and `src/ast/visitExpr.zig`)
- Created a new WebCore body variant (`Render: struct { path: []const u8
}`)
- Created when `return Response.render(...)`
- When handled, it re-invokes dev server to render the new path
Enables server-side sourcemaps for the dev server:
- New source providers for server-side:
(`DevServerSourceProvider.{h,cpp}`)
- IncrementalGraph and SourceMapStore are updated to support this
There are numerous other stuff:
- allow `app` configuration from Bun.serve(...)
- fix errors stopping dev server
- fix use after free related to in
RequestContext.finishRunningErrorHandler
- Request.cookies
- Make `"use client";` components work
- Fix some bugs using `require(...)` in dev server
- Fix catch-all routes not working in the dev server
- Updates `findSourceMappingURL(...)` to use `std.mem.lastIndexOf(...)`
because
the sourcemap that should be used is the last one anyway
---------
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Claude Bot <claude-bot@bun.sh>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Alistair Smith <hi@alistair.sh>
417 lines
13 KiB
TypeScript
417 lines
13 KiB
TypeScript
import { expect } from "bun:test";
|
|
import { ByteBuffer } from "peechy";
|
|
import { decodeFallbackMessageContainer } from "../../../src/api/schema";
|
|
import { devTest } from "../bake-harness";
|
|
|
|
function getFallbackMessageContainer(text: string) {
|
|
const regex = /\s*\<script id="__bunfallback" type="binary\/peechy"\>([^\<]+)\<\/script\>/gm;
|
|
const match = regex.exec(text);
|
|
|
|
const encodedData = match![1].trim();
|
|
const binary_string = globalThis.atob(encodedData);
|
|
|
|
let len = binary_string.length;
|
|
let bytes = new Uint8Array(len);
|
|
for (var i = 0; i < len; i++) {
|
|
bytes[i] = binary_string.charCodeAt(i);
|
|
}
|
|
|
|
const fallback_message_container = decodeFallbackMessageContainer(new ByteBuffer(bytes));
|
|
return fallback_message_container;
|
|
}
|
|
|
|
// Test case 1: Simple page which throws an error when streaming = false
|
|
devTest("error thrown when streaming = false", {
|
|
framework: "react",
|
|
files: {
|
|
"pages/index.tsx": `
|
|
export const streaming = false;
|
|
export const mode = "ssr";
|
|
|
|
export default async function IndexPage() {
|
|
throw new Error('LMAO')
|
|
}
|
|
`,
|
|
},
|
|
async test(dev) {
|
|
const response = await dev.fetch("/");
|
|
expect(response.status).toBe(500);
|
|
},
|
|
});
|
|
|
|
// Test case 2: Simple page which throws an error when streaming = true
|
|
devTest("error thrown when streaming = true", {
|
|
framework: "react",
|
|
files: {
|
|
"pages/index.tsx": `
|
|
export const streaming = true;
|
|
export const mode = "ssr";
|
|
|
|
export default async function IndexPage() {
|
|
throw new Error('LMAO')
|
|
}
|
|
`,
|
|
},
|
|
async test(dev) {
|
|
const response = await dev.fetch("/");
|
|
|
|
// Streaming might return 200 and then error, or 500
|
|
const text = await response.text();
|
|
|
|
const fallback_message_container = getFallbackMessageContainer(text);
|
|
expect(fallback_message_container.problems?.exceptions[0].message).toContain("LMAO");
|
|
},
|
|
});
|
|
|
|
// Test case 3: Using Response.render() with streaming = true (should error)
|
|
devTest("Response.render() with streaming = true should error", {
|
|
framework: "react",
|
|
files: {
|
|
"pages/index.tsx": `
|
|
export const streaming = true;
|
|
export const mode = "ssr";
|
|
|
|
export default async function IndexPage() {
|
|
return Response.render("/other");
|
|
}
|
|
`,
|
|
"pages/other.tsx": `
|
|
export default function OtherPage() {
|
|
return <h1>Other Page</h1>;
|
|
}
|
|
`,
|
|
},
|
|
async test(dev) {
|
|
const response = await dev.fetch("/");
|
|
const text = await response.text();
|
|
// Response.render() is not available during streaming
|
|
expect(text.toLowerCase()).toContain("error");
|
|
},
|
|
});
|
|
|
|
// Test case 4: Using new Response(<jsx />, { ... }) with custom headers
|
|
devTest("new Response with JSX and custom headers", {
|
|
framework: "react",
|
|
files: {
|
|
"pages/index.tsx": `
|
|
export const streaming = false;
|
|
export const mode = "ssr";
|
|
|
|
export default async function IndexPage() {
|
|
return new Response(<h1>Hello World</h1>, {
|
|
status: 201,
|
|
headers: {
|
|
"X-Custom-Header": "test-value",
|
|
"X-Another-Header": "another-value"
|
|
}
|
|
});
|
|
}
|
|
`,
|
|
},
|
|
async test(dev) {
|
|
const response = await dev.fetch("/");
|
|
expect(response.status).toBe(201);
|
|
expect(response.headers.get("X-Custom-Header")).toBe("test-value");
|
|
expect(response.headers.get("X-Another-Header")).toBe("another-value");
|
|
const text = await response.text();
|
|
expect(text).toContain("<h1>Hello World</h1>");
|
|
},
|
|
});
|
|
|
|
// Test case 5: new Response with JSX when streaming = true (should error)
|
|
devTest("new Response with JSX when streaming = true should error", {
|
|
framework: "react",
|
|
files: {
|
|
"pages/index.tsx": `
|
|
export const streaming = true;
|
|
export const mode = "ssr";
|
|
|
|
export default async function IndexPage() {
|
|
return new Response(<h1>Hello World</h1>, {
|
|
status: 201,
|
|
headers: {
|
|
"X-Custom-Header": "test-value"
|
|
}
|
|
});
|
|
}
|
|
`,
|
|
},
|
|
async test(dev) {
|
|
const response = await dev.fetch("/");
|
|
const text = await response.text();
|
|
const fallback_message_container = getFallbackMessageContainer(text);
|
|
expect(fallback_message_container.problems?.exceptions[0].message).toContain(
|
|
'"new Response(<jsx />, { ... })" is not available when `export const streaming = true`',
|
|
);
|
|
},
|
|
});
|
|
|
|
// Test case 6: Response.redirect() - content matching
|
|
devTest("Response.redirect() - content matching", {
|
|
framework: "react",
|
|
files: {
|
|
"pages/index.tsx": `
|
|
export const streaming = false;
|
|
export const mode = "ssr";
|
|
|
|
export default async function IndexPage() {
|
|
return Response.redirect("/lmao");
|
|
}
|
|
`,
|
|
"pages/lmao.tsx": `
|
|
export default function LmaoPage() {
|
|
return <h1>LMAO Page</h1>;
|
|
}
|
|
`,
|
|
},
|
|
async test(dev) {
|
|
// Test with redirect following (default behavior)
|
|
const response = await dev.fetch("/");
|
|
expect(response.status).toBe(200); // After following redirect
|
|
const text = await response.text();
|
|
expect(text).toContain("<h1>LMAO Page</h1>");
|
|
},
|
|
});
|
|
|
|
// Test case 7: Response.redirect() - HTTP redirect status/headers
|
|
devTest("Response.redirect() - HTTP redirect status and headers", {
|
|
framework: "react",
|
|
files: {
|
|
"pages/index.tsx": `
|
|
export const streaming = false;
|
|
export const mode = "ssr";
|
|
|
|
export default async function IndexPage() {
|
|
return Response.redirect("/lmao");
|
|
}
|
|
`,
|
|
"pages/lmao.tsx": `
|
|
export default function LmaoPage() {
|
|
return <h1>LMAO Page</h1>;
|
|
}
|
|
`,
|
|
},
|
|
async test(dev) {
|
|
// Test without following redirects
|
|
const response = await dev.fetch("/", { redirect: "manual" });
|
|
expect(response.status).toBe(302); // Default redirect status
|
|
expect(response.headers.get("Location")).toBe("/lmao");
|
|
},
|
|
});
|
|
|
|
// Test case 8: Response.redirect() when streaming = true (should error)
|
|
devTest("Response.redirect() when streaming = true should error", {
|
|
framework: "react",
|
|
files: {
|
|
"pages/index.tsx": `
|
|
export const streaming = true;
|
|
export const mode = "ssr";
|
|
|
|
export default async function IndexPage() {
|
|
return Response.redirect("/lmao");
|
|
}
|
|
`,
|
|
"pages/lmao.tsx": `
|
|
export default function LmaoPage() {
|
|
return <h1>LMAO Page</h1>;
|
|
}
|
|
`,
|
|
},
|
|
async test(dev) {
|
|
const response = await dev.fetch("/");
|
|
const text = await response.text();
|
|
// Response.redirect() during streaming should error
|
|
expect(text.toLowerCase()).toContain("error");
|
|
},
|
|
});
|
|
|
|
// Test case 9: Response.render() acts like Next.js rewrite
|
|
devTest("Response.render() works like Next.js rewrite", {
|
|
framework: "react",
|
|
files: {
|
|
"pages/index.tsx": `
|
|
export const streaming = false;
|
|
export const mode = "ssr";
|
|
|
|
export default async function IndexPage() {
|
|
return Response.render("/new-route");
|
|
}
|
|
`,
|
|
"pages/new-route.tsx": `
|
|
export default function NewRoutePage() {
|
|
return <h1>New Route Content</h1>;
|
|
}
|
|
`,
|
|
},
|
|
async test(dev) {
|
|
const response = await dev.fetch("/");
|
|
expect(response.status).toBe(200);
|
|
const text = await response.text();
|
|
expect(text).toContain("<h1>New Route Content</h1>");
|
|
|
|
// Verify it's a rewrite, not a redirect
|
|
expect(response.url).toContain("/"); // URL should remain the original
|
|
},
|
|
});
|
|
|
|
// Test case 10: Response.render() with dynamic route
|
|
devTest("Response.render() with dynamic route", {
|
|
framework: "react",
|
|
files: {
|
|
"pages/product.tsx": `
|
|
export const streaming = false;
|
|
export const mode = "ssr";
|
|
|
|
export default async function ProductPage() {
|
|
return Response.render("/category/electronics");
|
|
}
|
|
`,
|
|
"pages/category/[slug].tsx": `
|
|
export default function CategoryPage({ params }) {
|
|
return <h1>Category: {params.slug}</h1>;
|
|
}
|
|
`,
|
|
},
|
|
async test(dev) {
|
|
const response = await dev.fetch("/product");
|
|
expect(response.status).toBe(200);
|
|
const text = await response.text();
|
|
expect(text).toContain("<h1>Category: <!-- -->electronics</h1>");
|
|
},
|
|
});
|
|
|
|
// Test case 12: Concurrent requests with different Response options (AsyncLocalStorage isolation)
|
|
devTest("concurrent requests maintain isolated Response options via AsyncLocalStorage", {
|
|
framework: "react",
|
|
files: {
|
|
"pages/request-a.tsx": `
|
|
export const streaming = false;
|
|
export const mode = "ssr";
|
|
|
|
export default async function RequestA() {
|
|
// Simulate some async work to increase chance of overlapping
|
|
await new Promise(resolve => setTimeout(resolve, 10));
|
|
|
|
return new Response(<h1>Request A</h1>, {
|
|
status: 201,
|
|
headers: {
|
|
"X-Request-Id": "request-a",
|
|
"X-Custom-A": "value-a"
|
|
}
|
|
});
|
|
}
|
|
`,
|
|
"pages/request-b.tsx": `
|
|
export const streaming = false;
|
|
export const mode = "ssr";
|
|
|
|
export default async function RequestB() {
|
|
// Different timing to create overlapping requests
|
|
await new Promise(resolve => setTimeout(resolve, 5));
|
|
|
|
return new Response(<h2>Request B</h2>, {
|
|
status: 202,
|
|
headers: {
|
|
"X-Request-Id": "request-b",
|
|
"X-Custom-B": "value-b"
|
|
}
|
|
});
|
|
}
|
|
`,
|
|
"pages/request-c.tsx": `
|
|
export const streaming = false;
|
|
export const mode = "ssr";
|
|
|
|
export default async function RequestC() {
|
|
// No delay for this one
|
|
return new Response(<h3>Request C</h3>, {
|
|
status: 203,
|
|
headers: {
|
|
"X-Request-Id": "request-c",
|
|
"X-Custom-C": "value-c"
|
|
}
|
|
});
|
|
}
|
|
`,
|
|
},
|
|
async test(dev) {
|
|
// Launch multiple concurrent requests
|
|
const promises: Promise<any>[] = [];
|
|
const requestCount = 5; // Multiple iterations to increase chance of catching issues
|
|
|
|
for (let i = 0; i < requestCount; i++) {
|
|
console.log("Iteration", i);
|
|
// Interleave different request types
|
|
promises.push(
|
|
dev.fetch("/request-a").then(async res => ({
|
|
path: "/request-a",
|
|
status: res.status,
|
|
headers: {
|
|
requestId: res.headers.get("X-Request-Id"),
|
|
customA: res.headers.get("X-Custom-A"),
|
|
customB: res.headers.get("X-Custom-B"),
|
|
customC: res.headers.get("X-Custom-C"),
|
|
},
|
|
text: await res.text(),
|
|
})),
|
|
);
|
|
|
|
promises.push(
|
|
dev.fetch("/request-b").then(async res => ({
|
|
path: "/request-b",
|
|
status: res.status,
|
|
headers: {
|
|
requestId: res.headers.get("X-Request-Id"),
|
|
customA: res.headers.get("X-Custom-A"),
|
|
customB: res.headers.get("X-Custom-B"),
|
|
customC: res.headers.get("X-Custom-C"),
|
|
},
|
|
text: await res.text(),
|
|
})),
|
|
);
|
|
|
|
promises.push(
|
|
dev.fetch("/request-c").then(async res => ({
|
|
path: "/request-c",
|
|
status: res.status,
|
|
headers: {
|
|
requestId: res.headers.get("X-Request-Id"),
|
|
customA: res.headers.get("X-Custom-A"),
|
|
customB: res.headers.get("X-Custom-B"),
|
|
customC: res.headers.get("X-Custom-C"),
|
|
},
|
|
text: await res.text(),
|
|
})),
|
|
);
|
|
}
|
|
|
|
const results = await Promise.all(promises);
|
|
|
|
// Verify each request maintained its own isolated Response options
|
|
for (const result of results) {
|
|
if (result.path === "/request-a") {
|
|
expect(result.status).toBe(201);
|
|
expect(result.headers.requestId).toBe("request-a");
|
|
expect(result.headers.customA).toBe("value-a");
|
|
expect(result.headers.customB).toBeNull(); // Should not leak from request-b
|
|
expect(result.headers.customC).toBeNull(); // Should not leak from request-c
|
|
expect(result.text).toContain("<h1>Request A</h1>");
|
|
} else if (result.path === "/request-b") {
|
|
expect(result.status).toBe(202);
|
|
expect(result.headers.requestId).toBe("request-b");
|
|
expect(result.headers.customA).toBeNull(); // Should not leak from request-a
|
|
expect(result.headers.customB).toBe("value-b");
|
|
expect(result.headers.customC).toBeNull(); // Should not leak from request-c
|
|
expect(result.text).toContain("<h2>Request B</h2>");
|
|
} else if (result.path === "/request-c") {
|
|
expect(result.status).toBe(203);
|
|
expect(result.headers.requestId).toBe("request-c");
|
|
expect(result.headers.customA).toBeNull(); // Should not leak from request-a
|
|
expect(result.headers.customB).toBeNull(); // Should not leak from request-b
|
|
expect(result.headers.customC).toBe("value-c");
|
|
expect(result.text).toContain("<h3>Request C</h3>");
|
|
}
|
|
}
|
|
},
|
|
});
|