pubblue 0.4.3 → 0.4.5

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.
@@ -2,21 +2,40 @@ import {
2
2
  CHANNELS,
3
3
  CONTROL_CHANNEL,
4
4
  decodeMessage,
5
- encodeMessage
6
- } from "./chunk-56IKFMJ2.js";
5
+ encodeMessage,
6
+ makeAckMessage,
7
+ parseAckMessage,
8
+ shouldAcknowledgeMessage
9
+ } from "./chunk-MW35LBNH.js";
7
10
 
8
11
  // src/lib/tunnel-daemon.ts
9
12
  import * as fs from "fs";
10
13
  import * as net from "net";
11
14
  import * as path from "path";
15
+
16
+ // src/lib/ack-routing.ts
17
+ function resolveAckChannel(input) {
18
+ if (input.messageChannelOpen) return input.messageChannel;
19
+ if (input.controlChannelOpen) return CONTROL_CHANNEL;
20
+ return null;
21
+ }
22
+
23
+ // src/lib/tunnel-daemon.ts
12
24
  var OFFER_TIMEOUT_MS = 1e4;
13
25
  var SIGNAL_POLL_WAITING_MS = 500;
14
26
  var SIGNAL_POLL_CONNECTED_MS = 2e3;
15
27
  var RECOVERY_DELAY_MS = 1e3;
28
+ var WRITE_ACK_TIMEOUT_MS = 5e3;
16
29
  var NOT_CONNECTED_WRITE_ERROR = "No browser connected. Ask the user to open the tunnel URL first, then retry.";
17
30
  function getTunnelWriteReadinessError(isConnected) {
18
31
  return isConnected ? null : NOT_CONNECTED_WRITE_ERROR;
19
32
  }
33
+ function shouldRecoverForBrowserAnswerChange(params) {
34
+ const { incomingBrowserAnswer, lastAppliedBrowserAnswer, remoteDescriptionApplied } = params;
35
+ if (!remoteDescriptionApplied) return false;
36
+ if (!incomingBrowserAnswer) return false;
37
+ return incomingBrowserAnswer !== lastAppliedBrowserAnswer;
38
+ }
20
39
  async function startDaemon(config) {
21
40
  const { tunnelId, apiClient, socketPath, infoPath } = config;
22
41
  const ndc = await import("node-datachannel");
@@ -26,10 +45,14 @@ async function startDaemon(config) {
26
45
  let connected = false;
27
46
  let recovering = false;
28
47
  let remoteDescriptionApplied = false;
48
+ let lastAppliedBrowserAnswer = null;
29
49
  let lastBrowserCandidateCount = 0;
30
50
  let lastSentCandidateCount = 0;
31
51
  const pendingRemoteCandidates = [];
32
52
  const localCandidates = [];
53
+ const stickyOutboundByChannel = /* @__PURE__ */ new Map();
54
+ const pendingOutboundAcks = /* @__PURE__ */ new Map();
55
+ const pendingDeliveryAcks = /* @__PURE__ */ new Map();
33
56
  let peer = null;
34
57
  let channels = /* @__PURE__ */ new Map();
35
58
  let pendingInboundBinaryMeta = /* @__PURE__ */ new Map();
@@ -37,6 +60,18 @@ async function startDaemon(config) {
37
60
  let localCandidateInterval = null;
38
61
  let localCandidateStopTimer = null;
39
62
  let recoveryTimer = null;
63
+ let lastError = null;
64
+ const debugEnabled = process.env.PUBBLUE_TUNNEL_DEBUG === "1";
65
+ function debugLog(message, error) {
66
+ if (!debugEnabled) return;
67
+ const detail = error === void 0 ? "" : ` | ${error instanceof Error ? `${error.name}: ${error.message}` : typeof error === "string" ? error : JSON.stringify(error)}`;
68
+ console.error(`[pubblue-daemon:${tunnelId}] ${message}${detail}`);
69
+ }
70
+ function markError(message, error) {
71
+ const detail = error === void 0 ? message : `${message}: ${error instanceof Error ? error.message : typeof error === "string" ? error : JSON.stringify(error)}`;
72
+ lastError = detail;
73
+ debugLog(message, error);
74
+ }
40
75
  function clearPollingTimer() {
41
76
  if (pollingTimer) {
42
77
  clearTimeout(pollingTimer);
@@ -61,14 +96,25 @@ async function startDaemon(config) {
61
96
  }
62
97
  function setupChannel(name, dc) {
63
98
  channels.set(name, dc);
99
+ dc.onOpen(() => {
100
+ if (name === CONTROL_CHANNEL) flushQueuedAcks();
101
+ });
64
102
  dc.onMessage((data) => {
65
103
  if (typeof data === "string") {
66
104
  const msg = decodeMessage(data);
67
105
  if (!msg) return;
106
+ const ack = parseAckMessage(msg);
107
+ if (ack) {
108
+ settlePendingAck(ack.messageId, true);
109
+ return;
110
+ }
68
111
  if (msg.type === "binary" && !msg.data) {
69
112
  pendingInboundBinaryMeta.set(name, msg);
70
113
  return;
71
114
  }
115
+ if (shouldAcknowledgeMessage(name, msg)) {
116
+ queueAck(msg.id, name);
117
+ }
72
118
  buffer.messages.push({ channel: name, msg, timestamp: Date.now() });
73
119
  return;
74
120
  }
@@ -85,9 +131,75 @@ async function startDaemon(config) {
85
131
  data: data.toString("base64"),
86
132
  meta: { size: data.length }
87
133
  };
134
+ if (shouldAcknowledgeMessage(name, binMsg)) {
135
+ queueAck(binMsg.id, name);
136
+ }
88
137
  buffer.messages.push({ channel: name, msg: binMsg, timestamp: Date.now() });
89
138
  });
90
139
  }
140
+ function getAckKey(messageId, channel) {
141
+ return `${channel}:${messageId}`;
142
+ }
143
+ function queueAck(messageId, channel) {
144
+ pendingOutboundAcks.set(getAckKey(messageId, channel), { messageId, channel });
145
+ flushQueuedAcks();
146
+ }
147
+ function flushQueuedAcks() {
148
+ const controlDc = channels.get(CONTROL_CHANNEL);
149
+ for (const [ackKey, ack] of pendingOutboundAcks) {
150
+ const messageDc = channels.get(ack.channel);
151
+ const targetChannel = resolveAckChannel({
152
+ controlChannelOpen: Boolean(controlDc?.isOpen()),
153
+ messageChannelOpen: Boolean(messageDc?.isOpen()),
154
+ messageChannel: ack.channel
155
+ });
156
+ if (!targetChannel) continue;
157
+ const encodedAck = encodeMessage(makeAckMessage(ack.messageId, ack.channel));
158
+ const primaryDc = targetChannel === CONTROL_CHANNEL ? controlDc : messageDc;
159
+ try {
160
+ if (primaryDc?.isOpen()) {
161
+ primaryDc.sendMessage(encodedAck);
162
+ pendingOutboundAcks.delete(ackKey);
163
+ continue;
164
+ }
165
+ } catch (error) {
166
+ markError("failed to flush queued ack on primary channel", error);
167
+ }
168
+ const fallbackChannel = targetChannel === ack.channel ? CONTROL_CHANNEL : ack.channel;
169
+ const fallbackDc = fallbackChannel === CONTROL_CHANNEL ? controlDc : messageDc;
170
+ try {
171
+ if (fallbackDc?.isOpen()) {
172
+ fallbackDc.sendMessage(encodedAck);
173
+ pendingOutboundAcks.delete(ackKey);
174
+ }
175
+ } catch (error) {
176
+ markError("failed to flush queued ack on fallback channel", error);
177
+ }
178
+ }
179
+ }
180
+ function waitForDeliveryAck(messageId, timeoutMs) {
181
+ return new Promise((resolve) => {
182
+ const timeout = setTimeout(() => {
183
+ pendingDeliveryAcks.delete(messageId);
184
+ resolve(false);
185
+ }, timeoutMs);
186
+ pendingDeliveryAcks.set(messageId, { resolve, timeout });
187
+ });
188
+ }
189
+ function settlePendingAck(messageId, received) {
190
+ const pending = pendingDeliveryAcks.get(messageId);
191
+ if (!pending) return;
192
+ clearTimeout(pending.timeout);
193
+ pendingDeliveryAcks.delete(messageId);
194
+ pending.resolve(received);
195
+ }
196
+ function failPendingAcks() {
197
+ for (const [messageId, pending] of pendingDeliveryAcks) {
198
+ clearTimeout(pending.timeout);
199
+ pending.resolve(false);
200
+ pendingDeliveryAcks.delete(messageId);
201
+ }
202
+ }
91
203
  function openDataChannel(name) {
92
204
  if (!peer) throw new Error("PeerConnection not initialized");
93
205
  const existing = channels.get(name);
@@ -113,9 +225,52 @@ async function startDaemon(config) {
113
225
  });
114
226
  });
115
227
  }
228
+ function maybePersistStickyOutbound(channel, msg, binaryPayload) {
229
+ if (channel !== CHANNELS.CANVAS) return;
230
+ if (msg.type === "event" && msg.data === "hide") {
231
+ stickyOutboundByChannel.delete(channel);
232
+ return;
233
+ }
234
+ if (msg.type !== "html") return;
235
+ stickyOutboundByChannel.set(channel, {
236
+ msg: {
237
+ ...msg,
238
+ meta: msg.meta ? { ...msg.meta } : void 0
239
+ },
240
+ binaryPayload
241
+ });
242
+ }
243
+ async function replayStickyOutboundMessages() {
244
+ if (!connected || recovering || stopped) return;
245
+ for (const [channel, sticky] of stickyOutboundByChannel) {
246
+ try {
247
+ let targetDc = channels.get(channel);
248
+ if (!targetDc) targetDc = openDataChannel(channel);
249
+ await waitForChannelOpen(targetDc, 3e3);
250
+ if (sticky.msg.type === "binary" && sticky.binaryPayload) {
251
+ targetDc.sendMessage(
252
+ encodeMessage({
253
+ ...sticky.msg,
254
+ meta: {
255
+ ...sticky.msg.meta || {},
256
+ size: sticky.binaryPayload.length
257
+ }
258
+ })
259
+ );
260
+ targetDc.sendMessageBinary(sticky.binaryPayload);
261
+ } else {
262
+ targetDc.sendMessage(encodeMessage(sticky.msg));
263
+ }
264
+ } catch (error) {
265
+ debugLog(`sticky outbound replay failed for channel ${channel}`, error);
266
+ }
267
+ }
268
+ }
116
269
  function resetNegotiationState() {
117
270
  connected = false;
271
+ failPendingAcks();
118
272
  remoteDescriptionApplied = false;
273
+ lastAppliedBrowserAnswer = null;
119
274
  lastBrowserCandidateCount = 0;
120
275
  lastSentCandidateCount = 0;
121
276
  pendingRemoteCandidates.length = 0;
@@ -128,7 +283,8 @@ async function startDaemon(config) {
128
283
  if (localCandidates.length <= lastSentCandidateCount) return;
129
284
  const newOnes = localCandidates.slice(lastSentCandidateCount);
130
285
  lastSentCandidateCount = localCandidates.length;
131
- await apiClient.signal(tunnelId, { candidates: newOnes }).catch(() => {
286
+ await apiClient.signal(tunnelId, { candidates: newOnes }).catch((error) => {
287
+ debugLog("failed to publish local ICE candidates", error);
132
288
  });
133
289
  }, 500);
134
290
  localCandidateStopTimer = setTimeout(() => {
@@ -144,9 +300,11 @@ async function startDaemon(config) {
144
300
  if (stopped || currentPeer !== peer) return;
145
301
  if (state === "connected") {
146
302
  connected = true;
303
+ flushQueuedAcks();
304
+ void replayStickyOutboundMessages();
147
305
  return;
148
306
  }
149
- if (state === "disconnected" || state === "failed") {
307
+ if (state === "disconnected" || state === "failed" || state === "closed") {
150
308
  connected = false;
151
309
  scheduleRecovery();
152
310
  }
@@ -169,10 +327,12 @@ async function startDaemon(config) {
169
327
  openDataChannel(CHANNELS.CANVAS);
170
328
  }
171
329
  function closeCurrentPeer() {
330
+ failPendingAcks();
172
331
  for (const dc of channels.values()) {
173
332
  try {
174
333
  dc.close();
175
- } catch {
334
+ } catch (error) {
335
+ debugLog("failed to close data channel cleanly", error);
176
336
  }
177
337
  }
178
338
  channels.clear();
@@ -180,7 +340,8 @@ async function startDaemon(config) {
180
340
  if (peer) {
181
341
  try {
182
342
  peer.close();
183
- } catch {
343
+ } catch (error) {
344
+ debugLog("failed to close peer connection cleanly", error);
184
345
  }
185
346
  peer = null;
186
347
  }
@@ -194,21 +355,33 @@ async function startDaemon(config) {
194
355
  }
195
356
  async function pollSignalingOnce() {
196
357
  const tunnel = await apiClient.get(tunnelId);
358
+ if (shouldRecoverForBrowserAnswerChange({
359
+ incomingBrowserAnswer: tunnel.browserAnswer,
360
+ lastAppliedBrowserAnswer,
361
+ remoteDescriptionApplied
362
+ })) {
363
+ connected = false;
364
+ scheduleRecovery(0, true);
365
+ return;
366
+ }
197
367
  if (tunnel.browserAnswer && !remoteDescriptionApplied) {
198
368
  if (!peer) return;
199
369
  try {
200
370
  const answer = JSON.parse(tunnel.browserAnswer);
201
371
  peer.setRemoteDescription(answer.sdp, answer.type);
202
372
  remoteDescriptionApplied = true;
373
+ lastAppliedBrowserAnswer = tunnel.browserAnswer;
203
374
  while (pendingRemoteCandidates.length > 0) {
204
375
  const next = pendingRemoteCandidates.shift();
205
376
  if (!next) break;
206
377
  try {
207
378
  peer.addRemoteCandidate(next.candidate, next.sdpMid);
208
- } catch {
379
+ } catch (error) {
380
+ debugLog("failed to apply queued remote ICE candidate", error);
209
381
  }
210
382
  }
211
- } catch {
383
+ } catch (error) {
384
+ markError("failed to apply browser answer", error);
212
385
  }
213
386
  }
214
387
  if (tunnel.browserCandidates.length > lastBrowserCandidateCount) {
@@ -225,7 +398,8 @@ async function startDaemon(config) {
225
398
  }
226
399
  if (!peer) continue;
227
400
  peer.addRemoteCandidate(parsed.candidate, sdpMid);
228
- } catch {
401
+ } catch (error) {
402
+ debugLog("failed to parse/apply browser ICE candidate", error);
229
403
  }
230
404
  }
231
405
  }
@@ -234,7 +408,8 @@ async function startDaemon(config) {
234
408
  if (stopped) return;
235
409
  try {
236
410
  await pollSignalingOnce();
237
- } catch {
411
+ } catch (error) {
412
+ markError("signaling poll failed", error);
238
413
  }
239
414
  scheduleNextPoll(remoteDescriptionApplied ? SIGNAL_POLL_CONNECTED_MS : SIGNAL_POLL_WAITING_MS);
240
415
  }
@@ -256,13 +431,14 @@ async function startDaemon(config) {
256
431
  recovering = false;
257
432
  }
258
433
  }
259
- function scheduleRecovery(delayMs = RECOVERY_DELAY_MS) {
434
+ function scheduleRecovery(delayMs = RECOVERY_DELAY_MS, force = false) {
260
435
  if (stopped || recovering || recoveryTimer) return;
261
436
  recoveryTimer = setTimeout(() => {
262
437
  recoveryTimer = null;
263
- if (stopped || connected) return;
264
- void recoverPeer().catch(() => {
265
- if (!stopped) scheduleRecovery(delayMs);
438
+ if (stopped || !force && connected) return;
439
+ void recoverPeer().catch((error) => {
440
+ markError("peer recovery failed", error);
441
+ if (!stopped) scheduleRecovery(delayMs, force);
266
442
  });
267
443
  }, delayMs);
268
444
  }
@@ -279,7 +455,8 @@ async function startDaemon(config) {
279
455
  if (stale) {
280
456
  try {
281
457
  fs.unlinkSync(socketPath);
282
- } catch {
458
+ } catch (error) {
459
+ debugLog("failed to remove stale daemon socket", error);
283
460
  }
284
461
  } else {
285
462
  throw new Error(`Daemon already running (socket: ${socketPath})`);
@@ -319,6 +496,7 @@ async function startDaemon(config) {
319
496
  await runNegotiationCycle();
320
497
  } catch (error) {
321
498
  const message = error instanceof Error ? error.message : String(error);
499
+ markError("initial negotiation failed", error);
322
500
  await cleanup();
323
501
  throw new Error(`Failed to generate WebRTC offer: ${message}`);
324
502
  }
@@ -332,13 +510,16 @@ async function startDaemon(config) {
332
510
  ipcServer.close();
333
511
  try {
334
512
  fs.unlinkSync(socketPath);
335
- } catch {
513
+ } catch (error) {
514
+ debugLog("failed to remove daemon socket during cleanup", error);
336
515
  }
337
516
  try {
338
517
  fs.unlinkSync(infoPath);
339
- } catch {
518
+ } catch (error) {
519
+ debugLog("failed to remove daemon info file during cleanup", error);
340
520
  }
341
- await apiClient.close(tunnelId).catch(() => {
521
+ await apiClient.close(tunnelId).catch((error) => {
522
+ markError("failed to close tunnel on API during cleanup", error);
342
523
  });
343
524
  }
344
525
  async function shutdown() {
@@ -359,30 +540,50 @@ async function startDaemon(config) {
359
540
  if (readinessError) return { ok: false, error: readinessError };
360
541
  const msg = req.params.msg;
361
542
  const binaryBase64 = typeof req.params.binaryBase64 === "string" ? req.params.binaryBase64 : void 0;
543
+ const binaryPayload = msg.type === "binary" && binaryBase64 ? Buffer.from(binaryBase64, "base64") : void 0;
362
544
  let targetDc = channels.get(channel);
363
545
  if (!targetDc) targetDc = openDataChannel(channel);
364
546
  try {
365
547
  await waitForChannelOpen(targetDc);
366
548
  } catch (error) {
367
549
  const message = error instanceof Error ? error.message : String(error);
550
+ markError(`channel "${channel}" failed to open`, error);
368
551
  return { ok: false, error: `Channel "${channel}" not open: ${message}` };
369
552
  }
370
- if (msg.type === "binary" && binaryBase64) {
371
- const payload = Buffer.from(binaryBase64, "base64");
372
- targetDc.sendMessage(
373
- encodeMessage({
374
- ...msg,
375
- meta: {
376
- ...msg.meta || {},
377
- size: payload.length
378
- }
379
- })
380
- );
381
- targetDc.sendMessageBinary(payload);
382
- } else {
383
- targetDc.sendMessage(encodeMessage(msg));
553
+ const waitForAck = shouldAcknowledgeMessage(channel, msg) ? waitForDeliveryAck(msg.id, WRITE_ACK_TIMEOUT_MS) : null;
554
+ try {
555
+ if (msg.type === "binary" && binaryPayload) {
556
+ targetDc.sendMessage(
557
+ encodeMessage({
558
+ ...msg,
559
+ meta: {
560
+ ...msg.meta || {},
561
+ size: binaryPayload.length
562
+ }
563
+ })
564
+ );
565
+ targetDc.sendMessageBinary(binaryPayload);
566
+ } else {
567
+ targetDc.sendMessage(encodeMessage(msg));
568
+ }
569
+ } catch (error) {
570
+ if (waitForAck) settlePendingAck(msg.id, false);
571
+ const message = error instanceof Error ? error.message : String(error);
572
+ markError(`failed to send message on channel "${channel}"`, error);
573
+ return { ok: false, error: `Failed to send on channel "${channel}": ${message}` };
384
574
  }
385
- return { ok: true };
575
+ if (waitForAck) {
576
+ const acked = await waitForAck;
577
+ if (!acked) {
578
+ markError(`delivery ack timeout for message ${msg.id}`);
579
+ return {
580
+ ok: false,
581
+ error: `Delivery not confirmed for message ${msg.id} within ${WRITE_ACK_TIMEOUT_MS}ms.`
582
+ };
583
+ }
584
+ }
585
+ maybePersistStickyOutbound(channel, msg, binaryPayload);
586
+ return { ok: true, delivered: true };
386
587
  }
387
588
  case "read": {
388
589
  const channel = req.params.channel;
@@ -406,7 +607,8 @@ async function startDaemon(config) {
406
607
  connected,
407
608
  uptime: Math.floor((Date.now() - startTime) / 1e3),
408
609
  channels: [...channels.keys()],
409
- bufferedMessages: buffer.messages.length
610
+ bufferedMessages: buffer.messages.length,
611
+ lastError
410
612
  };
411
613
  }
412
614
  case "close": {
@@ -452,5 +654,6 @@ function generateOffer(peer, timeoutMs) {
452
654
 
453
655
  export {
454
656
  getTunnelWriteReadinessError,
657
+ shouldRecoverForBrowserAnswerChange,
455
658
  startDaemon
456
659
  };
@@ -0,0 +1,60 @@
1
+ // src/lib/bridge-protocol.ts
2
+ var CONTROL_CHANNEL = "_control";
3
+ var CHANNELS = {
4
+ CHAT: "chat",
5
+ CANVAS: "canvas",
6
+ AUDIO: "audio",
7
+ MEDIA: "media",
8
+ FILE: "file"
9
+ };
10
+ var idCounter = 0;
11
+ function generateMessageId() {
12
+ const ts = Date.now().toString(36);
13
+ const seq = (idCounter++).toString(36);
14
+ const rand = Math.random().toString(36).slice(2, 6);
15
+ return `${ts}-${seq}-${rand}`;
16
+ }
17
+ function encodeMessage(msg) {
18
+ return JSON.stringify(msg);
19
+ }
20
+ function decodeMessage(raw) {
21
+ try {
22
+ const parsed = JSON.parse(raw);
23
+ if (parsed && typeof parsed.id === "string" && typeof parsed.type === "string") {
24
+ return parsed;
25
+ }
26
+ return null;
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+ function makeEventMessage(event, meta) {
32
+ return { id: generateMessageId(), type: "event", data: event, meta };
33
+ }
34
+ function makeAckMessage(messageId, channel) {
35
+ return makeEventMessage("ack", { messageId, channel, receivedAt: Date.now() });
36
+ }
37
+ function parseAckMessage(msg) {
38
+ if (msg.type !== "event" || msg.data !== "ack" || !msg.meta) return null;
39
+ const messageId = typeof msg.meta.messageId === "string" ? msg.meta.messageId : null;
40
+ const channel = typeof msg.meta.channel === "string" ? msg.meta.channel : null;
41
+ if (!messageId || !channel) return null;
42
+ const receivedAt = typeof msg.meta.receivedAt === "number" ? msg.meta.receivedAt : void 0;
43
+ return { messageId, channel, receivedAt };
44
+ }
45
+ function shouldAcknowledgeMessage(channel, msg) {
46
+ return channel !== CONTROL_CHANNEL && parseAckMessage(msg) === null;
47
+ }
48
+ var MAX_TUNNEL_EXPIRY_MS = 7 * 24 * 60 * 60 * 1e3;
49
+ var DEFAULT_TUNNEL_EXPIRY_MS = 24 * 60 * 60 * 1e3;
50
+
51
+ export {
52
+ CONTROL_CHANNEL,
53
+ CHANNELS,
54
+ generateMessageId,
55
+ encodeMessage,
56
+ decodeMessage,
57
+ makeAckMessage,
58
+ parseAckMessage,
59
+ shouldAcknowledgeMessage
60
+ };
package/dist/index.js CHANGED
@@ -3,8 +3,9 @@ import {
3
3
  TunnelApiClient
4
4
  } from "./chunk-BV423NLA.js";
5
5
  import {
6
+ CHANNELS,
6
7
  generateMessageId
7
- } from "./chunk-56IKFMJ2.js";
8
+ } from "./chunk-MW35LBNH.js";
8
9
 
9
10
  // src/index.ts
10
11
  import * as fs3 from "fs";
@@ -185,6 +186,9 @@ function tunnelInfoDir() {
185
186
  function tunnelInfoPath(tunnelId) {
186
187
  return path2.join(tunnelInfoDir(), `${tunnelId}.json`);
187
188
  }
189
+ function tunnelLogPath(tunnelId) {
190
+ return path2.join(tunnelInfoDir(), `${tunnelId}.log`);
191
+ }
188
192
  function createApiClient() {
189
193
  const config = getConfig();
190
194
  return new TunnelApiClient(config.baseUrl, config.apiKey);
@@ -219,6 +223,12 @@ function getFollowReadDelayMs(disconnected, consecutiveFailures) {
219
223
  if (!disconnected) return 1e3;
220
224
  return Math.min(5e3, 1e3 * 2 ** Math.min(consecutiveFailures, 3));
221
225
  }
226
+ function resolveTunnelIdSelection(tunnelIdArg, tunnelOpt) {
227
+ return tunnelOpt || tunnelIdArg;
228
+ }
229
+ function buildDaemonForkStdio(logFd) {
230
+ return ["ignore", logFd, logFd, "ipc"];
231
+ }
222
232
  function registerTunnelCommands(program2) {
223
233
  const tunnel = program2.command("tunnel").description("P2P encrypted tunnel to browser");
224
234
  tunnel.command("start").description("Start a new tunnel (spawns background daemon)").option("--expires <duration>", "Auto-close after duration (e.g. 4h, 1d)", "24h").option("--foreground", "Run in foreground (don't fork)").action(async (opts) => {
@@ -230,7 +240,7 @@ function registerTunnelCommands(program2) {
230
240
  const socketPath = getSocketPath(result.tunnelId);
231
241
  const infoPath = tunnelInfoPath(result.tunnelId);
232
242
  if (opts.foreground) {
233
- const { startDaemon } = await import("./tunnel-daemon-QPXIGRW7.js");
243
+ const { startDaemon } = await import("./tunnel-daemon-DR4A65ME.js");
234
244
  console.log(`Tunnel started: ${result.url}`);
235
245
  console.log(`Tunnel ID: ${result.tunnelId}`);
236
246
  console.log(`Expires: ${new Date(result.expiresAt).toISOString()}`);
@@ -250,9 +260,11 @@ function registerTunnelCommands(program2) {
250
260
  } else {
251
261
  const daemonScript = path2.join(import.meta.dirname, "tunnel-daemon-entry.js");
252
262
  const config = getConfig();
263
+ const logPath = tunnelLogPath(result.tunnelId);
264
+ const daemonLogFd = fs2.openSync(logPath, "a");
253
265
  const child = fork(daemonScript, [], {
254
266
  detached: true,
255
- stdio: "ignore",
267
+ stdio: buildDaemonForkStdio(daemonLogFd),
256
268
  env: {
257
269
  ...process.env,
258
270
  PUBBLUE_DAEMON_TUNNEL_ID: result.tunnelId,
@@ -262,10 +274,15 @@ function registerTunnelCommands(program2) {
262
274
  PUBBLUE_DAEMON_INFO: infoPath
263
275
  }
264
276
  });
277
+ fs2.closeSync(daemonLogFd);
278
+ if (child.connected) {
279
+ child.disconnect();
280
+ }
265
281
  child.unref();
266
282
  const ready = await waitForDaemonReady(infoPath, child, 5e3);
267
283
  if (!ready) {
268
284
  console.error("Daemon failed to start. Cleaning up tunnel...");
285
+ console.error(`Daemon log: ${logPath}`);
269
286
  await apiClient.close(result.tunnelId).catch(() => {
270
287
  });
271
288
  process.exit(1);
@@ -273,6 +290,7 @@ function registerTunnelCommands(program2) {
273
290
  console.log(`Tunnel started: ${result.url}`);
274
291
  console.log(`Tunnel ID: ${result.tunnelId}`);
275
292
  console.log(`Expires: ${new Date(result.expiresAt).toISOString()}`);
293
+ console.log(`Daemon log: ${logPath}`);
276
294
  }
277
295
  });
278
296
  tunnel.command("write").description("Write data to a channel").argument("[message]", "Text message (or use --file)").option("-t, --tunnel <tunnelId>", "Tunnel ID (auto-detected if one active)").option("-c, --channel <channel>", "Channel name", "chat").option("-f, --file <file>", "Read content from file").action(
@@ -333,18 +351,24 @@ function registerTunnelCommands(program2) {
333
351
  }
334
352
  }
335
353
  );
336
- tunnel.command("read").description("Read buffered messages from channels").argument("[tunnelId]", "Tunnel ID (auto-detected if one active)").option("-c, --channel <channel>", "Filter by channel").option("--follow", "Stream messages continuously").action(
354
+ tunnel.command("read").description("Read buffered messages from channels").argument("[tunnelId]", "Tunnel ID (auto-detected if one active)").option("-t, --tunnel <tunnelId>", "Tunnel ID (alternative to positional arg)").option("-c, --channel <channel>", "Filter by channel").option("--follow", "Stream messages continuously").option("--all", "With --follow, include all channels instead of chat-only default").action(
337
355
  async (tunnelIdArg, opts) => {
338
- const tunnelId = tunnelIdArg || await resolveActiveTunnel();
356
+ const tunnelId = resolveTunnelIdSelection(tunnelIdArg, opts.tunnel) || await resolveActiveTunnel();
339
357
  const socketPath = getSocketPath(tunnelId);
358
+ const readChannel = opts.channel || (opts.follow && !opts.all ? CHANNELS.CHAT : void 0);
340
359
  if (opts.follow) {
360
+ if (!opts.channel && !opts.all) {
361
+ console.error(
362
+ "Following chat channel by default. Use `--all` to include binary/file channels."
363
+ );
364
+ }
341
365
  let consecutiveFailures = 0;
342
366
  let warnedDisconnected = false;
343
367
  while (true) {
344
368
  try {
345
369
  const response = await ipcCall(socketPath, {
346
370
  method: "read",
347
- params: { channel: opts.channel }
371
+ params: { channel: readChannel }
348
372
  });
349
373
  if (warnedDisconnected) {
350
374
  console.error("Daemon reconnected.");
@@ -356,10 +380,11 @@ function registerTunnelCommands(program2) {
356
380
  console.log(JSON.stringify(m));
357
381
  }
358
382
  }
359
- } catch {
383
+ } catch (error) {
360
384
  consecutiveFailures += 1;
361
385
  if (!warnedDisconnected) {
362
- console.error("Daemon disconnected. Waiting for recovery...");
386
+ const detail = error instanceof Error ? ` ${error.message}` : "";
387
+ console.error(`Daemon disconnected. Waiting for recovery...${detail}`);
363
388
  warnedDisconnected = true;
364
389
  }
365
390
  }
@@ -369,7 +394,7 @@ function registerTunnelCommands(program2) {
369
394
  } else {
370
395
  const response = await ipcCall(socketPath, {
371
396
  method: "read",
372
- params: { channel: opts.channel }
397
+ params: { channel: readChannel }
373
398
  });
374
399
  if (!response.ok) {
375
400
  console.error(`Failed: ${response.error}`);
@@ -379,8 +404,8 @@ function registerTunnelCommands(program2) {
379
404
  }
380
405
  }
381
406
  );
382
- tunnel.command("channels").description("List active channels").argument("[tunnelId]", "Tunnel ID").action(async (tunnelIdArg) => {
383
- const tunnelId = tunnelIdArg || await resolveActiveTunnel();
407
+ tunnel.command("channels").description("List active channels").argument("[tunnelId]", "Tunnel ID").option("-t, --tunnel <tunnelId>", "Tunnel ID (alternative to positional arg)").action(async (tunnelIdArg, opts) => {
408
+ const tunnelId = resolveTunnelIdSelection(tunnelIdArg, opts.tunnel) || await resolveActiveTunnel();
384
409
  const socketPath = getSocketPath(tunnelId);
385
410
  const response = await ipcCall(socketPath, { method: "channels", params: {} });
386
411
  if (response.channels) {
@@ -389,8 +414,8 @@ function registerTunnelCommands(program2) {
389
414
  }
390
415
  }
391
416
  });
392
- tunnel.command("status").description("Check tunnel connection status").argument("[tunnelId]", "Tunnel ID").action(async (tunnelIdArg) => {
393
- const tunnelId = tunnelIdArg || await resolveActiveTunnel();
417
+ tunnel.command("status").description("Check tunnel connection status").argument("[tunnelId]", "Tunnel ID").option("-t, --tunnel <tunnelId>", "Tunnel ID (alternative to positional arg)").action(async (tunnelIdArg, opts) => {
418
+ const tunnelId = resolveTunnelIdSelection(tunnelIdArg, opts.tunnel) || await resolveActiveTunnel();
394
419
  const socketPath = getSocketPath(tunnelId);
395
420
  const response = await ipcCall(socketPath, { method: "status", params: {} });
396
421
  console.log(` Status: ${response.connected ? "connected" : "waiting"}`);
@@ -398,6 +423,13 @@ function registerTunnelCommands(program2) {
398
423
  const chNames = Array.isArray(response.channels) ? response.channels.map((c) => typeof c === "string" ? c : String(c)) : [];
399
424
  console.log(` Channels: ${chNames.join(", ")}`);
400
425
  console.log(` Buffered: ${response.bufferedMessages ?? 0} messages`);
426
+ if (typeof response.lastError === "string" && response.lastError.length > 0) {
427
+ console.log(` Last error: ${response.lastError}`);
428
+ }
429
+ const logPath = tunnelLogPath(tunnelId);
430
+ if (fs2.existsSync(logPath)) {
431
+ console.log(` Log: ${logPath}`);
432
+ }
401
433
  });
402
434
  tunnel.command("list").description("List active tunnels").action(async () => {
403
435
  const apiClient = createApiClient();
@@ -600,7 +632,7 @@ function readFile(filePath) {
600
632
  basename: path3.basename(resolved)
601
633
  };
602
634
  }
603
- program.name("pubblue").description("Publish static content and get shareable URLs").version("0.4.3");
635
+ program.name("pubblue").description("Publish static content and get shareable URLs").version("0.4.5");
604
636
  program.command("configure").description("Configure the CLI with your API key").option("--api-key <key>", "Your API key (less secure: appears in shell history)").option("--api-key-stdin", "Read API key from stdin").action(async (opts) => {
605
637
  try {
606
638
  const apiKey = await resolveConfigureApiKey(opts);
@@ -0,0 +1,11 @@
1
+ import {
2
+ getTunnelWriteReadinessError,
3
+ shouldRecoverForBrowserAnswerChange,
4
+ startDaemon
5
+ } from "./chunk-AIEPM67G.js";
6
+ import "./chunk-MW35LBNH.js";
7
+ export {
8
+ getTunnelWriteReadinessError,
9
+ shouldRecoverForBrowserAnswerChange,
10
+ startDaemon
11
+ };
@@ -3,8 +3,8 @@ import {
3
3
  } from "./chunk-BV423NLA.js";
4
4
  import {
5
5
  startDaemon
6
- } from "./chunk-YHFY3TW5.js";
7
- import "./chunk-56IKFMJ2.js";
6
+ } from "./chunk-AIEPM67G.js";
7
+ import "./chunk-MW35LBNH.js";
8
8
 
9
9
  // src/tunnel-daemon-entry.ts
10
10
  var tunnelId = process.env.PUBBLUE_DAEMON_TUNNEL_ID;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pubblue",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "description": "CLI tool for publishing static content via pub.blue",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,40 +0,0 @@
1
- // src/lib/bridge-protocol.ts
2
- var CONTROL_CHANNEL = "_control";
3
- var CHANNELS = {
4
- CHAT: "chat",
5
- CANVAS: "canvas",
6
- AUDIO: "audio",
7
- MEDIA: "media",
8
- FILE: "file"
9
- };
10
- var idCounter = 0;
11
- function generateMessageId() {
12
- const ts = Date.now().toString(36);
13
- const seq = (idCounter++).toString(36);
14
- const rand = Math.random().toString(36).slice(2, 6);
15
- return `${ts}-${seq}-${rand}`;
16
- }
17
- function encodeMessage(msg) {
18
- return JSON.stringify(msg);
19
- }
20
- function decodeMessage(raw) {
21
- try {
22
- const parsed = JSON.parse(raw);
23
- if (parsed && typeof parsed.id === "string" && typeof parsed.type === "string") {
24
- return parsed;
25
- }
26
- return null;
27
- } catch {
28
- return null;
29
- }
30
- }
31
- var MAX_TUNNEL_EXPIRY_MS = 7 * 24 * 60 * 60 * 1e3;
32
- var DEFAULT_TUNNEL_EXPIRY_MS = 24 * 60 * 60 * 1e3;
33
-
34
- export {
35
- CONTROL_CHANNEL,
36
- CHANNELS,
37
- generateMessageId,
38
- encodeMessage,
39
- decodeMessage
40
- };
@@ -1,9 +0,0 @@
1
- import {
2
- getTunnelWriteReadinessError,
3
- startDaemon
4
- } from "./chunk-YHFY3TW5.js";
5
- import "./chunk-56IKFMJ2.js";
6
- export {
7
- getTunnelWriteReadinessError,
8
- startDaemon
9
- };