expediate 1.0.3 → 1.0.5
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/LICENSE +16 -16
- package/README.md +417 -30
- package/dist/apis.d.ts +138 -21
- package/dist/apis.d.ts.map +1 -1
- package/dist/apis.js +172 -79
- package/dist/apis.js.map +1 -1
- package/dist/cjs/apis.js +327 -0
- package/dist/cjs/git.js +293 -0
- package/dist/cjs/index.js +2583 -0
- package/dist/cjs/jwt-auth.js +532 -0
- package/dist/cjs/middleware.js +511 -0
- package/dist/cjs/mimetypes.json +1 -0
- package/dist/cjs/misc.js +787 -0
- package/dist/cjs/openapi.js +485 -0
- package/dist/cjs/package.json +1 -0
- package/dist/cjs/router.js +898 -0
- package/dist/cjs/static.js +669 -0
- package/dist/git.d.ts +71 -8
- package/dist/git.d.ts.map +1 -1
- package/dist/git.js +127 -72
- package/dist/git.js.map +1 -1
- package/dist/index.d.ts +17 -13
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +14 -24
- package/dist/index.js.map +1 -1
- package/dist/jwt-auth.d.ts +147 -57
- package/dist/jwt-auth.d.ts.map +1 -1
- package/dist/jwt-auth.js +445 -205
- 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 +1 -1
- package/dist/misc.d.ts +153 -12
- package/dist/misc.d.ts.map +1 -1
- package/dist/misc.js +325 -97
- package/dist/misc.js.map +1 -1
- package/dist/openapi.d.ts +290 -0
- package/dist/openapi.d.ts.map +1 -0
- package/dist/openapi.js +481 -0
- package/dist/openapi.js.map +1 -0
- package/dist/router.d.ts +407 -45
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +665 -137
- package/dist/router.js.map +1 -1
- package/dist/static.d.ts +1 -1
- package/dist/static.d.ts.map +1 -1
- package/dist/static.js +93 -86
- package/dist/static.js.map +1 -1
- package/package.json +21 -4
- package/.npmignore +0 -16
package/dist/cjs/apis.js
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/* Copyright 2021 Fabien Bavent
|
|
2
|
+
*
|
|
3
|
+
* Permission is hereby granted, free of charge, to any person obtaining a
|
|
4
|
+
* copy of this software and associated documentation files (the "Software"),
|
|
5
|
+
* to deal in the Software without restriction, including without limitation
|
|
6
|
+
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
|
7
|
+
* and/or sell copies of the Software, and to permit persons to whom the
|
|
8
|
+
* Software is furnished to do so, subject to the following conditions:
|
|
9
|
+
*
|
|
10
|
+
* The above copyright notice and this permission notice shall be included
|
|
11
|
+
* in all copies or substantial portions of the Software.
|
|
12
|
+
*
|
|
13
|
+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
|
14
|
+
* OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
|
|
16
|
+
* THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
18
|
+
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
|
19
|
+
* DEALINGS IN THE SOFTWARE.
|
|
20
|
+
*/
|
|
21
|
+
'use strict';
|
|
22
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
|
+
exports.apiBuilder = apiBuilder;
|
|
24
|
+
const router_js_1 = require("./router.js");
|
|
25
|
+
const openapi_js_1 = require("./openapi.js");
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Internal helpers
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
/**
|
|
30
|
+
* Instantiate a new service module for the given scope `key` and await its
|
|
31
|
+
* setup lifecycle hook.
|
|
32
|
+
*
|
|
33
|
+
* The lifecycle is:
|
|
34
|
+
* 1. **`data(key)`** — create the initial data object (`Partial<TInstance>`);
|
|
35
|
+
* methods are mixed in next to complete the shape.
|
|
36
|
+
* 2. **Mix in methods** — each entry in `service.methods` is copied onto the
|
|
37
|
+
* instance as a regular function bound to `this = instance`.
|
|
38
|
+
* 3. **`await setup()`** — if `setup` returns a `Promise`, it is fully
|
|
39
|
+
* awaited before the instance is returned. Any rejection propagates to
|
|
40
|
+
* the caller.
|
|
41
|
+
*
|
|
42
|
+
* @param service - The service definition.
|
|
43
|
+
* @param key - The scope key (`'singleton'`, `null`, or a session ID).
|
|
44
|
+
* @returns A Promise resolving to a fully initialised service instance.
|
|
45
|
+
*/
|
|
46
|
+
async function buildModule(service, key) {
|
|
47
|
+
// `data()` returns Partial<TInstance>; methods are mixed in below to complete
|
|
48
|
+
// the instance shape. The cast is intentional and safe — by the time this
|
|
49
|
+
// function returns the instance IS a full TInstance.
|
|
50
|
+
const instance = service.data
|
|
51
|
+
? service.data(key)
|
|
52
|
+
: { $key: key };
|
|
53
|
+
// Mix service methods into the instance, bound to `this = instance`.
|
|
54
|
+
// Regular function expressions are used (not arrow functions) so that each
|
|
55
|
+
// method has its own `arguments` object and `this` binding works correctly.
|
|
56
|
+
if (service.methods) {
|
|
57
|
+
for (const methodName of Object.keys(service.methods)) {
|
|
58
|
+
const method = service.methods[methodName];
|
|
59
|
+
instance[methodName] = function (...args) {
|
|
60
|
+
return method.apply(instance, args);
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// Await setup so that async initialisation (DB connections, config fetches,
|
|
65
|
+
// etc.) completes before the instance is considered ready.
|
|
66
|
+
if (service.setup)
|
|
67
|
+
await service.setup.apply(instance, []);
|
|
68
|
+
return instance;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Resolve the correct service instance for an incoming request.
|
|
72
|
+
*
|
|
73
|
+
* Instance lifecycle by scope:
|
|
74
|
+
* - **Singleton** (`scope` absent): always returns `modules['singleton']`.
|
|
75
|
+
* The singleton is guaranteed to be fully initialised before routes run,
|
|
76
|
+
* so this path is synchronous within the async wrapper.
|
|
77
|
+
* - **Keyed** (`scope` returns a string): look up `modules[key]`; on first
|
|
78
|
+
* access, build and cache the instance (in-flight builds for the same key
|
|
79
|
+
* are deduplicated via `building` to avoid concurrent double-builds).
|
|
80
|
+
* - **Ephemeral** (`scope` returns `null`): create a fresh, uncached instance
|
|
81
|
+
* for every request.
|
|
82
|
+
*
|
|
83
|
+
* @param service - The service definition.
|
|
84
|
+
* @param modules - The resolved-instance cache (mutated on first keyed access).
|
|
85
|
+
* @param building - In-flight build-promise cache; prevents duplicate builds
|
|
86
|
+
* for the same key under concurrent requests.
|
|
87
|
+
* @param req - The incoming request.
|
|
88
|
+
* @returns A Promise resolving to the service instance for this request.
|
|
89
|
+
*/
|
|
90
|
+
async function resolveInstance(service, modules, building, req) {
|
|
91
|
+
if (typeof service.scope !== 'function') {
|
|
92
|
+
// Singleton — routes only run after setup is complete, so this is safe.
|
|
93
|
+
return modules['singleton'];
|
|
94
|
+
}
|
|
95
|
+
const key = service.scope(req);
|
|
96
|
+
if (key === null) {
|
|
97
|
+
// Ephemeral — create a fresh, uncached instance for every request.
|
|
98
|
+
return buildModule(service, null);
|
|
99
|
+
}
|
|
100
|
+
// Keyed — retrieve from resolved cache or initiate (and deduplicate) a build.
|
|
101
|
+
if (modules[key])
|
|
102
|
+
return modules[key];
|
|
103
|
+
if (!building[key]) {
|
|
104
|
+
building[key] = buildModule(service, key).then(instance => {
|
|
105
|
+
modules[key] = instance;
|
|
106
|
+
delete building[key];
|
|
107
|
+
return instance;
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
return building[key];
|
|
111
|
+
}
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// Response helpers
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
/**
|
|
116
|
+
* Send a JSON response with the appropriate `Content-Type` header.
|
|
117
|
+
*
|
|
118
|
+
* @param res - The outgoing response.
|
|
119
|
+
* @param data - Any JSON-serialisable value.
|
|
120
|
+
*/
|
|
121
|
+
function sendJson(res, data) {
|
|
122
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
123
|
+
res.send(JSON.stringify(data));
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Translate a caught error (thrown or rejected by a service method) into an
|
|
127
|
+
* HTTP error response.
|
|
128
|
+
*
|
|
129
|
+
* Expected shape: `{ status?, data?, message? }` (see {@link ApiError}).
|
|
130
|
+
* Any other thrown value is treated as an opaque 500 Internal Server Error.
|
|
131
|
+
*
|
|
132
|
+
* @param res - The outgoing response.
|
|
133
|
+
* @param err - The caught value.
|
|
134
|
+
*/
|
|
135
|
+
function sendError(res, err) {
|
|
136
|
+
const e = err;
|
|
137
|
+
const status = e?.status ?? 500;
|
|
138
|
+
if (e?.data !== undefined) {
|
|
139
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
140
|
+
res.status(status).send(JSON.stringify(e.data));
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
res.status(status).send(e?.message ?? 'Internal error');
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// ---------------------------------------------------------------------------
|
|
147
|
+
// Public API
|
|
148
|
+
// ---------------------------------------------------------------------------
|
|
149
|
+
/**
|
|
150
|
+
* Build an Express-compatible router from a service definition object.
|
|
151
|
+
*
|
|
152
|
+
* The returned router is suitable for mounting via `app.use()`:
|
|
153
|
+
*
|
|
154
|
+
* ```ts
|
|
155
|
+
* import myService from './my-service.js';
|
|
156
|
+
*
|
|
157
|
+
* app.use('/api', apiBuilder(myService));
|
|
158
|
+
* ```
|
|
159
|
+
*
|
|
160
|
+
* **Singleton setup lifecycle:**
|
|
161
|
+
* When `service.scope` is absent the service is a singleton. The framework
|
|
162
|
+
* pre-builds the instance eagerly and registers a **503 Service not ready**
|
|
163
|
+
* guard middleware on the router immediately. Route handlers are registered
|
|
164
|
+
* only once `setup()` resolves (or immediately, for sync setup). Any request
|
|
165
|
+
* that arrives before setup completes receives a 503 response.
|
|
166
|
+
*
|
|
167
|
+
* **Keyed / ephemeral setup lifecycle:**
|
|
168
|
+
* Route handlers are registered immediately. For each request,
|
|
169
|
+
* `resolveInstance()` is awaited inside the handler, so `setup()` is always
|
|
170
|
+
* complete before the service method is invoked.
|
|
171
|
+
*
|
|
172
|
+
* **Route handlers** declared in `service.GET`, `service.POST`, etc. are
|
|
173
|
+
* called with `this` bound to the service instance. They receive two
|
|
174
|
+
* arguments:
|
|
175
|
+
* 1. `params` — merged route + query-string parameters from `req.params`.
|
|
176
|
+
* 2. `body` — the parsed request body from `req.body` (requires a
|
|
177
|
+
* body-parsing middleware such as `json()` to run first).
|
|
178
|
+
*
|
|
179
|
+
* **Return values:**
|
|
180
|
+
* - A **truthy value** (or a `Promise` resolving to one) → serialised as JSON
|
|
181
|
+
* with status `200 OK`.
|
|
182
|
+
* - A **falsy value** (`undefined`, `null`, `false`, `0`, `''`) or a
|
|
183
|
+
* `Promise` resolving to one → `201 No Content` (useful for mutations).
|
|
184
|
+
*
|
|
185
|
+
* **Error handling:**
|
|
186
|
+
* - Throwing or rejecting with `{ status, message }` sends the
|
|
187
|
+
* corresponding HTTP error.
|
|
188
|
+
* - Throwing or rejecting with `{ status, data }` sends the `data` object
|
|
189
|
+
* as a JSON body.
|
|
190
|
+
* - Any other thrown value produces `500 Internal Server Error`.
|
|
191
|
+
*
|
|
192
|
+
* @param service - The service definition (see {@link ServiceDefinition}).
|
|
193
|
+
* @returns A router instance pre-configured with all declared routes.
|
|
194
|
+
*/
|
|
195
|
+
function apiBuilder(service) {
|
|
196
|
+
const api = (0, router_js_1.default)();
|
|
197
|
+
/** Resolved instance cache: populated once setup completes for a given key. */
|
|
198
|
+
const modules = {};
|
|
199
|
+
/** In-flight build promises: deduplicates concurrent keyed instance builds. */
|
|
200
|
+
const building = {};
|
|
201
|
+
/**
|
|
202
|
+
* Register all route handlers from a route map for a given HTTP method.
|
|
203
|
+
*
|
|
204
|
+
* Each handler:
|
|
205
|
+
* 1. Resolves the correct service instance (awaiting setup when needed).
|
|
206
|
+
* 2. Invokes the service method with `(params, body)`.
|
|
207
|
+
* 3. Sends the return value as JSON (or 201 if falsy).
|
|
208
|
+
* 4. Catches thrown / rejected {@link ApiError} objects and translates them
|
|
209
|
+
* into the appropriate HTTP error response.
|
|
210
|
+
*
|
|
211
|
+
* @param routeMap - Map of path patterns to service methods (`undefined` = skip).
|
|
212
|
+
* @param register - Registers a handler on the router for the current HTTP method.
|
|
213
|
+
*/
|
|
214
|
+
function buildRoutes(routeMap, register) {
|
|
215
|
+
if (!routeMap)
|
|
216
|
+
return;
|
|
217
|
+
// Sort routes by decreasing specificity so that more precise patterns
|
|
218
|
+
// (more segments, fewer parameters) are registered first in the router.
|
|
219
|
+
// Without this, a plain path like '/items' would match '/items/1' as a
|
|
220
|
+
// prefix and steal requests intended for '/items/:id'.
|
|
221
|
+
// Specificity = (segment count * 100) - (parameter count * 10).
|
|
222
|
+
const sortedPaths = Object.keys(routeMap).sort((a, b) => {
|
|
223
|
+
const score = (p) => {
|
|
224
|
+
const segs = p.split('/').filter(s => s.length > 0);
|
|
225
|
+
return segs.length * 100 - segs.filter(s => s.startsWith(':')).length * 10;
|
|
226
|
+
};
|
|
227
|
+
return score(b) - score(a) || b.localeCompare(a);
|
|
228
|
+
});
|
|
229
|
+
for (const path of sortedPaths) {
|
|
230
|
+
const method = routeMap[path];
|
|
231
|
+
register(path, (req, res) => {
|
|
232
|
+
const params = req.params;
|
|
233
|
+
const body = req.body;
|
|
234
|
+
// Await instance resolution (no-op microtask for singletons; may
|
|
235
|
+
// trigger async buildModule for keyed / ephemeral instances).
|
|
236
|
+
resolveInstance(service, modules, building, req)
|
|
237
|
+
.then(instance => {
|
|
238
|
+
const ret = method.apply(instance, [params, body]);
|
|
239
|
+
if (ret instanceof Promise) {
|
|
240
|
+
return ret
|
|
241
|
+
.then(val => {
|
|
242
|
+
if (val !== undefined && val !== null && val !== false && val !== 0 && val !== '')
|
|
243
|
+
sendJson(res, val);
|
|
244
|
+
else
|
|
245
|
+
res.status(201).end();
|
|
246
|
+
})
|
|
247
|
+
.catch(err => sendError(res, err));
|
|
248
|
+
}
|
|
249
|
+
if (ret !== undefined && ret !== null && ret !== false && ret !== 0 && ret !== '')
|
|
250
|
+
sendJson(res, ret);
|
|
251
|
+
else
|
|
252
|
+
res.status(201).end();
|
|
253
|
+
})
|
|
254
|
+
.catch(err => sendError(res, err));
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
/** Convenience wrapper to register routes for all five HTTP verbs. */
|
|
259
|
+
function registerAllRoutes() {
|
|
260
|
+
buildRoutes(service.GET, (path, h) => api.get(path, h));
|
|
261
|
+
buildRoutes(service.POST, (path, h) => api.post(path, h));
|
|
262
|
+
buildRoutes(service.PUT, (path, h) => api.put(path, h));
|
|
263
|
+
buildRoutes(service.DELETE, (path, h) => api.delete(path, h));
|
|
264
|
+
buildRoutes(service.PATCH, (path, h) => api.patch(path, h));
|
|
265
|
+
}
|
|
266
|
+
if (typeof service.scope !== 'function') {
|
|
267
|
+
// ── Singleton ─────────────────────────────────────────────────────────
|
|
268
|
+
// Register a "not ready" guard first so that requests arriving while
|
|
269
|
+
// setup is in progress receive 503 rather than 404.
|
|
270
|
+
let ready = false;
|
|
271
|
+
api.use('/', (_req, res, next) => {
|
|
272
|
+
if (!ready) {
|
|
273
|
+
res.statusCode = 503;
|
|
274
|
+
res.end('Service not ready');
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
next();
|
|
278
|
+
});
|
|
279
|
+
// Build the singleton asynchronously, then flip the guard and register
|
|
280
|
+
// routes. Even for synchronous setup() functions, buildModule() is async
|
|
281
|
+
// (it uses await internally), so route registration happens in a microtask
|
|
282
|
+
// that runs before any I/O callbacks — routes are always in place by the
|
|
283
|
+
// time the first HTTP request can be processed.
|
|
284
|
+
buildModule(service, 'singleton')
|
|
285
|
+
.then(instance => {
|
|
286
|
+
modules['singleton'] = instance;
|
|
287
|
+
ready = true;
|
|
288
|
+
registerAllRoutes();
|
|
289
|
+
})
|
|
290
|
+
.catch(err => {
|
|
291
|
+
// setup() rejected — log the error; the guard permanently returns 503.
|
|
292
|
+
console.error('[apiBuilder] singleton setup() rejected:', err);
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
else {
|
|
296
|
+
// ── Keyed / ephemeral ─────────────────────────────────────────────────
|
|
297
|
+
// Register routes immediately. Each handler awaits resolveInstance(),
|
|
298
|
+
// which in turn awaits buildModule() for first-time keyed instances.
|
|
299
|
+
registerAllRoutes();
|
|
300
|
+
}
|
|
301
|
+
// ── OpenAPI introspection ──────────────────────────────────────────────────
|
|
302
|
+
/**
|
|
303
|
+
* Generate an OpenAPI 3.1.0 document from the service definition.
|
|
304
|
+
* Delegates to {@link openApiSpec} from `openapi.ts`.
|
|
305
|
+
*/
|
|
306
|
+
api.spec = function (opts) {
|
|
307
|
+
return (0, openapi_js_1.openApiSpec)(service, opts);
|
|
308
|
+
};
|
|
309
|
+
/**
|
|
310
|
+
* Return a middleware handler that serves the OpenAPI spec as JSON or YAML.
|
|
311
|
+
* The spec is generated once and cached on the first call to the returned handler.
|
|
312
|
+
*/
|
|
313
|
+
api.specHandler = function (opts, format = 'json') {
|
|
314
|
+
let cached = null;
|
|
315
|
+
const contentType = format === 'yaml'
|
|
316
|
+
? 'application/yaml; charset=utf-8'
|
|
317
|
+
: 'application/json; charset=utf-8';
|
|
318
|
+
return function (_req, res) {
|
|
319
|
+
if (!cached)
|
|
320
|
+
cached = (0, openapi_js_1.serializeSpec)((0, openapi_js_1.openApiSpec)(service, opts), format);
|
|
321
|
+
res.setHeader('Content-Type', contentType);
|
|
322
|
+
res.end(cached);
|
|
323
|
+
};
|
|
324
|
+
};
|
|
325
|
+
return api;
|
|
326
|
+
}
|
|
327
|
+
exports.default = apiBuilder;
|
package/dist/cjs/git.js
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
/* Copyright 2021 Fabien Bavent
|
|
2
|
+
*
|
|
3
|
+
* Permission is hereby granted, free of charge, to any person obtaining a
|
|
4
|
+
* copy of this software and associated documentation files (the "Software"),
|
|
5
|
+
* to deal in the Software without restriction, including without limitation
|
|
6
|
+
* the rights to use, copy, modify, merge, publish, distribute, sublicense,
|
|
7
|
+
* and/or sell copies of the Software, and to permit persons to whom the
|
|
8
|
+
* Software is furnished to do so, subject to the following conditions:
|
|
9
|
+
*
|
|
10
|
+
* The above copyright notice and this permission notice shall be included
|
|
11
|
+
* in all copies or substantial portions of the Software.
|
|
12
|
+
*
|
|
13
|
+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
|
|
14
|
+
* OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
15
|
+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
|
|
16
|
+
* THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
17
|
+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
18
|
+
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
|
19
|
+
* DEALINGS IN THE SOFTWARE.
|
|
20
|
+
*/
|
|
21
|
+
'use strict';
|
|
22
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
|
+
exports.gitHandler = gitHandler;
|
|
24
|
+
exports.gitCreate = gitCreate;
|
|
25
|
+
const child_process_1 = require("child_process");
|
|
26
|
+
const zlib_1 = require("zlib");
|
|
27
|
+
const fs_1 = require("fs");
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
// PKT-LINE helpers
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
/**
|
|
32
|
+
* Encode a string as a Git PKT-LINE frame.
|
|
33
|
+
*
|
|
34
|
+
* The Git Smart HTTP protocol wraps each line in a 4-hex-digit length prefix
|
|
35
|
+
* that counts the total frame length (4 bytes for the prefix itself plus the
|
|
36
|
+
* payload bytes, **not** characters).
|
|
37
|
+
*
|
|
38
|
+
* @param str - The plain-text payload to wrap (must be ASCII or UTF-8).
|
|
39
|
+
* @returns The framed string, e.g. `"001e# service=git-upload-pack\n"`.
|
|
40
|
+
*
|
|
41
|
+
* @see https://git-scm.com/docs/pack-protocol#_pkt_line_format
|
|
42
|
+
*
|
|
43
|
+
* @example
|
|
44
|
+
* ```ts
|
|
45
|
+
* pktLine('# service=git-upload-pack\n')
|
|
46
|
+
* // → '001e# service=git-upload-pack\n'
|
|
47
|
+
* // ^^^^ 4 + 26 = 30 = 0x1e
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
function pktLine(str) {
|
|
51
|
+
const byteLen = Buffer.byteLength(str, 'utf8') + 4; // +4 for the 4-char hex prefix itself
|
|
52
|
+
return byteLen.toString(16).padStart(4, '0') + str;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* The Git PKT-LINE flush packet.
|
|
56
|
+
* Signals the end of a list of PKT-LINE records.
|
|
57
|
+
*/
|
|
58
|
+
const PKT_FLUSH = '0000';
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Middleware factory
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
/**
|
|
63
|
+
* Middleware factory that exposes a Git repository over the **Git Smart HTTP
|
|
64
|
+
* protocol** (read-only fetch / clone, via `git-upload-pack`).
|
|
65
|
+
*
|
|
66
|
+
* Mount this handler at the root of a repository-scoped path so that the two
|
|
67
|
+
* sub-paths it handles (`/info/refs` and `/git-upload-pack`) are reachable:
|
|
68
|
+
*
|
|
69
|
+
* ```ts
|
|
70
|
+
* app.use('/repos/:repo', gitHandler({
|
|
71
|
+
* repository: (req) => path.join('/srv/git', req.params.repo + '.git'),
|
|
72
|
+
* }));
|
|
73
|
+
* ```
|
|
74
|
+
*
|
|
75
|
+
* **Supported endpoints:**
|
|
76
|
+
*
|
|
77
|
+
* | Method | Path | Purpose |
|
|
78
|
+
* |--------|--------------------|----------------------------------------------|
|
|
79
|
+
* | GET | `/info/refs` | Smart HTTP capability advertisement |
|
|
80
|
+
* | POST | `/git-upload-pack` | Pack-file negotiation and transfer |
|
|
81
|
+
* | POST | `/git-receive-pack`| Pack-file publish and transfer |
|
|
82
|
+
*
|
|
83
|
+
* **Compression:** gzip-compressed POST bodies are transparently decompressed
|
|
84
|
+
* before being piped to `git-upload-pack`.
|
|
85
|
+
*
|
|
86
|
+
* @param opt - Handler configuration (see {@link GitHandlerOptions}).
|
|
87
|
+
* @returns An Express-compatible middleware function `(req, res) => void`.
|
|
88
|
+
* @throws {TypeError} When `opt.repository` is not a function.
|
|
89
|
+
*/
|
|
90
|
+
function gitHandler(opt) {
|
|
91
|
+
if (typeof opt.repository !== 'function')
|
|
92
|
+
throw new TypeError('gitHandler: opt.repository must be a function');
|
|
93
|
+
const gitHome = opt.gitPath ?? '';
|
|
94
|
+
return (req, res) => {
|
|
95
|
+
// Resolve the repository path for this request.
|
|
96
|
+
const gitDirectory = opt.repository(req);
|
|
97
|
+
if (!gitDirectory)
|
|
98
|
+
return void res.status(404).send('Repository not found');
|
|
99
|
+
const urlPath = req.path; // sub-path after the mount prefix
|
|
100
|
+
// ── GET /info/refs?service=git-xxxxxx-pack ──────────────────────────
|
|
101
|
+
if (req.method === 'GET' && urlPath === '/info/refs') {
|
|
102
|
+
let args = [];
|
|
103
|
+
const service = req.queries?.url?.service;
|
|
104
|
+
if (service === 'git-upload-pack')
|
|
105
|
+
args = buildArgs(opt, ['--stateless-rpc', '--advertise-refs', gitDirectory]);
|
|
106
|
+
else if (service === 'git-receive-pack')
|
|
107
|
+
args = [gitDirectory];
|
|
108
|
+
else
|
|
109
|
+
return void res.status(403).send(`Service ${service} is not supported`);
|
|
110
|
+
res.setHeader('Content-Type', `application/x-${service}-advertisement`);
|
|
111
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
112
|
+
// The Smart HTTP advertisement starts with a PKT-LINE service banner
|
|
113
|
+
// followed by a flush packet (0000), then the git-upload-pack output.
|
|
114
|
+
res.write(pktLine(`# service=${service}\n`));
|
|
115
|
+
res.write(PKT_FLUSH);
|
|
116
|
+
const proc = (0, child_process_1.spawn)(gitHome + service, args, {
|
|
117
|
+
env: { ...process.env, GIT_PROTOCOL: req.headers['git-protocol'] || '' },
|
|
118
|
+
});
|
|
119
|
+
proc.on('error', (err) => {
|
|
120
|
+
console.error(`[${service} GET] spawn error:`, err.message);
|
|
121
|
+
if (!res.writableEnded)
|
|
122
|
+
res.status(500).send(`${service} unavailable: ${err.message}`);
|
|
123
|
+
});
|
|
124
|
+
proc.stdout.pipe(res);
|
|
125
|
+
proc.stdout.on('error', (err) => {
|
|
126
|
+
console.warn(`[${service} GET] stdout error:`, err.message);
|
|
127
|
+
});
|
|
128
|
+
proc.stderr.on('data', (d) => console.error(`[${service} GET]`, d.toString()));
|
|
129
|
+
proc.on('close', (code) => {
|
|
130
|
+
if (code !== 0) {
|
|
131
|
+
console.error(`[${service} GET] exited with code ${code}`);
|
|
132
|
+
if (!res.writableEnded)
|
|
133
|
+
res.status(500).send(`${service} failed`);
|
|
134
|
+
}
|
|
135
|
+
// When code === 0, proc.stdout has already piped all data and called
|
|
136
|
+
// res.end() automatically (default pipe behaviour).
|
|
137
|
+
});
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
// ── POST /git-upload-pack or /git-receive-pack ──────────────────────
|
|
141
|
+
if (req.method === 'POST' && (urlPath === '/git-upload-pack' || urlPath === '/git-receive-pack')) {
|
|
142
|
+
const contentType = req.headers['content-type'] || '';
|
|
143
|
+
const service = urlPath.substring(1);
|
|
144
|
+
if (contentType !== `application/x-${service}-request`)
|
|
145
|
+
return void res.status(415).send('Unsupported Media Type');
|
|
146
|
+
let args = [];
|
|
147
|
+
if (service === 'git-upload-pack')
|
|
148
|
+
args = buildArgs(opt, ['--stateless-rpc', gitDirectory]);
|
|
149
|
+
else if (service === 'git-receive-pack')
|
|
150
|
+
args = [gitDirectory];
|
|
151
|
+
else
|
|
152
|
+
return void res.status(403).send(`Service ${service} is not supported`);
|
|
153
|
+
res.setHeader('Content-Type', `application/x-${service}-result`);
|
|
154
|
+
res.setHeader('Cache-Control', 'no-cache');
|
|
155
|
+
const proc = (0, child_process_1.spawn)(gitHome + service, args, {
|
|
156
|
+
env: { ...process.env, GIT_PROTOCOL: req.headers['git-protocol'] || '' },
|
|
157
|
+
});
|
|
158
|
+
proc.on('error', (err) => {
|
|
159
|
+
console.error(`[${service} POST] spawn error:`, err.message);
|
|
160
|
+
if (!res.writableEnded)
|
|
161
|
+
res.status(500).send(`${service} unavailable: ${err.message}`);
|
|
162
|
+
});
|
|
163
|
+
// Transparently decompress gzip-encoded request bodies.
|
|
164
|
+
const encoding = req.headers['content-encoding'];
|
|
165
|
+
if (encoding === 'gzip') {
|
|
166
|
+
const gunzip = (0, zlib_1.createGunzip)();
|
|
167
|
+
gunzip.on('error', (err) => {
|
|
168
|
+
console.warn(`[${service} POST] gunzip error:`, err.message);
|
|
169
|
+
if (!res.writableEnded)
|
|
170
|
+
res.status(400).send('Failed to decompress request body');
|
|
171
|
+
});
|
|
172
|
+
req.pipe(gunzip).pipe(proc.stdin);
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
req.pipe(proc.stdin);
|
|
176
|
+
}
|
|
177
|
+
proc.stdout.pipe(res);
|
|
178
|
+
proc.stdout.on('error', (err) => {
|
|
179
|
+
console.warn(`[${service} POST] stdout error:`, err.message);
|
|
180
|
+
});
|
|
181
|
+
proc.stderr.on('data', (d) => console.error(`[${service} POST]`, d.toString()));
|
|
182
|
+
proc.on('close', (code) => {
|
|
183
|
+
if (code !== 0) {
|
|
184
|
+
console.error(`[${service} POST] exited with code ${code}`);
|
|
185
|
+
if (!res.writableEnded)
|
|
186
|
+
res.status(500).send(`${service} failed`);
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
proc.stdin.on('error', (err) => {
|
|
190
|
+
// EPIPE is expected when the client disconnects mid-stream; it is not
|
|
191
|
+
// a server-side fault and does not require an error response.
|
|
192
|
+
if (err.code !== 'EPIPE')
|
|
193
|
+
console.warn(`[${service} POST] stdin error:`, err.message);
|
|
194
|
+
});
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
// Unrecognised path inside the repository mount.
|
|
198
|
+
res.status(404).send('Not found');
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Initialise a new Git repository at `gitDirectory` by running `git init`.
|
|
203
|
+
*
|
|
204
|
+
* By default a **bare** repository is created (no working tree), which is the
|
|
205
|
+
* conventional layout for server-side hosting. Pass `{ bare: false }` to
|
|
206
|
+
* create a standard repository with a working tree instead.
|
|
207
|
+
*
|
|
208
|
+
* @param gitDirectory - Absolute filesystem path of the directory in which
|
|
209
|
+
* the repository will be created. The directory is created by Git if it
|
|
210
|
+
* does not already exist.
|
|
211
|
+
* @param opt - Creation options. All fields are optional.
|
|
212
|
+
* @param opt.gitPath - Directory containing the `git` binary (with trailing
|
|
213
|
+
* separator). Defaults to `''` so that the system `PATH` is used.
|
|
214
|
+
* @param opt.bare - When `true` (default) a bare repository is created
|
|
215
|
+
* (`git init --bare`). When `false` a regular working-tree repository is
|
|
216
|
+
* created (`git init`).
|
|
217
|
+
* @param opt.description - Text written to the repository's `description`
|
|
218
|
+
* file after initialisation. Bare: `<gitDirectory>/description`;
|
|
219
|
+
* non-bare: `<gitDirectory>/.git/description`. Skipped when omitted.
|
|
220
|
+
* @returns A `Promise` that resolves when the repository has been
|
|
221
|
+
* successfully created, or rejects with an error message string when the
|
|
222
|
+
* `git` process fails to start or exits with a non-zero code.
|
|
223
|
+
*
|
|
224
|
+
* @example
|
|
225
|
+
* ```ts
|
|
226
|
+
* // Create a bare repository (default — suitable for server hosting)
|
|
227
|
+
* await gitCreate('/srv/git/myproject.git', { description: 'My project' });
|
|
228
|
+
*
|
|
229
|
+
* // Create a regular repository with a working tree
|
|
230
|
+
* await gitCreate('/home/user/myproject', { bare: false });
|
|
231
|
+
* ```
|
|
232
|
+
*/
|
|
233
|
+
function gitCreate(gitDirectory, opt) {
|
|
234
|
+
return new Promise((resolve, reject) => {
|
|
235
|
+
const gitHome = opt.gitPath ?? '';
|
|
236
|
+
const isBare = opt.bare !== false; // default true
|
|
237
|
+
const args = isBare
|
|
238
|
+
? ['init', '--bare', gitDirectory]
|
|
239
|
+
: ['init', gitDirectory];
|
|
240
|
+
const proc = (0, child_process_1.spawn)(gitHome + 'git', args, {
|
|
241
|
+
env: { ...process.env },
|
|
242
|
+
});
|
|
243
|
+
proc.on('error', (err) => {
|
|
244
|
+
console.error(`[git init] spawn error:`, err.message);
|
|
245
|
+
reject(`git unavailable: ${err.message}`);
|
|
246
|
+
});
|
|
247
|
+
proc.stdout.on('error', (err) => {
|
|
248
|
+
console.warn(`[git init] stdout error:`, err.message);
|
|
249
|
+
});
|
|
250
|
+
proc.stderr.on('data', (d) => console.error(`[git init]`, d.toString()));
|
|
251
|
+
proc.on('close', (code) => {
|
|
252
|
+
if (code !== 0) {
|
|
253
|
+
return reject('git failed');
|
|
254
|
+
}
|
|
255
|
+
if (opt.description) {
|
|
256
|
+
// Bare repos store the description at the root; working-tree repos
|
|
257
|
+
// store it inside the hidden .git sub-directory.
|
|
258
|
+
const descPath = isBare
|
|
259
|
+
? `${gitDirectory}/description`
|
|
260
|
+
: `${gitDirectory}/.git/description`;
|
|
261
|
+
(0, fs_1.writeFileSync)(descPath, opt.description);
|
|
262
|
+
}
|
|
263
|
+
resolve();
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
// ---------------------------------------------------------------------------
|
|
268
|
+
// Internal helpers
|
|
269
|
+
// ---------------------------------------------------------------------------
|
|
270
|
+
/**
|
|
271
|
+
* Build the argument list for `git-upload-pack`.
|
|
272
|
+
*
|
|
273
|
+
* Prepends an optional `--timeout=<n>` argument when configured, then
|
|
274
|
+
* inserts the `--strict` / `--no-strict` flag, followed by any additional
|
|
275
|
+
* arguments the caller provides.
|
|
276
|
+
*
|
|
277
|
+
* @param opt - Handler options.
|
|
278
|
+
* @param trailing - Arguments appended after the strict flag
|
|
279
|
+
* (e.g. `['--stateless-rpc', '--advertise-refs', dir]`).
|
|
280
|
+
* @returns The complete argument array ready for `spawn`.
|
|
281
|
+
*/
|
|
282
|
+
function buildArgs(opt, trailing) {
|
|
283
|
+
const args = [];
|
|
284
|
+
if (opt.timeout) {
|
|
285
|
+
const seconds = parseInt(String(opt.timeout), 10);
|
|
286
|
+
if (!isNaN(seconds) && seconds > 0)
|
|
287
|
+
args.push(`--timeout=${seconds}`);
|
|
288
|
+
}
|
|
289
|
+
if (!opt.strict)
|
|
290
|
+
args.push('--no-strict');
|
|
291
|
+
return [...args, ...trailing];
|
|
292
|
+
}
|
|
293
|
+
exports.default = gitHandler;
|