import { http, path as stdPath } from "./deps.ts";import { serveAsset } from "./asset.ts";import { context } from "./context.ts"import { noMatch } from "./router.ts";import { HttpError, packResponse, unpack } from "./serial.ts";import { cookieJar } from "./cookie.ts";import { webSocket } from "./ws.ts";import { normalizeParser } from "./parser.ts";import { serveBundle } from "./bundle.ts";import type { EndpointRequest } from "./client.ts";import type { Parser, ParserInput, ParserOutput } from "./parser.ts";import type { CookieJar } from "./cookie.ts";import type { ServeAssetOptions } from "./asset.ts";import type { WS } from "./ws.ts";import type { QueryRecord, ParamRecord } from "./context.ts";import type { ServeBundleOptions } from "./bundle.ts";import type { PackedResponse } from "./serial.ts";
export interface EndpointSchema { path?: string | null; param?: Parser<ParamRecord> | null; keys?: [string, ...string[]] | null; ctx?: ((c: ContextArg) => any) | null; query?: Parser<QueryRecord> | null; maxBodySize?: number | null; body?: Parser | null; error?: ((x: ErrorArg) => any) | null;}
export interface ContextArg { req: Request; headers: Headers; url: URL; conn: http.ConnInfo; cookie: CookieJar; path: string; query: QueryRecord; param: ParamRecord; cleanup: (fn: () => Promise<void> | void) => void;}
export interface ErrorArg { req: Request; headers: Headers; url: URL; conn: http.ConnInfo; path: string; query: QueryRecord; param: ParamRecord; error: unknown; res: typeof packResponse; bundle: (opt: ServeBundleOptions) => Promise<Response>; asset: (opt?: ServeAssetOptions) => Promise<Response>; redirect: (to: string, status?: number) => Response;}
export interface ResolveArg< Param extends EndpointSchema["param"], Ctx extends EndpointSchema["ctx"], Query extends EndpointSchema["query"], Body extends EndpointSchema["body"],> { req: Request; headers: Headers; url: URL; conn: http.ConnInfo; cookie: CookieJar; path: string; param: EndpointSchema["param"] extends Param ? ParamRecord : ParserOutput<Param>; ctx: EndpointSchema["ctx"] extends Ctx ? undefined : ParserOutput<Ctx>; query: EndpointSchema["query"] extends Query ? QueryRecord : ParserOutput<Query>; body: EndpointSchema["body"] extends Body ? undefined : ParserOutput<Body>; res: typeof packResponse; bundle: (opt: ServeBundleOptions) => Promise<Response>; asset: (opt?: ServeAssetOptions) => Promise<Response>; redirect: (to: string, status?: number) => Response;}
declare const _endpoint: unique symbol;
export type Endpoint< Schema extends EndpointSchema | null, Result,> = ( Schema extends null ? {} : Schema) & (( req: EndpointRequest<{ socket?: false; query: ( Schema extends { query: Parser } ? ParserInput<Schema["query"]> : QueryRecord | undefined ); body: ( Schema extends { body: Parser } ? ParserInput<Schema["body"]> : undefined ); result: ( Result extends PackedResponse<any, any, any> ? Result : PackedResponse<Result> ); }>, conn: http.ConnInfo,) => Promise<Response>) & { [_endpoint]?: true;};
export function endpoint< Schema extends EndpointSchema | null, Ctx extends EndpointSchema["ctx"], Param extends EndpointSchema["param"], Query extends EndpointSchema["query"], Body extends EndpointSchema["body"], Result = undefined,>( schema: EndpointSchema & Schema & { param?: Param; ctx?: Ctx; query?: Query; body?: Body; } | null, resolve: ( (x: ResolveArg<Param, Ctx, Query, Body>) => Promise<Result> | Result ) | null,): Endpoint<Schema, Result>;export function endpoint( _schema: EndpointSchema | null, _resolve: ((x: ResolveArg<any, any, any, any>) => any) | null,) { const schema = _schema || {}; const resolve = _resolve || (async () => {});
const checkMethod = methodChecker(schema.body); const matchPath = pathMatcher({ path: schema.path, param: schema.param, }); const parseInput = inputParser({ query: schema.query, body: schema.body, maxBodySize: schema.maxBodySize, });
const handler = async (req: Request, conn: http.ConnInfo) => { const cavCtx = context(req); if (cavCtx.redirect) { return cavCtx.redirect; }
const asset = (opt?: ServeAssetOptions) => serveAsset(req, opt); const bundle = (opt: ServeBundleOptions) => serveBundle(req, opt); const redirect = (to: string, status?: number) => { if (to.startsWith("../") || to.startsWith(".")) { to = stdPath.join(url.pathname, to); } const u = new URL(to, url.origin); return new Response(null, { status: status || 302, headers: { "location": u.href }, }); };
const headers = new Headers(); const { url } = cavCtx; const cleanupTasks: (() => Promise<void> | void)[] = []; let output: unknown = undefined; let path: string; let param: unknown; let unparsedParam: ParamRecord; let error: unknown = undefined;
try { ({ path, param, unparsedParam } = await matchPath(req)); } catch { return noMatch(new Response("404 not found", { status: 404 })); }
try { const options = await checkMethod(req); if (options) { return options; }
const cookie = await cookieJar(req, schema.keys || undefined); cleanupTasks.push(() => cookie.setCookies(headers));
let ctx: unknown = undefined; if (schema.ctx) { ctx = await schema.ctx({ req, headers, url, conn, cookie, path, query: cavCtx.query, param: unparsedParam, cleanup: (task: () => Promise<void> | void) => { cleanupTasks.push(task); }, }); }
const { query, body } = await parseInput(req); output = await resolve({ req, headers, url, conn, cookie, path, param: param as any, ctx: ctx as any, query: query as any, body: body as any, res: packResponse, bundle, asset, redirect, }); } catch (err) { error = err; if (schema.error) { try { output = await schema.error({ req, headers, url, conn, error, path: cavCtx.path, param: cavCtx.param, query: cavCtx.query, res: packResponse, bundle, asset: (opt?: ServeAssetOptions) => serveAsset(req, opt), redirect, }); error = null; } catch (err) { error = err; } } }
while (cleanupTasks.length) { const task = cleanupTasks.pop()!; await task(); }
let status: number | undefined = undefined; if (error instanceof HttpError) { status = error.status; output = error.expose ? error : error.message; } else if (error) { const bugtrace = crypto.randomUUID().slice(0, 5); console.error(`ERROR: Uncaught exception [${bugtrace}] -`, error); status = 500; output = `500 internal server error [${bugtrace}]`; }
const response = packResponse({ status, headers, body: output }); if (req.method === "HEAD") { return new Response(null, { headers: response.headers, status: response.status, statusText: response.statusText, }); } return response; };
return Object.assign(handler, schema) as any;}
function methodChecker( body?: Parser | null,): (req: Request) => Promise<Response | null> { const parseBody = body && normalizeParser(body);
let allowed: Set<string> | null = null; return async (req: Request) => { if (!allowed) { allowed = new Set(["OPTIONS"]); let postRequired = false; if (parseBody) { try { await parseBody(undefined); } catch { postRequired = true; } } if (postRequired) { allowed.add("POST"); } else { allowed.add("GET"); allowed.add("HEAD"); if (parseBody) { allowed.add("POST"); } } }
if (!allowed.has(req.method)) { throw new HttpError("405 method not allowed", { status: 405 }); }
return ( req.method === "OPTIONS" ? new Response(null, { headers: { allow: Array.from(allowed.values()).join(", "), }, }) : null ); };}
function pathMatcher(opt: { path?: string | null; param?: Parser | null;}): (req: Request) => Promise<{ path: string; param: unknown; unparsedParam: ParamRecord;}> { const useFullPath = opt.path && opt.path.startsWith("^"); const pattern = new URLPattern( useFullPath ? opt.path!.slice(1) : opt.path || "/", "http://_", ); const parseParam = ( typeof opt.param === "function" ? opt.param : opt.param ? opt.param.parse : null );
return async (req: Request) => { const cavCtx = context(req); const path = useFullPath ? cavCtx.url.pathname : cavCtx.path; const match = pattern.exec(path, "http://_");
if (!match) { throw new HttpError("404 not found", { status: 404 }); }
delete match.pathname.groups["0"];
const unparsedParam = { ...cavCtx.param }; for (const [k, v] of Object.entries(match.pathname.groups)) { unparsedParam[k] = v; }
let param = unparsedParam; if (!parseParam) { return { path, param, unparsedParam }; }
try { param = await parseParam(param); } catch { try { param = await parseParam(undefined); } catch { throw new HttpError("404 not found", { status: 404 }); } }
return { path, param, unparsedParam }; };}
function inputParser(opt: { query?: Parser | null; body?: Parser | null; maxBodySize?: number | null;}): (req: Request) => Promise<{ query: unknown; body: unknown;}> { const parseQuery = opt.query && normalizeParser(opt.query); const parseBody = opt.body && normalizeParser(opt.body);
return async (req) => { const cavCtx = context(req);
let query: unknown = cavCtx.query; if (parseQuery) { try { query = await parseQuery(query); } catch (err) { try { query = await parseQuery(undefined); } catch { if (err instanceof HttpError) { throw err; } throw new HttpError(( err instanceof Error ? err.message : "400 bad request" ), { status: 400, detail: { original: err } }); } } }
let body: unknown = undefined; if (req.body && parseBody) { body = await unpack(req, { maxBodySize: ( typeof opt.maxBodySize === "number" ? opt.maxBodySize : undefined ), });
try { body = await parseBody(body); } catch (err) { if (err instanceof HttpError) { throw err; } throw new HttpError(( err instanceof Error ? err.message : "400 bad request" ), { status: 400, detail: { original: err } }); } }
return { query, body }; };}
export type AssetsInit = Omit<ServeAssetOptions, "path">;
export function assets(init?: AssetsInit) { return endpoint({ path: "*" as const }, ({ asset }) => asset(init));}
export type BundleInit = ServeBundleOptions;
export function bundle(init: BundleInit) { serveBundle(new Request("http://_"), init).catch(() => {}); return endpoint(null, ({ bundle }) => bundle(init));}
export function redirect(to: string, status?: number) { return endpoint(null, ({ redirect }) => redirect(to, status || 302));}
export interface SocketSchema extends Omit< EndpointSchema, "maxBodySize" | "body"> { recv?: Parser | null; send?: any;}
export type Socket<Schema extends SocketSchema | null> = ( Schema extends null ? {} : Schema) & (( req: EndpointRequest<{ socket: true; query: ( Schema extends { query: Parser } ? ParserInput<Schema["query"]> : QueryRecord | undefined ); body: ( Schema extends { body: Parser } ? ParserInput<Schema["body"]> : undefined ); result: WS<( Schema extends { recv: Parser } ? ParserInput<Schema["recv"]> : unknown ), ( Schema extends SocketSchema ? ( null | undefined extends Schema["send"] ? unknown : Schema["send"] ) : unknown )>; }>, conn: http.ConnInfo,) => Promise<Response>);
export interface SetupArg< Param extends SocketSchema["param"], Ctx extends SocketSchema["ctx"], Query extends SocketSchema["query"], Send extends SocketSchema["send"], Recv extends SocketSchema["recv"],> extends Omit< ResolveArg<Param, Ctx, Query, any>, "body" | "asset" | "bundle" | "redirect" | "res"> { ws: WS<Send, ( SocketSchema["recv"] extends Recv ? unknown : ParserOutput<Recv> )>;}
export function socket< Schema extends SocketSchema | null, Param extends SocketSchema["param"], Ctx extends SocketSchema["ctx"], Query extends SocketSchema["query"], Send extends SocketSchema["send"], Recv extends SocketSchema["recv"],>( schema: SocketSchema & Schema & { param?: Param; ctx?: Ctx; query?: Query; send?: Send; recv?: Recv; } | null, setup: ( | ((x: SetupArg<Param, Ctx, Query, Send, Recv>) => Promise<void> | void) | null ),): Socket<Schema>;export function socket( _schema: SocketSchema | null, _setup: ( | ((x: SetupArg<any, any, any, any, any>) => Promise<void> | void) | null ),) { const schema = _schema || {}; const setup = _setup || (() => {}); const recv = normalizeParser(schema.recv || ((m) => m));
return endpoint(schema, async x => { let socket: WebSocket; let response: Response; try { ({ socket, response } = Deno.upgradeWebSocket(x.req, { protocol: "json", })); } catch { x.headers.set("upgrade", "websocket"); throw new HttpError("426 upgrade required", { status: 426 }); }
const ws = webSocket(socket, { recv });
if (setup) { await setup({ ...x, ws }); }
return response; });}