import { http, path } from "./deps.ts";import { createEtagHash, should304 } from "./_etag.ts";import type { RouterShape, RouterRequest, Handler,} from "./client.ts";
export interface RouterContext { url: URL; path: string; query: Record<string, string | string[]>; param: Record<string, string>; redirect: Response | null;}
const _routerContext = Symbol("_routerContext");
export type QueryRecord = Record<string, string | string[]>;
export type ParamRecord = Record<string, string>;
export function routerContext(request: Request): RouterContext { const req = request as Request & { [_routerContext]?: RouterContext }; if (req[_routerContext]) { return req[_routerContext]!; }
const url = new URL(req.url); const path = `/${url.pathname.split("/").filter((p) => !!p).join("/")}`; let redirect: Response | null = null; if (path !== url.pathname) { url.pathname = path; redirect = new Response(null, { status: 302, headers: { "location": url.href }, }); }
const query: Record<string, string | string[]> = {}; for (const [k, v] of url.searchParams.entries()) { const old = query[k]; if (typeof old === "string") { query[k] = [old, v]; } else if (Array.isArray(old)) { query[k] = [...old, v]; } else { query[k] = v; } }
const ctx: RouterContext = { url, path, param: {}, query, redirect, }; Object.assign(req, { [_routerContext]: ctx }); return ctx;}
export type Router<S extends RouterShape = {}> = S & (( req: RouterRequest<S>, conn: http.ConnInfo,) => Promise<Response>);
export function router<S extends RouterShape>(routes: S): Router<S> { const shape: Record<string, Handler | Handler[]> = {}; for (let [k, v] of Object.entries(routes)) { if (typeof v === "undefined" || v === null) { continue; }
k = k.split("/").filter(k2 => !!k2).join("/");
if (typeof v === "string") { const staticStr = v;
const ext = path.extname(k); let type: string; switch(ext) { case ".html": type = "text/html; charset=UTF-8"; break; case ".md": type = "text/markdown; charset=UTF-8"; break; case ".css": type = "text/css; charset=UTF-8"; break; case ".txt": type = "text/plain; charset=UTF-8"; break; case ".js": type = "text/javascript; charset=UTF-8"; break; case ".json": type = "application/json; charset=UTF-8"; break; case ".svg": type = "image/svg+xml; charset=UTF-8"; break; case ".rss": type = "application/rss+xml; charset=UTF-8"; break; case ".xml": type = "application/xml; charset=UTF-8"; break; default: type = "text/html; charset=UTF-8"; }
const etag = createEtagHash(staticStr); const modified = new Date();
v = async (req: Request) => { if (req.method === "OPTIONS") { return new Response(null, { status: 204, headers: { "allow": "OPTIONS, GET, HEAD" }, }); } if (req.method !== "GET" && req.method !== "HEAD") { return new Response("405 method not allowed", { status: 405, headers: { "allow": "OPTIONS, GET, HEAD" }, }); }
const headers = new Headers(); headers.set("etag", await etag); headers.set("last-modified", modified.toUTCString()); headers.set("content-type", type);
if (should304({ req, etag: await etag, modified, })) { return new Response(null, { status: 304, headers }); } const res = new Response(staticStr, { headers }); if (req.method === "HEAD") { return new Response(null, { headers: res.headers }); } return res; }; }
const old = shape[k]; if (!old) { if (typeof v === "function" || Array.isArray(v)) { shape[k] = v; } else { shape[k] = router(v); } } else if (Array.isArray(old)) { if (typeof v === "function") { shape[k] = [...old, v]; } else if (Array.isArray(v)) { shape[k] = [...old, ...v]; } else { shape[k] = [...old, router(v)]; } } else { if (typeof v === "function") { shape[k] = [old, v]; } else if (Array.isArray(v)) { shape[k] = [old, ...v]; } else { shape[k] = [old, router(v)]; } } }
for (const k of Object.keys(shape)) { if (k === "*") { continue; }
const split = k.split("/"); for (const s of split) { if (s === "." || s === "..") { throw new SyntaxError( "'.' and '..' aren't allowed in route path segments", ); }
if ( !s.match(/^:[a-zA-Z_$]+[a-zA-Z_$0-9]*$/) && s.match(/[^\\][:*?(){}]/) ) { throw new SyntaxError( `"${k}" isn't a valid Router route. The Router only supports basic path segments and named path segments (param). Wildcards, RegExp, optionals, and other advanced URLPattern syntax isn't supported, with the exception of the solo wildcard "*"`, ); } } }
const paths = Object.keys(shape); const sortedPaths: string[] = paths.sort((a, b) => { if (a === b) { return 0; }
if (a === "*") { return 1; } if (b === "*") { return -1; }
const la = a.split("/").length; const lb = b.split("/").length; if (la !== lb) { return lb - la; }
return paths.indexOf(b) - paths.indexOf(a); });
const patterns = new Map<URLPattern, Handler | Handler[]>(); for (const p of sortedPaths) { const pattern = ( p === "*" ? `/:__nextPath*/*?` : !p ? "/" : `/${p}/:__nextPath*/*?` ); patterns.set(new URLPattern(pattern, "http://_._"), shape[p]); } const handler = async ( req: Request, conn: http.ConnInfo, ): Promise<Response> => { const ctx = routerContext(req); if (ctx.redirect) { return ctx.redirect; }
let lastNoMatch: Response | null = null;
for (const [pattern, fn] of patterns.entries()) { const match = pattern.exec(ctx.path, "http://_._"); if (!match) { continue; } const param = { ...ctx.param }; for (const [k, v] of Object.entries(match.pathname.groups)) { param[k] = v; }
delete param["0"];
const path = `/${param.__nextPath || ""}`; delete param.__nextPath;
const oPath = ctx.path; const oParam = ctx.param;
Object.assign(ctx, { path, param });
try { if (Array.isArray(fn)) { for (const f of fn) { const response = await f(req, conn); if (didMatch(response)) { return response; } else { lastNoMatch = response; } } } else { const response = await fn(req, conn); if (didMatch(response)) { return response; } else { lastNoMatch = response; } } } finally { Object.assign(ctx, { path: oPath, param: oParam }); } }
if (lastNoMatch) { return lastNoMatch; } return noMatch(new Response("404 not found", { status: 404 })); };
return Object.assign(handler, routes);}
const _noMatch = Symbol("_noMatch");
export function noMatch(res: Response): Response { return Object.assign(res, { [_noMatch]: true });}
export function didMatch(res: Response): boolean { return !(res as Response & Record<symbol, boolean>)[_noMatch];}