@stuntman/server 0.1.0 → 0.1.1

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.
@@ -1,145 +0,0 @@
1
- doctype html
2
- html
3
- head
4
- title Stuntman - rule editor
5
- script(src='https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.35.0/min/vs/loader.min.js')
6
- style
7
- include style.css
8
- body(style='color: rgb(204, 204, 204); background-color: rgb(50, 50, 50)')
9
- button#newRule(type='button', onclick='window.newRule()') New rule
10
- button#saveRule(type='button', onclick='window.saveRule()', disabled) Save rule
11
- div(style='width: 100%; overflow: hidden')
12
- div(style='width: 230px; float: left')
13
- h3 Rules
14
- ul#ruleKeys.no-bullets
15
- each ruleId in ruleKeys
16
- li
17
- button.rule(
18
- type='button',
19
- onclick='window.setRuleModel(this.getAttribute("data-rule-id"))',
20
- data-rule-id=ruleId
21
- )= ruleId
22
- div(style='margin-left: 240px')
23
- #container(style='height: 400px')
24
- script.
25
- const uuidv4 = () => {
26
- function getRandomSymbol(symbol) {
27
- var array;
28
-
29
- if (symbol === 'y') {
30
- array = ['8', '9', 'a', 'b'];
31
- return array[Math.floor(Math.random() * array.length)];
32
- }
33
-
34
- array = new Uint8Array(1);
35
- window.crypto.getRandomValues(array);
36
- return (array[0] % 16).toString(16);
37
- }
38
-
39
- return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, getRandomSymbol);
40
- };
41
- require.config({
42
- paths: {
43
- vs: 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.35.0/min/vs',
44
- },
45
- });
46
- require(['vs/editor/editor.main'], function () {
47
- monaco.languages.typescript.javascriptDefaults.setEagerModelSync(true);
48
- monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({
49
- noSemanticValidation: false,
50
- noSyntaxValidation: false,
51
- });
52
-
53
- // compiler options
54
- monaco.languages.typescript.javascriptDefaults.setCompilerOptions({
55
- target: monaco.languages.typescript.ScriptTarget.ES6,
56
- allowNonTsExtensions: true,
57
- noLib: true,
58
- moduleResolution: monaco.languages.typescript.ModuleResolutionKind.NodeJs,
59
- module: monaco.languages.typescript.ModuleKind.CommonJS,
60
- noEmit: true,
61
- checkJs: true,
62
- allowJs: true,
63
- isolatedModules: true,
64
- typeRoots: ['node_modules/@types'],
65
- });
66
-
67
- monaco.languages.typescript.typescriptDefaults.addExtraLib(`!{INDEX_DTS}`, 'file:///node_modules/@types/stuntman/index.d.ts');
68
- const models = {};
69
- const rules = eval('(!{rules})');
70
- for (const ruleId of Object.keys(rules)) {
71
- models[ruleId] = monaco.editor.createModel("import type * as Stuntman from 'stuntman';\n\nvar STUNTMAN_RULE: Stuntman.Rule = " + rules[ruleId] + ';', 'typescript', `file:///${ruleId}.ts`);
72
- }
73
- const editor = monaco.editor.create(document.getElementById('container'), {
74
- theme: 'vs-dark',
75
- autoIndent: true,
76
- formatOnPaste: true,
77
- formatOnType: true,
78
- automaticLayout: true,
79
- autoIndent: true,
80
- tabSize: 2,
81
- });
82
- editor.onDidChangeModel((event) => {
83
- const isInternal = /^file:\/\/\/internal\/.+/.test(event.newModelUrl);
84
- if (isInternal) {
85
- document.getElementById('saveRule').setAttribute('disabled', 'true');
86
- } else {
87
- document.getElementById('saveRule').removeAttribute('disabled');
88
- }
89
-
90
- setTimeout(() => {
91
- editor.getAction('editor.action.formatDocument').run();
92
- }, 100);
93
- });
94
-
95
- window.setRuleModel = (ruleId) => {
96
- if (history.pushState) {
97
- const newUrl = `${window.location.protocol}//${window.location.host}${window.location.pathname}?ruleId=${encodeURIComponent(ruleId)}`;
98
- window.history.pushState({ path: newUrl }, '', newUrl);
99
- }
100
- editor.setModel(models[ruleId]);
101
- };
102
-
103
- const urlSearchParams = new URLSearchParams(window.location.search);
104
- if (urlSearchParams.has('ruleId') && urlSearchParams.get('ruleId') in models) {
105
- editor.setModel(models[urlSearchParams.get('ruleId')]);
106
- }
107
-
108
- window.saveRule = () => {
109
- document.getElementById('saveRule').setAttribute('disabled', 'true');
110
- const modelUri = editor.getModel().uri;
111
- const result = monaco.languages.typescript.getTypeScriptWorker();
112
- result.then((worker) => {
113
- worker(modelUri).then(function (client) {
114
- client.getEmitOutput(modelUri.toString()).then((output) => {
115
- const ruleFunctionText = output.outputFiles[0].text.replace(/^export .+$/im, '');
116
- const newId = new Function(ruleFunctionText + '\n return STUNTMAN_RULE;')().id;
117
- fetch('/webgui/rules/unsafesave', {
118
- method: 'POST',
119
- headers: { 'content-type': 'text/plain' },
120
- body: ruleFunctionText + '\n return STUNTMAN_RULE;',
121
- }).then((response) => {
122
- if (response.ok) {
123
- window.location.reload();
124
- return;
125
- }
126
- alert('Error when saving rule');
127
- document.getElementById('saveRule').removeAttribute('disabled');
128
- });
129
- });
130
- });
131
- });
132
- };
133
-
134
- window.newRule = () => {
135
- const ruleId = uuidv4();
136
- const emptyRule = `import type * as Stuntman from \'stuntman\';\n\nvar STUNTMAN_RULE: Stuntman.Rule = { id: '${ruleId}', matches: (req: Stuntman.Request) => true, ttlSeconds: 600 };`;
137
- models[ruleId] = monaco.editor.createModel(emptyRule, 'typescript', `file:///${ruleId}.ts`);
138
- const ruleKeyNode = document.getElementById('ruleKeys').firstChild;
139
- const ruleKeyNodeClone = ruleKeyNode.cloneNode(true);
140
- ruleKeyNodeClone.getElementsByTagName('button')[0].setAttribute('data-rule-id', ruleId);
141
- ruleKeyNodeClone.getElementsByTagName('button')[0].innerText = ruleId;
142
- document.getElementById('ruleKeys').appendChild(ruleKeyNodeClone);
143
- window.setRuleModel(ruleId);
144
- };
145
- });
@@ -1,28 +0,0 @@
1
- div#container {
2
- resize: vertical;
3
- overflow: auto;
4
- }
5
-
6
- body {
7
- font-family: Menlo, Monaco, 'Courier New', monospace;
8
- }
9
-
10
- button.rule {
11
- font-family: Menlo, Monaco, 'Courier New', monospace;
12
- background: none !important;
13
- border: none;
14
- padding: 0 !important;
15
- color: #aaa;
16
- text-decoration: underline;
17
- cursor: pointer;
18
- margin-top: 10px;
19
- font-size: x-small;
20
- text-align: left;
21
- }
22
-
23
- ul.no-bullets {
24
- list-style-type: none; /* Remove bullets */
25
- padding: 0; /* Remove padding */
26
- margin: 0; /* Remove margins */
27
- text-align: left;
28
- }
@@ -1,37 +0,0 @@
1
- doctype html
2
- html
3
- head
4
- title Stuntman - rule editor
5
- script(src='https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.35.0/min/vs/loader.min.js')
6
- style
7
- include style.css
8
- body(style='color: rgb(204, 204, 204); background-color: rgb(50, 50, 50)')
9
- div(style='width: 100%; overflow: hidden')
10
- div(style='width: 200px; float: left')
11
- h3 Traffic log
12
- div(style='margin-left: 220px')
13
- #container(style='height: 800px')
14
- script.
15
- require.config({
16
- paths: {
17
- vs: 'https://cdnjs.cloudflare.com/ajax/libs/monaco-editor/0.35.0/min/vs',
18
- },
19
- });
20
- require(['vs/editor/editor.main'], function () {
21
- const traffic = !{ traffic };
22
- const model = monaco.editor.createModel(JSON.stringify(traffic, null, 2), 'json');
23
- const editor = monaco.editor.create(document.getElementById('container'), {
24
- theme: 'vs-dark',
25
- autoIndent: true,
26
- formatOnPaste: true,
27
- formatOnType: true,
28
- automaticLayout: true,
29
- readOnly: true,
30
- });
31
- editor.onDidChangeModel((event) => {
32
- setTimeout(() => {
33
- editor.getAction('editor.action.formatDocument').run();
34
- }, 100);
35
- });
36
- editor.setModel(model);
37
- });
@@ -1,8 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- import { Mock } from '../mock';
4
- import { serverConfig } from '@stuntman/shared';
5
-
6
- const mock = new Mock(serverConfig);
7
-
8
- mock.start();
package/src/index.ts DELETED
@@ -1 +0,0 @@
1
- export { Mock as StuntmanMock } from './mock';
package/src/ipUtils.ts DELETED
@@ -1,83 +0,0 @@
1
- import { networkInterfaces } from 'os';
2
- import dns, { Resolver as DNSResolver } from 'node:dns';
3
- import { getDnsResolutionCache } from './storage';
4
- import { logger } from '@stuntman/shared';
5
-
6
- const localhostIPs: string[] = ['127.0.0.1'];
7
- const IP_WITH_OPTIONAL_PORT_REGEX = /^(?:[0-9]{1,3}\.){3}[0-9]{1,3}(:[0-9]+)?$/i;
8
-
9
- for (const nets of Object.values(networkInterfaces())) {
10
- if (!nets) {
11
- continue;
12
- }
13
- for (const net of nets) {
14
- const familyV4Value = typeof net.family === 'string' ? 'IPv4' : 4;
15
- if (net.family === familyV4Value && !localhostIPs.includes(net.address)) {
16
- localhostIPs.push(net.address);
17
- }
18
- }
19
- }
20
-
21
- export class IPUtils {
22
- protected dnsResolutionCache;
23
- protected mockUuid: string;
24
- externalDns: dns.Resolver | null = null;
25
-
26
- constructor(options: { mockUuid: string; externalDns?: string[] }) {
27
- this.mockUuid = options.mockUuid;
28
- if (options.externalDns?.length) {
29
- this.externalDns = new DNSResolver();
30
- this.externalDns.setServers(options.externalDns);
31
- }
32
- this.dnsResolutionCache = getDnsResolutionCache(this.mockUuid);
33
- }
34
-
35
- isLocalhostIP(ip: string): boolean {
36
- return localhostIPs.includes(ip);
37
- }
38
-
39
- private async resolveIPs(hostname: string, options?: { useExternalDns?: true }): Promise<[string, ...string[]]> {
40
- return new Promise<[string, ...string[]]>((resolve, reject) => {
41
- const callback = (error: NodeJS.ErrnoException | null, addresses: string | string[]) => {
42
- if (error) {
43
- logger.debug({ error, hostname }, 'error resolving hostname');
44
- reject(error);
45
- return;
46
- }
47
- if (typeof addresses === 'string') {
48
- logger.debug({ ip: [addresses], hostname }, 'resolved hostname');
49
- resolve([addresses]);
50
- return;
51
- }
52
- if (!addresses || addresses.length === 0) {
53
- logger.debug({ hostname }, 'missing IPs');
54
- reject(new Error('No addresses found'));
55
- return;
56
- }
57
- logger.debug({ ip: addresses, hostname }, 'resolved hostname');
58
- resolve([addresses[0], ...addresses.slice(1)]);
59
- };
60
- if (options?.useExternalDns) {
61
- if (!this.externalDns) {
62
- reject(new Error('external dns servers not set'));
63
- return;
64
- }
65
- this.externalDns.resolve(hostname, callback);
66
- return;
67
- }
68
- dns.lookup(hostname, callback);
69
- });
70
- }
71
-
72
- async resolveIP(hostname: string, options?: { useExternalDns?: true }): Promise<string> {
73
- const cachedIP = this.dnsResolutionCache.get(hostname);
74
- if (cachedIP) {
75
- return cachedIP;
76
- }
77
- return (await this.resolveIPs(hostname, options))[0];
78
- }
79
-
80
- isIP(hostname: string): boolean {
81
- return IP_WITH_OPTIONAL_PORT_REGEX.test(hostname);
82
- }
83
- }
package/src/mock.ts DELETED
@@ -1,349 +0,0 @@
1
- import http from 'http';
2
- import https from 'https';
3
- import express from 'express';
4
- import { v4 as uuidv4 } from 'uuid';
5
- import { ruleExecutor } from './ruleExecutor';
6
- import { getTrafficStore } from './storage';
7
- import { RawHeaders, logger, HttpCode } from '@stuntman/shared';
8
- import RequestContext from './requestContext';
9
- import type * as Stuntman from '@stuntman/shared';
10
- import { IPUtils } from './ipUtils';
11
- import LRUCache from 'lru-cache';
12
- import { API } from './api/api';
13
-
14
- type WithRequiredProperty<Type, Key extends keyof Type> = Type & {
15
- [Property in Key]-?: Type[Property];
16
- };
17
-
18
- const naiveGQLParser = (body: Buffer | string): Stuntman.GQLRequestBody | undefined => {
19
- try {
20
- let json: Stuntman.GQLRequestBody | undefined = undefined;
21
- try {
22
- json = JSON.parse(Buffer.isBuffer(body) ? body.toString('utf-8') : body);
23
- } catch (kiss) {
24
- // and swallow
25
- }
26
- if (!json?.query && !json?.operationName) {
27
- return;
28
- }
29
- const lines = json.query
30
- .split('\n')
31
- .map((l) => l.replace(/^\s+/g, '').trim())
32
- .filter((l) => !!l);
33
- if (/^query /.test(lines[0])) {
34
- json.type = 'query';
35
- } else if (/^mutation /.test(lines[0])) {
36
- json.type = 'mutation';
37
- } else {
38
- throw new Error(`Unable to resolve query type of ${lines[0]}`);
39
- }
40
- json.methodName = lines[json.operationName ? 1 : 0].split('(')[0].split('{')[0];
41
- return json;
42
- } catch (error) {
43
- logger.debug(error, 'unable to parse GQL');
44
- }
45
- return undefined;
46
- };
47
-
48
- // TODO add proper web proxy mode
49
-
50
- export class Mock {
51
- protected mockUuid: string;
52
- protected options: Stuntman.ServerConfig;
53
- protected mockApp: express.Express;
54
- protected MOCK_DOMAIN_REGEX: RegExp;
55
- protected URL_PORT_REGEX: RegExp;
56
- protected server: http.Server | null = null;
57
- protected serverHttps: https.Server | null = null;
58
- protected trafficStore: LRUCache<string, Stuntman.LogEntry>;
59
- protected ipUtils: IPUtils | null = null;
60
- private _api: API | null = null;
61
-
62
- get apiServer() {
63
- if (this.options.api.disabled) {
64
- return null;
65
- }
66
- if (!this._api) {
67
- this._api = new API({ ...this.options.api, mockUuid: this.mockUuid }, this.options.webgui);
68
- }
69
- return this._api;
70
- }
71
-
72
- constructor(options: Stuntman.ServerConfig) {
73
- this.mockUuid = uuidv4();
74
- this.options = options;
75
- if (this.options.mock.httpsPort && (!this.options.mock.httpsKey || !this.options.mock.httpsCert)) {
76
- throw new Error('missing https key/cert');
77
- }
78
-
79
- this.MOCK_DOMAIN_REGEX = new RegExp(
80
- `(?:\\.([0-9]+))?\\.(?:(?:${this.options.mock.domain})|(?:localhost))(https?)?(:${this.options.mock.port}${
81
- this.options.mock.httpsPort ? `|${this.options.mock.httpsPort}` : ''
82
- })?(?:\\b|$)`,
83
- 'i'
84
- );
85
- this.URL_PORT_REGEX = new RegExp(
86
- `^(https?:\\/\\/[^:/]+):(?:${this.options.mock.port}${
87
- this.options.mock.httpsPort ? `|${this.options.mock.httpsPort}` : ''
88
- })(\\/.*)`,
89
- 'i'
90
- );
91
- this.trafficStore = getTrafficStore(this.mockUuid, this.options.storage.traffic);
92
- this.ipUtils =
93
- !this.options.mock.externalDns || this.options.mock.externalDns.length === 0
94
- ? null
95
- : new IPUtils({ mockUuid: this.mockUuid, externalDns: this.options.mock.externalDns });
96
-
97
- this.mockApp = express();
98
- // TODO for now request body is just a buffer passed further, not inflated
99
- this.mockApp.use(express.raw({ type: '*/*' }));
100
-
101
- this.mockApp.use((req: express.Request, res: express.Response, next: express.NextFunction) => {
102
- RequestContext.bind(req, this.mockUuid);
103
- next();
104
- });
105
-
106
- this.mockApp.all(/.*/, async (req, res) => {
107
- const ctx: RequestContext | null = RequestContext.get(req);
108
- const requestUuid = ctx?.uuid || uuidv4();
109
- const timestamp = Date.now();
110
- const originalHostname = req.hostname;
111
- const unproxiedHostname = req.hostname.replace(this.MOCK_DOMAIN_REGEX, '');
112
- const isProxiedHostname = originalHostname !== unproxiedHostname;
113
- const originalRequest = {
114
- id: requestUuid,
115
- timestamp,
116
- url: `${req.protocol}://${req.hostname}${req.originalUrl}`,
117
- method: req.method,
118
- rawHeaders: new RawHeaders(...req.rawHeaders),
119
- ...(Buffer.isBuffer(req.body) && { body: req.body.toString('utf-8') }),
120
- };
121
- logger.debug(originalRequest, 'processing request');
122
- const logContext: Record<string, any> = {
123
- requestId: originalRequest.id,
124
- };
125
- const mockEntry: WithRequiredProperty<Stuntman.LogEntry, 'modifiedRequest'> = {
126
- originalRequest,
127
- modifiedRequest: {
128
- ...this.unproxyRequest(req),
129
- id: requestUuid,
130
- timestamp,
131
- ...(originalRequest.body && { gqlBody: naiveGQLParser(originalRequest.body) }),
132
- },
133
- };
134
- if (!isProxiedHostname) {
135
- this.removeProxyPort(mockEntry.modifiedRequest);
136
- }
137
- const matchingRule = await ruleExecutor.findMatchingRule(mockEntry.modifiedRequest);
138
- if (matchingRule) {
139
- mockEntry.mockRuleId = matchingRule.id;
140
- mockEntry.labels = matchingRule.labels;
141
- if (matchingRule.actions?.mockResponse) {
142
- const staticResponse =
143
- typeof matchingRule.actions.mockResponse === 'function'
144
- ? matchingRule.actions.mockResponse(mockEntry.modifiedRequest)
145
- : matchingRule.actions.mockResponse;
146
- mockEntry.modifiedResponse = staticResponse;
147
- logger.debug({ ...logContext, staticResponse }, 'replying with mocked response');
148
- if (matchingRule.storeTraffic) {
149
- this.trafficStore.set(requestUuid, mockEntry);
150
- }
151
- if (staticResponse.rawHeaders) {
152
- for (const header of staticResponse.rawHeaders.toHeaderPairs()) {
153
- res.setHeader(header[0], header[1]);
154
- }
155
- }
156
- res.status(staticResponse.status || 200);
157
- res.send(staticResponse.body);
158
- // static response blocks any further processing
159
- return;
160
- }
161
- if (matchingRule.actions?.modifyRequest) {
162
- mockEntry.modifiedRequest = matchingRule.actions?.modifyRequest(mockEntry.modifiedRequest);
163
- logger.debug({ ...logContext, modifiedRequest: mockEntry.modifiedRequest }, 'modified original request');
164
- }
165
- }
166
- if (this.ipUtils && !isProxiedHostname && !this.ipUtils.isIP(originalHostname)) {
167
- const hostname = originalHostname.split(':')[0];
168
- try {
169
- const internalIPs = await this.ipUtils.resolveIP(hostname);
170
- if (this.ipUtils.isLocalhostIP(internalIPs) && this.options.mock.externalDns.length) {
171
- const externalIPs = await this.ipUtils.resolveIP(hostname, { useExternalDns: true });
172
- logger.debug({ ...logContext, hostname, externalIPs, internalIPs }, 'switched to external IP');
173
- mockEntry.modifiedRequest.url = mockEntry.modifiedRequest.url.replace(
174
- /^(https?:\/\/)[^:/]+/i,
175
- `$1${externalIPs}`
176
- );
177
- }
178
- } catch (error) {
179
- // swallow the exeception, don't think much can be done at this point
180
- logger.warn({ ...logContext, error }, `error trying to resolve IP for "${hostname}"`);
181
- }
182
- }
183
-
184
- let controller: AbortController | null = new AbortController();
185
- const fetchTimeout = setTimeout(() => {
186
- if (controller) {
187
- controller.abort(`timeout after ${this.options.mock.timeout}`);
188
- }
189
- }, this.options.mock.timeout);
190
- req.on('close', () => {
191
- logger.debug(logContext, 'remote client canceled the request');
192
- clearTimeout(fetchTimeout);
193
- if (controller) {
194
- controller.abort('remote client canceled the request');
195
- }
196
- });
197
- let targetResponse: Response;
198
- const hasKeepAlive = !!mockEntry.modifiedRequest.rawHeaders
199
- .toHeaderPairs()
200
- .find((h) => /^connection$/.test(h[0]) && /^keep-alive$/.test(h[1]));
201
- try {
202
- targetResponse = await fetch(mockEntry.modifiedRequest.url, {
203
- redirect: 'manual',
204
- headers: mockEntry.modifiedRequest.rawHeaders
205
- .toHeaderPairs()
206
- .filter((h) => !/^connection$/.test(h[0]) && !/^keep-alive$/.test(h[1])),
207
- body: mockEntry.modifiedRequest.body,
208
- method: mockEntry.modifiedRequest.method,
209
- keepalive: !!hasKeepAlive,
210
- });
211
- } finally {
212
- controller = null;
213
- clearTimeout(fetchTimeout);
214
- }
215
- const targetResponseBuffer = Buffer.from(await targetResponse.arrayBuffer());
216
- const originalResponse = {
217
- timestamp: Date.now(),
218
- body: targetResponseBuffer.toString('binary'),
219
- status: targetResponse.status,
220
- rawHeaders: new RawHeaders(
221
- ...Array.from(targetResponse.headers.entries()).flatMap(([key, value]) => [key, value])
222
- ),
223
- };
224
- logger.debug({ ...logContext, originalResponse }, 'received response');
225
- mockEntry.originalResponse = originalResponse;
226
- let modifedResponse: Stuntman.Response = {
227
- ...originalResponse,
228
- rawHeaders: new RawHeaders(
229
- ...Array.from(targetResponse.headers.entries()).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(
236
- new RegExp(`(?:^|\\b)(${unproxiedHostname.replace('.', '\\.')})(?:\\b|$)`, 'igm'),
237
- originalHostname
238
- ),
239
- ];
240
- })
241
- ),
242
- };
243
- if (matchingRule?.actions?.modifyResponse) {
244
- modifedResponse = matchingRule?.actions?.modifyResponse(mockEntry.modifiedRequest, originalResponse);
245
- logger.debug({ ...logContext, modifedResponse }, 'modified response');
246
- }
247
-
248
- mockEntry.modifiedResponse = modifedResponse;
249
- if (matchingRule?.storeTraffic) {
250
- this.trafficStore.set(requestUuid, mockEntry);
251
- }
252
-
253
- if (modifedResponse.status) {
254
- res.status(modifedResponse.status);
255
- }
256
- if (modifedResponse.rawHeaders) {
257
- for (const header of modifedResponse.rawHeaders.toHeaderPairs()) {
258
- // since fetch decompresses responses we need to get rid of some headers
259
- // TODO maybe could be handled better than just skipping, although express should add these back for new body
260
- if (/^content-(?:length|encoding)$/i.test(header[0])) {
261
- continue;
262
- }
263
- res.setHeader(header[0], header[1]);
264
- }
265
- }
266
- res.end(Buffer.from(modifedResponse.body, 'binary'));
267
- });
268
-
269
- this.mockApp.use((error: Error, req: express.Request, res: express.Response, _next: express.NextFunction) => {
270
- const ctx: RequestContext | null = RequestContext.get(req);
271
- const uuid = ctx?.uuid || uuidv4();
272
- logger.error({ ...error, uuid }, 'Unexpected error');
273
- if (res) {
274
- res.status(HttpCode.INTERNAL_SERVER_ERROR).json({
275
- error: { message: error.message, httpCode: HttpCode.INTERNAL_SERVER_ERROR, uuid },
276
- });
277
- return;
278
- }
279
- console.log('mock encountered a critical error. exiting');
280
- process.exit(1);
281
- });
282
- }
283
-
284
- public start() {
285
- if (this.server) {
286
- throw new Error('mock server already started');
287
- }
288
- if (this.options.mock.httpsPort) {
289
- this.serverHttps = https
290
- .createServer(
291
- {
292
- key: this.options.mock.httpsKey,
293
- cert: this.options.mock.httpsCert,
294
- },
295
- this.mockApp
296
- )
297
- .listen(this.options.mock.httpsPort, () => {
298
- logger.info(`Mock listening on ${this.options.mock.domain}:${this.options.mock.httpsPort}`);
299
- });
300
- }
301
- this.server = this.mockApp.listen(this.options.mock.port, () => {
302
- logger.info(`Mock listening on ${this.options.mock.domain}:${this.options.mock.port}`);
303
- if (!this.options.api.disabled) {
304
- this.apiServer?.start();
305
- }
306
- });
307
- }
308
-
309
- public stop() {
310
- if (!this.server) {
311
- throw new Error('mock server not started');
312
- }
313
- if (!this.options.api.disabled) {
314
- this.apiServer?.stop();
315
- }
316
- this.server.close((error) => {
317
- logger.warn(error, 'problem closing server');
318
- this.server = null;
319
- });
320
- }
321
-
322
- protected unproxyRequest(req: express.Request): Stuntman.BaseRequest {
323
- const protocol = (this.MOCK_DOMAIN_REGEX.exec(req.hostname) || [])[2] || req.protocol;
324
- const port = (this.MOCK_DOMAIN_REGEX.exec(req.hostname) || [])[1] || undefined;
325
-
326
- // TODO unproxied req might fail if there's a signed url :shrug:
327
- // but then we can probably switch DNS for some particular 3rd party server to point to mock
328
- // and in mock have a mapping rule for that domain to point directly to some IP :thinking:
329
- return {
330
- url: `${protocol}://${req.hostname.replace(this.MOCK_DOMAIN_REGEX, '')}${port ? `:${port}` : ''}${req.originalUrl}`,
331
- rawHeaders: new RawHeaders(...req.rawHeaders.map((h) => h.replace(this.MOCK_DOMAIN_REGEX, ''))),
332
- method: req.method,
333
- ...(Buffer.isBuffer(req.body) && { body: req.body.toString('utf-8') }),
334
- };
335
- }
336
-
337
- protected removeProxyPort(req: Stuntman.Request): void {
338
- if (this.URL_PORT_REGEX.test(req.url)) {
339
- req.url = req.url.replace(this.URL_PORT_REGEX, '$1$2');
340
- }
341
- const host = req.rawHeaders.get('host') || '';
342
- if (
343
- host.endsWith(`:${this.options.mock.port}`) ||
344
- (this.options.mock.httpsPort && host.endsWith(`:${this.options.mock.httpsPort}`))
345
- ) {
346
- req.rawHeaders.set('host', host.split(':')[0]);
347
- }
348
- }
349
- }