Skip to main content
Go to Latest
File
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792
// Copyright 2022 Connor Logan. All rights reserved. MIT License.
// TODO: What happens when you try to upgrade for an assets request? // TODO: accept multiple strings for the path init option // TODO: files and blobs that flush to disk when a certain memory threshold is// reached. Using them works the same as regular files and blobs. They get// deleted at the end of the request // TODO: Incorporate the standard library's multipart reader // TODO: CORS // TODO: RpcLimits
import { http, path as stdPath } from "./deps.ts";import { NO_MATCH, requestData, requestBody, response, bakeCookie, upgradeWebSocket,} from "./http.ts";import { HttpError } from "./client.ts";import { serveAsset } from "./assets.ts";
import type { Socket, EndpointRequest, EndpointResponse,} from "./client.ts";import type { Cookie, Res,} from "./http.ts";import type { AnyParser, ParserOutput, ParserInput, ParserObject, ParserFunction,} from "./parser.ts";import type { Serializers } from "./serial.ts";import type { ServeAssetOptions } from "./assets.ts";
/** * An http.Handler constructed by an RpcFactory. Rpcs are one of two fundamental * building blocks of Cav server applications, the other being Stacks. Rpcs are * responsible for handling a request, and Stacks are responsible for routing a * request to the appropriate Rpc. Once a Request reaches an Rpc and the Rpc's * path matches the request, the Rpc is expected to handle all errors and return * a Response. If the path doesn't match the Rpc's path, the special NO_MATCH * error will be thrown. Uncaught errors bubble up to and are handled by the * top-level Server, which will log them and respond with a 500 Response. */export interface Rpc< Resp = unknown, Groups extends AnyParser | null = null, Context extends Ctx<unknown> | null = null, Query extends AnyParser | null = null, Message extends AnyParser | null = null, Upgrade extends boolean | null = null,> { ( req: EndpointRequest<ParserInput<Query>, ParserInput<Message>, Upgrade>, conn: http.ConnInfo, ): Promise<EndpointResponse<Resp>>; /** The RpcInit options used to construct this Rpc. */ readonly init: RpcInit<Resp, Groups, Context, Query, Message, Upgrade>;}
/** * Alias for an Rpc with any resolver or init types. Useful for type * constraints. */// export type AnyRpc = Rpc<// // deno-lint-ignore no-explicit-any// any,// AnyParser | null,// // deno-lint-ignore no-explicit-any// Ctx<any> | null,// AnyParser | null,// AnyParser | null// >;
/** * Initializer options when constructing Rpcs. */export interface RpcInit< Resp = unknown, Groups extends AnyParser | null = null, Context extends Ctx<unknown> | null = null, Query extends AnyParser | null = null, Message extends AnyParser | null = null, Upgrade extends boolean | null = null,>{ /** * If the routed path of the request doesn't match this URLPattern string, the * NO_MATCH error will be thrown and the stack will continue searching for * matching routes. If this string starts with "^", the full Request path is * used instead of the routed path. (The routed path is determined by the * containing stack, the full path comes from `req.url.pathname`.) The default * behavior expects that the containing stack(s) consumed the entire path, * thus leaving the Rpc path as "/". The full URLPattern syntax is supported, * and any captured path groups will be merged with the path groups captured * by the containing stack(s) before undergoing groups parsing. (See the docs * for the "groups" property.) The path that matched this string is available * on the ResolveArg, CtxArg, and ResolveErrorArg as the "path" property. * Default: `"/"` */ path?: string | null; /** * When a Request matches the path for this Rpc, all captured groups from the * Rpc and the Stack will be parsed using this Parser. If the Parser throws an * error, the error will be converted into a NO_MATCH and the containing stack * will continue looking for matching routes to handle the request. The result * of parsing is available on the ResolverArg as the "groups" property. If the * parser is "optional" (i.e. it successfully parses `undefined`), all parsing * errors will be suppressed and that fallback value will be used for the * ResolverArg whenever an error is thrown. Default: `null` */ groups?: Groups; /** * Controls what to do when a matching path does or does not have a trailing * slash at the end of it. "require" will cause paths to not match if there's * no trailing slash. "allow" will allow but not require trailing slashes * (note that this may have negative SEO implications if multiple paths lead * to the same page being served). "reject" will cause paths with a trailing * slash to not match. "redirect" will cause otherwise matching requests with * a trailing slash to be redirected to the same path without the trailing * slash. This setting is ignored if the requested path is the root path "/". * Default: `"redirect"` */ // REVIEW: I decided to remove the ability to change this behavior. I like the // consistency and aesthetic of "no trailing slashes", and there's code in // http.ts to account for re-based index files. There may be edge cases and it // might still be true that banning trailing slashes is a mistake, so I'm just // commenting the code out for now instead of removing it. // trailingSlash?: TrailingSlashOpt | null; /** * This limits the maximum size of the Request body. Note that, currently, the * entire Request body is loaded into memory during request processing. * Support for large file uploads is in the works. The unit is bytes. Default: * `5 * 1024 * 1024` (5 MB) */ maxBodySize?: number | null; /** * Keys used when creating the "cookie" that's available on the ResolverArg. * If this isn't provided, a random fallback key will be used. Default: `null` */ keys?: [string, ...string[]] | null; /** * Serializers used when serializing and deserializing request and response * bodies as well as socket messages. Default: `null` */ serializers?: Serializers | null; /** * If true, this causes requests to be upgraded into web sockets. Requests * that don't request an upgrade will be rejected. The resolve function should * return the response from the `upgrade()` utility available on the * ResolveArg. Default: `null` */ upgrade?: Upgrade; /** * A Ctx function responsible for constructing the "ctx" property available on * the ResolverArg. This is only run if the requested path matched. Default: * `null` */ ctx?: Context; /** * A parser used to parse the "query" object from the RequestData associated * with a Request. This data comes from the query string in the url of the * request. Default: `null` */ query?: Query; /** * A parser used to either (1) parse the Request body after it's unpacked * using unpackBody or (2) parse the message received if this Rpc results in a * webSocket connection via the "upgrade" utility function available on the * ResolverArg. If this Rpc is not a socket-type Rpc and this parser does not * successfully parse `undefined`, the allowed methods for this Rpc will only * include "POST", not "GET" or "HEAD". If this Parser is omitted, the only * allowed methods are "GET" and "HEAD". If it does parse `undefined`, all * three methods are allowed. Default: `null` */ message?: Message; /** * This function is called to resolve the parsed request into a response to * pack and send back to the client. */ resolve: Resolve< Resp, Groups, Context, Query, Message, Upgrade >; /** * When an error is thrown during processing, this function is meant to handle * the error. The return value will be packed into a Response to send back to * the client. Re-throwing the error will cause that error to be packed as * the response. Default: `null` */ resolveError?: ResolveError | null;}
/** * Constructs a new RpcInit. This simply returns the first argument, it's only * provided for typing purposes so that you don't need to manually specify the * type parameters. */export function rpcInit< Resp = unknown, Groups extends AnyParser | null = null, Context extends Ctx<unknown> | null = null, Query extends AnyParser | null = null, Message extends AnyParser | null = null, Upgrade extends boolean | null = null,>( init: RpcInit<Resp, Groups, Context, Query, Message, Upgrade>,): RpcInit<Resp, Groups, Context, Query, Message, Upgrade> { return init;}
/** * Matches any given RpcInit. Useful for type constraints. */export type AnyRpcInit = RpcInit< // deno-lint-ignore no-explicit-any any, AnyParser | null, // deno-lint-ignore no-explicit-any Ctx<any> | null, AnyParser | null, AnyParser | null, boolean | null>;
/** * Controls whether an Rpc redirects, allows, requires, or rejects paths with trailing slashes. */// export type TrailingSlashOpt = "redirect" | "allow" | "require" | "reject";
/** * In Cav, there is no middleware. To fill the gap, Rpcs can leverage Ctx * functions to create context-dependent data related to a request. These * functions are where you'd set up databases, create session objects, etc. The * value returned from this function is available as the `ctx` property for the * various Rpc event handler functions. */export interface Ctx<Val = unknown> { (x: CtxArg): Promise<Val> | Val;}
/** Arguments available to the Ctx function of an Rpc. */export interface CtxArg { /** The incoming Request. */ req: Request; /** * A ResponseInit applied to the Rpc response after resolving and packing the * value to send to the client. The Headers object is always available. If the * resolved value is a Response object already, the status and statusText will * be ignored but the headers will still be applied. */ res: Res; /** The WHATWG URL for the current Request. */ url: URL; /** The Deno-provided ConnInfo associated with the request. */ conn: http.ConnInfo; /** * The cookie constructed after the Rpc path matched the request. For socket * Rpcs, the cookies can only be altered inside the Ctx function. Alterations * inside the other event handlers won't be synced with the client because the * Response object was already sent. */ cookie: Cookie; /** The path that matched the Rpc's path init option. */ path: string; /** The raw query object associated with this request. */ query: Record<string, string | string[]>; /** The raw path groups object associated with this request. */ groups: Record<string, string>; /** * Some Ctx functions may need to run cleanup tasks once a Response is ready * to send back to the client. Functions registered with "whenDone" will be * added to a stack structure, and just before the Rpc returns a response, * each cleanup function will be popped from the stack and executed. */ whenDone: (fn: () => Promise<void> | void) => void;}
/** * After an Rpc matches with an incoming request, the Resolve function is * responsible for resolving the request data into a response to send back to * the client. The value returned from the Resolver will be packed with the * top-level response() function, i.e. it undergoes packing via packBody(). */export interface Resolve< Resp = unknown, Groups extends AnyParser | null = null, Context extends Ctx<unknown> | null = null, Query extends AnyParser | null = null, Message extends AnyParser | null = null, Upgrade extends boolean | null = null,> { (x: ResolveArg< Groups, Context, Query, Message, Upgrade >): Promise<Resp> | Resp;}
/** Arguments available to a Resolver function. */export interface ResolveArg< Groups extends AnyParser | null = null, Context extends Ctx<unknown> | null = null, Query extends AnyParser | null = null, Message extends AnyParser | null = null, Upgrade extends boolean | null = null,> { /** The incoming Request this Rpc is handling. */ req: Request; /** * A ResponseInit applied to the Rpc response after resolving and packing the * value to send to the client. The Headers object is always available. If the * resolved value is a Response object already, the status and statusText will * be ignored but the headers will still be applied. */ res: Res; /** The WHATWG URL for this request. */ url: URL; /** Connection information provided by Deno. */ conn: http.ConnInfo; /** A Cookie baked with the req and res headers. */ cookie: Cookie; /** The path that matched this Rpc's path init option. */ path: string; /** The parsed path groups object captured while routing the request. */ groups: ParserOutput<Groups>; /** The context created by this Rpc's Ctx function. */ ctx: Context extends Ctx<infer C> ? C : undefined; /** The parsed query string parameters object. */ query: ParserOutput<Query>; /** If this isn't a socket-type Rpc, this will be the parsed request body. */ message: Upgrade extends true ? undefined : ParserOutput<Message>; /** * Searches for an asset on disk and either returns a Response containing that * asset or throws a 404 error if the asset isn't found. See the documentation * for the top-level asset() function for more details; this function is the * same thing but bound to the Request received by the Rpc. */ asset: (opt: ServeAssetOptions) => Promise<Response>; /** * Packs a response to return. */ // response: typeof response; // REVIEW: I don't think this is needed now /** * Returns a redirect Response. If the redirect path doesn't specify an * origin, the origin of the current request is used. If the path starts with * a ".", it is joined with the pathname of the request to get the final * redirect path. If the status isn't provided, 302 is used. */ redirect: (to: string, status?: number) => Response; /** * Upgrades the request to become a web socket. This is only available if the * `upgrade` init option is `true`. The Response returned by this function * should be returned by the Rpc's resolve function. */ upgrade: Upgrade extends true ? <Send = unknown>() => Socket<Send, ( Message extends AnyParser ? ParserOutput<Message> : unknown )> : undefined;}
/** * Handler for handling errors that occur during response resolution. Meant to * turn the errors into responses to send back to the client, using the same * serialization process and utilties available in the resolve function. If an * error is re-thrown, that error will be serialized as the response. */ export interface ResolveError { (x: ResolveErrorArg): unknown;}
/** Arguments available to the OnError function of an Rpc. */export interface ResolveErrorArg extends CtxArg { /** The thrown error. */ error: unknown; /** * If the error happened after context creation, this will be the returned * context object. Otherwise, it'll be undefined. */ ctx?: unknown; /** * If the error happened after message parsing, this will be the parsed * message. Otherwise, it'll be undefined. */ message?: unknown; /** * Only provided if this is a non-socket Rpc. Searches for an asset on disk * and either returns a Response containing that asset or throws a NO_MATCH * error if the asset isn't found. See the documentation for the top-level * asset() function for more details; this function is the same thing but * bound to the Request received by the Rpc. */ asset: (opt: ServeAssetOptions) => Promise<Response>; /** * Only provided if this is a non-socket Rpc. Packs a response to return. Use * this if you need to set status/Text. */ // response: typeof response;}
/** * Creates an endpoint handler for resolving Requests into Responses. */export function rpc< Resp = unknown, Groups extends AnyParser | null = null, Context extends Ctx<unknown> | null = null, Query extends AnyParser | null = null, Message extends AnyParser | null = null, Upgrade extends boolean | null = null,>( init: RpcInit< Resp, Groups, Context, Query, Message, Upgrade >,): Rpc<Resp, Groups, Context, Query, Message, Upgrade> { const useFullPath = init.path && init.path.startsWith("^"); const pathPattern = new URLPattern( init.path && useFullPath ? init.path.slice(1) : init.path || "/", "http://_._", // Doesn't matter, but stay consistent );
const parsers: Record< "groups" | "query" | "message", // deno-lint-ignore no-explicit-any ParserFunction<any, any> | null > = { groups: ( typeof init.groups === "function" ? init.groups : init.groups ? (v: unknown) => (init.groups as ParserObject).parse(v) : null ), query: ( typeof init.query === "function" ? init.query : init.query ? (v: unknown) => (init.query as ParserObject).parse(v) : null ), message: ( typeof init.message === "function" ? init.message : init.message ? (v: unknown) => (init.message as ParserObject).parse(v) : null ), };
// Calculate the allowed methods based on the schema behavior const methods = new Set<string>(["OPTIONS"]); if (init.upgrade) { methods.add("GET"); methods.add("HEAD"); } else { (async function () { const postAllowed = !!parsers.message; let postRequired = false; if (parsers.message) { try { await parsers.message(undefined); } catch { postRequired = true; } } if (postAllowed) { methods.add("POST"); } if (!postRequired) { methods.add("GET"); methods.add("HEAD"); } })(); }
const handler = async (req: Request, conn: http.ConnInfo) => { const data = requestData(req); if (data instanceof Response) { // Handle malformed path redirect return data; } const { res, url, path: _path } = data; const path = useFullPath ? url.pathname : _path;
const asset = async (opt: ServeAssetOptions) => { return await serveAsset(req, opt); };
let socket: Socket | null = null; let socketResponse: Response | null = null; // deno-lint-ignore no-explicit-any const upgrade: ResolveArg<any, any, any, any, true>["upgrade"] = () => { if (socket) { throw new Error( "upgrade() should only be called once for every upgraded request" ); } const u = upgradeWebSocket(req, { message: async (m: unknown) => { if (!init.message) { return m; } try { return ( typeof init.message === "function" ? await init.message(m) : typeof init.message === "object" ? await init.message.parse(m) : m ); } catch (e) { socket!.send(new HttpError("400 bad request", { status: 400, expose: e, })); } }, serializers: init.serializers, }); socket = u.socket; socketResponse = u.response; return u.socket; }
const redirect = (to: string, status?: number) => { if (to.startsWith(".")) { to = stdPath.join(url.pathname, to); } const u = new URL(to, url.origin); return Response.redirect(u.href, status || 302); };
// Path matching. If there's no match, throw next to indicate to the // containing stack that it should continue searching for matches const match = pathPattern.exec(path, "http://_._"); if (!match) { throw NO_MATCH; }
// Merge the matching path groups with the path groups on the // request, then parse it. If the parse fails, try to parse // undefined. If that also fails, throw next let groups: unknown = undefined; if (parsers.groups) { groups = { ...data.groups, ...match.pathname.groups }; const pg = groups as Record<string | number, string>; try { groups = await parsers.groups(pg); } catch { try { groups = await parsers.groups(undefined); } catch { throw NO_MATCH; } } }
// Now that there's for sure a match, handle trailing slashes if (url.pathname !== "/") { if (url.pathname.endsWith("/")) { const u = new URL(url.href); u.pathname = u.pathname.slice(0, u.pathname.length - 1); return Response.redirect(u.href, 302); } }
const cookie = await bakeCookie({ req, headers: res.headers, keys: init.keys || undefined, });
const whenDones: (() => Promise<void> | void)[] = []; const whenDone = (fn: () => Promise<void> | void) => { whenDones.push(fn); };
const _response: typeof response = (body, _init) => { const resp = response(body, { ..._init, headers: res.headers, serializers: init.serializers || undefined, }); if (req.method === "HEAD") { return new Response(null, { headers: resp.headers }); } return resp; };
const ctxArg = { req, res, url, conn, cookie, query: data.query, path, groups: data.groups, whenDone, };
try { // Get the custom context and merge its contents into the rpc context. // Errors thrown inside the custom context are allowed to bubble up to // the error catcher (no error wrapping) let ctx: unknown = undefined; if (init.ctx) { ctx = await init.ctx(ctxArg); }
// Check the method against the allowed methods if (!methods.has(req.method)) { throw new HttpError("405 method not allowed", { status: 405 }); }
// If it's an OPTIONS request, handle it and return early if (req.method === "OPTIONS") { res.headers.append( "Allow", Array.from(methods.values()).join(", "), ); await cookie.flush(); return _response(null, { status: 204, headers: res.headers, }); }
// Parse the query. If parsing fails, try to parse undefined instead. If // that also fails, rethrow the first error if it was a HttpError, or // wrap it in a HttpError if not let query: unknown = undefined; if (parsers.query) { try { query = await parsers.query(data.query); } catch (e) { try { query = await parsers.query(undefined); } catch (_trashed) { if (e instanceof HttpError) { throw e; } throw new HttpError("400 bad request", { status: 400, expose: { reason: "Query failed to parse", error: e }, }); } } }
// Attempt to parse the request body if there's a body parser. If // parsing fails, rethrow if it's an HttpError and wrap in a 400 if not. // If there's no body parser or if this is a socket rpc, skip this step // (don't touch the body) let message: unknown = undefined; if (!init.upgrade && parsers.message) { message = await requestBody(req, { maxSize: init.maxBodySize || undefined, serializers: init.serializers || undefined, });
try { message = await parsers.message(message); } catch (e) { if (e instanceof HttpError) { throw e; } throw new HttpError("400 bad request", { status: 400, expose: { reason: "Message failed to parse", error: e }, }); } }
// Resolve to the response body and return the final response const r = !init.resolve ? undefined : await init.resolve({ req, res, url, conn, cookie, path, groups, ctx, query, message, asset, redirect, // response: _response, upgrade: init.upgrade ? upgrade : undefined, // deno-lint-ignore no-explicit-any } as ResolveArg<any, any, any, any, any>);
if (init.upgrade && (!socket || r !== socket)) { throw new Error("Upgraded Rpcs must resolve to the Socket returned by the upgrade() utility"); }
await cookie.flush(); return _response(init.upgrade ? socketResponse : r); } catch (e) { let e2: unknown = e; if (init.resolveError) { try { const r = await init.resolveError({ ...ctxArg, asset, // response: _response, error: e, }); await cookie.flush(); return _response(r); } catch (e3) { e2 = e3; } }
// Every next branch leads to a return before the cookie is ever // modified again, so flush here instead of further down await cookie.flush();
// If it's the NO_MATCH error, 500+ HttpError, or if it's any error // besides an HttpError, let it bubble without serializing. The Server // will catch it if ( e2 === NO_MATCH || !(e2 instanceof HttpError) || e2.status >= 500 ) { throw e2; }
// If it's an HttpError, send it back as a response return _response(e2, { status: e2.status }); } finally { let fn = whenDones.pop(); while (fn) { await fn(); fn = whenDones.pop(); } } };
return Object.assign(handler, { init });}
// TODO: Add RpcInit options/** Initializer options for the assets() utility function. */export type AssetsInit = Omit<ServeAssetOptions, "path">;
// TODO: Add RpcInit options/** * Utility for creating an Rpc handler specifically for serving static assets. * The resolver's path argument is used as the asset path. */export function assets(init?: AssetsInit) { return rpc({ path: "*", resolve: x => x.asset({ ...init, path: x.path, }), });}
/** * Utility for creating an Rpc handler that always redirects. If an * origin isn't provided in the redirect url, the origin of the request will be * used. Paths can also be relative; if the path starts with a ".", the path * will be joined with the pathname of the request using the std path.join() * function. If the status isn't provided, 302 is used. Note that paths with * trailing slashes will be redirected first to the path without the trailing * slash before being redirect to the specified destination. (2 hops) */export function redirect(to: string, status?: number) { return rpc({ path: "*", resolve: x => { return x.redirect(to, status || 302); }, });}