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 { 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 = {route}; } return ( {styles.map(url => ( // `data-bake-ssr` is used on the client-side to construct the styles array. ))} {route} ); } function component(mod: any, params: Record | 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 ; } // `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, ): Promise { // 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 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 = [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 { 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; }