@loadstrike/loadstrike-sdk 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.
package/README.md ADDED
@@ -0,0 +1,73 @@
1
+ # LoadStrike for TypeScript and JavaScript
2
+
3
+ LoadStrike is a TypeScript and JavaScript SDK for defining and running load, traffic-correlation, and reporting scenarios directly inside your application or test process.
4
+
5
+ ## Requirements
6
+
7
+ - Node.js 20 or later
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm install @loadstrike/loadstrike-sdk
13
+ ```
14
+
15
+ ## What It Provides
16
+
17
+ - Scenario, step, load-simulation, threshold, and metric primitives
18
+ - Native in-process execution with structured run results
19
+ - Local and distributed cluster execution helpers
20
+ - HTML, TXT, CSV, and Markdown report generation
21
+
22
+ ## Supported Transports
23
+
24
+ - HTTP
25
+ - Kafka
26
+ - RabbitMQ
27
+ - NATS
28
+ - Redis Streams
29
+ - Azure Event Hubs
30
+ - Push Diffusion
31
+ - Delegate and custom stream endpoints
32
+
33
+ ## Supported Reporting Sinks
34
+
35
+ - InfluxDB
36
+ - TimescaleDB
37
+ - Grafana Loki
38
+ - Datadog
39
+ - Splunk HEC
40
+ - OpenTelemetry Collector
41
+
42
+ ## Quick Start
43
+
44
+ ```ts
45
+ import {
46
+ LoadStrikeResponse,
47
+ LoadStrikeRunner,
48
+ LoadStrikeScenario,
49
+ LoadStrikeSimulation,
50
+ LoadStrikeStep
51
+ } from "@loadstrike/loadstrike-sdk";
52
+
53
+ const scenario = LoadStrikeScenario
54
+ .create("orders", async (context) => {
55
+ return LoadStrikeStep.run(
56
+ "publish-order",
57
+ context,
58
+ async () => LoadStrikeResponse.ok("200")
59
+ );
60
+ })
61
+ .withLoadSimulations(LoadStrikeSimulation.inject(10, 1, 20));
62
+
63
+ const result = await LoadStrikeRunner
64
+ .registerScenarios(scenario)
65
+ .withRunnerKey("rkl_your_runner_key")
66
+ .run();
67
+ ```
68
+
69
+ A valid `RunnerKey` is required to execute live workloads.
70
+
71
+ ## Documentation
72
+
73
+ https://loadstrike.com/documentation
@@ -0,0 +1,410 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DistributedClusterAgent = exports.DistributedClusterCoordinator = exports.LocalClusterCoordinator = void 0;
4
+ exports.planAgentScenarioAssignments = planAgentScenarioAssignments;
5
+ const node_crypto_1 = require("node:crypto");
6
+ const transports_js_1 = require("./transports.js");
7
+ class LocalClusterCoordinator {
8
+ constructor(options = {}) {
9
+ this.options = options;
10
+ }
11
+ async dispatch(agents, scenarioNames, assignmentOptions = {}) {
12
+ const timeoutMs = Math.max(this.options.commandTimeoutMs ?? 120000, 1);
13
+ const retryCount = Math.max(this.options.retryCount ?? 0, 0);
14
+ const assignments = planAgentScenarioAssignments(scenarioNames, agents.length, assignmentOptions);
15
+ const tasks = agents.map((agent, index) => {
16
+ const dispatch = { scenarioNames: assignments[index] ?? [] };
17
+ return this.executeWithRetry(agent, dispatch, timeoutMs, retryCount);
18
+ });
19
+ const nodeResults = await Promise.all(tasks);
20
+ return {
21
+ nodeResults,
22
+ allRequestCount: nodeResults.reduce((sum, x) => sum + x.allRequestCount, 0),
23
+ allOkCount: nodeResults.reduce((sum, x) => sum + x.allOkCount, 0),
24
+ allFailCount: nodeResults.reduce((sum, x) => sum + x.allFailCount, 0),
25
+ failedNodes: nodeResults.filter((x) => !x.success).length
26
+ };
27
+ }
28
+ async executeWithRetry(agent, dispatch, timeoutMs, retryCount) {
29
+ let lastResult = {
30
+ nodeId: agent.nodeId,
31
+ success: false,
32
+ allRequestCount: 0,
33
+ allOkCount: 0,
34
+ allFailCount: 0,
35
+ error: "agent did not execute"
36
+ };
37
+ for (let attempt = 0; attempt <= retryCount; attempt += 1) {
38
+ lastResult = await this.withTimeout(agent.execute(dispatch), timeoutMs, agent.nodeId);
39
+ if (lastResult.success) {
40
+ return lastResult;
41
+ }
42
+ }
43
+ return lastResult;
44
+ }
45
+ async withTimeout(run, timeoutMs, nodeId) {
46
+ let timeoutHandle;
47
+ const timeout = new Promise((resolve) => {
48
+ timeoutHandle = setTimeout(() => {
49
+ resolve({
50
+ nodeId,
51
+ success: false,
52
+ allRequestCount: 0,
53
+ allOkCount: 0,
54
+ allFailCount: 0,
55
+ error: `node timed out after ${timeoutMs}ms`
56
+ });
57
+ }, timeoutMs);
58
+ });
59
+ const result = await Promise.race([run, timeout]);
60
+ if (timeoutHandle) {
61
+ clearTimeout(timeoutHandle);
62
+ }
63
+ return result;
64
+ }
65
+ }
66
+ exports.LocalClusterCoordinator = LocalClusterCoordinator;
67
+ function planAgentScenarioAssignments(scenarioNames, agentCount, options = {}) {
68
+ const totalAgents = Math.max(agentCount, 0);
69
+ const assignments = Array.from({ length: totalAgents }, () => []);
70
+ if (totalAgents === 0) {
71
+ return assignments;
72
+ }
73
+ if (options.explicitAssignments?.length) {
74
+ for (let i = 0; i < totalAgents; i += 1) {
75
+ assignments[i] = [...(options.explicitAssignments[i] ?? [])];
76
+ }
77
+ return assignments;
78
+ }
79
+ const selected = new Set((options.selectedScenarioNames ?? scenarioNames).map((x) => String(x)));
80
+ const ordered = scenarioNames.filter((name) => selected.has(name));
81
+ if (!ordered.length) {
82
+ return assignments;
83
+ }
84
+ const weights = Array.from({ length: totalAgents }, (_, i) => {
85
+ const raw = options.agentWeights?.[i];
86
+ if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) {
87
+ return Math.max(Math.trunc(raw), 1);
88
+ }
89
+ return 1;
90
+ });
91
+ const weightedCycle = buildWeightedCycle(weights);
92
+ let cycleIndex = 0;
93
+ const targets = Array.from({ length: totalAgents }, (_, i) => {
94
+ const values = options.agentTargets?.[i];
95
+ if (!values?.length) {
96
+ return null;
97
+ }
98
+ return new Set(values.map((x) => String(x)));
99
+ });
100
+ for (const scenarioName of ordered) {
101
+ const eligible = targets
102
+ .map((target, index) => ({ index, target }))
103
+ .filter((row) => row.target == null || row.target.has(scenarioName))
104
+ .map((row) => row.index);
105
+ if (!eligible.length) {
106
+ continue;
107
+ }
108
+ let selectedAgent = eligible[0];
109
+ for (let attempt = 0; attempt < weightedCycle.length; attempt += 1) {
110
+ const candidate = weightedCycle[(cycleIndex + attempt) % weightedCycle.length];
111
+ if (eligible.includes(candidate)) {
112
+ selectedAgent = candidate;
113
+ cycleIndex = (cycleIndex + attempt + 1) % weightedCycle.length;
114
+ break;
115
+ }
116
+ }
117
+ assignments[selectedAgent].push(scenarioName);
118
+ }
119
+ return assignments;
120
+ }
121
+ class DistributedClusterCoordinator {
122
+ constructor(options) {
123
+ this.options = options;
124
+ }
125
+ async dispatch(assignments) {
126
+ const expected = Math.max(this.options.expectedAgentResults, 0);
127
+ const timeoutMs = Math.max(this.options.commandTimeoutMs ?? 120000, 1);
128
+ const runSubject = this.buildRunSubject();
129
+ const replySubject = this.buildReplySubject();
130
+ const commandProducer = transports_js_1.EndpointAdapterFactory.create(this.natsEndpoint("Produce", "coordinator-command", runSubject));
131
+ const resultConsumer = transports_js_1.EndpointAdapterFactory.create(this.natsEndpoint("Consume", "coordinator-result", replySubject));
132
+ try {
133
+ // Prime the reply subscription before commands are published because core NATS subjects do not retain messages.
134
+ await resultConsumer.consume();
135
+ for (let i = 0; i < assignments.length; i += 1) {
136
+ const commandId = (0, node_crypto_1.randomUUID)().replace(/-/g, "");
137
+ const command = {
138
+ commandId,
139
+ sessionId: this.options.sessionId,
140
+ testSuite: this.options.testSuite,
141
+ testName: this.options.testName,
142
+ agentIndex: i,
143
+ agentCount: expected,
144
+ targetScenarios: assignments[i] ?? [],
145
+ replySubject
146
+ };
147
+ await commandProducer.produce({
148
+ headers: {
149
+ "x-cluster-command-id": commandId,
150
+ "x-cluster-reply-subject": replySubject
151
+ },
152
+ body: command
153
+ });
154
+ }
155
+ const byCommand = new Map();
156
+ const deadline = Date.now() + timeoutMs;
157
+ while (byCommand.size < expected && Date.now() < deadline) {
158
+ const payload = await resultConsumer.consume();
159
+ if (!payload?.body || typeof payload.body !== "object" || Array.isArray(payload.body)) {
160
+ await sleep(5);
161
+ continue;
162
+ }
163
+ const result = parseRunResult(payload.body);
164
+ if (!result.commandId) {
165
+ continue;
166
+ }
167
+ byCommand.set(result.commandId, result);
168
+ }
169
+ const nodeResults = Array.from(byCommand.values()).map((result) => convertRunResult(result));
170
+ return {
171
+ nodeResults,
172
+ allRequestCount: nodeResults.reduce((sum, x) => sum + x.allRequestCount, 0),
173
+ allOkCount: nodeResults.reduce((sum, x) => sum + x.allOkCount, 0),
174
+ allFailCount: nodeResults.reduce((sum, x) => sum + x.allFailCount, 0),
175
+ failedNodes: nodeResults.filter((x) => !x.success).length + Math.max(expected - nodeResults.length, 0),
176
+ missingNodes: Math.max(expected - nodeResults.length, 0),
177
+ runSubject,
178
+ replySubject
179
+ };
180
+ }
181
+ finally {
182
+ await resultConsumer.dispose?.().catch(() => { });
183
+ await commandProducer.dispose?.().catch(() => { });
184
+ }
185
+ }
186
+ buildRunSubject() {
187
+ return `loadstrike.${sanitizeToken(this.options.clusterId)}.${sanitizeToken(this.options.agentGroup ?? "default")}.run`;
188
+ }
189
+ buildReplySubject() {
190
+ return `loadstrike.${sanitizeToken(this.options.clusterId)}.${sanitizeToken(this.options.sessionId)}.reply.${(0, node_crypto_1.randomUUID)().replace(/-/g, "")}`;
191
+ }
192
+ natsEndpoint(mode, name, subject) {
193
+ return {
194
+ kind: "Nats",
195
+ mode,
196
+ name,
197
+ trackingField: "header:x-cluster-command-id",
198
+ nats: {
199
+ ...(this.options.nats ?? {}),
200
+ Subject: subject,
201
+ StartFromEarliest: true
202
+ }
203
+ };
204
+ }
205
+ }
206
+ exports.DistributedClusterCoordinator = DistributedClusterCoordinator;
207
+ class DistributedClusterAgent {
208
+ constructor(options) {
209
+ this.commandConsumer = null;
210
+ this.options = options;
211
+ }
212
+ async dispose() {
213
+ await this.commandConsumer?.dispose?.().catch(() => { });
214
+ this.commandConsumer = null;
215
+ }
216
+ async pollAndExecuteOnce(execute) {
217
+ const runSubject = `loadstrike.${sanitizeToken(this.options.clusterId)}.${sanitizeToken(this.options.agentGroup ?? "default")}.run`;
218
+ const queueGroup = `loadstrike.${sanitizeToken(this.options.clusterId)}.${sanitizeToken(this.options.agentGroup ?? "default")}.agents`;
219
+ if (!this.commandConsumer) {
220
+ this.commandConsumer = transports_js_1.EndpointAdapterFactory.create({
221
+ kind: "Nats",
222
+ mode: "Consume",
223
+ name: `agent-${this.options.agentId}`,
224
+ trackingField: "header:x-cluster-command-id",
225
+ nats: {
226
+ ...(this.options.nats ?? {}),
227
+ Subject: runSubject,
228
+ QueueGroup: queueGroup,
229
+ StartFromEarliest: true
230
+ }
231
+ });
232
+ }
233
+ const payload = await this.commandConsumer.consume();
234
+ if (!payload?.body || typeof payload.body !== "object" || Array.isArray(payload.body)) {
235
+ return false;
236
+ }
237
+ const command = parseRunCommand(payload.body);
238
+ if (!command.commandId) {
239
+ return false;
240
+ }
241
+ const replySubject = stringOrDefault(payload.headers?.["x-cluster-reply-subject"], command.replySubject ?? "");
242
+ const resultProducer = replySubject
243
+ ? transports_js_1.EndpointAdapterFactory.create({
244
+ kind: "Nats",
245
+ mode: "Produce",
246
+ name: `agent-${this.options.agentId}-result`,
247
+ trackingField: "header:x-cluster-command-id",
248
+ nats: {
249
+ ...(this.options.nats ?? {}),
250
+ Subject: replySubject
251
+ }
252
+ })
253
+ : null;
254
+ try {
255
+ let result;
256
+ try {
257
+ const nodeResult = await execute({ scenarioNames: command.targetScenarios });
258
+ result = {
259
+ commandId: command.commandId,
260
+ agentId: this.options.agentId,
261
+ isSuccess: nodeResult.success,
262
+ errorMessage: nodeResult.error,
263
+ stats: nodeResult.stats ?? {
264
+ allRequestCount: nodeResult.allRequestCount,
265
+ allOkCount: nodeResult.allOkCount,
266
+ allFailCount: nodeResult.allFailCount
267
+ }
268
+ };
269
+ }
270
+ catch (error) {
271
+ result = {
272
+ commandId: command.commandId,
273
+ agentId: this.options.agentId,
274
+ isSuccess: false,
275
+ errorMessage: String(error ?? "agent execution failed")
276
+ };
277
+ }
278
+ if (resultProducer) {
279
+ await resultProducer.produce({
280
+ headers: {
281
+ "x-cluster-command-id": result.commandId
282
+ },
283
+ body: result
284
+ });
285
+ }
286
+ return true;
287
+ }
288
+ finally {
289
+ await resultProducer?.dispose?.().catch(() => { });
290
+ }
291
+ }
292
+ }
293
+ exports.DistributedClusterAgent = DistributedClusterAgent;
294
+ function parseRunCommand(value) {
295
+ const targetScenarios = Array.isArray(value.targetScenarios)
296
+ ? value.targetScenarios.map((x) => String(x))
297
+ : Array.isArray(value.TargetScenarios)
298
+ ? value.TargetScenarios.map((x) => String(x))
299
+ : [];
300
+ return {
301
+ commandId: stringOrDefault(value.commandId, stringOrDefault(value.CommandId, "")),
302
+ sessionId: stringOrDefault(value.sessionId, stringOrDefault(value.SessionId, "")),
303
+ testSuite: stringOrDefault(value.testSuite, stringOrDefault(value.TestSuite, "")),
304
+ testName: stringOrDefault(value.testName, stringOrDefault(value.TestName, "")),
305
+ agentIndex: numberOrDefault(value.agentIndex, numberOrDefault(value.AgentIndex, 0)),
306
+ agentCount: numberOrDefault(value.agentCount, numberOrDefault(value.AgentCount, 0)),
307
+ targetScenarios,
308
+ replySubject: stringOrDefault(value.replySubject, stringOrDefault(value.ReplySubject, ""))
309
+ };
310
+ }
311
+ function parseRunResult(value) {
312
+ const statsValue = value.stats ?? value.Stats;
313
+ const statsRecord = statsValue && typeof statsValue === "object" && !Array.isArray(statsValue)
314
+ ? statsValue
315
+ : null;
316
+ return {
317
+ commandId: stringOrDefault(value.commandId, stringOrDefault(value.CommandId, "")),
318
+ agentId: stringOrDefault(value.agentId, stringOrDefault(value.AgentId, "")),
319
+ isSuccess: booleanOrDefault(value.isSuccess, booleanOrDefault(value.IsSuccess, false)),
320
+ errorMessage: stringOrDefault(value.errorMessage, stringOrDefault(value.ErrorMessage, "")),
321
+ stats: statsRecord
322
+ ? {
323
+ allBytes: numberOrDefault(statsRecord.allBytes, numberOrDefault(statsRecord.AllBytes, 0)),
324
+ allRequestCount: numberOrDefault(statsRecord.allRequestCount, numberOrDefault(statsRecord.AllRequestCount, 0)),
325
+ allOkCount: numberOrDefault(statsRecord.allOkCount, numberOrDefault(statsRecord.AllOkCount, 0)),
326
+ allFailCount: numberOrDefault(statsRecord.allFailCount, numberOrDefault(statsRecord.AllFailCount, 0)),
327
+ durationMs: numberOrDefault(statsRecord.durationMs, numberOrDefault(statsRecord.DurationMs, 0)),
328
+ metrics: recordOrUndefined(statsRecord.metrics ?? statsRecord.Metrics),
329
+ scenarioStats: arrayOrUndefined(statsRecord.scenarioStats ?? statsRecord.ScenarioStats),
330
+ thresholds: arrayOrUndefined(statsRecord.thresholds ?? statsRecord.Thresholds),
331
+ pluginsData: arrayOrUndefined(statsRecord.pluginsData ?? statsRecord.PluginsData),
332
+ nodeInfo: recordOrUndefined(statsRecord.nodeInfo ?? statsRecord.NodeInfo),
333
+ testInfo: recordOrUndefined(statsRecord.testInfo ?? statsRecord.TestInfo)
334
+ }
335
+ : undefined
336
+ };
337
+ }
338
+ function convertRunResult(result) {
339
+ return {
340
+ nodeId: result.agentId || "unknown-agent",
341
+ success: result.isSuccess,
342
+ allRequestCount: result.stats?.allRequestCount ?? 0,
343
+ allOkCount: result.stats?.allOkCount ?? 0,
344
+ allFailCount: result.stats?.allFailCount ?? 0,
345
+ stats: result.stats,
346
+ error: result.errorMessage ?? ""
347
+ };
348
+ }
349
+ function sanitizeToken(value) {
350
+ if (!value.trim()) {
351
+ return "default";
352
+ }
353
+ return value.replace(/[^a-zA-Z0-9\-_]/g, "_");
354
+ }
355
+ function buildWeightedCycle(weights) {
356
+ const cycle = [];
357
+ for (let i = 0; i < weights.length; i += 1) {
358
+ for (let repeat = 0; repeat < Math.max(weights[i], 1); repeat += 1) {
359
+ cycle.push(i);
360
+ }
361
+ }
362
+ return cycle.length ? cycle : [0];
363
+ }
364
+ function stringOrDefault(value, fallback) {
365
+ if (typeof value === "string" && value.trim()) {
366
+ return value;
367
+ }
368
+ return fallback;
369
+ }
370
+ function numberOrDefault(value, fallback) {
371
+ if (typeof value === "number" && Number.isFinite(value)) {
372
+ return value;
373
+ }
374
+ if (typeof value === "string" && value.trim()) {
375
+ const parsed = Number(value);
376
+ if (Number.isFinite(parsed)) {
377
+ return parsed;
378
+ }
379
+ }
380
+ return fallback;
381
+ }
382
+ function recordOrUndefined(value) {
383
+ return value && typeof value === "object" && !Array.isArray(value)
384
+ ? value
385
+ : undefined;
386
+ }
387
+ function arrayOrUndefined(value) {
388
+ return Array.isArray(value) ? value : undefined;
389
+ }
390
+ function booleanOrDefault(value, fallback) {
391
+ if (typeof value === "boolean") {
392
+ return value;
393
+ }
394
+ if (typeof value === "number") {
395
+ return value !== 0;
396
+ }
397
+ if (typeof value === "string" && value.trim()) {
398
+ const normalized = value.trim().toLowerCase();
399
+ if (normalized === "true" || normalized === "1" || normalized === "yes") {
400
+ return true;
401
+ }
402
+ if (normalized === "false" || normalized === "0" || normalized === "no") {
403
+ return false;
404
+ }
405
+ }
406
+ return fallback;
407
+ }
408
+ async function sleep(ms) {
409
+ await new Promise((resolve) => setTimeout(resolve, ms));
410
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });