rwsdk
Version:
Build fast, server-driven webapps on Cloudflare with SSR, RSC, and realtime
674 lines (671 loc) • 26.9 kB
JavaScript
import React from "react";
import { isValidElementType } from "react-is";
const METHOD_VERBS = ["delete", "get", "head", "patch", "post", "put"];
const pathCache = new Map();
function compilePath(routePath) {
const cached = pathCache.get(routePath);
if (cached)
return cached;
// Check for invalid pattern: multiple colons in a segment (e.g., /:param1:param2/)
if (routePath.includes(":")) {
const segments = routePath.split("/");
for (const segment of segments) {
if ((segment.match(/:/g) || []).length > 1) {
throw new Error(`RedwoodSDK: Invalid route pattern: segment "${segment}" in "${routePath}" contains multiple colons. Each route parameter should use a single colon (e.g., ":id"). Check for accidental double colons ("::").`);
}
}
}
// Check for invalid pattern: double wildcard (e.g., /**/)
if (routePath.indexOf("**") !== -1) {
throw new Error(`RedwoodSDK: Invalid route pattern: "${routePath}" contains "**". Use "*" for a single wildcard segment. Double wildcards are not supported.`);
}
const isStatic = !routePath.includes(":") && !routePath.includes("*");
if (isStatic) {
const result = { isStatic: true, regex: null, paramMap: [] };
pathCache.set(routePath, result);
return result;
}
const paramMap = [];
let wildcardCounter = 0;
const tokenRegex = /:([a-zA-Z0-9_]+)|\*/g;
let matchToken;
while ((matchToken = tokenRegex.exec(routePath)) !== null) {
if (matchToken[1]) {
paramMap.push({ name: matchToken[1], isWildcard: false });
}
else {
paramMap.push({ name: `$${wildcardCounter++}`, isWildcard: true });
}
}
const pattern = routePath
.replace(/:[a-zA-Z0-9_]+/g, "([^/]+)") // Convert :param to capture group
.replace(/\*/g, "(.*)"); // Convert * to wildcard capture group
const result = {
isStatic: false,
regex: new RegExp(`^${pattern}$`),
paramMap,
};
pathCache.set(routePath, result);
return result;
}
export function matchPath(routePath, requestPath) {
const compiled = compilePath(routePath);
if (compiled.isStatic) {
return routePath === requestPath ? {} : null;
}
const matches = requestPath.match(compiled.regex);
if (!matches) {
return null;
}
const params = {};
for (let i = 0; i < compiled.paramMap.length; i++) {
const param = compiled.paramMap[i];
params[param.name] = matches[i + 1];
}
return params;
}
function flattenRoutes(routes) {
return routes.reduce((acc, route) => {
if (Array.isArray(route)) {
return [...acc, ...flattenRoutes(route)];
}
return [
...acc,
route,
];
}, []);
}
function isMethodHandlers(handler) {
return (typeof handler === "object" && handler !== null && !Array.isArray(handler));
}
function handleOptionsRequest(methodHandlers) {
const methods = new Set([
...(methodHandlers.config?.disableOptions ? [] : ["OPTIONS"]),
...METHOD_VERBS.filter((verb) => methodHandlers[verb]).map((verb) => verb.toUpperCase()),
...Object.keys(methodHandlers.custom ?? {}).map((method) => method.toUpperCase()),
]);
return new Response(null, {
status: 204,
headers: {
Allow: Array.from(methods).sort().join(", "),
},
});
}
function handleMethodNotAllowed(methodHandlers) {
const optionsResponse = handleOptionsRequest(methodHandlers);
return new Response("Method Not Allowed", {
status: 405,
headers: optionsResponse.headers,
});
}
function getHandlerForMethod(methodHandlers, method) {
const lowerMethod = method.toLowerCase();
// Check standard method verbs
if (METHOD_VERBS.includes(lowerMethod)) {
return methodHandlers[lowerMethod];
}
// Check custom methods (already normalized to lowercase)
return methodHandlers.custom?.[lowerMethod];
}
export function defineRoutes(routes) {
const flattenedRoutes = flattenRoutes(routes);
const compiledRoutes = flattenedRoutes.map((route) => {
if (typeof route === "function") {
return { type: "middleware", handler: route };
}
if (typeof route === "object" &&
route !== null &&
"__rwExcept" in route &&
route.__rwExcept === true) {
return { type: "except", handler: route };
}
const routeDef = route;
const compiledPath = compilePath(routeDef.path);
return {
type: "definition",
path: routeDef.path,
handler: routeDef.handler,
layouts: routeDef.layouts,
isStatic: compiledPath.isStatic,
regex: compiledPath.regex ?? undefined,
paramNames: compiledPath.paramMap
.filter((p) => !p.isWildcard)
.map((p) => p.name),
wildcardCount: compiledPath.paramMap.filter((p) => p.isWildcard).length,
};
});
return {
routes: flattenedRoutes,
async handle({ request, renderPage, getRequestInfo, onError, runWithRequestInfoOverrides, rscActionHandler, }) {
const requestInfo = getRequestInfo();
const url = new URL(request.url);
let path = url.pathname;
if (path !== "/" && !path.endsWith("/")) {
path = path + "/";
}
requestInfo.path = path;
// --- Helpers ---
// (Hoisted for readability)
function parseHandlers(handler) {
const handlers = Array.isArray(handler) ? handler : [handler];
const routeMiddlewares = handlers.slice(0, Math.max(handlers.length - 1, 0));
const componentHandler = handlers[handlers.length - 1];
return {
routeMiddlewares: routeMiddlewares,
componentHandler,
};
}
function renderElement(element) {
const requestInfo = getRequestInfo();
// Try to preserve the component name from the element's type
const elementType = element.type;
const componentName = typeof elementType === "function" && elementType.name
? elementType.name
: "Element";
const Element = () => element;
// Set the name for better debugging
Object.defineProperty(Element, "name", {
value: componentName,
configurable: true,
});
return renderPage(requestInfo, Element, onError);
}
async function handleMiddlewareResult(result) {
if (result instanceof Response) {
return result;
}
if (result && React.isValidElement(result)) {
return await renderElement(result);
}
return undefined;
}
function isExceptHandler(route) {
return route.type === "except";
}
async function executeExceptHandlers(error, startIndex) {
// Search backwards from startIndex to find the most recent except handler
for (let i = startIndex; i >= 0; i--) {
const route = compiledRoutes[i];
if (isExceptHandler(route)) {
try {
const result = await route.handler.handler(error, getRequestInfo());
const handled = await handleMiddlewareResult(result);
if (handled) {
return handled;
}
// If the handler didn't return a Response or JSX, continue to next handler (further back)
}
catch (nextError) {
// If the except handler itself throws, try the next one (further back)
return await executeExceptHandlers(nextError, i - 1);
}
}
}
// No handler found, throw to top-level onError
onError(error);
throw error;
}
// --- Main flow ---
let firstRouteDefinitionEncountered = false;
let actionHandled = false;
const handleAction = async () => {
// Handle RSC actions once per request, based on the incoming URL.
if (!actionHandled) {
const url = new URL(request.url);
if (url.searchParams.has("__rsc_action_id")) {
requestInfo.rw.actionResult = await rscActionHandler(request);
}
actionHandled = true;
}
};
try {
let currentRouteIndex = 0;
for (const route of compiledRoutes) {
// Skip except handlers during normal execution
if (route.type === "except") {
currentRouteIndex++;
continue;
}
if (route.type === "middleware") {
// This is a global middleware.
try {
const result = await route.handler(getRequestInfo());
const handled = await handleMiddlewareResult(result);
if (handled) {
return handled; // Short-circuit
}
}
catch (error) {
return await executeExceptHandlers(error, currentRouteIndex);
}
currentRouteIndex++;
continue;
}
// This is a RouteDefinition (route.type === "definition").
// The first time we see one, we handle any RSC actions.
if (!firstRouteDefinitionEncountered) {
firstRouteDefinitionEncountered = true;
try {
await handleAction();
}
catch (error) {
return await executeExceptHandlers(error, currentRouteIndex);
}
}
let params = null;
if (route.isStatic) {
if (route.path === path) {
params = {};
}
}
else if (route.regex) {
const matches = path.match(route.regex);
if (matches) {
params = {};
for (let i = 0; i < route.paramNames.length; i++) {
params[route.paramNames[i]] = matches[i + 1];
}
for (let i = 0; i < route.wildcardCount; i++) {
params[`$${i}`] = matches[route.paramNames.length + i + 1];
}
}
}
if (!params) {
currentRouteIndex++;
continue; // Not a match, keep going.
}
// Resolve handler if method-based routing
let handler;
if (isMethodHandlers(route.handler)) {
const requestMethod = request.method;
// Handle OPTIONS request
if (requestMethod === "OPTIONS" &&
!route.handler.config?.disableOptions) {
return handleOptionsRequest(route.handler);
}
// Try to find handler for the request method
handler = getHandlerForMethod(route.handler, requestMethod);
if (!handler) {
// Method not supported for this route
if (!route.handler.config?.disable405) {
return handleMethodNotAllowed(route.handler);
}
// If 405 is disabled, continue to next route
currentRouteIndex++;
continue;
}
}
else {
handler = route.handler;
}
// Found a match: run route-specific middlewares, then the final component, then stop.
try {
return await runWithRequestInfoOverrides({ params }, async () => {
const { routeMiddlewares, componentHandler } = parseHandlers(handler);
// Route-specific middlewares
for (const mw of routeMiddlewares) {
const result = await mw(getRequestInfo());
const handled = await handleMiddlewareResult(result);
if (handled) {
return handled;
}
}
// Final component/handler
if (isRouteComponent(componentHandler)) {
const requestInfo = getRequestInfo();
const WrappedComponent = wrapWithLayouts(wrapHandlerToThrowResponses(componentHandler), route.layouts || [], requestInfo);
if (!isClientReference(componentHandler)) {
requestInfo.rw.pageRouteResolved = Promise.withResolvers();
}
return await renderPage(requestInfo, WrappedComponent, onError);
}
// Handle non-component final handler (e.g., returns new Response)
const tailResult = await componentHandler(getRequestInfo());
const handledTail = await handleMiddlewareResult(tailResult);
if (handledTail) {
return handledTail;
}
const handlerName = typeof componentHandler === "function" &&
componentHandler.name
? componentHandler.name
: "anonymous";
const errorMessage = `Route handler did not return a Response or React element.
Route: ${route.path}
Matched path: ${path}
Method: ${request.method}
Handler: ${handlerName}
Route handlers must return one of:
- A Response object (e.g., \`new Response("OK")\`)
- A React element (e.g., \`<div>Hello</div>\`)
- \`void\` (if handled by middleware earlier in the chain)`;
return new Response(errorMessage, {
status: 500,
headers: { "Content-Type": "text/plain" },
});
});
}
catch (error) {
return await executeExceptHandlers(error, currentRouteIndex);
}
}
// If we've gotten this far, no route was matched.
// We still need to handle a possible action if the app has no route definitions at all.
if (!firstRouteDefinitionEncountered) {
try {
await handleAction();
}
catch (error) {
return await executeExceptHandlers(error, compiledRoutes.length - 1);
}
}
return new Response("Not Found", { status: 404 });
}
catch (error) {
// Top-level catch for any unhandled errors
return await executeExceptHandlers(error, compiledRoutes.length - 1);
}
},
};
}
/**
* Defines a route handler for a path pattern.
*
* Supports three types of path patterns:
* - Static: /about, /contact
* - Parameters: /users/:id, /posts/:postId/edit
* - Wildcards: /files/\*, /api/\*\/download
*
* @example
* // Static route
* route("/about", () => <AboutPage />)
*
* @example
* // Route with parameters
* route("/users/:id", ({ params }) => {
* return <UserProfile userId={params.id} />
* })
*
* @example
* // Route with wildcards
* route("/files/*", ({ params }) => {
* const filePath = params.$0
* return <FileViewer path={filePath} />
* })
*
* @example
* // Method-based routing
* route("/api/users", {
* get: () => Response.json(users),
* post: ({ request }) => Response.json({ status: "created" }, { status: 201 }),
* delete: () => new Response(null, { status: 204 }),
* })
*
* @example
* // Route with middleware array
* route("/admin", [isAuthenticated, isAdmin, () => <AdminDashboard />])
*/
export function route(path, handler) {
let normalizedPath = path;
if (!normalizedPath.startsWith("/")) {
normalizedPath = "/" + normalizedPath;
}
if (!normalizedPath.endsWith("/")) {
normalizedPath = normalizedPath + "/";
}
// Normalize custom method keys to lowercase
if (isMethodHandlers(handler) && handler.custom) {
handler = {
...handler,
custom: Object.fromEntries(Object.entries(handler.custom).map(([method, methodHandler]) => [
method.toLowerCase(),
methodHandler,
])),
};
}
return {
path: normalizedPath,
handler,
__rwPath: normalizedPath,
};
}
/**
* Defines a route handler for the root path "/".
*
* @example
* // Homepage
* index(() => <HomePage />)
*
* @example
* // With middleware
* index([logRequest, () => <HomePage />])
*/
export function index(handler) {
return route("/", handler);
}
/**
* Defines an error handler that catches errors from routes, middleware, and RSC actions.
*
* @example
* // Global error handler
* except((error, requestInfo) => {
* console.error(error);
* return new Response("Internal Server Error", { status: 500 });
* })
*
* @example
* // Error handler that returns a React component
* except((error) => {
* return <ErrorPage error={error} />;
* })
*/
export function except(handler) {
return { __rwExcept: true, handler };
}
/**
* Prefixes a group of routes with a path.
*
* @example
* // Organize blog routes under /blog
* const blogRoutes = [
* route("/", () => <BlogIndex />),
* route("/post/:id", ({ params }) => <BlogPost id={params.id} />),
* route("/admin", [isAuthenticated, () => <BlogAdmin />]),
* ]
*
* // In worker.tsx
* defineApp([
* render(Document, [
* route("/", () => <HomePage />),
* prefix("/blog", blogRoutes),
* ]),
* ])
*/
function joinPaths(p1, p2) {
// Normalize p1: ensure it doesn't end with / (except if it's just "/")
const part1 = p1 === "/" ? "/" : p1.endsWith("/") ? p1.slice(0, -1) : p1;
// Normalize p2: ensure it starts with /
const part2 = p2.startsWith("/") ? p2 : `/${p2}`;
return part1 + part2;
}
export function prefix(prefixPath, routes) {
// Normalize prefix path
let normalizedPrefix = prefixPath;
if (!normalizedPrefix.startsWith("/")) {
normalizedPrefix = "/" + normalizedPrefix;
}
if (!normalizedPrefix.endsWith("/")) {
normalizedPrefix = normalizedPrefix + "/";
}
// Check if prefix has parameters
const hasParams = normalizedPrefix.includes(":") || normalizedPrefix.includes("*");
// Create a pattern for matching: if prefix has params, append wildcard to match any path under it
const matchPattern = hasParams
? normalizedPrefix.endsWith("/")
? normalizedPrefix.slice(0, -1) + "/*"
: normalizedPrefix + "/*"
: normalizedPrefix;
const prefixed = routes.map((r) => {
if (typeof r === "function") {
const middleware = (requestInfo) => {
const path = requestInfo.path;
// Check if path matches the prefix pattern
let matches = false;
let prefixParams = {};
if (hasParams) {
// Use matchPath to check if path matches and extract params
const params = matchPath(matchPattern, path);
if (params) {
matches = true;
prefixParams = params;
}
}
else {
// For static prefixes, use simple string matching
if (path === normalizedPrefix || path.startsWith(normalizedPrefix)) {
matches = true;
}
}
if (matches) {
// Merge prefix params with existing params
const mergedParams = { ...requestInfo.params, ...prefixParams };
// Create a new requestInfo with merged params
const modifiedRequestInfo = {
...requestInfo,
params: mergedParams,
};
return r(modifiedRequestInfo);
}
return;
};
return middleware;
}
if (typeof r === "object" &&
r !== null &&
"__rwExcept" in r &&
r.__rwExcept === true) {
// Pass through ExceptHandler as-is
return r;
}
if (Array.isArray(r)) {
// Recursively process nested route arrays
return prefix(prefixPath, r);
}
const routeDef = r;
// Use joinPaths to properly combine paths
const combinedPath = joinPaths(prefixPath, routeDef.path);
// Normalize double slashes
const normalizedCombinedPath = combinedPath.replace(/\/+/g, "/");
return {
path: normalizedCombinedPath,
handler: routeDef.handler,
...(routeDef.layouts && { layouts: routeDef.layouts }),
};
});
return prefixed;
}
function wrapWithLayouts(Component, layouts = [], requestInfo) {
if (layouts.length === 0) {
return Component;
}
// Check if the final route component is a client component
const isRouteClientComponent = Object.prototype.hasOwnProperty.call(Component, "$$isClientReference");
// Create nested layout structure - layouts[0] should be outermost, so use reduceRight
return layouts.reduceRight((WrappedComponent, Layout) => {
const Wrapped = (props) => {
const isClientComponent = Object.prototype.hasOwnProperty.call(Layout, "$$isClientReference");
return React.createElement(Layout, {
children: React.createElement(WrappedComponent, isRouteClientComponent ? {} : props),
// Only pass requestInfo to server components to avoid serialization issues
...(isClientComponent ? {} : { requestInfo }),
});
};
return Wrapped;
}, Component);
}
// context(justinvdm, 31 Jul 2025): We need to wrap the handler's that might
// return react elements, so that it throws the response to bubble it up and
// break out of react rendering context This way, we're able to return a
// response from the handler while still staying within react rendering context
export const wrapHandlerToThrowResponses = (handler) => {
if (isClientReference(handler) ||
!isRouteComponent(handler) ||
Object.prototype.hasOwnProperty.call(handler, "__rwsdk_route_component")) {
return handler;
}
const ComponentWrappedToThrowResponses = async (requestInfo) => {
const result = await handler(requestInfo);
if (result instanceof Response) {
requestInfo.rw.pageRouteResolved?.reject(result);
throw result;
}
requestInfo.rw.pageRouteResolved?.resolve();
return result;
};
ComponentWrappedToThrowResponses.__rwsdk_route_component = true;
return ComponentWrappedToThrowResponses;
};
/**
* Wraps routes with a layout component.
*
* @example
* // Define a layout component
* function BlogLayout({ children }: { children?: React.ReactNode }) {
* return (
* <div>
* <nav>Blog Navigation</nav>
* <main>{children}</main>
* </div>
* )
* }
*
* // Apply layout to routes
* const blogRoutes = layout(BlogLayout, [
* route("/", () => <BlogIndex />),
* route("/post/:id", ({ params }) => <BlogPost id={params.id} />),
* ])
*/
export function layout(LayoutComponent, routes) {
return routes.map((route) => {
if (typeof route === "function") {
// Pass through middleware as-is
return route;
}
if (typeof route === "object" &&
route !== null &&
"__rwExcept" in route &&
route.__rwExcept === true) {
// Pass through ExceptHandler as-is
return route;
}
if (Array.isArray(route)) {
// Recursively process nested route arrays
return layout(LayoutComponent, route);
}
const routeDef = route;
return {
...routeDef,
layouts: [LayoutComponent, ...(routeDef.layouts || [])],
};
});
}
export function render(Document, routes, options = {}) {
options = {
rscPayload: true,
ssr: true,
...options,
};
const documentMiddleware = ({ rw }) => {
rw.Document = Document;
rw.rscPayload = options.rscPayload ?? true;
rw.ssr = options.ssr ?? true;
};
return [documentMiddleware, ...routes];
}
function isRouteComponent(handler) {
return (Object.prototype.hasOwnProperty.call(handler, "__rwsdk_route_component") ||
(isValidElementType(handler) && handler.toString().includes("jsx")) ||
isClientReference(handler));
}
export const isClientReference = (value) => {
return Object.prototype.hasOwnProperty.call(value, "$$isClientReference");
};