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.
- package/LICENSE +16 -16
- package/README.md +417 -30
- package/dist/apis.d.ts +138 -21
- package/dist/apis.d.ts.map +1 -1
- package/dist/apis.js +172 -79
- package/dist/apis.js.map +1 -1
- package/dist/cjs/apis.js +327 -0
- package/dist/cjs/git.js +293 -0
- package/dist/cjs/index.js +2583 -0
- package/dist/cjs/jwt-auth.js +532 -0
- package/dist/cjs/middleware.js +511 -0
- package/dist/cjs/mimetypes.json +1 -0
- package/dist/cjs/misc.js +787 -0
- package/dist/cjs/openapi.js +485 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/router.js +898 -0
- package/dist/cjs/static.js +669 -0
- package/dist/git.d.ts +71 -8
- package/dist/git.d.ts.map +1 -1
- package/dist/git.js +127 -72
- package/dist/git.js.map +1 -1
- package/dist/index.d.ts +17 -13
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -24
- package/dist/index.js.map +1 -1
- package/dist/jwt-auth.d.ts +147 -57
- package/dist/jwt-auth.d.ts.map +1 -1
- package/dist/jwt-auth.js +445 -205
- package/dist/jwt-auth.js.map +1 -1
- package/dist/middleware.d.ts +476 -0
- package/dist/middleware.d.ts.map +1 -0
- package/dist/middleware.js +647 -0
- package/dist/middleware.js.map +1 -0
- package/dist/mimetypes.json +1 -1
- package/dist/misc.d.ts +153 -12
- package/dist/misc.d.ts.map +1 -1
- package/dist/misc.js +325 -97
- package/dist/misc.js.map +1 -1
- package/dist/openapi.d.ts +290 -0
- package/dist/openapi.d.ts.map +1 -0
- package/dist/openapi.js +481 -0
- package/dist/openapi.js.map +1 -0
- package/dist/router.d.ts +407 -45
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +665 -137
- package/dist/router.js.map +1 -1
- package/dist/static.d.ts +1 -1
- package/dist/static.d.ts.map +1 -1
- package/dist/static.js +93 -86
- package/dist/static.js.map +1 -1
- package/package.json +21 -4
- 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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
|
111
|
-
*
|
|
112
|
-
*
|
|
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
|
-
*
|
|
115
|
-
*
|
|
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.
|
|
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) =>
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
//
|
|
134
|
-
|
|
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
|
|
247
|
-
* @param res
|
|
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]
|
|
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
|
-
|
|
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
|
|
269
|
-
const eqIdx =
|
|
458
|
+
for (const part of req.headers.cookie.split(';')) {
|
|
459
|
+
const eqIdx = part.indexOf('=');
|
|
270
460
|
if (eqIdx === -1)
|
|
271
461
|
continue;
|
|
272
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
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`.
|
|
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
|
-
* **
|
|
380
|
-
*
|
|
381
|
-
*
|
|
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
|
-
* **
|
|
386
|
-
*
|
|
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
|
-
* @
|
|
393
|
-
*
|
|
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
|
|
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
|
|
402
|
-
*
|
|
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.
|
|
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.
|
|
415
|
-
*
|
|
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
|
-
|
|
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),
|
|
429
|
-
//
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
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
|
-
|
|
450
|
-
res.status(500).end(`Error ${method} ${url}`);
|
|
903
|
+
invokeErrorHandler(e);
|
|
451
904
|
}
|
|
452
905
|
};
|
|
453
|
-
//
|
|
454
|
-
//
|
|
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
|
|
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
|
-
//
|
|
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
|
|
481
|
-
all: makeRegister(null, false),
|
|
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
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
-
|
|
1029
|
+
export default createRouter;
|
|
502
1030
|
//# sourceMappingURL=router.js.map
|