diff --git a/examples/react-server/README.md b/examples/react-server/README.md index 5d34f0fd..cd72c58a 100644 --- a/examples/react-server/README.md +++ b/examples/react-server/README.md @@ -24,8 +24,9 @@ pnpm cf-release - [x] dev - [x] build - [x] hmr - - [x] browser (?) + - [x] browser - [x] react-server +- [x] server action - [ ] integrate to `@hiogawa/react-server` ## references diff --git a/examples/react-server/e2e/basic.test.ts b/examples/react-server/e2e/basic.test.ts index 18ee5084..30a8b917 100644 --- a/examples/react-server/e2e/basic.test.ts +++ b/examples/react-server/e2e/basic.test.ts @@ -1,9 +1,38 @@ -import { test, expect } from "@playwright/test"; +import { test, type Page } from "@playwright/test"; -test("basic", async ({ page }) => { +test("client-component", async ({ page }) => { await page.goto("/"); - await expect(page.locator("#root")).toContainText("hydrated: true"); - await expect(page.locator("#root")).toContainText("Count: 0"); - await page.getByRole("button", { name: "+" }).click(); - await expect(page.locator("#root")).toContainText("Count: 1"); + await page.getByText("hydrated: true").click(); + await page.getByTestId("client-component").getByText("Count: 0").click(); + await page + .getByTestId("client-component") + .getByRole("button", { name: "+" }) + .click(); + await page.getByTestId("client-component").getByText("Count: 1").click(); }); + +test("server-action @js", async ({ page }) => { + await page.goto("/"); + await page.getByText("hydrated: true").click(); + await testServerAction(page); +}); + +test("server-action @nojs", async ({ browser }) => { + const page = await browser.newPage({ javaScriptEnabled: false }); + await page.goto("/"); + await testServerAction(page); +}); + +async function testServerAction(page: Page) { + await page.getByTestId("server-action").getByText("Count: 0").click(); + await page + .getByTestId("server-action") + .getByRole("button", { name: "+" }) + .click(); + await page.getByTestId("server-action").getByText("Count: 1").click(); + await page + .getByTestId("server-action") + .getByRole("button", { name: "-" }) + .click(); + await page.getByTestId("server-action").getByText("Count: 0").click(); +} diff --git a/examples/react-server/src/entry-client.tsx b/examples/react-server/src/entry-client.tsx index a10590b4..d7381491 100644 --- a/examples/react-server/src/entry-client.tsx +++ b/examples/react-server/src/entry-client.tsx @@ -4,6 +4,8 @@ import reactDomClient from "react-dom/client"; import { readRscStreamScript } from "./utils/rsc-stream-script"; import { initializeWebpackServer } from "./features/use-client/server"; import type { StreamData } from "./entry-react-server"; +import { __global } from "./global"; +import { injectActionId } from "./features/server-action/utils"; async function main() { if (window.location.search.includes("__noCsr")) { @@ -15,10 +17,22 @@ async function main() { "react-server-dom-webpack/client.browser" ); + __global.callServer = (id, args) => { + tinyassert(args.length === 1); + tinyassert(args[0] instanceof FormData); + const body = args[0]; + injectActionId(body, id); + const streamData = reactServerDomClient.createFromFetch( + fetch("/?__rsc", { method: "POST", body }), + { callServer: __global.callServer }, + ); + __setStreamData(streamData); + }; + const initialStreamData = reactServerDomClient.createFromReadableStream( readRscStreamScript(), - {}, + { callServer: __global.callServer }, ); let __setStreamData: (v: Promise) => void; @@ -48,7 +62,7 @@ async function main() { console.log("[react-server] hot update", e.file); const streamData = reactServerDomClient.createFromFetch( fetch("/?__rsc"), - {}, + { callServer: __global.callServer }, ); __setStreamData(streamData); }); diff --git a/examples/react-server/src/entry-react-server.tsx b/examples/react-server/src/entry-react-server.tsx index b6dcb28e..c8d6079f 100644 --- a/examples/react-server/src/entry-react-server.tsx +++ b/examples/react-server/src/entry-react-server.tsx @@ -1,10 +1,15 @@ import reactServerDomServer from "react-server-dom-webpack/server.edge"; import Page from "./routes/page"; import { createBundlerConfig } from "./features/use-client/react-server"; +import { serverActionHandler } from "./features/server-action/react-server"; export type StreamData = React.ReactNode; -export async function handler({}: { request: Request }) { +export async function handler({ request }: { request: Request }) { + if (request.method === "POST") { + await serverActionHandler({ request }); + } + const root = ; const stream = reactServerDomServer.renderToReadableStream( diff --git a/examples/react-server/src/features/server-action/browser.ts b/examples/react-server/src/features/server-action/browser.ts new file mode 100644 index 00000000..ee44e34a --- /dev/null +++ b/examples/react-server/src/features/server-action/browser.ts @@ -0,0 +1,8 @@ +import reactServerDomClient from "react-server-dom-webpack/client.browser"; +import { __global } from "../../global"; + +export function createServerReference(id: string) { + return reactServerDomClient.createServerReference(id, (...args) => + __global.callServer(...args), + ); +} diff --git a/examples/react-server/src/features/server-action/react-server.ts b/examples/react-server/src/features/server-action/react-server.ts new file mode 100644 index 00000000..4ef0d602 --- /dev/null +++ b/examples/react-server/src/features/server-action/react-server.ts @@ -0,0 +1,35 @@ +import reactServerDomWebpack from "react-server-dom-webpack/server.edge"; +import { tinyassert } from "@hiogawa/utils"; +import { ejectActionId } from "./utils"; + +export function registerServerReference( + action: Function, + id: string, + name: string, +) { + return reactServerDomWebpack.registerServerReference(action, id, name); +} + +export async function serverActionHandler({ request }: { request: Request }) { + const formData = await request.formData(); + const actionId = ejectActionId(formData); + const action = await importServerAction(actionId); + await action(formData); +} + +async function importServerReference(id: string): Promise { + if (import.meta.env.DEV) { + return import(/* @vite-ignore */ id); + } else { + const references = await import("virtual:server-reference" as string); + const dynImport = references.default[id]; + tinyassert(dynImport, `server reference not found '${id}'`); + return dynImport(); + } +} + +async function importServerAction(id: string): Promise { + const [file, name] = id.split("#") as [string, string]; + const mod: any = await importServerReference(file); + return mod[name]; +} diff --git a/examples/react-server/src/features/server-action/server.ts b/examples/react-server/src/features/server-action/server.ts new file mode 100644 index 00000000..85ca5b48 --- /dev/null +++ b/examples/react-server/src/features/server-action/server.ts @@ -0,0 +1,8 @@ +import reactServerDomClient from "react-server-dom-webpack/client.edge"; + +export function createServerReference(id: string) { + return reactServerDomClient.createServerReference(id, (...args) => { + console.error(args); + throw new Error("unexpected callServer during SSR"); + }); +} diff --git a/examples/react-server/src/features/server-action/utils.ts b/examples/react-server/src/features/server-action/utils.ts new file mode 100644 index 00000000..56b30bfe --- /dev/null +++ b/examples/react-server/src/features/server-action/utils.ts @@ -0,0 +1,22 @@ +import { tinyassert } from "@hiogawa/utils"; + +// uniformly handle simple use cases of form action +// both for progressive enhancement and for client-side request +// without using encodeReply/decodeReply/decodeAction API +const ACTION_ID_PREFIX = "$ACTION_ID_"; + +export function injectActionId(formData: FormData, id: string) { + formData.set(ACTION_ID_PREFIX + id, ""); +} + +export function ejectActionId(formData: FormData) { + let id: string | undefined; + formData.forEach((_v, k) => { + if (k.startsWith(ACTION_ID_PREFIX)) { + id = k.slice(ACTION_ID_PREFIX.length); + formData.delete(k); + } + }); + tinyassert(id); + return id; +} diff --git a/examples/react-server/src/global.tsx b/examples/react-server/src/global.tsx index 55496f51..57f7d6ed 100644 --- a/examples/react-server/src/global.tsx +++ b/examples/react-server/src/global.tsx @@ -1,9 +1,11 @@ import type { ViteDevServer } from "vite"; import type { ModuleRunner } from "vite/module-runner"; +import type { CallServerCallback } from "./types"; // quick global hacks... export const __global: { server: ViteDevServer; reactServerRunner: ModuleRunner; + callServer: CallServerCallback; } = ((globalThis as any).__VITE_REACT_SERVER_GLOBAL ??= {}); diff --git a/examples/react-server/src/routes/_action.tsx b/examples/react-server/src/routes/_action.tsx new file mode 100644 index 00000000..d8f34465 --- /dev/null +++ b/examples/react-server/src/routes/_action.tsx @@ -0,0 +1,11 @@ +"use server"; + +let count = 0; + +export function getCounter() { + return count; +} + +export function changeCounter(formData: FormData) { + count += Number(formData.get("value")); +} diff --git a/examples/react-server/src/routes/_client.tsx b/examples/react-server/src/routes/_client.tsx index 7f7dbd7d..1967795c 100644 --- a/examples/react-server/src/routes/_client.tsx +++ b/examples/react-server/src/routes/_client.tsx @@ -11,8 +11,8 @@ export function ClientComponent() { }, []); return ( -
-

Hello use client

+
+

Hello Client Component

hydrated: {String(hydrated)}
Count: {count}
diff --git a/examples/react-server/src/routes/page.tsx b/examples/react-server/src/routes/page.tsx index 8d724300..6d93f012 100644 --- a/examples/react-server/src/routes/page.tsx +++ b/examples/react-server/src/routes/page.tsx @@ -1,10 +1,29 @@ +import { changeCounter, getCounter } from "./_action"; import { ClientComponent } from "./_client"; export default function Page() { return (
-

Hello react server

+

Hello Server Component

+
); } + +function ServerActionDemo() { + return ( +
+

Hello Server Action

+
+
Count: {getCounter()}
+ + +
+
+ ); +} diff --git a/examples/react-server/src/types/index.ts b/examples/react-server/src/types/index.ts index f4ffd508..a3ccc3d8 100644 --- a/examples/react-server/src/types/index.ts +++ b/examples/react-server/src/types/index.ts @@ -31,4 +31,4 @@ export type WebpackRequire = (id: string) => Promise; // TODO export type WebpackChunkLoad = (id: string) => Promise; -export type CallServerCallback = (id: any, args: any) => Promise; +export type CallServerCallback = (id: string, args: unknown[]) => unknown; diff --git a/examples/react-server/src/types/react.d.ts b/examples/react-server/src/types/react.d.ts index 07d5c848..6f8d9dcc 100644 --- a/examples/react-server/src/types/react.d.ts +++ b/examples/react-server/src/types/react.d.ts @@ -17,10 +17,21 @@ declare module "react-server-dom-webpack/server.edge" { id: string, name: string, ): T; + + export function registerServerReference( + ref: T, + id: string, + name: string, + ): T; } // https://github.com/facebook/react/blob/89021fb4ec9aa82194b0788566e736a4cedfc0e4/packages/react-server-dom-webpack/src/ReactFlightDOMClientEdge.js declare module "react-server-dom-webpack/client.edge" { + export function createServerReference( + id: string, + callServer: import(".").CallServerCallback, + ): Function; + export function createFromReadableStream( stream: ReadableStream, options: { @@ -31,6 +42,11 @@ declare module "react-server-dom-webpack/client.edge" { // https://github.com/facebook/react/blob/89021fb4ec9aa82194b0788566e736a4cedfc0e4/packages/react-server-dom-webpack/src/ReactFlightDOMClientBrowser.js declare module "react-server-dom-webpack/client.browser" { + export function createServerReference( + id: string, + callServer: import(".").CallServerCallback, + ): Function; + export function createFromReadableStream( stream: ReadableStream, options?: { diff --git a/examples/react-server/vite.config.ts b/examples/react-server/vite.config.ts index 72081bb2..e7fbc2fb 100644 --- a/examples/react-server/vite.config.ts +++ b/examples/react-server/vite.config.ts @@ -19,11 +19,12 @@ export default defineConfig((_env) => ({ plugins: [ react(), vitePluginSsrMiddleware({ - entry: "/src/adapters/node", + entry: process.env["SERVER_ENTRY"] ?? "/src/adapters/node", preview: new URL("./dist/server/index.js", import.meta.url).toString(), }), vitePluginReactServer(), vitePluginSilenceUseClientBuildWarning(), + vitePluginServerAction(), ], environments: { @@ -133,6 +134,18 @@ function vitePluginReactServer(): PluginOption { } function vitePluginUseClient(): PluginOption { + /* + [input] + + "use client" + export function Counter() {} + + [output] + + import { registerClientReference as $$register } from "...runtime..." + export const Counter = $$register("", "Counter"); + + */ const transformPlugin: Plugin = { name: vitePluginUseClient.name + ":transform", async transform(code, id, _options) { @@ -140,33 +153,7 @@ function vitePluginUseClient(): PluginOption { manager.clientReferences.delete(id); if (/^("use client")|('use client')/.test(code)) { manager.clientReferences.add(id); - const ast = await parseAstAsync(code); - const exportNames = new Set(); - for (const node of ast.body) { - // named exports - if (node.type === "ExportNamedDeclaration") { - if (node.declaration) { - if ( - node.declaration.type === "FunctionDeclaration" || - node.declaration.type === "ClassDeclaration" - ) { - /** - * export function foo() {} - */ - exportNames.add(node.declaration.id.name); - } else if (node.declaration.type === "VariableDeclaration") { - /** - * export const foo = 1, bar = 2 - */ - for (const decl of node.declaration.declarations) { - if (decl.id.type === "Identifier") { - exportNames.add(decl.id.name); - } - } - } - } - } - } + const { exportNames } = await parseExports(code); let result = `import { registerClientReference as $$register } from "/src/features/use-client/react-server";\n`; for (const name of exportNames) { result += `export const ${name} = $$register("${id}", "${name}");\n`; @@ -185,34 +172,29 @@ function vitePluginUseClient(): PluginOption { return [ transformPlugin, + /* + [output] + + export default { + "": () => import(""), + ... + } + + */ createVirtualPlugin("client-reference", function () { tinyassert(this.environment?.name !== "react-server"); tinyassert(!this.meta.watchMode); - let result = `export default {\n`; - for (let id of manager.clientReferences) { - result += `"${id}": () => import("${id}"),\n`; - } - result += "};\n"; - return result; + return [ + `export default {`, + [...manager.clientReferences].map( + (id) => `"${id}": () => import("${id}"),`, + ), + `}`, + ].join("\n"); }), ]; } -function createVirtualPlugin(name: string, load: Plugin["load"]) { - name = "virtual:" + name; - return { - name: `virtual-${name}`, - resolveId(source, _importer, _options) { - return source === name ? "\0" + name : undefined; - }, - load(id, options) { - if (id === "\0" + name) { - return (load as any).apply(this, [id, options]); - } - }, - } satisfies Plugin; -} - function vitePluginSilenceUseClientBuildWarning(): Plugin { return { name: vitePluginSilenceUseClientBuildWarning.name, @@ -230,7 +212,8 @@ function vitePluginSilenceUseClientBuildWarning(): Plugin { } if ( warning.code === "MODULE_LEVEL_DIRECTIVE" && - warning.message.includes(`"use client"`) + (warning.message.includes(`"use client"`) || + warning.message.includes(`"use server"`)) ) { return; } @@ -245,3 +228,173 @@ function vitePluginSilenceUseClientBuildWarning(): Plugin { }), }; } + +function vitePluginServerAction(): PluginOption { + /* + [input] + + "use server" + export function hello() {} + + [output] (react-server) + + export function hello() { ... } + import { registerServerReference as $$register } from "...runtime..." + hello = $$register(hello, "", "hello"); + + [output] (client) + + import { createServerReference as $$create } from "...runtime..." + export const hello = $$create("#hello"); + + */ + const transformPlugin: Plugin = { + name: vitePluginServerAction.name + ":transform", + async transform(code, id) { + if (/^("use server")|('use server')/.test(code)) { + const { exportNames, writableCode } = await parseExports(code); + if (this.environment?.name === "react-server") { + let result = writableCode; + result += `import { registerServerReference as $$register } from "/src/features/server-action/react-server";\n`; + for (const name of exportNames) { + result += `${name} = $$register(${name}, "${id}", "${name}");\n`; + } + return { code: result, map: null }; + } else { + const runtime = + this.environment?.name === "client" ? "browser" : "server"; + let result = `import { createServerReference as $$create } from "/src/features/server-action/${runtime}";\n`; + for (const name of exportNames) { + result += `export const ${name} = $$create("${id}#${name}");\n`; + } + return { code: result, map: null }; + } + } + return; + }, + }; + + /* + [output] + + export default { + "": () => import(""), + ... + } + + */ + const virtualServerReference: Plugin = { + apply: "build", + ...createVirtualPlugin("server-reference", async function () { + tinyassert(this.environment?.name === "react-server"); + const files: string[] = []; + await traverseFiles(path.resolve("./src"), (file, e) => { + if (e.isFile()) { + files.push(file); + } + }); + const ids: string[] = []; + for (const file of files) { + const code = await fs.promises.readFile(file, "utf-8"); + if (/^("use server")|('use server')/.test(code)) { + ids.push(file); + } + } + return [ + "export default {", + ...ids.map((id) => `"${id}": () => import("${id}"),\n`), + "}", + ].join("\n"); + }), + }; + + return [transformPlugin, virtualServerReference]; +} + +import fs from "node:fs"; +import path from "node:path"; + +async function traverseFiles( + dir: string, + callback: (filepath: string, e: fs.Dirent) => void, +) { + const entries = await fs.promises.readdir(dir, { withFileTypes: true }); + for (const e of entries) { + const filepath = path.join(e.path, e.name); + callback(filepath, e); + if (e.isDirectory()) { + await traverseFiles(filepath, callback); + } + } +} + +// +// plugin utils +// + +function createVirtualPlugin(name: string, load: Plugin["load"]) { + name = "virtual:" + name; + return { + name: `virtual-${name}`, + resolveId(source, _importer, _options) { + return source === name ? "\0" + name : undefined; + }, + load(id, options) { + if (id === "\0" + name) { + return (load as any).apply(this, [id, options]); + } + }, + } satisfies Plugin; +} + +// +// ast utils +// + +async function parseExports(code: string) { + const ast = await parseAstAsync(code); + const exportNames = new Set(); + let writableCode = code; + for (const node of ast.body) { + // named exports + if (node.type === "ExportNamedDeclaration") { + if (node.declaration) { + if ( + node.declaration.type === "FunctionDeclaration" || + node.declaration.type === "ClassDeclaration" + ) { + /** + * export function foo() {} + */ + exportNames.add(node.declaration.id.name); + } else if (node.declaration.type === "VariableDeclaration") { + /** + * export const foo = 1, bar = 2 + */ + if (node.declaration.kind === "const") { + const start = (node.declaration as any).start; + writableCode = replaceCode(writableCode, start, start + 5, "let "); + } + for (const decl of node.declaration.declarations) { + if (decl.id.type === "Identifier") { + exportNames.add(decl.id.name); + } + } + } + } + } + } + return { + exportNames, + writableCode, + }; +} + +function replaceCode( + code: string, + start: number, + end: number, + content: string, +) { + return code.slice(0, start) + content + code.slice(end); +}