express-api-stress-tester 2.0.3 → 2.0.5
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/package.json +1 -1
- package/src/core/httpEngine.js +64 -7
- package/src/core/worker.js +23 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "express-api-stress-tester",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.5",
|
|
4
4
|
"description": "High-performance distributed API stress testing platform for Express.js APIs — simulate up to 10M concurrent virtual users",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/src/core/httpEngine.js
CHANGED
|
@@ -4,7 +4,31 @@
|
|
|
4
4
|
* Provides keep-alive, pipelining, and precise response-time tracking
|
|
5
5
|
* via process.hrtime.bigint().
|
|
6
6
|
*/
|
|
7
|
-
|
|
7
|
+
async function ensureWebStreamsGlobals() {
|
|
8
|
+
if (typeof globalThis.ReadableStream !== 'undefined') return;
|
|
9
|
+
try {
|
|
10
|
+
const web = await import('node:stream/web');
|
|
11
|
+
if (web.ReadableStream && typeof globalThis.ReadableStream === 'undefined') {
|
|
12
|
+
globalThis.ReadableStream = web.ReadableStream;
|
|
13
|
+
}
|
|
14
|
+
if (web.WritableStream && typeof globalThis.WritableStream === 'undefined') {
|
|
15
|
+
globalThis.WritableStream = web.WritableStream;
|
|
16
|
+
}
|
|
17
|
+
if (web.TransformStream && typeof globalThis.TransformStream === 'undefined') {
|
|
18
|
+
globalThis.TransformStream = web.TransformStream;
|
|
19
|
+
}
|
|
20
|
+
} catch {
|
|
21
|
+
// ignore
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
let cachedUndici = null;
|
|
26
|
+
async function getUndici() {
|
|
27
|
+
if (cachedUndici) return cachedUndici;
|
|
28
|
+
await ensureWebStreamsGlobals();
|
|
29
|
+
cachedUndici = await import('undici');
|
|
30
|
+
return cachedUndici;
|
|
31
|
+
}
|
|
8
32
|
|
|
9
33
|
const CONTROL_CHARS_REGEX = /[\0\r\n]/g;
|
|
10
34
|
const MAX_WARNED_HEADER_VALUES = 100;
|
|
@@ -36,14 +60,28 @@ export class HttpEngine {
|
|
|
36
60
|
this.timeout = timeout;
|
|
37
61
|
this.invalidHeaderWarningCache = { map: new Map(), queue: [] };
|
|
38
62
|
|
|
39
|
-
this.pool =
|
|
63
|
+
this.pool = null;
|
|
64
|
+
this.poolPromise = null;
|
|
65
|
+
this.poolOptions = {
|
|
40
66
|
connections,
|
|
41
67
|
pipelining,
|
|
42
68
|
keepAliveTimeout: 30_000,
|
|
43
69
|
keepAliveMaxTimeout: 60_000,
|
|
44
70
|
headersTimeout: timeout,
|
|
45
71
|
bodyTimeout: timeout,
|
|
46
|
-
}
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async ensurePool() {
|
|
76
|
+
if (this.pool) return this.pool;
|
|
77
|
+
if (!this.poolPromise) {
|
|
78
|
+
this.poolPromise = (async () => {
|
|
79
|
+
const { Pool } = await getUndici();
|
|
80
|
+
this.pool = new Pool(this.baseUrl, this.poolOptions);
|
|
81
|
+
return this.pool;
|
|
82
|
+
})();
|
|
83
|
+
}
|
|
84
|
+
return this.poolPromise;
|
|
47
85
|
}
|
|
48
86
|
|
|
49
87
|
/**
|
|
@@ -57,6 +95,7 @@ export class HttpEngine {
|
|
|
57
95
|
* @returns {Promise<{ statusCode: number, headers: object, body: string, responseTime: number }>}
|
|
58
96
|
*/
|
|
59
97
|
async request({ method = 'GET', path = '/', headers = {}, body = null } = {}) {
|
|
98
|
+
const pool = await this.ensurePool();
|
|
60
99
|
const mergedHeaders = normalizeHeaders(
|
|
61
100
|
{ ...this.defaultHeaders, ...headers },
|
|
62
101
|
this.invalidHeaderWarningCache,
|
|
@@ -64,7 +103,7 @@ export class HttpEngine {
|
|
|
64
103
|
|
|
65
104
|
const start = process.hrtime.bigint();
|
|
66
105
|
|
|
67
|
-
const { statusCode, headers: resHeaders, body: resBody } = await
|
|
106
|
+
const { statusCode, headers: resHeaders, body: resBody } = await pool.request({
|
|
68
107
|
method: method.toUpperCase(),
|
|
69
108
|
path,
|
|
70
109
|
headers: mergedHeaders,
|
|
@@ -74,7 +113,16 @@ export class HttpEngine {
|
|
|
74
113
|
});
|
|
75
114
|
|
|
76
115
|
// Consume the body fully (undici requirement to free the socket)
|
|
77
|
-
|
|
116
|
+
// Drain via async iteration to avoid depending on Web Streams globals.
|
|
117
|
+
if (resBody) {
|
|
118
|
+
try {
|
|
119
|
+
for await (const _chunk of resBody) {
|
|
120
|
+
// discard
|
|
121
|
+
}
|
|
122
|
+
} catch {
|
|
123
|
+
// ignore body drain errors
|
|
124
|
+
}
|
|
125
|
+
}
|
|
78
126
|
|
|
79
127
|
const end = process.hrtime.bigint();
|
|
80
128
|
// Convert nanoseconds → milliseconds (floating point)
|
|
@@ -83,7 +131,7 @@ export class HttpEngine {
|
|
|
83
131
|
return {
|
|
84
132
|
statusCode,
|
|
85
133
|
headers: resHeaders,
|
|
86
|
-
body:
|
|
134
|
+
body: '',
|
|
87
135
|
responseTime,
|
|
88
136
|
};
|
|
89
137
|
}
|
|
@@ -92,7 +140,16 @@ export class HttpEngine {
|
|
|
92
140
|
* Gracefully close the connection pool.
|
|
93
141
|
*/
|
|
94
142
|
async close() {
|
|
95
|
-
|
|
143
|
+
if (this.pool) {
|
|
144
|
+
await this.pool.close();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (this.poolPromise) {
|
|
148
|
+
const pool = await this.poolPromise;
|
|
149
|
+
if (pool && typeof pool.close === 'function') {
|
|
150
|
+
await pool.close();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
96
153
|
}
|
|
97
154
|
}
|
|
98
155
|
|
package/src/core/worker.js
CHANGED
|
@@ -94,6 +94,24 @@ let maxLatency = -Infinity;
|
|
|
94
94
|
const statusCodes = {};
|
|
95
95
|
const perEndpoint = {};
|
|
96
96
|
|
|
97
|
+
function normalizeBaseForRelativeUrlResolution(base, maybeRelativePath) {
|
|
98
|
+
if (!base || !maybeRelativePath) return base;
|
|
99
|
+
if (maybeRelativePath.startsWith('http://') || maybeRelativePath.startsWith('https://')) return base;
|
|
100
|
+
if (maybeRelativePath.startsWith('/')) return base;
|
|
101
|
+
|
|
102
|
+
try {
|
|
103
|
+
const parsedBase = new URL(base);
|
|
104
|
+
if (parsedBase.pathname && !parsedBase.pathname.endsWith('/')) {
|
|
105
|
+
parsedBase.pathname = `${parsedBase.pathname}/`;
|
|
106
|
+
return parsedBase.toString();
|
|
107
|
+
}
|
|
108
|
+
} catch {
|
|
109
|
+
// ignore
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return base;
|
|
113
|
+
}
|
|
114
|
+
|
|
97
115
|
/**
|
|
98
116
|
* Resolve the full URL for a route.
|
|
99
117
|
*/
|
|
@@ -102,7 +120,8 @@ function resolveUrl(route) {
|
|
|
102
120
|
const path = route.path || route.url || '';
|
|
103
121
|
try {
|
|
104
122
|
if (path) {
|
|
105
|
-
|
|
123
|
+
const normalizedBase = normalizeBaseForRelativeUrlResolution(base, path);
|
|
124
|
+
return new URL(path, normalizedBase).toString();
|
|
106
125
|
}
|
|
107
126
|
if (base) {
|
|
108
127
|
return new URL(base).toString();
|
|
@@ -326,7 +345,9 @@ async function executeRequest(task) {
|
|
|
326
345
|
parsed = new URL(targetUrl);
|
|
327
346
|
} catch {
|
|
328
347
|
try {
|
|
329
|
-
|
|
348
|
+
const base = config.baseUrl || config.url;
|
|
349
|
+
const normalizedBase = normalizeBaseForRelativeUrlResolution(base, targetUrl);
|
|
350
|
+
parsed = new URL(targetUrl, normalizedBase);
|
|
330
351
|
} catch (err) {
|
|
331
352
|
throw new Error(
|
|
332
353
|
`Failed to resolve URL "${targetUrl}" with base "${config.baseUrl || config.url}": ${err.message}`,
|