rwsdk
Version:
Build fast, server-driven webapps on Cloudflare with SSR, RSC, and realtime
220 lines (219 loc) • 9.26 kB
JavaScript
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]);
});
}
};