expediate 1.0.3 → 1.0.5

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 (52) hide show
  1. package/LICENSE +16 -16
  2. package/README.md +417 -30
  3. package/dist/apis.d.ts +138 -21
  4. package/dist/apis.d.ts.map +1 -1
  5. package/dist/apis.js +172 -79
  6. package/dist/apis.js.map +1 -1
  7. package/dist/cjs/apis.js +327 -0
  8. package/dist/cjs/git.js +293 -0
  9. package/dist/cjs/index.js +2583 -0
  10. package/dist/cjs/jwt-auth.js +532 -0
  11. package/dist/cjs/middleware.js +511 -0
  12. package/dist/cjs/mimetypes.json +1 -0
  13. package/dist/cjs/misc.js +787 -0
  14. package/dist/cjs/openapi.js +485 -0
  15. package/dist/cjs/package.json +1 -0
  16. package/dist/cjs/router.js +898 -0
  17. package/dist/cjs/static.js +669 -0
  18. package/dist/git.d.ts +71 -8
  19. package/dist/git.d.ts.map +1 -1
  20. package/dist/git.js +127 -72
  21. package/dist/git.js.map +1 -1
  22. package/dist/index.d.ts +17 -13
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +14 -24
  25. package/dist/index.js.map +1 -1
  26. package/dist/jwt-auth.d.ts +147 -57
  27. package/dist/jwt-auth.d.ts.map +1 -1
  28. package/dist/jwt-auth.js +445 -205
  29. package/dist/jwt-auth.js.map +1 -1
  30. package/dist/middleware.d.ts +476 -0
  31. package/dist/middleware.d.ts.map +1 -0
  32. package/dist/middleware.js +647 -0
  33. package/dist/middleware.js.map +1 -0
  34. package/dist/mimetypes.json +1 -1
  35. package/dist/misc.d.ts +153 -12
  36. package/dist/misc.d.ts.map +1 -1
  37. package/dist/misc.js +325 -97
  38. package/dist/misc.js.map +1 -1
  39. package/dist/openapi.d.ts +290 -0
  40. package/dist/openapi.d.ts.map +1 -0
  41. package/dist/openapi.js +481 -0
  42. package/dist/openapi.js.map +1 -0
  43. package/dist/router.d.ts +407 -45
  44. package/dist/router.d.ts.map +1 -1
  45. package/dist/router.js +665 -137
  46. package/dist/router.js.map +1 -1
  47. package/dist/static.d.ts +1 -1
  48. package/dist/static.d.ts.map +1 -1
  49. package/dist/static.js +93 -86
  50. package/dist/static.js.map +1 -1
  51. package/package.json +21 -4
  52. package/.npmignore +0 -16
package/dist/router.js CHANGED
@@ -19,42 +19,14 @@
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"));
22
+ import * as crypto from 'crypto';
23
+ import * as fs from 'fs';
24
+ import * as http from 'http';
25
+ import * as https from 'https';
26
+ 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';
58
30
  // ---------------------------------------------------------------------------
59
31
  // Pattern compilation
60
32
  // ---------------------------------------------------------------------------
@@ -68,7 +40,43 @@ const https = __importStar(require("https"));
68
40
  * @returns `true` if the pattern should be treated as a glob.
69
41
  */
70
42
  function isGlobPattern(pattern) {
71
- return /(?<!\\)[*?]/.test(pattern);
43
+ // Walk character-by-character, skipping over :name(constraint) segments so
44
+ // that regex metacharacters (e.g. '?') inside inline constraints are not
45
+ // mistaken for glob wildcards.
46
+ let i = 0;
47
+ while (i < pattern.length) {
48
+ const ch = pattern[i];
49
+ if (ch === '\\') {
50
+ i += 2;
51
+ continue;
52
+ } // escaped — skip next char
53
+ if (ch === ':') {
54
+ i++;
55
+ while (i < pattern.length && /\w/.test(pattern[i]))
56
+ i++; // skip param name
57
+ if (i < pattern.length && pattern[i] === '(') {
58
+ // Skip balanced constraint parens so their '?' / '*' are not counted.
59
+ let depth = 1;
60
+ i++;
61
+ while (i < pattern.length && depth > 0) {
62
+ if (pattern[i] === '\\') {
63
+ i += 2;
64
+ continue;
65
+ }
66
+ if (pattern[i] === '(')
67
+ depth++;
68
+ else if (pattern[i] === ')')
69
+ depth--;
70
+ i++;
71
+ }
72
+ }
73
+ continue;
74
+ }
75
+ if (ch === '*' || ch === '?')
76
+ return true;
77
+ i++;
78
+ }
79
+ return false;
72
80
  }
73
81
  /**
74
82
  * Compile a `.gitignore`-style glob string into a prefix-anchored `RegExp`.
@@ -103,35 +111,114 @@ function compileGlob(glob) {
103
111
  .replace(/\x00GLOBSTAR\x00/g, '.*'); // cross-segment wildcard
104
112
  return new RegExp('^' + src);
105
113
  }
114
+ /**
115
+ /**
116
+ * Extract the content between a balanced pair of parentheses starting at
117
+ * `openIdx` in `str`, skipping backslash-escaped characters.
118
+ *
119
+ * Returns the inner pattern and the index of the closing `)` so callers can
120
+ * inspect any literal suffix that follows the constraint (e.g. `\.txt` in
121
+ * `:name([\w-]+)\.txt`).
122
+ *
123
+ * @param str - The full segment string, e.g. `':id(\\d+)'`.
124
+ * @param openIdx - Index of the opening `(`.
125
+ * @returns `{ pattern, closeIdx }` — the inner pattern string and the index
126
+ * of the matching `)`.
127
+ * @throws {SyntaxError} When parentheses are unbalanced.
128
+ */
129
+ function extractInlinePattern(str, openIdx) {
130
+ let depth = 0;
131
+ let i = openIdx;
132
+ for (; i < str.length; i++) {
133
+ if (str[i] === '\\') {
134
+ i++;
135
+ continue;
136
+ } // skip escape sequences
137
+ if (str[i] === '(') {
138
+ depth++;
139
+ continue;
140
+ }
141
+ if (str[i] === ')') {
142
+ depth--;
143
+ if (depth === 0)
144
+ break;
145
+ }
146
+ }
147
+ if (depth !== 0)
148
+ throw new SyntaxError(`Unbalanced parentheses in route segment '${str}'`);
149
+ return { pattern: str.slice(openIdx + 1, i), closeIdx: i };
150
+ }
106
151
  /**
107
152
  * Compile a plain path string with optional `:name` parameter segments into a
108
153
  * prefix-anchored `RegExp` that uses named capture groups.
109
154
  *
110
- * Each `:name` segment is converted to `(?<name>[^/]+)`, making named
111
- * captures available directly on the `RegExp` match result.
112
- * Literal segments are escaped and matched exactly.
155
+ * **Basic parameters** — Each `:name` segment is converted to
156
+ * `(?<name>[^/]+)`, matching any non-slash sequence.
157
+ *
158
+ * **Inline constraints** — A parameter may optionally be followed by a
159
+ * parenthesised regex pattern: `:name(pattern)`. The pattern replaces the
160
+ * default `[^/]+` body, so only paths where that segment matches the
161
+ * constraint will be routed to the handler.
162
+ *
163
+ * ```
164
+ * :id → (?<id>[^/]+) (any non-slash value)
165
+ * :id(\d+) → (?<id>\d+) (digits only)
166
+ * :slug([\w-]+) → (?<slug>[\w-]+) (word chars and hyphens)
167
+ * ```
113
168
  *
114
- * The expression matches up to a segment boundary (`/` or end-of-string) so
115
- * that `/users` never inadvertently matches `/users-admin`.
169
+ * Literal segments are regex-escaped and matched exactly. The expression
170
+ * matches up to a segment boundary (`/` or end-of-string) so that `/users`
171
+ * never inadvertently matches `/users-admin`.
116
172
  *
117
- * @param path - A plain path string such as `'/users/:id/posts'`.
173
+ * @param path - A plain path string such as `'/users/:id(\d+)/posts'`.
118
174
  * @returns A prefix-anchored `RegExp` with named groups for each parameter.
175
+ * @throws {SyntaxError} When an inline constraint is malformed, contains
176
+ * named capture groups (which conflict with the outer wrapper), or produces
177
+ * an invalid `RegExp`.
119
178
  *
120
179
  * @example
121
180
  * ```ts
122
- * const re = compilePlainPath('/users/:id');
123
- * re.exec('/users/42/comments')?.groups; // { id: '42' }
181
+ * const re = compilePlainPath('/users/:id(\\d+)');
182
+ * re.test('/users/42'); // true
183
+ * re.test('/users/abc'); // false
184
+ * re.exec('/users/7')?.groups; // { id: '7' }
124
185
  * ```
125
186
  */
126
187
  function compilePlainPath(path) {
127
188
  const segments = path.split('/').filter((s) => s.length > 0);
128
189
  const src = segments
129
- .map((seg) => seg.startsWith(':')
130
- ? `(?<${seg.slice(1)}>[^/]+)` // named parameter segment
131
- : seg.replace(/[.+^${}()|[\]\\]/g, '\\$&'))
190
+ .map((seg) => {
191
+ if (!seg.startsWith(':'))
192
+ return seg.replace(/[.+^${}()|[\]\\]/g, '\\$&'); // escaped literal
193
+ // Parameter segment: extract name and optional inline constraint.
194
+ const parenIdx = seg.indexOf('(', 1);
195
+ if (parenIdx === -1) {
196
+ // Plain :name — match any non-slash sequence.
197
+ return `(?<${seg.slice(1)}>[^/]+)`;
198
+ }
199
+ // :name(pattern) — optionally followed by a literal suffix, e.g. \.txt
200
+ const name = seg.slice(1, parenIdx);
201
+ if (!name)
202
+ throw new SyntaxError(`Route parameter missing name before '(' in segment '${seg}'`);
203
+ const { pattern, closeIdx } = extractInlinePattern(seg, parenIdx);
204
+ // Named capture groups inside the constraint conflict with the outer
205
+ // (?<name>…) wrapper and would cause duplicate-group errors.
206
+ if (/\(\?<[^>]+>/.test(pattern))
207
+ throw new SyntaxError(`Inline constraint for ':${name}' must not contain named capture groups.`);
208
+ // Any literal characters after the closing ')' are regex-escaped and
209
+ // appended — e.g. ':name([\\w-]+)\\.txt' → '(?<name>[\\w-]+)\\.txt'.
210
+ const suffix = seg.slice(closeIdx + 1);
211
+ const escapedSuffix = suffix.replace(/[.+^${}()|[\]\\]/g, '\\$&');
212
+ return `(?<${name}>${pattern})${escapedSuffix}`;
213
+ })
132
214
  .join('/');
133
- // Allow a trailing slash or an additional path segment after the prefix.
134
- return new RegExp('^/?' + src + '(?=/|$)');
215
+ // Validate and return surface any regex syntax errors as SyntaxError.
216
+ try {
217
+ return new RegExp('^/?' + src + '(?=/|$)');
218
+ }
219
+ catch (e) {
220
+ throw new SyntaxError(`Invalid inline regex constraint in path '${path}': ${e.message}`);
221
+ }
135
222
  }
136
223
  /**
137
224
  * Convert any supported path pattern into the single canonical `RegExp`
@@ -140,7 +227,7 @@ function compilePlainPath(path) {
140
227
  * | Input type | Strategy |
141
228
  * |--------------|-------------------------------------------------------|
142
229
  * | Glob string | {@link compileGlob} — `.gitignore`-style wildcards |
143
- * | Plain string | {@link compilePlainPath} — `:name` → named groups |
230
+ * | Plain string | {@link compilePlainPath} — `:name` or `:name(re)` → named groups |
144
231
  * | `RegExp` | Used as-is; named groups are surfaced as params |
145
232
  *
146
233
  * @param path - The raw path pattern supplied by the caller.
@@ -221,6 +308,78 @@ function matchRouteLayer(layer, req, path) {
221
308
  return true;
222
309
  }
223
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
+ // ---------------------------------------------------------------------------
224
383
  // HTTP object augmentation
225
384
  // ---------------------------------------------------------------------------
226
385
  /**
@@ -239,47 +398,159 @@ function matchRouteLayer(layer, req, path) {
239
398
  *
240
399
  * **Helpers added to `res`:**
241
400
  * - `send(data?)` — write `data` and end the response.
401
+ * - `json(data)` — serialise to JSON and end.
242
402
  * - `status(code, headers?)` — set the status code and optional headers.
243
403
  * - `redirect(url)` — issue a 302 redirect.
244
404
  * - `cookie(name, val, opts)` — append a `Set-Cookie` header.
245
405
  *
246
- * @param req - The raw incoming message to augment.
247
- * @param res - The raw server response to augment.
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`.
248
410
  */
249
- function updateHttpObjects(req, res) {
411
+ function updateHttpObjects(req, res, secret, trustProxy) {
250
412
  const rReq = req;
251
413
  const rRes = res;
252
414
  if (rReq.queries)
253
415
  return; // Already augmented.
254
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
+ }
255
429
  const qry = new URL(`http://${req.headers.host}${req.url}`);
256
430
  rReq.originalUrl = req.url;
257
431
  rReq.path = qry.pathname;
258
432
  // Parse URL query parameters.
433
+ // FEAT-03: repeated keys (e.g. ?tag=a&tag=b) accumulate into arrays.
259
434
  const urlParams = {};
260
- for (const [key, value] of qry.searchParams.entries())
261
- urlParams[key] = value;
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
+ }
262
447
  rReq.queries.url = urlParams;
263
- rReq.params = { ...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;
264
454
  // Parse cookies.
265
455
  if (rReq.cookies == null) {
266
456
  rReq.cookies = {};
267
457
  if (req.headers.cookie) {
268
- for (const raw of req.headers.cookie.split(';')) {
269
- const eqIdx = raw.indexOf('=');
458
+ for (const part of req.headers.cookie.split(';')) {
459
+ const eqIdx = part.indexOf('=');
270
460
  if (eqIdx === -1)
271
461
  continue;
272
- rReq.cookies[raw.slice(0, eqIdx).trim()] = raw.slice(eqIdx + 1).trim();
273
- // TODO: 's:' prefix signed cookie, 'j:' prefix → JSON cookie
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
+ }
274
482
  }
275
483
  }
276
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
+ };
277
543
  rRes.setHeader('X-Powered-By', 'Expediate');
278
544
  rRes.send = (data) => {
279
545
  if (data)
280
546
  res.write(data);
281
547
  res.end();
282
548
  };
549
+ rRes.json = (data) => {
550
+ res.setHeader('Content-Type', 'application/json');
551
+ res.write(JSON.stringify(data));
552
+ res.end();
553
+ };
283
554
  rRes.status = (code, headers) => {
284
555
  res.statusCode = code;
285
556
  if (headers)
@@ -295,26 +566,88 @@ function updateHttpObjects(req, res) {
295
566
  };
296
567
  rRes.cookie = (name, value, options) => {
297
568
  const opts = options ?? {};
298
- if (opts.signed && !req.secret)
299
- throw new Error('cookieParser("secret") required for signed cookies');
569
+ // Serialise: objects get the j: prefix so the reader can JSON-decode them.
300
570
  let val = typeof value === 'object' ? 'j:' + JSON.stringify(value) : String(value);
301
- if (opts.signed)
302
- val = 's:' + val; // sign() integration point
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
+ }
303
576
  let txt = `${name}=${val}`;
304
577
  if (opts.maxAge != null) {
305
- opts.expires = new Date(Date.now() + opts.maxAge);
306
- opts.maxAge = Math.floor(opts.maxAge / 1000);
307
- txt += `; Max-Age=${opts.maxAge}`;
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}`;
308
582
  }
309
583
  if (opts.expires)
310
584
  txt += `; Expires=${opts.expires.toUTCString()}`;
311
585
  txt += `; Path=${opts.path ?? '/'}`;
312
- res.setHeader('Set-Cookie', txt);
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}"`);
313
629
  return rRes;
314
630
  };
315
631
  }
632
+ /**
633
+ * Test whether a layer's path pattern matches the given path string,
634
+ * **ignoring the HTTP method**. Does NOT mutate `req`.
635
+ *
636
+ * Used exclusively for **405 Method Not Allowed** detection: when
637
+ * `matchRouteLayer` returns `false` due to a method mismatch, calling this
638
+ * function lets the dispatcher confirm that the path itself is registered
639
+ * (just under a different method) so it can respond with 405 and an
640
+ * `Allow` header instead of the generic 404.
641
+ *
642
+ * @param layer - The layer whose path pattern to test.
643
+ * @param path - The current value of `req.path`.
644
+ * @returns `true` when the path pattern matches, regardless of method.
645
+ */
646
+ function pathMatchesLayer(layer, path) {
647
+ return layer.regex.test(path);
648
+ }
316
649
  // ---------------------------------------------------------------------------
317
- // Route registration
650
+ // Route registration helpers
318
651
  // ---------------------------------------------------------------------------
319
652
  /**
320
653
  * Recursively resolve a `MiddlewareArg` value down to individual `Middleware`
@@ -329,11 +662,7 @@ function updateHttpObjects(req, res) {
329
662
  * @param method - HTTP method string or `null` for method-agnostic layers.
330
663
  * @param path - URL pattern (plain string, glob, or `RegExp`).
331
664
  * @param arg - The middleware value(s) to register.
332
- * @param stripPath - Forwarded to `buildRouteLayer`. Pass `true` for prefix-
333
- * style registrations (`use`) so the matched prefix
334
- * is stripped from `req.path`, and `false` for exact-method
335
- * routes so that chained middlewares sharing the same path
336
- * each see the unmodified path.
665
+ * @param stripPath - Forwarded to `buildRouteLayer`.
337
666
  * @throws {TypeError} When `arg` contains a value that cannot be resolved to
338
667
  * a `Middleware` function.
339
668
  */
@@ -354,6 +683,25 @@ function registerRoute(routes, method, path, arg, stripPath) {
354
683
  'function, a Router instance, or an array of either');
355
684
  }
356
685
  }
686
+ /**
687
+ * If `arg` is a `Router` instance, return its configured {@link Router.prefix};
688
+ * otherwise return `undefined`.
689
+ *
690
+ * Used by `router.use()` to infer the mount path when no explicit path is
691
+ * provided:
692
+ * ```ts
693
+ * const v1 = createRouter('/api/v1');
694
+ * app.use(v1); // prefix '/api/v1' is inferred automatically
695
+ * ```
696
+ *
697
+ * @param arg - The first argument passed to `router.use()`.
698
+ * @returns The router's prefix string, or `undefined`.
699
+ */
700
+ function extractRouterPrefix(arg) {
701
+ if (Array.isArray(arg) || typeof arg === 'function')
702
+ return undefined;
703
+ return arg.prefix;
704
+ }
357
705
  // ---------------------------------------------------------------------------
358
706
  // Router factory
359
707
  // ---------------------------------------------------------------------------
@@ -365,106 +713,204 @@ function registerRoute(routes, method, path, arg, stripPath) {
365
713
  * mounted inside another router:
366
714
  * ```ts
367
715
  * parent.use('/api', child);
716
+ * // or, if child has a prefix:
717
+ * parent.use(child);
718
+ * ```
719
+ *
720
+ * **Prefix shorthand:**
721
+ * Pass a path string as the first argument to associate a prefix with this
722
+ * router. The prefix is used automatically when the router is mounted via
723
+ * `parent.use(child)`:
724
+ * ```ts
725
+ * const v1 = createRouter('/api/v1');
726
+ * v1.get('/users', handler); // handler is reached at /api/v1/users
727
+ * app.use(v1); // same as app.use('/api/v1', v1)
368
728
  * ```
369
729
  *
370
730
  * **Path patterns** accepted by all route-registration methods:
371
731
  * - Plain strings with optional `:name` segments — e.g. `'/users/:id'`.
372
- * Each `:name` is compiled to a named capture group and exposed in
373
- * `req.params` on a match.
374
732
  * - Glob strings following `.gitignore` rules — e.g. `'/**\/*.php'`.
375
- * Supported wildcards: `?` (one non-slash char), `*` (any non-slash chars),
376
- * `**` (any chars, including slashes).
377
733
  * - `RegExp` objects — used directly; named groups become route parameters.
378
734
  *
379
- * **Middleware arguments** accept any number of variadic `MiddlewareArg`
380
- * values, each of which may be:
381
- * - A `Middleware` function.
382
- * - A `Router` instance (its `listener` is registered automatically).
383
- * - An array of either of the above.
735
+ * **Error handling:**
736
+ * Register a global error handler with `router.onError()`. It is called
737
+ * when any middleware throws, rejects, or calls `next(err)`.
384
738
  *
385
- * **Path stripping behaviour:**
386
- * - `use` strip the matched path prefix from `req.path` before
387
- * invoking middleware. Nested routers therefore only see the remaining suffix.
388
- * - `all` / `get` / `post` / `put` / `delete` / `patch` — leave `req.path` intact
389
- * so that multiple middlewares registered for the same exact path can each
390
- * match and be invoked in sequence via `next()`.
739
+ * **Graceful shutdown:**
740
+ * Call `router.shutdown()` to stop the server created by `router.listen()`.
391
741
  *
392
- * @returns A fully initialised `Router` ready to register routes and
393
- * optionally start an HTTP or HTTPS server.
742
+ * @param prefixOrOpts - Optional path prefix string (e.g. `'/api/v1'`) **or**
743
+ * an {@link RouterOptions} object.
744
+ * @param opts - Options when `prefixOrOpts` is a string.
745
+ * @returns A fully initialised `Router`.
394
746
  *
395
747
  * @example
396
748
  * ```ts
397
- * const auth = createRouter();
398
- * auth.post('/login', handleLogin);
399
- * auth.post('/logout', handleLogout);
749
+ * const app = createRouter({ secret: process.env.COOKIE_SECRET, timeout: 30_000 });
400
750
  *
401
- * const app = createRouter();
402
- * app.use('/auth', auth); // mount a sub-router
403
- * app.get('/users/:id', requireAuth, getUser); // multiple middleware
404
- * app.get('/**\/*.php', (req, res) => // glob pattern
405
- * res.status(403).send('Forbidden'));
751
+ * const v1 = createRouter('/api/v1');
752
+ * v1.get('/users', handler);
406
753
  *
407
- * app.listen(3000, () => console.log('Listening on :3000'));
754
+ * app.use(v1);
755
+ * app.onError((err, _req, res) => res.status(500).json({ error: String(err) }));
756
+ * app.setNotFound((_req, res) => res.status(404).json({ error: 'Not Found' }));
757
+ *
758
+ * app.listen(3000, () => console.log('Listening'));
759
+ * process.on('SIGTERM', () => app.shutdown(10_000));
408
760
  * ```
409
761
  */
410
- function createRouter() {
762
+ function createRouter(prefixOrOpts, opts) {
763
+ // Resolve overloaded first argument.
764
+ const routerPrefix = typeof prefixOrOpts === 'string' ? prefixOrOpts : undefined;
765
+ const options = typeof prefixOrOpts === 'object' ? prefixOrOpts : (opts ?? {});
766
+ const secret = options.secret;
767
+ const timeoutMs = options.timeout;
768
+ const trustProxy = options.trustProxy ?? false;
411
769
  const routes = [];
770
+ /** Currently registered error handler, or `undefined` for the default 500. */
771
+ let errorHandler;
772
+ /** Currently registered not-found handler, or `undefined` for the default 404. */
773
+ let notFoundHandler;
774
+ /** Server created by `router.listen()`, used by `router.shutdown()`. */
775
+ let activeServer = null;
776
+ /** All open sockets tracked for forced teardown on shutdown. */
777
+ const activeSockets = new Set();
778
+ // ──────────────────────────────────────────────────────────────────────────
779
+ // Core dispatch listener
780
+ // ──────────────────────────────────────────────────────────────────────────
412
781
  /**
413
782
  * Core dispatch function. Walks the route table in registration order and
414
- * invokes the first layer that matches the current request. Falls back to a
415
- * 404 response when no layer matches and no upstream `done` callback is set.
783
+ * invokes the first layer that matches the current request.
784
+ *
785
+ * - **404** (or custom `setNotFound` handler) — no layer's path matched.
786
+ * - **405 Method Not Allowed** — a layer's path matched but no layer
787
+ * accepted the HTTP method. The `Allow` header lists all registered methods.
788
+ * - **500** (or custom `onError` handler) — a middleware threw or rejected,
789
+ * or `next(err)` was called with a non-null error.
416
790
  */
417
791
  const listener = (req, res, done) => {
418
792
  const method = req.method;
419
793
  const url = req.url;
420
794
  let idx = 0;
421
- updateHttpObjects(req, res);
422
- const next = () => {
795
+ updateHttpObjects(req, res, secret, trustProxy);
796
+ // ── Optional per-request timeout ──────────────────────────────────────
797
+ // Uses both a socket-level idle timeout (as the transport boundary) and a
798
+ // wall-clock setTimeout guard. The wall-clock timer is the primary
799
+ // mechanism because it is unaffected by the OS socket buffer state;
800
+ // socket.setTimeout is set in addition so the idle signal propagates down
801
+ // to keep-alive connections that would otherwise hold the socket open.
802
+ if (timeoutMs) {
803
+ if (req.socket)
804
+ req.socket.setTimeout(timeoutMs);
805
+ const timer = setTimeout(() => {
806
+ if (!res.writableEnded)
807
+ res.status(408).end('Request Timeout');
808
+ }, timeoutMs);
809
+ res.once('finish', () => {
810
+ clearTimeout(timer);
811
+ if (req.socket)
812
+ req.socket.setTimeout(0);
813
+ });
814
+ }
815
+ // ── Centralised error dispatch ─────────────────────────────────────────
816
+ // Invoked for sync throws, async rejections, and next(err) calls.
817
+ const invokeErrorHandler = (e) => {
818
+ if (res.writableEnded)
819
+ return;
820
+ if (errorHandler) {
821
+ try {
822
+ errorHandler(e, req, res);
823
+ }
824
+ catch (e2) {
825
+ // console.error('Root Err', e2)
826
+ if (!res.writableEnded)
827
+ res.status(500).end(`Error ${method} ${url}`);
828
+ }
829
+ }
830
+ else {
831
+ console.warn(e);
832
+ res.status(500).end(`Error ${method} ${url}`);
833
+ }
834
+ };
835
+ // ── Safe middleware invocation ─────────────────────────────────────────
836
+ // Catches sync throws AND async rejections, routing both to invokeErrorHandler.
837
+ const invoke = (mw, nextFn) => {
838
+ try {
839
+ const ret = mw(req, res, nextFn);
840
+ if (ret instanceof Promise)
841
+ ret.catch(invokeErrorHandler);
842
+ }
843
+ catch (e) {
844
+ invokeErrorHandler(e);
845
+ }
846
+ };
847
+ // Accumulate methods from layers whose path matched but whose method
848
+ // did not, for a 405 response with an accurate Allow header.
849
+ const allowedMethods = new Set();
850
+ // ── Main dispatch loop ─────────────────────────────────────────────────
851
+ const next = (err) => {
852
+ // If an error is passed, skip remaining routes and call error handler.
853
+ if (err != null) {
854
+ invokeErrorHandler(err);
855
+ return;
856
+ }
423
857
  while (idx < routes.length) {
424
858
  const layer = routes[idx++];
425
859
  const pathBefore = req.path;
426
860
  if (matchRouteLayer(layer, req, req.path)) {
427
861
  if (layer.stripPath) {
428
- // For prefix layers (use), save the pre-strip path so we can
429
- // restore it if the sub-router calls done() and control returns here.
430
- // Without restoration, subsequent layers in this router would see
431
- // the truncated path and fail to match their own patterns.
432
- const strippedPath = req.path;
433
- return layer.middleware(req, res, () => {
434
- req.path = pathBefore; // restore for the next sibling layer
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, () => {
865
+ req.path = pathBefore;
435
866
  next();
436
867
  });
868
+ return;
437
869
  }
438
- return layer.middleware(req, res, next);
870
+ invoke(layer.middleware, next);
871
+ return;
439
872
  }
873
+ // Path matched but method did not → remember for 405 detection.
874
+ if (layer.method !== null && pathMatchesLayer(layer, pathBefore)) {
875
+ allowedMethods.add(layer.method);
876
+ }
877
+ }
878
+ // All layers exhausted without a full match.
879
+ if (allowedMethods.size > 0) {
880
+ // Path is registered, but not for this method.
881
+ const allow = [...allowedMethods].sort().join(', ');
882
+ res.status(405, { Allow: allow }).end(`Cannot ${method} ${url}`);
883
+ return;
440
884
  }
885
+ // Genuine 404 — delegate to parent router, not-found handler, or default.
441
886
  if (done)
442
887
  return done();
888
+ if (notFoundHandler) {
889
+ try {
890
+ notFoundHandler(req, res, () => { });
891
+ }
892
+ catch (e) {
893
+ invokeErrorHandler(e);
894
+ }
895
+ return;
896
+ }
443
897
  res.status(404).end(`Cannot ${method} ${url}`);
444
898
  };
445
899
  try {
446
900
  next();
447
901
  }
448
902
  catch (e) {
449
- console.warn(e);
450
- res.status(500).end(`Error ${method} ${url}`);
903
+ invokeErrorHandler(e);
451
904
  }
452
905
  };
453
- // -------------------------------------------------------------------------
454
- // Internal helper — produce the uniform registration function for a method.
455
- // -------------------------------------------------------------------------
906
+ // ──────────────────────────────────────────────────────────────────────────
907
+ // Registration helper for method-specific routes
908
+ // ──────────────────────────────────────────────────────────────────────────
456
909
  /**
457
910
  * Return the route-registration function used by all HTTP-method helpers.
458
911
  *
459
- * The produced function accepts a mandatory `path` followed by any number
460
- * of `MiddlewareArg` values (functions, `Router` instances, or arrays
461
- * thereof), and delegates each to `registerRoute`.
462
- *
463
912
  * @param method - HTTP method to restrict layers to, or `null` for any.
464
- * @param stripPath - Whether the matched path prefix should be stripped from
465
- * `req.path` before middleware is invoked. `true` for
466
- * prefix-style registrations, `false` for exact-method ones.
467
- * @returns A variadic route-registration function.
913
+ * @param stripPath - Whether the matched path prefix should be stripped.
468
914
  */
469
915
  function makeRegister(method, stripPath) {
470
916
  return (path, ...args) => {
@@ -472,31 +918,113 @@ function createRouter() {
472
918
  registerRoute(routes, method, path, arg, stripPath);
473
919
  };
474
920
  }
475
- // -------------------------------------------------------------------------
476
- // Public router API
477
- // -------------------------------------------------------------------------
921
+ // ──────────────────────────────────────────────────────────────────────────
922
+ // `use()` supports both path-first and no-path (Router-first) forms
923
+ // ──────────────────────────────────────────────────────────────────────────
924
+ /**
925
+ * Register prefix-style middleware. Accepts:
926
+ * 1. `use(path, ...middlewares)` — explicit path.
927
+ * 2. `use(routerOrMiddleware, ...more)` — infers path from Router.prefix or '/'.
928
+ */
929
+ const use = (pathOrFirst, ...args) => {
930
+ if (typeof pathOrFirst === 'string' || pathOrFirst instanceof RegExp) {
931
+ // Normal form: explicit path string or RegExp.
932
+ for (const arg of args)
933
+ registerRoute(routes, null, pathOrFirst, arg, true);
934
+ }
935
+ else {
936
+ // No explicit path: the first argument is itself a middleware / Router.
937
+ // Infer mount path from the Router's prefix, or default to '/'.
938
+ const inferredPath = extractRouterPrefix(pathOrFirst) ?? '/';
939
+ registerRoute(routes, null, inferredPath, pathOrFirst, true);
940
+ for (const arg of args)
941
+ registerRoute(routes, null, '/', arg, true);
942
+ }
943
+ };
944
+ // ──────────────────────────────────────────────────────────────────────────
945
+ // Public router object
946
+ // ──────────────────────────────────────────────────────────────────────────
478
947
  const router = {
948
+ prefix: routerPrefix,
479
949
  listener,
480
- use: makeRegister(null, true), // prefix — strip path
481
- all: makeRegister(null, false), // exact — keep path
950
+ use,
951
+ all: makeRegister(null, false),
482
952
  get: makeRegister('GET', false),
483
953
  put: makeRegister('PUT', false),
484
954
  post: makeRegister('POST', false),
485
955
  delete: makeRegister('DELETE', false),
486
956
  patch: makeRegister('PATCH', false),
957
+ // ── onError ─────────────────────────────────────────────────────────────
958
+ onError(handler) {
959
+ errorHandler = handler;
960
+ },
961
+ // ── setNotFound ──────────────────────────────────────────────────────────
962
+ setNotFound(handler) {
963
+ notFoundHandler = handler;
964
+ },
965
+ // ── routes ───────────────────────────────────────────────────────────────
966
+ routes() {
967
+ return routes.map((l) => ({
968
+ method: l.method,
969
+ path: l.path,
970
+ stripPath: l.stripPath,
971
+ }));
972
+ },
973
+ // ── shutdown ─────────────────────────────────────────────────────────────
974
+ shutdown(timeout = 5000) {
975
+ if (!activeServer)
976
+ return Promise.resolve();
977
+ return new Promise((resolve, reject) => {
978
+ // Stop accepting new connections. Resolves when all existing
979
+ // connections have been closed (or when forcibly destroyed below).
980
+ activeServer.close((err) => {
981
+ if (err)
982
+ reject(err);
983
+ else
984
+ resolve();
985
+ });
986
+ // Forcibly destroy any remaining idle sockets after the grace period.
987
+ if (timeout > 0) {
988
+ setTimeout(() => {
989
+ for (const socket of activeSockets)
990
+ socket.destroy();
991
+ activeSockets.clear();
992
+ }, timeout);
993
+ }
994
+ });
995
+ },
996
+ // ── listen ───────────────────────────────────────────────────────────────
487
997
  listen(port, opts, cb) {
488
998
  if (typeof opts === 'function') {
489
999
  cb = opts;
490
1000
  opts = undefined;
491
1001
  }
492
1002
  const rawListener = listener;
493
- if (opts && opts.key && opts.cert)
494
- https.createServer(opts, rawListener).listen(port, cb);
495
- else
496
- http.createServer(rawListener).listen(port, cb);
1003
+ let server;
1004
+ const tlsOpts = opts;
1005
+ if (tlsOpts?.key && tlsOpts?.cert) {
1006
+ if (tlsOpts.http2) {
1007
+ // HTTP/2 secure server — same TLS options, different factory.
1008
+ server = http2.createSecureServer(tlsOpts, rawListener);
1009
+ }
1010
+ else {
1011
+ server = https.createServer(tlsOpts, rawListener);
1012
+ }
1013
+ }
1014
+ else {
1015
+ server = http.createServer(rawListener);
1016
+ }
1017
+ // Track open sockets so shutdown() can forcibly destroy them.
1018
+ server.on('connection', (socket) => {
1019
+ activeSockets.add(socket);
1020
+ socket.once('close', () => activeSockets.delete(socket));
1021
+ });
1022
+ activeServer = server;
1023
+ server.listen(port, cb);
1024
+ return server;
497
1025
  },
498
1026
  };
499
1027
  return router;
500
1028
  }
501
- exports.default = createRouter;
1029
+ export default createRouter;
502
1030
  //# sourceMappingURL=router.js.map