@henriquecosta/chaos-api 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/dist/adapters/express.d.ts +21 -0
- package/dist/adapters/express.js +40 -0
- package/dist/adapters/fastify.d.ts +12 -0
- package/dist/adapters/fastify.js +44 -0
- package/dist/adapters/koa.d.ts +12 -0
- package/dist/adapters/koa.js +47 -0
- package/dist/adapters/nestjs.d.ts +34 -0
- package/dist/adapters/nestjs.js +52 -0
- package/dist/bin/chaos-api.d.ts +2 -0
- package/dist/bin/chaos-api.js +33 -0
- package/dist/core/activity-log.d.ts +24 -0
- package/dist/core/activity-log.js +26 -0
- package/dist/core/scenario-engine.d.ts +12 -0
- package/dist/core/scenario-engine.js +38 -0
- package/dist/core/state-store.d.ts +26 -0
- package/dist/core/state-store.js +69 -0
- package/dist/core/types.d.ts +41 -0
- package/dist/core/types.js +1 -0
- package/dist/dashboard/server/control-api.d.ts +9 -0
- package/dist/dashboard/server/control-api.js +140 -0
- package/dist/dashboard/server/index.d.ts +10 -0
- package/dist/dashboard/server/index.js +49 -0
- package/dist/dashboard/ui/app.js +336 -0
- package/dist/dashboard/ui/index.html +215 -0
- package/dist/guardrail.d.ts +10 -0
- package/dist/guardrail.js +20 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +13 -0
- package/dist/outbound/chaos-fetch.d.ts +13 -0
- package/dist/outbound/chaos-fetch.js +50 -0
- package/dist/outbound/index.d.ts +1 -0
- package/dist/outbound/index.js +1 -0
- package/dist/presets/catalog.d.ts +8 -0
- package/dist/presets/catalog.js +157 -0
- package/dist/presets/index.d.ts +15 -0
- package/dist/presets/index.js +22 -0
- package/dist/presets/types.d.ts +17 -0
- package/dist/presets/types.js +1 -0
- package/dist/scenarios/connection-reset.d.ts +7 -0
- package/dist/scenarios/connection-reset.js +8 -0
- package/dist/scenarios/delay.d.ts +6 -0
- package/dist/scenarios/delay.js +9 -0
- package/dist/scenarios/error-response.d.ts +9 -0
- package/dist/scenarios/error-response.js +13 -0
- package/dist/scenarios/index.d.ts +7 -0
- package/dist/scenarios/index.js +7 -0
- package/dist/scenarios/malformed-response.d.ts +11 -0
- package/dist/scenarios/malformed-response.js +13 -0
- package/dist/scenarios/random-error.d.ts +6 -0
- package/dist/scenarios/random-error.js +7 -0
- package/dist/scenarios/random-timeout.d.ts +7 -0
- package/dist/scenarios/random-timeout.js +8 -0
- package/dist/scenarios/registry.d.ts +12 -0
- package/dist/scenarios/registry.js +20 -0
- package/dist/scenarios/stale-response.d.ts +9 -0
- package/dist/scenarios/stale-response.js +10 -0
- package/dist/scenarios/unavailable-503.d.ts +5 -0
- package/dist/scenarios/unavailable-503.js +9 -0
- package/dist/scenarios/unavailable.d.ts +7 -0
- package/dist/scenarios/unavailable.js +9 -0
- package/package.json +59 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { NextFunction, Request, Response } from "express";
|
|
2
|
+
import type { Server } from "node:http";
|
|
3
|
+
import { ActivityLog } from "../core/activity-log.js";
|
|
4
|
+
import { StateStore } from "../core/state-store.js";
|
|
5
|
+
export interface ChaosOptions {
|
|
6
|
+
/** Bypass the NODE_ENV=production guardrail. Not recommended. */
|
|
7
|
+
allowInProduction?: boolean;
|
|
8
|
+
/** Reuse an existing StateStore (e.g. to share state across adapters in tests). */
|
|
9
|
+
store?: StateStore;
|
|
10
|
+
/** Reuse an existing ActivityLog (e.g. to share state across adapters in tests). */
|
|
11
|
+
activityLog?: ActivityLog;
|
|
12
|
+
/** If set, starts the local control API on this port for the dashboard UI to talk to. */
|
|
13
|
+
controlPort?: number;
|
|
14
|
+
}
|
|
15
|
+
export interface ChaosInstance {
|
|
16
|
+
(req: Request, res: Response, next: NextFunction): void;
|
|
17
|
+
store: StateStore;
|
|
18
|
+
activityLog: ActivityLog;
|
|
19
|
+
controlApi?: Server;
|
|
20
|
+
}
|
|
21
|
+
export declare function chaos(options?: ChaosOptions): ChaosInstance;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { ActivityLog } from "../core/activity-log.js";
|
|
2
|
+
import { ScenarioEngine } from "../core/scenario-engine.js";
|
|
3
|
+
import { StateStore } from "../core/state-store.js";
|
|
4
|
+
import { createControlApi } from "../dashboard/server/control-api.js";
|
|
5
|
+
import { isBlockedByGuardrail } from "../guardrail.js";
|
|
6
|
+
export function chaos(options = {}) {
|
|
7
|
+
const store = options.store ?? new StateStore();
|
|
8
|
+
const activityLog = options.activityLog ?? new ActivityLog();
|
|
9
|
+
const engine = new ScenarioEngine(store, activityLog);
|
|
10
|
+
const middleware = ((req, res, next) => {
|
|
11
|
+
if (isBlockedByGuardrail(options)) {
|
|
12
|
+
next();
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
const controller = {
|
|
16
|
+
status: (code) => {
|
|
17
|
+
res.status(code);
|
|
18
|
+
},
|
|
19
|
+
header: (name, value) => {
|
|
20
|
+
res.setHeader(name, value);
|
|
21
|
+
},
|
|
22
|
+
send: (body) => {
|
|
23
|
+
res.send(body);
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
engine
|
|
27
|
+
.resolve({ method: req.method, path: req.path }, controller)
|
|
28
|
+
.then((result) => {
|
|
29
|
+
if (result === "continue")
|
|
30
|
+
next();
|
|
31
|
+
})
|
|
32
|
+
.catch(next);
|
|
33
|
+
});
|
|
34
|
+
middleware.store = store;
|
|
35
|
+
middleware.activityLog = activityLog;
|
|
36
|
+
if (options.controlPort) {
|
|
37
|
+
middleware.controlApi = createControlApi(store, activityLog).listen(options.controlPort);
|
|
38
|
+
}
|
|
39
|
+
return middleware;
|
|
40
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { FastifyInstance } from "fastify";
|
|
2
|
+
import type { Server } from "node:http";
|
|
3
|
+
import { ActivityLog } from "../core/activity-log.js";
|
|
4
|
+
import { StateStore } from "../core/state-store.js";
|
|
5
|
+
import type { ChaosOptions } from "./express.js";
|
|
6
|
+
export interface ChaosFastifyPlugin {
|
|
7
|
+
(fastify: FastifyInstance): Promise<void>;
|
|
8
|
+
store: StateStore;
|
|
9
|
+
activityLog: ActivityLog;
|
|
10
|
+
controlApi?: Server;
|
|
11
|
+
}
|
|
12
|
+
export declare function chaosFastifyPlugin(options?: ChaosOptions): ChaosFastifyPlugin;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { ActivityLog } from "../core/activity-log.js";
|
|
2
|
+
import { ScenarioEngine } from "../core/scenario-engine.js";
|
|
3
|
+
import { StateStore } from "../core/state-store.js";
|
|
4
|
+
import { createControlApi } from "../dashboard/server/control-api.js";
|
|
5
|
+
import { isBlockedByGuardrail } from "../guardrail.js";
|
|
6
|
+
export function chaosFastifyPlugin(options = {}) {
|
|
7
|
+
const store = options.store ?? new StateStore();
|
|
8
|
+
const activityLog = options.activityLog ?? new ActivityLog();
|
|
9
|
+
const engine = new ScenarioEngine(store, activityLog);
|
|
10
|
+
const plugin = (async (fastify) => {
|
|
11
|
+
fastify.addHook("onRequest", async (request, reply) => {
|
|
12
|
+
if (isBlockedByGuardrail(options))
|
|
13
|
+
return;
|
|
14
|
+
const controller = {
|
|
15
|
+
status: (code) => {
|
|
16
|
+
reply.code(code);
|
|
17
|
+
},
|
|
18
|
+
header: (name, value) => {
|
|
19
|
+
reply.header(name, value);
|
|
20
|
+
},
|
|
21
|
+
send: (body) => {
|
|
22
|
+
reply.send(body);
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
const path = request.url.split("?")[0];
|
|
26
|
+
const result = await engine.resolve({ method: request.method, path }, controller);
|
|
27
|
+
if (result === "terminated" && !reply.sent) {
|
|
28
|
+
// random-timeout: no response was written on purpose — take Fastify
|
|
29
|
+
// out of the request lifecycle so it doesn't send a default reply.
|
|
30
|
+
reply.hijack();
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
// Skip Fastify's plugin encapsulation so the onRequest hook applies to routes
|
|
35
|
+
// registered on the parent instance, not just inside this plugin's own context.
|
|
36
|
+
// This is the same mechanism the `fastify-plugin` package uses internally.
|
|
37
|
+
plugin[Symbol.for("skip-override")] = true;
|
|
38
|
+
plugin.store = store;
|
|
39
|
+
plugin.activityLog = activityLog;
|
|
40
|
+
if (options.controlPort) {
|
|
41
|
+
plugin.controlApi = createControlApi(store, activityLog).listen(options.controlPort);
|
|
42
|
+
}
|
|
43
|
+
return plugin;
|
|
44
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Middleware } from "koa";
|
|
2
|
+
import type { Server } from "node:http";
|
|
3
|
+
import { ActivityLog } from "../core/activity-log.js";
|
|
4
|
+
import { StateStore } from "../core/state-store.js";
|
|
5
|
+
import type { ChaosOptions } from "./express.js";
|
|
6
|
+
export interface ChaosKoaMiddleware extends Middleware {
|
|
7
|
+
store: StateStore;
|
|
8
|
+
activityLog: ActivityLog;
|
|
9
|
+
controlApi?: Server;
|
|
10
|
+
}
|
|
11
|
+
/** docs/PRD.md 6.6 — thin adapter over the shared core, same pattern as adapters/express.ts and adapters/fastify.ts. */
|
|
12
|
+
export declare function chaosKoaMiddleware(options?: ChaosOptions): ChaosKoaMiddleware;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { ActivityLog } from "../core/activity-log.js";
|
|
2
|
+
import { ScenarioEngine } from "../core/scenario-engine.js";
|
|
3
|
+
import { StateStore } from "../core/state-store.js";
|
|
4
|
+
import { createControlApi } from "../dashboard/server/control-api.js";
|
|
5
|
+
import { isBlockedByGuardrail } from "../guardrail.js";
|
|
6
|
+
/** docs/PRD.md 6.6 — thin adapter over the shared core, same pattern as adapters/express.ts and adapters/fastify.ts. */
|
|
7
|
+
export function chaosKoaMiddleware(options = {}) {
|
|
8
|
+
const store = options.store ?? new StateStore();
|
|
9
|
+
const activityLog = options.activityLog ?? new ActivityLog();
|
|
10
|
+
const engine = new ScenarioEngine(store, activityLog);
|
|
11
|
+
const middleware = (async (ctx, next) => {
|
|
12
|
+
if (isBlockedByGuardrail(options)) {
|
|
13
|
+
await next();
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
let responded = false;
|
|
17
|
+
const controller = {
|
|
18
|
+
status: (code) => {
|
|
19
|
+
ctx.status = code;
|
|
20
|
+
responded = true;
|
|
21
|
+
},
|
|
22
|
+
header: (name, value) => {
|
|
23
|
+
ctx.set(name, value);
|
|
24
|
+
},
|
|
25
|
+
send: (body) => {
|
|
26
|
+
ctx.body = body;
|
|
27
|
+
responded = true;
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
const result = await engine.resolve({ method: ctx.method, path: ctx.path }, controller);
|
|
31
|
+
if (result === "terminated") {
|
|
32
|
+
if (!responded) {
|
|
33
|
+
// connection-reset: no response was written on purpose — take Koa out of the
|
|
34
|
+
// request lifecycle so it doesn't send a default reply / close the socket.
|
|
35
|
+
ctx.respond = false;
|
|
36
|
+
}
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
await next();
|
|
40
|
+
});
|
|
41
|
+
middleware.store = store;
|
|
42
|
+
middleware.activityLog = activityLog;
|
|
43
|
+
if (options.controlPort) {
|
|
44
|
+
middleware.controlApi = createControlApi(store, activityLog).listen(options.controlPort);
|
|
45
|
+
}
|
|
46
|
+
return middleware;
|
|
47
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { Server } from "node:http";
|
|
2
|
+
import { ActivityLog } from "../core/activity-log.js";
|
|
3
|
+
import { StateStore } from "../core/state-store.js";
|
|
4
|
+
import type { ChaosOptions } from "./express.js";
|
|
5
|
+
/**
|
|
6
|
+
* Minimal structural shapes for Nest's req/res — deliberately not `@nestjs/common` types, so
|
|
7
|
+
* this package doesn't need Nest (or Express/Fastify) as a dependency just to type this file.
|
|
8
|
+
* Works under both platform-express (req/res are Express-decorated) and platform-fastify
|
|
9
|
+
* (functional middleware gets the raw `http.IncomingMessage`/`ServerResponse`).
|
|
10
|
+
*/
|
|
11
|
+
export interface NestLikeRequest {
|
|
12
|
+
method: string;
|
|
13
|
+
url: string;
|
|
14
|
+
originalUrl?: string;
|
|
15
|
+
}
|
|
16
|
+
export interface NestLikeResponse {
|
|
17
|
+
statusCode?: number;
|
|
18
|
+
status?(code: number): unknown;
|
|
19
|
+
send?(body?: unknown): unknown;
|
|
20
|
+
setHeader(name: string, value: string): unknown;
|
|
21
|
+
end(chunk?: unknown): unknown;
|
|
22
|
+
}
|
|
23
|
+
export type NestMiddlewareFn = (req: NestLikeRequest, res: NestLikeResponse, next: (err?: unknown) => void) => void;
|
|
24
|
+
export interface ChaosNestMiddleware extends NestMiddlewareFn {
|
|
25
|
+
store: StateStore;
|
|
26
|
+
activityLog: ActivityLog;
|
|
27
|
+
controlApi?: Server;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* docs/PRD.md 6.6 — thin adapter over the shared core, same pattern as adapters/express.ts and
|
|
31
|
+
* adapters/fastify.ts. Register as functional middleware: `consumer.apply(createChaosNestMiddleware()).forRoutes("*")`
|
|
32
|
+
* in a NestModule's `configure()`, or just `app.use(createChaosNestMiddleware())` in main.ts.
|
|
33
|
+
*/
|
|
34
|
+
export declare function createChaosNestMiddleware(options?: ChaosOptions): ChaosNestMiddleware;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { ActivityLog } from "../core/activity-log.js";
|
|
2
|
+
import { ScenarioEngine } from "../core/scenario-engine.js";
|
|
3
|
+
import { StateStore } from "../core/state-store.js";
|
|
4
|
+
import { createControlApi } from "../dashboard/server/control-api.js";
|
|
5
|
+
import { isBlockedByGuardrail } from "../guardrail.js";
|
|
6
|
+
/**
|
|
7
|
+
* docs/PRD.md 6.6 — thin adapter over the shared core, same pattern as adapters/express.ts and
|
|
8
|
+
* adapters/fastify.ts. Register as functional middleware: `consumer.apply(createChaosNestMiddleware()).forRoutes("*")`
|
|
9
|
+
* in a NestModule's `configure()`, or just `app.use(createChaosNestMiddleware())` in main.ts.
|
|
10
|
+
*/
|
|
11
|
+
export function createChaosNestMiddleware(options = {}) {
|
|
12
|
+
const store = options.store ?? new StateStore();
|
|
13
|
+
const activityLog = options.activityLog ?? new ActivityLog();
|
|
14
|
+
const engine = new ScenarioEngine(store, activityLog);
|
|
15
|
+
const middleware = ((req, res, next) => {
|
|
16
|
+
if (isBlockedByGuardrail(options)) {
|
|
17
|
+
next();
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
const controller = {
|
|
21
|
+
status: (code) => {
|
|
22
|
+
if (typeof res.status === "function")
|
|
23
|
+
res.status(code);
|
|
24
|
+
else
|
|
25
|
+
res.statusCode = code;
|
|
26
|
+
},
|
|
27
|
+
header: (name, value) => {
|
|
28
|
+
res.setHeader(name, value);
|
|
29
|
+
},
|
|
30
|
+
send: (body) => {
|
|
31
|
+
if (typeof res.send === "function")
|
|
32
|
+
res.send(body);
|
|
33
|
+
else
|
|
34
|
+
res.end(typeof body === "string" ? body : JSON.stringify(body));
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
const path = (req.originalUrl ?? req.url).split("?")[0];
|
|
38
|
+
engine
|
|
39
|
+
.resolve({ method: req.method, path }, controller)
|
|
40
|
+
.then((result) => {
|
|
41
|
+
if (result === "continue")
|
|
42
|
+
next();
|
|
43
|
+
})
|
|
44
|
+
.catch(next);
|
|
45
|
+
});
|
|
46
|
+
middleware.store = store;
|
|
47
|
+
middleware.activityLog = activityLog;
|
|
48
|
+
if (options.controlPort) {
|
|
49
|
+
middleware.controlApi = createControlApi(store, activityLog).listen(options.controlPort);
|
|
50
|
+
}
|
|
51
|
+
return middleware;
|
|
52
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { ActivityLog } from "../core/activity-log.js";
|
|
3
|
+
import { StateStore } from "../core/state-store.js";
|
|
4
|
+
import { createControlApi } from "../dashboard/server/control-api.js";
|
|
5
|
+
import { startDashboard } from "../dashboard/server/index.js";
|
|
6
|
+
const [, , command, ...rest] = process.argv;
|
|
7
|
+
const DEFAULT_CONTROL_PORT = 51820;
|
|
8
|
+
function flagValue(flag) {
|
|
9
|
+
const index = rest.indexOf(flag);
|
|
10
|
+
return index >= 0 ? rest[index + 1] : undefined;
|
|
11
|
+
}
|
|
12
|
+
function hasFlag(flag) {
|
|
13
|
+
return rest.includes(flag);
|
|
14
|
+
}
|
|
15
|
+
if (command === "dashboard") {
|
|
16
|
+
const portFlag = flagValue("--port");
|
|
17
|
+
startDashboard({ port: portFlag ? Number(portFlag) : undefined });
|
|
18
|
+
if (!hasFlag("--no-control-api")) {
|
|
19
|
+
const controlPortFlag = flagValue("--control-port");
|
|
20
|
+
const controlPort = controlPortFlag ? Number(controlPortFlag) : DEFAULT_CONTROL_PORT;
|
|
21
|
+
const store = new StateStore();
|
|
22
|
+
const activityLog = new ActivityLog();
|
|
23
|
+
createControlApi(store, activityLog).listen(controlPort, () => {
|
|
24
|
+
console.log(`[chaos-api] standalone control API running at http://localhost:${controlPort} ` +
|
|
25
|
+
"(demo StateStore, not wired to a real app — pass --no-control-api if your app " +
|
|
26
|
+
"already runs chaos({ controlPort }) itself)");
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
console.error(`Unknown command: ${command ?? "(none)"}. Usage: chaos-api dashboard [--port <n>] [--control-port <n>] [--no-control-api]`);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ScenarioDirection, ScenarioType } from "./types.js";
|
|
2
|
+
export interface ActivityEvent {
|
|
3
|
+
id: string;
|
|
4
|
+
timestamp: number;
|
|
5
|
+
scenarioId: string;
|
|
6
|
+
scenarioType: ScenarioType;
|
|
7
|
+
direction: ScenarioDirection;
|
|
8
|
+
method: string;
|
|
9
|
+
path: string;
|
|
10
|
+
}
|
|
11
|
+
export type RecordActivityInput = Omit<ActivityEvent, "id" | "timestamp">;
|
|
12
|
+
/**
|
|
13
|
+
* In-memory ring buffer of fired scenarios (docs/PRD.md 6.5 "feed de atividade") — no external
|
|
14
|
+
* dependency, capped so it can't grow unbounded in a long-running process.
|
|
15
|
+
*/
|
|
16
|
+
export declare class ActivityLog {
|
|
17
|
+
private readonly capacity;
|
|
18
|
+
private readonly events;
|
|
19
|
+
constructor(capacity?: number);
|
|
20
|
+
record(input: RecordActivityInput): ActivityEvent;
|
|
21
|
+
/** Newest first. */
|
|
22
|
+
list(limit?: number): ActivityEvent[];
|
|
23
|
+
clear(): void;
|
|
24
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
/**
|
|
3
|
+
* In-memory ring buffer of fired scenarios (docs/PRD.md 6.5 "feed de atividade") — no external
|
|
4
|
+
* dependency, capped so it can't grow unbounded in a long-running process.
|
|
5
|
+
*/
|
|
6
|
+
export class ActivityLog {
|
|
7
|
+
capacity;
|
|
8
|
+
events = [];
|
|
9
|
+
constructor(capacity = 200) {
|
|
10
|
+
this.capacity = capacity;
|
|
11
|
+
}
|
|
12
|
+
record(input) {
|
|
13
|
+
const event = { id: randomUUID(), timestamp: Date.now(), ...input };
|
|
14
|
+
this.events.unshift(event);
|
|
15
|
+
if (this.events.length > this.capacity)
|
|
16
|
+
this.events.length = this.capacity;
|
|
17
|
+
return event;
|
|
18
|
+
}
|
|
19
|
+
/** Newest first. */
|
|
20
|
+
list(limit) {
|
|
21
|
+
return limit !== undefined ? this.events.slice(0, limit) : [...this.events];
|
|
22
|
+
}
|
|
23
|
+
clear() {
|
|
24
|
+
this.events.length = 0;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ActivityLog } from "./activity-log.js";
|
|
2
|
+
import type { StateStore } from "./state-store.js";
|
|
3
|
+
import type { ChaosRequestInfo, ChaosResponseController, ScenarioResult } from "./types.js";
|
|
4
|
+
export declare class ScenarioEngine {
|
|
5
|
+
private readonly store;
|
|
6
|
+
private readonly activityLog?;
|
|
7
|
+
constructor(store: StateStore, activityLog?: ActivityLog | undefined);
|
|
8
|
+
resolve(req: ChaosRequestInfo, res: ChaosResponseController): Promise<ScenarioResult>;
|
|
9
|
+
/** docs/PRD.md 6.4 — `req.path` carries the outbound call's destination host, not a route. */
|
|
10
|
+
resolveOutbound(req: ChaosRequestInfo, res: ChaosResponseController): Promise<ScenarioResult>;
|
|
11
|
+
private apply;
|
|
12
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { SCENARIO_REGISTRY } from "../scenarios/registry.js";
|
|
2
|
+
/** Fixed apply order for combined scenarios — matches docs/architecture-and-walkthrough.md. */
|
|
3
|
+
const PRIORITY = SCENARIO_REGISTRY.map((def) => def.type);
|
|
4
|
+
const HANDLERS = Object.fromEntries(SCENARIO_REGISTRY.map((def) => [def.type, def.handler]));
|
|
5
|
+
export class ScenarioEngine {
|
|
6
|
+
store;
|
|
7
|
+
activityLog;
|
|
8
|
+
constructor(store, activityLog) {
|
|
9
|
+
this.store = store;
|
|
10
|
+
this.activityLog = activityLog;
|
|
11
|
+
}
|
|
12
|
+
async resolve(req, res) {
|
|
13
|
+
return this.apply(this.store.getActiveForPath(req.path), req, res);
|
|
14
|
+
}
|
|
15
|
+
/** docs/PRD.md 6.4 — `req.path` carries the outbound call's destination host, not a route. */
|
|
16
|
+
async resolveOutbound(req, res) {
|
|
17
|
+
return this.apply(this.store.getActiveOutbound(req.path), req, res);
|
|
18
|
+
}
|
|
19
|
+
async apply(active, req, res) {
|
|
20
|
+
const ordered = PRIORITY.flatMap((type) => active.filter((s) => s.type === type));
|
|
21
|
+
for (const scenario of ordered) {
|
|
22
|
+
if (Math.random() > scenario.rate)
|
|
23
|
+
continue;
|
|
24
|
+
this.activityLog?.record({
|
|
25
|
+
scenarioId: scenario.id,
|
|
26
|
+
scenarioType: scenario.type,
|
|
27
|
+
direction: scenario.direction,
|
|
28
|
+
method: req.method,
|
|
29
|
+
path: req.path,
|
|
30
|
+
});
|
|
31
|
+
const handler = HANDLERS[scenario.type];
|
|
32
|
+
const result = await handler({ req, res }, scenario.options);
|
|
33
|
+
if (result === "terminated")
|
|
34
|
+
return "terminated";
|
|
35
|
+
}
|
|
36
|
+
return "continue";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { LegacyScenarioType, ScenarioConfig, ScenarioDirection, ScenarioScope, ScenarioType } from "./types.js";
|
|
2
|
+
export interface RegisterScenarioInput {
|
|
3
|
+
type: ScenarioType | LegacyScenarioType;
|
|
4
|
+
scope?: ScenarioScope;
|
|
5
|
+
/** "inbound" (default) matches request path; "outbound" matches destination host (docs/PRD.md 6.4). */
|
|
6
|
+
direction?: ScenarioDirection;
|
|
7
|
+
rate?: number;
|
|
8
|
+
enabled?: boolean;
|
|
9
|
+
options?: Record<string, unknown>;
|
|
10
|
+
}
|
|
11
|
+
export type UpdateScenarioInput = Partial<Omit<ScenarioConfig, "id" | "type">>;
|
|
12
|
+
/** Converts a route glob (`/orders/*`) into a matching RegExp. Only `*` is a wildcard. */
|
|
13
|
+
export declare function globToRegex(pattern: string): RegExp;
|
|
14
|
+
/** In-memory registry of active chaos scenarios. One instance lives per middleware. */
|
|
15
|
+
export declare class StateStore {
|
|
16
|
+
private readonly scenarios;
|
|
17
|
+
register(input: RegisterScenarioInput): ScenarioConfig;
|
|
18
|
+
update(id: string, patch: UpdateScenarioInput): ScenarioConfig | undefined;
|
|
19
|
+
remove(id: string): boolean;
|
|
20
|
+
get(id: string): ScenarioConfig | undefined;
|
|
21
|
+
list(): ScenarioConfig[];
|
|
22
|
+
getActiveForPath(path: string): ScenarioConfig[];
|
|
23
|
+
/** docs/PRD.md 6.4 — scenarios scoped to an outbound call's destination host instead of a route. */
|
|
24
|
+
getActiveOutbound(host: string): ScenarioConfig[];
|
|
25
|
+
clear(): void;
|
|
26
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
/** v1 -> v2 primitive mapping (docs/PRD.md 6.2). Keeps pre-refactor configs working unchanged. */
|
|
3
|
+
const LEGACY_TYPE_ALIASES = {
|
|
4
|
+
"random-error": "error-response",
|
|
5
|
+
"random-timeout": "connection-reset",
|
|
6
|
+
"unavailable-503": "unavailable",
|
|
7
|
+
};
|
|
8
|
+
function normalizeType(type) {
|
|
9
|
+
return type in LEGACY_TYPE_ALIASES ? LEGACY_TYPE_ALIASES[type] : type;
|
|
10
|
+
}
|
|
11
|
+
/** Converts a route glob (`/orders/*`) into a matching RegExp. Only `*` is a wildcard. */
|
|
12
|
+
export function globToRegex(pattern) {
|
|
13
|
+
const escaped = pattern
|
|
14
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
15
|
+
.replace(/\*/g, ".*");
|
|
16
|
+
return new RegExp(`^${escaped}$`);
|
|
17
|
+
}
|
|
18
|
+
function matchesScope(scope, path) {
|
|
19
|
+
if (scope === "global")
|
|
20
|
+
return true;
|
|
21
|
+
return globToRegex(scope.pattern).test(path);
|
|
22
|
+
}
|
|
23
|
+
/** In-memory registry of active chaos scenarios. One instance lives per middleware. */
|
|
24
|
+
export class StateStore {
|
|
25
|
+
scenarios = new Map();
|
|
26
|
+
register(input) {
|
|
27
|
+
if (input.rate !== undefined && (input.rate < 0 || input.rate > 1)) {
|
|
28
|
+
throw new Error(`rate must be between 0 and 1, got ${input.rate}`);
|
|
29
|
+
}
|
|
30
|
+
const scenario = {
|
|
31
|
+
id: randomUUID(),
|
|
32
|
+
type: normalizeType(input.type),
|
|
33
|
+
scope: input.scope ?? "global",
|
|
34
|
+
direction: input.direction ?? "inbound",
|
|
35
|
+
rate: input.rate ?? 1,
|
|
36
|
+
enabled: input.enabled ?? true,
|
|
37
|
+
options: input.options ?? {},
|
|
38
|
+
};
|
|
39
|
+
this.scenarios.set(scenario.id, scenario);
|
|
40
|
+
return scenario;
|
|
41
|
+
}
|
|
42
|
+
update(id, patch) {
|
|
43
|
+
const existing = this.scenarios.get(id);
|
|
44
|
+
if (!existing)
|
|
45
|
+
return undefined;
|
|
46
|
+
const updated = { ...existing, ...patch, id: existing.id, type: existing.type };
|
|
47
|
+
this.scenarios.set(id, updated);
|
|
48
|
+
return updated;
|
|
49
|
+
}
|
|
50
|
+
remove(id) {
|
|
51
|
+
return this.scenarios.delete(id);
|
|
52
|
+
}
|
|
53
|
+
get(id) {
|
|
54
|
+
return this.scenarios.get(id);
|
|
55
|
+
}
|
|
56
|
+
list() {
|
|
57
|
+
return [...this.scenarios.values()];
|
|
58
|
+
}
|
|
59
|
+
getActiveForPath(path) {
|
|
60
|
+
return this.list().filter((s) => s.enabled && s.direction === "inbound" && matchesScope(s.scope, path));
|
|
61
|
+
}
|
|
62
|
+
/** docs/PRD.md 6.4 — scenarios scoped to an outbound call's destination host instead of a route. */
|
|
63
|
+
getActiveOutbound(host) {
|
|
64
|
+
return this.list().filter((s) => s.enabled && s.direction === "outbound" && matchesScope(s.scope, host));
|
|
65
|
+
}
|
|
66
|
+
clear() {
|
|
67
|
+
this.scenarios.clear();
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** v2 primitives (PRD docs/PRD.md 6.2) — the ~85-item preset catalog (6.3) all resolve to one of these. */
|
|
2
|
+
export type ScenarioType = "delay" | "error-response" | "connection-reset" | "unavailable" | "malformed-response" | "stale-response";
|
|
3
|
+
/**
|
|
4
|
+
* v1 type names, kept accepted at registration time so existing configs don't break.
|
|
5
|
+
* `StateStore.register` normalizes these to their v2 primitive equivalent (see
|
|
6
|
+
* `LEGACY_TYPE_ALIASES` in state-store.ts) — a stored `ScenarioConfig.type` is always
|
|
7
|
+
* a `ScenarioType`, never one of these.
|
|
8
|
+
*/
|
|
9
|
+
export type LegacyScenarioType = "random-error" | "random-timeout" | "unavailable-503";
|
|
10
|
+
export type ScenarioScope = "global" | {
|
|
11
|
+
pattern: string;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* inbound (default) — scope pattern matches request path, e.g. "/orders/*".
|
|
15
|
+
* outbound (docs/PRD.md 6.4) — scope pattern matches destination host, e.g. "api.stripe.com".
|
|
16
|
+
*/
|
|
17
|
+
export type ScenarioDirection = "inbound" | "outbound";
|
|
18
|
+
export interface ScenarioConfig {
|
|
19
|
+
id: string;
|
|
20
|
+
type: ScenarioType;
|
|
21
|
+
scope: ScenarioScope;
|
|
22
|
+
direction: ScenarioDirection;
|
|
23
|
+
/** 0..1 — fraction of matching requests this scenario applies to. */
|
|
24
|
+
rate: number;
|
|
25
|
+
enabled: boolean;
|
|
26
|
+
options: Record<string, unknown>;
|
|
27
|
+
}
|
|
28
|
+
export interface ChaosRequestInfo {
|
|
29
|
+
method: string;
|
|
30
|
+
path: string;
|
|
31
|
+
}
|
|
32
|
+
export interface ChaosResponseController {
|
|
33
|
+
status(code: number): void;
|
|
34
|
+
header(name: string, value: string): void;
|
|
35
|
+
send(body?: unknown): void;
|
|
36
|
+
}
|
|
37
|
+
export type ScenarioResult = "continue" | "terminated";
|
|
38
|
+
export type ScenarioHandler = (ctx: {
|
|
39
|
+
req: ChaosRequestInfo;
|
|
40
|
+
res: ChaosResponseController;
|
|
41
|
+
}, options: Record<string, unknown>) => Promise<ScenarioResult> | ScenarioResult;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { type Server } from "node:http";
|
|
2
|
+
import type { ActivityLog } from "../../core/activity-log.js";
|
|
3
|
+
import type { StateStore } from "../../core/state-store.js";
|
|
4
|
+
/**
|
|
5
|
+
* Local control API for the scenario StateStore living inside the user's app process.
|
|
6
|
+
* dashboard-ui talks to this directly (CORS-open, localhost-only by convention) — the
|
|
7
|
+
* dashboard-server process only serves static UI files, it does not proxy this API.
|
|
8
|
+
*/
|
|
9
|
+
export declare function createControlApi(store: StateStore, activityLog?: ActivityLog): Server;
|