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.
- package/CHANGELOG.md +138 -0
- package/CONTRIBUTING.md +150 -0
- package/README.md +278 -779
- package/dist/apis.d.ts +372 -12
- package/dist/apis.d.ts.map +1 -1
- package/dist/apis.js +483 -65
- package/dist/apis.js.map +1 -1
- package/dist/cjs/index.js +2290 -807
- package/dist/git.d.ts +1 -1
- package/dist/git.d.ts.map +1 -1
- package/dist/git.js +5 -5
- package/dist/git.js.map +1 -1
- package/dist/http-objects.d.ts +26 -0
- package/dist/http-objects.d.ts.map +1 -0
- package/dist/http-objects.js +588 -0
- package/dist/http-objects.js.map +1 -0
- package/dist/index.d.ts +6 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/jwt-auth.d.ts +11 -0
- package/dist/jwt-auth.d.ts.map +1 -1
- package/dist/jwt-auth.js +9 -9
- package/dist/jwt-auth.js.map +1 -1
- package/dist/middleware.js +2 -2
- package/dist/middleware.js.map +1 -1
- package/dist/mimetypes.json +882 -1
- package/dist/misc.d.ts +161 -25
- package/dist/misc.d.ts.map +1 -1
- package/dist/misc.js +228 -80
- package/dist/misc.js.map +1 -1
- package/dist/openapi.d.ts +156 -13
- package/dist/openapi.d.ts.map +1 -1
- package/dist/openapi.js +214 -71
- package/dist/openapi.js.map +1 -1
- package/dist/router-types.d.ts +760 -0
- package/dist/router-types.d.ts.map +1 -0
- package/dist/router-types.js +23 -0
- package/dist/router-types.js.map +1 -0
- package/dist/router.d.ts +7 -530
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +128 -375
- package/dist/router.js.map +1 -1
- package/dist/static.d.ts +2 -2
- package/dist/static.d.ts.map +1 -1
- package/dist/static.js +77 -22
- package/dist/static.js.map +1 -1
- package/docs/THREAT_MODEL.md +52 -0
- package/docs/api-builder-v2-design.md +644 -0
- package/docs/api-builder-v3-design.md +397 -0
- package/docs/api-builder.md +454 -0
- package/docs/benchmark.md +27 -0
- package/docs/body-parsing.md +223 -0
- package/docs/errors.md +359 -0
- package/docs/expediate.png +0 -0
- package/docs/git.md +139 -0
- package/docs/jwt-auth.md +251 -0
- package/docs/logo.svg +12 -0
- package/docs/middleware.md +264 -0
- package/docs/openapi.md +180 -0
- package/docs/router.md +356 -0
- package/docs/static.md +128 -0
- package/docs/wiki.json +123 -0
- package/package.json +30 -8
- package/dist/cjs/apis.js +0 -327
- package/dist/cjs/git.js +0 -293
- package/dist/cjs/jwt-auth.js +0 -532
- package/dist/cjs/middleware.js +0 -511
- package/dist/cjs/mimetypes.json +0 -1
- package/dist/cjs/misc.js +0 -787
- package/dist/cjs/openapi.js +0 -485
- package/dist/cjs/router.js +0 -898
- package/dist/cjs/static.js +0 -669
package/README.md
CHANGED
|
@@ -15,8 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
---
|
|
17
17
|
|
|
18
|
-
**
|
|
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
|
-
|
|
44
|
-
|
|
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(
|
|
88
|
-
app.use(
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
app.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
153
|
-
|
|
114
|
+
app.route('/users/:id')
|
|
115
|
+
.get(getUser)
|
|
116
|
+
.put(replaceUser)
|
|
117
|
+
.delete(removeUser);
|
|
154
118
|
```
|
|
155
119
|
|
|
156
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
135
|
+
Every request is augmented before middleware runs:
|
|
184
136
|
|
|
185
|
-
| Field |
|
|
186
|
-
|
|
187
|
-
| `req.
|
|
188
|
-
| `req.
|
|
189
|
-
| `req.
|
|
190
|
-
| `req.
|
|
191
|
-
| `req.
|
|
192
|
-
| `req.
|
|
193
|
-
| `req.
|
|
194
|
-
| `req.
|
|
195
|
-
| `req.
|
|
196
|
-
| `req.
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
|
210
|
-
res.
|
|
211
|
-
res.status(
|
|
212
|
-
res.
|
|
213
|
-
res.
|
|
214
|
-
res.json(
|
|
215
|
-
res.
|
|
216
|
-
res.
|
|
217
|
-
res.
|
|
218
|
-
res.
|
|
219
|
-
res.
|
|
220
|
-
res.
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
-
|
|
179
|
+
→ Full reference: [docs/errors.md](docs/errors.md)
|
|
232
180
|
|
|
233
|
-
|
|
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
|
|
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.
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
|
|
249
|
-
|
|
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
|
-
|
|
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('/
|
|
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
|
|
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
|
-
|
|
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.
|
|
275
|
-
app.use('/auth', authRouter.listener); // equivalent
|
|
215
|
+
app.all('/**', (_req, res) => res.status(404).json({ error: 'Not Found' }));
|
|
276
216
|
```
|
|
277
217
|
|
|
278
|
-
|
|
218
|
+
Without one, unmatched requests fall back to the built-in `Cannot METHOD /path` 404.
|
|
279
219
|
|
|
280
|
-
|
|
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
|
-
|
|
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
|
-
|
|
239
|
+
→ Full reference: [docs/body-parsing.md](docs/body-parsing.md)
|
|
318
240
|
|
|
319
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
256
|
+
Request bodies encoded with `gzip`, `deflate`, or `br` (Brotli) are decompressed automatically (disable with `inflate: false`).
|
|
381
257
|
|
|
382
|
-
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
301
|
+
// Serve a directory
|
|
302
|
+
app.use('/public', serveStatic('./dist', { maxAge: 86_400_000 }));
|
|
449
303
|
|
|
450
|
-
|
|
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
|
-
|
|
470
|
-
|
|
471
|
-
sendFile(req as any, res 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
|
-
|
|
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
|
-
|
|
317
|
+
## Middleware suite
|
|
613
318
|
|
|
614
|
-
|
|
615
|
-
import { securityHeaders } from 'expediate';
|
|
319
|
+
→ Full reference: [docs/middleware.md](docs/middleware.md)
|
|
616
320
|
|
|
617
|
-
|
|
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
|
-
| `
|
|
625
|
-
| `
|
|
626
|
-
| `
|
|
627
|
-
| `
|
|
628
|
-
| `
|
|
629
|
-
| `
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
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
|
-
|
|
643
|
-
app.use(
|
|
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
|
|
349
|
+
## JWT authentication
|
|
695
350
|
|
|
696
|
-
|
|
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:
|
|
704
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
720
|
-
{
|
|
721
|
-
"accessToken": "eyJ...",
|
|
722
|
-
"refreshToken": "a3f8...",
|
|
723
|
-
"expiresIn": 900,
|
|
724
|
-
"tokenType": "Bearer"
|
|
725
|
-
}
|
|
726
|
-
```
|
|
377
|
+
---
|
|
727
378
|
|
|
728
|
-
|
|
379
|
+
## API service builder
|
|
729
380
|
|
|
730
|
-
|
|
731
|
-
// Request body
|
|
732
|
-
{ "username": "alice", "refreshToken": "a3f8..." }
|
|
381
|
+
→ Full reference: [docs/api-builder.md](docs/api-builder.md)
|
|
733
382
|
|
|
734
|
-
|
|
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
|
-
|
|
385
|
+
```ts
|
|
386
|
+
import { createRouter, json, apiBuilder } from 'expediate';
|
|
387
|
+
import type { ServiceDefinition, ApiContext } from 'expediate';
|
|
739
388
|
|
|
740
|
-
|
|
389
|
+
interface State { items: string[]; }
|
|
741
390
|
|
|
742
|
-
|
|
743
|
-
{
|
|
744
|
-
// Response 200 — refresh token revoked
|
|
745
|
-
```
|
|
391
|
+
const service: ServiceDefinition<State> = {
|
|
392
|
+
data: () => ({ items: [] }),
|
|
746
393
|
|
|
747
|
-
|
|
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
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
app.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
app.
|
|
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
|
-
|
|
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
|
-
|
|
785
|
-
```
|
|
430
|
+
import { apiBuilder, defineController, describe, createJwtPlugin } from 'expediate';
|
|
786
431
|
|
|
787
|
-
|
|
432
|
+
const jwt = createJwtPlugin({ accessTokenSecret: SECRET });
|
|
788
433
|
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
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
|
-
|
|
816
|
-
|
|
817
|
-
|
|
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
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
471
|
+
→ Full reference: [docs/openapi.md](docs/openapi.md)
|
|
848
472
|
|
|
849
473
|
```ts
|
|
850
|
-
import {
|
|
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
|
-
'/
|
|
873
|
-
|
|
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
|
|
894
|
-
app.use('/',
|
|
895
|
-
app.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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 {
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
561
|
+
import type {
|
|
562
|
+
JwtConfig, JwtPlugin, TokenPayload, TokenStore,
|
|
563
|
+
UserRecord, RefreshTokenRecord,
|
|
564
|
+
} from 'expediate';
|
|
1070
565
|
|
|
1071
|
-
// OpenAPI
|
|
1072
|
-
import type {
|
|
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';
|