expediate 1.0.4 → 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 (69) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/CONTRIBUTING.md +150 -0
  3. package/LICENSE +16 -16
  4. package/README.md +330 -444
  5. package/dist/apis.d.ts +504 -27
  6. package/dist/apis.d.ts.map +1 -1
  7. package/dist/apis.js +618 -107
  8. package/dist/apis.js.map +1 -1
  9. package/dist/cjs/index.js +4066 -0
  10. package/dist/cjs/package.json +1 -0
  11. package/dist/git.d.ts +72 -9
  12. package/dist/git.d.ts.map +1 -1
  13. package/dist/git.js +129 -74
  14. package/dist/git.js.map +1 -1
  15. package/dist/http-objects.d.ts +26 -0
  16. package/dist/http-objects.d.ts.map +1 -0
  17. package/dist/http-objects.js +588 -0
  18. package/dist/http-objects.js.map +1 -0
  19. package/dist/index.d.ts +18 -13
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +15 -24
  22. package/dist/index.js.map +1 -1
  23. package/dist/jwt-auth.d.ts +158 -57
  24. package/dist/jwt-auth.d.ts.map +1 -1
  25. package/dist/jwt-auth.js +447 -207
  26. package/dist/jwt-auth.js.map +1 -1
  27. package/dist/middleware.d.ts +476 -0
  28. package/dist/middleware.d.ts.map +1 -0
  29. package/dist/middleware.js +647 -0
  30. package/dist/middleware.js.map +1 -0
  31. package/dist/mimetypes.json +882 -1
  32. package/dist/misc.d.ts +268 -25
  33. package/dist/misc.d.ts.map +1 -1
  34. package/dist/misc.js +449 -168
  35. package/dist/misc.js.map +1 -1
  36. package/dist/openapi.d.ts +433 -0
  37. package/dist/openapi.d.ts.map +1 -0
  38. package/dist/openapi.js +624 -0
  39. package/dist/openapi.js.map +1 -0
  40. package/dist/router-types.d.ts +760 -0
  41. package/dist/router-types.d.ts.map +1 -0
  42. package/dist/router-types.js +23 -0
  43. package/dist/router-types.js.map +1 -0
  44. package/dist/router.d.ts +37 -201
  45. package/dist/router.d.ts.map +1 -1
  46. package/dist/router.js +502 -244
  47. package/dist/router.js.map +1 -1
  48. package/dist/static.d.ts +3 -3
  49. package/dist/static.d.ts.map +1 -1
  50. package/dist/static.js +164 -105
  51. package/dist/static.js.map +1 -1
  52. package/docs/THREAT_MODEL.md +52 -0
  53. package/docs/api-builder-v2-design.md +644 -0
  54. package/docs/api-builder-v3-design.md +397 -0
  55. package/docs/api-builder.md +454 -0
  56. package/docs/benchmark.md +27 -0
  57. package/docs/body-parsing.md +223 -0
  58. package/docs/errors.md +359 -0
  59. package/docs/expediate.png +0 -0
  60. package/docs/git.md +139 -0
  61. package/docs/jwt-auth.md +251 -0
  62. package/docs/logo.svg +12 -0
  63. package/docs/middleware.md +264 -0
  64. package/docs/openapi.md +180 -0
  65. package/docs/router.md +356 -0
  66. package/docs/static.md +128 -0
  67. package/docs/wiki.json +123 -0
  68. package/package.json +47 -8
  69. package/.npmignore +0 -16
package/README.md CHANGED
@@ -1,11 +1,21 @@
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>
6
15
 
7
- [![NPM Version][npm-image]][npm-url]
8
- [![NPM Downloads][downloads-image]][downloads-url]
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 with **zero runtime dependencies** beyond Node.js itself.
9
19
 
10
20
  ---
11
21
 
@@ -13,31 +23,14 @@ A lightweight, zero-dependency TypeScript HTTP routing framework for Node.js.
13
23
 
14
24
  - [Installation](#installation)
15
25
  - [Quick start](#quick-start)
26
+ - [Why Expediate?](#why-expediate)
16
27
  - [Router](#router)
17
- - [Creating a router](#creating-a-router)
18
- - [Route registration](#route-registration)
19
- - [Path patterns](#path-patterns)
20
- - [Response helpers](#response-helpers)
21
- - [Sub-routers](#sub-routers)
22
- - [Starting the server](#starting-the-server)
23
28
  - [Body parsing](#body-parsing)
24
- - [`json()`](#json)
25
- - [`formData()`](#formdata)
26
- - [`parseBody()`](#parsebody)
27
29
  - [Static files](#static-files)
28
- - [`serveStatic()`](#servestatic)
29
- - [`serveFile()`](#servefile)
30
- - [`sendFile()`](#sendfile)
31
- - [Request logging](#request-logging)
32
- - [JWT Authentication](#jwt-authentication)
33
- - [Setup](#setup)
34
- - [Protecting routes](#protecting-routes)
35
- - [Role and permission guards](#role-and-permission-guards)
36
- - [Configuration reference](#configuration-reference)
30
+ - [Middleware suite](#middleware-suite)
31
+ - [JWT authentication](#jwt-authentication)
37
32
  - [API service builder](#api-service-builder)
38
- - [Defining a service](#defining-a-service)
39
- - [Scoping](#scoping)
40
- - [Error handling](#error-handling)
33
+ - [OpenAPI spec generation](#openapi-spec-generation)
41
34
  - [Git Smart HTTP gateway](#git-smart-http-gateway)
42
35
  - [TypeScript types](#typescript-types)
43
36
 
@@ -49,7 +42,9 @@ A lightweight, zero-dependency TypeScript HTTP routing framework for Node.js.
49
42
  npm install expediate
50
43
  ```
51
44
 
52
- 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.
53
48
 
54
49
  ---
55
50
 
@@ -60,16 +55,15 @@ import { createRouter, json, logger } from 'expediate';
60
55
 
61
56
  const app = createRouter();
62
57
 
63
- app.use('/', logger());
64
- app.use('/', json());
58
+ app.use(logger());
59
+ app.use(json());
65
60
 
66
61
  app.get('/hello/:name', (req, res) => {
67
62
  res.send(`Hello, ${req.params.name}!`);
68
63
  });
69
64
 
70
65
  app.post('/echo', (req, res) => {
71
- res.setHeader('Content-Type', 'application/json');
72
- res.send(JSON.stringify((req as any).body));
66
+ res.json((req as any).body);
73
67
  });
74
68
 
75
69
  app.listen(3000, () => console.log('Listening on :3000'));
@@ -77,552 +71,443 @@ app.listen(3000, () => console.log('Listening on :3000'));
77
71
 
78
72
  ---
79
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
+
80
84
  ## Router
81
85
 
82
- ### Creating a router
86
+ Full reference: [docs/router.md](docs/router.md)
83
87
 
84
88
  ```ts
85
89
  import { createRouter } from 'expediate';
86
90
 
87
- 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
+ });
88
96
  ```
89
97
 
90
- `createRouter()` returns a `Router` object that also acts as a middleware function itself, making it nestable.
91
-
92
98
  ### Route registration
93
99
 
94
- All registration methods share the same signature:
95
-
96
100
  ```ts
97
- router.METHOD(path, ...middleware)
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
98
107
  ```
99
108
 
100
- | Method | HTTP verb | Notes |
101
- |---|---|---|
102
- | `router.get(path, ...mw)` | `GET` | Path is not stripped from `req.path` — chained middlewares see the full path |
103
- | `router.post(path, ...mw)` | `POST` | |
104
- | `router.put(path, ...mw)` | `PUT` | |
105
- | `router.delete(path, ...mw)` | `DELETE` | |
106
- | `router.patch(path, ...mw)` | `PATCH` | |
107
- | `router.use(path, ...mw)` | any | **Strips the matched prefix** from `req.path` before calling middleware; used for mounting sub-routers |
108
- | `router.all(path, ...mw)` | any | Matches any method but don't strips the prefix |
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`.
109
110
 
110
- Each middleware slot accepts a `Middleware` function, a `Router` instance, or an array of either:
111
+ To register several methods against one path without repeating it, use the chainable `route()` builder:
111
112
 
112
113
  ```ts
113
- const guard: Middleware = (req, res, next) => {
114
- if (!req.headers.authorization) return res.status(401).end();
115
- next();
116
- };
117
-
118
- app.get('/secret', guard, (req, res) => res.send('classified'));
119
- app.get('/multi', [guard, anotherMiddleware], handler);
114
+ app.route('/users/:id')
115
+ .get(getUser)
116
+ .put(replaceUser)
117
+ .delete(removeUser);
120
118
  ```
121
119
 
122
- ### Path patterns
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.
123
121
 
124
- Three pattern types are supported:
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.
125
123
 
126
- **Plain strings with `:param` segments**
127
- ```ts
128
- app.get('/users/:id', handler); // req.params.id
129
- app.get('/orgs/:org/repos/:repo', handler); // req.params.org, req.params.repo
130
- ```
124
+ ### Path patterns
131
125
 
132
- **Glob patterns** (`.gitignore` wildcard rules)
133
126
  ```ts
134
- app.get('/api/*', handler); // one path segment
135
- app.get('/api/**', handler); // any depth
136
- app.get('/**/*.php', handler); // PHP files in any subdirectory
137
- app.get('/v?/status', handler); // one character wildcard
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)
138
131
  ```
139
132
 
140
- **Regular expressions** — named capture groups become `req.params` entries
133
+ ### Request fields
134
+
135
+ Every request is augmented before middleware runs:
136
+
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`).
155
+
156
+ ### Response helpers
157
+
141
158
  ```ts
142
- app.get(/^\/users\/(?<id>\d+)/, handler); // req.params.id
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
143
175
  ```
144
176
 
145
- > **Route specificity:** when using `apiBuilder`, routes are automatically sorted by specificity (more segments / fewer parameters first) to prevent shorter paths from shadowing longer ones.
177
+ ### Error handling
146
178
 
147
- ### Response helpers
179
+ Full reference: [docs/errors.md](docs/errors.md)
180
+
181
+ Any error thrown by a middleware — synchronous throw, rejected `async` middleware, or an explicit `next(err)` — enters the router's **error channel**.
148
182
 
149
- Every response object is augmented with convenience methods:
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:
150
184
 
151
185
  ```ts
152
- res.send('Hello'); // write body and end
153
- res.send(); // end with no body
154
- res.status(404).send('Not found'); // set status + body (chainable)
155
- res.status(201).end(); // set status and end
156
- res.redirect('/new-url'); // 302 redirect
157
- res.cookie('session', 'abc', {
158
- maxAge: 3_600_000, // milliseconds
159
- path: '/api',
160
- signed: false,
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
161
189
  });
162
- ```
163
-
164
- <!-- Every response also carries `X-Powered-By: Expediate`. -->
165
190
 
166
- ### Sub-routers
191
+ app.error((err, _req, res, _next) => {
192
+ res.status((err as any)?.status ?? 500).json({ error: String(err) });
193
+ });
194
+ ```
167
195
 
168
- 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`.
169
197
 
170
198
  ```ts
171
199
  const api = createRouter();
172
- api.get('/users', listUsers);
173
- api.get('/users/:id', getUser);
174
- api.post('/users', createUser);
200
+ api.get('/items/:id', () => { throw new Error('boom'); });
175
201
 
176
202
  const app = createRouter();
177
- 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) }));
178
206
  ```
179
207
 
180
- 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):
181
213
 
182
214
  ```ts
183
- app.use('/auth', authRouter); // Router instance
184
- app.use('/auth', authRouter.listener); // equivalent
215
+ app.all('/**', (_req, res) => res.status(404).json({ error: 'Not Found' }));
185
216
  ```
186
217
 
187
- ### Starting the server
218
+ Without one, unmatched requests fall back to the built-in `Cannot METHOD /path` 404.
219
+
220
+ ### Server
188
221
 
189
222
  ```ts
190
- // HTTP
191
- app.listen(3000, () => console.log('Ready'));
223
+ const server = app.listen(3000, () => console.log('Ready'));
192
224
 
193
225
  // HTTPS
194
- import { readFileSync } from 'fs';
195
- app.listen(443, {
196
- key: readFileSync('server.key'),
197
- cert: readFileSync('server.crt'),
198
- });
226
+ app.listen(443, { key: readFileSync('key.pem'), cert: readFileSync('cert.pem') });
227
+
228
+ // HTTP/2
229
+ app.listen(443, { key, cert, http2: true });
230
+
231
+ // Graceful shutdown
232
+ process.on('SIGTERM', () => app.shutdown(10_000));
199
233
  ```
200
234
 
201
235
  ---
202
236
 
203
237
  ## Body parsing
204
238
 
205
- 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)
206
240
 
207
- ### `json()`
208
-
209
- 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:
210
242
 
211
243
  ```ts
212
- import { json } from 'expediate';
244
+ import { json, formData, formEncoded, raw, text, parseBody } from 'expediate';
213
245
 
214
- app.use('/', json());
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
215
251
 
216
- app.post('/data', (req, res) => {
217
- const body = (req as any).body;
218
- res.json({ received: body });
219
- });
252
+ // Or catch everything at once (415 for unsupported types)
253
+ app.use(parseBody());
220
254
  ```
221
255
 
222
- | Option | Type | Default | Description |
223
- |---|---|---|---|
224
- | `limit` | `string \| number` | `'100kb'` | Maximum body size. Accepts `'10kb'`, `'2mb'`, `'1gb'`, or a number of bytes |
225
- | `inflate` | `boolean` | `true` | Decompress gzip/deflate bodies automatically |
226
- | `reviver` | `Reviver \| null` | `null` | Passed as the second argument to `JSON.parse` |
227
- | `strict` | `boolean` | `true` | Reserved for top-level primitive rejection |
228
-
229
- **Status codes returned on error:**
230
- - `413 Content Too Large` — body exceeds `limit`
231
- - `415 Unsupported Media Type` — wrong `Content-Type` or unsupported encoding
232
- - `500 Internal Server Error` — malformed JSON or unsupported charset
233
-
234
- ### `formData()`
256
+ Request bodies encoded with `gzip`, `deflate`, or `br` (Brotli) are decompressed automatically (disable with `inflate: false`).
235
257
 
236
- Parses `multipart/form-data` bodies. Populates `req.body` with an array of `FormPart` objects, each exposing:
237
- - `headers` — part headers (lowercased, e.g. `content-disposition`)
238
- - `content` — raw `Buffer` of the part body
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):
239
259
 
240
260
  ```ts
241
- import { formData } from 'expediate';
242
-
243
- app.post('/upload', formData(), (req, res) => {
244
- const parts = (req as any).body as FormPart[];
245
- for (const part of parts) {
246
- const disp = part.headers['content-disposition'];
247
- console.log(disp, '-', part.content.length, 'bytes');
248
- }
249
- res.status(201).end();
250
- });
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
+ );
251
268
  ```
252
269
 
253
- Accepts the same `limit` and `inflate` options as `json()`.
254
-
255
- ### `parseBody()`
256
-
257
- Auto-detects the `Content-Type` and dispatches to the appropriate parser. Supports:
258
-
259
- | Content-Type | Result in `req.body` |
260
- |---|---|
261
- | `application/json` | Parsed JS value |
262
- | `multipart/form-data` | `FormPart[]` |
263
- | `text/plain` | Decoded string |
270
+ Streaming multipart:
264
271
 
265
272
  ```ts
266
- import { parseBody } from 'expediate';
273
+ import { streamFormData } from 'expediate';
267
274
 
268
- app.use('/', parseBody());
275
+ app.post('/upload', async (req, res) => {
276
+ for await (const part of streamFormData(req)) {
277
+ for await (const chunk of part.stream) { /* consume */ }
278
+ }
279
+ res.send('ok');
280
+ });
269
281
  ```
270
282
 
271
- Unsupported MIME types receive `415 Unsupported Media Type`.
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 |
272
291
 
273
292
  ---
274
293
 
275
294
  ## Static files
276
295
 
277
- ### `serveStatic()`
278
-
279
- Serve an entire directory of static assets:
296
+ Full reference: [docs/static.md](docs/static.md)
280
297
 
281
298
  ```ts
282
- import { serveStatic } from 'expediate';
283
-
284
- app.use('/public', serveStatic('./dist'));
285
- ```
286
-
287
- Features: ETag, Last-Modified, conditional GET (304), Cache-Control, MIME-type detection, dot-file handling, directory index redirect, gzip/deflate decompression.
288
-
289
- | Option | Type | Default | Description |
290
- |---|---|---|---|
291
- | `maxage` / `maxAge` | `number` | `0` | Cache lifetime in **milliseconds** → `Cache-Control: public, max-age=<s>` |
292
- | `immutable` | `boolean` | `false` | Appends `, immutable` to `Cache-Control` |
293
- | `etag` | `boolean` | `true` | Send weak `ETag` header |
294
- | `lastModified` | `boolean` | `true` | Send `Last-Modified` header |
295
- | `dotfiles` | `'allow' \| 'deny' \| 'hide'` | `'hide'` | Dot-file access policy |
296
- | `redirect` | `boolean` | `true` | Redirect directory requests to `index.html` |
297
- | `fallthrough` | `boolean` | `true` | Call `next()` instead of sending 404 for missing files |
298
- | `contentType` | `string \| null` | `null` | Override auto-detected `Content-Type` |
299
- | `headers` | `Record<string, string>` | security defaults | Extra response headers merged with built-in CSP / XCTO headers |
300
-
301
- ### `serveFile()`
299
+ import { serveStatic, serveFile, sendFile } from 'expediate';
302
300
 
303
- 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 }));
304
303
 
305
- ```ts
306
- import { serveFile } from 'expediate';
307
-
308
- // Serve dist/index.html for every unmatched route
304
+ // SPA catch-all
309
305
  app.get('/**', serveFile('./dist/index.html'));
310
- ```
311
-
312
- Supports the same caching and method-filtering options as `serveStatic()`. Returns `500 EISDIR` if the path points to a directory.
313
-
314
- ### `sendFile()`
315
306
 
316
- Low-level utility for sending an arbitrary file path dynamically:
317
-
318
- ```ts
319
- import { sendFile } from 'expediate';
320
- import type { StaticOptions } from 'expediate';
321
-
322
- const opts: StaticOptions = { etag: true, lastModified: true };
323
-
324
- app.get('/downloads/:file', (req, res) => {
325
- const filePath = path.join('./downloads', req.params.file);
326
- 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));
327
310
  });
328
311
  ```
329
312
 
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.
314
+
330
315
  ---
331
316
 
332
- ## Request logging
317
+ ## Middleware suite
333
318
 
334
- ```ts
335
- import { logger } from 'expediate';
336
-
337
- app.use('/', logger({
338
- track: true, // warn on requests that never finish (dev only)
339
- trackTimeout: 30_000, // ms before emitting a LOST warning
340
- user: (req) => (req as any).user?.username ?? '-',
341
- locale: 'en-US',
342
- logger: (msg) => process.stderr.write(msg + '\n'),
343
- }));
344
- ```
319
+ → Full reference: [docs/middleware.md](docs/middleware.md)
345
320
 
346
- Output format (one line per completed request, ANSI-coloured by status class):
321
+ | Middleware | Purpose |
322
+ |---|---|
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 |
347
332
 
348
- ```
349
- 21 Mar, 14:32 200 GET /api/users 127.0.0.1 <alice> 4 ms (1234)
350
- ```
333
+ ```ts
334
+ import {
335
+ compress, conditionalGet, cacheControl, requestId,
336
+ rateLimit, csrf, securityHeaders, cors, logger,
337
+ } from 'expediate';
351
338
 
352
- | Option | Type | Default | Description |
353
- |---|---|---|---|
354
- | `track` | `boolean` | `false` | Enable lost-request detection |
355
- | `trackTimeout` | `number` | `30000` | Timeout in ms before a LOST line is emitted |
356
- | `user` | `(req) => string` | `() => '-'` | Extract a user identity from the request |
357
- | `locale` | `string` | `'en-GB'` | BCP 47 locale for the timestamp |
358
- | `dateFormat` | `Intl.DateTimeFormatOptions` | short date+time | Timestamp format |
359
- | `logger` | `(msg: string) => void` | `console.log` | Custom logging sink |
339
+ app.use(compress());
340
+ app.use(securityHeaders());
341
+ app.use(cors({ origin: 'https://example.com' }));
342
+ app.use(requestId());
343
+ app.use(logger());
344
+ app.use(rateLimit({ windowMs: 60_000, max: 100 }));
345
+ ```
360
346
 
361
347
  ---
362
348
 
363
- ## JWT Authentication
349
+ ## JWT authentication
364
350
 
365
- ### Setup
351
+ Full reference: [docs/jwt-auth.md](docs/jwt-auth.md)
366
352
 
367
353
  ```ts
368
354
  import { createRouter, json, createJwtPlugin } from 'expediate';
369
355
 
370
356
  const app = createRouter();
371
357
  const auth = createJwtPlugin({
372
- accessTokenSecret: process.env.JWT_SECRET!,
373
- 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),
374
361
  });
375
362
 
376
- // Mount auth endpoints (require json() body parser first)
377
363
  app.post('/auth/login', json(), auth.login);
378
364
  app.post('/auth/refresh', json(), auth.refresh);
379
365
  app.post('/auth/logout', json(), auth.logout);
380
- ```
381
-
382
- #### `POST /auth/login`
383
366
 
384
- ```json
385
- // Request body
386
- { "username": "alice", "password": "password123" }
387
-
388
- // Response 200
389
- {
390
- "accessToken": "eyJ...",
391
- "refreshToken": "a3f8...",
392
- "expiresIn": 900,
393
- "tokenType": "Bearer"
394
- }
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);
395
371
  ```
396
372
 
397
- #### `POST /auth/refresh`
398
-
399
- ```json
400
- // Request body
401
- { "username": "alice", "refreshToken": "a3f8..." }
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.
402
374
 
403
- // Response 200 new token pair (old refresh token is invalidated)
404
- { "accessToken": "eyJ...", "refreshToken": "b9c2...", ... }
405
- ```
375
+ > **Security note:** always supply `fetchUser` and `isPasswordValid`. The defaults use demo credentials and SHA-256 password hashing, which are unsuitable for production.
406
376
 
407
- Refresh tokens are **rotated** on every use — the presented token is always invalidated and a fresh pair is issued.
377
+ ---
408
378
 
409
- #### `POST /auth/logout`
379
+ ## API service builder
410
380
 
411
- ```json
412
- { "refreshToken": "b9c2..." }
413
- // Response 200 — refresh token revoked
414
- ```
381
+ → Full reference: [docs/api-builder.md](docs/api-builder.md)
415
382
 
416
- ### Protecting routes
383
+ Define REST endpoints as a controller-style service object with automatic scoping, lifecycle management, and error translation:
417
384
 
418
385
  ```ts
419
- // authenticate silently populates req.user; calls next() even on failure
420
- // authorize — rejects with 401 if req.user is not set
421
- app.get('/me', auth.authenticate, auth.authorize, (req, res) => {
422
- res.send(`Hello, ${(req as any).user.username}`);
423
- });
424
- ```
425
-
426
- `req.user` is populated with the decoded `TokenPayload`:
386
+ import { createRouter, json, apiBuilder } from 'expediate';
387
+ import type { ServiceDefinition, ApiContext } from 'expediate';
427
388
 
428
- ```ts
429
- interface TokenPayload {
430
- sub: string; // user ID
431
- username: string;
432
- iss: string; // issuer
433
- iat: number; // issued at (Unix s)
434
- exp: number; // expires at (Unix s)
435
- roles?: string[];
436
- permissions?: string[];
437
- }
438
- ```
389
+ interface State { items: string[]; }
439
390
 
440
- ### Role and permission guards
391
+ const service: ServiceDefinition<State> = {
392
+ data: () => ({ items: [] }),
441
393
 
442
- ```ts
443
- // Require at least one of the listed roles
444
- app.delete('/admin/users/:id', ...auth.requireRole('admin'), deleteUser);
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
+ };
445
405
 
446
- // Require ALL listed permissions
447
- app.put('/posts/:id', ...auth.requirePermission('write', 'publish'), updatePost);
406
+ const app = createRouter();
407
+ app.use('/', json());
408
+ app.use('/api', apiBuilder(service));
409
+ app.listen(3000);
448
410
  ```
449
411
 
450
- Both factories return `[authenticate, guard]` spread them into the route registration:
451
-
452
- ```ts
453
- app.get('/report', ...auth.requireRole('admin', 'editor'), getReport);
454
- ```
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.
455
413
 
456
- ### Configuration reference
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()`):
457
415
 
458
416
  ```ts
459
- const auth = createJwtPlugin({
460
- // Secrets always override in production
461
- accessTokenSecret: 'change-me',
462
- refreshTokenSecret: 'change-me',
463
-
464
- // Expiry
465
- accessTokenExpiry: 15 * 60, // 15 minutes (seconds)
466
- refreshTokenExpiry: 7 * 24 * 3600, // 7 days (seconds)
467
-
468
- // Token claims
469
- issuer: 'my-app',
470
- checkIssuer: true, // reject tokens with wrong iss claim
471
- alg: 'HS256', // 'HS256' | 'HS384' | 'HS512'
472
-
473
- // User lookup (replace with a database query) — must override
474
- // Returned object must have a username field
475
- fetchUser: async (username) => {
476
- return await db.users.findOne({ username });
477
- },
478
-
479
- // Password validation — default look for SHA256(user.passwordHash), replace with bcrypt/argon2
480
- isPasswordValid: async (user, password) => {
481
- return await bcrypt.compare(password, user.passwordHash);
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()
482
422
  },
483
-
484
- // Custom JWT payload
485
- payload: (user) => ({
486
- sub: user.id,
487
- email: user.email,
488
- roles: user.roles,
489
- }),
490
-
491
- // Custom token store (replace with Redis for multi-instance deployments)
492
- refreshTokenStore: redisAdapter,
493
- });
423
+ // …routes…
424
+ };
494
425
  ```
495
426
 
496
- > **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
-
498
- ---
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:
499
428
 
500
- ## API service builder
429
+ ```ts
430
+ import { apiBuilder, defineController, describe, createJwtPlugin } from 'expediate';
501
431
 
502
- `apiBuilder` lets you define REST endpoints as a controller-style service object, handling scoping, lifecycle, method binding, and error translation automatically.
432
+ const jwt = createJwtPlugin({ accessTokenSecret: SECRET });
503
433
 
504
- ### Defining a service
505
-
506
- ```ts
507
- import { createRouter, json, apiBuilder } from 'expediate';
508
- import type { ServiceDefinition } from 'expediate';
509
-
510
- interface TodoState {
511
- items: Record<string, { title: string; done: boolean }>;
512
- nextId: number;
513
- }
514
-
515
- const todoService: ServiceDefinition<TodoState> = {
516
- // Initial state
517
- data: () => ({ items: {}, nextId: 1 }),
518
-
519
- // Shared helper methods (bound to `this`)
520
- methods: {
521
- findOrThrow(this: TodoState, id: string) {
522
- const item = this.items[id];
523
- if (!item) throw { httpStatus: 404, message: 'Todo not found' };
524
- return item;
525
- },
526
- },
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
527
439
 
528
440
  GET: {
529
- '/todos': function (this: TodoState) { return Object.entries(this.items).map(([id, v]) => ({ id, ...v })); },
530
- '/todos/:id': function (this: TodoState, params) { return this.findOrThrow(params.id); },
531
- },
532
-
533
- POST: {
534
- '/todos': function (this: TodoState, _params, body: any) {
535
- const id = String(this.nextId++);
536
- this.items[id] = { title: body.title, done: false };
537
- return { id, ...this.items[id] };
538
- },
441
+ '/pages/:slug': describe(
442
+ (ctx) => wiki.readPage(ctx.params.proj, ctx.params.slug),
443
+ { summary: 'Read a wiki page' }),
539
444
  },
540
-
541
- DELETE: {
542
- '/todos/:id': function (this: TodoState, params) {
543
- this.findOrThrow(params.id);
544
- delete this.items[params.id];
545
- return undefined; // → 201 No Content
546
- },
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' }),
547
449
  },
548
- };
549
-
550
- const app = createRouter();
551
- app.use('/', json());
552
- app.use('/api', apiBuilder(todoService));
450
+ });
553
451
 
554
- app.listen(3000);
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, … */],
456
+ });
555
457
  ```
556
458
 
557
- **Handler conventions:**
459
+ Duplicate `(verb, path)` pairs across controllers **throw at build time** instead of silently shadowing.
558
460
 
559
- | Return value | HTTP response |
560
- |---|---|
561
- | Truthy value or `Promise` resolving to one | `200 OK` with JSON body |
562
- | `undefined`, `null`, `false`, `0`, `''` | `201 No Content` |
563
- | Throw `{ httpStatus, message }` | `<httpStatus>` with plain-text body |
564
- | Throw `{ httpStatus, data }` | `<httpStatus>` with JSON body |
565
- | Throw anything else | `500 Internal Server Error` |
566
-
567
- ### Scoping
568
-
569
- Control how many instances of the service state are created:
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:
570
462
 
571
463
  ```ts
572
- const service: ServiceDefinition = {
573
- // Singleton (default — omit `scope`): one global instance
574
- // scope: undefined
575
-
576
- // Per-session: same instance reused for all requests sharing the key
577
- scope: (req) => (req as any).session?.ssid ?? null,
578
-
579
- // Per-request: fresh instance for every request (null key)
580
- scope: () => null,
581
-
582
- data: () => ({ /* initial state */ }),
583
- };
464
+ apiBuilder(service, { validateResponses: true }); // validate both incoming bodies and outgoing responses
584
465
  ```
585
466
 
586
- > The key returned by the `scope()` method is store at `this.$key`.
467
+ ---
587
468
 
588
- ### Error handling
469
+ ## OpenAPI spec generation
589
470
 
590
- Throw structured errors from any handler or method to send precise HTTP responses:
471
+ Full reference: [docs/openapi.md](docs/openapi.md)
591
472
 
592
473
  ```ts
593
- // Plain message
594
- throw { httpStatus: 404, message: 'Resource not found' };
595
-
596
- // JSON body
597
- throw { httpStatus: 422, data: { field: 'email', error: 'invalid format' } };
474
+ import { apiBuilder, describe } from 'expediate';
598
475
 
599
- // Guard pattern for async setup
600
- methods: {
601
- throwIfNotReady(this: any) {
602
- if (!this.ready)
603
- throw { httpStatus: 503, message: 'Service initialising try again shortly' };
476
+ const service: ServiceDefinition<State> = {
477
+ GET: {
478
+ '/items': describe(
479
+ function (this: State) { return this.items; },
480
+ { summary: 'List items', responses: { '200': { description: 'Item array' } } },
481
+ ),
604
482
  },
605
- },
606
- setup: function (this: any) {
607
- this.loadData().then(() => { this.ready = true; });
608
- },
483
+ };
484
+
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'));
609
489
  ```
610
490
 
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.
492
+
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).
494
+
611
495
  ---
612
496
 
613
497
  ## Git Smart HTTP gateway
614
498
 
615
- 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:
616
502
 
617
503
  ```ts
618
- import { createRouter, gitHandler } from 'expediate';
504
+ import { createRouter, gitHandler, gitCreate } from 'expediate';
619
505
  import path from 'path';
620
506
 
621
507
  const app = createRouter();
622
508
 
623
509
  app.use('/repos/:repo', gitHandler({
624
510
  repository: (req) => {
625
- // Resolve the repo path from the request; return falsy to 404
626
511
  const name = req.params.repo;
627
512
  if (!/^[\w.-]+$/.test(name)) return null;
628
513
  return path.join('/srv/git', name + '.git');
@@ -632,52 +517,58 @@ app.use('/repos/:repo', gitHandler({
632
517
  app.listen(3000);
633
518
  ```
634
519
 
635
- Clients can now clone with:
636
-
637
520
  ```bash
638
521
  git clone http://localhost:3000/repos/myproject
522
+ git push http://localhost:3000/repos/myproject HEAD:main
639
523
  ```
640
524
 
641
- | Option | Type | Default | Description |
642
- |---|---|---|---|
643
- | `repository` | `(req) => string \| null` | **required** | Resolve the absolute path to the bare repository. Return falsy to send 404 |
644
- | `gitPath` | `string` | `''` | Directory prefix for the `git-upload-pack` binary (include trailing `/`) |
645
- | `strict` | `boolean` | `false` | When `true`, omits `--no-strict` — git will reject non-bare repositories |
646
- | `timeout` | `number \| string` | none | Kill `git-upload-pack` after this many **seconds** |
647
-
648
- **Supported endpoints:**
649
-
650
- | Method | Path | Description |
651
- |---|---|---|
652
- | `GET` | `/info/refs?service=git-upload-pack` | Capability advertisement |
653
- | `POST` | `/git-upload-pack` | Pack negotiation and transfer |
525
+ Create new repositories programmatically:
654
526
 
655
- Gzip-compressed `POST` bodies are decompressed transparently. Spawn errors (e.g. `git` not installed) return `500` with a descriptive message.
527
+ ```ts
528
+ await gitCreate('/srv/git/newrepo.git', { description: 'New repository' });
529
+ ```
656
530
 
657
531
  ---
658
532
 
659
533
  ## TypeScript types
660
534
 
661
- Full type declarations are included. Key types exported from the package:
535
+ Full declarations are included. Key exports:
662
536
 
663
537
  ```ts
664
538
  // Router
665
- import type { Router, RouterRequest, RouterResponse, Middleware, MiddlewareArg } from 'expediate';
539
+ import type {
540
+ Router, RouterOptions, RouterRequest, RouterResponse,
541
+ Middleware, MiddlewareArg, NextFunction, ErrorHandler, ErrorMiddleware,
542
+ Layer, RouteInfo, RouteBuilder, CookieOptions, TlsOptions, StringMap,
543
+ } from 'expediate';
666
544
 
667
545
  // Body parsing
668
- import type { BodyOptions, FormPart } from 'expediate';
546
+ import type {
547
+ BodyOptions, BodyTypeMatcher, VerifyFn, FormPart, FormPartStream,
548
+ LoggerOptions, CorsOptions,
549
+ } from 'expediate';
669
550
 
670
551
  // Static files
671
- import type { StaticOptions } from 'expediate';
552
+ import type { StaticOptions, Mime } from 'expediate';
672
553
 
673
- // Logging
674
- import type { LoggerOptions } from 'expediate';
554
+ // Middleware
555
+ import type {
556
+ CompressOptions, RequestIdOptions, RateLimitOptions,
557
+ CacheControlOptions, CsrfOptions, SecurityHeadersOptions,
558
+ } from 'expediate';
675
559
 
676
560
  // JWT
677
- import type { JwtConfig, JwtPlugin, TokenPayload, UserRecord, TokenStore } from 'expediate';
678
-
679
- // API builder
680
- import type { ServiceDefinition, ServiceMethod, ApiError } from 'expediate';
561
+ import type {
562
+ JwtConfig, JwtPlugin, TokenPayload, TokenStore,
563
+ UserRecord, RefreshTokenRecord,
564
+ } from 'expediate';
565
+
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';
681
572
 
682
573
  // Git
683
574
  import type { GitHandlerOptions } from 'expediate';
@@ -688,8 +579,3 @@ import type { GitHandlerOptions } from 'expediate';
688
579
  ## License
689
580
 
690
581
  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