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/README.md CHANGED
@@ -1,84 +1,695 @@
1
- # Expediate
1
+ # expediate
2
2
 
3
- This small library serve as a router facility for a web server.
4
- For compatibility reason it keep the exact same interface as express.js,
5
- however it doesn't intent to provide all the features.
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
- app.get('/', function (req, res) {
19
- res.send('Hello World')
20
- })
10
+ ---
21
11
 
22
- app.listen(3000)
23
- ```
12
+ ## Table of contents
24
13
 
25
- The main reason for this package is that expressjs grow to become extra
26
- complicated with tons of dependencies. Expediate aims to keep thing simple
27
- while retaining the most widely used features.
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
- This is a [Node.js](https://nodejs.org/en/) module available through the
32
- [npm registry](https://www.npmjs.com/).
48
+ ```bash
49
+ npm install expediate
50
+ ```
33
51
 
34
- Before installing, [download and install Node.js](https://nodejs.org/en/download/).
52
+ Node.js 18 is required. The package ships as native ESM with full TypeScript declarations.
35
53
 
36
- If this is a brand new project, make sure to create a `package.json` first with
37
- the [`npm init` command](https://docs.npmjs.com/creating-a-package-json-file).
54
+ ---
38
55
 
39
- Installation is done using the
40
- [`npm install` command](https://docs.npmjs.com/getting-started/installing-npm-packages-locally):
56
+ ## Quick start
41
57
 
42
- ```bash
43
- $ npm install expediate
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
- ## Usage
78
+ ---
79
+
80
+ ## Router
47
81
 
82
+ ### Creating a router
48
83
 
49
- ### Middleware
84
+ ```ts
85
+ import { createRouter } from 'expediate';
50
86
 
51
- json => Parse Json body
52
- static => Serve static files
53
- urlencdeded => Parse url encoded
87
+ const app = createRouter();
88
+ ```
54
89
 
55
- ```js
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
- var app = expediate();
60
- var app.use('/api', apiApp);
92
+ ### Route registration
61
93
 
62
- app.listen(80)
63
- app.listen(443, { key: 'xx', cert: 'xx' })
64
- apiApp.listen(8900) // Only api
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
- ## License
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
- [MIT](LICENSE)
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
-