pughost 0.0.1
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/LICENSE +7 -0
- package/README.md +101 -0
- package/bin/toxiproxy-server +0 -0
- package/cli.js +177 -0
- package/package.json +39 -0
- package/scripts/install.js +97 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright (c) 2026 Victor Durosaro
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
|
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# pughost
|
|
2
|
+
|
|
3
|
+
> **Localhost is a lie. Test in reality.**
|
|
4
|
+
|
|
5
|
+
[](https://www.npmjs.com/package/pughost)
|
|
6
|
+
[](https://opensource.org/licenses/MIT)
|
|
7
|
+
|
|
8
|
+
Your API works flawlessly on localhost. Then it hits production—users on 3G in Lagos, satellite connections in rural areas, congested airport WiFi—and everything breaks. Timeouts, race conditions, spinners that never stop.
|
|
9
|
+
|
|
10
|
+
**Pughost** injects real-world network chaos into your local development. Find the bugs before your users do.
|
|
11
|
+
|
|
12
|
+
## What It Does
|
|
13
|
+
|
|
14
|
+
Pughost is a network simulation layer between your local client and server. It introduces:
|
|
15
|
+
- **Latency** — delayed responses (3G, satellite)
|
|
16
|
+
- **Jitter** — variable delays (unstable connections)
|
|
17
|
+
- **Bandwidth limits** — throttled data transfer
|
|
18
|
+
- **Packet loss** — dropped connections
|
|
19
|
+
|
|
20
|
+
## Installation
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm install -g pughost
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Automatically downloads the correct binary for your OS (macOS, Linux, Windows) and architecture (x64, arm64). No manual setup required.
|
|
27
|
+
|
|
28
|
+
## Quick Start
|
|
29
|
+
|
|
30
|
+
**1. Initialize** — Generate a config file in your project:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pughost init
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
This creates `pughost.json`:
|
|
37
|
+
|
|
38
|
+
```json
|
|
39
|
+
{
|
|
40
|
+
"upstream": "localhost:3000",
|
|
41
|
+
"proxyPort": 3001,
|
|
42
|
+
"scenarios": {
|
|
43
|
+
"mobile_3g_slow": { "latency": 1000, "jitter": 500, "bandwidth": 50 },
|
|
44
|
+
"wifi_cafe_crowded": { "latency": 100, "jitter": 800, "packet_loss": 0.05 },
|
|
45
|
+
"satellite_link": { "latency": 800, "bandwidth": 1000 }
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
**2. Start your backend** — Run your server on its normal port (e.g., 3000).
|
|
51
|
+
|
|
52
|
+
**3. Activate chaos** — Start the proxy with a scenario:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pughost start -s mobile_3g_slow
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
**4. Test through the proxy** — Point your client to `http://localhost:3001` instead of `:3000`.
|
|
59
|
+
|
|
60
|
+
| Endpoint | Behavior |
|
|
61
|
+
|----------|----------|
|
|
62
|
+
| `localhost:3000` | Normal (dev mode) |
|
|
63
|
+
| `localhost:3001` | Chaos (reality mode) |
|
|
64
|
+
|
|
65
|
+
## Commands
|
|
66
|
+
|
|
67
|
+
| Command | Description |
|
|
68
|
+
|---------|-------------|
|
|
69
|
+
| `pughost init` | Create `pughost.json` config file |
|
|
70
|
+
| `pughost list` | Show available scenarios |
|
|
71
|
+
| `pughost start -s <name>` | Start proxy with scenario |
|
|
72
|
+
| `pughost --help` | Show all commands |
|
|
73
|
+
|
|
74
|
+
## Scenario Parameters
|
|
75
|
+
|
|
76
|
+
| Parameter | Unit | Description |
|
|
77
|
+
|-----------|------|-------------|
|
|
78
|
+
| `latency` | ms | Base delay added to responses |
|
|
79
|
+
| `jitter` | ms | Random variance in latency |
|
|
80
|
+
| `bandwidth` | kbps | Max data transfer rate |
|
|
81
|
+
| `packet_loss` | 0.0–1.0 | Probability of connection drop |
|
|
82
|
+
|
|
83
|
+
## Use Cases
|
|
84
|
+
|
|
85
|
+
- **Mobile app testing** — Simulate 3G/4G conditions against your local API
|
|
86
|
+
- **Emerging markets** — Test for users in Nigeria, India, Indonesia
|
|
87
|
+
- **Resilience testing** — Verify timeout handling, retry logic, loading states
|
|
88
|
+
- **QA workflows** — Consistent network conditions for bug reproduction
|
|
89
|
+
|
|
90
|
+
## How It Works
|
|
91
|
+
|
|
92
|
+
Pughost wraps [Toxiproxy](https://github.com/Shopify/toxiproxy) (by Shopify) and manages its lifecycle automatically. You get battle-tested chaos engineering without the setup complexity.
|
|
93
|
+
|
|
94
|
+
## Requirements
|
|
95
|
+
|
|
96
|
+
- Node.js >= 18
|
|
97
|
+
- Internet access on first install (downloads Toxiproxy binary)
|
|
98
|
+
|
|
99
|
+
## License
|
|
100
|
+
|
|
101
|
+
MIT © Victor Durosaro
|
|
Binary file
|
package/cli.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import fs from 'fs';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import { spawn } from 'child_process';
|
|
7
|
+
import { fileURLToPath } from 'url';
|
|
8
|
+
|
|
9
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
10
|
+
const __dirname = path.dirname(__filename);
|
|
11
|
+
const TOXIPROXY_API = 'http://localhost:8474';
|
|
12
|
+
|
|
13
|
+
const program = new Command();
|
|
14
|
+
|
|
15
|
+
program
|
|
16
|
+
.name('pughost')
|
|
17
|
+
.description('Localhost is a Lie. Test in Reality.')
|
|
18
|
+
.version('0.0.1');
|
|
19
|
+
|
|
20
|
+
const getBinaryPath = () => {
|
|
21
|
+
const platform = process.platform;
|
|
22
|
+
const ext = platform === 'win32' ? '.exe' : '';
|
|
23
|
+
const binPath = path.join(__dirname, 'bin', `toxiproxy-server${ext}`);
|
|
24
|
+
|
|
25
|
+
if (!fs.existsSync(binPath)) {
|
|
26
|
+
console.error(`Critical: Engine not found at ${binPath}`);
|
|
27
|
+
console.error(`Run 'npm install' to fix.`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
return binPath;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const waitForToxiproxy = async (retries = 10) => {
|
|
34
|
+
for (let i = 0; i < retries; i++) {
|
|
35
|
+
try {
|
|
36
|
+
const res = await fetch(`${TOXIPROXY_API}/version`);
|
|
37
|
+
if (res.ok) return true;
|
|
38
|
+
} catch (e) {
|
|
39
|
+
await new Promise(r => setTimeout(r, 200));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return false;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
let proxyProcess = null;
|
|
46
|
+
const cleanup = () => {
|
|
47
|
+
if (proxyProcess) {
|
|
48
|
+
console.log('\nShutting Pughost down...');
|
|
49
|
+
proxyProcess.kill();
|
|
50
|
+
proxyProcess = null;
|
|
51
|
+
}
|
|
52
|
+
process.exit();
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
process.on('SIGINT', cleanup);
|
|
56
|
+
process.on('SIGTERM', cleanup);
|
|
57
|
+
|
|
58
|
+
program.command('init')
|
|
59
|
+
.description('Generate configuration file')
|
|
60
|
+
.action(() => {
|
|
61
|
+
const configPath = path.join(process.cwd(), 'pughost.json');
|
|
62
|
+
if (fs.existsSync(configPath)) {
|
|
63
|
+
console.error('Error: pughost.json already exists.');
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
const defaultConfig = {
|
|
67
|
+
upstream: "localhost:3000",
|
|
68
|
+
proxyPort: 3001,
|
|
69
|
+
scenarios: {
|
|
70
|
+
mobile_3g_slow: { latency: 1000, jitter: 500, bandwidth: 50 },
|
|
71
|
+
wifi_cafe_crowded: { latency: 100, jitter: 800, packet_loss: 0.05 },
|
|
72
|
+
satellite_link: { latency: 800, bandwidth: 1000 }
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
fs.writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2));
|
|
76
|
+
console.log('Created pughost.json');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
program.command('list')
|
|
80
|
+
.description('List available scenarios')
|
|
81
|
+
.action(() => {
|
|
82
|
+
const configPath = path.join(process.cwd(), 'pughost.json');
|
|
83
|
+
if (!fs.existsSync(configPath)) {
|
|
84
|
+
console.error('No config found. Run "pughost init" first.');
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
88
|
+
console.log('\nAvailable Scenarios:');
|
|
89
|
+
Object.keys(config.scenarios).forEach(key => {
|
|
90
|
+
console.log(` - ${key}`);
|
|
91
|
+
});
|
|
92
|
+
console.log('');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
program.command('start')
|
|
96
|
+
.description('Start the proxy with a scenario')
|
|
97
|
+
.requiredOption('-s, --scenario <name>', 'Name of scenario')
|
|
98
|
+
.action(async (options) => {
|
|
99
|
+
const configPath = path.join(process.cwd(), 'pughost.json');
|
|
100
|
+
if (!fs.existsSync(configPath)) {
|
|
101
|
+
console.error('Error: pughost.json not found.');
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
106
|
+
const scenario = config.scenarios[options.scenario];
|
|
107
|
+
|
|
108
|
+
if (!scenario) {
|
|
109
|
+
console.error(`Error: Scenario "${options.scenario}" not found.`);
|
|
110
|
+
process.exit(1);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
console.log('pughost active...');
|
|
114
|
+
const binPath = getBinaryPath();
|
|
115
|
+
proxyProcess = spawn(binPath, [], { stdio: 'pipe' });
|
|
116
|
+
|
|
117
|
+
proxyProcess.stderr.on('data', (data) => {
|
|
118
|
+
if (data.toString().includes('address already in use')) {
|
|
119
|
+
console.error('Error: Port 8474 is in use. Is Pughost already running?');
|
|
120
|
+
cleanup();
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const ready = await waitForToxiproxy();
|
|
125
|
+
if (!ready) {
|
|
126
|
+
console.error('Error: Pughost failed to start.');
|
|
127
|
+
cleanup();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
try {
|
|
131
|
+
await fetch(`${TOXIPROXY_API}/proxies/pughost`, { method: 'DELETE' }).catch(() => {});
|
|
132
|
+
const createRes = await fetch(`${TOXIPROXY_API}/proxies`, {
|
|
133
|
+
method: 'POST',
|
|
134
|
+
headers: { 'Content-Type': 'application/json' },
|
|
135
|
+
body: JSON.stringify({
|
|
136
|
+
name: 'pughost',
|
|
137
|
+
listen: `localhost:${config.proxyPort}`,
|
|
138
|
+
upstream: config.upstream,
|
|
139
|
+
enabled: true
|
|
140
|
+
})
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
if (!createRes.ok) throw new Error(`Failed to create proxy: ${createRes.statusText}`);
|
|
144
|
+
|
|
145
|
+
console.log(`chaos config: ${options.scenario}`);
|
|
146
|
+
|
|
147
|
+
const toxics = [];
|
|
148
|
+
if (scenario.latency) toxics.push({ type: 'latency', attributes: { latency: scenario.latency, jitter: scenario.jitter || 0 } });
|
|
149
|
+
if (scenario.bandwidth) toxics.push({ type: 'bandwidth', attributes: { rate: scenario.bandwidth } });
|
|
150
|
+
if (scenario.packet_loss) toxics.push({ type: 'timeout', toxicity: scenario.packet_loss, attributes: { timeout: 0 } });
|
|
151
|
+
|
|
152
|
+
for (const toxic of toxics) {
|
|
153
|
+
toxic.stream = 'downstream';
|
|
154
|
+
|
|
155
|
+
const res = await fetch(`${TOXIPROXY_API}/proxies/pughost/toxics`, {
|
|
156
|
+
method: 'POST',
|
|
157
|
+
headers: { 'Content-Type': 'application/json' },
|
|
158
|
+
body: JSON.stringify(toxic)
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
if (!res.ok) {
|
|
162
|
+
console.warn(`Failed to apply toxic ${toxic.type}: ${res.statusText}`);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
console.log('\nProxy Live:');
|
|
167
|
+
console.log(`Target: ${config.upstream}`);
|
|
168
|
+
console.log(`Proxy: http://localhost:${config.proxyPort}\n`);
|
|
169
|
+
console.log('Press Ctrl+C to stop.\n');
|
|
170
|
+
|
|
171
|
+
} catch (err) {
|
|
172
|
+
console.error('Configuration Error:', err.message);
|
|
173
|
+
cleanup();
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
program.parse(process.argv);
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pughost",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Production Parity for Local Development",
|
|
5
|
+
"scripts": {
|
|
6
|
+
"test": "echo \"Error: no test specified\" && exit 1",
|
|
7
|
+
"postinstall": "node scripts/install.js",
|
|
8
|
+
"link": "npm link"
|
|
9
|
+
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/psyberpath/pughost.git"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"chaos-engineering",
|
|
16
|
+
"network",
|
|
17
|
+
"latency",
|
|
18
|
+
"jitter",
|
|
19
|
+
"toxiproxy",
|
|
20
|
+
"simulation",
|
|
21
|
+
"dx"
|
|
22
|
+
],
|
|
23
|
+
"author": "Victor Durosaro",
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"type": "module",
|
|
26
|
+
"bugs": {
|
|
27
|
+
"url": "https://github.com/psyberpath/pughost/issues"
|
|
28
|
+
},
|
|
29
|
+
"homepage": "https://github.com/psyberpath/pughost#readme",
|
|
30
|
+
"bin": {
|
|
31
|
+
"pughost": "./cli.js"
|
|
32
|
+
},
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=18.0.0"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"commander": "^14.0.2"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import https from 'https';
|
|
4
|
+
import { fileURLToPath } from 'url';
|
|
5
|
+
|
|
6
|
+
const TOXIPROXY_VERSION = 'v2.11.0';
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
8
|
+
const __dirname = path.dirname(__filename);
|
|
9
|
+
const BIN_PATH = path.join(__dirname, '..', 'bin');
|
|
10
|
+
|
|
11
|
+
const getPlatform = () => {
|
|
12
|
+
switch (process.platform) {
|
|
13
|
+
case 'darwin': return 'darwin';
|
|
14
|
+
case 'linux': return 'linux';
|
|
15
|
+
case 'win32': return 'windows';
|
|
16
|
+
default:
|
|
17
|
+
console.error(`Unsupported platform: ${process.platform}`);
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const getArchitecture = (platform) => {
|
|
23
|
+
const arch = process.arch;
|
|
24
|
+
|
|
25
|
+
if (platform === 'darwin' && arch === 'arm64') return 'arm64';
|
|
26
|
+
if (platform === 'darwin' && arch === 'x64') return 'amd64';
|
|
27
|
+
|
|
28
|
+
switch (arch) {
|
|
29
|
+
case 'x64': return 'amd64';
|
|
30
|
+
case 'arm64': return 'arm64';
|
|
31
|
+
case 'ia32': return '386';
|
|
32
|
+
default:
|
|
33
|
+
console.error(`Unsupported architecture: ${arch}`);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const downloadFile = (url, dest) => {
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
const file = fs.createWriteStream(dest);
|
|
41
|
+
console.log(`Downloading from: ${url}`);
|
|
42
|
+
|
|
43
|
+
const request = https.get(url, (response) => {
|
|
44
|
+
if (response.statusCode === 302 || response.statusCode === 301) {
|
|
45
|
+
return downloadFile(response.headers.location, dest).then(resolve).catch(reject);
|
|
46
|
+
}
|
|
47
|
+
if (response.statusCode !== 200) {
|
|
48
|
+
return reject(new Error(`Failed to download: ${response.statusCode}`));
|
|
49
|
+
}
|
|
50
|
+
response.pipe(file);
|
|
51
|
+
file.on('finish', () => {
|
|
52
|
+
file.close(() => resolve());
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
request.on('error', (err) => {
|
|
57
|
+
fs.unlink(dest, () => {});
|
|
58
|
+
reject(err);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const install = async () => {
|
|
64
|
+
const platform = getPlatform();
|
|
65
|
+
const architecture = getArchitecture(platform);
|
|
66
|
+
const ext = platform === 'windows' ? '.exe' : '';
|
|
67
|
+
|
|
68
|
+
const binaryName = `toxiproxy-server-${platform}-${architecture}${ext}`;
|
|
69
|
+
const downloadUrl = `https://github.com/Shopify/toxiproxy/releases/download/${TOXIPROXY_VERSION}/${binaryName}`;
|
|
70
|
+
const finalPath = path.join(BIN_PATH, `toxiproxy-server${ext}`);
|
|
71
|
+
|
|
72
|
+
console.log(`\nPughost Installer`);
|
|
73
|
+
console.log(`††††††††††††††††††††††††`);
|
|
74
|
+
console.log(`Detected: ${platform} (${process.arch})`);
|
|
75
|
+
console.log(`Fetching engine: ${TOXIPROXY_VERSION}...`);
|
|
76
|
+
|
|
77
|
+
if (!fs.existsSync(BIN_PATH)) {
|
|
78
|
+
fs.mkdirSync(BIN_PATH, { recursive: true });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
await downloadFile(downloadUrl, finalPath);
|
|
83
|
+
console.log(`Download complete.`);
|
|
84
|
+
|
|
85
|
+
if (platform !== 'windows') {
|
|
86
|
+
fs.chmodSync(finalPath, '755');
|
|
87
|
+
console.log(`Permissions set.`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
console.log(`Ready. Run 'pughost --help' to start.\n`);
|
|
91
|
+
} catch (error) {
|
|
92
|
+
console.error(`Installation failed: ${error.message}`);
|
|
93
|
+
process.exit(1);
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
install();
|