yxorp 0.2.3 → 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/middleware/bootstrap.middleware.d.ts +4 -3
- package/dist/middleware/mock.middleware.js +5 -5
- package/dist/middleware/proxy.middleware.js +10 -1
- package/dist/middleware/proxyRes.middleware.js +6 -4
- package/dist/middleware/rewrite.middleware.js +2 -2
- package/dist/middleware/static.middleware.d.ts +5 -0
- package/dist/middleware/static.middleware.js +19 -6
- 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/rules-matchers/method-path-rules-matcher.d.ts +2 -2
- package/dist/services/yxorp-server.service.js +2 -2
- 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:
|
|
@@ -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;
|
|
@@ -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;
|
|
@@ -31,14 +31,14 @@ class MockMiddleware {
|
|
|
31
31
|
res.setHeader('content-type', 'application/json');
|
|
32
32
|
res.end(JSON.stringify({ error: `Mock script "${mockRule.script}" does not export a function` }));
|
|
33
33
|
this.logger.error(`Mock script "${fullPath}" does not export a function (module.exports = (req, res) => {...})`);
|
|
34
|
-
this.logger.info(
|
|
34
|
+
this.logger.info((0, access_log_1.formatAccessLog)('mock', res.statusCode, req));
|
|
35
35
|
return;
|
|
36
36
|
}
|
|
37
37
|
await handler(req, res);
|
|
38
38
|
if (!res.headersSent) {
|
|
39
39
|
res.setHeader('content-type', 'application/json');
|
|
40
40
|
}
|
|
41
|
-
this.logger.info(
|
|
41
|
+
this.logger.info((0, access_log_1.formatAccessLog)('mock', res.statusCode || 200, req));
|
|
42
42
|
return;
|
|
43
43
|
}
|
|
44
44
|
if ('file' in mockRule) {
|
|
@@ -50,7 +50,7 @@ class MockMiddleware {
|
|
|
50
50
|
}
|
|
51
51
|
res.setHeader('content-length', file.length);
|
|
52
52
|
res.end(file);
|
|
53
|
-
this.logger.info(
|
|
53
|
+
this.logger.info((0, access_log_1.formatAccessLog)('mock', res.statusCode, req));
|
|
54
54
|
return;
|
|
55
55
|
}
|
|
56
56
|
next();
|
|
@@ -61,7 +61,7 @@ class MockMiddleware {
|
|
|
61
61
|
// calling next() would send the request into ProxyMiddleware/httpProxy.web,
|
|
62
62
|
// which would try to write to an already-finished response.
|
|
63
63
|
if (res.headersSent) {
|
|
64
|
-
this.logger.info(
|
|
64
|
+
this.logger.info((0, access_log_1.formatAccessLog)('mock', res.statusCode, req));
|
|
65
65
|
if (!res.writableEnded) {
|
|
66
66
|
res.end();
|
|
67
67
|
}
|
|
@@ -28,7 +28,16 @@ class ProxyMiddleware {
|
|
|
28
28
|
prependPath: !target,
|
|
29
29
|
target: target || proxyOptions.target,
|
|
30
30
|
};
|
|
31
|
-
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
|
+
});
|
|
32
41
|
}
|
|
33
42
|
}
|
|
34
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
|
}
|
|
@@ -24,7 +26,7 @@ class ProxyResMiddleware {
|
|
|
24
26
|
// it sets req.rewriteLogged in both cases) — log here only for plain
|
|
25
27
|
// pass-through, so every proxied request gets exactly one log line.
|
|
26
28
|
if (!req.rewriteLogged) {
|
|
27
|
-
this.logger.info(
|
|
29
|
+
this.logger.info((0, access_log_1.formatAccessLog)('proxy', res.statusCode, req));
|
|
28
30
|
}
|
|
29
31
|
}
|
|
30
32
|
catch (e) {
|
|
@@ -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;
|
|
@@ -62,7 +62,7 @@ class RewriteMiddleware {
|
|
|
62
62
|
*/
|
|
63
63
|
logRewrite(req, proxyRes) {
|
|
64
64
|
req.rewriteLogged = true;
|
|
65
|
-
this.logger.info(
|
|
65
|
+
this.logger.info((0, access_log_1.formatAccessLog)('rewrite', proxyRes.statusCode, req));
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
exports.RewriteMiddleware = RewriteMiddleware;
|
|
@@ -24,6 +24,11 @@ export declare class StaticMiddleware implements Middleware<[req: IncomingMessag
|
|
|
24
24
|
* than enumerating the entire tree just to find one file.
|
|
25
25
|
*/
|
|
26
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
|
+
*/
|
|
27
32
|
private isFile;
|
|
28
33
|
private findCaseInsensitive;
|
|
29
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;
|
|
@@ -37,7 +37,7 @@ class StaticMiddleware {
|
|
|
37
37
|
}
|
|
38
38
|
res.setHeader('content-length', file.length);
|
|
39
39
|
res.end(file);
|
|
40
|
-
this.logger.info(
|
|
40
|
+
this.logger.info((0, access_log_1.formatAccessLog)('static', res.statusCode, req));
|
|
41
41
|
}
|
|
42
42
|
catch (e) {
|
|
43
43
|
this.logger.error(e);
|
|
@@ -75,7 +75,7 @@ class StaticMiddleware {
|
|
|
75
75
|
return undefined;
|
|
76
76
|
}
|
|
77
77
|
const exactPath = path_1.default.join(directory, ...segments);
|
|
78
|
-
if (await this.isFile(exactPath)) {
|
|
78
|
+
if (await this.isFile(exactPath, directory)) {
|
|
79
79
|
return exactPath;
|
|
80
80
|
}
|
|
81
81
|
if (!rule.caseInsensitive) {
|
|
@@ -89,12 +89,25 @@ class StaticMiddleware {
|
|
|
89
89
|
}
|
|
90
90
|
current = path_1.default.join(current, match);
|
|
91
91
|
}
|
|
92
|
-
return (await this.isFile(current)) ? current : undefined;
|
|
92
|
+
return (await this.isFile(current, directory)) ? current : undefined;
|
|
93
93
|
}
|
|
94
|
-
|
|
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) {
|
|
95
100
|
try {
|
|
96
101
|
const stats = await promises_1.default.stat(filePath);
|
|
97
|
-
|
|
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);
|
|
98
111
|
}
|
|
99
112
|
catch {
|
|
100
113
|
return false;
|
|
@@ -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
|
}
|
|
@@ -12,7 +12,7 @@ export interface MethodPathRule {
|
|
|
12
12
|
export declare abstract class MethodPathRulesMatcher<T extends MethodPathRule> {
|
|
13
13
|
protected abstract getRules(): T[];
|
|
14
14
|
match(url: string, method: string): T | undefined;
|
|
15
|
-
params(url: string, rule: T):
|
|
15
|
+
params(url: string, rule: T): Record<string, string> | undefined;
|
|
16
16
|
/**
|
|
17
17
|
* Finds the matching rule and its path params in a single pass — avoids
|
|
18
18
|
* compiling/running the path-to-regexp matcher twice (once via `match()`,
|
|
@@ -20,7 +20,7 @@ export declare abstract class MethodPathRulesMatcher<T extends MethodPathRule> {
|
|
|
20
20
|
*/
|
|
21
21
|
matchWithParams(url: string, method: string): {
|
|
22
22
|
rule: T;
|
|
23
|
-
params:
|
|
23
|
+
params: Record<string, string>;
|
|
24
24
|
} | undefined;
|
|
25
25
|
private find;
|
|
26
26
|
private matchPath;
|
|
@@ -23,7 +23,7 @@ function createServer(config, logger, rewriteRulesMatcher, mockRulesMatcher, rem
|
|
|
23
23
|
// 4. HttpServer wraps the server pipeline
|
|
24
24
|
const server = new http_server_service_1.HttpServer(serverPipeline, logger);
|
|
25
25
|
// 5. Attach proxy pipeline to proxyRes event
|
|
26
|
-
proxy.on('proxyRes', (
|
|
26
|
+
proxy.on('proxyRes', (proxyRes, req, res) => {
|
|
27
27
|
// proxy.execute() is async — an uncaught rejection here would otherwise
|
|
28
28
|
// become an unhandled promise rejection and can crash the whole process,
|
|
29
29
|
// leaving the client hanging with no response.
|
|
@@ -34,7 +34,7 @@ function createServer(config, logger, rewriteRulesMatcher, mockRulesMatcher, rem
|
|
|
34
34
|
res.end();
|
|
35
35
|
}
|
|
36
36
|
});
|
|
37
|
-
})
|
|
37
|
+
});
|
|
38
38
|
// 6. WebSocket upgrade handling
|
|
39
39
|
server.addListener('upgrade', (req, socket, head) => {
|
|
40
40
|
const url = req.url || '';
|
|
@@ -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": {
|