UNPKG

rwsdk

Version:

Build fast, server-driven webapps on Cloudflare with SSR, RSC, and realtime

220 lines (219 loc) 9.26 kB
import { Fragment as _Fragment, jsx as _jsx } from "react/jsx-runtime"; // note(justinvdm, 14 Aug 2025): Rendering related imports and logic go here. // See client.tsx for the actual client entrypoint. // context(justinvdm, 14 Aug 2025): `react-server-dom-webpack` uses this global // to load modules, so we need to define it here before importing // "react-server-dom-webpack." // prettier-ignore import "./setWebpackRequire"; import React from "react"; import { hydrateRoot } from "react-dom/client"; import { createFromFetch, createFromReadableStream, encodeReply, } from "react-server-dom-webpack/client.browser"; import { rscStream } from "rsc-html-stream/client"; export { default as React } from "react"; export { ClientOnly } from "./ClientOnly.js"; export { initClientNavigation, navigate } from "./navigation.js"; import { getCachedNavigationResponse } from "./navigationCache.js"; import { isActionResponse } from "./types"; export const fetchTransport = (transportContext) => { const fetchCallServer = async (id, args, source = "action") => { const url = new URL(window.location.href); url.searchParams.set("__rsc", ""); const isAction = id != null; if (isAction) { url.searchParams.set("__rsc_action_id", id); } let fetchPromise; if (!isAction && source === "navigation") { // Try to get cached response first const cachedResponse = await getCachedNavigationResponse(url); if (cachedResponse) { fetchPromise = Promise.resolve(cachedResponse); } else { // Fall back to network fetch on cache miss fetchPromise = fetch(url, { method: "GET", redirect: "manual", }); } } else { fetchPromise = fetch(url, { method: "POST", redirect: "manual", body: args != null ? await encodeReply(args) : null, }); } // If there's a response handler, check the response first if (transportContext.handleResponse) { const response = await fetchPromise; const shouldContinue = transportContext.handleResponse(response); if (!shouldContinue) { return; } // Continue with the response if handler returned true const streamData = createFromFetch(Promise.resolve(response), { callServer: fetchCallServer, }); transportContext.setRscPayload(streamData); const result = await streamData; const rawActionResult = result.actionResult; if (isActionResponse(rawActionResult)) { const actionResponse = rawActionResult.__rw_action_response; const handledByHook = transportContext.onActionResponse?.(actionResponse) === true; if (!handledByHook) { const location = actionResponse.headers["location"]; const isRedirect = actionResponse.status >= 300 && actionResponse.status < 400; if (location && isRedirect) { window.location.href = location; return undefined; } } return rawActionResult; } return rawActionResult; } // Original behavior when no handler is present const streamData = createFromFetch(fetchPromise, { callServer: fetchCallServer, }); transportContext.setRscPayload(streamData); const result = await streamData; const rawActionResult = result.actionResult; if (isActionResponse(rawActionResult)) { const actionResponse = rawActionResult.__rw_action_response; const handledByHook = transportContext.onActionResponse?.(actionResponse) === true; if (!handledByHook) { const location = actionResponse.headers["location"]; const isRedirect = actionResponse.status >= 300 && actionResponse.status < 400; if (location && isRedirect) { window.location.href = location; return undefined; } } return rawActionResult; } return rawActionResult; }; return fetchCallServer; }; /** * Initializes the React client and hydrates the RSC payload. * * This function sets up client-side hydration for React Server Components, * making the page interactive. Call this from your client entry point. * * @param transport - Custom transport for server communication (defaults to fetchTransport) * @param hydrateRootOptions - Options passed to React's `hydrateRoot`. Supports all React hydration options including: * - `onUncaughtError`: Handler for uncaught errors (async errors, event handler errors). * If not provided, defaults to logging errors to console. * - `onCaughtError`: Handler for errors caught by error boundaries * - `onRecoverableError`: Handler for recoverable errors * @param handleResponse - Custom response handler for navigation errors (navigation GETs) * @param onHydrationUpdate - Callback invoked after a new RSC payload has been committed on the client * @param onActionResponse - Optional hook invoked when an action returns a Response; * return true to signal that the response has been handled and * default behaviour (e.g. redirects) should be skipped * * @example * // Basic usage * import { initClient } from "rwsdk/client"; * * initClient(); * * @example * // With client-side navigation * import { initClient, initClientNavigation } from "rwsdk/client"; * * const { handleResponse } = initClientNavigation(); * initClient({ handleResponse }); * * @example * // With error handling * initClient({ * hydrateRootOptions: { * onUncaughtError: (error, errorInfo) => { * console.error("Uncaught error:", error); * // Send to monitoring service * sendToSentry(error, errorInfo); * }, * onCaughtError: (error, errorInfo) => { * console.error("Caught error:", error); * // Handle errors from error boundaries * sendToSentry(error, errorInfo); * }, * }, * }); * * @example * // With custom React hydration options * initClient({ * hydrateRootOptions: { * onRecoverableError: (error) => { * console.warn("Recoverable error:", error); * }, * }, * }); */ export const initClient = async ({ transport = fetchTransport, hydrateRootOptions, handleResponse, onHydrationUpdate, onActionResponse, } = {}) => { const transportContext = { setRscPayload: () => { }, handleResponse, onHydrationUpdate, onActionResponse, }; let transportCallServer = transport(transportContext); const callServer = (id, args, source) => { return transportCallServer(id, args, source); }; const upgradeToRealtime = async ({ key } = {}) => { const { realtimeTransport } = await import("../lib/realtime/client"); const createRealtimeTransport = realtimeTransport({ key }); transportCallServer = createRealtimeTransport(transportContext); }; globalThis.__rsc_callServer = callServer; globalThis.__rw = { callServer, upgradeToRealtime, }; const rootEl = document.getElementById("hydrate-root"); if (!rootEl) { throw new Error('RedwoodSDK: No element with id "hydrate-root" found in the document. This element is required for hydration. Ensure your Document component contains a <div id="hydrate-root">{children}</div>.'); } let rscPayload; // context(justinvdm, 18 Jun 2025): We inject the RSC payload // unless render(Document, [...], { rscPayload: false }) was used. if (globalThis.__FLIGHT_DATA) { rscPayload = createFromReadableStream(rscStream, { callServer, }); } function Content() { const [streamData, setStreamData] = React.useState(rscPayload); const [_isPending, startTransition] = React.useTransition(); transportContext.setRscPayload = (v) => startTransition(() => { setStreamData(v); }); React.useEffect(() => { if (!streamData) return; transportContext.onHydrationUpdate?.(); }, [streamData]); return (_jsx(_Fragment, { children: streamData ? React.use(streamData).node : null })); } hydrateRoot(rootEl, _jsx(Content, {}), { onUncaughtError: (error, { componentStack }) => { console.error("Uncaught error: %O\n\nComponent stack:%s", error, componentStack); }, ...hydrateRootOptions, }); if (import.meta.hot) { import.meta.hot.on("rsc:update", (e) => { console.log("[rwsdk] hot update", e.file); callServer("__rsc_hot_update", [e.file]); }); } };