@stuntman/server 0.1.1 → 0.1.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.
package/README.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Stuntman
2
2
 
3
+ Stuntman is a proxy/mock server that can be deployed remotely together with your application under test, working as either pass-through proxy allowing you to inspect traffic or proxy/mock which can intercept requests/responses and modify them or stub with predefined ones.
4
+
5
+ It offers API and client library that can be used for example within E2E functional test scripts to dynamically alter it's behaviour for specific traffic matching set of rules of your definition.
6
+
7
+ In order to get more familiar with the concept and how to use it please refer to [example app](https://github.com/andrzej-woof/stuntman/tree/master/apps/example#readme)
8
+
9
+ > **_NOTE:_** This project is at a very early stage of developement and as such may often contain breaking changes in upcoming releases before reaching stable version 1.0.0
10
+
3
11
  ## Building from source
4
12
 
5
13
  ### Prerequisites
@@ -22,6 +30,7 @@ pnpm stuntman
22
30
  ## Configuration
23
31
 
24
32
  Stuntman uses [config](https://github.com/node-config/node-config)
33
+
25
34
  You can create `config/default.json` with settings of your liking matching `ServerConfig` type
26
35
 
27
36
  ## Running as a package
@@ -68,8 +77,6 @@ for local playground you can also use `http://www.example.com.localhost:2015`
68
77
 
69
78
  ### Take a look at client
70
79
 
71
- Take a look at `./src/clientTestExample.ts`, you can use it to set up some rules
72
-
73
80
  Mind the scope of `Stuntman.RemotableFunction` like `matches`, `modifyRequest`, `modifyResponse`.
74
81
  `Stuntman.RemotableFunction.localFn` contains the function, but since it'll be executed on a remote mock server it cannot access any variables outside it's body. In order to pass variable values into the function use `Stuntman.RemotableFunction.variables` for example:
75
82
 
package/dist/api/api.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /// <reference types="node" />
2
2
  import http from 'http';
3
- import { Express as ExpressServer } from 'express';
3
+ import { NextFunction, Request, Response, Express as ExpressServer } from 'express';
4
4
  import type * as Stuntman from '@stuntman/shared';
5
5
  import LRUCache from 'lru-cache';
6
6
  type ApiOptions = Stuntman.ApiConfig & {
@@ -11,6 +11,9 @@ export declare class API {
11
11
  protected apiApp: ExpressServer;
12
12
  trafficStore: LRUCache<string, Stuntman.LogEntry>;
13
13
  server: http.Server | null;
14
+ auth: (req: Request, type: 'read' | 'write') => void;
15
+ authReadOnly: (req: Request, res: Response, next: NextFunction) => void;
16
+ authReadWrite: (req: Request, res: Response, next: NextFunction) => void;
14
17
  constructor(options: ApiOptions, webGuiOptions?: Stuntman.WebGuiConfig);
15
18
  private initWebGui;
16
19
  start(): void;
package/dist/api/api.js CHANGED
@@ -11,54 +11,75 @@ const ruleExecutor_1 = require("../ruleExecutor");
11
11
  const shared_1 = require("@stuntman/shared");
12
12
  const requestContext_1 = __importDefault(require("../requestContext"));
13
13
  const serialize_javascript_1 = __importDefault(require("serialize-javascript"));
14
- const validatiors_1 = require("./validatiors");
14
+ const validators_1 = require("./validators");
15
15
  const utils_1 = require("./utils");
16
+ const API_KEY_HEADER = 'x-api-key';
16
17
  class API {
17
18
  constructor(options, webGuiOptions) {
18
19
  this.server = null;
20
+ if (!options.apiKeyReadOnly !== !options.apiKeyReadWrite) {
21
+ throw new Error('apiKeyReadOnly and apiKeyReadWrite options need to be set either both or none');
22
+ }
19
23
  this.options = options;
20
24
  this.trafficStore = (0, storage_1.getTrafficStore)(this.options.mockUuid);
21
25
  this.apiApp = (0, express_1.default)();
22
26
  this.apiApp.use(express_1.default.json());
23
27
  this.apiApp.use(express_1.default.text());
28
+ this.auth = (req, type) => {
29
+ const hasValidReadKey = req.header(API_KEY_HEADER) === this.options.apiKeyReadOnly;
30
+ const hasValidWriteKey = req.header(API_KEY_HEADER) === this.options.apiKeyReadWrite;
31
+ const hasValidKey = type === 'read' ? hasValidReadKey || hasValidWriteKey : hasValidWriteKey;
32
+ if (!hasValidKey) {
33
+ throw new shared_1.AppError({ httpCode: shared_1.HttpCode.UNAUTHORIZED, message: 'unauthorized' });
34
+ }
35
+ return;
36
+ };
37
+ this.authReadOnly = (req, res, next) => {
38
+ this.auth(req, 'read');
39
+ next();
40
+ };
41
+ this.authReadWrite = (req, res, next) => {
42
+ this.auth(req, 'write');
43
+ next();
44
+ };
24
45
  this.apiApp.use((req, res, next) => {
25
46
  requestContext_1.default.bind(req, this.options.mockUuid);
26
47
  next();
27
48
  });
28
- this.apiApp.get('/rule', async (req, res) => {
29
- res.send((0, shared_1.stringify)(await ruleExecutor_1.ruleExecutor.getRules()));
49
+ this.apiApp.get('/rule', this.authReadOnly, async (req, res) => {
50
+ res.send((0, shared_1.stringify)(await (0, ruleExecutor_1.getRuleExecutor)(this.options.mockUuid).getRules()));
30
51
  });
31
- this.apiApp.get('/rule/:ruleId', async (req, res) => {
32
- res.send((0, shared_1.stringify)(await ruleExecutor_1.ruleExecutor.getRule(req.params.ruleId)));
52
+ this.apiApp.get('/rule/:ruleId', this.authReadOnly, async (req, res) => {
53
+ res.send((0, shared_1.stringify)(await (0, ruleExecutor_1.getRuleExecutor)(this.options.mockUuid).getRule(req.params.ruleId)));
33
54
  });
34
- this.apiApp.get('/rule/:ruleId/disable', (req, res) => {
35
- ruleExecutor_1.ruleExecutor.disableRule(req.params.ruleId);
55
+ this.apiApp.get('/rule/:ruleId/disable', this.authReadWrite, (req, res) => {
56
+ (0, ruleExecutor_1.getRuleExecutor)(this.options.mockUuid).disableRule(req.params.ruleId);
36
57
  res.send();
37
58
  });
38
- this.apiApp.get('/rule/:ruleId/enable', (req, res) => {
39
- ruleExecutor_1.ruleExecutor.enableRule(req.params.ruleId);
59
+ this.apiApp.get('/rule/:ruleId/enable', this.authReadWrite, (req, res) => {
60
+ (0, ruleExecutor_1.getRuleExecutor)(this.options.mockUuid).enableRule(req.params.ruleId);
40
61
  res.send();
41
62
  });
42
- this.apiApp.post('/rule', async (req, res) => {
63
+ this.apiApp.post('/rule', this.authReadWrite, async (req, res) => {
43
64
  const deserializedRule = (0, utils_1.deserializeRule)(req.body);
44
- (0, validatiors_1.validateDeserializedRule)(deserializedRule);
65
+ (0, validators_1.validateDeserializedRule)(deserializedRule);
45
66
  // eslint-disable-next-line @typescript-eslint/ban-ts-comment
46
67
  // @ts-ignore
47
- const rule = await ruleExecutor_1.ruleExecutor.addRule(deserializedRule);
68
+ const rule = await (0, ruleExecutor_1.getRuleExecutor)(this.options.mockUuid).addRule(deserializedRule);
48
69
  res.send((0, shared_1.stringify)(rule));
49
70
  });
50
- this.apiApp.get('/rule/:ruleId/remove', async (req, res) => {
51
- await ruleExecutor_1.ruleExecutor.removeRule(req.params.ruleId);
71
+ this.apiApp.get('/rule/:ruleId/remove', this.authReadWrite, async (req, res) => {
72
+ await (0, ruleExecutor_1.getRuleExecutor)(this.options.mockUuid).removeRule(req.params.ruleId);
52
73
  res.send();
53
74
  });
54
- this.apiApp.get('/traffic', (req, res) => {
75
+ this.apiApp.get('/traffic', this.authReadOnly, (req, res) => {
55
76
  const serializedTraffic = {};
56
77
  for (const [key, value] of this.trafficStore.entries()) {
57
78
  serializedTraffic[key] = value;
58
79
  }
59
80
  res.json(serializedTraffic);
60
81
  });
61
- this.apiApp.get('/traffic/:ruleIdOrLabel', (req, res) => {
82
+ this.apiApp.get('/traffic/:ruleIdOrLabel', this.authReadOnly, (req, res) => {
62
83
  const serializedTraffic = {};
63
84
  for (const [key, value] of this.trafficStore.entries()) {
64
85
  if (value.mockRuleId === req.params.ruleIdOrLabel || (value.labels || []).includes(req.params.ruleIdOrLabel)) {
@@ -67,7 +88,13 @@ class API {
67
88
  }
68
89
  res.json(serializedTraffic);
69
90
  });
70
- this.apiApp.use((error, req, res, _next) => {
91
+ if (!(webGuiOptions === null || webGuiOptions === void 0 ? void 0 : webGuiOptions.disabled)) {
92
+ this.apiApp.set('views', __dirname + '/webgui');
93
+ this.apiApp.set('view engine', 'pug');
94
+ this.initWebGui();
95
+ }
96
+ this.apiApp.all(/.*/, (req, res) => res.status(404).send());
97
+ this.apiApp.use((error, req, res) => {
71
98
  const ctx = requestContext_1.default.get(req);
72
99
  const uuid = (ctx === null || ctx === void 0 ? void 0 : ctx.uuid) || (0, uuid_1.v4)();
73
100
  if (error instanceof shared_1.AppError && error.isOperational && res) {
@@ -84,24 +111,20 @@ class API {
84
111
  });
85
112
  return;
86
113
  }
87
- console.log('Application encountered a critical error. Exiting');
114
+ // eslint-disable-next-line no-console
115
+ console.log('API server encountered a critical error. Exiting');
88
116
  process.exit(1);
89
117
  });
90
- this.apiApp.set('views', __dirname + '/webgui');
91
- this.apiApp.set('view engine', 'pug');
92
- if (!(webGuiOptions === null || webGuiOptions === void 0 ? void 0 : webGuiOptions.disabled)) {
93
- this.initWebGui();
94
- }
95
118
  }
96
119
  initWebGui() {
97
- this.apiApp.get('/webgui/rules', async (req, res) => {
120
+ this.apiApp.get('/webgui/rules', this.authReadOnly, async (req, res) => {
98
121
  const rules = {};
99
- for (const rule of await ruleExecutor_1.ruleExecutor.getRules()) {
122
+ for (const rule of await (0, ruleExecutor_1.getRuleExecutor)(this.options.mockUuid).getRules()) {
100
123
  rules[rule.id] = (0, serialize_javascript_1.default)((0, utils_1.liveRuleToRule)(rule), { unsafe: true });
101
124
  }
102
125
  res.render('rules', { rules: (0, utils_1.escapedSerialize)(rules), INDEX_DTS: shared_1.INDEX_DTS, ruleKeys: Object.keys(rules) });
103
126
  });
104
- this.apiApp.get('/webgui/traffic', async (req, res) => {
127
+ this.apiApp.get('/webgui/traffic', this.authReadOnly, async (req, res) => {
105
128
  const serializedTraffic = [];
106
129
  for (const value of this.trafficStore.values()) {
107
130
  serializedTraffic.push(value);
@@ -111,7 +134,7 @@ class API {
111
134
  });
112
135
  });
113
136
  // TODO make webui way better and safer, nicer formatting, eslint/prettier, blackjack and hookers
114
- this.apiApp.post('/webgui/rules/unsafesave', async (req, res) => {
137
+ this.apiApp.post('/webgui/rules/unsafesave', this.authReadWrite, async (req, res) => {
115
138
  const rule = new Function(req.body)();
116
139
  if (!rule ||
117
140
  !rule.id ||
@@ -120,7 +143,7 @@ class API {
120
143
  rule.ttlSeconds > shared_1.MAX_RULE_TTL_SECONDS) {
121
144
  throw new shared_1.AppError({ httpCode: shared_1.HttpCode.BAD_REQUEST, message: 'Invalid rule' });
122
145
  }
123
- await ruleExecutor_1.ruleExecutor.addRule({
146
+ await (0, ruleExecutor_1.getRuleExecutor)(this.options.mockUuid).addRule({
124
147
  id: rule.id,
125
148
  matches: rule.matches,
126
149
  ttlSeconds: rule.ttlSeconds,
package/dist/api/utils.js CHANGED
@@ -6,11 +6,11 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.liveRuleToRule = exports.escapedSerialize = exports.deserializeRule = void 0;
7
7
  const serialize_javascript_1 = __importDefault(require("serialize-javascript"));
8
8
  const shared_1 = require("@stuntman/shared");
9
- const validatiors_1 = require("./validatiors");
9
+ const validators_1 = require("./validators");
10
10
  // TODO
11
11
  const deserializeRule = (serializedRule) => {
12
12
  shared_1.logger.debug(serializedRule, 'attempt to deserialize rule');
13
- (0, validatiors_1.validateSerializedRuleProperties)(serializedRule);
13
+ (0, validators_1.validateSerializedRuleProperties)(serializedRule);
14
14
  const rule = {
15
15
  id: serializedRule.id,
16
16
  matches: (req) => new Function('____arg0', serializedRule.matches.remoteFn)(req),
@@ -104,7 +104,7 @@ const validateDeserializedRule = (deserializedRule) => {
104
104
  shared_1.logger.error({ ruleId: deserializedRule.id }, error);
105
105
  throw new shared_1.AppError({
106
106
  httpCode: shared_1.HttpCode.UNPROCESSABLE_ENTITY,
107
- message: 'match function returned invalid value',
107
+ message: 'match function threw an error',
108
108
  });
109
109
  }
110
110
  if (matchValidationResult !== true &&
package/dist/mock.d.ts CHANGED
@@ -8,7 +8,7 @@ import { IPUtils } from './ipUtils';
8
8
  import LRUCache from 'lru-cache';
9
9
  import { API } from './api/api';
10
10
  export declare class Mock {
11
- protected mockUuid: string;
11
+ readonly mockUuid: string;
12
12
  protected options: Stuntman.ServerConfig;
13
13
  protected mockApp: express.Express;
14
14
  protected MOCK_DOMAIN_REGEX: RegExp;
@@ -19,7 +19,10 @@ export declare class Mock {
19
19
  protected ipUtils: IPUtils | null;
20
20
  private _api;
21
21
  get apiServer(): API | null;
22
+ get ruleExecutor(): Stuntman.RuleExecutorInterface;
22
23
  constructor(options: Stuntman.ServerConfig);
24
+ private proxyRequest;
25
+ private requestHandler;
23
26
  start(): void;
24
27
  stop(): void;
25
28
  protected unproxyRequest(req: express.Request): Stuntman.BaseRequest;
package/dist/mock.js CHANGED
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.Mock = void 0;
7
+ const undici_1 = require("undici");
7
8
  const https_1 = __importDefault(require("https"));
8
9
  const express_1 = __importDefault(require("express"));
9
10
  const uuid_1 = require("uuid");
@@ -57,6 +58,9 @@ class Mock {
57
58
  }
58
59
  return this._api;
59
60
  }
61
+ get ruleExecutor() {
62
+ return (0, ruleExecutor_1.getRuleExecutor)(this.mockUuid);
63
+ }
60
64
  constructor(options) {
61
65
  this.server = null;
62
66
  this.serverHttps = null;
@@ -67,7 +71,7 @@ class Mock {
67
71
  if (this.options.mock.httpsPort && (!this.options.mock.httpsKey || !this.options.mock.httpsCert)) {
68
72
  throw new Error('missing https key/cert');
69
73
  }
70
- this.MOCK_DOMAIN_REGEX = new RegExp(`(?:\\.([0-9]+))?\\.(?:(?:${this.options.mock.domain})|(?:localhost))(https?)?(:${this.options.mock.port}${this.options.mock.httpsPort ? `|${this.options.mock.httpsPort}` : ''})?(?:\\b|$)`, 'i');
74
+ this.MOCK_DOMAIN_REGEX = new RegExp(`(?:\\.([0-9]+))?\\.(?:(?:${this.options.mock.domain}(https?)?)|(?:localhost))(:${this.options.mock.port}${this.options.mock.httpsPort ? `|${this.options.mock.httpsPort}` : ''})?(?:\\b|$)`, 'i');
71
75
  this.URL_PORT_REGEX = new RegExp(`^(https?:\\/\\/[^:/]+):(?:${this.options.mock.port}${this.options.mock.httpsPort ? `|${this.options.mock.httpsPort}` : ''})(\\/.*)`, 'i');
72
76
  this.trafficStore = (0, storage_1.getTrafficStore)(this.mockUuid, this.options.storage.traffic);
73
77
  this.ipUtils =
@@ -81,157 +85,7 @@ class Mock {
81
85
  requestContext_1.default.bind(req, this.mockUuid);
82
86
  next();
83
87
  });
84
- this.mockApp.all(/.*/, async (req, res) => {
85
- var _a, _b, _c, _d, _e;
86
- const ctx = requestContext_1.default.get(req);
87
- const requestUuid = (ctx === null || ctx === void 0 ? void 0 : ctx.uuid) || (0, uuid_1.v4)();
88
- const timestamp = Date.now();
89
- const originalHostname = req.hostname;
90
- const unproxiedHostname = req.hostname.replace(this.MOCK_DOMAIN_REGEX, '');
91
- const isProxiedHostname = originalHostname !== unproxiedHostname;
92
- const originalRequest = {
93
- id: requestUuid,
94
- timestamp,
95
- url: `${req.protocol}://${req.hostname}${req.originalUrl}`,
96
- method: req.method,
97
- rawHeaders: new shared_1.RawHeaders(...req.rawHeaders),
98
- ...(Buffer.isBuffer(req.body) && { body: req.body.toString('utf-8') }),
99
- };
100
- shared_1.logger.debug(originalRequest, 'processing request');
101
- const logContext = {
102
- requestId: originalRequest.id,
103
- };
104
- const mockEntry = {
105
- originalRequest,
106
- modifiedRequest: {
107
- ...this.unproxyRequest(req),
108
- id: requestUuid,
109
- timestamp,
110
- ...(originalRequest.body && { gqlBody: naiveGQLParser(originalRequest.body) }),
111
- },
112
- };
113
- if (!isProxiedHostname) {
114
- this.removeProxyPort(mockEntry.modifiedRequest);
115
- }
116
- const matchingRule = await ruleExecutor_1.ruleExecutor.findMatchingRule(mockEntry.modifiedRequest);
117
- if (matchingRule) {
118
- mockEntry.mockRuleId = matchingRule.id;
119
- mockEntry.labels = matchingRule.labels;
120
- if ((_a = matchingRule.actions) === null || _a === void 0 ? void 0 : _a.mockResponse) {
121
- const staticResponse = typeof matchingRule.actions.mockResponse === 'function'
122
- ? matchingRule.actions.mockResponse(mockEntry.modifiedRequest)
123
- : matchingRule.actions.mockResponse;
124
- mockEntry.modifiedResponse = staticResponse;
125
- shared_1.logger.debug({ ...logContext, staticResponse }, 'replying with mocked response');
126
- if (matchingRule.storeTraffic) {
127
- this.trafficStore.set(requestUuid, mockEntry);
128
- }
129
- if (staticResponse.rawHeaders) {
130
- for (const header of staticResponse.rawHeaders.toHeaderPairs()) {
131
- res.setHeader(header[0], header[1]);
132
- }
133
- }
134
- res.status(staticResponse.status || 200);
135
- res.send(staticResponse.body);
136
- // static response blocks any further processing
137
- return;
138
- }
139
- if ((_b = matchingRule.actions) === null || _b === void 0 ? void 0 : _b.modifyRequest) {
140
- mockEntry.modifiedRequest = (_c = matchingRule.actions) === null || _c === void 0 ? void 0 : _c.modifyRequest(mockEntry.modifiedRequest);
141
- shared_1.logger.debug({ ...logContext, modifiedRequest: mockEntry.modifiedRequest }, 'modified original request');
142
- }
143
- }
144
- if (this.ipUtils && !isProxiedHostname && !this.ipUtils.isIP(originalHostname)) {
145
- const hostname = originalHostname.split(':')[0];
146
- try {
147
- const internalIPs = await this.ipUtils.resolveIP(hostname);
148
- if (this.ipUtils.isLocalhostIP(internalIPs) && this.options.mock.externalDns.length) {
149
- const externalIPs = await this.ipUtils.resolveIP(hostname, { useExternalDns: true });
150
- shared_1.logger.debug({ ...logContext, hostname, externalIPs, internalIPs }, 'switched to external IP');
151
- mockEntry.modifiedRequest.url = mockEntry.modifiedRequest.url.replace(/^(https?:\/\/)[^:/]+/i, `$1${externalIPs}`);
152
- }
153
- }
154
- catch (error) {
155
- // swallow the exeception, don't think much can be done at this point
156
- shared_1.logger.warn({ ...logContext, error }, `error trying to resolve IP for "${hostname}"`);
157
- }
158
- }
159
- let controller = new AbortController();
160
- const fetchTimeout = setTimeout(() => {
161
- if (controller) {
162
- controller.abort(`timeout after ${this.options.mock.timeout}`);
163
- }
164
- }, this.options.mock.timeout);
165
- req.on('close', () => {
166
- shared_1.logger.debug(logContext, 'remote client canceled the request');
167
- clearTimeout(fetchTimeout);
168
- if (controller) {
169
- controller.abort('remote client canceled the request');
170
- }
171
- });
172
- let targetResponse;
173
- const hasKeepAlive = !!mockEntry.modifiedRequest.rawHeaders
174
- .toHeaderPairs()
175
- .find((h) => /^connection$/.test(h[0]) && /^keep-alive$/.test(h[1]));
176
- try {
177
- targetResponse = await fetch(mockEntry.modifiedRequest.url, {
178
- redirect: 'manual',
179
- headers: mockEntry.modifiedRequest.rawHeaders
180
- .toHeaderPairs()
181
- .filter((h) => !/^connection$/.test(h[0]) && !/^keep-alive$/.test(h[1])),
182
- body: mockEntry.modifiedRequest.body,
183
- method: mockEntry.modifiedRequest.method,
184
- keepalive: !!hasKeepAlive,
185
- });
186
- }
187
- finally {
188
- controller = null;
189
- clearTimeout(fetchTimeout);
190
- }
191
- const targetResponseBuffer = Buffer.from(await targetResponse.arrayBuffer());
192
- const originalResponse = {
193
- timestamp: Date.now(),
194
- body: targetResponseBuffer.toString('binary'),
195
- status: targetResponse.status,
196
- rawHeaders: new shared_1.RawHeaders(...Array.from(targetResponse.headers.entries()).flatMap(([key, value]) => [key, value])),
197
- };
198
- shared_1.logger.debug({ ...logContext, originalResponse }, 'received response');
199
- mockEntry.originalResponse = originalResponse;
200
- let modifedResponse = {
201
- ...originalResponse,
202
- rawHeaders: new shared_1.RawHeaders(...Array.from(targetResponse.headers.entries()).flatMap(([key, value]) => {
203
- // TODO this replace may be too aggressive and doesn't handle protocol (won't be necessary with a trusted cert and mock serving http+https)
204
- return [
205
- key,
206
- isProxiedHostname
207
- ? value
208
- : value.replace(new RegExp(`(?:^|\\b)(${unproxiedHostname.replace('.', '\\.')})(?:\\b|$)`, 'igm'), originalHostname),
209
- ];
210
- })),
211
- };
212
- if ((_d = matchingRule === null || matchingRule === void 0 ? void 0 : matchingRule.actions) === null || _d === void 0 ? void 0 : _d.modifyResponse) {
213
- modifedResponse = (_e = matchingRule === null || matchingRule === void 0 ? void 0 : matchingRule.actions) === null || _e === void 0 ? void 0 : _e.modifyResponse(mockEntry.modifiedRequest, originalResponse);
214
- shared_1.logger.debug({ ...logContext, modifedResponse }, 'modified response');
215
- }
216
- mockEntry.modifiedResponse = modifedResponse;
217
- if (matchingRule === null || matchingRule === void 0 ? void 0 : matchingRule.storeTraffic) {
218
- this.trafficStore.set(requestUuid, mockEntry);
219
- }
220
- if (modifedResponse.status) {
221
- res.status(modifedResponse.status);
222
- }
223
- if (modifedResponse.rawHeaders) {
224
- for (const header of modifedResponse.rawHeaders.toHeaderPairs()) {
225
- // since fetch decompresses responses we need to get rid of some headers
226
- // TODO maybe could be handled better than just skipping, although express should add these back for new body
227
- if (/^content-(?:length|encoding)$/i.test(header[0])) {
228
- continue;
229
- }
230
- res.setHeader(header[0], header[1]);
231
- }
232
- }
233
- res.end(Buffer.from(modifedResponse.body, 'binary'));
234
- });
88
+ this.mockApp.all(/.*/, this.requestHandler);
235
89
  this.mockApp.use((error, req, res, _next) => {
236
90
  const ctx = requestContext_1.default.get(req);
237
91
  const uuid = (ctx === null || ctx === void 0 ? void 0 : ctx.uuid) || (0, uuid_1.v4)();
@@ -242,10 +96,169 @@ class Mock {
242
96
  });
243
97
  return;
244
98
  }
245
- console.log('mock encountered a critical error. exiting');
99
+ // eslint-disable-next-line no-console
100
+ console.error('mock server encountered a critical error. exiting');
246
101
  process.exit(1);
247
102
  });
248
103
  }
104
+ async proxyRequest(req, mockEntry, logContext) {
105
+ let controller = new AbortController();
106
+ const fetchTimeout = setTimeout(() => {
107
+ if (controller) {
108
+ controller.abort(`timeout after ${this.options.mock.timeout}`);
109
+ }
110
+ }, this.options.mock.timeout);
111
+ req.on('close', () => {
112
+ shared_1.logger.debug(logContext, 'remote client canceled the request');
113
+ clearTimeout(fetchTimeout);
114
+ if (controller) {
115
+ controller.abort('remote client canceled the request');
116
+ }
117
+ });
118
+ let targetResponse;
119
+ try {
120
+ const requestOptions = {
121
+ headers: mockEntry.modifiedRequest.rawHeaders,
122
+ body: mockEntry.modifiedRequest.body,
123
+ method: mockEntry.modifiedRequest.method.toUpperCase(),
124
+ };
125
+ shared_1.logger.debug({
126
+ ...logContext,
127
+ url: mockEntry.modifiedRequest.url,
128
+ ...requestOptions,
129
+ }, 'outgoing request attempt');
130
+ targetResponse = await (0, undici_1.request)(mockEntry.modifiedRequest.url, requestOptions);
131
+ }
132
+ catch (error) {
133
+ shared_1.logger.error({ ...logContext, error, request: mockEntry.modifiedRequest }, 'error fetching');
134
+ throw error;
135
+ }
136
+ finally {
137
+ controller = null;
138
+ clearTimeout(fetchTimeout);
139
+ }
140
+ const targetResponseBuffer = Buffer.from(await targetResponse.body.arrayBuffer());
141
+ return {
142
+ timestamp: Date.now(),
143
+ body: targetResponseBuffer.toString('binary'),
144
+ status: targetResponse.statusCode,
145
+ rawHeaders: shared_1.RawHeaders.fromHeadersRecord(targetResponse.headers),
146
+ };
147
+ }
148
+ async requestHandler(req, res) {
149
+ var _a, _b, _c, _d, _e;
150
+ const ctx = requestContext_1.default.get(req);
151
+ const requestUuid = (ctx === null || ctx === void 0 ? void 0 : ctx.uuid) || (0, uuid_1.v4)();
152
+ const timestamp = Date.now();
153
+ const originalHostname = req.headers.host || req.hostname;
154
+ const unproxiedHostname = req.hostname.replace(this.MOCK_DOMAIN_REGEX, '');
155
+ const isProxiedHostname = originalHostname !== unproxiedHostname;
156
+ const originalRequest = {
157
+ id: requestUuid,
158
+ timestamp,
159
+ url: `${req.protocol}://${req.hostname}${req.originalUrl}`,
160
+ method: req.method,
161
+ rawHeaders: new shared_1.RawHeaders(...req.rawHeaders),
162
+ ...((Buffer.isBuffer(req.body) && { body: req.body.toString('utf-8') }) ||
163
+ (typeof req.body === 'string' && { body: req.body })),
164
+ };
165
+ shared_1.logger.debug(originalRequest, 'processing request');
166
+ const logContext = {
167
+ requestId: originalRequest.id,
168
+ };
169
+ const mockEntry = {
170
+ originalRequest,
171
+ modifiedRequest: {
172
+ ...this.unproxyRequest(req),
173
+ id: requestUuid,
174
+ timestamp,
175
+ ...(originalRequest.body && { gqlBody: naiveGQLParser(originalRequest.body) }),
176
+ },
177
+ };
178
+ if (!isProxiedHostname) {
179
+ this.removeProxyPort(mockEntry.modifiedRequest);
180
+ }
181
+ const matchingRule = await (0, ruleExecutor_1.getRuleExecutor)(this.mockUuid).findMatchingRule(mockEntry.modifiedRequest);
182
+ if (matchingRule) {
183
+ mockEntry.mockRuleId = matchingRule.id;
184
+ mockEntry.labels = matchingRule.labels;
185
+ if ((_a = matchingRule.actions) === null || _a === void 0 ? void 0 : _a.mockResponse) {
186
+ const staticResponse = typeof matchingRule.actions.mockResponse === 'function'
187
+ ? matchingRule.actions.mockResponse(mockEntry.modifiedRequest)
188
+ : matchingRule.actions.mockResponse;
189
+ mockEntry.modifiedResponse = staticResponse;
190
+ shared_1.logger.debug({ ...logContext, staticResponse }, 'replying with mocked response');
191
+ if (matchingRule.storeTraffic) {
192
+ this.trafficStore.set(requestUuid, mockEntry);
193
+ }
194
+ if (staticResponse.rawHeaders) {
195
+ for (const header of staticResponse.rawHeaders.toHeaderPairs()) {
196
+ res.setHeader(header[0], header[1]);
197
+ }
198
+ }
199
+ res.status(staticResponse.status || 200);
200
+ res.send(staticResponse.body);
201
+ // static response blocks any further processing
202
+ return;
203
+ }
204
+ if ((_b = matchingRule.actions) === null || _b === void 0 ? void 0 : _b.modifyRequest) {
205
+ mockEntry.modifiedRequest = (_c = matchingRule.actions) === null || _c === void 0 ? void 0 : _c.modifyRequest(mockEntry.modifiedRequest);
206
+ shared_1.logger.debug({ ...logContext, modifiedRequest: mockEntry.modifiedRequest }, 'modified original request');
207
+ }
208
+ }
209
+ if (this.ipUtils && !isProxiedHostname && !this.ipUtils.isIP(originalHostname)) {
210
+ const hostname = originalHostname.split(':')[0];
211
+ try {
212
+ const internalIPs = await this.ipUtils.resolveIP(hostname);
213
+ if (this.ipUtils.isLocalhostIP(internalIPs) && this.options.mock.externalDns.length) {
214
+ const externalIPs = await this.ipUtils.resolveIP(hostname, { useExternalDns: true });
215
+ shared_1.logger.debug({ ...logContext, hostname, externalIPs, internalIPs }, 'switched to external IP');
216
+ mockEntry.modifiedRequest.url = mockEntry.modifiedRequest.url.replace(/^(https?:\/\/)[^:/]+/i, `$1${externalIPs}`);
217
+ }
218
+ }
219
+ catch (error) {
220
+ // swallow the exeception, don't think much can be done at this point
221
+ shared_1.logger.warn({ ...logContext, error }, `error trying to resolve IP for "${hostname}"`);
222
+ }
223
+ }
224
+ const originalResponse = await this.proxyRequest(req, mockEntry, logContext);
225
+ shared_1.logger.debug({ ...logContext, originalResponse }, 'received response');
226
+ mockEntry.originalResponse = originalResponse;
227
+ let modifedResponse = {
228
+ ...originalResponse,
229
+ rawHeaders: new shared_1.RawHeaders(...Array.from(originalResponse.rawHeaders.toHeaderPairs()).flatMap(([key, value]) => {
230
+ // TODO this replace may be too aggressive and doesn't handle protocol (won't be necessary with a trusted cert and mock serving http+https)
231
+ return [
232
+ key,
233
+ isProxiedHostname
234
+ ? value
235
+ : value.replace(new RegExp(`(?:^|\\b)(${unproxiedHostname.replace('.', '\\.')})(?:\\b|$)`, 'igm'), originalHostname),
236
+ ];
237
+ })),
238
+ };
239
+ if ((_d = matchingRule === null || matchingRule === void 0 ? void 0 : matchingRule.actions) === null || _d === void 0 ? void 0 : _d.modifyResponse) {
240
+ modifedResponse = (_e = matchingRule === null || matchingRule === void 0 ? void 0 : matchingRule.actions) === null || _e === void 0 ? void 0 : _e.modifyResponse(mockEntry.modifiedRequest, originalResponse);
241
+ shared_1.logger.debug({ ...logContext, modifedResponse }, 'modified response');
242
+ }
243
+ mockEntry.modifiedResponse = modifedResponse;
244
+ if (matchingRule === null || matchingRule === void 0 ? void 0 : matchingRule.storeTraffic) {
245
+ this.trafficStore.set(requestUuid, mockEntry);
246
+ }
247
+ if (modifedResponse.status) {
248
+ res.status(modifedResponse.status);
249
+ }
250
+ if (modifedResponse.rawHeaders) {
251
+ for (const header of modifedResponse.rawHeaders.toHeaderPairs()) {
252
+ // since fetch decompresses responses we need to get rid of some headers
253
+ // TODO maybe could be handled better than just skipping, although express should add these back for new body
254
+ // if (/^content-(?:length|encoding)$/i.test(header[0])) {
255
+ // continue;
256
+ // }
257
+ res.setHeader(header[0], isProxiedHostname ? header[1].replace(unproxiedHostname, originalHostname) : header[1]);
258
+ }
259
+ }
260
+ res.end(Buffer.from(modifedResponse.body, 'binary'));
261
+ }
249
262
  start() {
250
263
  if (this.server) {
251
264
  throw new Error('mock server already started');
@@ -1,6 +1,7 @@
1
1
  import type * as Stuntman from '@stuntman/shared';
2
- declare class RuleExecutor {
2
+ declare class RuleExecutor implements Stuntman.RuleExecutorInterface {
3
3
  private _rules;
4
+ private rulesLock;
4
5
  private get enabledRules();
5
6
  constructor(rules?: Stuntman.Rule[]);
6
7
  private hasExpired;
@@ -17,5 +18,5 @@ declare class RuleExecutor {
17
18
  getRules(): Promise<readonly Stuntman.LiveRule[]>;
18
19
  getRule(id: string): Promise<Stuntman.LiveRule | undefined>;
19
20
  }
20
- export declare const ruleExecutor: RuleExecutor;
21
+ export declare const getRuleExecutor: (mockUuid: string) => RuleExecutor;
21
22
  export {};
@@ -3,11 +3,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.ruleExecutor = void 0;
6
+ exports.getRuleExecutor = void 0;
7
7
  const await_lock_1 = __importDefault(require("await-lock"));
8
8
  const shared_1 = require("@stuntman/shared");
9
9
  const rules_1 = require("./rules");
10
- const rulesLock = new await_lock_1.default();
10
+ const ruleExecutors = {};
11
11
  const transformMockRuleToLive = (rule) => {
12
12
  var _a;
13
13
  return {
@@ -28,6 +28,7 @@ class RuleExecutor {
28
28
  .sort((a, b) => { var _a, _b; return ((_a = a.priority) !== null && _a !== void 0 ? _a : shared_1.DEFAULT_RULE_PRIORITY) - ((_b = b.priority) !== null && _b !== void 0 ? _b : shared_1.DEFAULT_RULE_PRIORITY); });
29
29
  }
30
30
  constructor(rules) {
31
+ this.rulesLock = new await_lock_1.default();
31
32
  this._rules = (rules || []).map(transformMockRuleToLive);
32
33
  }
33
34
  hasExpired() {
@@ -38,7 +39,7 @@ class RuleExecutor {
38
39
  if (!this.hasExpired()) {
39
40
  return;
40
41
  }
41
- await rulesLock.acquireAsync();
42
+ await this.rulesLock.acquireAsync();
42
43
  const now = Date.now();
43
44
  try {
44
45
  this._rules = this._rules.filter((r) => {
@@ -50,12 +51,12 @@ class RuleExecutor {
50
51
  });
51
52
  }
52
53
  finally {
53
- await rulesLock.release();
54
+ await this.rulesLock.release();
54
55
  }
55
56
  }
56
57
  async addRule(rule, overwrite) {
57
58
  await this.cleanUpExpired();
58
- await rulesLock.acquireAsync();
59
+ await this.rulesLock.acquireAsync();
59
60
  try {
60
61
  if (this._rules.some((r) => r.id === rule.id)) {
61
62
  if (!overwrite) {
@@ -69,7 +70,7 @@ class RuleExecutor {
69
70
  return liveRule;
70
71
  }
71
72
  finally {
72
- await rulesLock.release();
73
+ await this.rulesLock.release();
73
74
  }
74
75
  }
75
76
  _removeRule(ruleOrId) {
@@ -83,17 +84,18 @@ class RuleExecutor {
83
84
  }
84
85
  async removeRule(ruleOrId) {
85
86
  await this.cleanUpExpired();
86
- await rulesLock.acquireAsync();
87
+ await this.rulesLock.acquireAsync();
87
88
  try {
88
89
  this._removeRule(ruleOrId);
89
90
  }
90
91
  finally {
91
- await rulesLock.release();
92
+ await this.rulesLock.release();
92
93
  }
93
94
  }
94
95
  enableRule(ruleOrId) {
95
96
  this._rules.forEach((r) => {
96
97
  if (r.id === (typeof ruleOrId === 'string' ? ruleOrId : ruleOrId.id)) {
98
+ r.counter = 0;
97
99
  r.isEnabled = true;
98
100
  shared_1.logger.debug({ ruleId: r.id }, 'rule enabled');
99
101
  }
@@ -112,11 +114,17 @@ class RuleExecutor {
112
114
  requestId: request.id,
113
115
  };
114
116
  const matchingRule = this.enabledRules.find((rule) => {
115
- const matchResult = rule.matches(request);
116
- if (typeof matchResult === 'boolean') {
117
- return matchResult;
117
+ try {
118
+ const matchResult = rule.matches(request);
119
+ shared_1.logger.trace({ ...logContext, matchResult }, `rule match attempt for ${rule.id}`);
120
+ if (typeof matchResult === 'boolean') {
121
+ return matchResult;
122
+ }
123
+ return matchResult.result;
124
+ }
125
+ catch (error) {
126
+ shared_1.logger.error({ ...logContext, ruleId: rule === null || rule === void 0 ? void 0 : rule.id, error }, 'error in rule match function');
118
127
  }
119
- return matchResult.result;
120
128
  });
121
129
  if (!matchingRule) {
122
130
  shared_1.logger.debug(logContext, 'no matching rule found');
@@ -124,7 +132,7 @@ class RuleExecutor {
124
132
  }
125
133
  const matchResult = matchingRule.matches(request);
126
134
  logContext.ruleId = matchingRule.id;
127
- shared_1.logger.debug(logContext, 'matching rule found');
135
+ shared_1.logger.debug({ ...logContext, matchResultMessage: typeof matchResult !== 'boolean' ? matchResult.description : null }, 'found matching rule');
128
136
  const matchingRuleClone = Object.freeze(Object.assign({}, matchingRule));
129
137
  ++matchingRule.counter;
130
138
  logContext.ruleCounter = matchingRule.counter;
@@ -132,6 +140,7 @@ class RuleExecutor {
132
140
  matchingRule.counter = 0;
133
141
  shared_1.logger.warn(logContext, "it's over 9000!!!");
134
142
  }
143
+ // TODO check if that works
135
144
  if (matchingRule.disableAfterUse) {
136
145
  if (typeof matchingRule.disableAfterUse === 'boolean' || matchingRule.disableAfterUse <= matchingRule.counter) {
137
146
  shared_1.logger.debug(logContext, 'disabling rule for future requests');
@@ -141,7 +150,7 @@ class RuleExecutor {
141
150
  if (matchingRule.removeAfterUse) {
142
151
  if (typeof matchingRule.removeAfterUse === 'boolean' || matchingRule.removeAfterUse <= matchingRule.counter) {
143
152
  shared_1.logger.debug(logContext, 'removing rule for future requests');
144
- this.removeRule(matchingRule);
153
+ await this.removeRule(matchingRule);
145
154
  }
146
155
  }
147
156
  if (typeof matchResult !== 'boolean') {
@@ -169,4 +178,10 @@ class RuleExecutor {
169
178
  return this._rules.find((r) => r.id === id);
170
179
  }
171
180
  }
172
- exports.ruleExecutor = new RuleExecutor(rules_1.DEFAULT_RULES.map((r) => ({ ...r, ttlSeconds: Infinity })));
181
+ const getRuleExecutor = (mockUuid) => {
182
+ if (!ruleExecutors[mockUuid]) {
183
+ ruleExecutors[mockUuid] = new RuleExecutor([...rules_1.DEFAULT_RULES, ...rules_1.CUSTOM_RULES].map((r) => ({ ...r, ttlSeconds: Infinity })));
184
+ }
185
+ return ruleExecutors[mockUuid];
186
+ };
187
+ exports.getRuleExecutor = getRuleExecutor;
@@ -1,2 +1,3 @@
1
1
  import type * as Stuntman from '@stuntman/shared';
2
2
  export declare const DEFAULT_RULES: Stuntman.DeployedRule[];
3
+ export declare const CUSTOM_RULES: Stuntman.DeployedRule[];
@@ -1,7 +1,70 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || function (mod) {
19
+ if (mod && mod.__esModule) return mod;
20
+ var result = {};
21
+ if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
22
+ __setModuleDefault(result, mod);
23
+ return result;
24
+ };
25
+ var __importDefault = (this && this.__importDefault) || function (mod) {
26
+ return (mod && mod.__esModule) ? mod : { "default": mod };
27
+ };
2
28
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.DEFAULT_RULES = void 0;
29
+ exports.CUSTOM_RULES = exports.DEFAULT_RULES = void 0;
30
+ const fs_1 = __importDefault(require("fs"));
31
+ const glob_1 = __importDefault(require("glob"));
32
+ const tsImport = __importStar(require("ts-import"));
4
33
  const catchAll_1 = require("./catchAll");
5
34
  const echo_1 = require("./echo");
6
- // TODO add option to load rules additional default rules from some nice configurable folder
35
+ const shared_1 = require("@stuntman/shared");
7
36
  exports.DEFAULT_RULES = [catchAll_1.catchAllRule, echo_1.echoRule];
37
+ exports.CUSTOM_RULES = [];
38
+ const loadAdditionalRules = () => {
39
+ if (!shared_1.serverConfig.mock.rulesPath || !fs_1.default.existsSync(shared_1.serverConfig.mock.rulesPath)) {
40
+ shared_1.logger.debug({ rulesPath: shared_1.serverConfig.mock.rulesPath }, `additional rules directory not found`);
41
+ return;
42
+ }
43
+ shared_1.logger.debug({ rulesPath: shared_1.serverConfig.mock.rulesPath }, `loading additional rules`);
44
+ const filePaths = glob_1.default.sync('*.[tj]s', { absolute: true, cwd: shared_1.serverConfig.mock.rulesPath });
45
+ for (const filePath of filePaths) {
46
+ // TODO add .ts rule support
47
+ try {
48
+ const loadedFile = /\.js$/.test(filePath) ? require(filePath) : tsImport.loadSync(filePath);
49
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
50
+ const exportedRules = Object.values(loadedFile).filter((rule) => {
51
+ if (!rule || !rule.id || typeof rule.matches !== 'function') {
52
+ shared_1.logger.error({ filePath, rule }, 'invalid exported rule');
53
+ return false;
54
+ }
55
+ return true;
56
+ });
57
+ exports.CUSTOM_RULES.push(...exportedRules);
58
+ }
59
+ catch (error) {
60
+ shared_1.logger.error({ filePath, error }, 'error importing rule');
61
+ }
62
+ }
63
+ const ruleIds = [...exports.DEFAULT_RULES, ...exports.CUSTOM_RULES].map((rule) => rule.id);
64
+ const duplicatedRuleIds = ruleIds.filter((currentValue, currentIndex) => ruleIds.indexOf(currentValue) !== currentIndex);
65
+ if (duplicatedRuleIds.length > 0) {
66
+ shared_1.logger.error({ duplicatedRuleIds }, 'duplicated rule ids');
67
+ throw new Error('duplicated rule ids');
68
+ }
69
+ };
70
+ loadAdditionalRules();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stuntman/server",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "Stuntman - HTTP proxy / mock server with API",
5
5
  "main": "dist/index.js",
6
6
  "repository": {
@@ -32,19 +32,26 @@
32
32
  "author": "Andrzej Pasterczyk",
33
33
  "license": "MIT",
34
34
  "dependencies": {
35
- "@stuntman/shared": "^0.1.1",
35
+ "@stuntman/shared": "^0.1.2",
36
36
  "await-lock": "2.2.2",
37
37
  "express": "5.0.0-beta.1",
38
+ "glob": "8.1.0",
38
39
  "lru-cache": "7.16.0",
39
40
  "object-sizeof": "2.6.1",
40
- "pug": "^3.0.2",
41
+ "pug": "3.0.2",
41
42
  "serialize-javascript": "6.0.1",
43
+ "ts-import": "4.0.0-beta.10",
44
+ "typescript": "4.9.5",
45
+ "undici": "5.20.0",
42
46
  "uuid": "9.0.0"
43
47
  },
44
48
  "devDependencies": {
49
+ "@prettier/plugin-pug": "2.4.1",
45
50
  "@types/express": "4.17.17",
51
+ "@types/glob": "8.1.0",
46
52
  "@types/serialize-javascript": "5.0.2",
47
- "@types/uuid": "9.0.0"
53
+ "@types/uuid": "9.0.0",
54
+ "prettier": "2.8.4"
48
55
  },
49
56
  "bin": {
50
57
  "stuntman": "./dist/bin/stuntman.js"
@@ -60,7 +67,7 @@
60
67
  "clean": "rm -fr dist",
61
68
  "build": "tsc && cp -rv src/api/webgui dist/api",
62
69
  "lint": "prettier --check . && eslint . --ext ts",
63
- "lint:fix": "prettier --write ./src && eslint ./src --ext ts --fix",
70
+ "lint:fix": "prettier --write ./{src,test} && eslint ./{src,test} --ext ts --fix",
64
71
  "start": "node ./dist/bin/stuntman.js",
65
72
  "start:dev": "nodemon --watch 'src/**/*.ts' --exec 'ts-node' ./src/bin/stuntman.ts",
66
73
  "start:debug": "node --inspect-brk=0.0.0.0 ./node_modules/.bin/ts-node --transpile-only ./src/bin/stuntman.ts"
File without changes