UNPKG

rwsdk

Version:

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

461 lines (460 loc) 17.7 kB
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"); };