|
| 1 | +import { |
| 2 | + renderToReadableStream, |
| 3 | + createTemporaryReferenceSet, |
| 4 | + decodeReply, |
| 5 | + loadServerAction, |
| 6 | + decodeAction, |
| 7 | + decodeFormState, |
| 8 | +} from "@vitejs/plugin-rsc/rsc"; |
| 9 | +import type { ReactFormState } from "react-dom/client"; |
| 10 | +import { Root } from "../root.tsx"; |
| 11 | +import { parseRenderRequest } from "./request.tsx"; |
| 12 | + |
| 13 | +// The schema of payload which is serialized into RSC stream on rsc environment |
| 14 | +// and deserialized on ssr/client environments. |
| 15 | +export type RscPayload = { |
| 16 | + // this demo renders/serializes/deserializes entire root html element |
| 17 | + // but this mechanism can be changed to render/fetch different parts of components |
| 18 | + // based on your own route conventions. |
| 19 | + root: React.ReactNode; |
| 20 | + |
| 21 | + // Server action return value of non-progressive enhancement case |
| 22 | + returnValue?: { ok: boolean; data: unknown }; |
| 23 | + |
| 24 | + // Server action form state (e.g. useActionState) of progressive enhancement case |
| 25 | + formState?: ReactFormState; |
| 26 | +}; |
| 27 | + |
| 28 | +// The plugin by default assumes `rsc` entry having default export of request handler. |
| 29 | +// however, how server entries are executed can be customized by registering own server handler. |
| 30 | +export default async function handler(request: Request): Promise<Response> { |
| 31 | + // Differentiate RSC, SSR, action, etc. |
| 32 | + const renderRequest = parseRenderRequest(request); |
| 33 | + request = renderRequest.request; |
| 34 | + |
| 35 | + // Handle server function request |
| 36 | + let returnValue: RscPayload["returnValue"] | undefined; |
| 37 | + let formState: ReactFormState | undefined; |
| 38 | + let temporaryReferences: unknown | undefined; |
| 39 | + let actionStatus: number | undefined; |
| 40 | + |
| 41 | + if (renderRequest.isAction === true) { |
| 42 | + if (renderRequest.actionId) { |
| 43 | + // Action is called via `ReactClient.setServerCallback`. |
| 44 | + const contentType = request.headers.get("content-type"); |
| 45 | + const body = contentType?.startsWith("multipart/form-data") |
| 46 | + ? await request.formData() |
| 47 | + : await request.text(); |
| 48 | + temporaryReferences = createTemporaryReferenceSet(); |
| 49 | + const args = await decodeReply(body, { temporaryReferences }); |
| 50 | + const action = await loadServerAction(renderRequest.actionId); |
| 51 | + try { |
| 52 | + // eslint-disable-next-line prefer-spread |
| 53 | + const data = await action.apply(null, args); |
| 54 | + returnValue = { ok: true, data }; |
| 55 | + } catch (error_) { |
| 56 | + returnValue = { ok: false, data: error_ }; |
| 57 | + actionStatus = 500; |
| 58 | + } |
| 59 | + } else { |
| 60 | + // Otherwise server function is called via `<form action={...}>` |
| 61 | + // before hydration (e.g. when JavaScript is disabled). |
| 62 | + // aka progressive enhancement. |
| 63 | + const formData = await request.formData(); |
| 64 | + const decodedAction = await decodeAction(formData); |
| 65 | + try { |
| 66 | + const result = await decodedAction(); |
| 67 | + formState = await decodeFormState(result, formData); |
| 68 | + } catch { |
| 69 | + // there's no single general obvious way to surface this error, |
| 70 | + // so explicitly return classic 500 response. |
| 71 | + return new Response("Internal Server Error: server action failed", { |
| 72 | + status: 500, |
| 73 | + }); |
| 74 | + } |
| 75 | + } |
| 76 | + } |
| 77 | + |
| 78 | + // Serialization from React VDOM tree to RSC stream. |
| 79 | + // We render RSC stream after handling server function request |
| 80 | + // so that new render reflects updated state from server function call |
| 81 | + // to achieve single round trip to mutate and fetch from server. |
| 82 | + const rscPayload: RscPayload = { |
| 83 | + root: <Root url={renderRequest.url} />, |
| 84 | + formState, |
| 85 | + returnValue, |
| 86 | + }; |
| 87 | + |
| 88 | + const rscOptions = { temporaryReferences }; |
| 89 | + const rscStream = renderToReadableStream<RscPayload>(rscPayload, rscOptions); |
| 90 | + |
| 91 | + // Respond RSC stream without HTML rendering as decided by `RenderRequest` |
| 92 | + if (renderRequest.isRsc) { |
| 93 | + return new Response(rscStream, { |
| 94 | + status: actionStatus, |
| 95 | + headers: { |
| 96 | + "content-type": "text/x-component;charset=utf-8", |
| 97 | + }, |
| 98 | + }); |
| 99 | + } |
| 100 | + |
| 101 | + // Delegate to SSR environment for HTML rendering. |
| 102 | + // The plugin provides `loadModule` helper to allow loading SSR environment entry module |
| 103 | + // in RSC environment. however this can be customized by implementing own runtime communication |
| 104 | + // e.g. `@cloudflare/vite-plugin`'s service binding. |
| 105 | + const ssrEntryModule = await import.meta.viteRsc.loadModule< |
| 106 | + typeof import("./entry.ssr.tsx") |
| 107 | + >("ssr", "index"); |
| 108 | + |
| 109 | + const ssrResult = await ssrEntryModule.renderHTML(rscStream, { |
| 110 | + formState, |
| 111 | + // Allow quick simulation of JavaScript disabled browser |
| 112 | + debugNoJS: renderRequest.url.searchParams.has("__nojs"), |
| 113 | + }); |
| 114 | + |
| 115 | + // Respond HTML |
| 116 | + return new Response(ssrResult.stream, { |
| 117 | + status: ssrResult.status, |
| 118 | + headers: { |
| 119 | + "Content-Type": "text/html", |
| 120 | + }, |
| 121 | + }); |
| 122 | +} |
| 123 | + |
| 124 | +if (import.meta.hot) { |
| 125 | + import.meta.hot.accept(); |
| 126 | +} |
0 commit comments