phantomback 1.0.3 → 2.0.0
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 +50 -5
- package/bin/phantomback.js +3 -0
- package/package.json +5 -1
- package/src/cli/commands.js +43 -8
- package/src/features/chaos.js +468 -0
- package/src/index.js +1 -0
- package/src/schema/parser.js +16 -1
- package/src/server/createServer.js +19 -0
- package/src/utils/logger.js +67 -1
package/README.md
CHANGED
|
@@ -284,14 +284,59 @@ curl http://localhost:3777/api/users \
|
|
|
284
284
|
|
|
285
285
|
---
|
|
286
286
|
|
|
287
|
+
## Reality Mode — Chaos Engineering
|
|
288
|
+
|
|
289
|
+
Test your frontend's resilience by simulating real-world production failures.
|
|
290
|
+
|
|
291
|
+
Enable in `phantom.config.js`:
|
|
292
|
+
|
|
293
|
+
```js
|
|
294
|
+
export default {
|
|
295
|
+
// ...
|
|
296
|
+
chaos: {
|
|
297
|
+
enabled: true,
|
|
298
|
+
latency: { min: 200, max: 5000 }, // latency jitter range (ms)
|
|
299
|
+
failureRate: 0.1, // 10% random 5xx responses
|
|
300
|
+
errorCodes: [500, 502, 503, 504],
|
|
301
|
+
connectionDropRate: 0.02, // 2% abrupt connection drops
|
|
302
|
+
corruptionRate: 0.02, // 2% malformed JSON
|
|
303
|
+
timeoutRate: 0.03, // 3% hanging responses
|
|
304
|
+
scenarios: ['latency', 'failure', 'drop', 'corruption', 'timeout'],
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
```
|
|
308
|
+
|
|
309
|
+
Or enable instantly from the CLI:
|
|
310
|
+
|
|
311
|
+
```bash
|
|
312
|
+
phantomback start --chaos # enable with defaults
|
|
313
|
+
phantomback start --chaos --chaos-failure 0.2 # 20% failure rate
|
|
314
|
+
phantomback start --chaos --chaos-latency 500,3000 # 500–3000 ms jitter
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
| Scenario | Config Key | Description |
|
|
318
|
+
|---|---|---|
|
|
319
|
+
| `latency` | `latency` | Injects random delay on ~30% of requests |
|
|
320
|
+
| `failure` | `failureRate` | Returns a random 5xx error |
|
|
321
|
+
| `drop` | `connectionDropRate` | Abruptly closes the TCP connection |
|
|
322
|
+
| `corruption` | `corruptionRate` | Sends malformed / partial JSON |
|
|
323
|
+
| `timeout` | `timeoutRate` | Hangs the response for ~30 seconds |
|
|
324
|
+
|
|
325
|
+
> **Full guide →** [phantombackxdocs.vercel.app/docs/reality-mode](https://phantombackxdocs.vercel.app/docs/reality-mode)
|
|
326
|
+
|
|
327
|
+
---
|
|
328
|
+
|
|
287
329
|
## CLI Reference
|
|
288
330
|
|
|
289
331
|
```bash
|
|
290
|
-
phantomback start
|
|
291
|
-
phantomback start --zero
|
|
292
|
-
phantomback start --port 4000
|
|
293
|
-
phantomback start --config ./my-api.config.js
|
|
294
|
-
phantomback
|
|
332
|
+
phantomback start # start with phantom.config.js
|
|
333
|
+
phantomback start --zero # zero-config demo mode
|
|
334
|
+
phantomback start --port 4000 # custom port
|
|
335
|
+
phantomback start --config ./my-api.config.js # custom config path
|
|
336
|
+
phantomback start --chaos # enable Reality Mode
|
|
337
|
+
phantomback start --chaos --chaos-failure 0.2 # 20% failure rate
|
|
338
|
+
phantomback start --chaos --chaos-latency 200,5000 # latency jitter range
|
|
339
|
+
phantomback init # generate starter config
|
|
295
340
|
phantomback --help
|
|
296
341
|
```
|
|
297
342
|
|
package/bin/phantomback.js
CHANGED
|
@@ -23,6 +23,9 @@ program
|
|
|
23
23
|
.option('--prefix <prefix>', 'API route prefix', '/api')
|
|
24
24
|
.option('-c, --config <path>', 'Path to config file')
|
|
25
25
|
.option('-z, --zero', 'Zero-config mode: generate a full demo backend')
|
|
26
|
+
.option('--chaos', 'Enable Reality Mode (chaos engineering)')
|
|
27
|
+
.option('--chaos-failure <rate>', 'Failure rate for Reality Mode (0-1)', parseFloat)
|
|
28
|
+
.option('--chaos-latency <range>', 'Latency range in ms (e.g. "200,5000")')
|
|
26
29
|
.action(async (options) => {
|
|
27
30
|
const { startCommand } = await import('../src/cli/commands.js');
|
|
28
31
|
await startCommand(options);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "phantomback",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
4
|
"description": "Instant fake backend generator with smart responses & chaos engineering. Drop in your API schema → get a fully functional, stateful REST server with realistic data, auth, pagination, filtering, and Reality Mode for chaos testing.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -27,6 +27,10 @@
|
|
|
27
27
|
"development",
|
|
28
28
|
"prototyping",
|
|
29
29
|
"chaos-engineering",
|
|
30
|
+
"reality-mode",
|
|
31
|
+
"chaos-testing",
|
|
32
|
+
"fault-injection",
|
|
33
|
+
"resilience",
|
|
30
34
|
"testing",
|
|
31
35
|
"frontend",
|
|
32
36
|
"faker",
|
package/src/cli/commands.js
CHANGED
|
@@ -69,14 +69,24 @@ export default {
|
|
|
69
69
|
},
|
|
70
70
|
},
|
|
71
71
|
|
|
72
|
-
// Chaos Engineering (Reality Mode)
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
72
|
+
// Chaos Engineering (Reality Mode)
|
|
73
|
+
chaos: {
|
|
74
|
+
enabled: false,
|
|
75
|
+
// Latency jitter range (ms) — random delays injected on ~30% of requests
|
|
76
|
+
latency: { min: 200, max: 5000 },
|
|
77
|
+
// Probability (0-1) of returning a random 5xx error
|
|
78
|
+
failureRate: 0.1,
|
|
79
|
+
// HTTP error codes used for random failures
|
|
80
|
+
errorCodes: [500, 502, 503, 504],
|
|
81
|
+
// Probability of abruptly dropping the connection
|
|
82
|
+
connectionDropRate: 0.02,
|
|
83
|
+
// Probability of sending malformed/partial JSON
|
|
84
|
+
corruptionRate: 0.02,
|
|
85
|
+
// Probability of request hanging (30s timeout)
|
|
86
|
+
timeoutRate: 0.03,
|
|
87
|
+
// Which chaos scenarios to activate
|
|
88
|
+
scenarios: ['latency', 'failure', 'drop', 'corruption', 'timeout'],
|
|
89
|
+
},
|
|
80
90
|
};
|
|
81
91
|
`;
|
|
82
92
|
|
|
@@ -114,6 +124,31 @@ export async function startCommand(options) {
|
|
|
114
124
|
if (options.port) config.port = parseInt(options.port, 10);
|
|
115
125
|
if (options.prefix) config.prefix = options.prefix;
|
|
116
126
|
|
|
127
|
+
// Reality Mode (Chaos) CLI overrides
|
|
128
|
+
if (options.chaos) {
|
|
129
|
+
config.chaos = config.chaos || {};
|
|
130
|
+
config.chaos.enabled = true;
|
|
131
|
+
}
|
|
132
|
+
if (options.chaosFailure !== undefined) {
|
|
133
|
+
config.chaos = config.chaos || {};
|
|
134
|
+
config.chaos.enabled = true;
|
|
135
|
+
const rate = options.chaosFailure;
|
|
136
|
+
if (rate < 0 || rate > 1) {
|
|
137
|
+
logger.warn('--chaos-failure must be between 0 and 1. Clamping to valid range.');
|
|
138
|
+
}
|
|
139
|
+
config.chaos.failureRate = Math.max(0, Math.min(1, rate));
|
|
140
|
+
}
|
|
141
|
+
if (options.chaosLatency) {
|
|
142
|
+
config.chaos = config.chaos || {};
|
|
143
|
+
config.chaos.enabled = true;
|
|
144
|
+
const parts = options.chaosLatency.split(',').map(Number);
|
|
145
|
+
if (parts.length === 2 && !isNaN(parts[0]) && !isNaN(parts[1]) && parts[0] >= 0 && parts[1] >= parts[0]) {
|
|
146
|
+
config.chaos.latency = { min: parts[0], max: parts[1] };
|
|
147
|
+
} else {
|
|
148
|
+
logger.warn('Invalid --chaos-latency format. Expected "min,max" (e.g. "200,5000"). Using defaults.');
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
117
152
|
// Check if using defaults (no config found and no resources)
|
|
118
153
|
if (Object.keys(config.resources).length === 0) {
|
|
119
154
|
logger.warn('No config found and no resources defined. Using zero-config defaults.');
|
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reality Mode — Chaos Engineering Middleware for PhantomBack
|
|
3
|
+
*
|
|
4
|
+
* Simulates real-world production instability during development:
|
|
5
|
+
* • Latency spikes (jitter)
|
|
6
|
+
* • Random HTTP failures (5xx errors)
|
|
7
|
+
* • Connection drops (socket destruction)
|
|
8
|
+
* • Response corruption (malformed JSON)
|
|
9
|
+
* • Request timeouts (hanging responses)
|
|
10
|
+
* • Out-of-order response delays
|
|
11
|
+
*
|
|
12
|
+
* Configuration:
|
|
13
|
+
* chaos: {
|
|
14
|
+
* enabled: true,
|
|
15
|
+
* latency: { min: 200, max: 5000 },
|
|
16
|
+
* failureRate: 0.1,
|
|
17
|
+
* errorCodes: [500, 502, 503, 504],
|
|
18
|
+
* connectionDropRate: 0.02,
|
|
19
|
+
* corruptionRate: 0.02,
|
|
20
|
+
* timeoutRate: 0.03,
|
|
21
|
+
* scenarios: ['latency', 'failure', 'drop', 'corruption', 'timeout'],
|
|
22
|
+
* }
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { logger } from '../utils/logger.js';
|
|
26
|
+
|
|
27
|
+
// ─── Default Chaos Configuration ─────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
const DEFAULT_CHAOS_CONFIG = {
|
|
30
|
+
enabled: false,
|
|
31
|
+
latency: { min: 200, max: 5000 },
|
|
32
|
+
failureRate: 0.1,
|
|
33
|
+
errorCodes: [500, 502, 503, 504],
|
|
34
|
+
connectionDropRate: 0.02,
|
|
35
|
+
corruptionRate: 0.02,
|
|
36
|
+
timeoutRate: 0.03,
|
|
37
|
+
scenarios: ['latency', 'failure', 'drop', 'corruption', 'timeout'],
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// ─── Chaos Engine ────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
export class ChaosEngine {
|
|
43
|
+
constructor(config = {}) {
|
|
44
|
+
this.config = {
|
|
45
|
+
...DEFAULT_CHAOS_CONFIG,
|
|
46
|
+
...config,
|
|
47
|
+
latency: {
|
|
48
|
+
...DEFAULT_CHAOS_CONFIG.latency,
|
|
49
|
+
...(config.latency || {}),
|
|
50
|
+
},
|
|
51
|
+
errorCodes: config.errorCodes || DEFAULT_CHAOS_CONFIG.errorCodes,
|
|
52
|
+
scenarios: config.scenarios || DEFAULT_CHAOS_CONFIG.scenarios,
|
|
53
|
+
};
|
|
54
|
+
this.stats = {
|
|
55
|
+
totalRequests: 0,
|
|
56
|
+
chaosApplied: 0,
|
|
57
|
+
latencySpikes: 0,
|
|
58
|
+
failures: 0,
|
|
59
|
+
drops: 0,
|
|
60
|
+
corruptions: 0,
|
|
61
|
+
timeouts: 0,
|
|
62
|
+
startedAt: new Date().toISOString(),
|
|
63
|
+
};
|
|
64
|
+
this.paused = false;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Check if a specific scenario is enabled */
|
|
68
|
+
isScenarioEnabled(name) {
|
|
69
|
+
return this.config.enabled && !this.paused && this.config.scenarios.includes(name);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Roll the dice — returns true with the given probability (0-1) */
|
|
73
|
+
shouldTrigger(rate) {
|
|
74
|
+
return Math.random() < rate;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Generate a random latency value within the configured jitter range */
|
|
78
|
+
getJitter() {
|
|
79
|
+
const { min, max } = this.config.latency;
|
|
80
|
+
return Math.floor(Math.random() * (max - min + 1)) + min;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Pick a random error code from the configured list */
|
|
84
|
+
getRandomErrorCode() {
|
|
85
|
+
const codes = this.config.errorCodes;
|
|
86
|
+
return codes[Math.floor(Math.random() * codes.length)];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Enable chaos at runtime */
|
|
90
|
+
enable() {
|
|
91
|
+
this.config.enabled = true;
|
|
92
|
+
this.paused = false;
|
|
93
|
+
logger.chaos('Reality Mode ENABLED');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Disable chaos at runtime */
|
|
97
|
+
disable() {
|
|
98
|
+
this.config.enabled = false;
|
|
99
|
+
logger.chaos('Reality Mode DISABLED');
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Pause chaos temporarily */
|
|
103
|
+
pause() {
|
|
104
|
+
this.paused = true;
|
|
105
|
+
logger.chaos('Reality Mode PAUSED');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Resume chaos after pause */
|
|
109
|
+
resume() {
|
|
110
|
+
this.paused = false;
|
|
111
|
+
logger.chaos('Reality Mode RESUMED');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Update chaos configuration at runtime */
|
|
115
|
+
configure(newConfig) {
|
|
116
|
+
this.config = { ...this.config, ...newConfig };
|
|
117
|
+
logger.chaos('Configuration updated');
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Get current status and stats */
|
|
121
|
+
getStatus() {
|
|
122
|
+
return {
|
|
123
|
+
enabled: this.config.enabled,
|
|
124
|
+
paused: this.paused,
|
|
125
|
+
active: this.config.enabled && !this.paused,
|
|
126
|
+
config: this.config,
|
|
127
|
+
stats: { ...this.stats },
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Reset stats counters */
|
|
132
|
+
resetStats() {
|
|
133
|
+
this.stats = {
|
|
134
|
+
totalRequests: 0,
|
|
135
|
+
chaosApplied: 0,
|
|
136
|
+
latencySpikes: 0,
|
|
137
|
+
failures: 0,
|
|
138
|
+
drops: 0,
|
|
139
|
+
corruptions: 0,
|
|
140
|
+
timeouts: 0,
|
|
141
|
+
startedAt: new Date().toISOString(),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// ─── Chaos Scenarios ─────────────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
const ERROR_MESSAGES = {
|
|
149
|
+
500: 'Internal Server Error — [Reality Mode] Simulated server crash',
|
|
150
|
+
502: 'Bad Gateway — [Reality Mode] Upstream service unavailable',
|
|
151
|
+
503: 'Service Unavailable — [Reality Mode] Server overloaded',
|
|
152
|
+
504: 'Gateway Timeout — [Reality Mode] Upstream request timed out',
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Scenario: Latency Spike
|
|
157
|
+
* Adds a random delay to simulate network jitter or slow backends
|
|
158
|
+
*/
|
|
159
|
+
function applyLatencySpike(engine, _req, _res) {
|
|
160
|
+
if (!engine.isScenarioEnabled('latency')) return null;
|
|
161
|
+
if (!engine.shouldTrigger(0.3)) return null; // 30% of requests get jitter
|
|
162
|
+
|
|
163
|
+
const delay = engine.getJitter();
|
|
164
|
+
engine.stats.latencySpikes++;
|
|
165
|
+
|
|
166
|
+
return new Promise((resolve) => {
|
|
167
|
+
logger.chaos(`Latency spike: +${delay}ms`);
|
|
168
|
+
setTimeout(resolve, delay);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Scenario: Random Failure
|
|
174
|
+
* Returns a random 5xx error response
|
|
175
|
+
*/
|
|
176
|
+
function applyFailure(engine, _req, res) {
|
|
177
|
+
if (!engine.isScenarioEnabled('failure')) return false;
|
|
178
|
+
if (!engine.shouldTrigger(engine.config.failureRate)) return false;
|
|
179
|
+
|
|
180
|
+
const code = engine.getRandomErrorCode();
|
|
181
|
+
engine.stats.failures++;
|
|
182
|
+
|
|
183
|
+
logger.chaos(`Random failure: HTTP ${code}`);
|
|
184
|
+
res.status(code).json({
|
|
185
|
+
success: false,
|
|
186
|
+
error: {
|
|
187
|
+
status: code,
|
|
188
|
+
message: ERROR_MESSAGES[code] || `HTTP ${code} — [Reality Mode] Simulated failure`,
|
|
189
|
+
chaos: true,
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Scenario: Connection Drop
|
|
198
|
+
* Destroys the socket mid-request to simulate network issues
|
|
199
|
+
*/
|
|
200
|
+
function applyConnectionDrop(engine, req, _res) {
|
|
201
|
+
if (!engine.isScenarioEnabled('drop')) return false;
|
|
202
|
+
if (!engine.shouldTrigger(engine.config.connectionDropRate)) return false;
|
|
203
|
+
|
|
204
|
+
engine.stats.drops++;
|
|
205
|
+
logger.chaos(`Connection drop: ${req.method} ${req.originalUrl}`);
|
|
206
|
+
|
|
207
|
+
// Destroy the underlying socket
|
|
208
|
+
if (req.socket) {
|
|
209
|
+
req.socket.destroy();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return true;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Scenario: Response Corruption
|
|
217
|
+
* Sends back malformed/partial JSON to test error handling
|
|
218
|
+
*/
|
|
219
|
+
function applyCorruption(engine, req, res) {
|
|
220
|
+
if (!engine.isScenarioEnabled('corruption')) return false;
|
|
221
|
+
if (!engine.shouldTrigger(engine.config.corruptionRate)) return false;
|
|
222
|
+
|
|
223
|
+
engine.stats.corruptions++;
|
|
224
|
+
logger.chaos(`Response corruption: ${req.method} ${req.originalUrl}`);
|
|
225
|
+
|
|
226
|
+
// Pick a random corruption type
|
|
227
|
+
const corruptions = [
|
|
228
|
+
// Truncated JSON
|
|
229
|
+
() => {
|
|
230
|
+
res.setHeader('Content-Type', 'application/json');
|
|
231
|
+
res.status(200).end('{"success":true,"data":[{"id":"abc","na');
|
|
232
|
+
},
|
|
233
|
+
// Invalid JSON
|
|
234
|
+
() => {
|
|
235
|
+
res.setHeader('Content-Type', 'application/json');
|
|
236
|
+
res.status(200).end('{success: true, data: undefined}');
|
|
237
|
+
},
|
|
238
|
+
// Empty body with 200
|
|
239
|
+
() => {
|
|
240
|
+
res.status(200).end('');
|
|
241
|
+
},
|
|
242
|
+
// Wrong content type
|
|
243
|
+
() => {
|
|
244
|
+
res.setHeader('Content-Type', 'text/html');
|
|
245
|
+
res.status(200).end('<html><body>Unexpected HTML response</body></html>');
|
|
246
|
+
},
|
|
247
|
+
// Partial response with wrong status
|
|
248
|
+
() => {
|
|
249
|
+
res.status(206).json({
|
|
250
|
+
success: true,
|
|
251
|
+
data: null,
|
|
252
|
+
error: { message: '[Reality Mode] Partial response — data truncated' },
|
|
253
|
+
chaos: true,
|
|
254
|
+
});
|
|
255
|
+
},
|
|
256
|
+
];
|
|
257
|
+
|
|
258
|
+
const corrupt = corruptions[Math.floor(Math.random() * corruptions.length)];
|
|
259
|
+
corrupt();
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Scenario: Request Timeout
|
|
265
|
+
* Holds the connection open without responding (simulates hung backend)
|
|
266
|
+
*/
|
|
267
|
+
function applyTimeout(engine, req, _res) {
|
|
268
|
+
if (!engine.isScenarioEnabled('timeout')) return false;
|
|
269
|
+
if (!engine.shouldTrigger(engine.config.timeoutRate)) return false;
|
|
270
|
+
|
|
271
|
+
engine.stats.timeouts++;
|
|
272
|
+
logger.chaos(`Request timeout: ${req.method} ${req.originalUrl} (hanging for 30s)`);
|
|
273
|
+
|
|
274
|
+
// Return a promise that resolves after 30s (or until client gives up)
|
|
275
|
+
return new Promise((resolve) => {
|
|
276
|
+
const timer = setTimeout(resolve, 30000);
|
|
277
|
+
|
|
278
|
+
// Clean up if client disconnects
|
|
279
|
+
req.on('close', () => {
|
|
280
|
+
clearTimeout(timer);
|
|
281
|
+
resolve();
|
|
282
|
+
});
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ─── Main Chaos Middleware ───────────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Create the Reality Mode middleware.
|
|
290
|
+
* This is the main entry point — attach to Express before resource routes.
|
|
291
|
+
*
|
|
292
|
+
* @param {ChaosEngine} engine - Chaos engine instance
|
|
293
|
+
* @returns {Function} Express middleware
|
|
294
|
+
*/
|
|
295
|
+
export function chaosMiddleware(engine) {
|
|
296
|
+
return async (req, res, next) => {
|
|
297
|
+
engine.stats.totalRequests++;
|
|
298
|
+
|
|
299
|
+
// Skip if chaos is disabled or paused
|
|
300
|
+
if (!engine.config.enabled || engine.paused) {
|
|
301
|
+
return next();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Skip chaos control endpoints
|
|
305
|
+
if (req.path.includes('/_chaos')) {
|
|
306
|
+
return next();
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Skip health check
|
|
310
|
+
if (req.path.includes('/_health')) {
|
|
311
|
+
return next();
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Add chaos header so clients know Reality Mode is active
|
|
315
|
+
res.setHeader('X-PhantomBack-Chaos', 'active');
|
|
316
|
+
|
|
317
|
+
// ── Execute scenarios in priority order ──
|
|
318
|
+
|
|
319
|
+
// 1. Connection Drop (highest priority — immediate termination)
|
|
320
|
+
if (applyConnectionDrop(engine, req, res)) {
|
|
321
|
+
engine.stats.chaosApplied++;
|
|
322
|
+
return; // Socket destroyed, nothing more to do
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// 2. Request Timeout (holds connection)
|
|
326
|
+
const timeoutResult = applyTimeout(engine, req, res);
|
|
327
|
+
if (timeoutResult instanceof Promise) {
|
|
328
|
+
engine.stats.chaosApplied++;
|
|
329
|
+
await timeoutResult;
|
|
330
|
+
// After timeout, destroy the socket (client likely already disconnected)
|
|
331
|
+
if (req.socket && !req.socket.destroyed) {
|
|
332
|
+
req.socket.destroy();
|
|
333
|
+
}
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// 3. Random Failure (returns error response)
|
|
338
|
+
if (applyFailure(engine, req, res)) {
|
|
339
|
+
engine.stats.chaosApplied++;
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// 4. Response Corruption (sends malformed data)
|
|
344
|
+
if (applyCorruption(engine, req, res)) {
|
|
345
|
+
engine.stats.chaosApplied++;
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// 5. Latency Spike (adds delay, then continues to real handler)
|
|
350
|
+
const latencyResult = applyLatencySpike(engine, req, res);
|
|
351
|
+
if (latencyResult instanceof Promise) {
|
|
352
|
+
engine.stats.chaosApplied++;
|
|
353
|
+
await latencyResult;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// If no chaos blocked the request, proceed normally
|
|
357
|
+
next();
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ─── Chaos Control Routes ────────────────────────────────────────────────────
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Register chaos control endpoints on the Express app.
|
|
365
|
+
* These allow runtime control of Reality Mode.
|
|
366
|
+
*
|
|
367
|
+
* Routes:
|
|
368
|
+
* GET {prefix}/_chaos — Status & stats
|
|
369
|
+
* POST {prefix}/_chaos/enable — Enable chaos
|
|
370
|
+
* POST {prefix}/_chaos/disable — Disable chaos
|
|
371
|
+
* POST {prefix}/_chaos/pause — Pause chaos
|
|
372
|
+
* POST {prefix}/_chaos/resume — Resume chaos
|
|
373
|
+
* POST {prefix}/_chaos/configure — Update config
|
|
374
|
+
* POST {prefix}/_chaos/reset — Reset stats
|
|
375
|
+
*/
|
|
376
|
+
export function createChaosRoutes(app, engine, config) {
|
|
377
|
+
const prefix = config.prefix || '/api';
|
|
378
|
+
|
|
379
|
+
// GET /_chaos — status dashboard
|
|
380
|
+
app.get(`${prefix}/_chaos`, (_req, res) => {
|
|
381
|
+
const status = engine.getStatus();
|
|
382
|
+
res.json({
|
|
383
|
+
success: true,
|
|
384
|
+
message: status.active
|
|
385
|
+
? '🔥 Reality Mode is ACTIVE — chaos is being injected!'
|
|
386
|
+
: '😴 Reality Mode is inactive',
|
|
387
|
+
...status,
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// POST /_chaos/enable
|
|
392
|
+
app.post(`${prefix}/_chaos/enable`, (_req, res) => {
|
|
393
|
+
engine.enable();
|
|
394
|
+
res.json({
|
|
395
|
+
success: true,
|
|
396
|
+
message: '🔥 Reality Mode ENABLED — brace yourself!',
|
|
397
|
+
...engine.getStatus(),
|
|
398
|
+
});
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
// POST /_chaos/disable
|
|
402
|
+
app.post(`${prefix}/_chaos/disable`, (_req, res) => {
|
|
403
|
+
engine.disable();
|
|
404
|
+
res.json({
|
|
405
|
+
success: true,
|
|
406
|
+
message: '😴 Reality Mode DISABLED — back to calm waters',
|
|
407
|
+
...engine.getStatus(),
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
// POST /_chaos/pause
|
|
412
|
+
app.post(`${prefix}/_chaos/pause`, (_req, res) => {
|
|
413
|
+
engine.pause();
|
|
414
|
+
res.json({
|
|
415
|
+
success: true,
|
|
416
|
+
message: '⏸️ Reality Mode PAUSED',
|
|
417
|
+
...engine.getStatus(),
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
// POST /_chaos/resume
|
|
422
|
+
app.post(`${prefix}/_chaos/resume`, (_req, res) => {
|
|
423
|
+
engine.resume();
|
|
424
|
+
res.json({
|
|
425
|
+
success: true,
|
|
426
|
+
message: '▶️ Reality Mode RESUMED',
|
|
427
|
+
...engine.getStatus(),
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
// POST /_chaos/configure — update chaos config at runtime
|
|
432
|
+
app.post(`${prefix}/_chaos/configure`, (req, res) => {
|
|
433
|
+
const updates = req.body;
|
|
434
|
+
if (!updates || typeof updates !== 'object') {
|
|
435
|
+
return res.status(400).json({
|
|
436
|
+
success: false,
|
|
437
|
+
error: { status: 400, message: 'Request body must be a JSON object with chaos config' },
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
engine.configure(updates);
|
|
442
|
+
res.json({
|
|
443
|
+
success: true,
|
|
444
|
+
message: '⚙️ Chaos configuration updated',
|
|
445
|
+
...engine.getStatus(),
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
// POST /_chaos/reset — reset stats
|
|
450
|
+
app.post(`${prefix}/_chaos/reset`, (_req, res) => {
|
|
451
|
+
engine.resetStats();
|
|
452
|
+
res.json({
|
|
453
|
+
success: true,
|
|
454
|
+
message: '📊 Chaos stats reset',
|
|
455
|
+
...engine.getStatus(),
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
// Log registered chaos routes
|
|
460
|
+
logger.chaos('Control endpoints registered:');
|
|
461
|
+
logger.route('GET', `${prefix}/_chaos`);
|
|
462
|
+
logger.route('POST', `${prefix}/_chaos/enable`);
|
|
463
|
+
logger.route('POST', `${prefix}/_chaos/disable`);
|
|
464
|
+
logger.route('POST', `${prefix}/_chaos/pause`);
|
|
465
|
+
logger.route('POST', `${prefix}/_chaos/resume`);
|
|
466
|
+
logger.route('POST', `${prefix}/_chaos/configure`);
|
|
467
|
+
logger.route('POST', `${prefix}/_chaos/reset`);
|
|
468
|
+
}
|
package/src/index.js
CHANGED
|
@@ -64,4 +64,5 @@ export { createServer } from './server/createServer.js';
|
|
|
64
64
|
export { parseConfig } from './schema/parser.js';
|
|
65
65
|
export { DEFAULT_RESOURCES } from './schema/defaults.js';
|
|
66
66
|
export { DataStore } from './data/store.js';
|
|
67
|
+
export { ChaosEngine, chaosMiddleware, createChaosRoutes } from './features/chaos.js';
|
|
67
68
|
export { logger } from './utils/logger.js';
|
package/src/schema/parser.js
CHANGED
|
@@ -16,6 +16,13 @@ export const DEFAULT_CONFIG = {
|
|
|
16
16
|
},
|
|
17
17
|
chaos: {
|
|
18
18
|
enabled: false,
|
|
19
|
+
latency: { min: 200, max: 5000 },
|
|
20
|
+
failureRate: 0.1,
|
|
21
|
+
errorCodes: [500, 502, 503, 504],
|
|
22
|
+
connectionDropRate: 0.02,
|
|
23
|
+
corruptionRate: 0.02,
|
|
24
|
+
timeoutRate: 0.03,
|
|
25
|
+
scenarios: ['latency', 'failure', 'drop', 'corruption', 'timeout'],
|
|
19
26
|
},
|
|
20
27
|
resources: {},
|
|
21
28
|
snapshot: false,
|
|
@@ -79,6 +86,8 @@ async function findConfigFile() {
|
|
|
79
86
|
* Merge user config with defaults
|
|
80
87
|
*/
|
|
81
88
|
function mergeConfig(userConfig) {
|
|
89
|
+
const userChaos = userConfig.chaos || {};
|
|
90
|
+
|
|
82
91
|
return {
|
|
83
92
|
...DEFAULT_CONFIG,
|
|
84
93
|
...userConfig,
|
|
@@ -88,7 +97,13 @@ function mergeConfig(userConfig) {
|
|
|
88
97
|
},
|
|
89
98
|
chaos: {
|
|
90
99
|
...DEFAULT_CONFIG.chaos,
|
|
91
|
-
...
|
|
100
|
+
...userChaos,
|
|
101
|
+
latency: {
|
|
102
|
+
...DEFAULT_CONFIG.chaos.latency,
|
|
103
|
+
...(userChaos.latency || {}),
|
|
104
|
+
},
|
|
105
|
+
errorCodes: userChaos.errorCodes || DEFAULT_CONFIG.chaos.errorCodes,
|
|
106
|
+
scenarios: userChaos.scenarios || DEFAULT_CONFIG.chaos.scenarios,
|
|
92
107
|
},
|
|
93
108
|
};
|
|
94
109
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createExpressApp, addErrorHandling } from './middleware.js';
|
|
2
2
|
import { createRouter } from './router.js';
|
|
3
3
|
import { createAuthRoutes } from '../features/auth.js';
|
|
4
|
+
import { ChaosEngine, chaosMiddleware, createChaosRoutes } from '../features/chaos.js';
|
|
4
5
|
import { seedAll } from '../data/seeder.js';
|
|
5
6
|
import { DataStore } from '../data/store.js';
|
|
6
7
|
import { logger } from '../utils/logger.js';
|
|
@@ -15,6 +16,22 @@ export async function createServer(config) {
|
|
|
15
16
|
const store = new DataStore();
|
|
16
17
|
const app = createExpressApp(config);
|
|
17
18
|
|
|
19
|
+
// Initialize Reality Mode (Chaos Engine)
|
|
20
|
+
const chaosConfig = config.chaos || {};
|
|
21
|
+
const chaos = new ChaosEngine(chaosConfig);
|
|
22
|
+
|
|
23
|
+
// Register chaos control endpoints (before chaos middleware so they're never affected)
|
|
24
|
+
createChaosRoutes(app, chaos, config);
|
|
25
|
+
|
|
26
|
+
// Always mount chaos middleware so runtime toggling via /_chaos/enable works
|
|
27
|
+
// The middleware itself checks engine.config.enabled internally
|
|
28
|
+
app.use(chaosMiddleware(chaos));
|
|
29
|
+
|
|
30
|
+
// Print chaos banner if enabled at startup
|
|
31
|
+
if (chaosConfig.enabled) {
|
|
32
|
+
logger.chaosBanner(chaosConfig);
|
|
33
|
+
}
|
|
34
|
+
|
|
18
35
|
// Seed data
|
|
19
36
|
seedAll(config.resources, store);
|
|
20
37
|
|
|
@@ -43,6 +60,7 @@ export async function createServer(config) {
|
|
|
43
60
|
app,
|
|
44
61
|
server,
|
|
45
62
|
store,
|
|
63
|
+
chaos,
|
|
46
64
|
stop: () =>
|
|
47
65
|
new Promise((resolve) => {
|
|
48
66
|
server.close(resolve);
|
|
@@ -53,5 +71,6 @@ export async function createServer(config) {
|
|
|
53
71
|
logger.info('Store has been reset and re-seeded');
|
|
54
72
|
},
|
|
55
73
|
getStore: () => store.toJSON(),
|
|
74
|
+
getChaos: () => chaos.getStatus(),
|
|
56
75
|
};
|
|
57
76
|
}
|
package/src/utils/logger.js
CHANGED
|
@@ -29,7 +29,7 @@ export const logger = {
|
|
|
29
29
|
console.log(chalk.hex('#a78bfa').bold(' ╔═══════════════════════════════════════╗'));
|
|
30
30
|
console.log(
|
|
31
31
|
chalk.hex('#a78bfa').bold(' ║ ') +
|
|
32
|
-
chalk.white.bold('PhantomBack
|
|
32
|
+
chalk.white.bold('PhantomBack v2.0.0') +
|
|
33
33
|
chalk.hex('#a78bfa').bold(' ║'),
|
|
34
34
|
);
|
|
35
35
|
console.log(
|
|
@@ -55,4 +55,70 @@ export const logger = {
|
|
|
55
55
|
}
|
|
56
56
|
console.log('');
|
|
57
57
|
},
|
|
58
|
+
|
|
59
|
+
// ── Reality Mode (Chaos) Logging ──
|
|
60
|
+
chaos: (...args) =>
|
|
61
|
+
console.log(PREFIX, chalk.hex('#ff6b6b').bold('⚡CHAOS'), ...args),
|
|
62
|
+
|
|
63
|
+
chaosBanner: (config) => {
|
|
64
|
+
console.log('');
|
|
65
|
+
console.log(chalk.hex('#ff6b6b').bold(' ┌───────────────────────────────────────┐'));
|
|
66
|
+
console.log(
|
|
67
|
+
chalk.hex('#ff6b6b').bold(' │ ') +
|
|
68
|
+
chalk.white.bold('⚡ Reality Mode ACTIVE ⚡') +
|
|
69
|
+
chalk.hex('#ff6b6b').bold(' │'),
|
|
70
|
+
);
|
|
71
|
+
console.log(
|
|
72
|
+
chalk.hex('#ff6b6b').bold(' │ ') +
|
|
73
|
+
chalk.dim('Chaos is being injected into your API') +
|
|
74
|
+
chalk.hex('#ff6b6b').bold(' │'),
|
|
75
|
+
);
|
|
76
|
+
console.log(chalk.hex('#ff6b6b').bold(' └───────────────────────────────────────┘'));
|
|
77
|
+
console.log('');
|
|
78
|
+
if (config) {
|
|
79
|
+
const scenarios = config.scenarios || [];
|
|
80
|
+
console.log(PREFIX, chalk.hex('#ff6b6b').bold('Active Scenarios:'));
|
|
81
|
+
if (scenarios.includes('latency')) {
|
|
82
|
+
console.log(
|
|
83
|
+
PREFIX,
|
|
84
|
+
chalk.dim(' ├─'),
|
|
85
|
+
chalk.yellow('⏱ Latency Spikes'),
|
|
86
|
+
chalk.dim(`(${config.latency?.min || 200}–${config.latency?.max || 5000}ms)`),
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
if (scenarios.includes('failure')) {
|
|
90
|
+
console.log(
|
|
91
|
+
PREFIX,
|
|
92
|
+
chalk.dim(' ├─'),
|
|
93
|
+
chalk.red('💥 Random Failures'),
|
|
94
|
+
chalk.dim(`(${(config.failureRate || 0.1) * 100}% rate)`),
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
if (scenarios.includes('drop')) {
|
|
98
|
+
console.log(
|
|
99
|
+
PREFIX,
|
|
100
|
+
chalk.dim(' ├─'),
|
|
101
|
+
chalk.magenta('🔌 Connection Drops'),
|
|
102
|
+
chalk.dim(`(${(config.connectionDropRate || 0.02) * 100}% rate)`),
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
if (scenarios.includes('corruption')) {
|
|
106
|
+
console.log(
|
|
107
|
+
PREFIX,
|
|
108
|
+
chalk.dim(' ├─'),
|
|
109
|
+
chalk.hex('#ff9f43')('🧩 Response Corruption'),
|
|
110
|
+
chalk.dim(`(${(config.corruptionRate || 0.02) * 100}% rate)`),
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
if (scenarios.includes('timeout')) {
|
|
114
|
+
console.log(
|
|
115
|
+
PREFIX,
|
|
116
|
+
chalk.dim(' ├─'),
|
|
117
|
+
chalk.hex('#ee5a24')('⏳ Request Timeouts'),
|
|
118
|
+
chalk.dim(`(${(config.timeoutRate || 0.03) * 100}% rate)`),
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
console.log('');
|
|
122
|
+
}
|
|
123
|
+
},
|
|
58
124
|
};
|