expediate 1.0.4 → 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 +112 -5
  36. package/dist/misc.d.ts.map +1 -1
  37. package/dist/misc.js +235 -102
  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 +405 -46
  44. package/dist/router.d.ts.map +1 -1
  45. package/dist/router.js +658 -153
  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 +88 -84
  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,43 +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"));
58
- const misc_1 = require("./misc");
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';
59
30
  // ---------------------------------------------------------------------------
60
31
  // Pattern compilation
61
32
  // ---------------------------------------------------------------------------
@@ -69,7 +40,43 @@ const misc_1 = require("./misc");
69
40
  * @returns `true` if the pattern should be treated as a glob.
70
41
  */
71
42
  function isGlobPattern(pattern) {
72
- 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;
73
80
  }
74
81
  /**
75
82
  * Compile a `.gitignore`-style glob string into a prefix-anchored `RegExp`.
@@ -104,35 +111,114 @@ function compileGlob(glob) {
104
111
  .replace(/\x00GLOBSTAR\x00/g, '.*'); // cross-segment wildcard
105
112
  return new RegExp('^' + src);
106
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
+ }
107
151
  /**
108
152
  * Compile a plain path string with optional `:name` parameter segments into a
109
153
  * prefix-anchored `RegExp` that uses named capture groups.
110
154
  *
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.
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
+ * ```
114
168
  *
115
- * The expression matches up to a segment boundary (`/` or end-of-string) so
116
- * 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`.
117
172
  *
118
- * @param path - A plain path string such as `'/users/:id/posts'`.
173
+ * @param path - A plain path string such as `'/users/:id(\d+)/posts'`.
119
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`.
120
178
  *
121
179
  * @example
122
180
  * ```ts
123
- * const re = compilePlainPath('/users/:id');
124
- * 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' }
125
185
  * ```
126
186
  */
127
187
  function compilePlainPath(path) {
128
188
  const segments = path.split('/').filter((s) => s.length > 0);
129
189
  const src = segments
130
- .map((seg) => seg.startsWith(':')
131
- ? `(?<${seg.slice(1)}>[^/]+)` // named parameter segment
132
- : 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
+ })
133
214
  .join('/');
134
- // Allow a trailing slash or an additional path segment after the prefix.
135
- 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
+ }
136
222
  }
137
223
  /**
138
224
  * Convert any supported path pattern into the single canonical `RegExp`
@@ -141,7 +227,7 @@ function compilePlainPath(path) {
141
227
  * | Input type | Strategy |
142
228
  * |--------------|-------------------------------------------------------|
143
229
  * | Glob string | {@link compileGlob} — `.gitignore`-style wildcards |
144
- * | Plain string | {@link compilePlainPath} — `:name` → named groups |
230
+ * | Plain string | {@link compilePlainPath} — `:name` or `:name(re)` → named groups |
145
231
  * | `RegExp` | Used as-is; named groups are surfaced as params |
146
232
  *
147
233
  * @param path - The raw path pattern supplied by the caller.
@@ -222,6 +308,78 @@ function matchRouteLayer(layer, req, path) {
222
308
  return true;
223
309
  }
224
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
+ // ---------------------------------------------------------------------------
225
383
  // HTTP object augmentation
226
384
  // ---------------------------------------------------------------------------
227
385
  /**
@@ -240,57 +398,146 @@ function matchRouteLayer(layer, req, path) {
240
398
  *
241
399
  * **Helpers added to `res`:**
242
400
  * - `send(data?)` — write `data` and end the response.
401
+ * - `json(data)` — serialise to JSON and end.
243
402
  * - `status(code, headers?)` — set the status code and optional headers.
244
403
  * - `redirect(url)` — issue a 302 redirect.
245
404
  * - `cookie(name, val, opts)` — append a `Set-Cookie` header.
246
405
  *
247
- * @param req - The raw incoming message to augment.
248
- * @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`.
249
410
  */
250
- function updateHttpObjects(req, res) {
411
+ function updateHttpObjects(req, res, secret, trustProxy) {
251
412
  const rReq = req;
252
413
  const rRes = res;
253
414
  if (rReq.queries)
254
415
  return; // Already augmented.
255
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
+ }
256
429
  const qry = new URL(`http://${req.headers.host}${req.url}`);
257
430
  rReq.originalUrl = req.url;
258
431
  rReq.path = qry.pathname;
259
432
  // Parse URL query parameters.
433
+ // FEAT-03: repeated keys (e.g. ?tag=a&tag=b) accumulate into arrays.
260
434
  const urlParams = {};
261
- for (const [key, value] of qry.searchParams.entries())
262
- 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
+ }
263
447
  rReq.queries.url = urlParams;
264
- 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;
265
454
  // Parse cookies.
266
455
  if (rReq.cookies == null) {
267
456
  rReq.cookies = {};
268
457
  if (req.headers.cookie) {
269
- for (const raw of req.headers.cookie.split(';')) {
270
- const eqIdx = raw.indexOf('=');
458
+ for (const part of req.headers.cookie.split(';')) {
459
+ const eqIdx = part.indexOf('=');
271
460
  if (eqIdx === -1)
272
461
  continue;
273
- rReq.cookies[raw.slice(0, eqIdx).trim()] = raw.slice(eqIdx + 1).trim();
274
- // 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
+ }
275
482
  }
276
483
  }
277
484
  }
485
+ const resolvedReqOpts = (opts) => ({
486
+ limit: opts?.limit ?? '100kb',
487
+ inflate: opts?.inflate ?? true,
488
+ reviver: null,
489
+ strict: opts?.strict ?? false,
490
+ });
278
491
  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));
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
+ }
294
541
  });
295
542
  };
296
543
  rRes.setHeader('X-Powered-By', 'Expediate');
@@ -300,6 +547,7 @@ function updateHttpObjects(req, res) {
300
547
  res.end();
301
548
  };
302
549
  rRes.json = (data) => {
550
+ res.setHeader('Content-Type', 'application/json');
303
551
  res.write(JSON.stringify(data));
304
552
  res.end();
305
553
  };
@@ -318,26 +566,88 @@ function updateHttpObjects(req, res) {
318
566
  };
319
567
  rRes.cookie = (name, value, options) => {
320
568
  const opts = options ?? {};
321
- if (opts.signed && !req.secret)
322
- throw new Error('cookieParser("secret") required for signed cookies');
569
+ // Serialise: objects get the j: prefix so the reader can JSON-decode them.
323
570
  let val = typeof value === 'object' ? 'j:' + JSON.stringify(value) : String(value);
324
- if (opts.signed)
325
- 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
+ }
326
576
  let txt = `${name}=${val}`;
327
577
  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}`;
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}`;
331
582
  }
332
583
  if (opts.expires)
333
584
  txt += `; Expires=${opts.expires.toUTCString()}`;
334
585
  txt += `; Path=${opts.path ?? '/'}`;
335
- 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}"`);
336
629
  return rRes;
337
630
  };
338
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
+ }
339
649
  // ---------------------------------------------------------------------------
340
- // Route registration
650
+ // Route registration helpers
341
651
  // ---------------------------------------------------------------------------
342
652
  /**
343
653
  * Recursively resolve a `MiddlewareArg` value down to individual `Middleware`
@@ -352,11 +662,7 @@ function updateHttpObjects(req, res) {
352
662
  * @param method - HTTP method string or `null` for method-agnostic layers.
353
663
  * @param path - URL pattern (plain string, glob, or `RegExp`).
354
664
  * @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.
665
+ * @param stripPath - Forwarded to `buildRouteLayer`.
360
666
  * @throws {TypeError} When `arg` contains a value that cannot be resolved to
361
667
  * a `Middleware` function.
362
668
  */
@@ -377,6 +683,25 @@ function registerRoute(routes, method, path, arg, stripPath) {
377
683
  'function, a Router instance, or an array of either');
378
684
  }
379
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
+ }
380
705
  // ---------------------------------------------------------------------------
381
706
  // Router factory
382
707
  // ---------------------------------------------------------------------------
@@ -388,106 +713,204 @@ function registerRoute(routes, method, path, arg, stripPath) {
388
713
  * mounted inside another router:
389
714
  * ```ts
390
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)
391
728
  * ```
392
729
  *
393
730
  * **Path patterns** accepted by all route-registration methods:
394
731
  * - 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
732
  * - 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
733
  * - `RegExp` objects — used directly; named groups become route parameters.
401
734
  *
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.
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)`.
407
738
  *
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()`.
739
+ * **Graceful shutdown:**
740
+ * Call `router.shutdown()` to stop the server created by `router.listen()`.
414
741
  *
415
- * @returns A fully initialised `Router` ready to register routes and
416
- * 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`.
417
746
  *
418
747
  * @example
419
748
  * ```ts
420
- * const auth = createRouter();
421
- * auth.post('/login', handleLogin);
422
- * auth.post('/logout', handleLogout);
749
+ * const app = createRouter({ secret: process.env.COOKIE_SECRET, timeout: 30_000 });
750
+ *
751
+ * const v1 = createRouter('/api/v1');
752
+ * v1.get('/users', handler);
423
753
  *
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'));
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' }));
429
757
  *
430
- * app.listen(3000, () => console.log('Listening on :3000'));
758
+ * app.listen(3000, () => console.log('Listening'));
759
+ * process.on('SIGTERM', () => app.shutdown(10_000));
431
760
  * ```
432
761
  */
433
- 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;
434
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
+ // ──────────────────────────────────────────────────────────────────────────
435
781
  /**
436
782
  * 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.
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.
439
790
  */
440
791
  const listener = (req, res, done) => {
441
792
  const method = req.method;
442
793
  const url = req.url;
443
794
  let idx = 0;
444
- updateHttpObjects(req, res);
445
- 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
+ }
446
857
  while (idx < routes.length) {
447
858
  const layer = routes[idx++];
448
859
  const pathBefore = req.path;
449
860
  if (matchRouteLayer(layer, req, req.path)) {
450
861
  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
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;
458
866
  next();
459
867
  });
868
+ return;
460
869
  }
461
- return layer.middleware(req, res, next);
870
+ invoke(layer.middleware, next);
871
+ return;
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);
462
876
  }
463
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;
884
+ }
885
+ // Genuine 404 — delegate to parent router, not-found handler, or default.
464
886
  if (done)
465
887
  return done();
888
+ if (notFoundHandler) {
889
+ try {
890
+ notFoundHandler(req, res, () => { });
891
+ }
892
+ catch (e) {
893
+ invokeErrorHandler(e);
894
+ }
895
+ return;
896
+ }
466
897
  res.status(404).end(`Cannot ${method} ${url}`);
467
898
  };
468
899
  try {
469
900
  next();
470
901
  }
471
902
  catch (e) {
472
- console.warn(e);
473
- res.status(500).end(`Error ${method} ${url}`);
903
+ invokeErrorHandler(e);
474
904
  }
475
905
  };
476
- // -------------------------------------------------------------------------
477
- // Internal helper — produce the uniform registration function for a method.
478
- // -------------------------------------------------------------------------
906
+ // ──────────────────────────────────────────────────────────────────────────
907
+ // Registration helper for method-specific routes
908
+ // ──────────────────────────────────────────────────────────────────────────
479
909
  /**
480
910
  * Return the route-registration function used by all HTTP-method helpers.
481
911
  *
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
912
  * @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.
913
+ * @param stripPath - Whether the matched path prefix should be stripped.
491
914
  */
492
915
  function makeRegister(method, stripPath) {
493
916
  return (path, ...args) => {
@@ -495,31 +918,113 @@ function createRouter() {
495
918
  registerRoute(routes, method, path, arg, stripPath);
496
919
  };
497
920
  }
498
- // -------------------------------------------------------------------------
499
- // Public router API
500
- // -------------------------------------------------------------------------
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
+ // ──────────────────────────────────────────────────────────────────────────
501
947
  const router = {
948
+ prefix: routerPrefix,
502
949
  listener,
503
- use: makeRegister(null, true), // prefix — strip path
504
- all: makeRegister(null, false), // exact — keep path
950
+ use,
951
+ all: makeRegister(null, false),
505
952
  get: makeRegister('GET', false),
506
953
  put: makeRegister('PUT', false),
507
954
  post: makeRegister('POST', false),
508
955
  delete: makeRegister('DELETE', false),
509
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 ───────────────────────────────────────────────────────────────
510
997
  listen(port, opts, cb) {
511
998
  if (typeof opts === 'function') {
512
999
  cb = opts;
513
1000
  opts = undefined;
514
1001
  }
515
1002
  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);
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;
520
1025
  },
521
1026
  };
522
1027
  return router;
523
1028
  }
524
- exports.default = createRouter;
1029
+ export default createRouter;
525
1030
  //# sourceMappingURL=router.js.map