mirror of
https://github.com/oven-sh/bun
synced 2026-03-01 13:01:06 +01: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>
241 lines
8.3 KiB
TypeScript
241 lines
8.3 KiB
TypeScript
import type { Bake } from "bun";
|
|
import { renderToHtml, renderToStaticHtml } from "bun-framework-react/ssr.tsx" with { bunBakeGraph: "ssr" };
|
|
import { serverManifest } from "bun:bake/server";
|
|
import type { AsyncLocalStorage } from "node:async_hooks";
|
|
import { PassThrough } from "node:stream";
|
|
import { renderToPipeableStream } from "react-server-dom-bun/server.node.unbundled.js";
|
|
import type { RequestContext } from "../hmr-runtime-server";
|
|
|
|
function assertReactComponent(Component: any) {
|
|
if (typeof Component !== "function") {
|
|
console.log("Expected a React component", Component, typeof Component);
|
|
throw new Error("Expected a React component");
|
|
}
|
|
}
|
|
|
|
// This function converts the route information into a React component tree.
|
|
function getPage(meta: Bake.RouteMetadata & { request?: Request }, styles: readonly string[]) {
|
|
let route = component(meta.pageModule, meta.params, meta.request);
|
|
for (const layout of meta.layouts) {
|
|
const Layout = layout.default;
|
|
if (import.meta.env.DEV) assertReactComponent(Layout);
|
|
route = <Layout params={meta.params}>{route}</Layout>;
|
|
}
|
|
|
|
return (
|
|
<html lang="en">
|
|
<head>
|
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
<title>Bun + React Server Components</title>
|
|
{styles.map(url => (
|
|
// `data-bake-ssr` is used on the client-side to construct the styles array.
|
|
<link key={url} rel="stylesheet" href={url} data-bake-ssr />
|
|
))}
|
|
</head>
|
|
<body>{route}</body>
|
|
</html>
|
|
);
|
|
}
|
|
|
|
function component(mod: any, params: Record<string, string> | null, request?: Request) {
|
|
const Page = mod.default;
|
|
let props = {};
|
|
if (import.meta.env.DEV) assertReactComponent(Page);
|
|
|
|
let method;
|
|
if ((import.meta.env.DEV || import.meta.env.STATIC) && (method = mod.getStaticProps)) {
|
|
if (mod.getServerSideProps) {
|
|
throw new Error("Cannot have both getStaticProps and getServerSideProps");
|
|
}
|
|
|
|
props = method();
|
|
}
|
|
|
|
// Pass request prop if mode is 'ssr'
|
|
if (mod.mode === "ssr" && request) {
|
|
props.request = request;
|
|
}
|
|
|
|
return <Page params={params} {...props} />;
|
|
}
|
|
|
|
// `server.tsx` exports a function to be used for handling user routes. It takes
|
|
// in the Request object, the route's module, extra route metadata, and the AsyncLocalStorage instance.
|
|
export async function render(
|
|
request: Request,
|
|
meta: Bake.RouteMetadata,
|
|
als?: AsyncLocalStorage<RequestContext>,
|
|
): Promise<Response> {
|
|
// The framework generally has two rendering modes.
|
|
// - Standard browser navigation
|
|
// - Client-side navigation
|
|
//
|
|
// For React, this means calling `renderToReadableStream` to generate the RSC
|
|
// payload, but only generate HTML for the former of these rendering modes.
|
|
// This is signaled by `client.tsx` via the `Accept` header.
|
|
const skipSSR = request.headers.get("Accept")?.includes("text/x-component");
|
|
|
|
// Check if the page module has a streaming export, default to false
|
|
const streaming = meta.pageModule.streaming ?? false;
|
|
|
|
// Do not render <link> tags if the request is skipping SSR.
|
|
const page = getPage(meta, skipSSR ? [] : meta.styles);
|
|
|
|
// TODO: write a lightweight version of PassThrough
|
|
const rscPayload = new PassThrough();
|
|
|
|
if (skipSSR) {
|
|
// "client.tsx" reads the start of the response to determine the
|
|
// CSS files to load. The styles are loaded before the new page
|
|
// is presented, to avoid a flash of unstyled content.
|
|
const int = Buffer.allocUnsafe(4);
|
|
const str = meta.styles.join("\n");
|
|
int.writeUInt32LE(str.length, 0);
|
|
rscPayload.write(int);
|
|
rscPayload.write(str);
|
|
}
|
|
|
|
// This renders Server Components to a ReadableStream "RSC Payload"
|
|
let pipe;
|
|
const signal: MiniAbortSignal = { aborted: undefined, abort: null! };
|
|
({ pipe, abort: signal.abort } = renderToPipeableStream(page, serverManifest, {
|
|
onError: err => {
|
|
if (signal.aborted) return;
|
|
|
|
// Mark as aborted and call the abort function
|
|
signal.aborted = err;
|
|
// @ts-expect-error
|
|
signal.abort(err);
|
|
rscPayload.destroy(err);
|
|
},
|
|
filterStackFrame: () => false,
|
|
}));
|
|
pipe(rscPayload);
|
|
|
|
if (skipSSR) {
|
|
const responseOptions = als?.getStore()?.responseOptions || {};
|
|
return new Response(rscPayload as any, {
|
|
status: 200,
|
|
headers: { "Content-Type": "text/x-component" },
|
|
...responseOptions,
|
|
});
|
|
}
|
|
|
|
// The RSC payload is rendered into HTML
|
|
if (streaming) {
|
|
const responseOptions = als?.getStore()?.responseOptions || {};
|
|
if (als) {
|
|
const state = als.getStore();
|
|
if (state) state.streamingStarted = true;
|
|
}
|
|
// Stream the response as before
|
|
return new Response(renderToHtml(rscPayload, meta.modules, signal), {
|
|
headers: {
|
|
"Content-Type": "text/html; charset=utf8",
|
|
},
|
|
...responseOptions,
|
|
});
|
|
} else {
|
|
// Buffer the entire response and return it all at once
|
|
const htmlStream = renderToHtml(rscPayload, meta.modules, signal);
|
|
const result = await htmlStream.bytes();
|
|
|
|
const opts = als?.getStore()?.responseOptions ?? { headers: {} };
|
|
const { headers, ...response_options } = opts;
|
|
|
|
const cookies = meta.pageModule.mode === "ssr" ? { "Set-Cookie": request.cookies.toSetCookieHeaders() } : {};
|
|
|
|
return new Response(result, {
|
|
headers: {
|
|
"Content-Type": "text/html; charset=utf8",
|
|
// TODO: merge cookies and cookies inside of headers
|
|
...cookies,
|
|
...headers,
|
|
},
|
|
...response_options,
|
|
});
|
|
}
|
|
}
|
|
|
|
// When a production build is performed, pre-rendering is invoked here. If this
|
|
// function returns no files, the route is always dynamic. When building an app
|
|
// to static files, all routes get pre-rendered (build failure if not possible).
|
|
export async function prerender(meta: Bake.RouteMetadata) {
|
|
const page = getPage(meta, meta.styles);
|
|
|
|
const rscPayload = renderToPipeableStream(page, serverManifest)
|
|
// TODO: write a lightweight version of PassThrough
|
|
.pipe(new PassThrough());
|
|
|
|
const int = new Uint32Array(1);
|
|
int[0] = meta.styles.length;
|
|
let rscChunks: Array<BlobPart> = [int.buffer as ArrayBuffer, meta.styles.join("\n")];
|
|
rscPayload.on("data", chunk => rscChunks.push(chunk));
|
|
|
|
let html;
|
|
try {
|
|
html = await renderToStaticHtml(rscPayload, meta.modules);
|
|
} catch (err) {
|
|
//console.error("ah fuck");
|
|
return undefined;
|
|
}
|
|
const rsc = new Blob(rscChunks, { type: "text/x-component" });
|
|
|
|
return {
|
|
// Each route generates a directory with framework-provided files. Keys are
|
|
// files relative to the route path, and values are anything `Bun.write`
|
|
// supports. Streams may result in lower memory usage.
|
|
files: {
|
|
// Directories like `blog/index.html` are preferred over `blog.html` because
|
|
// certain static hosts do not support this conversion. By using `index.html`,
|
|
// the static build is more portable.
|
|
"/index.html": html,
|
|
|
|
// The RSC payload is provided so client-side can use this file for seamless
|
|
// client-side navigation. This is equivalent to 'Accept: text/x-component'
|
|
// for the non-static build.s
|
|
"/index.rsc": rsc,
|
|
},
|
|
|
|
// In the future, it will be possible to return data for a partially
|
|
// pre-rendered page instead of a fully rendered route. Bun might also
|
|
// expose caching options here.
|
|
};
|
|
}
|
|
|
|
export async function getParams(meta: Bake.ParamsMetadata): Promise<Bake.GetParamIterator> {
|
|
const getStaticPaths = meta.pageModule.getStaticPaths;
|
|
if (getStaticPaths == null) {
|
|
if (import.meta.env.STATIC) {
|
|
throw new Error(
|
|
"In files with dynamic params, a `getStaticPaths` function must be exported to tell Bun what files to render.",
|
|
);
|
|
} else {
|
|
return { pages: [], exhaustive: false };
|
|
}
|
|
}
|
|
const result = await meta.pageModule.getStaticPaths();
|
|
// Remap the Next.js pagess paradigm to Bun's format
|
|
if (result.paths) {
|
|
return {
|
|
pages: result.paths.map(path => path.params),
|
|
};
|
|
}
|
|
// Allow returning the array directly
|
|
return result;
|
|
}
|
|
|
|
// When a dynamic build uses static assets, Bun can map content types in the
|
|
// user's `Accept` header to the different static files.
|
|
export const contentTypeToStaticFile = {
|
|
"text/html": "index.html",
|
|
"text/x-component": "index.rsc",
|
|
};
|
|
|
|
/** Instead of using AbortController, this is used */
|
|
export interface MiniAbortSignal {
|
|
aborted: Error | undefined;
|
|
/** Caller must set `aborted` to true before calling. */
|
|
abort: () => void;
|
|
}
|