pubblue 0.4.4 → 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
@@ -226,25 +227,136 @@ function getFollowReadDelayMs(disconnected, consecutiveFailures) {
226
227
  function resolveTunnelIdSelection(tunnelIdArg, tunnelOpt) {
227
228
  return tunnelOpt || tunnelIdArg;
228
229
  }
230
+ function buildDaemonForkStdio(logFd) {
231
+ return ["ignore", logFd, logFd, "ipc"];
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
+ }
229
270
  function registerTunnelCommands(program2) {
230
271
  const tunnel = program2.command("tunnel").description("P2P encrypted tunnel to browser");
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) => {
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) => {
232
273
  await ensureNodeDatachannelAvailable();
233
274
  const apiClient = createApiClient();
234
- const result = await apiClient.create({
235
- expiresIn: opts.expires
236
- });
237
- const socketPath = getSocketPath(result.tunnelId);
238
- 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);
239
350
  if (opts.foreground) {
240
- const { startDaemon } = await import("./tunnel-daemon-DR4A65ME.js");
241
- console.log(`Tunnel started: ${result.url}`);
242
- console.log(`Tunnel ID: ${result.tunnelId}`);
243
- 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");
244
356
  console.log("Running in foreground. Press Ctrl+C to stop.");
245
357
  try {
246
358
  await startDaemon({
247
- tunnelId: result.tunnelId,
359
+ tunnelId: target.tunnelId,
248
360
  apiClient,
249
361
  socketPath,
250
362
  infoPath
@@ -255,16 +367,33 @@ function registerTunnelCommands(program2) {
255
367
  process.exit(1);
256
368
  }
257
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
+ }
258
388
  const daemonScript = path2.join(import.meta.dirname, "tunnel-daemon-entry.js");
259
389
  const config = getConfig();
260
- const logPath = tunnelLogPath(result.tunnelId);
261
390
  const daemonLogFd = fs2.openSync(logPath, "a");
262
391
  const child = fork(daemonScript, [], {
263
392
  detached: true,
264
- stdio: ["ignore", daemonLogFd, daemonLogFd],
393
+ stdio: buildDaemonForkStdio(daemonLogFd),
265
394
  env: {
266
395
  ...process.env,
267
- PUBBLUE_DAEMON_TUNNEL_ID: result.tunnelId,
396
+ PUBBLUE_DAEMON_TUNNEL_ID: target.tunnelId,
268
397
  PUBBLUE_DAEMON_BASE_URL: config.baseUrl,
269
398
  PUBBLUE_DAEMON_API_KEY: config.apiKey,
270
399
  PUBBLUE_DAEMON_SOCKET: socketPath,
@@ -272,18 +401,51 @@ function registerTunnelCommands(program2) {
272
401
  }
273
402
  });
274
403
  fs2.closeSync(daemonLogFd);
404
+ if (child.connected) {
405
+ child.disconnect();
406
+ }
275
407
  child.unref();
276
- const ready = await waitForDaemonReady(infoPath, child, 5e3);
277
- if (!ready) {
278
- 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"}`);
279
417
  console.error(`Daemon log: ${logPath}`);
280
- await apiClient.close(result.tunnelId).catch(() => {
281
- });
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);
425
+ process.exit(1);
426
+ }
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);
282
442
  process.exit(1);
283
443
  }
284
- console.log(`Tunnel started: ${result.url}`);
285
- console.log(`Tunnel ID: ${result.tunnelId}`);
286
- console.log(`Expires: ${new Date(result.expiresAt).toISOString()}`);
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");
287
449
  console.log(`Daemon log: ${logPath}`);
288
450
  }
289
451
  });
@@ -441,19 +603,16 @@ function registerTunnelCommands(program2) {
441
603
  });
442
604
  tunnel.command("close").description("Close a tunnel and stop its daemon").argument("<tunnelId>", "Tunnel ID").action(async (tunnelId) => {
443
605
  const socketPath = getSocketPath(tunnelId);
444
- let closedByDaemon = false;
445
606
  try {
446
- const daemonResult = await ipcCall(socketPath, { method: "close", params: {} });
447
- closedByDaemon = daemonResult.ok;
607
+ await ipcCall(socketPath, { method: "close", params: {} });
448
608
  } catch {
449
- closedByDaemon = false;
450
609
  }
451
- if (!closedByDaemon) {
452
- const apiClient = createApiClient();
453
- try {
454
- await apiClient.close(tunnelId);
455
- } catch (error) {
456
- 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)) {
457
616
  console.error(`Failed to close tunnel ${tunnelId}: ${message}`);
458
617
  process.exit(1);
459
618
  }
@@ -477,23 +636,65 @@ async function resolveActiveTunnel() {
477
636
  console.error(`Multiple active tunnels: ${active.join(", ")}. Specify one.`);
478
637
  process.exit(1);
479
638
  }
480
- function waitForDaemonReady(infoPath, child, timeoutMs) {
639
+ function waitForDaemonReady({
640
+ child,
641
+ infoPath,
642
+ socketPath,
643
+ timeoutMs
644
+ }) {
481
645
  return new Promise((resolve3) => {
482
646
  let settled = false;
483
- const done = (value) => {
647
+ let pollInFlight = false;
648
+ let lastIpcError = null;
649
+ const done = (result) => {
484
650
  if (settled) return;
485
651
  settled = true;
486
652
  clearInterval(poll);
487
653
  clearTimeout(timeout);
488
- 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}` });
489
660
  };
490
- child.on("exit", () => done(false));
661
+ child.on("exit", onExit);
491
662
  const poll = setInterval(() => {
492
- if (fs2.existsSync(infoPath)) done(true);
493
- }, 100);
494
- 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);
495
677
  });
496
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
+ }
497
698
 
498
699
  // src/lib/api.ts
499
700
  var PubApiClient = class {
@@ -626,7 +827,7 @@ function readFile(filePath) {
626
827
  basename: path3.basename(resolved)
627
828
  };
628
829
  }
629
- program.name("pubblue").description("Publish static content and get shareable URLs").version("0.4.4");
830
+ program.name("pubblue").description("Publish static content and get shareable URLs").version("0.4.7");
630
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) => {
631
832
  try {
632
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.4",
3
+ "version": "0.4.7",
4
4
  "description": "CLI tool for publishing static content via pub.blue",
5
5
  "type": "module",
6
6
  "bin": {