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