@montytools/cli 0.2.5 → 0.2.6

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.
Files changed (2) hide show
  1. package/bin/monty.mjs +77 -25
  2. package/package.json +1 -1
package/bin/monty.mjs CHANGED
@@ -17,6 +17,8 @@ import { CATALOG, REGISTRIES } from "./catalog.mjs";
17
17
  const CONFIG_DIR = join(homedir(), ".monty");
18
18
  const CONFIG_PATH = join(CONFIG_DIR, "config.json");
19
19
  const DEFAULT_HOST = "https://usemonty.dev";
20
+ const DEV_SESSION_HEARTBEAT_MS = 30_000;
21
+ const DEV_SESSION_REQUEST_TIMEOUT_MS = 10_000;
20
22
  // Every app's source lives in one predictable place. `monty create` stamps
21
23
  // here by default (override with --dir) and `monty login` provisions it.
22
24
  const MONTY_HOME = join(homedir(), "Monty");
@@ -511,23 +513,27 @@ async function dev() {
511
513
  } catch { /* transient fs hiccup — next beat retries */ }
512
514
  }
513
515
 
514
- async function endSession() {
515
- if (ended) return;
516
- ended = true;
517
- if (hbTimer) clearInterval(hbTimer);
518
- try { tunnelChild?.kill(); } catch { /* already gone */ }
516
+ async function clearDevSession(timeoutMs = 2000) {
519
517
  if (cfg?.key) {
520
518
  try {
521
519
  await fetch(`${host}/api/dev-session`, {
522
520
  method: "POST",
523
521
  headers: { authorization: `Bearer ${cfg.key}`, "content-type": "application/json" },
524
522
  body: JSON.stringify({ slug: meta.slug, end: true }),
525
- signal: AbortSignal.timeout(2000),
523
+ signal: AbortSignal.timeout(timeoutMs),
526
524
  });
527
525
  } catch { /* best effort */ }
528
526
  }
529
527
  }
530
528
 
529
+ async function endSession() {
530
+ if (ended) return;
531
+ ended = true;
532
+ if (hbTimer) clearInterval(hbTimer);
533
+ try { tunnelChild?.kill(); } catch { /* already gone */ }
534
+ await clearDevSession();
535
+ }
536
+
531
537
  async function heartbeat(originUrl) {
532
538
  await refreshSchemaIfChanged();
533
539
  try {
@@ -542,11 +548,12 @@ async function dev() {
542
548
  buildId,
543
549
  schemaJson: currentMeta.schemaJson,
544
550
  }),
551
+ signal: AbortSignal.timeout(DEV_SESSION_REQUEST_TIMEOUT_MS),
545
552
  });
546
553
  const data = await r.json().catch(() => null);
547
554
  if (!r.ok) {
548
555
  console.log(`dev-session: ${data?.code ?? r.status}${data?.fix ? ` — ${data.fix}` : ""}`);
549
- return;
556
+ return false;
550
557
  }
551
558
  if (data?.publishRequested && !publishing) {
552
559
  publishing = true;
@@ -567,7 +574,11 @@ async function dev() {
567
574
  });
568
575
  publishing = false;
569
576
  }
570
- } catch { /* transient network hiccup — next beat retries */ }
577
+ return true;
578
+ } catch {
579
+ console.log("dev-session: heartbeat failed — retrying on the next beat");
580
+ return false;
581
+ }
571
582
  }
572
583
 
573
584
  async function startDevSession() {
@@ -575,27 +586,60 @@ async function dev() {
575
586
  console.log("dev: not logged in — workspace dev mode disabled (run `monty login`)");
576
587
  return;
577
588
  }
589
+ await clearDevSession();
578
590
  let originUrl = `http://localhost:${port}`;
591
+ let tunnelUpdate = Promise.resolve();
592
+ let tunnelVersion = 0;
593
+ async function activateTunnelUrl(url, { initial = false } = {}) {
594
+ const version = ++tunnelVersion;
595
+ if (!initial) {
596
+ console.log(`tunnel: changed to ${url}`);
597
+ await clearDevSession();
598
+ }
599
+ console.log("tunnel: waiting for DNS to go live (prevents cached failures in your browser)…");
600
+ const dnsLive = await waitForDns(url.replace("https://", ""));
601
+ if (ended || version !== tunnelVersion) return "superseded";
602
+ if (!dnsLive) {
603
+ if (initial) {
604
+ console.log("tunnel: DNS never propagated — dev mode registered on localhost (visible on this machine's browser only)");
605
+ } else {
606
+ console.log("tunnel: DNS never propagated for the new URL — keeping Studio offline until the next tunnel URL");
607
+ }
608
+ return "failed";
609
+ }
610
+ originUrl = url;
611
+ console.log(initial ? "tunnel: DNS live" : "tunnel: DNS live; Studio URL updated");
612
+ if (!initial && !(await heartbeat(originUrl))) {
613
+ console.log("dev-session: Studio still has no registered tunnel; the next heartbeat will retry");
614
+ }
615
+ return "activated";
616
+ }
617
+ const registerTunnelUrl = (url) => {
618
+ tunnelUpdate = tunnelUpdate.then(async () => {
619
+ if (ended || url === originUrl) return;
620
+ await activateTunnelUrl(url);
621
+ }).catch(() => {
622
+ console.log("tunnel: URL update failed — waiting for the next tunnel URL");
623
+ });
624
+ };
579
625
  if (!rest.includes("--no-tunnel")) {
580
626
  console.log("tunnel: starting (cloudflared quick tunnel)…");
581
- const t = await startTunnel(port);
627
+ const t = await startTunnel(port, registerTunnelUrl);
582
628
  tunnelChild = t.child;
583
629
  if (t.url) {
584
630
  console.log(`tunnel: ${t.url}`);
585
- console.log("tunnel: waiting for DNS to go live (prevents cached failures in your browser)…");
586
- if (await waitForDns(t.url.replace("https://", ""))) {
587
- originUrl = t.url;
588
- console.log("tunnel: DNS live");
589
- } else {
590
- console.log("tunnel: DNS never propagated — dev mode registered on localhost (visible on this machine's browser only)");
591
- }
631
+ await activateTunnelUrl(t.url, { initial: true });
592
632
  } else {
593
633
  console.log("tunnel: unavailable — dev mode registered on localhost (visible on this machine's browser only)");
594
634
  }
595
635
  }
596
- await heartbeat(originUrl);
597
- console.log(`studio: ${host}/studio/${meta.slug} — your app is live there while this runs; click Publish to ship`);
598
- hbTimer = setInterval(() => void heartbeat(originUrl), 30_000);
636
+ const registered = await heartbeat(originUrl);
637
+ console.log(
638
+ registered
639
+ ? `studio: ${host}/studio/${meta.slug} — your app is live there while this runs; click Publish to ship`
640
+ : `studio: waiting for ${host}/api/dev-session — leave this running; the next heartbeat will retry`,
641
+ );
642
+ hbTimer = setInterval(() => void heartbeat(originUrl), DEV_SESSION_HEARTBEAT_MS);
599
643
  }
600
644
 
601
645
  let announced = false;
@@ -629,6 +673,7 @@ async function resolvesVia(dohBase, hostname) {
629
673
  try {
630
674
  const r = await fetch(`${dohBase}?name=${hostname}&type=A`, {
631
675
  headers: { accept: "application/dns-json" },
676
+ signal: AbortSignal.timeout(3000),
632
677
  });
633
678
  const d = await r.json();
634
679
  return Array.isArray(d.Answer) && d.Answer.some((a) => a.type === 1);
@@ -658,7 +703,7 @@ async function waitForDns(hostname) {
658
703
  // Cloudflare quick tunnel via the cloudflared npm wrapper (downloads the
659
704
  // binary on first use). Resolves with the public URL, or null on failure —
660
705
  // dev mode then falls back to localhost-only registration.
661
- function startTunnel(port) {
706
+ function startTunnel(port, onUrlChange) {
662
707
  return new Promise((resolve) => {
663
708
  let child;
664
709
  try {
@@ -675,12 +720,19 @@ function startTunnel(port) {
675
720
  resolve({ child, url: null });
676
721
  }
677
722
  }, 45_000);
723
+ let currentUrl = null;
678
724
  const scan = (chunk) => {
679
- const m = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/.exec(String(chunk));
680
- if (m && !settled) {
681
- settled = true;
682
- clearTimeout(timer);
683
- resolve({ child, url: m[0] });
725
+ const urls = String(chunk).match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/g) ?? [];
726
+ for (const url of urls) {
727
+ if (url === currentUrl) continue;
728
+ currentUrl = url;
729
+ if (!settled) {
730
+ settled = true;
731
+ clearTimeout(timer);
732
+ resolve({ child, url });
733
+ } else {
734
+ onUrlChange?.(url);
735
+ }
684
736
  }
685
737
  };
686
738
  child.stdout.on("data", scan);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@montytools/cli",
3
- "version": "0.2.5",
3
+ "version": "0.2.6",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "monty": "./bin/monty.mjs"