expediate 1.0.5 → 1.0.6

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 (73) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/CONTRIBUTING.md +150 -0
  3. package/README.md +278 -779
  4. package/dist/apis.d.ts +372 -12
  5. package/dist/apis.d.ts.map +1 -1
  6. package/dist/apis.js +483 -65
  7. package/dist/apis.js.map +1 -1
  8. package/dist/cjs/index.js +2290 -807
  9. package/dist/git.d.ts +1 -1
  10. package/dist/git.d.ts.map +1 -1
  11. package/dist/git.js +5 -5
  12. package/dist/git.js.map +1 -1
  13. package/dist/http-objects.d.ts +26 -0
  14. package/dist/http-objects.d.ts.map +1 -0
  15. package/dist/http-objects.js +588 -0
  16. package/dist/http-objects.js.map +1 -0
  17. package/dist/index.d.ts +6 -5
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +2 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/jwt-auth.d.ts +11 -0
  22. package/dist/jwt-auth.d.ts.map +1 -1
  23. package/dist/jwt-auth.js +9 -9
  24. package/dist/jwt-auth.js.map +1 -1
  25. package/dist/middleware.js +2 -2
  26. package/dist/middleware.js.map +1 -1
  27. package/dist/mimetypes.json +882 -1
  28. package/dist/misc.d.ts +161 -25
  29. package/dist/misc.d.ts.map +1 -1
  30. package/dist/misc.js +228 -80
  31. package/dist/misc.js.map +1 -1
  32. package/dist/openapi.d.ts +156 -13
  33. package/dist/openapi.d.ts.map +1 -1
  34. package/dist/openapi.js +214 -71
  35. package/dist/openapi.js.map +1 -1
  36. package/dist/router-types.d.ts +760 -0
  37. package/dist/router-types.d.ts.map +1 -0
  38. package/dist/router-types.js +23 -0
  39. package/dist/router-types.js.map +1 -0
  40. package/dist/router.d.ts +7 -530
  41. package/dist/router.d.ts.map +1 -1
  42. package/dist/router.js +128 -375
  43. package/dist/router.js.map +1 -1
  44. package/dist/static.d.ts +2 -2
  45. package/dist/static.d.ts.map +1 -1
  46. package/dist/static.js +77 -22
  47. package/dist/static.js.map +1 -1
  48. package/docs/THREAT_MODEL.md +52 -0
  49. package/docs/api-builder-v2-design.md +644 -0
  50. package/docs/api-builder-v3-design.md +397 -0
  51. package/docs/api-builder.md +454 -0
  52. package/docs/benchmark.md +27 -0
  53. package/docs/body-parsing.md +223 -0
  54. package/docs/errors.md +359 -0
  55. package/docs/expediate.png +0 -0
  56. package/docs/git.md +139 -0
  57. package/docs/jwt-auth.md +251 -0
  58. package/docs/logo.svg +12 -0
  59. package/docs/middleware.md +264 -0
  60. package/docs/openapi.md +180 -0
  61. package/docs/router.md +356 -0
  62. package/docs/static.md +128 -0
  63. package/docs/wiki.json +123 -0
  64. package/package.json +30 -8
  65. package/dist/cjs/apis.js +0 -327
  66. package/dist/cjs/git.js +0 -293
  67. package/dist/cjs/jwt-auth.js +0 -532
  68. package/dist/cjs/middleware.js +0 -511
  69. package/dist/cjs/mimetypes.json +0 -1
  70. package/dist/cjs/misc.js +0 -787
  71. package/dist/cjs/openapi.js +0 -485
  72. package/dist/cjs/router.js +0 -898
  73. package/dist/cjs/static.js +0 -669
package/README.md CHANGED
@@ -15,8 +15,7 @@
15
15
 
16
16
  ---
17
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.
19
-
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 with **zero runtime dependencies** beyond Node.js itself.
20
19
 
21
20
  ---
22
21
 
@@ -24,45 +23,15 @@
24
23
 
25
24
  - [Installation](#installation)
26
25
  - [Quick start](#quick-start)
26
+ - [Why Expediate?](#why-expediate)
27
27
  - [Router](#router)
28
- - [Creating a router](#creating-a-router)
29
- - [Route registration](#route-registration)
30
- - [Path patterns](#path-patterns)
31
- - [Request fields](#request-fields)
32
- - [Response helpers](#response-helpers)
33
- - [Error handling](#error-handling)
34
- - [Sub-routers](#sub-routers)
35
- - [Starting the server](#starting-the-server)
36
28
  - [Body parsing](#body-parsing)
37
- - [`json()`](#json)
38
- - [`formData()`](#formdata)
39
- - [`formEncoded()`](#formencoded)
40
- - [`parseBody()`](#parsebody)
41
- - [`streamFormData()`](#streamformdata)
42
29
  - [Static files](#static-files)
43
- - [`serveStatic()`](#servestatic)
44
- - [`serveFile()`](#servefile)
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)
54
- - [Request logging](#request-logging)
55
- - [JWT Authentication](#jwt-authentication)
56
- - [Setup](#setup)
57
- - [Protecting routes](#protecting-routes)
58
- - [Role and permission guards](#role-and-permission-guards)
59
- - [Configuration reference](#configuration-reference)
30
+ - [Middleware suite](#middleware-suite)
31
+ - [JWT authentication](#jwt-authentication)
60
32
  - [API service builder](#api-service-builder)
61
- - [Defining a service](#defining-a-service)
62
- - [Scoping](#scoping)
63
- - [Service error handling](#service-error-handling)
64
- - [Git Smart HTTP gateway](#git-smart-http-gateway)
65
33
  - [OpenAPI spec generation](#openapi-spec-generation)
34
+ - [Git Smart HTTP gateway](#git-smart-http-gateway)
66
35
  - [TypeScript types](#typescript-types)
67
36
 
68
37
  ---
@@ -73,7 +42,9 @@
73
42
  npm install expediate
74
43
  ```
75
44
 
76
- Node.js ≥ 18 is required. The package ships as native ESM with full TypeScript declarations.
45
+ Node.js ≥ 18.20.0 is required — the floor where `with { type: 'json' }` JSON import attributes exist (the ESM build uses one for `mimetypes.json`); on 18.20.0–18.20.4, 20.10.0–20.18.2, and all of 21.x, Node logs an `ExperimentalWarning` for that syntax but runs fine. For a warning-free run, use Node ≥ 18.20.5, ≥ 20.18.3, or ≥ 22.12.0. The package ships as native ESM with full TypeScript declarations and a CommonJS compatibility bundle (the CJS build is bundled with esbuild specifically so this JSON import works under `require()` too).
46
+
47
+ **Bun / Deno:** not officially tested, but likely to work with caveats. Both runtimes support `with { type: 'json' }` and the package's `exports` map resolves cleanly under either. The main risk area is `src/router.ts`'s use of `node:http2.createSecureServer` and raw socket fields (`req.socket.setTimeout`, `req.socket.remoteAddress`, `req.socket.encrypted`) for HTTP/2 + TLS: Bun's `node:http2` server support has known gaps (no `allowHTTP1`, no `enableConnectProtocol`, no ALPN-based push), and Deno's `node:http2` compatibility, while improving, is newer and less battle-tested than its `node:http`. Plain HTTP/HTTPS routes are the safer bet on either runtime.
77
48
 
78
49
  ---
79
50
 
@@ -84,16 +55,15 @@ import { createRouter, json, logger } from 'expediate';
84
55
 
85
56
  const app = createRouter();
86
57
 
87
- app.use('/', logger());
88
- app.use('/', json());
58
+ app.use(logger());
59
+ app.use(json());
89
60
 
90
61
  app.get('/hello/:name', (req, res) => {
91
62
  res.send(`Hello, ${req.params.name}!`);
92
63
  });
93
64
 
94
65
  app.post('/echo', (req, res) => {
95
- res.setHeader('Content-Type', 'application/json');
96
- res.send(JSON.stringify((req as any).body));
66
+ res.json((req as any).body);
97
67
  });
98
68
 
99
69
  app.listen(3000, () => console.log('Listening on :3000'));
@@ -101,871 +71,443 @@ app.listen(3000, () => console.log('Listening on :3000'));
101
71
 
102
72
  ---
103
73
 
74
+ ## Why Expediate?
75
+
76
+ - **Zero runtime dependencies.** The entire framework — router, body parsers, static files, compression, JWT, Git gateway, OpenAPI — uses only Node.js built-ins.
77
+ - **TypeScript-first.** Strict compiler settings, full type declarations for every public API.
78
+ - **Express-compatible API.** Familiar route registration, `req`/`res` helpers, middleware signature, signed cookies, and error handling — easy migration path.
79
+ - **Real HTTP testing.** The test suite spins up live servers on ephemeral ports rather than mocking. Behaviour you see in tests is behaviour you get in production.
80
+ - **ESM + CJS dual output.** Works with both `import` and `require`.
81
+
82
+ ---
83
+
104
84
  ## Router
105
85
 
106
- ### Creating a router
86
+ Full reference: [docs/router.md](docs/router.md)
107
87
 
108
88
  ```ts
109
89
  import { createRouter } from 'expediate';
110
90
 
111
- const app = createRouter();
91
+ const app = createRouter({
92
+ secret: process.env.COOKIE_SECRET,
93
+ timeout: 30_000, // 408 if no response starts within 30 s
94
+ trustProxy: true, // trust X-Forwarded-* headers
95
+ });
112
96
  ```
113
97
 
114
- `createRouter()` returns a `Router` object that also acts as a middleware function itself, making it nestable.
115
-
116
98
  ### Route registration
117
99
 
118
- All registration methods share the same signature:
119
-
120
- ```ts
121
- router.METHOD(path, ...middleware)
122
- ```
123
-
124
- | Method | HTTP verb | Notes |
125
- |---|---|---|
126
- | `router.get(path, ...mw)` | `GET` | Path is not stripped from `req.path` — chained middlewares see the full path |
127
- | `router.post(path, ...mw)` | `POST` | |
128
- | `router.put(path, ...mw)` | `PUT` | |
129
- | `router.delete(path, ...mw)` | `DELETE` | |
130
- | `router.patch(path, ...mw)` | `PATCH` | |
131
- | `router.use(path, ...mw)` | any | **Strips the matched prefix** from `req.path` before calling middleware; used for mounting sub-routers |
132
- | `router.all(path, ...mw)` | any | Matches any method but don't strips the prefix |
133
-
134
- Each middleware slot accepts a `Middleware` function, a `Router` instance, or an array of either:
135
-
136
100
  ```ts
137
- const guard: Middleware = (req, res, next) => {
138
- if (!req.headers.authorization) return res.status(401).end();
139
- next();
140
- };
141
-
142
- app.get('/secret', guard, (req, res) => res.send('classified'));
143
- app.get('/multi', [guard, anotherMiddleware], handler);
101
+ app.get('/users', listUsers); // endpoint match (GET only)
102
+ app.post('/users', createUser);
103
+ app.put('/users/:id', updateUser);
104
+ app.delete('/users/:id', deleteUser);
105
+ app.all('/health', handler); // any method, endpoint match
106
+ app.use('/api', apiRouter); // prefix mount strips /api from req.path
144
107
  ```
145
108
 
146
- ### Path patterns
109
+ `use()` strips the matched prefix from `req.path` and sets `req.baseUrl` for nested routers. Method routes (`get`, `post`, etc.) use **endpoint matching** — `get('/users')` matches `/users` but not `/users/42`.
147
110
 
148
- Three pattern types are supported:
111
+ To register several methods against one path without repeating it, use the chainable `route()` builder:
149
112
 
150
- **Plain strings with `:param` segments**
151
113
  ```ts
152
- app.get('/users/:id', handler); // req.params.id
153
- app.get('/orgs/:org/repos/:repo', handler); // req.params.org, req.params.repo
114
+ app.route('/users/:id')
115
+ .get(getUser)
116
+ .put(replaceUser)
117
+ .delete(removeUser);
154
118
  ```
155
119
 
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:
120
+ Each call forwards to the matching method (`get`, `post`, …) with the cached path and returns the builder, so behaviour is identical to calling those methods directly.
157
121
 
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).
122
+ **Method handling.** `HEAD` requests are served by the matching `GET` handler (Node strips the response body). An unhandled `OPTIONS` request on a registered path automatically returns `204 No Content` with an `Allow` header, and a method mismatch on a registered path returns `405 Method Not Allowed` with `Allow` (both list `HEAD` when a `GET` exists, plus `OPTIONS`). Register `app.head(path, …)` or `app.options(path, …)` (also on the `route()` builder) for method-specific handlers; explicit handlers and `cors()` run first, so they override these defaults.
165
123
 
166
- **Glob patterns** (`.gitignore` wildcard rules)
167
- ```ts
168
- app.get('/api/*', handler); // one path segment
169
- app.get('/api/**', handler); // any depth
170
- app.get('/**/*.php', handler); // PHP files in any subdirectory
171
- app.get('/v?/status', handler); // one character wildcard
172
- ```
124
+ ### Path patterns
173
125
 
174
- **Regular expressions** — named capture groups become `req.params` entries
175
126
  ```ts
176
- app.get(/^\/users\/(?<id>\d+)/, handler); // req.params.id
127
+ app.get('/users/:id', handler); // named param
128
+ app.get('/items/:id(\\d+)', handler); // inline regex constraint (digits only)
129
+ app.get('/api/**', handler); // glob — any depth
130
+ app.get(/^\/v\d+\/status/, handler); // RegExp (no /g or /y flags)
177
131
  ```
178
132
 
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.
180
-
181
133
  ### Request fields
182
134
 
183
- Every request object is augmented with additional fields before any middleware runs:
135
+ Every request is augmented before middleware runs:
184
136
 
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
- ```
137
+ | Field | Description |
138
+ |---|---|
139
+ | `req.params` | Named route params + flat URL query params |
140
+ | `req.query` | URL query params (repeated keys arrays) |
141
+ | `req.path` | Current path (stripped by `use()` layers) |
142
+ | `req.baseUrl` | Accumulated prefix from parent `use()` mounts |
143
+ | `req.hostname` | `Host` header with port stripped |
144
+ | `req.protocol` | `'http'` or `'https'` |
145
+ | `req.secure` | `true` when protocol is `https` |
146
+ | `req.ip` | Remote IP (respects `X-Forwarded-For` when `trustProxy: true`) |
147
+ | `req.ips` | Full XFF chain as an array |
148
+ | `req.cookies` | Parsed cookies; `s:` values are HMAC-verified |
149
+ | `req.json()` | Parse body as JSON (Promise) |
150
+ | `req.text()` | Read body as text (Promise) |
151
+ | `req.formData()` | Parse body as multipart (Promise) |
152
+ | `req.header(name)` | Read a request header by name (case-insensitive) |
153
+
154
+ The `req.json()`, `req.text()`, and `req.formData()` helpers accept the same `BodyOptions` as the parser middleware, including `limit`, `inflate`, and the `verify` hook (a throw rejects the returned promise with the error's `status`, default `403`).
203
155
 
204
156
  ### Response helpers
205
157
 
206
- Every response object is augmented with convenience methods:
207
-
208
158
  ```ts
209
- res.send('Hello'); // write body and end
210
- res.send(); // end with no body
211
- res.status(404).send('Not found'); // set status + body (chainable)
212
- res.status(201).end(); // set status and end
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
220
- res.cookie('session', 'abc', {
221
- maxAge: 3_600_000, // milliseconds
222
- path: '/api',
223
- httpOnly: true,
224
- secure: true,
225
- sameSite: 'Strict',
226
- });
159
+ res.send('Hello'); // write and end
160
+ res.json({ ok: true }); // JSON body + Content-Type
161
+ res.status(201).send('Created'); // set status code (integer 100–999)
162
+ res.redirect('/new-url'); // 302 Found
163
+ res.type('text/csv').send(data); // set Content-Type
164
+ res.etag('v1').json(payload); // weak ETag W/"v1"
165
+ res.cookie('session', 'abc', { ... }); // Set-Cookie (value percent-encoded)
166
+ res.clearCookie('session'); // Max-Age=0 + Expires=epoch
167
+ res.download('/path/file.pdf'); // Content-Disposition: attachment
168
+ res.attachment('report.pdf').send(buf); // set disposition + Content-Type
169
+ res.sendStatus(200); // status + standard text body
170
+ res.header('Cache-Control', 'no-store'); // set a header (replaces)
171
+ res.append('X-Custom', 'v1'); // append to header
172
+ res.vary('Accept'); // add to Vary header
173
+ res.location('/new-path'); // set Location
174
+ res.locals['user'] = currentUser; // request-scoped storage
227
175
  ```
228
176
 
229
177
  ### Error handling
230
178
 
231
- Register a global error handler to catch sync throws, async rejections, and `next(err)` calls:
179
+ Full reference: [docs/errors.md](docs/errors.md)
232
180
 
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
- ```
181
+ Any error thrown by a middleware — synchronous throw, rejected `async` middleware, or an explicit `next(err)` — enters the router's **error channel**.
239
182
 
240
- Register a custom 404 handler for unmatched routes:
183
+ Register ordered error middleware with `app.error()`. The error value is the **first** argument (to distinguish it from a normal middleware). Each handler either ends the response or calls `next` to pass control along — `next()` forwards the same error, `next(err)` replaces it:
241
184
 
242
185
  ```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:
186
+ app.error((err, _req, res, next) => {
187
+ if ((err as any)?.status === 404) return res.status(404).json({ error: 'Not Found' });
188
+ next(err); // not ours — let the next handler (or the parent router) deal with it
189
+ });
247
190
 
248
- ```ts
249
- app.use('/protected', (req, _res, next) => {
250
- if (!req.headers.authorization) return next(new Error('Unauthorized'));
251
- next();
191
+ app.error((err, _req, res, _next) => {
192
+ res.status((err as any)?.status ?? 500).json({ error: String(err) });
252
193
  });
253
194
  ```
254
195
 
255
- <!-- Every response also carries `X-Powered-By: Expediate`. -->
256
-
257
- ### Sub-routers
258
-
259
- Routers are fully nestable. Use `router.use()` to mount a child router under a prefix — the prefix is stripped from `req.path` before child middleware runs:
196
+ **Bubbling.** When a router's `error()` chain is exhausted without ending the response, the error falls back to that router's `onError()` handler (if any), and otherwise **bubbles up to the parent router** that mounted it via `use()`. This means a single handler on the root router can catch failures raised deep inside nested sub-routers. A top-level router with no handler sends a plain `500`.
260
197
 
261
198
  ```ts
262
199
  const api = createRouter();
263
- api.get('/users', listUsers);
264
- api.get('/users/:id', getUser);
265
- api.post('/users', createUser);
200
+ api.get('/items/:id', () => { throw new Error('boom'); });
266
201
 
267
202
  const app = createRouter();
268
- app.use('/api/v1', api); // /api/v1/users api sees /users
203
+ app.use('/api', api); // api has no error handler…
204
+ app.error((err, _req, res) => // …so the failure bubbles up to here
205
+ res.status(500).json({ error: String(err) }));
269
206
  ```
270
207
 
271
- Pass a `Router` instance directly (no need to unwrap `.listener`):
208
+ `onError()` remains available as a simple, single terminal fallback `(err, req, res)` with no `next` handy when you only want one catch-all and no bubbling.
209
+
210
+ > Caveat: only the returned promise is tracked. A middleware that calls `next()` and *then* throws later from a detached callback (`setTimeout`, an event emitter) is outside the framework's reach.
211
+
212
+ For a custom 404, register a catch-all as the **last** layer. Layers match in registration order, so it only runs when no earlier route claimed the request (`/**` matches any path, and `all()` matches any method):
272
213
 
273
214
  ```ts
274
- app.use('/auth', authRouter); // Router instance
275
- app.use('/auth', authRouter.listener); // equivalent
215
+ app.all('/**', (_req, res) => res.status(404).json({ error: 'Not Found' }));
276
216
  ```
277
217
 
278
- ### Starting the server
218
+ Without one, unmatched requests fall back to the built-in `Cannot METHOD /path` 404.
279
219
 
280
- `router.listen()` returns the underlying `http.Server` (or `https.Server`) instance:
220
+ ### Server
281
221
 
282
222
  ```ts
283
- // HTTP
284
223
  const server = app.listen(3000, () => console.log('Ready'));
285
224
 
286
225
  // HTTPS
287
- import { readFileSync } from 'fs';
288
- app.listen(443, {
289
- key: readFileSync('server.key'),
290
- cert: readFileSync('server.crt'),
291
- });
226
+ app.listen(443, { key: readFileSync('key.pem'), cert: readFileSync('cert.pem') });
292
227
 
293
228
  // HTTP/2
294
229
  app.listen(443, { key, cert, http2: true });
295
230
 
296
231
  // Graceful shutdown
297
232
  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 }, ...]
311
233
  ```
312
234
 
313
235
  ---
314
236
 
315
237
  ## Body parsing
316
238
 
317
- All body-parsing middleware must be registered **before** route handlers that need `req.body`.
239
+ Full reference: [docs/body-parsing.md](docs/body-parsing.md)
318
240
 
319
- ### `json()`
320
-
321
- Parses `application/json` request bodies and populates `req.body`. Also attaches `res.json(data)` for sending JSON responses.
241
+ Typed parsers call `next()` when the request content-type does not match, so it is safe to stack them globally:
322
242
 
323
243
  ```ts
324
- import { json } from 'expediate';
325
-
326
- app.use('/', json());
327
-
328
- app.post('/data', (req, res) => {
329
- const body = (req as any).body;
330
- res.json({ received: body });
331
- });
332
- ```
333
-
334
- | Option | Type | Default | Description |
335
- |---|---|---|---|
336
- | `limit` | `string \| number` | `'100kb'` | Maximum body size. Accepts `'10kb'`, `'2mb'`, `'1gb'`, or a number of bytes |
337
- | `inflate` | `boolean` | `true` | Decompress gzip/deflate bodies automatically |
338
- | `reviver` | `Reviver \| null` | `null` | Passed as the second argument to `JSON.parse` |
339
- | `strict` | `boolean` | `true` | Reserved for top-level primitive rejection |
340
-
341
- **Status codes returned on error:**
342
- - `413 Content Too Large` — body exceeds `limit`
343
- - `415 Unsupported Media Type` — wrong `Content-Type` or unsupported encoding
344
- - `500 Internal Server Error` — malformed JSON or unsupported charset
345
-
346
- ### `formData()`
347
-
348
- Parses `multipart/form-data` bodies. Populates `req.body` with an array of `FormPart` objects, each exposing:
349
- - `headers` — part headers (lowercased, e.g. `content-disposition`)
350
- - `content` — raw `Buffer` of the part body
351
-
352
- ```ts
353
- import { formData } from 'expediate';
354
-
355
- app.post('/upload', formData(), (req, res) => {
356
- const parts = (req as any).body as FormPart[];
357
- for (const part of parts) {
358
- const disp = part.headers['content-disposition'];
359
- console.log(disp, '-', part.content.length, 'bytes');
360
- }
361
- res.status(201).end();
362
- });
363
- ```
364
-
365
- Accepts the same `limit` and `inflate` options as `json()`.
244
+ import { json, formData, formEncoded, raw, text, parseBody } from 'expediate';
366
245
 
367
- ### `formEncoded()`
246
+ app.use(json()); // application/json → req.body
247
+ app.use(formEncoded()); // application/x-www-form-urlencoded → req.body
248
+ app.use(formData()); // multipart/form-data → req.body as FormPart[]
249
+ app.use(text()); // text/plain → req.body as string
250
+ app.use(raw()); // application/octet-stream → req.body as Buffer
368
251
 
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
- });
252
+ // Or catch everything at once (415 for unsupported types)
253
+ app.use(parseBody());
378
254
  ```
379
255
 
380
- ### `parseBody()`
256
+ Request bodies encoded with `gzip`, `deflate`, or `br` (Brotli) are decompressed automatically (disable with `inflate: false`).
381
257
 
382
- Auto-detects the `Content-Type` and dispatches to the appropriate parser. Supports:
383
-
384
- | Content-Type | Result in `req.body` |
385
- |---|---|
386
- | `application/json` | Parsed JS value |
387
- | `multipart/form-data` | `FormPart[]` |
388
- | `application/x-www-form-urlencoded` | `Record<string, string \| string[]>` |
389
- | `text/plain` | Decoded string |
258
+ Override which requests a parser handles with `type`, and inspect the raw bytes before parsing with `verify` (throw to reject handy for webhook signature checks):
390
259
 
391
260
  ```ts
392
- import { parseBody } from 'expediate';
393
-
394
- app.use('/', parseBody());
261
+ app.post('/webhook',
262
+ json({
263
+ type: 'application/*', // string, string[], or (req) => boolean
264
+ verify: (req, res, buf) => verifySignature(req, buf), // throw → 403 (or err.status)
265
+ }),
266
+ handler,
267
+ );
395
268
  ```
396
269
 
397
- Unsupported MIME types receive `415 Unsupported Media Type`.
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.
270
+ Streaming multipart:
402
271
 
403
272
  ```ts
404
273
  import { streamFormData } from 'expediate';
405
274
 
406
275
  app.post('/upload', async (req, res) => {
407
276
  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...
277
+ for await (const chunk of part.stream) { /* consume */ }
413
278
  }
414
279
  res.send('ok');
415
280
  });
416
281
  ```
417
282
 
283
+ | Option | Default | Description |
284
+ |---|---|---|
285
+ | `limit` | `'100kb'` | Maximum body size |
286
+ | `inflate` | `true` | Accept gzip/deflate/br encoded bodies |
287
+ | `reviver` | `null` | JSON.parse reviver |
288
+ | `strict` | `true` | `json()` only — reject a bare top-level JSON primitive (string, number, boolean, `null`) with `400 Bad Request` |
289
+ | `type` | per parser | Content-type matcher: string, string[], or `(req) => boolean` (supports `*` wildcards) |
290
+ | `verify` | `null` | Hook `(req, res, buf, encoding)` run on the raw body before parsing; throw to reject |
291
+
418
292
  ---
419
293
 
420
294
  ## Static files
421
295
 
422
- ### `serveStatic()`
423
-
424
- Serve an entire directory of static assets:
296
+ Full reference: [docs/static.md](docs/static.md)
425
297
 
426
298
  ```ts
427
- import { serveStatic } from 'expediate';
428
-
429
- app.use('/public', serveStatic('./dist'));
430
- ```
431
-
432
- Features: ETag, Last-Modified, conditional GET (304), Cache-Control, MIME-type detection, dot-file handling, directory index redirect, gzip/deflate decompression.
433
-
434
- | Option | Type | Default | Description |
435
- |---|---|---|---|
436
- | `maxage` / `maxAge` | `number` | `0` | Cache lifetime in **milliseconds** → `Cache-Control: public, max-age=<s>` |
437
- | `immutable` | `boolean` | `false` | Appends `, immutable` to `Cache-Control` |
438
- | `etag` | `boolean` | `true` | Send weak `ETag` header |
439
- | `lastModified` | `boolean` | `true` | Send `Last-Modified` header |
440
- | `dotfiles` | `'allow' \| 'deny' \| 'hide'` | `'hide'` | Dot-file access policy |
441
- | `redirect` | `boolean` | `true` | Redirect directory requests to `index.html` |
442
- | `fallthrough` | `boolean` | `true` | Call `next()` instead of sending 404 for missing files |
443
- | `contentType` | `string \| null` | `null` | Override auto-detected `Content-Type` |
444
- | `headers` | `Record<string, string>` | security defaults | Extra response headers merged with built-in CSP / XCTO headers |
445
-
446
- ### `serveFile()`
299
+ import { serveStatic, serveFile, sendFile } from 'expediate';
447
300
 
448
- Serve a single fixed file for every request — ideal for SPA catch-all routes:
301
+ // Serve a directory
302
+ app.use('/public', serveStatic('./dist', { maxAge: 86_400_000 }));
449
303
 
450
- ```ts
451
- import { serveFile } from 'expediate';
452
-
453
- // Serve dist/index.html for every unmatched route
304
+ // SPA catch-all
454
305
  app.get('/**', serveFile('./dist/index.html'));
455
- ```
456
-
457
- Supports the same caching and method-filtering options as `serveStatic()`. Returns `500 EISDIR` if the path points to a directory.
458
-
459
- ### `sendFile()`
460
-
461
- Low-level utility for sending an arbitrary file path dynamically:
462
-
463
- ```ts
464
- import { sendFile } from 'expediate';
465
- import type { StaticOptions } from 'expediate';
466
-
467
- const opts: StaticOptions = { etag: true, lastModified: true };
468
306
 
469
- app.get('/downloads/:file', (req, res) => {
470
- const filePath = path.join('./downloads', req.params.file);
471
- sendFile(req as any, res as any, filePath, opts as any);
307
+ // Dynamic path
308
+ app.get('/files/:name', (req, res) => {
309
+ sendFile(req as any, res as any, path.join('./files', req.params.name));
472
310
  });
473
311
  ```
474
312
 
475
- ---
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 }));
313
+ Features: weak ETags, `Last-Modified`, `304 Not Modified`, `Cache-Control`, dot-file policies, path traversal protection, HTML-escaped directory listings, `400` for malformed percent-encoded paths.
571
314
 
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()`
315
+ ---
611
316
 
612
- Sets a hardened baseline of HTTP security headers.
317
+ ## Middleware suite
613
318
 
614
- ```ts
615
- import { securityHeaders } from 'expediate';
319
+ → Full reference: [docs/middleware.md](docs/middleware.md)
616
320
 
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 |
321
+ | Middleware | Purpose |
623
322
  |---|---|
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';
323
+ | `compress()` | Brotli / gzip / deflate response compression |
324
+ | `conditionalGet()` | `If-None-Match` / `If-Modified-Since` → 304 |
325
+ | `cacheControl()` | `Cache-Control`, `Expires`, `Vary` headers |
326
+ | `requestId()` | Unique `req.id` + response header |
327
+ | `rateLimit()` | Sliding-window in-memory rate limiting |
328
+ | `csrf()` | Double-submit cookie CSRF protection |
329
+ | `securityHeaders()` | HSTS, X-Frame-Options, CSP baseline, etc. |
330
+ | `cors()` | Cross-Origin Resource Sharing headers |
331
+ | `logger()` | One-line request log with timing |
332
+
333
+ ```ts
334
+ import {
335
+ compress, conditionalGet, cacheControl, requestId,
336
+ rateLimit, csrf, securityHeaders, cors, logger,
337
+ } from 'expediate';
639
338
 
339
+ app.use(compress());
340
+ app.use(securityHeaders());
640
341
  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
-
663
- ## Request logging
664
-
665
- ```ts
666
- import { logger } from 'expediate';
667
-
668
- app.use('/', logger({
669
- track: true, // warn on requests that never finish (dev only)
670
- trackTimeout: 30_000, // ms before emitting a LOST warning
671
- user: (req) => (req as any).user?.username ?? '-',
672
- locale: 'en-US',
673
- logger: (msg) => process.stderr.write(msg + '\n'),
674
- }));
675
- ```
676
-
677
- Output format (one line per completed request, ANSI-coloured by status class):
678
-
679
- ```
680
- 21 Mar, 14:32 200 GET /api/users 127.0.0.1 <alice> 4 ms (1234)
342
+ app.use(requestId());
343
+ app.use(logger());
344
+ app.use(rateLimit({ windowMs: 60_000, max: 100 }));
681
345
  ```
682
346
 
683
- | Option | Type | Default | Description |
684
- |---|---|---|---|
685
- | `track` | `boolean` | `false` | Enable lost-request detection |
686
- | `trackTimeout` | `number` | `30000` | Timeout in ms before a LOST line is emitted |
687
- | `user` | `(req) => string` | `() => '-'` | Extract a user identity from the request |
688
- | `locale` | `string` | `'en-GB'` | BCP 47 locale for the timestamp |
689
- | `dateFormat` | `Intl.DateTimeFormatOptions` | short date+time | Timestamp format |
690
- | `logger` | `(msg: string) => void` | `console.log` | Custom logging sink |
691
-
692
347
  ---
693
348
 
694
- ## JWT Authentication
349
+ ## JWT authentication
695
350
 
696
- ### Setup
351
+ Full reference: [docs/jwt-auth.md](docs/jwt-auth.md)
697
352
 
698
353
  ```ts
699
354
  import { createRouter, json, createJwtPlugin } from 'expediate';
700
355
 
701
356
  const app = createRouter();
702
357
  const auth = createJwtPlugin({
703
- accessTokenSecret: process.env.JWT_SECRET!,
704
- refreshTokenSecret: process.env.JWT_REFRESH_SECRET!,
358
+ accessTokenSecret: process.env.JWT_SECRET!,
359
+ fetchUser: (username) => db.users.findOne({ username }),
360
+ isPasswordValid: (user, pw) => bcrypt.compare(pw, user.passwordHash),
705
361
  });
706
362
 
707
- // Mount auth endpoints (require json() body parser first)
708
363
  app.post('/auth/login', json(), auth.login);
709
364
  app.post('/auth/refresh', json(), auth.refresh);
710
365
  app.post('/auth/logout', json(), auth.logout);
366
+
367
+ // Protect routes
368
+ app.get('/me', auth.authenticate, auth.authorize, meHandler);
369
+ app.delete('/admin/:id', ...auth.requireRole('admin'), deleteUser);
370
+ app.put('/posts/:id', ...auth.requirePermission('write'), updatePost);
711
371
  ```
712
372
 
713
- #### `POST /auth/login`
373
+ Supported algorithms: `HS256/384/512`, `RS256/384/512`, `ES256/384/512`. Refresh tokens rotate on every use. Fully implemented with Node.js `crypto` — no third-party JWT library.
714
374
 
715
- ```json
716
- // Request body
717
- { "username": "alice", "password": "password123" }
375
+ > **Security note:** always supply `fetchUser` and `isPasswordValid`. The defaults use demo credentials and SHA-256 password hashing, which are unsuitable for production.
718
376
 
719
- // Response 200
720
- {
721
- "accessToken": "eyJ...",
722
- "refreshToken": "a3f8...",
723
- "expiresIn": 900,
724
- "tokenType": "Bearer"
725
- }
726
- ```
377
+ ---
727
378
 
728
- #### `POST /auth/refresh`
379
+ ## API service builder
729
380
 
730
- ```json
731
- // Request body
732
- { "username": "alice", "refreshToken": "a3f8..." }
381
+ → Full reference: [docs/api-builder.md](docs/api-builder.md)
733
382
 
734
- // Response 200 new token pair (old refresh token is invalidated)
735
- { "accessToken": "eyJ...", "refreshToken": "b9c2...", ... }
736
- ```
383
+ Define REST endpoints as a controller-style service object with automatic scoping, lifecycle management, and error translation:
737
384
 
738
- Refresh tokens are **rotated** on every use — the presented token is always invalidated and a fresh pair is issued.
385
+ ```ts
386
+ import { createRouter, json, apiBuilder } from 'expediate';
387
+ import type { ServiceDefinition, ApiContext } from 'expediate';
739
388
 
740
- #### `POST /auth/logout`
389
+ interface State { items: string[]; }
741
390
 
742
- ```json
743
- { "refreshToken": "b9c2..." }
744
- // Response 200 — refresh token revoked
745
- ```
391
+ const service: ServiceDefinition<State> = {
392
+ data: () => ({ items: [] }),
746
393
 
747
- ### Protecting routes
394
+ GET: {
395
+ '/items': function (this: State) { return this.items; },
396
+ '/items/:id': function (this: State, ctx: ApiContext) { return this.items[+ctx.params.id]; },
397
+ },
398
+ POST: {
399
+ '/items': function (this: State, _ctx: ApiContext, body: any) {
400
+ this.items.push(body.name);
401
+ return this.items;
402
+ },
403
+ },
404
+ };
748
405
 
749
- ```ts
750
- // authenticate — silently populates req.user; calls next() even on failure
751
- // authorize — rejects with 401 if req.user is not set
752
- app.get('/me', auth.authenticate, auth.authorize, (req, res) => {
753
- res.send(`Hello, ${(req as any).user.username}`);
754
- });
406
+ const app = createRouter();
407
+ app.use('/', json());
408
+ app.use('/api', apiBuilder(service));
409
+ app.listen(3000);
755
410
  ```
756
411
 
757
- `req.user` is populated with the decoded `TokenPayload`:
758
-
759
- ```ts
760
- interface TokenPayload {
761
- sub: string; // user ID
762
- username: string;
763
- iss: string; // issuer
764
- iat: number; // issued at (Unix s)
765
- exp: number; // expires at (Unix s)
766
- roles?: string[];
767
- permissions?: string[];
768
- }
769
- ```
412
+ Three scoping modes: **singleton** (one global instance), **keyed** (one instance per key), **ephemeral** (new instance per request). Routes are automatically sorted by specificity so declaration order does not matter.
770
413
 
771
- ### Role and permission guards
414
+ Errors thrown by a handler, guard, auth check, or validation are translated to HTTP automatically (`{ status, message | data }`, else `500`). Add a `service.onError` hook to log or reshape them before that translation — return nothing to keep the default, return an `ApiError` to override the response, or throw to escalate the error to the surrounding app's error channel (`app.error()`):
772
415
 
773
416
  ```ts
774
- // Require at least one of the listed roles
775
- app.delete('/admin/users/:id', ...auth.requireRole('admin'), deleteUser);
776
-
777
- // Require ALL listed permissions
778
- app.put('/posts/:id', ...auth.requirePermission('write', 'publish'), updatePost);
417
+ const service: ServiceDefinition<State> = {
418
+ onError(err, ctx, _req) {
419
+ metrics.increment('api.error', { path: ctx.path });
420
+ if (err instanceof DbTimeout) return { status: 503, message: 'Try again shortly' };
421
+ // return nothing → default translation; throw → bubble up to app.error()
422
+ },
423
+ // …routes…
424
+ };
779
425
  ```
780
426
 
781
- Both factories return `[authenticate, guard]` spread them into the route registration:
427
+ Large APIs split into per-domain **controllers** that merge into one router and one OpenAPI document — with **guards**, a declarative **auth binding** bridged to the JWT plugin, and **runtime request validation** from declared JSON Schemas:
782
428
 
783
429
  ```ts
784
- app.get('/report', ...auth.requireRole('admin', 'editor'), getReport);
785
- ```
430
+ import { apiBuilder, defineController, describe, createJwtPlugin } from 'expediate';
786
431
 
787
- ### Configuration reference
432
+ const jwt = createJwtPlugin({ accessTokenSecret: SECRET });
788
433
 
789
- ```ts
790
- const auth = createJwtPlugin({
791
- // Secrets — always override in production
792
- accessTokenSecret: 'change-me',
793
- refreshTokenSecret: 'change-me',
794
-
795
- // Expiry
796
- accessTokenExpiry: 15 * 60, // 15 minutes (seconds)
797
- refreshTokenExpiry: 7 * 24 * 3600, // 7 days (seconds)
798
-
799
- // Token claims
800
- issuer: 'my-app',
801
- checkIssuer: true, // reject tokens with wrong iss claim
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'),
809
-
810
- // User lookup (replace with a database query) — must override
811
- fetchUser: async (username) => {
812
- return await db.users.findOne({ username });
813
- },
434
+ const wikiController = defineController({
435
+ prefix: '/p/:proj/wiki',
436
+ tags: ['Wiki'],
437
+ permission: 'wiki.read', // auth.check() runs for every route
438
+ guards: [loadProject], // pre-handler hooks; results land in ctx.state
814
439
 
815
- // Password validation — default uses SHA-256; replace with bcrypt/argon2
816
- isPasswordValid: async (user, password) => {
817
- return await bcrypt.compare(password, user.passwordHash);
440
+ GET: {
441
+ '/pages/:slug': describe(
442
+ (ctx) => wiki.readPage(ctx.params.proj, ctx.params.slug),
443
+ { summary: 'Read a wiki page' }),
818
444
  },
445
+ PUT: {
446
+ '/pages/:slug': describe(
447
+ (ctx, body) => wiki.writePage(ctx.params.proj, ctx.params.slug, body),
448
+ { summary: 'Create or update a page', permission: 'wiki.write' }),
449
+ },
450
+ });
819
451
 
820
- // Custom JWT payload
821
- payload: (user) => ({
822
- sub: user.id,
823
- email: user.email,
824
- roles: user.roles,
825
- }),
826
-
827
- // Custom token store (replace with Redis for multi-instance deployments)
828
- refreshTokenStore: redisAdapter,
452
+ const api = apiBuilder({
453
+ auth: { authenticate: jwt.authenticate }, // default check reads ctx.user.permissions
454
+ validate: true, // enforce declared requestBody schemas (400 + fieldErrors)
455
+ controllers: [wikiController /* , issuesController, … */],
829
456
  });
830
457
  ```
831
458
 
832
- A built-in in-memory token store factory is available for testing:
459
+ Duplicate `(verb, path)` pairs across controllers **throw at build time** instead of silently shadowing.
460
+
461
+ `apiBuilder(service, options?)` takes an optional second argument (`ApiBuilderOptions`) to control validation. When provided it overrides `service.validate`: `validateRequests` defaults **on** (cancel with `{ validateRequests: false }`) and `validateResponses` opts in to checking each handler's return against its declared `200` schema — `true` returns `500` on a mismatch (the server's fault), `'warn'` only logs it:
833
462
 
834
463
  ```ts
835
- import { createMapTokenStore } from 'expediate';
836
- const auth = createJwtPlugin({ refreshTokenStore: createMapTokenStore() });
464
+ apiBuilder(service, { validateResponses: true }); // validate both incoming bodies and outgoing responses
837
465
  ```
838
466
 
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.
840
-
841
467
  ---
842
468
 
843
- ## API service builder
844
-
845
- `apiBuilder` lets you define REST endpoints as a controller-style service object, handling scoping, lifecycle, method binding, and error translation automatically.
469
+ ## OpenAPI spec generation
846
470
 
847
- ### Defining a service
471
+ Full reference: [docs/openapi.md](docs/openapi.md)
848
472
 
849
473
  ```ts
850
- import { createRouter, json, apiBuilder } from 'expediate';
851
- import type { ServiceDefinition } from 'expediate';
852
-
853
- interface TodoState {
854
- items: Record<string, { title: string; done: boolean }>;
855
- nextId: number;
856
- }
857
-
858
- const todoService: ServiceDefinition<TodoState> = {
859
- // Initial state
860
- data: () => ({ items: {}, nextId: 1 }),
861
-
862
- // Shared helper methods (bound to `this`)
863
- methods: {
864
- findOrThrow(this: TodoState, id: string) {
865
- const item = this.items[id];
866
- if (!item) throw { status: 404, message: 'Todo not found' };
867
- return item;
868
- },
869
- },
474
+ import { apiBuilder, describe } from 'expediate';
870
475
 
476
+ const service: ServiceDefinition<State> = {
871
477
  GET: {
872
- '/todos': function (this: TodoState) { return Object.entries(this.items).map(([id, v]) => ({ id, ...v })); },
873
- '/todos/:id': function (this: TodoState, params) { return this.findOrThrow(params.id); },
874
- },
875
-
876
- POST: {
877
- '/todos': function (this: TodoState, _params, body: any) {
878
- const id = String(this.nextId++);
879
- this.items[id] = { title: body.title, done: false };
880
- return { id, ...this.items[id] };
881
- },
882
- },
883
-
884
- DELETE: {
885
- '/todos/:id': function (this: TodoState, params) {
886
- this.findOrThrow(params.id);
887
- delete this.items[params.id];
888
- return undefined; // → 201 No Content
889
- },
478
+ '/items': describe(
479
+ function (this: State) { return this.items; },
480
+ { summary: 'List items', responses: { '200': { description: 'Item array' } } },
481
+ ),
890
482
  },
891
483
  };
892
484
 
893
- const app = createRouter();
894
- app.use('/', json());
895
- app.use('/api', apiBuilder(todoService));
896
-
897
- app.listen(3000);
898
- ```
899
-
900
- **Handler conventions:**
901
-
902
- | Return value | HTTP response |
903
- |---|---|
904
- | Truthy value or `Promise` resolving to one | `200 OK` with JSON body |
905
- | `undefined`, `null`, `false`, `0`, `''` | `201 No Content` |
906
- | Throw `{ status, message }` | `<status>` with plain-text body |
907
- | Throw `{ status, data }` | `<status>` with JSON body |
908
- | Throw anything else | `500 Internal Server Error` |
909
-
910
- ### Scoping
911
-
912
- Control how many instances of the service state are created:
913
-
914
- ```ts
915
- const service: ServiceDefinition = {
916
- // Singleton (default — omit `scope`): one global instance
917
- // scope: undefined
918
-
919
- // Per-session: same instance reused for all requests sharing the key
920
- scope: (req) => (req as any).session?.ssid ?? null,
921
-
922
- // Per-request: fresh instance for every request (null key)
923
- scope: () => null,
924
-
925
- data: () => ({ /* initial state */ }),
926
- };
485
+ const api = apiBuilder(service);
486
+ app.use('/api', api);
487
+ app.get('/openapi.json', api.specHandler({ title: 'Items API', version: '1.0.0' }));
488
+ app.get('/openapi.yaml', api.specHandler({ title: 'Items API', version: '1.0.0' }, 'yaml'));
927
489
  ```
928
490
 
929
- > The key returned by the `scope()` method is store at `this.$key`.
491
+ Controllers merge into a single document. Routes carrying a `permission` automatically emit `security: [{ bearerAuth: [] }]`, an `x-required-permissions` vendor extension, and the matching `components.securitySchemes` entry.
930
492
 
931
- ### Service error handling
932
-
933
- Throw structured errors from any handler or method to send precise HTTP responses:
934
-
935
- ```ts
936
- // Plain message
937
- throw { status: 404, message: 'Resource not found' };
938
-
939
- // JSON body
940
- throw { status: 422, data: { field: 'email', error: 'invalid format' } };
941
-
942
- // Guard pattern for async setup
943
- methods: {
944
- throwIfNotReady(this: any) {
945
- if (!this.ready)
946
- throw { status: 503, message: 'Service initialising — try again shortly' };
947
- },
948
- },
949
- setup: function (this: any) {
950
- this.loadData().then(() => { this.ready = true; });
951
- },
952
- ```
493
+ `openApiSpec()` also accepts an **array of sources** — useful for documenting routes that have no `ServiceDefinition` at all (e.g. JWT auth endpoints mounted directly with `app.post(...)`) alongside a real API in one merged document. See [docs/openapi.md](docs/openapi.md#multiple-sources).
953
494
 
954
495
  ---
955
496
 
956
497
  ## Git Smart HTTP gateway
957
498
 
958
- Serve a Git repository over HTTP (fetch / clone only — push is not supported):
499
+ Full reference: [docs/git.md](docs/git.md)
500
+
501
+ Serve Git repositories over HTTP — supports clone, fetch, and push:
959
502
 
960
503
  ```ts
961
- import { createRouter, gitHandler } from 'expediate';
504
+ import { createRouter, gitHandler, gitCreate } from 'expediate';
962
505
  import path from 'path';
963
506
 
964
507
  const app = createRouter();
965
508
 
966
509
  app.use('/repos/:repo', gitHandler({
967
510
  repository: (req) => {
968
- // Resolve the repo path from the request; return falsy to 404
969
511
  const name = req.params.repo;
970
512
  if (!/^[\w.-]+$/.test(name)) return null;
971
513
  return path.join('/srv/git', name + '.git');
@@ -975,83 +517,36 @@ app.use('/repos/:repo', gitHandler({
975
517
  app.listen(3000);
976
518
  ```
977
519
 
978
- Clients can now clone with:
979
-
980
520
  ```bash
981
521
  git clone http://localhost:3000/repos/myproject
522
+ git push http://localhost:3000/repos/myproject HEAD:main
982
523
  ```
983
524
 
984
- | Option | Type | Default | Description |
985
- |---|---|---|---|
986
- | `repository` | `(req) => string \| null | Promise<string \| null>` | **required** | Resolve the absolute path to the bare repository. Return falsy to send 404 |
987
- | `gitPath` | `string` | `''` | Directory prefix for the `git-upload-pack` binary (include trailing `/`) |
988
- | `strict` | `boolean` | `false` | When `true`, omits `--no-strict` — git will reject non-bare repositories |
989
- | `timeout` | `number \| string` | none | Kill `git-upload-pack` after this many **seconds** |
990
-
991
- **Supported endpoints:**
992
-
993
- | Method | Path | Description |
994
- |---|---|---|
995
- | `GET` | `/info/refs?service=git-upload-pack` | Capability advertisement |
996
- | `POST` | `/git-upload-pack` | Pack negotiation and transfer |
997
-
998
- Gzip-compressed `POST` bodies are decompressed transparently. Spawn errors (e.g. `git` not installed) return `500` with a descriptive message.
999
-
1000
- ---
1001
-
1002
- ## OpenAPI spec generation
1003
-
1004
- Annotate service definitions and generate an OpenAPI 3.1 document automatically.
525
+ Create new repositories programmatically:
1005
526
 
1006
527
  ```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' }));
528
+ await gitCreate('/srv/git/newrepo.git', { description: 'New repository' });
1037
529
  ```
1038
530
 
1039
531
  ---
1040
532
 
1041
533
  ## TypeScript types
1042
534
 
1043
- Full type declarations are included. Key types exported from the package:
535
+ Full declarations are included. Key exports:
1044
536
 
1045
537
  ```ts
1046
538
  // Router
1047
539
  import type {
1048
540
  Router, RouterOptions, RouterRequest, RouterResponse,
1049
- Middleware, MiddlewareArg, NextFunction, ErrorHandler,
1050
- Layer, RouteInfo, CookieOptions, TlsOptions, StringMap,
541
+ Middleware, MiddlewareArg, NextFunction, ErrorHandler, ErrorMiddleware,
542
+ Layer, RouteInfo, RouteBuilder, CookieOptions, TlsOptions, StringMap,
1051
543
  } from 'expediate';
1052
544
 
1053
545
  // Body parsing
1054
- import type { BodyOptions, FormPart, FormPartStream, LoggerOptions } from 'expediate';
546
+ import type {
547
+ BodyOptions, BodyTypeMatcher, VerifyFn, FormPart, FormPartStream,
548
+ LoggerOptions, CorsOptions,
549
+ } from 'expediate';
1055
550
 
1056
551
  // Static files
1057
552
  import type { StaticOptions, Mime } from 'expediate';
@@ -1063,13 +558,17 @@ import type {
1063
558
  } from 'expediate';
1064
559
 
1065
560
  // JWT
1066
- import type { JwtConfig, JwtPlugin, TokenPayload, UserRecord, TokenStore, RefreshTokenRecord } from 'expediate';
1067
-
1068
- // API builder
1069
- import type { ServiceDefinition, ServiceMethod, ServiceMethods, RouteMap, ApiError } from 'expediate';
561
+ import type {
562
+ JwtConfig, JwtPlugin, TokenPayload, TokenStore,
563
+ UserRecord, RefreshTokenRecord,
564
+ } from 'expediate';
1070
565
 
1071
- // OpenAPI
1072
- import type { OperationMeta, OpenApiServiceMeta, SpecOptions, OpenApiDocument } from 'expediate';
566
+ // API builder + OpenAPI
567
+ import type {
568
+ ServiceDefinition, ServiceMethod, ServiceMethods, RouteMap, ApiError,
569
+ ApiContext, ControllerDefinition, Guard, AuthBinding, ApiBuilderOptions,
570
+ OperationMeta, OpenApiServiceMeta, SpecOptions, OpenApiDocument,
571
+ } from 'expediate';
1073
572
 
1074
573
  // Git
1075
574
  import type { GitHandlerOptions } from 'expediate';