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/LICENSE
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
MIT License
|
|
2
2
|
|
|
3
|
-
Copyright
|
|
3
|
+
Copyright © 2021 Fabien Bavent
|
|
4
4
|
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
this software and associated documentation files (the
|
|
7
|
-
the Software without restriction, including without limitation the rights
|
|
8
|
-
use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
of the Software, and to permit persons to whom the Software is
|
|
10
|
-
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the “Software”), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
11
|
|
|
12
|
-
The above copyright notice and this permission notice shall be included in
|
|
13
|
-
copies or substantial portions of the Software.
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
15
|
+
The Software is provided “as is”, without warranty of any kind, express or
|
|
16
|
+
implied, including but not limited to the warranties of merchantability,
|
|
17
|
+
fitness for a particular purpose and noninfringement. In no event shall the
|
|
18
|
+
authors or copyright holders be liable for any claim, damages or other
|
|
19
|
+
liability, whether in an action of contract, tort or otherwise, arising from,
|
|
20
|
+
out of or in connection with the software or the use or other dealings in the
|
|
21
|
+
Software.
|
package/README.md
CHANGED
|
@@ -1,11 +1,22 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="docs/expediate.png" alt="expediate" width="260" />
|
|
3
|
+
</p>
|
|
2
4
|
|
|
3
|
-
|
|
5
|
+
<p align="center">
|
|
6
|
+
A lightweight, zero-dependency TypeScript HTTP routing framework for Node.js.
|
|
7
|
+
</p>
|
|
4
8
|
|
|
5
|
-
|
|
9
|
+
<p align="center">
|
|
10
|
+
<a href="https://www.npmjs.com/package/expediate"><img src="https://img.shields.io/npm/v/expediate.svg" alt="npm version" /></a>
|
|
11
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT License" /></a>
|
|
12
|
+
<img src="https://img.shields.io/badge/node-%3E%3D18-brightgreen" alt="Node ≥ 18" />
|
|
13
|
+
<a href="https://npmcharts.com/compare/expediate?minimal=true"><img src="https://img.shields.io/npm/dm/expediate.svg" alt="npm downloads" /></a>
|
|
14
|
+
</p>
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
**expediate** provides an Express-compatible API surface with full TypeScript types, built-in body parsing, static file serving, JWT authentication, multipart form handling, a Git Smart HTTP gateway, and a suite of production-ready middleware — all in a single package with no runtime dependencies beyond Node.js itself.
|
|
6
19
|
|
|
7
|
-
[![NPM Version][npm-image]][npm-url]
|
|
8
|
-
[![NPM Downloads][downloads-image]][downloads-url]
|
|
9
20
|
|
|
10
21
|
---
|
|
11
22
|
|
|
@@ -17,17 +28,29 @@ A lightweight, zero-dependency TypeScript HTTP routing framework for Node.js.
|
|
|
17
28
|
- [Creating a router](#creating-a-router)
|
|
18
29
|
- [Route registration](#route-registration)
|
|
19
30
|
- [Path patterns](#path-patterns)
|
|
31
|
+
- [Request fields](#request-fields)
|
|
20
32
|
- [Response helpers](#response-helpers)
|
|
33
|
+
- [Error handling](#error-handling)
|
|
21
34
|
- [Sub-routers](#sub-routers)
|
|
22
35
|
- [Starting the server](#starting-the-server)
|
|
23
36
|
- [Body parsing](#body-parsing)
|
|
24
37
|
- [`json()`](#json)
|
|
25
38
|
- [`formData()`](#formdata)
|
|
39
|
+
- [`formEncoded()`](#formencoded)
|
|
26
40
|
- [`parseBody()`](#parsebody)
|
|
41
|
+
- [`streamFormData()`](#streamformdata)
|
|
27
42
|
- [Static files](#static-files)
|
|
28
43
|
- [`serveStatic()`](#servestatic)
|
|
29
44
|
- [`serveFile()`](#servefile)
|
|
30
45
|
- [`sendFile()`](#sendfile)
|
|
46
|
+
- [Middleware](#middleware)
|
|
47
|
+
- [`compress()`](#compress)
|
|
48
|
+
- [`conditionalGet()`](#conditionalget)
|
|
49
|
+
- [`cacheControl()`](#cachecontrol)
|
|
50
|
+
- [`requestId()`](#requestid)
|
|
51
|
+
- [`rateLimit()`](#ratelimit)
|
|
52
|
+
- [`csrf()`](#csrf)
|
|
53
|
+
- [`securityHeaders()`](#securityheaders)
|
|
31
54
|
- [Request logging](#request-logging)
|
|
32
55
|
- [JWT Authentication](#jwt-authentication)
|
|
33
56
|
- [Setup](#setup)
|
|
@@ -37,8 +60,9 @@ A lightweight, zero-dependency TypeScript HTTP routing framework for Node.js.
|
|
|
37
60
|
- [API service builder](#api-service-builder)
|
|
38
61
|
- [Defining a service](#defining-a-service)
|
|
39
62
|
- [Scoping](#scoping)
|
|
40
|
-
- [
|
|
63
|
+
- [Service error handling](#service-error-handling)
|
|
41
64
|
- [Git Smart HTTP gateway](#git-smart-http-gateway)
|
|
65
|
+
- [OpenAPI spec generation](#openapi-spec-generation)
|
|
42
66
|
- [TypeScript types](#typescript-types)
|
|
43
67
|
|
|
44
68
|
---
|
|
@@ -129,6 +153,16 @@ app.get('/users/:id', handler); // req.params.id
|
|
|
129
153
|
app.get('/orgs/:org/repos/:repo', handler); // req.params.org, req.params.repo
|
|
130
154
|
```
|
|
131
155
|
|
|
156
|
+
Parameters accept an optional **inline regex constraint** in parentheses. Only paths where the segment matches the constraint are routed to the handler — other values fall through to the next matching route:
|
|
157
|
+
|
|
158
|
+
```ts
|
|
159
|
+
app.get('/items/:id(\\d+)', handler); // digits only — /items/42 ✓ /items/abc ✗
|
|
160
|
+
app.get('/files/:name([\\w-]+)', handler); // word chars and hyphens
|
|
161
|
+
app.get('/v:ver(\\d+)/status', handler); // literal suffix after constraint
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
The constraint replaces the default `[^/]+` body; params are always strings — no coercion is performed. Named capture groups inside constraints are not allowed (they conflict with the outer `(?<name>…)` wrapper).
|
|
165
|
+
|
|
132
166
|
**Glob patterns** (`.gitignore` wildcard rules)
|
|
133
167
|
```ts
|
|
134
168
|
app.get('/api/*', handler); // one path segment
|
|
@@ -144,6 +178,29 @@ app.get(/^\/users\/(?<id>\d+)/, handler); // req.params.id
|
|
|
144
178
|
|
|
145
179
|
> **Route specificity:** when using `apiBuilder`, routes are automatically sorted by specificity (more segments / fewer parameters first) to prevent shorter paths from shadowing longer ones.
|
|
146
180
|
|
|
181
|
+
### Request fields
|
|
182
|
+
|
|
183
|
+
Every request object is augmented with additional fields before any middleware runs:
|
|
184
|
+
|
|
185
|
+
| Field | Type | Description |
|
|
186
|
+
|---|---|---|
|
|
187
|
+
| `req.originalUrl` | `string` | Raw URL string, never modified |
|
|
188
|
+
| `req.path` | `string` | Pathname portion of the URL; rewritten by `use()` prefix layers |
|
|
189
|
+
| `req.params` | `Record<string, string>` | Merged map of URL query params and named route params |
|
|
190
|
+
| `req.queries.url` | `Record<string, string \| string[]>` | URL query parameters (repeated keys collect into arrays) |
|
|
191
|
+
| `req.queries.route` | `Record<string, string>` | Named route parameters captured from the path pattern |
|
|
192
|
+
| `req.cookies` | `Record<string, unknown>` | Parsed `Cookie` header values |
|
|
193
|
+
| `req.ip` | `string` | Remote client IP. When `trustProxy: true`, taken from `X-Forwarded-For` |
|
|
194
|
+
| `req.json(opts?)` | `Promise<unknown>` | Read and parse the request body as JSON |
|
|
195
|
+
| `req.text(opts?)` | `Promise<string>` | Read and decode the request body as plain text |
|
|
196
|
+
| `req.formData(opts?)` | `Promise<FormPart[]>` | Read and parse a `multipart/form-data` body |
|
|
197
|
+
|
|
198
|
+
Enable proxy IP trust when running behind nginx / a load balancer:
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
const app = createRouter({ trustProxy: true });
|
|
202
|
+
```
|
|
203
|
+
|
|
147
204
|
### Response helpers
|
|
148
205
|
|
|
149
206
|
Every response object is augmented with convenience methods:
|
|
@@ -154,10 +211,44 @@ res.send(); // end with no body
|
|
|
154
211
|
res.status(404).send('Not found'); // set status + body (chainable)
|
|
155
212
|
res.status(201).end(); // set status and end
|
|
156
213
|
res.redirect('/new-url'); // 302 redirect
|
|
214
|
+
res.json({ ok: true }); // JSON body + Content-Type header
|
|
215
|
+
res.type('text/csv').send(data); // set Content-Type (chainable)
|
|
216
|
+
res.etag('v1').json(payload); // weak ETag W/"v1" (chainable)
|
|
217
|
+
res.etag(sha256hex, true).send(buf); // strong ETag "sha256hex"
|
|
218
|
+
res.download('/path/to/file.pdf'); // Content-Disposition: attachment
|
|
219
|
+
res.download('/path/to/file.pdf', 'invoice.pdf'); // custom download name
|
|
157
220
|
res.cookie('session', 'abc', {
|
|
158
221
|
maxAge: 3_600_000, // milliseconds
|
|
159
222
|
path: '/api',
|
|
160
|
-
|
|
223
|
+
httpOnly: true,
|
|
224
|
+
secure: true,
|
|
225
|
+
sameSite: 'Strict',
|
|
226
|
+
});
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
### Error handling
|
|
230
|
+
|
|
231
|
+
Register a global error handler to catch sync throws, async rejections, and `next(err)` calls:
|
|
232
|
+
|
|
233
|
+
```ts
|
|
234
|
+
app.onError((err, _req, res) => {
|
|
235
|
+
const status = (err as any)?.status ?? 500;
|
|
236
|
+
res.status(status).json({ error: String(err) });
|
|
237
|
+
});
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
Register a custom 404 handler for unmatched routes:
|
|
241
|
+
|
|
242
|
+
```ts
|
|
243
|
+
app.setNotFound((_req, res) => res.status(404).json({ error: 'Not Found' }));
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
Pass an error to `next()` from within a middleware to skip to the error handler:
|
|
247
|
+
|
|
248
|
+
```ts
|
|
249
|
+
app.use('/protected', (req, _res, next) => {
|
|
250
|
+
if (!req.headers.authorization) return next(new Error('Unauthorized'));
|
|
251
|
+
next();
|
|
161
252
|
});
|
|
162
253
|
```
|
|
163
254
|
|
|
@@ -186,9 +277,11 @@ app.use('/auth', authRouter.listener); // equivalent
|
|
|
186
277
|
|
|
187
278
|
### Starting the server
|
|
188
279
|
|
|
280
|
+
`router.listen()` returns the underlying `http.Server` (or `https.Server`) instance:
|
|
281
|
+
|
|
189
282
|
```ts
|
|
190
283
|
// HTTP
|
|
191
|
-
app.listen(3000, () => console.log('Ready'));
|
|
284
|
+
const server = app.listen(3000, () => console.log('Ready'));
|
|
192
285
|
|
|
193
286
|
// HTTPS
|
|
194
287
|
import { readFileSync } from 'fs';
|
|
@@ -196,6 +289,25 @@ app.listen(443, {
|
|
|
196
289
|
key: readFileSync('server.key'),
|
|
197
290
|
cert: readFileSync('server.crt'),
|
|
198
291
|
});
|
|
292
|
+
|
|
293
|
+
// HTTP/2
|
|
294
|
+
app.listen(443, { key, cert, http2: true });
|
|
295
|
+
|
|
296
|
+
// Graceful shutdown
|
|
297
|
+
process.on('SIGTERM', () => app.shutdown(10_000));
|
|
298
|
+
|
|
299
|
+
// Discover OS-assigned ephemeral port (useful in tests)
|
|
300
|
+
const srv = app.listen(0, () => {
|
|
301
|
+
const { port } = srv.address() as AddressInfo;
|
|
302
|
+
console.log(`Listening on port ${port}`);
|
|
303
|
+
});
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
Inspect all registered routes at runtime:
|
|
307
|
+
|
|
308
|
+
```ts
|
|
309
|
+
console.log(app.routes());
|
|
310
|
+
// [{ method: 'GET', path: '/users', stripPath: false }, ...]
|
|
199
311
|
```
|
|
200
312
|
|
|
201
313
|
---
|
|
@@ -252,6 +364,19 @@ app.post('/upload', formData(), (req, res) => {
|
|
|
252
364
|
|
|
253
365
|
Accepts the same `limit` and `inflate` options as `json()`.
|
|
254
366
|
|
|
367
|
+
### `formEncoded()`
|
|
368
|
+
|
|
369
|
+
Parses `application/x-www-form-urlencoded` bodies. Repeated keys (e.g. `tags=a&tags=b`) produce array values.
|
|
370
|
+
|
|
371
|
+
```ts
|
|
372
|
+
import { formEncoded } from 'expediate';
|
|
373
|
+
|
|
374
|
+
app.post('/form', formEncoded(), (req, res) => {
|
|
375
|
+
const { username, tags } = (req as any).body;
|
|
376
|
+
res.json({ username, tags }); // tags may be string | string[]
|
|
377
|
+
});
|
|
378
|
+
```
|
|
379
|
+
|
|
255
380
|
### `parseBody()`
|
|
256
381
|
|
|
257
382
|
Auto-detects the `Content-Type` and dispatches to the appropriate parser. Supports:
|
|
@@ -260,6 +385,7 @@ Auto-detects the `Content-Type` and dispatches to the appropriate parser. Suppor
|
|
|
260
385
|
|---|---|
|
|
261
386
|
| `application/json` | Parsed JS value |
|
|
262
387
|
| `multipart/form-data` | `FormPart[]` |
|
|
388
|
+
| `application/x-www-form-urlencoded` | `Record<string, string \| string[]>` |
|
|
263
389
|
| `text/plain` | Decoded string |
|
|
264
390
|
|
|
265
391
|
```ts
|
|
@@ -270,6 +396,25 @@ app.use('/', parseBody());
|
|
|
270
396
|
|
|
271
397
|
Unsupported MIME types receive `415 Unsupported Media Type`.
|
|
272
398
|
|
|
399
|
+
### `streamFormData()`
|
|
400
|
+
|
|
401
|
+
Async generator that yields each part of a `multipart/form-data` body as a stream, without waiting for the entire body to buffer first.
|
|
402
|
+
|
|
403
|
+
```ts
|
|
404
|
+
import { streamFormData } from 'expediate';
|
|
405
|
+
|
|
406
|
+
app.post('/upload', async (req, res) => {
|
|
407
|
+
for await (const part of streamFormData(req)) {
|
|
408
|
+
const name = part.headers['content-disposition'];
|
|
409
|
+
const chunks: Buffer[] = [];
|
|
410
|
+
for await (const chunk of part.stream) chunks.push(chunk);
|
|
411
|
+
const content = Buffer.concat(chunks);
|
|
412
|
+
// process content...
|
|
413
|
+
}
|
|
414
|
+
res.send('ok');
|
|
415
|
+
});
|
|
416
|
+
```
|
|
417
|
+
|
|
273
418
|
---
|
|
274
419
|
|
|
275
420
|
## Static files
|
|
@@ -329,6 +474,192 @@ app.get('/downloads/:file', (req, res) => {
|
|
|
329
474
|
|
|
330
475
|
---
|
|
331
476
|
|
|
477
|
+
## Middleware
|
|
478
|
+
|
|
479
|
+
A suite of production-ready middleware is included. All middleware factories return standard `Middleware` functions and can be mounted globally or on individual routes.
|
|
480
|
+
|
|
481
|
+
### `compress()`
|
|
482
|
+
|
|
483
|
+
Transparent response compression (Brotli, gzip, deflate). Must be mounted **before** any middleware that writes response bodies.
|
|
484
|
+
|
|
485
|
+
```ts
|
|
486
|
+
import { compress } from 'expediate';
|
|
487
|
+
|
|
488
|
+
app.use(compress()); // default: Brotli > gzip > deflate, threshold 1 KB
|
|
489
|
+
app.use(compress({ threshold: 512, brotliQuality: 6 }));
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
| Option | Type | Default | Description |
|
|
493
|
+
|---|---|---|---|
|
|
494
|
+
| `threshold` | `number` | `1024` | Minimum body size in bytes before compression is applied |
|
|
495
|
+
| `br` | `boolean` | `true` | Enable Brotli when the client supports it |
|
|
496
|
+
| `brotliQuality` | `number` | `4` | Brotli quality level (0–11) |
|
|
497
|
+
| `gzipLevel` | `number` | default | gzip / deflate compression level (1–9) |
|
|
498
|
+
| `filter` | `(req, res) => boolean` | — | Custom function to skip compression for specific responses |
|
|
499
|
+
|
|
500
|
+
### `conditionalGet()`
|
|
501
|
+
|
|
502
|
+
Handles `If-None-Match` and `If-Modified-Since` request headers (RFC 7232). When the response ETag or `Last-Modified` header indicates the client's cache is still fresh, a **304 Not Modified** is sent instead of the full response body.
|
|
503
|
+
|
|
504
|
+
```ts
|
|
505
|
+
import { conditionalGet } from 'expediate';
|
|
506
|
+
|
|
507
|
+
app.get('/api/user/:id', conditionalGet(), (req, res) => {
|
|
508
|
+
const user = getUser(req.params.id);
|
|
509
|
+
res.etag(user.updatedAt.toISOString()); // set before sending
|
|
510
|
+
res.json(user); // → 304 when client is up to date
|
|
511
|
+
});
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
`res.etag(value, strong?)` is a response helper available on every response:
|
|
515
|
+
|
|
516
|
+
```ts
|
|
517
|
+
res.etag('v1'); // weak ETag: W/"v1"
|
|
518
|
+
res.etag(sha256hex, true); // strong ETag: "sha256hex"
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
Freshness is checked in RFC 7232 priority order: `If-None-Match` first (weak comparison, `*` wildcard supported), then `If-Modified-Since`. Only GET and HEAD are eligible for 304 — other methods pass through unchanged.
|
|
522
|
+
|
|
523
|
+
### `cacheControl()`
|
|
524
|
+
|
|
525
|
+
Sets `Cache-Control`, `Expires`, and `Vary` response headers.
|
|
526
|
+
|
|
527
|
+
```ts
|
|
528
|
+
import { cacheControl } from 'expediate';
|
|
529
|
+
|
|
530
|
+
app.use('/api', cacheControl({ noStore: true }));
|
|
531
|
+
app.use('/static', cacheControl({ maxAge: 31_536_000, immutable: true }));
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
| Option | Type | Description |
|
|
535
|
+
|---|---|---|
|
|
536
|
+
| `maxAge` | `number` | `max-age=<seconds>`. Also sets `Expires`. |
|
|
537
|
+
| `sMaxAge` | `number` | `s-maxage=<seconds>` for shared/CDN caches. |
|
|
538
|
+
| `public` / `private` | `boolean` | Cache scope directive. |
|
|
539
|
+
| `noStore` | `boolean` | Disables caching entirely. |
|
|
540
|
+
| `noCache` | `boolean` | Requires revalidation before serving cached response. |
|
|
541
|
+
| `mustRevalidate` | `boolean` | Stale responses must be revalidated. |
|
|
542
|
+
| `immutable` | `boolean` | Response body will never change within its `max-age`. |
|
|
543
|
+
| `vary` | `string \| string[]` | Sets the `Vary` header. |
|
|
544
|
+
|
|
545
|
+
### `requestId()`
|
|
546
|
+
|
|
547
|
+
Attaches a unique `req.id` to every request and echoes it in the response header.
|
|
548
|
+
|
|
549
|
+
```ts
|
|
550
|
+
import { requestId } from 'expediate';
|
|
551
|
+
|
|
552
|
+
app.use(requestId());
|
|
553
|
+
app.get('/health', (req, res) => res.json({ id: req.id, status: 'ok' }));
|
|
554
|
+
```
|
|
555
|
+
|
|
556
|
+
| Option | Type | Default | Description |
|
|
557
|
+
|---|---|---|---|
|
|
558
|
+
| `header` | `string` | `'x-request-id'` | Header name to read and write. |
|
|
559
|
+
| `allowFromHeader` | `boolean` | `true` | Reuse client-supplied ID (set `false` in security-sensitive contexts). |
|
|
560
|
+
| `generator` | `() => string` | `crypto.randomUUID` | Custom ID generator. |
|
|
561
|
+
|
|
562
|
+
### `rateLimit()`
|
|
563
|
+
|
|
564
|
+
In-memory sliding-window rate limiting.
|
|
565
|
+
|
|
566
|
+
```ts
|
|
567
|
+
import { rateLimit } from 'expediate';
|
|
568
|
+
|
|
569
|
+
// 100 requests per minute per IP
|
|
570
|
+
app.use(rateLimit({ windowMs: 60_000, max: 100 }));
|
|
571
|
+
|
|
572
|
+
// Tighter limit on login
|
|
573
|
+
app.post('/auth/login', rateLimit({ windowMs: 60_000, max: 5 }), loginHandler);
|
|
574
|
+
```
|
|
575
|
+
|
|
576
|
+
| Option | Type | Default | Description |
|
|
577
|
+
|---|---|---|---|
|
|
578
|
+
| `windowMs` | `number` | **required** | Sliding window duration in milliseconds. |
|
|
579
|
+
| `max` | `number` | **required** | Maximum requests per client key within the window. |
|
|
580
|
+
| `keyBy` | `(req) => string` | `req.ip` | Key extraction function. |
|
|
581
|
+
| `message` | `string` | `'Too Many Requests'` | Body of the 429 response. |
|
|
582
|
+
| `statusCode` | `number` | `429` | HTTP status on limit exceeded. |
|
|
583
|
+
| `headers` | `boolean` | `true` | Set `X-RateLimit-*` headers on every response. |
|
|
584
|
+
|
|
585
|
+
> **Note:** state is in-memory and is lost on process restart. Not suitable for multi-process deployments without a shared store.
|
|
586
|
+
|
|
587
|
+
### `csrf()`
|
|
588
|
+
|
|
589
|
+
CSRF protection using the double-submit cookie pattern.
|
|
590
|
+
|
|
591
|
+
```ts
|
|
592
|
+
import { csrf } from 'expediate';
|
|
593
|
+
|
|
594
|
+
app.use(csrf());
|
|
595
|
+
app.get('/form', (req, res) =>
|
|
596
|
+
res.send(`<input type="hidden" name="_csrf" value="${req.csrfToken!()}">`));
|
|
597
|
+
// POST /form is validated automatically
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
Safe methods (GET, HEAD, OPTIONS, TRACE) are exempted. State-mutating requests must include the token in `X-CSRF-Token` header or `_csrf` body field.
|
|
601
|
+
|
|
602
|
+
| Option | Type | Default | Description |
|
|
603
|
+
|---|---|---|---|
|
|
604
|
+
| `cookieName` | `string` | `'_csrf'` | Cookie that stores the token. |
|
|
605
|
+
| `headerName` | `string` | `'x-csrf-token'` | Request header carrying the token. |
|
|
606
|
+
| `fieldName` | `string` | `'_csrf'` | Parsed body field (fallback when header absent). |
|
|
607
|
+
| `secure` | `boolean` | `false` | Mark the CSRF cookie as `Secure`. |
|
|
608
|
+
| `sameSite` | `'Strict' \| 'Lax' \| 'None'` | `'Strict'` | `SameSite` attribute of the CSRF cookie. |
|
|
609
|
+
|
|
610
|
+
### `securityHeaders()`
|
|
611
|
+
|
|
612
|
+
Sets a hardened baseline of HTTP security headers.
|
|
613
|
+
|
|
614
|
+
```ts
|
|
615
|
+
import { securityHeaders } from 'expediate';
|
|
616
|
+
|
|
617
|
+
app.use(securityHeaders());
|
|
618
|
+
// Disable HSTS on plain HTTP:
|
|
619
|
+
app.use(securityHeaders({ hsts: false }));
|
|
620
|
+
```
|
|
621
|
+
|
|
622
|
+
| Header set by default | Default value |
|
|
623
|
+
|---|---|
|
|
624
|
+
| `Strict-Transport-Security` | `max-age=15552000; includeSubDomains` |
|
|
625
|
+
| `X-Frame-Options` | `SAMEORIGIN` |
|
|
626
|
+
| `X-Content-Type-Options` | `nosniff` |
|
|
627
|
+
| `Referrer-Policy` | `strict-origin-when-cross-origin` |
|
|
628
|
+
| `Permissions-Policy` | `geolocation=(), microphone=(), camera=()` |
|
|
629
|
+
| `X-XSS-Protection` | `0` |
|
|
630
|
+
|
|
631
|
+
Every header can be individually disabled (pass `false`) or overridden with a custom string.
|
|
632
|
+
|
|
633
|
+
### `cors()`
|
|
634
|
+
|
|
635
|
+
Adds Cross-Origin Resource Sharing headers to responses. CORS headers are only set when the request includes an `Origin` header (standard browser behaviour). OPTIONS preflight requests are handled and terminated; other requests call `next()`.
|
|
636
|
+
|
|
637
|
+
```ts
|
|
638
|
+
import { cors } from 'expediate';
|
|
639
|
+
|
|
640
|
+
app.use(cors({ origin: 'https://example.com' }));
|
|
641
|
+
|
|
642
|
+
// Allow multiple origins, credentials, and custom max-age
|
|
643
|
+
app.use(cors({
|
|
644
|
+
origin: ['https://app.example.com', 'https://admin.example.com'],
|
|
645
|
+
allowCredentials: true,
|
|
646
|
+
maxAge: 86400,
|
|
647
|
+
}));
|
|
648
|
+
```
|
|
649
|
+
|
|
650
|
+
| Option | Type | Default | Description |
|
|
651
|
+
|---|---|---|---|
|
|
652
|
+
| `origin` | `string \| string[]` | `'*'` | Value of `Access-Control-Allow-Origin` |
|
|
653
|
+
| `allowHeaders` | `string \| string[]` | `'Accept, Content-Type, Authorization'` | Allowed request headers |
|
|
654
|
+
| `allowMethods` | `string \| string[]` | `'GET,HEAD,PUT,PATCH,POST,DELETE'` | Allowed HTTP methods |
|
|
655
|
+
| `allowCredentials` | `boolean` | — | Set `Access-Control-Allow-Credentials: true` |
|
|
656
|
+
| `maxAge` | `number` | — | Preflight cache lifetime in seconds |
|
|
657
|
+
| `vary` | `string \| string[]` | — | Value of the `Vary` response header |
|
|
658
|
+
| `optionsStatus` | `number` | `204` | Status code for OPTIONS preflight responses |
|
|
659
|
+
| `preflight` | `(req) => boolean` | — | Custom guard: return `false` to reject the request (OPTIONS → 403, others → 400) |
|
|
660
|
+
|
|
661
|
+
---
|
|
662
|
+
|
|
332
663
|
## Request logging
|
|
333
664
|
|
|
334
665
|
```ts
|
|
@@ -469,14 +800,19 @@ const auth = createJwtPlugin({
|
|
|
469
800
|
issuer: 'my-app',
|
|
470
801
|
checkIssuer: true, // reject tokens with wrong iss claim
|
|
471
802
|
alg: 'HS256', // 'HS256' | 'HS384' | 'HS512'
|
|
803
|
+
// 'RS256' | 'RS384' | 'RS512'
|
|
804
|
+
// 'ES256' | 'ES384' | 'ES512'
|
|
805
|
+
|
|
806
|
+
// For RS*/ES* algorithms supply PEM keys instead of a shared secret
|
|
807
|
+
// accessTokenPrivateKey: readFileSync('private.pem', 'utf8'),
|
|
808
|
+
// accessTokenPublicKey: readFileSync('public.pem', 'utf8'),
|
|
472
809
|
|
|
473
810
|
// User lookup (replace with a database query) — must override
|
|
474
|
-
// Returned object must have a username field
|
|
475
811
|
fetchUser: async (username) => {
|
|
476
812
|
return await db.users.findOne({ username });
|
|
477
813
|
},
|
|
478
814
|
|
|
479
|
-
// Password validation — default
|
|
815
|
+
// Password validation — default uses SHA-256; replace with bcrypt/argon2
|
|
480
816
|
isPasswordValid: async (user, password) => {
|
|
481
817
|
return await bcrypt.compare(password, user.passwordHash);
|
|
482
818
|
},
|
|
@@ -493,6 +829,13 @@ const auth = createJwtPlugin({
|
|
|
493
829
|
});
|
|
494
830
|
```
|
|
495
831
|
|
|
832
|
+
A built-in in-memory token store factory is available for testing:
|
|
833
|
+
|
|
834
|
+
```ts
|
|
835
|
+
import { createMapTokenStore } from 'expediate';
|
|
836
|
+
const auth = createJwtPlugin({ refreshTokenStore: createMapTokenStore() });
|
|
837
|
+
```
|
|
838
|
+
|
|
496
839
|
> **Security note:** the default password hashing uses SHA-256, which is fast and unsuitable for production. Replace `isPasswordValid` with a bcrypt or argon2 implementation.
|
|
497
840
|
|
|
498
841
|
---
|
|
@@ -520,7 +863,7 @@ const todoService: ServiceDefinition<TodoState> = {
|
|
|
520
863
|
methods: {
|
|
521
864
|
findOrThrow(this: TodoState, id: string) {
|
|
522
865
|
const item = this.items[id];
|
|
523
|
-
if (!item) throw {
|
|
866
|
+
if (!item) throw { status: 404, message: 'Todo not found' };
|
|
524
867
|
return item;
|
|
525
868
|
},
|
|
526
869
|
},
|
|
@@ -560,8 +903,8 @@ app.listen(3000);
|
|
|
560
903
|
|---|---|
|
|
561
904
|
| Truthy value or `Promise` resolving to one | `200 OK` with JSON body |
|
|
562
905
|
| `undefined`, `null`, `false`, `0`, `''` | `201 No Content` |
|
|
563
|
-
| Throw `{
|
|
564
|
-
| Throw `{
|
|
906
|
+
| Throw `{ status, message }` | `<status>` with plain-text body |
|
|
907
|
+
| Throw `{ status, data }` | `<status>` with JSON body |
|
|
565
908
|
| Throw anything else | `500 Internal Server Error` |
|
|
566
909
|
|
|
567
910
|
### Scoping
|
|
@@ -585,22 +928,22 @@ const service: ServiceDefinition = {
|
|
|
585
928
|
|
|
586
929
|
> The key returned by the `scope()` method is store at `this.$key`.
|
|
587
930
|
|
|
588
|
-
###
|
|
931
|
+
### Service error handling
|
|
589
932
|
|
|
590
933
|
Throw structured errors from any handler or method to send precise HTTP responses:
|
|
591
934
|
|
|
592
935
|
```ts
|
|
593
936
|
// Plain message
|
|
594
|
-
throw {
|
|
937
|
+
throw { status: 404, message: 'Resource not found' };
|
|
595
938
|
|
|
596
939
|
// JSON body
|
|
597
|
-
throw {
|
|
940
|
+
throw { status: 422, data: { field: 'email', error: 'invalid format' } };
|
|
598
941
|
|
|
599
942
|
// Guard pattern for async setup
|
|
600
943
|
methods: {
|
|
601
944
|
throwIfNotReady(this: any) {
|
|
602
945
|
if (!this.ready)
|
|
603
|
-
throw {
|
|
946
|
+
throw { status: 503, message: 'Service initialising — try again shortly' };
|
|
604
947
|
},
|
|
605
948
|
},
|
|
606
949
|
setup: function (this: any) {
|
|
@@ -640,7 +983,7 @@ git clone http://localhost:3000/repos/myproject
|
|
|
640
983
|
|
|
641
984
|
| Option | Type | Default | Description |
|
|
642
985
|
|---|---|---|---|
|
|
643
|
-
| `repository` | `(req) => string \| null
|
|
986
|
+
| `repository` | `(req) => string \| null | Promise<string \| null>` | **required** | Resolve the absolute path to the bare repository. Return falsy to send 404 |
|
|
644
987
|
| `gitPath` | `string` | `''` | Directory prefix for the `git-upload-pack` binary (include trailing `/`) |
|
|
645
988
|
| `strict` | `boolean` | `false` | When `true`, omits `--no-strict` — git will reject non-bare repositories |
|
|
646
989
|
| `timeout` | `number \| string` | none | Kill `git-upload-pack` after this many **seconds** |
|
|
@@ -656,28 +999,77 @@ Gzip-compressed `POST` bodies are decompressed transparently. Spawn errors (e.g.
|
|
|
656
999
|
|
|
657
1000
|
---
|
|
658
1001
|
|
|
1002
|
+
## OpenAPI spec generation
|
|
1003
|
+
|
|
1004
|
+
Annotate service definitions and generate an OpenAPI 3.1 document automatically.
|
|
1005
|
+
|
|
1006
|
+
```ts
|
|
1007
|
+
import { describe, openApiSpec, serializeSpec } from 'expediate';
|
|
1008
|
+
|
|
1009
|
+
const todoService = describe({
|
|
1010
|
+
summary: 'Todo list API',
|
|
1011
|
+
description: 'Manage todos',
|
|
1012
|
+
GET: {
|
|
1013
|
+
'/todos': {
|
|
1014
|
+
summary: 'List all todos',
|
|
1015
|
+
responses: { 200: { description: 'Todo array' } },
|
|
1016
|
+
handler: function (this: TodoState) { return Object.values(this.items); },
|
|
1017
|
+
},
|
|
1018
|
+
},
|
|
1019
|
+
POST: {
|
|
1020
|
+
'/todos': {
|
|
1021
|
+
summary: 'Create a todo',
|
|
1022
|
+
requestBody: { required: true },
|
|
1023
|
+
responses: { 200: { description: 'Created todo' } },
|
|
1024
|
+
handler: function (this: TodoState, _params, body: any) {
|
|
1025
|
+
const id = String(this.nextId++);
|
|
1026
|
+
this.items[id] = { title: body.title, done: false };
|
|
1027
|
+
return { id, ...this.items[id] };
|
|
1028
|
+
},
|
|
1029
|
+
},
|
|
1030
|
+
},
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
const app = createRouter();
|
|
1034
|
+
app.use('/api', apiBuilder(todoService));
|
|
1035
|
+
app.get('/openapi.json', openApiSpec(todoService, { title: 'Todo API', version: '1.0.0' }));
|
|
1036
|
+
app.get('/openapi.yaml', openApiSpec(todoService, { title: 'Todo API', version: '1.0.0', format: 'yaml' }));
|
|
1037
|
+
```
|
|
1038
|
+
|
|
1039
|
+
---
|
|
1040
|
+
|
|
659
1041
|
## TypeScript types
|
|
660
1042
|
|
|
661
1043
|
Full type declarations are included. Key types exported from the package:
|
|
662
1044
|
|
|
663
1045
|
```ts
|
|
664
1046
|
// Router
|
|
665
|
-
import type {
|
|
1047
|
+
import type {
|
|
1048
|
+
Router, RouterOptions, RouterRequest, RouterResponse,
|
|
1049
|
+
Middleware, MiddlewareArg, NextFunction, ErrorHandler,
|
|
1050
|
+
Layer, RouteInfo, CookieOptions, TlsOptions, StringMap,
|
|
1051
|
+
} from 'expediate';
|
|
666
1052
|
|
|
667
1053
|
// Body parsing
|
|
668
|
-
import type { BodyOptions, FormPart } from 'expediate';
|
|
1054
|
+
import type { BodyOptions, FormPart, FormPartStream, LoggerOptions } from 'expediate';
|
|
669
1055
|
|
|
670
1056
|
// Static files
|
|
671
|
-
import type { StaticOptions } from 'expediate';
|
|
1057
|
+
import type { StaticOptions, Mime } from 'expediate';
|
|
672
1058
|
|
|
673
|
-
//
|
|
674
|
-
import type {
|
|
1059
|
+
// Middleware
|
|
1060
|
+
import type {
|
|
1061
|
+
CompressOptions, RequestIdOptions, RateLimitOptions,
|
|
1062
|
+
CacheControlOptions, CsrfOptions, SecurityHeadersOptions,
|
|
1063
|
+
} from 'expediate';
|
|
675
1064
|
|
|
676
1065
|
// JWT
|
|
677
|
-
import type { JwtConfig, JwtPlugin, TokenPayload, UserRecord, TokenStore } from 'expediate';
|
|
1066
|
+
import type { JwtConfig, JwtPlugin, TokenPayload, UserRecord, TokenStore, RefreshTokenRecord } from 'expediate';
|
|
678
1067
|
|
|
679
1068
|
// API builder
|
|
680
|
-
import type { ServiceDefinition, ServiceMethod, ApiError } from 'expediate';
|
|
1069
|
+
import type { ServiceDefinition, ServiceMethod, ServiceMethods, RouteMap, ApiError } from 'expediate';
|
|
1070
|
+
|
|
1071
|
+
// OpenAPI
|
|
1072
|
+
import type { OperationMeta, OpenApiServiceMeta, SpecOptions, OpenApiDocument } from 'expediate';
|
|
681
1073
|
|
|
682
1074
|
// Git
|
|
683
1075
|
import type { GitHandlerOptions } from 'expediate';
|
|
@@ -688,8 +1080,3 @@ import type { GitHandlerOptions } from 'expediate';
|
|
|
688
1080
|
## License
|
|
689
1081
|
|
|
690
1082
|
MIT © 2021 Fabien Bavent
|
|
691
|
-
|
|
692
|
-
[npm-image]: https://img.shields.io/npm/v/expediate.svg
|
|
693
|
-
[npm-url]: https://npmjs.org/package/expediate
|
|
694
|
-
[downloads-image]: https://img.shields.io/npm/dm/expediate.svg
|
|
695
|
-
[downloads-url]: https://npmcharts.com/compare/expediate?minimal=true
|