rwsdk
Version:
Build fast, server-driven webapps on Cloudflare with SSR, RSC, and realtime
240 lines (239 loc) • 9.98 kB
JavaScript
import React from "react";
import { isValidElementType } from "react-is";
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];
}, []);
}
export function defineRoutes(routes) {
const flattenedRoutes = flattenRoutes(routes);
return {
routes: flattenedRoutes,
async handle({ request, renderPage, getRequestInfo, onError, runWithRequestInfoOverrides, }) {
const url = new URL(request.url);
let path = url.pathname;
// Must end with a trailing slash.
if (path !== "/" && !path.endsWith("/")) {
path = path + "/";
}
// Find matching route
let match = null;
for (const route of flattenedRoutes) {
if (typeof route === "function") {
const r = await route(getRequestInfo());
if (r instanceof Response) {
return r;
}
continue;
}
const params = matchPath(route.path, path);
if (params) {
match = { params, handler: route.handler, layouts: route.layouts };
break;
}
}
if (!match) {
// todo(peterp, 2025-01-28): Allow the user to define their own "not found" route.
return new Response("Not Found", { status: 404 });
}
let { params, handler, layouts } = match;
return runWithRequestInfoOverrides({ params }, async () => {
const handlers = Array.isArray(handler) ? handler : [handler];
for (const h of handlers) {
if (isRouteComponent(h)) {
const requestInfo = getRequestInfo();
const WrappedComponent = wrapWithLayouts(wrapHandlerToThrowResponses(h), layouts || [], requestInfo);
if (!isClientReference(h)) {
// context(justinvdm, 31 Jul 2025): We now know we're dealing with a page route,
// so we create a deferred so that we can signal when we're done determining whether
// we're returning a response or a react element
requestInfo.rw.pageRouteResolved = Promise.withResolvers();
}
return await renderPage(requestInfo, WrappedComponent, onError);
}
else {
const r = await h(getRequestInfo());
if (r instanceof Response) {
return r;
}
}
}
// Add fallback return
return new Response("Response not returned from route handler", {
status: 500,
});
});
},
};
}
export function route(path, handler) {
if (!path.endsWith("/")) {
path = path + "/";
}
return {
path,
handler,
};
}
export function index(handler) {
return route("/", handler);
}
export function prefix(prefixPath, routes) {
return routes.map((r) => {
if (typeof r === "function") {
// Pass through middleware as-is
return r;
}
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;
};
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 || [])],
};
});
}
export function render(Document, routes,
/**
* @param options - Configuration options for rendering.
* @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.
*/
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");
};