Files
bun.sh/src/bake/bun-framework-react/server.tsx
Zack Radisic a89e61fcaa ssg 3 (#22138)
### 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>
2025-09-30 05:26:32 -07:00

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