rax-radix-tree 1.0.2 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,7 +1,9 @@
1
- # Name
1
+ # rax-radix-tree
2
2
 
3
3
  A high-performance radix tree implementation for Node.js, providing efficient routing and pattern matching capabilities. This library is built on top of the rax radix tree implementation in ANSI C (https://github.com/antirez/rax), offering excellent performance and scalability for large-scale applications.
4
4
 
5
+ > Ships with TypeScript type definitions (`index.d.ts`).
6
+
5
7
  # Table of Contents
6
8
 
7
9
  - [Name](#name)
@@ -9,13 +11,17 @@ A high-performance radix tree implementation for Node.js, providing efficient ro
9
11
  - [Synopsis](#synopsis)
10
12
  - [RadixTree](#radixtree)
11
13
  - [HttpRadixTree](#httpradixtree)
14
+ - [Matching Priority](#matching-priority)
12
15
  - [Class Methods](#class-methods)
13
16
  - [RadixTree](#radixtree-1)
14
17
  - [constructor](#constructor)
15
18
  - [findAllRoutes](#findallroutes)
16
19
  - [findRoute](#findroute)
20
+ - [addRoute](#addroute)
21
+ - [removeRoute](#removeroute)
17
22
  - [HttpRadixTree](#httpradixtree-1)
18
23
  - [constructor](#constructor-1)
24
+ - [findRoute (request fields)](#findroute-request-fields)
19
25
  - [Examples](#examples)
20
26
  - [Full Path Match](#full-path-match)
21
27
  - [Prefix Match](#prefix-match)
@@ -113,6 +119,20 @@ console.log(httpResult4); // null
113
119
 
114
120
  [Back to TOC](#table-of-contents)
115
121
 
122
+ # Matching Priority
123
+
124
+ `findAllRoutes` collects matches from three internal structures and returns them in a fixed category order:
125
+
126
+ 1. Exact matches (paths without wildcards)
127
+ 2. Suffix matches (paths starting with `*` / `**`, e.g. `**.jpg`)
128
+ 3. Prefix matches (paths ending with `*` / `**`, e.g. `/api/**`)
129
+
130
+ Within each category, routes are sorted by descending path length (longer, more specific patterns first). `findRoute` returns the first route in this combined order (after applying the custom match function, if any).
131
+
132
+ If a request omits the `method` argument (or passes an empty string), method filtering is skipped and routes match regardless of their configured `methods`. When a non-empty `method` is provided, a route matches only if its `methods` includes that method or `ALL`.
133
+
134
+ [Back to TOC](#table-of-contents)
135
+
116
136
  # Class Methods
117
137
 
118
138
  ## RadixTree
@@ -128,7 +148,14 @@ Creates a new RadixTree instance.
128
148
  Parameters:
129
149
 
130
150
  - `routes`: Array of route configurations
131
- - `matchFunc`: Optional custom matching function
151
+ - `matchFunc`: Optional custom matching function with signature `(route, req) => boolean`. When provided, `findRoute` returns the first route (in priority order) for which `matchFunc` returns a truthy value. `HttpRadixTree` uses this internally to apply host / remote address / expression checks.
152
+
153
+ ```js
154
+ const tree = new RadixTree(routes, (route, req) => {
155
+ // custom logic, e.g. only accept internal traffic
156
+ return req.internal === true;
157
+ });
158
+ ```
132
159
 
133
160
  Each route can contain the following properties:
134
161
 
@@ -149,11 +176,11 @@ Finds all routes that match the given path and method.
149
176
  Parameters:
150
177
 
151
178
  - `path`: The path to match against
152
- - `method`: (Optional) The HTTP method to match
179
+ - `method`: (Optional) The HTTP method to match. If omitted or an empty string, method filtering is skipped and routes match regardless of their configured `methods`.
153
180
 
154
181
  Returns:
155
182
 
156
- - An array of matching routes
183
+ - An array of matching routes, ordered as described in [Matching Priority](#matching-priority)
157
184
 
158
185
  ### findRoute
159
186
 
@@ -171,6 +198,30 @@ Returns:
171
198
 
172
199
  - The matching route object if found, or null if no match is found
173
200
 
201
+ ### addRoute
202
+
203
+ ```js
204
+ addRoute(route: Route): this
205
+ ```
206
+
207
+ Dynamically adds a single route after construction. The `route` argument has the same shape as the entries passed to the constructor (it must contain `paths`). Returns the tree instance for chaining.
208
+
209
+ ```js
210
+ tree.addRoute({ paths: ['/api/reports/**'], methods: ['GET'], meta: { id: 99 } });
211
+ ```
212
+
213
+ ### removeRoute
214
+
215
+ ```js
216
+ removeRoute(routePath: string): number
217
+ ```
218
+
219
+ Removes all routes previously registered for a given path pattern. `routePath` must match a pattern that was supplied in some route's `paths` (e.g. `'/api/users'`, `'/api/*'`, `'**.jpg'`). Returns the number of route entries removed (`0` if none matched).
220
+
221
+ ```js
222
+ const removed = tree.removeRoute('/api/reports/**'); // e.g. 1
223
+ ```
224
+
174
225
  ## HttpRadixTree
175
226
 
176
227
  ### constructor
@@ -191,7 +242,7 @@ Each HTTP route can contain the following properties:
191
242
  |-------------|-----------|------------------------------------------------------------------------------------------------|---------------------------------------------|
192
243
  | paths | Required | List of request paths to match. Supports the same matching rules as RadixTree. | `["/api/**", "/users/*"]` |
193
244
  | methods | Optional | List of HTTP methods to match. Same as RadixTree. | `["GET", "POST"]` |
194
- | hosts | Optional | List of host addresses to match. Supports wildcards. For example, `*.example.com` can match `foo.example.com` and `bar.example.com`. | `["example.com", "*.api.com"]` |
245
+ | hosts | Optional | List of host addresses to match. Supports wildcards. `*.example.com` matches the apex `example.com` and any subdomain such as `foo.example.com`, but requires a dot boundary, so it does NOT match `badexample.com`. A `:port` suffix in the request `Host` header is ignored during comparison. | `["example.com", "*.api.com"]` |
195
246
  | remoteAddrs | Optional | List of remote addresses (IPv4 or IPv6) to match. Supports CIDR format. | `["127.0.0.1", "192.168.1.0/24", "::1", "fe80::/10"]` |
196
247
  | exprs | Optional | List of expressions to evaluate request parameters. Uses [filtrex](https://github.com/joewalnes/filtrex) syntax. | `["age >= 18", "category in ('electronics', 'books')"]` |
197
248
  | meta | Optional | Metadata to be returned when the route matches. | `{ id: 1 }` |
@@ -200,6 +251,25 @@ HttpRadixTree adds support for matching hosts, remote addresses, and custom expr
200
251
 
201
252
  Both classes provide powerful route matching capabilities and can be chosen based on different needs. RadixTree is suitable for general path matching scenarios, while HttpRadixTree is more suitable for complex HTTP routing matching requirements.
202
253
 
254
+ ### findRoute (request fields)
255
+
256
+ `HttpRadixTree.findRoute(req)` accepts a request object with the following fields:
257
+
258
+ | Field | Description |
259
+ |--------------|-------------------------------------------------------------------------------------------------|
260
+ | `path` | Required. The request path to match. |
261
+ | `method` | Optional. The HTTP method. See [Matching Priority](#matching-priority) for omitted-method behavior. |
262
+ | `headers` | Optional. Request headers object. `headers.host` is used for `hosts` matching. |
263
+ | `remoteAddr` | Optional. Client IP used for `remoteAddrs` matching. |
264
+ | `args` | Optional. Query/body parameters exposed to `exprs`. |
265
+
266
+ Expression (`exprs`) variables are built from `args` and `headers`:
267
+
268
+ - `args` keys are lowercased (e.g. `age`, `category`).
269
+ - `headers` keys are lowercased and `-` is replaced with `_` (e.g. `user-agent` becomes `user_agent`, `x-id` becomes `x_id`).
270
+ - Numeric-looking string values are coerced to numbers before evaluation.
271
+ - `method` and `path` are always available as variables.
272
+
203
273
  [Back to TOC](#table-of-contents)
204
274
 
205
275
  # Examples
@@ -422,6 +492,13 @@ console.log(tree.findRoute({
422
492
  npm install rax-radix-tree
423
493
  ```
424
494
 
495
+ This package contains a native addon that is compiled from source on install via `node-gyp`. Make sure the following build prerequisites are available:
496
+
497
+ - Node.js >= 16 (N-API / `node-addon-api` v8)
498
+ - A C/C++ toolchain: Python 3, `make`, and a compiler (Xcode Command Line Tools on macOS, `build-essential` on Debian/Ubuntu, or the Visual Studio Build Tools on Windows)
499
+
500
+ > Note: no prebuilt binaries are published yet, so a compiler toolchain is required at install time. Contributions adding prebuilt binaries (e.g. via `prebuildify`) are welcome.
501
+
425
502
 
426
503
  # Benchmarks
427
504
 
@@ -8,28 +8,46 @@ function contains(str, substr) {
8
8
  return str.includes(substr)
9
9
  }
10
10
 
11
+ // Strip an optional :port from a Host header value so that
12
+ // `example.com:8080` matches a configured host of `example.com`.
13
+ // Bracketed IPv6 (`[::1]` / `[::1]:8080`) is unwrapped to `::1`.
14
+ function normalizeHost(host) {
15
+ if (!host) {
16
+ return host
17
+ }
18
+ if (host.charCodeAt(0) === 91 /* '[' */) {
19
+ const end = host.indexOf(']')
20
+ return end === -1 ? host : host.slice(1, end)
21
+ }
22
+ const colon = host.indexOf(':')
23
+ // Only strip when there is a single colon (IPv4 / domain + port);
24
+ // a bare IPv6 address has multiple colons and must be left intact.
25
+ if (colon !== -1 && host.indexOf(':', colon + 1) === -1) {
26
+ return host.slice(0, colon)
27
+ }
28
+ return host
29
+ }
30
+
11
31
  function matchHosts(configHosts, requestHost) {
12
32
  if (!requestHost) {
13
33
  return false
14
34
  }
15
- const matched = configHosts.some(host => {
16
- if (host.startsWith('*.')) {
17
- const domain = host.slice(2)
18
- return requestHost.endsWith(domain)
35
+ const host = normalizeHost(requestHost)
36
+ const matched = configHosts.some(configHost => {
37
+ if (configHost.startsWith('*.')) {
38
+ // `*.example.com` matches the apex `example.com` and any
39
+ // subdomain such as `foo.example.com`, but requires a dot
40
+ // boundary so it does NOT match `badexample.com`.
41
+ const domain = configHost.slice(2) // "example.com"
42
+ return host === domain || host.endsWith('.' + domain)
19
43
  }
20
- return host === requestHost
44
+ return configHost === host
21
45
  })
22
46
 
23
47
  return matched
24
48
  }
25
49
 
26
- const cache = new LRUCache({
27
- ttl: 1000 * 60,
28
- max: 10000
29
- });
30
-
31
-
32
- function matchRemoteAddrs(configAddrs, requestIp) {
50
+ function matchRemoteAddrs(configAddrs, requestIp, cache) {
33
51
  if (!requestIp) {
34
52
  return false
35
53
  }
@@ -94,7 +112,7 @@ function matchExprs(compiledExprs, req) {
94
112
  return false
95
113
  }
96
114
 
97
- function httpMatchRoute(route, req) {
115
+ function httpMatchRoute(route, req, cache) {
98
116
  // Check hosts
99
117
  const host = req.headers ? req.headers.host : ''
100
118
  if (route.hosts && route.hosts.length > 0 && !matchHosts(route.hosts, host)) {
@@ -103,7 +121,7 @@ function httpMatchRoute(route, req) {
103
121
 
104
122
  // Check remoteAddrs
105
123
  if (route.remoteAddrs && route.remoteAddrs.length > 0
106
- && !matchRemoteAddrs(route.remoteAddrs, req.remoteAddr)) {
124
+ && !matchRemoteAddrs(route.remoteAddrs, req.remoteAddr, cache)) {
107
125
  return false
108
126
  }
109
127
 
@@ -143,7 +161,23 @@ class HttpRadixTree extends RadixTree {
143
161
  }));
144
162
  route.compiledExprs = compiledExprs;
145
163
  }
146
- super(routes, httpMatchRoute);
164
+ // Per-instance remote-address match cache so that multiple trees do
165
+ // not share (and pollute) one another's cached results.
166
+ const remoteAddrCache = new LRUCache({
167
+ ttl: 1000 * 60,
168
+ max: 10000,
169
+ });
170
+ super(routes, (route, req) => httpMatchRoute(route, req, remoteAddrCache));
171
+ }
172
+
173
+ addRoute(route) {
174
+ route = Object.assign({}, route);
175
+ const exprs = route.exprs || [];
176
+ route.compiledExprs = exprs.map(expr => ({
177
+ expr,
178
+ compiledExpr: compileExpression(expr, {extraFunctions: {contains}}),
179
+ }));
180
+ return super.addRoute(route);
147
181
  }
148
182
  }
149
183
 
package/index.d.ts ADDED
@@ -0,0 +1,82 @@
1
+ export type HttpMethod =
2
+ | 'GET'
3
+ | 'POST'
4
+ | 'PUT'
5
+ | 'DELETE'
6
+ | 'PATCH'
7
+ | 'HEAD'
8
+ | 'OPTIONS'
9
+ | 'CONNECT'
10
+ | 'TRACE'
11
+ | 'ALL';
12
+
13
+ export interface Route {
14
+ /**
15
+ * Path patterns to match. Full match by default; a trailing `*` matches a
16
+ * single segment (no `/`), `**` matches anything, and a leading `**`/`*`
17
+ * performs suffix matching (e.g. `**.jpg`).
18
+ */
19
+ paths: string[];
20
+ /** HTTP methods to match. Defaults to `['ALL']` when omitted. */
21
+ methods?: HttpMethod[] | string[];
22
+ /** Arbitrary metadata returned on a match. */
23
+ meta?: unknown;
24
+ [key: string]: unknown;
25
+ }
26
+
27
+ export interface HttpRoute extends Route {
28
+ /** Host names to match. Supports `*.example.com` wildcards. */
29
+ hosts?: string[];
30
+ /** Remote addresses to match. Supports CIDR (IPv4 / IPv6). */
31
+ remoteAddrs?: string[];
32
+ /** filtrex expressions evaluated against request args/headers. */
33
+ exprs?: string[];
34
+ }
35
+
36
+ /** A route as returned by lookups: the original config plus parsed metadata. */
37
+ export type MatchedRoute = Route & {
38
+ path: string;
39
+ [key: string]: unknown;
40
+ };
41
+
42
+ export interface RequestLike {
43
+ path: string;
44
+ method?: string;
45
+ [key: string]: unknown;
46
+ }
47
+
48
+ export interface HttpRequestLike extends RequestLike {
49
+ headers?: Record<string, string>;
50
+ remoteAddr?: string;
51
+ args?: Record<string, string | number>;
52
+ }
53
+
54
+ export type MatchFunction = (route: MatchedRoute, req: RequestLike) => boolean;
55
+
56
+ export class RadixTree {
57
+ constructor(routes: Route[], matchFunc?: MatchFunction);
58
+
59
+ /**
60
+ * Find every route matching `path` (and, when provided, `method`).
61
+ * Results are ordered exact -> suffix -> prefix. When `method` is omitted
62
+ * or empty, method filtering is skipped and all routes may match.
63
+ */
64
+ findAllRoutes(path: string, method?: string): MatchedRoute[];
65
+
66
+ /** Find the first matching route for a request, or null. */
67
+ findRoute(req: RequestLike): MatchedRoute | null;
68
+
69
+ /** Dynamically add a route after construction. Returns `this`. */
70
+ addRoute(route: Route): this;
71
+
72
+ /** Remove all routes registered for `routePath`. Returns the count removed. */
73
+ removeRoute(routePath: string): number;
74
+ }
75
+
76
+ export class HttpRadixTree extends RadixTree {
77
+ constructor(routes: HttpRoute[]);
78
+
79
+ findRoute(req: HttpRequestLike): MatchedRoute | null;
80
+
81
+ addRoute(route: HttpRoute): this;
82
+ }
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "rax-radix-tree",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "Node.js binding for rax, a radix tree implementation in ANSI C (https://github.com/antirez/rax)",
5
5
  "author": "igeeky.io@gmail.com",
6
6
  "license": "MIT",
7
7
  "main": "index.js",
8
+ "types": "index.d.ts",
8
9
  "scripts": {
9
10
  "install": "node-gyp rebuild",
10
11
  "test": "mocha ./test/*.test.js"
package/radix-tree.js CHANGED
@@ -163,8 +163,9 @@ class RadixTree {
163
163
  _routeSimpleMatch(routeInfo, reqPath, method) {
164
164
  const { treePath, pattern, matchType } = routeInfo.pathInfo;
165
165
  const routeMethods = routeInfo.methods;
166
- // Check method matching
167
- if (!routeMethods.includes('ALL') && !routeMethods.includes(method)) {
166
+ // Check method matching. An empty/omitted request method matches any
167
+ // route regardless of its configured methods.
168
+ if (method && !routeMethods.includes('ALL') && !routeMethods.includes(method)) {
168
169
  return false;
169
170
  }
170
171
 
@@ -213,19 +214,16 @@ class RadixTree {
213
214
  // Suffix tree lookup logic
214
215
  if (this.suffixRouteCount > 0) {
215
216
  const reversePath = path.split('').reverse().join('');
216
- const treeIt = this.suffixTree.search(reversePath);
217
- if (treeIt) {
218
- let idx;
219
- while ((idx = treeIt.up()) !== -1) {
220
- if (idx >= this.routes.length) {
221
- console.error("Index out of range");
222
- break;
223
- }
224
- const routesArr = this.routes[idx];
225
- for (const route of routesArr) {
226
- if (this._routeSimpleMatch(route, reversePath, method)) {
227
- allRoutes.push(route);
228
- }
217
+ const indices = this.suffixTree.prefixMatch(reversePath);
218
+ for (const idx of indices) {
219
+ if (idx >= this.routes.length) {
220
+ console.error("Index out of range");
221
+ break;
222
+ }
223
+ const routesArr = this.routes[idx];
224
+ for (const route of routesArr) {
225
+ if (this._routeSimpleMatch(route, reversePath, method)) {
226
+ allRoutes.push(route);
229
227
  }
230
228
  }
231
229
  }
@@ -233,19 +231,16 @@ class RadixTree {
233
231
 
234
232
  // Prefix tree lookup logic
235
233
  if (this.prefixRouteCount > 0) {
236
- const treeIt = this.prefixTree.search(path);
237
- if (treeIt) {
238
- let idx;
239
- while ((idx = treeIt.up()) !== -1) {
240
- if (idx >= this.routes.length) {
241
- console.error("Index out of range");
242
- break;
243
- }
244
- const routesArr = this.routes[idx];
245
- for (const route of routesArr) {
246
- if (this._routeSimpleMatch(route, path, method)) {
247
- allRoutes.push(route);
248
- }
234
+ const indices = this.prefixTree.prefixMatch(path);
235
+ for (const idx of indices) {
236
+ if (idx >= this.routes.length) {
237
+ console.error("Index out of range");
238
+ break;
239
+ }
240
+ const routesArr = this.routes[idx];
241
+ for (const route of routesArr) {
242
+ if (this._routeSimpleMatch(route, path, method)) {
243
+ allRoutes.push(route);
249
244
  }
250
245
  }
251
246
  }
@@ -278,6 +273,79 @@ class RadixTree {
278
273
 
279
274
  return null;
280
275
  }
276
+
277
+ /**
278
+ * Dynamically add a single route configuration after construction.
279
+ * The route has the same shape as those passed to the constructor.
280
+ * @param {Object} route - A route configuration containing `paths`.
281
+ * @returns {RadixTree} this, for chaining.
282
+ */
283
+ addRoute(route) {
284
+ route = Object.assign({}, route);
285
+ const routePaths = route.paths;
286
+ delete route.paths;
287
+ for (const routePath of routePaths) {
288
+ this._initRoute(route, routePath);
289
+ }
290
+ this._sortAllRoutes();
291
+ return this;
292
+ }
293
+
294
+ /**
295
+ * Dynamically remove all routes registered for a given path pattern.
296
+ * The pattern must match one previously supplied in a route's `paths`
297
+ * (e.g. '/api/users', '/api/*', '**.jpg').
298
+ * @param {string} routePath - The path pattern to remove.
299
+ * @returns {number} The number of route entries removed.
300
+ */
301
+ removeRoute(routePath) {
302
+ const pathInfo = parsePath(routePath);
303
+ const treePath = pathInfo.treePath;
304
+
305
+ if (pathInfo.matchType === MATCH_TYPE_EQUALS) {
306
+ const index = this.equalsMap[treePath];
307
+ if (index === undefined) {
308
+ return 0;
309
+ }
310
+ const removed = this._pruneBucket(index, routePath);
311
+ this.equalsRouteCount -= removed;
312
+ if (this.routes[index] === undefined) {
313
+ delete this.equalsMap[treePath];
314
+ }
315
+ return removed;
316
+ }
317
+
318
+ const tree = pathInfo.matchType === MATCH_TYPE_SUFFIX ? this.suffixTree : this.prefixTree;
319
+ const index = tree.find(treePath);
320
+ if (index === null) {
321
+ return 0;
322
+ }
323
+ const removed = this._pruneBucket(index, routePath);
324
+ if (pathInfo.matchType === MATCH_TYPE_PREFIX) {
325
+ this.prefixRouteCount -= removed;
326
+ } else {
327
+ this.suffixRouteCount -= removed;
328
+ }
329
+ if (this.routes[index] === undefined) {
330
+ tree.remove(treePath);
331
+ }
332
+ return removed;
333
+ }
334
+
335
+ // Remove every routeInfo whose origin path equals `routePath` from the
336
+ // bucket at `index`. Empties the slot (set to undefined) when nothing is
337
+ // left; the index slot is intentionally never reused so existing indices
338
+ // stored in the radix trees / equalsMap stay valid.
339
+ _pruneBucket(index, routePath) {
340
+ const bucket = this.routes[index];
341
+ if (!bucket) {
342
+ return 0;
343
+ }
344
+ const kept = bucket.filter(r => r.path !== routePath);
345
+ const removed = bucket.length - kept.length;
346
+ this.routes[index] = kept.length === 0 ? undefined : kept;
347
+ return removed;
348
+ }
281
349
  };
282
350
 
283
351
  exports.parsePath = parsePath;
@@ -2,6 +2,7 @@
2
2
  #include <string>
3
3
  #include <iostream>
4
4
  #include <climits>
5
+ #include <cstdlib>
5
6
 
6
7
  extern "C" {
7
8
  #include "radix_tree.h"
@@ -11,6 +12,7 @@ class RaxIterator : public Napi::ObjectWrap<RaxIterator> {
11
12
  public:
12
13
  static Napi::Object Init(Napi::Env env, Napi::Object exports);
13
14
  RaxIterator(const Napi::CallbackInfo& info);
15
+ ~RaxIterator();
14
16
 
15
17
  private:
16
18
  static Napi::FunctionReference constructor;
@@ -30,6 +32,7 @@ class RadixTree : public Napi::ObjectWrap<RadixTree> {
30
32
  public:
31
33
  static Napi::Object Init(Napi::Env env, Napi::Object exports);
32
34
  RadixTree(const Napi::CallbackInfo& info);
35
+ ~RadixTree();
33
36
 
34
37
  private:
35
38
  static Napi::FunctionReference constructor;
@@ -40,6 +43,7 @@ private:
40
43
  Napi::Value Remove(const Napi::CallbackInfo& info);
41
44
  Napi::Value Find(const Napi::CallbackInfo& info);
42
45
  Napi::Value Search(const Napi::CallbackInfo& info);
46
+ Napi::Value PrefixMatch(const Napi::CallbackInfo& info);
43
47
  void Cleanup(const Napi::CallbackInfo& info);
44
48
  };
45
49
 
@@ -81,6 +85,13 @@ RaxIterator::RaxIterator(const Napi::CallbackInfo& info) : Napi::ObjectWrap<RaxI
81
85
  this->path.len = pathStr.length();
82
86
  }
83
87
 
88
+ RaxIterator::~RaxIterator() {
89
+ if (this->path.buf != nullptr) {
90
+ delete[] this->path.buf;
91
+ this->path.buf = nullptr;
92
+ }
93
+ }
94
+
84
95
  Napi::Value RaxIterator::Next(const Napi::CallbackInfo& info) {
85
96
  Napi::Env env = info.Env();
86
97
  int result = raxtree_next(this->iterator, &this->path);
@@ -114,6 +125,7 @@ Napi::Object RadixTree::Init(Napi::Env env, Napi::Object exports) {
114
125
  InstanceMethod("remove", &RadixTree::Remove),
115
126
  InstanceMethod("find", &RadixTree::Find),
116
127
  InstanceMethod("search", &RadixTree::Search),
128
+ InstanceMethod("prefixMatch", &RadixTree::PrefixMatch),
117
129
  InstanceMethod("cleanup", &RadixTree::Cleanup),
118
130
  });
119
131
 
@@ -129,6 +141,18 @@ RadixTree::RadixTree(const Napi::CallbackInfo& info) : Napi::ObjectWrap<RadixTre
129
141
  this->treeIt = raxtree_new_it(this->tree);
130
142
  }
131
143
 
144
+ RadixTree::~RadixTree() {
145
+ if (this->treeIt != nullptr) {
146
+ raxtree_stop(this->treeIt);
147
+ free(this->treeIt);
148
+ this->treeIt = nullptr;
149
+ }
150
+ if (this->tree != nullptr) {
151
+ raxtree_destroy(this->tree);
152
+ this->tree = nullptr;
153
+ }
154
+ }
155
+
132
156
  Napi::Value RadixTree::Insert(const Napi::CallbackInfo& info) {
133
157
  Napi::Env env = info.Env();
134
158
  if (info.Length() < 2) {
@@ -225,9 +249,44 @@ Napi::Value RadixTree::Search(const Napi::CallbackInfo& info) {
225
249
  return iteratorInstance;
226
250
  }
227
251
 
252
+ // PrefixMatch seeks the largest key <= `key` and walks up the tree collecting
253
+ // the values (route indices) of every ancestor key that is a prefix of `key`.
254
+ // Doing the whole walk natively avoids one JS<->C++ boundary crossing per
255
+ // ancestor and avoids allocating an iterator wrapper object per lookup.
256
+ Napi::Value RadixTree::PrefixMatch(const Napi::CallbackInfo& info) {
257
+ Napi::Env env = info.Env();
258
+ if (info.Length() < 1) {
259
+ Napi::TypeError::New(env, "Wrong number of arguments").ThrowAsJavaScriptException();
260
+ return env.Null();
261
+ }
262
+
263
+ std::string ele = info[0].As<Napi::String>().Utf8Value();
264
+ rt_str_t path;
265
+ path.buf = (unsigned char*)ele.c_str();
266
+ path.len = ele.length();
267
+
268
+ raxtree_search(this->tree, this->treeIt, &path);
269
+
270
+ Napi::Array result = Napi::Array::New(env);
271
+ uint32_t i = 0;
272
+ int idx;
273
+ while ((idx = raxtree_up(this->treeIt, &path)) != -1) {
274
+ result.Set(i++, Napi::Number::New(env, idx));
275
+ }
276
+
277
+ return result;
278
+ }
279
+
228
280
  void RadixTree::Cleanup(const Napi::CallbackInfo& info) {
229
- raxtree_stop(this->treeIt);
230
- raxtree_destroy(this->tree);
281
+ if (this->treeIt != nullptr) {
282
+ raxtree_stop(this->treeIt);
283
+ free(this->treeIt);
284
+ this->treeIt = nullptr;
285
+ }
286
+ if (this->tree != nullptr) {
287
+ raxtree_destroy(this->tree);
288
+ this->tree = nullptr;
289
+ }
231
290
  }
232
291
 
233
292
  Napi::Object InitAll(Napi::Env env, Napi::Object exports) {
@@ -0,0 +1,140 @@
1
+ const assert = require('assert');
2
+ const { RadixTree, HttpRadixTree } = require('../index');
3
+
4
+ describe('Regressions', function () {
5
+ describe('Wildcard host dot boundary', function () {
6
+ let tree;
7
+ beforeEach(function () {
8
+ const routes = [
9
+ { paths: ['/**'], meta: { id: 1 }, hosts: ['*.example.com'] },
10
+ ];
11
+ tree = new HttpRadixTree(routes);
12
+ });
13
+
14
+ it('should match a subdomain', function () {
15
+ const result = tree.findRoute({ path: '/', headers: { host: 'foo.example.com' } });
16
+ assert.notStrictEqual(result, null);
17
+ assert.strictEqual(result.meta.id, 1);
18
+ });
19
+
20
+ it('should match the apex domain', function () {
21
+ const result = tree.findRoute({ path: '/', headers: { host: 'example.com' } });
22
+ assert.notStrictEqual(result, null);
23
+ assert.strictEqual(result.meta.id, 1);
24
+ });
25
+
26
+ it('should NOT match a domain sharing the suffix without a dot boundary', function () {
27
+ const result = tree.findRoute({ path: '/', headers: { host: 'badexample.com' } });
28
+ assert.strictEqual(result, null);
29
+ });
30
+
31
+ it('should NOT match an unrelated domain that ends with the base', function () {
32
+ const result = tree.findRoute({ path: '/', headers: { host: 'notexample.com' } });
33
+ assert.strictEqual(result, null);
34
+ });
35
+ });
36
+
37
+ describe('Host header with port', function () {
38
+ let tree;
39
+ beforeEach(function () {
40
+ const routes = [
41
+ { paths: ['/**'], meta: { id: 1 }, hosts: ['example.com'] },
42
+ { paths: ['/**'], meta: { id: 2 }, hosts: ['*.example.com'] },
43
+ ];
44
+ tree = new HttpRadixTree(routes);
45
+ });
46
+
47
+ it('should match exact host when the request host carries a port', function () {
48
+ const result = tree.findRoute({ path: '/', headers: { host: 'example.com:8080' } });
49
+ assert.notStrictEqual(result, null);
50
+ assert.strictEqual(result.meta.id, 1);
51
+ });
52
+
53
+ it('should match wildcard host when the request host carries a port', function () {
54
+ const result = tree.findRoute({ path: '/', headers: { host: 'api.example.com:443' } });
55
+ assert.notStrictEqual(result, null);
56
+ assert.strictEqual(result.meta.id, 2);
57
+ });
58
+ });
59
+
60
+ describe('findAllRoutes with omitted method', function () {
61
+ let tree;
62
+ beforeEach(function () {
63
+ const routes = [
64
+ { paths: ['/api/x'], methods: ['POST'], meta: { id: 1 } },
65
+ { paths: ['/api/x'], methods: ['ALL'], meta: { id: 2 } },
66
+ ];
67
+ tree = new RadixTree(routes);
68
+ });
69
+
70
+ it('should match method-specific routes when method is omitted', function () {
71
+ const result = tree.findAllRoutes('/api/x');
72
+ assert.strictEqual(result.length, 2);
73
+ assert.deepStrictEqual(result.map(r => r.meta.id).sort(), [1, 2]);
74
+ });
75
+
76
+ it('should still filter by method when one is provided', function () {
77
+ const result = tree.findAllRoutes('/api/x', 'GET');
78
+ assert.strictEqual(result.length, 1);
79
+ assert.strictEqual(result[0].meta.id, 2);
80
+ });
81
+ });
82
+
83
+ describe('Dynamic addRoute / removeRoute', function () {
84
+ it('should add exact, prefix and suffix routes after construction', function () {
85
+ const tree = new RadixTree([]);
86
+ tree.addRoute({ paths: ['/api/users'], meta: { id: 1 } });
87
+ tree.addRoute({ paths: ['/api/posts/**'], meta: { id: 2 } });
88
+ tree.addRoute({ paths: ['**.jpg'], meta: { id: 3 } });
89
+
90
+ assert.strictEqual(tree.findAllRoutes('/api/users')[0].meta.id, 1);
91
+ assert.strictEqual(tree.findAllRoutes('/api/posts/2023/05')[0].meta.id, 2);
92
+ assert.strictEqual(tree.findAllRoutes('/img/a.jpg')[0].meta.id, 3);
93
+ });
94
+
95
+ it('should remove an exact route and report the count', function () {
96
+ const tree = new RadixTree([
97
+ { paths: ['/api/users'], meta: { id: 1 } },
98
+ { paths: ['/api/users'], meta: { id: 2 } },
99
+ ]);
100
+ assert.strictEqual(tree.findAllRoutes('/api/users').length, 2);
101
+ const removed = tree.removeRoute('/api/users');
102
+ assert.strictEqual(removed, 2);
103
+ assert.strictEqual(tree.findAllRoutes('/api/users').length, 0);
104
+ });
105
+
106
+ it('should remove a prefix route without affecting others', function () {
107
+ const tree = new RadixTree([
108
+ { paths: ['/api/*'], meta: { id: 1 } },
109
+ { paths: ['/api/**'], meta: { id: 2 } },
110
+ ]);
111
+ assert.strictEqual(tree.findAllRoutes('/api/x').length, 2);
112
+ assert.strictEqual(tree.removeRoute('/api/*'), 1);
113
+ const result = tree.findAllRoutes('/api/x');
114
+ assert.strictEqual(result.length, 1);
115
+ assert.strictEqual(result[0].meta.id, 2);
116
+ });
117
+
118
+ it('should remove a suffix route', function () {
119
+ const tree = new RadixTree([{ paths: ['**.jpg'], meta: { id: 1 } }]);
120
+ assert.strictEqual(tree.findAllRoutes('/a/b.jpg').length, 1);
121
+ assert.strictEqual(tree.removeRoute('**.jpg'), 1);
122
+ assert.strictEqual(tree.findAllRoutes('/a/b.jpg').length, 0);
123
+ });
124
+
125
+ it('should return 0 when removing an unknown path', function () {
126
+ const tree = new RadixTree([{ paths: ['/x'], meta: { id: 1 } }]);
127
+ assert.strictEqual(tree.removeRoute('/does/not/exist'), 0);
128
+ });
129
+
130
+ it('should support HttpRadixTree.addRoute with compiled exprs', function () {
131
+ const tree = new HttpRadixTree([]);
132
+ tree.addRoute({ paths: ['/p/**'], exprs: ['age >= 18'], meta: { id: 1 } });
133
+ const ok = tree.findRoute({ path: '/p/1', args: { age: '20' } });
134
+ assert.notStrictEqual(ok, null);
135
+ assert.strictEqual(ok.meta.id, 1);
136
+ const no = tree.findRoute({ path: '/p/1', args: { age: '10' } });
137
+ assert.strictEqual(no, null);
138
+ });
139
+ });
140
+ });