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.
- package/CHANGELOG.md +138 -0
- package/CONTRIBUTING.md +150 -0
- package/README.md +278 -779
- package/dist/apis.d.ts +372 -12
- package/dist/apis.d.ts.map +1 -1
- package/dist/apis.js +483 -65
- package/dist/apis.js.map +1 -1
- package/dist/cjs/index.js +2290 -807
- package/dist/git.d.ts +1 -1
- package/dist/git.d.ts.map +1 -1
- package/dist/git.js +5 -5
- package/dist/git.js.map +1 -1
- package/dist/http-objects.d.ts +26 -0
- package/dist/http-objects.d.ts.map +1 -0
- package/dist/http-objects.js +588 -0
- package/dist/http-objects.js.map +1 -0
- package/dist/index.d.ts +6 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/jwt-auth.d.ts +11 -0
- package/dist/jwt-auth.d.ts.map +1 -1
- package/dist/jwt-auth.js +9 -9
- package/dist/jwt-auth.js.map +1 -1
- package/dist/middleware.js +2 -2
- package/dist/middleware.js.map +1 -1
- package/dist/mimetypes.json +882 -1
- package/dist/misc.d.ts +161 -25
- package/dist/misc.d.ts.map +1 -1
- package/dist/misc.js +228 -80
- package/dist/misc.js.map +1 -1
- package/dist/openapi.d.ts +156 -13
- package/dist/openapi.d.ts.map +1 -1
- package/dist/openapi.js +214 -71
- package/dist/openapi.js.map +1 -1
- package/dist/router-types.d.ts +760 -0
- package/dist/router-types.d.ts.map +1 -0
- package/dist/router-types.js +23 -0
- package/dist/router-types.js.map +1 -0
- package/dist/router.d.ts +7 -530
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +128 -375
- package/dist/router.js.map +1 -1
- package/dist/static.d.ts +2 -2
- package/dist/static.d.ts.map +1 -1
- package/dist/static.js +77 -22
- package/dist/static.js.map +1 -1
- package/docs/THREAT_MODEL.md +52 -0
- package/docs/api-builder-v2-design.md +644 -0
- package/docs/api-builder-v3-design.md +397 -0
- package/docs/api-builder.md +454 -0
- package/docs/benchmark.md +27 -0
- package/docs/body-parsing.md +223 -0
- package/docs/errors.md +359 -0
- package/docs/expediate.png +0 -0
- package/docs/git.md +139 -0
- package/docs/jwt-auth.md +251 -0
- package/docs/logo.svg +12 -0
- package/docs/middleware.md +264 -0
- package/docs/openapi.md +180 -0
- package/docs/router.md +356 -0
- package/docs/static.md +128 -0
- package/docs/wiki.json +123 -0
- package/package.json +30 -8
- package/dist/cjs/apis.js +0 -327
- package/dist/cjs/git.js +0 -293
- package/dist/cjs/jwt-auth.js +0 -532
- package/dist/cjs/middleware.js +0 -511
- package/dist/cjs/mimetypes.json +0 -1
- package/dist/cjs/misc.js +0 -787
- package/dist/cjs/openapi.js +0 -485
- package/dist/cjs/router.js +0 -898
- 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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
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**
|
|
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
|
-
|
|
821
|
-
|
|
822
|
-
|
|
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
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
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
|
-
|
|
831
|
-
|
|
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
|
|
863
|
-
// calls done() so
|
|
864
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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,
|
|
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
|
-
// ──
|
|
962
|
-
|
|
963
|
-
|
|
714
|
+
// ── error ────────────────────────────────────────────────────────────────
|
|
715
|
+
error(handler) {
|
|
716
|
+
errorHandlers.push(handler);
|
|
964
717
|
},
|
|
965
718
|
// ── routes ───────────────────────────────────────────────────────────────
|
|
966
719
|
routes() {
|