expediate 1.0.0 → 1.0.1
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/.npmignore +2 -1
- package/package.json +3 -4
- package/src/apis.ts +428 -0
- package/src/git.ts +326 -0
- package/src/index.ts +85 -0
- package/src/jwt-auth.ts +861 -0
- package/src/mimetypes.json +1 -0
- package/src/misc.ts +734 -0
- package/src/router.ts +736 -0
- package/src/static.ts +904 -0
- package/dist/apis.js +0 -250
- package/dist/git.js +0 -244
- package/dist/jwt-auth.js +0 -575
- package/dist/misc.js +0 -549
- package/dist/router.js +0 -502
- package/dist/static.js +0 -703
package/src/router.ts
ADDED
|
@@ -0,0 +1,736 @@
|
|
|
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
|
+
|
|
23
|
+
import * as http from 'http';
|
|
24
|
+
import * as https from 'https';
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Types
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
/** A key-value map of arbitrary string values. */
|
|
31
|
+
type StringMap = Record<string, string>;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Extended HTTP incoming message that carries parsed routing metadata.
|
|
35
|
+
* Augments the standard `http.IncomingMessage` with fields populated by
|
|
36
|
+
* the router before any middleware is invoked.
|
|
37
|
+
*/
|
|
38
|
+
interface RouterRequest extends http.IncomingMessage {
|
|
39
|
+
/** The original, unmodified URL string from the HTTP request. */
|
|
40
|
+
originalUrl: string;
|
|
41
|
+
/**
|
|
42
|
+
* The current path being matched. Prefix-style layers (`use`)
|
|
43
|
+
* rewrite this field after matching so that nested routers only see the
|
|
44
|
+
* remaining suffix. Exact-method layers (`all`, `get`, `post`, etc.) leave it
|
|
45
|
+
* untouched so that chained middlewares sharing the same path each match.
|
|
46
|
+
*/
|
|
47
|
+
path: string;
|
|
48
|
+
/**
|
|
49
|
+
* Aggregated parameters from all sources.
|
|
50
|
+
* URL query parameters are merged first; named route parameters (from both
|
|
51
|
+
* plain-string and RegExp patterns) override them when a route matches.
|
|
52
|
+
*/
|
|
53
|
+
params: StringMap;
|
|
54
|
+
/**
|
|
55
|
+
* Structured query buckets:
|
|
56
|
+
* - `url` — parameters parsed from the query string.
|
|
57
|
+
* - `route` — named parameters captured from the route pattern.
|
|
58
|
+
*/
|
|
59
|
+
queries: {
|
|
60
|
+
url?: StringMap;
|
|
61
|
+
route?: StringMap;
|
|
62
|
+
};
|
|
63
|
+
/** Parsed cookies sent with the request. */
|
|
64
|
+
cookies: StringMap;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Extended HTTP server response with convenience helpers.
|
|
69
|
+
* Augments the standard `http.ServerResponse`.
|
|
70
|
+
*/
|
|
71
|
+
interface RouterResponse extends http.ServerResponse {
|
|
72
|
+
/**
|
|
73
|
+
* Write `data` (if provided) and end the response.
|
|
74
|
+
* Equivalent to `res.write(data); res.end()`.
|
|
75
|
+
*/
|
|
76
|
+
send(data?: string): void;
|
|
77
|
+
/**
|
|
78
|
+
* Set the HTTP status code and optional response headers, then return
|
|
79
|
+
* `this` so calls can be chained (e.g. `res.status(404).end(...)`).
|
|
80
|
+
*/
|
|
81
|
+
status(code: number, headers?: StringMap): this;
|
|
82
|
+
/** Redirect the client to `url` with a 302 Found response. */
|
|
83
|
+
redirect(url: string): void;
|
|
84
|
+
/**
|
|
85
|
+
* Append a `Set-Cookie` header for the given `name`/`value` pair.
|
|
86
|
+
* Returns `this` to allow chaining.
|
|
87
|
+
*/
|
|
88
|
+
cookie(name: string, value: string | object, options?: CookieOptions): this;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Options accepted by `res.cookie()`. */
|
|
92
|
+
interface CookieOptions {
|
|
93
|
+
/** Sign the cookie value (requires a secret on the request). */
|
|
94
|
+
signed?: boolean;
|
|
95
|
+
/** Expiry date for the cookie. */
|
|
96
|
+
expires?: Date;
|
|
97
|
+
/** Max age in milliseconds; converted to seconds in the `Set-Cookie` header. */
|
|
98
|
+
maxAge?: number;
|
|
99
|
+
/** Cookie path (defaults to `'/'`). */
|
|
100
|
+
path?: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Options for HTTPS servers passed to `router.listen()`. */
|
|
104
|
+
interface TlsOptions {
|
|
105
|
+
key: string | Buffer;
|
|
106
|
+
cert: string | Buffer;
|
|
107
|
+
[key: string]: unknown;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* A middleware function. Receives the current request and response objects
|
|
112
|
+
* and a `next` callback to hand control to the next registered middleware.
|
|
113
|
+
*/
|
|
114
|
+
type Middleware = (
|
|
115
|
+
req: RouterRequest,
|
|
116
|
+
res: RouterResponse,
|
|
117
|
+
next: NextFunction,
|
|
118
|
+
) => void;
|
|
119
|
+
|
|
120
|
+
/** Callback used to pass control to the next matching middleware. */
|
|
121
|
+
type NextFunction = () => void;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* A value that can be registered as a route handler: a single `Middleware`
|
|
125
|
+
* function, a `Router` instance (whose `listener` will be used), or an array
|
|
126
|
+
* of either. Arrays may not be nested.
|
|
127
|
+
*/
|
|
128
|
+
type MiddlewareArg = Middleware | Router | (Middleware | Router)[];
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Internal representation of a single registered route.
|
|
132
|
+
* One layer is created per middleware function for every `router.get(...)` /
|
|
133
|
+
* `router.use(...)` etc. call, and stored in the route table.
|
|
134
|
+
*/
|
|
135
|
+
interface Layer {
|
|
136
|
+
/** HTTP method this layer is restricted to, or `null` for any method. */
|
|
137
|
+
method: string | null;
|
|
138
|
+
/** The original path pattern supplied by the caller. */
|
|
139
|
+
path: string | RegExp;
|
|
140
|
+
/**
|
|
141
|
+
* The compiled `RegExp` used for matching, regardless of whether the
|
|
142
|
+
* original `path` was a plain string, a glob, or already a `RegExp`.
|
|
143
|
+
*
|
|
144
|
+
* - Plain strings (e.g. `'/users/:id'`) are compiled with named capture
|
|
145
|
+
* groups so that `:id` becomes `(?<id>[^/]+)`.
|
|
146
|
+
* - Glob strings (e.g. `'/**\/*.php'`) are compiled following `.gitignore`
|
|
147
|
+
* wildcard rules.
|
|
148
|
+
* - `RegExp` values are stored as-is; named groups are surfaced directly
|
|
149
|
+
* as route parameters.
|
|
150
|
+
*/
|
|
151
|
+
regex: RegExp;
|
|
152
|
+
/**
|
|
153
|
+
* When `true`, the portion of `req.path` consumed by `layer.regex` is
|
|
154
|
+
* stripped before invoking the middleware, exposing only the remaining
|
|
155
|
+
* suffix to nested routers.
|
|
156
|
+
*
|
|
157
|
+
* Set to `true` for prefix-style registrations (`use`) and
|
|
158
|
+
* `false` for exact-method routes (`all`, `get`, `post`, etc.). Stripping on
|
|
159
|
+
* exact-match routes would break chained middlewares sharing the same path,
|
|
160
|
+
* because each subsequent layer would see a truncated path that no longer
|
|
161
|
+
* matches its own pattern.
|
|
162
|
+
*/
|
|
163
|
+
stripPath: boolean;
|
|
164
|
+
/** The middleware function to invoke when the layer matches. */
|
|
165
|
+
middleware: Middleware;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The public interface of the object returned by `createRouter()`.
|
|
170
|
+
*
|
|
171
|
+
* All route-registration methods share the same uniform signature: a mandatory
|
|
172
|
+
* `path` argument followed by any number of `MiddlewareArg` values.
|
|
173
|
+
* A `MiddlewareArg` may be a `Middleware` function, a `Router` instance
|
|
174
|
+
* (whose `listener` is used automatically), or an array of either.
|
|
175
|
+
*/
|
|
176
|
+
interface Router {
|
|
177
|
+
/**
|
|
178
|
+
* Register middleware for all HTTP methods, scoped to `path`.
|
|
179
|
+
* The matched path prefix is stripped from `req.path` before the middleware
|
|
180
|
+
* is invoked, so nested routers only see the remaining suffix.
|
|
181
|
+
* Equivalent to Express's `app.use()`.
|
|
182
|
+
*/
|
|
183
|
+
use(path: string | RegExp, ...args: MiddlewareArg[]): void;
|
|
184
|
+
/**
|
|
185
|
+
* Register middleware for all HTTP methods.
|
|
186
|
+
* Unlike `use`, it doesn't strips the matched prefix from `req.path`.
|
|
187
|
+
*/
|
|
188
|
+
all(path: string | RegExp, ...args: MiddlewareArg[]): void;
|
|
189
|
+
/** Register middleware for `GET` requests. */
|
|
190
|
+
get(path: string | RegExp, ...args: MiddlewareArg[]): void;
|
|
191
|
+
/** Register middleware for `PUT` requests. */
|
|
192
|
+
put(path: string | RegExp, ...args: MiddlewareArg[]): void;
|
|
193
|
+
/** Register middleware for `POST` requests. */
|
|
194
|
+
post(path: string | RegExp, ...args: MiddlewareArg[]): void;
|
|
195
|
+
/** Register middleware for `DELETE` requests. */
|
|
196
|
+
delete(path: string | RegExp, ...args: MiddlewareArg[]): void;
|
|
197
|
+
/** Register middleware for `PATCH` requests. */
|
|
198
|
+
patch(path: string | RegExp, ...args: MiddlewareArg[]): void;
|
|
199
|
+
/**
|
|
200
|
+
* Start listening on the given port.
|
|
201
|
+
* When `opts` contains both `key` and `cert`, an HTTPS server is created;
|
|
202
|
+
* otherwise a plain HTTP server is used.
|
|
203
|
+
*/
|
|
204
|
+
listen(port: number, opts?: TlsOptions | (() => void), cb?: () => void): void;
|
|
205
|
+
/**
|
|
206
|
+
* The underlying `(req, res, next)` function, allowing this router to be
|
|
207
|
+
* mounted as middleware inside another router:
|
|
208
|
+
* `parent.use('/api', child)`.
|
|
209
|
+
*/
|
|
210
|
+
readonly listener: Middleware;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
// Pattern compilation
|
|
215
|
+
// ---------------------------------------------------------------------------
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Determine whether a path string contains glob characters (`*` or `?`).
|
|
219
|
+
*
|
|
220
|
+
* A pattern is considered a glob when it contains at least one unescaped
|
|
221
|
+
* `*` or `?` character, following the same convention as `.gitignore`.
|
|
222
|
+
*
|
|
223
|
+
* @param pattern - The path string to inspect.
|
|
224
|
+
* @returns `true` if the pattern should be treated as a glob.
|
|
225
|
+
*/
|
|
226
|
+
function isGlobPattern(pattern: string): boolean {
|
|
227
|
+
return /(?<!\\)[*?]/.test(pattern);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Compile a `.gitignore`-style glob string into a prefix-anchored `RegExp`.
|
|
232
|
+
*
|
|
233
|
+
* Supported wildcard syntax:
|
|
234
|
+
* - `?` — matches exactly one character that is not `/`.
|
|
235
|
+
* - `*` — matches zero or more characters that are not `/`.
|
|
236
|
+
* - `**` — matches zero or more path segments (any characters including `/`).
|
|
237
|
+
*
|
|
238
|
+
* The returned expression is anchored at the start (`^`) so it always matches
|
|
239
|
+
* a prefix of the current path; the matched portion is stripped by
|
|
240
|
+
* `matchRouteLayer` when `layer.stripPath` is `true`.
|
|
241
|
+
*
|
|
242
|
+
* @param glob - The glob pattern to compile.
|
|
243
|
+
* @returns A prefix-anchored `RegExp`.
|
|
244
|
+
*
|
|
245
|
+
* @example
|
|
246
|
+
* ```ts
|
|
247
|
+
* compileGlob('/**\/*.php').test('/admin/index.php'); // true
|
|
248
|
+
* compileGlob('/api/*') .test('/api/users'); // true
|
|
249
|
+
* compileGlob('/api/*') .test('/api/users/123'); // false
|
|
250
|
+
* ```
|
|
251
|
+
*/
|
|
252
|
+
function compileGlob(glob: string): RegExp {
|
|
253
|
+
// Escape all regex special characters, leaving our wildcard characters intact.
|
|
254
|
+
let src = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
255
|
+
|
|
256
|
+
// Replace in order: `**` must be handled before `*`.
|
|
257
|
+
src = src
|
|
258
|
+
.replace(/\*\*/g, '\x00GLOBSTAR\x00') // temporary placeholder
|
|
259
|
+
.replace(/\*/g, '[^/]*') // single-segment wildcard
|
|
260
|
+
.replace(/\?/g, '[^/]') // single-character wildcard
|
|
261
|
+
.replace(/\x00GLOBSTAR\x00/g, '.*'); // cross-segment wildcard
|
|
262
|
+
|
|
263
|
+
return new RegExp('^' + src);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Compile a plain path string with optional `:name` parameter segments into a
|
|
268
|
+
* prefix-anchored `RegExp` that uses named capture groups.
|
|
269
|
+
*
|
|
270
|
+
* Each `:name` segment is converted to `(?<name>[^/]+)`, making named
|
|
271
|
+
* captures available directly on the `RegExp` match result.
|
|
272
|
+
* Literal segments are escaped and matched exactly.
|
|
273
|
+
*
|
|
274
|
+
* The expression matches up to a segment boundary (`/` or end-of-string) so
|
|
275
|
+
* that `/users` never inadvertently matches `/users-admin`.
|
|
276
|
+
*
|
|
277
|
+
* @param path - A plain path string such as `'/users/:id/posts'`.
|
|
278
|
+
* @returns A prefix-anchored `RegExp` with named groups for each parameter.
|
|
279
|
+
*
|
|
280
|
+
* @example
|
|
281
|
+
* ```ts
|
|
282
|
+
* const re = compilePlainPath('/users/:id');
|
|
283
|
+
* re.exec('/users/42/comments')?.groups; // { id: '42' }
|
|
284
|
+
* ```
|
|
285
|
+
*/
|
|
286
|
+
function compilePlainPath(path: string): RegExp {
|
|
287
|
+
const segments = path.split('/').filter((s) => s.length > 0);
|
|
288
|
+
const src = segments
|
|
289
|
+
.map((seg) =>
|
|
290
|
+
seg.startsWith(':')
|
|
291
|
+
? `(?<${seg.slice(1)}>[^/]+)` // named parameter segment
|
|
292
|
+
: seg.replace(/[.+^${}()|[\]\\]/g, '\\$&'), // escaped literal
|
|
293
|
+
)
|
|
294
|
+
.join('/');
|
|
295
|
+
|
|
296
|
+
// Allow a trailing slash or an additional path segment after the prefix.
|
|
297
|
+
return new RegExp('^/?' + src + '(?=/|$)');
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Convert any supported path pattern into the single canonical `RegExp`
|
|
302
|
+
* used by `matchRouteLayer`.
|
|
303
|
+
*
|
|
304
|
+
* | Input type | Strategy |
|
|
305
|
+
* |--------------|-------------------------------------------------------|
|
|
306
|
+
* | Glob string | {@link compileGlob} — `.gitignore`-style wildcards |
|
|
307
|
+
* | Plain string | {@link compilePlainPath} — `:name` → named groups |
|
|
308
|
+
* | `RegExp` | Used as-is; named groups are surfaced as params |
|
|
309
|
+
*
|
|
310
|
+
* @param path - The raw path pattern supplied by the caller.
|
|
311
|
+
* @returns A `RegExp` suitable for use in `matchRouteLayer`.
|
|
312
|
+
*/
|
|
313
|
+
function compilePattern(path: string | RegExp): RegExp {
|
|
314
|
+
if (path instanceof RegExp) return path;
|
|
315
|
+
if (isGlobPattern(path)) return compileGlob(path);
|
|
316
|
+
return compilePlainPath(path);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ---------------------------------------------------------------------------
|
|
320
|
+
// Layer construction
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Build a `Layer` that represents a single registered route entry.
|
|
325
|
+
*
|
|
326
|
+
* The `path` argument may be a plain string with `:name` parameters, a glob
|
|
327
|
+
* string, or a `RegExp`. In all three cases the pattern is pre-compiled into
|
|
328
|
+
* a single `RegExp` stored as `layer.regex`.
|
|
329
|
+
*
|
|
330
|
+
* @param method - HTTP method to restrict this layer to (uppercased), or
|
|
331
|
+
* `null` to match any method.
|
|
332
|
+
* @param path - URL path pattern (plain string, glob, or `RegExp`).
|
|
333
|
+
* @param middleware - The middleware function to invoke on a match.
|
|
334
|
+
* @param stripPath - When `true`, the matched prefix is stripped from
|
|
335
|
+
* `req.path` before the middleware runs. Pass `true` for
|
|
336
|
+
* prefix/`use` registrations and `false` for exact-method
|
|
337
|
+
* routes so that chained middlewares sharing the same path
|
|
338
|
+
* can each match in turn.
|
|
339
|
+
* @returns A fully initialised `Layer` ready to be pushed into the route table.
|
|
340
|
+
* @throws {TypeError} When `middleware` is not a callable function.
|
|
341
|
+
*/
|
|
342
|
+
function buildRouteLayer(
|
|
343
|
+
method: string | null,
|
|
344
|
+
path: string | RegExp,
|
|
345
|
+
middleware: Middleware,
|
|
346
|
+
stripPath: boolean,
|
|
347
|
+
): Layer {
|
|
348
|
+
if (typeof middleware !== 'function')
|
|
349
|
+
throw new TypeError('Incorrect middleware type: expected a function');
|
|
350
|
+
|
|
351
|
+
return { method, path, regex: compilePattern(path), stripPath, middleware };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
// Layer matching
|
|
356
|
+
// ---------------------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Test whether an incoming request matches the given layer and, on success,
|
|
360
|
+
* mutate `req` to reflect the match.
|
|
361
|
+
*
|
|
362
|
+
* All pattern types (plain string, glob, `RegExp`) are handled identically
|
|
363
|
+
* through `layer.regex`. Named capture groups in the match result are merged
|
|
364
|
+
* into `req.params` and `req.queries.route`.
|
|
365
|
+
*
|
|
366
|
+
* Path stripping is conditional on `layer.stripPath`:
|
|
367
|
+
* - `true` (prefix routes) — the matched prefix is removed from `req.path`
|
|
368
|
+
* so nested routers only see the remaining suffix.
|
|
369
|
+
* - `false` (exact-method routes) — `req.path` is left unchanged so that
|
|
370
|
+
* subsequent chained middlewares sharing the same pattern can still match.
|
|
371
|
+
*
|
|
372
|
+
* @param layer - The layer to test.
|
|
373
|
+
* @param req - The incoming request (mutated in-place on a successful match).
|
|
374
|
+
* @param path - The current value of `req.path` to test against.
|
|
375
|
+
* @returns `true` if the layer matches and `req` has been updated;
|
|
376
|
+
* `false` otherwise.
|
|
377
|
+
*/
|
|
378
|
+
function matchRouteLayer(
|
|
379
|
+
layer: Layer,
|
|
380
|
+
req: RouterRequest,
|
|
381
|
+
path: string,
|
|
382
|
+
): boolean {
|
|
383
|
+
if (layer.method && layer.method !== req.method) return false;
|
|
384
|
+
|
|
385
|
+
const m = layer.regex.exec(path);
|
|
386
|
+
if (m === null) return false;
|
|
387
|
+
|
|
388
|
+
const captured: StringMap = (m.groups as StringMap | undefined) ?? {};
|
|
389
|
+
|
|
390
|
+
// Only rewrite req.path for prefix-style (use) registrations.
|
|
391
|
+
// Exact-method routes leave req.path intact so chained middlewares
|
|
392
|
+
// sharing the same path each see the full, unmodified path.
|
|
393
|
+
if (layer.stripPath) {
|
|
394
|
+
req.path = path.slice(m[0].length) || '/';
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
req.queries.route = captured;
|
|
398
|
+
Object.assign(req.params, captured);
|
|
399
|
+
|
|
400
|
+
return true;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// ---------------------------------------------------------------------------
|
|
404
|
+
// HTTP object augmentation
|
|
405
|
+
// ---------------------------------------------------------------------------
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Augment a raw `http.IncomingMessage` / `http.ServerResponse` pair with the
|
|
409
|
+
* additional fields and helpers expected by router middleware.
|
|
410
|
+
*
|
|
411
|
+
* This function is idempotent — it exits immediately when `req.queries` is
|
|
412
|
+
* already defined, so it is safe to call multiple times on the same pair.
|
|
413
|
+
*
|
|
414
|
+
* **Fields added to `req`:**
|
|
415
|
+
* - `originalUrl` — the unmodified URL string.
|
|
416
|
+
* - `path` — the pathname portion of the URL.
|
|
417
|
+
* - `params` — merged map initialised from URL query parameters.
|
|
418
|
+
* - `queries` — structured query buckets (`url`, `route`).
|
|
419
|
+
* - `cookies` — parsed `Cookie` header values.
|
|
420
|
+
*
|
|
421
|
+
* **Helpers added to `res`:**
|
|
422
|
+
* - `send(data?)` — write `data` and end the response.
|
|
423
|
+
* - `status(code, headers?)` — set the status code and optional headers.
|
|
424
|
+
* - `redirect(url)` — issue a 302 redirect.
|
|
425
|
+
* - `cookie(name, val, opts)` — append a `Set-Cookie` header.
|
|
426
|
+
*
|
|
427
|
+
* @param req - The raw incoming message to augment.
|
|
428
|
+
* @param res - The raw server response to augment.
|
|
429
|
+
*/
|
|
430
|
+
function updateHttpObjects(
|
|
431
|
+
req: http.IncomingMessage,
|
|
432
|
+
res: http.ServerResponse,
|
|
433
|
+
): void {
|
|
434
|
+
const rReq = req as RouterRequest;
|
|
435
|
+
const rRes = res as RouterResponse;
|
|
436
|
+
|
|
437
|
+
if (rReq.queries) return; // Already augmented.
|
|
438
|
+
|
|
439
|
+
rReq.queries = {};
|
|
440
|
+
|
|
441
|
+
const qry = new URL(`http://${req.headers.host}${req.url}`);
|
|
442
|
+
rReq.originalUrl = req.url!;
|
|
443
|
+
rReq.path = qry.pathname;
|
|
444
|
+
|
|
445
|
+
// Parse URL query parameters.
|
|
446
|
+
const urlParams: StringMap = {};
|
|
447
|
+
for (const [key, value] of qry.searchParams.entries()) urlParams[key] = value;
|
|
448
|
+
rReq.queries.url = urlParams;
|
|
449
|
+
rReq.params = { ...urlParams };
|
|
450
|
+
|
|
451
|
+
// Parse cookies.
|
|
452
|
+
if (rReq.cookies == null) {
|
|
453
|
+
rReq.cookies = {};
|
|
454
|
+
if (req.headers.cookie) {
|
|
455
|
+
for (const raw of req.headers.cookie.split(';')) {
|
|
456
|
+
const eqIdx = raw.indexOf('=');
|
|
457
|
+
if (eqIdx === -1) continue;
|
|
458
|
+
rReq.cookies[raw.slice(0, eqIdx).trim()] = raw.slice(eqIdx + 1).trim();
|
|
459
|
+
// TODO: 's:' prefix → signed cookie, 'j:' prefix → JSON cookie
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
rRes.setHeader('X-Powered-By', 'Expediate');
|
|
465
|
+
|
|
466
|
+
rRes.send = (data?: string): void => {
|
|
467
|
+
if (data) res.write(data);
|
|
468
|
+
res.end();
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
rRes.status = (code: number, headers?: StringMap): typeof rRes => {
|
|
472
|
+
res.statusCode = code;
|
|
473
|
+
if (headers)
|
|
474
|
+
for (const [k, v] of Object.entries(headers)) res.setHeader(k, v);
|
|
475
|
+
return rRes;
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
rRes.redirect = (url: string): void => {
|
|
479
|
+
res.setHeader('location', url);
|
|
480
|
+
res.writeHead(302);
|
|
481
|
+
res.write(`Found. Redirecting to ${url}`);
|
|
482
|
+
res.end();
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
rRes.cookie = (
|
|
486
|
+
name: string,
|
|
487
|
+
value: string | object,
|
|
488
|
+
options?: CookieOptions,
|
|
489
|
+
): typeof rRes => {
|
|
490
|
+
const opts: CookieOptions = options ?? {};
|
|
491
|
+
|
|
492
|
+
if (opts.signed && !(req as any).secret)
|
|
493
|
+
throw new Error('cookieParser("secret") required for signed cookies');
|
|
494
|
+
|
|
495
|
+
let val =
|
|
496
|
+
typeof value === 'object' ? 'j:' + JSON.stringify(value) : String(value);
|
|
497
|
+
|
|
498
|
+
if (opts.signed) val = 's:' + val; // sign() integration point
|
|
499
|
+
|
|
500
|
+
let txt = `${name}=${val}`;
|
|
501
|
+
|
|
502
|
+
if (opts.maxAge != null) {
|
|
503
|
+
opts.expires = new Date(Date.now() + opts.maxAge);
|
|
504
|
+
opts.maxAge = Math.floor(opts.maxAge / 1000);
|
|
505
|
+
txt += `; Max-Age=${opts.maxAge}`;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
if (opts.expires) txt += `; Expires=${opts.expires.toUTCString()}`;
|
|
509
|
+
txt += `; Path=${opts.path ?? '/'}`;
|
|
510
|
+
|
|
511
|
+
res.setHeader('Set-Cookie', txt);
|
|
512
|
+
return rRes;
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// ---------------------------------------------------------------------------
|
|
517
|
+
// Route registration
|
|
518
|
+
// ---------------------------------------------------------------------------
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Recursively resolve a `MiddlewareArg` value down to individual `Middleware`
|
|
522
|
+
* functions and push one `Layer` per function into the route table.
|
|
523
|
+
*
|
|
524
|
+
* Accepted input shapes (processed recursively):
|
|
525
|
+
* - A `Middleware` function → pushed directly as a layer.
|
|
526
|
+
* - A `Router` instance → its `listener` is unwrapped and pushed.
|
|
527
|
+
* - An array of either → each element is processed recursively.
|
|
528
|
+
*
|
|
529
|
+
* @param routes - The mutable route table to push into.
|
|
530
|
+
* @param method - HTTP method string or `null` for method-agnostic layers.
|
|
531
|
+
* @param path - URL pattern (plain string, glob, or `RegExp`).
|
|
532
|
+
* @param arg - The middleware value(s) to register.
|
|
533
|
+
* @param stripPath - Forwarded to `buildRouteLayer`. Pass `true` for prefix-
|
|
534
|
+
* style registrations (`use`) so the matched prefix
|
|
535
|
+
* is stripped from `req.path`, and `false` for exact-method
|
|
536
|
+
* routes so that chained middlewares sharing the same path
|
|
537
|
+
* each see the unmodified path.
|
|
538
|
+
* @throws {TypeError} When `arg` contains a value that cannot be resolved to
|
|
539
|
+
* a `Middleware` function.
|
|
540
|
+
*/
|
|
541
|
+
function registerRoute(
|
|
542
|
+
routes: Layer[],
|
|
543
|
+
method: string | null,
|
|
544
|
+
path: string | RegExp,
|
|
545
|
+
arg: MiddlewareArg,
|
|
546
|
+
stripPath: boolean,
|
|
547
|
+
): void {
|
|
548
|
+
if (Array.isArray(arg)) {
|
|
549
|
+
for (const item of arg) registerRoute(routes, method, path, item, stripPath);
|
|
550
|
+
} else if (typeof arg === 'function') {
|
|
551
|
+
routes.push(buildRouteLayer(method, path, arg, stripPath));
|
|
552
|
+
} else if (arg && typeof (arg as Router).listener === 'function') {
|
|
553
|
+
// Router instance — unwrap its listener.
|
|
554
|
+
routes.push(buildRouteLayer(method, path, (arg as Router).listener, stripPath));
|
|
555
|
+
} else {
|
|
556
|
+
throw new TypeError(
|
|
557
|
+
'Unexpected value registered as middleware: expected a Middleware ' +
|
|
558
|
+
'function, a Router instance, or an array of either',
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// ---------------------------------------------------------------------------
|
|
564
|
+
// Router factory
|
|
565
|
+
// ---------------------------------------------------------------------------
|
|
566
|
+
|
|
567
|
+
/**
|
|
568
|
+
* Create and return a new `Router` instance.
|
|
569
|
+
*
|
|
570
|
+
* The returned object exposes an Express-compatible routing API and doubles
|
|
571
|
+
* as a middleware function itself (via `router.listener`), so it can be
|
|
572
|
+
* mounted inside another router:
|
|
573
|
+
* ```ts
|
|
574
|
+
* parent.use('/api', child);
|
|
575
|
+
* ```
|
|
576
|
+
*
|
|
577
|
+
* **Path patterns** accepted by all route-registration methods:
|
|
578
|
+
* - Plain strings with optional `:name` segments — e.g. `'/users/:id'`.
|
|
579
|
+
* Each `:name` is compiled to a named capture group and exposed in
|
|
580
|
+
* `req.params` on a match.
|
|
581
|
+
* - Glob strings following `.gitignore` rules — e.g. `'/**\/*.php'`.
|
|
582
|
+
* Supported wildcards: `?` (one non-slash char), `*` (any non-slash chars),
|
|
583
|
+
* `**` (any chars, including slashes).
|
|
584
|
+
* - `RegExp` objects — used directly; named groups become route parameters.
|
|
585
|
+
*
|
|
586
|
+
* **Middleware arguments** accept any number of variadic `MiddlewareArg`
|
|
587
|
+
* values, each of which may be:
|
|
588
|
+
* - A `Middleware` function.
|
|
589
|
+
* - A `Router` instance (its `listener` is registered automatically).
|
|
590
|
+
* - An array of either of the above.
|
|
591
|
+
*
|
|
592
|
+
* **Path stripping behaviour:**
|
|
593
|
+
* - `use` — strip the matched path prefix from `req.path` before
|
|
594
|
+
* invoking middleware. Nested routers therefore only see the remaining suffix.
|
|
595
|
+
* - `all` / `get` / `post` / `put` / `delete` / `patch` — leave `req.path` intact
|
|
596
|
+
* so that multiple middlewares registered for the same exact path can each
|
|
597
|
+
* match and be invoked in sequence via `next()`.
|
|
598
|
+
*
|
|
599
|
+
* @returns A fully initialised `Router` ready to register routes and
|
|
600
|
+
* optionally start an HTTP or HTTPS server.
|
|
601
|
+
*
|
|
602
|
+
* @example
|
|
603
|
+
* ```ts
|
|
604
|
+
* const auth = createRouter();
|
|
605
|
+
* auth.post('/login', handleLogin);
|
|
606
|
+
* auth.post('/logout', handleLogout);
|
|
607
|
+
*
|
|
608
|
+
* const app = createRouter();
|
|
609
|
+
* app.use('/auth', auth); // mount a sub-router
|
|
610
|
+
* app.get('/users/:id', requireAuth, getUser); // multiple middleware
|
|
611
|
+
* app.get('/**\/*.php', (req, res) => // glob pattern
|
|
612
|
+
* res.status(403).send('Forbidden'));
|
|
613
|
+
*
|
|
614
|
+
* app.listen(3000, () => console.log('Listening on :3000'));
|
|
615
|
+
* ```
|
|
616
|
+
*/
|
|
617
|
+
function createRouter(): Router {
|
|
618
|
+
const routes: Layer[] = [];
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Core dispatch function. Walks the route table in registration order and
|
|
622
|
+
* invokes the first layer that matches the current request. Falls back to a
|
|
623
|
+
* 404 response when no layer matches and no upstream `done` callback is set.
|
|
624
|
+
*/
|
|
625
|
+
const listener: Middleware = (
|
|
626
|
+
req: RouterRequest,
|
|
627
|
+
res: RouterResponse,
|
|
628
|
+
done?: NextFunction,
|
|
629
|
+
): void => {
|
|
630
|
+
const method = req.method;
|
|
631
|
+
const url = req.url;
|
|
632
|
+
let idx = 0;
|
|
633
|
+
|
|
634
|
+
updateHttpObjects(req, res);
|
|
635
|
+
|
|
636
|
+
const next: NextFunction = (): void => {
|
|
637
|
+
while (idx < routes.length) {
|
|
638
|
+
const layer = routes[idx++];
|
|
639
|
+
const pathBefore = req.path;
|
|
640
|
+
if (matchRouteLayer(layer, req, req.path)) {
|
|
641
|
+
if (layer.stripPath) {
|
|
642
|
+
// For prefix layers (use), save the pre-strip path so we can
|
|
643
|
+
// restore it if the sub-router calls done() and control returns here.
|
|
644
|
+
// Without restoration, subsequent layers in this router would see
|
|
645
|
+
// the truncated path and fail to match their own patterns.
|
|
646
|
+
const strippedPath = req.path;
|
|
647
|
+
return layer.middleware(req, res, () => {
|
|
648
|
+
req.path = pathBefore; // restore for the next sibling layer
|
|
649
|
+
next();
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
return layer.middleware(req, res, next);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
if (done) return done();
|
|
656
|
+
res.status(404).end(`Cannot ${method} ${url}`);
|
|
657
|
+
};
|
|
658
|
+
|
|
659
|
+
try {
|
|
660
|
+
next();
|
|
661
|
+
} catch (e) {
|
|
662
|
+
console.warn(e);
|
|
663
|
+
res.status(500).end(`Error ${method} ${url}`);
|
|
664
|
+
}
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
// -------------------------------------------------------------------------
|
|
668
|
+
// Internal helper — produce the uniform registration function for a method.
|
|
669
|
+
// -------------------------------------------------------------------------
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Return the route-registration function used by all HTTP-method helpers.
|
|
673
|
+
*
|
|
674
|
+
* The produced function accepts a mandatory `path` followed by any number
|
|
675
|
+
* of `MiddlewareArg` values (functions, `Router` instances, or arrays
|
|
676
|
+
* thereof), and delegates each to `registerRoute`.
|
|
677
|
+
*
|
|
678
|
+
* @param method - HTTP method to restrict layers to, or `null` for any.
|
|
679
|
+
* @param stripPath - Whether the matched path prefix should be stripped from
|
|
680
|
+
* `req.path` before middleware is invoked. `true` for
|
|
681
|
+
* prefix-style registrations, `false` for exact-method ones.
|
|
682
|
+
* @returns A variadic route-registration function.
|
|
683
|
+
*/
|
|
684
|
+
function makeRegister(method: string | null, stripPath: boolean) {
|
|
685
|
+
return (path: string | RegExp, ...args: MiddlewareArg[]): void => {
|
|
686
|
+
for (const arg of args) registerRoute(routes, method, path, arg, stripPath);
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// -------------------------------------------------------------------------
|
|
691
|
+
// Public router API
|
|
692
|
+
// -------------------------------------------------------------------------
|
|
693
|
+
|
|
694
|
+
const router: Router = {
|
|
695
|
+
listener,
|
|
696
|
+
use: makeRegister(null, true), // prefix — strip path
|
|
697
|
+
all: makeRegister(null, false), // exact — keep path
|
|
698
|
+
get: makeRegister('GET', false),
|
|
699
|
+
put: makeRegister('PUT', false),
|
|
700
|
+
post: makeRegister('POST', false),
|
|
701
|
+
delete: makeRegister('DELETE', false),
|
|
702
|
+
patch: makeRegister('PATCH', false),
|
|
703
|
+
|
|
704
|
+
listen(
|
|
705
|
+
port: number,
|
|
706
|
+
opts?: TlsOptions | (() => void),
|
|
707
|
+
cb?: () => void,
|
|
708
|
+
): void {
|
|
709
|
+
if (typeof opts === 'function') {
|
|
710
|
+
cb = opts;
|
|
711
|
+
opts = undefined;
|
|
712
|
+
}
|
|
713
|
+
const rawListener = listener as unknown as http.RequestListener;
|
|
714
|
+
if (opts && (opts as TlsOptions).key && (opts as TlsOptions).cert)
|
|
715
|
+
https.createServer(opts as TlsOptions, rawListener).listen(port, cb);
|
|
716
|
+
else
|
|
717
|
+
http.createServer(rawListener).listen(port, cb);
|
|
718
|
+
},
|
|
719
|
+
};
|
|
720
|
+
|
|
721
|
+
return router;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
export default createRouter;
|
|
725
|
+
export type {
|
|
726
|
+
Router,
|
|
727
|
+
RouterRequest,
|
|
728
|
+
RouterResponse,
|
|
729
|
+
Middleware,
|
|
730
|
+
MiddlewareArg,
|
|
731
|
+
NextFunction,
|
|
732
|
+
Layer,
|
|
733
|
+
CookieOptions,
|
|
734
|
+
TlsOptions,
|
|
735
|
+
StringMap,
|
|
736
|
+
};
|