Skip to content

Commit

Permalink
feat(rsbuild-rsc): support server action (#60)
Browse files Browse the repository at this point in the history
  • Loading branch information
hi-ogawa authored Jul 28, 2024
1 parent 28efeb1 commit 1f3dccb
Show file tree
Hide file tree
Showing 13 changed files with 367 additions and 19 deletions.
2 changes: 1 addition & 1 deletion rsbuild-rsc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ pnpm preview
- [x] basic ssr
- [x] basic hydration
- [x] basic client reference
- [ ] server reference
- [x] basic server reference
- [ ] css
- [ ] server hmr
- [x] client hmr
Expand Down
131 changes: 126 additions & 5 deletions rsbuild-rsc/rsbuild.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,54 @@ export default defineConfig((env) => {
const dev = env.command === "dev";

const clientReferences = new Set<string>();
const serverReferences = new Set<string>();

// ensure dist dir
// ensure dist dir since we save some build artifact even during dev
mkdirSync("dist", { recursive: true });

return {
plugins: [pluginReact()],
environments: {
// extra build to scan server references
// since it doesn't look like it's possible to
// dynamically add entry (e.g. hooks.finishMake + compilation.addModuleTree)
scan: {
output: {
target: "node",
distPath: {
root: "dist/scan",
},
},
source: {
entry: {
index: "./src/entry-server",
},
define: {
"import.meta.env.DEV": dev,
"import.meta.env.SSR": true,
},
},
tools: {
rspack: (config, utils) => {
utils.addRules([
{
test: /\.tsx$/,
use: {
loader: path.resolve(
"./src/lib/webpack/use-server-server-loader.js",
),
options: { serverReferences },
},
},
]);
return utils.mergeConfig(config, {
resolve: {
conditionNames: ["react-server", "..."],
},
});
},
},
},
server: {
output: {
target: "node",
Expand All @@ -44,6 +85,9 @@ export default defineConfig((env) => {
},
tools: {
rspack: (config, utils) => {
config.dependencies ??= [];
config.dependencies.push("scan");

utils.addRules([
{
test: /\.tsx$/,
Expand All @@ -54,7 +98,63 @@ export default defineConfig((env) => {
options: { clientReferences },
},
},
{
test: /\.tsx$/,
use: {
loader: path.resolve(
"./src/lib/webpack/use-server-server-loader.js",
),
options: { serverReferences: new Set() },
},
},
createVirtualModuleRule(
path.resolve("./src/lib/virtual-server-references.js"),
() => {
// fake side effect to avoid tree shaking
return [
`export default Math.random() < 0 && [`,
...[...serverReferences].map(
(file) =>
`import(/* webpackMode: "eager" */ ${JSON.stringify(file)}),`,
),
`]`,
].join("\n");
},
),
]);

utils.appendPlugins([
{
name: "rsc-plugin-server",
apply(compiler: Rspack.Compiler) {
const NAME = "rsc-plugin-server";

compiler.hooks.done.tap(NAME, (stats) => {
const preliminaryManifest: PreliminaryManifest = {};

const statsJson = stats.toJson();
tinyassert(statsJson.chunks);
for (const chunk of statsJson.chunks) {
tinyassert(chunk.modules);
for (const mod of chunk.modules) {
if (!mod.nameForCondition) continue;
if (serverReferences.has(mod.nameForCondition)) {
tinyassert(mod.id);
preliminaryManifest[mod.nameForCondition] = {
id: mod.id,
chunks: [],
};
}
}
}

const code = `export default ${JSON.stringify(preliminaryManifest, null, 2)}`;
writeFileSync("./dist/__server_manifest.mjs", code);
});
},
},
]);

return utils.mergeConfig(config, {
resolve: {
conditionNames: ["react-server", "..."],
Expand Down Expand Up @@ -99,6 +199,18 @@ export default defineConfig((env) => {
].join("\n");
},
),
{
test: /\.tsx?$/,
use: {
loader: path.resolve(
"./src/lib/webpack/use-server-client-loader.js",
),
options: {
serverReferences,
runtime: "react-server-dom-webpack/client.browser",
},
},
},
]);

utils.appendPlugins([
Expand Down Expand Up @@ -141,9 +253,6 @@ export default defineConfig((env) => {
});
},
},
]);

utils.appendPlugins([
{
name: "client-assets",
apply(compiler: Rspack.Compiler) {
Expand Down Expand Up @@ -185,7 +294,7 @@ export default defineConfig((env) => {
tools: {
rspack: (config, utils) => {
config.dependencies ??= [];
config.dependencies.push("server", "web");
config.dependencies.push("web");

utils.addRules([
createVirtualModuleRule(
Expand All @@ -202,6 +311,18 @@ export default defineConfig((env) => {
].join("\n");
},
),
{
test: /\.tsx?$/,
use: {
loader: path.resolve(
"./src/lib/webpack/use-server-client-loader.js",
),
options: {
serverReferences,
runtime: "react-server-dom-webpack/client.edge",
},
},
},
]);

utils.appendPlugins([
Expand Down
43 changes: 35 additions & 8 deletions rsbuild-rsc/src/entry-browser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,32 @@ import type { FlightData } from "./entry-server";
import "./lib/virtual-client-references-browser.js";
import "./style.css";
import { setupBrowserRouter } from "./lib/router/browser";
import type { CallServerCallback } from "./types/react-types";

async function main() {
const url = new URL(window.location.href);
if (url.searchParams.has("__nojs")) {
return;
}

// TODO
const callServer = () => {};
let $__setFlight: (flight: Promise<FlightData>) => void;

const callServer: CallServerCallback = async (id, args) => {
const url = new URL(window.location.href);
url.searchParams.set("__f", "");
url.searchParams.set("__a", id);
const flight = ReactClient.createFromFetch<FlightData>(
fetch(url, {
method: "POST",
body: await ReactClient.encodeReply(args),
}),
{ callServer },
);
$__setFlight(flight);
return (await flight).actionResult;
};

(self as any).__f_call_server = callServer;

// [flight => react node] react client
const initialFlight = ReactClient.createFromReadableStream<FlightData>(
Expand All @@ -26,15 +43,15 @@ async function main() {
React.useState<Promise<FlightData>>(initialFlight);

React.useEffect(() => {
$__setFlight = (flight) => React.startTransition(() => setFlight(flight));

return setupBrowserRouter(() => {
const url = new URL(window.location.href);
url.searchParams.set("__f", "");
React.startTransition(() =>
setFlight(
ReactClient.createFromFetch<FlightData>(fetch(url), {
callServer,
}),
),
$__setFlight(
ReactClient.createFromFetch<FlightData>(fetch(url), {
callServer,
}),
);
});
}, []);
Expand All @@ -48,12 +65,22 @@ async function main() {
}

// [react node => html] react dom client
const formState = (await initialFlight).actionResult;
React.startTransition(() => {
ReactDOMClient.hydrateRoot(
document,
<React.StrictMode>{browserRoot}</React.StrictMode>,
{
formState,
},
);
});
}

main();

declare module "react-dom/client" {
interface HydrationOptions {
formState?: unknown;
}
}
59 changes: 56 additions & 3 deletions rsbuild-rsc/src/entry-server.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,40 @@
import { tinyassert } from "@hiogawa/utils";
import React from "react";
import ReactServer from "react-server-dom-webpack/server.edge";
import { getClientManifest } from "./lib/client-manifest";
import { getServerManifest } from "./lib/server-manifest";
import "./lib/virtual-server-references.js";

export type FlightData = {
node: React.ReactNode;
actionResult?: ActionResult;
};

export type ServerResult = {
flightStream: ReadableStream<Uint8Array>;
actionResult?: ActionResult;
};

export async function handler(request: Request): Promise<ServerResult> {
// TODO: action
let actionResult: ActionResult | undefined;
if (request.method === "POST") {
actionResult = await actionHandler(request);
}

// [react node -> flight] react server
const node = <Router request={request} />;
const { browserManifest } = await getClientManifest();
const flightStream = ReactServer.renderToReadableStream<FlightData>(
{ node },
{ node, actionResult },
browserManifest,
);
return { flightStream };
return { flightStream, actionResult };
}

//
// file system routes
//

async function Router(props: { request: Request }) {
const url = new URL(props.request.url);
const { default: Layout } = await import("./routes/layout");
Expand All @@ -37,5 +47,48 @@ async function Router(props: { request: Request }) {
const { default: Page } = await import("./routes/stream/page");
page = <Page />;
}
if (url.pathname === "/action") {
const { default: Page } = await import("./routes/action/page");
page = <Page />;
}
return <Layout>{page}</Layout>;
}

//
// server action
//

type ActionResult = unknown;

export async function actionHandler(request: Request): Promise<ActionResult> {
const url = new URL(request.url);
const { serverManifest } = await getServerManifest();
let boundAction: Function;
if (url.searchParams.has("__f")) {
// client stream request
const contentType = request.headers.get("content-type");
const body = contentType?.startsWith("multipart/form-data")
? await request.formData()
: await request.text();
const args = await ReactServer.decodeReply(body);
const $$id = url.searchParams.get("__a");
tinyassert($$id);
const entry = serverManifest[$$id];
// @ts-expect-error
const mod = __webpack_require__(entry.id);
boundAction = () => mod[entry.name](...args);
} else {
// progressive enhancement
const formData = await request.formData();
const decodedAction = await ReactServer.decodeAction(
formData,
serverManifest,
);
boundAction = async () => {
const result = await decodedAction();
const formState = await ReactServer.decodeFormState(result, formData);
return formState;
};
}
return boundAction();
}
10 changes: 8 additions & 2 deletions rsbuild-rsc/src/entry-ssr.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ declare let __rsbuild_server__: ServerAPIs;

export default async function handler(request: Request): Promise<Response> {
const reactServer = await importReactServer();
const { flightStream } = await reactServer.handler(request);
const { flightStream, actionResult } = await reactServer.handler(request);

const url = new URL(request.url);
if (url.searchParams.has("__f")) {
Expand Down Expand Up @@ -47,8 +47,8 @@ export default async function handler(request: Request): Promise<Response> {

// [react node => html] react dom server
const htmlStream = await ReactDOMServer.renderToReadableStream(ssrRoot, {
// TODO
bootstrapModules: assets.js,
formState: actionResult,
});

const htmlStreamFinal = htmlStream
Expand Down Expand Up @@ -113,3 +113,9 @@ async function getStatsJson(): Promise<Rspack.StatsCompilation> {
).default;
}
}

declare module "react-dom/server" {
interface RenderToReadableStreamOptions {
formState?: unknown;
}
}
Loading

0 comments on commit 1f3dccb

Please sign in to comment.