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.
- package/CHANGELOG.md +138 -0
- package/CONTRIBUTING.md +150 -0
- package/LICENSE +16 -16
- package/README.md +330 -444
- package/dist/apis.d.ts +504 -27
- package/dist/apis.d.ts.map +1 -1
- package/dist/apis.js +618 -107
- package/dist/apis.js.map +1 -1
- package/dist/cjs/index.js +4066 -0
- package/dist/cjs/package.json +1 -0
- package/dist/git.d.ts +72 -9
- package/dist/git.d.ts.map +1 -1
- package/dist/git.js +129 -74
- 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 +18 -13
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -24
- package/dist/index.js.map +1 -1
- package/dist/jwt-auth.d.ts +158 -57
- package/dist/jwt-auth.d.ts.map +1 -1
- package/dist/jwt-auth.js +447 -207
- package/dist/jwt-auth.js.map +1 -1
- package/dist/middleware.d.ts +476 -0
- package/dist/middleware.d.ts.map +1 -0
- package/dist/middleware.js +647 -0
- package/dist/middleware.js.map +1 -0
- package/dist/mimetypes.json +882 -1
- package/dist/misc.d.ts +268 -25
- package/dist/misc.d.ts.map +1 -1
- package/dist/misc.js +449 -168
- package/dist/misc.js.map +1 -1
- package/dist/openapi.d.ts +433 -0
- package/dist/openapi.d.ts.map +1 -0
- package/dist/openapi.js +624 -0
- package/dist/openapi.js.map +1 -0
- 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 +37 -201
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +502 -244
- package/dist/router.js.map +1 -1
- package/dist/static.d.ts +3 -3
- package/dist/static.d.ts.map +1 -1
- package/dist/static.js +164 -105
- 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 +47 -8
- package/.npmignore +0 -16
package/README.md
CHANGED
|
@@ -1,11 +1,21 @@
|
|
|
1
|
-
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="docs/expediate.png" alt="expediate" width="260" />
|
|
3
|
+
</p>
|
|
2
4
|
|
|
3
|
-
|
|
5
|
+
<p align="center">
|
|
6
|
+
A lightweight, zero-dependency TypeScript HTTP routing framework for Node.js.
|
|
7
|
+
</p>
|
|
4
8
|
|
|
5
|
-
|
|
9
|
+
<p align="center">
|
|
10
|
+
<a href="https://www.npmjs.com/package/expediate"><img src="https://img.shields.io/npm/v/expediate.svg" alt="npm version" /></a>
|
|
11
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="MIT License" /></a>
|
|
12
|
+
<img src="https://img.shields.io/badge/node-%3E%3D18-brightgreen" alt="Node ≥ 18" />
|
|
13
|
+
<a href="https://npmcharts.com/compare/expediate?minimal=true"><img src="https://img.shields.io/npm/dm/expediate.svg" alt="npm downloads" /></a>
|
|
14
|
+
</p>
|
|
6
15
|
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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(
|
|
64
|
-
app.use(
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
111
|
+
To register several methods against one path without repeating it, use the chainable `route()` builder:
|
|
111
112
|
|
|
112
113
|
```ts
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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('/
|
|
135
|
-
app.get('/
|
|
136
|
-
app.get('
|
|
137
|
-
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)
|
|
138
131
|
```
|
|
139
132
|
|
|
140
|
-
|
|
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
|
-
|
|
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
|
-
|
|
177
|
+
### Error handling
|
|
146
178
|
|
|
147
|
-
|
|
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
|
-
|
|
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
|
-
|
|
153
|
-
res.
|
|
154
|
-
|
|
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
|
-
|
|
191
|
+
app.error((err, _req, res, _next) => {
|
|
192
|
+
res.status((err as any)?.status ?? 500).json({ error: String(err) });
|
|
193
|
+
});
|
|
194
|
+
```
|
|
167
195
|
|
|
168
|
-
|
|
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('/
|
|
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
|
|
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
|
-
|
|
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.
|
|
184
|
-
app.use('/auth', authRouter.listener); // equivalent
|
|
215
|
+
app.all('/**', (_req, res) => res.status(404).json({ error: 'Not Found' }));
|
|
185
216
|
```
|
|
186
217
|
|
|
187
|
-
|
|
218
|
+
Without one, unmatched requests fall back to the built-in `Cannot METHOD /path` 404.
|
|
219
|
+
|
|
220
|
+
### Server
|
|
188
221
|
|
|
189
222
|
```ts
|
|
190
|
-
|
|
191
|
-
app.listen(3000, () => console.log('Ready'));
|
|
223
|
+
const server = app.listen(3000, () => console.log('Ready'));
|
|
192
224
|
|
|
193
225
|
// HTTPS
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
239
|
+
→ Full reference: [docs/body-parsing.md](docs/body-parsing.md)
|
|
206
240
|
|
|
207
|
-
|
|
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(
|
|
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
|
-
|
|
217
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
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 {
|
|
273
|
+
import { streamFormData } from 'expediate';
|
|
267
274
|
|
|
268
|
-
app.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
301
|
+
// Serve a directory
|
|
302
|
+
app.use('/public', serveStatic('./dist', { maxAge: 86_400_000 }));
|
|
304
303
|
|
|
305
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
##
|
|
317
|
+
## Middleware suite
|
|
333
318
|
|
|
334
|
-
|
|
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
|
-
|
|
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
|
-
|
|
350
|
-
|
|
333
|
+
```ts
|
|
334
|
+
import {
|
|
335
|
+
compress, conditionalGet, cacheControl, requestId,
|
|
336
|
+
rateLimit, csrf, securityHeaders, cors, logger,
|
|
337
|
+
} from 'expediate';
|
|
351
338
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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
|
|
349
|
+
## JWT authentication
|
|
364
350
|
|
|
365
|
-
|
|
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:
|
|
373
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
377
|
+
---
|
|
408
378
|
|
|
409
|
-
|
|
379
|
+
## API service builder
|
|
410
380
|
|
|
411
|
-
|
|
412
|
-
{ "refreshToken": "b9c2..." }
|
|
413
|
-
// Response 200 — refresh token revoked
|
|
414
|
-
```
|
|
381
|
+
→ Full reference: [docs/api-builder.md](docs/api-builder.md)
|
|
415
382
|
|
|
416
|
-
|
|
383
|
+
Define REST endpoints as a controller-style service object with automatic scoping, lifecycle management, and error translation:
|
|
417
384
|
|
|
418
385
|
```ts
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
|
|
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
|
-
|
|
391
|
+
const service: ServiceDefinition<State> = {
|
|
392
|
+
data: () => ({ items: [] }),
|
|
441
393
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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
|
-
|
|
447
|
-
app.
|
|
406
|
+
const app = createRouter();
|
|
407
|
+
app.use('/', json());
|
|
408
|
+
app.use('/api', apiBuilder(service));
|
|
409
|
+
app.listen(3000);
|
|
448
410
|
```
|
|
449
411
|
|
|
450
|
-
|
|
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
|
-
|
|
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
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
429
|
+
```ts
|
|
430
|
+
import { apiBuilder, defineController, describe, createJwtPlugin } from 'expediate';
|
|
501
431
|
|
|
502
|
-
|
|
432
|
+
const jwt = createJwtPlugin({ accessTokenSecret: SECRET });
|
|
503
433
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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
|
-
'/
|
|
530
|
-
|
|
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
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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
|
-
|
|
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
|
-
**
|
|
459
|
+
Duplicate `(verb, path)` pairs across controllers **throw at build time** instead of silently shadowing.
|
|
558
460
|
|
|
559
|
-
|
|
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
|
-
|
|
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
|
-
|
|
467
|
+
---
|
|
587
468
|
|
|
588
|
-
|
|
469
|
+
## OpenAPI spec generation
|
|
589
470
|
|
|
590
|
-
|
|
471
|
+
→ Full reference: [docs/openapi.md](docs/openapi.md)
|
|
591
472
|
|
|
592
473
|
```ts
|
|
593
|
-
|
|
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
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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
|
-
|
|
607
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
535
|
+
Full declarations are included. Key exports:
|
|
662
536
|
|
|
663
537
|
```ts
|
|
664
538
|
// Router
|
|
665
|
-
import type {
|
|
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 {
|
|
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
|
-
//
|
|
674
|
-
import type {
|
|
554
|
+
// Middleware
|
|
555
|
+
import type {
|
|
556
|
+
CompressOptions, RequestIdOptions, RateLimitOptions,
|
|
557
|
+
CacheControlOptions, CsrfOptions, SecurityHeadersOptions,
|
|
558
|
+
} from 'expediate';
|
|
675
559
|
|
|
676
560
|
// JWT
|
|
677
|
-
import type {
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
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
|