pubblue 0.4.3 → 0.4.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.
@@ -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,9 @@ 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
+ }
222
229
  function registerTunnelCommands(program2) {
223
230
  const tunnel = program2.command("tunnel").description("P2P encrypted tunnel to browser");
224
231
  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 +237,7 @@ function registerTunnelCommands(program2) {
230
237
  const socketPath = getSocketPath(result.tunnelId);
231
238
  const infoPath = tunnelInfoPath(result.tunnelId);
232
239
  if (opts.foreground) {
233
- const { startDaemon } = await import("./tunnel-daemon-QPXIGRW7.js");
240
+ const { startDaemon } = await import("./tunnel-daemon-DR4A65ME.js");
234
241
  console.log(`Tunnel started: ${result.url}`);
235
242
  console.log(`Tunnel ID: ${result.tunnelId}`);
236
243
  console.log(`Expires: ${new Date(result.expiresAt).toISOString()}`);
@@ -250,9 +257,11 @@ function registerTunnelCommands(program2) {
250
257
  } else {
251
258
  const daemonScript = path2.join(import.meta.dirname, "tunnel-daemon-entry.js");
252
259
  const config = getConfig();
260
+ const logPath = tunnelLogPath(result.tunnelId);
261
+ const daemonLogFd = fs2.openSync(logPath, "a");
253
262
  const child = fork(daemonScript, [], {
254
263
  detached: true,
255
- stdio: "ignore",
264
+ stdio: ["ignore", daemonLogFd, daemonLogFd],
256
265
  env: {
257
266
  ...process.env,
258
267
  PUBBLUE_DAEMON_TUNNEL_ID: result.tunnelId,
@@ -262,10 +271,12 @@ function registerTunnelCommands(program2) {
262
271
  PUBBLUE_DAEMON_INFO: infoPath
263
272
  }
264
273
  });
274
+ fs2.closeSync(daemonLogFd);
265
275
  child.unref();
266
276
  const ready = await waitForDaemonReady(infoPath, child, 5e3);
267
277
  if (!ready) {
268
278
  console.error("Daemon failed to start. Cleaning up tunnel...");
279
+ console.error(`Daemon log: ${logPath}`);
269
280
  await apiClient.close(result.tunnelId).catch(() => {
270
281
  });
271
282
  process.exit(1);
@@ -273,6 +284,7 @@ function registerTunnelCommands(program2) {
273
284
  console.log(`Tunnel started: ${result.url}`);
274
285
  console.log(`Tunnel ID: ${result.tunnelId}`);
275
286
  console.log(`Expires: ${new Date(result.expiresAt).toISOString()}`);
287
+ console.log(`Daemon log: ${logPath}`);
276
288
  }
277
289
  });
278
290
  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 +345,24 @@ function registerTunnelCommands(program2) {
333
345
  }
334
346
  }
335
347
  );
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(
348
+ 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
349
  async (tunnelIdArg, opts) => {
338
- const tunnelId = tunnelIdArg || await resolveActiveTunnel();
350
+ const tunnelId = resolveTunnelIdSelection(tunnelIdArg, opts.tunnel) || await resolveActiveTunnel();
339
351
  const socketPath = getSocketPath(tunnelId);
352
+ const readChannel = opts.channel || (opts.follow && !opts.all ? CHANNELS.CHAT : void 0);
340
353
  if (opts.follow) {
354
+ if (!opts.channel && !opts.all) {
355
+ console.error(
356
+ "Following chat channel by default. Use `--all` to include binary/file channels."
357
+ );
358
+ }
341
359
  let consecutiveFailures = 0;
342
360
  let warnedDisconnected = false;
343
361
  while (true) {
344
362
  try {
345
363
  const response = await ipcCall(socketPath, {
346
364
  method: "read",
347
- params: { channel: opts.channel }
365
+ params: { channel: readChannel }
348
366
  });
349
367
  if (warnedDisconnected) {
350
368
  console.error("Daemon reconnected.");
@@ -356,10 +374,11 @@ function registerTunnelCommands(program2) {
356
374
  console.log(JSON.stringify(m));
357
375
  }
358
376
  }
359
- } catch {
377
+ } catch (error) {
360
378
  consecutiveFailures += 1;
361
379
  if (!warnedDisconnected) {
362
- console.error("Daemon disconnected. Waiting for recovery...");
380
+ const detail = error instanceof Error ? ` ${error.message}` : "";
381
+ console.error(`Daemon disconnected. Waiting for recovery...${detail}`);
363
382
  warnedDisconnected = true;
364
383
  }
365
384
  }
@@ -369,7 +388,7 @@ function registerTunnelCommands(program2) {
369
388
  } else {
370
389
  const response = await ipcCall(socketPath, {
371
390
  method: "read",
372
- params: { channel: opts.channel }
391
+ params: { channel: readChannel }
373
392
  });
374
393
  if (!response.ok) {
375
394
  console.error(`Failed: ${response.error}`);
@@ -379,8 +398,8 @@ function registerTunnelCommands(program2) {
379
398
  }
380
399
  }
381
400
  );
382
- tunnel.command("channels").description("List active channels").argument("[tunnelId]", "Tunnel ID").action(async (tunnelIdArg) => {
383
- const tunnelId = tunnelIdArg || await resolveActiveTunnel();
401
+ 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) => {
402
+ const tunnelId = resolveTunnelIdSelection(tunnelIdArg, opts.tunnel) || await resolveActiveTunnel();
384
403
  const socketPath = getSocketPath(tunnelId);
385
404
  const response = await ipcCall(socketPath, { method: "channels", params: {} });
386
405
  if (response.channels) {
@@ -389,8 +408,8 @@ function registerTunnelCommands(program2) {
389
408
  }
390
409
  }
391
410
  });
392
- tunnel.command("status").description("Check tunnel connection status").argument("[tunnelId]", "Tunnel ID").action(async (tunnelIdArg) => {
393
- const tunnelId = tunnelIdArg || await resolveActiveTunnel();
411
+ 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) => {
412
+ const tunnelId = resolveTunnelIdSelection(tunnelIdArg, opts.tunnel) || await resolveActiveTunnel();
394
413
  const socketPath = getSocketPath(tunnelId);
395
414
  const response = await ipcCall(socketPath, { method: "status", params: {} });
396
415
  console.log(` Status: ${response.connected ? "connected" : "waiting"}`);
@@ -398,6 +417,13 @@ function registerTunnelCommands(program2) {
398
417
  const chNames = Array.isArray(response.channels) ? response.channels.map((c) => typeof c === "string" ? c : String(c)) : [];
399
418
  console.log(` Channels: ${chNames.join(", ")}`);
400
419
  console.log(` Buffered: ${response.bufferedMessages ?? 0} messages`);
420
+ if (typeof response.lastError === "string" && response.lastError.length > 0) {
421
+ console.log(` Last error: ${response.lastError}`);
422
+ }
423
+ const logPath = tunnelLogPath(tunnelId);
424
+ if (fs2.existsSync(logPath)) {
425
+ console.log(` Log: ${logPath}`);
426
+ }
401
427
  });
402
428
  tunnel.command("list").description("List active tunnels").action(async () => {
403
429
  const apiClient = createApiClient();
@@ -600,7 +626,7 @@ function readFile(filePath) {
600
626
  basename: path3.basename(resolved)
601
627
  };
602
628
  }
603
- program.name("pubblue").description("Publish static content and get shareable URLs").version("0.4.3");
629
+ program.name("pubblue").description("Publish static content and get shareable URLs").version("0.4.4");
604
630
  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
631
  try {
606
632
  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.4",
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
- };