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