Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat!: add http method matching #59

Closed
wants to merge 18 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions benchmark/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@ Below results are based on my personal PC using WSL2. You can use provided scrip
Directly benchmarking `lookup` performance using [benchmark](https://www.npmjs.com/package/benchmark)

Scripts:

- `pnpm bench`
- `pnpm bench:profile` (using [0x](https://www.npmjs.com/package/0x) to generate flamegraph)


```
--- Test environment ---

Expand All @@ -34,14 +34,14 @@ Stats:
lookup x 1,365,609 ops/sec Β±0.64% (88 runs sampled)
Stats:
- /choot/123: 7074324
```
```

## HTTP Benchmark


Using [`autocannon`](https://github.com/mcollina/autocannon) and a simple http listener using lookup for realworld performance.

Scripts:

- `pnpm bench:http`

```
Expand Down
24 changes: 18 additions & 6 deletions benchmark/direct.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
/* eslint-disable no-console */
import Benchmark from "benchmark"; // https://www.npmjs.com/package/benchmark'
import { printEnv, benchSets, printStats, router, logSection } from "./utils.mjs";
import {
printEnv,
benchSets,
printStats,
router,
logSection,
} from "./utils.mjs";

async function main () {
async function main() {
printEnv();

for (const bench of benchSets) {
Expand All @@ -11,13 +17,19 @@ async function main () {
const stats = {};
suite.add("lookup", () => {
for (const req of bench.requests) {
const match = router.lookup(req.path);
if (!match) { stats[match] = (stats[match] || 0) + 1; }
const match = router.lookup(req.path, { method: req.method });
if (!match) {
stats[match] = (stats[match] || 0) + 1;
}
stats[req.path] = (stats[req.path] || 0) + 1;
}
});
suite.on("cycle", (event) => { console.log(String(event.target)); });
const promise = new Promise(resolve => suite.on("complete", () => resolve()));
suite.on("cycle", (event) => {
console.log(String(event.target));
});
const promise = new Promise((resolve) =>
suite.on("complete", () => resolve())
);
suite.run({ async: true });
await promise;
printStats(stats);
Expand Down
36 changes: 24 additions & 12 deletions benchmark/http.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,23 @@

import autocannon from "autocannon"; // https://github.com/mcollina/autocannon
import { listen } from "listhen";
import { printEnv, benchSets, printStats, logSection, router } from "./utils.mjs";
import {
printEnv,
benchSets,
printStats,
logSection,
router,
} from "./utils.mjs";

async function main () {
async function main() {
printEnv();

for (const bench of benchSets) {
logSection(`Benchmark: ${bench.title}`);
const { listener, stats } = await createServer();
const instance = autocannon({
url: listener.url,
requests: bench.requests
requests: bench.requests,
});
autocannon.track(instance);
process.once("SIGINT", () => {
Expand All @@ -29,16 +35,22 @@ async function main () {
// eslint-disable-next-line unicorn/prefer-top-level-await
main().catch(console.error);

async function createServer () {
async function createServer() {
const stats = {};
const listener = await listen((req, res) => {
stats[req.url] = (stats[req.url] || 0) + 1;
const match = router.lookup(req.url);
if (!match) {
stats[match] = (stats[match] || 0) + 1;
}
res.end(JSON.stringify((match || { error: 404 })));
}, { showURL: false });
const listener = await listen(
(req, res) => {
stats[req.url] = (stats[req.url] || 0) + 1;
const match = router.lookup(
req.url,
req.url.endsWith("get") ? { method: "GET" } : undefined
);
if (!match) {
stats[match] = (stats[match] || 0) + 1;
}
res.end(JSON.stringify(match || { error: 404 }));
},
{ showURL: false }
);

return { listener, stats };
}
61 changes: 37 additions & 24 deletions benchmark/utils.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@ import { readFileSync } from "node:fs";
import os from "node:os";
import { createRouter } from "radix3";

export const logSection = (title) => { console.log(`\n--- ${title} ---\n`); };
export const logSection = (title) => {
console.log(`\n--- ${title} ---\n`);
};

const pkgVersion = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
const pkgVersion = JSON.parse(
readFileSync(new URL("../package.json", import.meta.url), "utf8")
).version;

export function printEnv () {
export function printEnv() {
logSection("Test environment");
console.log("Node.js version:", process.versions.node);
console.log("Radix3 version:", pkgVersion);
Expand All @@ -17,36 +21,45 @@ export function printEnv () {
console.log("");
}

export function printStats (stats) {
console.log("Stats:\n" + Object.entries(stats).map(([path, hits]) => ` - ${path}: ${hits}`).join("\n"));
export function printStats(stats) {
console.log(
"Stats:\n" +
Object.entries(stats)
.map(([path, hits]) => ` - ${path}: ${hits}`)
.join("\n")
);
}

export const router = createRouter({
routes: Object.fromEntries([
"/hello",
"/cool",
"/hi",
"/helium",
"/coooool",
"/chrome",
"/choot",
"/choot/:choo",
"/ui/**",
"/ui/components/**"
].map(path => [path, { path }]))
routes: Object.fromEntries(
[
"/hello",
"/cool",
"/hi",
"/helium",
"/coooool",
"/chrome",
"/choot",
"/choot/:choo",
"/ui/**",
"/ui/components/**",
].map((path) => [path, { path }])
),
});

router.insert("/method-get", { path: "/method-get" }, { method: "GET" });

export const benchSets = [
{
title: "static route",
requests: [
{ path: "/choot" }
]
requests: [{ path: "/choot" }],
},
{
title: "dynamic route",
requests: [
{ path: "/choot/123" }
]
}
requests: [{ path: "/choot/123" }],
},
{
title: "method route",
requests: [{ path: "/method-get", method: "GET" }],
},
];
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@
"bench:profile": "0x -o -D benchmark/.profile -- node ./benchmark/direct.mjs",
"build": "unbuild",
"dev": "vitest",
"lint": "eslint --ext .ts,.mjs . && prettier -c src tests",
"lint:fix": "eslint --fix --ext .ts,.mjs . && prettier -w src tests",
"lint": "eslint --ext .ts,.mjs . && prettier -c src tests benchmark",
"lint:fix": "eslint --fix --ext .ts,.mjs . && prettier -w src tests benchmark",
"playground": "pnpm jiti ./playground.ts",
"release": "pnpm test && pnpm build && changelogen --release && git push --follow-tags && pnpm publish",
"test": "pnpm lint && vitest run"
Expand Down
67 changes: 53 additions & 14 deletions src/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,39 +5,71 @@ import type {
RadixRouter,
RadixNodeData,
RadixRouterOptions,
LookupOptions,
InsertOptions,
StaticRoutesMap,
} from "./types";
import { NODE_TYPES } from "./types";
import { HTTPMethods, NODE_TYPES } from "./types";

export function createRouter<T extends RadixNodeData = RadixNodeData>(
options: RadixRouterOptions = {}
): RadixRouter<T> {
const ctx: RadixRouterContext = {
options,
rootNode: createRadixNode(),
staticRoutesMap: {},
staticRoutesMap: Object.fromEntries(
HTTPMethods.map((method) => [method, {}])
) as StaticRoutesMap,
};

const normalizeTrailingSlash = (p) =>
const normalizePath = (p: string) =>
options.strictTrailingSlash ? p : p.replace(/\/$/, "") || "/";

const getInsertOptions = (p: string) =>
"method" in options
? {
path: normalizePath(p),
payload: options.routes[p].payload,
method: options.routes[p].method,
}
: {
path: normalizePath(p),
payload: options.routes[p],
};

if (options.routes) {
for (const path in options.routes) {
insert(ctx, normalizeTrailingSlash(path), options.routes[path]);
insert(ctx, getInsertOptions(path));
}
}

const insertRoute = (pathOrObj: string | InsertOptions<T>, data?: T) => {
const isStr = typeof pathOrObj === "string";
return insert(ctx, {
path: normalizePath(isStr ? pathOrObj : pathOrObj.path),
payload: isStr ? data : pathOrObj.payload,
method: isStr ? undefined : pathOrObj.method,
});
};

return {
ctx,
// @ts-ignore
lookup: (path: string) => lookup(ctx, normalizeTrailingSlash(path)),
insert: (path: string, data: any) =>
insert(ctx, normalizeTrailingSlash(path), data),
remove: (path: string) => remove(ctx, normalizeTrailingSlash(path)),
// @ts-expect-error - types are not matching
lookup: (path: string, options?: LookupOptions) =>
lookup(ctx, normalizePath(path), options),
insert: insertRoute,
remove: (path: string) => remove(ctx, normalizePath(path)),
};
}

function lookup(ctx: RadixRouterContext, path: string): MatchedRoute {
const staticPathNode = ctx.staticRoutesMap[path];
function lookup(
ctx: RadixRouterContext,
path: string,
options?: LookupOptions
): MatchedRoute {
const method = options?.method;

const staticPathNode = ctx.staticRoutesMap[method ?? "ALL"][path];
if (staticPathNode) {
return staticPathNode.data;
}
Expand Down Expand Up @@ -83,6 +115,10 @@ function lookup(ctx: RadixRouterContext, path: string): MatchedRoute {
return null;
}

if (method && node.method !== method) {
return null;
}

if (paramsFound) {
return {
...node.data,
Expand All @@ -93,7 +129,9 @@ function lookup(ctx: RadixRouterContext, path: string): MatchedRoute {
return node.data;
}

function insert(ctx: RadixRouterContext, path: string, data: any) {
function insert<T>(ctx: RadixRouterContext, options: InsertOptions<T>) {
const { path, payload: data, method = undefined } = options;

let isStaticRoute = true;

const sections = path.split("/");
Expand All @@ -111,7 +149,7 @@ function insert(ctx: RadixRouterContext, path: string, data: any) {
const type = getNodeType(section);

// Create new node to represent the next part of the path
childNode = createRadixNode({ type, parent: node });
childNode = createRadixNode({ type, parent: node, method });

node.children.set(section, childNode);

Expand All @@ -136,7 +174,7 @@ function insert(ctx: RadixRouterContext, path: string, data: any) {
// Optimization, if a route is static and does not have any
// variable sections, we can store it into a map for faster retrievals
if (isStaticRoute === true) {
ctx.staticRoutesMap[path] = node;
ctx.staticRoutesMap[method || "ALL"][path] = node;
}

return node;
Expand Down Expand Up @@ -175,6 +213,7 @@ function createRadixNode(options: Partial<RadixNode> = {}): RadixNode {
parent: options.parent || null,
children: new Map(),
data: options.data || null,
method: options.method || null,
paramName: options.paramName || null,
wildcardChildNode: null,
placeholderChildNode: null,
Expand Down
Loading