pubblue 0.4.11 → 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,3 +1,11 @@
1
+ import {
2
+ PubApiError,
3
+ buildBridgeForkStdio,
4
+ isBridgeRunning,
5
+ latestCliVersionPath,
6
+ readLatestCliVersion,
7
+ stopBridge
8
+ } from "./chunk-YI45G6AG.js";
1
9
  import {
2
10
  CHANNELS,
3
11
  CONTROL_CHANNEL,
@@ -6,9 +14,10 @@ import {
6
14
  makeAckMessage,
7
15
  parseAckMessage,
8
16
  shouldAcknowledgeMessage
9
- } from "./chunk-4YTJ2WKF.js";
17
+ } from "./chunk-PFZT7M3E.js";
10
18
 
11
19
  // src/lib/tunnel-daemon.ts
20
+ import { fork } from "child_process";
12
21
  import * as fs from "fs";
13
22
  import * as net from "net";
14
23
  import * as path from "path";
@@ -54,12 +63,16 @@ function generateOffer(peer, timeoutMs) {
54
63
  }
55
64
 
56
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;
57
69
  var OFFER_TIMEOUT_MS = 1e4;
58
- var SIGNAL_POLL_WAITING_MS = 500;
59
- var SIGNAL_POLL_CONNECTED_MS = 2e3;
70
+ var SIGNAL_POLL_WAITING_MS = 5e3;
71
+ var SIGNAL_POLL_CONNECTED_MS = 15e3;
72
+ var LOCAL_CANDIDATE_FLUSH_MS = 2e3;
60
73
  var RECOVERY_DELAY_MS = 1e3;
61
74
  var WRITE_ACK_TIMEOUT_MS = 5e3;
62
- 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.";
63
76
  function getTunnelWriteReadinessError(isConnected) {
64
77
  return isConnected ? null : NOT_CONNECTED_WRITE_ERROR;
65
78
  }
@@ -69,10 +82,21 @@ function shouldRecoverForBrowserAnswerChange(params) {
69
82
  if (!incomingBrowserAnswer) return false;
70
83
  return incomingBrowserAnswer !== lastAppliedBrowserAnswer;
71
84
  }
85
+ function getSignalPollDelayMs(params) {
86
+ const baseDelay = params.remoteDescriptionApplied ? SIGNAL_POLL_CONNECTED_MS : SIGNAL_POLL_WAITING_MS;
87
+ if (params.retryAfterSeconds === void 0) return baseDelay;
88
+ if (!Number.isFinite(params.retryAfterSeconds) || params.retryAfterSeconds <= 0) {
89
+ return baseDelay;
90
+ }
91
+ return Math.max(baseDelay, Math.ceil(params.retryAfterSeconds * 1e3));
92
+ }
72
93
 
73
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;
74
98
  async function startDaemon(config) {
75
- const { tunnelId, apiClient, socketPath, infoPath, cliVersion } = config;
99
+ const { slug, apiClient, socketPath, infoPath, cliVersion } = config;
76
100
  const ndc = await import("node-datachannel");
77
101
  const buffer = { messages: [] };
78
102
  const startTime = Date.now();
@@ -95,12 +119,15 @@ async function startDaemon(config) {
95
119
  let localCandidateInterval = null;
96
120
  let localCandidateStopTimer = null;
97
121
  let recoveryTimer = null;
122
+ let healthCheckTimer = null;
98
123
  let lastError = null;
99
124
  const debugEnabled = process.env.PUBBLUE_TUNNEL_DEBUG === "1";
125
+ let lastConnectedAt = startTime;
126
+ const versionFilePath = latestCliVersionPath();
100
127
  function debugLog(message, error) {
101
128
  if (!debugEnabled) return;
102
129
  const detail = error === void 0 ? "" : ` | ${error instanceof Error ? `${error.name}: ${error.message}` : typeof error === "string" ? error : JSON.stringify(error)}`;
103
- console.error(`[pubblue-daemon:${tunnelId}] ${message}${detail}`);
130
+ console.error(`[pubblue-daemon:${slug}] ${message}${detail}`);
104
131
  }
105
132
  function markError(message, error) {
106
133
  const detail = error === void 0 ? message : `${message}: ${error instanceof Error ? error.message : typeof error === "string" ? error : JSON.stringify(error)}`;
@@ -129,6 +156,27 @@ async function startDaemon(config) {
129
156
  recoveryTimer = null;
130
157
  }
131
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
+ }
132
180
  function setupChannel(name, dc) {
133
181
  channels.set(name, dc);
134
182
  dc.onOpen(() => {
@@ -318,10 +366,10 @@ async function startDaemon(config) {
318
366
  if (localCandidates.length <= lastSentCandidateCount) return;
319
367
  const newOnes = localCandidates.slice(lastSentCandidateCount);
320
368
  lastSentCandidateCount = localCandidates.length;
321
- await apiClient.signal(tunnelId, { candidates: newOnes }).catch((error) => {
369
+ await apiClient.signal(slug, { candidates: newOnes }).catch((error) => {
322
370
  debugLog("failed to publish local ICE candidates", error);
323
371
  });
324
- }, 500);
372
+ }, LOCAL_CANDIDATE_FLUSH_MS);
325
373
  localCandidateStopTimer = setTimeout(() => {
326
374
  clearLocalCandidateTimers();
327
375
  }, 3e4);
@@ -335,6 +383,7 @@ async function startDaemon(config) {
335
383
  if (stopped || currentPeer !== peer) return;
336
384
  if (state === "connected") {
337
385
  connected = true;
386
+ lastConnectedAt = Date.now();
338
387
  flushQueuedAcks();
339
388
  void replayStickyOutboundMessages();
340
389
  return;
@@ -389,9 +438,9 @@ async function startDaemon(config) {
389
438
  }, delayMs);
390
439
  }
391
440
  async function pollSignalingOnce() {
392
- const tunnel = await apiClient.get(tunnelId);
441
+ const session = await apiClient.getLive(slug);
393
442
  if (shouldRecoverForBrowserAnswerChange({
394
- incomingBrowserAnswer: tunnel.browserAnswer,
443
+ incomingBrowserAnswer: session.browserAnswer,
395
444
  lastAppliedBrowserAnswer,
396
445
  remoteDescriptionApplied
397
446
  })) {
@@ -399,13 +448,13 @@ async function startDaemon(config) {
399
448
  scheduleRecovery(0, true);
400
449
  return;
401
450
  }
402
- if (tunnel.browserAnswer && !remoteDescriptionApplied) {
451
+ if (session.browserAnswer && !remoteDescriptionApplied) {
403
452
  if (!peer) return;
404
453
  try {
405
- const answer = JSON.parse(tunnel.browserAnswer);
454
+ const answer = JSON.parse(session.browserAnswer);
406
455
  peer.setRemoteDescription(answer.sdp, answer.type);
407
456
  remoteDescriptionApplied = true;
408
- lastAppliedBrowserAnswer = tunnel.browserAnswer;
457
+ lastAppliedBrowserAnswer = session.browserAnswer;
409
458
  while (pendingRemoteCandidates.length > 0) {
410
459
  const next = pendingRemoteCandidates.shift();
411
460
  if (!next) break;
@@ -419,9 +468,9 @@ async function startDaemon(config) {
419
468
  markError("failed to apply browser answer", error);
420
469
  }
421
470
  }
422
- if (tunnel.browserCandidates.length > lastBrowserCandidateCount) {
423
- const newCandidates = tunnel.browserCandidates.slice(lastBrowserCandidateCount);
424
- lastBrowserCandidateCount = tunnel.browserCandidates.length;
471
+ if (session.browserCandidates.length > lastBrowserCandidateCount) {
472
+ const newCandidates = session.browserCandidates.slice(lastBrowserCandidateCount);
473
+ lastBrowserCandidateCount = session.browserCandidates.length;
425
474
  for (const c of newCandidates) {
426
475
  try {
427
476
  const parsed = JSON.parse(c);
@@ -441,18 +490,24 @@ async function startDaemon(config) {
441
490
  }
442
491
  async function runPollingLoop() {
443
492
  if (stopped) return;
493
+ let retryAfterSeconds;
444
494
  try {
445
495
  await pollSignalingOnce();
446
496
  } catch (error) {
497
+ if (error instanceof PubApiError && error.status === 429) {
498
+ retryAfterSeconds = error.retryAfterSeconds;
499
+ }
447
500
  markError("signaling poll failed", error);
448
501
  }
449
- scheduleNextPoll(remoteDescriptionApplied ? SIGNAL_POLL_CONNECTED_MS : SIGNAL_POLL_WAITING_MS);
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));
450
505
  }
451
506
  async function runNegotiationCycle() {
452
507
  if (!peer) throw new Error("PeerConnection not initialized");
453
508
  resetNegotiationState();
454
509
  const offer = await generateOffer(peer, OFFER_TIMEOUT_MS);
455
- await apiClient.signal(tunnelId, { offer });
510
+ await apiClient.signal(slug, { offer });
456
511
  startLocalCandidateFlush();
457
512
  }
458
513
  async function recoverPeer() {
@@ -532,15 +587,78 @@ async function startDaemon(config) {
532
587
  if (!fs.existsSync(infoDir)) fs.mkdirSync(infoDir, { recursive: true });
533
588
  fs.writeFileSync(
534
589
  infoPath,
535
- JSON.stringify({ pid: process.pid, tunnelId, socketPath, startedAt: startTime, cliVersion })
590
+ JSON.stringify({ pid: process.pid, slug, socketPath, startedAt: startTime, cliVersion })
536
591
  );
592
+ startHealthCheckTimer();
537
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();
538
653
  async function cleanup() {
539
654
  if (stopped) return;
540
655
  stopped = true;
541
656
  clearPollingTimer();
542
657
  clearLocalCandidateTimers();
543
658
  clearRecoveryTimer();
659
+ clearHealthCheckTimer();
660
+ clearBridgeCheckTimer();
661
+ await stopBridgeProcess();
544
662
  closeCurrentPeer();
545
663
  ipcServer.close();
546
664
  try {
@@ -654,7 +772,5 @@ async function startDaemon(config) {
654
772
  }
655
773
 
656
774
  export {
657
- getTunnelWriteReadinessError,
658
- shouldRecoverForBrowserAnswerChange,
659
775
  startDaemon
660
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
  };