rwsdk
Version:
Build fast, server-driven webapps on Cloudflare with SSR, RSC, and realtime
277 lines (276 loc) • 11.7 kB
JavaScript
import { 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";
export { NavigationPending, useNavigationPending, } from "./navigationPending.js";
import { getCachedNavigationResponse } from "./navigationCache.js";
import { NavigationPayloadProvider } from "./navigationPending.js";
import { abortPendingNavigation, isPendingNavigationCommit, isSameNavigationDocumentUrl, } from "./navigationState.js";
import { configureRecovery } from "./recovery.js";
import { isActionResponse } from "./types";
export const fetchTransport = (transportContext) => {
const fetchCallServer = async (id, args, source = "action", method = "POST") => {
const pageUrl = new URL(window.location.href);
const url = new URL(pageUrl.href);
url.searchParams.set("__rsc", "");
const isAction = id != null;
if (isAction) {
url.searchParams.set("__rsc_action_id", id);
// If args are provided and method is GET, serialize them into the query string
if (args != null && method === "GET") {
url.searchParams.set("args", JSON.stringify(args));
}
}
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 {
const headers = new Headers();
// Add x-rsc-data-only header if we want to skip the React tree render on the server
if (source === "query") {
headers.set("x-rsc-data-only", "true");
}
if (method === "GET") {
fetchPromise = fetch(url, {
method: "GET",
headers,
redirect: "manual",
});
}
else {
fetchPromise = fetch(url, {
method: "POST",
headers,
redirect: "manual",
body: args != null ? await encodeReply(args) : null,
});
}
}
const discardStaleNavigationResponse = () => {
if (source !== "navigation" ||
isSameNavigationDocumentUrl(pageUrl, window.location.href)) {
return false;
}
if (isPendingNavigationCommit(pageUrl)) {
abortPendingNavigation();
}
return true;
};
const processActionResponse = (rawActionResult) => {
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;
}
if (actionResponse.status >= 400) {
throw new Error(`Server function returned status ${actionResponse.status}`);
}
}
return rawActionResult;
}
return rawActionResult;
};
// If there's a response handler, check the response first
if (transportContext.handleResponse) {
const response = await fetchPromise;
if (discardStaleNavigationResponse()) {
return undefined;
}
const shouldContinue = transportContext.handleResponse(response);
if (!shouldContinue) {
return undefined;
}
// Continue with the response if handler returned true
const streamData = createFromFetch(Promise.resolve(response), {
callServer: fetchCallServer,
});
if (source === "navigation" || source === "action") {
if (discardStaleNavigationResponse()) {
return undefined;
}
transportContext.setRscPayload(streamData, {
source,
href: pageUrl.href,
});
}
const result = await streamData;
return processActionResponse(result.actionResult);
}
// Original behavior when no handler is present
const response = await fetchPromise;
if (discardStaleNavigationResponse()) {
return undefined;
}
const location = response.headers.get("Location");
if (response.status >= 300 && response.status < 400 && location) {
window.location.href = location;
return undefined;
}
const streamData = createFromFetch(Promise.resolve(response), {
callServer: fetchCallServer,
});
if (source === "navigation" || source === "action") {
if (discardStaleNavigationResponse()) {
return undefined;
}
transportContext.setRscPayload(streamData, {
source,
href: pageUrl.href,
});
}
const result = await streamData;
return processActionResponse(result.actionResult);
};
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 onHydrated - 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, initClientNavigation } from "rwsdk/client";
*
* // RedwoodSDK uses RSC RPC to emulate client side navigation.
* // https://docs.rwsdk.com/guides/frontend/client-side-nav/
* const { handleResponse, onHydrated } = initClientNavigation();
* initClient({ handleResponse, onHydrated });
*
* @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, onHydrated, onActionResponse, onModuleNotFound, } = {}) => {
if (onModuleNotFound) {
configureRecovery({ onModuleNotFound });
}
const transportContext = {
setRscPayload: () => { },
handleResponse,
onHydrated,
onActionResponse,
};
let transportCallServer = transport(transportContext);
const callServer = (id, args, source, method) => {
return transportCallServer(id, args, source, method);
};
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 {children}.');
}
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 [streamState, setStreamState] = React.useState(rscPayload
? {
data: rscPayload,
meta: { source: "initial", href: window.location.href },
}
: null);
const [_isPending, startTransition] = React.useTransition();
transportContext.setRscPayload = (v, meta) => startTransition(() => {
setStreamState({ data: v, meta });
});
React.useEffect(() => {
if (!streamState)
return;
transportContext.onHydrated?.(streamState.meta);
}, [streamState]);
return (_jsx(NavigationPayloadProvider, { meta: streamState?.meta, children: streamState
? React.use(streamState.data).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]);
});
}
};