@dutodev/cli 0.4.4 → 0.4.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.
package/README.md CHANGED
@@ -106,7 +106,7 @@ Com os túneis no ar, o terminal mostra cada túnel (URL, destino local, requisi
106
106
  | `tab`, `↑↓` | Alterna entre túneis e requisições e move a seleção |
107
107
  | `q` ou Ctrl+C | Fecha os túneis e sai |
108
108
 
109
- Se o túnel está no ar mas nada responde na porta local, quem abrir o endereço no navegador vê uma página do Duto que tenta de novo a cada 3 segundos e volta sozinha quando o serviço sobe. Clientes que não são navegador (`curl`, APIs) recebem a mesma resposta em texto. Em ambos os casos o status é `503` com `Retry-After: 3`.
109
+ Se o túnel está no ar mas nada responde na porta local, quem abrir o endereço no navegador vê uma página do Duto que volta sozinha quando o serviço sobe. Ela espera por uma conexão só (SSE): a CLI checa a porta na sua máquina e avisa a página assim que o serviço responde, sem recarregar de tempos em tempos. Clientes que não são navegador (`curl`, APIs) recebem a mesma resposta em texto. Em ambos os casos o status é `503` com `Retry-After: 3`. Os caminhos `/__duto/…` são reservados para isso e nunca chegam ao seu app.
110
110
 
111
111
  Fora de um terminal interativo (pipe, CI) ou com `--no-ui`, a CLI imprime um log simples. No Windows a tela cheia vem desligada; use `--ui` para tentar.
112
112
 
@@ -373,6 +373,7 @@ async function withFullscreen(stdout, run2) {
373
373
  const onSignal = () => ac.abort();
374
374
  process.on("SIGINT", onSignal);
375
375
  process.on("SIGTERM", onSignal);
376
+ process.on("SIGHUP", onSignal);
376
377
  process.once("exit", restore);
377
378
  stdout.write(ENTER_SCREEN);
378
379
  try {
@@ -380,6 +381,7 @@ async function withFullscreen(stdout, run2) {
380
381
  } finally {
381
382
  process.off("SIGINT", onSignal);
382
383
  process.off("SIGTERM", onSignal);
384
+ process.off("SIGHUP", onSignal);
383
385
  process.off("exit", restore);
384
386
  restore();
385
387
  }
package/dist/index.js CHANGED
@@ -33,7 +33,7 @@ var fail = (msg) => `${paint("err", "\u2716", process.stderr)} ${msg}`;
33
33
 
34
34
  // src/index.ts
35
35
  import { hostname } from "os";
36
- import { relative } from "path";
36
+ import { join as join5, relative } from "path";
37
37
  import cac from "cac";
38
38
 
39
39
  // src/api.ts
@@ -368,6 +368,24 @@ var esc = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">":
368
368
  function wantsHtml(accept) {
369
369
  return /\btext\/html\b/i.test(accept ?? "");
370
370
  }
371
+ var WAIT_SCRIPT = `
372
+ <script>
373
+ (function () {
374
+ var delay = 3000;
375
+ function poll() {
376
+ setTimeout(function () {
377
+ fetch(location.href, { method: "HEAD", cache: "no-store" })
378
+ .then(function (r) { if (r.status !== 503) location.reload(); else again(); })
379
+ .catch(again);
380
+ }, delay);
381
+ }
382
+ function again() { delay = Math.min(delay * 2, 30000); poll(); }
383
+ if (!window.EventSource) return poll();
384
+ var fails = 0, es = new EventSource("/__duto/espera");
385
+ es.addEventListener("up", function () { es.close(); location.reload(); });
386
+ es.onerror = function () { if (++fails >= 3) { es.close(); poll(); } };
387
+ })();
388
+ </script>`;
371
389
  function unavailablePage(o) {
372
390
  const tunnel2 = esc(o.tunnel);
373
391
  const local = `localhost:${o.port}`;
@@ -376,7 +394,7 @@ function unavailablePage(o) {
376
394
  <head>
377
395
  <meta charset="utf-8">
378
396
  <meta name="viewport" content="width=device-width, initial-scale=1">
379
- ${o.retry ? '<meta http-equiv="refresh" content="3">\n' : ""}<meta name="robots" content="noindex">
397
+ <meta name="robots" content="noindex">
380
398
  <title>Nada respondendo em ${local} \xB7 duto</title>
381
399
  <style>
382
400
  :root{--bg:#f6f6f3;--fg:#0f1110;--muted:#5f6360;--line:#dcddd8;--card:#fff;--signal:#2dd4bf;--signal-text:#0f766e}
@@ -400,8 +418,8 @@ h1 code{font-size:.86em;font-weight:600}
400
418
  <h1>Nada respondendo em <code>${local}</code></h1>
401
419
  <p>O t\xFAnel <code>${tunnel2}</code> est\xE1 no ar, mas o servi\xE7o local n\xE3o respondeu. Suba o servidor na m\xE1quina que abriu o t\xFAnel.</p>
402
420
  <p>Se voc\xEA s\xF3 recebeu este link, avise quem o enviou.</p>
403
- <div class="retry">${o.retry ? "<b>Tentando de novo a cada 3 s.</b> A p\xE1gina volta sozinha quando o servi\xE7o responder." : "Recarregue a p\xE1gina quando o servi\xE7o estiver no ar."}</div>
404
- </main>
421
+ <div class="retry">${o.retry ? `<b>Aguardando o servi\xE7o.</b> A p\xE1gina volta sozinha quando ele responder.<noscript> Recarregue quando o servi\xE7o estiver no ar.</noscript>` : "Recarregue a p\xE1gina quando o servi\xE7o estiver no ar."}</div>
422
+ </main>${o.retry ? WAIT_SCRIPT : ""}
405
423
  </body>
406
424
  </html>
407
425
  `;
@@ -485,16 +503,64 @@ function rewriteLocation(location, localHost2, publicHost) {
485
503
  function hostOf(req) {
486
504
  return (req.headers.host ?? "").replace(/:\d+$/, "").toLowerCase();
487
505
  }
506
+ var DUTO_PREFIX = "/__duto/";
507
+ var WAIT_PATH = "/__duto/espera";
508
+ var WAIT_MAX_MS = 10 * 6e4;
509
+ async function isUp(port) {
510
+ return await probe("127.0.0.1", port) || await probe("::1", port);
511
+ }
512
+ function waitForService(res, port, checkMs, pingMs) {
513
+ res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", "x-accel-buffering": "no" });
514
+ res.write(": duto\n\n");
515
+ let busy = false;
516
+ const stop = () => {
517
+ clearInterval(check);
518
+ clearInterval(ping);
519
+ clearTimeout(limit);
520
+ };
521
+ const check = setInterval(async () => {
522
+ if (busy) return;
523
+ busy = true;
524
+ const up = await isUp(port);
525
+ busy = false;
526
+ if (up && !res.writableEnded) {
527
+ stop();
528
+ res.end("event: up\ndata: 1\n\n");
529
+ }
530
+ }, checkMs);
531
+ const ping = setInterval(() => res.write(": ping\n\n"), pingMs);
532
+ const limit = setTimeout(() => {
533
+ stop();
534
+ res.end();
535
+ }, WAIT_MAX_MS);
536
+ res.on("close", stop);
537
+ }
488
538
  async function startProxy(o) {
489
539
  const max = o.maxBody ?? MAX_BODY;
490
540
  let nextId = 1;
541
+ const routeWaitMs = o.routeWaitMs ?? 3e3;
542
+ async function routeFor(req) {
543
+ const host = hostOf(req);
544
+ for (let waited = 0; ; waited += 50) {
545
+ const route = o.routes.get(host);
546
+ if (route || waited >= routeWaitMs) return route;
547
+ await new Promise((r) => setTimeout(r, 50));
548
+ }
549
+ }
491
550
  const server = createServer(async (req, res) => {
492
- const route = o.routes.get(hostOf(req));
551
+ req.pause();
552
+ const route = await routeFor(req);
493
553
  if (!route) {
494
554
  res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
495
555
  res.end("T\xFAnel n\xE3o encontrado nesta sess\xE3o");
496
556
  return;
497
557
  }
558
+ if ((req.url ?? "").startsWith(DUTO_PREFIX)) {
559
+ if (req.url === WAIT_PATH && req.method === "GET") return waitForService(res, route.port, o.waitCheckMs ?? 1e3, o.waitPingMs ?? 25e3);
560
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
561
+ res.end("Rota reservada do Duto");
562
+ return;
563
+ }
498
564
  const id = nextId++;
499
565
  const started = Date.now();
500
566
  const headers = rewrite(req.headers, route);
@@ -563,7 +629,7 @@ async function startProxy(o) {
563
629
  });
564
630
  server.on("upgrade", async (req, client, head) => {
565
631
  client.on("error", () => client.destroy());
566
- const route = o.routes.get(hostOf(req));
632
+ const route = await routeFor(req);
567
633
  if (!route) return void client.end("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n");
568
634
  const id = nextId++;
569
635
  const started = Date.now();
@@ -628,6 +694,74 @@ async function startProxy(o) {
628
694
  };
629
695
  }
630
696
 
697
+ // src/session/orphans.ts
698
+ import { spawnSync } from "child_process";
699
+ import { existsSync as existsSync4, mkdirSync as mkdirSync3, readdirSync, readFileSync as readFileSync3, rmSync as rmSync2, writeFileSync as writeFileSync3 } from "fs";
700
+ import { join as join4 } from "path";
701
+ var fileOf = (dir, cfPid) => join4(dir, `cloudflared-${cfPid}.json`);
702
+ function recordCloudflared(dir, cfPid, sessionPid = process.pid) {
703
+ try {
704
+ mkdirSync3(dir, { recursive: true });
705
+ writeFileSync3(fileOf(dir, cfPid), JSON.stringify({ sessionPid, cfPid, at: Date.now() }));
706
+ } catch {
707
+ }
708
+ }
709
+ function forgetCloudflared(dir, cfPid) {
710
+ rmSync2(fileOf(dir, cfPid), { force: true });
711
+ }
712
+ var realProc = {
713
+ alive(pid) {
714
+ try {
715
+ process.kill(pid, 0);
716
+ return true;
717
+ } catch (err) {
718
+ return err.code === "EPERM";
719
+ }
720
+ },
721
+ isCloudflared(pid) {
722
+ try {
723
+ if (process.platform === "linux") return readFileSync3(`/proc/${pid}/cmdline`, "utf8").includes("cloudflared");
724
+ if (process.platform === "darwin") return spawnSync("ps", ["-p", String(pid), "-o", "command="], { encoding: "utf8" }).stdout.includes("cloudflared");
725
+ } catch {
726
+ return false;
727
+ }
728
+ return false;
729
+ },
730
+ kill(pid) {
731
+ try {
732
+ process.kill(pid, "SIGTERM");
733
+ } catch {
734
+ }
735
+ }
736
+ };
737
+ function reapOrphans(dir, proc = realProc) {
738
+ if (!existsSync4(dir)) return 0;
739
+ let killed = 0;
740
+ for (const name of readdirSync(dir)) {
741
+ if (!/^cloudflared-\d+\.json$/.test(name)) continue;
742
+ const path = join4(dir, name);
743
+ let rec;
744
+ try {
745
+ rec = JSON.parse(readFileSync3(path, "utf8"));
746
+ } catch {
747
+ rmSync2(path, { force: true });
748
+ continue;
749
+ }
750
+ const { sessionPid, cfPid } = rec;
751
+ if (typeof sessionPid !== "number" || typeof cfPid !== "number") {
752
+ rmSync2(path, { force: true });
753
+ continue;
754
+ }
755
+ if (proc.alive(sessionPid)) continue;
756
+ if (proc.alive(cfPid) && proc.isCloudflared(cfPid)) {
757
+ proc.kill(cfPid);
758
+ killed++;
759
+ }
760
+ rmSync2(path, { force: true });
761
+ }
762
+ return killed;
763
+ }
764
+
631
765
  // src/session/records.ts
632
766
  var RecordStore = class {
633
767
  constructor(max = 500) {
@@ -709,6 +843,7 @@ var Session = class {
709
843
  }
710
844
  /** Abre os túneis em sequência; falhas ficam no estado e não interrompem os outros. */
711
845
  async start(specs) {
846
+ if (this.o.runDir) reapOrphans(this.o.runDir);
712
847
  this.proxy = await startProxy({ routes: this.routes, store: this.records });
713
848
  this.tunnels = specs.map((s) => this.blank(s, false));
714
849
  this.emit();
@@ -797,6 +932,11 @@ var Session = class {
797
932
  stdio: ["ignore", "ignore", "pipe"]
798
933
  });
799
934
  this.child = c;
935
+ const runDir = this.o.runDir;
936
+ if (runDir && c.pid) {
937
+ recordCloudflared(runDir, c.pid);
938
+ c.once("exit", () => forgetCloudflared(runDir, c.pid));
939
+ }
800
940
  let buf = "";
801
941
  c.stderr?.on("data", (chunk) => {
802
942
  if (c !== this.child) return;
@@ -954,21 +1094,23 @@ async function runSession(specs, source, flags) {
954
1094
  } catch (err) {
955
1095
  fail2(err instanceof Error ? err.message : String(err));
956
1096
  }
957
- const session = new Session({ api, deviceId: config.deviceId, deviceName: hostname(), cloudflaredPath, source });
1097
+ const session = new Session({ api, deviceId: config.deviceId, deviceName: hostname(), cloudflaredPath, source, runDir: join5(dutoHome(), "run") });
958
1098
  if (wantsUi(flags)) {
959
- const { runFullscreen } = await import("./fullscreen-5437X7MP.js");
960
- process.exit(await runFullscreen(session, specs, "0.4.4"));
1099
+ const { runFullscreen } = await import("./fullscreen-EPWB53EP.js");
1100
+ process.exit(await runFullscreen(session, specs, "0.4.6"));
961
1101
  }
962
- console.log(`${mark()} ${paint("muted", `${"0.4.4"} \xB7 ${source} \xB7 Ctrl+C para encerrar`)}`);
1102
+ console.log(`${mark()} ${paint("muted", `${"0.4.6"} \xB7 ${source} \xB7 Ctrl+C para encerrar`)}`);
963
1103
  const stopLog = runPlain(session, (l) => console.log(l));
964
1104
  const stopped = new Promise((resolve) => {
965
1105
  const onSignal = () => {
966
1106
  process.off("SIGINT", onSignal);
967
1107
  process.off("SIGTERM", onSignal);
1108
+ process.off("SIGHUP", onSignal);
968
1109
  resolve();
969
1110
  };
970
1111
  process.on("SIGINT", onSignal);
971
1112
  process.on("SIGTERM", onSignal);
1113
+ process.on("SIGHUP", onSignal);
972
1114
  });
973
1115
  await Promise.race([session.start(specs), stopped]);
974
1116
  await stopped;
@@ -1046,7 +1188,7 @@ cli.help(
1046
1188
  body: s.body.replace(/^\s*For more info, run any command with the `--help` flag:/m, " Mais detalhes de um comando: duto <comando> --help").replace("Display this message", "Mostra esta ajuda").replace("Display version number", "Mostra a vers\xE3o")
1047
1189
  }))
1048
1190
  );
1049
- cli.version("0.4.4");
1191
+ cli.version("0.4.6");
1050
1192
  try {
1051
1193
  cli.parse(process.argv, { run: false });
1052
1194
  await cli.runMatchedCommand();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dutodev/cli",
3
- "version": "0.4.4",
3
+ "version": "0.4.6",
4
4
  "description": "Exponha uma porta local em https://<nome>-<usuário>.duto.dev com um comando.",
5
5
  "license": "MIT",
6
6
  "type": "module",