expediate 0.0.3 → 1.0.1

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/src/git.ts ADDED
@@ -0,0 +1,326 @@
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
+
23
+ import { spawn } from 'child_process';
24
+ import { createGunzip } from 'zlib';
25
+
26
+ import type { RouterRequest, RouterResponse } from './router.js';
27
+
28
+ // ---------------------------------------------------------------------------
29
+ // Types
30
+ // ---------------------------------------------------------------------------
31
+
32
+ /**
33
+ * Configuration options for {@link gitHandler}.
34
+ */
35
+ export interface GitHandlerOptions {
36
+ /**
37
+ * Resolve the absolute filesystem path of the Git repository for an
38
+ * incoming request.
39
+ *
40
+ * Return a non-empty string to serve that repository, or a falsy value
41
+ * (`''`, `null`, `undefined`) to respond with **404 Repository not found**.
42
+ *
43
+ * This is the only required option; all others have defaults.
44
+ *
45
+ * @example
46
+ * ```ts
47
+ * repository: (req) => path.join('/srv/git', req.params.repo + '.git')
48
+ * ```
49
+ */
50
+ repository: (req: RouterRequest) => string | null | undefined | false;
51
+
52
+ /**
53
+ * Directory that contains the `git-upload-pack` executable, including a
54
+ * trailing path separator (e.g. `'/usr/lib/git-core/'`).
55
+ *
56
+ * Leave empty (default) to locate the binary via the system `PATH`.
57
+ */
58
+ gitPath?: string;
59
+
60
+ /**
61
+ * When `true`, the `--strict` flag is passed to `git-upload-pack`.
62
+ * This causes the process to exit with an error when the resolved path is
63
+ * not a bare Git repository.
64
+ *
65
+ * Defaults to `false` (non-strict / `--no-strict`).
66
+ */
67
+ strict?: boolean;
68
+
69
+ /**
70
+ * Kill the `git-upload-pack` process if it does not complete within this
71
+ * many **seconds**. When omitted or `0`, no timeout is applied.
72
+ */
73
+ timeout?: number | string;
74
+ }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // PKT-LINE helpers
78
+ // ---------------------------------------------------------------------------
79
+
80
+ /**
81
+ * Encode a string as a Git PKT-LINE frame.
82
+ *
83
+ * The Git Smart HTTP protocol wraps each line in a 4-hex-digit length prefix
84
+ * that counts the total frame length (4 bytes for the prefix itself plus the
85
+ * payload bytes, **not** characters).
86
+ *
87
+ * @param str - The plain-text payload to wrap (must be ASCII or UTF-8).
88
+ * @returns The framed string, e.g. `"001e# service=git-upload-pack\n"`.
89
+ *
90
+ * @see https://git-scm.com/docs/pack-protocol#_pkt_line_format
91
+ *
92
+ * @example
93
+ * ```ts
94
+ * pktLine('# service=git-upload-pack\n')
95
+ * // → '001e# service=git-upload-pack\n'
96
+ * // ^^^^ 4 + 26 = 30 = 0x1e
97
+ * ```
98
+ */
99
+ function pktLine(str: string): string {
100
+ // BUG FIX: the original used `str.length` (character count / UTF-16 code
101
+ // units) instead of the actual byte length. The Git PKT-LINE spec requires
102
+ // the 4-hex-digit prefix to represent the *byte* count of the whole frame
103
+ // (prefix + payload). For ASCII-only service names this difference is zero,
104
+ // but using Buffer.byteLength is correct and future-proof.
105
+ const byteLen = Buffer.byteLength(str, 'utf8') + 4; // +4 for the 4-char hex prefix itself
106
+ return byteLen.toString(16).padStart(4, '0') + str;
107
+ }
108
+
109
+ /**
110
+ * The Git PKT-LINE flush packet.
111
+ * Signals the end of a list of PKT-LINE records.
112
+ */
113
+ const PKT_FLUSH = '0000';
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Middleware factory
117
+ // ---------------------------------------------------------------------------
118
+
119
+ /**
120
+ * Middleware factory that exposes a Git repository over the **Git Smart HTTP
121
+ * protocol** (read-only fetch / clone, via `git-upload-pack`).
122
+ *
123
+ * Mount this handler at the root of a repository-scoped path so that the two
124
+ * sub-paths it handles (`/info/refs` and `/git-upload-pack`) are reachable:
125
+ *
126
+ * ```ts
127
+ * app.use('/repos/:repo', gitHandler({
128
+ * repository: (req) => path.join('/srv/git', req.params.repo + '.git'),
129
+ * }));
130
+ * ```
131
+ *
132
+ * **Supported endpoints:**
133
+ *
134
+ * | Method | Path | Purpose |
135
+ * |--------|-------------------|----------------------------------------------|
136
+ * | GET | `/info/refs` | Smart HTTP capability advertisement |
137
+ * | POST | `/git-upload-pack`| Pack-file negotiation and transfer |
138
+ *
139
+ * Only `git-upload-pack` (fetch / clone) is implemented.
140
+ * `git-receive-pack` (push) is intentionally excluded.
141
+ *
142
+ * **Compression:** gzip-compressed POST bodies are transparently decompressed
143
+ * before being piped to `git-upload-pack`.
144
+ *
145
+ * @param opt - Handler configuration (see {@link GitHandlerOptions}).
146
+ * @returns An Express-compatible middleware function `(req, res) => void`.
147
+ * @throws {TypeError} When `opt.repository` is not a function.
148
+ */
149
+ export function gitHandler(opt: GitHandlerOptions): (req: RouterRequest, res: RouterResponse) => void {
150
+ if (typeof opt.repository !== 'function')
151
+ throw new TypeError('gitHandler: opt.repository must be a function');
152
+
153
+ const gitBin = (opt.gitPath ?? '') + 'git-upload-pack';
154
+
155
+ return (req: RouterRequest, res: RouterResponse): void => {
156
+ // Resolve the repository path for this request.
157
+ const gitDirectory = opt.repository(req);
158
+ if (!gitDirectory)
159
+ return void res.status(404).send('Repository not found');
160
+
161
+ const urlPath = req.path; // sub-path after the mount prefix
162
+
163
+ // ── GET /info/refs?service=git-upload-pack ──────────────────────────
164
+ if (req.method === 'GET' && urlPath === '/info/refs') {
165
+ // BUG FIX: `req.queries.url` can be undefined when no query parameters
166
+ // are present, causing `req.queries.url.service` to throw a TypeError.
167
+ const service = req.queries?.url?.service;
168
+
169
+ if (service !== 'git-upload-pack')
170
+ return void res.status(403).send('Only git-upload-pack is supported');
171
+
172
+ res.setHeader('Content-Type', `application/x-${service}-advertisement`);
173
+ res.setHeader('Cache-Control', 'no-cache');
174
+
175
+ // The Smart HTTP advertisement starts with a PKT-LINE service banner
176
+ // followed by a flush packet (0000), then the git-upload-pack output.
177
+ res.write(pktLine(`# service=${service}\n`));
178
+ res.write(PKT_FLUSH);
179
+
180
+ const args = buildArgs(opt, ['--stateless-rpc', '--advertise-refs', gitDirectory]);
181
+ const proc = spawn(gitBin, args, {
182
+ env: { ...process.env, GIT_PROTOCOL: (req.headers['git-protocol'] as string) || '' },
183
+ });
184
+
185
+ // BUG FIX: the original did not listen for spawn errors (e.g. ENOENT
186
+ // when git is not installed). Without this handler, a missing binary
187
+ // causes an uncaught exception that crashes the server process.
188
+ proc.on('error', (err) => {
189
+ console.error('[git-upload-pack refs] spawn error:', err.message);
190
+ if (!res.writableEnded) res.status(500).send(`git-upload-pack unavailable: ${err.message}`);
191
+ });
192
+
193
+ proc.stdout.pipe(res);
194
+ proc.stdout.on('error', (err) => {
195
+ console.warn('[git-upload-pack refs] stdout error:', err.message);
196
+ });
197
+
198
+ proc.stderr.on('data', (d: Buffer) =>
199
+ console.error('[git-upload-pack refs]', d.toString()),
200
+ );
201
+
202
+ proc.on('close', (code) => {
203
+ if (code !== 0) {
204
+ console.error(`[git-upload-pack refs] exited with code ${code}`);
205
+ if (!res.writableEnded) res.status(500).send('git-upload-pack failed');
206
+ }
207
+ // When code === 0, proc.stdout has already piped all data and called
208
+ // res.end() automatically (default pipe behaviour).
209
+ });
210
+
211
+ return;
212
+ }
213
+
214
+ // ── POST /git-upload-pack ───────────────────────────────────────────
215
+ if (req.method === 'POST' && urlPath === '/git-upload-pack') {
216
+ const contentType = (req.headers['content-type'] as string) || '';
217
+
218
+ // BUG FIX: the original called `res.send(415).send(...)`. Our router's
219
+ // `res.send()` signature is `send(body?)` — passing 415 as the body
220
+ // writes the number as a string, then the chained `.send()` fails
221
+ // because `res.send()` already ended the response. Corrected to
222
+ // `res.status(415).send(...)`.
223
+ if (contentType !== 'application/x-git-upload-pack-request')
224
+ return void res.status(415).send('Unsupported Media Type');
225
+
226
+ res.setHeader('Content-Type', 'application/x-git-upload-pack-result');
227
+ res.setHeader('Cache-Control', 'no-cache');
228
+
229
+ const args = buildArgs(opt, ['--stateless-rpc', gitDirectory]);
230
+ const proc = spawn(gitBin, args, {
231
+ env: { ...process.env, GIT_PROTOCOL: (req.headers['git-protocol'] as string) || '' },
232
+ });
233
+
234
+ // BUG FIX: same missing spawn-error handler as the GET branch.
235
+ proc.on('error', (err) => {
236
+ console.error('[git-upload-pack pack] spawn error:', err.message);
237
+ if (!res.writableEnded) res.status(500).send(`git-upload-pack unavailable: ${err.message}`);
238
+ });
239
+
240
+ // Transparently decompress gzip-encoded request bodies.
241
+ const encoding = (req.headers['content-encoding'] as string | undefined);
242
+ if (encoding === 'gzip') {
243
+ const gunzip = createGunzip();
244
+ gunzip.on('error', (err) => {
245
+ console.warn('[git-upload-pack pack] gunzip error:', err.message);
246
+ if (!res.writableEnded) res.status(400).send('Failed to decompress request body');
247
+ });
248
+ (req as any).pipe(gunzip).pipe(proc.stdin);
249
+ } else {
250
+ (req as any).pipe(proc.stdin);
251
+ }
252
+
253
+ proc.stdout.pipe(res);
254
+ proc.stdout.on('error', (err) => {
255
+ console.warn('[git-upload-pack pack] stdout error:', err.message);
256
+ });
257
+
258
+ // BUG FIX: the original tagged the POST stderr with the same label as
259
+ // the GET branch ('[git-upload-pack refs]'), making log messages from
260
+ // the two branches indistinguishable. Corrected to '[git-upload-pack pack]'.
261
+ proc.stderr.on('data', (d: Buffer) =>
262
+ console.error('[git-upload-pack pack]', d.toString()),
263
+ );
264
+
265
+ proc.on('close', (code) => {
266
+ if (code !== 0) {
267
+ console.error(`[git-upload-pack pack] exited with code ${code}`);
268
+ if (!res.writableEnded) res.status(500).send('git-upload-pack failed');
269
+ }
270
+ });
271
+
272
+ proc.stdin.on('error', (err: NodeJS.ErrnoException) => {
273
+ // EPIPE is expected when the client disconnects mid-stream; it is not
274
+ // a server-side fault and does not require an error response.
275
+ if (err.code !== 'EPIPE')
276
+ console.warn('[git-upload-pack pack] stdin error:', err.message);
277
+ });
278
+
279
+ return;
280
+ }
281
+
282
+ // Unrecognised path inside the repository mount.
283
+ res.status(404).send('Not found');
284
+ };
285
+ }
286
+
287
+ // ---------------------------------------------------------------------------
288
+ // Internal helpers
289
+ // ---------------------------------------------------------------------------
290
+
291
+ /**
292
+ * Build the argument list for `git-upload-pack`.
293
+ *
294
+ * Prepends an optional `--timeout=<n>` argument when configured, then
295
+ * inserts the `--strict` / `--no-strict` flag, followed by any additional
296
+ * arguments the caller provides.
297
+ *
298
+ * @param opt - Handler options.
299
+ * @param trailing - Arguments appended after the strict flag
300
+ * (e.g. `['--stateless-rpc', '--advertise-refs', dir]`).
301
+ * @returns The complete argument array ready for `spawn`.
302
+ */
303
+ function buildArgs(opt: GitHandlerOptions, trailing: string[]): string[] {
304
+ const args: string[] = [];
305
+
306
+ if (opt.timeout) {
307
+ // BUG FIX: `parseInt` without an explicit radix may misinterpret strings
308
+ // that start with '0' as octal in some environments. Always pass base 10.
309
+ const seconds = parseInt(String(opt.timeout), 10);
310
+ if (!isNaN(seconds) && seconds > 0)
311
+ args.push(`--timeout=${seconds}`);
312
+ }
313
+
314
+ // BUG FIX: the original used `opt.bareOnly ? '--strict' : '--no-strict'`.
315
+ // `git-upload-pack` does not accept `--strict` — that flag belongs to
316
+ // `git-receive-pack`. The correct option for upload-pack is `--no-strict`
317
+ // (which relaxes the requirement that the path must be a bare repository).
318
+ // When the caller sets `strict: true` we simply omit `--no-strict`; when
319
+ // `strict` is false (default) we pass `--no-strict` to allow non-bare repos.
320
+ if (!opt.strict)
321
+ args.push('--no-strict');
322
+
323
+ return [...args, ...trailing];
324
+ }
325
+
326
+ export default gitHandler;
package/src/index.ts ADDED
@@ -0,0 +1,85 @@
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
+ /**
22
+ * @module expediate
23
+ * TypeScript package for web server routing.
24
+ */
25
+
26
+ // ── Router ────────────────────────────────────────────────────────────────────
27
+ import createRouter from './router';
28
+ export { createRouter }
29
+ export type {
30
+ Router,
31
+ RouterRequest,
32
+ RouterResponse,
33
+ Middleware,
34
+ MiddlewareArg,
35
+ NextFunction,
36
+ Layer,
37
+ CookieOptions,
38
+ TlsOptions,
39
+ StringMap,
40
+ } from './router';
41
+
42
+ // ── Static ────────────────────────────────────────────────────────────────────
43
+
44
+ export { serveStatic, serveFile, sendFile, mime } from './static';
45
+ export type {
46
+ StaticOptions,
47
+ Mime
48
+ } from './static';
49
+
50
+ // ── Miscallenous ──────────────────────────────────────────────────────────────
51
+
52
+ export { json, formData, parseBody, logger } from './misc';
53
+ export type {
54
+ BodyOptions,
55
+ LoggerOptions,
56
+ FormPart,
57
+ } from './misc';
58
+
59
+ // ── JWT Authentication ────────────────────────────────────────────────────────
60
+ import createJwtPlugin from './jwt-auth'
61
+ export { createJwtPlugin }
62
+ export type {
63
+ JwtPlugin,
64
+ JwtConfig
65
+ } from './jwt-auth';
66
+
67
+ // ── Git repository ────────────────────────────────────────────────────────────
68
+ import gitHandler from './git'
69
+ export { gitHandler }
70
+ export type {
71
+ GitHandlerOptions,
72
+ } from './git'
73
+
74
+ // ── API Service ───────────────────────────────────────────────────────────────
75
+ import apiBuilder from './apis'
76
+ export { apiBuilder }
77
+ export type {
78
+ ApiError,
79
+ ServiceMethod,
80
+ ServiceInstance,
81
+ ServiceMethods,
82
+ RouteMap,
83
+ ServiceDefinition
84
+ } from './apis'
85
+