expediate 1.0.4 → 1.0.6

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.
Files changed (69) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/CONTRIBUTING.md +150 -0
  3. package/LICENSE +16 -16
  4. package/README.md +330 -444
  5. package/dist/apis.d.ts +504 -27
  6. package/dist/apis.d.ts.map +1 -1
  7. package/dist/apis.js +618 -107
  8. package/dist/apis.js.map +1 -1
  9. package/dist/cjs/index.js +4066 -0
  10. package/dist/cjs/package.json +1 -0
  11. package/dist/git.d.ts +72 -9
  12. package/dist/git.d.ts.map +1 -1
  13. package/dist/git.js +129 -74
  14. package/dist/git.js.map +1 -1
  15. package/dist/http-objects.d.ts +26 -0
  16. package/dist/http-objects.d.ts.map +1 -0
  17. package/dist/http-objects.js +588 -0
  18. package/dist/http-objects.js.map +1 -0
  19. package/dist/index.d.ts +18 -13
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +15 -24
  22. package/dist/index.js.map +1 -1
  23. package/dist/jwt-auth.d.ts +158 -57
  24. package/dist/jwt-auth.d.ts.map +1 -1
  25. package/dist/jwt-auth.js +447 -207
  26. package/dist/jwt-auth.js.map +1 -1
  27. package/dist/middleware.d.ts +476 -0
  28. package/dist/middleware.d.ts.map +1 -0
  29. package/dist/middleware.js +647 -0
  30. package/dist/middleware.js.map +1 -0
  31. package/dist/mimetypes.json +882 -1
  32. package/dist/misc.d.ts +268 -25
  33. package/dist/misc.d.ts.map +1 -1
  34. package/dist/misc.js +449 -168
  35. package/dist/misc.js.map +1 -1
  36. package/dist/openapi.d.ts +433 -0
  37. package/dist/openapi.d.ts.map +1 -0
  38. package/dist/openapi.js +624 -0
  39. package/dist/openapi.js.map +1 -0
  40. package/dist/router-types.d.ts +760 -0
  41. package/dist/router-types.d.ts.map +1 -0
  42. package/dist/router-types.js +23 -0
  43. package/dist/router-types.js.map +1 -0
  44. package/dist/router.d.ts +37 -201
  45. package/dist/router.d.ts.map +1 -1
  46. package/dist/router.js +502 -244
  47. package/dist/router.js.map +1 -1
  48. package/dist/static.d.ts +3 -3
  49. package/dist/static.d.ts.map +1 -1
  50. package/dist/static.js +164 -105
  51. package/dist/static.js.map +1 -1
  52. package/docs/THREAT_MODEL.md +52 -0
  53. package/docs/api-builder-v2-design.md +644 -0
  54. package/docs/api-builder-v3-design.md +397 -0
  55. package/docs/api-builder.md +454 -0
  56. package/docs/benchmark.md +27 -0
  57. package/docs/body-parsing.md +223 -0
  58. package/docs/errors.md +359 -0
  59. package/docs/expediate.png +0 -0
  60. package/docs/git.md +139 -0
  61. package/docs/jwt-auth.md +251 -0
  62. package/docs/logo.svg +12 -0
  63. package/docs/middleware.md +264 -0
  64. package/docs/openapi.md +180 -0
  65. package/docs/router.md +356 -0
  66. package/docs/static.md +128 -0
  67. package/docs/wiki.json +123 -0
  68. package/package.json +47 -8
  69. package/.npmignore +0 -16
package/dist/router.js CHANGED
@@ -19,43 +19,10 @@
19
19
  * DEALINGS IN THE SOFTWARE.
20
20
  */
21
21
  'use strict';
22
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
23
- if (k2 === undefined) k2 = k;
24
- var desc = Object.getOwnPropertyDescriptor(m, k);
25
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
26
- desc = { enumerable: true, get: function() { return m[k]; } };
27
- }
28
- Object.defineProperty(o, k2, desc);
29
- }) : (function(o, m, k, k2) {
30
- if (k2 === undefined) k2 = k;
31
- o[k2] = m[k];
32
- }));
33
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
34
- Object.defineProperty(o, "default", { enumerable: true, value: v });
35
- }) : function(o, v) {
36
- o["default"] = v;
37
- });
38
- var __importStar = (this && this.__importStar) || (function () {
39
- var ownKeys = function(o) {
40
- ownKeys = Object.getOwnPropertyNames || function (o) {
41
- var ar = [];
42
- for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
43
- return ar;
44
- };
45
- return ownKeys(o);
46
- };
47
- return function (mod) {
48
- if (mod && mod.__esModule) return mod;
49
- var result = {};
50
- if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
51
- __setModuleDefault(result, mod);
52
- return result;
53
- };
54
- })();
55
- Object.defineProperty(exports, "__esModule", { value: true });
56
- const http = __importStar(require("http"));
57
- const https = __importStar(require("https"));
58
- const misc_1 = require("./misc");
22
+ import * as http from 'http';
23
+ import * as https from 'https';
24
+ import * as http2 from 'http2';
25
+ import { updateHttpObjects } from './http-objects.js';
59
26
  // ---------------------------------------------------------------------------
60
27
  // Pattern compilation
61
28
  // ---------------------------------------------------------------------------
@@ -69,7 +36,43 @@ const misc_1 = require("./misc");
69
36
  * @returns `true` if the pattern should be treated as a glob.
70
37
  */
71
38
  function isGlobPattern(pattern) {
72
- return /(?<!\\)[*?]/.test(pattern);
39
+ // Walk character-by-character, skipping over :name(constraint) segments so
40
+ // that regex metacharacters (e.g. '?') inside inline constraints are not
41
+ // mistaken for glob wildcards.
42
+ let i = 0;
43
+ while (i < pattern.length) {
44
+ const ch = pattern[i];
45
+ if (ch === '\\') {
46
+ i += 2;
47
+ continue;
48
+ } // escaped — skip next char
49
+ if (ch === ':') {
50
+ i++;
51
+ while (i < pattern.length && /\w/.test(pattern[i]))
52
+ i++; // skip param name
53
+ if (i < pattern.length && pattern[i] === '(') {
54
+ // Skip balanced constraint parens so their '?' / '*' are not counted.
55
+ let depth = 1;
56
+ i++;
57
+ while (i < pattern.length && depth > 0) {
58
+ if (pattern[i] === '\\') {
59
+ i += 2;
60
+ continue;
61
+ }
62
+ if (pattern[i] === '(')
63
+ depth++;
64
+ else if (pattern[i] === ')')
65
+ depth--;
66
+ i++;
67
+ }
68
+ }
69
+ continue;
70
+ }
71
+ if (ch === '*' || ch === '?')
72
+ return true;
73
+ i++;
74
+ }
75
+ return false;
73
76
  }
74
77
  /**
75
78
  * Compile a `.gitignore`-style glob string into a prefix-anchored `RegExp`.
@@ -93,7 +96,7 @@ function isGlobPattern(pattern) {
93
96
  * compileGlob('/api/*') .test('/api/users/123'); // false
94
97
  * ```
95
98
  */
96
- function compileGlob(glob) {
99
+ function compileGlob(glob, exact = false) {
97
100
  // Escape all regex special characters, leaving our wildcard characters intact.
98
101
  let src = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&');
99
102
  // Replace in order: `**` must be handled before `*`.
@@ -102,37 +105,118 @@ function compileGlob(glob) {
102
105
  .replace(/\*/g, '[^/]*') // single-segment wildcard
103
106
  .replace(/\?/g, '[^/]') // single-character wildcard
104
107
  .replace(/\x00GLOBSTAR\x00/g, '.*'); // cross-segment wildcard
105
- return new RegExp('^' + src);
108
+ return new RegExp('^' + src + (exact ? '$' : ''));
109
+ }
110
+ /**
111
+ /**
112
+ * Extract the content between a balanced pair of parentheses starting at
113
+ * `openIdx` in `str`, skipping backslash-escaped characters.
114
+ *
115
+ * Returns the inner pattern and the index of the closing `)` so callers can
116
+ * inspect any literal suffix that follows the constraint (e.g. `\.txt` in
117
+ * `:name([\w-]+)\.txt`).
118
+ *
119
+ * @param str - The full segment string, e.g. `':id(\\d+)'`.
120
+ * @param openIdx - Index of the opening `(`.
121
+ * @returns `{ pattern, closeIdx }` — the inner pattern string and the index
122
+ * of the matching `)`.
123
+ * @throws {SyntaxError} When parentheses are unbalanced.
124
+ */
125
+ function extractInlinePattern(str, openIdx) {
126
+ let depth = 0;
127
+ let i = openIdx;
128
+ for (; i < str.length; i++) {
129
+ if (str[i] === '\\') {
130
+ i++;
131
+ continue;
132
+ } // skip escape sequences
133
+ if (str[i] === '(') {
134
+ depth++;
135
+ continue;
136
+ }
137
+ if (str[i] === ')') {
138
+ depth--;
139
+ if (depth === 0)
140
+ break;
141
+ }
142
+ }
143
+ if (depth !== 0)
144
+ throw new SyntaxError(`Unbalanced parentheses in route segment '${str}'`);
145
+ return { pattern: str.slice(openIdx + 1, i), closeIdx: i };
106
146
  }
107
147
  /**
108
148
  * Compile a plain path string with optional `:name` parameter segments into a
109
149
  * prefix-anchored `RegExp` that uses named capture groups.
110
150
  *
111
- * Each `:name` segment is converted to `(?<name>[^/]+)`, making named
112
- * captures available directly on the `RegExp` match result.
113
- * Literal segments are escaped and matched exactly.
151
+ * **Basic parameters** — Each `:name` segment is converted to
152
+ * `(?<name>[^/]+)`, matching any non-slash sequence.
114
153
  *
115
- * The expression matches up to a segment boundary (`/` or end-of-string) so
116
- * that `/users` never inadvertently matches `/users-admin`.
154
+ * **Inline constraints** A parameter may optionally be followed by a
155
+ * parenthesised regex pattern: `:name(pattern)`. The pattern replaces the
156
+ * default `[^/]+` body, so only paths where that segment matches the
157
+ * constraint will be routed to the handler.
117
158
  *
118
- * @param path - A plain path string such as `'/users/:id/posts'`.
159
+ * ```
160
+ * :id → (?<id>[^/]+) (any non-slash value)
161
+ * :id(\d+) → (?<id>\d+) (digits only)
162
+ * :slug([\w-]+) → (?<slug>[\w-]+) (word chars and hyphens)
163
+ * ```
164
+ *
165
+ * Literal segments are regex-escaped and matched exactly. The expression
166
+ * matches up to a segment boundary (`/` or end-of-string) so that `/users`
167
+ * never inadvertently matches `/users-admin`.
168
+ *
169
+ * @param path - A plain path string such as `'/users/:id(\d+)/posts'`.
119
170
  * @returns A prefix-anchored `RegExp` with named groups for each parameter.
171
+ * @throws {SyntaxError} When an inline constraint is malformed, contains
172
+ * named capture groups (which conflict with the outer wrapper), or produces
173
+ * an invalid `RegExp`.
120
174
  *
121
175
  * @example
122
176
  * ```ts
123
- * const re = compilePlainPath('/users/:id');
124
- * re.exec('/users/42/comments')?.groups; // { id: '42' }
177
+ * const re = compilePlainPath('/users/:id(\\d+)');
178
+ * re.test('/users/42'); // true
179
+ * re.test('/users/abc'); // false
180
+ * re.exec('/users/7')?.groups; // { id: '7' }
125
181
  * ```
126
182
  */
127
- function compilePlainPath(path) {
183
+ function compilePlainPath(path, exact = false) {
128
184
  const segments = path.split('/').filter((s) => s.length > 0);
129
185
  const src = segments
130
- .map((seg) => seg.startsWith(':')
131
- ? `(?<${seg.slice(1)}>[^/]+)` // named parameter segment
132
- : seg.replace(/[.+^${}()|[\]\\]/g, '\\$&'))
186
+ .map((seg) => {
187
+ if (!seg.startsWith(':'))
188
+ return seg.replace(/[.+^${}()|[\]\\]/g, '\\$&'); // escaped literal
189
+ // Parameter segment: extract name and optional inline constraint.
190
+ const parenIdx = seg.indexOf('(', 1);
191
+ if (parenIdx === -1) {
192
+ // Plain :name — match any non-slash sequence.
193
+ return `(?<${seg.slice(1)}>[^/]+)`;
194
+ }
195
+ // :name(pattern) — optionally followed by a literal suffix, e.g. \.txt
196
+ const name = seg.slice(1, parenIdx);
197
+ if (!name)
198
+ throw new SyntaxError(`Route parameter missing name before '(' in segment '${seg}'`);
199
+ const { pattern, closeIdx } = extractInlinePattern(seg, parenIdx);
200
+ // Named capture groups inside the constraint conflict with the outer
201
+ // (?<name>…) wrapper and would cause duplicate-group errors.
202
+ if (/\(\?<[^>]+>/.test(pattern))
203
+ throw new SyntaxError(`Inline constraint for ':${name}' must not contain named capture groups.`);
204
+ // Any literal characters after the closing ')' are regex-escaped and
205
+ // appended — e.g. ':name([\\w-]+)\\.txt' → '(?<name>[\\w-]+)\\.txt'.
206
+ const suffix = seg.slice(closeIdx + 1);
207
+ const escapedSuffix = suffix.replace(/[.+^${}()|[\]\\]/g, '\\$&');
208
+ return `(?<${name}>${pattern})${escapedSuffix}`;
209
+ })
133
210
  .join('/');
134
- // Allow a trailing slash or an additional path segment after the prefix.
135
- return new RegExp('^/?' + src + '(?=/|$)');
211
+ // Validate and return surface any regex syntax errors as SyntaxError.
212
+ try {
213
+ return new RegExp(exact
214
+ ? '^/?' + src + (src ? '/?' : '') + '$'
215
+ : '^/?' + src + '(?=/|$)');
216
+ }
217
+ catch (e) {
218
+ throw new SyntaxError(`Invalid inline regex constraint in path '${path}': ${e.message}`, { cause: e });
219
+ }
136
220
  }
137
221
  /**
138
222
  * Convert any supported path pattern into the single canonical `RegExp`
@@ -141,18 +225,18 @@ function compilePlainPath(path) {
141
225
  * | Input type | Strategy |
142
226
  * |--------------|-------------------------------------------------------|
143
227
  * | Glob string | {@link compileGlob} — `.gitignore`-style wildcards |
144
- * | Plain string | {@link compilePlainPath} — `:name` → named groups |
228
+ * | Plain string | {@link compilePlainPath} — `:name` or `:name(re)` → named groups |
145
229
  * | `RegExp` | Used as-is; named groups are surfaced as params |
146
230
  *
147
231
  * @param path - The raw path pattern supplied by the caller.
148
232
  * @returns A `RegExp` suitable for use in `matchRouteLayer`.
149
233
  */
150
- function compilePattern(path) {
234
+ function compilePattern(path, exact = false) {
151
235
  if (path instanceof RegExp)
152
236
  return path;
153
237
  if (isGlobPattern(path))
154
- return compileGlob(path);
155
- return compilePlainPath(path);
238
+ return compileGlob(path, exact);
239
+ return compilePlainPath(path, exact);
156
240
  }
157
241
  // ---------------------------------------------------------------------------
158
242
  // Layer construction
@@ -179,7 +263,15 @@ function compilePattern(path) {
179
263
  function buildRouteLayer(method, path, middleware, stripPath) {
180
264
  if (typeof middleware !== 'function')
181
265
  throw new TypeError('Incorrect middleware type: expected a function');
182
- return { method, path, regex: compilePattern(path), stripPath, middleware };
266
+ // Reject RegExp patterns with the global (g) or sticky (y) flag.
267
+ // Both flags make RegExp.exec()/test() stateful: lastIndex advances after
268
+ // each match, so the same regex object alternates match/no-match across
269
+ // requests, causing intermittent 404s that are nearly impossible to debug.
270
+ // Express 4 silently allowed this (a known footgun); we reject it early.
271
+ if (path instanceof RegExp && (path.global || path.sticky))
272
+ throw new TypeError(`Route RegExp /${path.source}/${path.flags} must not use the g (global) or y (sticky) flag — ` +
273
+ 'these flags make exec() stateful and cause intermittent routing failures.');
274
+ return { method, path, regex: compilePattern(path, !stripPath), stripPath, middleware };
183
275
  }
184
276
  // ---------------------------------------------------------------------------
185
277
  // Layer matching
@@ -205,12 +297,17 @@ function buildRouteLayer(method, path, middleware, stripPath) {
205
297
  * `false` otherwise.
206
298
  */
207
299
  function matchRouteLayer(layer, req, path) {
208
- if (layer.method && layer.method !== req.method)
300
+ // A HEAD request is served by a matching GET layer (RFC 7231 §4.3.2); Node
301
+ // suppresses the response body for HEAD automatically, so the GET handler can
302
+ // run unchanged.
303
+ if (layer.method &&
304
+ layer.method !== req.method &&
305
+ !(req.method === 'HEAD' && layer.method === 'GET'))
209
306
  return false;
210
307
  const m = layer.regex.exec(path);
211
308
  if (m === null)
212
309
  return false;
213
- const captured = m.groups ?? {};
310
+ const captured = (m.groups) ?? {};
214
311
  // Only rewrite req.path for prefix-style (use) registrations.
215
312
  // Exact-method routes leave req.path intact so chained middlewares
216
313
  // sharing the same path each see the full, unmodified path.
@@ -221,123 +318,25 @@ function matchRouteLayer(layer, req, path) {
221
318
  Object.assign(req.params, captured);
222
319
  return true;
223
320
  }
224
- // ---------------------------------------------------------------------------
225
- // HTTP object augmentation
226
- // ---------------------------------------------------------------------------
227
321
  /**
228
- * Augment a raw `http.IncomingMessage` / `http.ServerResponse` pair with the
229
- * additional fields and helpers expected by router middleware.
230
- *
231
- * This function is idempotent it exits immediately when `req.queries` is
232
- * already defined, so it is safe to call multiple times on the same pair.
233
- *
234
- * **Fields added to `req`:**
235
- * - `originalUrl` the unmodified URL string.
236
- * - `path` — the pathname portion of the URL.
237
- * - `params` — merged map initialised from URL query parameters.
238
- * - `queries` — structured query buckets (`url`, `route`).
239
- * - `cookies` parsed `Cookie` header values.
240
- *
241
- * **Helpers added to `res`:**
242
- * - `send(data?)` — write `data` and end the response.
243
- * - `status(code, headers?)` — set the status code and optional headers.
244
- * - `redirect(url)` — issue a 302 redirect.
245
- * - `cookie(name, val, opts)` — append a `Set-Cookie` header.
246
- *
247
- * @param req - The raw incoming message to augment.
248
- * @param res - The raw server response to augment.
322
+ * Test whether a layer's path pattern matches the given path string,
323
+ * **ignoring the HTTP method**. Does NOT mutate `req`.
324
+ *
325
+ * Used exclusively for **405 Method Not Allowed** detection: when
326
+ * `matchRouteLayer` returns `false` due to a method mismatch, calling this
327
+ * function lets the dispatcher confirm that the path itself is registered
328
+ * (just under a different method) so it can respond with 405 and an
329
+ * `Allow` header instead of the generic 404.
330
+ *
331
+ * @param layer - The layer whose path pattern to test.
332
+ * @param path - The current value of `req.path`.
333
+ * @returns `true` when the path pattern matches, regardless of method.
249
334
  */
250
- function updateHttpObjects(req, res) {
251
- const rReq = req;
252
- const rRes = res;
253
- if (rReq.queries)
254
- return; // Already augmented.
255
- rReq.queries = {};
256
- const qry = new URL(`http://${req.headers.host}${req.url}`);
257
- rReq.originalUrl = req.url;
258
- rReq.path = qry.pathname;
259
- // Parse URL query parameters.
260
- const urlParams = {};
261
- for (const [key, value] of qry.searchParams.entries())
262
- urlParams[key] = value;
263
- rReq.queries.url = urlParams;
264
- rReq.params = { ...urlParams };
265
- // Parse cookies.
266
- if (rReq.cookies == null) {
267
- rReq.cookies = {};
268
- if (req.headers.cookie) {
269
- for (const raw of req.headers.cookie.split(';')) {
270
- const eqIdx = raw.indexOf('=');
271
- if (eqIdx === -1)
272
- continue;
273
- rReq.cookies[raw.slice(0, eqIdx).trim()] = raw.slice(eqIdx + 1).trim();
274
- // TODO: 's:' prefix → signed cookie, 'j:' prefix → JSON cookie
275
- }
276
- }
277
- }
278
- rReq.json = (opts) => {
279
- return new Promise((resolve, reject) => {
280
- (0, misc_1.readReqBody)(rReq, { limit: opts?.limit ?? '100kb', inflate: opts?.inflate ?? true, reviver: null, strict: false }, 'application/json')
281
- .then(ret => {
282
- if (ret == null)
283
- return resolve(null);
284
- const charset = (0, misc_1.extractCharset)(ret.mimetype);
285
- try {
286
- rReq.body = JSON.parse(ret.content.toString(charset), opts?.reviver ?? undefined);
287
- return resolve(rReq.body);
288
- }
289
- catch (ex) {
290
- reject({ status: 500, message: ex.message });
291
- }
292
- })
293
- .catch(err => reject(err));
294
- });
295
- };
296
- rRes.setHeader('X-Powered-By', 'Expediate');
297
- rRes.send = (data) => {
298
- if (data)
299
- res.write(data);
300
- res.end();
301
- };
302
- rRes.json = (data) => {
303
- res.write(JSON.stringify(data));
304
- res.end();
305
- };
306
- rRes.status = (code, headers) => {
307
- res.statusCode = code;
308
- if (headers)
309
- for (const [k, v] of Object.entries(headers))
310
- res.setHeader(k, v);
311
- return rRes;
312
- };
313
- rRes.redirect = (url) => {
314
- res.setHeader('location', url);
315
- res.writeHead(302);
316
- res.write(`Found. Redirecting to ${url}`);
317
- res.end();
318
- };
319
- rRes.cookie = (name, value, options) => {
320
- const opts = options ?? {};
321
- if (opts.signed && !req.secret)
322
- throw new Error('cookieParser("secret") required for signed cookies');
323
- let val = typeof value === 'object' ? 'j:' + JSON.stringify(value) : String(value);
324
- if (opts.signed)
325
- val = 's:' + val; // sign() integration point
326
- let txt = `${name}=${val}`;
327
- if (opts.maxAge != null) {
328
- opts.expires = new Date(Date.now() + opts.maxAge);
329
- opts.maxAge = Math.floor(opts.maxAge / 1000);
330
- txt += `; Max-Age=${opts.maxAge}`;
331
- }
332
- if (opts.expires)
333
- txt += `; Expires=${opts.expires.toUTCString()}`;
334
- txt += `; Path=${opts.path ?? '/'}`;
335
- res.setHeader('Set-Cookie', txt);
336
- return rRes;
337
- };
335
+ function pathMatchesLayer(layer, path) {
336
+ return layer.regex.test(path);
338
337
  }
339
338
  // ---------------------------------------------------------------------------
340
- // Route registration
339
+ // Route registration helpers
341
340
  // ---------------------------------------------------------------------------
342
341
  /**
343
342
  * Recursively resolve a `MiddlewareArg` value down to individual `Middleware`
@@ -352,11 +351,7 @@ function updateHttpObjects(req, res) {
352
351
  * @param method - HTTP method string or `null` for method-agnostic layers.
353
352
  * @param path - URL pattern (plain string, glob, or `RegExp`).
354
353
  * @param arg - The middleware value(s) to register.
355
- * @param stripPath - Forwarded to `buildRouteLayer`. Pass `true` for prefix-
356
- * style registrations (`use`) so the matched prefix
357
- * is stripped from `req.path`, and `false` for exact-method
358
- * routes so that chained middlewares sharing the same path
359
- * each see the unmodified path.
354
+ * @param stripPath - Forwarded to `buildRouteLayer`.
360
355
  * @throws {TypeError} When `arg` contains a value that cannot be resolved to
361
356
  * a `Middleware` function.
362
357
  */
@@ -368,15 +363,34 @@ function registerRoute(routes, method, path, arg, stripPath) {
368
363
  else if (typeof arg === 'function') {
369
364
  routes.push(buildRouteLayer(method, path, arg, stripPath));
370
365
  }
371
- else if (arg && typeof arg.listener === 'function') {
366
+ else if (arg && typeof (arg).listener === 'function') {
372
367
  // Router instance — unwrap its listener.
373
- routes.push(buildRouteLayer(method, path, arg.listener, stripPath));
368
+ routes.push(buildRouteLayer(method, path, (arg).listener, stripPath));
374
369
  }
375
370
  else {
376
371
  throw new TypeError('Unexpected value registered as middleware: expected a Middleware ' +
377
372
  'function, a Router instance, or an array of either');
378
373
  }
379
374
  }
375
+ /**
376
+ * If `arg` is a `Router` instance, return its configured {@link Router.prefix};
377
+ * otherwise return `undefined`.
378
+ *
379
+ * Used by `router.use()` to infer the mount path when no explicit path is
380
+ * provided:
381
+ * ```ts
382
+ * const v1 = createRouter('/api/v1');
383
+ * app.use(v1); // prefix '/api/v1' is inferred automatically
384
+ * ```
385
+ *
386
+ * @param arg - The first argument passed to `router.use()`.
387
+ * @returns The router's prefix string, or `undefined`.
388
+ */
389
+ function extractRouterPrefix(arg) {
390
+ if (Array.isArray(arg) || typeof arg === 'function')
391
+ return undefined;
392
+ return (arg).prefix;
393
+ }
380
394
  // ---------------------------------------------------------------------------
381
395
  // Router factory
382
396
  // ---------------------------------------------------------------------------
@@ -388,79 +402,231 @@ function registerRoute(routes, method, path, arg, stripPath) {
388
402
  * mounted inside another router:
389
403
  * ```ts
390
404
  * parent.use('/api', child);
405
+ * // or, if child has a prefix:
406
+ * parent.use(child);
407
+ * ```
408
+ *
409
+ * **Prefix shorthand:**
410
+ * Pass a path string as the first argument to associate a prefix with this
411
+ * router. The prefix is used automatically when the router is mounted via
412
+ * `parent.use(child)`:
413
+ * ```ts
414
+ * const v1 = createRouter('/api/v1');
415
+ * v1.get('/users', handler); // handler is reached at /api/v1/users
416
+ * app.use(v1); // same as app.use('/api/v1', v1)
391
417
  * ```
392
418
  *
393
419
  * **Path patterns** accepted by all route-registration methods:
394
420
  * - Plain strings with optional `:name` segments — e.g. `'/users/:id'`.
395
- * Each `:name` is compiled to a named capture group and exposed in
396
- * `req.params` on a match.
397
421
  * - Glob strings following `.gitignore` rules — e.g. `'/**\/*.php'`.
398
- * Supported wildcards: `?` (one non-slash char), `*` (any non-slash chars),
399
- * `**` (any chars, including slashes).
400
422
  * - `RegExp` objects — used directly; named groups become route parameters.
401
423
  *
402
- * **Middleware arguments** accept any number of variadic `MiddlewareArg`
403
- * values, each of which may be:
404
- * - A `Middleware` function.
405
- * - A `Router` instance (its `listener` is registered automatically).
406
- * - An array of either of the above.
424
+ * **Error handling:**
425
+ * Register a global error handler with `router.onError()`. It is called
426
+ * when any middleware throws, rejects, or calls `next(err)`.
407
427
  *
408
- * **Path stripping behaviour:**
409
- * - `use` strip the matched path prefix from `req.path` before
410
- * invoking middleware. Nested routers therefore only see the remaining suffix.
411
- * - `all` / `get` / `post` / `put` / `delete` / `patch` — leave `req.path` intact
412
- * so that multiple middlewares registered for the same exact path can each
413
- * match and be invoked in sequence via `next()`.
428
+ * **Graceful shutdown:**
429
+ * Call `router.shutdown()` to stop the server created by `router.listen()`.
414
430
  *
415
- * @returns A fully initialised `Router` ready to register routes and
416
- * optionally start an HTTP or HTTPS server.
431
+ * @param prefixOrOpts - Optional path prefix string (e.g. `'/api/v1'`) **or**
432
+ * an {@link RouterOptions} object.
433
+ * @param opts - Options when `prefixOrOpts` is a string.
434
+ * @returns A fully initialised `Router`.
417
435
  *
418
436
  * @example
419
437
  * ```ts
420
- * const auth = createRouter();
421
- * auth.post('/login', handleLogin);
422
- * auth.post('/logout', handleLogout);
438
+ * const app = createRouter({ secret: process.env.COOKIE_SECRET, timeout: 30_000 });
439
+ *
440
+ * const v1 = createRouter('/api/v1');
441
+ * v1.get('/users', handler);
423
442
  *
424
- * const app = createRouter();
425
- * app.use('/auth', auth); // mount a sub-router
426
- * app.get('/users/:id', requireAuth, getUser); // multiple middleware
427
- * app.get('/**\/*.php', (req, res) => // glob pattern
428
- * res.status(403).send('Forbidden'));
443
+ * app.use(v1);
444
+ * app.onError((err, _req, res) => res.status(500).json({ error: String(err) }));
429
445
  *
430
- * app.listen(3000, () => console.log('Listening on :3000'));
446
+ * // Custom 404: register a catch-all as the LAST layer. Because layers match
447
+ * // in registration order, it only runs when nothing earlier claimed the
448
+ * // request. Use `all('/**', …)` (glob) to match any method and path.
449
+ * app.all('/**', (_req, res) => res.status(404).json({ error: 'Not Found' }));
450
+ *
451
+ * app.listen(3000, () => console.log('Listening'));
452
+ * process.on('SIGTERM', () => app.shutdown(10_000));
431
453
  * ```
432
454
  */
433
- function createRouter() {
455
+ function createRouter(prefixOrOpts, opts) {
456
+ // Resolve overloaded first argument.
457
+ const routerPrefix = typeof prefixOrOpts === 'string' ? prefixOrOpts : undefined;
458
+ const options = typeof prefixOrOpts === 'object' ? prefixOrOpts : (opts ?? {});
459
+ const secret = options.secret;
460
+ const timeoutMs = options.timeout;
461
+ const trustProxy = options.trustProxy ?? false;
434
462
  const routes = [];
463
+ /** Ordered error-handling middlewares registered via `router.error()`. */
464
+ const errorHandlers = [];
465
+ /** Terminal fallback registered via `router.onError()`, or `undefined`. */
466
+ let errorHandler;
467
+ /** Server created by `router.listen()`, used by `router.shutdown()`. */
468
+ let activeServer = null;
469
+ /** All open sockets tracked for forced teardown on shutdown. */
470
+ const activeSockets = new Set();
471
+ // ──────────────────────────────────────────────────────────────────────────
472
+ // Core dispatch listener
473
+ // ──────────────────────────────────────────────────────────────────────────
435
474
  /**
436
475
  * Core dispatch function. Walks the route table in registration order and
437
- * invokes the first layer that matches the current request. Falls back to a
438
- * 404 response when no layer matches and no upstream `done` callback is set.
476
+ * invokes the first layer that matches the current request.
477
+ *
478
+ * - **404** — no layer's path matched (register a catch-all last to customise).
479
+ * - **405 Method Not Allowed** — a layer's path matched but no layer
480
+ * accepted the HTTP method. The `Allow` header lists all registered methods.
481
+ * - **500** (or custom `onError` handler) — a middleware threw or rejected,
482
+ * or `next(err)` was called with a non-null error.
439
483
  */
440
484
  const listener = (req, res, done) => {
441
485
  const method = req.method;
442
486
  const url = req.url;
443
487
  let idx = 0;
444
- updateHttpObjects(req, res);
445
- const next = () => {
488
+ updateHttpObjects(req, res, secret, trustProxy);
489
+ // ── Optional per-request timeout ──────────────────────────────────────
490
+ // Uses both a socket-level idle timeout (as the transport boundary) and a
491
+ // wall-clock setTimeout guard. The wall-clock timer is the primary
492
+ // mechanism because it is unaffected by the OS socket buffer state;
493
+ // socket.setTimeout is set in addition so the idle signal propagates down
494
+ // to keep-alive connections that would otherwise hold the socket open.
495
+ if (timeoutMs) {
496
+ if (req.socket)
497
+ req.socket.setTimeout(timeoutMs);
498
+ const timer = setTimeout(() => {
499
+ if (!res.writableEnded)
500
+ res.status(408).end('Request Timeout');
501
+ }, timeoutMs);
502
+ res.once('finish', () => {
503
+ clearTimeout(timer);
504
+ if (req.socket)
505
+ req.socket.setTimeout(0);
506
+ });
507
+ }
508
+ // ── Centralised error dispatch ─────────────────────────────────────────
509
+ // Invoked for sync throws, async rejections, and next(err) calls.
510
+ //
511
+ // Resolution order:
512
+ // 1. Each error() middleware in turn (it may end the response or forward).
513
+ // 2. The onError() terminal fallback, if registered.
514
+ // 3. Bubble to the parent router's error channel via done(err).
515
+ // 4. Top-level router with no handler → default 500.
516
+ const invokeErrorHandler = (e) => {
517
+ if (res.writableEnded)
518
+ return;
519
+ let i = 0;
520
+ const runNext = (err) => {
521
+ if (res.writableEnded)
522
+ return;
523
+ // 1. Ordered error() middleware chain.
524
+ if (i < errorHandlers.length) {
525
+ const handler = errorHandlers[i++];
526
+ try {
527
+ // `next()` forwards the same error; `next(newErr)` replaces it.
528
+ handler(err, req, res, (nextErr) => runNext(nextErr ?? err));
529
+ }
530
+ catch (e2) {
531
+ runNext(e2);
532
+ }
533
+ return;
534
+ }
535
+ // 2. onError() terminal fallback for this router.
536
+ if (errorHandler) {
537
+ try {
538
+ errorHandler(err, req, res);
539
+ }
540
+ catch {
541
+ if (!res.writableEnded)
542
+ res.status(500).end(`Error ${method} ${url}`);
543
+ }
544
+ return;
545
+ }
546
+ // 3. Bubble to the parent router's error channel (when mounted via use()).
547
+ if (done) {
548
+ done(err);
549
+ return;
550
+ }
551
+ // 4. Top-level router with no handler: default 500.
552
+ res.status(500).end(`Error ${method} ${url}`);
553
+ };
554
+ runNext(e);
555
+ };
556
+ // ── Safe middleware invocation ─────────────────────────────────────────
557
+ // Catches sync throws AND async rejections, routing both to invokeErrorHandler.
558
+ const invoke = (mw, nextFn) => {
559
+ try {
560
+ const ret = mw(req, res, nextFn);
561
+ if (ret instanceof Promise)
562
+ ret.catch(invokeErrorHandler);
563
+ }
564
+ catch (e) {
565
+ invokeErrorHandler(e);
566
+ }
567
+ };
568
+ // Accumulate methods from layers whose path matched but whose method
569
+ // did not, for a 405 response with an accurate Allow header.
570
+ const allowedMethods = new Set();
571
+ // ── Main dispatch loop ─────────────────────────────────────────────────
572
+ const next = (err) => {
573
+ // If an error is passed, skip remaining routes and call error handler.
574
+ if (err != null) {
575
+ invokeErrorHandler(err);
576
+ return;
577
+ }
446
578
  while (idx < routes.length) {
447
579
  const layer = routes[idx++];
448
580
  const pathBefore = req.path;
449
581
  if (matchRouteLayer(layer, req, req.path)) {
450
582
  if (layer.stripPath) {
451
- // For prefix layers (use), save the pre-strip path so we can
452
- // restore it if the sub-router calls done() and control returns here.
453
- // Without restoration, subsequent layers in this router would see
454
- // the truncated path and fail to match their own patterns.
455
- const strippedPath = req.path;
456
- return layer.middleware(req, res, () => {
457
- req.path = pathBefore; // restore for the next sibling layer
458
- next();
583
+ // For prefix layers (use), restore req.path and req.baseUrl after
584
+ // the sub-router calls done() so subsequent sibling layers see the
585
+ // original values.
586
+ const baseUrlBefore = req.baseUrl;
587
+ const strippedPrefix = pathBefore.slice(0, pathBefore.length - req.path.length);
588
+ req.baseUrl = baseUrlBefore + strippedPrefix;
589
+ // The continuation doubles as the sub-router's `done`: it runs both
590
+ // when the sub-router falls through (no error) and when it bubbles an
591
+ // error up. Forward `err` so a bubbled error reaches this router's
592
+ // error channel instead of being silently dropped.
593
+ invoke(layer.middleware, (err) => {
594
+ req.path = pathBefore;
595
+ req.baseUrl = baseUrlBefore;
596
+ next(err);
459
597
  });
598
+ return;
460
599
  }
461
- return layer.middleware(req, res, next);
600
+ invoke(layer.middleware, next);
601
+ return;
462
602
  }
603
+ // Path matched but method did not → remember for 405 detection.
604
+ if (layer.method !== null && pathMatchesLayer(layer, pathBefore)) {
605
+ allowedMethods.add(layer.method);
606
+ }
607
+ }
608
+ // All layers exhausted without a full match.
609
+ if (allowedMethods.size > 0) {
610
+ // The path is registered, just not for this method. Build the Allow
611
+ // header, advertising HEAD (served by GET) and OPTIONS (handled here)
612
+ // alongside the explicitly registered methods.
613
+ if (allowedMethods.has('GET'))
614
+ allowedMethods.add('HEAD');
615
+ allowedMethods.add('OPTIONS');
616
+ const allow = [...allowedMethods].sort().join(', ');
617
+ // Automatic OPTIONS: when nothing claimed the request (no explicit
618
+ // OPTIONS route, no cors() middleware), reply 204 with the Allow header.
619
+ if (method === 'OPTIONS') {
620
+ res.status(204, { Allow: allow }).end();
621
+ return;
622
+ }
623
+ // Otherwise the method is genuinely not allowed for this path.
624
+ res.status(405, { Allow: allow }).end(`Cannot ${method} ${url}`);
625
+ return;
463
626
  }
627
+ // Genuine 404 — delegate to the parent router, or send the default 404.
628
+ // To customise, register a catch-all layer last (e.g. `app.all('/**', …)`);
629
+ // it matches in registration order after every real route.
464
630
  if (done)
465
631
  return done();
466
632
  res.status(404).end(`Cannot ${method} ${url}`);
@@ -469,25 +635,17 @@ function createRouter() {
469
635
  next();
470
636
  }
471
637
  catch (e) {
472
- console.warn(e);
473
- res.status(500).end(`Error ${method} ${url}`);
638
+ invokeErrorHandler(e);
474
639
  }
475
640
  };
476
- // -------------------------------------------------------------------------
477
- // Internal helper — produce the uniform registration function for a method.
478
- // -------------------------------------------------------------------------
641
+ // ──────────────────────────────────────────────────────────────────────────
642
+ // Registration helper for method-specific routes
643
+ // ──────────────────────────────────────────────────────────────────────────
479
644
  /**
480
645
  * Return the route-registration function used by all HTTP-method helpers.
481
646
  *
482
- * The produced function accepts a mandatory `path` followed by any number
483
- * of `MiddlewareArg` values (functions, `Router` instances, or arrays
484
- * thereof), and delegates each to `registerRoute`.
485
- *
486
647
  * @param method - HTTP method to restrict layers to, or `null` for any.
487
- * @param stripPath - Whether the matched path prefix should be stripped from
488
- * `req.path` before middleware is invoked. `true` for
489
- * prefix-style registrations, `false` for exact-method ones.
490
- * @returns A variadic route-registration function.
648
+ * @param stripPath - Whether the matched path prefix should be stripped.
491
649
  */
492
650
  function makeRegister(method, stripPath) {
493
651
  return (path, ...args) => {
@@ -495,31 +653,131 @@ function createRouter() {
495
653
  registerRoute(routes, method, path, arg, stripPath);
496
654
  };
497
655
  }
498
- // -------------------------------------------------------------------------
499
- // Public router API
500
- // -------------------------------------------------------------------------
656
+ // ──────────────────────────────────────────────────────────────────────────
657
+ // `use()` supports both path-first and no-path (Router-first) forms
658
+ // ──────────────────────────────────────────────────────────────────────────
659
+ /**
660
+ * Register prefix-style middleware. Accepts:
661
+ * 1. `use(path, ...middlewares)` — explicit path.
662
+ * 2. `use(routerOrMiddleware, ...more)` — infers path from Router.prefix or '/'.
663
+ */
664
+ const use = (pathOrFirst, ...args) => {
665
+ if (typeof pathOrFirst === 'string' || pathOrFirst instanceof RegExp) {
666
+ // Normal form: explicit path string or RegExp.
667
+ for (const arg of args)
668
+ registerRoute(routes, null, pathOrFirst, arg, true);
669
+ }
670
+ else {
671
+ // No explicit path: the first argument is itself a middleware / Router.
672
+ // Infer mount path from the Router's prefix, or default to '/'.
673
+ const inferredPath = extractRouterPrefix(pathOrFirst) ?? '/';
674
+ registerRoute(routes, null, inferredPath, pathOrFirst, true);
675
+ for (const arg of args)
676
+ registerRoute(routes, null, '/', arg, true);
677
+ }
678
+ };
679
+ // ──────────────────────────────────────────────────────────────────────────
680
+ // Public router object
681
+ // ──────────────────────────────────────────────────────────────────────────
501
682
  const router = {
683
+ prefix: routerPrefix,
502
684
  listener,
503
- use: makeRegister(null, true), // prefix — strip path
504
- all: makeRegister(null, false), // exact — keep path
685
+ use,
686
+ all: makeRegister(null, false),
505
687
  get: makeRegister('GET', false),
506
688
  put: makeRegister('PUT', false),
507
689
  post: makeRegister('POST', false),
508
690
  delete: makeRegister('DELETE', false),
509
691
  patch: makeRegister('PATCH', false),
692
+ head: makeRegister('HEAD', false),
693
+ options: makeRegister('OPTIONS', false),
694
+ // ── route ────────────────────────────────────────────────────────────────
695
+ route(path) {
696
+ // Each method forwards to the router's own registration helper with the
697
+ // cached path and returns the builder so calls can be chained.
698
+ const builder = {
699
+ all(...args) { router.all(path, ...args); return builder; },
700
+ get(...args) { router.get(path, ...args); return builder; },
701
+ put(...args) { router.put(path, ...args); return builder; },
702
+ post(...args) { router.post(path, ...args); return builder; },
703
+ delete(...args) { router.delete(path, ...args); return builder; },
704
+ patch(...args) { router.patch(path, ...args); return builder; },
705
+ head(...args) { router.head(path, ...args); return builder; },
706
+ options(...args) { router.options(path, ...args); return builder; },
707
+ };
708
+ return builder;
709
+ },
710
+ // ── onError ─────────────────────────────────────────────────────────────
711
+ onError(handler) {
712
+ errorHandler = handler;
713
+ },
714
+ // ── error ────────────────────────────────────────────────────────────────
715
+ error(handler) {
716
+ errorHandlers.push(handler);
717
+ },
718
+ // ── routes ───────────────────────────────────────────────────────────────
719
+ routes() {
720
+ return routes.map((l) => ({
721
+ method: l.method,
722
+ path: l.path,
723
+ stripPath: l.stripPath,
724
+ }));
725
+ },
726
+ // ── shutdown ─────────────────────────────────────────────────────────────
727
+ shutdown(timeout = 5000) {
728
+ if (!activeServer)
729
+ return Promise.resolve();
730
+ return new Promise((resolve, reject) => {
731
+ // Stop accepting new connections. Resolves when all existing
732
+ // connections have been closed (or when forcibly destroyed below).
733
+ activeServer.close((err) => {
734
+ if (err)
735
+ reject(err);
736
+ else
737
+ resolve();
738
+ });
739
+ // Forcibly destroy any remaining idle sockets after the grace period.
740
+ if (timeout > 0) {
741
+ setTimeout(() => {
742
+ for (const socket of activeSockets)
743
+ socket.destroy();
744
+ activeSockets.clear();
745
+ }, timeout);
746
+ }
747
+ });
748
+ },
749
+ // ── listen ───────────────────────────────────────────────────────────────
510
750
  listen(port, opts, cb) {
511
751
  if (typeof opts === 'function') {
512
752
  cb = opts;
513
753
  opts = undefined;
514
754
  }
515
755
  const rawListener = listener;
516
- if (opts && opts.key && opts.cert)
517
- https.createServer(opts, rawListener).listen(port, cb);
518
- else
519
- http.createServer(rawListener).listen(port, cb);
756
+ let server;
757
+ const tlsOpts = opts;
758
+ if (tlsOpts?.key && tlsOpts?.cert) {
759
+ if (tlsOpts.http2) {
760
+ // HTTP/2 secure server — same TLS options, different factory.
761
+ server = http2.createSecureServer(tlsOpts, rawListener);
762
+ }
763
+ else {
764
+ server = https.createServer(tlsOpts, rawListener);
765
+ }
766
+ }
767
+ else {
768
+ server = http.createServer(rawListener);
769
+ }
770
+ // Track open sockets so shutdown() can forcibly destroy them.
771
+ server.on('connection', (socket) => {
772
+ activeSockets.add(socket);
773
+ socket.once('close', () => activeSockets.delete(socket));
774
+ });
775
+ activeServer = server;
776
+ server.listen(port, cb);
777
+ return server;
520
778
  },
521
779
  };
522
780
  return router;
523
781
  }
524
- exports.default = createRouter;
782
+ export default createRouter;
525
783
  //# sourceMappingURL=router.js.map