@contrast/route-coverage 1.45.1 → 1.46.0
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/lib/index.d.ts +2 -0
- package/lib/index.js +15 -17
- package/lib/install/express/express5.js +489 -202
- package/package.json +9 -9
- package/lib/install/http.js +0 -44
- package/lib/normalized-url-mapper.js +0 -174
package/lib/index.d.ts
CHANGED
|
@@ -23,11 +23,13 @@ import { Scopes } from '@contrast/scopes';
|
|
|
23
23
|
export { RouteInfo };
|
|
24
24
|
|
|
25
25
|
export interface RouteCoverage extends Installable {
|
|
26
|
+
_normalizedUrlMapper: any;
|
|
26
27
|
discover(info: RouteInfo): void;
|
|
27
28
|
discoveryFinished(): void;
|
|
28
29
|
queue(info: RouteInfo): void;
|
|
29
30
|
queuingFinished(): void;
|
|
30
31
|
observe(info: RouteInfo): void;
|
|
32
|
+
uriPathToNormalizedUrl(uriPath: string): string;
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
export interface Core {
|
package/lib/index.js
CHANGED
|
@@ -16,7 +16,6 @@
|
|
|
16
16
|
'use strict';
|
|
17
17
|
|
|
18
18
|
const { callChildComponentMethodsSync, Event } = require('@contrast/common');
|
|
19
|
-
const NormalizedUrlMapper = require('./normalized-url-mapper');
|
|
20
19
|
|
|
21
20
|
/**
|
|
22
21
|
* @param {import('.').Core & {
|
|
@@ -36,18 +35,14 @@ module.exports = function init(core) {
|
|
|
36
35
|
const routeQueue = new Map();
|
|
37
36
|
|
|
38
37
|
const routeIdentifier = (method, signature) => `${method}.${signature}`;
|
|
39
|
-
const routeCoverage = core.routeCoverage = {
|
|
40
|
-
_normalizedUrlMapper: new NormalizedUrlMapper(),
|
|
41
|
-
|
|
42
|
-
uriPathToNormalizedUrl(uriPath) {
|
|
43
|
-
return this._normalizedUrlMapper.map(uriPath);
|
|
44
|
-
},
|
|
45
38
|
|
|
39
|
+
const routeCoverage = core.routeCoverage = {
|
|
46
40
|
discover(info) {
|
|
47
|
-
|
|
48
|
-
routeInfo.
|
|
49
|
-
this._normalizedUrlMapper.handleDiscover(info);
|
|
41
|
+
const id = routeIdentifier(info.method, info.signature);
|
|
42
|
+
if (routeInfo.get(id)) return;
|
|
50
43
|
|
|
44
|
+
logger.trace({ info }, 'Discovered new route:');
|
|
45
|
+
routeInfo.set(id, info);
|
|
51
46
|
},
|
|
52
47
|
|
|
53
48
|
discoveryFinished() {
|
|
@@ -67,7 +62,7 @@ module.exports = function init(core) {
|
|
|
67
62
|
if (routeQueue.size === 1) {
|
|
68
63
|
setTimeout(() => {
|
|
69
64
|
this.discoveryFinished();
|
|
70
|
-
},
|
|
65
|
+
}, 10_000);
|
|
71
66
|
}
|
|
72
67
|
},
|
|
73
68
|
|
|
@@ -99,16 +94,12 @@ module.exports = function init(core) {
|
|
|
99
94
|
route.method = info.method;
|
|
100
95
|
route.url = info.url;
|
|
101
96
|
const store = scopes.sources.getStore();
|
|
102
|
-
if (store && !store.route) {
|
|
103
|
-
store.route = route;
|
|
104
|
-
}
|
|
105
97
|
|
|
98
|
+
if (store) store.route = route;
|
|
106
99
|
if (recentlyObserved.has(route.signature)) return;
|
|
107
100
|
|
|
108
101
|
recentlyObserved.add(route.signature);
|
|
109
|
-
|
|
110
102
|
logger.trace({ info }, 'Observed route:');
|
|
111
|
-
|
|
112
103
|
// these events need source correlation
|
|
113
104
|
messages.emit(Event.ROUTE_COVERAGE_OBSERVATION, {
|
|
114
105
|
...route,
|
|
@@ -122,7 +113,6 @@ module.exports = function init(core) {
|
|
|
122
113
|
},
|
|
123
114
|
};
|
|
124
115
|
|
|
125
|
-
require('./install/http')(core);
|
|
126
116
|
require('./install/express')(core);
|
|
127
117
|
require('./install/fastify')(core);
|
|
128
118
|
require('./install/graphql')(core);
|
|
@@ -130,5 +120,13 @@ module.exports = function init(core) {
|
|
|
130
120
|
require('./install/koa')(core);
|
|
131
121
|
require('./install/restify')(core);
|
|
132
122
|
|
|
123
|
+
messages.on(Event.SERVER_LISTENING, () => {
|
|
124
|
+
// we wait to report in timers event loop phase, this way we can
|
|
125
|
+
// have components respond to this synchronously to finalize discovery
|
|
126
|
+
setImmediate(() => {
|
|
127
|
+
core.routeCoverage.discoveryFinished();
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
133
131
|
return routeCoverage;
|
|
134
132
|
};
|
|
@@ -14,6 +14,22 @@
|
|
|
14
14
|
*/
|
|
15
15
|
'use strict';
|
|
16
16
|
|
|
17
|
+
const { AsyncLocalStorage } = require('node:async_hooks');
|
|
18
|
+
const {
|
|
19
|
+
get,
|
|
20
|
+
set,
|
|
21
|
+
isString,
|
|
22
|
+
Event,
|
|
23
|
+
primordials: {
|
|
24
|
+
ArrayPrototypeJoin,
|
|
25
|
+
StringPrototypeSubstring,
|
|
26
|
+
StringPrototypeToLowerCase,
|
|
27
|
+
StringPrototypeReplace,
|
|
28
|
+
}
|
|
29
|
+
} = require('@contrast/common');
|
|
30
|
+
const { funcInfo } = require('@contrast/fn-inspect');
|
|
31
|
+
const Core = require('@contrast/core/lib/ioc/core');
|
|
32
|
+
|
|
17
33
|
const METHODS = [
|
|
18
34
|
'all',
|
|
19
35
|
'get',
|
|
@@ -24,233 +40,504 @@ const METHODS = [
|
|
|
24
40
|
'options',
|
|
25
41
|
'head',
|
|
26
42
|
];
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
isString,
|
|
31
|
-
primordials: {
|
|
32
|
-
ArrayPrototypeJoin,
|
|
33
|
-
ArrayPrototypeSlice,
|
|
34
|
-
StringPrototypeSplit,
|
|
35
|
-
StringPrototypeToLowerCase,
|
|
36
|
-
StringPrototypeReplace,
|
|
37
|
-
StringPrototypeSlice,
|
|
38
|
-
PathBasename
|
|
39
|
-
}
|
|
40
|
-
} = require('@contrast/common');
|
|
41
|
-
const { match } = require('path-to-regexp');
|
|
42
|
-
const fnInspect = require('@contrast/fn-inspect');
|
|
43
|
-
|
|
44
|
-
module.exports = function init(core) {
|
|
45
|
-
|
|
46
|
-
const discovered = [];
|
|
47
|
-
const routerMap = new Map();
|
|
48
|
-
const { patcher, depHooks, routeCoverage } = core;
|
|
49
|
-
|
|
50
|
-
const removeTrailingSlash = (url) => (url.endsWith('/') && url !== '/') ? StringPrototypeSlice.call(url, 0, -1) : url;
|
|
51
|
-
const format = (url) => Array.isArray(url)
|
|
52
|
-
? `/[${ArrayPrototypeJoin.call(url)}]`
|
|
53
|
-
: removeTrailingSlash(url); // remove trailing slash
|
|
54
|
-
const isRouter = (layer) => layer?.name && StringPrototypeToLowerCase.call(layer.name) === 'router';
|
|
55
|
-
|
|
56
|
-
function getFnName({ method, file, lineNumber, column }) {
|
|
57
|
-
if (method) return method;
|
|
58
|
-
if (!file) return '(anonymous)';
|
|
59
|
-
const base = PathBasename(file);
|
|
60
|
-
return `(anonymous ${base} ${lineNumber}:${column})`;
|
|
61
|
-
}
|
|
43
|
+
const componentName = 'routeCoverage.express5';
|
|
44
|
+
const kMetaKey = Symbol('cs_meta');
|
|
45
|
+
const enumerable = false;
|
|
62
46
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
framework,
|
|
73
|
-
originalUrl,
|
|
74
|
-
normalizedUrl: url,
|
|
75
|
-
signature: createSignature(url, method, obj, fnName)
|
|
76
|
-
};
|
|
77
|
-
}
|
|
47
|
+
module.exports = Core.makeComponent({
|
|
48
|
+
name: componentName,
|
|
49
|
+
factory: (core) => new ExpressInstrumentation(core),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
class ExpressInstrumentation {
|
|
53
|
+
constructor(core) {
|
|
54
|
+
// decorate
|
|
55
|
+
set(core, componentName, this);
|
|
78
56
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const updatedNormalizedUrl = removeTrailingSlash(prefix + normalizedUrl);
|
|
83
|
-
const updatedAddedPrefix = removeTrailingSlash(prefix + addedPrefix);
|
|
84
|
-
const updatedSignature = createSignature(updatedUrl, method, 'router', fnName);
|
|
85
|
-
return {
|
|
86
|
-
...routeInfo,
|
|
87
|
-
url: updatedUrl,
|
|
88
|
-
addedPrefix: updatedAddedPrefix,
|
|
89
|
-
normalizedUrl: updatedNormalizedUrl,
|
|
90
|
-
signature: updatedSignature
|
|
91
|
-
};
|
|
57
|
+
this.core = core;
|
|
58
|
+
this.methodScope = new AsyncLocalStorage();
|
|
59
|
+
this.handleScope = new AsyncLocalStorage();
|
|
92
60
|
}
|
|
93
61
|
|
|
94
|
-
|
|
95
|
-
const
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
normalizedUrl,
|
|
100
|
-
signature,
|
|
101
|
-
method,
|
|
102
|
-
framework
|
|
103
|
-
});
|
|
62
|
+
install() {
|
|
63
|
+
const self = this;
|
|
64
|
+
const { core, handleScope, methodScope } = this;
|
|
65
|
+
const patchType = 'route-coverage-express';
|
|
66
|
+
const name = 'express-5';
|
|
104
67
|
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
const prefix = routeInfo?.addedPrefix || '';
|
|
109
|
-
urlToMatch = routeInfo.originalUrl.map(seg => prefix + seg);
|
|
110
|
-
}
|
|
111
|
-
const matchUrl = match(urlToMatch);
|
|
112
|
-
routeInfo.match = matchUrl;
|
|
68
|
+
//
|
|
69
|
+
// discovery instrumentation
|
|
70
|
+
//
|
|
113
71
|
|
|
114
|
-
|
|
115
|
-
|
|
72
|
+
core.depHooks.resolve({ name: 'express', version: '5' }, (express) => {
|
|
73
|
+
// wrap router and app methods in "method scope" to capture info to help build signatures.
|
|
74
|
+
// express has a number of APIs that work at different levels of abstraction, and we need to patch
|
|
75
|
+
// all of them. the scopes let us know what top-level APIs are being called by application code.
|
|
76
|
+
[...METHODS, 'use', 'route'].forEach((method) => {
|
|
77
|
+
// then setup app and router to run in method scopes
|
|
78
|
+
core.patcher.patch(express.application, method, {
|
|
79
|
+
name: `express.application.${method}`,
|
|
80
|
+
patchType: `${patchType}-discovery`,
|
|
81
|
+
around(next, data) {
|
|
82
|
+
if (methodScope.getStore()) return next();
|
|
83
|
+
return methodScope.run({ method, args: data.args, type: 'app' }, next);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
116
86
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
post(data) {
|
|
124
|
-
const [req] = data.args;
|
|
125
|
-
const [url] = StringPrototypeSplit.call(req.originalUrl, '?');
|
|
126
|
-
const method = StringPrototypeToLowerCase.call(req.method);
|
|
127
|
-
if (url && method) {
|
|
128
|
-
if (isRouter) {
|
|
129
|
-
for (const route of discovered) {
|
|
130
|
-
if (route.match(url) && route.fnName === handlerName) {
|
|
131
|
-
const { signature, normalizedUrl } = route;
|
|
132
|
-
routeCoverage.observe({ url, method, signature, framework, normalizedUrl });
|
|
133
|
-
break;
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
} else {
|
|
137
|
-
const { signature, normalizedUrl } = routeInfo;
|
|
138
|
-
routeCoverage.observe({ url, method, signature, framework, normalizedUrl });
|
|
87
|
+
core.patcher.patch(express.Router.prototype, method, {
|
|
88
|
+
name: `express.Router.prototype.${method}`,
|
|
89
|
+
patchType: `${patchType}-discovery`,
|
|
90
|
+
around(next, data) {
|
|
91
|
+
if (methodScope.getStore()) return next();
|
|
92
|
+
return methodScope.run({ method, args: data.args, type: 'router' }, next);
|
|
139
93
|
}
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
});
|
|
143
|
-
}
|
|
94
|
+
});
|
|
95
|
+
});
|
|
144
96
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
97
|
+
// app[method] and router[method] end up calling this
|
|
98
|
+
// Append metadata to the created Route object at layer.route.
|
|
99
|
+
// we also patch the returned Route's methods for building signatures
|
|
100
|
+
core.patcher.patch(express.Router.prototype, 'route', {
|
|
101
|
+
name: 'express.Route',
|
|
102
|
+
patchType: `${patchType}-discovery`,
|
|
103
|
+
post(data) {
|
|
104
|
+
const { result } = data;
|
|
105
|
+
const methodStore = methodScope.getStore();
|
|
106
|
+
const meta = {
|
|
107
|
+
paths: ExpressInstrumentation.normalizePaths(data.args[0]),
|
|
108
|
+
method: methodStore?.method,
|
|
109
|
+
type: methodStore?.type || 'route',
|
|
110
|
+
};
|
|
155
111
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
112
|
+
Object.defineProperty(result, kMetaKey, {
|
|
113
|
+
enumerable,
|
|
114
|
+
value: meta
|
|
115
|
+
});
|
|
160
116
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
117
|
+
// patch route instance methods we do that here when we have
|
|
118
|
+
// todo move to prototype to help w/ memory
|
|
119
|
+
METHODS.forEach((method) => {
|
|
120
|
+
if (result[method]) {
|
|
121
|
+
core.patcher.patch(result, method, {
|
|
122
|
+
name: `express.Router.prototype.route${method}`,
|
|
123
|
+
patchType: `${patchType}-discovery`,
|
|
124
|
+
pre(data) {
|
|
125
|
+
data._stackIdx = data.obj.stack?.length;
|
|
126
|
+
},
|
|
127
|
+
post(data) {
|
|
128
|
+
if (data.obj.stack?.length > data._stackIdx) {
|
|
129
|
+
for (let i = data._stackIdx; i < data.obj.stack.length; i++) {
|
|
130
|
+
const layer = data.obj.stack[i];
|
|
131
|
+
const methodStore = methodScope.getStore();
|
|
132
|
+
const meta = {
|
|
133
|
+
type: methodStore?.type || 'route',
|
|
134
|
+
method: methodStore?.method == 'all' ? 'all' : method,
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
Object.defineProperty(layer, kMetaKey, {
|
|
138
|
+
enumerable,
|
|
139
|
+
value: meta,
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
});
|
|
165
145
|
}
|
|
166
146
|
});
|
|
167
147
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
pre(data) {
|
|
172
|
-
const Router = data.obj;
|
|
173
|
-
const [path, ...args] = data.args;
|
|
174
|
-
if (!isString(path) && !Array.isArray(path)) return;
|
|
175
|
-
|
|
176
|
-
const fns = args.flat(Infinity);
|
|
177
|
-
const routes = routerMap.get(Router) || [];
|
|
178
|
-
for (let i = 0; i < fns.length; i++) {
|
|
179
|
-
if (typeof fns[i] !== 'function') continue;
|
|
180
|
-
const routeInfo = createRouteInfo(path, method, 'router', fns[i]);
|
|
148
|
+
return result;
|
|
149
|
+
},
|
|
150
|
+
});
|
|
181
151
|
|
|
182
|
-
|
|
152
|
+
core.patcher.patch(express.Router.prototype, 'use', {
|
|
153
|
+
name: `${name}.Router.prototype.use`,
|
|
154
|
+
patchType: `${patchType}-discovery`,
|
|
155
|
+
pre(data) {
|
|
156
|
+
data._stackLength = data.obj.stack?.length;
|
|
157
|
+
},
|
|
158
|
+
post(data) {
|
|
159
|
+
if (data.obj.stack.length > data._stackLength) {
|
|
160
|
+
for (let i = data._stackLength; i < data.obj.stack.length; i++) {
|
|
161
|
+
const layer = data.obj.stack[i];
|
|
162
|
+
const paths = ExpressInstrumentation.normalizePaths(data.args[0]);
|
|
163
|
+
const methodStore = methodScope.getStore();
|
|
164
|
+
const meta = {
|
|
165
|
+
paths,
|
|
166
|
+
method: 'use',
|
|
167
|
+
type: methodStore?.type || 'router',
|
|
168
|
+
};
|
|
183
169
|
|
|
184
|
-
|
|
185
|
-
|
|
170
|
+
if (layer) {
|
|
171
|
+
Object.defineProperty(layer, kMetaKey, {
|
|
172
|
+
enumerable: false,
|
|
173
|
+
value: meta
|
|
174
|
+
});
|
|
186
175
|
}
|
|
187
|
-
routerMap.set(Router, routes);
|
|
188
176
|
}
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
return core.patcher.patch(express, {
|
|
182
|
+
name: 'express-5.application',
|
|
183
|
+
patchType: `${patchType}-discovery`,
|
|
184
|
+
post(data) {
|
|
185
|
+
const app = data.result;
|
|
186
|
+
core.messages.on(Event.SERVER_LISTENING, () => {
|
|
187
|
+
if (!app.router.stack[0]) {
|
|
188
|
+
core.logger.debug('no routes detected in express router stack');
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
self.handleDiscovery(app);
|
|
189
192
|
});
|
|
190
|
-
|
|
193
|
+
return app;
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
});
|
|
191
197
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
198
|
+
core.depHooks.resolve({ name: 'express', version: '5' }, (express) => {
|
|
199
|
+
core.patcher.patch(express.application, 'handle', {
|
|
200
|
+
name: 'express.application.handle',
|
|
201
|
+
patchType: `${patchType}-discovery`,
|
|
202
|
+
around(next, data) {
|
|
203
|
+
// wrap request handling in "handle scope". the scope's store data
|
|
204
|
+
// helps for building observation templates as routing occurs
|
|
205
|
+
const store = {
|
|
206
|
+
matchIdx: -1,
|
|
207
|
+
templateSegments: [],
|
|
208
|
+
};
|
|
209
|
+
return handleScope.run(store, next);
|
|
210
|
+
}
|
|
211
|
+
});
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
//
|
|
215
|
+
// observation instrumentation
|
|
216
|
+
//
|
|
217
|
+
|
|
218
|
+
// when Layer.match gets called, matchers functions run underneath. the API doesn't present a really clean
|
|
219
|
+
// way to instrument, so we're using scopes. we reference the scope's store in the instrumented matcher
|
|
220
|
+
// functions so we can correlate a matcher that succeeds to its corresponding route template segment.
|
|
221
|
+
core.depHooks.resolve({ name: 'router', file: 'lib/layer.js', version: '2' }, (Layer) => {
|
|
222
|
+
core.patcher.patch(Layer.prototype, 'match', {
|
|
223
|
+
name: 'Layer.prototype.match',
|
|
224
|
+
patchType: `${patchType}-observation`,
|
|
225
|
+
pre(data) {
|
|
226
|
+
data._store = handleScope.getStore();
|
|
227
|
+
if (!data._store) return;
|
|
228
|
+
|
|
229
|
+
// we check in post hook whether any matcher instrumentation reset this in scope.
|
|
230
|
+
// matchers will set this to a number only if multiple matchers run and one succeeds.
|
|
231
|
+
// use the index of that matcher to get associated template segment from the metadata.
|
|
232
|
+
data._store.matcherIdx = null;
|
|
233
|
+
// save reference to metadata source
|
|
234
|
+
data[kMetaKey] = data.obj[kMetaKey] || data.obj.route?.[kMetaKey];
|
|
235
|
+
},
|
|
236
|
+
post(data) {
|
|
237
|
+
// whenever a layer matches, save the corresponding
|
|
238
|
+
// template segment metadata in the handle scope store
|
|
239
|
+
const { result } = data;
|
|
240
|
+
if (!result || !data._store || !data[kMetaKey]?.paths) return;
|
|
241
|
+
|
|
242
|
+
let template;
|
|
243
|
+
if (data._store.matcherIdx != null) {
|
|
244
|
+
template = data[kMetaKey].paths[data._store.matcherIdx];
|
|
245
|
+
} else {
|
|
246
|
+
template = data[kMetaKey].paths[0];
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// if the layer matches, we know to push corresponding path to store's template segments.
|
|
250
|
+
// we pop this value from the array in hook to all `next` callbacks below.
|
|
251
|
+
data._store.templateSegments.push(template);
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// patch the `next` callback of every Layer's request handler.
|
|
256
|
+
// we pop the value from the stack of route template segments being managed.
|
|
257
|
+
core.patcher.patch(Layer.prototype, 'handleRequest', {
|
|
258
|
+
name: 'Layer.prototype.handleRequest',
|
|
259
|
+
patchType: `${patchType}-observation`,
|
|
260
|
+
pre(data) {
|
|
261
|
+
const next = data.args[2];
|
|
262
|
+
const meta = data.obj[kMetaKey] || data.obj.route?.[kMetaKey];
|
|
263
|
+
if (meta?.paths) {
|
|
264
|
+
const store = handleScope.getStore();
|
|
265
|
+
// this runs often and there's no need to use patcher here. monkey patch directly to optimize
|
|
266
|
+
data.args[2] = function(...args) {
|
|
267
|
+
if (store) store.templateSegments.pop();
|
|
268
|
+
const ret = next(...args);
|
|
269
|
+
return ret;
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
// instrument the Layer constructor. this will allow us to patch
|
|
276
|
+
// created matchers to help us build observation template from metadata.
|
|
277
|
+
// if matcher was successful we store index of it in handle scope.
|
|
278
|
+
return core.patcher.patch(Layer, {
|
|
279
|
+
name: 'router.Layer',
|
|
280
|
+
patchType: `${patchType}-observation`,
|
|
281
|
+
pre(data) {
|
|
282
|
+
data._methodScope = methodScope.getStore();
|
|
283
|
+
},
|
|
284
|
+
post(data) {
|
|
285
|
+
const instance = data.result;
|
|
286
|
+
// only instrument matchers if the Layer is being instantiated within method scope, and
|
|
287
|
+
// if there are multiple matchers and we need the index to correlate to tempate segment
|
|
288
|
+
if (data._methodScope && instance.matchers.length > 1) {
|
|
289
|
+
for (let i = 0; i < instance.matchers.length; i++) {
|
|
290
|
+
const matcher = instance.matchers[i];
|
|
291
|
+
instance.matchers[i] = function(...args) {
|
|
292
|
+
const result = matcher.apply(this, args);
|
|
293
|
+
if (result) {
|
|
294
|
+
const store = handleScope.getStore();
|
|
295
|
+
if (store) store.matcherIdx = i;
|
|
209
296
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
discover(routeInfo);
|
|
213
|
-
fns[i] = wrapForObservation(fns[i], routeInfo);
|
|
214
|
-
}
|
|
297
|
+
return result;
|
|
298
|
+
};
|
|
215
299
|
}
|
|
216
|
-
data.args.splice(prefix ? 1 : 0, fns.length, ...fns);
|
|
217
300
|
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
301
|
+
// patch handle to report observation when called. it checks handle
|
|
302
|
+
// scope to get current request's template to match with discovery info
|
|
303
|
+
core.patcher.patch(instance, 'handle', {
|
|
304
|
+
name: 'router.Layer.handle',
|
|
305
|
+
patchType: `${patchType}-observation`,
|
|
306
|
+
pre(data) {
|
|
307
|
+
if (instance[kMetaKey]?.observables) {
|
|
308
|
+
const store = handleScope.getStore();
|
|
309
|
+
if (store) {
|
|
310
|
+
const method = StringPrototypeToLowerCase.call(data.args[0].method || '');
|
|
311
|
+
const template = ArrayPrototypeJoin.call(store.templateSegments, '') || '/';
|
|
312
|
+
|
|
313
|
+
if (instance[kMetaKey]?.observables?.[template]) {
|
|
314
|
+
self.observe({
|
|
315
|
+
url: data.args[0].originalUrl,
|
|
316
|
+
normalizedUrl: template,
|
|
317
|
+
method,
|
|
318
|
+
signature: instance[kMetaKey].observables[template],
|
|
319
|
+
});
|
|
320
|
+
} else {
|
|
321
|
+
core.logger.error({
|
|
322
|
+
// url: data.args[0].originalUrl, // this would need masking to log
|
|
323
|
+
method,
|
|
324
|
+
template,
|
|
325
|
+
observables: instance[kMetaKey]?.observables,
|
|
326
|
+
}, 'unable to map route template to signature');
|
|
327
|
+
}
|
|
241
328
|
}
|
|
242
|
-
routerRoutes = selfNested ? routes : routerRoutes.concat(routes);
|
|
243
|
-
} else if (prefix && typeof fns[i] === 'function') {
|
|
244
|
-
const routeInfo = createRouteInfo(prefix, 'use', 'router', fns[i]);
|
|
245
|
-
routerRoutes.push(routeInfo);
|
|
246
|
-
fns[i] = wrapForObservation(fns[i], routeInfo);
|
|
247
329
|
}
|
|
330
|
+
},
|
|
331
|
+
});
|
|
332
|
+
},
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
discover(info) {
|
|
338
|
+
const { method, observables } = info;
|
|
339
|
+
if (!method || !observables) return;
|
|
340
|
+
|
|
341
|
+
for (const [normalizedUrl, signature] of Object.entries(observables)) {
|
|
342
|
+
this.core.routeCoverage.discover({
|
|
343
|
+
url: normalizedUrl,
|
|
344
|
+
normalizedUrl,
|
|
345
|
+
method,
|
|
346
|
+
signature,
|
|
347
|
+
framework: 'express',
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
observe(info) {
|
|
353
|
+
this.core.routeCoverage.observe({ framework: 'express', ...info });
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Traverse the application's router "stack" and generate route discovery events
|
|
358
|
+
* using layer/route metadata that was appended by methods like router.post().
|
|
359
|
+
* @param {object} app express instance
|
|
360
|
+
*/
|
|
361
|
+
handleDiscovery(app) {
|
|
362
|
+
const self = this;
|
|
363
|
+
const router = app.router || app._router;
|
|
364
|
+
|
|
365
|
+
// traverse fn executes this callback when visiting Layer instances
|
|
366
|
+
this.traverse(router, (path, key, value, target, state) => {
|
|
367
|
+
if (value.stack?.length > 0 || value.route) return;
|
|
368
|
+
|
|
369
|
+
// get metadata for this Layer
|
|
370
|
+
// metadata is on Layers within stacks and on Routes instances.
|
|
371
|
+
const metas = [];
|
|
372
|
+
for (let i = 0; i < path.length; i++) {
|
|
373
|
+
const seg = path[i];
|
|
374
|
+
if (Number.isFinite((Number(seg))) || seg == 'route') {
|
|
375
|
+
const metaPath = ArrayPrototypeJoin.call(path.slice(0, i + 1), '.');
|
|
376
|
+
const layerOrRoute = get(router, metaPath);
|
|
377
|
+
if (layerOrRoute?.[kMetaKey]) {
|
|
378
|
+
metas.push(layerOrRoute[kMetaKey]);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// mounted routers aren't discoverable since they themselves don't
|
|
384
|
+
// represent routes, they dispatch to sub routers/route handlers.
|
|
385
|
+
if (value.name != 'router' && value.handle?.name != 'router') {
|
|
386
|
+
// `value` is a terminal Layer with observable signatures.
|
|
387
|
+
// emit discovery after appending metadata.
|
|
388
|
+
if (value[kMetaKey]) {
|
|
389
|
+
const observables = this.generateObservables(metas, value.handle);
|
|
390
|
+
if (observables) {
|
|
391
|
+
if (!value[kMetaKey].observables) {
|
|
392
|
+
value[kMetaKey].observables = observables;
|
|
393
|
+
} else {
|
|
394
|
+
Object.assign(value[kMetaKey].observables, observables);
|
|
248
395
|
}
|
|
249
|
-
routerMap.set(Router, routerRoutes);
|
|
250
|
-
data.args.splice(prefix ? 1 : 0, fns.length, ...fns);
|
|
251
396
|
}
|
|
252
|
-
|
|
253
|
-
|
|
397
|
+
self.discover(value[kMetaKey]);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Traverses the top-level app's routing stack and executes the provided callback when
|
|
405
|
+
* visiting nodes. The callback is invoked only to visit Layer instances, objects and
|
|
406
|
+
* functions, since these are the only 2 types that could have our metadata attached.
|
|
407
|
+
*/
|
|
408
|
+
traverse(target, cb, path = [], data = new Map()) {
|
|
409
|
+
loopKeys: for (const key in target) {
|
|
410
|
+
path.push(key);
|
|
411
|
+
|
|
412
|
+
// only visit Layer instances
|
|
413
|
+
const maybeLayer = target[key];
|
|
414
|
+
if (
|
|
415
|
+
maybeLayer?.constructor?.name == 'Layer' &&
|
|
416
|
+
!maybeLayer?.stack?.length
|
|
417
|
+
) {
|
|
418
|
+
let _data = data.get(maybeLayer);
|
|
419
|
+
|
|
420
|
+
if (!_data) {
|
|
421
|
+
_data = { paths: [] };
|
|
422
|
+
data.set(maybeLayer, _data);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// you can mount a router on itself
|
|
426
|
+
// prevent infinitely recursing into self-mounted routers
|
|
427
|
+
for (const visitedPath of _data.paths) {
|
|
428
|
+
// these conditions indicate recursive nesting at particular path
|
|
429
|
+
if (
|
|
430
|
+
path.length > visitedPath.length &&
|
|
431
|
+
visitedPath.every((el, i) => path[i] == el)
|
|
432
|
+
) {
|
|
433
|
+
path.pop();
|
|
434
|
+
continue loopKeys;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
_data.paths.push([...path]); // copy because path argument mutates
|
|
439
|
+
|
|
440
|
+
const halt = cb(path, key, maybeLayer, target) === false;
|
|
441
|
+
if (halt) return;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// might be able to fine-tune this a bit more
|
|
445
|
+
if (typeof maybeLayer == 'object' || typeof maybeLayer == 'function') {
|
|
446
|
+
this.traverse(maybeLayer, cb, path, data);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
path.pop();
|
|
254
450
|
}
|
|
255
|
-
}
|
|
256
|
-
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
generateObservables(metas, handler) {
|
|
454
|
+
const { core } = this;
|
|
455
|
+
handler = core.patcher.unwrap(handler);
|
|
456
|
+
|
|
457
|
+
let type = '';
|
|
458
|
+
let method = '';
|
|
459
|
+
let templates = [];
|
|
460
|
+
const info = funcInfo(handler);
|
|
461
|
+
|
|
462
|
+
// ignore 3rd-party mw/handlers
|
|
463
|
+
if (info.file?.includes?.('node_modules')) {
|
|
464
|
+
return null;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
let file = info.file ?
|
|
468
|
+
StringPrototypeReplace.call(info.file, core.appInfo.app_dir, '') :
|
|
469
|
+
'';
|
|
470
|
+
if (file.length > 30) {
|
|
471
|
+
file = `...${StringPrototypeSubstring.call(file, file.length - 40)}`;
|
|
472
|
+
}
|
|
473
|
+
const handlerName = info.method || handler.name || 'anonymous';
|
|
474
|
+
const formattedHandler = (file && Number.isFinite(info.lineNumber) && Number.isFinite(info.column)) ?
|
|
475
|
+
`[${handlerName} ${file} ${info.lineNumber}:${info.column}]` :
|
|
476
|
+
`[Function: ${handlerName}]`; // what util.inspect(handler) would return
|
|
477
|
+
|
|
478
|
+
// loop backwards
|
|
479
|
+
for (let i = metas.length - 1; i >= 0; i--) {
|
|
480
|
+
const meta = metas[i];
|
|
481
|
+
// use the most recent `type` and `method` used when building routes, so don't overwrite if set
|
|
482
|
+
if (!type && meta.type) type = meta.type;
|
|
483
|
+
if (!method && meta.method) method = meta.method;
|
|
484
|
+
|
|
485
|
+
// builds out all possible template combinations that the Layer is able to handle during routing
|
|
486
|
+
if (Array.isArray(meta.paths)) {
|
|
487
|
+
if (!templates.length) {
|
|
488
|
+
templates = [...meta.paths];
|
|
489
|
+
} else {
|
|
490
|
+
const _t = [];
|
|
491
|
+
for (const templateSegment of meta.paths) {
|
|
492
|
+
for (const templateAcc of templates) {
|
|
493
|
+
_t.push(`${templateSegment}${templateAcc}`);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
templates = [..._t];
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// build signature lookup based on each template (normalizeUri)
|
|
502
|
+
const map = templates.reduce((acc, routeTemplate) => {
|
|
503
|
+
if (!routeTemplate) routeTemplate = '/';
|
|
504
|
+
acc[routeTemplate] = `${type}.${method}('${routeTemplate}', ${formattedHandler})`;
|
|
505
|
+
return acc;
|
|
506
|
+
}, {});
|
|
507
|
+
|
|
508
|
+
return map;
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
static normalizePathSegment(value) {
|
|
512
|
+
if (!value || value == '/') {
|
|
513
|
+
// app.[method](handler) and app.[method]('/', handler) are the same so default to empty string
|
|
514
|
+
return '';
|
|
515
|
+
}
|
|
516
|
+
if (value instanceof RegExp) {
|
|
517
|
+
const rxString = value.toString();
|
|
518
|
+
// todo: figure out best way to represent regexp in route template
|
|
519
|
+
return `/[${StringPrototypeSubstring.call(rxString, 1, rxString.length - 1)}]`;
|
|
520
|
+
}
|
|
521
|
+
return value;
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
static normalizePaths(paths) {
|
|
525
|
+
const ret = [];
|
|
526
|
+
|
|
527
|
+
// same as mounting as /
|
|
528
|
+
if (typeof paths == 'function') {
|
|
529
|
+
// default to ''
|
|
530
|
+
ret.push('');
|
|
531
|
+
} else if (isString(paths)) {
|
|
532
|
+
ret.push(ExpressInstrumentation.normalizePathSegment(paths));
|
|
533
|
+
} else if (Array.isArray(paths)) {
|
|
534
|
+
paths = paths.flat(Infinity).filter((v) => typeof v !== 'function');
|
|
535
|
+
if (paths.length) ret.push(...paths.map(ExpressInstrumentation.normalizePathSegment));
|
|
536
|
+
else ret.push('');
|
|
537
|
+
} else if (paths instanceof RegExp) {
|
|
538
|
+
ret.push(ExpressInstrumentation.normalizePathSegment(paths));
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
return ret;
|
|
542
|
+
}
|
|
543
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@contrast/route-coverage",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.46.0",
|
|
4
4
|
"description": "Handles route discovery and observation",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"author": "Contrast Security <nodejs@contrastsecurity.com> (https://www.contrastsecurity.com)",
|
|
@@ -20,14 +20,14 @@
|
|
|
20
20
|
"test": "bash ../scripts/test.sh"
|
|
21
21
|
},
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@contrast/common": "1.
|
|
24
|
-
"@contrast/config": "1.
|
|
25
|
-
"@contrast/
|
|
23
|
+
"@contrast/common": "1.35.0",
|
|
24
|
+
"@contrast/config": "1.50.0",
|
|
25
|
+
"@contrast/core": "1.55.0",
|
|
26
|
+
"@contrast/dep-hooks": "1.24.0",
|
|
26
27
|
"@contrast/fn-inspect": "^4.3.0",
|
|
27
|
-
"@contrast/logger": "1.
|
|
28
|
-
"@contrast/patcher": "1.
|
|
29
|
-
"@contrast/scopes": "1.
|
|
30
|
-
"semver": "^7.6.0"
|
|
31
|
-
"path-to-regexp": "^8.2.0"
|
|
28
|
+
"@contrast/logger": "1.28.0",
|
|
29
|
+
"@contrast/patcher": "1.27.0",
|
|
30
|
+
"@contrast/scopes": "1.25.0",
|
|
31
|
+
"semver": "^7.6.0"
|
|
32
32
|
}
|
|
33
33
|
}
|
package/lib/install/http.js
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Copyright: 2025 Contrast Security, Inc
|
|
3
|
-
* Contact: support@contrastsecurity.com
|
|
4
|
-
* License: Commercial
|
|
5
|
-
|
|
6
|
-
* NOTICE: This Software and the patented inventions embodied within may only be
|
|
7
|
-
* used as part of Contrast Security’s commercial offerings. Even though it is
|
|
8
|
-
* made available through public repositories, use of this Software is subject to
|
|
9
|
-
* the applicable End User Licensing Agreement found at
|
|
10
|
-
* https://www.contrastsecurity.com/enduser-terms-0317a or as otherwise agreed
|
|
11
|
-
* between Contrast Security and the End User. The Software may not be reverse
|
|
12
|
-
* engineered, modified, repackaged, sold, redistributed or otherwise used in a
|
|
13
|
-
* way not consistent with the End User License Agreement.
|
|
14
|
-
*/
|
|
15
|
-
'use strict';
|
|
16
|
-
|
|
17
|
-
module.exports = function init(core) {
|
|
18
|
-
const { depHooks, patcher, routeCoverage } = core;
|
|
19
|
-
|
|
20
|
-
let handled;
|
|
21
|
-
|
|
22
|
-
return core.routeCoverage.http = {
|
|
23
|
-
install() {
|
|
24
|
-
[['http', '*'], ['https', '*'], ['spdy', '<5']].forEach(([name, version]) => {
|
|
25
|
-
depHooks.resolve({ name, version }, (_export) => {
|
|
26
|
-
if (!_export?.Server?.prototype) return;
|
|
27
|
-
|
|
28
|
-
patcher.patch(_export.Server.prototype, 'listen', {
|
|
29
|
-
name: `${name}.Server.prototype.listen`,
|
|
30
|
-
patchType: 'route-coverage',
|
|
31
|
-
post(data) {
|
|
32
|
-
data.result?.on('listening', () => {
|
|
33
|
-
if (!handled) {
|
|
34
|
-
handled = true;
|
|
35
|
-
routeCoverage.discoveryFinished();
|
|
36
|
-
}
|
|
37
|
-
});
|
|
38
|
-
}
|
|
39
|
-
});
|
|
40
|
-
});
|
|
41
|
-
});
|
|
42
|
-
}
|
|
43
|
-
};
|
|
44
|
-
};
|
|
@@ -1,174 +0,0 @@
|
|
|
1
|
-
/*
|
|
2
|
-
* Copyright: 2025 Contrast Security, Inc
|
|
3
|
-
* Contact: support@contrastsecurity.com
|
|
4
|
-
* License: Commercial
|
|
5
|
-
|
|
6
|
-
* NOTICE: This Software and the patented inventions embodied within may only be
|
|
7
|
-
* used as part of Contrast Security’s commercial offerings. Even though it is
|
|
8
|
-
* made available through public repositories, use of this Software is subject to
|
|
9
|
-
* the applicable End User Licensing Agreement found at
|
|
10
|
-
* https://www.contrastsecurity.com/enduser-terms-0317a or as otherwise agreed
|
|
11
|
-
* between Contrast Security and the End User. The Software may not be reverse
|
|
12
|
-
* engineered, modified, repackaged, sold, redistributed or otherwise used in a
|
|
13
|
-
* way not consistent with the End User License Agreement.
|
|
14
|
-
*/
|
|
15
|
-
'use strict';
|
|
16
|
-
|
|
17
|
-
const {
|
|
18
|
-
get,
|
|
19
|
-
set,
|
|
20
|
-
primordials: { StringPrototypeSubstr, StringPrototypeSplit }
|
|
21
|
-
} = require('@contrast/common');
|
|
22
|
-
|
|
23
|
-
class NormalizedUrlMapper {
|
|
24
|
-
constructor() {
|
|
25
|
-
this._db = {
|
|
26
|
-
// index by static routes e.g.
|
|
27
|
-
// '/' => {}
|
|
28
|
-
// '/home' => {}
|
|
29
|
-
static: new Map(),
|
|
30
|
-
// or segment-count for parameterized routes
|
|
31
|
-
// '2' => { '/users/:id', '/profile/:id' }
|
|
32
|
-
// '3' => { '/users/:id/orders', '/profile/:id/address' }
|
|
33
|
-
parameterized: {},
|
|
34
|
-
// regex is used instead of string path
|
|
35
|
-
regex: new Map(),
|
|
36
|
-
// dynamic beyond parameterization e.g. regex syntax, '/abc+', '/abc*', '/[v1|v1.1]/users'.
|
|
37
|
-
// we could key off of segment length here like we do above.
|
|
38
|
-
dynamic: {},
|
|
39
|
-
};
|
|
40
|
-
this._defaultDynamicRe = /\(|\?|\||\[|\*|\+|\{/;
|
|
41
|
-
this._hapiDynamicRe = /\(|\?|\||\[|\*|\+/;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
_getPathSegments(uriPath) {
|
|
45
|
-
if (!uriPath?.length) return null;
|
|
46
|
-
return StringPrototypeSplit.call(StringPrototypeSubstr.call(uriPath, 1), '/');
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
_looksDynamic(segment, framework) {
|
|
50
|
-
if (framework == 'hapi') {
|
|
51
|
-
return this._hapiDynamicRe.test(segment);
|
|
52
|
-
}
|
|
53
|
-
// app.get('/::fiddle') will handle requests to '/:fiddle'
|
|
54
|
-
if (segment.includes('::')) return true;
|
|
55
|
-
return this._defaultDynamicRe.test(segment);
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
_looksParamaterized(segment, framework) {
|
|
59
|
-
if (framework == 'hapi') {
|
|
60
|
-
return segment.startsWith('{');
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
// no point iterating last character
|
|
64
|
-
for (let idx = 0; idx < segment.length - 1; idx++) {
|
|
65
|
-
// '/foo::bar' is not parameterized (fastify) - maps to '/foo:bar'
|
|
66
|
-
// make sure ':' appears by itself
|
|
67
|
-
if (
|
|
68
|
-
segment[idx] == ':' &&
|
|
69
|
-
segment[idx - 1] != ':' &&
|
|
70
|
-
segment[idx + 1] != ':'
|
|
71
|
-
) {
|
|
72
|
-
return true;
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
return false;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Tries to map a raw URL path to the "normalized" value used to declare the route.
|
|
81
|
-
* Aims to be as performant as possible.
|
|
82
|
-
* @param {string} uriPath path without queries
|
|
83
|
-
* @returns {}
|
|
84
|
-
*/
|
|
85
|
-
_query(uriPath) {
|
|
86
|
-
// check if static first
|
|
87
|
-
if (this._db.static.has(uriPath)) return this._db.static.get(uriPath);
|
|
88
|
-
|
|
89
|
-
// else check dynamic routes by segment count
|
|
90
|
-
const _segments = this._getPathSegments(uriPath);
|
|
91
|
-
const entriesToCheck = this._db.parameterized[_segments.length];
|
|
92
|
-
|
|
93
|
-
if (entriesToCheck) {
|
|
94
|
-
for (const [, route] of entriesToCheck.entries()) {
|
|
95
|
-
const { segments } = route;
|
|
96
|
-
if (segments.every((seg, idx) => !seg || seg == _segments[idx])) {
|
|
97
|
-
return route;
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
return null;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* Registers route discovery meta into _db.
|
|
107
|
-
* @param {import('@contrast/common').RouteInfo} routeInfo
|
|
108
|
-
*/
|
|
109
|
-
handleDiscover(routeInfo) {
|
|
110
|
-
if (!routeInfo.normalizedUrl) {
|
|
111
|
-
// todo should log but don't have core
|
|
112
|
-
return;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
let segments;
|
|
116
|
-
let dbIndex;
|
|
117
|
-
let isParameterized = false;
|
|
118
|
-
let isDynamic = false;
|
|
119
|
-
const isRegExp = routeInfo.normalizedUrl?.constructor?.name == 'RegExp';
|
|
120
|
-
|
|
121
|
-
if (!isRegExp) {
|
|
122
|
-
segments = this._getPathSegments(routeInfo.normalizedUrl);
|
|
123
|
-
|
|
124
|
-
for (let i = 0; i < segments.length; i++) {
|
|
125
|
-
const segment = segments[i];
|
|
126
|
-
// these heuristic checks may not scale for all frameworks. we may want to dispatch to
|
|
127
|
-
// a framework-specific strategy that can specialize in mapping to the _db entry index.
|
|
128
|
-
if (this._looksDynamic(segment, routeInfo.framework)) {
|
|
129
|
-
isDynamic = true;
|
|
130
|
-
} else if (this._looksParamaterized(segment, routeInfo.framework)) {
|
|
131
|
-
isParameterized = true;
|
|
132
|
-
// replace segments to check with undefined if parameterized
|
|
133
|
-
segments[i] = undefined;
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
const meta = {
|
|
139
|
-
normalizedUrl: routeInfo.normalizedUrl,
|
|
140
|
-
signature: routeInfo.signature,
|
|
141
|
-
};
|
|
142
|
-
|
|
143
|
-
if (isDynamic) {
|
|
144
|
-
dbIndex = `dynamic.${segments.length}`;
|
|
145
|
-
} else if (isParameterized) {
|
|
146
|
-
// can be both dynamic and parameterized, e.g. '/:file{.:ext}', '/api/(v1|v2)/:user'
|
|
147
|
-
// but that's not what we want in this case
|
|
148
|
-
dbIndex = `parameterized.${segments.length}`;
|
|
149
|
-
meta.segments = segments;
|
|
150
|
-
} else if (isRegExp) {
|
|
151
|
-
dbIndex = 'regex';
|
|
152
|
-
} else {
|
|
153
|
-
dbIndex = 'static';
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
// ensure appropriate collection is there
|
|
157
|
-
if (!get(this._db, dbIndex)) set(this._db, dbIndex, new Map());
|
|
158
|
-
get(this._db, dbIndex).set(routeInfo.normalizedUrl, meta);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Returns the normalizedUrl associated with raw uriPath. If the
|
|
163
|
-
* value can't be determined from internal _db, this return null.
|
|
164
|
-
* @param {string} uriPath
|
|
165
|
-
* @returns {string}
|
|
166
|
-
*/
|
|
167
|
-
map(uriPath) {
|
|
168
|
-
/** @type import('@contrast/common').RouteInfo */
|
|
169
|
-
const record = this._query(uriPath);
|
|
170
|
-
return record ? record.normalizedUrl : null;
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
module.exports = NormalizedUrlMapper;
|