-
Notifications
You must be signed in to change notification settings - Fork 65
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: implement new Router and Node Router
- Loading branch information
Showing
11 changed files
with
1,480 additions
and
287 deletions.
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
export { createRouter } from "./node.js"; | ||
export type { HandlerOptions, NextHandler } from "./types.js"; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,63 @@ | ||
import type { IncomingMessage, ServerResponse } from "http"; | ||
import { Router } from "./router.js"; | ||
import type { HandlerOptions, HttpMethod } from "./types.js"; | ||
|
||
export type RequestHandler< | ||
Req extends IncomingMessage, | ||
Res extends ServerResponse | ||
> = (req: Req, res: Res) => void | Promise<void>; | ||
|
||
export class NodeRouter< | ||
Req extends IncomingMessage = IncomingMessage, | ||
Res extends ServerResponse = ServerResponse | ||
> extends Router<RequestHandler<Req, Res>> { | ||
async run(req: Req, res: Res) { | ||
const { fns } = this.find( | ||
req.method as HttpMethod, | ||
getPathname(req.url as string) | ||
); | ||
if (!fns.length) return; | ||
return Router.exec(fns, req, res); | ||
} | ||
handler(options?: HandlerOptions<RequestHandler<Req, Res>>) { | ||
const onNoMatch = options?.onNoMatch || onnomatch; | ||
const onError = options?.onError || onerror; | ||
return async (req: Req, res: Res) => { | ||
const { fns, middleOnly } = this.find( | ||
req.method as HttpMethod, | ||
getPathname(req.url as string) | ||
); | ||
try { | ||
if (fns.length === 0 || middleOnly) { | ||
await onNoMatch(req, res); | ||
} else { | ||
await Router.exec(fns, req, res); | ||
} | ||
} catch (err) { | ||
await onError(err, req, res); | ||
} | ||
}; | ||
} | ||
} | ||
|
||
function onnomatch(req: IncomingMessage, res: ServerResponse) { | ||
res.statusCode = 404; | ||
res.end(req.method !== "HEAD" && `Route ${req.method} ${req.url} not found`); | ||
} | ||
function onerror(err: unknown, req: IncomingMessage, res: ServerResponse) { | ||
res.statusCode = 500; | ||
// @ts-expect-error: we render regardless | ||
res.end(err?.stack); | ||
} | ||
|
||
export function getPathname(url: string) { | ||
const queryIdx = url.indexOf("?"); | ||
return queryIdx !== -1 ? url.substring(0, queryIdx) : url; | ||
} | ||
|
||
export function createRouter< | ||
Req extends IncomingMessage, | ||
Res extends ServerResponse | ||
>() { | ||
return new NodeRouter<Req, Res>(); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
declare module "regexparam" { | ||
export function parse( | ||
route: string | RegExp, | ||
loose?: boolean | ||
): { | ||
keys: string[] | false; | ||
pattern: RegExp; | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,124 @@ | ||
/** | ||
* Agnostic router class | ||
* Adapted from lukeed/trouter library: | ||
* https://github.com/lukeed/trouter/blob/master/index.mjs | ||
*/ | ||
import { parse } from "regexparam"; | ||
import type { | ||
FindResult, | ||
FunctionLike, | ||
HttpMethod, | ||
Nextable, | ||
RouteMatch, | ||
} from "./types.js"; | ||
|
||
export type Route<H> = { | ||
method: HttpMethod | ""; | ||
fns: H[]; | ||
isMiddle: boolean; | ||
} & ( | ||
| { | ||
keys: string[] | false; | ||
pattern: RegExp; | ||
} | ||
| { matchAll: true } | ||
); | ||
|
||
type RouteShortcutMethod<This, H extends FunctionLike> = ( | ||
route: RouteMatch | Nextable<H>, | ||
...fns: Nextable<H>[] | ||
) => This; | ||
|
||
export class Router<H extends FunctionLike> { | ||
routes: Route<Nextable<H>>[]; | ||
constructor() { | ||
this.routes = []; | ||
} | ||
public add( | ||
method: HttpMethod | "", | ||
route: RouteMatch | Nextable<H>, | ||
...fns: Nextable<H>[] | ||
): this { | ||
if (typeof route === "function") { | ||
fns.unshift(route); | ||
route = ""; | ||
} | ||
if (route === "") | ||
this.routes.push({ matchAll: true, method, fns, isMiddle: false }); | ||
else { | ||
const { keys, pattern } = parse(route); | ||
this.routes.push({ keys, pattern, method, fns, isMiddle: false }); | ||
} | ||
return this; | ||
} | ||
public all: RouteShortcutMethod<this, H> = this.add.bind(this, ""); | ||
public get: RouteShortcutMethod<this, H> = this.add.bind(this, "GET"); | ||
public head: RouteShortcutMethod<this, H> = this.add.bind(this, "HEAD"); | ||
public post: RouteShortcutMethod<this, H> = this.add.bind(this, "POST"); | ||
public put: RouteShortcutMethod<this, H> = this.add.bind(this, "PUT"); | ||
public patch: RouteShortcutMethod<this, H> = this.add.bind(this, "PATCH"); | ||
public delete: RouteShortcutMethod<this, H> = this.add.bind(this, "DELETE"); | ||
|
||
public use(base: RouteMatch | Nextable<H>, ...fns: Nextable<H>[]) { | ||
if (typeof base === "function") { | ||
fns.unshift(base); | ||
base = "/"; | ||
} | ||
const { keys, pattern } = parse(base, true); | ||
this.routes.push({ keys, pattern, method: "", fns, isMiddle: true }); | ||
return this; | ||
} | ||
|
||
static async exec<H extends FunctionLike>( | ||
fns: Nextable<H>[], | ||
...args: Parameters<H> | ||
): Promise<unknown> { | ||
let i = 0; | ||
const next = () => fns[++i](...args, next); | ||
return fns[i](...args, next); | ||
} | ||
|
||
find(method: HttpMethod, pathname: string): FindResult<H> { | ||
let middleOnly = true; | ||
const fns: Nextable<H>[] = []; | ||
const params: Record<string, string> = {}; | ||
const isHead = method === "HEAD"; | ||
for (const route of this.routes) { | ||
if ( | ||
route.method !== method && | ||
// matches any method | ||
route.method !== "" && | ||
// The HEAD method requests that the target resource transfer a representation of its state, as for a GET request... | ||
!(isHead && route.method === "GET") | ||
) { | ||
continue; | ||
} | ||
let matched = false; | ||
if ("matchAll" in route) { | ||
matched = true; | ||
} else { | ||
if (route.keys === false) { | ||
// routes.key is RegExp: https://github.com/lukeed/regexparam/blob/master/src/index.js#L2 | ||
const matches = route.pattern.exec(pathname); | ||
if (matches === null) continue; | ||
if (matches.groups !== void 0) | ||
for (const k in matches.groups) params[k] = matches.groups[k]; | ||
matched = true; | ||
} else if (route.keys.length > 0) { | ||
const matches = route.pattern.exec(pathname); | ||
if (matches === null) continue; | ||
for (let j = 0; j < route.keys.length; ) | ||
params[route.keys[j]] = matches[++j]; | ||
matched = true; | ||
} else if (route.pattern.test(pathname)) { | ||
matched = true; | ||
} // else not a match | ||
} | ||
if (matched) { | ||
fns.push(...route.fns); | ||
if (!route.isMiddle) middleOnly = false; | ||
} | ||
} | ||
return { fns, params, middleOnly }; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
export type HttpMethod = "GET" | "HEAD" | "POST" | "PUT" | "PATCH" | "DELETE"; | ||
|
||
export type FunctionLike = (...args: any[]) => unknown; | ||
|
||
export type RouteMatch = string | RegExp; | ||
|
||
export interface HandlerOptions<Handler extends FunctionLike> { | ||
onNoMatch?: Handler; | ||
onError?: (err: unknown, ...args: Parameters<Handler>) => ReturnType<Handler>; | ||
} | ||
|
||
export type NextHandler = () => any | Promise<any>; | ||
|
||
export type Nextable<H extends FunctionLike> = ( | ||
...args: [...Parameters<H>, NextHandler] | ||
) => any | Promise<any>; | ||
|
||
export type FindResult<H extends FunctionLike> = { | ||
fns: Nextable<H>[]; | ||
params: Record<string, string>; | ||
middleOnly: boolean; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
import { test } from "tap"; | ||
import { createRouter } from "../src/index.js"; | ||
|
||
test("imports", async (t) => { | ||
t.ok(createRouter); | ||
}); |
Oops, something went wrong.