dripfeed 0.1.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.
@@ -0,0 +1,758 @@
1
+ let node_fs_promises = require("node:fs/promises");
2
+ let c12 = require("c12");
3
+ let zod = require("zod");
4
+ //#region src/core/types.ts
5
+ /** Check if an HTTP status code represents a successful response */
6
+ const isSuccess = (status) => status !== null && status >= 200 && status < 400;
7
+ //#endregion
8
+ //#region src/adapters/reporters/console.ts
9
+ const GREEN = "\x1B[32m";
10
+ const RED = "\x1B[31m";
11
+ const DIM = "\x1B[2m";
12
+ const BOLD = "\x1B[1m";
13
+ const RESET = "\x1B[0m";
14
+ const CYAN = "\x1B[36m";
15
+ const YELLOW = "\x1B[33m";
16
+ const pad = (s, len) => s.padEnd(len);
17
+ const rpad = (s, len) => s.padStart(len);
18
+ const createConsoleReporter = () => {
19
+ let requestNum = 0;
20
+ return {
21
+ onRequest(result, counts) {
22
+ requestNum++;
23
+ const ok = isSuccess(result.status);
24
+ const icon = ok ? `${GREEN}\u2713${RESET}` : `${RED}\u2717${RESET}`;
25
+ const num = rpad(`#${requestNum}`, 6);
26
+ const name = pad(result.endpoint, 24);
27
+ const status = result.status ? ok ? `${GREEN}${result.status}${RESET}` : `${RED}${result.status}${RESET}` : `${RED}ERR${RESET}`;
28
+ const duration = rpad(`${result.duration_ms}ms`, 8);
29
+ const total = counts.ok + counts.fail;
30
+ const pct = total > 0 ? (counts.ok / total * 100).toFixed(1) : "100.0";
31
+ const summary = `${DIM}ok:${counts.ok} fail:${counts.fail} (${pct}%)${RESET}`;
32
+ process.stdout.write(`${icon} ${num} ${name} ${status} ${duration} | ${summary}\n`);
33
+ if (!ok && result.error) process.stdout.write(` ${DIM}${result.error}${RESET}\n`);
34
+ },
35
+ onComplete(stats) {
36
+ const divider = `${DIM}${"─".repeat(70)}${RESET}`;
37
+ process.stdout.write(`\n${divider}\n`);
38
+ process.stdout.write(`${BOLD}Soak Test Summary${RESET}\n`);
39
+ process.stdout.write(`${divider}\n\n`);
40
+ process.stdout.write(` Duration: ${stats.duration_s}s\n`);
41
+ process.stdout.write(` Requests: ${stats.total_requests}\n`);
42
+ process.stdout.write(` Success: ${GREEN}${stats.success_count}${RESET} Failures: ${stats.failure_count > 0 ? RED : ""}${stats.failure_count}${RESET}\n`);
43
+ process.stdout.write(` Uptime: ${stats.uptime_pct}%\n\n`);
44
+ process.stdout.write(` ${CYAN}Latency${RESET}\n`);
45
+ process.stdout.write(` min: ${stats.latency.min}ms avg: ${stats.latency.avg}ms `);
46
+ process.stdout.write(`p50: ${stats.latency.p50}ms p95: ${stats.latency.p95}ms `);
47
+ process.stdout.write(`p99: ${stats.latency.p99}ms max: ${stats.latency.max}ms\n\n`);
48
+ if (stats.endpoints.length > 0) {
49
+ process.stdout.write(` ${CYAN}Endpoints${RESET}\n`);
50
+ for (const ep of stats.endpoints) {
51
+ const errPart = ep.error_count > 0 ? ` ${RED}${ep.error_count} errors${RESET}` : "";
52
+ process.stdout.write(` ${pad(ep.name, 24)} ${rpad(String(ep.requests), 5)} reqs avg: ${rpad(String(ep.avg_ms), 5)}ms p95: ${rpad(String(ep.p95_ms), 5)}ms${errPart}\n`);
53
+ }
54
+ process.stdout.write("\n");
55
+ }
56
+ if (stats.errors.length > 0) {
57
+ process.stdout.write(` ${RED}Errors${RESET}\n`);
58
+ for (const err of stats.errors.slice(0, 10)) {
59
+ const status = err.status ?? "NET";
60
+ process.stdout.write(` ${pad(err.endpoint, 24)} ${YELLOW}${status}${RESET} x${err.count}\n`);
61
+ }
62
+ process.stdout.write("\n");
63
+ }
64
+ if (stats.thresholds) {
65
+ process.stdout.write(` ${CYAN}Thresholds${RESET}\n`);
66
+ for (const t of stats.thresholds) {
67
+ const icon = t.passed ? `${GREEN}\u2713${RESET}` : `${RED}\u2717${RESET}`;
68
+ process.stdout.write(` ${icon} ${pad(t.name, 12)} target: ${pad(t.target, 12)} actual: ${t.actual}\n`);
69
+ }
70
+ if (!stats.thresholds.every((t) => t.passed)) process.stdout.write(`\n ${RED}${BOLD}THRESHOLDS FAILED${RESET}\n`);
71
+ process.stdout.write("\n");
72
+ }
73
+ }
74
+ };
75
+ };
76
+ //#endregion
77
+ //#region src/adapters/reporters/json.ts
78
+ const createJsonReporter = (outputPath) => ({
79
+ onRequest() {},
80
+ onComplete(stats) {
81
+ const json = JSON.stringify(stats, null, 2);
82
+ if (outputPath) (0, node_fs_promises.writeFile)(outputPath, json).catch((err) => {
83
+ process.stderr.write(`[dripfeed] Failed to write report to ${outputPath}: ${err.message}\n`);
84
+ });
85
+ else process.stdout.write(`${json}\n`);
86
+ }
87
+ });
88
+ //#endregion
89
+ //#region src/adapters/reporters/markdown.ts
90
+ const generateMarkdown = (stats) => {
91
+ const lines = [
92
+ "# Soak Test Report",
93
+ "",
94
+ "## Summary",
95
+ "",
96
+ `| Metric | Value |`,
97
+ `|--------|-------|`,
98
+ `| Duration | ${stats.duration_s}s |`,
99
+ `| Total Requests | ${stats.total_requests} |`,
100
+ `| Success | ${stats.success_count} |`,
101
+ `| Failures | ${stats.failure_count} |`,
102
+ `| Uptime | ${stats.uptime_pct}% |`,
103
+ "",
104
+ "## Latency",
105
+ "",
106
+ "| Metric | Value |",
107
+ "|--------|-------|",
108
+ `| Min | ${stats.latency.min}ms |`,
109
+ `| Avg | ${stats.latency.avg}ms |`,
110
+ `| P50 | ${stats.latency.p50}ms |`,
111
+ `| P95 | ${stats.latency.p95}ms |`,
112
+ `| P99 | ${stats.latency.p99}ms |`,
113
+ `| Max | ${stats.latency.max}ms |`,
114
+ ""
115
+ ];
116
+ if (stats.endpoints.length > 0) {
117
+ lines.push("## Endpoints", "", "| Endpoint | Requests | Avg | P95 | Errors |", "|----------|----------|-----|-----|--------|");
118
+ for (const ep of stats.endpoints) lines.push(`| ${ep.name} | ${ep.requests} | ${ep.avg_ms}ms | ${ep.p95_ms}ms | ${ep.error_count} |`);
119
+ lines.push("");
120
+ }
121
+ if (stats.errors.length > 0) {
122
+ lines.push("## Errors", "", "| Endpoint | Status | Count |", "|----------|--------|-------|");
123
+ for (const err of stats.errors) lines.push(`| ${err.endpoint} | ${err.status ?? "Network"} | ${err.count} |`);
124
+ lines.push("");
125
+ }
126
+ if (stats.thresholds) {
127
+ lines.push("## Thresholds", "", "| Check | Target | Actual | Result |", "|-------|--------|--------|--------|");
128
+ for (const t of stats.thresholds) {
129
+ const icon = t.passed ? "PASS" : "FAIL";
130
+ lines.push(`| ${t.name} | ${t.target} | ${t.actual} | ${icon} |`);
131
+ }
132
+ lines.push("");
133
+ }
134
+ return lines.join("\n");
135
+ };
136
+ const createMarkdownReporter = (outputPath) => ({
137
+ onRequest() {},
138
+ onComplete(stats) {
139
+ const md = generateMarkdown(stats);
140
+ if (outputPath) (0, node_fs_promises.writeFile)(outputPath, md).catch((err) => {
141
+ process.stderr.write(`[dripfeed] Failed to write report to ${outputPath}: ${err.message}\n`);
142
+ });
143
+ else process.stdout.write(`${md}\n`);
144
+ }
145
+ });
146
+ //#endregion
147
+ //#region src/adapters/storage/json.ts
148
+ const FLUSH_INTERVAL = 10;
149
+ const createJsonStorage = (filePath) => {
150
+ let buffer = [];
151
+ let flushed = [];
152
+ const flush = async () => {
153
+ if (buffer.length === 0) return;
154
+ flushed = [...flushed, ...buffer];
155
+ buffer = [];
156
+ await (0, node_fs_promises.writeFile)(filePath, JSON.stringify(flushed, null, 2));
157
+ };
158
+ return {
159
+ init: async () => {
160
+ try {
161
+ const data = await (0, node_fs_promises.readFile)(filePath, "utf-8");
162
+ flushed = JSON.parse(data);
163
+ } catch {
164
+ flushed = [];
165
+ }
166
+ },
167
+ record: async (result) => {
168
+ buffer.push(result);
169
+ if (buffer.length >= FLUSH_INTERVAL) await flush();
170
+ },
171
+ getAll: async () => [...flushed, ...buffer],
172
+ close: async () => {
173
+ await flush();
174
+ }
175
+ };
176
+ };
177
+ //#endregion
178
+ //#region src/adapters/storage/memory.ts
179
+ const createMemoryStorage = () => {
180
+ const results = [];
181
+ return {
182
+ init: async () => {},
183
+ record: async (result) => {
184
+ results.push(result);
185
+ },
186
+ getAll: async () => [...results],
187
+ close: async () => {}
188
+ };
189
+ };
190
+ //#endregion
191
+ //#region src/utils/runtime.ts
192
+ const isBun = typeof globalThis !== "undefined" && "Bun" in globalThis;
193
+ const isDeno = typeof globalThis !== "undefined" && "Deno" in globalThis;
194
+ const isNode = !isBun && !isDeno;
195
+ //#endregion
196
+ //#region src/adapters/storage/sqlite.ts
197
+ const CREATE_TABLE = `
198
+ CREATE TABLE IF NOT EXISTS results (
199
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
200
+ timestamp TEXT NOT NULL,
201
+ endpoint TEXT NOT NULL,
202
+ method TEXT NOT NULL,
203
+ url TEXT NOT NULL,
204
+ status INTEGER,
205
+ duration_ms REAL NOT NULL,
206
+ response_body TEXT,
207
+ error TEXT
208
+ )`;
209
+ const INSERT = `
210
+ INSERT INTO results (timestamp, endpoint, method, url, status, duration_ms, response_body, error)
211
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`;
212
+ const openBunSqlite = async (path) => {
213
+ const { Database } = await import("bun:sqlite");
214
+ const db = new Database(path);
215
+ db.exec("PRAGMA journal_mode = WAL");
216
+ return {
217
+ exec: (sql) => db.exec(sql),
218
+ prepare: (sql) => {
219
+ const stmt = db.prepare(sql);
220
+ return {
221
+ run: (...params) => stmt.run(...params),
222
+ all: (...params) => stmt.all(...params)
223
+ };
224
+ },
225
+ close: () => db.close()
226
+ };
227
+ };
228
+ const openBetterSqlite = async (path) => {
229
+ let mod;
230
+ try {
231
+ mod = await import("better-sqlite3");
232
+ } catch {
233
+ throw new Error("SQLite storage requires \"better-sqlite3\" on Node.js. Install it:\n npm install better-sqlite3\nOr use storage: \"json\" in your dripfeed config.");
234
+ }
235
+ const db = mod.default(path);
236
+ db.exec("PRAGMA journal_mode = WAL");
237
+ return {
238
+ exec: (sql) => db.exec(sql),
239
+ prepare: (sql) => {
240
+ const stmt = db.prepare(sql);
241
+ return {
242
+ run: (...params) => stmt.run(...params),
243
+ all: (...params) => stmt.all(...params)
244
+ };
245
+ },
246
+ close: () => db.close()
247
+ };
248
+ };
249
+ const createSqliteStorage = (dbPath) => {
250
+ let db = null;
251
+ let insertStmt = null;
252
+ return {
253
+ init: async () => {
254
+ db = isBun ? await openBunSqlite(dbPath) : await openBetterSqlite(dbPath);
255
+ db.exec(CREATE_TABLE);
256
+ insertStmt = db.prepare(INSERT);
257
+ },
258
+ record: async (result) => {
259
+ insertStmt?.run(result.timestamp, result.endpoint, result.method, result.url, result.status, result.duration_ms, result.response_body, result.error);
260
+ },
261
+ getAll: async () => {
262
+ return (db?.prepare("SELECT * FROM results ORDER BY id").all() ?? []).map((row) => ({
263
+ timestamp: row.timestamp,
264
+ endpoint: row.endpoint,
265
+ method: row.method,
266
+ url: row.url,
267
+ status: row.status,
268
+ duration_ms: row.duration_ms,
269
+ response_body: row.response_body,
270
+ error: row.error
271
+ }));
272
+ },
273
+ close: async () => {
274
+ db?.close();
275
+ }
276
+ };
277
+ };
278
+ //#endregion
279
+ //#region src/adapters/storage/index.ts
280
+ const createStorage = (config) => {
281
+ switch (config.storage ?? "sqlite") {
282
+ case "memory": return createMemoryStorage();
283
+ case "json": return createJsonStorage(config.db ?? "dripfeed-results.json");
284
+ case "sqlite": return createSqliteStorage(config.db ?? "dripfeed-results.db");
285
+ }
286
+ };
287
+ //#endregion
288
+ //#region src/core/config.ts
289
+ const endpointSchema = zod.z.object({
290
+ name: zod.z.string(),
291
+ url: zod.z.string().url(),
292
+ method: zod.z.string().default("GET"),
293
+ headers: zod.z.record(zod.z.string(), zod.z.string()).optional(),
294
+ body: zod.z.unknown().optional(),
295
+ timeout: zod.z.string().optional(),
296
+ weight: zod.z.number().positive().default(1)
297
+ });
298
+ const thresholdSchema = zod.z.object({
299
+ error_rate: zod.z.string().optional(),
300
+ p50: zod.z.string().optional(),
301
+ p95: zod.z.string().optional(),
302
+ p99: zod.z.string().optional(),
303
+ max: zod.z.string().optional()
304
+ });
305
+ const configSchema = zod.z.object({
306
+ interval: zod.z.string().default("3s"),
307
+ duration: zod.z.string().optional(),
308
+ timeout: zod.z.string().default("30s"),
309
+ storage: zod.z.enum([
310
+ "sqlite",
311
+ "json",
312
+ "memory"
313
+ ]).default("sqlite"),
314
+ db: zod.z.string().optional(),
315
+ rotation: zod.z.enum([
316
+ "weighted-random",
317
+ "round-robin",
318
+ "sequential"
319
+ ]).default("weighted-random"),
320
+ headers: zod.z.record(zod.z.string(), zod.z.string()).optional(),
321
+ endpoints: zod.z.array(endpointSchema).min(1, "At least one endpoint is required"),
322
+ thresholds: thresholdSchema.optional()
323
+ });
324
+ /** Parse and validate a raw config object into a fully-typed ParsedConfig with defaults applied.
325
+ * Use this when creating a soak test programmatically without a config file. */
326
+ const parseConfig = (raw) => configSchema.parse(raw);
327
+ const interpolateEnv = (value) => {
328
+ if (typeof value === "string") return value.replace(/\$\{(\w+)\}/g, (_, key) => {
329
+ const val = process.env[key];
330
+ if (val === void 0) process.stderr.write(`[dripfeed] Warning: environment variable "${key}" is not set\n`);
331
+ return val ?? "";
332
+ });
333
+ if (Array.isArray(value)) return value.map(interpolateEnv);
334
+ if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, interpolateEnv(v)]));
335
+ return value;
336
+ };
337
+ const loadDripfeedConfig = async (overrides) => {
338
+ const { config } = await (0, c12.loadConfig)({ name: "dripfeed" });
339
+ const interpolated = interpolateEnv({
340
+ ...config,
341
+ ...overrides
342
+ });
343
+ return configSchema.parse(interpolated);
344
+ };
345
+ //#endregion
346
+ //#region src/utils/duration.ts
347
+ const UNITS = {
348
+ ms: 1,
349
+ s: 1e3,
350
+ m: 6e4,
351
+ h: 36e5,
352
+ d: 864e5
353
+ };
354
+ const DURATION_RE = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)$/;
355
+ const parseDuration = (input) => {
356
+ const match = input.trim().match(DURATION_RE);
357
+ if (!match) throw new Error(`Invalid duration: "${input}". Expected format: "3s", "10m", "2h"`);
358
+ const value = Number.parseFloat(match[1] ?? "0");
359
+ const unit = UNITS[match[2] ?? "ms"] ?? 1;
360
+ return Math.round(value * unit);
361
+ };
362
+ //#endregion
363
+ //#region src/utils/http.ts
364
+ const timedFetch = async (endpoint, globalHeaders, timeout = "30s") => {
365
+ const timeoutMs = parseDuration(endpoint.timeout ?? timeout);
366
+ const headers = {
367
+ ...globalHeaders,
368
+ ...endpoint.headers
369
+ };
370
+ const method = endpoint.method ?? "GET";
371
+ const url = endpoint.url;
372
+ const start = performance.now();
373
+ try {
374
+ const response = await fetch(url, {
375
+ method,
376
+ headers,
377
+ body: endpoint.body ? JSON.stringify(endpoint.body) : void 0,
378
+ signal: AbortSignal.timeout(timeoutMs),
379
+ redirect: "follow"
380
+ });
381
+ const durationMs = Math.round(performance.now() - start);
382
+ const body = response.status >= 400 ? await response.text().catch(() => null) : null;
383
+ return {
384
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
385
+ endpoint: endpoint.name,
386
+ method,
387
+ url,
388
+ status: response.status,
389
+ duration_ms: durationMs,
390
+ response_body: body,
391
+ error: null
392
+ };
393
+ } catch (err) {
394
+ const durationMs = Math.round(performance.now() - start);
395
+ const message = err instanceof Error ? err.message : String(err);
396
+ return {
397
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
398
+ endpoint: endpoint.name,
399
+ method,
400
+ url,
401
+ status: null,
402
+ duration_ms: durationMs,
403
+ response_body: null,
404
+ error: message
405
+ };
406
+ }
407
+ };
408
+ //#endregion
409
+ //#region src/utils/stats.ts
410
+ const percentile = (sorted, p) => {
411
+ if (sorted.length === 0) return 0;
412
+ const idx = p / 100 * (sorted.length - 1);
413
+ const lower = Math.floor(idx);
414
+ const upper = Math.ceil(idx);
415
+ const lowerVal = sorted[lower] ?? 0;
416
+ const upperVal = sorted[upper] ?? 0;
417
+ if (lower === upper) return lowerVal;
418
+ return lowerVal + (upperVal - lowerVal) * (idx - lower);
419
+ };
420
+ const computeLatency = (durations) => {
421
+ if (durations.length === 0) return {
422
+ min: 0,
423
+ avg: 0,
424
+ p50: 0,
425
+ p95: 0,
426
+ p99: 0,
427
+ max: 0
428
+ };
429
+ const sorted = [...durations].sort((a, b) => a - b);
430
+ const sum = sorted.reduce((a, b) => a + b, 0);
431
+ return {
432
+ min: sorted[0] ?? 0,
433
+ avg: Math.round(sum / sorted.length),
434
+ p50: Math.round(percentile(sorted, 50)),
435
+ p95: Math.round(percentile(sorted, 95)),
436
+ p99: Math.round(percentile(sorted, 99)),
437
+ max: sorted.at(-1) ?? 0
438
+ };
439
+ };
440
+ const computeEndpointStats = (results) => {
441
+ const byEndpoint = /* @__PURE__ */ new Map();
442
+ for (const r of results) {
443
+ const list = byEndpoint.get(r.endpoint) ?? [];
444
+ list.push(r);
445
+ byEndpoint.set(r.endpoint, list);
446
+ }
447
+ return [...byEndpoint.entries()].map(([name, items]) => {
448
+ const durations = items.map((r) => r.duration_ms);
449
+ const sorted = [...durations].sort((a, b) => a - b);
450
+ const sum = durations.reduce((a, b) => a + b, 0);
451
+ return {
452
+ name,
453
+ requests: items.length,
454
+ avg_ms: Math.round(sum / items.length),
455
+ p95_ms: Math.round(percentile(sorted, 95)),
456
+ error_count: items.filter((r) => !isSuccess(r.status)).length
457
+ };
458
+ });
459
+ };
460
+ const computeErrors = (results) => {
461
+ const key = (r) => `${r.endpoint}:${r.status}`;
462
+ const groups = /* @__PURE__ */ new Map();
463
+ for (const r of results) {
464
+ if (isSuccess(r.status)) continue;
465
+ const k = key(r);
466
+ const existing = groups.get(k);
467
+ if (existing) existing.count++;
468
+ else groups.set(k, {
469
+ endpoint: r.endpoint,
470
+ status: r.status,
471
+ count: 1,
472
+ sample_body: r.response_body
473
+ });
474
+ }
475
+ return [...groups.values()].sort((a, b) => b.count - a.count);
476
+ };
477
+ const parseThresholdValue = (s) => {
478
+ const cleaned = s.replace(/[<>=%\s]/g, "");
479
+ if (cleaned.endsWith("ms")) return Number.parseFloat(cleaned);
480
+ if (cleaned.endsWith("s")) return Number.parseFloat(cleaned) * 1e3;
481
+ return Number.parseFloat(cleaned);
482
+ };
483
+ const evaluateThresholds = (thresholds, latency, errorRate) => {
484
+ const results = [];
485
+ if (thresholds.error_rate) {
486
+ const target = parseThresholdValue(thresholds.error_rate);
487
+ results.push({
488
+ name: "error_rate",
489
+ target: thresholds.error_rate,
490
+ actual: `${errorRate.toFixed(2)}%`,
491
+ passed: errorRate < target
492
+ });
493
+ }
494
+ for (const [configKey, statKey] of [
495
+ ["p50", "p50"],
496
+ ["p95", "p95"],
497
+ ["p99", "p99"],
498
+ ["max", "max"]
499
+ ]) {
500
+ const threshold = thresholds[configKey];
501
+ if (!threshold) continue;
502
+ const targetMs = parseDuration(threshold.replace(/[<>\s]/g, ""));
503
+ results.push({
504
+ name: configKey,
505
+ target: threshold,
506
+ actual: `${latency[statKey]}ms`,
507
+ passed: latency[statKey] < targetMs
508
+ });
509
+ }
510
+ return results;
511
+ };
512
+ const computeStats = (results, startTime, thresholds, endTime) => {
513
+ const durationS = Math.round(((endTime ?? /* @__PURE__ */ new Date()).getTime() - startTime.getTime()) / 1e3);
514
+ const latency = computeLatency(results.map((r) => r.duration_ms));
515
+ const successCount = results.filter((r) => isSuccess(r.status)).length;
516
+ const failureCount = results.length - successCount;
517
+ const uptimePct = results.length > 0 ? successCount / results.length * 100 : 100;
518
+ const statusCodes = {};
519
+ for (const r of results) if (r.status !== null) statusCodes[r.status] = (statusCodes[r.status] ?? 0) + 1;
520
+ const errorRate = results.length > 0 ? failureCount / results.length * 100 : 0;
521
+ return {
522
+ duration_s: durationS,
523
+ total_requests: results.length,
524
+ success_count: successCount,
525
+ failure_count: failureCount,
526
+ uptime_pct: Math.round(uptimePct * 100) / 100,
527
+ latency,
528
+ status_codes: statusCodes,
529
+ endpoints: computeEndpointStats(results),
530
+ errors: computeErrors(results),
531
+ thresholds: thresholds ? evaluateThresholds(thresholds, latency, errorRate) : void 0
532
+ };
533
+ };
534
+ //#endregion
535
+ //#region src/core/runner.ts
536
+ const createWeightedRandom = () => {
537
+ return (endpoints) => {
538
+ const totalWeight = endpoints.reduce((sum, ep) => sum + (ep.weight ?? 1), 0);
539
+ let rand = Math.random() * totalWeight;
540
+ for (const ep of endpoints) {
541
+ rand -= ep.weight ?? 1;
542
+ if (rand <= 0) return ep;
543
+ }
544
+ return endpoints[endpoints.length - 1];
545
+ };
546
+ };
547
+ const createRoundRobin = () => {
548
+ let idx = -1;
549
+ return (endpoints) => {
550
+ idx = (idx + 1) % endpoints.length;
551
+ return endpoints[idx];
552
+ };
553
+ };
554
+ const createPicker = (rotation) => {
555
+ switch (rotation) {
556
+ case "round-robin":
557
+ case "sequential": return createRoundRobin();
558
+ default: return createWeightedRandom();
559
+ }
560
+ };
561
+ const toEndpoints = (config) => config.endpoints.map((ep) => ({
562
+ name: ep.name,
563
+ url: ep.url,
564
+ method: ep.method,
565
+ headers: ep.headers,
566
+ body: ep.body,
567
+ timeout: ep.timeout,
568
+ weight: ep.weight
569
+ }));
570
+ const createSoakTest = (config, reporters = []) => {
571
+ const storage = createStorage(config);
572
+ const endpoints = toEndpoints(config);
573
+ const globalHeaders = config.headers;
574
+ const pick = createPicker(config.rotation);
575
+ const intervalMs = Math.max(100, parseDuration(config.interval));
576
+ let timer = null;
577
+ let startTime = null;
578
+ let okCount = 0;
579
+ let failCount = 0;
580
+ let running = false;
581
+ const tick = async () => {
582
+ if (!running) return;
583
+ const result = await timedFetch(pick(endpoints), globalHeaders, config.timeout);
584
+ if (!running) return;
585
+ await storage.record(result);
586
+ isSuccess(result.status) ? okCount++ : failCount++;
587
+ for (const reporter of reporters) reporter.onRequest(result, {
588
+ ok: okCount,
589
+ fail: failCount
590
+ });
591
+ };
592
+ const getStats = async () => {
593
+ return computeStats(await storage.getAll(), startTime ?? /* @__PURE__ */ new Date(), config.thresholds);
594
+ };
595
+ const safeTick = () => {
596
+ tick().catch((err) => {
597
+ process.stderr.write(`[dripfeed] tick error: ${err instanceof Error ? err.message : err}\n`);
598
+ });
599
+ };
600
+ const start = async () => {
601
+ if (running) return;
602
+ running = true;
603
+ await storage.init();
604
+ startTime = /* @__PURE__ */ new Date();
605
+ safeTick();
606
+ timer = setInterval(safeTick, intervalMs);
607
+ };
608
+ const stop = async () => {
609
+ running = false;
610
+ if (timer) {
611
+ clearInterval(timer);
612
+ timer = null;
613
+ }
614
+ const stats = await getStats();
615
+ for (const reporter of reporters) reporter.onComplete(stats);
616
+ await storage.close();
617
+ return stats;
618
+ };
619
+ const run = async (opts) => {
620
+ const durationStr = opts?.duration ?? config.duration;
621
+ await start();
622
+ if (durationStr) {
623
+ const durationMs = parseDuration(durationStr);
624
+ await new Promise((resolve) => {
625
+ setTimeout(() => resolve(), durationMs);
626
+ });
627
+ } else await new Promise((resolve) => {
628
+ const handler = () => {
629
+ process.removeListener("SIGINT", handler);
630
+ process.removeListener("SIGTERM", handler);
631
+ resolve();
632
+ };
633
+ process.on("SIGINT", handler);
634
+ process.on("SIGTERM", handler);
635
+ });
636
+ return stop();
637
+ };
638
+ return {
639
+ start,
640
+ stop,
641
+ run
642
+ };
643
+ };
644
+ //#endregion
645
+ Object.defineProperty(exports, "computeStats", {
646
+ enumerable: true,
647
+ get: function() {
648
+ return computeStats;
649
+ }
650
+ });
651
+ Object.defineProperty(exports, "configSchema", {
652
+ enumerable: true,
653
+ get: function() {
654
+ return configSchema;
655
+ }
656
+ });
657
+ Object.defineProperty(exports, "createConsoleReporter", {
658
+ enumerable: true,
659
+ get: function() {
660
+ return createConsoleReporter;
661
+ }
662
+ });
663
+ Object.defineProperty(exports, "createJsonReporter", {
664
+ enumerable: true,
665
+ get: function() {
666
+ return createJsonReporter;
667
+ }
668
+ });
669
+ Object.defineProperty(exports, "createJsonStorage", {
670
+ enumerable: true,
671
+ get: function() {
672
+ return createJsonStorage;
673
+ }
674
+ });
675
+ Object.defineProperty(exports, "createMarkdownReporter", {
676
+ enumerable: true,
677
+ get: function() {
678
+ return createMarkdownReporter;
679
+ }
680
+ });
681
+ Object.defineProperty(exports, "createMemoryStorage", {
682
+ enumerable: true,
683
+ get: function() {
684
+ return createMemoryStorage;
685
+ }
686
+ });
687
+ Object.defineProperty(exports, "createSoakTest", {
688
+ enumerable: true,
689
+ get: function() {
690
+ return createSoakTest;
691
+ }
692
+ });
693
+ Object.defineProperty(exports, "createSqliteStorage", {
694
+ enumerable: true,
695
+ get: function() {
696
+ return createSqliteStorage;
697
+ }
698
+ });
699
+ Object.defineProperty(exports, "createStorage", {
700
+ enumerable: true,
701
+ get: function() {
702
+ return createStorage;
703
+ }
704
+ });
705
+ Object.defineProperty(exports, "isBun", {
706
+ enumerable: true,
707
+ get: function() {
708
+ return isBun;
709
+ }
710
+ });
711
+ Object.defineProperty(exports, "isDeno", {
712
+ enumerable: true,
713
+ get: function() {
714
+ return isDeno;
715
+ }
716
+ });
717
+ Object.defineProperty(exports, "isNode", {
718
+ enumerable: true,
719
+ get: function() {
720
+ return isNode;
721
+ }
722
+ });
723
+ Object.defineProperty(exports, "isSuccess", {
724
+ enumerable: true,
725
+ get: function() {
726
+ return isSuccess;
727
+ }
728
+ });
729
+ Object.defineProperty(exports, "loadDripfeedConfig", {
730
+ enumerable: true,
731
+ get: function() {
732
+ return loadDripfeedConfig;
733
+ }
734
+ });
735
+ Object.defineProperty(exports, "parseConfig", {
736
+ enumerable: true,
737
+ get: function() {
738
+ return parseConfig;
739
+ }
740
+ });
741
+ Object.defineProperty(exports, "parseDuration", {
742
+ enumerable: true,
743
+ get: function() {
744
+ return parseDuration;
745
+ }
746
+ });
747
+ Object.defineProperty(exports, "percentile", {
748
+ enumerable: true,
749
+ get: function() {
750
+ return percentile;
751
+ }
752
+ });
753
+ Object.defineProperty(exports, "timedFetch", {
754
+ enumerable: true,
755
+ get: function() {
756
+ return timedFetch;
757
+ }
758
+ });