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.
- package/LICENSE +21 -0
- package/README.md +574 -0
- package/benchmark/01.match-equals.js +21 -0
- package/benchmark/02.match-suffix.js +20 -0
- package/benchmark/03.match-prefix.js +28 -0
- package/benchmark/04.match-equals-prefix-suffix.js +36 -0
- package/benchmark/05.match-hosts.js +25 -0
- package/benchmark/06.match-wildcard-hosts.js +25 -0
- package/benchmark/07.match-remote-address.js +40 -0
- package/benchmark/08.match-exprs-headers.js +50 -0
- package/benchmark/09.match-exprs-args.js +50 -0
- package/benchmark/run.sh +3 -0
- package/benchmark/utility.js +56 -0
- package/binding.gyp +14 -0
- package/http-radix-tree.js +150 -0
- package/index.js +5 -0
- package/package.json +38 -0
- package/radix-tree.js +284 -0
- package/src/radix_tree.c +169 -0
- package/src/radix_tree.h +39 -0
- package/src/rax/COPYING +9 -0
- package/src/rax/Makefile +26 -0
- package/src/rax/README.md +488 -0
- package/src/rax/crc16.c +88 -0
- package/src/rax/rax-oom-test.c +175 -0
- package/src/rax/rax-test.c +1065 -0
- package/src/rax/rax.c +1996 -0
- package/src/rax/rax.h +217 -0
- package/src/rax/rax_malloc.h +43 -0
- package/src/rax/rax_oom_malloc.h +63 -0
- package/src/rax/rc4rand.c +77 -0
- package/src/rax/rc4rand.h +9 -0
- package/src/rax_wrapper.cpp +238 -0
- package/test/00.path.test.js +84 -0
- package/test/10.tree.test.js +217 -0
- package/test/20.http-tree.test.js +316 -0
|
@@ -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);
|
package/benchmark/run.sh
ADDED
|
@@ -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
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
|
+
}
|