Files
bun.sh/packages/bun-framework-react/server.tsx
2025-09-24 19:08:31 -07:00

244 lines
8.5 KiB
TypeScript

import { renderToHtml, renderToStaticHtml } from "bun-framework-react/ssr.tsx" with { bunBakeGraph: "ssr" };
import * as Bake from "bun:app";
import { serverManifest } from "bun:app/server";
import type { AsyncLocalStorage } from "node:async_hooks";
import { PassThrough } from "node:stream";
import type { RequestContext } from "../../src/bake/hmr-runtime-server.ts";
import { renderToPipeableStream } from "./vendor/react-server-dom-bun/server.node.unbundled.js";
function assertReactComponent(Component: unknown): asserts Component is React.JSXElementConstructor<unknown> {
if (typeof Component !== "function") {
console.log("Expected a React component", Component, typeof Component);
throw new Error("Expected a React component");
}
}
function getPage(meta: Bake.RouteMetadata & { request?: Request | undefined }, styles: readonly string[]) {
let route = component(meta.pageModule, meta.params, meta.request);
for (const layout of meta.layouts) {
const Layout = layout.default as typeof layout.default & { displayName?: string };
Layout.displayName ??= "Layout";
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" />
{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 | string[]> | null, request?: Request) {
if (!mod || !mod.default) {
throw new Error("Pages must have a default export that is a React component");
}
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();
}
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;
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: (reason?: any) => void;
}