portok 1.0.2 → 1.0.4

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 CHANGED
@@ -197,6 +197,7 @@ portok <command> [options]
197
197
  Management Commands:
198
198
  init Initialize portok directories (/etc/portok, /var/lib/portok)
199
199
  add <name> Create a new service instance
200
+ remove <name> Remove a service instance (stops, disables, deletes config/state)
200
201
  list List all configured instances and their status
201
202
 
202
203
  Service Control Commands:
@@ -226,6 +227,10 @@ Options for 'add' command:
226
227
  --health <path> Health check path (default: /health)
227
228
  --force Overwrite existing config
228
229
 
230
+ Options for 'remove' command:
231
+ --force Skip confirmation prompt
232
+ --keep-state Keep state file (/var/lib/portok/<name>.json)
233
+
229
234
  Options for 'logs' command:
230
235
  --follow, -f Follow log output
231
236
  --lines, -n Number of lines to show (default: 50)
@@ -275,6 +280,10 @@ sudo portok restart api
275
280
  sudo portok enable api # Enable at boot
276
281
  sudo portok disable api # Disable at boot
277
282
 
283
+ # Remove a service (stops, disables, removes config and state)
284
+ sudo portok remove api --force
285
+ sudo portok remove api --force --keep-state # Keep state file
286
+
278
287
  # View logs
279
288
  portok logs api
280
289
  portok logs api --follow # Follow log output
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  const autocannon = require('autocannon');
7
- const { createMockServer, startDaemon, getFreePort, formatNumber } = require('./run.js');
7
+ const { createMockServer, startDaemon, getFreePort, formatNumber } = require('./helpers.js');
8
8
 
9
9
  async function run({ duration, adminToken }) {
10
10
  const shortDuration = Math.max(2, Math.floor(duration / 2));
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  const autocannon = require('autocannon');
7
- const { createMockServer, startDaemon, getFreePort, formatNumber } = require('./run.js');
7
+ const { createMockServer, startDaemon, getFreePort, formatNumber } = require('./helpers.js');
8
8
 
9
9
  async function run({ duration, adminToken }) {
10
10
  // Setup
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Benchmark Helpers
3
+ * Shared utilities for all benchmark files
4
+ */
5
+
6
+ const { spawn } = require('node:child_process');
7
+ const http = require('node:http');
8
+ const path = require('node:path');
9
+
10
+ const ADMIN_TOKEN = process.env.ADMIN_TOKEN || 'bench-token-12345';
11
+
12
+ function formatNumber(n) {
13
+ return n.toLocaleString('en-US', { maximumFractionDigits: 2 });
14
+ }
15
+
16
+ async function getFreePort() {
17
+ return new Promise((resolve, reject) => {
18
+ const server = http.createServer();
19
+ server.listen(0, '127.0.0.1', () => {
20
+ const port = server.address().port;
21
+ server.close(() => resolve(port));
22
+ });
23
+ server.on('error', reject);
24
+ });
25
+ }
26
+
27
+ async function waitFor(condition, timeout = 10000) {
28
+ const start = Date.now();
29
+ while (Date.now() - start < timeout) {
30
+ if (await condition()) return true;
31
+ await new Promise(r => setTimeout(r, 100));
32
+ }
33
+ throw new Error('Timeout waiting for condition');
34
+ }
35
+
36
+ // =============================================================================
37
+ // Mock Server
38
+ // =============================================================================
39
+
40
+ async function createMockServer(options = {}) {
41
+ const { responseDelay = 0, responseSize = 13 } = options;
42
+ const port = await getFreePort();
43
+
44
+ const responseBody = responseSize <= 13
45
+ ? 'Hello, World!'
46
+ : 'x'.repeat(responseSize);
47
+
48
+ const server = http.createServer((req, res) => {
49
+ if (req.url === '/health') {
50
+ res.writeHead(200, { 'Content-Type': 'application/json' });
51
+ res.end('{"status":"ok"}');
52
+ return;
53
+ }
54
+
55
+ if (responseDelay > 0) {
56
+ setTimeout(() => {
57
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
58
+ res.end(responseBody);
59
+ }, responseDelay);
60
+ } else {
61
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
62
+ res.end(responseBody);
63
+ }
64
+ });
65
+
66
+ return new Promise((resolve, reject) => {
67
+ server.listen(port, '127.0.0.1', () => {
68
+ resolve({
69
+ port,
70
+ close: () => new Promise(r => server.close(r)),
71
+ });
72
+ });
73
+ server.on('error', reject);
74
+ });
75
+ }
76
+
77
+ // =============================================================================
78
+ // Daemon Starter
79
+ // =============================================================================
80
+
81
+ async function startDaemon(proxyPort, targetPort, extraEnv = {}) {
82
+ const daemonPath = path.join(__dirname, '..', 'portokd.js');
83
+
84
+ const env = {
85
+ ...process.env,
86
+ LISTEN_PORT: String(proxyPort),
87
+ INITIAL_TARGET_PORT: String(targetPort),
88
+ ADMIN_TOKEN,
89
+ DRAIN_MS: '100',
90
+ ROLLBACK_WINDOW_MS: '1000',
91
+ ROLLBACK_CHECK_EVERY_MS: '100',
92
+ ROLLBACK_FAIL_THRESHOLD: '2',
93
+ ...extraEnv,
94
+ };
95
+
96
+ const daemon = spawn('node', [daemonPath], {
97
+ env,
98
+ stdio: ['ignore', 'pipe', 'pipe'],
99
+ });
100
+
101
+ // Wait for daemon to be ready
102
+ await waitFor(async () => {
103
+ try {
104
+ const res = await fetch(`http://127.0.0.1:${proxyPort}/__status`, {
105
+ headers: { 'x-admin-token': ADMIN_TOKEN },
106
+ });
107
+ return res.ok;
108
+ } catch {
109
+ return false;
110
+ }
111
+ }, 10000);
112
+
113
+ return daemon;
114
+ }
115
+
116
+ module.exports = {
117
+ createMockServer,
118
+ startDaemon,
119
+ getFreePort,
120
+ waitFor,
121
+ formatNumber,
122
+ ADMIN_TOKEN,
123
+ };
124
+
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  const autocannon = require('autocannon');
7
- const { createMockServer, startDaemon, getFreePort } = require('./run.js');
7
+ const { createMockServer, startDaemon, getFreePort } = require('./helpers.js');
8
8
 
9
9
  async function run({ duration, adminToken }) {
10
10
  // Setup
package/bench/run.js CHANGED
@@ -5,9 +5,6 @@
5
5
  * Orchestrates all benchmarks and outputs formatted results
6
6
  */
7
7
 
8
- const { spawn } = require('node:child_process');
9
- const http = require('node:http');
10
-
11
8
  // =============================================================================
12
9
  // Configuration
13
10
  // =============================================================================
@@ -31,86 +28,6 @@ function log(msg) {
31
28
  }
32
29
  }
33
30
 
34
- function formatNumber(n) {
35
- return n.toLocaleString('en-US', { maximumFractionDigits: 2 });
36
- }
37
-
38
- async function getFreePort() {
39
- return new Promise((resolve, reject) => {
40
- const server = http.createServer();
41
- server.listen(0, '127.0.0.1', () => {
42
- const port = server.address().port;
43
- server.close(() => resolve(port));
44
- });
45
- server.on('error', reject);
46
- });
47
- }
48
-
49
- async function waitFor(condition, timeout = 10000) {
50
- const start = Date.now();
51
- while (Date.now() - start < timeout) {
52
- if (await condition()) return true;
53
- await new Promise(r => setTimeout(r, 100));
54
- }
55
- throw new Error('Timeout waiting for condition');
56
- }
57
-
58
- // =============================================================================
59
- // Mock Server
60
- // =============================================================================
61
-
62
- async function createMockServer(port = 0) {
63
- const server = http.createServer((req, res) => {
64
- if (req.url === '/health') {
65
- res.writeHead(200, { 'Content-Type': 'application/json' });
66
- res.end('{"status":"healthy"}');
67
- return;
68
- }
69
- res.writeHead(200, { 'Content-Type': 'text/plain' });
70
- res.end('OK');
71
- });
72
-
73
- await new Promise(resolve => server.listen(port, '127.0.0.1', resolve));
74
- return {
75
- port: server.address().port,
76
- close: () => new Promise(resolve => server.close(resolve)),
77
- };
78
- }
79
-
80
- // =============================================================================
81
- // Start Daemon
82
- // =============================================================================
83
-
84
- async function startDaemon(listenPort, targetPort) {
85
- const proc = spawn('node', ['portokd.js'], {
86
- env: {
87
- ...process.env,
88
- LISTEN_PORT: String(listenPort),
89
- INITIAL_TARGET_PORT: String(targetPort),
90
- ADMIN_TOKEN,
91
- STATE_FILE: `/tmp/portok-bench-${Date.now()}.json`,
92
- DRAIN_MS: '1000',
93
- ROLLBACK_WINDOW_MS: '60000',
94
- ROLLBACK_CHECK_EVERY_MS: '5000',
95
- ROLLBACK_FAIL_THRESHOLD: '3',
96
- },
97
- stdio: 'pipe',
98
- });
99
-
100
- await waitFor(async () => {
101
- try {
102
- const res = await fetch(`http://127.0.0.1:${listenPort}/__status`, {
103
- headers: { 'x-admin-token': ADMIN_TOKEN },
104
- });
105
- return res.ok;
106
- } catch {
107
- return false;
108
- }
109
- });
110
-
111
- return proc;
112
- }
113
-
114
31
  // =============================================================================
115
32
  // Run Individual Benchmarks
116
33
  // =============================================================================
@@ -206,6 +123,3 @@ main().catch(err => {
206
123
  console.error('Benchmark failed:', err);
207
124
  process.exit(1);
208
125
  });
209
-
210
- module.exports = { createMockServer, startDaemon, getFreePort, waitFor, formatNumber, ADMIN_TOKEN };
211
-
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  const autocannon = require('autocannon');
7
- const { createMockServer, startDaemon, getFreePort, formatNumber, ADMIN_TOKEN } = require('./run.js');
7
+ const { createMockServer, startDaemon, getFreePort, formatNumber, ADMIN_TOKEN } = require('./helpers.js');
8
8
 
9
9
  async function run({ duration, adminToken }) {
10
10
  // Setup two servers
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  const autocannon = require('autocannon');
7
- const { createMockServer, startDaemon, getFreePort, formatNumber } = require('./run.js');
7
+ const { createMockServer, startDaemon, getFreePort, formatNumber } = require('./helpers.js');
8
8
 
9
9
  async function run({ duration, adminToken }) {
10
10
  // Setup
@@ -56,7 +56,7 @@ services:
56
56
  - ROLLBACK_FAIL_THRESHOLD=3
57
57
  ports:
58
58
  - "3000:3000"
59
- command: ["node", "portokd.mjs"]
59
+ command: ["node", "portokd.js"]
60
60
  tmpfs:
61
61
  - /tmp
62
62
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "portok",
3
- "version": "1.0.2",
3
+ "version": "1.0.4",
4
4
  "description": "Zero-downtime deployment proxy - routes traffic through a stable port to internal app instances with health-gated switching",
5
5
  "main": "portokd.js",
6
6
  "bin": {
package/portok.js CHANGED
@@ -314,6 +314,8 @@ async function cmdSwitch(baseUrl, token, port, options) {
314
314
  async function cmdInit(options) {
315
315
  const configDir = ENV_FILE_DIR;
316
316
  const stateDir = '/var/lib/portok';
317
+ const systemdDir = '/etc/systemd/system';
318
+ const serviceFileName = 'portok@.service';
317
319
 
318
320
  console.log(`${colors.bold}Initializing Portok...${colors.reset}\n`);
319
321
 
@@ -351,6 +353,85 @@ async function cmdInit(options) {
351
353
  results.push({ path: stateDir, status: 'error', error: err.message });
352
354
  }
353
355
 
356
+ // Install systemd template unit (embedded content)
357
+ const systemdDest = path.join(systemdDir, serviceFileName);
358
+ const serviceContent = `# Portok systemd template unit for multi-instance deployments
359
+ # Auto-generated by: portok init
360
+
361
+ [Unit]
362
+ Description=Portok Zero-Downtime Proxy (%i)
363
+ After=network.target
364
+ Documentation=https://github.com/nicatdursunlu/portok
365
+
366
+ [Service]
367
+ Type=simple
368
+ User=www-data
369
+ Group=www-data
370
+
371
+ # Instance configuration from env file
372
+ EnvironmentFile=/etc/portok/%i.env
373
+
374
+ # Set instance name automatically from systemd specifier
375
+ Environment=INSTANCE_NAME=%i
376
+
377
+ # Working directory
378
+ WorkingDirectory=/opt/portok
379
+
380
+ # Start the daemon
381
+ ExecStart=/usr/bin/node /opt/portok/portokd.js
382
+
383
+ # Restart policy
384
+ Restart=always
385
+ RestartSec=5
386
+ StartLimitBurst=5
387
+ StartLimitIntervalSec=60
388
+
389
+ # Logging
390
+ StandardOutput=journal
391
+ StandardError=journal
392
+ SyslogIdentifier=portok-%i
393
+
394
+ # Security hardening
395
+ NoNewPrivileges=true
396
+ ProtectSystem=strict
397
+ ProtectHome=true
398
+ PrivateTmp=true
399
+ ProtectKernelTunables=true
400
+ ProtectKernelModules=true
401
+ ProtectControlGroups=true
402
+
403
+ # Allow writing to state directory
404
+ ReadWritePaths=/var/lib/portok
405
+
406
+ # Network access (required for proxying)
407
+ RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
408
+
409
+ [Install]
410
+ WantedBy=multi-user.target
411
+ `;
412
+
413
+ try {
414
+ const existingContent = fs.existsSync(systemdDest) ? fs.readFileSync(systemdDest, 'utf-8') : null;
415
+
416
+ if (existingContent === serviceContent) {
417
+ results.push({ path: systemdDest, status: 'exists' });
418
+ } else {
419
+ fs.writeFileSync(systemdDest, serviceContent, { mode: 0o644 });
420
+ results.push({ path: systemdDest, status: 'created' });
421
+
422
+ // Reload systemd daemon
423
+ try {
424
+ const { execSync } = require('child_process');
425
+ execSync('systemctl daemon-reload', { stdio: 'pipe' });
426
+ results.push({ path: 'systemctl daemon-reload', status: 'created' });
427
+ } catch (e) {
428
+ // Ignore if systemctl not available (e.g., in Docker)
429
+ }
430
+ }
431
+ } catch (err) {
432
+ results.push({ path: systemdDest, status: 'error', error: err.message });
433
+ }
434
+
354
435
  if (options.json) {
355
436
  console.log(JSON.stringify({ success: true, results }, null, 2));
356
437
  return 0;
@@ -360,9 +441,11 @@ async function cmdInit(options) {
360
441
  for (const r of results) {
361
442
  const icon = r.status === 'created' ? `${colors.green}✓${colors.reset}` :
362
443
  r.status === 'exists' ? `${colors.dim}○${colors.reset}` :
444
+ r.status === 'skipped' ? `${colors.yellow}○${colors.reset}` :
363
445
  `${colors.red}✗${colors.reset}`;
364
446
  const status = r.status === 'created' ? 'Created' :
365
447
  r.status === 'exists' ? 'Already exists' :
448
+ r.status === 'skipped' ? `Skipped: ${r.error}` :
366
449
  `Error: ${r.error}`;
367
450
  console.log(` ${icon} ${r.path} - ${status}`);
368
451
  }
@@ -377,7 +460,8 @@ async function cmdInit(options) {
377
460
  console.log(`\n${colors.green}Portok initialized successfully!${colors.reset}`);
378
461
  console.log(`\nNext steps:`);
379
462
  console.log(` 1. Create a service: ${colors.cyan}portok add <name>${colors.reset}`);
380
- console.log(` 2. Or manually create: ${colors.dim}${configDir}/<name>.env${colors.reset}`);
463
+ console.log(` 2. Start service: ${colors.cyan}portok start <name>${colors.reset}`);
464
+ console.log(` 3. Enable at boot: ${colors.cyan}portok enable <name>${colors.reset}`);
381
465
 
382
466
  return 0;
383
467
  }
@@ -495,6 +579,130 @@ ROLLBACK_FAIL_THRESHOLD=3
495
579
  return 0;
496
580
  }
497
581
 
582
+ async function cmdRemove(name, options) {
583
+ if (!name) {
584
+ console.error(`${colors.red}Error:${colors.reset} Service name is required`);
585
+ console.error('Usage: portok remove <name> [--force] [--keep-state]');
586
+ return 1;
587
+ }
588
+
589
+ const envFilePath = path.join(ENV_FILE_DIR, `${name}.env`);
590
+ const stateFilePath = `/var/lib/portok/${name}.json`;
591
+
592
+ // Check if service exists
593
+ if (!fs.existsSync(envFilePath)) {
594
+ console.error(`${colors.red}Error:${colors.reset} Service '${name}' not found at ${envFilePath}`);
595
+ return 1;
596
+ }
597
+
598
+ // Confirmation (unless --force)
599
+ if (!options.force && !options.json) {
600
+ console.log(`${colors.yellow}Warning:${colors.reset} This will remove service '${name}'`);
601
+ console.log(` Config file: ${envFilePath}`);
602
+ if (fs.existsSync(stateFilePath) && !options['keep-state']) {
603
+ console.log(` State file: ${stateFilePath}`);
604
+ }
605
+ console.log(`\nUse ${colors.cyan}--force${colors.reset} to confirm removal.`);
606
+ return 1;
607
+ }
608
+
609
+ const results = [];
610
+ const { execSync } = require('child_process');
611
+
612
+ // 1. Stop the service if running
613
+ try {
614
+ execSync(`systemctl is-active portok@${name}`, { stdio: 'pipe' });
615
+ // Service is running, stop it
616
+ try {
617
+ execSync(`systemctl stop portok@${name}`, { stdio: 'pipe' });
618
+ results.push({ action: 'stop', status: 'success' });
619
+ } catch (e) {
620
+ results.push({ action: 'stop', status: 'error', error: e.message });
621
+ }
622
+ } catch {
623
+ // Service not running, skip
624
+ results.push({ action: 'stop', status: 'skipped' });
625
+ }
626
+
627
+ // 2. Disable the service
628
+ try {
629
+ execSync(`systemctl is-enabled portok@${name}`, { stdio: 'pipe' });
630
+ // Service is enabled, disable it
631
+ try {
632
+ execSync(`systemctl disable portok@${name}`, { stdio: 'pipe' });
633
+ results.push({ action: 'disable', status: 'success' });
634
+ } catch (e) {
635
+ results.push({ action: 'disable', status: 'error', error: e.message });
636
+ }
637
+ } catch {
638
+ // Service not enabled, skip
639
+ results.push({ action: 'disable', status: 'skipped' });
640
+ }
641
+
642
+ // 3. Remove config file
643
+ try {
644
+ fs.unlinkSync(envFilePath);
645
+ results.push({ action: 'remove-config', status: 'success', path: envFilePath });
646
+ } catch (err) {
647
+ results.push({ action: 'remove-config', status: 'error', error: err.message });
648
+ }
649
+
650
+ // 4. Remove state file (unless --keep-state)
651
+ if (!options['keep-state']) {
652
+ if (fs.existsSync(stateFilePath)) {
653
+ try {
654
+ fs.unlinkSync(stateFilePath);
655
+ results.push({ action: 'remove-state', status: 'success', path: stateFilePath });
656
+ } catch (err) {
657
+ results.push({ action: 'remove-state', status: 'error', error: err.message });
658
+ }
659
+ } else {
660
+ results.push({ action: 'remove-state', status: 'skipped' });
661
+ }
662
+ } else {
663
+ results.push({ action: 'remove-state', status: 'kept' });
664
+ }
665
+
666
+ const hasError = results.some(r => r.status === 'error');
667
+
668
+ if (options.json) {
669
+ console.log(JSON.stringify({ success: !hasError, name, results }, null, 2));
670
+ return hasError ? 1 : 0;
671
+ }
672
+
673
+ // Display results
674
+ console.log(`\n${colors.bold}Removing service '${name}'...${colors.reset}\n`);
675
+
676
+ for (const r of results) {
677
+ const icon = r.status === 'success' ? `${colors.green}✓${colors.reset}` :
678
+ r.status === 'skipped' ? `${colors.dim}○${colors.reset}` :
679
+ r.status === 'kept' ? `${colors.dim}○${colors.reset}` :
680
+ `${colors.red}✗${colors.reset}`;
681
+
682
+ const actionLabel = {
683
+ 'stop': 'Stop service',
684
+ 'disable': 'Disable service',
685
+ 'remove-config': 'Remove config',
686
+ 'remove-state': 'Remove state',
687
+ }[r.action];
688
+
689
+ const statusLabel = r.status === 'success' ? 'Done' :
690
+ r.status === 'skipped' ? 'Skipped (not running/enabled)' :
691
+ r.status === 'kept' ? 'Kept (--keep-state)' :
692
+ `Error: ${r.error}`;
693
+
694
+ console.log(` ${icon} ${actionLabel.padEnd(16)} ${statusLabel}`);
695
+ }
696
+
697
+ if (hasError) {
698
+ console.log(`\n${colors.yellow}Warning:${colors.reset} Some operations failed. Check errors above.`);
699
+ return 1;
700
+ }
701
+
702
+ console.log(`\n${colors.green}✓ Service '${name}' removed successfully!${colors.reset}`);
703
+ return 0;
704
+ }
705
+
498
706
  async function cmdList(options) {
499
707
  const configDir = ENV_FILE_DIR;
500
708
 
@@ -728,6 +936,7 @@ ${colors.bold}COMMANDS${colors.reset}
728
936
  ${colors.cyan}Management:${colors.reset}
729
937
  init Initialize portok directories (/etc/portok, /var/lib/portok)
730
938
  add <name> Create a new service instance
939
+ remove <name> Remove a service instance (config + state)
731
940
  list List all configured instances and their status
732
941
 
733
942
  ${colors.cyan}Service Control:${colors.reset}
@@ -757,6 +966,10 @@ ${colors.bold}OPTIONS${colors.reset}
757
966
  --health <path> Health check path (default: /health)
758
967
  --force Overwrite existing config
759
968
 
969
+ ${colors.dim}For 'remove' command:${colors.reset}
970
+ --force Skip confirmation prompt
971
+ --keep-state Keep state file (/var/lib/portok/<name>.json)
972
+
760
973
  ${colors.dim}For 'logs' command:${colors.reset}
761
974
  --follow, -f Follow log output
762
975
  --lines, -n Number of lines to show (default: 50)
@@ -779,6 +992,9 @@ ${colors.bold}EXAMPLES${colors.reset}
779
992
  sudo portok restart api
780
993
  portok logs api --follow
781
994
 
995
+ ${colors.dim}# Remove a service${colors.reset}
996
+ sudo portok remove api --force
997
+
782
998
  ${colors.dim}# Check status of an instance${colors.reset}
783
999
  portok status --instance api
784
1000
 
@@ -813,7 +1029,7 @@ async function main() {
813
1029
  }
814
1030
 
815
1031
  // Management commands (don't require daemon connection)
816
- const managementCommands = ['init', 'add', 'list', 'start', 'stop', 'restart', 'enable', 'disable', 'logs'];
1032
+ const managementCommands = ['init', 'add', 'remove', 'list', 'start', 'stop', 'restart', 'enable', 'disable', 'logs'];
817
1033
 
818
1034
  if (managementCommands.includes(args.command)) {
819
1035
  let exitCode = 1;
@@ -824,6 +1040,9 @@ async function main() {
824
1040
  case 'add':
825
1041
  exitCode = await cmdAdd(args.positional[0], args.options);
826
1042
  break;
1043
+ case 'remove':
1044
+ exitCode = await cmdRemove(args.positional[0], args.options);
1045
+ break;
827
1046
  case 'list':
828
1047
  exitCode = await cmdList(args.options);
829
1048
  break;
package/portok@.service CHANGED
@@ -29,7 +29,7 @@ Environment=INSTANCE_NAME=%i
29
29
  WorkingDirectory=/opt/portok
30
30
 
31
31
  # Start the daemon
32
- ExecStart=/usr/bin/node /opt/portok/portokd.mjs
32
+ ExecStart=/usr/bin/node /opt/portok/portokd.js
33
33
 
34
34
  # Restart policy
35
35
  Restart=always
@@ -210,25 +210,34 @@ describe('Connection Draining', () => {
210
210
 
211
211
  await new Promise(resolve => setTimeout(resolve, 100));
212
212
 
213
- // Start continuous traffic
213
+ // Start continuous traffic using a more reliable approach
214
214
  let requestCount = 0;
215
215
  let errorCount = 0;
216
-
217
- const interval = setInterval(async () => {
218
- try {
219
- const res = await fetch(`http://127.0.0.1:${proxyPort}/`);
220
- if (res.ok) {
221
- requestCount++;
222
- } else {
216
+ let running = true;
217
+
218
+ // Use a loop instead of setInterval for more reliable timing
219
+ const trafficLoop = async () => {
220
+ while (running) {
221
+ try {
222
+ const res = await fetch(`http://127.0.0.1:${proxyPort}/`);
223
+ if (res.ok) {
224
+ requestCount++;
225
+ } else {
226
+ errorCount++;
227
+ }
228
+ } catch {
223
229
  errorCount++;
224
230
  }
225
- } catch {
226
- errorCount++;
231
+ // Small delay between requests
232
+ await new Promise(resolve => setTimeout(resolve, 30));
227
233
  }
228
- }, 50);
234
+ };
235
+
236
+ // Start traffic in background
237
+ const trafficPromise = trafficLoop();
229
238
 
230
239
  // Wait a bit, then switch
231
- await new Promise(resolve => setTimeout(resolve, 200));
240
+ await new Promise(resolve => setTimeout(resolve, 150));
232
241
 
233
242
  await fetch(`http://127.0.0.1:${proxyPort}/__switch?port=${mockServer2.port}`, {
234
243
  method: 'POST',
@@ -236,12 +245,15 @@ describe('Connection Draining', () => {
236
245
  });
237
246
 
238
247
  // Continue traffic during switch
239
- await new Promise(resolve => setTimeout(resolve, 500));
248
+ await new Promise(resolve => setTimeout(resolve, 300));
240
249
 
241
- clearInterval(interval);
250
+ // Stop traffic
251
+ running = false;
252
+ await trafficPromise;
242
253
 
243
254
  // Should have processed requests with minimal errors
244
- assert.ok(requestCount > 5, `Expected at least 5 requests, got ${requestCount}`);
255
+ // At least 3 requests is enough to verify no drops during switch
256
+ assert.ok(requestCount >= 3, `Expected at least 3 requests, got ${requestCount}`);
245
257
  assert.strictEqual(errorCount, 0, `Expected 0 errors, got ${errorCount}`);
246
258
  });
247
259
  });