@montytools/cli 0.2.5 → 0.2.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.
Files changed (2) hide show
  1. package/bin/monty.mjs +96 -27
  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");
@@ -487,6 +489,7 @@ async function dev() {
487
489
  let hbTimer = null;
488
490
  let publishing = false;
489
491
  let ended = false;
492
+ const sessionId = `dev_${randomBytes(16).toString("hex")}`;
490
493
  const buildFile = join(appDir, ".monty", "build");
491
494
  const buildId = existsSync(buildFile) ? readFileSync(buildFile, "utf8").trim() : undefined;
492
495
  // The DEV schema channel: heartbeats carry the compiled schema, and edits
@@ -511,24 +514,38 @@ async function dev() {
511
514
  } catch { /* transient fs hiccup — next beat retries */ }
512
515
  }
513
516
 
514
- async function endSession() {
515
- if (ended) return;
516
- ended = true;
517
- if (hbTimer) clearInterval(hbTimer);
518
- try { tunnelChild?.kill(); } catch { /* already gone */ }
517
+ async function clearDevSession(timeoutMs = 2000) {
519
518
  if (cfg?.key) {
520
519
  try {
521
520
  await fetch(`${host}/api/dev-session`, {
522
521
  method: "POST",
523
522
  headers: { authorization: `Bearer ${cfg.key}`, "content-type": "application/json" },
524
- body: JSON.stringify({ slug: meta.slug, end: true }),
525
- signal: AbortSignal.timeout(2000),
523
+ body: JSON.stringify({ slug: meta.slug, sessionId, end: true }),
524
+ signal: AbortSignal.timeout(timeoutMs),
526
525
  });
527
526
  } catch { /* best effort */ }
528
527
  }
529
528
  }
530
529
 
531
- async function heartbeat(originUrl) {
530
+ async function endSession() {
531
+ if (ended) return;
532
+ ended = true;
533
+ if (hbTimer) clearInterval(hbTimer);
534
+ try { tunnelChild?.kill(); } catch { /* already gone */ }
535
+ await clearDevSession();
536
+ }
537
+
538
+ function stopSuperseded(fix) {
539
+ if (ended) return;
540
+ ended = true;
541
+ if (hbTimer) clearInterval(hbTimer);
542
+ try { tunnelChild?.kill(); } catch { /* already gone */ }
543
+ try { child.kill(); } catch { /* already gone */ }
544
+ console.log(`dev-session: superseded — ${fix}`);
545
+ setTimeout(() => process.exit(0), 50);
546
+ }
547
+
548
+ async function heartbeat(originUrl, { claim = false } = {}) {
532
549
  await refreshSchemaIfChanged();
533
550
  try {
534
551
  const r = await fetch(`${host}/api/dev-session`, {
@@ -537,16 +554,23 @@ async function dev() {
537
554
  body: JSON.stringify({
538
555
  slug: meta.slug,
539
556
  tunnelUrl: originUrl,
557
+ sessionId,
558
+ claim,
540
559
  name: currentMeta.name,
541
560
  icon: currentMeta.icon,
542
561
  buildId,
543
562
  schemaJson: currentMeta.schemaJson,
544
563
  }),
564
+ signal: AbortSignal.timeout(DEV_SESSION_REQUEST_TIMEOUT_MS),
545
565
  });
546
566
  const data = await r.json().catch(() => null);
547
567
  if (!r.ok) {
568
+ if (data?.code === "DEV_SESSION_SUPERSEDED") {
569
+ stopSuperseded(data.fix ?? "A newer `monty dev` session is active for this app.");
570
+ return false;
571
+ }
548
572
  console.log(`dev-session: ${data?.code ?? r.status}${data?.fix ? ` — ${data.fix}` : ""}`);
549
- return;
573
+ return false;
550
574
  }
551
575
  if (data?.publishRequested && !publishing) {
552
576
  publishing = true;
@@ -567,7 +591,11 @@ async function dev() {
567
591
  });
568
592
  publishing = false;
569
593
  }
570
- } catch { /* transient network hiccup — next beat retries */ }
594
+ return true;
595
+ } catch {
596
+ console.log("dev-session: heartbeat failed — retrying on the next beat");
597
+ return false;
598
+ }
571
599
  }
572
600
 
573
601
  async function startDevSession() {
@@ -575,27 +603,60 @@ async function dev() {
575
603
  console.log("dev: not logged in — workspace dev mode disabled (run `monty login`)");
576
604
  return;
577
605
  }
606
+ await clearDevSession();
578
607
  let originUrl = `http://localhost:${port}`;
608
+ let tunnelUpdate = Promise.resolve();
609
+ let tunnelVersion = 0;
610
+ async function activateTunnelUrl(url, { initial = false } = {}) {
611
+ const version = ++tunnelVersion;
612
+ if (!initial) {
613
+ console.log(`tunnel: changed to ${url}`);
614
+ await clearDevSession();
615
+ }
616
+ console.log("tunnel: waiting for DNS to go live (prevents cached failures in your browser)…");
617
+ const dnsLive = await waitForDns(url.replace("https://", ""));
618
+ if (ended || version !== tunnelVersion) return "superseded";
619
+ if (!dnsLive) {
620
+ if (initial) {
621
+ console.log("tunnel: DNS never propagated — dev mode registered on localhost (visible on this machine's browser only)");
622
+ } else {
623
+ console.log("tunnel: DNS never propagated for the new URL — keeping Studio offline until the next tunnel URL");
624
+ }
625
+ return "failed";
626
+ }
627
+ originUrl = url;
628
+ console.log(initial ? "tunnel: DNS live" : "tunnel: DNS live; Studio URL updated");
629
+ if (!initial && !(await heartbeat(originUrl))) {
630
+ console.log("dev-session: Studio still has no registered tunnel; the next heartbeat will retry");
631
+ }
632
+ return "activated";
633
+ }
634
+ const registerTunnelUrl = (url) => {
635
+ tunnelUpdate = tunnelUpdate.then(async () => {
636
+ if (ended || url === originUrl) return;
637
+ await activateTunnelUrl(url);
638
+ }).catch(() => {
639
+ console.log("tunnel: URL update failed — waiting for the next tunnel URL");
640
+ });
641
+ };
579
642
  if (!rest.includes("--no-tunnel")) {
580
643
  console.log("tunnel: starting (cloudflared quick tunnel)…");
581
- const t = await startTunnel(port);
644
+ const t = await startTunnel(port, registerTunnelUrl);
582
645
  tunnelChild = t.child;
583
646
  if (t.url) {
584
647
  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
- }
648
+ await activateTunnelUrl(t.url, { initial: true });
592
649
  } else {
593
650
  console.log("tunnel: unavailable — dev mode registered on localhost (visible on this machine's browser only)");
594
651
  }
595
652
  }
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);
653
+ const registered = await heartbeat(originUrl, { claim: true });
654
+ console.log(
655
+ registered
656
+ ? `studio: ${host}/studio/${meta.slug} — your app is live there while this runs; click Publish to ship`
657
+ : `studio: waiting for ${host}/api/dev-session — leave this running; the next heartbeat will retry`,
658
+ );
659
+ hbTimer = setInterval(() => void heartbeat(originUrl), DEV_SESSION_HEARTBEAT_MS);
599
660
  }
600
661
 
601
662
  let announced = false;
@@ -629,6 +690,7 @@ async function resolvesVia(dohBase, hostname) {
629
690
  try {
630
691
  const r = await fetch(`${dohBase}?name=${hostname}&type=A`, {
631
692
  headers: { accept: "application/dns-json" },
693
+ signal: AbortSignal.timeout(3000),
632
694
  });
633
695
  const d = await r.json();
634
696
  return Array.isArray(d.Answer) && d.Answer.some((a) => a.type === 1);
@@ -658,7 +720,7 @@ async function waitForDns(hostname) {
658
720
  // Cloudflare quick tunnel via the cloudflared npm wrapper (downloads the
659
721
  // binary on first use). Resolves with the public URL, or null on failure —
660
722
  // dev mode then falls back to localhost-only registration.
661
- function startTunnel(port) {
723
+ function startTunnel(port, onUrlChange) {
662
724
  return new Promise((resolve) => {
663
725
  let child;
664
726
  try {
@@ -675,12 +737,19 @@ function startTunnel(port) {
675
737
  resolve({ child, url: null });
676
738
  }
677
739
  }, 45_000);
740
+ let currentUrl = null;
678
741
  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] });
742
+ const urls = String(chunk).match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/g) ?? [];
743
+ for (const url of urls) {
744
+ if (url === currentUrl) continue;
745
+ currentUrl = url;
746
+ if (!settled) {
747
+ settled = true;
748
+ clearTimeout(timer);
749
+ resolve({ child, url });
750
+ } else {
751
+ onUrlChange?.(url);
752
+ }
684
753
  }
685
754
  };
686
755
  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.7",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "monty": "./bin/monty.mjs"