用 TypeScript 简单实现一个 Actix Web 的 Router

maokaihe maokaihe #TypeScript #Actix Web #Router #Source Code Reading

用一段一百多行的 TypeScript 把 Actix Web 的 ResourceDef / Router / recognize 思路抄一遍,看看 Rust 的路由设计落到动态语言里是什么样。

引言

Actix Web 的路由设计没有使用常见的Radix树,然后我用AI帮我分析一下actix的实现方式,写一个简化版本的。源代码是Rust写的,最近vibe做项目用TS比较多, 所以就用TS来实现了一下,同时巩固一下对TS类型系统使用方式的理解。

代码写完之后用Minimax帮我写了一下博客,然后有的地方我补充了一点。

完整代码不长,主要由三块组成:

  • ResourceDef:URL 模式的编译产物,分静态和动态两种。
  • Router<T, U>:注册路由的容器,泛型 T 是 handler,U 是 guard。
  • recognize:根据请求路径找到第一条匹配的路由,必要时再过 guard。

下面按这条线把代码拆开讲。

整体形态

先看一眼顶层结构:

interface ResourceDef {
tp: Pattern,
raw: string,
}
interface StaticPattern {
kind: 'static',
value: string,
}
interface DynamicPattern {
kind: 'dynamic',
rg: RegExp,
names: string[],
}
type Pattern = StaticPattern | DynamicPattern;

ResourceDef 是路由的“模式描述”。raw 保留原始字符串只是为了调试和日志;真正用于匹配的是 tp 这棵 union。

这里做了一个很关键的分支:/api/users 这种完全没有占位符的模式,单独走 StaticPattern,匹配时直接 === 字符串相等;/user/{id:\d+}/x/{name} 这种带 {} 的,才走 DynamicPattern,靠正则去匹配。

把静态和动态分开不是性能优化——至少不全是。更主要的原因是它在类型上更诚实:静态路径根本没有“参数”这个概念,下游不必再写 params ?? {} 之类的判空。TypeScript 的 discriminated union 把这种差异直接落到了类型上,匹配方一看 rdef.tp.kind === 'static' 就能确定 params 必为 null。

在Rust里面这两个Pattern是使用枚举来实现的,不过由于只有rust可以实现类似

enum Pattern {
Static(StaticPattern),
Dynamic(DynamicPattern),
...
}

TS的枚举enum没有rust这样强大,但是TS有很好用的和类型,使用|来实现,这点其实我也是很喜欢的。

不过相应地,discriminated union 还是要靠 kind 字段来“打标签”,匹配的时候也只能写 if (r.kind === '...') 链。

把模式字符串编译成 ResourceDef

new_resourcedef 是这套设计的入口:把 "/user/{id:\d+}/x/{name}" 这样的字符串,编译成一个 ResourceDef。

function new_resourcedef(pattern: string): ResourceDef {
if (!pattern.includes("{")) {
return {
tp: { kind: 'static', value: pattern },
raw: pattern,
};
}
const paramRegex = /\{([a-zA-Z0-9_]+)(?::([^}]+))?\}/g;
let regexStr = "^";
let last_index = 0;
const names: string[] = [];
let match: RegExpExecArray | null;
while ((match = paramRegex.exec(pattern)) !== null) {
const staticPart = pattern.slice(last_index, match.index);
regexStr += escapeReGex(staticPart);
const paramName = match[1];
const customRegex = match[2] || "[^/]+";
names.push(paramName);
regexStr += `(${customRegex})`;
last_index = paramRegex.lastIndex;
}
regexStr += escapeReGex(pattern.slice(last_index));
regexStr += "$";
return {
tp: {
kind: 'dynamic',
rg: new RegExp(regexStr),
names,
},
raw: pattern,
};
}

思路非常直接:用 g 标志的正则把 {} 当作占位符一段段扫出来,每扫到一个,就把它前后的文本当作静态片段,把 {} 内部替换成捕获组。

几个要点:

  1. 占位符语法:{name} 走默认 [^/]+,{name:regex} 走自定义正则。Actix Web 的语法就是这样,抄过来手感一致。

  2. 静态片段必须转义:用户写的 /user/{id},中间的 / 是字面量。但如果哪天用户在路径里写了 . 或者 ?,它是字面量,不是正则元字符。所以 staticPart 要过一遍 escapeReGex,否则它会被当成正则语义。

  3. names 和捕获组的顺序对齐:第 i 个名字对应第 i+1 个捕获组(match[0] 是整串)。后面 matchPath 会依赖这个对应关系。

  4. 首尾锚点 ^ 和 $:不加锚点,/user/alice 也会匹配 /user/{id},那就出事了。

  5. TS里面返回值是一个结构体的时候,是使用名义子类型的方式,也就是只要字段一样,就是看作是一个类型,这在Go里面好像叫做Duck Type

  6. TS里面正则表达式是原生就有的一个RegExp,感觉还是挺好用的

escapeReGex 也很朴素:

function escapeReGex(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

把正则元字符加反斜杠。注意它必须放在 new_resourcedef 调 escapeReGex(pattern.slice(last_index)) 之后才能正确收尾——最后一个占位符之后那段静态文本也得被转义。

真正干活:matchPath

ResourceDef 编译完了,匹配就只需要一个函数:

function matchPath(rdef: ResourceDef, path: string): {
matched: boolean;
params: Record<string, string> | null
} {
if (rdef.tp.kind === 'static') {
return {
matched: rdef.tp.value === path,
params: null,
};
}
const match = rdef.tp.rg.exec(path);
if (!match) {
return { matched: false, params: null };
}
const params: Record<string, string> = {};
for (let i = 0; i < rdef.tp.names.length; i++) {
params[rdef.tp.names[i]] = match[i + 1];
}
return { matched: true, params };
}

读起来几乎不用解释:

  • 静态模式直接判断是不是一样,动态模式需要判断正则能否match
  • 命中后,把 names[i] 映射到 match[i + 1]。这就是为什么 names 的顺序必须和捕获组顺序严格一致——这个一对一映射是整个机制的核心约定。

kind 字段作为判别标签仍然是 TypeScript 最常见的写法;如果想把 if 链收口成穷尽匹配,可以继续看这篇延伸:绕过 kind 字段:ts-pattern、never 与 Pattern Matching 提案 →。

容器:Router<T, U>

add 把 pattern 编译、塞进一个数组;getRoutes 把它原样返回。没什么设计可言,唯一值得注意的是两个泛型:

interface RouterInner<T, U> {
resource: ResourceDef,
handler: T,
guard: U | null;
}
export class Router<T, U> {
private inner: RouterInner<T, U>[] = [];
public add(pattern: string, handler: T, guard: U | null = null): void {
const resourceDef = new_resourcedef(pattern);
this.inner.push({ resource: resourceDef, handler, guard });
}
public getRoutes(): RouterInner<T, U>[] {
return this.inner;
}
}

T 是 handler 类型,U 是 guard 类型。Actix Web 把 handler 写成 async fn、guard 写成 Predicate,靠宏展开成 trait object;这里我们偷懒用泛型,让 add("/user", fetchUser, ["admin"]) 这种调用顺手就把 T 和 U 推出来,使用方不必关心 trait 边界。

Router 本身不做匹配。它只负责把模式和 handler 配对存起来,留给 recognize 决定谁先谁后。这一点很重要——它意味着匹配顺序、guard 校验这些“路由层策略”独立于“路由注册”,将来想换成 trie 或者 radix tree,只需替换 recognize,Router 不用动。

路由匹配入口:recognize

recognize 才是真正有策略的地方:

export interface MatchResult<T> {
handler: T;
params: Record<string, string> | null;
rdef: ResourceDef;
}
export function recognize<T, U>(
router: Router<T, U>,
reqPath: string,
reqContext: any,
checkGuard: (reqCtx: any, guard: U) => boolean
): MatchResult<T> | null {
const cleanPath = reqPath.split('?')[0];
for (const route of router.getRoutes()) {
const { matched, params } = matchPath(route.resource, cleanPath);
if (!matched) continue;
if (route.guard && checkGuard && reqContext) {
if (!checkGuard(reqContext, route.guard)) continue;
}
return {
handler: route.handler,
params,
rdef: route.resource,
};
}
return null;
}

三件事:

  1. 剥离 query string:/users?limit=10 的 ?limit=10 不属于路径匹配的范围,先 split('?')[0] 砍掉。
  2. 顺序扫 + 命中即返回:按注册顺序逐条匹配,第一条 match 又过了 guard 的就是答案。这就是 Actix Web“先注册先生效”的语义。
  3. 把 guard 抽成回调:checkGuard 是外面传进来的谓词——它可能是“用户是否登录”、“是否带有某个 token”、“请求 method 是不是 GET”。recognize 本身不需要知道这些逻辑,它只关心“guard 通过没”。

把 checkGuard 抽象成函数而不是写死在 recognize 里,好处是 guard 们可以共享一套判定逻辑(比如所有 ["admin"] 类型的 guard 都走同一个 isAdmin),而 recognize 不用 import 任何业务模块。

跑起来

把这些拼起来,可以这样用:

type Handler = (params: Record<string, string>) => string;
type Guard = string;
const router = new Router<Handler, Guard>();
router.add("/api/users", () => "list users");
router.add("/user/{id:\\d+}", (params) => `get user ${params.id}`, "auth");
router.add("/user/{id:\\d+}/posts/{slug}", (params) => {
return `user ${params.id} post ${params.slug}`;
});
const checkGuard = (ctx: any, guard: Guard) => ctx.roles?.includes(guard);
recognize(router, "/api/users", null, checkGuard);
// → { handler: list users, params: null, rdef: ... }
recognize(router, "/user/42", { roles: ["auth"] }, checkGuard);
// → { handler: get user 42, params: { id: "42" }, rdef: ... }
recognize(router, "/user/42/posts/hello",{ roles: ["auth"] }, checkGuard);
// → { handler: user 42 post hello, params: { id: "42", slug: "hello" }, rdef: ... }
recognize(router, "/user/42?x=1", { roles: ["auth"] }, checkGuard);
// → params: { id: "42" },query 部分被剥掉
recognize(router, "/user/abc", { roles: ["auth"] }, checkGuard);
// → null,`\d+` 不匹配 "abc"
recognize(router, "/user/42", { roles: [] }, checkGuard);
// → null,guard 失败就跳过

整段代码跑下来不到 200 行,但 StaticPattern / DynamicPattern 分支、占位符语法、guard 回调这几个 Actix Web 的关键设计都搬过来了。

几个值得讲的细节

正则 g 标志的 lastIndex 陷阱。paramRegex 用了 /g,所以 paramRegex.exec 每调用一次都会从 lastIndex 继续。这个特性在这里帮我们省掉了手动维护扫描指针,但它也意味着这个 RegExp 对象不能用并发——如果两个请求同时 new_resourcedef 同一个 pattern,lastIndex 会被互相覆盖。所以正确做法是 new_resourcedef 内部每次 new RegExp(paramRegex.source, paramRegex.flags) 出一份新的,或者干脆用 String.prototype.matchAll 拿迭代器而不是反复 exec。抄代码时这点容易踩。

\\d+ 在字符串里写成 "\\d+" 是常规操作。TypeScript 里的 String.prototype.replace 和 new RegExp 接收的是字符串而不是正则字面量,所以转义得自己写两层。Actix Web 用 regex::Regex 字符串构造也会撞到相同问题,只不过 Rust 字符串字面量已经把 \ 转义过一次了。

没有优先级,只有顺序。/user/{id} 和 /user/me 同时注册,命中谁取决于谁先 add。这是 Actix Web 的真实行为,没做 trie 之前也没有“静态优先”的魔法;如果业务上需要 /user/me 抢先,必须先注册、并且写一条同样字面的精确匹配。

recognize 是 O(n)。每来一个请求都把注册的路由从头扫一遍,直到命中或全部失配。理解这层语义之后再谈索引,就知道索引是在不破坏“先注册先生效”的前提下,尽量缩小候选集合;它不是把路由策略直接替换掉。

Tip (延伸阅读)

用 Radix Tree 给 Router 做索引:一个 TypeScript 简化实现 →

总结

整套设计的精髓可以压成几句话:

  1. 模式先编译,再匹配。注册时把 "/user/{id:\d+}" 编译成 RegExp + names,匹配时只剩一行 exec。
  2. 静态与动态分类型。没有参数的路径根本不走正则,连“未来会不会有几个参数”这种不确定性都消灭在类型层。
  3. 匹配和守卫分两层。Router 只存,recognize 才决定顺序;guard 又是回调,recognize 不用关心业务规则。

把 Rust 那种带生命周期、trait object、宏展开的复杂实现压到 200 行 TypeScript,关键不是省代码,而是保留设计骨架——哪些事该分离、哪些信息该在编译期固定下来、哪些责任该留给注册方。读原版 Actix Web 源码时容易陷在语法细节里,反倒是这种“用别的语言再抄一遍”的方式,能把骨架看得更清楚。

绕过 `kind` 字段:ts-pattern、never 与 Pattern Matching 提案

maokaihe maokaihe #TypeScript #Pattern Matching

TypeScript 的 discriminated union 是不是非要带 `kind` 字段?三种 workaround 试一下

引言

在抄 Actix Web Router 那一篇里,路由模式被分成了两种:

interface StaticPattern {
kind: 'static',
value: string,
}
interface DynamicPattern {
kind: 'dynamic',
rg: RegExp,
names: string[],
}
type Pattern = StaticPattern | DynamicPattern;

每次加成员要加 kind: 'xxx'、匹配要写 if (r.kind === '...')。写多了觉得有点啰嗦——Rust 有原生 match,TypeScript 没有;要丢掉 kind 字段,又拿不到原生穷尽性检查。

下面是三种 workaround:从“拉库”到“零依赖”再到“等标准”。

库:ts-pattern

最成熟的方案是 ts-pattern。它提供 match(v).with(...) 链式 API,对 discriminated union 特别友好:

import { match } from 'ts-pattern';
function matchPath(rdef: ResourceDef, path: string) {
return match(rdef.tp)
.with({ kind: 'static' }, ({ value }) => ({
matched: value === path,
params: null,
}))
.with({ kind: 'dynamic' }, ({ rg, names }) => {
const m = rg.exec(path);
if (!m) return { matched: false, params: null };
const params: Record<string, string> = {};
names.forEach((n, i) => { params[n] = m[i + 1]; });
return { matched: true, params };
})
.exhaustive();
}

最关键的是末尾的 .exhaustive()——少写一个分支,编译期就报错。也就是说,kind 字段的“判别”工作被库接过去了,而“忘了处理某个分支”这种 bug 直接在编译阶段被卡住。代价是引入一个外部依赖。

不拉库:never + switch 收口

不想拉依赖的话,传统做法是 never + switch:

function assertNever(x: never): never {
throw new Error(`Unhandled: ${JSON.stringify(x)}`);
}
function matchPath(rdef: ResourceDef, path: string) {
switch (rdef.tp.kind) {
case 'static':
return { matched: rdef.tp.value === path, params: null };
case 'dynamic': {
const m = rdef.tp.rg.exec(path);
if (!m) return { matched: false, params: null };
const params: Record<string, string> = {};
rdef.tp.names.forEach((n, i) => { params[n] = m[i + 1]; });
return { matched: true, params };
}
default:
return assertNever(rdef.tp); // 漏 case 时这里会编译报错
}
}

assertNever 的参数是 never,编译器会逼着你把所有 case 走完。将来加 kind: 'regex-literal' 之类的新分支时,switch 漏掉哪个、default 里就立刻“开骂”。kind 字段没消失,但 if 链变成了 switch + 穷尽性检查,啰嗦程度比纯 if 要好不少。

未来:TC39 的 Pattern Matching 提案

ECMAScript 早就有 Pattern Matching 提案,目前还在 Stage 1。提案语法长这样:

match (rdef.tp) {
when ({ kind: 'static', value }) {
return { matched: value === path, params: null };
}
when ({ kind: 'dynamic', rg, names }) {
return { matched: rg.test(path), params: ... };
}
}

写起来几乎和 Rust 一样自然,编译器原生穷尽性检查,听起来是终极解。但要等到所有主流运行时都支持,估计还有几年——目前还得靠 Babel 插件才能跑。所以至少短期内,前两条 workaround 还得顶着用。

总结

  • 项目已经用了 ts-pattern:直接上,.exhaustive() 把穷尽性握在手里。
  • 不想加依赖、又愿意保留 kind:用 never 兜底。
  • 等 stdlib:还有得等。

kind 字段本身不丑——只是当 union 成员越来越多时,人为维护的“标签 + if 链”会越来越容易出错。把“穷尽性”交给编译器 / 库来做,正是这三种方案共同的卖点。

用 Radix Tree 给 Router 做索引:一个 TypeScript 简化实现

maokaihe maokaihe

从一棵压缩前缀树出发,实现路由的注册、分裂与精确查找,并看看它和顺序扫描 Router 的边界。

上一篇里,recognize 每次请求都会按注册顺序遍历所有路由。这个方案非常直白,也保留了 Actix Web 的“先注册先生效”语义;但路由越来越多时,匹配成本自然会变成 O(n)。

这一篇换一个角度:先不处理 {id}、guard、method,也不试图复刻完整框架,只给纯字符串路径的精确匹配做一个 Radix Tree(压缩前缀树)索引。实现不长,但“插入时如何拆节点”很值得亲手写一遍。

Note (边界)

下面的 Router 只支持完整字符串匹配:/users/42 与 /users/{id} 是两个普通字符串,不会解析参数。动态段、优先级、HTTP method 等属于下一层路由策略,而不是这棵树本身。

为什么不是普通 Trie

普通 Trie 一条边只放一个字符。注册 /apple、/apply 时,树会变成 a → p → p → l,然后才分叉到 e 与 y。当 key 是 URL 这类长字符串时,单字符节点很多,而且绝大多数都只有一个孩子。

Radix Tree 把这段“只有一个孩子”的路径压缩到一条边里:

普通 Trie
root → a → p → p → l → e *
└→ y *
Radix Tree
root → appl
├→ e *
└→ y *

因此节点保存的不是单个字符,而是一段 prefix。查询时仍然从根向下走;区别只在于每一步会消费一整段公共前缀。

节点与 Router

先把节点定义得足够小:一段压缩前缀、子节点、终点标记和可选 handler。

interface RadixNode<T> {
prefix: string;
children: RadixNode<T>[];
isEnd: boolean;
handler?: T;
}
class Router<T> {
private root: RadixNode<T>;
constructor() {
this.root = {
prefix: "",
children: [],
isEnd: false,
};
}
}

root 不代表任何路径,所以它的 prefix 固定为空。isEnd 与 children 必须分开:/user 注册后又注册 /user/profile,user 既是一个完整路由,也仍然有孩子。

handler?: T 也正好表达这个关系:非终点没有 handler;终点有 handler。严格来说,调用方不应该向 add 传 undefined 作为 handler,否则 find 无法区分“路由不存在”和“注册的值就是 undefined”。实际项目里可改用 Map 或专门的 sentinel 来消除这个歧义。

核心工具:公共前缀长度

插入时不需要直接比较所有情况;先算出当前边与待插入字符串共享了多少字符即可。

// Length of the shared prefix between two strings
private commonPrefixLength(a: string, b: string): number {
const len = Math.min(a.length, b.length);
let i = 0;
while (i < len && a[i] === b[i]) {
i++;
}
return i;
}

例如 apple 与 apply 的结果是 4,apple 与 app 的结果是 3,apple 与 banana 则是 0。这一个数字足以把插入分成五种情形。

插入:五种情形

公开的 add 只从根开始,真正的工作交给递归 insert:

public add(pattern: string, handler: T): void {
this.insert(this.root, pattern, handler);
}

对父节点的每个孩子,比较 child.prefix 和新 pattern 的公共前缀长度。根据两者的关系,插入可以分成下列五种情形。

关系 例子 要做的事
完全相同 app / app 覆盖该节点的 handler
旧边是新 key 的前缀 app / apple 把 le 继续插入 app 的孩子
新 key 是旧边的前缀 apple / app 将旧节点缩为 app,旧后缀 le 成为孩子
只部分重合 apple / apply 提取共同部分 appl,分出 e、y 两个孩子
没有公共前缀 apple / banana 继续检查兄弟;均不匹配时追加一条新边

没有任何公共前缀时,继续检查下一个兄弟;所有兄弟都不匹配,直接追加一条新边即可。

完整实现如下:

interface RadixNode<T> {
prefix: string;
children: RadixNode<T>[];
isEnd: boolean;
handler?: T;
}
class Router<T> {
private root: RadixNode<T>;
constructor() {
this.root = {
prefix: "",
children: [],
isEnd: false,
};
}
// Length of the shared prefix between two strings
private commonPrefixLength(a: string, b: string): number {
const len = Math.min(a.length, b.length);
let i = 0;
while (i < len && a[i] === b[i]) {
i++;
}
return i;
}
public add(pattern: string, handler: T): void {
this.insert(this.root, pattern, handler);
}
private insert(parent: RadixNode<T>, pattern: string, handler: T): void {
for (let i = 0; i < parent.children.length; i++) {
const child = parent.children[i];
const commonLength = this.commonPrefixLength(child.prefix, pattern);
// No shared prefix: check the next sibling
if (commonLength === 0) continue;
// Exact match: same prefix; attach handler to this child
if (commonLength === child.prefix.length && commonLength === pattern.length) {
child.isEnd = true;
child.handler = handler;
return;
}
// Child's prefix fully covers the new pattern: recurse with the remainder
if (commonLength === child.prefix.length) {
this.insert(child, pattern.slice(commonLength), handler);
return;
}
// New pattern fully covers the child: shrink child to the leftover suffix
// child: apple, pattern: app
if (commonLength === pattern.length) {
const oldChild: RadixNode<T> = {
prefix: child.prefix.slice(commonLength),
children: child.children,
isEnd: child.isEnd,
handler: child.handler,
};
child.prefix = pattern;
child.children = [oldChild];
child.isEnd = true;
child.handler = handler;
return;
}
// Partial overlap: split at the shared prefix into two children
const oldChild: RadixNode<T> = {
prefix: child.prefix.slice(commonLength),
children: child.children,
isEnd: child.isEnd,
handler: child.handler,
};
const newChild: RadixNode<T> = {
prefix: pattern.slice(commonLength),
children: [],
isEnd: true,
handler,
};
child.prefix = pattern.slice(0, commonLength);
child.children = [oldChild, newChild];
child.isEnd = false;
child.handler = undefined;
return;
}
// No matching child exists: append a new sibling
parent.children.push({
prefix: pattern,
children: [],
isEnd: true,
handler,
});
}
// Exact-match lookup; returns the handler only if the path ends on a terminal node
public find(path: string): T | undefined {
return this.search(this.root, path);
}
private search(parent: RadixNode<T>, path: string): T | undefined {
for (const child of parent.children) {
if (!path.startsWith(child.prefix)) continue;
const remaining = path.slice(child.prefix.length);
if (remaining.length === 0) {
return child.isEnd ? child.handler : undefined;
}
return this.search(child, remaining);
}
return undefined;
}
// Debug: print the tree; terminal nodes are marked with "*"
public print(): void {
this.printNode(this.root, 0);
}
private printNode(node: RadixNode<T>, depth: number): void {
if (node !== this.root) {
console.log(`${" ".repeat(depth * 2)}${node.prefix}${node.isEnd ? " *" : ""}`);
}
for (const child of node.children) {
this.printNode(child, depth + 1);
}
}
}

这里最容易写错的是“分裂”时旧节点的状态转移。不能直接丢掉原来的 children、isEnd 和 handler,它们都属于 oldSuffix 那个新建的 oldChild。共同前缀节点只是新产生的中间节点:除非新 pattern 恰好在这里结束,否则它本身不是终点。

以依次插入 apple、apply 为例,第二次插入结束后结构是:

root
└─ appl
├─ e *
└─ y *

再插入 app,会触发“新 key 是旧边前缀”的分支:

root
└─ app *
└─ l
├─ e *
└─ y *

这也是 isEnd 必不可少的原因:app 不是叶子,但它是一个合法路由。

查找:只在终点返回 handler

查找过程和插入相反:找到以当前 path 开头的边,消费掉这段前缀,再向下递归。路径恰好消耗完时,必须检查 isEnd;否则 /app 会把只注册过 /apple 的树误判为命中。

同一父节点下的边不会拥有相同的首字符:一旦发生重合,插入时已经把它们拆成共同的中间节点。因此命中第一条 startsWith 的边后,可以直接向下走,不必回溯检查其他兄弟。

这个版本假定注册的 pattern 非空。若允许 add(""),空前缀会让 search 在根节点不消耗任何字符地递归;最简单的 API 约束是拒绝空 pattern,或专门把根节点当作空路径的终点处理。

跑起来

可以用一组覆盖四种插入分支的 key 做最小验证:

const router = new Router<string>();
router.add("/apple", "apple");
router.add("/apply", "apply"); // partial overlap
router.add("/app", "app"); // new pattern covers an existing edge
router.add("/app/store", "store"); // existing edge covers the new pattern
router.add("/banana", "banana"); // no shared prefix
router.add("/app", "app v2"); // exact match: overwrite handler
console.log(router.find("/app")); // "app v2"
console.log(router.find("/apple")); // "apple"
console.log(router.find("/apply")); // "apply"
console.log(router.find("/app/store")); // "store"
console.log(router.find("/ap")); // undefined
console.log(router.find("/banana")); // "banana"
router.print();