UNPKG

convex

Version:

Client for the Convex Cloud

328 lines (310 loc) 9.66 kB
import { performJsSyscall } from "./impl/syscall.js"; import { PublicHttpAction } from "./registration.js"; // Note: this list is duplicated in the dashboard. /** * A list of the methods supported by Convex HTTP actions. * * HEAD is handled by Convex by running GET and stripping the body. * CONNECT is not supported and will not be supported. * TRACE is not supported and will not be supported. * * @public */ export const ROUTABLE_HTTP_METHODS = [ "GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH", ] as const; /** * A type representing the methods supported by Convex HTTP actions. * * HEAD is handled by Convex by running GET and stripping the body. * CONNECT is not supported and will not be supported. * TRACE is not supported and will not be supported. * * @public */ export type RoutableMethod = (typeof ROUTABLE_HTTP_METHODS)[number]; export function normalizeMethod( method: RoutableMethod | "HEAD", ): RoutableMethod { // This router routes HEAD requests as GETs, letting Axum strip thee response // bodies are response bodies afterward. if (method === "HEAD") return "GET"; return method; } /** * Return a new {@link HttpRouter} object. * * @public */ export const httpRouter = () => new HttpRouter(); /** * A type representing a route to an HTTP action using an exact request URL path match. * * Used by {@link HttpRouter} to route requests to HTTP actions. * * @public */ export type RouteSpecWithPath = { /** * Exact HTTP request path to route. */ path: string; /** * HTTP method ("GET", "POST", ...) to route. */ method: RoutableMethod; /** * The HTTP action to execute. */ handler: PublicHttpAction; }; /** * A type representing a route to an HTTP action using a request URL path prefix match. * * Used by {@link HttpRouter} to route requests to HTTP actions. * * @public */ export type RouteSpecWithPathPrefix = { /** * An HTTP request path prefix to route. Requests with a path starting with this value * will be routed to the HTTP action. */ pathPrefix: string; /** * HTTP method ("GET", "POST", ...) to route. */ method: RoutableMethod; /** * The HTTP action to execute. */ handler: PublicHttpAction; }; /** * A type representing a route to an HTTP action. * * Used by {@link HttpRouter} to route requests to HTTP actions. * * @public */ export type RouteSpec = RouteSpecWithPath | RouteSpecWithPathPrefix; /** * HTTP router for specifying the paths and methods of {@link httpActionGeneric}s * * An example `convex/http.js` file might look like this. * * ```js * import { httpRouter } from "convex/server"; * import { getMessagesByAuthor } from "./getMessagesByAuthor"; * import { httpAction } from "./_generated/server"; * * const http = httpRouter(); * * // HTTP actions can be defined inline... * http.route({ * path: "/message", * method: "POST", * handler: httpAction(async ({ runMutation }, request) => { * const { author, body } = await request.json(); * * await runMutation(api.sendMessage.default, { body, author }); * return new Response(null, { * status: 200, * }); * }) * }); * * // ...or they can be imported from other files. * http.route({ * path: "/getMessagesByAuthor", * method: "GET", * handler: getMessagesByAuthor, * }); * * // Convex expects the router to be the default export of `convex/http.js`. * export default http; * ``` * * @public */ export class HttpRouter { exactRoutes: Map<string, Map<RoutableMethod, PublicHttpAction>> = new Map(); prefixRoutes: Map<RoutableMethod, Map<string, PublicHttpAction>> = new Map(); isRouter = true; /** * Specify an HttpAction to be used to respond to requests * for an HTTP method (e.g. "GET") and a path or pathPrefix. * * Paths must begin with a slash. Path prefixes must also end in a slash. * * ```js * // matches `/profile` (but not `/profile/`) * http.route({ path: "/profile", method: "GET", handler: getProfile}) * * // matches `/profiles/`, `/profiles/abc`, and `/profiles/a/c/b` (but not `/profile`) * http.route({ pathPrefix: "/profile/", method: "GET", handler: getProfile}) * ``` */ route = (spec: RouteSpec) => { if (!spec.handler) throw new Error(`route requires handler`); if (!spec.method) throw new Error(`route requires method`); const { method, handler } = spec; if (!ROUTABLE_HTTP_METHODS.includes(method)) { throw new Error( `'${method}' is not an allowed HTTP method (like GET, POST, PUT etc.)`, ); } if ("path" in spec) { if ("pathPrefix" in spec) { throw new Error( `Invalid httpRouter route: cannot contain both 'path' and 'pathPrefix'`, ); } if (!spec.path.startsWith("/")) { throw new Error(`path '${spec.path}' does not start with a /`); } const methods: Map<RoutableMethod, PublicHttpAction> = this.exactRoutes.has(spec.path) ? this.exactRoutes.get(spec.path)! : new Map(); if (methods.has(method)) { throw new Error( `Path '${spec.path}' for method ${method} already in use`, ); } methods.set(method, handler); this.exactRoutes.set(spec.path, methods); } else if ("pathPrefix" in spec) { if (!spec.pathPrefix.startsWith("/")) { throw new Error( `pathPrefix '${spec.pathPrefix}' does not start with a /`, ); } if (!spec.pathPrefix.endsWith("/")) { throw new Error(`pathPrefix ${spec.pathPrefix} must end with a /`); } const prefixes = this.prefixRoutes.get(method) || new Map<string, PublicHttpAction>(); if (prefixes.has(spec.pathPrefix)) { throw new Error( `${spec.method} pathPrefix ${spec.pathPrefix} is already defined`, ); } prefixes.set(spec.pathPrefix, handler); this.prefixRoutes.set(method, prefixes); } else { throw new Error( `Invalid httpRouter route entry: must contain either field 'path' or 'pathPrefix'`, ); } }; /** * Returns a list of routed HTTP actions. * * These are used to populate the list of routes shown in the Functions page of the Convex dashboard. * * @returns - an array of [path, method, endpoint] tuples. */ getRoutes = (): Array< Readonly<[string, RoutableMethod, (...args: any[]) => any]> > => { const exactPaths: string[] = [...this.exactRoutes.keys()].sort(); const exact = exactPaths.flatMap((path) => [...this.exactRoutes.get(path)!.keys()] .sort() .map( (method) => [path, method, this.exactRoutes.get(path)!.get(method)!] as const, ), ); const prefixPathMethods = [...this.prefixRoutes.keys()].sort(); const prefixes = prefixPathMethods.flatMap((method) => [...this.prefixRoutes.get(method)!.keys()] .sort() .map( (pathPrefix) => [ `${pathPrefix}*`, method, this.prefixRoutes.get(method)!.get(pathPrefix)!, ] as const, ), ); return [...exact, ...prefixes]; }; /** * Returns the appropriate HTTP action and its routed request path and method. * * The path and method returned are used for logging and metrics, and should * match up with one of the routes returned by `getRoutes`. * * For example, * * ```js * http.route({ pathPrefix: "/profile/", method: "GET", handler: getProfile}); * * http.lookup("/profile/abc", "GET") // returns [getProfile, "GET", "/profile/*"] *``` * * @returns - a tuple [{@link PublicHttpAction}, method, path] or null. */ lookup = ( path: string, method: RoutableMethod | "HEAD", ): Readonly<[PublicHttpAction, RoutableMethod, string]> | null => { method = normalizeMethod(method); const exactMatch = this.exactRoutes.get(path)?.get(method); if (exactMatch) return [exactMatch, method, path]; const prefixes = this.prefixRoutes.get(method) || new Map(); const prefixesSorted = [...prefixes.entries()].sort( ([prefixA, _a], [prefixB, _b]) => prefixB.length - prefixA.length, ); for (const [pathPrefix, endpoint] of prefixesSorted) { if (path.startsWith(pathPrefix)) { return [endpoint, method, `${pathPrefix}*`]; } } return null; }; /** * Given a JSON string representation of a Request object, return a Response * by routing the request and running the appropriate endpoint or returning * a 404 Response. * * @param argsStr - a JSON string representing a Request object. * * @returns - a Response object. */ runRequest = async ( argsStr: string, requestRoute: string, ): Promise<string> => { const request = performJsSyscall("requestFromConvexJson", { convexJson: JSON.parse(argsStr), }); let pathname = requestRoute; if (!pathname || typeof pathname !== "string") { pathname = new URL(request.url).pathname; } const method = request.method; const match = this.lookup(pathname, method as RoutableMethod); if (!match) { const response = new Response(`No HttpAction routed for ${pathname}`, { status: 404, }); return JSON.stringify( performJsSyscall("convexJsonFromResponse", { response }), ); } const [endpoint, _method, _path] = match; const response = await endpoint.invokeHttpAction(request); return JSON.stringify( performJsSyscall("convexJsonFromResponse", { response }), ); }; }