Skip to content

Commit

Permalink
feat: implement new Router and Node Router
Browse files Browse the repository at this point in the history
  • Loading branch information
hoangvvo committed Jul 2, 2022
1 parent 070ca56 commit 707ce0a
Show file tree
Hide file tree
Showing 11 changed files with 1,480 additions and 287 deletions.
503 changes: 225 additions & 278 deletions README.md

Large diffs are not rendered by default.

14 changes: 7 additions & 7 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
"connect"
],
"type": "module",
"module": "dist/esm/index.js",
"files": [
"dist"
],
Expand All @@ -25,6 +24,7 @@
}
},
"main": "./dist/commonjs/index.cjs",
"module": "./dist/esm/index.js",
"types": "./dist/types/index.d.ts",
"sideEffects": false,
"scripts": {
Expand Down Expand Up @@ -72,6 +72,6 @@
"coverage": false
},
"dependencies": {
"regexparam": "^2.0.0"
"regexparam": "^2.0.1"
}
}
2 changes: 2 additions & 0 deletions src/index.ts
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";
63 changes: 63 additions & 0 deletions src/node.ts
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>();
}
9 changes: 9 additions & 0 deletions src/regexparam.d.ts
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;
};
}
124 changes: 124 additions & 0 deletions src/router.ts
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 };
}
}
22 changes: 22 additions & 0 deletions src/types.ts
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;
};
6 changes: 6 additions & 0 deletions test/index.test.ts
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);
});
Loading

0 comments on commit 707ce0a

Please sign in to comment.