1 /* eslint-disable security/detect-object-injection */
5 * A very simple router.
8 const { METHODS: httpMethods
} = require('node:http');
9 const common
= require('../common');
10 const { DingusError
, RouterNoPathError
, RouterNoMethodError
} = require('../errors');
11 const PathParameter
= require('./path-parameter');
13 // Internal identifiers for route entries.
14 const kPathMethods
= Symbol('kSqueepDingusRouterPathMethods');
16 const defaultOptions
= {
17 ignoreTrailingSlash: false,
22 * @typedef {Array<string|PathParameter>} RoutePath
23 * @property {{[method: string]: PathHandler}} kPathMethods (symbol key)
29 * @typedef {object} PathHandler
30 * @property {HandlerFn} handler invoked on path match
31 * @property {any[]} handlerArgs passed to handler
34 * @typedef MatchedPath
35 * @property {object} pathParams populated param fields
36 * @property {RoutePath=} matchedPath matched path
40 * A naïve router which maps incoming requests to handler functions
41 * by way of url path and request method.
43 * Regex parsing of paths was eschewed, as a design decision.
45 * Instead, each path to be searched for is deconstructed into a list
46 * of its constituent parts as strings or objects, for invariant or
47 * parameterized parts respectively. Each search path is assigned a
48 * mapping of methods to handler functions.
51 static kPathMethods
= kPathMethods
;
55 * @param {object} options options object
56 * @param {boolean} options.ignoreTrailingSlash discard any trailing slashes when registering and comparing paths (default: false)
57 * @param {string} options.paramPrefix prefix of a path part denoting a named parameter when registering paths (default: ':')
59 constructor(options
= {}) {
60 common
.setOptions(this, defaultOptions
, options
);
63 * Keep lists of registered paths to match, indexed by number of path parts.
64 * @type {{[length: number]: RoutePath[]}}
66 this.pathsByLength
= {
73 * Prepare a path for insertion into search list.
74 * A route path is an Array of path parts, with a symbolic property of an object mapping method handlers.
75 * @param {string} rawPath path string
76 * @returns {RoutePath} route path
79 _pathToRoutePath(rawPath
) {
80 const routePath
= rawPath
82 .map((p
) => this._pathPartMunge(p
));
84 if (this.ignoreTrailingSlash
85 && routePath
[routePath
.length
- 1] === '') {
89 routePath
[kPathMethods
] = {};
91 Object
.defineProperty(routePath
, 'path', {
96 Object
.freeze(routePath
);
102 * Convert a path part string to parameter if needed.
103 * Remove escape from an escaped parameter.
104 * @param {string} part component of a path
105 * @returns {PathParameter|string} component or parameter object
108 _pathPartMunge(part
) {
109 if (part
.startsWith(this.paramPrefix
)) {
110 return new PathParameter(part
.slice(this.paramPrefix
.length
));
112 if (part
.startsWith('\\' + this.paramPrefix
)) {
113 return part
.slice(1);
120 * Compare checkPath to fixedPath, no param substitution, params must match.
121 * @param {RoutePath} routePath route path
122 * @param {RoutePath} checkPath path to match
123 * @returns {boolean} matched
126 static _pathCompareExact(routePath
, checkPath
) {
127 if (routePath
.length
!== checkPath
.length
) {
130 for (let i
= 0; i
< routePath
.length
; i
++) {
131 const fixedPart
= routePath
[i
];
132 const checkPart
= checkPath
[i
];
133 if (fixedPart
instanceof PathParameter
&& checkPart
instanceof PathParameter
) {
134 if (fixedPart
.parameter
!== checkPart
.parameter
) {
137 } else if (fixedPart
!== checkPart
) {
146 * Compare routePath to fixedPath, populating params.
147 * @param {RoutePath} routePath route path
148 * @param {Array<string>} checkPath request path to check
149 * @param {object} returnParams populated param components on match
150 * @returns {boolean} matched
153 static _pathCompareParam(routePath
, checkPath
, returnParams
= {}) {
156 if (routePath
.length
!== checkPath
.length
) {
159 for (let i
= 0; i
< routePath
.length
; i
++) {
160 const fixedPart
= routePath
[i
];
161 const checkPart
= checkPath
[i
];
162 if (fixedPart
instanceof PathParameter
) {
163 params
[fixedPart
.parameter
] = checkPart
;
164 } else if (fixedPart
!== checkPart
) {
168 Object
.assign(returnParams
, params
);
174 * Search for an existing path, return matched path and path parameters.
175 * @param {Array<string>} matchParts path components
176 * @returns {MatchedPath} matched or not
179 _pathFind(matchParts
) {
182 matchedPath: undefined,
184 const pathsByLength
= this.pathsByLength
[matchParts
.length
];
186 for (const p
of pathsByLength
) {
187 if (Router
._pathCompareParam(p
, matchParts
, result
.pathParams
)) {
188 result
.matchedPath
= p
;
191 result
.pathParams
= {}; // Reset after potential population from failed match.
199 * Return a matching path, no param substitution, params must match
200 * @param {RoutePath} routePath path
201 * @returns {RoutePath=} matched
204 _pathFindExact(routePath
) {
205 const pathsByLength
= this.pathsByLength
[routePath
.length
];
207 for (const p
of pathsByLength
) {
208 if (Router
._pathCompareExact(p
, routePath
)) {
218 * Insert a new path handler.
219 * @param {string|string[]} methods methods to match for this handler, '*' allowed
220 * @param {string} urlPath request path to match
221 * @param {HandlerFn} handler handler to invoke on match
222 * @param {any[]} handlerArgs additional arguments for handler
224 on(methods
, urlPath
, handler
, handlerArgs
= []) {
225 const matchParts
= this._pathToRoutePath(urlPath
);
226 let existingPath
= this._pathFindExact(matchParts
);
228 existingPath
= matchParts
;
229 if (!(matchParts
.length
in this.pathsByLength
)) {
230 this.pathsByLength
[matchParts
.length
] = [];
232 this.pathsByLength
[matchParts
.length
].push(existingPath
);
234 if (!Array
.isArray(methods
)) {
237 if (!Array
.isArray(handlerArgs
)) {
238 throw new TypeError(`handlerArgs must be an Array, not '${typeof handlerArgs}'`);
240 methods
.forEach((method
) => {
241 if (!httpMethods
.includes(method
) && method
!== '*') {
242 throw new DingusError(`invalid method '${method}'`);
244 existingPath
[kPathMethods
][method
] = { handler
, handlerArgs
};
250 * Return an object, which contains a matching handler and any extra
251 * arguments, for a requested url.
252 * Also sets path named-parameters as #params and the matched path as
253 * #matchedPath on the context.
254 * @param {string} method request method
255 * @param {string[]} urlPath request path
256 * @param {object} ctx request context, updated with matched metadata
257 * @returns {PathHandler} matched path
259 lookup(method
, urlPath
, ctx
= {}) {
260 const pathParts
= urlPath
.split('/').map((part
) => decodeURIComponent(part
));
261 if (this.ignoreTrailingSlash
262 && pathParts
[pathParts
.length
- 1] === '') {
265 const { matchedPath
, pathParams
} = this._pathFind(pathParts
);
266 ctx
.params
= pathParams
;
268 ctx
.matchedPath
= matchedPath
.path
;
269 if (method
in matchedPath
[kPathMethods
]) {
270 return matchedPath
[kPathMethods
][method
];
272 if ('*' in matchedPath
[kPathMethods
]) {
273 return matchedPath
[kPathMethods
]['*'];
275 throw new RouterNoMethodError();
277 ctx
.unmatchedPath
= pathParts
;
278 throw new RouterNoPathError();
284 module
.exports
= Router
;