pubblue 0.4.5 → 0.4.7

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,4 +1,12 @@
1
1
  // src/lib/tunnel-api.ts
2
+ var TunnelApiError = class extends Error {
3
+ constructor(message, status, retryAfterSeconds) {
4
+ super(message);
5
+ this.status = status;
6
+ this.retryAfterSeconds = retryAfterSeconds;
7
+ this.name = "TunnelApiError";
8
+ }
9
+ };
2
10
  var TunnelApiClient = class {
3
11
  constructor(baseUrl, apiKey) {
4
12
  this.baseUrl = baseUrl;
@@ -14,13 +22,22 @@ var TunnelApiClient = class {
14
22
  ...options.headers
15
23
  }
16
24
  });
25
+ const retryAfterHeader = res.headers.get("Retry-After");
26
+ const parsedRetryAfterSeconds = typeof retryAfterHeader === "string" ? Number.parseInt(retryAfterHeader, 10) : void 0;
27
+ const retryAfterSeconds = parsedRetryAfterSeconds !== void 0 && Number.isFinite(parsedRetryAfterSeconds) ? parsedRetryAfterSeconds : void 0;
17
28
  let data;
18
29
  try {
19
30
  data = await res.json();
20
31
  } catch {
21
32
  data = {};
22
33
  }
23
- if (!res.ok) throw new Error(data.error || `Request failed: ${res.status}`);
34
+ if (!res.ok) {
35
+ if (res.status === 429) {
36
+ const retrySuffix = retryAfterSeconds !== void 0 ? ` Retry after ${retryAfterSeconds}s.` : "";
37
+ throw new TunnelApiError(`Rate limit exceeded.${retrySuffix}`, res.status, retryAfterSeconds);
38
+ }
39
+ throw new TunnelApiError(data.error || `Request failed: ${res.status}`, res.status);
40
+ }
24
41
  return data;
25
42
  }
26
43
  async create(opts) {
@@ -53,5 +70,6 @@ var TunnelApiClient = class {
53
70
  };
54
71
 
55
72
  export {
73
+ TunnelApiError,
56
74
  TunnelApiClient
57
75
  };
@@ -485,13 +485,6 @@ async function startDaemon(config) {
485
485
  });
486
486
  });
487
487
  ipcServer.listen(socketPath);
488
- const infoDir = path.dirname(infoPath);
489
- if (!fs.existsSync(infoDir)) fs.mkdirSync(infoDir, { recursive: true });
490
- fs.writeFileSync(
491
- infoPath,
492
- JSON.stringify({ pid: process.pid, tunnelId, socketPath, startedAt: startTime })
493
- );
494
- scheduleNextPoll(0);
495
488
  try {
496
489
  await runNegotiationCycle();
497
490
  } catch (error) {
@@ -500,6 +493,13 @@ async function startDaemon(config) {
500
493
  await cleanup();
501
494
  throw new Error(`Failed to generate WebRTC offer: ${message}`);
502
495
  }
496
+ const infoDir = path.dirname(infoPath);
497
+ if (!fs.existsSync(infoDir)) fs.mkdirSync(infoDir, { recursive: true });
498
+ fs.writeFileSync(
499
+ infoPath,
500
+ JSON.stringify({ pid: process.pid, tunnelId, socketPath, startedAt: startTime })
501
+ );
502
+ scheduleNextPoll(0);
503
503
  async function cleanup() {
504
504
  if (stopped) return;
505
505
  stopped = true;
@@ -518,9 +518,6 @@ async function startDaemon(config) {
518
518
  } catch (error) {
519
519
  debugLog("failed to remove daemon info file during cleanup", error);
520
520
  }
521
- await apiClient.close(tunnelId).catch((error) => {
522
- markError("failed to close tunnel on API during cleanup", error);
523
- });
524
521
  }
525
522
  async function shutdown() {
526
523
  await cleanup();
package/dist/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
- TunnelApiClient
4
- } from "./chunk-BV423NLA.js";
3
+ TunnelApiClient,
4
+ TunnelApiError
5
+ } from "./chunk-F4VBBHDR.js";
5
6
  import {
6
7
  CHANNELS,
7
8
  generateMessageId
@@ -229,25 +230,133 @@ function resolveTunnelIdSelection(tunnelIdArg, tunnelOpt) {
229
230
  function buildDaemonForkStdio(logFd) {
230
231
  return ["ignore", logFd, logFd, "ipc"];
231
232
  }
233
+ function getPublicTunnelUrl(tunnelId) {
234
+ const base = process.env.PUBBLUE_PUBLIC_URL || "https://pub.blue";
235
+ return `${base.replace(/\/$/, "")}/t/${tunnelId}`;
236
+ }
237
+ function pickReusableTunnel(tunnels, nowMs = Date.now()) {
238
+ const active = tunnels.filter((t) => t.status === "active" && t.expiresAt > nowMs).sort((a, b) => b.createdAt - a.createdAt);
239
+ return active[0] ?? null;
240
+ }
241
+ function readLogTail(logPath, maxChars = 4e3) {
242
+ if (!fs2.existsSync(logPath)) return null;
243
+ try {
244
+ const content = fs2.readFileSync(logPath, "utf-8");
245
+ if (content.length <= maxChars) return content;
246
+ return content.slice(-maxChars);
247
+ } catch {
248
+ return null;
249
+ }
250
+ }
251
+ function formatApiError(error) {
252
+ if (error instanceof TunnelApiError) {
253
+ if (error.status === 429 && error.retryAfterSeconds !== void 0) {
254
+ return `Rate limit exceeded. Retry after ${error.retryAfterSeconds}s.`;
255
+ }
256
+ return `${error.message} (HTTP ${error.status})`;
257
+ }
258
+ return error instanceof Error ? error.message : String(error);
259
+ }
260
+ async function cleanupCreatedTunnelOnStartFailure(apiClient, target) {
261
+ if (!target.createdNew) return;
262
+ try {
263
+ await apiClient.close(target.tunnelId);
264
+ } catch (closeError) {
265
+ console.error(
266
+ `Failed to clean up newly created tunnel ${target.tunnelId}: ${formatApiError(closeError)}`
267
+ );
268
+ }
269
+ }
232
270
  function registerTunnelCommands(program2) {
233
271
  const tunnel = program2.command("tunnel").description("P2P encrypted tunnel to browser");
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) => {
272
+ tunnel.command("start").description("Start a tunnel daemon (reuses existing tunnel when possible)").option("--expires <duration>", "Auto-close after duration (e.g. 4h, 1d)", "24h").option("-t, --tunnel <tunnelId>", "Attach/start daemon for an existing tunnel").option("--new", "Always create a new tunnel (skip single-tunnel reuse)").option("--foreground", "Run in foreground (don't fork)").action(async (opts) => {
235
273
  await ensureNodeDatachannelAvailable();
236
274
  const apiClient = createApiClient();
237
- const result = await apiClient.create({
238
- expiresIn: opts.expires
239
- });
240
- const socketPath = getSocketPath(result.tunnelId);
241
- const infoPath = tunnelInfoPath(result.tunnelId);
275
+ let target = null;
276
+ if (opts.tunnel) {
277
+ try {
278
+ const existing = await apiClient.get(opts.tunnel);
279
+ if (existing.status === "closed" || existing.expiresAt <= Date.now()) {
280
+ console.error(`Tunnel ${opts.tunnel} is closed or expired.`);
281
+ process.exit(1);
282
+ }
283
+ target = {
284
+ createdNew: false,
285
+ expiresAt: existing.expiresAt,
286
+ mode: "existing",
287
+ tunnelId: existing.tunnelId,
288
+ url: getPublicTunnelUrl(existing.tunnelId)
289
+ };
290
+ } catch (error) {
291
+ console.error(`Failed to use tunnel ${opts.tunnel}: ${formatApiError(error)}`);
292
+ process.exit(1);
293
+ }
294
+ } else if (!opts.new) {
295
+ try {
296
+ const listed = await apiClient.list();
297
+ const active = listed.filter((t) => t.status === "active" && t.expiresAt > Date.now()).sort((a, b) => b.createdAt - a.createdAt);
298
+ const reusable = pickReusableTunnel(listed);
299
+ if (reusable) {
300
+ target = {
301
+ createdNew: false,
302
+ expiresAt: reusable.expiresAt,
303
+ mode: "existing",
304
+ tunnelId: reusable.tunnelId,
305
+ url: getPublicTunnelUrl(reusable.tunnelId)
306
+ };
307
+ if (active.length > 1) {
308
+ console.error(
309
+ [
310
+ `Multiple active tunnels found: ${active.map((t) => t.tunnelId).join(", ")}`,
311
+ `Reusing most recent active tunnel ${reusable.tunnelId}.`,
312
+ "Use --tunnel <id> to choose explicitly or --new to force creation."
313
+ ].join("\n")
314
+ );
315
+ } else {
316
+ console.error(
317
+ `Reusing existing active tunnel ${reusable.tunnelId}. Use --new to force creation.`
318
+ );
319
+ }
320
+ }
321
+ } catch (error) {
322
+ console.error(`Failed to list tunnels for reuse check: ${formatApiError(error)}`);
323
+ process.exit(1);
324
+ }
325
+ }
326
+ if (!target) {
327
+ try {
328
+ const created = await apiClient.create({
329
+ expiresIn: opts.expires
330
+ });
331
+ target = {
332
+ createdNew: true,
333
+ expiresAt: created.expiresAt,
334
+ mode: "created",
335
+ tunnelId: created.tunnelId,
336
+ url: created.url
337
+ };
338
+ } catch (error) {
339
+ console.error(`Failed to create tunnel: ${formatApiError(error)}`);
340
+ process.exit(1);
341
+ }
342
+ }
343
+ if (!target) {
344
+ console.error("Failed to resolve tunnel target.");
345
+ process.exit(1);
346
+ }
347
+ const socketPath = getSocketPath(target.tunnelId);
348
+ const infoPath = tunnelInfoPath(target.tunnelId);
349
+ const logPath = tunnelLogPath(target.tunnelId);
242
350
  if (opts.foreground) {
243
- const { startDaemon } = await import("./tunnel-daemon-DR4A65ME.js");
244
- console.log(`Tunnel started: ${result.url}`);
245
- console.log(`Tunnel ID: ${result.tunnelId}`);
246
- console.log(`Expires: ${new Date(result.expiresAt).toISOString()}`);
351
+ const { startDaemon } = await import("./tunnel-daemon-4LV6HLYN.js");
352
+ console.log(`Tunnel started: ${target.url}`);
353
+ console.log(`Tunnel ID: ${target.tunnelId}`);
354
+ console.log(`Expires: ${new Date(target.expiresAt).toISOString()}`);
355
+ if (target.mode === "existing") console.log("Mode: attached existing tunnel");
247
356
  console.log("Running in foreground. Press Ctrl+C to stop.");
248
357
  try {
249
358
  await startDaemon({
250
- tunnelId: result.tunnelId,
359
+ tunnelId: target.tunnelId,
251
360
  apiClient,
252
361
  socketPath,
253
362
  infoPath
@@ -258,16 +367,33 @@ function registerTunnelCommands(program2) {
258
367
  process.exit(1);
259
368
  }
260
369
  } else {
370
+ if (isDaemonRunning(target.tunnelId)) {
371
+ try {
372
+ const status = await ipcCall(socketPath, { method: "status", params: {} });
373
+ if (!status.ok) throw new Error(String(status.error || "status check failed"));
374
+ } catch (error) {
375
+ console.error(
376
+ `Daemon process exists but is not responding: ${error instanceof Error ? error.message : String(error)}`
377
+ );
378
+ console.error("Run `pubblue tunnel close <id>` and start again.");
379
+ process.exit(1);
380
+ }
381
+ console.log(`Tunnel started: ${target.url}`);
382
+ console.log(`Tunnel ID: ${target.tunnelId}`);
383
+ console.log(`Expires: ${new Date(target.expiresAt).toISOString()}`);
384
+ console.log("Daemon already running for this tunnel.");
385
+ console.log(`Daemon log: ${logPath}`);
386
+ return;
387
+ }
261
388
  const daemonScript = path2.join(import.meta.dirname, "tunnel-daemon-entry.js");
262
389
  const config = getConfig();
263
- const logPath = tunnelLogPath(result.tunnelId);
264
390
  const daemonLogFd = fs2.openSync(logPath, "a");
265
391
  const child = fork(daemonScript, [], {
266
392
  detached: true,
267
393
  stdio: buildDaemonForkStdio(daemonLogFd),
268
394
  env: {
269
395
  ...process.env,
270
- PUBBLUE_DAEMON_TUNNEL_ID: result.tunnelId,
396
+ PUBBLUE_DAEMON_TUNNEL_ID: target.tunnelId,
271
397
  PUBBLUE_DAEMON_BASE_URL: config.baseUrl,
272
398
  PUBBLUE_DAEMON_API_KEY: config.apiKey,
273
399
  PUBBLUE_DAEMON_SOCKET: socketPath,
@@ -279,17 +405,47 @@ function registerTunnelCommands(program2) {
279
405
  child.disconnect();
280
406
  }
281
407
  child.unref();
282
- const ready = await waitForDaemonReady(infoPath, child, 5e3);
283
- if (!ready) {
284
- console.error("Daemon failed to start. Cleaning up tunnel...");
408
+ console.log(`Starting daemon for tunnel ${target.tunnelId}...`);
409
+ const ready = await waitForDaemonReady({
410
+ child,
411
+ infoPath,
412
+ socketPath,
413
+ timeoutMs: 8e3
414
+ });
415
+ if (!ready.ok) {
416
+ console.error(`Daemon failed to start: ${ready.reason ?? "unknown reason"}`);
285
417
  console.error(`Daemon log: ${logPath}`);
286
- await apiClient.close(result.tunnelId).catch(() => {
287
- });
418
+ const tail = readLogTail(logPath);
419
+ if (tail) {
420
+ console.error("---- daemon log tail ----");
421
+ console.error(tail.trimEnd());
422
+ console.error("---- end daemon log tail ----");
423
+ }
424
+ await cleanupCreatedTunnelOnStartFailure(apiClient, target);
288
425
  process.exit(1);
289
426
  }
290
- console.log(`Tunnel started: ${result.url}`);
291
- console.log(`Tunnel ID: ${result.tunnelId}`);
292
- console.log(`Expires: ${new Date(result.expiresAt).toISOString()}`);
427
+ const offerReady = await waitForAgentOffer({
428
+ apiClient,
429
+ tunnelId: target.tunnelId,
430
+ timeoutMs: 5e3
431
+ });
432
+ if (!offerReady.ok) {
433
+ console.error(`Daemon started but signaling is not ready: ${offerReady.reason}`);
434
+ console.error(`Daemon log: ${logPath}`);
435
+ const tail = readLogTail(logPath);
436
+ if (tail) {
437
+ console.error("---- daemon log tail ----");
438
+ console.error(tail.trimEnd());
439
+ console.error("---- end daemon log tail ----");
440
+ }
441
+ await cleanupCreatedTunnelOnStartFailure(apiClient, target);
442
+ process.exit(1);
443
+ }
444
+ console.log(`Tunnel started: ${target.url}`);
445
+ console.log(`Tunnel ID: ${target.tunnelId}`);
446
+ console.log(`Expires: ${new Date(target.expiresAt).toISOString()}`);
447
+ if (target.mode === "existing") console.log("Mode: attached existing tunnel");
448
+ console.log("Daemon health: OK");
293
449
  console.log(`Daemon log: ${logPath}`);
294
450
  }
295
451
  });
@@ -447,19 +603,16 @@ function registerTunnelCommands(program2) {
447
603
  });
448
604
  tunnel.command("close").description("Close a tunnel and stop its daemon").argument("<tunnelId>", "Tunnel ID").action(async (tunnelId) => {
449
605
  const socketPath = getSocketPath(tunnelId);
450
- let closedByDaemon = false;
451
606
  try {
452
- const daemonResult = await ipcCall(socketPath, { method: "close", params: {} });
453
- closedByDaemon = daemonResult.ok;
607
+ await ipcCall(socketPath, { method: "close", params: {} });
454
608
  } catch {
455
- closedByDaemon = false;
456
609
  }
457
- if (!closedByDaemon) {
458
- const apiClient = createApiClient();
459
- try {
460
- await apiClient.close(tunnelId);
461
- } catch (error) {
462
- const message = error instanceof Error ? error.message : String(error);
610
+ const apiClient = createApiClient();
611
+ try {
612
+ await apiClient.close(tunnelId);
613
+ } catch (error) {
614
+ const message = formatApiError(error);
615
+ if (!/Tunnel not found/i.test(message)) {
463
616
  console.error(`Failed to close tunnel ${tunnelId}: ${message}`);
464
617
  process.exit(1);
465
618
  }
@@ -483,23 +636,65 @@ async function resolveActiveTunnel() {
483
636
  console.error(`Multiple active tunnels: ${active.join(", ")}. Specify one.`);
484
637
  process.exit(1);
485
638
  }
486
- function waitForDaemonReady(infoPath, child, timeoutMs) {
639
+ function waitForDaemonReady({
640
+ child,
641
+ infoPath,
642
+ socketPath,
643
+ timeoutMs
644
+ }) {
487
645
  return new Promise((resolve3) => {
488
646
  let settled = false;
489
- const done = (value) => {
647
+ let pollInFlight = false;
648
+ let lastIpcError = null;
649
+ const done = (result) => {
490
650
  if (settled) return;
491
651
  settled = true;
492
652
  clearInterval(poll);
493
653
  clearTimeout(timeout);
494
- resolve3(value);
654
+ child.off("exit", onExit);
655
+ resolve3(result);
656
+ };
657
+ const onExit = (code, signal) => {
658
+ const suffix = signal ? ` (signal ${signal})` : "";
659
+ done({ ok: false, reason: `daemon exited with code ${code ?? 0}${suffix}` });
495
660
  };
496
- child.on("exit", () => done(false));
661
+ child.on("exit", onExit);
497
662
  const poll = setInterval(() => {
498
- if (fs2.existsSync(infoPath)) done(true);
499
- }, 100);
500
- const timeout = setTimeout(() => done(false), timeoutMs);
663
+ if (pollInFlight || !fs2.existsSync(infoPath)) return;
664
+ pollInFlight = true;
665
+ void ipcCall(socketPath, { method: "status", params: {} }).then((status) => {
666
+ if (status.ok) done({ ok: true });
667
+ }).catch((error) => {
668
+ lastIpcError = error instanceof Error ? error.message : String(error);
669
+ }).finally(() => {
670
+ pollInFlight = false;
671
+ });
672
+ }, 120);
673
+ const timeout = setTimeout(() => {
674
+ const reason = lastIpcError ? `timed out after ${timeoutMs}ms waiting for daemon readiness (last IPC error: ${lastIpcError})` : `timed out after ${timeoutMs}ms waiting for daemon readiness`;
675
+ done({ ok: false, reason });
676
+ }, timeoutMs);
501
677
  });
502
678
  }
679
+ async function waitForAgentOffer(params) {
680
+ const startedAt = Date.now();
681
+ let lastError = null;
682
+ while (Date.now() - startedAt < params.timeoutMs) {
683
+ try {
684
+ const tunnel = await params.apiClient.get(params.tunnelId);
685
+ if (typeof tunnel.agentOffer === "string" && tunnel.agentOffer.length > 0) {
686
+ return { ok: true };
687
+ }
688
+ } catch (error) {
689
+ lastError = formatApiError(error);
690
+ }
691
+ await new Promise((resolve3) => setTimeout(resolve3, 150));
692
+ }
693
+ return {
694
+ ok: false,
695
+ reason: lastError ? `agent offer was not published in time (last API error: ${lastError})` : "agent offer was not published in time"
696
+ };
697
+ }
503
698
 
504
699
  // src/lib/api.ts
505
700
  var PubApiClient = class {
@@ -632,7 +827,7 @@ function readFile(filePath) {
632
827
  basename: path3.basename(resolved)
633
828
  };
634
829
  }
635
- program.name("pubblue").description("Publish static content and get shareable URLs").version("0.4.5");
830
+ program.name("pubblue").description("Publish static content and get shareable URLs").version("0.4.7");
636
831
  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) => {
637
832
  try {
638
833
  const apiKey = await resolveConfigureApiKey(opts);
@@ -2,7 +2,7 @@ import {
2
2
  getTunnelWriteReadinessError,
3
3
  shouldRecoverForBrowserAnswerChange,
4
4
  startDaemon
5
- } from "./chunk-AIEPM67G.js";
5
+ } from "./chunk-HOHLQGQT.js";
6
6
  import "./chunk-MW35LBNH.js";
7
7
  export {
8
8
  getTunnelWriteReadinessError,
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  TunnelApiClient
3
- } from "./chunk-BV423NLA.js";
3
+ } from "./chunk-F4VBBHDR.js";
4
4
  import {
5
5
  startDaemon
6
- } from "./chunk-AIEPM67G.js";
6
+ } from "./chunk-HOHLQGQT.js";
7
7
  import "./chunk-MW35LBNH.js";
8
8
 
9
9
  // src/tunnel-daemon-entry.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pubblue",
3
- "version": "0.4.5",
3
+ "version": "0.4.7",
4
4
  "description": "CLI tool for publishing static content via pub.blue",
5
5
  "type": "module",
6
6
  "bin": {