UNPKG

rwsdk

Version:

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

674 lines (671 loc) 26.9 kB
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"); };