The previous article covered the wrong turns that led to the final R19 prototype. Now it is time to discuss the code, the build step, and the two-server runtime underneath it.
IMPORTANT:
These snippets are simplified versions of the prototype. They omit validation, authorization, error handling, and other work required before exposing a framework server to real traffic. The complete experimental code is in the R19 repository.
It begins with the build step. I wanted the runtime to stay small enough to understand, so transpilation, bundling, and most of the React-specific module work happen ahead of time.
The build script uses Bun as both bundler and runtime. Its API felt familiar after esbuild, although the differences were exactly where this experiment became interesting.
The result was a 50-line-ish script that transpiles the source, bundles it, creates the references React expects, and writes everything to build.
First, what needs to be built? Essentially everything. The script groups files in src into pages, components, and assets. Assets are anything outside the TypeScript entry points, such as favicon.ico, robots.txt, and static CSS.
const entries = (await readdir(resolve("src"), { recursive: true })).reduce(
(acc, file) => { ... }, // Nothing too interesting here, just categorizing the files
{ pages: [], components: [], assets: [] }
)The page build uses a custom rsc-register plugin. Bun invokes it when loading matching TypeScript modules, where it creates the Client Component and Server Function references required by the RSC protocol. We will come back to those references in a moment.
const server = await Bun.build({
target: "bun",
entrypoints: entries["pages"],
outdir: resolve("build", "app"),
plugins: [
{
name: "rsc-register",
setup(build) {
// We'll talk about this one next...
},
},
],
});NOTE:
"Pages" and "components" are separate build inputs because they need different treatment. The server graph receives references at client boundaries, while Client Component modules still need their actual code bundled for the browser.
This is where the magic happens. Every TypeScript module loaded by the page build that matches /\.tsx?$/ passes through the plugin. It reads the file and looks for a use client or use server directive. Without one, Bun handles the module normally. With a directive and at least one export, the plugin replaces those exports with references.
IMPORTANT:
Bun.Transpilerscans the file and lists its exports without evaluating it. The React fixtures use a JavaScript parser for the same kind of static analysis.
{
name: "rsc-register",
setup(build) {
build.onLoad({ filter: /\.tsx?$/ }, async args => {
const content = await Bun.file(args.path).text()
const directives = content.match(/(?:^|\n|;)"use (client|server)";?/)
if (!directives) return { contents: content } // If there are no directives, we let it be bundled
const { exports } = new Bun.Transpiler({ loader: "tsx" }).scan(content)
if (exports.length === 0) return { contents: content } // If there are no exports, we also let it be bundled
return {
contents: exports.map(e => createReference(e, args.path, directives[1])).join("\n")
}
})
}
}Finally, we create the reference. The RSC protocol uses these objects to identify a Client Component that must be loaded elsewhere or a Server Function that can be invoked across the boundary. This helper emits the simplified shape used by the prototype:
export const createReference = (e: string, path: string, directive: string) => {
const id = `/${relative(".", path)
.replace("src", "build")
.replace(/\..+$/, ".js")}#${e}`; // React uses this to identify the component
const mod = `${
e === "default" ? parse(path).base.replace(/\..+$/, "") : ""
}_${e}`; // We create a unique name for the component export
return directive === "server"
? // a server reference keeps the implementation out of the client bundle
`const ${mod}=()=>{throw new Error("This function is expected to only run on the server")};${mod}.$$typeof=Symbol.for("react.server.reference");${mod}.$$id="${id}";${mod}.$$bound=null;${
e === "default"
? `export{${mod} as default}`
: `export {${mod} as ${e}}`
};`
: `${
e === "default" ? "export default {" : `export const ${e} = {`
}$$typeof:Symbol.for("react.client.reference"),$$id:"${id}",$$async:true};`;
};Yeah... It is not pretty. Expanded, the generated code is easier to read.
This would be the output for a Server Function reference with a default export:
const mod = () => {
throw new Error("This function is expected to only run on the server");
};
mod.$$typeof = Symbol.for("react.server.reference");
mod.$$id = "/path/to/file.js#exportName";
mod.$$bound = null;
export { mod as default };CAUTION:
The Server Function implementation must stay out of the client bundle. This placeholder carries its reference metadata and throws if something tries to execute it in the wrong environment.
And this would be the output for a client component reference with a named export:
export const exportName = {
$$typeof: Symbol.for("react.client.reference"),
$$id: "/path/to/file.js#exportName",
$$async: true,
};NOTE:
The Client Component's implementation does not belong in the RSC module graph. That graph receives a reference instead, while a separate client build contains the code that can use browser APIs and hooks.
The client side is more direct: bundle the Client Component entry points and write them to build.
const client = await Bun.build({
target: "bun",
entrypoints: entries["components"],
outdir: resolve("build"),
});Assets are everything else. This prototype copies them to build without an image or CSS optimization pipeline.
entries["assets"].forEach((asset) =>
Bun.write(asset.replace("src", "build"), Bun.file(asset)),
);NOTE:
Bun's asset handling rewrites imported file paths to their build output, so the prototype does not need to manage those paths itself.
Now for the runtime: how the RSC payload is produced, how Server Functions are called, and how that result becomes HTML.
The prototype has two servers, rsc.ts and ssr.ts, and each depends on the other.
NOTE:
There are ways to do this in one server, as Next.js does, but the two renderers still need separate module graphs. Josh explained how Next handled it at the time: "the way next does this from a single process is to use webpack layers. The server bundle has both the RSC and SSR modules in the same graph but there is a copy for RSC and a separate copy for SSR for react packages and client components only see the SSR one and server components only see the RSC one."
I wanted to keep it simple, so I decided to split them up. (Also I have no idea how any of those other things work :))
This server produces the RSC payloads at the center of the prototype. It handles GET and POST requests on "/*". A GET renders the requested page to an RSC stream:
.get("/*", async (req, res) => {
let mod = (
await import(resolve("build/app", `.${req.path}/page.js`))
).default(req.query); // We will use the query as props for the page
renderToPipeableStream(mod, moduleBaseURL).pipe(res);
})POST requests decode the Server Function arguments, invoke the referenced function, render the route again, and return a new RSC stream containing both the result and the new root.
.post("/*", bodyParser.text(), async (req, res) => {
const actionReference = req.headers["rsa-reference"];
const actionOrigin = req.headers["rsa-origin"];
// Resolve the action
const [filepath, name] = actionReference.split("#");
const action = (await import(`.${resolve(filepath)}`))[name];
let args = await decodeReply(req.body, moduleBaseURL); // Decode the arguments
const returnValue = await action.apply(null, args); // Call the action
const root = (
await import(resolve("build/app", `.${actionOrigin}/page.js`))
).default(req.query); // We will use the query as props for the page
renderToPipeableStream({ returnValue, root }, moduleBaseURL).pipe(res); // Render the app with the RSC, action result and the new root
})renderToPipeableStreamThis renderToPipeableStream comes from the RSC server package. Given a React tree and the module base URL, it encodes that tree into React's wire format and exposes the result as a pipeable stream. The implementation is more involved, but that is its role from the framework's point of view.
This is the front-facing server, handling GET requests from users (although, please don't). It forwards the request to the RSC server with node:http, then passes the response stream to react-server-dom-esm/client.node.
Layout is the global HTML shell containing metadata and scripts. Root reads the decoded RSC stream with use and becomes the page content inside that shell.
renderToPipeableStream has the same name here, but it is a different function from react-dom/server.node. It takes the reconstructed React tree and streams HTML to the response. (Believe me, it took me a while to figure this out.)
.get("/*", async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`)
url.port = "3001" // forward to the RSC server
return http.get(url, async rsc => {
let Root = () => use(createFromNodeStream(rsc, resolve("build/") + "/", moduleBaseURL)) // Create a root component from the RSC result
const Layout = (await import(resolve("build/_layout"))).default // Load a HTML shell layout
renderToPipeableStream(createElement(Layout, { children: createElement(Root) })).pipe(res) // render the element as HTML and send it to the client
})
})And guess what? We're almost done!
Do those static paths and fixed module base URLs look like enough for one prototype? Yeah, me too. Naturally, I added static HTML export instead. Pages without query parameters could be rendered during the build and served directly, while the dynamic server remained available for everything else.
const pages = (await readdir(resolve("src"), { recursive: true }))
.filter((file) => file.endsWith("page.tsx"))
.map((page) => page.split("/").at(-2));
const exports = await Promise.all(
pages.map(async (page) => [
page,
await (
await fetch(`http://localhost:3000${page === "app" ? "/" : `/${page}`}`)
).text(),
]),
);
await Promise.all(
exports.map(([page, content]) =>
Bun.write(
`./build/static/${page === "app" ? "index" : page}.html`,
content!,
),
),
);Please, stop laughing. It is not a general-purpose exporter, but for this tiny router it is fast, simple, and easy to understand. Well, it is a solution.
With a few adjustments to our ssr.ts, we can now serve the HTML files, and we're done!
.get("/*", async (req, res) => {
// ...
if (url.searchParams.size === 0) {
const page = (req.path === "/" ? "index" : req.path.slice(1)) + ".html"
log("Defaulting to static page:", `"${page}"`.green)
try {
return res.send(await Bun.file(resolve("build/static/", "./" + page)).text())
} catch {
log("File not found, falling back to SSR".dim)
}
}
// ...
})
new Worker(new URL("./export.ts", import.meta.url).href).addEventListener("close", _ =>
log("Pages exported successfully! 🚀".bold)
)After all the wrong turns and PHP jokes, the part that stayed with me was how powerful this model could be. It also had rough edges, unstable framework APIs, and plenty of room to become slow or complicated. Building the smallest version I could was the only way I found to separate those tradeoffs from the magic.
See you in my next months-long, dubiously useful project!
P.S. Delba dropped this banger of a visualizer for React references and where Client Component code runs
View this tweet on X
latest commit : 194 additions 32 deletions