yxorp 0.2.2 → 0.2.4
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 +8 -0
- package/dist/index.js +11 -0
- package/dist/middleware/bootstrap.middleware.d.ts +4 -3
- package/dist/middleware/bootstrap.middleware.js +10 -14
- package/dist/middleware/mock.middleware.js +27 -8
- package/dist/middleware/proxy.middleware.js +22 -5
- package/dist/middleware/proxyRes.middleware.js +10 -7
- package/dist/middleware/rawBody.middleware.d.ts +4 -1
- package/dist/middleware/rawBody.middleware.js +16 -5
- package/dist/middleware/rewrite.middleware.d.ts +7 -0
- package/dist/middleware/rewrite.middleware.js +16 -3
- package/dist/middleware/static.middleware.d.ts +23 -1
- package/dist/middleware/static.middleware.js +80 -36
- package/dist/services/cli-overrides.js +19 -2
- package/dist/services/config-validator.d.ts +2 -1
- package/dist/services/config-validator.js +83 -5
- package/dist/services/config-watcher.js +9 -1
- package/dist/services/config.service.d.ts +1 -1
- package/dist/services/config.service.js +3 -0
- package/dist/services/http-proxy.service.d.ts +4 -3
- package/dist/services/http-server.service.d.ts +3 -1
- package/dist/services/http-server.service.js +12 -2
- package/dist/services/rules-matchers/method-path-rules-matcher.d.ts +27 -0
- package/dist/services/rules-matchers/method-path-rules-matcher.js +50 -0
- package/dist/services/rules-matchers/mock-rules-matcher.service.d.ts +3 -3
- package/dist/services/rules-matchers/mock-rules-matcher.service.js +5 -26
- package/dist/services/rules-matchers/rewrite-rules-matcher.service.d.ts +3 -3
- package/dist/services/rules-matchers/rewrite-rules-matcher.service.js +5 -26
- package/dist/services/yxorp-server.service.js +26 -9
- package/dist/utils/access-log.d.ts +7 -0
- package/dist/utils/access-log.js +14 -0
- package/dist/utils/headers.d.ts +8 -0
- package/dist/utils/headers.js +35 -0
- package/package.json +1 -2
package/README.md
CHANGED
|
@@ -264,6 +264,14 @@ For more advanced patterns, see the [path-to-regexp documentation](https://githu
|
|
|
264
264
|
|
|
265
265
|
---
|
|
266
266
|
|
|
267
|
+
### A Note on Script Security
|
|
268
|
+
|
|
269
|
+
Mock and rewrite scripts (`"script": "./path/to/file.js"`) are loaded with Node's `require()` and run with the same privileges as Yxorp itself — full filesystem, network, and process access, with no sandboxing.
|
|
270
|
+
|
|
271
|
+
Only point Yxorp at config files (and the scripts they reference) that you trust, the same way you'd treat any other local Node script. Don't load a config from an untrusted source.
|
|
272
|
+
|
|
273
|
+
---
|
|
274
|
+
|
|
267
275
|
## Full Example
|
|
268
276
|
|
|
269
277
|
Here's a complete config showing all features in action:
|
package/dist/index.js
CHANGED
|
@@ -86,6 +86,7 @@ const configWatcher = (0, config_watcher_1.watchConfig)(configPath, (newConfig)
|
|
|
86
86
|
logger.info('Config reloaded');
|
|
87
87
|
}, (e) => logger.error(`Config reload failed: ${e.message || e}`));
|
|
88
88
|
// --- Graceful shutdown ---
|
|
89
|
+
const SHUTDOWN_TIMEOUT_MS = 5000;
|
|
89
90
|
let shuttingDown = false;
|
|
90
91
|
function shutdown(signal) {
|
|
91
92
|
if (shuttingDown)
|
|
@@ -93,7 +94,17 @@ function shutdown(signal) {
|
|
|
93
94
|
shuttingDown = true;
|
|
94
95
|
logger.info(`Received ${signal}, shutting down...`);
|
|
95
96
|
configWatcher.close();
|
|
97
|
+
// server.close()'s callback only fires once every open connection is
|
|
98
|
+
// closed — keep-alive HTTP sockets and proxied WebSocket connections
|
|
99
|
+
// (proxyOptions has `ws: true`) can keep it pending indefinitely. Force
|
|
100
|
+
// an exit after a grace period so a stuck connection can't hang the process.
|
|
101
|
+
const forceExitTimer = setTimeout(() => {
|
|
102
|
+
logger.error(`Graceful shutdown timed out after ${SHUTDOWN_TIMEOUT_MS}ms — forcing exit`);
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}, SHUTDOWN_TIMEOUT_MS);
|
|
105
|
+
forceExitTimer.unref();
|
|
96
106
|
server.close(() => {
|
|
107
|
+
clearTimeout(forceExitTimer);
|
|
97
108
|
process.exit(0);
|
|
98
109
|
});
|
|
99
110
|
}
|
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
import { IncomingMessage, ServerResponse } from 'http';
|
|
2
3
|
import { Middleware } from '../services/pipeline.service';
|
|
3
4
|
import { RewriteRulesMatcher } from '../services/rules-matchers/rewrite-rules-matcher.service';
|
|
4
5
|
import { MockRulesMatcher } from '../services/rules-matchers/mock-rules-matcher.service';
|
|
5
6
|
import { LoggerService } from '../services/logger.service';
|
|
6
|
-
export declare class BootstrapMiddleware implements Middleware<[req: IncomingMessage, res:
|
|
7
|
+
export declare class BootstrapMiddleware implements Middleware<[req: IncomingMessage, res: ServerResponse]> {
|
|
7
8
|
private rewriteRulesMatcher;
|
|
8
9
|
private mockRulesMatcher;
|
|
9
10
|
private logger;
|
|
10
11
|
constructor(rewriteRulesMatcher: RewriteRulesMatcher, mockRulesMatcher: MockRulesMatcher, logger: LoggerService);
|
|
11
|
-
use(req: IncomingMessage, _res:
|
|
12
|
+
use(req: IncomingMessage, _res: ServerResponse, next: () => void): void;
|
|
12
13
|
private setRewriteRule;
|
|
13
14
|
private setMockRule;
|
|
14
15
|
private setQueryParams;
|
|
@@ -27,26 +27,22 @@ class BootstrapMiddleware {
|
|
|
27
27
|
if (!req.url || !req.method) {
|
|
28
28
|
return;
|
|
29
29
|
}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
30
|
+
// matchWithParams() finds the rule and decodes its path params in a single
|
|
31
|
+
// pass — match() + params() back to back would compile/run path-to-regexp twice.
|
|
32
|
+
const matched = this.rewriteRulesMatcher.matchWithParams(req.url, req.method);
|
|
33
|
+
if (matched) {
|
|
34
|
+
req.rewriteRule = matched.rule;
|
|
35
|
+
req.rewriteRuleParams = matched.params || {};
|
|
37
36
|
}
|
|
38
37
|
}
|
|
39
38
|
setMockRule(req) {
|
|
40
39
|
if (!req.url || !req.method) {
|
|
41
40
|
return;
|
|
42
41
|
}
|
|
43
|
-
const
|
|
44
|
-
if (
|
|
45
|
-
req.mockRule =
|
|
46
|
-
|
|
47
|
-
if (mockRuleParams) {
|
|
48
|
-
req.mockRuleParams = mockRuleParams || {};
|
|
49
|
-
}
|
|
42
|
+
const matched = this.mockRulesMatcher.matchWithParams(req.url, req.method);
|
|
43
|
+
if (matched) {
|
|
44
|
+
req.mockRule = matched.rule;
|
|
45
|
+
req.mockRuleParams = matched.params || {};
|
|
50
46
|
}
|
|
51
47
|
}
|
|
52
48
|
setQueryParams(req) {
|
|
@@ -7,7 +7,7 @@ exports.MockMiddleware = void 0;
|
|
|
7
7
|
const mime_1 = __importDefault(require("mime"));
|
|
8
8
|
const promises_1 = __importDefault(require("fs/promises"));
|
|
9
9
|
const path_1 = __importDefault(require("path"));
|
|
10
|
-
const
|
|
10
|
+
const access_log_1 = require("../utils/access-log");
|
|
11
11
|
class MockMiddleware {
|
|
12
12
|
constructor(logger) {
|
|
13
13
|
this.logger = logger;
|
|
@@ -23,13 +23,22 @@ class MockMiddleware {
|
|
|
23
23
|
const fullPath = path_1.default.resolve(mockRule.script);
|
|
24
24
|
delete require.cache[require.resolve(fullPath)];
|
|
25
25
|
const handler = require(fullPath);
|
|
26
|
-
if (typeof handler
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
26
|
+
if (typeof handler !== 'function') {
|
|
27
|
+
// A script that doesn't export a function (e.g. `exports.handler = ...`
|
|
28
|
+
// instead of `module.exports = ...`) would otherwise leave the request
|
|
29
|
+
// hanging forever — respond with a clear error instead.
|
|
30
|
+
res.statusCode = 500;
|
|
31
|
+
res.setHeader('content-type', 'application/json');
|
|
32
|
+
res.end(JSON.stringify({ error: `Mock script "${mockRule.script}" does not export a function` }));
|
|
33
|
+
this.logger.error(`Mock script "${fullPath}" does not export a function (module.exports = (req, res) => {...})`);
|
|
34
|
+
this.logger.info((0, access_log_1.formatAccessLog)('mock', res.statusCode, req));
|
|
35
|
+
return;
|
|
31
36
|
}
|
|
32
|
-
|
|
37
|
+
await handler(req, res);
|
|
38
|
+
if (!res.headersSent) {
|
|
39
|
+
res.setHeader('content-type', 'application/json');
|
|
40
|
+
}
|
|
41
|
+
this.logger.info((0, access_log_1.formatAccessLog)('mock', res.statusCode || 200, req));
|
|
33
42
|
return;
|
|
34
43
|
}
|
|
35
44
|
if ('file' in mockRule) {
|
|
@@ -41,13 +50,23 @@ class MockMiddleware {
|
|
|
41
50
|
}
|
|
42
51
|
res.setHeader('content-length', file.length);
|
|
43
52
|
res.end(file);
|
|
44
|
-
this.logger.info(
|
|
53
|
+
this.logger.info((0, access_log_1.formatAccessLog)('mock', res.statusCode, req));
|
|
45
54
|
return;
|
|
46
55
|
}
|
|
47
56
|
next();
|
|
48
57
|
}
|
|
49
58
|
catch (e) {
|
|
50
59
|
this.logger.error(e);
|
|
60
|
+
// If the script handler already wrote (part of) a response before throwing,
|
|
61
|
+
// calling next() would send the request into ProxyMiddleware/httpProxy.web,
|
|
62
|
+
// which would try to write to an already-finished response.
|
|
63
|
+
if (res.headersSent) {
|
|
64
|
+
this.logger.info((0, access_log_1.formatAccessLog)('mock', res.statusCode, req));
|
|
65
|
+
if (!res.writableEnded) {
|
|
66
|
+
res.end();
|
|
67
|
+
}
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
51
70
|
next();
|
|
52
71
|
}
|
|
53
72
|
}
|
|
@@ -11,16 +11,33 @@ class ProxyMiddleware {
|
|
|
11
11
|
use(req, res) {
|
|
12
12
|
const url = req.url || '';
|
|
13
13
|
const proxyOptions = this.config.get().proxyOptions;
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
14
|
+
let target;
|
|
15
|
+
try {
|
|
16
|
+
const remoteRule = this.remoteRulesMatcher.match(url);
|
|
17
|
+
target = remoteRule
|
|
18
|
+
? this.remoteRulesMatcher.toPath(url, remoteRule)
|
|
19
|
+
: undefined;
|
|
20
|
+
}
|
|
21
|
+
catch (e) {
|
|
22
|
+
// A malformed `remoteRules[].path` pattern would otherwise throw
|
|
23
|
+
// synchronously here on every request — fall back to the default target.
|
|
24
|
+
this.logger.error(e);
|
|
25
|
+
}
|
|
18
26
|
const options = {
|
|
19
27
|
...proxyOptions,
|
|
20
28
|
prependPath: !target,
|
|
21
29
|
target: target || proxyOptions.target,
|
|
22
30
|
};
|
|
23
|
-
this.httpProxy.web(req, res, options).catch((error) =>
|
|
31
|
+
this.httpProxy.web(req, res, options).catch((error) => {
|
|
32
|
+
// httpxy rejects web()'s promise when the proxy has no 'error' listener
|
|
33
|
+
// (e.g. target ECONNREFUSED/DNS failure) — without this, the client
|
|
34
|
+
// would otherwise hang forever waiting for a response that never comes.
|
|
35
|
+
this.logger.error(error);
|
|
36
|
+
if (!res.headersSent) {
|
|
37
|
+
res.statusCode = 502;
|
|
38
|
+
res.end();
|
|
39
|
+
}
|
|
40
|
+
});
|
|
24
41
|
}
|
|
25
42
|
}
|
|
26
43
|
exports.ProxyMiddleware = ProxyMiddleware;
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.ProxyResMiddleware = void 0;
|
|
4
|
-
const
|
|
4
|
+
const access_log_1 = require("../utils/access-log");
|
|
5
|
+
const headers_1 = require("../utils/headers");
|
|
5
6
|
class ProxyResMiddleware {
|
|
6
7
|
constructor(logger) {
|
|
7
8
|
this.logger = logger;
|
|
8
9
|
}
|
|
9
10
|
use(proxyRes, req, res) {
|
|
10
11
|
try {
|
|
11
|
-
//
|
|
12
|
+
// Hop-by-hop headers (incl. transfer-encoding, since we reconstruct the
|
|
13
|
+
// body with a known length) must not be forwarded to the client.
|
|
12
14
|
for (let key in proxyRes.headers) {
|
|
13
|
-
if (key
|
|
15
|
+
if ((0, headers_1.isHopByHopHeader)(key, proxyRes.headers))
|
|
14
16
|
continue;
|
|
15
17
|
res.setHeader(key, proxyRes.headers[key]);
|
|
16
18
|
}
|
|
@@ -20,10 +22,11 @@ class ProxyResMiddleware {
|
|
|
20
22
|
res.removeHeader('content-length');
|
|
21
23
|
res.setHeader('content-length', response.length);
|
|
22
24
|
res.end(response);
|
|
23
|
-
// RewriteMiddleware already logs rewritten responses
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
|
|
25
|
+
// RewriteMiddleware already logs rewritten responses (success AND failure —
|
|
26
|
+
// it sets req.rewriteLogged in both cases) — log here only for plain
|
|
27
|
+
// pass-through, so every proxied request gets exactly one log line.
|
|
28
|
+
if (!req.rewriteLogged) {
|
|
29
|
+
this.logger.info((0, access_log_1.formatAccessLog)('proxy', res.statusCode, req));
|
|
27
30
|
}
|
|
28
31
|
}
|
|
29
32
|
catch (e) {
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
/// <reference types="node" />
|
|
2
2
|
import { IncomingMessage, ServerResponse } from 'http';
|
|
3
3
|
import { Middleware } from '../services/pipeline.service';
|
|
4
|
+
import { LoggerService } from '../services/logger.service';
|
|
4
5
|
export declare class RawBodyMiddleware implements Middleware<[proxyRes: IncomingMessage, req: IncomingMessage, res: ServerResponse]> {
|
|
5
|
-
|
|
6
|
+
private logger;
|
|
7
|
+
constructor(logger: LoggerService);
|
|
8
|
+
use(proxyRes: IncomingMessage, _req: IncomingMessage, res: ServerResponse, next: () => void): Promise<void>;
|
|
6
9
|
}
|
|
@@ -2,8 +2,11 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.RawBodyMiddleware = void 0;
|
|
4
4
|
class RawBodyMiddleware {
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
constructor(logger) {
|
|
6
|
+
this.logger = logger;
|
|
7
|
+
}
|
|
8
|
+
use(proxyRes, _req, res, next) {
|
|
9
|
+
return new Promise((resolve) => {
|
|
7
10
|
const body = [];
|
|
8
11
|
proxyRes.on('data', (chunk) => {
|
|
9
12
|
body.push(chunk);
|
|
@@ -14,9 +17,17 @@ class RawBodyMiddleware {
|
|
|
14
17
|
resolve();
|
|
15
18
|
});
|
|
16
19
|
proxyRes.on('error', (err) => {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
+
// The upstream response stream broke mid-flight — the body we have is
|
|
21
|
+
// incomplete/unreliable. Don't continue through Rewrite/ProxyRes with
|
|
22
|
+
// it (that could produce a broken-but-200-looking response); send a
|
|
23
|
+
// clean error response instead and resolve cleanly so this doesn't
|
|
24
|
+
// surface as an unhandled rejection racing with a normal response.
|
|
25
|
+
this.logger.error(err);
|
|
26
|
+
if (!res.headersSent) {
|
|
27
|
+
res.statusCode = 502;
|
|
28
|
+
res.end();
|
|
29
|
+
}
|
|
30
|
+
resolve();
|
|
20
31
|
});
|
|
21
32
|
});
|
|
22
33
|
}
|
|
@@ -6,4 +6,11 @@ export declare class RewriteMiddleware implements Middleware<[proxyRes: Incoming
|
|
|
6
6
|
private logger;
|
|
7
7
|
constructor(logger: LoggerService);
|
|
8
8
|
use(proxyRes: IncomingMessage, req: IncomingMessage, res: ServerResponse, next: () => void): Promise<void>;
|
|
9
|
+
/**
|
|
10
|
+
* Logs the access line for a rewritten response and marks the request as
|
|
11
|
+
* logged, so ProxyResMiddleware (which would otherwise also log plain
|
|
12
|
+
* pass-through responses) knows to stay silent — including on the error path
|
|
13
|
+
* above, where the rewrite itself failed but the response still went out.
|
|
14
|
+
*/
|
|
15
|
+
private logRewrite;
|
|
9
16
|
}
|
|
@@ -7,7 +7,7 @@ exports.RewriteMiddleware = void 0;
|
|
|
7
7
|
const http_encoding_1 = require("http-encoding");
|
|
8
8
|
const promises_1 = __importDefault(require("fs/promises"));
|
|
9
9
|
const path_1 = __importDefault(require("path"));
|
|
10
|
-
const
|
|
10
|
+
const access_log_1 = require("../utils/access-log");
|
|
11
11
|
class RewriteMiddleware {
|
|
12
12
|
constructor(logger) {
|
|
13
13
|
this.logger = logger;
|
|
@@ -30,7 +30,7 @@ class RewriteMiddleware {
|
|
|
30
30
|
const rewritedResponse = await handler(encodedResponse, proxyRes, req, res);
|
|
31
31
|
proxyRes.rawBody = await (0, http_encoding_1.encodeBuffer)(rewritedResponse, encoding);
|
|
32
32
|
}
|
|
33
|
-
this.
|
|
33
|
+
this.logRewrite(req, proxyRes);
|
|
34
34
|
next();
|
|
35
35
|
return;
|
|
36
36
|
}
|
|
@@ -40,7 +40,7 @@ class RewriteMiddleware {
|
|
|
40
40
|
if (rewriteRule.statusCode) {
|
|
41
41
|
proxyRes.statusCode = rewriteRule.statusCode;
|
|
42
42
|
}
|
|
43
|
-
this.
|
|
43
|
+
this.logRewrite(req, proxyRes);
|
|
44
44
|
next();
|
|
45
45
|
return;
|
|
46
46
|
}
|
|
@@ -48,8 +48,21 @@ class RewriteMiddleware {
|
|
|
48
48
|
}
|
|
49
49
|
catch (e) {
|
|
50
50
|
this.logger.error(e);
|
|
51
|
+
// Even on failure the response still goes out via ProxyResMiddleware —
|
|
52
|
+
// make sure the request still gets exactly one access-log line.
|
|
53
|
+
this.logRewrite(req, proxyRes);
|
|
51
54
|
next();
|
|
52
55
|
}
|
|
53
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Logs the access line for a rewritten response and marks the request as
|
|
59
|
+
* logged, so ProxyResMiddleware (which would otherwise also log plain
|
|
60
|
+
* pass-through responses) knows to stay silent — including on the error path
|
|
61
|
+
* above, where the rewrite itself failed but the response still went out.
|
|
62
|
+
*/
|
|
63
|
+
logRewrite(req, proxyRes) {
|
|
64
|
+
req.rewriteLogged = true;
|
|
65
|
+
this.logger.info((0, access_log_1.formatAccessLog)('rewrite', proxyRes.statusCode, req));
|
|
66
|
+
}
|
|
54
67
|
}
|
|
55
68
|
exports.RewriteMiddleware = RewriteMiddleware;
|
|
@@ -8,5 +8,27 @@ export declare class StaticMiddleware implements Middleware<[req: IncomingMessag
|
|
|
8
8
|
private logger;
|
|
9
9
|
constructor(config: Config, logger: LoggerService);
|
|
10
10
|
use(req: IncomingMessage, res: ServerResponse, next: () => void): Promise<void>;
|
|
11
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Resolves a request URL path to an actual file on disk under the rule's
|
|
13
|
+
* directory — without enumerating the whole directory tree on every request
|
|
14
|
+
* (the previous implementation did a full recursive `readdir` per request,
|
|
15
|
+
* the most expensive thing on this hot path).
|
|
16
|
+
*
|
|
17
|
+
* Fast path: build the candidate path directly from the URL and `fs.stat`
|
|
18
|
+
* it — this covers the overwhelming majority of requests (correct case,
|
|
19
|
+
* matches exactly) with zero directory listings.
|
|
20
|
+
*
|
|
21
|
+
* Fallback (only when the fast path misses AND `caseInsensitive` is set):
|
|
22
|
+
* walk down the path segment by segment, listing only the relevant
|
|
23
|
+
* directory at each level and matching case-insensitively — far cheaper
|
|
24
|
+
* than enumerating the entire tree just to find one file.
|
|
25
|
+
*/
|
|
26
|
+
private resolveFile;
|
|
27
|
+
/**
|
|
28
|
+
* Confirms `filePath` is a regular file AND, after resolving symlinks on
|
|
29
|
+
* both sides, still lives inside `directory` — a symlink placed under
|
|
30
|
+
* `directory` that points outside it must not be served.
|
|
31
|
+
*/
|
|
32
|
+
private isFile;
|
|
33
|
+
private findCaseInsensitive;
|
|
12
34
|
}
|
|
@@ -7,7 +7,7 @@ exports.StaticMiddleware = void 0;
|
|
|
7
7
|
const path_1 = __importDefault(require("path"));
|
|
8
8
|
const promises_1 = __importDefault(require("fs/promises"));
|
|
9
9
|
const mime_1 = __importDefault(require("mime"));
|
|
10
|
-
const
|
|
10
|
+
const access_log_1 = require("../utils/access-log");
|
|
11
11
|
class StaticMiddleware {
|
|
12
12
|
constructor(config, logger) {
|
|
13
13
|
this.config = config;
|
|
@@ -25,58 +25,102 @@ class StaticMiddleware {
|
|
|
25
25
|
next();
|
|
26
26
|
return;
|
|
27
27
|
}
|
|
28
|
-
const
|
|
29
|
-
const dirents = await this.readdir(pathToDirectory);
|
|
30
|
-
const filePath = dirents
|
|
31
|
-
.filter(dirent => !dirent.isDirectory())
|
|
32
|
-
.map(dirent => ({
|
|
33
|
-
urlPath: path_1.default.join(currentStaticRule.path, path_1.default.join(dirent.path, dirent.name).replace(pathToDirectory, '')).replace(/\\/g, '/'),
|
|
34
|
-
path: path_1.default.join(dirent.path, dirent.name).replace(/\\/g, '/'),
|
|
35
|
-
}))
|
|
36
|
-
.filter(dirent => {
|
|
37
|
-
const pathname = path_1.default.extname(urlPath)
|
|
38
|
-
? urlPath
|
|
39
|
-
: path_1.default.join(urlPath, currentStaticRule.directoryIndex || '').replace(/\\/g, '/');
|
|
40
|
-
if (currentStaticRule.caseInsensitive) {
|
|
41
|
-
return dirent.urlPath.toLocaleLowerCase() === pathname.toLowerCase();
|
|
42
|
-
}
|
|
43
|
-
else {
|
|
44
|
-
return dirent.urlPath === pathname;
|
|
45
|
-
}
|
|
46
|
-
})
|
|
47
|
-
.map(dirent => dirent.path)[0];
|
|
28
|
+
const filePath = await this.resolveFile(currentStaticRule, urlPath);
|
|
48
29
|
if (!filePath) {
|
|
49
30
|
next();
|
|
50
31
|
return;
|
|
51
32
|
}
|
|
52
|
-
const file = await promises_1.default.readFile(
|
|
53
|
-
const mimeType = mime_1.default.getType(
|
|
33
|
+
const file = await promises_1.default.readFile(filePath);
|
|
34
|
+
const mimeType = mime_1.default.getType(filePath);
|
|
54
35
|
if (mimeType) {
|
|
55
36
|
res.setHeader('content-type', mimeType);
|
|
56
37
|
}
|
|
57
38
|
res.setHeader('content-length', file.length);
|
|
58
39
|
res.end(file);
|
|
59
|
-
this.logger.info(
|
|
40
|
+
this.logger.info((0, access_log_1.formatAccessLog)('static', res.statusCode, req));
|
|
60
41
|
}
|
|
61
42
|
catch (e) {
|
|
62
43
|
this.logger.error(e);
|
|
63
44
|
next();
|
|
64
45
|
}
|
|
65
46
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Resolves a request URL path to an actual file on disk under the rule's
|
|
49
|
+
* directory — without enumerating the whole directory tree on every request
|
|
50
|
+
* (the previous implementation did a full recursive `readdir` per request,
|
|
51
|
+
* the most expensive thing on this hot path).
|
|
52
|
+
*
|
|
53
|
+
* Fast path: build the candidate path directly from the URL and `fs.stat`
|
|
54
|
+
* it — this covers the overwhelming majority of requests (correct case,
|
|
55
|
+
* matches exactly) with zero directory listings.
|
|
56
|
+
*
|
|
57
|
+
* Fallback (only when the fast path misses AND `caseInsensitive` is set):
|
|
58
|
+
* walk down the path segment by segment, listing only the relevant
|
|
59
|
+
* directory at each level and matching case-insensitively — far cheaper
|
|
60
|
+
* than enumerating the entire tree just to find one file.
|
|
61
|
+
*/
|
|
62
|
+
async resolveFile(rule, urlPath) {
|
|
63
|
+
const directory = path_1.default.resolve(rule.directory);
|
|
64
|
+
const relativeUrlPath = urlPath.slice(rule.path.length);
|
|
65
|
+
const pathname = path_1.default.extname(relativeUrlPath)
|
|
66
|
+
? relativeUrlPath
|
|
67
|
+
: path_1.default.join(relativeUrlPath, rule.directoryIndex || '');
|
|
68
|
+
const segments = pathname.split(/[\\/]+/).filter(Boolean);
|
|
69
|
+
// Defense in depth — `new URL()` already normalizes `..` segments out of
|
|
70
|
+
// urlPath, but reject anything that could still escape `directory`.
|
|
71
|
+
if (segments.includes('..')) {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
if (segments.length === 0) {
|
|
75
|
+
return undefined;
|
|
76
|
+
}
|
|
77
|
+
const exactPath = path_1.default.join(directory, ...segments);
|
|
78
|
+
if (await this.isFile(exactPath, directory)) {
|
|
79
|
+
return exactPath;
|
|
80
|
+
}
|
|
81
|
+
if (!rule.caseInsensitive) {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
let current = directory;
|
|
85
|
+
for (const segment of segments) {
|
|
86
|
+
const match = await this.findCaseInsensitive(current, segment);
|
|
87
|
+
if (!match) {
|
|
88
|
+
return undefined;
|
|
77
89
|
}
|
|
90
|
+
current = path_1.default.join(current, match);
|
|
91
|
+
}
|
|
92
|
+
return (await this.isFile(current, directory)) ? current : undefined;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Confirms `filePath` is a regular file AND, after resolving symlinks on
|
|
96
|
+
* both sides, still lives inside `directory` — a symlink placed under
|
|
97
|
+
* `directory` that points outside it must not be served.
|
|
98
|
+
*/
|
|
99
|
+
async isFile(filePath, directory) {
|
|
100
|
+
try {
|
|
101
|
+
const stats = await promises_1.default.stat(filePath);
|
|
102
|
+
if (!stats.isFile()) {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
const [realFile, realDirectory] = await Promise.all([
|
|
106
|
+
promises_1.default.realpath(filePath),
|
|
107
|
+
promises_1.default.realpath(directory),
|
|
108
|
+
]);
|
|
109
|
+
const relative = path_1.default.relative(realDirectory, realFile);
|
|
110
|
+
return !relative.startsWith('..') && !path_1.default.isAbsolute(relative);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
async findCaseInsensitive(directory, name) {
|
|
117
|
+
try {
|
|
118
|
+
const entries = await promises_1.default.readdir(directory);
|
|
119
|
+
return entries.find(entry => entry.toLowerCase() === name.toLowerCase());
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return undefined;
|
|
78
123
|
}
|
|
79
|
-
return files;
|
|
80
124
|
}
|
|
81
125
|
}
|
|
82
126
|
exports.StaticMiddleware = StaticMiddleware;
|
|
@@ -1,9 +1,26 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.applyCliOverrides = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* Reads a flag's value from argv, supporting both `--flag value` and
|
|
6
|
+
* `--flag=value` forms. For the space-separated form, a following token that
|
|
7
|
+
* itself looks like a flag (starts with `--`) is NOT treated as this flag's
|
|
8
|
+
* value — `--port --target http://x` should leave `--port` unset rather than
|
|
9
|
+
* swallowing `--target` as its value.
|
|
10
|
+
*/
|
|
4
11
|
function getArgValue(argv, flag) {
|
|
5
|
-
const
|
|
6
|
-
|
|
12
|
+
const prefix = `${flag}=`;
|
|
13
|
+
for (let i = 0; i < argv.length; i++) {
|
|
14
|
+
const arg = argv[i];
|
|
15
|
+
if (arg.startsWith(prefix)) {
|
|
16
|
+
return arg.slice(prefix.length);
|
|
17
|
+
}
|
|
18
|
+
if (arg === flag) {
|
|
19
|
+
const next = argv[i + 1];
|
|
20
|
+
return next !== undefined && !next.startsWith('--') ? next : undefined;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return undefined;
|
|
7
24
|
}
|
|
8
25
|
/**
|
|
9
26
|
* Applies `--port`/`--target` CLI flags on top of a resolved config, without
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Intentionally permissive — it only checks the shape of fields that would
|
|
6
6
|
* otherwise cause confusing crashes deeper in the pipeline (e.g. a missing
|
|
7
|
-
* `target` blowing up inside httpxy with an opaque stack trace
|
|
7
|
+
* `target` blowing up inside httpxy with an opaque stack trace, or a bad
|
|
8
|
+
* `path` pattern throwing synchronously inside path-to-regexp's `match()`).
|
|
8
9
|
*/
|
|
9
10
|
export declare function validateConfig(config: any): string[];
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.validateConfig = void 0;
|
|
4
|
+
const path_to_regexp_1 = require("path-to-regexp");
|
|
4
5
|
const RULE_ARRAYS = ['remoteRules', 'staticRules', 'mockRules', 'rewriteRules'];
|
|
6
|
+
const MAX_PORT = 65535;
|
|
5
7
|
/**
|
|
6
8
|
* Validates a parsed config object and returns a list of human-readable
|
|
7
9
|
* error messages. An empty array means the config is valid.
|
|
8
10
|
*
|
|
9
11
|
* Intentionally permissive — it only checks the shape of fields that would
|
|
10
12
|
* otherwise cause confusing crashes deeper in the pipeline (e.g. a missing
|
|
11
|
-
* `target` blowing up inside httpxy with an opaque stack trace
|
|
13
|
+
* `target` blowing up inside httpxy with an opaque stack trace, or a bad
|
|
14
|
+
* `path` pattern throwing synchronously inside path-to-regexp's `match()`).
|
|
12
15
|
*/
|
|
13
16
|
function validateConfig(config) {
|
|
14
17
|
const errors = [];
|
|
@@ -19,20 +22,95 @@ function validateConfig(config) {
|
|
|
19
22
|
errors.push('"target" is required and must be a non-empty string (e.g. "https://api.example.com")');
|
|
20
23
|
}
|
|
21
24
|
const { proxyPort } = config;
|
|
22
|
-
const isValidPort = (typeof proxyPort === 'number' && Number.isInteger(proxyPort) && proxyPort >= 0) ||
|
|
23
|
-
(typeof proxyPort === 'string' && proxyPort.trim() !== '' && Number.isInteger(Number(proxyPort)));
|
|
25
|
+
const isValidPort = (typeof proxyPort === 'number' && Number.isInteger(proxyPort) && proxyPort >= 0 && proxyPort <= MAX_PORT) ||
|
|
26
|
+
(typeof proxyPort === 'string' && proxyPort.trim() !== '' && Number.isInteger(Number(proxyPort)) && Number(proxyPort) >= 0 && Number(proxyPort) <= MAX_PORT);
|
|
24
27
|
if (!isValidPort) {
|
|
25
|
-
errors.push(
|
|
28
|
+
errors.push(`"proxyPort" is required and must be a number or numeric string between 0 and ${MAX_PORT} (e.g. 3000)`);
|
|
26
29
|
}
|
|
27
30
|
if (config.proxyHeaders !== undefined &&
|
|
28
31
|
(typeof config.proxyHeaders !== 'object' || config.proxyHeaders === null || Array.isArray(config.proxyHeaders))) {
|
|
29
32
|
errors.push('"proxyHeaders" must be an object of string key-value pairs');
|
|
30
33
|
}
|
|
31
34
|
for (const key of RULE_ARRAYS) {
|
|
32
|
-
|
|
35
|
+
const rules = config[key];
|
|
36
|
+
if (rules === undefined) {
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (!Array.isArray(rules)) {
|
|
33
40
|
errors.push(`"${key}" must be an array`);
|
|
41
|
+
continue;
|
|
34
42
|
}
|
|
43
|
+
rules.forEach((rule, index) => {
|
|
44
|
+
errors.push(...validateRule(key, rule, index));
|
|
45
|
+
});
|
|
35
46
|
}
|
|
36
47
|
return errors;
|
|
37
48
|
}
|
|
38
49
|
exports.validateConfig = validateConfig;
|
|
50
|
+
function validateRule(key, rule, index) {
|
|
51
|
+
const errors = [];
|
|
52
|
+
const label = `"${key}[${index}]"`;
|
|
53
|
+
if (!rule || typeof rule !== 'object' || Array.isArray(rule)) {
|
|
54
|
+
return [`${label} must be an object`];
|
|
55
|
+
}
|
|
56
|
+
if (typeof rule.path !== 'string' || !rule.path.trim()) {
|
|
57
|
+
errors.push(`${label} is missing a non-empty "path" string`);
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
const pathError = validatePathPattern(rule.path);
|
|
61
|
+
if (pathError) {
|
|
62
|
+
errors.push(`${label} has an invalid "path" pattern: ${pathError}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
switch (key) {
|
|
66
|
+
case 'remoteRules':
|
|
67
|
+
if (typeof rule.target !== 'string' || !rule.target.trim()) {
|
|
68
|
+
errors.push(`${label} is missing a non-empty "target" string`);
|
|
69
|
+
}
|
|
70
|
+
break;
|
|
71
|
+
case 'staticRules':
|
|
72
|
+
if (typeof rule.directory !== 'string' || !rule.directory.trim()) {
|
|
73
|
+
errors.push(`${label} is missing a non-empty "directory" string`);
|
|
74
|
+
}
|
|
75
|
+
break;
|
|
76
|
+
case 'mockRules':
|
|
77
|
+
case 'rewriteRules':
|
|
78
|
+
if (typeof rule.method !== 'string' || !rule.method.trim()) {
|
|
79
|
+
errors.push(`${label} is missing a non-empty "method" string`);
|
|
80
|
+
}
|
|
81
|
+
errors.push(...validateFileOrScriptRule(label, rule));
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
return errors;
|
|
85
|
+
}
|
|
86
|
+
function validateFileOrScriptRule(label, rule) {
|
|
87
|
+
const hasFile = 'file' in rule;
|
|
88
|
+
const hasScript = 'script' in rule;
|
|
89
|
+
if (hasFile && hasScript) {
|
|
90
|
+
return [`${label} must declare only one of "file" or "script", not both`];
|
|
91
|
+
}
|
|
92
|
+
if (!hasFile && !hasScript) {
|
|
93
|
+
return [`${label} must declare either "file" or "script"`];
|
|
94
|
+
}
|
|
95
|
+
if (hasFile && (typeof rule.file !== 'string' || !rule.file.trim())) {
|
|
96
|
+
return [`${label} has a "file" that must be a non-empty string`];
|
|
97
|
+
}
|
|
98
|
+
if (hasScript && (typeof rule.script !== 'string' || !rule.script.trim())) {
|
|
99
|
+
return [`${label} has a "script" that must be a non-empty string`];
|
|
100
|
+
}
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Compiles a path-to-regexp pattern to catch malformed patterns at
|
|
105
|
+
* config-load/reload time, before they reach the rule matchers (where they'd
|
|
106
|
+
* otherwise throw synchronously on every matching request).
|
|
107
|
+
*/
|
|
108
|
+
function validatePathPattern(path) {
|
|
109
|
+
try {
|
|
110
|
+
(0, path_to_regexp_1.match)(path);
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
catch (e) {
|
|
114
|
+
return (e === null || e === void 0 ? void 0 : e.message) || String(e);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -5,9 +5,17 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.watchConfig = void 0;
|
|
7
7
|
const fs_1 = __importDefault(require("fs"));
|
|
8
|
+
const path_1 = __importDefault(require("path"));
|
|
8
9
|
function watchConfig(configPath, onReload, onError) {
|
|
10
|
+
const dir = path_1.default.dirname(configPath);
|
|
11
|
+
const filename = path_1.default.basename(configPath);
|
|
9
12
|
let debounceTimer = null;
|
|
10
|
-
|
|
13
|
+
// Watch the parent directory rather than the file itself — editors that
|
|
14
|
+
// save via atomic rename (vim, etc.) replace the inode, after which
|
|
15
|
+
// fs.watch on the old file path stops firing.
|
|
16
|
+
return fs_1.default.watch(dir, (_eventType, changedFile) => {
|
|
17
|
+
if (changedFile && changedFile !== filename)
|
|
18
|
+
return;
|
|
11
19
|
if (debounceTimer)
|
|
12
20
|
return;
|
|
13
21
|
debounceTimer = setTimeout(() => {
|
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
/// <reference types="node" />
|
|
3
3
|
/// <reference types="node" />
|
|
4
4
|
import { IncomingMessage, ServerResponse } from 'http';
|
|
5
|
+
import { ProxyServer } from 'httpxy';
|
|
5
6
|
import { Pipeline } from './pipeline.service';
|
|
6
7
|
export declare class HttpProxy {
|
|
7
8
|
private pipeline;
|
|
8
|
-
readonly on:
|
|
9
|
+
readonly on: ProxyServer<IncomingMessage, ServerResponse>['on'];
|
|
9
10
|
private proxy;
|
|
10
11
|
constructor(pipeline: Pipeline<[proxyRes: IncomingMessage, req: IncomingMessage, res: ServerResponse]>);
|
|
11
12
|
execute: (args_0: IncomingMessage, args_1: IncomingMessage, args_2: ServerResponse<IncomingMessage>) => Promise<void>;
|
|
12
|
-
web(req: IncomingMessage, res: ServerResponse, options?: Record<string, any>):
|
|
13
|
-
ws(req: IncomingMessage, socket: any, options: Record<string, any>, head?: Buffer):
|
|
13
|
+
web(req: IncomingMessage, res: ServerResponse, options?: Record<string, any>): Promise<void>;
|
|
14
|
+
ws(req: IncomingMessage, socket: any, options: Record<string, any>, head?: Buffer): Promise<void>;
|
|
14
15
|
}
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
/// <reference types="node" />
|
|
2
2
|
import { IncomingMessage, Server, ServerResponse } from 'http';
|
|
3
3
|
import { Pipeline } from './pipeline.service';
|
|
4
|
+
import { LoggerService } from './logger.service';
|
|
4
5
|
export declare class HttpServer {
|
|
5
6
|
private pipeline;
|
|
7
|
+
private logger;
|
|
6
8
|
readonly use: Pipeline<[IncomingMessage, ServerResponse]>['use'];
|
|
7
9
|
readonly on: Server['on'];
|
|
8
10
|
readonly addListener: Server['addListener'];
|
|
@@ -11,5 +13,5 @@ export declare class HttpServer {
|
|
|
11
13
|
readonly close: Server['close'];
|
|
12
14
|
readonly address: Server['address'];
|
|
13
15
|
private readonly server;
|
|
14
|
-
constructor(pipeline: Pipeline<[req: IncomingMessage, res: ServerResponse]
|
|
16
|
+
constructor(pipeline: Pipeline<[req: IncomingMessage, res: ServerResponse]>, logger: LoggerService);
|
|
15
17
|
}
|
|
@@ -6,11 +6,21 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
exports.HttpServer = void 0;
|
|
7
7
|
const http_1 = __importDefault(require("http"));
|
|
8
8
|
class HttpServer {
|
|
9
|
-
constructor(pipeline) {
|
|
9
|
+
constructor(pipeline, logger) {
|
|
10
10
|
this.pipeline = pipeline;
|
|
11
|
+
this.logger = logger;
|
|
11
12
|
this.server = http_1.default.createServer((req, res) => {
|
|
12
13
|
req.startTime = Date.now();
|
|
13
|
-
|
|
14
|
+
// pipeline.execute() is async — an uncaught rejection here (e.g. a
|
|
15
|
+
// synchronous throw deep in a middleware) would otherwise become an
|
|
16
|
+
// unhandled promise rejection and can crash the whole process.
|
|
17
|
+
this.pipeline.execute(req, res).catch((e) => {
|
|
18
|
+
this.logger.error(e);
|
|
19
|
+
if (!res.headersSent) {
|
|
20
|
+
res.statusCode = 502;
|
|
21
|
+
res.end();
|
|
22
|
+
}
|
|
23
|
+
});
|
|
14
24
|
});
|
|
15
25
|
this.use = this.pipeline.use.bind(this.pipeline);
|
|
16
26
|
this.on = this.server.on.bind(this.server);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export interface MethodPathRule {
|
|
2
|
+
method: string;
|
|
3
|
+
path: string;
|
|
4
|
+
disable?: boolean;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Shared base for matchers that pick a rule by HTTP method + URL path
|
|
8
|
+
* (MockRulesMatcher, RewriteRulesMatcher) — both used to be byte-for-byte
|
|
9
|
+
* identical aside from the rule type. Subclasses only need to provide the
|
|
10
|
+
* list of candidate rules.
|
|
11
|
+
*/
|
|
12
|
+
export declare abstract class MethodPathRulesMatcher<T extends MethodPathRule> {
|
|
13
|
+
protected abstract getRules(): T[];
|
|
14
|
+
match(url: string, method: string): T | undefined;
|
|
15
|
+
params(url: string, rule: T): Record<string, string> | undefined;
|
|
16
|
+
/**
|
|
17
|
+
* Finds the matching rule and its path params in a single pass — avoids
|
|
18
|
+
* compiling/running the path-to-regexp matcher twice (once via `match()`,
|
|
19
|
+
* again via `params()`) for the same url+rule, as BootstrapMiddleware does.
|
|
20
|
+
*/
|
|
21
|
+
matchWithParams(url: string, method: string): {
|
|
22
|
+
rule: T;
|
|
23
|
+
params: Record<string, string>;
|
|
24
|
+
} | undefined;
|
|
25
|
+
private find;
|
|
26
|
+
private matchPath;
|
|
27
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MethodPathRulesMatcher = void 0;
|
|
4
|
+
const path_to_regexp_1 = require("path-to-regexp");
|
|
5
|
+
/**
|
|
6
|
+
* Shared base for matchers that pick a rule by HTTP method + URL path
|
|
7
|
+
* (MockRulesMatcher, RewriteRulesMatcher) — both used to be byte-for-byte
|
|
8
|
+
* identical aside from the rule type. Subclasses only need to provide the
|
|
9
|
+
* list of candidate rules.
|
|
10
|
+
*/
|
|
11
|
+
class MethodPathRulesMatcher {
|
|
12
|
+
match(url, method) {
|
|
13
|
+
var _a;
|
|
14
|
+
return (_a = this.find(url, method)) === null || _a === void 0 ? void 0 : _a.rule;
|
|
15
|
+
}
|
|
16
|
+
params(url, rule) {
|
|
17
|
+
var _a;
|
|
18
|
+
return (_a = this.matchPath(rule.path, url)) === null || _a === void 0 ? void 0 : _a.params;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Finds the matching rule and its path params in a single pass — avoids
|
|
22
|
+
* compiling/running the path-to-regexp matcher twice (once via `match()`,
|
|
23
|
+
* again via `params()`) for the same url+rule, as BootstrapMiddleware does.
|
|
24
|
+
*/
|
|
25
|
+
matchWithParams(url, method) {
|
|
26
|
+
const found = this.find(url, method);
|
|
27
|
+
return found ? { rule: found.rule, params: found.matchResult.params } : undefined;
|
|
28
|
+
}
|
|
29
|
+
find(url, method) {
|
|
30
|
+
for (const rule of this.getRules()) {
|
|
31
|
+
if (rule.disable) {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (rule.method.toLowerCase() !== method.toLowerCase()) {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const matchResult = this.matchPath(rule.path, url);
|
|
38
|
+
if (matchResult) {
|
|
39
|
+
return { rule, matchResult };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
matchPath(path, url) {
|
|
44
|
+
const matchResult = (0, path_to_regexp_1.match)(path, {
|
|
45
|
+
decode: decodeURIComponent,
|
|
46
|
+
})(url);
|
|
47
|
+
return matchResult || undefined;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
exports.MethodPathRulesMatcher = MethodPathRulesMatcher;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { Config } from '../config.service';
|
|
2
2
|
import { MockRule } from '../../types/yxorp-config';
|
|
3
|
-
|
|
3
|
+
import { MethodPathRulesMatcher } from './method-path-rules-matcher';
|
|
4
|
+
export declare class MockRulesMatcher extends MethodPathRulesMatcher<MockRule> {
|
|
4
5
|
private config;
|
|
5
6
|
constructor(config: Config);
|
|
6
|
-
|
|
7
|
-
params(url: string, mockRule: MockRule): Object | undefined;
|
|
7
|
+
protected getRules(): MockRule[];
|
|
8
8
|
}
|
|
@@ -1,35 +1,14 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.MockRulesMatcher = void 0;
|
|
4
|
-
const
|
|
5
|
-
class MockRulesMatcher {
|
|
4
|
+
const method_path_rules_matcher_1 = require("./method-path-rules-matcher");
|
|
5
|
+
class MockRulesMatcher extends method_path_rules_matcher_1.MethodPathRulesMatcher {
|
|
6
6
|
constructor(config) {
|
|
7
|
+
super();
|
|
7
8
|
this.config = config;
|
|
8
9
|
}
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
for (let mockRule of mockRules) {
|
|
12
|
-
if (mockRule.disable) {
|
|
13
|
-
continue;
|
|
14
|
-
}
|
|
15
|
-
if (mockRule.method.toLowerCase() !== method.toLowerCase()) {
|
|
16
|
-
continue;
|
|
17
|
-
}
|
|
18
|
-
const matchResult = (0, path_to_regexp_1.match)(mockRule.path, {
|
|
19
|
-
decode: decodeURIComponent,
|
|
20
|
-
})(url);
|
|
21
|
-
if (matchResult) {
|
|
22
|
-
return mockRule;
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
params(url, mockRule) {
|
|
27
|
-
const matchResult = (0, path_to_regexp_1.match)(mockRule.path, {
|
|
28
|
-
decode: decodeURIComponent,
|
|
29
|
-
})(url);
|
|
30
|
-
if (matchResult) {
|
|
31
|
-
return matchResult.params;
|
|
32
|
-
}
|
|
10
|
+
getRules() {
|
|
11
|
+
return this.config.get().mockRules || [];
|
|
33
12
|
}
|
|
34
13
|
}
|
|
35
14
|
exports.MockRulesMatcher = MockRulesMatcher;
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { Config } from '../config.service';
|
|
2
2
|
import { RewriteRule } from '../../types/yxorp-config';
|
|
3
|
-
|
|
3
|
+
import { MethodPathRulesMatcher } from './method-path-rules-matcher';
|
|
4
|
+
export declare class RewriteRulesMatcher extends MethodPathRulesMatcher<RewriteRule> {
|
|
4
5
|
private config;
|
|
5
6
|
constructor(config: Config);
|
|
6
|
-
|
|
7
|
-
params(url: string, rewriteRule: RewriteRule): Object | undefined;
|
|
7
|
+
protected getRules(): RewriteRule[];
|
|
8
8
|
}
|
|
@@ -1,35 +1,14 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.RewriteRulesMatcher = void 0;
|
|
4
|
-
const
|
|
5
|
-
class RewriteRulesMatcher {
|
|
4
|
+
const method_path_rules_matcher_1 = require("./method-path-rules-matcher");
|
|
5
|
+
class RewriteRulesMatcher extends method_path_rules_matcher_1.MethodPathRulesMatcher {
|
|
6
6
|
constructor(config) {
|
|
7
|
+
super();
|
|
7
8
|
this.config = config;
|
|
8
9
|
}
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
for (let rewriteRule of rewriteRules) {
|
|
12
|
-
if (rewriteRule.disable) {
|
|
13
|
-
continue;
|
|
14
|
-
}
|
|
15
|
-
if (rewriteRule.method.toLowerCase() !== method.toLowerCase()) {
|
|
16
|
-
continue;
|
|
17
|
-
}
|
|
18
|
-
const matchResult = (0, path_to_regexp_1.match)(rewriteRule.path, {
|
|
19
|
-
decode: decodeURIComponent,
|
|
20
|
-
})(url);
|
|
21
|
-
if (matchResult) {
|
|
22
|
-
return rewriteRule;
|
|
23
|
-
}
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
params(url, rewriteRule) {
|
|
27
|
-
const matchResult = (0, path_to_regexp_1.match)(rewriteRule.path, {
|
|
28
|
-
decode: decodeURIComponent,
|
|
29
|
-
})(url);
|
|
30
|
-
if (matchResult) {
|
|
31
|
-
return matchResult.params;
|
|
32
|
-
}
|
|
10
|
+
getRules() {
|
|
11
|
+
return this.config.get().rewriteRules || [];
|
|
33
12
|
}
|
|
34
13
|
}
|
|
35
14
|
exports.RewriteRulesMatcher = RewriteRulesMatcher;
|
|
@@ -14,26 +14,43 @@ const static_middleware_1 = require("../middleware/static.middleware");
|
|
|
14
14
|
function createServer(config, logger, rewriteRulesMatcher, mockRulesMatcher, remoteRulesMatcher) {
|
|
15
15
|
// 1. Proxy pipeline (outgoing responses) — no deps on proxy/server
|
|
16
16
|
const proxyPipeline = new pipeline_service_1.Pipeline();
|
|
17
|
-
proxyPipeline.use(new rawBody_middleware_1.RawBodyMiddleware(), new rewrite_middleware_1.RewriteMiddleware(logger), new proxyRes_middleware_1.ProxyResMiddleware(logger));
|
|
17
|
+
proxyPipeline.use(new rawBody_middleware_1.RawBodyMiddleware(logger), new rewrite_middleware_1.RewriteMiddleware(logger), new proxyRes_middleware_1.ProxyResMiddleware(logger));
|
|
18
18
|
// 2. HttpProxy wraps the proxy pipeline
|
|
19
19
|
const proxy = new http_proxy_service_1.HttpProxy(proxyPipeline);
|
|
20
20
|
// 3. Server pipeline (incoming requests)
|
|
21
21
|
const serverPipeline = new pipeline_service_1.Pipeline();
|
|
22
22
|
serverPipeline.use(new static_middleware_1.StaticMiddleware(config, logger), new bootstrap_middleware_1.BootstrapMiddleware(rewriteRulesMatcher, mockRulesMatcher, logger), new mock_middleware_1.MockMiddleware(logger), new proxy_middleware_1.ProxyMiddleware(proxy, remoteRulesMatcher, config, logger));
|
|
23
23
|
// 4. HttpServer wraps the server pipeline
|
|
24
|
-
const server = new http_server_service_1.HttpServer(serverPipeline);
|
|
24
|
+
const server = new http_server_service_1.HttpServer(serverPipeline, logger);
|
|
25
25
|
// 5. Attach proxy pipeline to proxyRes event
|
|
26
|
-
proxy.on('proxyRes', (
|
|
27
|
-
proxy.execute(
|
|
28
|
-
|
|
26
|
+
proxy.on('proxyRes', (proxyRes, req, res) => {
|
|
27
|
+
// proxy.execute() is async — an uncaught rejection here would otherwise
|
|
28
|
+
// become an unhandled promise rejection and can crash the whole process,
|
|
29
|
+
// leaving the client hanging with no response.
|
|
30
|
+
proxy.execute(proxyRes, req, res).catch((e) => {
|
|
31
|
+
logger.error(e);
|
|
32
|
+
if (!res.headersSent) {
|
|
33
|
+
res.statusCode = 502;
|
|
34
|
+
res.end();
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
});
|
|
29
38
|
// 6. WebSocket upgrade handling
|
|
30
39
|
server.addListener('upgrade', (req, socket, head) => {
|
|
31
40
|
const url = req.url || '';
|
|
32
41
|
const proxyOptions = config.get().proxyOptions;
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
42
|
+
let target;
|
|
43
|
+
try {
|
|
44
|
+
const remoteRule = remoteRulesMatcher.match(url, true);
|
|
45
|
+
target = remoteRule
|
|
46
|
+
? remoteRulesMatcher.toPath(url, remoteRule)
|
|
47
|
+
: undefined;
|
|
48
|
+
}
|
|
49
|
+
catch (e) {
|
|
50
|
+
// A malformed `remoteRules[].path` pattern would otherwise throw
|
|
51
|
+
// synchronously here on every upgrade — fall back to the default target.
|
|
52
|
+
logger.error(e);
|
|
53
|
+
}
|
|
37
54
|
const options = {
|
|
38
55
|
...proxyOptions,
|
|
39
56
|
prependPath: !target,
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { IncomingMessage } from 'http';
|
|
2
|
+
/**
|
|
3
|
+
* Formats a single access-log line with the label column padded to a fixed
|
|
4
|
+
* width so entries from different middlewares (mock, rewrite, proxy,
|
|
5
|
+
* static) line up in the log output.
|
|
6
|
+
*/
|
|
7
|
+
export declare function formatAccessLog(label: string, statusCode: number | string | undefined, req: IncomingMessage): string;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.formatAccessLog = void 0;
|
|
4
|
+
const request_timing_1 = require("./request-timing");
|
|
5
|
+
const LABEL_WIDTH = 8;
|
|
6
|
+
/**
|
|
7
|
+
* Formats a single access-log line with the label column padded to a fixed
|
|
8
|
+
* width so entries from different middlewares (mock, rewrite, proxy,
|
|
9
|
+
* static) line up in the log output.
|
|
10
|
+
*/
|
|
11
|
+
function formatAccessLog(label, statusCode, req) {
|
|
12
|
+
return `${label.padEnd(LABEL_WIDTH)}${statusCode} ${req.method} ${req.url} ${(0, request_timing_1.elapsedMs)(req)}ms`;
|
|
13
|
+
}
|
|
14
|
+
exports.formatAccessLog = formatAccessLog;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
import { IncomingHttpHeaders } from 'http';
|
|
3
|
+
/**
|
|
4
|
+
* RFC 2616 §13.5.1 hop-by-hop headers must not be forwarded between a proxy
|
|
5
|
+
* and its client — plus any header dynamically named in the `Connection`
|
|
6
|
+
* header's value.
|
|
7
|
+
*/
|
|
8
|
+
export declare function isHopByHopHeader(name: string, headers: IncomingHttpHeaders): boolean;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isHopByHopHeader = void 0;
|
|
4
|
+
const HOP_BY_HOP_HEADERS = new Set([
|
|
5
|
+
'connection',
|
|
6
|
+
'keep-alive',
|
|
7
|
+
'proxy-authenticate',
|
|
8
|
+
'proxy-authorization',
|
|
9
|
+
'te',
|
|
10
|
+
'trailer',
|
|
11
|
+
'transfer-encoding',
|
|
12
|
+
'upgrade',
|
|
13
|
+
]);
|
|
14
|
+
/**
|
|
15
|
+
* RFC 2616 §13.5.1 hop-by-hop headers must not be forwarded between a proxy
|
|
16
|
+
* and its client — plus any header dynamically named in the `Connection`
|
|
17
|
+
* header's value.
|
|
18
|
+
*/
|
|
19
|
+
function isHopByHopHeader(name, headers) {
|
|
20
|
+
const lower = name.toLowerCase();
|
|
21
|
+
if (HOP_BY_HOP_HEADERS.has(lower)) {
|
|
22
|
+
return true;
|
|
23
|
+
}
|
|
24
|
+
const connection = headers.connection;
|
|
25
|
+
if (connection) {
|
|
26
|
+
const named = (Array.isArray(connection) ? connection.join(',') : connection)
|
|
27
|
+
.split(',')
|
|
28
|
+
.map((value) => value.trim().toLowerCase());
|
|
29
|
+
if (named.includes(lower)) {
|
|
30
|
+
return true;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
exports.isHopByHopHeader = isHopByHopHeader;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yxorp",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.4",
|
|
4
4
|
"description": "A local reverse proxy for rewriting, mocking, and debugging API responses.",
|
|
5
5
|
"files": [
|
|
6
6
|
"dist/",
|
|
@@ -27,7 +27,6 @@
|
|
|
27
27
|
"mime": "^3.0.0",
|
|
28
28
|
"path-to-regexp": "^8.4.2",
|
|
29
29
|
"qs": "^6.15.2",
|
|
30
|
-
"tslib": "^2.8.1",
|
|
31
30
|
"winston": "^3.19.0"
|
|
32
31
|
},
|
|
33
32
|
"devDependencies": {
|