rax-radix-tree 1.0.1

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.
@@ -0,0 +1,25 @@
1
+
2
+ const { benchmark, md5 } = require('./utility');
3
+ const routeCount = 1000 * 1;
4
+ const matchTimes = 1000 * 100;
5
+
6
+ const path = "/12345";
7
+ const routes = [];
8
+ for (let i = 1; i <= routeCount; i++) {
9
+ routes.push({
10
+ paths: [path],
11
+ priority: i,
12
+ hosts: [md5(i.toString())],
13
+ meta: `hosts_${i}`
14
+ });
15
+ }
16
+
17
+ const middleValue = Math.floor(routeCount / 2).toString();
18
+ const req = {
19
+ path: path,
20
+ headers: {
21
+ host: md5(middleValue)
22
+ }
23
+ };
24
+
25
+ benchmark("Match hosts", routeCount, matchTimes, routes, req);
@@ -0,0 +1,25 @@
1
+ const { benchmark, md5 } = require('./utility');
2
+
3
+ const routeCount = 1000 * 1;
4
+ const matchTimes = 1000 * 100;
5
+
6
+ const path = "/12345";
7
+ const routes = [];
8
+ for (let i = 1; i <= routeCount; i++) {
9
+ routes.push({
10
+ paths: [path],
11
+ priority: i,
12
+ hosts: [`*.${md5(i.toString())}`],
13
+ meta: `wildcard_hosts_${i}`
14
+ });
15
+ }
16
+
17
+ const middleValue = Math.floor(routeCount / 2).toString();
18
+ const req = {
19
+ path: path,
20
+ headers: {
21
+ host: `1.${md5(middleValue)}`
22
+ }
23
+ };
24
+
25
+ benchmark("Match wildcard hosts", routeCount, matchTimes, routes, req);
@@ -0,0 +1,40 @@
1
+
2
+ const { benchmark, md5 } = require('./utility');
3
+ const routeCount = 1000 * 1;
4
+ const matchTimes = 1000 * 1;
5
+ const routes = [];
6
+
7
+ function genRemoteAddr(i) {
8
+ return `127.0.${Math.floor(i / 256)}.${i % 256}`;
9
+ }
10
+
11
+
12
+ for (let i = 1; i <= routeCount; i++) {
13
+ routes.push({
14
+ paths: ["/admin/**"],
15
+ remoteAddrs: [`${genRemoteAddr(i)}`],
16
+ meta: { access: 'local' }
17
+ });
18
+ }
19
+
20
+ // Add a wildcard route
21
+ routes.push({
22
+ paths: ["/**"],
23
+ meta: { access: 'public' }
24
+ });
25
+
26
+ const matchPath = "/admin/dashboard";
27
+
28
+ const reqMatch = {
29
+ path: matchPath,
30
+ remoteAddr: genRemoteAddr(routeCount/2)
31
+ };
32
+
33
+ benchmark("Match remote address, route found", routeCount, matchTimes, routes, reqMatch);
34
+
35
+ const reqNotMatch = {
36
+ path: matchPath,
37
+ remoteAddr: "192.168.1.1"
38
+ };
39
+
40
+ benchmark("Match remote address, route not found", routeCount, matchTimes, routes, reqNotMatch);
@@ -0,0 +1,50 @@
1
+ const { benchmark, md5 } = require('./utility');
2
+ const routeCount = 1000 * 1;
3
+ const matchTimes = 1000 * 1;
4
+ const routes = [];
5
+
6
+ function genUserAgent(i) {
7
+ return md5(`useragent${i}`);
8
+ }
9
+
10
+ function genLanguage(i) {
11
+ return md5(`language${i}`);
12
+ }
13
+
14
+ for (let i = 1; i <= routeCount; i++) {
15
+ const userAgent = genUserAgent(i);
16
+ const language = genLanguage(i);
17
+ routes.push({
18
+ paths: ['/**'],
19
+ exprs: [`contains(user_agent, "${userAgent}") and accept_language == "${language}"`],
20
+ meta: { version: `v${i}` }
21
+ });
22
+ }
23
+
24
+ routes.push({
25
+ paths: ['/**'],
26
+ meta: { version: 'default' }
27
+ });
28
+
29
+ const matchPath = '/';
30
+
31
+ const middleRoute = routes[Math.floor(routeCount / 2)];
32
+ const reqMatch = {
33
+ path: matchPath,
34
+ headers: {
35
+ 'user-agent': middleRoute.exprs[0].split('"')[1] + '/91.0.4472.124 Safari/537.36',
36
+ 'accept-language': middleRoute.exprs[0].split('"')[3]
37
+ }
38
+ };
39
+
40
+ benchmark("Match expressions and headers, route found", routeCount, matchTimes, routes, reqMatch);
41
+
42
+ const reqNotMatch = {
43
+ path: matchPath,
44
+ headers: {
45
+ 'user-agent': 'curl/7.64.1',
46
+ 'accept-language': 'fr-FR'
47
+ }
48
+ };
49
+
50
+ benchmark("Match expressions and headers, route not found", routeCount, matchTimes, routes, reqNotMatch);
@@ -0,0 +1,50 @@
1
+ const { benchmark, md5 } = require('./utility');
2
+ const routeCount = 1000 * 1;
3
+ const matchTimes = 1000 * 1;
4
+ const routes = [];
5
+
6
+ function genRandomCategory(i) {
7
+ return md5(i.toString());
8
+ }
9
+
10
+ function genRandomPrice() {
11
+ return Math.floor(Math.random() * 1000) + 1;
12
+ }
13
+
14
+ for (let i = 1; i <= routeCount; i++) {
15
+ const category = genRandomCategory(i);
16
+ const price = genRandomPrice();
17
+ routes.push({
18
+ paths: ["/products/**"],
19
+ exprs: [`category == "${category}" and price == ${price}`],
20
+ meta: { discount: '10%' }
21
+ });
22
+ }
23
+
24
+ routes.push({
25
+ paths: ["/products/**"],
26
+ meta: { discount: '0%' }
27
+ });
28
+
29
+ const matchPath = "/products/item";
30
+
31
+ const middleRoute = routes[Math.floor(routeCount / 2)];
32
+ const reqMatch = {
33
+ path: matchPath,
34
+ args: {
35
+ category: middleRoute.exprs[0].split('"')[1],
36
+ price: middleRoute.exprs[0].split('==')[2].trim()
37
+ }
38
+ };
39
+
40
+ benchmark("Match expressions and parameters, route found", routeCount, matchTimes, routes, reqMatch);
41
+
42
+ const reqNotMatch = {
43
+ path: matchPath,
44
+ args: {
45
+ category: md5('nonexistent'),
46
+ price: '0'
47
+ }
48
+ };
49
+
50
+ benchmark("Match expressions and parameters, route not found", routeCount, matchTimes, routes, reqNotMatch);
@@ -0,0 +1,3 @@
1
+ for file in benchmark/*.*.js; do
2
+ node "$file"
3
+ done
@@ -0,0 +1,56 @@
1
+
2
+ const { HttpRadixTree } = require('../index');
3
+ const crypto = require('crypto');
4
+ const profiler = require('v8-profiler-node8');
5
+
6
+ function md5(input) {
7
+ return crypto.createHash('md5').update(input).digest('hex');
8
+ }
9
+
10
+
11
+ function benchmark(benchmarkName, routeCount, matchTimes, routes, req, profile=false) {
12
+ const rx = new HttpRadixTree(routes);
13
+
14
+ let res;
15
+ if (profile) {
16
+ profiler.startProfiling('findRoute', true);
17
+ }
18
+ const startTime = process.hrtime();
19
+ for (let i = 0; i < matchTimes; i++) {
20
+ res = rx.findRoute(req);
21
+ }
22
+ const [seconds, nanoseconds] = process.hrtime(startTime);
23
+ const duration = seconds * 1000 + nanoseconds / 1e6;
24
+ if (profile) {
25
+ var profileResult = profiler.stopProfiling();
26
+ var fs = require('fs');
27
+ profileResult.export(function(error, result) {
28
+ const currentTime = new Date().toLocaleString('zh-CN', {
29
+ year: '2-digit',
30
+ month: '2-digit',
31
+ day: '2-digit',
32
+ hour: '2-digit',
33
+ minute: '2-digit',
34
+ hour12: false
35
+ }).replace(/[\/\s:]/g, '-').replace(',', '_');
36
+ const fileName = `profile_result_${benchmarkName.replace(/[\s,]+/g, '_')}_${currentTime}.json`;
37
+ console.log(`Writing profile result to file: ${fileName}`);
38
+ fs.writeFileSync(fileName, result);
39
+ profileResult.delete();
40
+ });
41
+ }
42
+
43
+ console.log(`## ${benchmarkName}`);
44
+ console.log("")
45
+ console.log("* Result: ", res ? res.meta : null);
46
+ console.log("* Number of routes: ", routeCount);
47
+ console.log("* Number of matches: ", matchTimes);
48
+ console.log("* Execution time: ", duration.toFixed(0), "ms");
49
+ console.log("* QPS: ", Math.floor(matchTimes / (duration / 1000)));
50
+ console.log("")
51
+ }
52
+
53
+ module.exports = {
54
+ benchmark,
55
+ md5
56
+ };
package/binding.gyp ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "targets": [
3
+ {
4
+ "target_name": "rax_radix_tree",
5
+ "cflags!": [ "-fno-exceptions" ],
6
+ "cflags_cc!": [ "-fno-exceptions" ],
7
+ "sources": [ "src/rax_wrapper.cpp", "src/radix_tree.c", "src/rax/rax.c" ],
8
+ "include_dirs": [
9
+ "<!@(node -p \"require('node-addon-api').include\")"
10
+ ],
11
+ "defines": [ "NAPI_DISABLE_CPP_EXCEPTIONS" ]
12
+ }
13
+ ]
14
+ }
@@ -0,0 +1,150 @@
1
+ const { RadixTree } = require('./radix-tree');
2
+ const { LRUCache } = require('lru-cache')
3
+ const compileExpression = require('filtrex').compileExpression
4
+ const { matches } = require('ip-matching');
5
+
6
+
7
+ function contains(str, substr) {
8
+ return str.includes(substr)
9
+ }
10
+
11
+ function matchHosts(configHosts, requestHost) {
12
+ if (!requestHost) {
13
+ return false
14
+ }
15
+ const matched = configHosts.some(host => {
16
+ if (host.startsWith('*.')) {
17
+ const domain = host.slice(2)
18
+ return requestHost.endsWith(domain)
19
+ }
20
+ return host === requestHost
21
+ })
22
+
23
+ return matched
24
+ }
25
+
26
+ const cache = new LRUCache({
27
+ ttl: 1000 * 60,
28
+ max: 10000
29
+ });
30
+
31
+
32
+ function matchRemoteAddrs(configAddrs, requestIp) {
33
+ if (!requestIp) {
34
+ return false
35
+ }
36
+ if (!Array.isArray(configAddrs) || configAddrs.length === 0) {
37
+ return false;
38
+ }
39
+ const cacheKey = configAddrs.join(',') + ':' + requestIp;
40
+ let matched = cache.get(cacheKey);
41
+ if (matched !== undefined) {
42
+ return matched
43
+ }
44
+ matched = configAddrs.some(addr => matches(requestIp, addr));
45
+ cache.set(cacheKey, matched)
46
+ return matched;
47
+ }
48
+
49
+ function matchExprs(compiledExprs, req) {
50
+ if (!Array.isArray(compiledExprs)) {
51
+ return false
52
+ }
53
+
54
+ const context = {
55
+ method: req.method,
56
+ path: req.path,
57
+ }
58
+ const tryParseNumber = (value) => {
59
+ if (typeof value === 'string') {
60
+ const num = Number(value);
61
+ return isNaN(num) ? value : num;
62
+ }
63
+ return value;
64
+ };
65
+ if (req.args) {
66
+ for (let key in req.args) {
67
+ key = key.toLowerCase()
68
+ context[key] = tryParseNumber(req.args[key])
69
+ }
70
+ }
71
+ if (req.headers) {
72
+ for (const [key, value] of Object.entries(req.headers)) {
73
+ const normalizedKey = key.toLowerCase().replace(/-/g, '_') // Replace - with _
74
+ context[normalizedKey] = tryParseNumber(value)
75
+ }
76
+ }
77
+
78
+ for (const { expr, compiledExpr } of compiledExprs) {
79
+ try {
80
+ const result = compiledExpr(context)
81
+ if (result instanceof Error) {
82
+ // console.info('matchExprs: eval expr %s with values(%s) error: %s', expr, JSON.stringify(context), result.message)
83
+ continue
84
+ }
85
+ if (result) {
86
+ return true
87
+ }
88
+ } catch (error) {
89
+ console.error('Error evaluating filter:', error)
90
+ return false
91
+ }
92
+ }
93
+
94
+ return false
95
+ }
96
+
97
+ function httpMatchRoute(route, req) {
98
+ // Check hosts
99
+ const host = req.headers ? req.headers.host : ''
100
+ if (route.hosts && route.hosts.length > 0 && !matchHosts(route.hosts, host)) {
101
+ return false
102
+ }
103
+
104
+ // Check remoteAddrs
105
+ if (route.remoteAddrs && route.remoteAddrs.length > 0
106
+ && !matchRemoteAddrs(route.remoteAddrs, req.remoteAddr)) {
107
+ return false
108
+ }
109
+
110
+ // Check exprs
111
+ if (route.compiledExprs && route.compiledExprs.length > 0
112
+ && !matchExprs(route.compiledExprs, req)) {
113
+ return false
114
+ }
115
+
116
+ return true
117
+ }
118
+
119
+ class HttpRadixTree extends RadixTree {
120
+ /**
121
+ * Creates a new HttpRadixTree instance
122
+ *
123
+ * @param {Array} routes - Array of route configurations
124
+ *
125
+ * Each route object should contain:
126
+ * @param {string|string[]} routes[].paths - Route path or array of paths
127
+ * @param {string|string[]} [routes[].methods] - HTTP method or array of methods
128
+ * @param {string[]} [routes[].hosts] - List of allowed hostnames, supports wildcards (e.g. *.example.com)
129
+ * @param {string[]} [routes[].remoteAddrs] - List of allowed remote IP addresses or CIDR ranges
130
+ * @param {string[]} [routes[].exprs] - List of filter expressions
131
+ *
132
+ * The httpMatchRoute function is used to match routes:
133
+ * - Checks if the request's host matches routes[].hosts
134
+ * - Checks if the request's remoteAddr matches routes[].remoteAddrs
135
+ * - Evaluates all expressions in routes[].compiledExprs
136
+ */
137
+ constructor(routes) {
138
+ for (const route of routes) {
139
+ const exprs = route.exprs || [];
140
+ const compiledExprs = exprs.map(expr => ({
141
+ expr,
142
+ compiledExpr: compileExpression(expr, {extraFunctions: {contains}}),
143
+ }));
144
+ route.compiledExprs = compiledExprs;
145
+ }
146
+ super(routes, httpMatchRoute);
147
+ }
148
+ }
149
+
150
+ exports.HttpRadixTree = HttpRadixTree;
package/index.js ADDED
@@ -0,0 +1,5 @@
1
+ const { RadixTree } = require('./radix-tree');
2
+ const { HttpRadixTree } = require('./http-radix-tree');
3
+
4
+ exports.RadixTree = RadixTree
5
+ exports.HttpRadixTree = HttpRadixTree
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "rax-radix-tree",
3
+ "version": "1.0.1",
4
+ "description": "Node.js binding for rax, a radix tree implementation in ANSI C (https://github.com/antirez/rax)",
5
+ "author": "igeeky.io@gmail.com",
6
+ "license": "MIT",
7
+ "main": "index.js",
8
+ "scripts": {
9
+ "install": "node-gyp rebuild",
10
+ "test": "mocha ./test/*.test.js"
11
+ },
12
+ "keywords": [
13
+ "radix tree",
14
+ "rax",
15
+ "radix",
16
+ "tree",
17
+ "prefix",
18
+ "suffix",
19
+ "path matching"
20
+ ],
21
+ "repository": {
22
+ "type": "git",
23
+ "url": "git+https://github.com/igeeky/rax-radix-tree.git"
24
+ },
25
+ "homepage": "https://github.com/igeeky/rax-radix-tree",
26
+ "dependencies": {
27
+ "filtrex": "^3.0.0",
28
+ "ip-matching": "^2.1.2",
29
+ "lru-cache": "^11.0.1",
30
+ "node-addon-api": "^8.1.0"
31
+ },
32
+ "devDependencies": {
33
+ "v8-profiler-node8": "^6.4.0",
34
+ "mocha": "^10.2.0",
35
+ "nyc": "^17.1.0"
36
+ },
37
+ "packageManager": "pnpm@9.9.0+sha512.60c18acd138bff695d339be6ad13f7e936eea6745660d4cc4a776d5247c540d0edee1a563695c183a66eb917ef88f2b4feb1fc25f32a7adcadc7aaf3438e99c1"
38
+ }