pubblue 0.4.12 → 0.5.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.
@@ -1,6 +1,11 @@
1
1
  import {
2
- TunnelApiError
3
- } from "./chunk-7NFHPJ76.js";
2
+ PubApiError,
3
+ buildBridgeForkStdio,
4
+ isBridgeRunning,
5
+ latestCliVersionPath,
6
+ readLatestCliVersion,
7
+ stopBridge
8
+ } from "./chunk-YI45G6AG.js";
4
9
  import {
5
10
  CHANNELS,
6
11
  CONTROL_CHANNEL,
@@ -9,9 +14,10 @@ import {
9
14
  makeAckMessage,
10
15
  parseAckMessage,
11
16
  shouldAcknowledgeMessage
12
- } from "./chunk-4YTJ2WKF.js";
17
+ } from "./chunk-PFZT7M3E.js";
13
18
 
14
19
  // src/lib/tunnel-daemon.ts
20
+ import { fork } from "child_process";
15
21
  import * as fs from "fs";
16
22
  import * as net from "net";
17
23
  import * as path from "path";
@@ -57,13 +63,16 @@ function generateOffer(peer, timeoutMs) {
57
63
  }
58
64
 
59
65
  // src/lib/tunnel-daemon-shared.ts
66
+ var BRIDGE_CHECK_INTERVAL_MS = 3e4;
67
+ var BRIDGE_MAX_RAPID_RESTARTS = 3;
68
+ var BRIDGE_RAPID_RESTART_WINDOW_MS = 5 * 60 * 1e3;
60
69
  var OFFER_TIMEOUT_MS = 1e4;
61
70
  var SIGNAL_POLL_WAITING_MS = 5e3;
62
71
  var SIGNAL_POLL_CONNECTED_MS = 15e3;
63
72
  var LOCAL_CANDIDATE_FLUSH_MS = 2e3;
64
73
  var RECOVERY_DELAY_MS = 1e3;
65
74
  var WRITE_ACK_TIMEOUT_MS = 5e3;
66
- var NOT_CONNECTED_WRITE_ERROR = "No browser connected. Ask the user to open the tunnel URL first, then retry.";
75
+ var NOT_CONNECTED_WRITE_ERROR = "No browser connected. Ask the user to open the pub URL first, then retry.";
67
76
  function getTunnelWriteReadinessError(isConnected) {
68
77
  return isConnected ? null : NOT_CONNECTED_WRITE_ERROR;
69
78
  }
@@ -83,8 +92,11 @@ function getSignalPollDelayMs(params) {
83
92
  }
84
93
 
85
94
  // src/lib/tunnel-daemon.ts
95
+ var IDLE_SLOWDOWN_AFTER_MS = 3 * 24 * 60 * 60 * 1e3;
96
+ var IDLE_SIGNAL_POLL_MS = 5 * 60 * 1e3;
97
+ var HEALTH_CHECK_INTERVAL_MS = 60 * 60 * 1e3;
86
98
  async function startDaemon(config) {
87
- const { tunnelId, apiClient, socketPath, infoPath, cliVersion } = config;
99
+ const { slug, apiClient, socketPath, infoPath, cliVersion } = config;
88
100
  const ndc = await import("node-datachannel");
89
101
  const buffer = { messages: [] };
90
102
  const startTime = Date.now();
@@ -107,12 +119,15 @@ async function startDaemon(config) {
107
119
  let localCandidateInterval = null;
108
120
  let localCandidateStopTimer = null;
109
121
  let recoveryTimer = null;
122
+ let healthCheckTimer = null;
110
123
  let lastError = null;
111
124
  const debugEnabled = process.env.PUBBLUE_TUNNEL_DEBUG === "1";
125
+ let lastConnectedAt = startTime;
126
+ const versionFilePath = latestCliVersionPath();
112
127
  function debugLog(message, error) {
113
128
  if (!debugEnabled) return;
114
129
  const detail = error === void 0 ? "" : ` | ${error instanceof Error ? `${error.name}: ${error.message}` : typeof error === "string" ? error : JSON.stringify(error)}`;
115
- console.error(`[pubblue-daemon:${tunnelId}] ${message}${detail}`);
130
+ console.error(`[pubblue-daemon:${slug}] ${message}${detail}`);
116
131
  }
117
132
  function markError(message, error) {
118
133
  const detail = error === void 0 ? message : `${message}: ${error instanceof Error ? error.message : typeof error === "string" ? error : JSON.stringify(error)}`;
@@ -141,6 +156,27 @@ async function startDaemon(config) {
141
156
  recoveryTimer = null;
142
157
  }
143
158
  }
159
+ function clearHealthCheckTimer() {
160
+ if (healthCheckTimer) {
161
+ clearInterval(healthCheckTimer);
162
+ healthCheckTimer = null;
163
+ }
164
+ }
165
+ function runHealthCheck() {
166
+ if (stopped) return;
167
+ if (cliVersion) {
168
+ const latest = readLatestCliVersion(versionFilePath);
169
+ if (latest && latest !== cliVersion) {
170
+ markError(`detected CLI upgrade (${cliVersion} \u2192 ${latest}); shutting down`);
171
+ void shutdown();
172
+ }
173
+ }
174
+ }
175
+ function startHealthCheckTimer() {
176
+ clearHealthCheckTimer();
177
+ healthCheckTimer = setInterval(runHealthCheck, HEALTH_CHECK_INTERVAL_MS);
178
+ runHealthCheck();
179
+ }
144
180
  function setupChannel(name, dc) {
145
181
  channels.set(name, dc);
146
182
  dc.onOpen(() => {
@@ -330,7 +366,7 @@ async function startDaemon(config) {
330
366
  if (localCandidates.length <= lastSentCandidateCount) return;
331
367
  const newOnes = localCandidates.slice(lastSentCandidateCount);
332
368
  lastSentCandidateCount = localCandidates.length;
333
- await apiClient.signal(tunnelId, { candidates: newOnes }).catch((error) => {
369
+ await apiClient.signal(slug, { candidates: newOnes }).catch((error) => {
334
370
  debugLog("failed to publish local ICE candidates", error);
335
371
  });
336
372
  }, LOCAL_CANDIDATE_FLUSH_MS);
@@ -347,6 +383,7 @@ async function startDaemon(config) {
347
383
  if (stopped || currentPeer !== peer) return;
348
384
  if (state === "connected") {
349
385
  connected = true;
386
+ lastConnectedAt = Date.now();
350
387
  flushQueuedAcks();
351
388
  void replayStickyOutboundMessages();
352
389
  return;
@@ -401,9 +438,9 @@ async function startDaemon(config) {
401
438
  }, delayMs);
402
439
  }
403
440
  async function pollSignalingOnce() {
404
- const tunnel = await apiClient.get(tunnelId);
441
+ const session = await apiClient.getLive(slug);
405
442
  if (shouldRecoverForBrowserAnswerChange({
406
- incomingBrowserAnswer: tunnel.browserAnswer,
443
+ incomingBrowserAnswer: session.browserAnswer,
407
444
  lastAppliedBrowserAnswer,
408
445
  remoteDescriptionApplied
409
446
  })) {
@@ -411,13 +448,13 @@ async function startDaemon(config) {
411
448
  scheduleRecovery(0, true);
412
449
  return;
413
450
  }
414
- if (tunnel.browserAnswer && !remoteDescriptionApplied) {
451
+ if (session.browserAnswer && !remoteDescriptionApplied) {
415
452
  if (!peer) return;
416
453
  try {
417
- const answer = JSON.parse(tunnel.browserAnswer);
454
+ const answer = JSON.parse(session.browserAnswer);
418
455
  peer.setRemoteDescription(answer.sdp, answer.type);
419
456
  remoteDescriptionApplied = true;
420
- lastAppliedBrowserAnswer = tunnel.browserAnswer;
457
+ lastAppliedBrowserAnswer = session.browserAnswer;
421
458
  while (pendingRemoteCandidates.length > 0) {
422
459
  const next = pendingRemoteCandidates.shift();
423
460
  if (!next) break;
@@ -431,9 +468,9 @@ async function startDaemon(config) {
431
468
  markError("failed to apply browser answer", error);
432
469
  }
433
470
  }
434
- if (tunnel.browserCandidates.length > lastBrowserCandidateCount) {
435
- const newCandidates = tunnel.browserCandidates.slice(lastBrowserCandidateCount);
436
- lastBrowserCandidateCount = tunnel.browserCandidates.length;
471
+ if (session.browserCandidates.length > lastBrowserCandidateCount) {
472
+ const newCandidates = session.browserCandidates.slice(lastBrowserCandidateCount);
473
+ lastBrowserCandidateCount = session.browserCandidates.length;
437
474
  for (const c of newCandidates) {
438
475
  try {
439
476
  const parsed = JSON.parse(c);
@@ -457,18 +494,20 @@ async function startDaemon(config) {
457
494
  try {
458
495
  await pollSignalingOnce();
459
496
  } catch (error) {
460
- if (error instanceof TunnelApiError && error.status === 429) {
497
+ if (error instanceof PubApiError && error.status === 429) {
461
498
  retryAfterSeconds = error.retryAfterSeconds;
462
499
  }
463
500
  markError("signaling poll failed", error);
464
501
  }
465
- scheduleNextPoll(getSignalPollDelayMs({ remoteDescriptionApplied, retryAfterSeconds }));
502
+ const baseDelay = getSignalPollDelayMs({ remoteDescriptionApplied, retryAfterSeconds });
503
+ const idleSlowdown = !connected && Date.now() - lastConnectedAt >= IDLE_SLOWDOWN_AFTER_MS ? IDLE_SIGNAL_POLL_MS : 0;
504
+ scheduleNextPoll(Math.max(baseDelay, idleSlowdown));
466
505
  }
467
506
  async function runNegotiationCycle() {
468
507
  if (!peer) throw new Error("PeerConnection not initialized");
469
508
  resetNegotiationState();
470
509
  const offer = await generateOffer(peer, OFFER_TIMEOUT_MS);
471
- await apiClient.signal(tunnelId, { offer });
510
+ await apiClient.signal(slug, { offer });
472
511
  startLocalCandidateFlush();
473
512
  }
474
513
  async function recoverPeer() {
@@ -548,15 +587,78 @@ async function startDaemon(config) {
548
587
  if (!fs.existsSync(infoDir)) fs.mkdirSync(infoDir, { recursive: true });
549
588
  fs.writeFileSync(
550
589
  infoPath,
551
- JSON.stringify({ pid: process.pid, tunnelId, socketPath, startedAt: startTime, cliVersion })
590
+ JSON.stringify({ pid: process.pid, slug, socketPath, startedAt: startTime, cliVersion })
552
591
  );
592
+ startHealthCheckTimer();
553
593
  scheduleNextPoll(0);
594
+ let bridgeCheckTimer = null;
595
+ const bridgeRestartTimestamps = [];
596
+ function pruneBridgeRestartTimestamps() {
597
+ const cutoff = Date.now() - BRIDGE_RAPID_RESTART_WINDOW_MS;
598
+ while (bridgeRestartTimestamps.length > 0 && bridgeRestartTimestamps[0] < cutoff) {
599
+ bridgeRestartTimestamps.shift();
600
+ }
601
+ }
602
+ function restartBridge() {
603
+ if (stopped || !config.bridge) return;
604
+ const { bridgeMode, bridgeScript, bridgeInfoPath, bridgeLogPath, bridgeProcessEnv } = config.bridge;
605
+ pruneBridgeRestartTimestamps();
606
+ if (bridgeRestartTimestamps.length >= BRIDGE_MAX_RAPID_RESTARTS) {
607
+ markError(
608
+ `bridge crashed ${BRIDGE_MAX_RAPID_RESTARTS} times within ${BRIDGE_RAPID_RESTART_WINDOW_MS / 1e3}s; giving up`
609
+ );
610
+ return;
611
+ }
612
+ bridgeRestartTimestamps.push(Date.now());
613
+ debugLog("bridge process is dead; restarting");
614
+ try {
615
+ const logFd = fs.openSync(bridgeLogPath, "a");
616
+ const child = fork(bridgeScript, [], {
617
+ detached: true,
618
+ stdio: buildBridgeForkStdio(logFd),
619
+ env: {
620
+ ...bridgeProcessEnv,
621
+ PUBBLUE_BRIDGE_MODE: bridgeMode,
622
+ PUBBLUE_BRIDGE_SLUG: slug,
623
+ PUBBLUE_BRIDGE_SOCKET: socketPath,
624
+ PUBBLUE_BRIDGE_INFO: bridgeInfoPath
625
+ }
626
+ });
627
+ fs.closeSync(logFd);
628
+ if (child.connected) child.disconnect();
629
+ child.unref();
630
+ } catch (error) {
631
+ markError("failed to restart bridge process", error);
632
+ }
633
+ }
634
+ function checkBridgeLiveness() {
635
+ if (stopped || !config.bridge) return;
636
+ if (!isBridgeRunning(slug)) restartBridge();
637
+ }
638
+ function startBridgeCheckTimer() {
639
+ if (!config.bridge) return;
640
+ bridgeCheckTimer = setInterval(checkBridgeLiveness, BRIDGE_CHECK_INTERVAL_MS);
641
+ }
642
+ function clearBridgeCheckTimer() {
643
+ if (bridgeCheckTimer) {
644
+ clearInterval(bridgeCheckTimer);
645
+ bridgeCheckTimer = null;
646
+ }
647
+ }
648
+ async function stopBridgeProcess() {
649
+ const error = await stopBridge(slug);
650
+ if (error) debugLog(error);
651
+ }
652
+ startBridgeCheckTimer();
554
653
  async function cleanup() {
555
654
  if (stopped) return;
556
655
  stopped = true;
557
656
  clearPollingTimer();
558
657
  clearLocalCandidateTimers();
559
658
  clearRecoveryTimer();
659
+ clearHealthCheckTimer();
660
+ clearBridgeCheckTimer();
661
+ await stopBridgeProcess();
560
662
  closeCurrentPeer();
561
663
  ipcServer.close();
562
664
  try {
@@ -670,8 +772,5 @@ async function startDaemon(config) {
670
772
  }
671
773
 
672
774
  export {
673
- getTunnelWriteReadinessError,
674
- shouldRecoverForBrowserAnswerChange,
675
- getSignalPollDelayMs,
676
775
  startDaemon
677
776
  };
@@ -48,6 +48,58 @@ function shouldAcknowledgeMessage(channel, msg) {
48
48
  var MAX_TUNNEL_EXPIRY_MS = 7 * 24 * 60 * 60 * 1e3;
49
49
  var DEFAULT_TUNNEL_EXPIRY_MS = 24 * 60 * 60 * 1e3;
50
50
 
51
+ // src/lib/tunnel-ipc.ts
52
+ import * as net from "net";
53
+ function getSocketPath(slug) {
54
+ return `/tmp/pubblue-${slug}.sock`;
55
+ }
56
+ async function ipcCall(socketPath, request) {
57
+ return new Promise((resolve, reject) => {
58
+ let settled = false;
59
+ let timeoutId = null;
60
+ const finish = (fn) => {
61
+ if (settled) return;
62
+ settled = true;
63
+ if (timeoutId) clearTimeout(timeoutId);
64
+ fn();
65
+ };
66
+ const client = net.createConnection(socketPath, () => {
67
+ client.write(`${JSON.stringify(request)}
68
+ `);
69
+ });
70
+ let data = "";
71
+ client.on("data", (chunk) => {
72
+ data += chunk.toString();
73
+ const newlineIdx = data.indexOf("\n");
74
+ if (newlineIdx !== -1) {
75
+ const line = data.slice(0, newlineIdx);
76
+ client.end();
77
+ try {
78
+ finish(() => resolve(JSON.parse(line)));
79
+ } catch {
80
+ finish(() => reject(new Error("Invalid response from daemon")));
81
+ }
82
+ }
83
+ });
84
+ client.on("error", (err) => {
85
+ if (err.code === "ECONNREFUSED" || err.code === "ENOENT") {
86
+ finish(() => reject(new Error("Daemon not running. Is the tunnel still active?")));
87
+ } else {
88
+ finish(() => reject(err));
89
+ }
90
+ });
91
+ client.on("end", () => {
92
+ if (!data.includes("\n")) {
93
+ finish(() => reject(new Error("Daemon closed connection unexpectedly")));
94
+ }
95
+ });
96
+ timeoutId = setTimeout(() => {
97
+ client.destroy();
98
+ finish(() => reject(new Error("Daemon request timed out")));
99
+ }, 1e4);
100
+ });
101
+ }
102
+
51
103
  export {
52
104
  CONTROL_CHANNEL,
53
105
  CHANNELS,
@@ -56,5 +108,7 @@ export {
56
108
  decodeMessage,
57
109
  makeAckMessage,
58
110
  parseAckMessage,
59
- shouldAcknowledgeMessage
111
+ shouldAcknowledgeMessage,
112
+ getSocketPath,
113
+ ipcCall
60
114
  };