UNPKG

rwsdk

Version:

Build fast, server-driven webapps on Cloudflare with SSR, RSC, and realtime

240 lines (239 loc) 9.98 kB
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"); };