word-stress 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/.env.example +8 -0
- package/.instructions.md +361 -0
- package/CHANGELOG.md +88 -0
- package/PUBLISHING.md +103 -0
- package/README.md +120 -0
- package/bin/word-stress +29 -0
- package/package.json +51 -0
- package/src/cli/commands.js +57 -0
- package/src/config.js +159 -0
- package/src/formatters/CsvFormatter.js +72 -0
- package/src/formatters/JsonFormatter.js +17 -0
- package/src/formatters/TableFormatter.js +95 -0
- package/src/formatters/factory.js +31 -0
- package/src/http/client.js +111 -0
- package/src/logger.js +59 -0
- package/src/main.js +75 -0
- package/src/metrics/MetricsCollector.js +169 -0
- package/src/test-modes/BurstTestMode.js +68 -0
- package/src/test-modes/SteadyStateTestMode.js +102 -0
- package/src/test-modes/factory.js +28 -0
- package/src/user-agent.js +51 -0
- package/src/utils.js +99 -0
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "word-stress",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A command-line tool for stress-testing WordPress and WooCommerce sites",
|
|
5
|
+
"main": "src/main.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"word-stress": "./bin/word-stress"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"start": "node bin/word-stress",
|
|
11
|
+
"dev": "node bin/word-stress",
|
|
12
|
+
"format": "prettier --write \"src/**/*.js\" \"bin/word-stress\"",
|
|
13
|
+
"format:check": "prettier --check \"src/**/*.js\" \"bin/word-stress\"",
|
|
14
|
+
"lint": "eslint src bin/word-stress",
|
|
15
|
+
"lint:fix": "eslint src bin/word-stress --fix",
|
|
16
|
+
"test": "node bin/word-stress --help && echo 'CLI help test passed'"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"wordpress",
|
|
20
|
+
"woocommerce",
|
|
21
|
+
"stress-testing",
|
|
22
|
+
"load-testing",
|
|
23
|
+
"performance",
|
|
24
|
+
"cli"
|
|
25
|
+
],
|
|
26
|
+
"author": "headwall",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "https://github.com/headwalluk/word-stress.git"
|
|
30
|
+
},
|
|
31
|
+
"homepage": "https://github.com/headwalluk/word-stress#readme",
|
|
32
|
+
"bugs": {
|
|
33
|
+
"url": "https://github.com/headwalluk/word-stress/issues"
|
|
34
|
+
},
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=21.0.0"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"chalk": "^5.6.2",
|
|
41
|
+
"cli-table3": "^0.6.5",
|
|
42
|
+
"commander": "^14.0.2",
|
|
43
|
+
"dotenv": "^17.2.3",
|
|
44
|
+
"ora": "^9.0.0",
|
|
45
|
+
"user-agents": "^1.1.669"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"eslint": "^9.39.2",
|
|
49
|
+
"prettier": "^3.7.4"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI command definitions using commander.js
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const { Command } = require('commander');
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Set up CLI commands
|
|
11
|
+
* @returns {Command} commander program
|
|
12
|
+
*/
|
|
13
|
+
function setupCli() {
|
|
14
|
+
// Read package.json for version
|
|
15
|
+
const packageJsonPath = path.join(__dirname, '../../package.json');
|
|
16
|
+
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
|
17
|
+
|
|
18
|
+
const program = new Command();
|
|
19
|
+
|
|
20
|
+
program
|
|
21
|
+
.name('word-stress')
|
|
22
|
+
.description('Stress-testing CLI tool for WordPress and WooCommerce sites')
|
|
23
|
+
.version(packageJson.version);
|
|
24
|
+
|
|
25
|
+
program
|
|
26
|
+
.argument('<domain>', 'Domain to test (e.g., example.com)')
|
|
27
|
+
.option('--clients <n>', 'Number of parallel clients for steady-state mode (default: 5)', '5')
|
|
28
|
+
.option('--interval <ms>', 'Milliseconds between each client request (default: 1000)', '1000')
|
|
29
|
+
.option('--duration <s>', 'Test duration in seconds (default: 60)', '60')
|
|
30
|
+
.option(
|
|
31
|
+
'--mode <mode>',
|
|
32
|
+
'Test mode: steady-state or burst (default: steady-state)',
|
|
33
|
+
'steady-state'
|
|
34
|
+
)
|
|
35
|
+
.option(
|
|
36
|
+
'--burst-clients <n>',
|
|
37
|
+
'Number of simultaneous requests for burst mode (required for burst)'
|
|
38
|
+
)
|
|
39
|
+
.option('--endpoint <path>', 'URL path/endpoint to test (default: /)', '/')
|
|
40
|
+
.option('--method <METHOD>', 'HTTP method: GET, POST, PUT, DELETE, PATCH (default: GET)', 'GET')
|
|
41
|
+
.option('--https <on|off>', 'Use HTTPS (default: on)', 'on')
|
|
42
|
+
.option('--timeout <ms>', 'Request timeout in milliseconds (default: 30000)', '30000')
|
|
43
|
+
.option('--follow-redirects <on|off>', 'Follow HTTP redirects (default: on)', 'on')
|
|
44
|
+
.option(
|
|
45
|
+
'--browser <name>',
|
|
46
|
+
'Browser type for user agent: firefox, chrome, safari (default: chrome)'
|
|
47
|
+
)
|
|
48
|
+
.option('--user-agent <string>', 'Custom user agent string (overrides --browser)')
|
|
49
|
+
.option('--output <format>', 'Output format: table, json, csv (default: table)', 'table')
|
|
50
|
+
.option('--verbose', 'Enable verbose logging', false);
|
|
51
|
+
|
|
52
|
+
return program;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
module.exports = {
|
|
56
|
+
setupCli,
|
|
57
|
+
};
|
package/src/config.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized configuration module
|
|
3
|
+
* Loads and normalizes all configuration from CLI arguments and environment variables
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
require('dotenv').config();
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Default configuration values
|
|
10
|
+
*/
|
|
11
|
+
const defaults = {
|
|
12
|
+
// Test parameters - Steady-State mode
|
|
13
|
+
clients: 5,
|
|
14
|
+
interval: 1000, // milliseconds
|
|
15
|
+
duration: 60, // seconds
|
|
16
|
+
|
|
17
|
+
// Test parameters - Burst mode
|
|
18
|
+
burstClients: null,
|
|
19
|
+
|
|
20
|
+
// Common parameters
|
|
21
|
+
domain: null,
|
|
22
|
+
endpoint: '/',
|
|
23
|
+
method: 'GET',
|
|
24
|
+
https: true,
|
|
25
|
+
timeout: 30000,
|
|
26
|
+
followRedirects: true,
|
|
27
|
+
output: 'table', // table, json, csv
|
|
28
|
+
|
|
29
|
+
// User agent
|
|
30
|
+
browser: 'chrome', // firefox, chrome, safari
|
|
31
|
+
customUserAgent: null,
|
|
32
|
+
|
|
33
|
+
// Test mode
|
|
34
|
+
mode: 'steady-state', // steady-state or burst
|
|
35
|
+
|
|
36
|
+
// Logging
|
|
37
|
+
logLevel: process.env.LOG_LEVEL || 'INFO',
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Build configuration from CLI arguments
|
|
42
|
+
* @param {Object} args - Parsed command line arguments
|
|
43
|
+
* @returns {Object} Configuration object
|
|
44
|
+
*/
|
|
45
|
+
function buildConfig(args) {
|
|
46
|
+
const config = { ...defaults };
|
|
47
|
+
|
|
48
|
+
// Domain is required positional argument
|
|
49
|
+
if (args.domain) {
|
|
50
|
+
config.domain = args.domain;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Steady-state parameters
|
|
54
|
+
if (args.clients !== undefined) {
|
|
55
|
+
config.clients = parseInt(args.clients, 10);
|
|
56
|
+
}
|
|
57
|
+
if (args.interval !== undefined) {
|
|
58
|
+
config.interval = parseInt(args.interval, 10);
|
|
59
|
+
}
|
|
60
|
+
if (args.duration !== undefined) {
|
|
61
|
+
config.duration = parseInt(args.duration, 10);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Burst parameters
|
|
65
|
+
if (args.mode === 'burst') {
|
|
66
|
+
config.mode = 'burst';
|
|
67
|
+
if (args.burstClients !== undefined) {
|
|
68
|
+
config.burstClients = parseInt(args.burstClients, 10);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Common parameters
|
|
73
|
+
if (args.endpoint !== undefined) {
|
|
74
|
+
config.endpoint = args.endpoint;
|
|
75
|
+
}
|
|
76
|
+
if (args.method !== undefined) {
|
|
77
|
+
config.method = args.method.toUpperCase();
|
|
78
|
+
}
|
|
79
|
+
if (args.https !== undefined) {
|
|
80
|
+
config.https = args.https === 'on' || args.https === true;
|
|
81
|
+
}
|
|
82
|
+
if (args.timeout !== undefined) {
|
|
83
|
+
config.timeout = parseInt(args.timeout, 10);
|
|
84
|
+
}
|
|
85
|
+
if (args.followRedirects !== undefined) {
|
|
86
|
+
config.followRedirects = args.followRedirects === 'on' || args.followRedirects === true;
|
|
87
|
+
}
|
|
88
|
+
if (args.output !== undefined) {
|
|
89
|
+
config.output = args.output.toLowerCase();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// User agent parameters
|
|
93
|
+
if (args.userAgent !== undefined) {
|
|
94
|
+
config.customUserAgent = args.userAgent;
|
|
95
|
+
}
|
|
96
|
+
if (args.browser !== undefined) {
|
|
97
|
+
config.browser = args.browser.toLowerCase();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return config;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Validate configuration
|
|
105
|
+
* @param {Object} config
|
|
106
|
+
* @throws {Error} if configuration is invalid
|
|
107
|
+
*/
|
|
108
|
+
function validate(config) {
|
|
109
|
+
if (!config.domain) {
|
|
110
|
+
throw new Error('Domain is required');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (config.mode === 'burst') {
|
|
114
|
+
if (!config.burstClients) {
|
|
115
|
+
throw new Error('--burst-clients is required for burst mode');
|
|
116
|
+
}
|
|
117
|
+
if (config.burstClients < 1) {
|
|
118
|
+
throw new Error('--burst-clients must be greater than 0');
|
|
119
|
+
}
|
|
120
|
+
} else {
|
|
121
|
+
if (config.clients < 1) {
|
|
122
|
+
throw new Error('--clients must be greater than 0');
|
|
123
|
+
}
|
|
124
|
+
if (config.interval < 1) {
|
|
125
|
+
throw new Error('--interval must be greater than 0');
|
|
126
|
+
}
|
|
127
|
+
if (config.duration < 1) {
|
|
128
|
+
throw new Error('--duration must be greater than 0');
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const validMethods = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'];
|
|
133
|
+
if (!validMethods.includes(config.method)) {
|
|
134
|
+
throw new Error(`--method must be one of: ${validMethods.join(', ')}`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const validOutputs = ['table', 'json', 'csv'];
|
|
138
|
+
if (!validOutputs.includes(config.output)) {
|
|
139
|
+
throw new Error(`--output must be one of: ${validOutputs.join(', ')}`);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (config.timeout < 1) {
|
|
143
|
+
throw new Error('--timeout must be greater than 0');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Validate browser if not using custom user agent
|
|
147
|
+
if (!config.customUserAgent) {
|
|
148
|
+
const validBrowsers = ['firefox', 'chrome', 'safari'];
|
|
149
|
+
if (!validBrowsers.includes(config.browser)) {
|
|
150
|
+
throw new Error(`--browser must be one of: ${validBrowsers.join(', ')}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
module.exports = {
|
|
156
|
+
buildConfig,
|
|
157
|
+
validate,
|
|
158
|
+
defaults,
|
|
159
|
+
};
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSV Formatter
|
|
3
|
+
* Outputs results as CSV for spreadsheet import
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Escape CSV field value
|
|
8
|
+
* @param {string|number} value - Value to escape
|
|
9
|
+
* @returns {string} CSV-escaped value
|
|
10
|
+
*/
|
|
11
|
+
function escapeCsvField(value) {
|
|
12
|
+
const stringValue = String(value);
|
|
13
|
+
|
|
14
|
+
// If contains comma, newline, or quote, wrap in quotes and escape quotes
|
|
15
|
+
if (stringValue.includes(',') || stringValue.includes('\n') || stringValue.includes('"')) {
|
|
16
|
+
return `"${stringValue.replace(/"/g, '""')}"`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return stringValue;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Format results as CSV
|
|
24
|
+
* @param {Object} results - Test results
|
|
25
|
+
* @returns {string} CSV formatted output
|
|
26
|
+
*/
|
|
27
|
+
function format(results) {
|
|
28
|
+
const rows = [];
|
|
29
|
+
|
|
30
|
+
// Header row
|
|
31
|
+
rows.push('Metric,Value');
|
|
32
|
+
|
|
33
|
+
// Summary metrics
|
|
34
|
+
rows.push(`Total Requests,${results.totalRequests}`);
|
|
35
|
+
rows.push(`Duration (seconds),${results.duration.toFixed(2)}`);
|
|
36
|
+
rows.push(`Success Rate (%),${results.successRate.toFixed(2)}`);
|
|
37
|
+
rows.push(`Throughput (req/s),${results.throughput.toFixed(2)}`);
|
|
38
|
+
rows.push(`Data Transferred (bytes),${results.dataTransferred}`);
|
|
39
|
+
|
|
40
|
+
// Response times
|
|
41
|
+
rows.push('');
|
|
42
|
+
rows.push('Response Time Metrics');
|
|
43
|
+
rows.push(`Min (ms),${results.responseTime.min}`);
|
|
44
|
+
rows.push(`Max (ms),${results.responseTime.max}`);
|
|
45
|
+
rows.push(`Average (ms),${results.responseTime.avg.toFixed(2)}`);
|
|
46
|
+
rows.push(`Median P50 (ms),${results.responseTime.median}`);
|
|
47
|
+
rows.push(`P95 (ms),${results.responseTime.p95.toFixed(2)}`);
|
|
48
|
+
rows.push(`P99 (ms),${results.responseTime.p99.toFixed(2)}`);
|
|
49
|
+
|
|
50
|
+
// Status codes
|
|
51
|
+
rows.push('');
|
|
52
|
+
rows.push('Status Code Distribution');
|
|
53
|
+
rows.push(`2xx Success,${results.statusCodes['2xx']}`);
|
|
54
|
+
rows.push(`3xx Redirect,${results.statusCodes['3xx']}`);
|
|
55
|
+
rows.push(`4xx Client Error,${results.statusCodes['4xx']}`);
|
|
56
|
+
rows.push(`5xx Server Error,${results.statusCodes['5xx']}`);
|
|
57
|
+
|
|
58
|
+
// Errors
|
|
59
|
+
if (results.errorTotal > 0) {
|
|
60
|
+
rows.push('');
|
|
61
|
+
rows.push('Error Details');
|
|
62
|
+
Object.entries(results.errors).forEach(([error, count]) => {
|
|
63
|
+
rows.push(`${escapeCsvField(error)},${count}`);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return rows.join('\n');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = {
|
|
71
|
+
format,
|
|
72
|
+
};
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON Formatter
|
|
3
|
+
* Outputs results as JSON
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Format results as JSON
|
|
8
|
+
* @param {Object} results - Test results
|
|
9
|
+
* @returns {string} JSON formatted output
|
|
10
|
+
*/
|
|
11
|
+
function format(results) {
|
|
12
|
+
return JSON.stringify(results, null, 2);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
module.exports = {
|
|
16
|
+
format,
|
|
17
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Table Formatter
|
|
3
|
+
* Outputs results as pretty ASCII tables
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const Table = require('cli-table3');
|
|
7
|
+
const { formatBytes } = require('../utils');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Format results as pretty tables
|
|
11
|
+
* @param {Object} results - Test results
|
|
12
|
+
* @returns {string} Formatted output
|
|
13
|
+
*/
|
|
14
|
+
function format(results) {
|
|
15
|
+
const output = [];
|
|
16
|
+
|
|
17
|
+
// Summary Table
|
|
18
|
+
const summaryTable = new Table({
|
|
19
|
+
head: ['Metric', 'Value'],
|
|
20
|
+
style: { head: [], border: ['cyan'] },
|
|
21
|
+
colWidths: [25, 40],
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
summaryTable.push(
|
|
25
|
+
['Total Requests', results.totalRequests.toString()],
|
|
26
|
+
['Duration', `${results.duration.toFixed(2)}s`],
|
|
27
|
+
['Success Rate', `${results.successRate.toFixed(2)}%`],
|
|
28
|
+
['Throughput', `${results.throughput.toFixed(2)} req/s`],
|
|
29
|
+
['Data Transferred', formatBytes(results.dataTransferred)]
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
output.push('Summary');
|
|
33
|
+
output.push(summaryTable.toString());
|
|
34
|
+
output.push('');
|
|
35
|
+
|
|
36
|
+
// Response Times Table
|
|
37
|
+
const responseTable = new Table({
|
|
38
|
+
head: ['Metric', 'Value'],
|
|
39
|
+
style: { head: [], border: ['cyan'] },
|
|
40
|
+
colWidths: [25, 40],
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
responseTable.push(
|
|
44
|
+
['Min', `${results.responseTime.min}ms`],
|
|
45
|
+
['Max', `${results.responseTime.max}ms`],
|
|
46
|
+
['Average', `${results.responseTime.avg.toFixed(2)}ms`],
|
|
47
|
+
['Median (P50)', `${results.responseTime.median}ms`],
|
|
48
|
+
['P95', `${results.responseTime.p95.toFixed(2)}ms`],
|
|
49
|
+
['P99', `${results.responseTime.p99.toFixed(2)}ms`]
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
output.push('Response Times');
|
|
53
|
+
output.push(responseTable.toString());
|
|
54
|
+
output.push('');
|
|
55
|
+
|
|
56
|
+
// Status Codes Table
|
|
57
|
+
const statusTable = new Table({
|
|
58
|
+
head: ['Status Code', 'Count'],
|
|
59
|
+
style: { head: [], border: ['cyan'] },
|
|
60
|
+
colWidths: [25, 40],
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
statusTable.push(
|
|
64
|
+
['2xx Success', results.statusCodes['2xx'].toString()],
|
|
65
|
+
['3xx Redirect', results.statusCodes['3xx'].toString()],
|
|
66
|
+
['4xx Client Error', results.statusCodes['4xx'].toString()],
|
|
67
|
+
['5xx Server Error', results.statusCodes['5xx'].toString()]
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
output.push('Status Codes');
|
|
71
|
+
output.push(statusTable.toString());
|
|
72
|
+
output.push('');
|
|
73
|
+
|
|
74
|
+
// Errors Table (if any)
|
|
75
|
+
if (results.errorTotal > 0) {
|
|
76
|
+
const errorTable = new Table({
|
|
77
|
+
head: ['Error Type', 'Count'],
|
|
78
|
+
style: { head: [], border: ['cyan'] },
|
|
79
|
+
colWidths: [40, 20],
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
Object.entries(results.errors).forEach(([error, count]) => {
|
|
83
|
+
errorTable.push([error, count.toString()]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
output.push('Errors');
|
|
87
|
+
output.push(errorTable.toString());
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return output.join('\n');
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
module.exports = {
|
|
94
|
+
format,
|
|
95
|
+
};
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Formatter Factory
|
|
3
|
+
* Selects and returns the appropriate formatter based on output format
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const TableFormatter = require('./TableFormatter');
|
|
7
|
+
const JsonFormatter = require('./JsonFormatter');
|
|
8
|
+
const CsvFormatter = require('./CsvFormatter');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Get formatter by name
|
|
12
|
+
* @param {string} format - Format name: 'table', 'json', or 'csv'
|
|
13
|
+
* @returns {Object} Formatter with format() method
|
|
14
|
+
* @throws {Error} if format is not recognized
|
|
15
|
+
*/
|
|
16
|
+
function getFormatter(format) {
|
|
17
|
+
switch (format.toLowerCase()) {
|
|
18
|
+
case 'table':
|
|
19
|
+
return TableFormatter;
|
|
20
|
+
case 'json':
|
|
21
|
+
return JsonFormatter;
|
|
22
|
+
case 'csv':
|
|
23
|
+
return CsvFormatter;
|
|
24
|
+
default:
|
|
25
|
+
throw new Error(`Unknown format: ${format}. Valid formats: table, json, csv`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
module.exports = {
|
|
30
|
+
getFormatter,
|
|
31
|
+
};
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP Client using Node's native Fetch API
|
|
3
|
+
*
|
|
4
|
+
* Handles HTTP requests with timeout support, error classification,
|
|
5
|
+
* and metrics collection (response time, size, status code).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const logger = require('../logger');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Make an HTTP request and return metrics
|
|
12
|
+
*
|
|
13
|
+
* @param {string} url - Full URL to request
|
|
14
|
+
* @param {Object} options - Request options
|
|
15
|
+
* @param {string} options.method - HTTP method (GET, POST, etc.) - default: GET
|
|
16
|
+
* @param {number} options.timeout - Timeout in milliseconds - default: 30000
|
|
17
|
+
* @param {boolean} options.followRedirects - Follow redirects - default: true
|
|
18
|
+
* @param {string} options.userAgent - User agent string - default: Node.js default
|
|
19
|
+
* @returns {Promise<Object>} Result object with metrics
|
|
20
|
+
* - statusCode: HTTP status code or null
|
|
21
|
+
* - responseTime: Response time in milliseconds
|
|
22
|
+
* - size: Response body size in bytes
|
|
23
|
+
* - error: Error string or null
|
|
24
|
+
* - errorType: 'network' | 'timeout' | 'http' | null
|
|
25
|
+
*/
|
|
26
|
+
async function makeRequest(url, options = {}) {
|
|
27
|
+
const { method = 'GET', timeout = 30000, followRedirects = true, userAgent = null } = options;
|
|
28
|
+
|
|
29
|
+
const startTime = Date.now();
|
|
30
|
+
let statusCode = null;
|
|
31
|
+
let size = 0;
|
|
32
|
+
let error = null;
|
|
33
|
+
let errorType = null;
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
// Create abort controller for timeout handling
|
|
37
|
+
const controller = new AbortController();
|
|
38
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
39
|
+
|
|
40
|
+
const fetchOptions = {
|
|
41
|
+
method,
|
|
42
|
+
signal: controller.signal,
|
|
43
|
+
redirect: followRedirects ? 'follow' : 'manual',
|
|
44
|
+
headers: {},
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
// Add User-Agent header if provided
|
|
48
|
+
if (userAgent) {
|
|
49
|
+
fetchOptions.headers['User-Agent'] = userAgent;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let response;
|
|
53
|
+
try {
|
|
54
|
+
response = await fetch(url, fetchOptions);
|
|
55
|
+
} finally {
|
|
56
|
+
clearTimeout(timeoutId);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
statusCode = response.status;
|
|
60
|
+
|
|
61
|
+
// Extract response size from Content-Length header or read body
|
|
62
|
+
const contentLength = response.headers.get('content-length');
|
|
63
|
+
if (contentLength) {
|
|
64
|
+
size = parseInt(contentLength, 10);
|
|
65
|
+
} else {
|
|
66
|
+
// If no Content-Length, read the body to get actual size
|
|
67
|
+
const body = await response.text();
|
|
68
|
+
size = Buffer.byteLength(body, 'utf8');
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Log 4xx and 5xx responses as warnings, not errors
|
|
72
|
+
if (response.status >= 400) {
|
|
73
|
+
logger.debug(`HTTP ${response.status} from ${url}`);
|
|
74
|
+
}
|
|
75
|
+
} catch (err) {
|
|
76
|
+
// Classify the error
|
|
77
|
+
if (err.name === 'AbortError') {
|
|
78
|
+
error = 'Request timeout';
|
|
79
|
+
errorType = 'timeout';
|
|
80
|
+
} else if (
|
|
81
|
+
err.code === 'ECONNREFUSED' ||
|
|
82
|
+
err.code === 'ENOTFOUND' ||
|
|
83
|
+
err.code === 'EHOSTUNREACH'
|
|
84
|
+
) {
|
|
85
|
+
error = `Network error: ${err.code}`;
|
|
86
|
+
errorType = 'network';
|
|
87
|
+
} else if (err.message.includes('fetch')) {
|
|
88
|
+
error = `Network error: ${err.message}`;
|
|
89
|
+
errorType = 'network';
|
|
90
|
+
} else {
|
|
91
|
+
error = err.message;
|
|
92
|
+
errorType = 'unknown';
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
logger.debug(`Request failed to ${url}: ${error}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const responseTime = Date.now() - startTime;
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
statusCode,
|
|
102
|
+
responseTime,
|
|
103
|
+
size,
|
|
104
|
+
error,
|
|
105
|
+
errorType,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
module.exports = {
|
|
110
|
+
makeRequest,
|
|
111
|
+
};
|
package/src/logger.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom logger with configurable log levels
|
|
3
|
+
* Respects LOG_LEVEL environment variable
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const LOG_LEVELS = {
|
|
7
|
+
DEBUG: 0,
|
|
8
|
+
INFO: 1,
|
|
9
|
+
WARN: 2,
|
|
10
|
+
ERROR: 3,
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
class Logger {
|
|
14
|
+
constructor(level = 'INFO') {
|
|
15
|
+
this.currentLevel = LOG_LEVELS[level.toUpperCase()] || LOG_LEVELS.INFO;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Log an info message
|
|
20
|
+
* @param {string} message
|
|
21
|
+
*/
|
|
22
|
+
log(message) {
|
|
23
|
+
if (this.currentLevel <= LOG_LEVELS.INFO) {
|
|
24
|
+
console.log(message);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Log a debug message
|
|
30
|
+
* @param {string} message
|
|
31
|
+
*/
|
|
32
|
+
debug(message) {
|
|
33
|
+
if (this.currentLevel <= LOG_LEVELS.DEBUG) {
|
|
34
|
+
console.log(`[DEBUG] ${message}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Log a warning message
|
|
40
|
+
* @param {string} message
|
|
41
|
+
*/
|
|
42
|
+
warn(message) {
|
|
43
|
+
if (this.currentLevel <= LOG_LEVELS.WARN) {
|
|
44
|
+
console.warn(`[WARN] ${message}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Log an error message
|
|
50
|
+
* @param {string} message
|
|
51
|
+
*/
|
|
52
|
+
error(message) {
|
|
53
|
+
if (this.currentLevel <= LOG_LEVELS.ERROR) {
|
|
54
|
+
console.error(`[ERROR] ${message}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
module.exports = new Logger(process.env.LOG_LEVEL || 'INFO');
|