yxorp 0.2.1 → 0.2.3

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.
Files changed (30) hide show
  1. package/README.md +14 -40
  2. package/dist/index.js +52 -6
  3. package/dist/middleware/bootstrap.middleware.js +10 -14
  4. package/dist/middleware/mock.middleware.js +27 -7
  5. package/dist/middleware/proxy.middleware.js +12 -4
  6. package/dist/middleware/proxyRes.middleware.d.ts +1 -1
  7. package/dist/middleware/proxyRes.middleware.js +8 -1
  8. package/dist/middleware/rawBody.middleware.d.ts +4 -1
  9. package/dist/middleware/rawBody.middleware.js +16 -5
  10. package/dist/middleware/rewrite.middleware.d.ts +7 -0
  11. package/dist/middleware/rewrite.middleware.js +16 -2
  12. package/dist/middleware/static.middleware.d.ts +18 -1
  13. package/dist/middleware/static.middleware.js +67 -39
  14. package/dist/services/cli-overrides.d.ts +7 -0
  15. package/dist/services/cli-overrides.js +42 -0
  16. package/dist/services/config-validator.d.ts +10 -0
  17. package/dist/services/config-validator.js +116 -0
  18. package/dist/services/http-server.service.d.ts +3 -1
  19. package/dist/services/http-server.service.js +13 -2
  20. package/dist/services/rules-matchers/method-path-rules-matcher.d.ts +27 -0
  21. package/dist/services/rules-matchers/method-path-rules-matcher.js +50 -0
  22. package/dist/services/rules-matchers/mock-rules-matcher.service.d.ts +3 -3
  23. package/dist/services/rules-matchers/mock-rules-matcher.service.js +5 -26
  24. package/dist/services/rules-matchers/rewrite-rules-matcher.service.d.ts +3 -3
  25. package/dist/services/rules-matchers/rewrite-rules-matcher.service.js +5 -26
  26. package/dist/services/yxorp-server.service.js +24 -7
  27. package/dist/types/yxorp-config.d.ts +0 -1
  28. package/dist/utils/request-timing.d.ts +6 -0
  29. package/dist/utils/request-timing.js +11 -0
  30. package/package.json +1 -1
package/README.md CHANGED
@@ -47,7 +47,7 @@ Yxorp looks for its config in this order:
47
47
  | 2 | `./yxorp.json` | In your project root |
48
48
  | 3 | `./.yxorp/settings.json` | Inside a hidden `.yxorp` directory |
49
49
 
50
- When config lives inside `.yxorp/`, all relative paths (scripts, mock files, static directories) are resolved relative to that directory. This keeps things tidy:
50
+ When config lives inside `.yxorp/`, all relative paths (mock files, rewrite files, static directories) are resolved relative to that directory. This keeps things tidy:
51
51
 
52
52
  ```
53
53
  my-project/
@@ -59,6 +59,14 @@ my-project/
59
59
  logo.svg
60
60
  ```
61
61
 
62
+ ### CLI overrides
63
+
64
+ `--port` and `--target` override the corresponding config values without touching the file — handy for one-off runs or scripting:
65
+
66
+ ```bash
67
+ yxorp --port 4000 --target http://localhost:8080
68
+ ```
69
+
62
70
  ---
63
71
 
64
72
  ## Config Reference
@@ -96,40 +104,6 @@ Extra HTTP headers to send with every proxied request to the target server. Usef
96
104
 
97
105
  ---
98
106
 
99
- ### `scripts`
100
-
101
- Additional JavaScript files loaded **once at startup**. Use them to set up shared data or helpers for your mock/rewrite scripts.
102
-
103
- Why `scripts` and not just `require()` inside each mock file? Because mock scripts are hot-reloaded on every request (their module cache is cleared). Any data they `require()` would also need to be re-imported. Scripts loaded via the `scripts` array sidestep this — they run once at startup and can stash data on `globalThis` for all mock/rewrite scripts to use.
104
-
105
- ```json
106
- "scripts": [
107
- "./scripts/seed-data.js"
108
- ]
109
- ```
110
-
111
- ```javascript
112
- // scripts/seed-data.js — runs once, survives hot-reload
113
- globalThis.users = [
114
- { id: 1, name: 'Alice' },
115
- { id: 2, name: 'Bob' },
116
- ];
117
- ```
118
-
119
- ```javascript
120
- // mock/user.js — hot-reloaded on every request, but seed-data is untouched
121
- module.exports = (req, res) => {
122
- const user = globalThis.users.find(u => u.id === Number(req.params.id));
123
- res.end(JSON.stringify(user));
124
- };
125
- ```
126
-
127
- As a rule of thumb:
128
- - **`scripts`** — for data you initialize once (lookup tables, config, seed data)
129
- - **`require()` inside mock/rewrite files** — for utility helpers you want imported fresh each time
130
-
131
- ---
132
-
133
107
  ### Mock Rules (`mockRules`)
134
108
 
135
109
  Intercept a matching request **before** it reaches the target and respond immediately. The target server never sees it.
@@ -155,7 +129,7 @@ Intercept a matching request **before** it reaches the target and respond immedi
155
129
  }
156
130
  ```
157
131
 
158
- The script receives `req` and `res` and does whatever it wants:
132
+ The script receives the raw Node.js `req` (`IncomingMessage`) and `res` (`ServerResponse`) — **just like writing a tiny backend**. There's no magic layered on top: you're fully responsible for setting the status code, headers, and body yourself, exactly as the real target server would.
159
133
 
160
134
  ```javascript
161
135
  // ./mock/create-user.js
@@ -166,6 +140,10 @@ module.exports = (req, res) => {
166
140
  };
167
141
  ```
168
142
 
143
+ If you don't set `res.statusCode`, it defaults to Node's `200`. If you don't call `res.end()`, the response hangs — Yxorp won't do it for you. The only assist it provides: if the script finishes without sending headers, Yxorp sets `content-type: application/json` for you (handy for quick `res.end(JSON.stringify(...))` one-liners).
144
+
145
+ This mirrors how a real backend works on purpose — mock scripts are meant to **stand in for the target server**, so the same rules apply: you decide what the client receives, down to the last header.
146
+
169
147
  **Hot-reload is built-in** — edit the script file and the next request picks up changes automatically. No restart needed.
170
148
 
171
149
  #### Disable a rule:
@@ -295,10 +273,6 @@ Here's a complete config showing all features in action:
295
273
  "target": "https://api.example.com",
296
274
  "proxyPort": 3000,
297
275
 
298
- "scripts": [
299
- "./scripts/globals.js"
300
- ],
301
-
302
276
  "staticRules": [
303
277
  {
304
278
  "path": "/assets",
package/dist/index.js CHANGED
@@ -2,8 +2,8 @@
2
2
  var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
- var _a;
6
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
+ const fs_1 = __importDefault(require("fs"));
7
7
  const path_1 = __importDefault(require("path"));
8
8
  const config_service_1 = require("./services/config.service");
9
9
  const logger_service_1 = require("./services/logger.service");
@@ -13,6 +13,15 @@ const mock_rules_matcher_service_1 = require("./services/rules-matchers/mock-rul
13
13
  const yxorp_server_service_1 = require("./services/yxorp-server.service");
14
14
  const config_resolver_1 = require("./services/config-resolver");
15
15
  const config_watcher_1 = require("./services/config-watcher");
16
+ const config_validator_1 = require("./services/config-validator");
17
+ const cli_overrides_1 = require("./services/cli-overrides");
18
+ // --- Version flag ---
19
+ if (process.argv.includes('--version') || process.argv.includes('-v')) {
20
+ const pkgPath = path_1.default.join(__dirname, '../package.json');
21
+ const pkg = JSON.parse(fs_1.default.readFileSync(pkgPath, 'utf-8'));
22
+ console.log(pkg.version);
23
+ process.exit(0);
24
+ }
16
25
  // --- Config resolution ---
17
26
  let configDir;
18
27
  let configPath;
@@ -27,6 +36,15 @@ catch (e) {
27
36
  console.error(e.message || e);
28
37
  process.exit(1);
29
38
  }
39
+ // --- CLI overrides ---
40
+ // e.g. `yxorp --port 4000 --target http://localhost:8080` — flags win over config values.
41
+ config = (0, cli_overrides_1.applyCliOverrides)(config, process.argv);
42
+ const configErrors = (0, config_validator_1.validateConfig)(config);
43
+ if (configErrors.length) {
44
+ console.error('Invalid config:');
45
+ configErrors.forEach(err => console.error(` - ${err}`));
46
+ process.exit(1);
47
+ }
30
48
  // Change CWD to config directory so relative paths in config work correctly
31
49
  if (configDir !== process.cwd()) {
32
50
  process.chdir(configDir);
@@ -45,9 +63,6 @@ function buildProxyConfig(cfg) {
45
63
  }
46
64
  return { ...cfg, proxyOptions };
47
65
  }
48
- (_a = config === null || config === void 0 ? void 0 : config.scripts) === null || _a === void 0 ? void 0 : _a.forEach(script => {
49
- require(path_1.default.resolve(script));
50
- });
51
66
  // Manual composition — no DI container
52
67
  const logger = new logger_service_1.LoggerService();
53
68
  const appConfig = new config_service_1.Config();
@@ -55,12 +70,43 @@ appConfig.set(buildProxyConfig(config));
55
70
  const mockRulesMatcher = new mock_rules_matcher_service_1.MockRulesMatcher(appConfig);
56
71
  const rewriteRulesMatcher = new rewrite_rules_matcher_service_1.RewriteRulesMatcher(appConfig);
57
72
  const remoteRulesMatcher = new remote_rules_matcher_service_1.RemoteRulesMatcher(appConfig);
58
- const { listen } = (0, yxorp_server_service_1.createServer)(appConfig, logger, rewriteRulesMatcher, mockRulesMatcher, remoteRulesMatcher);
73
+ const { server, listen } = (0, yxorp_server_service_1.createServer)(appConfig, logger, rewriteRulesMatcher, mockRulesMatcher, remoteRulesMatcher);
59
74
  listen(config.proxyPort, () => {
60
75
  console.log(`Yxorp server started successfully on http://localhost:${config.proxyPort}`);
61
76
  });
62
77
  // --- Config hot-reload ---
63
- (0, config_watcher_1.watchConfig)(configPath, (newConfig) => {
78
+ const configWatcher = (0, config_watcher_1.watchConfig)(configPath, (newConfig) => {
79
+ const errors = (0, config_validator_1.validateConfig)(newConfig);
80
+ if (errors.length) {
81
+ logger.error('Config reload skipped — invalid config:');
82
+ errors.forEach(err => logger.error(` - ${err}`));
83
+ return;
84
+ }
64
85
  appConfig.set(buildProxyConfig(newConfig));
65
86
  logger.info('Config reloaded');
66
87
  }, (e) => logger.error(`Config reload failed: ${e.message || e}`));
88
+ // --- Graceful shutdown ---
89
+ const SHUTDOWN_TIMEOUT_MS = 5000;
90
+ let shuttingDown = false;
91
+ function shutdown(signal) {
92
+ if (shuttingDown)
93
+ return;
94
+ shuttingDown = true;
95
+ logger.info(`Received ${signal}, shutting down...`);
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();
106
+ server.close(() => {
107
+ clearTimeout(forceExitTimer);
108
+ process.exit(0);
109
+ });
110
+ }
111
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
112
+ process.on('SIGINT', () => shutdown('SIGINT'));
@@ -27,26 +27,22 @@ class BootstrapMiddleware {
27
27
  if (!req.url || !req.method) {
28
28
  return;
29
29
  }
30
- const rewriteRule = this.rewriteRulesMatcher.match(req.url, req.method);
31
- if (rewriteRule) {
32
- req.rewriteRule = rewriteRule;
33
- const rewriteRuleParams = this.rewriteRulesMatcher.params(req.url, rewriteRule);
34
- if (rewriteRuleParams) {
35
- req.rewriteRuleParams = rewriteRuleParams || {};
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 mockRule = this.mockRulesMatcher.match(req.url, req.method);
44
- if (mockRule) {
45
- req.mockRule = mockRule;
46
- const mockRuleParams = this.mockRulesMatcher.params(req.url, mockRule);
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,6 +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 request_timing_1 = require("../utils/request-timing");
10
11
  class MockMiddleware {
11
12
  constructor(logger) {
12
13
  this.logger = logger;
@@ -22,13 +23,22 @@ class MockMiddleware {
22
23
  const fullPath = path_1.default.resolve(mockRule.script);
23
24
  delete require.cache[require.resolve(fullPath)];
24
25
  const handler = require(fullPath);
25
- if (typeof handler === 'function') {
26
- await handler(req, res);
27
- if (!res.headersSent) {
28
- res.setHeader('content-type', 'application/json');
29
- }
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(`mock ${res.statusCode} ${req.method} ${req.url} ${(0, request_timing_1.elapsedMs)(req)}ms`);
35
+ return;
30
36
  }
31
- this.logger.info(`mock ${res.statusCode || 200} ${req.method} ${req.url}`);
37
+ await handler(req, res);
38
+ if (!res.headersSent) {
39
+ res.setHeader('content-type', 'application/json');
40
+ }
41
+ this.logger.info(`mock ${res.statusCode || 200} ${req.method} ${req.url} ${(0, request_timing_1.elapsedMs)(req)}ms`);
32
42
  return;
33
43
  }
34
44
  if ('file' in mockRule) {
@@ -40,13 +50,23 @@ class MockMiddleware {
40
50
  }
41
51
  res.setHeader('content-length', file.length);
42
52
  res.end(file);
43
- this.logger.info(`mock ${res.statusCode} ${req.method} ${req.url}`);
53
+ this.logger.info(`mock ${res.statusCode} ${req.method} ${req.url} ${(0, request_timing_1.elapsedMs)(req)}ms`);
44
54
  return;
45
55
  }
46
56
  next();
47
57
  }
48
58
  catch (e) {
49
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(`mock ${res.statusCode} ${req.method} ${req.url} ${(0, request_timing_1.elapsedMs)(req)}ms`);
65
+ if (!res.writableEnded) {
66
+ res.end();
67
+ }
68
+ return;
69
+ }
50
70
  next();
51
71
  }
52
72
  }
@@ -11,10 +11,18 @@ class ProxyMiddleware {
11
11
  use(req, res) {
12
12
  const url = req.url || '';
13
13
  const proxyOptions = this.config.get().proxyOptions;
14
- const remoteRule = this.remoteRulesMatcher.match(url);
15
- const target = remoteRule
16
- ? this.remoteRulesMatcher.toPath(url, remoteRule)
17
- : undefined;
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,
@@ -5,5 +5,5 @@ import { LoggerService } from '../services/logger.service';
5
5
  export declare class ProxyResMiddleware implements Middleware<[proxyRes: IncomingMessage, req: IncomingMessage, res: ServerResponse]> {
6
6
  private logger;
7
7
  constructor(logger: LoggerService);
8
- use(proxyRes: IncomingMessage, _req: IncomingMessage, res: ServerResponse): void;
8
+ use(proxyRes: IncomingMessage, req: IncomingMessage, res: ServerResponse): void;
9
9
  }
@@ -1,11 +1,12 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.ProxyResMiddleware = void 0;
4
+ const request_timing_1 = require("../utils/request-timing");
4
5
  class ProxyResMiddleware {
5
6
  constructor(logger) {
6
7
  this.logger = logger;
7
8
  }
8
- use(proxyRes, _req, res) {
9
+ use(proxyRes, req, res) {
9
10
  try {
10
11
  // Skip transfer-encoding since we reconstruct the body with known length
11
12
  for (let key in proxyRes.headers) {
@@ -19,6 +20,12 @@ class ProxyResMiddleware {
19
20
  res.removeHeader('content-length');
20
21
  res.setHeader('content-length', response.length);
21
22
  res.end(response);
23
+ // RewriteMiddleware already logs rewritten responses (success AND failure —
24
+ // it sets req.rewriteLogged in both cases) — log here only for plain
25
+ // pass-through, so every proxied request gets exactly one log line.
26
+ if (!req.rewriteLogged) {
27
+ this.logger.info(`proxy ${res.statusCode} ${req.method} ${req.url} ${(0, request_timing_1.elapsedMs)(req)}ms`);
28
+ }
22
29
  }
23
30
  catch (e) {
24
31
  this.logger.error(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
- use(proxyRes: IncomingMessage, _req: IncomingMessage, _res: ServerResponse, next: () => void): Promise<void>;
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
- use(proxyRes, _req, _res, next) {
6
- return new Promise((resolve, reject) => {
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
- proxyRes.rawBody = Buffer.concat(body);
18
- next();
19
- reject(err);
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,6 +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 request_timing_1 = require("../utils/request-timing");
10
11
  class RewriteMiddleware {
11
12
  constructor(logger) {
12
13
  this.logger = logger;
@@ -29,7 +30,7 @@ class RewriteMiddleware {
29
30
  const rewritedResponse = await handler(encodedResponse, proxyRes, req, res);
30
31
  proxyRes.rawBody = await (0, http_encoding_1.encodeBuffer)(rewritedResponse, encoding);
31
32
  }
32
- this.logger.info(`rewrite ${proxyRes.statusCode} ${req.method} ${req.url}`);
33
+ this.logRewrite(req, proxyRes);
33
34
  next();
34
35
  return;
35
36
  }
@@ -39,7 +40,7 @@ class RewriteMiddleware {
39
40
  if (rewriteRule.statusCode) {
40
41
  proxyRes.statusCode = rewriteRule.statusCode;
41
42
  }
42
- this.logger.info(`rewrite ${proxyRes.statusCode} ${req.method} ${req.url}`);
43
+ this.logRewrite(req, proxyRes);
43
44
  next();
44
45
  return;
45
46
  }
@@ -47,8 +48,21 @@ class RewriteMiddleware {
47
48
  }
48
49
  catch (e) {
49
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);
50
54
  next();
51
55
  }
52
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(`rewrite ${proxyRes.statusCode} ${req.method} ${req.url} ${(0, request_timing_1.elapsedMs)(req)}ms`);
66
+ }
53
67
  }
54
68
  exports.RewriteMiddleware = RewriteMiddleware;
@@ -8,5 +8,22 @@ 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
- private readdir;
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
+ private isFile;
28
+ private findCaseInsensitive;
12
29
  }
@@ -7,6 +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 request_timing_1 = require("../utils/request-timing");
10
11
  class StaticMiddleware {
11
12
  constructor(config, logger) {
12
13
  this.config = config;
@@ -17,10 +18,6 @@ class StaticMiddleware {
17
18
  const staticRules = this.config.get().staticRules || [];
18
19
  const url = new URL(req.url || '', 'http://fake.com');
19
20
  const urlPath = url.pathname;
20
- if (!staticRules) {
21
- next();
22
- return;
23
- }
24
21
  const currentStaticRule = staticRules.filter((staticRule) => {
25
22
  return urlPath.startsWith(staticRule.path);
26
23
  })[0];
@@ -28,58 +25,89 @@ class StaticMiddleware {
28
25
  next();
29
26
  return;
30
27
  }
31
- const pathToDirectory = path_1.default.resolve(currentStaticRule.directory);
32
- const dirents = await this.readdir(pathToDirectory);
33
- const filePath = dirents
34
- .filter(dirent => !dirent.isDirectory())
35
- .map(dirent => ({
36
- urlPath: path_1.default.join(currentStaticRule.path, path_1.default.join(dirent.path, dirent.name).replace(pathToDirectory, '')).replace(/\\/g, '/'),
37
- path: path_1.default.join(dirent.path, dirent.name).replace(/\\/g, '/'),
38
- }))
39
- .filter(dirent => {
40
- const pathname = path_1.default.extname(urlPath)
41
- ? urlPath
42
- : path_1.default.join(urlPath, currentStaticRule.directoryIndex || '').replace(/\\/g, '/');
43
- if (currentStaticRule.caseInsensitive) {
44
- return dirent.urlPath.toLocaleLowerCase() === pathname.toLowerCase();
45
- }
46
- else {
47
- return dirent.urlPath === pathname;
48
- }
49
- })
50
- .map(dirent => dirent.path)[0];
28
+ const filePath = await this.resolveFile(currentStaticRule, urlPath);
51
29
  if (!filePath) {
52
30
  next();
53
31
  return;
54
32
  }
55
- const file = await promises_1.default.readFile(path_1.default.resolve(filePath));
56
- const mimeType = mime_1.default.getType(path_1.default.resolve(filePath));
33
+ const file = await promises_1.default.readFile(filePath);
34
+ const mimeType = mime_1.default.getType(filePath);
57
35
  if (mimeType) {
58
36
  res.setHeader('content-type', mimeType);
59
37
  }
60
38
  res.setHeader('content-length', file.length);
61
39
  res.end(file);
62
- this.logger.info(`static ${res.statusCode} ${req.method} ${req.url}`);
40
+ this.logger.info(`static ${res.statusCode} ${req.method} ${req.url} ${(0, request_timing_1.elapsedMs)(req)}ms`);
63
41
  }
64
42
  catch (e) {
65
43
  this.logger.error(e);
66
44
  next();
67
45
  }
68
46
  }
69
- async readdir(pathname, subpathname) {
70
- const files = [];
71
- const fullpath = path_1.default.join(pathname, subpathname || '');
72
- const dirents = await promises_1.default.readdir(fullpath, {
73
- withFileTypes: true,
74
- });
75
- for (let dirent of dirents) {
76
- dirent.path = fullpath;
77
- files.push(dirent);
78
- if (dirent.isDirectory()) {
79
- files.push(...await this.readdir(fullpath, dirent.name));
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)) {
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;
80
89
  }
90
+ current = path_1.default.join(current, match);
91
+ }
92
+ return (await this.isFile(current)) ? current : undefined;
93
+ }
94
+ async isFile(filePath) {
95
+ try {
96
+ const stats = await promises_1.default.stat(filePath);
97
+ return stats.isFile();
98
+ }
99
+ catch {
100
+ return false;
101
+ }
102
+ }
103
+ async findCaseInsensitive(directory, name) {
104
+ try {
105
+ const entries = await promises_1.default.readdir(directory);
106
+ return entries.find(entry => entry.toLowerCase() === name.toLowerCase());
107
+ }
108
+ catch {
109
+ return undefined;
81
110
  }
82
- return files;
83
111
  }
84
112
  }
85
113
  exports.StaticMiddleware = StaticMiddleware;
@@ -0,0 +1,7 @@
1
+ import { ConfigFile } from '../types/yxorp-config';
2
+ /**
3
+ * Applies `--port`/`--target` CLI flags on top of a resolved config, without
4
+ * touching the config file. CLI flags win over config values. Returns a new
5
+ * object — the input config is left untouched.
6
+ */
7
+ export declare function applyCliOverrides(config: ConfigFile, argv: string[]): ConfigFile;
@@ -0,0 +1,42 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
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
+ */
11
+ function getArgValue(argv, flag) {
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;
24
+ }
25
+ /**
26
+ * Applies `--port`/`--target` CLI flags on top of a resolved config, without
27
+ * touching the config file. CLI flags win over config values. Returns a new
28
+ * object — the input config is left untouched.
29
+ */
30
+ function applyCliOverrides(config, argv) {
31
+ const portOverride = getArgValue(argv, '--port');
32
+ const targetOverride = getArgValue(argv, '--target');
33
+ if (portOverride === undefined && targetOverride === undefined) {
34
+ return config;
35
+ }
36
+ return {
37
+ ...config,
38
+ ...(portOverride !== undefined ? { proxyPort: portOverride } : {}),
39
+ ...(targetOverride !== undefined ? { target: targetOverride } : {}),
40
+ };
41
+ }
42
+ exports.applyCliOverrides = applyCliOverrides;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Validates a parsed config object and returns a list of human-readable
3
+ * error messages. An empty array means the config is valid.
4
+ *
5
+ * Intentionally permissive — it only checks the shape of fields that would
6
+ * otherwise cause confusing crashes deeper in the pipeline (e.g. a missing
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()`).
9
+ */
10
+ export declare function validateConfig(config: any): string[];
@@ -0,0 +1,116 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.validateConfig = void 0;
4
+ const path_to_regexp_1 = require("path-to-regexp");
5
+ const RULE_ARRAYS = ['remoteRules', 'staticRules', 'mockRules', 'rewriteRules'];
6
+ const MAX_PORT = 65535;
7
+ /**
8
+ * Validates a parsed config object and returns a list of human-readable
9
+ * error messages. An empty array means the config is valid.
10
+ *
11
+ * Intentionally permissive — it only checks the shape of fields that would
12
+ * otherwise cause confusing crashes deeper in the pipeline (e.g. a missing
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()`).
15
+ */
16
+ function validateConfig(config) {
17
+ const errors = [];
18
+ if (!config || typeof config !== 'object' || Array.isArray(config)) {
19
+ return ['Config must be a JSON object'];
20
+ }
21
+ if (typeof config.target !== 'string' || !config.target.trim()) {
22
+ errors.push('"target" is required and must be a non-empty string (e.g. "https://api.example.com")');
23
+ }
24
+ const { proxyPort } = config;
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);
27
+ if (!isValidPort) {
28
+ errors.push(`"proxyPort" is required and must be a number or numeric string between 0 and ${MAX_PORT} (e.g. 3000)`);
29
+ }
30
+ if (config.proxyHeaders !== undefined &&
31
+ (typeof config.proxyHeaders !== 'object' || config.proxyHeaders === null || Array.isArray(config.proxyHeaders))) {
32
+ errors.push('"proxyHeaders" must be an object of string key-value pairs');
33
+ }
34
+ for (const key of RULE_ARRAYS) {
35
+ const rules = config[key];
36
+ if (rules === undefined) {
37
+ continue;
38
+ }
39
+ if (!Array.isArray(rules)) {
40
+ errors.push(`"${key}" must be an array`);
41
+ continue;
42
+ }
43
+ rules.forEach((rule, index) => {
44
+ errors.push(...validateRule(key, rule, index));
45
+ });
46
+ }
47
+ return errors;
48
+ }
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
+ }
@@ -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,10 +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
- this.pipeline.execute(req, res);
13
+ req.startTime = Date.now();
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
+ });
13
24
  });
14
25
  this.use = this.pipeline.use.bind(this.pipeline);
15
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): Object | 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: Object;
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
- export declare class MockRulesMatcher {
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
- match(url: string, method: string): MockRule | undefined;
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 path_to_regexp_1 = require("path-to-regexp");
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
- match(url, method) {
10
- const mockRules = this.config.get().mockRules || [];
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
- export declare class RewriteRulesMatcher {
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
- match(url: string, method: string): RewriteRule | undefined;
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 path_to_regexp_1 = require("path-to-regexp");
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
- match(url, method) {
10
- const rewriteRules = this.config.get().rewriteRules || [];
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
26
  proxy.on('proxyRes', ((proxyRes, req, res) => {
27
- proxy.execute(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
+ });
28
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
- const remoteRule = remoteRulesMatcher.match(url, true);
34
- const target = remoteRule
35
- ? remoteRulesMatcher.toPath(url, remoteRule)
36
- : undefined;
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,
@@ -4,7 +4,6 @@ export interface YxorpConfig extends ConfigFile {
4
4
  export interface ConfigFile {
5
5
  target: string;
6
6
  proxyPort: string | number;
7
- scripts?: string[];
8
7
  proxyHeaders?: Record<string, string>;
9
8
  remoteRules?: RemoteRule[];
10
9
  staticRules?: StaticRule[];
@@ -0,0 +1,6 @@
1
+ import { IncomingMessage } from 'http';
2
+ /**
3
+ * Milliseconds elapsed since the request started (set by HttpServer on arrival).
4
+ * Falls back to 0 if startTime wasn't recorded for some reason.
5
+ */
6
+ export declare function elapsedMs(req: IncomingMessage): number;
@@ -0,0 +1,11 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.elapsedMs = void 0;
4
+ /**
5
+ * Milliseconds elapsed since the request started (set by HttpServer on arrival).
6
+ * Falls back to 0 if startTime wasn't recorded for some reason.
7
+ */
8
+ function elapsedMs(req) {
9
+ return req.startTime !== undefined ? Date.now() - req.startTime : 0;
10
+ }
11
+ exports.elapsedMs = elapsedMs;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yxorp",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "A local reverse proxy for rewriting, mocking, and debugging API responses.",
5
5
  "files": [
6
6
  "dist/",