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.
Files changed (52) hide show
  1. package/LICENSE +16 -16
  2. package/README.md +417 -30
  3. package/dist/apis.d.ts +138 -21
  4. package/dist/apis.d.ts.map +1 -1
  5. package/dist/apis.js +172 -79
  6. package/dist/apis.js.map +1 -1
  7. package/dist/cjs/apis.js +327 -0
  8. package/dist/cjs/git.js +293 -0
  9. package/dist/cjs/index.js +2583 -0
  10. package/dist/cjs/jwt-auth.js +532 -0
  11. package/dist/cjs/middleware.js +511 -0
  12. package/dist/cjs/mimetypes.json +1 -0
  13. package/dist/cjs/misc.js +787 -0
  14. package/dist/cjs/openapi.js +485 -0
  15. package/dist/cjs/package.json +1 -0
  16. package/dist/cjs/router.js +898 -0
  17. package/dist/cjs/static.js +669 -0
  18. package/dist/git.d.ts +71 -8
  19. package/dist/git.d.ts.map +1 -1
  20. package/dist/git.js +127 -72
  21. package/dist/git.js.map +1 -1
  22. package/dist/index.d.ts +17 -13
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +14 -24
  25. package/dist/index.js.map +1 -1
  26. package/dist/jwt-auth.d.ts +147 -57
  27. package/dist/jwt-auth.d.ts.map +1 -1
  28. package/dist/jwt-auth.js +445 -205
  29. package/dist/jwt-auth.js.map +1 -1
  30. package/dist/middleware.d.ts +476 -0
  31. package/dist/middleware.d.ts.map +1 -0
  32. package/dist/middleware.js +647 -0
  33. package/dist/middleware.js.map +1 -0
  34. package/dist/mimetypes.json +1 -1
  35. package/dist/misc.d.ts +112 -5
  36. package/dist/misc.d.ts.map +1 -1
  37. package/dist/misc.js +235 -102
  38. package/dist/misc.js.map +1 -1
  39. package/dist/openapi.d.ts +290 -0
  40. package/dist/openapi.d.ts.map +1 -0
  41. package/dist/openapi.js +481 -0
  42. package/dist/openapi.js.map +1 -0
  43. package/dist/router.d.ts +405 -46
  44. package/dist/router.d.ts.map +1 -1
  45. package/dist/router.js +658 -153
  46. package/dist/router.js.map +1 -1
  47. package/dist/static.d.ts +1 -1
  48. package/dist/static.d.ts.map +1 -1
  49. package/dist/static.js +88 -84
  50. package/dist/static.js.map +1 -1
  51. package/package.json +21 -4
  52. package/.npmignore +0 -16
package/LICENSE CHANGED
@@ -1,21 +1,21 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2021 Fabien Bavent
3
+ Copyright © 2021 Fabien Bavent
4
4
 
5
- Permission is hereby granted, free of charge, to any person obtaining a copy of
6
- this software and associated documentation files (the "Software"), to deal in
7
- the Software without restriction, including without limitation the rights to
8
- use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
9
- of the Software, and to permit persons to whom the Software is furnished to do
10
- so, subject to the following conditions:
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 all
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
- 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.
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
- # expediate
1
+ <p align="center">
2
+ <img src="docs/expediate.png" alt="expediate" width="260" />
3
+ </p>
2
4
 
3
- A lightweight, zero-dependency TypeScript HTTP routing framework for Node.js.
5
+ <p align="center">
6
+ A lightweight, zero-dependency TypeScript HTTP routing framework for Node.js.
7
+ </p>
4
8
 
5
- **expediate** provides an Express-compatible API surface with full TypeScript types, built-in body parsing, static file serving, JWT authentication, multipart form handling, and a Git Smart HTTP gateway — all in a single package with no runtime dependencies beyond Node.js itself.
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
- - [Error handling](#error-handling)
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
- signed: false,
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 look for SHA256(user.passwordHash), replace with bcrypt/argon2
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 { httpStatus: 404, message: 'Todo not found' };
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 `{ httpStatus, message }` | `<httpStatus>` with plain-text body |
564
- | Throw `{ httpStatus, data }` | `<httpStatus>` with JSON body |
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
- ### Error handling
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 { httpStatus: 404, message: 'Resource not found' };
937
+ throw { status: 404, message: 'Resource not found' };
595
938
 
596
939
  // JSON body
597
- throw { httpStatus: 422, data: { field: 'email', error: 'invalid format' } };
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 { httpStatus: 503, message: 'Service initialising — try again shortly' };
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` | **required** | Resolve the absolute path to the bare repository. Return falsy to send 404 |
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 { Router, RouterRequest, RouterResponse, Middleware, MiddlewareArg } from 'expediate';
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
- // Logging
674
- import type { LoggerOptions } from 'expediate';
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