diff --git a/.gitattributes b/.gitattributes
index 50f21bf12fb1..88ad321e7a67 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -47,6 +47,7 @@ examples/**/* linguist-documentation
vendor/*.c linguist-vendored
vendor/brotli/** linguist-vendored
+packages/bun-framework-react/vendor/** linguist-vendored -diff -merge
test/js/node/test/fixtures linguist-vendored
test/js/node/test/common linguist-vendored
diff --git a/bun.lock b/bun.lock
index fedf606d751b..7f4f6e5f0205 100644
--- a/bun.lock
+++ b/bun.lock
@@ -41,6 +41,7 @@
},
"overrides": {
"@types/bun": "workspace:packages/@types/bun",
+ "@types/node": "24.3.1",
"bun-types": "workspace:packages/bun-types",
},
"packages": {
@@ -160,7 +161,7 @@
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
- "@types/node": ["@types/node@24.2.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-DRh5K+ka5eJic8CjH7td8QpYEV6Zo10gfRkjHCO3weqZHWDtAaSTFtl4+VMqOJ4N5jcuhZ9/l+yy8rVgw7BQeQ=="],
+ "@types/node": ["@types/node@24.3.1", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g=="],
"@types/react": ["@types/react@19.1.10", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-EhBeSYX0Y6ye8pNebpKrwFJq7BoQ8J5SO6NlvNwwHjSj6adXJViPQrKlsyPw7hLBLvckEMO1yxeGdR82YBBlDg=="],
diff --git a/cmake/Sources.json b/cmake/Sources.json
index 5ae49306939b..c8b3fc48f830 100644
--- a/cmake/Sources.json
+++ b/cmake/Sources.json
@@ -27,6 +27,10 @@
"paths": ["src/bake/*.ts", "src/bake/*/*.{ts,css}"],
"exclude": ["src/bake/generated.ts"]
},
+ {
+ "output": "BunFrameworkReactSources.txt",
+ "paths": ["packages/bun-framework-react/*.{ts,tsx,js,jsx}", "packages/bun-framework-react/src/**/*.{ts,tsx,js,jsx}"]
+ },
{
"output": "BindgenSources.txt",
"paths": ["src/**/*.bind.ts"]
diff --git a/package.json b/package.json
index b5d18cf725ce..82c9e3b0cf2f 100644
--- a/package.json
+++ b/package.json
@@ -23,7 +23,8 @@
},
"resolutions": {
"bun-types": "workspace:packages/bun-types",
- "@types/bun": "workspace:packages/@types/bun"
+ "@types/bun": "workspace:packages/@types/bun",
+ "@types/node": "24.3.1"
},
"scripts": {
"build": "bun --silent run build:debug",
diff --git a/packages/bun-framework-react/.gitignore b/packages/bun-framework-react/.gitignore
new file mode 100644
index 000000000000..f26dd6d5367f
--- /dev/null
+++ b/packages/bun-framework-react/.gitignore
@@ -0,0 +1 @@
+bun-framework-react*.tgz
\ No newline at end of file
diff --git a/packages/bun-framework-react/README.md b/packages/bun-framework-react/README.md
new file mode 100644
index 000000000000..2e088819861e
--- /dev/null
+++ b/packages/bun-framework-react/README.md
@@ -0,0 +1,10 @@
+
+
+# `bun-framework-react`
+
+An implementation of the Bun Rendering API for React, with RSC (React Server Components)
+
+1. `bun add bun-framework-react --dev`
+2. Make a `pages/index.tsx` file, with a page component defualt export
+3. Run `bun --app`
+4. Open [localhost:3000](https://localhost:3000) 🎉
diff --git a/packages/bun-framework-react/bun.lock b/packages/bun-framework-react/bun.lock
new file mode 100644
index 000000000000..f2eb864e1cd3
--- /dev/null
+++ b/packages/bun-framework-react/bun.lock
@@ -0,0 +1,32 @@
+{
+ "lockfileVersion": 1,
+ "workspaces": {
+ "": {
+ "name": "bun-framework-react",
+ "dependencies": {
+ "react": "0.0.0-experimental-a757cb76-20251002",
+ "react-dom": "0.0.0-experimental-a757cb76-20251002",
+ "react-refresh": "0.0.0-experimental-a757cb76-20251002",
+ },
+ "devDependencies": {
+ "@types/react": "^19.1.13",
+ "@types/react-dom": "^19.1.9",
+ },
+ },
+ },
+ "packages": {
+ "@types/react": ["@types/react@19.1.13", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-hHkbU/eoO3EG5/MZkuFSKmYqPbSVk5byPFa3e7y/8TybHiLMACgI8seVYlicwk7H5K/rI2px9xrQp/C+AUDTiQ=="],
+
+ "@types/react-dom": ["@types/react-dom@19.1.9", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ=="],
+
+ "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
+
+ "react": ["react@0.0.0-experimental-a757cb76-20251002", "", {}, "sha512-7ZcE4sSUGgrXgUWa84iwC9DqwDFbQBgffFmu2DoNqFseruA/JjxQDXKwpV5acdxOM/0uzfSGrapHU3C3ZLiU2g=="],
+
+ "react-dom": ["react-dom@0.0.0-experimental-a757cb76-20251002", "", { "dependencies": { "scheduler": "0.0.0-experimental-a757cb76-20251002" }, "peerDependencies": { "react": "0.0.0-experimental-a757cb76-20251002" } }, "sha512-XjIkmW8mMx9kURHJUY+dhv1Ugan3RmEJIwrZEbAFcJA4S8RXL1wl+xsQJpDCh8kmeX/n25VAmFY8/j1MzqUHfA=="],
+
+ "react-refresh": ["react-refresh@0.0.0-experimental-a757cb76-20251002", "", {}, "sha512-uYd+N2W8/LymZQyY5u1BMWVvLlBV+5SxztBsFjOGuitE4x7sSCj8TwgS+8bxIEBucEVJglfOhDPCPohL/uEQdg=="],
+
+ "scheduler": ["scheduler@0.0.0-experimental-a757cb76-20251002", "", {}, "sha512-YCVGuzmF7u5HIpOdPFD4tZTPzQlOrtViag7uaWjJXfFx37C8sypfNeSXNXoYJeT/ICybxP1EsbHh2oByQHC2Cg=="],
+ }
+}
diff --git a/packages/bun-framework-react/client.tsx b/packages/bun-framework-react/client.tsx
new file mode 100644
index 000000000000..de64cbf911bd
--- /dev/null
+++ b/packages/bun-framework-react/client.tsx
@@ -0,0 +1,64 @@
+import { onServerSideReload } from "bun:app/client";
+import { hydrateRoot } from "react-dom/client";
+import { initialRscPayloadThen } from "./src/client/app.ts";
+import { router } from "./src/client/constants.ts";
+import { Root } from "./src/client/root.tsx";
+
+hydrateRoot(document, , {
+ onUncaughtError(e) {
+ console.error(e);
+ },
+});
+
+const firstPageId = Date.now();
+{
+ history.replaceState(firstPageId, "", location.href);
+ initialRscPayloadThen(result => {
+ if (router.hasNavigatedSinceDOMContentLoaded()) return;
+
+ // Collect the list of CSS files that were added from SSR
+ const links = document.querySelectorAll("link[data-bake-ssr]");
+ router.css.clear();
+
+ for (let i = 0; i < links.length; i++) {
+ const link = links[i];
+ if (!link) continue;
+ const href = new URL(link.href).pathname;
+ router.css.push(href);
+
+ // Hack: cannot add this to `cssFiles` because React owns the element, and
+ // it will be removed when any navigation is performed.
+ }
+
+ router.setCachedPage(firstPageId, {
+ css: [...router.css.getList()],
+ element: result,
+ });
+ });
+
+ if (document.startViewTransition !== undefined) {
+ // View transitions are used by navigations to ensure that the page rerender
+ // all happens in one operation. Additionally, developers may animate
+ // different elements. The default fade animation is disabled so that the
+ // out-of-the-box experience feels like there are no view transitions.
+ // This is done client-side because a React error will unmount all elements.
+ const sheet = new CSSStyleSheet();
+ document.adoptedStyleSheets.push(sheet);
+ sheet.replaceSync(":where(*)::view-transition-group(root){animation:none}");
+ }
+}
+
+window.addEventListener("popstate", async event => {
+ const state = typeof event.state === "number" ? event.state : undefined;
+ await router.navigate(location.href, state);
+});
+
+if (import.meta.env.DEV) {
+ // Frameworks can call `onServerSideReload` to hook into server-side hot
+ // module reloading.
+ onServerSideReload(async () => {
+ const newId = Date.now();
+ history.replaceState(newId, "", location.href);
+ await router.navigate(location.href, newId);
+ });
+}
diff --git a/packages/bun-framework-react/index.ts b/packages/bun-framework-react/index.ts
new file mode 100644
index 000000000000..7d0b686856b9
--- /dev/null
+++ b/packages/bun-framework-react/index.ts
@@ -0,0 +1,35 @@
+import type { Framework } from "bun:app";
+
+function resolve(specifier: string) {
+ return Bun.fileURLToPath(import.meta.resolve(specifier));
+}
+
+const framework: Framework = {
+ serverComponents: {
+ separateSSRGraph: true,
+ serverRuntimeImportSource: resolve("./vendor/react-server-dom-bun/server.node.js"),
+ },
+ reactFastRefresh: {
+ importSource: resolve("react-refresh/runtime"),
+ },
+ fileSystemRouterTypes: [
+ {
+ root: "pages",
+ clientEntryPoint: resolve("./client.tsx"),
+ serverEntryPoint: resolve("./server.tsx"),
+ extensions: [".tsx", ".jsx"],
+ style: "nextjs-pages",
+ layouts: true,
+ ignoreUnderscores: true,
+ prefix: "/",
+ ignoreDirs: ["node_modules", ".git"],
+ },
+ ],
+ // bundlerOptions: {
+ // ssr: {
+ // conditions: ["react-server"],
+ // },
+ // },
+};
+
+export default framework;
diff --git a/packages/bun-framework-react/package.json b/packages/bun-framework-react/package.json
new file mode 100644
index 000000000000..3c0ffac09e4f
--- /dev/null
+++ b/packages/bun-framework-react/package.json
@@ -0,0 +1,36 @@
+{
+ "name": "bun-framework-react",
+ "version": "0.0.0-canary.10",
+ "devDependencies": {
+ "@types/react": "^19.1.13",
+ "@types/react-dom": "^19.1.9"
+ },
+ "exports": {
+ ".": "./index.ts",
+ "./package.json": "./package.json",
+ "./ssr.tsx": "./ssr.tsx",
+ "./server.tsx": "./server.tsx",
+ "./client.tsx": "./client.tsx",
+ "./*": "./src/components/*.tsx"
+ },
+ "description": "React framework integration with RSC, for the Bun Rendering API",
+ "files": [
+ "./vendor",
+ "./src",
+ "./*.ts",
+ "./*.tsx",
+ "./README.md"
+ ],
+ "keywords": [
+ "bun",
+ "react",
+ "framework",
+ "bake"
+ ],
+ "type": "module",
+ "dependencies": {
+ "react": "0.0.0-experimental-a757cb76-20251002",
+ "react-dom": "0.0.0-experimental-a757cb76-20251002",
+ "react-refresh": "0.0.0-experimental-a757cb76-20251002"
+ }
+}
diff --git a/src/bake/bun-framework-react/server.tsx b/packages/bun-framework-react/server.tsx
similarity index 90%
rename from src/bake/bun-framework-react/server.tsx
rename to packages/bun-framework-react/server.tsx
index 62eca8a7b5f4..8dbf586decab 100644
--- a/src/bake/bun-framework-react/server.tsx
+++ b/packages/bun-framework-react/server.tsx
@@ -1,23 +1,24 @@
-import type { Bake } from "bun";
import { renderToHtml, renderToStaticHtml } from "bun-framework-react/ssr.tsx" with { bunBakeGraph: "ssr" };
-import { serverManifest } from "bun:bake/server";
+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 { renderToPipeableStream } from "react-server-dom-bun/server.node.unbundled.js";
-import type { RequestContext } from "../hmr-runtime-server";
+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: any) {
+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");
}
}
-// This function converts the route information into a React component tree.
-function getPage(meta: Bake.RouteMetadata & { request?: Request }, styles: readonly string[]) {
+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;
+ const Layout = layout.default as typeof layout.default & { displayName?: string };
+ Layout.displayName ??= "Layout";
if (import.meta.env.DEV) assertReactComponent(Layout);
route = {route};
}
@@ -26,7 +27,6 @@ function getPage(meta: Bake.RouteMetadata & { request?: Request }, styles: reado
- Bun + React Server Components
{styles.map(url => (
// `data-bake-ssr` is used on the client-side to construct the styles array.
@@ -37,8 +37,12 @@ function getPage(meta: Bake.RouteMetadata & { request?: Request }, styles: reado
);
}
-function component(mod: any, params: Record | null, request?: Request) {
+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);
@@ -51,7 +55,6 @@ function component(mod: any, params: Record | null, request?: Re
props = method();
}
- // Pass request prop if mode is 'ssr'
if (mod.mode === "ssr" && request) {
props.request = request;
}
@@ -76,7 +79,7 @@ export async function render(
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;
+ const streaming = meta.pageModule?.streaming ?? false;
// Do not render tags if the request is skipping SSR.
const page = getPage(meta, skipSSR ? [] : meta.styles);
@@ -104,7 +107,6 @@ export async function render(
// Mark as aborted and call the abort function
signal.aborted = err;
- // @ts-expect-error
signal.abort(err);
rscPayload.destroy(err);
},
@@ -236,5 +238,5 @@ export const contentTypeToStaticFile = {
export interface MiniAbortSignal {
aborted: Error | undefined;
/** Caller must set `aborted` to true before calling. */
- abort: () => void;
+ abort: (reason?: any) => void;
}
diff --git a/packages/bun-framework-react/src/client/app.ts b/packages/bun-framework-react/src/client/app.ts
new file mode 100644
index 000000000000..e07792642741
--- /dev/null
+++ b/packages/bun-framework-react/src/client/app.ts
@@ -0,0 +1,96 @@
+import type { ReactNode, SetStateAction } from "react";
+import { createFromReadableStream } from "../../vendor/react-server-dom-bun/client.browser.js";
+import { store, useStore, type Store } from "./store.ts";
+
+export type NonNullishReactNode = Exclude;
+export type RenderableRscPayload = Promise | NonNullishReactNode;
+
+const encoder = new TextEncoder();
+
+function enqueueChunks(
+ controller: ReadableStreamDefaultController>,
+ ...chunks: (string | Uint8Array)[]
+) {
+ for (let chunk of chunks) {
+ if (typeof chunk === "string") {
+ chunk = encoder.encode(chunk);
+ }
+
+ controller.enqueue(chunk);
+ }
+}
+
+export interface AppState {
+ /**
+ * The renderable RSC payload
+ */
+ rsc: RenderableRscPayload;
+
+ /**
+ * A controller that aborts on the first render
+ */
+ abortOnRender?: AbortController | undefined;
+}
+
+// The initial RSC payload is put into inline