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.
- 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 +112 -5
- package/dist/misc.d.ts.map +1 -1
- package/dist/misc.js +235 -102
- 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 +405 -46
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +658 -153
- 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 +88 -84
- package/dist/static.js.map +1 -1
- package/package.json +21 -4
- package/.npmignore +0 -16
|
@@ -0,0 +1,898 @@
|
|
|
1
|
+
/* Copyright 2021 Fabien Bavent
|
|
2
|
+
*
|
|
3
|
+
* Permission is hereby granted, free of charge, to any person obtaining a
|
|
4
|
+
* copy of this software and associated documentation files (the "Software"),
|
|
5
|
+
* to deal in the Software without restriction, including without limitation
|
|
6
|
+
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
|
7
|
+
* and/or sell copies of the Software, and to permit persons to whom the
|
|
8
|
+
* Software is furnished to do so, subject to the following conditions:
|
|
9
|
+
*
|
|
10
|
+
* The above copyright notice and this permission notice shall be included
|
|
11
|
+
* in all copies or substantial portions of the Software.
|
|
12
|
+
*
|
|
13
|
+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
|
14
|
+
* OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
|
|
16
|
+
* THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
18
|
+
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
|
19
|
+
* DEALINGS IN THE SOFTWARE.
|
|
20
|
+
*/
|
|
21
|
+
'use strict';
|
|
22
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
|
+
const crypto = require("crypto");
|
|
24
|
+
const fs = require("fs");
|
|
25
|
+
const http = require("http");
|
|
26
|
+
const https = require("https");
|
|
27
|
+
const http2 = require("http2");
|
|
28
|
+
const path = require("path");
|
|
29
|
+
const misc_js_1 = require("./misc.js");
|
|
30
|
+
const static_js_1 = require("./static.js");
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Pattern compilation
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
/**
|
|
35
|
+
* Determine whether a path string contains glob characters (`*` or `?`).
|
|
36
|
+
*
|
|
37
|
+
* A pattern is considered a glob when it contains at least one unescaped
|
|
38
|
+
* `*` or `?` character, following the same convention as `.gitignore`.
|
|
39
|
+
*
|
|
40
|
+
* @param pattern - The path string to inspect.
|
|
41
|
+
* @returns `true` if the pattern should be treated as a glob.
|
|
42
|
+
*/
|
|
43
|
+
function isGlobPattern(pattern) {
|
|
44
|
+
return /(?<!\\)[*?]/.test(pattern);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Compile a `.gitignore`-style glob string into a prefix-anchored `RegExp`.
|
|
48
|
+
*
|
|
49
|
+
* Supported wildcard syntax:
|
|
50
|
+
* - `?` — matches exactly one character that is not `/`.
|
|
51
|
+
* - `*` — matches zero or more characters that are not `/`.
|
|
52
|
+
* - `**` — matches zero or more path segments (any characters including `/`).
|
|
53
|
+
*
|
|
54
|
+
* The returned expression is anchored at the start (`^`) so it always matches
|
|
55
|
+
* a prefix of the current path; the matched portion is stripped by
|
|
56
|
+
* `matchRouteLayer` when `layer.stripPath` is `true`.
|
|
57
|
+
*
|
|
58
|
+
* @param glob - The glob pattern to compile.
|
|
59
|
+
* @returns A prefix-anchored `RegExp`.
|
|
60
|
+
*
|
|
61
|
+
* @example
|
|
62
|
+
* ```ts
|
|
63
|
+
* compileGlob('/**\/*.php').test('/admin/index.php'); // true
|
|
64
|
+
* compileGlob('/api/*') .test('/api/users'); // true
|
|
65
|
+
* compileGlob('/api/*') .test('/api/users/123'); // false
|
|
66
|
+
* ```
|
|
67
|
+
*/
|
|
68
|
+
function compileGlob(glob) {
|
|
69
|
+
// Escape all regex special characters, leaving our wildcard characters intact.
|
|
70
|
+
let src = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
71
|
+
// Replace in order: `**` must be handled before `*`.
|
|
72
|
+
src = src
|
|
73
|
+
.replace(/\*\*/g, '\x00GLOBSTAR\x00') // temporary placeholder
|
|
74
|
+
.replace(/\*/g, '[^/]*') // single-segment wildcard
|
|
75
|
+
.replace(/\?/g, '[^/]') // single-character wildcard
|
|
76
|
+
.replace(/\x00GLOBSTAR\x00/g, '.*'); // cross-segment wildcard
|
|
77
|
+
return new RegExp('^' + src);
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Compile a plain path string with optional `:name` parameter segments into a
|
|
81
|
+
* prefix-anchored `RegExp` that uses named capture groups.
|
|
82
|
+
*
|
|
83
|
+
* Each `:name` segment is converted to `(?<name>[^/]+)`, making named
|
|
84
|
+
* captures available directly on the `RegExp` match result.
|
|
85
|
+
* Literal segments are escaped and matched exactly.
|
|
86
|
+
*
|
|
87
|
+
* The expression matches up to a segment boundary (`/` or end-of-string) so
|
|
88
|
+
* that `/users` never inadvertently matches `/users-admin`.
|
|
89
|
+
*
|
|
90
|
+
* @param path - A plain path string such as `'/users/:id/posts'`.
|
|
91
|
+
* @returns A prefix-anchored `RegExp` with named groups for each parameter.
|
|
92
|
+
*
|
|
93
|
+
* @example
|
|
94
|
+
* ```ts
|
|
95
|
+
* const re = compilePlainPath('/users/:id');
|
|
96
|
+
* re.exec('/users/42/comments')?.groups; // { id: '42' }
|
|
97
|
+
* ```
|
|
98
|
+
*/
|
|
99
|
+
function compilePlainPath(path) {
|
|
100
|
+
const segments = path.split('/').filter((s) => s.length > 0);
|
|
101
|
+
const src = segments
|
|
102
|
+
.map((seg) => seg.startsWith(':')
|
|
103
|
+
? `(?<${seg.slice(1)}>[^/]+)` // named parameter segment
|
|
104
|
+
: seg.replace(/[.+^${}()|[\]\\]/g, '\\$&'))
|
|
105
|
+
.join('/');
|
|
106
|
+
// Allow a trailing slash or an additional path segment after the prefix.
|
|
107
|
+
return new RegExp('^/?' + src + '(?=/|$)');
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Convert any supported path pattern into the single canonical `RegExp`
|
|
111
|
+
* used by `matchRouteLayer`.
|
|
112
|
+
*
|
|
113
|
+
* | Input type | Strategy |
|
|
114
|
+
* |--------------|-------------------------------------------------------|
|
|
115
|
+
* | Glob string | {@link compileGlob} — `.gitignore`-style wildcards |
|
|
116
|
+
* | Plain string | {@link compilePlainPath} — `:name` → named groups |
|
|
117
|
+
* | `RegExp` | Used as-is; named groups are surfaced as params |
|
|
118
|
+
*
|
|
119
|
+
* @param path - The raw path pattern supplied by the caller.
|
|
120
|
+
* @returns A `RegExp` suitable for use in `matchRouteLayer`.
|
|
121
|
+
*/
|
|
122
|
+
function compilePattern(path) {
|
|
123
|
+
if (path instanceof RegExp)
|
|
124
|
+
return path;
|
|
125
|
+
if (isGlobPattern(path))
|
|
126
|
+
return compileGlob(path);
|
|
127
|
+
return compilePlainPath(path);
|
|
128
|
+
}
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
// Layer construction
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
/**
|
|
133
|
+
* Build a `Layer` that represents a single registered route entry.
|
|
134
|
+
*
|
|
135
|
+
* The `path` argument may be a plain string with `:name` parameters, a glob
|
|
136
|
+
* string, or a `RegExp`. In all three cases the pattern is pre-compiled into
|
|
137
|
+
* a single `RegExp` stored as `layer.regex`.
|
|
138
|
+
*
|
|
139
|
+
* @param method - HTTP method to restrict this layer to (uppercased), or
|
|
140
|
+
* `null` to match any method.
|
|
141
|
+
* @param path - URL path pattern (plain string, glob, or `RegExp`).
|
|
142
|
+
* @param middleware - The middleware function to invoke on a match.
|
|
143
|
+
* @param stripPath - When `true`, the matched prefix is stripped from
|
|
144
|
+
* `req.path` before the middleware runs. Pass `true` for
|
|
145
|
+
* prefix/`use` registrations and `false` for exact-method
|
|
146
|
+
* routes so that chained middlewares sharing the same path
|
|
147
|
+
* can each match in turn.
|
|
148
|
+
* @returns A fully initialised `Layer` ready to be pushed into the route table.
|
|
149
|
+
* @throws {TypeError} When `middleware` is not a callable function.
|
|
150
|
+
*/
|
|
151
|
+
function buildRouteLayer(method, path, middleware, stripPath) {
|
|
152
|
+
if (typeof middleware !== 'function')
|
|
153
|
+
throw new TypeError('Incorrect middleware type: expected a function');
|
|
154
|
+
return { method, path, regex: compilePattern(path), stripPath, middleware };
|
|
155
|
+
}
|
|
156
|
+
// ---------------------------------------------------------------------------
|
|
157
|
+
// Layer matching
|
|
158
|
+
// ---------------------------------------------------------------------------
|
|
159
|
+
/**
|
|
160
|
+
* Test whether an incoming request matches the given layer and, on success,
|
|
161
|
+
* mutate `req` to reflect the match.
|
|
162
|
+
*
|
|
163
|
+
* All pattern types (plain string, glob, `RegExp`) are handled identically
|
|
164
|
+
* through `layer.regex`. Named capture groups in the match result are merged
|
|
165
|
+
* into `req.params` and `req.queries.route`.
|
|
166
|
+
*
|
|
167
|
+
* Path stripping is conditional on `layer.stripPath`:
|
|
168
|
+
* - `true` (prefix routes) — the matched prefix is removed from `req.path`
|
|
169
|
+
* so nested routers only see the remaining suffix.
|
|
170
|
+
* - `false` (exact-method routes) — `req.path` is left unchanged so that
|
|
171
|
+
* subsequent chained middlewares sharing the same pattern can still match.
|
|
172
|
+
*
|
|
173
|
+
* @param layer - The layer to test.
|
|
174
|
+
* @param req - The incoming request (mutated in-place on a successful match).
|
|
175
|
+
* @param path - The current value of `req.path` to test against.
|
|
176
|
+
* @returns `true` if the layer matches and `req` has been updated;
|
|
177
|
+
* `false` otherwise.
|
|
178
|
+
*/
|
|
179
|
+
function matchRouteLayer(layer, req, path) {
|
|
180
|
+
if (layer.method && layer.method !== req.method)
|
|
181
|
+
return false;
|
|
182
|
+
const m = layer.regex.exec(path);
|
|
183
|
+
if (m === null)
|
|
184
|
+
return false;
|
|
185
|
+
const captured = m.groups ?? {};
|
|
186
|
+
// Only rewrite req.path for prefix-style (use) registrations.
|
|
187
|
+
// Exact-method routes leave req.path intact so chained middlewares
|
|
188
|
+
// sharing the same path each see the full, unmodified path.
|
|
189
|
+
if (layer.stripPath) {
|
|
190
|
+
req.path = path.slice(m[0].length) || '/';
|
|
191
|
+
}
|
|
192
|
+
req.queries.route = captured;
|
|
193
|
+
Object.assign(req.params, captured);
|
|
194
|
+
return true;
|
|
195
|
+
}
|
|
196
|
+
// ---------------------------------------------------------------------------
|
|
197
|
+
// Cookie helpers
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
/**
|
|
200
|
+
* Decode a raw cookie value, stripping the `j:` prefix and JSON-parsing the
|
|
201
|
+
* payload when present. Plain string values are returned unchanged.
|
|
202
|
+
*
|
|
203
|
+
* @param raw - The raw cookie value as it appears after the `=` in the header.
|
|
204
|
+
* @returns The decoded value: a JS value for `j:` cookies, or the raw string.
|
|
205
|
+
*/
|
|
206
|
+
function decodeJsonCookie(raw) {
|
|
207
|
+
if (!raw.startsWith('j:'))
|
|
208
|
+
return raw;
|
|
209
|
+
try {
|
|
210
|
+
return JSON.parse(raw.slice(2));
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
return raw; // malformed JSON — fall back to raw string
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* Sign a cookie value with HMAC-SHA256.
|
|
218
|
+
*
|
|
219
|
+
* The produced string follows the `cookie-signature` wire format:
|
|
220
|
+
* `s:<value>.<base64url-HMAC>`, where `<value>` is the raw (possibly
|
|
221
|
+
* `j:`-prefixed) string and the HMAC is computed over that raw string.
|
|
222
|
+
*
|
|
223
|
+
* @param value - The raw cookie value to sign (may include a `j:` prefix).
|
|
224
|
+
* @param secret - The HMAC secret.
|
|
225
|
+
* @returns The signed cookie string with the `s:` prefix.
|
|
226
|
+
*/
|
|
227
|
+
function signCookieValue(value, secret) {
|
|
228
|
+
const sig = crypto
|
|
229
|
+
.createHmac('sha256', secret)
|
|
230
|
+
.update(value)
|
|
231
|
+
.digest('base64url');
|
|
232
|
+
return `s:${value}.${sig}`;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Verify and decode a signed cookie value.
|
|
236
|
+
*
|
|
237
|
+
* The input must start with `s:` and follow the format produced by
|
|
238
|
+
* {@link signCookieValue}: `s:<value>.<base64url-HMAC>`.
|
|
239
|
+
*
|
|
240
|
+
* Uses `crypto.timingSafeEqual` to prevent timing-based signature attacks.
|
|
241
|
+
*
|
|
242
|
+
* @param signed - The raw `Set-Cookie` value including the `s:` prefix.
|
|
243
|
+
* @param secret - The HMAC secret to verify against.
|
|
244
|
+
* @returns The inner value string on success, or `false` when the signature
|
|
245
|
+
* is absent or does not match (indicating a tampered cookie).
|
|
246
|
+
*/
|
|
247
|
+
function verifyCookieValue(signed, secret) {
|
|
248
|
+
if (!signed.startsWith('s:'))
|
|
249
|
+
return false;
|
|
250
|
+
const withoutPrefix = signed.slice(2);
|
|
251
|
+
const lastDot = withoutPrefix.lastIndexOf('.');
|
|
252
|
+
if (lastDot === -1)
|
|
253
|
+
return false;
|
|
254
|
+
const value = withoutPrefix.slice(0, lastDot);
|
|
255
|
+
const received = withoutPrefix.slice(lastDot + 1);
|
|
256
|
+
const expected = crypto
|
|
257
|
+
.createHmac('sha256', secret)
|
|
258
|
+
.update(value)
|
|
259
|
+
.digest('base64url');
|
|
260
|
+
const receivedBuf = Buffer.from(received, 'base64url');
|
|
261
|
+
const expectedBuf = Buffer.from(expected, 'base64url');
|
|
262
|
+
if (receivedBuf.length !== expectedBuf.length)
|
|
263
|
+
return false;
|
|
264
|
+
if (!crypto.timingSafeEqual(receivedBuf, expectedBuf))
|
|
265
|
+
return false;
|
|
266
|
+
return value;
|
|
267
|
+
}
|
|
268
|
+
// ---------------------------------------------------------------------------
|
|
269
|
+
// HTTP object augmentation
|
|
270
|
+
// ---------------------------------------------------------------------------
|
|
271
|
+
/**
|
|
272
|
+
* Augment a raw `http.IncomingMessage` / `http.ServerResponse` pair with the
|
|
273
|
+
* additional fields and helpers expected by router middleware.
|
|
274
|
+
*
|
|
275
|
+
* This function is idempotent — it exits immediately when `req.queries` is
|
|
276
|
+
* already defined, so it is safe to call multiple times on the same pair.
|
|
277
|
+
*
|
|
278
|
+
* **Fields added to `req`:**
|
|
279
|
+
* - `originalUrl` — the unmodified URL string.
|
|
280
|
+
* - `path` — the pathname portion of the URL.
|
|
281
|
+
* - `params` — merged map initialised from URL query parameters.
|
|
282
|
+
* - `queries` — structured query buckets (`url`, `route`).
|
|
283
|
+
* - `cookies` — parsed `Cookie` header values.
|
|
284
|
+
*
|
|
285
|
+
* **Helpers added to `res`:**
|
|
286
|
+
* - `send(data?)` — write `data` and end the response.
|
|
287
|
+
* - `json(data)` — serialise to JSON and end.
|
|
288
|
+
* - `status(code, headers?)` — set the status code and optional headers.
|
|
289
|
+
* - `redirect(url)` — issue a 302 redirect.
|
|
290
|
+
* - `cookie(name, val, opts)` — append a `Set-Cookie` header.
|
|
291
|
+
*
|
|
292
|
+
* @param req - The raw incoming message to augment.
|
|
293
|
+
* @param res - The raw server response to augment.
|
|
294
|
+
* @param secret - Optional cookie-signing secret.
|
|
295
|
+
* @param trustProxy - When `true`, resolve `req.ip` from `X-Forwarded-For`.
|
|
296
|
+
*/
|
|
297
|
+
function updateHttpObjects(req, res, secret, trustProxy) {
|
|
298
|
+
const rReq = req;
|
|
299
|
+
const rRes = res;
|
|
300
|
+
if (rReq.queries)
|
|
301
|
+
return; // Already augmented.
|
|
302
|
+
rReq.queries = {};
|
|
303
|
+
// Resolve the client IP address.
|
|
304
|
+
// When trustProxy is true the leftmost value in X-Forwarded-For is used
|
|
305
|
+
// (the originating client behind the proxy chain). Otherwise we read the
|
|
306
|
+
// raw TCP remote address directly from the socket, which cannot be spoofed.
|
|
307
|
+
if (trustProxy) {
|
|
308
|
+
const xff = req.headers['x-forwarded-for'];
|
|
309
|
+
const first = Array.isArray(xff) ? xff[0] : xff;
|
|
310
|
+
rReq.ip = (first ? first.split(',')[0].trim() : req.socket?.remoteAddress) ?? '';
|
|
311
|
+
}
|
|
312
|
+
else {
|
|
313
|
+
rReq.ip = req.socket?.remoteAddress ?? '';
|
|
314
|
+
}
|
|
315
|
+
const qry = new URL(`http://${req.headers.host}${req.url}`);
|
|
316
|
+
rReq.originalUrl = req.url;
|
|
317
|
+
rReq.path = qry.pathname;
|
|
318
|
+
// Parse URL query parameters.
|
|
319
|
+
// FEAT-03: repeated keys (e.g. ?tag=a&tag=b) accumulate into arrays.
|
|
320
|
+
const urlParams = {};
|
|
321
|
+
for (const [key, value] of qry.searchParams.entries()) {
|
|
322
|
+
const existing = urlParams[key];
|
|
323
|
+
if (existing === undefined) {
|
|
324
|
+
urlParams[key] = value;
|
|
325
|
+
}
|
|
326
|
+
else if (Array.isArray(existing)) {
|
|
327
|
+
existing.push(value);
|
|
328
|
+
}
|
|
329
|
+
else {
|
|
330
|
+
urlParams[key] = [existing, value];
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
rReq.queries.url = urlParams;
|
|
334
|
+
// params stays StringMap — use first value for repeated keys.
|
|
335
|
+
const flatParams = {};
|
|
336
|
+
for (const [key, value] of Object.entries(urlParams)) {
|
|
337
|
+
flatParams[key] = Array.isArray(value) ? value[0] : value;
|
|
338
|
+
}
|
|
339
|
+
rReq.params = flatParams;
|
|
340
|
+
// Parse cookies.
|
|
341
|
+
if (rReq.cookies == null) {
|
|
342
|
+
rReq.cookies = {};
|
|
343
|
+
if (req.headers.cookie) {
|
|
344
|
+
for (const part of req.headers.cookie.split(';')) {
|
|
345
|
+
const eqIdx = part.indexOf('=');
|
|
346
|
+
if (eqIdx === -1)
|
|
347
|
+
continue;
|
|
348
|
+
const name = part.slice(0, eqIdx).trim();
|
|
349
|
+
const rawVal = part.slice(eqIdx + 1).trim();
|
|
350
|
+
if (rawVal.startsWith('s:')) {
|
|
351
|
+
// Signed cookie — verify the HMAC signature.
|
|
352
|
+
if (secret) {
|
|
353
|
+
const inner = verifyCookieValue(rawVal, secret);
|
|
354
|
+
if (inner === false)
|
|
355
|
+
continue; // tampered — silently omit
|
|
356
|
+
rReq.cookies[name] = decodeJsonCookie(inner);
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
// No secret configured — include the raw value so the application
|
|
360
|
+
// can at least inspect that a signed cookie was sent.
|
|
361
|
+
rReq.cookies[name] = rawVal;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
else {
|
|
365
|
+
// Plain or JSON-encoded cookie.
|
|
366
|
+
rReq.cookies[name] = decodeJsonCookie(rawVal);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
const resolvedReqOpts = (opts) => ({
|
|
372
|
+
limit: opts?.limit ?? '100kb',
|
|
373
|
+
inflate: opts?.inflate ?? true,
|
|
374
|
+
reviver: null,
|
|
375
|
+
strict: opts?.strict ?? false,
|
|
376
|
+
});
|
|
377
|
+
rReq.json = (opts) => {
|
|
378
|
+
return (0, misc_js_1.readReqBody)(rReq, resolvedReqOpts(opts), 'application/json')
|
|
379
|
+
.then(ret => {
|
|
380
|
+
if (ret == null)
|
|
381
|
+
return null;
|
|
382
|
+
const charset = (0, misc_js_1.extractCharset)(ret.mimetype);
|
|
383
|
+
try {
|
|
384
|
+
const parsed = JSON.parse(ret.content.toString(charset), opts?.reviver ?? undefined);
|
|
385
|
+
rReq.body = parsed;
|
|
386
|
+
return parsed;
|
|
387
|
+
}
|
|
388
|
+
catch (ex) {
|
|
389
|
+
return Promise.reject({ status: 400, message: 'Bad Request: ' + ex.message });
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
};
|
|
393
|
+
rReq.text = (opts) => {
|
|
394
|
+
return (0, misc_js_1.readReqBody)(rReq, resolvedReqOpts(opts), null)
|
|
395
|
+
.then(ret => {
|
|
396
|
+
if (ret == null)
|
|
397
|
+
return null;
|
|
398
|
+
const charset = (0, misc_js_1.extractCharset)(ret.mimetype);
|
|
399
|
+
return ret.content.toString(charset);
|
|
400
|
+
});
|
|
401
|
+
};
|
|
402
|
+
rReq.formData = (opts) => {
|
|
403
|
+
return (0, misc_js_1.readReqBody)(rReq, resolvedReqOpts(opts), 'multipart/form-data')
|
|
404
|
+
.then(ret => {
|
|
405
|
+
if (ret == null)
|
|
406
|
+
return null;
|
|
407
|
+
try {
|
|
408
|
+
const parts = (0, misc_js_1.parseMultipartBody)(ret.mimetype, ret.content);
|
|
409
|
+
rReq.body = parts;
|
|
410
|
+
return parts;
|
|
411
|
+
}
|
|
412
|
+
catch (ex) {
|
|
413
|
+
return Promise.reject({ status: ex.status ?? 500, message: ex.message ?? String(ex) });
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
};
|
|
417
|
+
rRes.setHeader('X-Powered-By', 'Expediate');
|
|
418
|
+
rRes.send = (data) => {
|
|
419
|
+
if (data)
|
|
420
|
+
res.write(data);
|
|
421
|
+
res.end();
|
|
422
|
+
};
|
|
423
|
+
rRes.json = (data) => {
|
|
424
|
+
res.setHeader('Content-Type', 'application/json');
|
|
425
|
+
res.write(JSON.stringify(data));
|
|
426
|
+
res.end();
|
|
427
|
+
};
|
|
428
|
+
rRes.status = (code, headers) => {
|
|
429
|
+
res.statusCode = code;
|
|
430
|
+
if (headers)
|
|
431
|
+
for (const [k, v] of Object.entries(headers))
|
|
432
|
+
res.setHeader(k, v);
|
|
433
|
+
return rRes;
|
|
434
|
+
};
|
|
435
|
+
rRes.redirect = (url) => {
|
|
436
|
+
res.setHeader('location', url);
|
|
437
|
+
res.writeHead(302);
|
|
438
|
+
res.write(`Found. Redirecting to ${url}`);
|
|
439
|
+
res.end();
|
|
440
|
+
};
|
|
441
|
+
rRes.cookie = (name, value, options) => {
|
|
442
|
+
const opts = options ?? {};
|
|
443
|
+
// Serialise: objects get the j: prefix so the reader can JSON-decode them.
|
|
444
|
+
let val = typeof value === 'object' ? 'j:' + JSON.stringify(value) : String(value);
|
|
445
|
+
if (opts.signed) {
|
|
446
|
+
if (!secret)
|
|
447
|
+
throw new Error('Signed cookies require a secret — pass { secret } to createRouter()');
|
|
448
|
+
val = signCookieValue(val, secret);
|
|
449
|
+
}
|
|
450
|
+
let txt = `${name}=${val}`;
|
|
451
|
+
if (opts.maxAge != null) {
|
|
452
|
+
const maxAgeMs = opts.maxAge;
|
|
453
|
+
const maxAgeSec = Math.floor(maxAgeMs / 1000);
|
|
454
|
+
opts.expires = new Date(Date.now() + maxAgeMs);
|
|
455
|
+
txt += `; Max-Age=${maxAgeSec}`;
|
|
456
|
+
}
|
|
457
|
+
if (opts.expires)
|
|
458
|
+
txt += `; Expires=${opts.expires.toUTCString()}`;
|
|
459
|
+
txt += `; Path=${opts.path ?? '/'}`;
|
|
460
|
+
if (opts.httpOnly)
|
|
461
|
+
txt += '; HttpOnly';
|
|
462
|
+
if (opts.secure)
|
|
463
|
+
txt += '; Secure';
|
|
464
|
+
if (opts.sameSite)
|
|
465
|
+
txt += `; SameSite=${opts.sameSite}`;
|
|
466
|
+
// Append rather than overwrite so multiple cookies can be set on the same
|
|
467
|
+
// response. res.setHeader() would replace any previously set Set-Cookie
|
|
468
|
+
// header; instead, accumulate into an array.
|
|
469
|
+
const existing = res.getHeader('Set-Cookie');
|
|
470
|
+
if (existing == null) {
|
|
471
|
+
res.setHeader('Set-Cookie', txt);
|
|
472
|
+
}
|
|
473
|
+
else if (Array.isArray(existing)) {
|
|
474
|
+
res.setHeader('Set-Cookie', [...existing, txt]);
|
|
475
|
+
}
|
|
476
|
+
else {
|
|
477
|
+
res.setHeader('Set-Cookie', [existing, txt]);
|
|
478
|
+
}
|
|
479
|
+
return rRes;
|
|
480
|
+
};
|
|
481
|
+
rRes.download = (filepath, filename) => {
|
|
482
|
+
const name = filename ?? path.basename(filepath);
|
|
483
|
+
// Use double-quotes and escape any double-quote in the filename per RFC 6266.
|
|
484
|
+
const safeName = name.replace(/"/g, '\\"');
|
|
485
|
+
res.setHeader('Content-Disposition', `attachment; filename="${safeName}"`);
|
|
486
|
+
// Guard: return 404 when the file does not exist (serveFile would send 500
|
|
487
|
+
// for any stat error; we want the conventional 404 for downloads).
|
|
488
|
+
fs.access(filepath, fs.constants.F_OK, (err) => {
|
|
489
|
+
if (err) {
|
|
490
|
+
if (!rRes.writableEnded)
|
|
491
|
+
rRes.status(404).end('Not Found');
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
(0, static_js_1.serveFile)(filepath)(rReq, rRes, () => { });
|
|
495
|
+
});
|
|
496
|
+
};
|
|
497
|
+
rRes.type = (mime) => {
|
|
498
|
+
res.setHeader('Content-Type', mime);
|
|
499
|
+
return rRes;
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Test whether a layer's path pattern matches the given path string,
|
|
504
|
+
* **ignoring the HTTP method**. Does NOT mutate `req`.
|
|
505
|
+
*
|
|
506
|
+
* Used exclusively for **405 Method Not Allowed** detection: when
|
|
507
|
+
* `matchRouteLayer` returns `false` due to a method mismatch, calling this
|
|
508
|
+
* function lets the dispatcher confirm that the path itself is registered
|
|
509
|
+
* (just under a different method) so it can respond with 405 and an
|
|
510
|
+
* `Allow` header instead of the generic 404.
|
|
511
|
+
*
|
|
512
|
+
* @param layer - The layer whose path pattern to test.
|
|
513
|
+
* @param path - The current value of `req.path`.
|
|
514
|
+
* @returns `true` when the path pattern matches, regardless of method.
|
|
515
|
+
*/
|
|
516
|
+
function pathMatchesLayer(layer, path) {
|
|
517
|
+
return layer.regex.test(path);
|
|
518
|
+
}
|
|
519
|
+
// ---------------------------------------------------------------------------
|
|
520
|
+
// Route registration helpers
|
|
521
|
+
// ---------------------------------------------------------------------------
|
|
522
|
+
/**
|
|
523
|
+
* Recursively resolve a `MiddlewareArg` value down to individual `Middleware`
|
|
524
|
+
* functions and push one `Layer` per function into the route table.
|
|
525
|
+
*
|
|
526
|
+
* Accepted input shapes (processed recursively):
|
|
527
|
+
* - A `Middleware` function → pushed directly as a layer.
|
|
528
|
+
* - A `Router` instance → its `listener` is unwrapped and pushed.
|
|
529
|
+
* - An array of either → each element is processed recursively.
|
|
530
|
+
*
|
|
531
|
+
* @param routes - The mutable route table to push into.
|
|
532
|
+
* @param method - HTTP method string or `null` for method-agnostic layers.
|
|
533
|
+
* @param path - URL pattern (plain string, glob, or `RegExp`).
|
|
534
|
+
* @param arg - The middleware value(s) to register.
|
|
535
|
+
* @param stripPath - Forwarded to `buildRouteLayer`.
|
|
536
|
+
* @throws {TypeError} When `arg` contains a value that cannot be resolved to
|
|
537
|
+
* a `Middleware` function.
|
|
538
|
+
*/
|
|
539
|
+
function registerRoute(routes, method, path, arg, stripPath) {
|
|
540
|
+
if (Array.isArray(arg)) {
|
|
541
|
+
for (const item of arg)
|
|
542
|
+
registerRoute(routes, method, path, item, stripPath);
|
|
543
|
+
}
|
|
544
|
+
else if (typeof arg === 'function') {
|
|
545
|
+
routes.push(buildRouteLayer(method, path, arg, stripPath));
|
|
546
|
+
}
|
|
547
|
+
else if (arg && typeof arg.listener === 'function') {
|
|
548
|
+
// Router instance — unwrap its listener.
|
|
549
|
+
routes.push(buildRouteLayer(method, path, arg.listener, stripPath));
|
|
550
|
+
}
|
|
551
|
+
else {
|
|
552
|
+
throw new TypeError('Unexpected value registered as middleware: expected a Middleware ' +
|
|
553
|
+
'function, a Router instance, or an array of either');
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
/**
|
|
557
|
+
* If `arg` is a `Router` instance, return its configured {@link Router.prefix};
|
|
558
|
+
* otherwise return `undefined`.
|
|
559
|
+
*
|
|
560
|
+
* Used by `router.use()` to infer the mount path when no explicit path is
|
|
561
|
+
* provided:
|
|
562
|
+
* ```ts
|
|
563
|
+
* const v1 = createRouter('/api/v1');
|
|
564
|
+
* app.use(v1); // prefix '/api/v1' is inferred automatically
|
|
565
|
+
* ```
|
|
566
|
+
*
|
|
567
|
+
* @param arg - The first argument passed to `router.use()`.
|
|
568
|
+
* @returns The router's prefix string, or `undefined`.
|
|
569
|
+
*/
|
|
570
|
+
function extractRouterPrefix(arg) {
|
|
571
|
+
if (Array.isArray(arg) || typeof arg === 'function')
|
|
572
|
+
return undefined;
|
|
573
|
+
return arg.prefix;
|
|
574
|
+
}
|
|
575
|
+
// ---------------------------------------------------------------------------
|
|
576
|
+
// Router factory
|
|
577
|
+
// ---------------------------------------------------------------------------
|
|
578
|
+
/**
|
|
579
|
+
* Create and return a new `Router` instance.
|
|
580
|
+
*
|
|
581
|
+
* The returned object exposes an Express-compatible routing API and doubles
|
|
582
|
+
* as a middleware function itself (via `router.listener`), so it can be
|
|
583
|
+
* mounted inside another router:
|
|
584
|
+
* ```ts
|
|
585
|
+
* parent.use('/api', child);
|
|
586
|
+
* // or, if child has a prefix:
|
|
587
|
+
* parent.use(child);
|
|
588
|
+
* ```
|
|
589
|
+
*
|
|
590
|
+
* **Prefix shorthand:**
|
|
591
|
+
* Pass a path string as the first argument to associate a prefix with this
|
|
592
|
+
* router. The prefix is used automatically when the router is mounted via
|
|
593
|
+
* `parent.use(child)`:
|
|
594
|
+
* ```ts
|
|
595
|
+
* const v1 = createRouter('/api/v1');
|
|
596
|
+
* v1.get('/users', handler); // handler is reached at /api/v1/users
|
|
597
|
+
* app.use(v1); // same as app.use('/api/v1', v1)
|
|
598
|
+
* ```
|
|
599
|
+
*
|
|
600
|
+
* **Path patterns** accepted by all route-registration methods:
|
|
601
|
+
* - Plain strings with optional `:name` segments — e.g. `'/users/:id'`.
|
|
602
|
+
* - Glob strings following `.gitignore` rules — e.g. `'/**\/*.php'`.
|
|
603
|
+
* - `RegExp` objects — used directly; named groups become route parameters.
|
|
604
|
+
*
|
|
605
|
+
* **Error handling:**
|
|
606
|
+
* Register a global error handler with `router.onError()`. It is called
|
|
607
|
+
* when any middleware throws, rejects, or calls `next(err)`.
|
|
608
|
+
*
|
|
609
|
+
* **Graceful shutdown:**
|
|
610
|
+
* Call `router.shutdown()` to stop the server created by `router.listen()`.
|
|
611
|
+
*
|
|
612
|
+
* @param prefixOrOpts - Optional path prefix string (e.g. `'/api/v1'`) **or**
|
|
613
|
+
* an {@link RouterOptions} object.
|
|
614
|
+
* @param opts - Options when `prefixOrOpts` is a string.
|
|
615
|
+
* @returns A fully initialised `Router`.
|
|
616
|
+
*
|
|
617
|
+
* @example
|
|
618
|
+
* ```ts
|
|
619
|
+
* const app = createRouter({ secret: process.env.COOKIE_SECRET, timeout: 30_000 });
|
|
620
|
+
*
|
|
621
|
+
* const v1 = createRouter('/api/v1');
|
|
622
|
+
* v1.get('/users', handler);
|
|
623
|
+
*
|
|
624
|
+
* app.use(v1);
|
|
625
|
+
* app.onError((err, _req, res) => res.status(500).json({ error: String(err) }));
|
|
626
|
+
* app.setNotFound((_req, res) => res.status(404).json({ error: 'Not Found' }));
|
|
627
|
+
*
|
|
628
|
+
* app.listen(3000, () => console.log('Listening'));
|
|
629
|
+
* process.on('SIGTERM', () => app.shutdown(10_000));
|
|
630
|
+
* ```
|
|
631
|
+
*/
|
|
632
|
+
function createRouter(prefixOrOpts, opts) {
|
|
633
|
+
// Resolve overloaded first argument.
|
|
634
|
+
const routerPrefix = typeof prefixOrOpts === 'string' ? prefixOrOpts : undefined;
|
|
635
|
+
const options = typeof prefixOrOpts === 'object' ? prefixOrOpts : (opts ?? {});
|
|
636
|
+
const secret = options.secret;
|
|
637
|
+
const timeoutMs = options.timeout;
|
|
638
|
+
const trustProxy = options.trustProxy ?? false;
|
|
639
|
+
const routes = [];
|
|
640
|
+
/** Currently registered error handler, or `undefined` for the default 500. */
|
|
641
|
+
let errorHandler;
|
|
642
|
+
/** Currently registered not-found handler, or `undefined` for the default 404. */
|
|
643
|
+
let notFoundHandler;
|
|
644
|
+
/** Server created by `router.listen()`, used by `router.shutdown()`. */
|
|
645
|
+
let activeServer = null;
|
|
646
|
+
/** All open sockets tracked for forced teardown on shutdown. */
|
|
647
|
+
const activeSockets = new Set();
|
|
648
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
649
|
+
// Core dispatch listener
|
|
650
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
651
|
+
/**
|
|
652
|
+
* Core dispatch function. Walks the route table in registration order and
|
|
653
|
+
* invokes the first layer that matches the current request.
|
|
654
|
+
*
|
|
655
|
+
* - **404** (or custom `setNotFound` handler) — no layer's path matched.
|
|
656
|
+
* - **405 Method Not Allowed** — a layer's path matched but no layer
|
|
657
|
+
* accepted the HTTP method. The `Allow` header lists all registered methods.
|
|
658
|
+
* - **500** (or custom `onError` handler) — a middleware threw or rejected,
|
|
659
|
+
* or `next(err)` was called with a non-null error.
|
|
660
|
+
*/
|
|
661
|
+
const listener = (req, res, done) => {
|
|
662
|
+
const method = req.method;
|
|
663
|
+
const url = req.url;
|
|
664
|
+
let idx = 0;
|
|
665
|
+
updateHttpObjects(req, res, secret, trustProxy);
|
|
666
|
+
// ── Optional per-request timeout ──────────────────────────────────────
|
|
667
|
+
// Uses both a socket-level idle timeout (as the transport boundary) and a
|
|
668
|
+
// wall-clock setTimeout guard. The wall-clock timer is the primary
|
|
669
|
+
// mechanism because it is unaffected by the OS socket buffer state;
|
|
670
|
+
// socket.setTimeout is set in addition so the idle signal propagates down
|
|
671
|
+
// to keep-alive connections that would otherwise hold the socket open.
|
|
672
|
+
if (timeoutMs) {
|
|
673
|
+
if (req.socket)
|
|
674
|
+
req.socket.setTimeout(timeoutMs);
|
|
675
|
+
const timer = setTimeout(() => {
|
|
676
|
+
if (!res.writableEnded)
|
|
677
|
+
res.status(408).end('Request Timeout');
|
|
678
|
+
}, timeoutMs);
|
|
679
|
+
res.once('finish', () => {
|
|
680
|
+
clearTimeout(timer);
|
|
681
|
+
if (req.socket)
|
|
682
|
+
req.socket.setTimeout(0);
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
// ── Centralised error dispatch ─────────────────────────────────────────
|
|
686
|
+
// Invoked for sync throws, async rejections, and next(err) calls.
|
|
687
|
+
const invokeErrorHandler = (e) => {
|
|
688
|
+
if (res.writableEnded)
|
|
689
|
+
return;
|
|
690
|
+
if (errorHandler) {
|
|
691
|
+
try {
|
|
692
|
+
errorHandler(e, req, res);
|
|
693
|
+
}
|
|
694
|
+
catch (e2) {
|
|
695
|
+
if (!res.writableEnded)
|
|
696
|
+
res.status(500).end(`Error ${method} ${url}`);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
else {
|
|
700
|
+
console.warn(e);
|
|
701
|
+
res.status(500).end(`Error ${method} ${url}`);
|
|
702
|
+
}
|
|
703
|
+
};
|
|
704
|
+
// ── Safe middleware invocation ─────────────────────────────────────────
|
|
705
|
+
// Catches sync throws AND async rejections, routing both to invokeErrorHandler.
|
|
706
|
+
const invoke = (mw, nextFn) => {
|
|
707
|
+
try {
|
|
708
|
+
const ret = mw(req, res, nextFn);
|
|
709
|
+
if (ret instanceof Promise)
|
|
710
|
+
ret.catch(invokeErrorHandler);
|
|
711
|
+
}
|
|
712
|
+
catch (e) {
|
|
713
|
+
invokeErrorHandler(e);
|
|
714
|
+
}
|
|
715
|
+
};
|
|
716
|
+
// Accumulate methods from layers whose path matched but whose method
|
|
717
|
+
// did not, for a 405 response with an accurate Allow header.
|
|
718
|
+
const allowedMethods = new Set();
|
|
719
|
+
// ── Main dispatch loop ─────────────────────────────────────────────────
|
|
720
|
+
const next = (err) => {
|
|
721
|
+
// If an error is passed, skip remaining routes and call error handler.
|
|
722
|
+
if (err != null) {
|
|
723
|
+
invokeErrorHandler(err);
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
while (idx < routes.length) {
|
|
727
|
+
const layer = routes[idx++];
|
|
728
|
+
const pathBefore = req.path;
|
|
729
|
+
if (matchRouteLayer(layer, req, req.path)) {
|
|
730
|
+
if (layer.stripPath) {
|
|
731
|
+
// For prefix layers (use), restore req.path after the sub-router
|
|
732
|
+
// calls done() so that subsequent sibling layers see the original path.
|
|
733
|
+
invoke(layer.middleware, () => {
|
|
734
|
+
req.path = pathBefore;
|
|
735
|
+
next();
|
|
736
|
+
});
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
invoke(layer.middleware, next);
|
|
740
|
+
return;
|
|
741
|
+
}
|
|
742
|
+
// Path matched but method did not → remember for 405 detection.
|
|
743
|
+
if (layer.method !== null && pathMatchesLayer(layer, pathBefore)) {
|
|
744
|
+
allowedMethods.add(layer.method);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
// All layers exhausted without a full match.
|
|
748
|
+
if (allowedMethods.size > 0) {
|
|
749
|
+
// Path is registered, but not for this method.
|
|
750
|
+
const allow = [...allowedMethods].sort().join(', ');
|
|
751
|
+
res.status(405, { Allow: allow }).end(`Cannot ${method} ${url}`);
|
|
752
|
+
return;
|
|
753
|
+
}
|
|
754
|
+
// Genuine 404 — delegate to parent router, not-found handler, or default.
|
|
755
|
+
if (done)
|
|
756
|
+
return done();
|
|
757
|
+
if (notFoundHandler) {
|
|
758
|
+
try {
|
|
759
|
+
notFoundHandler(req, res, () => { });
|
|
760
|
+
}
|
|
761
|
+
catch (e) {
|
|
762
|
+
invokeErrorHandler(e);
|
|
763
|
+
}
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
res.status(404).end(`Cannot ${method} ${url}`);
|
|
767
|
+
};
|
|
768
|
+
try {
|
|
769
|
+
next();
|
|
770
|
+
}
|
|
771
|
+
catch (e) {
|
|
772
|
+
invokeErrorHandler(e);
|
|
773
|
+
}
|
|
774
|
+
};
|
|
775
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
776
|
+
// Registration helper for method-specific routes
|
|
777
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
778
|
+
/**
|
|
779
|
+
* Return the route-registration function used by all HTTP-method helpers.
|
|
780
|
+
*
|
|
781
|
+
* @param method - HTTP method to restrict layers to, or `null` for any.
|
|
782
|
+
* @param stripPath - Whether the matched path prefix should be stripped.
|
|
783
|
+
*/
|
|
784
|
+
function makeRegister(method, stripPath) {
|
|
785
|
+
return (path, ...args) => {
|
|
786
|
+
for (const arg of args)
|
|
787
|
+
registerRoute(routes, method, path, arg, stripPath);
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
791
|
+
// `use()` — supports both path-first and no-path (Router-first) forms
|
|
792
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
793
|
+
/**
|
|
794
|
+
* Register prefix-style middleware. Accepts:
|
|
795
|
+
* 1. `use(path, ...middlewares)` — explicit path.
|
|
796
|
+
* 2. `use(routerOrMiddleware, ...more)` — infers path from Router.prefix or '/'.
|
|
797
|
+
*/
|
|
798
|
+
const use = (pathOrFirst, ...args) => {
|
|
799
|
+
if (typeof pathOrFirst === 'string' || pathOrFirst instanceof RegExp) {
|
|
800
|
+
// Normal form: explicit path string or RegExp.
|
|
801
|
+
for (const arg of args)
|
|
802
|
+
registerRoute(routes, null, pathOrFirst, arg, true);
|
|
803
|
+
}
|
|
804
|
+
else {
|
|
805
|
+
// No explicit path: the first argument is itself a middleware / Router.
|
|
806
|
+
// Infer mount path from the Router's prefix, or default to '/'.
|
|
807
|
+
const inferredPath = extractRouterPrefix(pathOrFirst) ?? '/';
|
|
808
|
+
registerRoute(routes, null, inferredPath, pathOrFirst, true);
|
|
809
|
+
for (const arg of args)
|
|
810
|
+
registerRoute(routes, null, '/', arg, true);
|
|
811
|
+
}
|
|
812
|
+
};
|
|
813
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
814
|
+
// Public router object
|
|
815
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
816
|
+
const router = {
|
|
817
|
+
prefix: routerPrefix,
|
|
818
|
+
listener,
|
|
819
|
+
use,
|
|
820
|
+
all: makeRegister(null, false),
|
|
821
|
+
get: makeRegister('GET', false),
|
|
822
|
+
put: makeRegister('PUT', false),
|
|
823
|
+
post: makeRegister('POST', false),
|
|
824
|
+
delete: makeRegister('DELETE', false),
|
|
825
|
+
patch: makeRegister('PATCH', false),
|
|
826
|
+
// ── onError ─────────────────────────────────────────────────────────────
|
|
827
|
+
onError(handler) {
|
|
828
|
+
errorHandler = handler;
|
|
829
|
+
},
|
|
830
|
+
// ── setNotFound ──────────────────────────────────────────────────────────
|
|
831
|
+
setNotFound(handler) {
|
|
832
|
+
notFoundHandler = handler;
|
|
833
|
+
},
|
|
834
|
+
// ── routes ───────────────────────────────────────────────────────────────
|
|
835
|
+
routes() {
|
|
836
|
+
return routes.map((l) => ({
|
|
837
|
+
method: l.method,
|
|
838
|
+
path: l.path,
|
|
839
|
+
stripPath: l.stripPath,
|
|
840
|
+
}));
|
|
841
|
+
},
|
|
842
|
+
// ── shutdown ─────────────────────────────────────────────────────────────
|
|
843
|
+
shutdown(timeout = 5000) {
|
|
844
|
+
if (!activeServer)
|
|
845
|
+
return Promise.resolve();
|
|
846
|
+
return new Promise((resolve, reject) => {
|
|
847
|
+
// Stop accepting new connections. Resolves when all existing
|
|
848
|
+
// connections have been closed (or when forcibly destroyed below).
|
|
849
|
+
activeServer.close((err) => {
|
|
850
|
+
if (err)
|
|
851
|
+
reject(err);
|
|
852
|
+
else
|
|
853
|
+
resolve();
|
|
854
|
+
});
|
|
855
|
+
// Forcibly destroy any remaining idle sockets after the grace period.
|
|
856
|
+
if (timeout > 0) {
|
|
857
|
+
setTimeout(() => {
|
|
858
|
+
for (const socket of activeSockets)
|
|
859
|
+
socket.destroy();
|
|
860
|
+
activeSockets.clear();
|
|
861
|
+
}, timeout);
|
|
862
|
+
}
|
|
863
|
+
});
|
|
864
|
+
},
|
|
865
|
+
// ── listen ───────────────────────────────────────────────────────────────
|
|
866
|
+
listen(port, opts, cb) {
|
|
867
|
+
if (typeof opts === 'function') {
|
|
868
|
+
cb = opts;
|
|
869
|
+
opts = undefined;
|
|
870
|
+
}
|
|
871
|
+
const rawListener = listener;
|
|
872
|
+
let server;
|
|
873
|
+
const tlsOpts = opts;
|
|
874
|
+
if (tlsOpts?.key && tlsOpts?.cert) {
|
|
875
|
+
if (tlsOpts.http2) {
|
|
876
|
+
// HTTP/2 secure server — same TLS options, different factory.
|
|
877
|
+
server = http2.createSecureServer(tlsOpts, rawListener);
|
|
878
|
+
}
|
|
879
|
+
else {
|
|
880
|
+
server = https.createServer(tlsOpts, rawListener);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
else {
|
|
884
|
+
server = http.createServer(rawListener);
|
|
885
|
+
}
|
|
886
|
+
// Track open sockets so shutdown() can forcibly destroy them.
|
|
887
|
+
server.on('connection', (socket) => {
|
|
888
|
+
activeSockets.add(socket);
|
|
889
|
+
socket.once('close', () => activeSockets.delete(socket));
|
|
890
|
+
});
|
|
891
|
+
activeServer = server;
|
|
892
|
+
server.listen(port, cb);
|
|
893
|
+
return server;
|
|
894
|
+
},
|
|
895
|
+
};
|
|
896
|
+
return router;
|
|
897
|
+
}
|
|
898
|
+
exports.default = createRouter;
|