@stuntman/server 0.1.6 → 0.1.8

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 (67) hide show
  1. package/README.md +3 -3
  2. package/dist/api/api.d.ts +7 -4
  3. package/dist/api/api.d.ts.map +1 -0
  4. package/dist/api/api.js +58 -28
  5. package/dist/api/api.js.map +1 -0
  6. package/dist/api/utils.d.ts +1 -0
  7. package/dist/api/utils.d.ts.map +1 -0
  8. package/dist/api/utils.js +1 -0
  9. package/dist/api/utils.js.map +1 -0
  10. package/dist/api/validators.d.ts +1 -0
  11. package/dist/api/validators.d.ts.map +1 -0
  12. package/dist/api/validators.js +1 -0
  13. package/dist/api/validators.js.map +1 -0
  14. package/dist/api/webgui/rules.pug +2 -2
  15. package/dist/api/webgui/style.css +3 -3
  16. package/dist/api/webgui/traffic.pug +1 -0
  17. package/dist/bin/stuntman.d.ts +1 -0
  18. package/dist/bin/stuntman.d.ts.map +1 -0
  19. package/dist/bin/stuntman.js +2 -1
  20. package/dist/bin/stuntman.js.map +1 -0
  21. package/dist/index.d.ts +1 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +1 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/ipUtils.d.ts +1 -0
  26. package/dist/ipUtils.d.ts.map +1 -0
  27. package/dist/ipUtils.js +1 -0
  28. package/dist/ipUtils.js.map +1 -0
  29. package/dist/mock.d.ts +6 -4
  30. package/dist/mock.d.ts.map +1 -0
  31. package/dist/mock.js +122 -143
  32. package/dist/mock.js.map +1 -0
  33. package/dist/requestContext.d.ts +1 -0
  34. package/dist/requestContext.d.ts.map +1 -0
  35. package/dist/requestContext.js +1 -0
  36. package/dist/requestContext.js.map +1 -0
  37. package/dist/ruleExecutor.d.ts +1 -0
  38. package/dist/ruleExecutor.d.ts.map +1 -0
  39. package/dist/ruleExecutor.js +2 -0
  40. package/dist/ruleExecutor.js.map +1 -0
  41. package/dist/rules/catchAll.d.ts +1 -0
  42. package/dist/rules/catchAll.d.ts.map +1 -0
  43. package/dist/rules/catchAll.js +1 -0
  44. package/dist/rules/catchAll.js.map +1 -0
  45. package/dist/rules/echo.d.ts +1 -0
  46. package/dist/rules/echo.d.ts.map +1 -0
  47. package/dist/rules/echo.js +1 -0
  48. package/dist/rules/echo.js.map +1 -0
  49. package/dist/rules/index.d.ts +1 -0
  50. package/dist/rules/index.d.ts.map +1 -0
  51. package/dist/rules/index.js +5 -4
  52. package/dist/rules/index.js.map +1 -0
  53. package/dist/storage.d.ts +1 -0
  54. package/dist/storage.d.ts.map +1 -0
  55. package/dist/storage.js +1 -0
  56. package/dist/storage.js.map +1 -0
  57. package/package.json +4 -4
  58. package/src/api/api.ts +62 -35
  59. package/src/api/webgui/rules.pug +2 -2
  60. package/src/api/webgui/style.css +3 -3
  61. package/src/api/webgui/traffic.pug +1 -0
  62. package/src/bin/stuntman.ts +2 -2
  63. package/src/ipUtils.ts +1 -1
  64. package/src/mock.ts +137 -160
  65. package/src/ruleExecutor.ts +2 -1
  66. package/src/rules/index.ts +5 -5
  67. package/src/storage.ts +2 -2
package/src/api/api.ts CHANGED
@@ -19,67 +19,81 @@ const API_KEY_HEADER = 'x-api-key';
19
19
 
20
20
  export class API {
21
21
  protected options: Required<ApiOptions>;
22
- protected apiApp: ExpressServer;
22
+ protected webGuiOptions: Stuntman.WebGuiConfig;
23
+ protected apiApp: ExpressServer | null = null;
23
24
  trafficStore: LRUCache<string, Stuntman.LogEntry>;
24
25
  server: http.Server | null = null;
25
- auth: (req: Request, type: 'read' | 'write') => void;
26
- authReadOnly: (req: Request, res: Response, next: NextFunction) => void;
27
- authReadWrite: (req: Request, res: Response, next: NextFunction) => void;
28
26
 
29
- constructor(options: ApiOptions, webGuiOptions?: Stuntman.WebGuiConfig) {
27
+ constructor(options: ApiOptions, webGuiOptions: Stuntman.WebGuiConfig = { disabled: false }) {
30
28
  if (!options.apiKeyReadOnly !== !options.apiKeyReadWrite) {
31
29
  throw new Error('apiKeyReadOnly and apiKeyReadWrite options need to be set either both or none');
32
30
  }
33
31
  this.options = options;
32
+ this.webGuiOptions = webGuiOptions;
34
33
 
35
34
  this.trafficStore = getTrafficStore(this.options.mockUuid);
36
- this.apiApp = express();
37
-
38
- this.apiApp.use(express.json());
39
- this.apiApp.use(express.text());
35
+ this.auth = this.auth.bind(this);
36
+ this.authReadOnly = this.authReadOnly.bind(this);
37
+ this.authReadWrite = this.authReadWrite.bind(this);
38
+ }
40
39
 
41
- this.auth = (req: Request, type: 'read' | 'write'): void => {
42
- if (!this.options.apiKeyReadOnly && !this.options.apiKeyReadWrite) {
43
- return;
44
- }
45
- const hasValidReadKey = req.header(API_KEY_HEADER) === this.options.apiKeyReadOnly;
46
- const hasValidWriteKey = req.header(API_KEY_HEADER) === this.options.apiKeyReadWrite;
47
- const hasValidKey = type === 'read' ? hasValidReadKey || hasValidWriteKey : hasValidWriteKey;
48
- if (!hasValidKey) {
49
- throw new AppError({ httpCode: HttpCode.UNAUTHORIZED, message: 'unauthorized' });
50
- }
40
+ private auth(req: Request, type: 'read' | 'write'): void {
41
+ if (!this.options.apiKeyReadOnly && !this.options.apiKeyReadWrite) {
51
42
  return;
52
- };
43
+ }
44
+ const hasValidReadKey = req.header(API_KEY_HEADER) === this.options.apiKeyReadOnly;
45
+ const hasValidWriteKey = req.header(API_KEY_HEADER) === this.options.apiKeyReadWrite;
46
+ const hasValidKey = type === 'read' ? hasValidReadKey || hasValidWriteKey : hasValidWriteKey;
47
+ if (!hasValidKey) {
48
+ throw new AppError({ httpCode: HttpCode.UNAUTHORIZED, message: 'unauthorized' });
49
+ }
50
+ return;
51
+ }
53
52
 
54
- this.authReadOnly = (req: Request, res: Response, next: NextFunction): void => {
55
- this.auth(req, 'read');
56
- next();
57
- };
53
+ protected authReadOnly(req: Request, _res: Response, next: NextFunction): void {
54
+ this.auth(req, 'read');
55
+ next();
56
+ }
58
57
 
59
- this.authReadWrite = (req: Request, res: Response, next: NextFunction): void => {
60
- this.auth(req, 'write');
61
- next();
62
- };
58
+ protected authReadWrite(req: Request, _res: Response, next: NextFunction): void {
59
+ this.auth(req, 'write');
60
+ next();
61
+ }
62
+
63
+ private initApi() {
64
+ this.apiApp = express();
63
65
 
64
- this.apiApp.use((req: Request, res: Response, next: NextFunction) => {
66
+ this.apiApp.use(express.json());
67
+ this.apiApp.use(express.text());
68
+
69
+ this.apiApp.use((req: Request, _res: Response, next: NextFunction) => {
65
70
  RequestContext.bind(req, this.options.mockUuid);
66
71
  next();
67
72
  });
68
73
 
69
- this.apiApp.get('/rule', this.authReadOnly, async (req, res) => {
74
+ this.apiApp.get('/rule', this.authReadOnly.bind, async (_req, res) => {
70
75
  res.send(stringify(await getRuleExecutor(this.options.mockUuid).getRules()));
71
76
  });
72
77
 
73
78
  this.apiApp.get('/rule/:ruleId', this.authReadOnly, async (req, res) => {
79
+ if (!req.params.ruleId) {
80
+ throw new AppError({ httpCode: HttpCode.BAD_REQUEST, message: 'missing ruleId' });
81
+ }
74
82
  res.send(stringify(await getRuleExecutor(this.options.mockUuid).getRule(req.params.ruleId)));
75
83
  });
76
84
 
77
85
  this.apiApp.get('/rule/:ruleId/disable', this.authReadWrite, (req, res) => {
86
+ if (!req.params.ruleId) {
87
+ throw new AppError({ httpCode: HttpCode.BAD_REQUEST, message: 'missing ruleId' });
88
+ }
78
89
  getRuleExecutor(this.options.mockUuid).disableRule(req.params.ruleId);
79
90
  res.send();
80
91
  });
81
92
 
82
93
  this.apiApp.get('/rule/:ruleId/enable', this.authReadWrite, (req, res) => {
94
+ if (!req.params.ruleId) {
95
+ throw new AppError({ httpCode: HttpCode.BAD_REQUEST, message: 'missing ruleId' });
96
+ }
83
97
  getRuleExecutor(this.options.mockUuid).enableRule(req.params.ruleId);
84
98
  res.send();
85
99
  });
@@ -98,11 +112,14 @@ export class API {
98
112
  );
99
113
 
100
114
  this.apiApp.get('/rule/:ruleId/remove', this.authReadWrite, async (req, res) => {
115
+ if (!req.params.ruleId) {
116
+ throw new AppError({ httpCode: HttpCode.BAD_REQUEST, message: 'missing ruleId' });
117
+ }
101
118
  await getRuleExecutor(this.options.mockUuid).removeRule(req.params.ruleId);
102
119
  res.send();
103
120
  });
104
121
 
105
- this.apiApp.get('/traffic', this.authReadOnly, (req, res) => {
122
+ this.apiApp.get('/traffic', this.authReadOnly, (_req, res) => {
106
123
  const serializedTraffic: Stuntman.LogEntry[] = [];
107
124
  for (const value of this.trafficStore.values()) {
108
125
  serializedTraffic.push(value);
@@ -111,6 +128,9 @@ export class API {
111
128
  });
112
129
 
113
130
  this.apiApp.get('/traffic/:ruleIdOrLabel', this.authReadOnly, (req, res) => {
131
+ if (!req.params.ruleIdOrLabel) {
132
+ throw new AppError({ httpCode: HttpCode.BAD_REQUEST, message: 'missing ruleIdOrLabel' });
133
+ }
114
134
  const serializedTraffic: Stuntman.LogEntry[] = [];
115
135
  for (const value of this.trafficStore.values()) {
116
136
  if (value.mockRuleId === req.params.ruleIdOrLabel || (value.labels || []).includes(req.params.ruleIdOrLabel)) {
@@ -120,13 +140,13 @@ export class API {
120
140
  res.json(serializedTraffic);
121
141
  });
122
142
 
123
- if (!webGuiOptions?.disabled) {
143
+ if (!this.webGuiOptions.disabled) {
124
144
  this.apiApp.set('views', __dirname + '/webgui');
125
145
  this.apiApp.set('view engine', 'pug');
126
146
  this.initWebGui();
127
147
  }
128
148
 
129
- this.apiApp.all(/.*/, (req: Request, res: Response) => res.status(404).send());
149
+ this.apiApp.all(/.*/, (_req: Request, res: Response) => res.status(404).send());
130
150
 
131
151
  this.apiApp.use((error: Error | AppError, req: Request, res: Response) => {
132
152
  const ctx: RequestContext | null = RequestContext.get(req);
@@ -152,7 +172,10 @@ export class API {
152
172
  }
153
173
 
154
174
  private initWebGui() {
155
- this.apiApp.get('/webgui/rules', this.authReadOnly, async (req, res) => {
175
+ if (!this.apiApp) {
176
+ throw new Error('initialization error');
177
+ }
178
+ this.apiApp.get('/webgui/rules', this.authReadOnly, async (_req, res) => {
156
179
  const rules: Record<string, string> = {};
157
180
  for (const rule of await getRuleExecutor(this.options.mockUuid).getRules()) {
158
181
  rules[rule.id] = serializeJavascript(liveRuleToRule(rule), { unsafe: true });
@@ -160,7 +183,7 @@ export class API {
160
183
  res.render('rules', { rules: escapedSerialize(rules), INDEX_DTS, ruleKeys: Object.keys(rules) });
161
184
  });
162
185
 
163
- this.apiApp.get('/webgui/traffic', this.authReadOnly, async (req, res) => {
186
+ this.apiApp.get('/webgui/traffic', this.authReadOnly, async (_req, res) => {
164
187
  const serializedTraffic: Stuntman.LogEntry[] = [];
165
188
  for (const value of this.trafficStore.values()) {
166
189
  serializedTraffic.push(value);
@@ -208,6 +231,10 @@ export class API {
208
231
  if (this.server) {
209
232
  throw new Error('mock server already started');
210
233
  }
234
+ this.initApi();
235
+ if (!this.apiApp) {
236
+ throw new Error('initialization error');
237
+ }
211
238
  this.server = this.apiApp.listen(this.options.port, () => {
212
239
  logger.info(`API listening on ${this.options.port}`);
213
240
  });
@@ -22,6 +22,7 @@ html
22
22
  div(style='margin-left: 240px')
23
23
  #container(style='height: 400px')
24
24
  script.
25
+ /* eslint no-undef: 0 */
25
26
  const uuidv4 = () => {
26
27
  function getRandomSymbol(symbol) {
27
28
  var array;
@@ -72,7 +73,6 @@ html
72
73
  }
73
74
  const editor = monaco.editor.create(document.getElementById('container'), {
74
75
  theme: 'vs-dark',
75
- autoIndent: true,
76
76
  formatOnPaste: true,
77
77
  formatOnType: true,
78
78
  automaticLayout: true,
@@ -135,7 +135,7 @@ html
135
135
 
136
136
  window.newRule = () => {
137
137
  const ruleId = uuidv4();
138
- const emptyRule = `import type * as Stuntman from \'stuntman\';\n\nvar STUNTMAN_RULE: Stuntman.Rule = { id: '${ruleId}', matches: (req: Stuntman.Request) => true, ttlSeconds: 600, actions: { mockResponse: { status: '200', body: '${ruleId}' }} };`;
138
+ const emptyRule = `import type * as Stuntman from 'stuntman';\n\nvar STUNTMAN_RULE: Stuntman.Rule = { id: '${ruleId}', matches: (req: Stuntman.Request) => true, ttlSeconds: 600, actions: { mockResponse: { status: '200', body: '${ruleId}' }} };`;
139
139
  models[ruleId] = monaco.editor.createModel(emptyRule, 'typescript', `file:///${ruleId}.ts`);
140
140
  const ruleKeyNode = document.getElementById('ruleKeys').firstChild;
141
141
  const ruleKeyNodeClone = ruleKeyNode.cloneNode(true);
@@ -21,8 +21,8 @@ button.rule {
21
21
  }
22
22
 
23
23
  ul.no-bullets {
24
- list-style-type: none; /* Remove bullets */
25
- padding: 0; /* Remove padding */
26
- margin: 0; /* Remove margins */
24
+ list-style-type: none;
25
+ padding: 0;
26
+ margin: 0;
27
27
  text-align: left;
28
28
  }
@@ -12,6 +12,7 @@ html
12
12
  div(style='margin-left: 220px')
13
13
  #container(style='height: 800px')
14
14
  script.
15
+ /* eslint no-undef: 0 */
15
16
  require.config({
16
17
  paths: {
17
18
  vs: 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.35.0/min/vs',
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { Mock } from '../mock';
4
- import { serverConfig } from '@stuntman/shared';
4
+ import { stuntmanConfig } from '@stuntman/shared';
5
5
 
6
- const mock = new Mock(serverConfig);
6
+ const mock = new Mock(stuntmanConfig);
7
7
 
8
8
  mock.start();
package/src/ipUtils.ts CHANGED
@@ -55,7 +55,7 @@ export class IPUtils {
55
55
  return;
56
56
  }
57
57
  logger.debug({ ip: addresses, hostname }, 'resolved hostname');
58
- resolve([addresses[0], ...addresses.slice(1)]);
58
+ resolve([addresses[0]!, ...addresses.slice(1)]);
59
59
  };
60
60
  if (options?.useExternalDns) {
61
61
  if (!this.externalDns) {
package/src/mock.ts CHANGED
@@ -6,7 +6,7 @@ import express from 'express';
6
6
  import { v4 as uuidv4 } from 'uuid';
7
7
  import { getRuleExecutor } from './ruleExecutor';
8
8
  import { getTrafficStore } from './storage';
9
- import { RawHeaders, logger, HttpCode } from '@stuntman/shared';
9
+ import { RawHeaders, logger, HttpCode, naiveGQLParser, escapeStringRegexp } from '@stuntman/shared';
10
10
  import RequestContext from './requestContext';
11
11
  import type * as Stuntman from '@stuntman/shared';
12
12
  import { IPUtils } from './ipUtils';
@@ -17,42 +17,12 @@ type WithRequiredProperty<Type, Key extends keyof Type> = Type & {
17
17
  [Property in Key]-?: Type[Property];
18
18
  };
19
19
 
20
- const naiveGQLParser = (body: Buffer | string): Stuntman.GQLRequestBody | undefined => {
21
- try {
22
- let json: Stuntman.GQLRequestBody | undefined = undefined;
23
- try {
24
- json = JSON.parse(Buffer.isBuffer(body) ? body.toString('utf-8') : body);
25
- } catch (kiss) {
26
- // and swallow
27
- }
28
- if (!json?.query && !json?.operationName) {
29
- return;
30
- }
31
- const lines = json.query
32
- .split('\n')
33
- .map((l) => l.replace(/^\s+/g, '').trim())
34
- .filter((l) => !!l);
35
- if (/^query /.test(lines[0])) {
36
- json.type = 'query';
37
- } else if (/^mutation /.test(lines[0])) {
38
- json.type = 'mutation';
39
- } else {
40
- throw new Error(`Unable to resolve query type of ${lines[0]}`);
41
- }
42
- json.methodName = lines[json.operationName ? 1 : 0].split('(')[0].split('{')[0];
43
- return json;
44
- } catch (error) {
45
- logger.debug(error, 'unable to parse GQL');
46
- }
47
- return undefined;
48
- };
49
-
50
20
  // TODO add proper web proxy mode
51
21
 
52
22
  export class Mock {
53
23
  public readonly mockUuid: string;
54
- protected options: Stuntman.ServerConfig;
55
- protected mockApp: express.Express;
24
+ protected options: Stuntman.Config;
25
+ protected mockApp: express.Express | null = null;
56
26
  protected MOCK_DOMAIN_REGEX: RegExp;
57
27
  protected URL_PORT_REGEX: RegExp;
58
28
  protected server: http.Server | null = null;
@@ -60,7 +30,6 @@ export class Mock {
60
30
  protected trafficStore: LRUCache<string, Stuntman.LogEntry>;
61
31
  protected ipUtils: IPUtils | null = null;
62
32
  private _api: API | null = null;
63
- private requestHandler: (req: express.Request, res: express.Response) => Promise<void>;
64
33
 
65
34
  get apiServer() {
66
35
  if (this.options.api.disabled) {
@@ -76,7 +45,7 @@ export class Mock {
76
45
  return getRuleExecutor(this.mockUuid);
77
46
  }
78
47
 
79
- constructor(options: Stuntman.ServerConfig) {
48
+ constructor(options: Stuntman.Config) {
80
49
  this.mockUuid = uuidv4();
81
50
  this.options = options;
82
51
  if (this.options.mock.httpsPort && (!this.options.mock.httpsKey || !this.options.mock.httpsCert)) {
@@ -101,147 +70,151 @@ export class Mock {
101
70
  ? null
102
71
  : new IPUtils({ mockUuid: this.mockUuid, externalDns: this.options.mock.externalDns });
103
72
 
104
- this.requestHandler = async (req: express.Request, res: express.Response): Promise<void> => {
105
- const ctx: RequestContext | null = RequestContext.get(req);
106
- const requestUuid = ctx?.uuid || uuidv4();
107
- const timestamp = Date.now();
108
- const originalHostname = req.headers.host || req.hostname;
109
- const unproxiedHostname = req.hostname.replace(this.MOCK_DOMAIN_REGEX, '');
110
- const isProxiedHostname = originalHostname !== unproxiedHostname;
111
- const originalRequest = {
73
+ this.requestHandler = this.requestHandler.bind(this);
74
+ }
75
+
76
+ private async requestHandler(req: express.Request, res: express.Response): Promise<void> {
77
+ const ctx: RequestContext | null = RequestContext.get(req);
78
+ const requestUuid = ctx?.uuid || uuidv4();
79
+ const timestamp = Date.now();
80
+ const originalHostname = req.headers.host || req.hostname;
81
+ const unproxiedHostname = req.hostname.replace(this.MOCK_DOMAIN_REGEX, '');
82
+ const isProxiedHostname = originalHostname !== unproxiedHostname;
83
+ const originalRequest = {
84
+ id: requestUuid,
85
+ timestamp,
86
+ url: `${req.protocol}://${req.hostname}${req.originalUrl}`,
87
+ method: req.method,
88
+ rawHeaders: new RawHeaders(...req.rawHeaders),
89
+ ...((Buffer.isBuffer(req.body) && { body: req.body.toString('utf-8') }) ||
90
+ (typeof req.body === 'string' && { body: req.body })),
91
+ };
92
+ logger.debug(originalRequest, 'processing request');
93
+ const logContext: Record<string, any> = {
94
+ requestId: originalRequest.id,
95
+ };
96
+ const mockEntry: WithRequiredProperty<Stuntman.LogEntry, 'modifiedRequest'> = {
97
+ originalRequest,
98
+ modifiedRequest: {
99
+ ...this.unproxyRequest(req),
112
100
  id: requestUuid,
113
101
  timestamp,
114
- url: `${req.protocol}://${req.hostname}${req.originalUrl}`,
115
- method: req.method,
116
- rawHeaders: new RawHeaders(...req.rawHeaders),
117
- ...((Buffer.isBuffer(req.body) && { body: req.body.toString('utf-8') }) ||
118
- (typeof req.body === 'string' && { body: req.body })),
119
- };
120
- logger.debug(originalRequest, 'processing request');
121
- const logContext: Record<string, any> = {
122
- requestId: originalRequest.id,
123
- };
124
- const mockEntry: WithRequiredProperty<Stuntman.LogEntry, 'modifiedRequest'> = {
125
- originalRequest,
126
- modifiedRequest: {
127
- ...this.unproxyRequest(req),
128
- id: requestUuid,
129
- timestamp,
130
- ...(originalRequest.body && { gqlBody: naiveGQLParser(originalRequest.body) }),
131
- },
132
- };
133
- if (!isProxiedHostname) {
134
- this.removeProxyPort(mockEntry.modifiedRequest);
135
- }
136
- const matchingRule = await getRuleExecutor(this.mockUuid).findMatchingRule(mockEntry.modifiedRequest);
137
- if (matchingRule) {
138
- mockEntry.mockRuleId = matchingRule.id;
139
- mockEntry.labels = matchingRule.labels;
140
- if (matchingRule.actions.mockResponse) {
141
- const staticResponse =
142
- typeof matchingRule.actions.mockResponse === 'function'
143
- ? matchingRule.actions.mockResponse(mockEntry.modifiedRequest)
144
- : matchingRule.actions.mockResponse;
145
- mockEntry.modifiedResponse = staticResponse;
146
- logger.debug({ ...logContext, staticResponse }, 'replying with mocked response');
147
- if (matchingRule.storeTraffic) {
148
- this.trafficStore.set(requestUuid, mockEntry);
149
- }
150
- if (staticResponse.rawHeaders) {
151
- for (const header of staticResponse.rawHeaders.toHeaderPairs()) {
152
- res.setHeader(header[0], header[1]);
153
- }
154
- }
155
- res.status(staticResponse.status || 200);
156
- res.send(staticResponse.body);
157
- // static response blocks any further processing
158
- return;
102
+ ...(originalRequest.body && { gqlBody: naiveGQLParser(originalRequest.body) }),
103
+ },
104
+ };
105
+ if (!isProxiedHostname) {
106
+ this.removeProxyPort(mockEntry.modifiedRequest);
107
+ }
108
+ const matchingRule = await getRuleExecutor(this.mockUuid).findMatchingRule(mockEntry.modifiedRequest);
109
+ if (matchingRule) {
110
+ mockEntry.mockRuleId = matchingRule.id;
111
+ mockEntry.labels = matchingRule.labels;
112
+ if (matchingRule.actions.mockResponse) {
113
+ const staticResponse =
114
+ typeof matchingRule.actions.mockResponse === 'function'
115
+ ? matchingRule.actions.mockResponse(mockEntry.modifiedRequest)
116
+ : matchingRule.actions.mockResponse;
117
+ mockEntry.modifiedResponse = staticResponse;
118
+ logger.debug({ ...logContext, staticResponse }, 'replying with mocked response');
119
+ if (matchingRule.storeTraffic) {
120
+ this.trafficStore.set(requestUuid, mockEntry);
159
121
  }
160
- if (matchingRule.actions.modifyRequest) {
161
- mockEntry.modifiedRequest = matchingRule.actions.modifyRequest(mockEntry.modifiedRequest);
162
- logger.debug({ ...logContext, modifiedRequest: mockEntry.modifiedRequest }, 'modified original request');
122
+ if (staticResponse.rawHeaders) {
123
+ for (const header of staticResponse.rawHeaders.toHeaderPairs()) {
124
+ res.setHeader(header[0], header[1]);
125
+ }
163
126
  }
127
+ res.status(staticResponse.status || 200);
128
+ res.send(staticResponse.body);
129
+ // static response blocks any further processing
130
+ return;
164
131
  }
165
- if (this.ipUtils && !isProxiedHostname && !this.ipUtils.isIP(originalHostname)) {
166
- const hostname = originalHostname.split(':')[0];
167
- try {
168
- const internalIPs = await this.ipUtils.resolveIP(hostname);
169
- if (this.ipUtils.isLocalhostIP(internalIPs) && this.options.mock.externalDns.length) {
170
- const externalIPs = await this.ipUtils.resolveIP(hostname, { useExternalDns: true });
171
- logger.debug({ ...logContext, hostname, externalIPs, internalIPs }, 'switched to external IP');
172
- mockEntry.modifiedRequest.url = mockEntry.modifiedRequest.url.replace(
173
- /^(https?:\/\/)[^:/]+/i,
174
- `$1${externalIPs}`
175
- );
176
- }
177
- } catch (error) {
178
- // swallow the exeception, don't think much can be done at this point
179
- logger.warn({ ...logContext, error }, `error trying to resolve IP for "${hostname}"`);
132
+ if (matchingRule.actions.modifyRequest) {
133
+ mockEntry.modifiedRequest = matchingRule.actions.modifyRequest(mockEntry.modifiedRequest);
134
+ logger.debug({ ...logContext, modifiedRequest: mockEntry.modifiedRequest }, 'modified original request');
135
+ }
136
+ }
137
+ if (this.ipUtils && !isProxiedHostname && !this.ipUtils.isIP(originalHostname)) {
138
+ const hostname = originalHostname.split(':')[0]!;
139
+ try {
140
+ const internalIPs = await this.ipUtils.resolveIP(hostname);
141
+ if (this.ipUtils.isLocalhostIP(internalIPs) && this.options.mock.externalDns.length) {
142
+ const externalIPs = await this.ipUtils.resolveIP(hostname, { useExternalDns: true });
143
+ logger.debug({ ...logContext, hostname, externalIPs, internalIPs }, 'switched to external IP');
144
+ mockEntry.modifiedRequest.url = mockEntry.modifiedRequest.url.replace(
145
+ /^(https?:\/\/)[^:/]+/i,
146
+ `$1${externalIPs}`
147
+ );
180
148
  }
149
+ } catch (error) {
150
+ // swallow the exeception, don't think much can be done at this point
151
+ logger.warn({ ...logContext, error }, `error trying to resolve IP for "${hostname}"`);
181
152
  }
153
+ }
182
154
 
183
- const originalResponse: Required<Stuntman.Response> = this.options.mock.disableProxy
184
- ? {
185
- timestamp: Date.now(),
186
- body: undefined,
187
- rawHeaders: new RawHeaders(),
188
- status: 404,
189
- }
190
- : await this.proxyRequest(req, mockEntry, logContext);
155
+ const originalResponse: Required<Stuntman.Response> = this.options.mock.disableProxy
156
+ ? {
157
+ timestamp: Date.now(),
158
+ body: undefined,
159
+ rawHeaders: new RawHeaders(),
160
+ status: 404,
161
+ }
162
+ : await this.proxyRequest(req, mockEntry, logContext);
191
163
 
192
- logger.debug({ ...logContext, originalResponse }, 'received response');
193
- mockEntry.originalResponse = originalResponse;
194
- let modifedResponse: Stuntman.Response = {
195
- ...originalResponse,
196
- rawHeaders: new RawHeaders(
197
- ...Array.from(originalResponse.rawHeaders.toHeaderPairs()).flatMap(([key, value]) => {
198
- // 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)
199
- return [
200
- key,
201
- isProxiedHostname
202
- ? value
203
- : value.replace(
204
- new RegExp(`(?:^|\\b)(${unproxiedHostname.replace('.', '\\.')})(?:\\b|$)`, 'igm'),
205
- originalHostname
206
- ),
207
- ];
208
- })
209
- ),
210
- };
211
- if (matchingRule?.actions.modifyResponse) {
212
- modifedResponse = matchingRule?.actions.modifyResponse(mockEntry.modifiedRequest, originalResponse);
213
- logger.debug({ ...logContext, modifedResponse }, 'modified response');
214
- }
164
+ logger.debug({ ...logContext, originalResponse }, 'received response');
165
+ mockEntry.originalResponse = originalResponse;
166
+ let modifedResponse: Stuntman.Response = {
167
+ ...originalResponse,
168
+ rawHeaders: new RawHeaders(
169
+ ...Array.from(originalResponse.rawHeaders.toHeaderPairs()).flatMap(([key, value]) => {
170
+ // 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)
171
+ return [
172
+ key,
173
+ isProxiedHostname
174
+ ? value
175
+ : value.replace(
176
+ new RegExp(`(?:^|\\b)(${escapeStringRegexp(unproxiedHostname)})(?:\\b|$)`, 'igm'),
177
+ originalHostname
178
+ ),
179
+ ];
180
+ })
181
+ ),
182
+ };
183
+ if (matchingRule?.actions.modifyResponse) {
184
+ modifedResponse = matchingRule?.actions.modifyResponse(mockEntry.modifiedRequest, originalResponse);
185
+ logger.debug({ ...logContext, modifedResponse }, 'modified response');
186
+ }
215
187
 
216
- mockEntry.modifiedResponse = modifedResponse;
217
- if (matchingRule?.storeTraffic) {
218
- this.trafficStore.set(requestUuid, mockEntry);
219
- }
188
+ mockEntry.modifiedResponse = modifedResponse;
189
+ if (matchingRule?.storeTraffic) {
190
+ this.trafficStore.set(requestUuid, mockEntry);
191
+ }
220
192
 
221
- if (modifedResponse.status) {
222
- res.status(modifedResponse.status);
223
- }
224
- if (modifedResponse.rawHeaders) {
225
- for (const header of modifedResponse.rawHeaders.toHeaderPairs()) {
226
- // since fetch decompresses responses we need to get rid of some headers
227
- // TODO maybe could be handled better than just skipping, although express should add these back for new body
228
- // if (/^content-(?:length|encoding)$/i.test(header[0])) {
229
- // continue;
230
- // }
231
- res.setHeader(
232
- header[0],
233
- isProxiedHostname ? header[1].replace(unproxiedHostname, originalHostname) : header[1]
234
- );
235
- }
193
+ if (modifedResponse.status) {
194
+ res.status(modifedResponse.status);
195
+ }
196
+ if (modifedResponse.rawHeaders) {
197
+ for (const header of modifedResponse.rawHeaders.toHeaderPairs()) {
198
+ // since fetch decompresses responses we need to get rid of some headers
199
+ // TODO maybe could be handled better than just skipping, although express should add these back for new body
200
+ // if (/^content-(?:length|encoding)$/i.test(header[0])) {
201
+ // continue;
202
+ // }
203
+ res.setHeader(header[0], isProxiedHostname ? header[1].replace(unproxiedHostname, originalHostname) : header[1]);
236
204
  }
237
- res.end(Buffer.from(modifedResponse.body, 'binary'));
238
- };
205
+ }
206
+ res.end(Buffer.from(modifedResponse.body, 'binary'));
207
+ }
239
208
 
209
+ init() {
210
+ if (this.mockApp) {
211
+ return;
212
+ }
240
213
  this.mockApp = express();
241
214
  // TODO for now request body is just a buffer passed further, not inflated
242
215
  this.mockApp.use(express.raw({ type: '*/*' }));
243
216
 
244
- this.mockApp.use((req: express.Request, res: express.Response, next: express.NextFunction) => {
217
+ this.mockApp.use((req: express.Request, _res: express.Response, next: express.NextFunction) => {
245
218
  RequestContext.bind(req, this.mockUuid);
246
219
  next();
247
220
  });
@@ -315,6 +288,10 @@ export class Mock {
315
288
  }
316
289
 
317
290
  public start() {
291
+ this.init();
292
+ if (!this.mockApp) {
293
+ throw new Error('initialization error');
294
+ }
318
295
  if (this.server) {
319
296
  throw new Error('mock server already started');
320
297
  }
@@ -376,7 +353,7 @@ export class Mock {
376
353
  host.endsWith(`:${this.options.mock.port}`) ||
377
354
  (this.options.mock.httpsPort && host.endsWith(`:${this.options.mock.httpsPort}`))
378
355
  ) {
379
- req.rawHeaders.set('host', host.split(':')[0]);
356
+ req.rawHeaders.set('host', host.split(':')[0]!);
380
357
  }
381
358
  }
382
359
  }
@@ -136,6 +136,7 @@ class RuleExecutor implements Stuntman.RuleExecutorInterface {
136
136
  } catch (error) {
137
137
  logger.error({ ...logContext, ruleId: rule?.id, error }, 'error in rule match function');
138
138
  }
139
+ return undefined;
139
140
  });
140
141
  if (!matchingRule) {
141
142
  logger.debug(logContext, 'no matching rule found');
@@ -207,5 +208,5 @@ export const getRuleExecutor = (mockUuid: string): RuleExecutor => {
207
208
  [...DEFAULT_RULES, ...CUSTOM_RULES].map((r) => ({ ...r, ttlSeconds: Infinity }))
208
209
  );
209
210
  }
210
- return ruleExecutors[mockUuid];
211
+ return ruleExecutors[mockUuid]!;
211
212
  };