latency-lab 1.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/LICENSE +21 -0
- package/README.md +434 -0
- package/dist/core.cjs +146 -0
- package/dist/core.cjs.map +1 -0
- package/dist/core.d.cts +78 -0
- package/dist/core.d.ts +78 -0
- package/dist/core.js +138 -0
- package/dist/core.js.map +1 -0
- package/dist/express.cjs +190 -0
- package/dist/express.cjs.map +1 -0
- package/dist/express.d.cts +54 -0
- package/dist/express.d.ts +54 -0
- package/dist/express.js +188 -0
- package/dist/express.js.map +1 -0
- package/dist/index.cjs +283 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +6 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +271 -0
- package/dist/index.js.map +1 -0
- package/dist/next.cjs +183 -0
- package/dist/next.cjs.map +1 -0
- package/dist/next.d.cts +76 -0
- package/dist/next.d.ts +76 -0
- package/dist/next.js +181 -0
- package/dist/next.js.map +1 -0
- package/dist/presets.cjs +45 -0
- package/dist/presets.cjs.map +1 -0
- package/dist/presets.d.cts +27 -0
- package/dist/presets.d.ts +27 -0
- package/dist/presets.js +43 -0
- package/dist/presets.js.map +1 -0
- package/dist/types.cjs +14 -0
- package/dist/types.cjs.map +1 -0
- package/dist/types.d.cts +89 -0
- package/dist/types.d.ts +89 -0
- package/dist/types.js +12 -0
- package/dist/types.js.map +1 -0
- package/package.json +127 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 latency-lab contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
# latency-lab
|
|
2
|
+
|
|
3
|
+
> Inject realistic network chaos into backend applications during local development and testing.
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/latency-lab)
|
|
6
|
+
[](https://opensource.org/licenses/MIT)
|
|
7
|
+
[](https://www.typescriptlang.org/)
|
|
8
|
+
[](#)
|
|
9
|
+
|
|
10
|
+
Unlike a simple `setTimeout` wrapper, `latency-lab` models real-world degraded network conditions: sine-wave quality fluctuations, bursty jitter, probabilistic packet loss, TCP-level connection drops, and HTTP error injection — all composable, all typed.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## Why latency-lab?
|
|
15
|
+
|
|
16
|
+
| Feature | Simple delay | latency-lab |
|
|
17
|
+
|---|---|---|
|
|
18
|
+
| Base delay | ✅ | ✅ |
|
|
19
|
+
| Random jitter | ❌ | ✅ |
|
|
20
|
+
| Wave fluctuations | ❌ | ✅ |
|
|
21
|
+
| Packet loss / TCP drop | ❌ | ✅ |
|
|
22
|
+
| HTTP error injection | ❌ | ✅ |
|
|
23
|
+
| Route exclusions | ❌ | ✅ |
|
|
24
|
+
| Typed presets | ❌ | ✅ |
|
|
25
|
+
| Zero runtime deps | ✅ | ✅ |
|
|
26
|
+
|
|
27
|
+
---
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npm install --save-dev latency-lab
|
|
33
|
+
# or
|
|
34
|
+
pnpm add -D latency-lab
|
|
35
|
+
# or
|
|
36
|
+
yarn add -D latency-lab
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Peer dependencies (install only what you need):
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
# For Express
|
|
43
|
+
npm install express
|
|
44
|
+
|
|
45
|
+
# For Next.js
|
|
46
|
+
npm install next
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Quick Start
|
|
52
|
+
|
|
53
|
+
### Express
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
import express from 'express';
|
|
57
|
+
import { chaosMiddleware, presets } from 'latency-lab';
|
|
58
|
+
|
|
59
|
+
const app = express();
|
|
60
|
+
|
|
61
|
+
// Use a preset
|
|
62
|
+
app.use(chaosMiddleware(presets.flakyCafeWifi));
|
|
63
|
+
|
|
64
|
+
// Or configure manually
|
|
65
|
+
app.use(chaosMiddleware({
|
|
66
|
+
baseDelay: 200,
|
|
67
|
+
jitter: 80,
|
|
68
|
+
wavePeriod: 30,
|
|
69
|
+
failureRate: 0.05,
|
|
70
|
+
failureType: 'random',
|
|
71
|
+
errorCodes: [500, 502, 503],
|
|
72
|
+
excludeRoutes: ['/health', '/metrics'],
|
|
73
|
+
}));
|
|
74
|
+
|
|
75
|
+
app.listen(3000);
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Next.js App Router
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
// app/api/users/route.ts
|
|
82
|
+
import { withChaos, presets } from 'latency-lab/next';
|
|
83
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
84
|
+
|
|
85
|
+
async function GET(_req: NextRequest): Promise<NextResponse> {
|
|
86
|
+
return NextResponse.json({ users: [] });
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const GET = withChaos(GET, presets.slow3g);
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
---
|
|
93
|
+
|
|
94
|
+
## Presets
|
|
95
|
+
|
|
96
|
+
Ready-to-use network profiles:
|
|
97
|
+
|
|
98
|
+
### `presets.subwayTunnel`
|
|
99
|
+
|
|
100
|
+
Sudden quality drops with intermittent total blackouts. High jitter, moderate loss.
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
{
|
|
104
|
+
baseDelay: 800,
|
|
105
|
+
jitter: 600,
|
|
106
|
+
wavePeriod: 8,
|
|
107
|
+
failureRate: 0.20,
|
|
108
|
+
failureType: 'tcp-drop',
|
|
109
|
+
errorCodes: [503, 504],
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### `presets.flakyCafeWifi`
|
|
114
|
+
|
|
115
|
+
Unpredictable café Wi-Fi — mostly works, occasionally terrible.
|
|
116
|
+
|
|
117
|
+
```ts
|
|
118
|
+
{
|
|
119
|
+
baseDelay: 150,
|
|
120
|
+
jitter: 300,
|
|
121
|
+
wavePeriod: 20,
|
|
122
|
+
failureRate: 0.08,
|
|
123
|
+
failureType: 'random',
|
|
124
|
+
errorCodes: [502, 503, 504],
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### `presets.slow3g`
|
|
129
|
+
|
|
130
|
+
Classic slow 3G — high latency, low jitter, low failure rate.
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
{
|
|
134
|
+
baseDelay: 400,
|
|
135
|
+
jitter: 100,
|
|
136
|
+
wavePeriod: 60,
|
|
137
|
+
failureRate: 0.03,
|
|
138
|
+
failureType: 'http-error',
|
|
139
|
+
errorCodes: [408, 503],
|
|
140
|
+
}
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### `presets.congestedStadium`
|
|
144
|
+
|
|
145
|
+
Stadium network — extremely variable, high congestion loss.
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
{
|
|
149
|
+
baseDelay: 600,
|
|
150
|
+
jitter: 800,
|
|
151
|
+
wavePeriod: 5,
|
|
152
|
+
failureRate: 0.30,
|
|
153
|
+
failureType: 'random',
|
|
154
|
+
errorCodes: [429, 503, 504, 520],
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## Express Examples
|
|
161
|
+
|
|
162
|
+
### Basic setup
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
import express from 'express';
|
|
166
|
+
import { chaosMiddleware } from 'latency-lab';
|
|
167
|
+
|
|
168
|
+
const app = express();
|
|
169
|
+
|
|
170
|
+
app.use(chaosMiddleware({
|
|
171
|
+
baseDelay: 300,
|
|
172
|
+
jitter: 150,
|
|
173
|
+
failureRate: 0.1,
|
|
174
|
+
failureType: 'http-error',
|
|
175
|
+
errorCodes: [500, 503],
|
|
176
|
+
}));
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### Excluding routes
|
|
180
|
+
|
|
181
|
+
```ts
|
|
182
|
+
app.use(chaosMiddleware({
|
|
183
|
+
baseDelay: 200,
|
|
184
|
+
jitter: 50,
|
|
185
|
+
failureRate: 0.05,
|
|
186
|
+
failureType: 'random',
|
|
187
|
+
errorCodes: [503],
|
|
188
|
+
excludeRoutes: ['/health', '/ready', '/_internal'],
|
|
189
|
+
}));
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### Conditional activation
|
|
193
|
+
|
|
194
|
+
```ts
|
|
195
|
+
if (process.env.CHAOS_ENABLED === 'true') {
|
|
196
|
+
app.use(chaosMiddleware(presets.flakyCafeWifi));
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
---
|
|
201
|
+
|
|
202
|
+
## Next.js Examples
|
|
203
|
+
|
|
204
|
+
### App Router — single route
|
|
205
|
+
|
|
206
|
+
```ts
|
|
207
|
+
// app/api/posts/route.ts
|
|
208
|
+
import { withChaos } from 'latency-lab/next';
|
|
209
|
+
import { NextRequest, NextResponse } from 'next/server';
|
|
210
|
+
|
|
211
|
+
async function GET(_req: NextRequest): Promise<NextResponse> {
|
|
212
|
+
const posts = await db.posts.findAll();
|
|
213
|
+
return NextResponse.json(posts);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export const GET = withChaos(GET, {
|
|
217
|
+
baseDelay: 300,
|
|
218
|
+
jitter: 100,
|
|
219
|
+
failureRate: 0.05,
|
|
220
|
+
failureType: 'http-error',
|
|
221
|
+
errorCodes: [503],
|
|
222
|
+
});
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
### App Router — route exclusions
|
|
226
|
+
|
|
227
|
+
```ts
|
|
228
|
+
export const GET = withChaos(GET, {
|
|
229
|
+
...presets.slow3g,
|
|
230
|
+
excludeRoutes: ['/api/health'],
|
|
231
|
+
});
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
236
|
+
## API Reference
|
|
237
|
+
|
|
238
|
+
### `calculateDelay(options: ChaosOptions): number`
|
|
239
|
+
|
|
240
|
+
Returns the computed delay in milliseconds for a single request.
|
|
241
|
+
|
|
242
|
+
The formula is:
|
|
243
|
+
|
|
244
|
+
```
|
|
245
|
+
delay = baseDelay + randomJitter + waveFluctuation
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
- **randomJitter**: uniform random in `[-jitter, +jitter]`
|
|
249
|
+
- **waveFluctuation**: `sin(now/1000 * 2π/wavePeriod) * jitter * 0.5` (zero when `wavePeriod` is omitted)
|
|
250
|
+
- Final value clamped to `≥ 0`
|
|
251
|
+
|
|
252
|
+
---
|
|
253
|
+
|
|
254
|
+
### `shouldFail(options: ChaosOptions): boolean`
|
|
255
|
+
|
|
256
|
+
Returns `true` with probability equal to `options.failureRate`.
|
|
257
|
+
|
|
258
|
+
```ts
|
|
259
|
+
shouldFail({ failureRate: 0.1, ... }) // ~10% chance
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
---
|
|
263
|
+
|
|
264
|
+
### `pickErrorCode(options: ChaosOptions): number`
|
|
265
|
+
|
|
266
|
+
Returns a randomly chosen HTTP status code from `options.errorCodes`.
|
|
267
|
+
|
|
268
|
+
Throws `ChaosConfigError` if the array is empty.
|
|
269
|
+
|
|
270
|
+
---
|
|
271
|
+
|
|
272
|
+
### `resolveFailureType(options: ChaosOptions): ResolvedFailureType`
|
|
273
|
+
|
|
274
|
+
When `failureType` is `'random'`, randomly picks between `'http-error'` and `'tcp-drop'`. Otherwise returns the configured type.
|
|
275
|
+
|
|
276
|
+
---
|
|
277
|
+
|
|
278
|
+
### `sleep(ms: number): Promise<void>`
|
|
279
|
+
|
|
280
|
+
Non-blocking async sleep using `setTimeout`.
|
|
281
|
+
|
|
282
|
+
---
|
|
283
|
+
|
|
284
|
+
### `validateChaosOptions(options: unknown): ChaosOptions`
|
|
285
|
+
|
|
286
|
+
Validates a chaos configuration object. Throws `ChaosConfigError` on invalid input.
|
|
287
|
+
|
|
288
|
+
---
|
|
289
|
+
|
|
290
|
+
### `chaosMiddleware(options: MiddlewareOptions): ConnectMiddleware`
|
|
291
|
+
|
|
292
|
+
Returns an Express/Connect-compatible middleware function.
|
|
293
|
+
|
|
294
|
+
```ts
|
|
295
|
+
import { chaosMiddleware } from 'latency-lab';
|
|
296
|
+
|
|
297
|
+
app.use(chaosMiddleware({
|
|
298
|
+
baseDelay: 200,
|
|
299
|
+
jitter: 80,
|
|
300
|
+
failureRate: 0.05,
|
|
301
|
+
failureType: 'http-error',
|
|
302
|
+
errorCodes: [503],
|
|
303
|
+
excludeRoutes: ['/health'],
|
|
304
|
+
}));
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
---
|
|
308
|
+
|
|
309
|
+
### `withChaos(handler, options): typeof handler`
|
|
310
|
+
|
|
311
|
+
Wraps a Next.js App Router handler with chaos injection.
|
|
312
|
+
|
|
313
|
+
```ts
|
|
314
|
+
import { withChaos } from 'latency-lab/next';
|
|
315
|
+
|
|
316
|
+
export const GET = withChaos(myGetHandler, presets.slow3g);
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
---
|
|
320
|
+
|
|
321
|
+
### `ChaosOptions`
|
|
322
|
+
|
|
323
|
+
```ts
|
|
324
|
+
interface ChaosOptions {
|
|
325
|
+
/** Base latency in milliseconds. Must be ≥ 0. */
|
|
326
|
+
baseDelay: number;
|
|
327
|
+
/** Maximum jitter added/subtracted from baseDelay. Must be ≥ 0. */
|
|
328
|
+
jitter: number;
|
|
329
|
+
/** Period of the sine-wave fluctuation in seconds. Optional. */
|
|
330
|
+
wavePeriod?: number;
|
|
331
|
+
/** Probability of a failure per request. Range: [0, 1]. */
|
|
332
|
+
failureRate: number;
|
|
333
|
+
/** How failures are expressed. */
|
|
334
|
+
failureType: 'http-error' | 'tcp-drop' | 'random';
|
|
335
|
+
/** Pool of HTTP status codes to pick from on failure. Must be non-empty. */
|
|
336
|
+
errorCodes: number[];
|
|
337
|
+
}
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
---
|
|
341
|
+
|
|
342
|
+
### `MiddlewareOptions`
|
|
343
|
+
|
|
344
|
+
```ts
|
|
345
|
+
interface MiddlewareOptions extends ChaosOptions {
|
|
346
|
+
/** Route path prefixes to exclude from chaos injection. */
|
|
347
|
+
excludeRoutes?: string[];
|
|
348
|
+
}
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
---
|
|
352
|
+
|
|
353
|
+
### `presets`
|
|
354
|
+
|
|
355
|
+
```ts
|
|
356
|
+
import { presets } from 'latency-lab';
|
|
357
|
+
|
|
358
|
+
presets.subwayTunnel
|
|
359
|
+
presets.flakyCafeWifi
|
|
360
|
+
presets.slow3g
|
|
361
|
+
presets.congestedStadium
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
All preset values are `readonly` and fully typed as `ChaosOptions`.
|
|
365
|
+
|
|
366
|
+
---
|
|
367
|
+
|
|
368
|
+
## FAQ
|
|
369
|
+
|
|
370
|
+
**Q: Does this work in production?**
|
|
371
|
+
|
|
372
|
+
No. `latency-lab` is designed for local development and CI testing. Never use it in production — it intentionally degrades request handling.
|
|
373
|
+
|
|
374
|
+
**Q: Can I compose multiple presets?**
|
|
375
|
+
|
|
376
|
+
Yes, using object spread:
|
|
377
|
+
|
|
378
|
+
```ts
|
|
379
|
+
const combined = {
|
|
380
|
+
...presets.slow3g,
|
|
381
|
+
failureRate: 0.2,
|
|
382
|
+
excludeRoutes: ['/health'],
|
|
383
|
+
};
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
**Q: Does it buffer response bodies?**
|
|
387
|
+
|
|
388
|
+
No. Delay is injected before the request reaches your handler. Response streaming is unaffected.
|
|
389
|
+
|
|
390
|
+
**Q: What does `tcp-drop` do in HTTP middleware?**
|
|
391
|
+
|
|
392
|
+
True TCP drops require operating at the socket level and cannot be done cleanly inside HTTP middleware. In `latency-lab`, `tcp-drop` approximates a dropped connection by destroying the socket (`res.socket?.destroy()` in Express, returning a 503 in Next.js). The behavior is documented in each adapter.
|
|
393
|
+
|
|
394
|
+
**Q: Does it affect WebSocket connections?**
|
|
395
|
+
|
|
396
|
+
No. It only affects standard HTTP request/response cycles.
|
|
397
|
+
|
|
398
|
+
---
|
|
399
|
+
|
|
400
|
+
## Performance Notes
|
|
401
|
+
|
|
402
|
+
- Zero runtime overhead when `failureRate: 0` and `baseDelay: 0` and `jitter: 0`
|
|
403
|
+
- Delay is implemented with `setTimeout` — no busy-waiting, no event loop blocking
|
|
404
|
+
- All calculations are synchronous and O(1)
|
|
405
|
+
- No memory retained per request
|
|
406
|
+
- Safe under high concurrency
|
|
407
|
+
|
|
408
|
+
---
|
|
409
|
+
|
|
410
|
+
## Limitations
|
|
411
|
+
|
|
412
|
+
- TCP drop simulation in Express destroys the underlying socket. Some HTTP clients may retry automatically.
|
|
413
|
+
- TCP drop in Next.js returns a 503 response (true socket destruction is not possible in App Router handlers).
|
|
414
|
+
- Wave fluctuation uses wall-clock time (`Date.now()`), which means multiple concurrent requests at the same instant receive similar wave offsets (by design).
|
|
415
|
+
- Route exclusion uses prefix matching. Regex patterns are not supported.
|
|
416
|
+
|
|
417
|
+
---
|
|
418
|
+
|
|
419
|
+
## Contributing
|
|
420
|
+
|
|
421
|
+
Contributions are welcome! Please follow these steps to contribute:
|
|
422
|
+
|
|
423
|
+
1. Fork the repository.
|
|
424
|
+
2. Create a new branch for your feature or bugfix.
|
|
425
|
+
3. Make your changes and ensure tests pass.
|
|
426
|
+
4. Submit a pull request with a clear description of your changes.
|
|
427
|
+
|
|
428
|
+
For major changes, please open an issue first to discuss what you would like to change.
|
|
429
|
+
|
|
430
|
+
---
|
|
431
|
+
|
|
432
|
+
## License
|
|
433
|
+
|
|
434
|
+
MIT © Mahesh Trapasiya
|
package/dist/core.cjs
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// src/types.ts
|
|
4
|
+
var ChaosConfigError = class extends Error {
|
|
5
|
+
name = "ChaosConfigError";
|
|
6
|
+
constructor(message) {
|
|
7
|
+
super(message);
|
|
8
|
+
Object.setPrototypeOf(this, new.target.prototype);
|
|
9
|
+
}
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
// src/core.ts
|
|
13
|
+
function validateChaosOptions(options) {
|
|
14
|
+
if (options === null || typeof options !== "object") {
|
|
15
|
+
throw new ChaosConfigError("ChaosOptions must be a plain object.");
|
|
16
|
+
}
|
|
17
|
+
const o = options;
|
|
18
|
+
if (typeof o["baseDelay"] !== "number" || !Number.isFinite(o["baseDelay"])) {
|
|
19
|
+
throw new ChaosConfigError(
|
|
20
|
+
`ChaosOptions.baseDelay must be a finite number, got: ${String(o["baseDelay"])}`
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
if (o["baseDelay"] < 0) {
|
|
24
|
+
throw new ChaosConfigError(
|
|
25
|
+
`ChaosOptions.baseDelay must be \u2265 0, got: ${String(o["baseDelay"])}`
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
if (typeof o["jitter"] !== "number" || !Number.isFinite(o["jitter"])) {
|
|
29
|
+
throw new ChaosConfigError(
|
|
30
|
+
`ChaosOptions.jitter must be a finite number, got: ${String(o["jitter"])}`
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
if (o["jitter"] < 0) {
|
|
34
|
+
throw new ChaosConfigError(
|
|
35
|
+
`ChaosOptions.jitter must be \u2265 0, got: ${String(o["jitter"])}`
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
if (o["wavePeriod"] !== void 0) {
|
|
39
|
+
if (typeof o["wavePeriod"] !== "number" || !Number.isFinite(o["wavePeriod"])) {
|
|
40
|
+
throw new ChaosConfigError(
|
|
41
|
+
`ChaosOptions.wavePeriod must be a finite number when provided, got: ${String(o["wavePeriod"])}`
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
if (o["wavePeriod"] <= 0) {
|
|
45
|
+
throw new ChaosConfigError(
|
|
46
|
+
`ChaosOptions.wavePeriod must be > 0 when provided, got: ${String(o["wavePeriod"])}`
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (typeof o["failureRate"] !== "number" || !Number.isFinite(o["failureRate"])) {
|
|
51
|
+
throw new ChaosConfigError(
|
|
52
|
+
`ChaosOptions.failureRate must be a finite number, got: ${String(o["failureRate"])}`
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
if (o["failureRate"] < 0 || o["failureRate"] > 1) {
|
|
56
|
+
throw new ChaosConfigError(
|
|
57
|
+
`ChaosOptions.failureRate must be in [0, 1], got: ${String(o["failureRate"])}`
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
const validFailureTypes = /* @__PURE__ */ new Set(["http-error", "tcp-drop", "random"]);
|
|
61
|
+
if (typeof o["failureType"] !== "string" || !validFailureTypes.has(o["failureType"])) {
|
|
62
|
+
throw new ChaosConfigError(
|
|
63
|
+
`ChaosOptions.failureType must be one of "http-error" | "tcp-drop" | "random", got: ${String(o["failureType"])}`
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
if (!Array.isArray(o["errorCodes"])) {
|
|
67
|
+
throw new ChaosConfigError(
|
|
68
|
+
`ChaosOptions.errorCodes must be an array, got: ${typeof o["errorCodes"]}`
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
if (o["errorCodes"].length === 0) {
|
|
72
|
+
throw new ChaosConfigError(
|
|
73
|
+
"ChaosOptions.errorCodes must contain at least one HTTP status code."
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
for (const code of o["errorCodes"]) {
|
|
77
|
+
if (typeof code !== "number" || !Number.isInteger(code) || code < 100 || code > 599) {
|
|
78
|
+
throw new ChaosConfigError(
|
|
79
|
+
`ChaosOptions.errorCodes contains invalid HTTP status code: ${String(code)}. Each code must be an integer in [100, 599].`
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
baseDelay: o["baseDelay"],
|
|
85
|
+
jitter: o["jitter"],
|
|
86
|
+
...o["wavePeriod"] !== void 0 ? { wavePeriod: o["wavePeriod"] } : {},
|
|
87
|
+
failureRate: o["failureRate"],
|
|
88
|
+
failureType: o["failureType"],
|
|
89
|
+
errorCodes: o["errorCodes"]
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function calculateDelay(options) {
|
|
93
|
+
const { baseDelay, jitter, wavePeriod } = options;
|
|
94
|
+
const randomJitter = (Math.random() * 2 - 1) * jitter;
|
|
95
|
+
let waveFluctuation = 0;
|
|
96
|
+
if (wavePeriod !== void 0) {
|
|
97
|
+
const tSeconds = Date.now() / 1e3;
|
|
98
|
+
const phase = tSeconds * (2 * Math.PI) / wavePeriod;
|
|
99
|
+
waveFluctuation = Math.sin(phase) * jitter * 0.5;
|
|
100
|
+
}
|
|
101
|
+
const raw = baseDelay + randomJitter + waveFluctuation;
|
|
102
|
+
return Math.max(0, raw);
|
|
103
|
+
}
|
|
104
|
+
function shouldFail(options) {
|
|
105
|
+
if (options.failureRate === 0) return false;
|
|
106
|
+
if (options.failureRate === 1) return true;
|
|
107
|
+
return Math.random() < options.failureRate;
|
|
108
|
+
}
|
|
109
|
+
function pickErrorCode(options) {
|
|
110
|
+
const { errorCodes } = options;
|
|
111
|
+
if (errorCodes.length === 0) {
|
|
112
|
+
throw new ChaosConfigError(
|
|
113
|
+
"Cannot pick an error code from an empty errorCodes array."
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
const index = Math.floor(Math.random() * errorCodes.length);
|
|
117
|
+
return errorCodes[index];
|
|
118
|
+
}
|
|
119
|
+
function resolveFailureType(options) {
|
|
120
|
+
if (options.failureType === "random") {
|
|
121
|
+
return Math.random() < 0.5 ? "http-error" : "tcp-drop";
|
|
122
|
+
}
|
|
123
|
+
return options.failureType;
|
|
124
|
+
}
|
|
125
|
+
function sleep(ms) {
|
|
126
|
+
if (ms <= 0) return Promise.resolve();
|
|
127
|
+
return new Promise((resolve) => {
|
|
128
|
+
setTimeout(resolve, ms);
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
function isExcluded(pathname, excludeRoutes) {
|
|
132
|
+
return excludeRoutes.some((prefix) => {
|
|
133
|
+
if (pathname === prefix) return true;
|
|
134
|
+
return pathname.startsWith(prefix.endsWith("/") ? prefix : `${prefix}/`) || pathname.startsWith(prefix);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
exports.calculateDelay = calculateDelay;
|
|
139
|
+
exports.isExcluded = isExcluded;
|
|
140
|
+
exports.pickErrorCode = pickErrorCode;
|
|
141
|
+
exports.resolveFailureType = resolveFailureType;
|
|
142
|
+
exports.shouldFail = shouldFail;
|
|
143
|
+
exports.sleep = sleep;
|
|
144
|
+
exports.validateChaosOptions = validateChaosOptions;
|
|
145
|
+
//# sourceMappingURL=core.cjs.map
|
|
146
|
+
//# sourceMappingURL=core.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/types.ts","../src/core.ts"],"names":[],"mappings":";;;AA4FO,IAAM,gBAAA,GAAN,cAA+B,KAAA,CAAM;AAAA,EACxB,IAAA,GAAO,kBAAA;AAAA,EAEzB,YAAY,OAAA,EAAiB;AAC3B,IAAA,KAAA,CAAM,OAAO,CAAA;AAEb,IAAA,MAAA,CAAO,cAAA,CAAe,IAAA,EAAM,GAAA,CAAA,MAAA,CAAW,SAAS,CAAA;AAAA,EAClD;AACF,CAAA;;;ACrFO,SAAS,qBAAqB,OAAA,EAAgC;AACnE,EAAA,IAAI,OAAA,KAAY,IAAA,IAAQ,OAAO,OAAA,KAAY,QAAA,EAAU;AACnD,IAAA,MAAM,IAAI,iBAAiB,sCAAsC,CAAA;AAAA,EACnE;AAEA,EAAA,MAAM,CAAA,GAAI,OAAA;AAGV,EAAA,IAAI,OAAO,CAAA,CAAE,WAAW,CAAA,KAAM,QAAA,IAAY,CAAC,MAAA,CAAO,QAAA,CAAS,CAAA,CAAE,WAAW,CAAC,CAAA,EAAG;AAC1E,IAAA,MAAM,IAAI,gBAAA;AAAA,MACR,CAAA,qDAAA,EAAwD,MAAA,CAAO,CAAA,CAAE,WAAW,CAAC,CAAC,CAAA;AAAA,KAChF;AAAA,EACF;AACA,EAAA,IAAI,CAAA,CAAE,WAAW,CAAA,GAAI,CAAA,EAAG;AACtB,IAAA,MAAM,IAAI,gBAAA;AAAA,MACR,CAAA,8CAAA,EAA4C,MAAA,CAAO,CAAA,CAAE,WAAW,CAAC,CAAC,CAAA;AAAA,KACpE;AAAA,EACF;AAGA,EAAA,IAAI,OAAO,CAAA,CAAE,QAAQ,CAAA,KAAM,QAAA,IAAY,CAAC,MAAA,CAAO,QAAA,CAAS,CAAA,CAAE,QAAQ,CAAC,CAAA,EAAG;AACpE,IAAA,MAAM,IAAI,gBAAA;AAAA,MACR,CAAA,kDAAA,EAAqD,MAAA,CAAO,CAAA,CAAE,QAAQ,CAAC,CAAC,CAAA;AAAA,KAC1E;AAAA,EACF;AACA,EAAA,IAAI,CAAA,CAAE,QAAQ,CAAA,GAAI,CAAA,EAAG;AACnB,IAAA,MAAM,IAAI,gBAAA;AAAA,MACR,CAAA,2CAAA,EAAyC,MAAA,CAAO,CAAA,CAAE,QAAQ,CAAC,CAAC,CAAA;AAAA,KAC9D;AAAA,EACF;AAGA,EAAA,IAAI,CAAA,CAAE,YAAY,CAAA,KAAM,MAAA,EAAW;AACjC,IAAA,IAAI,OAAO,CAAA,CAAE,YAAY,CAAA,KAAM,QAAA,IAAY,CAAC,MAAA,CAAO,QAAA,CAAS,CAAA,CAAE,YAAY,CAAC,CAAA,EAAG;AAC5E,MAAA,MAAM,IAAI,gBAAA;AAAA,QACR,CAAA,oEAAA,EAAuE,MAAA,CAAO,CAAA,CAAE,YAAY,CAAC,CAAC,CAAA;AAAA,OAChG;AAAA,IACF;AACA,IAAA,IAAI,CAAA,CAAE,YAAY,CAAA,IAAK,CAAA,EAAG;AACxB,MAAA,MAAM,IAAI,gBAAA;AAAA,QACR,CAAA,wDAAA,EAA2D,MAAA,CAAO,CAAA,CAAE,YAAY,CAAC,CAAC,CAAA;AAAA,OACpF;AAAA,IACF;AAAA,EACF;AAGA,EAAA,IAAI,OAAO,CAAA,CAAE,aAAa,CAAA,KAAM,QAAA,IAAY,CAAC,MAAA,CAAO,QAAA,CAAS,CAAA,CAAE,aAAa,CAAC,CAAA,EAAG;AAC9E,IAAA,MAAM,IAAI,gBAAA;AAAA,MACR,CAAA,uDAAA,EAA0D,MAAA,CAAO,CAAA,CAAE,aAAa,CAAC,CAAC,CAAA;AAAA,KACpF;AAAA,EACF;AACA,EAAA,IAAI,EAAE,aAAa,CAAA,GAAI,KAAK,CAAA,CAAE,aAAa,IAAI,CAAA,EAAG;AAChD,IAAA,MAAM,IAAI,gBAAA;AAAA,MACR,CAAA,iDAAA,EAAoD,MAAA,CAAO,CAAA,CAAE,aAAa,CAAC,CAAC,CAAA;AAAA,KAC9E;AAAA,EACF;AAGA,EAAA,MAAM,oCAAoB,IAAI,GAAA,CAAY,CAAC,YAAA,EAAc,UAAA,EAAY,QAAQ,CAAC,CAAA;AAC9E,EAAA,IAAI,OAAO,CAAA,CAAE,aAAa,CAAA,KAAM,QAAA,IAAY,CAAC,iBAAA,CAAkB,GAAA,CAAI,CAAA,CAAE,aAAa,CAAC,CAAA,EAAG;AACpF,IAAA,MAAM,IAAI,gBAAA;AAAA,MACR,CAAA,mFAAA,EACU,MAAA,CAAO,CAAA,CAAE,aAAa,CAAC,CAAC,CAAA;AAAA,KACpC;AAAA,EACF;AAGA,EAAA,IAAI,CAAC,KAAA,CAAM,OAAA,CAAQ,CAAA,CAAE,YAAY,CAAC,CAAA,EAAG;AACnC,IAAA,MAAM,IAAI,gBAAA;AAAA,MACR,CAAA,+CAAA,EAAkD,OAAO,CAAA,CAAE,YAAY,CAAC,CAAA;AAAA,KAC1E;AAAA,EACF;AACA,EAAA,IAAI,CAAA,CAAE,YAAY,CAAA,CAAE,MAAA,KAAW,CAAA,EAAG;AAChC,IAAA,MAAM,IAAI,gBAAA;AAAA,MACR;AAAA,KACF;AAAA,EACF;AACA,EAAA,KAAA,MAAW,IAAA,IAAQ,CAAA,CAAE,YAAY,CAAA,EAAgB;AAC/C,IAAA,IAAI,OAAO,IAAA,KAAS,QAAA,IAAY,CAAC,MAAA,CAAO,SAAA,CAAU,IAAI,CAAA,IAAK,IAAA,GAAO,GAAA,IAAO,IAAA,GAAO,GAAA,EAAK;AACnF,MAAA,MAAM,IAAI,gBAAA;AAAA,QACR,CAAA,2DAAA,EAA8D,MAAA,CAAO,IAAI,CAAC,CAAA,6CAAA;AAAA,OAE5E;AAAA,IACF;AAAA,EACF;AAEA,EAAA,OAAO;AAAA,IACL,SAAA,EAAW,EAAE,WAAW,CAAA;AAAA,IACxB,MAAA,EAAQ,EAAE,QAAQ,CAAA;AAAA,IAClB,GAAI,CAAA,CAAE,YAAY,CAAA,KAAM,MAAA,GAAY,EAAE,UAAA,EAAY,CAAA,CAAE,YAAY,CAAA,EAAY,GAAI,EAAC;AAAA,IACjF,WAAA,EAAa,EAAE,aAAa,CAAA;AAAA,IAC5B,WAAA,EAAa,EAAE,aAAa,CAAA;AAAA,IAC5B,UAAA,EAAY,EAAE,YAAY;AAAA,GAC5B;AACF;AAsBO,SAAS,eAAe,OAAA,EAA+B;AAC5D,EAAA,MAAM,EAAE,SAAA,EAAW,MAAA,EAAQ,UAAA,EAAW,GAAI,OAAA;AAG1C,EAAA,MAAM,YAAA,GAAA,CAAgB,IAAA,CAAK,MAAA,EAAO,GAAI,IAAI,CAAA,IAAK,MAAA;AAG/C,EAAA,IAAI,eAAA,GAAkB,CAAA;AACtB,EAAA,IAAI,eAAe,MAAA,EAAW;AAC5B,IAAA,MAAM,QAAA,GAAW,IAAA,CAAK,GAAA,EAAI,GAAI,GAAA;AAC9B,IAAA,MAAM,KAAA,GAAS,QAAA,IAAY,CAAA,GAAI,IAAA,CAAK,EAAA,CAAA,GAAO,UAAA;AAG3C,IAAA,eAAA,GAAkB,IAAA,CAAK,GAAA,CAAI,KAAK,CAAA,GAAI,MAAA,GAAS,GAAA;AAAA,EAC/C;AAEA,EAAA,MAAM,GAAA,GAAM,YAAY,YAAA,GAAe,eAAA;AACvC,EAAA,OAAO,IAAA,CAAK,GAAA,CAAI,CAAA,EAAG,GAAG,CAAA;AACxB;AAaO,SAAS,WAAW,OAAA,EAAgC;AACzD,EAAA,IAAI,OAAA,CAAQ,WAAA,KAAgB,CAAA,EAAG,OAAO,KAAA;AACtC,EAAA,IAAI,OAAA,CAAQ,WAAA,KAAgB,CAAA,EAAG,OAAO,IAAA;AACtC,EAAA,OAAO,IAAA,CAAK,MAAA,EAAO,GAAI,OAAA,CAAQ,WAAA;AACjC;AAUO,SAAS,cAAc,OAAA,EAA+B;AAC3D,EAAA,MAAM,EAAE,YAAW,GAAI,OAAA;AACvB,EAAA,IAAI,UAAA,CAAW,WAAW,CAAA,EAAG;AAE3B,IAAA,MAAM,IAAI,gBAAA;AAAA,MACR;AAAA,KACF;AAAA,EACF;AACA,EAAA,MAAM,QAAQ,IAAA,CAAK,KAAA,CAAM,KAAK,MAAA,EAAO,GAAI,WAAW,MAAM,CAAA;AAE1D,EAAA,OAAO,WAAW,KAAK,CAAA;AACzB;AAWO,SAAS,mBAAmB,OAAA,EAA4C;AAC7E,EAAA,IAAI,OAAA,CAAQ,gBAAgB,QAAA,EAAU;AACpC,IAAA,OAAO,IAAA,CAAK,MAAA,EAAO,GAAI,GAAA,GAAM,YAAA,GAAe,UAAA;AAAA,EAC9C;AACA,EAAA,OAAO,OAAA,CAAQ,WAAA;AACjB;AAcO,SAAS,MAAM,EAAA,EAA2B;AAC/C,EAAA,IAAI,EAAA,IAAM,CAAA,EAAG,OAAO,OAAA,CAAQ,OAAA,EAAQ;AACpC,EAAA,OAAO,IAAI,OAAA,CAAc,CAAC,OAAA,KAAY;AACpC,IAAA,UAAA,CAAW,SAAS,EAAE,CAAA;AAAA,EACxB,CAAC,CAAA;AACH;AAiBO,SAAS,UAAA,CAAW,UAAkB,aAAA,EAA2C;AACtF,EAAA,OAAO,aAAA,CAAc,IAAA,CAAK,CAAC,MAAA,KAAW;AACpC,IAAA,IAAI,QAAA,KAAa,QAAQ,OAAO,IAAA;AAEhC,IAAA,OAAO,QAAA,CAAS,UAAA,CAAW,MAAA,CAAO,QAAA,CAAS,GAAG,CAAA,GAAI,MAAA,GAAS,CAAA,EAAG,MAAM,CAAA,CAAA,CAAG,CAAA,IACrE,QAAA,CAAS,WAAW,MAAM,CAAA;AAAA,EAC9B,CAAC,CAAA;AACH","file":"core.cjs","sourcesContent":["/**\n * How a simulated failure is expressed to the client.\n *\n * - `'http-error'` — Respond with an HTTP error status code drawn from `errorCodes`.\n * - `'tcp-drop'` — Approximate a TCP connection drop by destroying the socket\n * (Express) or returning a 503 (Next.js, where true socket\n * destruction is unavailable in App Router handlers).\n * - `'random'` — Randomly choose between `'http-error'` and `'tcp-drop'`\n * each time a failure occurs.\n */\nexport type FailureType = 'http-error' | 'tcp-drop' | 'random';\n\n/**\n * Resolved failure type after `'random'` has been evaluated.\n * Never `'random'` — always a concrete action.\n */\nexport type ResolvedFailureType = Exclude<FailureType, 'random'>;\n\n/**\n * Core chaos configuration.\n *\n * All time values are in **milliseconds** unless otherwise noted.\n */\nexport interface ChaosOptions {\n /**\n * Base latency added to every request in milliseconds.\n * Must be ≥ 0.\n */\n baseDelay: number;\n\n /**\n * Maximum magnitude of random jitter added to or subtracted from\n * `baseDelay` in milliseconds. Must be ≥ 0.\n *\n * Actual jitter per request is sampled uniformly from `[-jitter, +jitter]`.\n */\n jitter: number;\n\n /**\n * Period of a sine-wave fluctuation applied on top of jitter, in **seconds**.\n *\n * This simulates slowly oscillating network quality (e.g., a roaming device\n * moving in and out of signal). When omitted, no wave fluctuation is applied.\n *\n * Must be > 0 when provided.\n */\n wavePeriod?: number;\n\n /**\n * Probability that a given request results in a simulated failure.\n * Must be in the range [0, 1].\n *\n * - `0` → failures never occur\n * - `1` → every request fails\n * - `0.1` → ~10% of requests fail\n */\n failureRate: number;\n\n /**\n * Determines how simulated failures are expressed to callers.\n */\n failureType: FailureType;\n\n /**\n * Pool of HTTP status codes to choose from when responding with an HTTP error.\n * Must contain at least one entry.\n *\n * Only used when `failureType` resolves to `'http-error'`.\n */\n errorCodes: number[];\n}\n\n/**\n * Options passed to framework-level middleware / handler wrappers.\n * Extends `ChaosOptions` with request-filtering capabilities.\n */\nexport interface MiddlewareOptions extends ChaosOptions {\n /**\n * List of URL path prefixes that should bypass chaos injection entirely.\n *\n * Matching is prefix-based and case-sensitive.\n *\n * @example\n * excludeRoutes: ['/health', '/metrics', '/_next']\n */\n excludeRoutes?: string[];\n}\n\n/**\n * Structured error thrown when a `ChaosOptions` or `MiddlewareOptions`\n * object fails validation.\n */\nexport class ChaosConfigError extends Error {\n override readonly name = 'ChaosConfigError';\n\n constructor(message: string) {\n super(message);\n // Maintain proper prototype chain in transpiled output\n Object.setPrototypeOf(this, new.target.prototype);\n }\n}\n","import { ChaosConfigError } from './types.js';\nimport type { ChaosOptions, ResolvedFailureType } from './types.js';\n\n// ---------------------------------------------------------------------------\n// Validation\n// ---------------------------------------------------------------------------\n\n/**\n * Validates a raw object as a `ChaosOptions`. Throws `ChaosConfigError`\n * with a descriptive message if any field is invalid or missing.\n *\n * This is the single source of truth for option validation — all public\n * entry-points (middleware factories, `withChaos`, etc.) should call this\n * before storing or using options.\n */\nexport function validateChaosOptions(options: unknown): ChaosOptions {\n if (options === null || typeof options !== 'object') {\n throw new ChaosConfigError('ChaosOptions must be a plain object.');\n }\n\n const o = options as Record<string, unknown>;\n\n // --- baseDelay -----------------------------------------------------------\n if (typeof o['baseDelay'] !== 'number' || !Number.isFinite(o['baseDelay'])) {\n throw new ChaosConfigError(\n `ChaosOptions.baseDelay must be a finite number, got: ${String(o['baseDelay'])}`,\n );\n }\n if (o['baseDelay'] < 0) {\n throw new ChaosConfigError(\n `ChaosOptions.baseDelay must be ≥ 0, got: ${String(o['baseDelay'])}`,\n );\n }\n\n // --- jitter --------------------------------------------------------------\n if (typeof o['jitter'] !== 'number' || !Number.isFinite(o['jitter'])) {\n throw new ChaosConfigError(\n `ChaosOptions.jitter must be a finite number, got: ${String(o['jitter'])}`,\n );\n }\n if (o['jitter'] < 0) {\n throw new ChaosConfigError(\n `ChaosOptions.jitter must be ≥ 0, got: ${String(o['jitter'])}`,\n );\n }\n\n // --- wavePeriod (optional) -----------------------------------------------\n if (o['wavePeriod'] !== undefined) {\n if (typeof o['wavePeriod'] !== 'number' || !Number.isFinite(o['wavePeriod'])) {\n throw new ChaosConfigError(\n `ChaosOptions.wavePeriod must be a finite number when provided, got: ${String(o['wavePeriod'])}`,\n );\n }\n if (o['wavePeriod'] <= 0) {\n throw new ChaosConfigError(\n `ChaosOptions.wavePeriod must be > 0 when provided, got: ${String(o['wavePeriod'])}`,\n );\n }\n }\n\n // --- failureRate ---------------------------------------------------------\n if (typeof o['failureRate'] !== 'number' || !Number.isFinite(o['failureRate'])) {\n throw new ChaosConfigError(\n `ChaosOptions.failureRate must be a finite number, got: ${String(o['failureRate'])}`,\n );\n }\n if (o['failureRate'] < 0 || o['failureRate'] > 1) {\n throw new ChaosConfigError(\n `ChaosOptions.failureRate must be in [0, 1], got: ${String(o['failureRate'])}`,\n );\n }\n\n // --- failureType ---------------------------------------------------------\n const validFailureTypes = new Set<string>(['http-error', 'tcp-drop', 'random']);\n if (typeof o['failureType'] !== 'string' || !validFailureTypes.has(o['failureType'])) {\n throw new ChaosConfigError(\n `ChaosOptions.failureType must be one of \"http-error\" | \"tcp-drop\" | \"random\", ` +\n `got: ${String(o['failureType'])}`,\n );\n }\n\n // --- errorCodes ----------------------------------------------------------\n if (!Array.isArray(o['errorCodes'])) {\n throw new ChaosConfigError(\n `ChaosOptions.errorCodes must be an array, got: ${typeof o['errorCodes']}`,\n );\n }\n if (o['errorCodes'].length === 0) {\n throw new ChaosConfigError(\n 'ChaosOptions.errorCodes must contain at least one HTTP status code.',\n );\n }\n for (const code of o['errorCodes'] as unknown[]) {\n if (typeof code !== 'number' || !Number.isInteger(code) || code < 100 || code > 599) {\n throw new ChaosConfigError(\n `ChaosOptions.errorCodes contains invalid HTTP status code: ${String(code)}. ` +\n 'Each code must be an integer in [100, 599].',\n );\n }\n }\n\n return {\n baseDelay: o['baseDelay'] as number,\n jitter: o['jitter'] as number,\n ...(o['wavePeriod'] !== undefined ? { wavePeriod: o['wavePeriod'] as number } : {}),\n failureRate: o['failureRate'] as number,\n failureType: o['failureType'] as ChaosOptions['failureType'],\n errorCodes: o['errorCodes'] as number[],\n };\n}\n\n// ---------------------------------------------------------------------------\n// Delay calculation\n// ---------------------------------------------------------------------------\n\n/**\n * Compute a realistic delay for a single request, in milliseconds.\n *\n * Formula:\n * ```\n * delay = baseDelay + randomJitter + waveFluctuation\n * ```\n *\n * - **randomJitter**: sampled uniformly from `[-jitter, +jitter]`\n * - **waveFluctuation**: `sin(t * 2π / wavePeriod) * jitter * 0.5` where\n * `t = Date.now() / 1000` in seconds (zero when `wavePeriod` is omitted)\n * - Result is clamped to `≥ 0` (negative delays are meaningless)\n *\n * @param options - Validated `ChaosOptions`.\n * @returns Delay in milliseconds (always ≥ 0).\n */\nexport function calculateDelay(options: ChaosOptions): number {\n const { baseDelay, jitter, wavePeriod } = options;\n\n // Uniform random jitter: value in [-jitter, +jitter]\n const randomJitter = (Math.random() * 2 - 1) * jitter;\n\n // Sine-wave fluctuation — models slow oscillation in network quality\n let waveFluctuation = 0;\n if (wavePeriod !== undefined) {\n const tSeconds = Date.now() / 1000;\n const phase = (tSeconds * (2 * Math.PI)) / wavePeriod;\n // Scale by half the jitter magnitude so the wave amplitude stays within\n // the same order of magnitude as the random jitter component.\n waveFluctuation = Math.sin(phase) * jitter * 0.5;\n }\n\n const raw = baseDelay + randomJitter + waveFluctuation;\n return Math.max(0, raw);\n}\n\n// ---------------------------------------------------------------------------\n// Failure logic\n// ---------------------------------------------------------------------------\n\n/**\n * Returns `true` with probability `options.failureRate`.\n *\n * Uses `Math.random()` — suitable for non-cryptographic simulation purposes.\n *\n * @param options - Validated `ChaosOptions`.\n */\nexport function shouldFail(options: ChaosOptions): boolean {\n if (options.failureRate === 0) return false;\n if (options.failureRate === 1) return true;\n return Math.random() < options.failureRate;\n}\n\n/**\n * Picks a random HTTP status code from `options.errorCodes`.\n *\n * Assumes `options.errorCodes` is non-empty (enforced by `validateChaosOptions`).\n *\n * @param options - Validated `ChaosOptions`.\n * @returns A status code from the configured pool.\n */\nexport function pickErrorCode(options: ChaosOptions): number {\n const { errorCodes } = options;\n if (errorCodes.length === 0) {\n // Guard: this should never happen after validation, but we protect anyway.\n throw new ChaosConfigError(\n 'Cannot pick an error code from an empty errorCodes array.',\n );\n }\n const index = Math.floor(Math.random() * errorCodes.length);\n // Non-null assertion is safe: index is always in [0, errorCodes.length - 1]\n return errorCodes[index]!;\n}\n\n/**\n * Resolves the configured `failureType` to a concrete action.\n *\n * When `failureType` is `'random'`, randomly selects between `'http-error'`\n * and `'tcp-drop'` with equal probability.\n *\n * @param options - Validated `ChaosOptions`.\n * @returns A `ResolvedFailureType` — never `'random'`.\n */\nexport function resolveFailureType(options: ChaosOptions): ResolvedFailureType {\n if (options.failureType === 'random') {\n return Math.random() < 0.5 ? 'http-error' : 'tcp-drop';\n }\n return options.failureType;\n}\n\n// ---------------------------------------------------------------------------\n// Async utilities\n// ---------------------------------------------------------------------------\n\n/**\n * Non-blocking async sleep.\n *\n * Uses `setTimeout` under the hood — never busy-waits, never blocks the\n * event loop. Safe to `await` from any async context.\n *\n * @param ms - Duration to sleep in milliseconds. Values ≤ 0 resolve immediately.\n */\nexport function sleep(ms: number): Promise<void> {\n if (ms <= 0) return Promise.resolve();\n return new Promise<void>((resolve) => {\n setTimeout(resolve, ms);\n });\n}\n\n// ---------------------------------------------------------------------------\n// Route exclusion helper\n// ---------------------------------------------------------------------------\n\n/**\n * Returns `true` if the given URL path matches any of the excluded route\n * prefixes.\n *\n * Matching is prefix-based and case-sensitive. A trailing slash on the\n * prefix is not required — `/health` excludes `/health`, `/health/`, and\n * `/health/check`.\n *\n * @param pathname - The incoming request path (e.g. `/api/users`).\n * @param excludeRoutes - Array of path prefixes to exclude.\n */\nexport function isExcluded(pathname: string, excludeRoutes: readonly string[]): boolean {\n return excludeRoutes.some((prefix) => {\n if (pathname === prefix) return true;\n // Ensure we match the prefix at a path boundary\n return pathname.startsWith(prefix.endsWith('/') ? prefix : `${prefix}/`) ||\n pathname.startsWith(prefix);\n });\n}\n"]}
|