Skip to main content
Module

x/cav/stack.ts

Experimental server framework for Deno
Go to Latest
File
// Copyright 2022 Connor Logan. All rights reserved. MIT License.// This module is server-only.// *bread*
// TODO: Add support for arrays of handlers // TODO: Don't throw errors when using advanced URLPattern syntax (just use the// unknown type on the client when they use it instead)
import { http } from "./deps.ts";import { requestData, NO_MATCH } from "./http.ts";
/** * An http.Handler whose sole purpose is routing requests to Rpcs or other * Handlers. Stacks are one of two fundamental building blocks of Cav server * applications, the other being Rpcs. If a matching handler or Rpc throws the * special NO_MATCH error, the stack continues looking for matching handlers to * process the request into a response. Stacks can capture path groups while * routing the request; the captured groups will become the basis for the * ResolverArg's `groups` property inside a matching Rpc (before parsing). */export interface Stack< R extends StackRoutes = StackRoutes,> { (req: Request, connInfo: http.ConnInfo): Promise<Response>; /** The StackRoutes object used to construct this stack. */ readonly routes: R;}
/** Type alias that matches any Stack. Useful for type constraints. */// deno-lint-ignore no-explicit-anyexport type AnyStack = Stack<any>;
/** * A routing map used by the constructed Stack to find matching handlers to * handle an incoming request. */export interface StackRoutes { [x: string]: http.Handler | StackRoutes | null;}
const nextPathGroupName = "__nextPath";
/** * Constructs a StackHandler using the given StackRoutes object. The first * matching handler in the provided routes to either return a Response or throw * an unknown error halts the matching process. If a handler throws the special * NO_MATCH error, the error is suppressed and matching continues. See the * documentation for more details about StackRoutes and how routing inside a * Stack works. * * TODO: the referenced documentation lol */export function stack<R extends StackRoutes>(routes: R): Stack<R> { // Stack routes can only use some of the features of URLPattern. If attempts // are made to use features that aren't supported, throw an error for (const [k, _] of Object.entries(routes)) { const split = k.split("/").filter(v => !!v);
// Trailing wildcard needs to be let through if (split[split.length-1] === "*") { split.pop(); }
// Make sure the stack route is simple for (const s of split) { if ( !s.match(/^:[a-zA-Z_$][a-zA-Z_$0-9]$/) && !s.match(/^[^:*?(){}]*$/) // TODO: I don't think this is fully correct ) { throw new SyntaxError( `"${k}" isn't a valid stack route. Stack routes only support basic path segments and named path segments (groups). Non-trailing wildcards, RegExp, optionals, and other advanced URLPattern syntax is not supported`, ); } } }
const handlers: Record<string, http.Handler> = {}; for (const k of Object.keys(routes)) { const v = routes[k]; if (v && typeof v === "object") { handlers[k] = stack(v); } else if (v) { handlers[k] = v; } }
// Sort the handlers like this: // 1. Solo wildcards are always last // 2. By path depth. Paths with more path segments get tested sooner. // (Trailing wildcards don't affect path depth) // 3. For two paths that have the same depth, index order is used (lower // index === higher priority) const paths = Object.keys(handlers); const sortedPaths: string[] = paths.sort((a, b) => { if (a === b) { return 0; } // #1 if (a === "*" || a === "/*") { return 1; } if (b === "*" || b === "/*") { return -1; }
// #2 if (a.endsWith("/*")) { a = a.slice(0, a.length - 2); } if (b.endsWith("/*")) { b = b.slice(0, b.length - 2); } const la = a.split("/").filter(v => !!v).length; const lb = b.split("/").filter(v => !!v).length; if (la !== lb) { return lb - la; }
// #3 return paths.indexOf(a) - paths.indexOf(b); });
const patterns = new Map<URLPattern, http.Handler>(); for (const op of sortedPaths) { let p = "/" + op.split("/").filter(v => !!v).join("/");
// When the path ends in a wildcard, that wildcard will be used to determine // the next path on the stack. Without it, the path will need to match // exactly, and the next "path" property for the request inside the nested // handler will be "/" if (p.endsWith("/*")) { p = `${p.slice(0, p.length - 2)}/:${nextPathGroupName}*/*?`; }
patterns.set(new URLPattern(p, "http://_._"), handlers[op]); }
const handler = async ( req: Request, conn: http.ConnInfo, ): Promise<Response> => { const data = requestData(req);
for (const [pattern, handler] of patterns.entries()) { const match = pattern.exec(data.path, "http://_._"); if (!match) { continue; }
const groups = { ...data.groups, ...match.pathname.groups }; const path = `/${groups[nextPathGroupName] || ""}`; delete groups[nextPathGroupName];
const oPath = data.path; const oGroups = data.groups;
// The data object is only created once for every request, therefore // modifications to the data object will be preserved across handling // contexts Object.assign(data, { path, groups });
try { return await handler(req, conn); } catch (e) { // Keep looking for matches if it's the NO_MATCH error. All other errors // bubble if (e === NO_MATCH) { continue; } throw e; } finally { // Before moving on, put the path and groups back to their original // state Object.assign(req, { path: oPath, groups: oGroups }); } }
// When nothing matches, throw NO_MATCH throw NO_MATCH; };
return Object.assign(handler, { routes });}