expediate 1.0.5 → 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 (73) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/CONTRIBUTING.md +150 -0
  3. package/README.md +278 -779
  4. package/dist/apis.d.ts +372 -12
  5. package/dist/apis.d.ts.map +1 -1
  6. package/dist/apis.js +483 -65
  7. package/dist/apis.js.map +1 -1
  8. package/dist/cjs/index.js +2290 -807
  9. package/dist/git.d.ts +1 -1
  10. package/dist/git.d.ts.map +1 -1
  11. package/dist/git.js +5 -5
  12. package/dist/git.js.map +1 -1
  13. package/dist/http-objects.d.ts +26 -0
  14. package/dist/http-objects.d.ts.map +1 -0
  15. package/dist/http-objects.js +588 -0
  16. package/dist/http-objects.js.map +1 -0
  17. package/dist/index.d.ts +6 -5
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +2 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/jwt-auth.d.ts +11 -0
  22. package/dist/jwt-auth.d.ts.map +1 -1
  23. package/dist/jwt-auth.js +9 -9
  24. package/dist/jwt-auth.js.map +1 -1
  25. package/dist/middleware.js +2 -2
  26. package/dist/middleware.js.map +1 -1
  27. package/dist/mimetypes.json +882 -1
  28. package/dist/misc.d.ts +161 -25
  29. package/dist/misc.d.ts.map +1 -1
  30. package/dist/misc.js +228 -80
  31. package/dist/misc.js.map +1 -1
  32. package/dist/openapi.d.ts +156 -13
  33. package/dist/openapi.d.ts.map +1 -1
  34. package/dist/openapi.js +214 -71
  35. package/dist/openapi.js.map +1 -1
  36. package/dist/router-types.d.ts +760 -0
  37. package/dist/router-types.d.ts.map +1 -0
  38. package/dist/router-types.js +23 -0
  39. package/dist/router-types.js.map +1 -0
  40. package/dist/router.d.ts +7 -530
  41. package/dist/router.d.ts.map +1 -1
  42. package/dist/router.js +128 -375
  43. package/dist/router.js.map +1 -1
  44. package/dist/static.d.ts +2 -2
  45. package/dist/static.d.ts.map +1 -1
  46. package/dist/static.js +77 -22
  47. package/dist/static.js.map +1 -1
  48. package/docs/THREAT_MODEL.md +52 -0
  49. package/docs/api-builder-v2-design.md +644 -0
  50. package/docs/api-builder-v3-design.md +397 -0
  51. package/docs/api-builder.md +454 -0
  52. package/docs/benchmark.md +27 -0
  53. package/docs/body-parsing.md +223 -0
  54. package/docs/errors.md +359 -0
  55. package/docs/expediate.png +0 -0
  56. package/docs/git.md +139 -0
  57. package/docs/jwt-auth.md +251 -0
  58. package/docs/logo.svg +12 -0
  59. package/docs/middleware.md +264 -0
  60. package/docs/openapi.md +180 -0
  61. package/docs/router.md +356 -0
  62. package/docs/static.md +128 -0
  63. package/docs/wiki.json +123 -0
  64. package/package.json +30 -8
  65. package/dist/cjs/apis.js +0 -327
  66. package/dist/cjs/git.js +0 -293
  67. package/dist/cjs/jwt-auth.js +0 -532
  68. package/dist/cjs/middleware.js +0 -511
  69. package/dist/cjs/mimetypes.json +0 -1
  70. package/dist/cjs/misc.js +0 -787
  71. package/dist/cjs/openapi.js +0 -485
  72. package/dist/cjs/router.js +0 -898
  73. package/dist/cjs/static.js +0 -669
package/dist/router.js CHANGED
@@ -19,14 +19,10 @@
19
19
  * DEALINGS IN THE SOFTWARE.
20
20
  */
21
21
  'use strict';
22
- import * as crypto from 'crypto';
23
- import * as fs from 'fs';
24
22
  import * as http from 'http';
25
23
  import * as https from 'https';
26
24
  import * as http2 from 'http2';
27
- import * as path from 'path';
28
- import { parseMultipartBody, extractCharset, readReqBody } from './misc.js';
29
- import { serveFile } from './static.js';
25
+ import { updateHttpObjects } from './http-objects.js';
30
26
  // ---------------------------------------------------------------------------
31
27
  // Pattern compilation
32
28
  // ---------------------------------------------------------------------------
@@ -100,7 +96,7 @@ function isGlobPattern(pattern) {
100
96
  * compileGlob('/api/*') .test('/api/users/123'); // false
101
97
  * ```
102
98
  */
103
- function compileGlob(glob) {
99
+ function compileGlob(glob, exact = false) {
104
100
  // Escape all regex special characters, leaving our wildcard characters intact.
105
101
  let src = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&');
106
102
  // Replace in order: `**` must be handled before `*`.
@@ -109,7 +105,7 @@ function compileGlob(glob) {
109
105
  .replace(/\*/g, '[^/]*') // single-segment wildcard
110
106
  .replace(/\?/g, '[^/]') // single-character wildcard
111
107
  .replace(/\x00GLOBSTAR\x00/g, '.*'); // cross-segment wildcard
112
- return new RegExp('^' + src);
108
+ return new RegExp('^' + src + (exact ? '$' : ''));
113
109
  }
114
110
  /**
115
111
  /**
@@ -184,7 +180,7 @@ function extractInlinePattern(str, openIdx) {
184
180
  * re.exec('/users/7')?.groups; // { id: '7' }
185
181
  * ```
186
182
  */
187
- function compilePlainPath(path) {
183
+ function compilePlainPath(path, exact = false) {
188
184
  const segments = path.split('/').filter((s) => s.length > 0);
189
185
  const src = segments
190
186
  .map((seg) => {
@@ -214,10 +210,12 @@ function compilePlainPath(path) {
214
210
  .join('/');
215
211
  // Validate and return — surface any regex syntax errors as SyntaxError.
216
212
  try {
217
- return new RegExp('^/?' + src + '(?=/|$)');
213
+ return new RegExp(exact
214
+ ? '^/?' + src + (src ? '/?' : '') + '$'
215
+ : '^/?' + src + '(?=/|$)');
218
216
  }
219
217
  catch (e) {
220
- throw new SyntaxError(`Invalid inline regex constraint in path '${path}': ${e.message}`);
218
+ throw new SyntaxError(`Invalid inline regex constraint in path '${path}': ${e.message}`, { cause: e });
221
219
  }
222
220
  }
223
221
  /**
@@ -233,12 +231,12 @@ function compilePlainPath(path) {
233
231
  * @param path - The raw path pattern supplied by the caller.
234
232
  * @returns A `RegExp` suitable for use in `matchRouteLayer`.
235
233
  */
236
- function compilePattern(path) {
234
+ function compilePattern(path, exact = false) {
237
235
  if (path instanceof RegExp)
238
236
  return path;
239
237
  if (isGlobPattern(path))
240
- return compileGlob(path);
241
- return compilePlainPath(path);
238
+ return compileGlob(path, exact);
239
+ return compilePlainPath(path, exact);
242
240
  }
243
241
  // ---------------------------------------------------------------------------
244
242
  // Layer construction
@@ -265,7 +263,15 @@ function compilePattern(path) {
265
263
  function buildRouteLayer(method, path, middleware, stripPath) {
266
264
  if (typeof middleware !== 'function')
267
265
  throw new TypeError('Incorrect middleware type: expected a function');
268
- 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 };
269
275
  }
270
276
  // ---------------------------------------------------------------------------
271
277
  // Layer matching
@@ -291,12 +297,17 @@ function buildRouteLayer(method, path, middleware, stripPath) {
291
297
  * `false` otherwise.
292
298
  */
293
299
  function matchRouteLayer(layer, req, path) {
294
- 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'))
295
306
  return false;
296
307
  const m = layer.regex.exec(path);
297
308
  if (m === null)
298
309
  return false;
299
- const captured = m.groups ?? {};
310
+ const captured = (m.groups) ?? {};
300
311
  // Only rewrite req.path for prefix-style (use) registrations.
301
312
  // Exact-method routes leave req.path intact so chained middlewares
302
313
  // sharing the same path each see the full, unmodified path.
@@ -307,328 +318,6 @@ function matchRouteLayer(layer, req, path) {
307
318
  Object.assign(req.params, captured);
308
319
  return true;
309
320
  }
310
- // ---------------------------------------------------------------------------
311
- // Cookie helpers
312
- // ---------------------------------------------------------------------------
313
- /**
314
- * Decode a raw cookie value, stripping the `j:` prefix and JSON-parsing the
315
- * payload when present. Plain string values are returned unchanged.
316
- *
317
- * @param raw - The raw cookie value as it appears after the `=` in the header.
318
- * @returns The decoded value: a JS value for `j:` cookies, or the raw string.
319
- */
320
- function decodeJsonCookie(raw) {
321
- if (!raw.startsWith('j:'))
322
- return raw;
323
- try {
324
- return JSON.parse(raw.slice(2));
325
- }
326
- catch {
327
- return raw; // malformed JSON — fall back to raw string
328
- }
329
- }
330
- /**
331
- * Sign a cookie value with HMAC-SHA256.
332
- *
333
- * The produced string follows the `cookie-signature` wire format:
334
- * `s:<value>.<base64url-HMAC>`, where `<value>` is the raw (possibly
335
- * `j:`-prefixed) string and the HMAC is computed over that raw string.
336
- *
337
- * @param value - The raw cookie value to sign (may include a `j:` prefix).
338
- * @param secret - The HMAC secret.
339
- * @returns The signed cookie string with the `s:` prefix.
340
- */
341
- function signCookieValue(value, secret) {
342
- const sig = crypto
343
- .createHmac('sha256', secret)
344
- .update(value)
345
- .digest('base64url');
346
- return `s:${value}.${sig}`;
347
- }
348
- /**
349
- * Verify and decode a signed cookie value.
350
- *
351
- * The input must start with `s:` and follow the format produced by
352
- * {@link signCookieValue}: `s:<value>.<base64url-HMAC>`.
353
- *
354
- * Uses `crypto.timingSafeEqual` to prevent timing-based signature attacks.
355
- *
356
- * @param signed - The raw `Set-Cookie` value including the `s:` prefix.
357
- * @param secret - The HMAC secret to verify against.
358
- * @returns The inner value string on success, or `false` when the signature
359
- * is absent or does not match (indicating a tampered cookie).
360
- */
361
- function verifyCookieValue(signed, secret) {
362
- if (!signed.startsWith('s:'))
363
- return false;
364
- const withoutPrefix = signed.slice(2);
365
- const lastDot = withoutPrefix.lastIndexOf('.');
366
- if (lastDot === -1)
367
- return false;
368
- const value = withoutPrefix.slice(0, lastDot);
369
- const received = withoutPrefix.slice(lastDot + 1);
370
- const expected = crypto
371
- .createHmac('sha256', secret)
372
- .update(value)
373
- .digest('base64url');
374
- const receivedBuf = Buffer.from(received, 'base64url');
375
- const expectedBuf = Buffer.from(expected, 'base64url');
376
- if (receivedBuf.length !== expectedBuf.length)
377
- return false;
378
- if (!crypto.timingSafeEqual(receivedBuf, expectedBuf))
379
- return false;
380
- return value;
381
- }
382
- // ---------------------------------------------------------------------------
383
- // HTTP object augmentation
384
- // ---------------------------------------------------------------------------
385
- /**
386
- * Augment a raw `http.IncomingMessage` / `http.ServerResponse` pair with the
387
- * additional fields and helpers expected by router middleware.
388
- *
389
- * This function is idempotent — it exits immediately when `req.queries` is
390
- * already defined, so it is safe to call multiple times on the same pair.
391
- *
392
- * **Fields added to `req`:**
393
- * - `originalUrl` — the unmodified URL string.
394
- * - `path` — the pathname portion of the URL.
395
- * - `params` — merged map initialised from URL query parameters.
396
- * - `queries` — structured query buckets (`url`, `route`).
397
- * - `cookies` — parsed `Cookie` header values.
398
- *
399
- * **Helpers added to `res`:**
400
- * - `send(data?)` — write `data` and end the response.
401
- * - `json(data)` — serialise to JSON and end.
402
- * - `status(code, headers?)` — set the status code and optional headers.
403
- * - `redirect(url)` — issue a 302 redirect.
404
- * - `cookie(name, val, opts)` — append a `Set-Cookie` header.
405
- *
406
- * @param req - The raw incoming message to augment.
407
- * @param res - The raw server response to augment.
408
- * @param secret - Optional cookie-signing secret.
409
- * @param trustProxy - When `true`, resolve `req.ip` from `X-Forwarded-For`.
410
- */
411
- function updateHttpObjects(req, res, secret, trustProxy) {
412
- const rReq = req;
413
- const rRes = res;
414
- if (rReq.queries)
415
- return; // Already augmented.
416
- rReq.queries = {};
417
- // Resolve the client IP address.
418
- // When trustProxy is true the leftmost value in X-Forwarded-For is used
419
- // (the originating client behind the proxy chain). Otherwise we read the
420
- // raw TCP remote address directly from the socket, which cannot be spoofed.
421
- if (trustProxy) {
422
- const xff = req.headers['x-forwarded-for'];
423
- const first = Array.isArray(xff) ? xff[0] : xff;
424
- rReq.ip = (first ? first.split(',')[0].trim() : req.socket?.remoteAddress) ?? '';
425
- }
426
- else {
427
- rReq.ip = req.socket?.remoteAddress ?? '';
428
- }
429
- const qry = new URL(`http://${req.headers.host}${req.url}`);
430
- rReq.originalUrl = req.url;
431
- rReq.path = qry.pathname;
432
- // Parse URL query parameters.
433
- // FEAT-03: repeated keys (e.g. ?tag=a&tag=b) accumulate into arrays.
434
- const urlParams = {};
435
- for (const [key, value] of qry.searchParams.entries()) {
436
- const existing = urlParams[key];
437
- if (existing === undefined) {
438
- urlParams[key] = value;
439
- }
440
- else if (Array.isArray(existing)) {
441
- existing.push(value);
442
- }
443
- else {
444
- urlParams[key] = [existing, value];
445
- }
446
- }
447
- rReq.queries.url = urlParams;
448
- // params stays StringMap — use first value for repeated keys.
449
- const flatParams = {};
450
- for (const [key, value] of Object.entries(urlParams)) {
451
- flatParams[key] = Array.isArray(value) ? value[0] : value;
452
- }
453
- rReq.params = flatParams;
454
- // Parse cookies.
455
- if (rReq.cookies == null) {
456
- rReq.cookies = {};
457
- if (req.headers.cookie) {
458
- for (const part of req.headers.cookie.split(';')) {
459
- const eqIdx = part.indexOf('=');
460
- if (eqIdx === -1)
461
- continue;
462
- const name = part.slice(0, eqIdx).trim();
463
- const rawVal = part.slice(eqIdx + 1).trim();
464
- if (rawVal.startsWith('s:')) {
465
- // Signed cookie — verify the HMAC signature.
466
- if (secret) {
467
- const inner = verifyCookieValue(rawVal, secret);
468
- if (inner === false)
469
- continue; // tampered — silently omit
470
- rReq.cookies[name] = decodeJsonCookie(inner);
471
- }
472
- else {
473
- // No secret configured — include the raw value so the application
474
- // can at least inspect that a signed cookie was sent.
475
- rReq.cookies[name] = rawVal;
476
- }
477
- }
478
- else {
479
- // Plain or JSON-encoded cookie.
480
- rReq.cookies[name] = decodeJsonCookie(rawVal);
481
- }
482
- }
483
- }
484
- }
485
- const resolvedReqOpts = (opts) => ({
486
- limit: opts?.limit ?? '100kb',
487
- inflate: opts?.inflate ?? true,
488
- reviver: null,
489
- strict: opts?.strict ?? false,
490
- });
491
- rReq.json = (opts) => {
492
- // If a body-parsing middleware already consumed the stream, return the cached value.
493
- if ('body' in rReq)
494
- return Promise.resolve(rReq.body ?? null);
495
- return readReqBody(rReq, resolvedReqOpts(opts), 'application/json')
496
- .then(ret => {
497
- if (ret == null)
498
- return null;
499
- const charset = extractCharset(ret.mimetype);
500
- try {
501
- const parsed = JSON.parse(ret.content.toString(charset), opts?.reviver ?? undefined);
502
- rReq.body = parsed;
503
- return parsed;
504
- }
505
- catch (ex) {
506
- return Promise.reject({ status: 400, message: 'Bad Request: ' + ex.message });
507
- }
508
- });
509
- };
510
- rReq.text = (opts) => {
511
- // If a body-parsing middleware already consumed the stream, return the cached string.
512
- const cached = rReq.body;
513
- if (typeof cached === 'string')
514
- return Promise.resolve(cached);
515
- return readReqBody(rReq, resolvedReqOpts(opts), null)
516
- .then(ret => {
517
- if (ret == null)
518
- return null;
519
- const charset = extractCharset(ret.mimetype);
520
- return ret.content.toString(charset);
521
- });
522
- };
523
- rReq.formData = (opts) => {
524
- // If a body-parsing middleware already consumed the stream, return the cached parts.
525
- const cached = rReq.body;
526
- if (Array.isArray(cached))
527
- return Promise.resolve(cached);
528
- return readReqBody(rReq, resolvedReqOpts(opts), 'multipart/form-data')
529
- .then(ret => {
530
- if (ret == null)
531
- return null;
532
- try {
533
- const parts = parseMultipartBody(ret.mimetype, ret.content);
534
- rReq.body = parts;
535
- return parts;
536
- }
537
- catch (ex) {
538
- // console.error('Body Err', ex)
539
- return Promise.reject({ status: ex.status ?? 500, message: ex.message ?? String(ex) });
540
- }
541
- });
542
- };
543
- rRes.setHeader('X-Powered-By', 'Expediate');
544
- rRes.send = (data) => {
545
- if (data)
546
- res.write(data);
547
- res.end();
548
- };
549
- rRes.json = (data) => {
550
- res.setHeader('Content-Type', 'application/json');
551
- res.write(JSON.stringify(data));
552
- res.end();
553
- };
554
- rRes.status = (code, headers) => {
555
- res.statusCode = code;
556
- if (headers)
557
- for (const [k, v] of Object.entries(headers))
558
- res.setHeader(k, v);
559
- return rRes;
560
- };
561
- rRes.redirect = (url) => {
562
- res.setHeader('location', url);
563
- res.writeHead(302);
564
- res.write(`Found. Redirecting to ${url}`);
565
- res.end();
566
- };
567
- rRes.cookie = (name, value, options) => {
568
- const opts = options ?? {};
569
- // Serialise: objects get the j: prefix so the reader can JSON-decode them.
570
- let val = typeof value === 'object' ? 'j:' + JSON.stringify(value) : String(value);
571
- if (opts.signed) {
572
- if (!secret)
573
- throw new Error('Signed cookies require a secret — pass { secret } to createRouter()');
574
- val = signCookieValue(val, secret);
575
- }
576
- let txt = `${name}=${val}`;
577
- if (opts.maxAge != null) {
578
- const maxAgeMs = opts.maxAge;
579
- const maxAgeSec = Math.floor(maxAgeMs / 1000);
580
- opts.expires = new Date(Date.now() + maxAgeMs);
581
- txt += `; Max-Age=${maxAgeSec}`;
582
- }
583
- if (opts.expires)
584
- txt += `; Expires=${opts.expires.toUTCString()}`;
585
- txt += `; Path=${opts.path ?? '/'}`;
586
- if (opts.httpOnly)
587
- txt += '; HttpOnly';
588
- if (opts.secure)
589
- txt += '; Secure';
590
- if (opts.sameSite)
591
- txt += `; SameSite=${opts.sameSite}`;
592
- // Append rather than overwrite so multiple cookies can be set on the same
593
- // response. res.setHeader() would replace any previously set Set-Cookie
594
- // header; instead, accumulate into an array.
595
- const existing = res.getHeader('Set-Cookie');
596
- if (existing == null) {
597
- res.setHeader('Set-Cookie', txt);
598
- }
599
- else if (Array.isArray(existing)) {
600
- res.setHeader('Set-Cookie', [...existing, txt]);
601
- }
602
- else {
603
- res.setHeader('Set-Cookie', [existing, txt]);
604
- }
605
- return rRes;
606
- };
607
- rRes.download = (filepath, filename) => {
608
- const name = filename ?? path.basename(filepath);
609
- // Use double-quotes and escape any double-quote in the filename per RFC 6266.
610
- const safeName = name.replace(/"/g, '\\"');
611
- res.setHeader('Content-Disposition', `attachment; filename="${safeName}"`);
612
- // Guard: return 404 when the file does not exist (serveFile would send 500
613
- // for any stat error; we want the conventional 404 for downloads).
614
- fs.access(filepath, fs.constants.F_OK, (err) => {
615
- if (err) {
616
- if (!rRes.writableEnded)
617
- rRes.status(404).end('Not Found');
618
- return;
619
- }
620
- serveFile(filepath)(rReq, rRes, () => { });
621
- });
622
- };
623
- rRes.type = (mime) => {
624
- res.setHeader('Content-Type', mime);
625
- return rRes;
626
- };
627
- rRes.etag = (value, strong = false) => {
628
- res.setHeader('ETag', strong ? `"${value}"` : `W/"${value}"`);
629
- return rRes;
630
- };
631
- }
632
321
  /**
633
322
  * Test whether a layer's path pattern matches the given path string,
634
323
  * **ignoring the HTTP method**. Does NOT mutate `req`.
@@ -674,9 +363,9 @@ function registerRoute(routes, method, path, arg, stripPath) {
674
363
  else if (typeof arg === 'function') {
675
364
  routes.push(buildRouteLayer(method, path, arg, stripPath));
676
365
  }
677
- else if (arg && typeof arg.listener === 'function') {
366
+ else if (arg && typeof (arg).listener === 'function') {
678
367
  // Router instance — unwrap its listener.
679
- routes.push(buildRouteLayer(method, path, arg.listener, stripPath));
368
+ routes.push(buildRouteLayer(method, path, (arg).listener, stripPath));
680
369
  }
681
370
  else {
682
371
  throw new TypeError('Unexpected value registered as middleware: expected a Middleware ' +
@@ -700,7 +389,7 @@ function registerRoute(routes, method, path, arg, stripPath) {
700
389
  function extractRouterPrefix(arg) {
701
390
  if (Array.isArray(arg) || typeof arg === 'function')
702
391
  return undefined;
703
- return arg.prefix;
392
+ return (arg).prefix;
704
393
  }
705
394
  // ---------------------------------------------------------------------------
706
395
  // Router factory
@@ -753,7 +442,11 @@ function extractRouterPrefix(arg) {
753
442
  *
754
443
  * app.use(v1);
755
444
  * app.onError((err, _req, res) => res.status(500).json({ error: String(err) }));
756
- * app.setNotFound((_req, res) => res.status(404).json({ error: 'Not Found' }));
445
+ *
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' }));
757
450
  *
758
451
  * app.listen(3000, () => console.log('Listening'));
759
452
  * process.on('SIGTERM', () => app.shutdown(10_000));
@@ -767,10 +460,10 @@ function createRouter(prefixOrOpts, opts) {
767
460
  const timeoutMs = options.timeout;
768
461
  const trustProxy = options.trustProxy ?? false;
769
462
  const routes = [];
770
- /** Currently registered error handler, or `undefined` for the default 500. */
463
+ /** Ordered error-handling middlewares registered via `router.error()`. */
464
+ const errorHandlers = [];
465
+ /** Terminal fallback registered via `router.onError()`, or `undefined`. */
771
466
  let errorHandler;
772
- /** Currently registered not-found handler, or `undefined` for the default 404. */
773
- let notFoundHandler;
774
467
  /** Server created by `router.listen()`, used by `router.shutdown()`. */
775
468
  let activeServer = null;
776
469
  /** All open sockets tracked for forced teardown on shutdown. */
@@ -782,7 +475,7 @@ function createRouter(prefixOrOpts, opts) {
782
475
  * Core dispatch function. Walks the route table in registration order and
783
476
  * invokes the first layer that matches the current request.
784
477
  *
785
- * - **404** (or custom `setNotFound` handler) — no layer's path matched.
478
+ * - **404** — no layer's path matched (register a catch-all last to customise).
786
479
  * - **405 Method Not Allowed** — a layer's path matched but no layer
787
480
  * accepted the HTTP method. The `Allow` header lists all registered methods.
788
481
  * - **500** (or custom `onError` handler) — a middleware threw or rejected,
@@ -814,23 +507,51 @@ function createRouter(prefixOrOpts, opts) {
814
507
  }
815
508
  // ── Centralised error dispatch ─────────────────────────────────────────
816
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.
817
516
  const invokeErrorHandler = (e) => {
818
517
  if (res.writableEnded)
819
518
  return;
820
- if (errorHandler) {
821
- try {
822
- errorHandler(e, req, res);
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;
823
534
  }
824
- catch (e2) {
825
- // console.error('Root Err', e2)
826
- if (!res.writableEnded)
827
- res.status(500).end(`Error ${method} ${url}`);
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;
828
545
  }
829
- }
830
- else {
831
- console.warn(e);
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.
832
552
  res.status(500).end(`Error ${method} ${url}`);
833
- }
553
+ };
554
+ runNext(e);
834
555
  };
835
556
  // ── Safe middleware invocation ─────────────────────────────────────────
836
557
  // Catches sync throws AND async rejections, routing both to invokeErrorHandler.
@@ -859,11 +580,20 @@ function createRouter(prefixOrOpts, opts) {
859
580
  const pathBefore = req.path;
860
581
  if (matchRouteLayer(layer, req, req.path)) {
861
582
  if (layer.stripPath) {
862
- // For prefix layers (use), restore req.path after the sub-router
863
- // calls done() so that subsequent sibling layers see the original path.
864
- invoke(layer.middleware, () => {
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) => {
865
594
  req.path = pathBefore;
866
- next();
595
+ req.baseUrl = baseUrlBefore;
596
+ next(err);
867
597
  });
868
598
  return;
869
599
  }
@@ -877,23 +607,28 @@ function createRouter(prefixOrOpts, opts) {
877
607
  }
878
608
  // All layers exhausted without a full match.
879
609
  if (allowedMethods.size > 0) {
880
- // Path is registered, but not for this method.
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');
881
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.
882
624
  res.status(405, { Allow: allow }).end(`Cannot ${method} ${url}`);
883
625
  return;
884
626
  }
885
- // Genuine 404 — delegate to parent router, not-found handler, or default.
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.
886
630
  if (done)
887
631
  return done();
888
- if (notFoundHandler) {
889
- try {
890
- notFoundHandler(req, res, () => { });
891
- }
892
- catch (e) {
893
- invokeErrorHandler(e);
894
- }
895
- return;
896
- }
897
632
  res.status(404).end(`Cannot ${method} ${url}`);
898
633
  };
899
634
  try {
@@ -954,13 +689,31 @@ function createRouter(prefixOrOpts, opts) {
954
689
  post: makeRegister('POST', false),
955
690
  delete: makeRegister('DELETE', false),
956
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
+ },
957
710
  // ── onError ─────────────────────────────────────────────────────────────
958
711
  onError(handler) {
959
712
  errorHandler = handler;
960
713
  },
961
- // ── setNotFound ──────────────────────────────────────────────────────────
962
- setNotFound(handler) {
963
- notFoundHandler = handler;
714
+ // ── error ────────────────────────────────────────────────────────────────
715
+ error(handler) {
716
+ errorHandlers.push(handler);
964
717
  },
965
718
  // ── routes ───────────────────────────────────────────────────────────────
966
719
  routes() {