rwsdk
Version:
Build fast, server-driven webapps on Cloudflare with SSR, RSC, and realtime
461 lines (460 loc) • 17.7 kB
JavaScript
import React from "react";
import { isValidElementType } from "react-is";
const METHOD_VERBS = ["delete", "get", "head", "patch", "post", "put"];
export function matchPath(routePath, requestPath) {
// 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(`Invalid route pattern: segment "${segment}" in "${routePath}" contains multiple colons.`);
}
}
}
// Check for invalid pattern: double wildcard (e.g., /**/)
if (routePath.indexOf("**") !== -1) {
throw new Error(`Invalid route pattern: "${routePath}" contains "**". Use "*" for a single wildcard segment.`);
}
const pattern = routePath
.replace(/:[a-zA-Z0-9]+/g, "([^/]+)") // Convert :param to capture group
.replace(/\*/g, "(.*)"); // Convert * to wildcard capture group
const regex = new RegExp(`^${pattern}$`);
const matches = requestPath.match(regex);
if (!matches) {
return null;
}
// Revised parameter extraction:
const params = {};
let currentMatchIndex = 1; // Regex matches are 1-indexed
// This regex finds either a named parameter token (e.g., ":id") or a wildcard star token ("*").
const tokenRegex = /:([a-zA-Z0-9_]+)|\*/g;
let matchToken;
let wildcardCounter = 0;
// Ensure regex starts from the beginning of the routePath for each call if it's stateful (it is with /g)
tokenRegex.lastIndex = 0;
while ((matchToken = tokenRegex.exec(routePath)) !== null) {
// Ensure we have a corresponding match from the regex execution
if (matches[currentMatchIndex] === undefined) {
// This case should ideally not be hit if routePath and pattern generation are correct
// and all parts of the regex matched.
// Consider logging a warning or throwing an error if critical.
break;
}
if (matchToken[1]) {
// This token is a named parameter (e.g., matchToken[1] is "id" for ":id")
params[matchToken[1]] = matches[currentMatchIndex];
}
else {
// This token is a wildcard "*"
params[`$${wildcardCounter}`] = matches[currentMatchIndex];
wildcardCounter++;
}
currentMatchIndex++;
}
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);
return {
routes: flattenedRoutes,
async handle({ request, renderPage, getRequestInfo, onError, runWithRequestInfoOverrides, rscActionHandler, }) {
const url = new URL(request.url);
let path = url.pathname;
// Must end with a trailing slash.
if (path !== "/" && !path.endsWith("/")) {
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();
const Element = () => element;
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;
}
// --- Main flow ---
let firstRouteDefinitionEncountered = false;
let actionHandled = false;
const handleAction = async () => {
if (!actionHandled && url.searchParams.has("__rsc_action_id")) {
getRequestInfo().rw.actionResult = await rscActionHandler(request);
actionHandled = true;
}
};
for (const route of flattenedRoutes) {
if (typeof route === "function") {
// This is a global middleware.
const result = await route(getRequestInfo());
const handled = await handleMiddlewareResult(result);
if (handled) {
return handled; // Short-circuit
}
continue;
}
// This is a RouteDefinition.
// The first time we see one, we handle any RSC actions.
if (!firstRouteDefinitionEncountered) {
firstRouteDefinitionEncountered = true;
await handleAction();
}
const params = matchPath(route.path, path);
if (!params) {
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
continue;
}
}
else {
handler = route.handler;
}
// Found a match: run route-specific middlewares, then the final component, then stop.
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;
}
return new Response("Response not returned from route handler", {
status: 500,
});
});
}
// 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) {
await handleAction();
}
return new Response("Not Found", { status: 404 });
},
};
}
/**
* 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) {
if (!path.endsWith("/")) {
path = path + "/";
}
// 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,
handler,
};
}
/**
* 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);
}
/**
* 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),
* ]),
* ])
*/
export function prefix(prefixPath, routes) {
return routes.map((r) => {
if (typeof r === "function") {
const middleware = (requestInfo) => {
const url = new URL(requestInfo.request.url);
if (url.pathname.startsWith(prefixPath)) {
return r(requestInfo);
}
return;
};
return middleware;
}
if (Array.isArray(r)) {
// Recursively process nested route arrays
return prefix(prefixPath, r);
}
// For RouteDefinition objects, update the path and preserve layouts
return {
path: prefixPath + r.path,
handler: r.handler,
...(r.layouts && { layouts: r.layouts }),
};
});
}
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) {
// Attach layouts directly to route definitions
return routes.map((route) => {
if (typeof route === "function") {
// Pass through middleware as-is
return route;
}
if (Array.isArray(route)) {
// Recursively process nested route arrays
return layout(LayoutComponent, route);
}
// For RouteDefinition objects, prepend the layout so outer layouts come first
return {
...route,
layouts: [LayoutComponent, ...(route.layouts || [])],
};
});
}
/**
* Wraps routes with a Document component and configures rendering options.
*
* @param options.rscPayload - Toggle the RSC payload that's appended to the Document. Disabling this will mean that interactivity no longer works.
* @param options.ssr - Disable sever side rendering for all these routes. This only allow client side rendering, which requires `rscPayload` to be enabled.
*
* @example
* // Basic usage
* defineApp([
* render(Document, [
* route("/", () => <HomePage />),
* route("/about", () => <AboutPage />),
* ]),
* ])
*
* @example
* // With custom rendering options
* render(Document, [
* route("/", () => <HomePage />),
* ], {
* rscPayload: true,
* ssr: true,
* })
*/
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");
};