@shipers-dev/multi 0.9.5 → 0.9.6

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.
Files changed (3) hide show
  1. package/dist/index.js +90 -15
  2. package/package.json +1 -1
  3. package/src/index.ts +87 -12
package/dist/index.js CHANGED
@@ -5703,7 +5703,7 @@ import { join as join3, dirname as dirname2 } from "path";
5703
5703
  // package.json
5704
5704
  var package_default = {
5705
5705
  name: "@shipers-dev/multi",
5706
- version: "0.9.5",
5706
+ version: "0.9.6",
5707
5707
  type: "module",
5708
5708
  bin: {
5709
5709
  "multi-agent": "./dist/index.js"
@@ -6144,24 +6144,16 @@ async function cmdConnect(apiUrl, config) {
6144
6144
  try {
6145
6145
  writeFileSync3(PORT_PATH, String(port));
6146
6146
  } catch {}
6147
- const cf = Bun.spawn(["cloudflared", "tunnel", "--no-autoupdate", "--url", `http://127.0.0.1:${port}`], {
6148
- stdout: "pipe",
6149
- stderr: "pipe",
6150
- stdin: "ignore"
6151
- });
6152
- const tunnelUrl = await parseTunnelUrl(cf.stderr);
6153
- if (!tunnelUrl) {
6147
+ let tunnel = await startTunnel(port);
6148
+ if (!tunnel) {
6154
6149
  log("\u274C cloudflared did not emit a tunnel URL \u2014 is `cloudflared` installed? (`brew install cloudflared`)");
6155
- try {
6156
- cf.kill();
6157
- } catch {}
6158
6150
  try {
6159
6151
  server.stop();
6160
6152
  } catch {}
6161
6153
  process.exit(1);
6162
6154
  }
6163
- log(`\u2601\uFE0F Tunnel up: ${tunnelUrl}`);
6164
- await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: "online", tunnel_url: tunnelUrl });
6155
+ log(`\u2601\uFE0F Tunnel up: ${tunnel.url}`);
6156
+ await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: "online", tunnel_url: tunnel.url });
6165
6157
  let alive = true;
6166
6158
  const shutdown = async (reason) => {
6167
6159
  if (!alive)
@@ -6172,7 +6164,7 @@ async function cmdConnect(apiUrl, config) {
6172
6164
  server.stop();
6173
6165
  } catch {}
6174
6166
  try {
6175
- cf.kill();
6167
+ tunnel?.child.kill();
6176
6168
  } catch {}
6177
6169
  try {
6178
6170
  await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: "offline", tunnel_url: null });
@@ -6190,19 +6182,102 @@ async function cmdConnect(apiUrl, config) {
6190
6182
  process.on("SIGINT", () => shutdown("SIGINT"));
6191
6183
  process.on("SIGTERM", () => shutdown("SIGTERM"));
6192
6184
  schedule();
6185
+ const restartTunnel = async (reason) => {
6186
+ if (!alive)
6187
+ return;
6188
+ log(`\uD83D\uDD01 Restarting tunnel (${reason})`);
6189
+ try {
6190
+ tunnel?.child.kill();
6191
+ } catch {}
6192
+ for (let attempt = 1;alive; attempt++) {
6193
+ const next = await startTunnel(port);
6194
+ if (next) {
6195
+ tunnel = next;
6196
+ log(`\u2601\uFE0F Tunnel up: ${tunnel.url}`);
6197
+ try {
6198
+ await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: "online", tunnel_url: tunnel.url });
6199
+ } catch (e) {
6200
+ log(`heartbeat error after tunnel restart: ${String(e)}`);
6201
+ }
6202
+ return;
6203
+ }
6204
+ const wait = Math.min(30000, 2000 * attempt);
6205
+ log(`tunnel restart failed, retry in ${wait}ms`);
6206
+ await sleep(wait);
6207
+ }
6208
+ };
6209
+ (async () => {
6210
+ while (alive) {
6211
+ const t = tunnel;
6212
+ if (!t) {
6213
+ await sleep(1000);
6214
+ continue;
6215
+ }
6216
+ const code = await t.child.exited;
6217
+ if (!alive)
6218
+ return;
6219
+ if (tunnel === t)
6220
+ await restartTunnel(`cloudflared exited code=${code}`);
6221
+ }
6222
+ })();
6223
+ let probeFailures = 0;
6224
+ let tick = 0;
6225
+ const PROBE_EVERY = 6;
6193
6226
  while (alive) {
6194
6227
  await sleep(20000);
6195
6228
  if (existsSync3(STOP_PATH)) {
6196
6229
  await shutdown("stop flag");
6197
6230
  break;
6198
6231
  }
6232
+ tick++;
6233
+ const currentUrl = tunnel?.url;
6234
+ if (currentUrl && tick % PROBE_EVERY === 0) {
6235
+ const ok = await probeTunnel(currentUrl);
6236
+ if (!ok) {
6237
+ probeFailures++;
6238
+ log(`tunnel probe failed (${probeFailures}/2): ${currentUrl}`);
6239
+ if (probeFailures >= 2) {
6240
+ probeFailures = 0;
6241
+ await restartTunnel("probe failed 2x");
6242
+ continue;
6243
+ }
6244
+ } else {
6245
+ probeFailures = 0;
6246
+ }
6247
+ }
6199
6248
  try {
6200
- await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: "online", tunnel_url: tunnelUrl });
6249
+ await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: "online", tunnel_url: tunnel?.url });
6201
6250
  } catch (e) {
6202
6251
  log(`heartbeat error: ${String(e)}`);
6203
6252
  }
6204
6253
  }
6205
6254
  }
6255
+ async function startTunnel(port) {
6256
+ const child = Bun.spawn(["cloudflared", "tunnel", "--no-autoupdate", "--url", `http://127.0.0.1:${port}`], {
6257
+ stdout: "pipe",
6258
+ stderr: "pipe",
6259
+ stdin: "ignore"
6260
+ });
6261
+ const url = await parseTunnelUrl(child.stderr);
6262
+ if (!url) {
6263
+ try {
6264
+ child.kill();
6265
+ } catch {}
6266
+ return null;
6267
+ }
6268
+ return { child, url };
6269
+ }
6270
+ async function probeTunnel(url) {
6271
+ try {
6272
+ const ctrl = new AbortController;
6273
+ const t = setTimeout(() => ctrl.abort(), 8000);
6274
+ const res = await fetch(url, { method: "GET", signal: ctrl.signal });
6275
+ clearTimeout(t);
6276
+ return res.status > 0;
6277
+ } catch {
6278
+ return false;
6279
+ }
6280
+ }
6206
6281
  async function cmdConnectDetached(apiUrl) {
6207
6282
  if (existsSync3(PID_PATH)) {
6208
6283
  const pid = Number(readFileSync3(PID_PATH, "utf8").trim());
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipers-dev/multi",
3
- "version": "0.9.5",
3
+ "version": "0.9.6",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "multi-agent": "./dist/index.js"
package/src/index.ts CHANGED
@@ -432,20 +432,16 @@ async function cmdConnect(apiUrl: string, config: Config) {
432
432
  log(`🌐 Local server: http://127.0.0.1:${port}`);
433
433
  try { writeFileSync(PORT_PATH, String(port)); } catch {}
434
434
 
435
- // Spawn cloudflared quick tunnel
436
- const cf = Bun.spawn(['cloudflared', 'tunnel', '--no-autoupdate', '--url', `http://127.0.0.1:${port}`], {
437
- stdout: 'pipe', stderr: 'pipe', stdin: 'ignore',
438
- });
439
- const tunnelUrl = await parseTunnelUrl(cf.stderr as ReadableStream<Uint8Array>);
440
- if (!tunnelUrl) {
435
+ // Spawn cloudflared quick tunnel (with self-healing on death)
436
+ let tunnel = await startTunnel(port);
437
+ if (!tunnel) {
441
438
  log('❌ cloudflared did not emit a tunnel URL — is `cloudflared` installed? (`brew install cloudflared`)');
442
- try { cf.kill(); } catch {}
443
439
  try { server.stop(); } catch {}
444
440
  process.exit(1);
445
441
  }
446
- log(`☁️ Tunnel up: ${tunnelUrl}`);
442
+ log(`☁️ Tunnel up: ${tunnel.url}`);
447
443
 
448
- await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: 'online', tunnel_url: tunnelUrl });
444
+ await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: 'online', tunnel_url: tunnel.url });
449
445
 
450
446
  let alive = true;
451
447
 
@@ -454,7 +450,7 @@ async function cmdConnect(apiUrl: string, config: Config) {
454
450
  alive = false;
455
451
  log(`🛑 Shutting down (${reason})`);
456
452
  try { server.stop(); } catch {}
457
- try { cf.kill(); } catch {}
453
+ try { tunnel?.child.kill(); } catch {}
458
454
  try { await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: 'offline', tunnel_url: null }); } catch {}
459
455
  if (existsSync(PID_PATH)) unlinkSync(PID_PATH);
460
456
  if (existsSync(STOP_PATH)) unlinkSync(STOP_PATH);
@@ -469,18 +465,97 @@ async function cmdConnect(apiUrl: string, config: Config) {
469
465
  // Kick the scheduler on startup to drain any leftover queued rows.
470
466
  schedule();
471
467
 
472
- // Heartbeat loop
468
+ // Tunnel self-heal: relaunch cloudflared if child exits or DNS stops resolving.
469
+ const restartTunnel = async (reason: string) => {
470
+ if (!alive) return;
471
+ log(`🔁 Restarting tunnel (${reason})`);
472
+ try { tunnel?.child.kill(); } catch {}
473
+ // Small backoff so we don't spam cloudflared edge under prolonged outage.
474
+ for (let attempt = 1; alive; attempt++) {
475
+ const next = await startTunnel(port);
476
+ if (next) {
477
+ tunnel = next;
478
+ log(`☁️ Tunnel up: ${tunnel.url}`);
479
+ try {
480
+ await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: 'online', tunnel_url: tunnel.url });
481
+ } catch (e) {
482
+ log(`heartbeat error after tunnel restart: ${String(e)}`);
483
+ }
484
+ return;
485
+ }
486
+ const wait = Math.min(30000, 2000 * attempt);
487
+ log(`tunnel restart failed, retry in ${wait}ms`);
488
+ await sleep(wait);
489
+ }
490
+ };
491
+
492
+ // Watch for cloudflared process exit.
493
+ (async () => {
494
+ while (alive) {
495
+ const t = tunnel;
496
+ if (!t) { await sleep(1000); continue; }
497
+ const code = await t.child.exited;
498
+ if (!alive) return;
499
+ if (tunnel === t) await restartTunnel(`cloudflared exited code=${code}`);
500
+ }
501
+ })();
502
+
503
+ // Heartbeat (20s) + tunnel liveness probe (every 6 ticks = ~2min).
504
+ let probeFailures = 0;
505
+ let tick = 0;
506
+ const PROBE_EVERY = 6;
473
507
  while (alive) {
474
508
  await sleep(20000);
475
509
  if (existsSync(STOP_PATH)) { await shutdown('stop flag'); break; }
510
+ tick++;
511
+ const currentUrl = tunnel?.url;
512
+ if (currentUrl && tick % PROBE_EVERY === 0) {
513
+ const ok = await probeTunnel(currentUrl);
514
+ if (!ok) {
515
+ probeFailures++;
516
+ log(`tunnel probe failed (${probeFailures}/2): ${currentUrl}`);
517
+ if (probeFailures >= 2) {
518
+ probeFailures = 0;
519
+ await restartTunnel('probe failed 2x');
520
+ continue;
521
+ }
522
+ } else {
523
+ probeFailures = 0;
524
+ }
525
+ }
476
526
  try {
477
- await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: 'online', tunnel_url: tunnelUrl });
527
+ await apiClient.post(`${apiUrl}/api/devices/${config.deviceId}/heartbeat`, { status: 'online', tunnel_url: tunnel?.url });
478
528
  } catch (e) {
479
529
  log(`heartbeat error: ${String(e)}`);
480
530
  }
481
531
  }
482
532
  }
483
533
 
534
+ async function startTunnel(port: number): Promise<{ child: ReturnType<typeof Bun.spawn>; url: string } | null> {
535
+ const child = Bun.spawn(['cloudflared', 'tunnel', '--no-autoupdate', '--url', `http://127.0.0.1:${port}`], {
536
+ stdout: 'pipe', stderr: 'pipe', stdin: 'ignore',
537
+ });
538
+ const url = await parseTunnelUrl(child.stderr as ReadableStream<Uint8Array>);
539
+ if (!url) {
540
+ try { child.kill(); } catch {}
541
+ return null;
542
+ }
543
+ return { child, url };
544
+ }
545
+
546
+ async function probeTunnel(url: string): Promise<boolean> {
547
+ try {
548
+ const ctrl = new AbortController();
549
+ const t = setTimeout(() => ctrl.abort(), 8000);
550
+ // Any HTTP response — even 404 — proves the tunnel edge is routing.
551
+ const res = await fetch(url, { method: 'GET', signal: ctrl.signal });
552
+ clearTimeout(t);
553
+ return res.status > 0;
554
+ } catch {
555
+ return false;
556
+ }
557
+ }
558
+
484
559
  async function cmdConnectDetached(apiUrl: string) {
485
560
  if (existsSync(PID_PATH)) {
486
561
  const pid = Number(readFileSync(PID_PATH, 'utf8').trim());