convex
Version:
Client for the Convex Cloud
258 lines (242 loc) • 7.83 kB
text/typescript
import { setupAuth } from "./impl/authentication_impl.js";
import { setupHttpCalls } from "./impl/http_impl.js";
import { performJsSyscall } from "./impl/syscall.js";
import { PublicHttpEndpoint } from "./registration.js";
// 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.
const routableMethods = [
"GET",
"POST",
"PUT",
"DELETE",
"OPTIONS",
"PATCH",
] as const;
type RoutableMethod = typeof routableMethods[number];
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();
type RouteSpec =
| {
path: string;
method: RoutableMethod;
handler: PublicHttpEndpoint<any>;
}
| {
pathPrefix: string;
method: RoutableMethod;
handler: PublicHttpEndpoint<any>;
};
/**
* HTTP router for specifying the paths and methods of {@link httpEndpointGeneric}s
*
* An example `convex/http.js` file might look like this.
*
* ```js
* import { httpRouter } from "./_generated/server";
* import { getMessagesByAuthor } from "./getMessagesByAuthor";
* import { httpEndpoint } from "./_generated/server";
*
* const http = httpRouter();
*
* // HTTP endpoints can be defined inline...
* http.route({
* path: "/message",
* method: "POST",
* handler: httpEndpoint(async ({ runMutation }, request) => {
* const { author, body } = await request.json();
*
* await runMutation("sendMessage", 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, PublicHttpEndpoint<any>>> =
new Map();
prefixRoutes: Map<RoutableMethod, Map<string, PublicHttpEndpoint<any>>> =
new Map();
isRouter = true;
/**
* Specify an HttpEndpoint 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})
* ```
* @returns - an array of [path, route, endpoints] tuples.
*/
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 (!routableMethods.includes(method)) {
throw new Error(
`'${method}' is not an allowed HTTP method (like GET, POST, PUT etc.)`
);
}
if ("path" in spec) {
if (!spec.path.startsWith("/")) {
throw new Error(`path '${spec.path}' does not start with a /`);
}
const prefixes =
this.prefixRoutes.get(method) ||
new Map<string, PublicHttpEndpoint<any>>();
for (const [prefix, _] of prefixes.entries()) {
if (spec.path.startsWith(prefix)) {
throw new Error(
`${spec.method} path ${spec.path} is shadowed by pathPrefix ${prefix}`
);
}
}
const methods: Map<
RoutableMethod,
PublicHttpEndpoint<any>
> = 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 (!spec.pathPrefix.startsWith("/")) {
throw new Error(`path '${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, PublicHttpEndpoint<any>>();
for (const [prefix, _] of prefixes.entries()) {
if (spec.pathPrefix.startsWith(prefix)) {
throw new Error(
`${spec.method} pathPrefix ${spec.pathPrefix} is shadowed by pathPrefix ${prefix}`
);
}
}
prefixes.set(spec.pathPrefix, handler);
this.prefixRoutes.set(method, prefixes);
}
};
/**
* Returns a list of routed HTTP endpoints.
*
* @returns - an array of [path, route, endpoints] tuples.
*/
getRoutes = () => {
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 endpoint for a request path and method.
*
* @returns - a {@link PublicHttpEndpoint} or null.
*/
lookup = (
path: string,
method: RoutableMethod | "HEAD"
): [PublicHttpEndpoint<any>, 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();
for (const [pathPrefix, endpoint] of prefixes.entries()) {
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): Promise<string> => {
const request = performJsSyscall("requestFromConvexJson", {
convexJson: JSON.parse(argsStr),
});
const 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 HttpEndpoint routed for ${pathname}`, {
status: 404,
});
return JSON.stringify(
performJsSyscall("convexJsonFromResponse", { response })
);
}
const [endpoint, _method, _path] = match;
const calls = setupHttpCalls();
const ctx = {
...calls,
auth: setupAuth(),
};
const response = await (endpoint as any)(ctx, request);
return JSON.stringify(
performJsSyscall("convexJsonFromResponse", { response })
);
};
}