├── version.json ├── .gitignore ├── .vscode └── settings.json ├── README.md ├── deno.json ├── LICENSE ├── util.ts ├── scripts └── build.ts ├── route.ts ├── mod.ts ├── router.test.ts ├── response.ts ├── routing.ts └── router.ts /version.json: -------------------------------------------------------------------------------- 1 | "0.0.0" 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | npm/ 2 | node_modules 3 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "deno.enable": true, 3 | "deno.unstable": true 4 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WebSI: Web Server Interface 2 | 3 | Universal HTTP abstraction for building web servers with TypeScript. 4 | 5 | ## Development 6 | 7 | ``` 8 | deno task build:npm 9 | ``` -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "imports": { 3 | "testing/": "https://deno.land/std@0.181.0/testing/" 4 | }, 5 | "tasks": { 6 | "build:npm": "deno run -A scripts/build.ts" 7 | }, 8 | "fmt": { 9 | "files": { 10 | "exclude": [ 11 | "README.md" 12 | ] 13 | }, 14 | "options": { 15 | "useTabs": true, 16 | "lineWidth": 101, 17 | "indentWidth": 2, 18 | "singleQuote": true 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | ============== 3 | 4 | Version 2.0, January 2004 5 | <> 6 | 7 | ### Terms and Conditions for use, reproduction, and distribution 8 | 9 | #### 1. Definitions 10 | 11 | “License” shall mean the terms and conditions for use, reproduction, and 12 | distribution as defined by Sections 1 through 9 of this document. 13 | 14 | “Licensor” shall mean the copyright owner or entity authorized by the copyright 15 | owner that is granting the License. 16 | 17 | “Legal Entity” shall mean the union of the acting entity and all other entities 18 | that control, are controlled by, or are under common control with that entity. 19 | For the purposes of this definition, “control” means **(i)** the power, direct or 20 | indirect, to cause the direction or management of such entity, whether by 21 | contract or otherwise, or **(ii)** ownership of fifty percent (50%) or more of the 22 | outstanding shares, or **(iii)** beneficial ownership of such entity. 23 | 24 | “You” (or “Your”) shall mean an individual or Legal Entity exercising 25 | permissions granted by this License. 26 | 27 | “Source” form shall mean the preferred form for making modifications, including 28 | but not limited to software source code, documentation source, and configuration 29 | files. 30 | -------------------------------------------------------------------------------- /util.ts: -------------------------------------------------------------------------------- 1 | import type { Handler, HandlerMapping, Pipeline } from "./mod.ts"; 2 | 3 | export function isPlainObject(object: unknown): object is T { 4 | const prototype = Object.getPrototypeOf(object); 5 | return prototype === null || prototype.constructor === Object; 6 | } 7 | 8 | export const isFunction = (value: unknown) => { 9 | return !!value && 10 | (Object.prototype.toString.call(value) === '[object Function]' || 11 | 'function' === typeof value || value instanceof Function); 12 | }; 13 | 14 | export function isPipeline( 15 | handler: Handler | HandlerMapping | Pipeline, 16 | ): handler is Pipeline { 17 | return Array.isArray(handler); 18 | } 19 | 20 | export function isHandlerMapping( 21 | handler: Handler | HandlerMapping | Pipeline, 22 | ): handler is HandlerMapping { 23 | return isPlainObject(handler); 24 | } 25 | 26 | export function isHandler( 27 | handler: Handler | HandlerMapping | Pipeline, 28 | ): handler is Handler { 29 | return isFunction(handler); 30 | } 31 | 32 | export const compose = (...functions: T[]) => (args: U) => 33 | functions.reduceRight((arg, fn) => fn(arg), args); 34 | 35 | export const segmentize = (pathname: string) => { 36 | const segments = pathname.split('/') 37 | return (segments[0] === '') ? segments.slice(1) : segments; 38 | } -------------------------------------------------------------------------------- /scripts/build.ts: -------------------------------------------------------------------------------- 1 | import { build, emptyDir } from "https://deno.land/x/dnt/mod.ts"; 2 | 3 | await emptyDir("./npm"); 4 | 5 | await build({ 6 | packageManager: 'pnpm', 7 | entryPoints: [ 8 | "./mod.ts", 9 | { name: "./response", path: "./response.ts" }, 10 | { name: "./route", path: "./route.ts" }, 11 | { name: "./router", path: "./router.ts" }, 12 | { name: "./routing", path: "./routing.ts" }, 13 | { name: "./util", path: "./util.ts" }, 14 | ], 15 | outDir: "./npm", 16 | scriptModule: false, 17 | esModule: true, 18 | typeCheck: false, 19 | shims: { 20 | custom: [ 21 | // { 22 | // package: { 23 | // name: "urlpattern-polyfill", 24 | // }, 25 | // globalNames: ["URLPattern"] 26 | // } 27 | // { 28 | // package: { name: "stream/web" }, 29 | // globalNames: ["ReadableStream"], 30 | // } 31 | ], 32 | }, 33 | test: false, 34 | compilerOptions: { 35 | target: "ES2021", 36 | lib: ["es2021", "dom", "dom.iterable"] 37 | }, 38 | package: { 39 | name: "websi", 40 | version: Deno.args[0], 41 | description: "Web Server Interface: Universal HTTP abstraction for TypeScript", 42 | type: "module", 43 | author: "Zaiste", 44 | license: "Apache-2.0", 45 | homepage: "https://websi.dev", 46 | repository: "https://github.com/zaiste/websi", 47 | bugs: { 48 | url: "https://github.com/zaiste/websi/issues" 49 | } 50 | }, 51 | }); 52 | 53 | // post build steps 54 | Deno.copyFileSync("LICENSE", "npm/LICENSE"); 55 | Deno.copyFileSync("README.md", "npm/README.md"); 56 | -------------------------------------------------------------------------------- /route.ts: -------------------------------------------------------------------------------- 1 | import type { Handler, HTTPMethod, Meta, Middleware, Pipeline, Route } from './mod.ts'; 2 | 3 | import { isPipeline } from './util.ts'; 4 | 5 | function createRoute( 6 | name: HTTPMethod, 7 | path: string, 8 | action: Handler | Pipeline, 9 | meta: Meta, 10 | ): Route { 11 | if (isPipeline(action)) { 12 | const h = action.pop() as Handler; 13 | return [ 14 | path, 15 | { 16 | [name]: h, 17 | middleware: [...(action as Middleware[])], 18 | meta, 19 | }, 20 | ]; 21 | } else { 22 | return [path, { [name]: action, middleware: [], meta }]; 23 | } 24 | } 25 | 26 | // use partial func application below 27 | 28 | export function GET(path: string, action: Handler | Pipeline, meta: Meta = {}) { 29 | return createRoute('GET', path, action, meta); 30 | } 31 | 32 | export function POST(path: string, action: Handler | Pipeline, meta: Meta = {}) { 33 | return createRoute('POST', path, action, meta); 34 | } 35 | 36 | export function PATCH(path: string, action: Handler | Pipeline, meta: Meta = {}) { 37 | return createRoute('PATCH', path, action, meta); 38 | } 39 | 40 | export function PUT(path: string, action: Handler | Pipeline, meta: Meta = {}) { 41 | return createRoute('PUT', path, action, meta); 42 | } 43 | 44 | export function DELETE(path: string, action: Handler | Pipeline, meta: Meta = {}) { 45 | return createRoute('DELETE', path, action, meta); 46 | } 47 | 48 | export function OPTIONS(path: string, action: Handler | Pipeline, meta: Meta = {}) { 49 | return createRoute('OPTIONS', path, action, meta); 50 | } 51 | 52 | export function ANY(path: string, action: Handler | Pipeline, meta: Meta = {}) { 53 | return createRoute('ANY', path, action, meta) 54 | } 55 | -------------------------------------------------------------------------------- /mod.ts: -------------------------------------------------------------------------------- 1 | import { Routing } from "./routing.ts"; 2 | 3 | export type PlainObject = Record | PlainObject[]; 4 | 5 | export type MaybePromise = T | Promise; 6 | 7 | // deno-lint-ignore no-empty-interface 8 | export interface Context { 9 | // explicitly empty for now 10 | } 11 | 12 | export interface Params { 13 | [name: string]: string | number | boolean | Params; 14 | } 15 | export interface RequestExtension { 16 | method: HTTPMethod 17 | params: Params; 18 | files: { 19 | [name: string]: File; 20 | }; 21 | } 22 | 23 | export type _Request = Request & RequestExtension; 24 | 25 | // deno-lint-ignore no-explicit-any 26 | export type Handler = (request: Request & RequestExtension, arg1: any, arg2: any) => MaybePromise; 27 | export type Middleware = (handler: Handler) => Handler; 28 | export type Pipeline = [...Middleware[], Handler]; 29 | export type ReversedPipeline = [Handler, ...Middleware[]]; 30 | 31 | export interface Meta { 32 | summary?: string; 33 | description?: string; 34 | parameters?: Array; 35 | responses?: Record; 36 | } 37 | export interface RoutePaths { 38 | // deno-lint-ignore no-explicit-any 39 | [name: string]: any; 40 | } 41 | export interface RouteOptions { 42 | middleware?: Middleware[]; 43 | meta?: Meta; 44 | } 45 | export interface HandlerMapping { 46 | GET?: Handler | Pipeline; 47 | POST?: Handler | Pipeline; 48 | PUT?: Handler | Pipeline; 49 | PATCH?: Handler | Pipeline; 50 | DELETE?: Handler | Pipeline; 51 | middleware?: Middleware[]; 52 | meta?: Meta; 53 | } 54 | 55 | export type Route = [string, HandlerMapping | Handler | Pipeline, Route?]; 56 | export type Routes = Route[]; 57 | 58 | export const HTTPMethod = { 59 | GET: 'GET', 60 | POST: 'POST', 61 | PUT: 'PUT', 62 | PATCH: 'PATCH', 63 | HEAD: 'HEAD', 64 | OPTIONS: 'OPTIONS', 65 | DELETE: 'DELETE', 66 | ANY: 'ANY', 67 | } as const; 68 | export type HTTPMethod = typeof HTTPMethod[keyof typeof HTTPMethod]; 69 | 70 | export interface HandlerParams { 71 | handler: Handler; 72 | names: string[]; 73 | } 74 | 75 | export interface HandlerParamsMap { 76 | [method: string]: HandlerParams; 77 | } 78 | 79 | export interface KeyValue { 80 | name: string; 81 | value: string; 82 | } 83 | 84 | export interface Options { 85 | port: number 86 | } 87 | 88 | export const Server = (routes: Routes, options: Options = { port: 4000 }) => ({ 89 | fetch: Routing(routes), 90 | ...options 91 | }) 92 | 93 | export { Router } from './router.ts' 94 | -------------------------------------------------------------------------------- /router.test.ts: -------------------------------------------------------------------------------- 1 | import { assertEquals, assertExists, assertNotEquals } from "testing/asserts.ts"; 2 | import { Router } from "./mod.ts"; 3 | 4 | Deno.test('static lookup', async (t) => { 5 | const router = new Router() 6 | 7 | router.add('GET', '/foo', 'foo via GET'); 8 | router.add('POST', '/foo', 'foo via POST'); 9 | 10 | await t.step("GET /foo", () => { 11 | const route = router.find('GET', '/foo') 12 | 13 | assertExists(route); 14 | 15 | assertEquals(route.handler, 'foo via GET'); 16 | assertNotEquals(route.handler, 'bar'); 17 | }) 18 | 19 | await t.step('POST /foo', () => { 20 | const route = router.find('POST', '/foo') 21 | 22 | assertExists(route); 23 | 24 | assertEquals(route.handler, 'foo via POST'); 25 | assertNotEquals(route.handler, 'bar'); 26 | }) 27 | 28 | await t.step('Not found', () => { 29 | const route = router.find('GET', '/bar') 30 | 31 | assertEquals(route, null); 32 | }) 33 | }); 34 | 35 | Deno.test('lookup order with dynamic pathnames', async (t) => { 36 | const router = new Router() 37 | 38 | router.add('GET', '/welcome/zaiste', 'welcome zaiste') 39 | router.add('GET', '/welcome/:name', 'welcome somebody') 40 | router.add('GET', '/welcome/krysia', 'welcome krysia') 41 | 42 | await t.step('GET /welcome/zaiste', () => { 43 | const route = router.find('GET', '/welcome/zaiste') 44 | 45 | assertExists(route); 46 | assertEquals(route.handler, 'welcome zaiste'); 47 | assertEquals(route.params, {}); 48 | }) 49 | 50 | await t.step('GET /welcome/antek', () => { 51 | const route = router.find('GET', '/welcome/antek') 52 | 53 | assertExists(route); 54 | assertEquals(route.handler, 'welcome somebody'); 55 | assertEquals(route.params, { name: 'antek' }); 56 | }) 57 | 58 | await t.step('GET /welcome/krysia', () => { 59 | const route = router.find('GET', '/welcome/krysia') 60 | 61 | assertExists(route); 62 | assertEquals(route.handler, 'welcome somebody'); 63 | assertEquals(route.params, { name: 'krysia' }); 64 | }) 65 | }); 66 | 67 | Deno.test('lookup order with dynamic & nested pathnames', async (t) => { 68 | const router = new Router() 69 | 70 | router.add('GET', '/welcome/:name', 'welcome somebody') 71 | router.add('GET', '/welcome/:name/invite', 'welcome and invite somebody') 72 | 73 | await t.step('GET /welcome/zaiste', () => { 74 | const route = router.find('GET', '/welcome/zaiste') 75 | 76 | assertExists(route); 77 | assertEquals(route.handler, 'welcome somebody'); 78 | assertEquals(route.params, { name: 'zaiste' }); 79 | }) 80 | 81 | await t.step('GET /welcome/zaiste/invite', () => { 82 | const route = router.find('GET', '/welcome/zaiste/invite') 83 | 84 | assertExists(route); 85 | assertEquals(route.handler, 'welcome and invite somebody'); 86 | assertEquals(route.params, { name: 'zaiste' }); 87 | }) 88 | }); -------------------------------------------------------------------------------- /response.ts: -------------------------------------------------------------------------------- 1 | import type { PlainObject } from './mod.ts'; 2 | import { isPlainObject } from './util.ts'; 3 | 4 | // 2xx 5 | export const OK = (body: BodyInit | T, headers = {}) => { 6 | const init = { status: 200, headers }; 7 | return isPlainObject(body) || Array.isArray(body) 8 | ? JSON(body, init) 9 | : new Response(body, init); 10 | }; 11 | 12 | export const Created = (body: BodyInit | T = '', headers = {}) => { 13 | const init = { status: 201, headers }; 14 | return isPlainObject(body) || Array.isArray(body) 15 | ? JSON(body, init) 16 | : new Response(body, init); 17 | }; 18 | 19 | export const Accepted = (headers = {}) => new Response('', { status: 202, headers }); 20 | 21 | export const NoContent = (headers = {}) => new Response('', { status: 204, headers }); 22 | 23 | // 3xx 24 | export const Redirect = (url: string, status = 302) => Response.redirect(url, status); 25 | 26 | // 4xx 27 | export const BadRequest = (body: BodyInit | T = '') => { 28 | const init = { status: 400 }; 29 | 30 | return isPlainObject(body) || Array.isArray(body) 31 | ? JSON(body, init) 32 | : new Response(body, init); 33 | }; 34 | 35 | export const Unauthorized = (body: BodyInit | T = '') => { 36 | const init = { status: 401 }; 37 | return isPlainObject(body) || Array.isArray(body) 38 | ? JSON(body, init) 39 | : new Response(body, init); 40 | }; 41 | 42 | export const Forbidden = (body: BodyInit | T = '') => { 43 | const init = { status: 403 }; 44 | return isPlainObject(body) || Array.isArray(body) 45 | ? JSON(body, init) 46 | : new Response(body, init); 47 | }; 48 | 49 | export const NotFound = (headers = {}) => new Response('Not Found', { status: 404, headers }); 50 | 51 | export const MethodNotAllowed = () => new Response('', { status: 405 }); 52 | 53 | export const NotAcceptable = () => new Response('', { status: 406 }); 54 | 55 | export const Conflict = (body: BodyInit | T = '') => { 56 | const init = { status: 409 }; 57 | return isPlainObject(body) || Array.isArray(body) 58 | ? JSON(body, init) 59 | : new Response(body, init); 60 | }; 61 | 62 | // 5xx 63 | export const InternalServerError = ( 64 | body: BodyInit | T = '', 65 | ) => { 66 | const init = { status: 500 }; 67 | 68 | return isPlainObject(body) || Array.isArray(body) 69 | ? JSON(body, init) 70 | : new Response(body, init); 71 | }; 72 | 73 | // Special case (type-related) 74 | 75 | export const HTML = (body: string | ReadableStream, headers = {}) => 76 | new Response(body, { 77 | status: 200, 78 | headers: { ...headers, 'Content-Type': 'text/html; charset=utf-8' }, 79 | }); 80 | 81 | export const Plain = (body?: BodyInit, init?: ResponseInit) => new Response(body, init); 82 | 83 | // FIXME why `body` cannot be `PlainObject` -> ask Michal 84 | export const JSON = (body: unknown, { status, headers }: ResponseInit) => { 85 | const json = globalThis.JSON.stringify(body, null, 2); 86 | 87 | return new Response(json, { 88 | status, 89 | headers: { ...headers || {}, 'Content-Type': 'application/json; charset=utf-8' }, 90 | }) 91 | } 92 | 93 | export const EventStream = (body: string | ReadableStream, headers = {}, status = 200) => 94 | new Response(body, { 95 | status, 96 | headers: { ...headers, 'Content-Type': 'text/event-stream' }, 97 | }); -------------------------------------------------------------------------------- /routing.ts: -------------------------------------------------------------------------------- 1 | import type { Handler, Middleware, Params, Pipeline, Routes, RequestExtension, _Request, } from './mod.ts'; 2 | 3 | import { HTTPMethod } from './mod.ts'; 4 | import { isHandler, isHandlerMapping, isPipeline, compose } from './util.ts'; 5 | import * as Response from './response.ts'; 6 | import { Router } from './router.ts'; 7 | 8 | const inferRequestValueType = (v: string): string | number | boolean => { 9 | if (v === '') { 10 | return true; 11 | } else if (v === 'true') { 12 | return true; 13 | } else if (v === 'false') { 14 | return false; 15 | } else if (!isNaN(Number(v))) { 16 | return +v; 17 | } 18 | return v; 19 | }; 20 | 21 | const parseBody = async (request: Request) => { 22 | const { headers } = request; 23 | 24 | const buffer = request.body; 25 | 26 | if (!buffer) { 27 | return { params: {}, files: {} }; 28 | } 29 | 30 | const contentType = headers.get('Content-Type')?.split(';')[0]; 31 | 32 | switch (contentType) { 33 | case 'application/x-www-form-urlencoded': { 34 | const form = await request.formData(); 35 | const params: Params = {}; 36 | for (const [key, value] of form) { 37 | params[key] = inferRequestValueType(value as string); 38 | } 39 | return { params, files: {} }; 40 | } 41 | case 'application/json': { 42 | const params = await request.json(); 43 | return { params, files: {} }; 44 | } 45 | case 'multipart/form-data': { 46 | const form = await request.formData(); 47 | 48 | const params: Params = {}; 49 | const files: Record = {}; 50 | for (const [key, value] of form) { 51 | if (value instanceof File) { 52 | // TODO add mimetype? encoding? 53 | files[key] = value; 54 | } else { 55 | params[key] = value; 56 | } 57 | } 58 | 59 | return { params, files }; 60 | } 61 | default: 62 | return { params: {}, files: {} }; 63 | } 64 | }; 65 | 66 | const RouteFinder = (router: Router): Middleware => { 67 | return (nextHandler: Handler) => 68 | async (request: Request & RequestExtension, arg1, arg2) => { 69 | const { method, url } = request; 70 | 71 | const pathname = new URL(url).pathname; 72 | const data = router.find(method, pathname) || router.find('ANY', pathname); 73 | 74 | if (data) { 75 | const { handler: foundHandler, params: pathParams } = data; 76 | 77 | const queryParams: Params = {}; 78 | const { searchParams } = new URL(url); 79 | for (const [key, value] of searchParams) { 80 | queryParams[key] = inferRequestValueType(value); 81 | } 82 | 83 | const { files, params: bodyParams } = await parseBody(request.clone()); 84 | 85 | request.params = { ...queryParams, ...pathParams, ...bodyParams }; 86 | request.files = files; 87 | 88 | return await foundHandler(request, arg1, arg2); 89 | } else { 90 | return nextHandler(request, arg1, arg2); 91 | } 92 | }; 93 | }; 94 | 95 | export const Routing = (routes: Routes = []) => { 96 | const router = new Router(); 97 | const middlewares: Middleware[] = []; 98 | 99 | const add = ( 100 | method: HTTPMethod | 'ANY', 101 | path: string, 102 | ...fns: [...Middleware[], Handler] 103 | ) => { 104 | const action = fns.pop() as Handler; 105 | 106 | // pipeline is a handler composed over middlewares, 107 | // `action` function must be explicitly extracted from the pipeline 108 | // as it has different signature, thus cannot be composed 109 | const pipeline: Handler = fns.length === 0 110 | ? action 111 | : compose(...(fns as Middleware[]))(action) as Handler; 112 | 113 | router.add(method, path, pipeline); 114 | }; 115 | 116 | for (const [path, unit] of routes) { 117 | if (isHandlerMapping(unit)) { 118 | const { middleware = [] } = unit; 119 | 120 | for (const [method, handler] of Object.entries(unit)) { 121 | if (method in HTTPMethod) { 122 | const handlerContainer: Pipeline = isPipeline(handler) ? handler : [handler]; 123 | const flow: Pipeline = [...middleware, ...handlerContainer]; 124 | add(method as HTTPMethod, path, ...flow); 125 | } 126 | // else: a key name undefined in the spec -> discarding 127 | } 128 | 129 | continue; 130 | } 131 | 132 | if (isPipeline(unit)) { 133 | add('ANY', path, ...unit); 134 | continue; 135 | } 136 | 137 | if (isHandler(unit)) { 138 | add('ANY', path, unit); 139 | continue; 140 | } 141 | } 142 | 143 | const NotFound: Handler = (_) => Response.NotFound(); 144 | const pipeline = compose(...middlewares, RouteFinder(router))(NotFound); 145 | 146 | // deno-lint-ignore no-explicit-any 147 | return (req: Request, arg1: any, arg2: any) => { 148 | const request = req as _Request; 149 | request.params = {}; 150 | request.files = {}; 151 | 152 | return pipeline(request, arg1, arg2); 153 | }; 154 | }; 155 | -------------------------------------------------------------------------------- /router.ts: -------------------------------------------------------------------------------- 1 | // This code is inspired by / derived from: 2 | // 3 | // Trek.js Router - fundon MIT License 4 | // https://github.com/trekjs/router 5 | // 6 | // Hono TrieRouter - Yusuke Wada and Hono contributors MIT License 7 | // https://github.com/honojs/hono/tree/main/src/router/trie-router 8 | 9 | import { Handler, HTTPMethod, Params } from "./mod.ts"; 10 | import { segmentize } from "./util.ts"; 11 | 12 | type Placeholder = string | '*' 13 | 14 | interface HandlerContainer { 15 | handler: T 16 | rank: number 17 | route: string 18 | } 19 | 20 | export const unpackOptionalDynamicParam = (pathname: string) => { 21 | const match = pathname.match(/(^.+)(\/\:[^\/]+)\?$/) 22 | if (!match) return null 23 | 24 | const base = match[1] 25 | const optional = base + match[2] 26 | return [base, optional] 27 | } 28 | 29 | export const extractPlaceholder = (segment: string): Placeholder | null => { 30 | if (segment === '*') return '*'; 31 | 32 | const match = segment.match(/^\:([^\{\}]+)$/) 33 | if (match) return match[1] 34 | 35 | return null 36 | } 37 | 38 | const hasDynamicParam = (name: string) => (node: Node): boolean => 39 | node.placeholders.some((n) => n === name) 40 | || Object.values(node.children).some(hasDynamicParam(name)) 41 | 42 | export class Node { 43 | children: Record> = {}; 44 | 45 | routes: Record> = {}; 46 | placeholders: Placeholder[] = []; 47 | 48 | rank = 0; 49 | name = ''; 50 | shouldCapture = false; 51 | 52 | insert(method: HTTPMethod, path: string, handler: T): Node { 53 | this.name = `${method} ${path}` 54 | this.rank = ++this.rank 55 | 56 | // deno-lint-ignore no-this-alias 57 | let node: Node = this 58 | 59 | const segments = segmentize(path) 60 | 61 | const parentPlaceholders: Placeholder[] = [] 62 | 63 | for (const segment of segments) { 64 | if (Object.keys(node.children).includes(segment)) { 65 | parentPlaceholders.push(...node.placeholders) 66 | node = node.children[segment] 67 | continue 68 | } 69 | 70 | node.children[segment] = new Node() 71 | 72 | const placeholder = extractPlaceholder(segment) 73 | 74 | if (placeholder) { 75 | // not wildcard 76 | if (placeholder !== '*') { 77 | this.shouldCapture = true 78 | 79 | for (const name of parentPlaceholders) { 80 | if (name === placeholder) { 81 | throw new Error(`Named param label duplicated '${placeholder}' for ${method} ${path}`) 82 | } 83 | } 84 | if (Object.values(node.children).some(hasDynamicParam(placeholder))) { 85 | throw new Error(`Named param label duplicated '${placeholder}' for ${method} ${path}`) 86 | } 87 | } 88 | node.placeholders.push(placeholder) 89 | parentPlaceholders.push(...node.placeholders) 90 | } 91 | parentPlaceholders.push(...node.placeholders) 92 | node = node.children[segment] 93 | node.shouldCapture = this.shouldCapture 94 | } 95 | 96 | node.routes[method] = { handler, route: this.name, rank: this.rank } 97 | 98 | return node 99 | } 100 | 101 | search(method: HTTPMethod | "ANY", path: string) { 102 | const candidates: HandlerContainer[] = [] 103 | const params: Params = {} 104 | 105 | let nodes = [this] as Node[]; 106 | const segments = segmentize(path) 107 | 108 | const size = segments.length; 109 | for (let idx = 0; idx < size; idx++) { 110 | const segment = segments[idx]; 111 | const isTerminal = idx === size - 1 112 | const temp: Node[] = [] 113 | let matched = false 114 | 115 | for (const node of nodes) { 116 | const next = node.children[segment] 117 | 118 | if (next) { 119 | if (isTerminal) { 120 | if (next.children['*']) { 121 | const found = next.children['*'].routes[method] 122 | if (found) candidates.push(found); 123 | } 124 | const found = next.routes[method]; 125 | if (found) candidates.push(found) 126 | matched = true 127 | } else { 128 | temp.push(next) 129 | } 130 | } 131 | 132 | for (const name of node.placeholders) { 133 | if (name === '*') { 134 | const child = node.children['*'] 135 | 136 | const found = child.routes[method] 137 | if (found) candidates.push(found) 138 | 139 | temp.push(child) 140 | 141 | continue 142 | } 143 | 144 | if (segment === '') continue 145 | 146 | if (isTerminal) { 147 | const found = node.children[`:${name}`].routes[method] 148 | if (found) candidates.push(found) 149 | } else { 150 | temp.push(node.children[`:${name}`]) 151 | } 152 | 153 | if (!matched) { 154 | params[name] = segment 155 | } else { 156 | if (node.children[segment] && node.children[segment].shouldCapture) { 157 | params[name] = segment 158 | } 159 | } 160 | } 161 | } 162 | 163 | nodes = temp 164 | } 165 | 166 | if (candidates.length > 0) { 167 | const handler = candidates 168 | .sort((a, b) => a.rank - b.rank) 169 | .map((s) => s.handler) 170 | .shift()! 171 | 172 | return { handler, params } 173 | } 174 | 175 | return null; 176 | } 177 | } 178 | 179 | 180 | export class Router { 181 | root: Node 182 | 183 | constructor() { 184 | this.root = new Node() 185 | } 186 | 187 | add(method: HTTPMethod, path: string, handler: T) { 188 | const results = unpackOptionalDynamicParam(path) 189 | if (results) { 190 | const [basePath, optionalPath] = results; 191 | 192 | this.root.insert(method, basePath, handler) 193 | this.root.insert(method, optionalPath, handler) 194 | } else { 195 | this.root.insert(method, path, handler) 196 | } 197 | } 198 | 199 | find(method: HTTPMethod | "ANY", path: string) { 200 | return this.root.search(method, path) 201 | } 202 | } --------------------------------------------------------------------------------