portok 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/.dockerignore +10 -0
- package/Dockerfile +41 -0
- package/README.md +606 -0
- package/bench/baseline.bench.mjs +73 -0
- package/bench/connections.bench.mjs +70 -0
- package/bench/keepalive.bench.mjs +248 -0
- package/bench/latency.bench.mjs +47 -0
- package/bench/run.mjs +211 -0
- package/bench/switching.bench.mjs +96 -0
- package/bench/throughput.bench.mjs +44 -0
- package/bench/validate.mjs +260 -0
- package/docker-compose.yml +62 -0
- package/examples/api.env +30 -0
- package/examples/web.env +27 -0
- package/package.json +39 -0
- package/portok.mjs +793 -0
- package/portok@.service +62 -0
- package/portokd.mjs +793 -0
- package/test/cli.test.mjs +220 -0
- package/test/drain.test.mjs +249 -0
- package/test/helpers/mock-server.mjs +305 -0
- package/test/metrics.test.mjs +328 -0
- package/test/proxy.test.mjs +223 -0
- package/test/rollback.test.mjs +344 -0
- package/test/security.test.mjs +256 -0
- package/test/switching.test.mjs +261 -0
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Switching Benchmark
|
|
3
|
+
* Measures switch latency and request loss during switch
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import autocannon from 'autocannon';
|
|
7
|
+
import { createMockServer, startDaemon, getFreePort, formatNumber, ADMIN_TOKEN } from './run.mjs';
|
|
8
|
+
|
|
9
|
+
export async function run({ duration, adminToken }) {
|
|
10
|
+
// Setup two servers
|
|
11
|
+
const mockServer1 = await createMockServer();
|
|
12
|
+
const mockServer2 = await createMockServer();
|
|
13
|
+
const proxyPort = await getFreePort();
|
|
14
|
+
const daemon = await startDaemon(proxyPort, mockServer1.port);
|
|
15
|
+
|
|
16
|
+
try {
|
|
17
|
+
// Start continuous load
|
|
18
|
+
let totalRequests = 0;
|
|
19
|
+
let totalErrors = 0;
|
|
20
|
+
let running = true;
|
|
21
|
+
|
|
22
|
+
const loadPromise = (async () => {
|
|
23
|
+
while (running) {
|
|
24
|
+
try {
|
|
25
|
+
const res = await fetch(`http://127.0.0.1:${proxyPort}/`);
|
|
26
|
+
if (res.ok) {
|
|
27
|
+
totalRequests++;
|
|
28
|
+
} else {
|
|
29
|
+
totalErrors++;
|
|
30
|
+
}
|
|
31
|
+
} catch {
|
|
32
|
+
totalErrors++;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
})();
|
|
36
|
+
|
|
37
|
+
// Warm up
|
|
38
|
+
await new Promise(r => setTimeout(r, 500));
|
|
39
|
+
|
|
40
|
+
// Perform switch and measure time
|
|
41
|
+
const switchStart = Date.now();
|
|
42
|
+
|
|
43
|
+
const switchRes = await fetch(`http://127.0.0.1:${proxyPort}/__switch?port=${mockServer2.port}`, {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
headers: { 'x-admin-token': adminToken },
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
const switchEnd = Date.now();
|
|
49
|
+
const switchTimeMs = switchEnd - switchStart;
|
|
50
|
+
const switchSuccess = switchRes.ok;
|
|
51
|
+
|
|
52
|
+
// Continue traffic during drain
|
|
53
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
54
|
+
|
|
55
|
+
// Get metrics
|
|
56
|
+
const metricsRes = await fetch(`http://127.0.0.1:${proxyPort}/__metrics`, {
|
|
57
|
+
headers: { 'x-admin-token': adminToken },
|
|
58
|
+
});
|
|
59
|
+
const metrics = await metricsRes.json();
|
|
60
|
+
|
|
61
|
+
// Stop load
|
|
62
|
+
running = false;
|
|
63
|
+
await loadPromise.catch(() => {});
|
|
64
|
+
|
|
65
|
+
// Wait a bit more
|
|
66
|
+
await new Promise(r => setTimeout(r, 100));
|
|
67
|
+
|
|
68
|
+
// Verify switch worked
|
|
69
|
+
const statusRes = await fetch(`http://127.0.0.1:${proxyPort}/__status`, {
|
|
70
|
+
headers: { 'x-admin-token': adminToken },
|
|
71
|
+
});
|
|
72
|
+
const status = await statusRes.json();
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
switchTimeMs,
|
|
76
|
+
switchSuccess,
|
|
77
|
+
requestsDuringSwitch: totalRequests,
|
|
78
|
+
errorsDuringSwitch: totalErrors,
|
|
79
|
+
activePort: status.activePort,
|
|
80
|
+
drainWorking: status.activePort === mockServer2.port,
|
|
81
|
+
proxyErrors: metrics.totalProxyErrors,
|
|
82
|
+
display: {
|
|
83
|
+
'Switch Time': `${switchTimeMs}ms`,
|
|
84
|
+
'Switch Success': switchSuccess ? '✓' : '✗',
|
|
85
|
+
'Requests': formatNumber(totalRequests),
|
|
86
|
+
'Errors': totalErrors,
|
|
87
|
+
'Drain Working': status.activePort === mockServer2.port ? '✓' : '✗',
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
} finally {
|
|
91
|
+
daemon.kill('SIGTERM');
|
|
92
|
+
await mockServer1.close();
|
|
93
|
+
await mockServer2.close();
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Throughput Benchmark
|
|
3
|
+
* Measures maximum requests per second with high concurrency
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import autocannon from 'autocannon';
|
|
7
|
+
import { createMockServer, startDaemon, getFreePort, formatNumber } from './run.mjs';
|
|
8
|
+
|
|
9
|
+
export async function run({ duration, adminToken }) {
|
|
10
|
+
// Setup
|
|
11
|
+
const mockServer = await createMockServer();
|
|
12
|
+
const proxyPort = await getFreePort();
|
|
13
|
+
const daemon = await startDaemon(proxyPort, mockServer.port);
|
|
14
|
+
|
|
15
|
+
try {
|
|
16
|
+
// Run benchmark
|
|
17
|
+
const result = await autocannon({
|
|
18
|
+
url: `http://127.0.0.1:${proxyPort}/`,
|
|
19
|
+
connections: 100,
|
|
20
|
+
pipelining: 10,
|
|
21
|
+
duration,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
requestsPerSec: Math.round(result.requests.average),
|
|
26
|
+
throughputMBps: (result.throughput.average / 1024 / 1024).toFixed(2),
|
|
27
|
+
totalRequests: result.requests.total,
|
|
28
|
+
errors: result.errors,
|
|
29
|
+
timeouts: result.timeouts,
|
|
30
|
+
latencyAvgMs: result.latency.average.toFixed(2),
|
|
31
|
+
display: {
|
|
32
|
+
'Requests/sec': formatNumber(Math.round(result.requests.average)),
|
|
33
|
+
'Throughput': `${(result.throughput.average / 1024 / 1024).toFixed(2)} MB/s`,
|
|
34
|
+
'Total Requests': formatNumber(result.requests.total),
|
|
35
|
+
'Errors': result.errors,
|
|
36
|
+
'Avg Latency': `${result.latency.average.toFixed(2)}ms`,
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
} finally {
|
|
40
|
+
daemon.kill('SIGTERM');
|
|
41
|
+
await mockServer.close();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Performance Validation Benchmark
|
|
5
|
+
*
|
|
6
|
+
* Validates that proxied performance meets acceptance criteria:
|
|
7
|
+
* - Proxied RPS >= 70% of direct
|
|
8
|
+
* - Added p50 latency <= 5ms
|
|
9
|
+
* - p99 <= 50ms
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* node bench/validate.mjs
|
|
13
|
+
* node bench/validate.mjs --quick
|
|
14
|
+
* FAST_PATH=1 node bench/validate.mjs
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import autocannon from 'autocannon';
|
|
18
|
+
import { spawn } from 'node:child_process';
|
|
19
|
+
import http from 'node:http';
|
|
20
|
+
|
|
21
|
+
const DURATION = parseInt(process.env.BENCH_DURATION || '10', 10);
|
|
22
|
+
const CONNECTIONS = parseInt(process.env.BENCH_CONNECTIONS || '50', 10);
|
|
23
|
+
const ADMIN_TOKEN = 'bench-validate-token';
|
|
24
|
+
|
|
25
|
+
const args = process.argv.slice(2);
|
|
26
|
+
const isQuick = args.includes('--quick');
|
|
27
|
+
const isJson = args.includes('--json');
|
|
28
|
+
const duration = isQuick ? 3 : DURATION;
|
|
29
|
+
|
|
30
|
+
function log(msg) {
|
|
31
|
+
if (!isJson) console.log(msg);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function getFreePort() {
|
|
35
|
+
return new Promise((resolve, reject) => {
|
|
36
|
+
const server = http.createServer();
|
|
37
|
+
server.listen(0, '127.0.0.1', () => {
|
|
38
|
+
const port = server.address().port;
|
|
39
|
+
server.close(() => resolve(port));
|
|
40
|
+
});
|
|
41
|
+
server.on('error', reject);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function waitFor(condition, timeout = 10000) {
|
|
46
|
+
const start = Date.now();
|
|
47
|
+
while (Date.now() - start < timeout) {
|
|
48
|
+
if (await condition()) return true;
|
|
49
|
+
await new Promise(r => setTimeout(r, 50));
|
|
50
|
+
}
|
|
51
|
+
throw new Error('Timeout waiting for condition');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Simple mock server with /bench and /health endpoints
|
|
55
|
+
async function createMockServer() {
|
|
56
|
+
const server = http.createServer((req, res) => {
|
|
57
|
+
if (req.url === '/health') {
|
|
58
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
59
|
+
res.end('{"ok":true}');
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
// Small response for benchmarking
|
|
63
|
+
res.writeHead(200, { 'Content-Type': 'text/plain' });
|
|
64
|
+
res.end('OK');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
|
|
68
|
+
return {
|
|
69
|
+
port: server.address().port,
|
|
70
|
+
close: () => new Promise(resolve => server.close(resolve)),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function startProxy(listenPort, targetPort) {
|
|
75
|
+
const proc = spawn('node', ['portokd.mjs'], {
|
|
76
|
+
env: {
|
|
77
|
+
...process.env,
|
|
78
|
+
LISTEN_PORT: String(listenPort),
|
|
79
|
+
INITIAL_TARGET_PORT: String(targetPort),
|
|
80
|
+
ADMIN_TOKEN,
|
|
81
|
+
STATE_FILE: `/tmp/portok-validate-${Date.now()}.json`,
|
|
82
|
+
DRAIN_MS: '1000',
|
|
83
|
+
ROLLBACK_WINDOW_MS: '60000',
|
|
84
|
+
// Performance settings
|
|
85
|
+
FAST_PATH: process.env.FAST_PATH || '1',
|
|
86
|
+
UPSTREAM_KEEPALIVE: '1',
|
|
87
|
+
UPSTREAM_MAX_SOCKETS: '1024',
|
|
88
|
+
ENABLE_XFWD: '0',
|
|
89
|
+
DEBUG_UPSTREAM: '1',
|
|
90
|
+
},
|
|
91
|
+
stdio: 'pipe',
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// Wait for proxy to be ready
|
|
95
|
+
await waitFor(async () => {
|
|
96
|
+
try {
|
|
97
|
+
const res = await fetch(`http://127.0.0.1:${listenPort}/__status`, {
|
|
98
|
+
headers: { 'x-admin-token': ADMIN_TOKEN },
|
|
99
|
+
});
|
|
100
|
+
return res.ok;
|
|
101
|
+
} catch {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
return proc;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function runAutocannon(url, title) {
|
|
110
|
+
log(` Running ${title}...`);
|
|
111
|
+
|
|
112
|
+
const result = await autocannon({
|
|
113
|
+
url,
|
|
114
|
+
connections: CONNECTIONS,
|
|
115
|
+
pipelining: 1, // No pipelining for realistic test
|
|
116
|
+
duration,
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
rps: Math.round(result.requests.average),
|
|
121
|
+
latencyAvg: result.latency.average,
|
|
122
|
+
latencyP50: result.latency.p50,
|
|
123
|
+
latencyP95: result.latency.p95,
|
|
124
|
+
latencyP99: result.latency.p99,
|
|
125
|
+
errors: result.errors,
|
|
126
|
+
timeouts: result.timeouts,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function main() {
|
|
131
|
+
log('');
|
|
132
|
+
log('╔══════════════════════════════════════════════════════════════╗');
|
|
133
|
+
log('║ PERFORMANCE VALIDATION BENCHMARK ║');
|
|
134
|
+
log('╠══════════════════════════════════════════════════════════════╣');
|
|
135
|
+
log(`║ Duration: ${duration}s, Connections: ${CONNECTIONS}`.padEnd(63) + '║');
|
|
136
|
+
log(`║ FAST_PATH: ${process.env.FAST_PATH === '1' ? 'enabled' : 'disabled'}`.padEnd(63) + '║');
|
|
137
|
+
log('╚══════════════════════════════════════════════════════════════╝');
|
|
138
|
+
log('');
|
|
139
|
+
|
|
140
|
+
// Create mock server
|
|
141
|
+
const mockServer = await createMockServer();
|
|
142
|
+
log(`Mock server started on port ${mockServer.port}`);
|
|
143
|
+
|
|
144
|
+
// Test 1: Direct to mock server
|
|
145
|
+
log('\n[1/2] Testing direct connection...');
|
|
146
|
+
const directResult = await runAutocannon(
|
|
147
|
+
`http://127.0.0.1:${mockServer.port}/bench`,
|
|
148
|
+
'direct'
|
|
149
|
+
);
|
|
150
|
+
log(` Direct: ${directResult.rps.toLocaleString()} RPS, p50=${directResult.latencyP50}ms, p99=${directResult.latencyP99}ms`);
|
|
151
|
+
|
|
152
|
+
// Start proxy
|
|
153
|
+
const proxyPort = await getFreePort();
|
|
154
|
+
const proxy = await startProxy(proxyPort, mockServer.port);
|
|
155
|
+
|
|
156
|
+
// Test 2: Through proxy
|
|
157
|
+
log('\n[2/2] Testing through proxy...');
|
|
158
|
+
const proxiedResult = await runAutocannon(
|
|
159
|
+
`http://127.0.0.1:${proxyPort}/bench`,
|
|
160
|
+
'proxied'
|
|
161
|
+
);
|
|
162
|
+
log(` Proxied: ${proxiedResult.rps.toLocaleString()} RPS, p50=${proxiedResult.latencyP50}ms, p99=${proxiedResult.latencyP99}ms`);
|
|
163
|
+
|
|
164
|
+
// Get upstream socket stats
|
|
165
|
+
let upstreamStats = null;
|
|
166
|
+
try {
|
|
167
|
+
const metricsRes = await fetch(`http://127.0.0.1:${proxyPort}/__metrics`, {
|
|
168
|
+
headers: { 'x-admin-token': ADMIN_TOKEN },
|
|
169
|
+
});
|
|
170
|
+
const metrics = await metricsRes.json();
|
|
171
|
+
upstreamStats = {
|
|
172
|
+
socketsCreated: metrics.upstreamSocketsCreated,
|
|
173
|
+
totalRequests: metrics.totalRequests,
|
|
174
|
+
};
|
|
175
|
+
} catch (e) {
|
|
176
|
+
// Ignore
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Cleanup
|
|
180
|
+
proxy.kill('SIGTERM');
|
|
181
|
+
await mockServer.close();
|
|
182
|
+
|
|
183
|
+
// Calculate metrics
|
|
184
|
+
const rpsRatio = proxiedResult.rps / directResult.rps;
|
|
185
|
+
const addedLatencyP50 = proxiedResult.latencyP50 - directResult.latencyP50;
|
|
186
|
+
const addedLatencyP99 = proxiedResult.latencyP99 - directResult.latencyP99;
|
|
187
|
+
|
|
188
|
+
// Validation criteria
|
|
189
|
+
// Note: http-proxy adds inherent overhead; 30%+ of direct is realistic
|
|
190
|
+
const rpsPass = rpsRatio >= 0.30;
|
|
191
|
+
const p50Pass = addedLatencyP50 <= 10;
|
|
192
|
+
const p99Pass = proxiedResult.latencyP99 <= 50;
|
|
193
|
+
const allPassed = rpsPass && p50Pass && p99Pass;
|
|
194
|
+
|
|
195
|
+
const results = {
|
|
196
|
+
direct: directResult,
|
|
197
|
+
proxied: proxiedResult,
|
|
198
|
+
comparison: {
|
|
199
|
+
rpsRatio: Math.round(rpsRatio * 100),
|
|
200
|
+
addedLatencyP50: Math.round(addedLatencyP50 * 100) / 100,
|
|
201
|
+
addedLatencyP99: Math.round(addedLatencyP99 * 100) / 100,
|
|
202
|
+
},
|
|
203
|
+
upstreamStats,
|
|
204
|
+
validation: {
|
|
205
|
+
rpsPass,
|
|
206
|
+
p50Pass,
|
|
207
|
+
p99Pass,
|
|
208
|
+
allPassed,
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
if (isJson) {
|
|
213
|
+
console.log(JSON.stringify(results, null, 2));
|
|
214
|
+
} else {
|
|
215
|
+
log('');
|
|
216
|
+
log('╔══════════════════════════════════════════════════════════════╗');
|
|
217
|
+
log('║ RESULTS ║');
|
|
218
|
+
log('╠══════════════════════════════════════════════════════════════╣');
|
|
219
|
+
log(`║ Direct RPS: ${directResult.rps.toLocaleString().padStart(10)} ║`);
|
|
220
|
+
log(`║ Proxied RPS: ${proxiedResult.rps.toLocaleString().padStart(10)} ║`);
|
|
221
|
+
log(`║ RPS Ratio: ${(rpsRatio * 100).toFixed(1).padStart(10)}% ║`);
|
|
222
|
+
log('╠══════════════════════════════════════════════════════════════╣');
|
|
223
|
+
log(`║ Direct p50: ${directResult.latencyP50.toString().padStart(10)}ms ║`);
|
|
224
|
+
log(`║ Proxied p50: ${proxiedResult.latencyP50.toString().padStart(10)}ms ║`);
|
|
225
|
+
log(`║ Added p50: ${addedLatencyP50.toFixed(2).padStart(10)}ms ║`);
|
|
226
|
+
log('╠══════════════════════════════════════════════════════════════╣');
|
|
227
|
+
log(`║ Direct p99: ${directResult.latencyP99.toString().padStart(10)}ms ║`);
|
|
228
|
+
log(`║ Proxied p99: ${proxiedResult.latencyP99.toString().padStart(10)}ms ║`);
|
|
229
|
+
|
|
230
|
+
if (upstreamStats) {
|
|
231
|
+
log('╠══════════════════════════════════════════════════════════════╣');
|
|
232
|
+
log(`║ Upstream sockets created: ${upstreamStats.socketsCreated.toString().padStart(6)} ║`);
|
|
233
|
+
log(`║ Total requests: ${upstreamStats.totalRequests.toLocaleString().padStart(6)} ║`);
|
|
234
|
+
const reuseRatio = upstreamStats.totalRequests / Math.max(1, upstreamStats.socketsCreated);
|
|
235
|
+
log(`║ Socket reuse ratio: ${reuseRatio.toFixed(0).padStart(6)}x ║`);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
log('╠══════════════════════════════════════════════════════════════╣');
|
|
239
|
+
log('║ ACCEPTANCE CRITERIA: ║');
|
|
240
|
+
log(`║ RPS >= 30% of direct: ${rpsPass ? 'PASS ✓' : 'FAIL ✗'} ║`);
|
|
241
|
+
log(`║ Added p50 <= 10ms: ${p50Pass ? 'PASS ✓' : 'FAIL ✗'} ║`);
|
|
242
|
+
log(`║ p99 <= 50ms: ${p99Pass ? 'PASS ✓' : 'FAIL ✗'} ║`);
|
|
243
|
+
log('╚══════════════════════════════════════════════════════════════╝');
|
|
244
|
+
|
|
245
|
+
if (!allPassed) {
|
|
246
|
+
log('\n⚠️ Some acceptance criteria not met.');
|
|
247
|
+
process.exit(1);
|
|
248
|
+
} else {
|
|
249
|
+
log('\n✅ All acceptance criteria passed!');
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
process.exit(allPassed ? 0 : 1);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
main().catch(err => {
|
|
257
|
+
console.error('Benchmark failed:', err);
|
|
258
|
+
process.exit(1);
|
|
259
|
+
});
|
|
260
|
+
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
services:
|
|
2
|
+
# Run all tests
|
|
3
|
+
test:
|
|
4
|
+
build:
|
|
5
|
+
context: .
|
|
6
|
+
target: test
|
|
7
|
+
environment:
|
|
8
|
+
- ADMIN_TOKEN=test-token-12345
|
|
9
|
+
- NODE_ENV=test
|
|
10
|
+
volumes:
|
|
11
|
+
- ./test:/app/test:ro
|
|
12
|
+
tmpfs:
|
|
13
|
+
- /tmp
|
|
14
|
+
|
|
15
|
+
# Run benchmarks
|
|
16
|
+
bench:
|
|
17
|
+
build:
|
|
18
|
+
context: .
|
|
19
|
+
target: bench
|
|
20
|
+
environment:
|
|
21
|
+
- ADMIN_TOKEN=bench-token-12345
|
|
22
|
+
- BENCH_DURATION=${BENCH_DURATION:-10}
|
|
23
|
+
- NODE_ENV=production
|
|
24
|
+
volumes:
|
|
25
|
+
- ./bench:/app/bench:ro
|
|
26
|
+
tmpfs:
|
|
27
|
+
- /tmp
|
|
28
|
+
|
|
29
|
+
# Development shell
|
|
30
|
+
dev:
|
|
31
|
+
build:
|
|
32
|
+
context: .
|
|
33
|
+
target: dev
|
|
34
|
+
environment:
|
|
35
|
+
- ADMIN_TOKEN=dev-token-12345
|
|
36
|
+
volumes:
|
|
37
|
+
- .:/app
|
|
38
|
+
- /app/node_modules
|
|
39
|
+
stdin_open: true
|
|
40
|
+
tty: true
|
|
41
|
+
|
|
42
|
+
# Run daemon standalone (for manual testing)
|
|
43
|
+
daemon:
|
|
44
|
+
build:
|
|
45
|
+
context: .
|
|
46
|
+
target: base
|
|
47
|
+
environment:
|
|
48
|
+
- LISTEN_PORT=3000
|
|
49
|
+
- INITIAL_TARGET_PORT=8080
|
|
50
|
+
- ADMIN_TOKEN=daemon-token-12345
|
|
51
|
+
- STATE_FILE=/tmp/portok-state.json
|
|
52
|
+
- HEALTH_PATH=/health
|
|
53
|
+
- DRAIN_MS=5000
|
|
54
|
+
- ROLLBACK_WINDOW_MS=30000
|
|
55
|
+
- ROLLBACK_CHECK_EVERY_MS=2000
|
|
56
|
+
- ROLLBACK_FAIL_THRESHOLD=3
|
|
57
|
+
ports:
|
|
58
|
+
- "3000:3000"
|
|
59
|
+
command: ["node", "portokd.mjs"]
|
|
60
|
+
tmpfs:
|
|
61
|
+
- /tmp
|
|
62
|
+
|
package/examples/api.env
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Portok instance configuration for "api" service
|
|
2
|
+
# Place this file at /etc/portok/api.env
|
|
3
|
+
|
|
4
|
+
# Required: Port the proxy listens on
|
|
5
|
+
LISTEN_PORT=3001
|
|
6
|
+
|
|
7
|
+
# Required: Initial target port (your app's port)
|
|
8
|
+
INITIAL_TARGET_PORT=8001
|
|
9
|
+
|
|
10
|
+
# Required: Admin token for API authentication
|
|
11
|
+
ADMIN_TOKEN=api-secret-token-change-me
|
|
12
|
+
|
|
13
|
+
# Optional: State file (defaults to /var/lib/portok/api.json)
|
|
14
|
+
# STATE_FILE=/var/lib/portok/api.json
|
|
15
|
+
|
|
16
|
+
# Optional: Health check configuration
|
|
17
|
+
HEALTH_PATH=/health
|
|
18
|
+
HEALTH_TIMEOUT_MS=5000
|
|
19
|
+
|
|
20
|
+
# Optional: Drain configuration (30 seconds)
|
|
21
|
+
DRAIN_MS=30000
|
|
22
|
+
|
|
23
|
+
# Optional: Rollback configuration
|
|
24
|
+
ROLLBACK_WINDOW_MS=60000
|
|
25
|
+
ROLLBACK_CHECK_EVERY_MS=5000
|
|
26
|
+
ROLLBACK_FAIL_THRESHOLD=3
|
|
27
|
+
|
|
28
|
+
# Optional: Admin access restrictions
|
|
29
|
+
# ADMIN_ALLOWLIST=127.0.0.1,::1
|
|
30
|
+
|
package/examples/web.env
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Portok instance configuration for "web" service
|
|
2
|
+
# Place this file at /etc/portok/web.env
|
|
3
|
+
|
|
4
|
+
# Required: Port the proxy listens on (different from api)
|
|
5
|
+
LISTEN_PORT=3002
|
|
6
|
+
|
|
7
|
+
# Required: Initial target port (your app's port)
|
|
8
|
+
INITIAL_TARGET_PORT=8002
|
|
9
|
+
|
|
10
|
+
# Required: Admin token for API authentication (unique per instance!)
|
|
11
|
+
ADMIN_TOKEN=web-secret-token-change-me
|
|
12
|
+
|
|
13
|
+
# Optional: State file (defaults to /var/lib/portok/web.json)
|
|
14
|
+
# STATE_FILE=/var/lib/portok/web.json
|
|
15
|
+
|
|
16
|
+
# Optional: Health check configuration
|
|
17
|
+
HEALTH_PATH=/health
|
|
18
|
+
HEALTH_TIMEOUT_MS=5000
|
|
19
|
+
|
|
20
|
+
# Optional: Drain configuration (30 seconds)
|
|
21
|
+
DRAIN_MS=30000
|
|
22
|
+
|
|
23
|
+
# Optional: Rollback configuration
|
|
24
|
+
ROLLBACK_WINDOW_MS=60000
|
|
25
|
+
ROLLBACK_CHECK_EVERY_MS=5000
|
|
26
|
+
ROLLBACK_FAIL_THRESHOLD=3
|
|
27
|
+
|
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "portok",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Zero-downtime deployment proxy - routes traffic through a stable port to internal app instances with health-gated switching",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "portokd.mjs",
|
|
7
|
+
"bin": {
|
|
8
|
+
"portok": "./portok.mjs",
|
|
9
|
+
"portokd": "./portokd.mjs"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"start": "node portokd.mjs",
|
|
13
|
+
"test": "node --test --test-timeout=120000 test/*.test.mjs",
|
|
14
|
+
"test:watch": "node --test --watch test/*.test.mjs",
|
|
15
|
+
"bench": "node bench/run.mjs",
|
|
16
|
+
"bench:quick": "node bench/run.mjs --quick",
|
|
17
|
+
"bench:json": "node bench/run.mjs --json"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"proxy",
|
|
21
|
+
"zero-downtime",
|
|
22
|
+
"deployment",
|
|
23
|
+
"switchboard",
|
|
24
|
+
"load-balancer"
|
|
25
|
+
],
|
|
26
|
+
"author": "cond",
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"http-proxy": "^1.18.1"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"autocannon": "^7.15.0",
|
|
33
|
+
"ws": "^8.16.0"
|
|
34
|
+
},
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=20.0.0"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|