@dutodev/cli 0.7.0 → 1.0.0

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/dist/index.js CHANGED
@@ -10,7 +10,7 @@ import {
10
10
  normalizeName,
11
11
  statusColor,
12
12
  validateName
13
- } from "./chunk-74IJFYGT.js";
13
+ } from "./chunk-BPOXISIM.js";
14
14
 
15
15
  // src/ui/brand.ts
16
16
  function colorOn(stream = process.stdout) {
@@ -20,7 +20,6 @@ function colorOn(stream = process.stdout) {
20
20
  }
21
21
  var SGR = {
22
22
  signal: "38;2;45;212;191",
23
- // #2dd4bf
24
23
  muted: "38;2;138;143;139",
25
24
  strong: "1",
26
25
  ok: "38;2;74;222;128",
@@ -52,9 +51,9 @@ var DutoApiError = class extends Error {
52
51
  code;
53
52
  };
54
53
  var MESSAGES = {
55
- unauthorized: "Authtoken inv\xE1lido. Gere um novo em https://duto.dev e rode: duto login <token>",
56
- suspended: "Sua conta est\xE1 suspensa.",
57
- network: "N\xE3o consegui falar com duto.dev. Verifique sua conex\xE3o."
54
+ unauthorized: "Invalid authtoken. Generate a new one at https://duto.dev and run: duto login <token>",
55
+ suspended: "Your account is suspended.",
56
+ network: "Could not reach duto.dev. Check your connection."
58
57
  };
59
58
  function createApiClient(o) {
60
59
  const doFetch = o.fetchImpl ?? fetch;
@@ -80,7 +79,7 @@ function createApiClient(o) {
80
79
  const code = data.error ?? "http_error";
81
80
  if (res.status === 401) throw new DutoApiError(MESSAGES.unauthorized, 401, code);
82
81
  if (res.status === 403 && code === "suspended") throw new DutoApiError(MESSAGES.suspended, 403, code);
83
- throw new DutoApiError(data.message ?? `Erro ${res.status} ao falar com duto.dev.`, res.status, code);
82
+ throw new DutoApiError(data.message ?? `duto.dev returned HTTP ${res.status}.`, res.status, code);
84
83
  }
85
84
  async function json(method, path, body) {
86
85
  const res = await call(method, path, body);
@@ -169,7 +168,7 @@ function assetFor(platform, arch) {
169
168
  case "win32/x64":
170
169
  return { asset: "cloudflared-windows-amd64.exe", kind: "exe" };
171
170
  default:
172
- throw new Error(`Plataforma n\xE3o suportada: ${key}`);
171
+ throw new Error(`Unsupported platform: ${key}`);
173
172
  }
174
173
  }
175
174
  function downloadUrl(asset, version = CLOUDFLARED_VERSION) {
@@ -184,13 +183,13 @@ async function ensureCloudflared(opts = {}) {
184
183
  opts.onDownload?.();
185
184
  mkdirSync2(binDir, { recursive: true });
186
185
  const res = await (opts.fetchImpl ?? fetch)(downloadUrl(asset));
187
- if (!res.ok) throw new Error(`N\xE3o consegui baixar o cloudflared (HTTP ${res.status}).`);
186
+ if (!res.ok) throw new Error(`Could not download cloudflared (HTTP ${res.status}).`);
188
187
  const bytes = Buffer.from(await res.arrayBuffer());
189
188
  const expected = (opts.checksums ?? CHECKSUMS)[asset];
190
189
  const actual = createHash("sha256").update(bytes).digest("hex");
191
190
  if (!expected || actual !== expected) {
192
191
  rmSync(target, { force: true });
193
- throw new Error("Falha na verifica\xE7\xE3o do cloudflared");
192
+ throw new Error("cloudflared checksum mismatch. Try again; if it keeps failing, update duto.");
194
193
  }
195
194
  const tmp = mkdtempSync(join2(binDir, ".download-"));
196
195
  try {
@@ -217,13 +216,13 @@ async function ensureCloudflaredWithProgress(write, opts = {}) {
217
216
  ...opts,
218
217
  onDownload: () => {
219
218
  downloading = true;
220
- write("\u2193 Baixando cloudflared (s\xF3 na primeira vez)... ");
219
+ write("\u2193 Downloading cloudflared (first run only)... ");
221
220
  }
222
221
  });
223
222
  if (downloading) write("ok\n");
224
223
  return path;
225
224
  } catch (err) {
226
- if (downloading) write("falhou\n");
225
+ if (downloading) write("failed\n");
227
226
  throw err;
228
227
  }
229
228
  }
@@ -274,8 +273,8 @@ var SOURCES = {
274
273
  github: { url: "https://api.github.com/meta", pick: (j) => j?.hooks }
275
274
  };
276
275
  function splitCidr(raw) {
277
- const [ip, p, ...resto] = raw.split("/");
278
- if (!ip || resto.length) return null;
276
+ const [ip, p, ...extra] = raw.split("/");
277
+ if (!ip || extra.length) return null;
279
278
  const v = isIP(ip);
280
279
  if (!v) return null;
281
280
  const max = v === 4 ? 32 : 128;
@@ -311,9 +310,9 @@ async function fetchRanges(name, f) {
311
310
  const res = await f(src.url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), headers: { accept: "application/json" } });
312
311
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
313
312
  const list = src.pick(await res.json());
314
- if (!Array.isArray(list)) throw new Error("formato inesperado");
313
+ if (!Array.isArray(list)) throw new Error("unexpected format");
315
314
  const out = list.filter((x) => typeof x === "string" && splitCidr(x) !== null);
316
- if (!out.length) throw new Error("lista vazia");
315
+ if (!out.length) throw new Error("empty list");
317
316
  return out.map((x) => x.includes("/") ? x : `${x}/${splitCidr(x).prefix}`);
318
317
  }
319
318
  async function resolveAllow(entries, o = {}) {
@@ -325,7 +324,8 @@ async function resolveAllow(entries, o = {}) {
325
324
  const cached = readCache(cacheFile);
326
325
  const fresh = cached && now - cached.fetchedAt >= 0 && now - cached.fetchedAt < CACHE_MS ? cached : null;
327
326
  const lists = {};
328
- if (fresh && names.every((n) => Array.isArray(fresh.lists[n]) && fresh.lists[n].length)) {
327
+ const coversEveryName = fresh && names.every((n) => Array.isArray(fresh.lists[n]) && fresh.lists[n].length);
328
+ if (fresh && coversEveryName) {
329
329
  for (const n of names) lists[n] = fresh.lists[n];
330
330
  } else {
331
331
  const net = {};
@@ -336,7 +336,8 @@ async function resolveAllow(entries, o = {}) {
336
336
  lists[name] = ALLOW_SNAPSHOT[name];
337
337
  }
338
338
  }
339
- if (names.length && Object.keys(net).length === names.length) {
339
+ const everyListFromNetwork = names.length > 0 && Object.keys(net).length === names.length;
340
+ if (everyListFromNetwork) {
340
341
  try {
341
342
  mkdirSync3(dirname(cacheFile), { recursive: true });
342
343
  writeFileSync3(cacheFile, JSON.stringify({ fetchedAt: now, lists: net }));
@@ -366,7 +367,7 @@ import { dirname as dirname2, join as join4 } from "path";
366
367
  import { isMap, isScalar, isSeq, LineCounter, parseDocument } from "yaml";
367
368
  var ConfigError = class extends Error {
368
369
  constructor(message2, line, file = CONFIG_FILES[0]) {
369
- super(!file ? message2 : line ? `${file}, linha ${line}: ${message2}` : `${file}: ${message2}`);
370
+ super(!file ? message2 : line ? `${file}, line ${line}: ${message2}` : `${file}: ${message2}`);
370
371
  this.line = line;
371
372
  this.name = "ConfigError";
372
373
  }
@@ -376,7 +377,7 @@ var CONFIG_FILES = ["duto.yml", "duto.yaml"];
376
377
  var LABEL = "[a-z0-9](?:[a-z0-9-]*[a-z0-9])?";
377
378
  var HOST_RE = new RegExp(`^${LABEL}(?:\\.${LABEL})*(?::\\d{1,5})?$`);
378
379
  var HEADER_NAME_RE = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
379
- var TUNNEL_KEYS = ["port", "host", "headers", "rewrite_redirects", "auth", "permitir"];
380
+ var TUNNEL_KEYS = ["port", "host", "headers", "rewrite_redirects", "auth", "allow"];
380
381
  var ENV_REF_RE = /\$\{([A-Z_0-9]+)\}/g;
381
382
  function parseHost(raw) {
382
383
  const host = raw.trim().toLowerCase();
@@ -402,11 +403,11 @@ function parseConfig(text, file = CONFIG_FILES[0], env = process.env) {
402
403
  const lineAt = (offset) => offset === void 0 ? void 0 : lc.linePos(offset).line;
403
404
  if (doc.errors.length) {
404
405
  const e = doc.errors[0];
405
- throw fail3("YAML inv\xE1lido. Confira a indenta\xE7\xE3o e os dois-pontos.", lineAt(e.pos[0]));
406
+ throw fail3("invalid YAML. Check the indentation and the colons.", lineAt(e.pos[0]));
406
407
  }
407
408
  const tunnels = isMap(doc.contents) ? doc.contents.get("tunnels", true) : void 0;
408
409
  if (!isMap(tunnels) || tunnels.items.length === 0) {
409
- throw fail3('declare os t\xFAneis em "tunnels:", um por linha (ex.: web: 3000).');
410
+ throw fail3('declare the tunnels under "tunnels:", one per line (e.g. web: 3000)');
410
411
  }
411
412
  const specs = [];
412
413
  for (const pair of tunnels.items) {
@@ -416,40 +417,40 @@ function parseConfig(text, file = CONFIG_FILES[0], env = process.env) {
416
417
  const v = validateName(name);
417
418
  if (!v.ok) {
418
419
  throw fail3(
419
- v.reason === "reserved" ? `nome reservado: "${raw}". Escolha outro.` : `nome inv\xE1lido: "${raw}" (use s\xF3 letras min\xFAsculas e n\xFAmeros, sem h\xEDfen)`,
420
+ v.reason === "reserved" ? `reserved name: "${raw}". Choose another.` : `invalid name: "${raw}" (use only lowercase letters and digits, no hyphen)`,
420
421
  line
421
422
  );
422
423
  }
423
424
  const port = portOf(pair.value);
424
- if (port === null) throw fail3(`porta inv\xE1lida para "${raw}" (use 1\u201365535)`, line);
425
+ if (port === null) throw fail3(`invalid port for "${raw}" (use 1\u201365535)`, line);
425
426
  const spec = { name, port };
426
427
  if (isMap(pair.value)) {
427
428
  const body = pair.value;
428
429
  for (const item of body.items) {
429
430
  const key = isScalar(item.key) ? String(item.key.value) : "";
430
- if (!TUNNEL_KEYS.includes(key)) throw fail3(`chave desconhecida em "${raw}": ${key} (use port, host, headers, rewrite_redirects, auth ou permitir)`, line);
431
+ if (!TUNNEL_KEYS.includes(key)) throw fail3(`unknown key in "${raw}": ${key} (use port, host, headers, rewrite_redirects, auth or allow)`, line);
431
432
  }
432
433
  const hostNode = body.get("host", true);
433
434
  if (hostNode !== void 0) {
434
435
  const host = isScalar(hostNode) && typeof hostNode.value === "string" ? parseHost(hostNode.value) : null;
435
- if (!host) throw fail3(`host inv\xE1lido para "${raw}": use s\xF3 o dom\xEDnio, como meuapp.test`, line);
436
+ if (!host) throw fail3(`invalid host for "${raw}": use only the domain, like myapp.test`, line);
436
437
  spec.host = host;
437
438
  }
438
439
  const headersNode = body.get("headers", true);
439
440
  if (headersNode !== void 0) {
440
- if (!isMap(headersNode)) throw fail3(`headers de "${raw}" deve ser uma lista de Nome: valor`, line);
441
+ if (!isMap(headersNode)) throw fail3(`headers in "${raw}" must be a list of Name: value`, line);
441
442
  const headers = {};
442
443
  for (const h of headersNode.items) {
443
444
  const hName = isScalar(h.key) ? String(h.key.value) : "";
444
445
  const hLine = lineAt(h.key?.range?.[0]);
445
- if (!HEADER_NAME_RE.test(hName)) throw fail3(`cabe\xE7alho inv\xE1lido em "${raw}": "${hName}"`, hLine);
446
- if (hName.toLowerCase() === "host") throw fail3(`para trocar o Host de "${raw}", use host: em vez de headers`, hLine);
446
+ if (!HEADER_NAME_RE.test(hName)) throw fail3(`invalid header in "${raw}": "${hName}"`, hLine);
447
+ if (hName.toLowerCase() === "host") throw fail3(`to change the Host of "${raw}", use host: instead of headers`, hLine);
447
448
  const value = h.value === null ? null : isScalar(h.value) ? h.value.value : void 0;
448
449
  if (value !== null && !["string", "number", "boolean"].includes(typeof value)) {
449
- throw fail3(`valor inv\xE1lido para o cabe\xE7alho "${hName}" em "${raw}"`, hLine);
450
+ throw fail3(`invalid value for header "${hName}" in "${raw}"`, hLine);
450
451
  }
451
452
  const str = value === null ? "" : String(value);
452
- if (/[\r\n]/.test(str)) throw fail3(`valor inv\xE1lido para o cabe\xE7alho "${hName}" em "${raw}"`, hLine);
453
+ if (/[\r\n]/.test(str)) throw fail3(`invalid value for header "${hName}" in "${raw}"`, hLine);
453
454
  headers[hName.toLowerCase()] = str;
454
455
  }
455
456
  spec.headers = headers;
@@ -457,50 +458,50 @@ function parseConfig(text, file = CONFIG_FILES[0], env = process.env) {
457
458
  const rewriteNode = body.get("rewrite_redirects", true);
458
459
  if (rewriteNode !== void 0) {
459
460
  const value = isScalar(rewriteNode) ? rewriteNode.value : void 0;
460
- if (typeof value !== "boolean") throw fail3(`rewrite_redirects de "${raw}" deve ser true ou false`, line);
461
- if (!spec.host) throw fail3(`rewrite_redirects de "${raw}" s\xF3 vale junto com host:`, line);
461
+ if (typeof value !== "boolean") throw fail3(`rewrite_redirects in "${raw}" must be true or false`, line);
462
+ if (!spec.host) throw fail3(`rewrite_redirects in "${raw}" only works together with host:`, line);
462
463
  if (value === false) spec.rewriteRedirects = false;
463
464
  }
464
465
  const authNode = body.get("auth", true);
465
466
  if (authNode !== void 0) {
466
- if (!isSeq(authNode)) throw fail3(`auth de "${raw}" deve ser uma lista de usuario:senha`, line);
467
+ if (!isSeq(authNode)) throw fail3(`auth in "${raw}" must be a list of user:password`, line);
467
468
  const auth = [];
468
469
  for (const item of authNode.items) {
469
470
  const iLine = lineAt(item.range?.[0]) ?? line;
470
471
  const text2 = isScalar(item) && typeof item.value === "string" ? item.value : null;
471
- if (text2 === null) throw fail3(`credencial inv\xE1lida em "${raw}": use usuario:senha`, iLine);
472
+ if (text2 === null) throw fail3(`invalid credential in "${raw}": use user:password`, iLine);
472
473
  const expanded = text2.replace(ENV_REF_RE, (_m, name2) => {
473
474
  const val = env[name2];
474
- if (val === void 0) throw fail3(`vari\xE1vel de ambiente ${name2} n\xE3o definida (usada em auth de "${raw}")`, iLine);
475
+ if (val === void 0) throw fail3(`environment variable ${name2} is not set (used in auth of "${raw}")`, iLine);
475
476
  return val;
476
477
  });
477
478
  const parsed = parseCredential(expanded);
478
479
  if (!parsed.ok) {
479
- if (parsed.reason === "format") throw fail3(`credencial inv\xE1lida em "${raw}": use usuario:senha`, iLine);
480
- if (parsed.reason === "user") throw fail3(`usu\xE1rio inv\xE1lido em "${raw}": de 1 a 128 caracteres, sem espa\xE7o`, iLine);
481
- throw fail3(`senha de "${expanded.slice(0, expanded.indexOf(":"))}" em "${raw}" precisa ter de 8 a 128 caracteres`, iLine);
480
+ if (parsed.reason === "format") throw fail3(`invalid credential in "${raw}": use user:password`, iLine);
481
+ if (parsed.reason === "user") throw fail3(`invalid user in "${raw}": 1 to 128 characters, no spaces`, iLine);
482
+ throw fail3(`password for "${expanded.slice(0, expanded.indexOf(":"))}" in "${raw}" must be 8 to 128 characters`, iLine);
482
483
  }
483
- if (auth.some((c) => c.user === parsed.cred.user)) throw fail3(`usu\xE1rio "${parsed.cred.user}" aparece duas vezes em "${raw}"`, iLine);
484
+ if (auth.some((c) => c.user === parsed.cred.user)) throw fail3(`user "${parsed.cred.user}" appears twice in "${raw}"`, iLine);
484
485
  auth.push(parsed.cred);
485
486
  }
486
- if (!auth.length) throw fail3(`auth de "${raw}" n\xE3o pode ficar vazia`, line);
487
+ if (!auth.length) throw fail3(`auth in "${raw}" cannot be empty`, line);
487
488
  spec.auth = auth;
488
489
  }
489
- const allowNode = body.get("permitir", true);
490
+ const allowNode = body.get("allow", true);
490
491
  if (allowNode !== void 0) {
491
- if (!isSeq(allowNode)) throw fail3(`permitir de "${raw}" deve ser uma lista`, line);
492
- if (!spec.auth) throw fail3(`permitir de "${raw}" s\xF3 vale junto com auth:`, line);
492
+ if (!isSeq(allowNode)) throw fail3(`allow in "${raw}" must be a list`, line);
493
+ if (!spec.auth) throw fail3(`allow in "${raw}" only works together with auth:`, line);
493
494
  const allow = [];
494
495
  for (const item of allowNode.items) {
495
496
  const iLine = lineAt(item.range?.[0]) ?? line;
496
497
  const value = isScalar(item) ? String(item.value) : "";
497
- if (!parseAllowEntry(value)) throw fail3(`permitir de "${raw}": "${value}" n\xE3o \xE9 um nome conhecido (stripe, github) nem uma faixa de IP`, iLine);
498
+ if (!parseAllowEntry(value)) throw fail3(`allow in "${raw}": "${value}" is not a known name (stripe, github) or an IP range`, iLine);
498
499
  allow.push(value);
499
500
  }
500
501
  if (allow.length) spec.allow = allow;
501
502
  }
502
503
  }
503
- if (specs.some((s) => s.name === name)) throw fail3(`o nome "${raw}" aparece duas vezes`, line);
504
+ if (specs.some((s) => s.name === name)) throw fail3(`the name "${raw}" appears twice`, line);
504
505
  specs.push(spec);
505
506
  }
506
507
  return specs;
@@ -509,7 +510,7 @@ function findConfig(cwd) {
509
510
  let dir = cwd;
510
511
  for (; ; ) {
511
512
  const present = CONFIG_FILES.filter((f) => existsSync3(join4(dir, f)));
512
- if (present.length > 1) throw new ConfigError(`${CONFIG_FILES.join(" e ")} na mesma pasta. Apague um dos dois.`, void 0, "");
513
+ if (present.length > 1) throw new ConfigError(`${CONFIG_FILES.join(" and ")} in the same folder. Delete one of them.`, void 0, "");
513
514
  if (present.length === 1) {
514
515
  const path = join4(dir, present[0]);
515
516
  return { path, tunnels: parseConfig(readFileSync3(path, "utf8"), present[0]) };
@@ -522,6 +523,9 @@ function findConfig(cwd) {
522
523
  }
523
524
 
524
525
  // src/inspector/jsonView.ts
526
+ function countLabel(n, one, many) {
527
+ return n + " " + (n === 1 ? one : many);
528
+ }
525
529
  function jsonSyntaxError(text) {
526
530
  try {
527
531
  JSON.parse(text);
@@ -697,14 +701,15 @@ th { color: ${JSON_KEY}; font-weight: 400; white-space: nowrap; }
697
701
  }
698
702
  `;
699
703
  var SCRIPT = String.raw`"use strict";
704
+ var countLabel = (${countLabel.toString()});
700
705
  var jsonSyntaxError = (${jsonSyntaxError.toString()});
701
706
  var jsonTokens = (${jsonTokens.toString()});
702
707
  var jsonIndent = (${jsonIndent.toString()});
703
708
  var COLORS = ${JSON.stringify(C)};
704
709
  var TOKEN = new URLSearchParams(location.search).get("t") || "";
705
- var TABS = ["Corpo", "Cabeçalhos", "Resposta", "Raw"];
710
+ var TABS = ["Body", "Headers", "Response", "Raw"];
706
711
 
707
- var state = { items: [], tunnels: [], selected: null, detail: null, detailKey: "", tab: "Corpo", ended: false };
712
+ var state = { items: [], tunnels: [], selected: null, detail: null, detailKey: "", tab: "Body", ended: false };
708
713
  var listSeq = 0, detailSeq = 0, msgTimer = null;
709
714
  var es = null, lost = false, replaying = false;
710
715
  function fail(e) { if (!state.ended) console.error(e); }
@@ -725,12 +730,12 @@ function banner(text) {
725
730
  }
726
731
  function ended() {
727
732
  state.ended = true;
728
- banner("Sessão encerrada; abra o inspetor de novo pela tecla i.");
733
+ banner("Session ended. Press i in duto to open the inspector again.");
729
734
  if (es) es.close();
730
735
  }
731
736
 
732
737
  function api(path, method) {
733
- return fetch(path, { method: method || "GET", headers: { "X-Duto-Inspetor": TOKEN }, cache: "no-store" }).then(function (r) {
738
+ return fetch(path, { method: method || "GET", headers: { "X-Duto-Inspector": TOKEN }, cache: "no-store" }).then(function (r) {
734
739
  if (r.status === 403) { ended(); throw new Error("403"); }
735
740
  return r.json().then(function (body) { return { status: r.status, body: body }; });
736
741
  });
@@ -742,22 +747,20 @@ function statusInfo(it) {
742
747
  var color = s >= 500 ? COLORS.err : s >= 400 ? COLORS.warn : s >= 300 ? COLORS.signal : COLORS.ok;
743
748
  return { text: String(s), color: color };
744
749
  }
745
- if (it.error) return { text: "erro", color: COLORS.err };
750
+ if (it.error) return { text: "error", color: COLORS.err };
746
751
  return { text: "…", color: COLORS.muted };
747
752
  }
748
753
  function duration(it) { return it.durationMs === null || it.durationMs === undefined ? "" : it.durationMs + " ms"; }
749
754
  function size(n) {
750
755
  if (n < 1024) return n + " B";
751
- if (n < 1024 * 1024) return (n / 1024).toFixed(1).replace(".", ",") + " KB";
752
- return (n / 1024 / 1024).toFixed(1).replace(".", ",") + " MB";
756
+ if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
757
+ return (n / 1024 / 1024).toFixed(1) + " MB";
753
758
  }
754
759
  function methodOf(it) { return it.websocket ? "WS" : it.method; }
755
760
  function header(h, name) {
756
761
  for (var k in h) if (k.toLowerCase() === name) return h[k];
757
762
  return "";
758
763
  }
759
-
760
- // Mesma regra do filtro da tela do terminal.
761
764
  function visible() {
762
765
  var f = $("filter").value.trim().toLowerCase();
763
766
  if (!f) return state.items;
@@ -768,13 +771,13 @@ function visible() {
768
771
 
769
772
  function renderTop() {
770
773
  var n = state.items.length;
771
- $("count").textContent = n + (n === 1 ? " requisição" : " requisições");
774
+ $("count").textContent = countLabel(n, "request", "requests");
772
775
  var box = $("tunnels");
773
776
  clear(box);
774
777
  state.tunnels.forEach(function (t) {
775
778
  var s = el("span");
776
779
  s.appendChild(el("span", "k", t.key));
777
- s.appendChild(document.createTextNode(" " + (t.url || "sem endereço ainda")));
780
+ s.appendChild(document.createTextNode(" " + (t.url || "no URL yet")));
778
781
  box.appendChild(s);
779
782
  });
780
783
  }
@@ -786,7 +789,7 @@ function renderList() {
786
789
  clear(list);
787
790
  var items = visible();
788
791
  if (items.length === 0) {
789
- list.appendChild(el("div", "empty", state.items.length === 0 ? "Nenhuma requisição ainda." : "Nada casa com o filtro."));
792
+ list.appendChild(el("div", "empty", state.items.length === 0 ? "No requests yet." : "No requests match the filter."));
790
793
  return;
791
794
  }
792
795
  items.forEach(function (it) {
@@ -812,7 +815,7 @@ function renderList() {
812
815
 
813
816
  function loadList() {
814
817
  var seq = ++listSeq;
815
- return Promise.all([api("/api/requisicoes"), api("/api/sessao")]).then(function (rs) {
818
+ return Promise.all([api("/api/requests"), api("/api/session")]).then(function (rs) {
816
819
  if (seq !== listSeq) return;
817
820
  state.items = rs[0].body.items || [];
818
821
  state.tunnels = rs[1].body.tunnels || [];
@@ -837,13 +840,13 @@ function select(id) {
837
840
 
838
841
  function loadDetail(id) {
839
842
  var seq = ++detailSeq;
840
- return api("/api/requisicoes/" + id).then(function (r) {
843
+ return api("/api/requests/" + id).then(function (r) {
841
844
  if (seq !== detailSeq || state.selected !== id) return;
842
845
  var key = JSON.stringify(r.body);
843
- if (key === state.detailKey) return; // nada mudou: mantém dobras e rolagem
846
+ if (key === state.detailKey) return;
844
847
  state.detailKey = key;
845
848
  state.detail = r.status === 200 ? r.body : null;
846
- if (r.status !== 200) renderGone(r.body && r.body.error ? r.body.error : "Essa requisição já saiu da lista.");
849
+ if (r.status !== 200) renderGone(r.body && r.body.error ? r.body.error : "That request is no longer in the list.");
847
850
  else renderDetail();
848
851
  }).catch(fail);
849
852
  }
@@ -872,7 +875,7 @@ function renderDetail() {
872
875
  if (duration(d)) parts.push(duration(d));
873
876
  parts.push(size(d.bytes));
874
877
  parts.push(d.tunnel);
875
- if (d.replayOf !== undefined) parts.push("↻ reenvio de #" + d.replayOf);
878
+ if (d.replayOf !== undefined) parts.push("↻ replay of #" + d.replayOf);
876
879
  head.appendChild(el("span", "mut", " · " + parts.join(" · ")));
877
880
  if (d.error) head.appendChild(el("div", "msg err", d.error));
878
881
 
@@ -891,12 +894,12 @@ function renderContent() {
891
894
  var box = $("content");
892
895
  var top = box.scrollTop;
893
896
  clear(box);
894
- if (state.tab === "Corpo") renderBody(box, d.reqBody, d.reqNote, d.reqTruncatedNote, d.reqHeaders);
895
- else if (state.tab === "Cabeçalhos") box.appendChild(table(d.reqHeaders));
896
- else if (state.tab === "Resposta") {
897
- box.appendChild(el("div", "sect", "Cabeçalhos"));
897
+ if (state.tab === "Body") renderBody(box, d.reqBody, d.reqNote, d.reqTruncatedNote, d.reqHeaders);
898
+ else if (state.tab === "Headers") box.appendChild(table(d.reqHeaders));
899
+ else if (state.tab === "Response") {
900
+ box.appendChild(el("div", "sect", "Headers"));
898
901
  box.appendChild(table(d.resHeaders));
899
- box.appendChild(el("div", "sect", "Corpo"));
902
+ box.appendChild(el("div", "sect", "Body"));
900
903
  renderBody(box, d.resBody, d.resNote, d.resTruncatedNote, d.resHeaders);
901
904
  } else {
902
905
  var lines = [d.method + " " + d.path + " HTTP/1.1"];
@@ -910,7 +913,7 @@ function renderContent() {
910
913
 
911
914
  function table(h) {
912
915
  var keys = Object.keys(h || {});
913
- if (keys.length === 0) return el("div", "note", "Sem cabeçalhos.");
916
+ if (keys.length === 0) return el("div", "note", "No headers.");
914
917
  var t = el("table");
915
918
  keys.forEach(function (k) {
916
919
  var tr = el("tr");
@@ -927,7 +930,7 @@ function renderBody(box, body, note, truncNote, headers) {
927
930
  return;
928
931
  }
929
932
  if (body === null || body === undefined) {
930
- box.appendChild(el("div", "note", "Sem corpo."));
933
+ box.appendChild(el("div", "note", "No body."));
931
934
  } else if (header(headers, "content-type").toLowerCase().indexOf("json") >= 0) {
932
935
  var err = jsonSyntaxError(body);
933
936
  if (err === null) box.appendChild(jsonTree(jsonIndent(body)));
@@ -947,8 +950,6 @@ function renderBody(box, body, note, truncNote, headers) {
947
950
  }
948
951
 
949
952
  var KIND = { key: "k", string: "s", number: "n", literal: "n", punct: "", space: "" };
950
-
951
- // JSON colorido; clicar no { ou [ recolhe para "{ ▸ N campos }".
952
953
  function jsonTree(text) {
953
954
  var pre = el("pre");
954
955
  var stack = [{ node: pre }];
@@ -973,8 +974,8 @@ function jsonTree(text) {
973
974
  var n = c.has ? c.count + 1 : 0;
974
975
  if (n > 0) {
975
976
  c.wrap.dataset.n = String(n);
976
- c.open.title = "recolher ou abrir";
977
- c.sum.textContent = " ▸ " + n + (c.obj ? (n === 1 ? " campo " : " campos ") : n === 1 ? " item " : " itens ");
977
+ c.open.title = "collapse or expand";
978
+ c.sum.textContent = " ▸ " + (c.obj ? countLabel(n, "field", "fields") : countLabel(n, "item", "items")) + " ";
978
979
  } else c.open.className = "";
979
980
  c.wrap.appendChild(document.createTextNode(t.text));
980
981
  return;
@@ -1006,13 +1007,11 @@ function renderButtons() {
1006
1007
  var notes = [];
1007
1008
  if (d.curl !== null && d.curlNote) notes.push(d.curlNote);
1008
1009
  var tunnel = state.tunnels.filter(function (t) { return t.key === d.tunnel; })[0];
1009
- if (d.curl !== null && tunnel && tunnel.auth) notes.push("Troque SENHA pela senha do túnel.");
1010
+ if (d.curl !== null && tunnel && tunnel.auth) notes.push("Replace PASSWORD with the tunnel password.");
1010
1011
  $("curlNote").textContent = notes.join(" ");
1011
1012
  }
1012
-
1013
- // "Copiar corpo" copia o corpo da aba ativa: a resposta na aba Resposta, senão a requisição.
1014
1013
  function copyable(d) {
1015
- return state.tab === "Resposta" ? { text: d.resBody, note: d.resNote } : { text: d.reqBody, note: d.reqNote };
1014
+ return state.tab === "Response" ? { text: d.resBody, note: d.resNote } : { text: d.reqBody, note: d.reqNote };
1016
1015
  }
1017
1016
 
1018
1017
  function showMsg(text, isErr) {
@@ -1031,10 +1030,10 @@ function hideMsg() {
1031
1030
 
1032
1031
  function copy(text) {
1033
1032
  navigator.clipboard.writeText(text).then(function () {
1034
- showMsg("Copiado.", false);
1033
+ showMsg("Copied.", false);
1035
1034
  msgTimer = setTimeout(hideMsg, 2000);
1036
1035
  }, function () {
1037
- showMsg("Não deu para copiar.", true);
1036
+ showMsg("Could not copy.", true);
1038
1037
  });
1039
1038
  }
1040
1039
 
@@ -1045,14 +1044,14 @@ function replay() {
1045
1044
  if (replaying) return;
1046
1045
  replaying = true;
1047
1046
  $("replay").disabled = true;
1048
- api("/api/requisicoes/" + d.id + "/reenviar", "POST").then(function (r) {
1047
+ api("/api/requests/" + d.id + "/replay", "POST").then(function (r) {
1049
1048
  replaying = false;
1050
1049
  if (state.detail) renderButtons();
1051
1050
  if (r.body && r.body.ok) {
1052
1051
  hideMsg();
1053
1052
  return loadList().then(function () { return select(r.body.id); });
1054
1053
  }
1055
- showMsg(r.body && r.body.reason ? r.body.reason : "Não deu para reenviar.", true);
1054
+ showMsg(r.body && r.body.reason ? r.body.reason : "Could not replay.", true);
1056
1055
  }).catch(function (e) {
1057
1056
  replaying = false;
1058
1057
  if (state.detail) renderButtons();
@@ -1071,11 +1070,10 @@ function move(delta) {
1071
1070
 
1072
1071
  function connect() {
1073
1072
  if (state.ended) return;
1074
- es = new EventSource("/api/eventos?t=" + encodeURIComponent(TOKEN));
1073
+ es = new EventSource("/api/events?t=" + encodeURIComponent(TOKEN));
1075
1074
  es.addEventListener("open", function () {
1076
1075
  if (state.ended) return;
1077
1076
  banner("");
1078
- // Reconectou: o que mudou no meio do caminho não chegou por evento.
1079
1077
  if (lost) {
1080
1078
  lost = false;
1081
1079
  loadList();
@@ -1085,22 +1083,18 @@ function connect() {
1085
1083
  es.addEventListener("error", function () {
1086
1084
  if (state.ended) return;
1087
1085
  lost = true;
1088
- banner("Desconectado da sessão.");
1089
- // Resposta não 200 (ex.: 403 de outra sessão) fecha o EventSource de vez.
1086
+ banner("Disconnected from the session.");
1090
1087
  if (es.readyState === 2) probe();
1091
1088
  });
1092
- es.addEventListener("mudou", function (ev) {
1089
+ es.addEventListener("changed", function (ev) {
1093
1090
  var ids = [];
1094
1091
  try { ids = JSON.parse(ev.data).ids || []; } catch (e) {}
1095
1092
  loadList();
1096
- // Lista vazia de ids: algo mudou fora da assinatura; busca o detalhe de novo também.
1097
1093
  if (state.selected !== null && (ids.length === 0 || ids.indexOf(state.selected) >= 0)) loadDetail(state.selected);
1098
1094
  });
1099
1095
  }
1100
-
1101
- // EventSource fechado: a API diz se a sessão acabou (403 → ended) ou se dá para reconectar.
1102
1096
  function probe() {
1103
- api("/api/sessao").then(function () {
1097
+ api("/api/session").then(function () {
1104
1098
  if (!state.ended) setTimeout(connect, 3000);
1105
1099
  }, function () {
1106
1100
  if (!state.ended) setTimeout(connect, 3000);
@@ -1150,35 +1144,35 @@ function init() {
1150
1144
  init();
1151
1145
  `;
1152
1146
  function inspectorPage(nonce) {
1153
- const tabs = ["Corpo", "Cabe\xE7alhos", "Resposta", "Raw"].map((t, i) => `<button type="button" class="tab${i === 0 ? " on" : ""}" role="tab" data-tab="${t}">${t}</button>`).join("");
1147
+ const tabs = ["Body", "Headers", "Response", "Raw"].map((t, i) => `<button type="button" class="tab${i === 0 ? " on" : ""}" role="tab" data-tab="${t}">${t}</button>`).join("");
1154
1148
  return `<!doctype html>
1155
- <html lang="pt-BR">
1149
+ <html lang="en">
1156
1150
  <head>
1157
1151
  <meta charset="utf-8">
1158
1152
  <meta name="viewport" content="width=device-width, initial-scale=1">
1159
- <title>duto \xB7 inspetor</title>
1153
+ <title>duto \xB7 inspector</title>
1160
1154
  <link rel="icon" href="data:,">
1161
1155
  <style nonce="${nonce}">${STYLE}</style>
1162
1156
  </head>
1163
1157
  <body>
1164
1158
  <div class="app">
1165
- <div class="top"><span><b>\u25B0 duto</b> \xB7 inspetor \xB7 <span id="count">0 requisi\xE7\xF5es</span></span><span class="tunnels" id="tunnels"></span></div>
1159
+ <div class="top"><span><b>\u25B0 duto</b> \xB7 inspector \xB7 <span id="count">0 requests</span></span><span class="tunnels" id="tunnels"></span></div>
1166
1160
  <div class="banner" id="banner" role="status" hidden></div>
1167
1161
  <div class="main">
1168
1162
  <div class="side">
1169
- <input class="search" id="filter" type="search" placeholder="filtrar\u2026" aria-label="Filtrar requisi\xE7\xF5es" autocomplete="off" spellcheck="false">
1170
- <div class="list" id="list" role="listbox" aria-label="Requisi\xE7\xF5es"></div>
1163
+ <input class="search" id="filter" type="search" placeholder="filter\u2026" aria-label="Filter requests" autocomplete="off" spellcheck="false">
1164
+ <div class="list" id="list" role="listbox" aria-label="Requests"></div>
1171
1165
  </div>
1172
1166
  <div class="detail">
1173
- <div class="empty" id="none">Escolha uma requisi\xE7\xE3o na lista.</div>
1167
+ <div class="empty" id="none">Select a request from the list.</div>
1174
1168
  <div class="detail pane" id="detail" hidden>
1175
1169
  <div class="head" id="head"></div>
1176
1170
  <div class="tabs" role="tablist">${tabs}</div>
1177
1171
  <div class="content" id="content"></div>
1178
1172
  <div class="btns">
1179
- <span><button type="button" class="btn pri" id="replay">\u21BB Reenviar</button></span>
1180
- <span><button type="button" class="btn" id="copyBody">Copiar corpo</button></span>
1181
- <span><button type="button" class="btn" id="copyCurl">Copiar como curl</button></span>
1173
+ <span><button type="button" class="btn pri" id="replay">\u21BB Replay</button></span>
1174
+ <span><button type="button" class="btn" id="copyBody">Copy body</button></span>
1175
+ <span><button type="button" class="btn" id="copyCurl">Copy as curl</button></span>
1182
1176
  <span class="msg" id="curlNote"></span>
1183
1177
  <span class="msg" id="msg" role="status" hidden></span>
1184
1178
  </div>
@@ -1197,21 +1191,21 @@ import { createHash as createHash2, randomBytes, timingSafeEqual } from "crypto"
1197
1191
  import { createServer } from "http";
1198
1192
 
1199
1193
  // src/session/replay.ts
1200
- var tunnelClosedReason = (tunnel2) => `O t\xFAnel ${tunnel2} n\xE3o est\xE1 aberto nesta sess\xE3o.`;
1194
+ var tunnelClosedReason = (tunnel2) => `Tunnel ${tunnel2} is not open in this session.`;
1201
1195
  function replayBlock(r) {
1202
- if (!r) return "Essa requisi\xE7\xE3o j\xE1 saiu da lista.";
1203
- if (r.websocket) return "WebSocket n\xE3o d\xE1 para reenviar.";
1196
+ if (!r) return "That request is no longer in the list.";
1197
+ if (r.websocket) return "WebSocket requests cannot be replayed.";
1204
1198
  if (r.noReplay) return r.noReplay;
1205
- if (r.access?.kind === "denied") return "Barrada pela senha do t\xFAnel; n\xE3o d\xE1 para reenviar.";
1206
- if (!r.reqComplete) return "A requisi\xE7\xE3o ainda n\xE3o chegou inteira.";
1207
- if (r.reqTruncated) return "Corpo acima de 64 KB; n\xE3o d\xE1 para reenviar.";
1208
- if (r.reqBodyBytes > 0 && r.reqRaw === null) return "Corpo bin\xE1rio n\xE3o \xE9 guardado; n\xE3o d\xE1 para reenviar.";
1199
+ if (r.access?.kind === "denied") return "Blocked by the tunnel password; cannot replay.";
1200
+ if (!r.reqComplete) return "The request has not fully arrived yet.";
1201
+ if (r.reqTruncated) return "Body over 64 KB; cannot replay.";
1202
+ if (r.reqBodyBytes > 0 && r.reqRaw === null) return "Binary body is not stored; cannot replay.";
1209
1203
  return null;
1210
1204
  }
1211
- var DROP = /* @__PURE__ */ new Set(["connection", "keep-alive", "transfer-encoding", "expect", "content-length", "upgrade", "te", "trailer", "proxy-connection"]);
1205
+ var HOP_BY_HOP_AND_BODY_HEADERS = /* @__PURE__ */ new Set(["connection", "keep-alive", "transfer-encoding", "expect", "content-length", "upgrade", "te", "trailer", "proxy-connection"]);
1212
1206
  function replayHeaders(r) {
1213
1207
  const out = {};
1214
- for (const [k, v] of Object.entries(r.reqHeaders)) if (!DROP.has(k.toLowerCase())) out[k] = v;
1208
+ for (const [k, v] of Object.entries(r.reqHeaders)) if (!HOP_BY_HOP_AND_BODY_HEADERS.has(k.toLowerCase())) out[k] = v;
1215
1209
  const hadLength = Object.keys(r.reqHeaders).some((k) => k.toLowerCase() === "content-length");
1216
1210
  if (r.reqBodyBytes > 0 || hadLength) out["content-length"] = String(r.reqRaw?.length ?? 0);
1217
1211
  return out;
@@ -1232,7 +1226,7 @@ function toCurl(r, o) {
1232
1226
  for (const [k, v] of Object.entries(r.reqHeaders)) {
1233
1227
  if (!skip(k)) parts.push(`-H ${shellQuote(`${k}: ${v}`)}`);
1234
1228
  }
1235
- if (o.authUser !== null) parts.push(`-H ${shellQuote(`X-Duto-Auth: ${o.authUser}:SENHA`)}`);
1229
+ if (o.authUser !== null) parts.push(`-H ${shellQuote(`X-Duto-Auth: ${o.authUser}:PASSWORD`)}`);
1236
1230
  if (r.reqBody !== null) parts.push(`--data-binary ${shellQuote(r.reqBody)}`);
1237
1231
  return parts.join(" \\\n ");
1238
1232
  }
@@ -1240,12 +1234,12 @@ function toCurl(r, o) {
1240
1234
  // src/inspector/server.ts
1241
1235
  var DEFAULT_PORT = 4141;
1242
1236
  var PORT_TRIES = 20;
1243
- var NO_TOKEN = "Abra o inspetor pela tecla i da tela do duto ou pelo endere\xE7o impresso no terminal.";
1244
- var GONE = "Essa requisi\xE7\xE3o j\xE1 saiu da lista.";
1245
- var NO_URL = "O t\xFAnel ainda n\xE3o tem endere\xE7o p\xFAblico.";
1246
- var CURL_BYTES = "O corpo do curl pode n\xE3o ser id\xEAntico ao recebido (bytes fora de UTF-8).";
1237
+ var NO_TOKEN = "Open the inspector with the i key in the duto screen, or with the address printed in the terminal.";
1238
+ var GONE = "That request is no longer in the list.";
1239
+ var NO_URL = "The tunnel has no public URL yet.";
1240
+ var CURL_BYTES = "The curl body may differ from the one received (non-UTF-8 bytes).";
1247
1241
  var sha = (s) => createHash2("sha256").update(s).digest();
1248
- var same = (a, b) => timingSafeEqual(sha(a), sha(b));
1242
+ var constantTimeEqual = (a, b) => timingSafeEqual(sha(a), sha(b));
1249
1243
  var first = (v) => Array.isArray(v) ? v[0] : v;
1250
1244
  function listItem(r) {
1251
1245
  return {
@@ -1308,7 +1302,7 @@ async function startInspector(o) {
1308
1302
  if (seen.get(r.id) !== s) ids.push(r.id);
1309
1303
  }
1310
1304
  seen = next;
1311
- const msg = `event: mudou
1305
+ const msg = `event: changed
1312
1306
  data: ${JSON.stringify({ ids })}
1313
1307
 
1314
1308
  `;
@@ -1339,19 +1333,19 @@ data: ${JSON.stringify({ ids })}
1339
1333
  const handle = (req, res) => {
1340
1334
  base(res);
1341
1335
  const host = first(req.headers.host)?.toLowerCase();
1342
- if (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`) return text(res, 403, "Host n\xE3o permitido.");
1336
+ if (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`) return text(res, 403, "Host not allowed.");
1343
1337
  let url2;
1344
1338
  try {
1345
1339
  url2 = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
1346
1340
  } catch {
1347
- return text(res, 404, "N\xE3o encontrado.");
1341
+ return text(res, 404, "Not found.");
1348
1342
  }
1349
1343
  const path = url2.pathname;
1350
1344
  const method = req.method ?? "GET";
1351
1345
  const queryToken = url2.searchParams.get("t") ?? "";
1352
- const headerToken = first(req.headers["x-duto-inspetor"]) ?? "";
1346
+ const headerToken = first(req.headers["x-duto-inspector"]) ?? "";
1353
1347
  if (method === "GET" && path === "/") {
1354
- if (!same(queryToken, token)) return text(res, 403, NO_TOKEN);
1348
+ if (!constantTimeEqual(queryToken, token)) return text(res, 403, NO_TOKEN);
1355
1349
  const nonce = randomBytes(16).toString("base64url");
1356
1350
  res.writeHead(200, {
1357
1351
  "Content-Type": "text/html; charset=utf-8",
@@ -1359,11 +1353,11 @@ data: ${JSON.stringify({ ids })}
1359
1353
  });
1360
1354
  return void res.end(o.page(nonce));
1361
1355
  }
1362
- const isEvents = method === "GET" && path === "/api/eventos";
1363
- const detailId = /^\/api\/requisicoes\/([1-9]\d{0,15})(\/reenviar)?$/.exec(path);
1364
- const known = method === "GET" && (path === "/api/sessao" || path === "/api/requisicoes" || detailId && !detailId[2]) || isEvents || method === "POST" && detailId && detailId[2];
1365
- if (!known) return text(res, 404, "N\xE3o encontrado.");
1366
- if (!same(headerToken, token) && !(isEvents && same(queryToken, token))) return json(res, 403, { error: NO_TOKEN });
1356
+ const isEvents = method === "GET" && path === "/api/events";
1357
+ const detailId = /^\/api\/requests\/([1-9]\d{0,15})(\/replay)?$/.exec(path);
1358
+ const known = method === "GET" && (path === "/api/session" || path === "/api/requests" || detailId && !detailId[2]) || isEvents || method === "POST" && detailId && detailId[2];
1359
+ if (!known) return text(res, 404, "Not found.");
1360
+ if (!constantTimeEqual(headerToken, token) && !(isEvents && constantTimeEqual(queryToken, token))) return json(res, 403, { error: NO_TOKEN });
1367
1361
  if (isEvents) {
1368
1362
  res.writeHead(200, { "Content-Type": "text/event-stream; charset=utf-8", Connection: "keep-alive" });
1369
1363
  res.write(": duto\n\n");
@@ -1372,10 +1366,10 @@ data: ${JSON.stringify({ ids })}
1372
1366
  res.on("close", () => clients.delete(res));
1373
1367
  return;
1374
1368
  }
1375
- if (path === "/api/sessao") {
1369
+ if (path === "/api/session") {
1376
1370
  return json(res, 200, { tunnels: session.getState().tunnels.map((t) => ({ key: t.key, url: t.url, auth: !!t.auth })) });
1377
1371
  }
1378
- if (path === "/api/requisicoes") return json(res, 200, { items: session.records.list().map(listItem) });
1372
+ if (path === "/api/requests") return json(res, 200, { items: session.records.list().map(listItem) });
1379
1373
  const id = Number(detailId[1]);
1380
1374
  if (!Number.isSafeInteger(id)) return json(res, 404, { error: GONE });
1381
1375
  if (method === "POST") return json(res, 200, session.replay(id));
@@ -1387,7 +1381,7 @@ data: ${JSON.stringify({ ids })}
1387
1381
  try {
1388
1382
  handle(req, res);
1389
1383
  } catch {
1390
- if (!res.headersSent) text(res, 500, "Erro interno.");
1384
+ if (!res.headersSent) text(res, 500, "Internal error.");
1391
1385
  else res.end();
1392
1386
  }
1393
1387
  });
@@ -1460,10 +1454,11 @@ async function isPortListening(port, timeoutMs = 500) {
1460
1454
 
1461
1455
  // src/session/auth.ts
1462
1456
  import { createHash as createHash3, createHmac, randomBytes as randomBytes2, timingSafeEqual as timingSafeEqual2 } from "crypto";
1463
- var TUNNEL_COOKIE = "__Host-duto_tunel";
1457
+ var TUNNEL_COOKIE = "__Host-duto_tunnel";
1458
+ var SAME_SITE_SIBLING_TUNNEL_COOKIE_REJECTED = "same-site";
1464
1459
  var QR_TTL_MS = 5 * 6e4;
1465
1460
  var sha2 = (s) => createHash3("sha256").update(s).digest();
1466
- var same2 = (a, b) => timingSafeEqual2(sha2(a), sha2(b));
1461
+ var constantTimeEqual2 = (a, b) => timingSafeEqual2(sha2(a), sha2(b));
1467
1462
  function splitPair(raw) {
1468
1463
  const i = raw.indexOf(":");
1469
1464
  return i < 0 ? null : { user: raw.slice(0, i), password: raw.slice(i + 1) };
@@ -1475,7 +1470,7 @@ function parseBasic(value) {
1475
1470
  var first2 = (v) => Array.isArray(v) ? v[0] : v;
1476
1471
  var bareHost = (s) => s.replace(/:\d+$/, "").toLowerCase();
1477
1472
  function sameHostOrigin(h) {
1478
- if (first2(h["sec-fetch-site"])?.toLowerCase() === "same-site") return false;
1473
+ if (first2(h["sec-fetch-site"])?.toLowerCase() === SAME_SITE_SIBLING_TUNNEL_COOKIE_REJECTED) return false;
1479
1474
  const origin = first2(h.origin);
1480
1475
  if (origin === void 0) return true;
1481
1476
  let host;
@@ -1510,16 +1505,16 @@ var TunnelAuth = class {
1510
1505
  }
1511
1506
  const cookieUser = this.cookieUser(h.cookie);
1512
1507
  if (cookieUser && sameHostOrigin(h)) return { kind: "user", user: cookieUser };
1513
- const duto = first2(h["x-duto-auth"]);
1514
- const cand = duto !== void 0 ? splitPair(duto) : parseBasic(first2(h.authorization));
1515
- if (cand && this.checkPassword(cand.user, cand.password)) return { kind: "user", user: cand.user };
1508
+ const dutoAuthHeader = first2(h["x-duto-auth"]);
1509
+ const candidate = dutoAuthHeader !== void 0 ? splitPair(dutoAuthHeader) : parseBasic(first2(h.authorization));
1510
+ if (candidate && this.checkPassword(candidate.user, candidate.password)) return { kind: "user", user: candidate.user };
1516
1511
  return { kind: "denied" };
1517
1512
  }
1518
1513
  checkPassword(user, password) {
1519
1514
  let ok2 = false;
1520
1515
  for (const c of this.credentials) {
1521
- const userOk = same2(c.user, user);
1522
- const passOk = same2(c.password, password);
1516
+ const userOk = constantTimeEqual2(c.user, user);
1517
+ const passOk = constantTimeEqual2(c.password, password);
1523
1518
  if (userOk && passOk) ok2 = true;
1524
1519
  }
1525
1520
  return ok2;
@@ -1536,7 +1531,7 @@ var TunnelAuth = class {
1536
1531
  }
1537
1532
  consumeQrToken(token) {
1538
1533
  const qr = this.qr;
1539
- if (!qr || !same2(qr.token, token)) return false;
1534
+ if (!qr || !constantTimeEqual2(qr.token, token)) return false;
1540
1535
  this.qr = null;
1541
1536
  return this.now() <= qr.expiresAt;
1542
1537
  }
@@ -1554,7 +1549,6 @@ var TunnelAuth = class {
1554
1549
  }
1555
1550
  return out;
1556
1551
  }
1557
- // Valor: usuário em base64url + "." + HMAC(túnel, usuário).
1558
1552
  cookieValue(user) {
1559
1553
  return `${Buffer.from(user).toString("base64url")}.${this.sign(user)}`;
1560
1554
  }
@@ -1567,10 +1561,10 @@ var TunnelAuth = class {
1567
1561
  for (const p of list) {
1568
1562
  const item = p.trim();
1569
1563
  if (!item.startsWith(`${TUNNEL_COOKIE}=`)) continue;
1570
- const [b64, sig2, ...resto] = item.slice(TUNNEL_COOKIE.length + 1).split(".");
1571
- if (!b64 || !sig2 || resto.length) continue;
1564
+ const [b64, sig2, ...extra] = item.slice(TUNNEL_COOKIE.length + 1).split(".");
1565
+ if (!b64 || !sig2 || extra.length) continue;
1572
1566
  const user = Buffer.from(b64, "base64url").toString("utf8");
1573
- if (same2(sig2, this.sign(user)) && this.credentials.some((c) => c.user === user)) return user;
1567
+ if (constantTimeEqual2(sig2, this.sign(user)) && this.credentials.some((c) => c.user === user)) return user;
1574
1568
  }
1575
1569
  return null;
1576
1570
  }
@@ -1622,14 +1616,14 @@ var WAIT_SCRIPT = `
1622
1616
  }
1623
1617
  function again() { delay = Math.min(delay * 2, 30000); poll(); }
1624
1618
  if (!window.EventSource) return poll();
1625
- var fails = 0, es = new EventSource("/__duto/espera");
1619
+ var fails = 0, es = new EventSource("/__duto/wait");
1626
1620
  es.addEventListener("up", function () { es.close(); location.reload(); });
1627
1621
  es.onerror = function () { if (++fails >= 3) { es.close(); poll(); } };
1628
1622
  })();
1629
1623
  </script>`;
1630
1624
  function pageShell(title, body, css = "") {
1631
1625
  return `<!doctype html>
1632
- <html lang="pt-BR">
1626
+ <html lang="en">
1633
1627
  <head>
1634
1628
  <meta charset="utf-8">
1635
1629
  <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -1661,13 +1655,13 @@ function unavailablePage(o) {
1661
1655
  const tunnel2 = esc(o.tunnel);
1662
1656
  const local = `localhost:${o.port}`;
1663
1657
  return pageShell(
1664
- `Nada respondendo em ${local} \xB7 duto`,
1658
+ `Nothing responding on ${local} \xB7 duto`,
1665
1659
  `<main>
1666
1660
  <div class="mark"><span class="dot"></span>duto \xB7 ${tunnel2}</div>
1667
- <h1>Nada respondendo em <code>${local}</code></h1>
1668
- <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>
1669
- <p>Se voc\xEA s\xF3 recebeu este link, avise quem o enviou.</p>
1670
- <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>
1661
+ <h1>Nothing is responding on <code>${local}</code></h1>
1662
+ <p>The tunnel <code>${tunnel2}</code> is live, but your local service did not respond. Start the server on the machine that opened the tunnel.</p>
1663
+ <p>If you were only sent this link, let whoever shared it know.</p>
1664
+ <div class="retry">${o.retry ? `<b>Waiting for the service.</b> This page reloads when it responds.<noscript> Reload once the service is up.</noscript>` : "Reload the page once the service is up."}</div>
1671
1665
  </main>${o.retry ? WAIT_SCRIPT : ""}`
1672
1666
  );
1673
1667
  }
@@ -1680,35 +1674,35 @@ button{margin-top:18px;width:100%;padding:10px;border:0;border-radius:.3rem;back
1680
1674
  .error{margin:14px 0 0;color:#b42318;font-weight:600}
1681
1675
  @media (prefers-color-scheme:dark){.error{color:#f97066}}
1682
1676
  `;
1683
- function safeVolta(raw) {
1677
+ function safeNext(raw) {
1684
1678
  if (!raw || !raw.startsWith("/") || raw.startsWith("//") || raw.startsWith("/\\")) return "/";
1685
1679
  return /^[\x21-\x7e]+$/.test(raw) ? raw : "/";
1686
1680
  }
1687
1681
  function loginPage(o) {
1688
1682
  return pageShell(
1689
- "Entrar \xB7 duto",
1683
+ "Sign in \xB7 duto",
1690
1684
  `<main>
1691
1685
  <div class="mark"><span class="dot"></span>duto \xB7 ${esc(o.tunnel)}</div>
1692
- <h1>Este t\xFAnel tem senha</h1>
1693
- <p>Pe\xE7a o acesso a quem enviou o link.</p>
1694
- <form method="post" action="/__duto/entrar">
1695
- <input type="hidden" name="volta" value="${esc(o.volta)}">
1696
- <label for="usuario">Usu\xE1rio</label>
1697
- <input id="usuario" name="usuario" autocomplete="username" autocapitalize="none" spellcheck="false" required autofocus>
1698
- <label for="senha">Senha</label>
1699
- <input id="senha" name="senha" type="password" autocomplete="current-password" required>
1700
- ${o.error ? `<p class="error" role="alert">Usu\xE1rio ou senha incorretos.</p>
1701
- ` : ""}<button type="submit">Entrar</button>
1686
+ <h1>This tunnel is password protected</h1>
1687
+ <p>Ask whoever sent you the link for access.</p>
1688
+ <form method="post" action="/__duto/login">
1689
+ <input type="hidden" name="next" value="${esc(o.next)}">
1690
+ <label for="username">Username</label>
1691
+ <input id="username" name="username" autocomplete="username" autocapitalize="none" spellcheck="false" required autofocus>
1692
+ <label for="password">Password</label>
1693
+ <input id="password" name="password" type="password" autocomplete="current-password" required>
1694
+ ${o.error ? `<p class="error" role="alert">Wrong username or password.</p>
1695
+ ` : ""}<button type="submit">Sign in</button>
1702
1696
  </form>
1703
- <div class="retry">Voc\xEA continua conectado at\xE9 o t\xFAnel fechar.</div>
1697
+ <div class="retry">You stay signed in until the tunnel closes.</div>
1704
1698
  </main>`,
1705
1699
  CSS
1706
1700
  );
1707
1701
  }
1708
1702
 
1709
1703
  // src/session/proxy.ts
1710
- var DENIED_NO_REPLAY = "Barrada pela senha do t\xFAnel; n\xE3o d\xE1 para reenviar.";
1711
- var PREFLIGHT_NO_REPLAY = "Preflight de CORS n\xE3o d\xE1 para reenviar.";
1704
+ var DENIED_NO_REPLAY = "Blocked by the tunnel password; cannot replay.";
1705
+ var PREFLIGHT_NO_REPLAY = "CORS preflight cannot be replayed.";
1712
1706
  var TEXT2 = /^(text\/|application\/(json|xml|javascript|x-www-form-urlencoded|graphql)|[^;]*\+(json|xml))/i;
1713
1707
  var MAX_BODY = 64 * 1024;
1714
1708
  var LOCAL_AGENT = new Agent({ keepAlive: false });
@@ -1726,7 +1720,6 @@ var Capture = class {
1726
1720
  max;
1727
1721
  chunks = [];
1728
1722
  size = 0;
1729
- /** Bytes vistos, capturados ou não. */
1730
1723
  total = 0;
1731
1724
  truncated = false;
1732
1725
  push(c) {
@@ -1749,6 +1742,7 @@ var Capture = class {
1749
1742
  return this.enabled && this.size > 0 ? Buffer.concat(this.chunks).toString("utf8") : null;
1750
1743
  }
1751
1744
  };
1745
+ var HOST_CACHE_MS = 5e3;
1752
1746
  var hostCache = /* @__PURE__ */ new Map();
1753
1747
  function probe(host, port) {
1754
1748
  return new Promise((resolve) => {
@@ -1764,7 +1758,7 @@ function probe(host, port) {
1764
1758
  }
1765
1759
  async function localHost(port) {
1766
1760
  const hit = hostCache.get(port);
1767
- if (hit && Date.now() - hit.at < 5e3) return hit.host;
1761
+ if (hit && Date.now() - hit.at < HOST_CACHE_MS) return hit.host;
1768
1762
  const host = await probe("127.0.0.1", port) ? "127.0.0.1" : await probe("::1", port) ? "::1" : null;
1769
1763
  if (!host) return "127.0.0.1";
1770
1764
  hostCache.set(port, { host, at: Date.now() });
@@ -1793,13 +1787,16 @@ function hostOf(req) {
1793
1787
  return (req.headers.host ?? "").replace(/:\d+$/, "").toLowerCase();
1794
1788
  }
1795
1789
  var DUTO_PREFIX = "/__duto/";
1796
- var WAIT_PATH = "/__duto/espera";
1790
+ var WAIT_PATH = "/__duto/wait";
1797
1791
  var WAIT_MAX_MS = 10 * 6e4;
1798
- var LOGIN_PATH = "/__duto/entrar";
1799
- var LOGOUT_PATH = "/__duto/sair";
1792
+ var DEFAULT_ROUTE_WAIT_MS = 3e3;
1793
+ var DEFAULT_WAIT_CHECK_MS = 1e3;
1794
+ var SSE_KEEPALIVE_MS = 25e3;
1795
+ var LOGIN_PATH = "/__duto/login";
1796
+ var LOGOUT_PATH = "/__duto/logout";
1800
1797
  var QR_PREFIX = "/__duto/qr/";
1801
1798
  var MAX_FORM = 8 * 1024;
1802
- var DENIED_TEXT = "Este t\xFAnel tem senha. Envie Authorization: Basic ou X-Duto-Auth: usuario:senha.";
1799
+ var DENIED_TEXT = "This tunnel is password protected. Send Authorization: Basic or X-Duto-Auth: user:password.";
1803
1800
  var WS_DENIED = 'HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm="duto"\r\nContent-Length: 0\r\nConnection: close\r\n\r\n';
1804
1801
  function readForm(req, max) {
1805
1802
  return new Promise((resolve) => {
@@ -1822,9 +1819,9 @@ function readForm(req, max) {
1822
1819
  }
1823
1820
  async function authRoute(req, res, route, auth) {
1824
1821
  const url = new URL(req.url ?? "/", "http://duto");
1825
- const page = (status, volta, error) => {
1822
+ const page = (status, next, error) => {
1826
1823
  res.writeHead(status, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
1827
- res.end(loginPage({ tunnel: route.tunnel, volta, error }));
1824
+ res.end(loginPage({ tunnel: route.tunnel, next, error }));
1828
1825
  };
1829
1826
  const go = (location, cookie) => {
1830
1827
  res.writeHead(303, { location, "cache-control": "no-store", ...cookie && { "set-cookie": cookie } });
@@ -1832,21 +1829,21 @@ async function authRoute(req, res, route, auth) {
1832
1829
  };
1833
1830
  const { pathname } = url;
1834
1831
  if (pathname === LOGIN_PATH && req.method === "GET") {
1835
- page(200, safeVolta(url.searchParams.get("volta")), false);
1832
+ page(200, safeNext(url.searchParams.get("next")), false);
1836
1833
  return true;
1837
1834
  }
1838
1835
  if (pathname === LOGIN_PATH && req.method === "POST") {
1839
1836
  const raw = await readForm(req, MAX_FORM);
1840
1837
  if (raw === null) {
1841
1838
  res.writeHead(413, { "content-type": "text/plain; charset=utf-8", connection: "close" });
1842
- res.end("Formul\xE1rio grande demais");
1839
+ res.end("Form too large");
1843
1840
  return true;
1844
1841
  }
1845
1842
  const form = new URLSearchParams(raw);
1846
- const user = form.get("usuario") ?? "";
1847
- const volta = safeVolta(form.get("volta"));
1848
- if (auth.checkPassword(user, form.get("senha") ?? "")) go(volta, auth.loginCookie(user));
1849
- else page(401, volta, true);
1843
+ const user = form.get("username") ?? "";
1844
+ const next = safeNext(form.get("next"));
1845
+ if (auth.checkPassword(user, form.get("password") ?? "")) go(next, auth.loginCookie(user));
1846
+ else page(401, next, true);
1850
1847
  return true;
1851
1848
  }
1852
1849
  if (pathname === LOGOUT_PATH && req.method === "GET") {
@@ -1862,7 +1859,7 @@ async function authRoute(req, res, route, auth) {
1862
1859
  }
1863
1860
  function deny(req, res, html) {
1864
1861
  const status = html ? 303 : 401;
1865
- const out = html ? { location: `${LOGIN_PATH}?volta=${encodeURIComponent(req.url ?? "/")}`, "cache-control": "no-store" } : { "www-authenticate": 'Basic realm="duto"', "content-type": "text/plain; charset=utf-8", "cache-control": "no-store", ...cors401(req.headers.origin) };
1862
+ const out = html ? { location: `${LOGIN_PATH}?next=${encodeURIComponent(req.url ?? "/")}`, "cache-control": "no-store" } : { "www-authenticate": 'Basic realm="duto"', "content-type": "text/plain; charset=utf-8", "cache-control": "no-store", ...cors401(req.headers.origin) };
1866
1863
  const body = html || req.method === "HEAD" ? "" : DENIED_TEXT;
1867
1864
  res.writeHead(status, out);
1868
1865
  res.end(body);
@@ -1904,7 +1901,7 @@ function waitForService(res, port, checkMs, pingMs) {
1904
1901
  async function startProxy(o) {
1905
1902
  const max = o.maxBody ?? MAX_BODY;
1906
1903
  let nextId = 1;
1907
- const routeWaitMs = o.routeWaitMs ?? 3e3;
1904
+ const routeWaitMs = o.routeWaitMs ?? DEFAULT_ROUTE_WAIT_MS;
1908
1905
  async function routeFor(req) {
1909
1906
  const host = hostOf(req);
1910
1907
  for (let waited = 0; ; waited += 50) {
@@ -1965,7 +1962,7 @@ async function startProxy(o) {
1965
1962
  function replay(id) {
1966
1963
  const r = o.store.get(id);
1967
1964
  const blocked = replayBlock(r);
1968
- if (blocked || !r) return { ok: false, reason: blocked ?? "Essa requisi\xE7\xE3o j\xE1 saiu da lista." };
1965
+ if (blocked || !r) return { ok: false, reason: blocked ?? "That request is no longer in the list." };
1969
1966
  const route = [...o.routes.values()].find((x) => x.tunnel === r.tunnel);
1970
1967
  if (!route) return { ok: false, reason: tunnelClosedReason(r.tunnel) };
1971
1968
  const newId = nextId++;
@@ -2006,17 +2003,17 @@ async function startProxy(o) {
2006
2003
  const route = await routeFor(req);
2007
2004
  if (!route) {
2008
2005
  res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
2009
- res.end("T\xFAnel n\xE3o encontrado nesta sess\xE3o");
2006
+ res.end("Tunnel not found in this session");
2010
2007
  return;
2011
2008
  }
2012
2009
  if ((req.url ?? "").startsWith(DUTO_PREFIX)) {
2013
2010
  if (req.url === WAIT_PATH && req.method === "GET") {
2014
2011
  if (route.auth?.access(req.headers).kind === "denied") return void deny(req, res, false);
2015
- return waitForService(res, route.port, o.waitCheckMs ?? 1e3, o.waitPingMs ?? 25e3);
2012
+ return waitForService(res, route.port, o.waitCheckMs ?? DEFAULT_WAIT_CHECK_MS, o.waitPingMs ?? SSE_KEEPALIVE_MS);
2016
2013
  }
2017
2014
  if (route.auth && await authRoute(req, res, route, route.auth)) return;
2018
2015
  res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
2019
- res.end("Rota reservada do Duto");
2016
+ res.end("Reserved Duto route");
2020
2017
  return;
2021
2018
  }
2022
2019
  const preflight = !!route.auth && isPreflight(req.method, req.headers);
@@ -2059,7 +2056,7 @@ async function startProxy(o) {
2059
2056
  req.resume();
2060
2057
  if (res.destroyed && res.writableFinished === false && !res.headersSent) return;
2061
2058
  const html = wantsHtml(req.headers.accept);
2062
- const msg = html ? unavailablePage({ tunnel: route.tunnel, port: route.port, retry: req.method === "GET" }) : `Nada escutando em localhost:${route.port}. Suba o servidor e tente de novo.`;
2059
+ const msg = html ? unavailablePage({ tunnel: route.tunnel, port: route.port, retry: req.method === "GET" }) : `Nothing is listening on localhost:${route.port}. Start the server and try again.`;
2063
2060
  if (!res.headersSent) {
2064
2061
  res.writeHead(503, { "content-type": html ? "text/html; charset=utf-8" : "text/plain; charset=utf-8", "cache-control": "no-store", "retry-after": "3" });
2065
2062
  res.end(req.method === "HEAD" ? void 0 : msg);
@@ -2223,7 +2220,6 @@ var RecordStore = class {
2223
2220
  get(id) {
2224
2221
  return this.items.find((x) => x.id === id);
2225
2222
  }
2226
- /** Mais recentes primeiro. Mesma referência até a próxima mudança (serve de snapshot para a tela). */
2227
2223
  list() {
2228
2224
  this.snapshot ??= [...this.items].reverse();
2229
2225
  return this.snapshot;
@@ -2243,8 +2239,8 @@ var READY = "Registered tunnel connection";
2243
2239
  var CLOSE_TIMEOUT_MS = 3e3;
2244
2240
  var HEARTBEAT_MS = 3e4;
2245
2241
  var RESTART_CF_MS = 2e3;
2246
- var ALREADY_OPEN = "J\xE1 aberto em outra sess\xE3o nesta m\xE1quina";
2247
- var STALE_MS = 75e3;
2242
+ var ALREADY_OPEN = "Already open in another session on this machine";
2243
+ var STALE_AFTER_MISSED_HEARTBEATS_MS = 75e3;
2248
2244
  function message(err) {
2249
2245
  return err instanceof Error ? err.message : String(err);
2250
2246
  }
@@ -2252,7 +2248,7 @@ function withTimeout(p, ms) {
2252
2248
  return Promise.race([p, new Promise((r) => setTimeout(() => r(void 0), ms).unref())]);
2253
2249
  }
2254
2250
  function keyOf(spec) {
2255
- return spec.name ?? "(padr\xE3o)";
2251
+ return spec.name ?? "(default)";
2256
2252
  }
2257
2253
  var Session = class {
2258
2254
  constructor(o) {
@@ -2264,8 +2260,7 @@ var Session = class {
2264
2260
  tunnels = [];
2265
2261
  routes = /* @__PURE__ */ new Map();
2266
2262
  specs = /* @__PURE__ */ new Map();
2267
- /** Um TunnelAuth por túnel, reaproveitado nas reaberturas para não desconectar quem já entrou. */
2268
- auths = /* @__PURE__ */ new Map();
2263
+ persistentAuths = /* @__PURE__ */ new Map();
2269
2264
  proxy = null;
2270
2265
  child = null;
2271
2266
  token = null;
@@ -2276,7 +2271,6 @@ var Session = class {
2276
2271
  connection = "connecting";
2277
2272
  listeners = /* @__PURE__ */ new Set();
2278
2273
  snapshot = null;
2279
- /** Mesma referência até a próxima mudança (serve de snapshot para a tela). */
2280
2274
  getState() {
2281
2275
  this.snapshot ??= { account: this.account, source: this.o.source, connection: this.connection, tunnels: this.tunnels.map((t) => ({ ...t })) };
2282
2276
  return this.snapshot;
@@ -2285,23 +2279,18 @@ var Session = class {
2285
2279
  this.listeners.add(fn);
2286
2280
  return () => this.listeners.delete(fn);
2287
2281
  }
2288
- /** Abre os túneis em sequência; falhas ficam no estado e não interrompem os outros. */
2289
2282
  async start(specs) {
2290
2283
  if (this.o.runDir) reapOrphans(this.o.runDir);
2291
2284
  this.proxy = await startProxy({ routes: this.routes, store: this.records });
2292
2285
  this.tunnels = specs.map((s) => this.blank(s, false));
2293
2286
  this.emit();
2294
- try {
2295
- const me = await this.o.api.me();
2296
- this.account = me.username ?? me.email;
2297
- } catch {
2298
- }
2287
+ const me = await this.o.api.me().catch(() => null);
2288
+ if (me) this.account = me.username ?? me.email;
2299
2289
  const existing = await this.o.api.endpoints().catch(() => []);
2300
2290
  for (const t of this.tunnels) await this.open(t, existing);
2301
2291
  const every = this.o.heartbeatMs ?? HEARTBEAT_MS;
2302
2292
  this.timer = setInterval(() => void this.beat(), every);
2303
2293
  }
2304
- /** Túnel avulso, só nesta sessão. */
2305
2294
  async add(spec) {
2306
2295
  if (this.tunnels.some((t2) => t2.key === keyOf(spec))) return;
2307
2296
  const t = this.blank(spec, true);
@@ -2310,33 +2299,29 @@ var Session = class {
2310
2299
  const existing = await this.o.api.endpoints().catch(() => []);
2311
2300
  await this.open(t, existing);
2312
2301
  }
2313
- /** Link para o QR: com senha, um token de uso único; sem senha, só a URL. */
2314
2302
  qrLink(key) {
2315
2303
  const t = this.tunnels.find((x) => x.key === key);
2316
2304
  if (!t?.url) return null;
2317
- const auth = this.auths.get(key);
2305
+ const auth = this.persistentAuths.get(key);
2318
2306
  if (!auth) return { url: t.url, expiresAt: null };
2319
2307
  const { token, expiresAt } = auth.issueQrToken();
2320
2308
  return { url: `${t.url}/__duto/qr/${token}`, expiresAt };
2321
2309
  }
2322
- /** Primeiro usuário configurado do túnel, ou null quando ele não tem senha. */
2323
2310
  authUser(key) {
2324
- return this.auths.get(key)?.qrUser ?? null;
2311
+ return this.persistentAuths.get(key)?.qrUser ?? null;
2325
2312
  }
2326
- /** Cria o TunnelAuth na primeira abertura (precisa do allow resolvido) e o mantém depois. */
2327
2313
  async authFor(key) {
2328
- const existing = this.auths.get(key);
2314
+ const existing = this.persistentAuths.get(key);
2329
2315
  if (existing) return existing;
2330
2316
  const spec = this.specs.get(key);
2331
2317
  if (!spec?.auth?.length) return void 0;
2332
2318
  const allow = spec.allow?.length ? await (this.o.resolveAllow ?? resolveAllow)(spec.allow) : void 0;
2333
2319
  const auth = new TunnelAuth({ tunnel: key, credentials: spec.auth, allow });
2334
- this.auths.set(key, auth);
2320
+ this.persistentAuths.set(key, auth);
2335
2321
  return auth;
2336
2322
  }
2337
- /** Reenvia uma requisição da lista ao serviço local. */
2338
2323
  replay(id) {
2339
- return this.proxy ? this.proxy.replay(id) : { ok: false, reason: "Sess\xE3o ainda n\xE3o come\xE7ou." };
2324
+ return this.proxy ? this.proxy.replay(id) : { ok: false, reason: "Session has not started yet." };
2340
2325
  }
2341
2326
  async stop() {
2342
2327
  if (this.stopped) return;
@@ -2355,10 +2340,10 @@ var Session = class {
2355
2340
  }
2356
2341
  async open(t, existing, retrying = false) {
2357
2342
  if (this.stopped) return;
2358
- const taken = existing?.some(
2359
- (e) => e.name === t.name && e.connected && e.deviceId === this.o.deviceId && e.lastUsedAt !== null && Date.now() - e.lastUsedAt < STALE_MS
2343
+ const takenByLiveSession = existing?.some(
2344
+ (e) => e.name === t.name && e.connected && e.deviceId === this.o.deviceId && e.lastUsedAt !== null && Date.now() - e.lastUsedAt < STALE_AFTER_MISSED_HEARTBEATS_MS
2360
2345
  );
2361
- if (taken) return this.fail(t, ALREADY_OPEN);
2346
+ if (takenByLiveSession) return this.fail(t, ALREADY_OPEN);
2362
2347
  try {
2363
2348
  const res = await this.o.api.openTunnel({
2364
2349
  deviceId: this.o.deviceId,
@@ -2534,7 +2519,7 @@ function runPlain(session, out) {
2534
2519
  }
2535
2520
 
2536
2521
  // src/index.ts
2537
- var LOGIN_HINT = "Fa\xE7a login primeiro: duto login <token> (pegue o token em https://duto.dev)";
2522
+ var LOGIN_HINT = "Log in first: duto login <token> (get your token at https://duto.dev)";
2538
2523
  function fail2(message2) {
2539
2524
  console.error(fail(message2));
2540
2525
  process.exit(1);
@@ -2542,19 +2527,19 @@ function fail2(message2) {
2542
2527
  function parsePort(raw) {
2543
2528
  const s = String(raw ?? "");
2544
2529
  const n = Number(s);
2545
- if (!/^\d+$/.test(s) || n < 1 || n > 65535) fail2(`Porta inv\xE1lida: ${s} (use 1\u201365535)`);
2530
+ if (!/^\d+$/.test(s) || n < 1 || n > 65535) fail2(`Invalid port: ${s} (use 1\u201365535)`);
2546
2531
  return n;
2547
2532
  }
2548
2533
  function parseName(raw) {
2549
2534
  if (raw === void 0 || raw === true || raw === "") return void 0;
2550
2535
  const name = normalizeName(String(raw));
2551
2536
  const v = validateName(name);
2552
- if (!v.ok) fail2(v.reason === "reserved" ? `Nome reservado: "${name}". Escolha outro.` : "Nome inv\xE1lido: use s\xF3 letras min\xFAsculas e n\xFAmeros (sem h\xEDfen)");
2537
+ if (!v.ok) fail2(v.reason === "reserved" ? `Reserved name: "${name}". Pick another.` : "Invalid name: use lowercase letters and numbers only (no hyphens)");
2553
2538
  return name;
2554
2539
  }
2555
2540
  function parseHostFlag(raw) {
2556
2541
  if (raw === void 0 || raw === true || raw === "") return void 0;
2557
- return parseHost(String(raw)) ?? fail2("Host inv\xE1lido: use s\xF3 o dom\xEDnio, como meuapp.test");
2542
+ return parseHost(String(raw)) ?? fail2("Invalid host: use the domain only, like myapp.test");
2558
2543
  }
2559
2544
  function listFlag(raw) {
2560
2545
  if (raw === void 0) return [];
@@ -2564,14 +2549,14 @@ function parseAuthFlags(raw) {
2564
2549
  const out = [];
2565
2550
  for (const entry of listFlag(raw)) {
2566
2551
  const r = parseCredential(entry);
2567
- if (!r.ok) fail2(r.reason === "password" ? "Senha precisa ter de 8 a 128 caracteres" : r.reason === "user" ? "Usu\xE1rio inv\xE1lido: de 1 a 128 caracteres, sem espa\xE7o" : "Credencial inv\xE1lida: use usuario:senha");
2568
- if (out.some((c) => c.user === r.cred.user)) fail2(`Usu\xE1rio "${r.cred.user}" aparece duas vezes`);
2552
+ if (!r.ok) fail2(r.reason === "password" ? "Password must be 8 to 128 characters" : r.reason === "user" ? "Invalid user: 1 to 128 characters, no spaces" : "Invalid credential: use user:password");
2553
+ if (out.some((c) => c.user === r.cred.user)) fail2(`Duplicate user "${r.cred.user}"`);
2569
2554
  out.push(r.cred);
2570
2555
  }
2571
2556
  return out;
2572
2557
  }
2573
2558
  function parseAllowFlags(raw) {
2574
- return listFlag(raw).map((v) => parseAllowEntry(v) ? v : fail2(`"${v}" n\xE3o \xE9 um nome conhecido (stripe, github) nem uma faixa de IP`));
2559
+ return listFlag(raw).map((v) => parseAllowEntry(v) ? v : fail2(`"${v}" is not a known name (stripe, github) or an IP range`));
2575
2560
  }
2576
2561
  function wantsUi(flags) {
2577
2562
  if (flags.ui === false) return false;
@@ -2600,14 +2585,14 @@ async function runSession(specs, source, flags) {
2600
2585
  }
2601
2586
  }
2602
2587
  if (wantsUi(flags)) {
2603
- const { runFullscreen } = await import("./fullscreen-YK365XSO.js");
2604
- const code = await runFullscreen(session, specs, "0.7.0", inspector, inspectorError);
2588
+ const { runFullscreen } = await import("./fullscreen-IWZCH3IP.js");
2589
+ const code = await runFullscreen(session, specs, "1.0.0", inspector, inspectorError);
2605
2590
  await inspector?.close().catch(() => void 0);
2606
2591
  process.exit(code);
2607
2592
  }
2608
- console.log(`${mark()} ${paint("muted", `${"0.7.0"} \xB7 ${source} \xB7 Ctrl+C para encerrar`)}`);
2609
- if (inspector) console.log(paint("muted", `inspetor: ${inspector.openUrl}`));
2610
- if (inspectorError) console.log(warn(`inspetor n\xE3o subiu: ${inspectorError}`));
2593
+ console.log(`${mark()} ${paint("muted", `${"1.0.0"} \xB7 ${source} \xB7 Ctrl+C to stop`)}`);
2594
+ if (inspector) console.log(paint("muted", `inspector: ${inspector.openUrl}`));
2595
+ if (inspectorError) console.log(warn(`inspector failed to start: ${inspectorError}`));
2611
2596
  const stopLog = runPlain(session, (l) => console.log(l));
2612
2597
  const stopped = new Promise((resolve) => {
2613
2598
  const onSignal = () => {
@@ -2629,9 +2614,9 @@ async function runSession(specs, source, flags) {
2629
2614
  }
2630
2615
  async function tunnel(rawPort, flags) {
2631
2616
  if (rawPort === void 0) {
2632
- if (flags.host !== void 0) fail2("Use --host junto com a porta (duto 8080 --host meuapp.test) ou host: no duto.yml");
2633
- if (flags.auth !== void 0) fail2("Use --auth junto com a porta (duto 3000 --auth ana:senha) ou auth: no duto.yml");
2634
- if (flags.permitir !== void 0) fail2("Use --permitir junto com a porta (duto 3000 --permitir stripe) ou permitir: no duto.yml");
2617
+ if (flags.host !== void 0) fail2("Use --host with a port (duto 8080 --host myapp.test) or host: in duto.yml");
2618
+ if (flags.auth !== void 0) fail2("Use --auth with a port (duto 3000 --auth ana:secret) or auth: in duto.yml");
2619
+ if (flags.allow !== void 0) fail2("Use --allow with a port (duto 3000 --allow stripe) or allow: in duto.yml");
2635
2620
  let found;
2636
2621
  try {
2637
2622
  found = findConfig(process.cwd());
@@ -2641,7 +2626,7 @@ async function tunnel(rawPort, flags) {
2641
2626
  if (!found) {
2642
2627
  cli.outputHelp();
2643
2628
  console.log(`
2644
- ${paint("signal", "Dica:")} crie um duto.yml com os t\xFAneis do projeto e rode s\xF3 ${paint("strong", "duto")}:
2629
+ ${paint("signal", "Tip:")} create a duto.yml with your project's tunnels and just run ${paint("strong", "duto")}:
2645
2630
 
2646
2631
  tunnels:
2647
2632
  web: 3000
@@ -2655,30 +2640,30 @@ ${paint("signal", "Dica:")} crie um duto.yml com os t\xFAneis do projeto e rode
2655
2640
  const name = parseName(flags.name) ?? null;
2656
2641
  const host = parseHostFlag(flags.host);
2657
2642
  const keepRedirects = flags.rewriteRedirects === false;
2658
- if (keepRedirects && !host) fail2("--no-rewrite-redirects s\xF3 vale junto com --host");
2643
+ if (keepRedirects && !host) fail2("--no-rewrite-redirects only works with --host");
2659
2644
  const auth = parseAuthFlags(flags.auth);
2660
- const allow = parseAllowFlags(flags.permitir);
2661
- if (allow.length && !auth.length) fail2("--permitir s\xF3 vale junto com --auth");
2662
- return runSession([{ name, port, ...host ? { host } : {}, ...keepRedirects ? { rewriteRedirects: false } : {}, ...auth.length ? { auth } : {}, ...allow.length ? { allow } : {} }], "linha de comando", flags);
2645
+ const allow = parseAllowFlags(flags.allow);
2646
+ if (allow.length && !auth.length) fail2("--allow only works with --auth");
2647
+ return runSession([{ name, port, ...host ? { host } : {}, ...keepRedirects ? { rewriteRedirects: false } : {}, ...auth.length ? { auth } : {}, ...allow.length ? { allow } : {} }], "command line", flags);
2663
2648
  }
2664
2649
  var cli = cac("duto");
2665
- cli.command("[port]", "Sem porta: abre os t\xFAneis do duto.yml. Com porta: abre um t\xFAnel para ela").option("--name <nome>", "Nome do endere\xE7o fixo (ex.: web \u2192 https://web-<usu\xE1rio>.duto.dev)").option("--host <host>", "Host que o servi\xE7o local recebe no lugar do p\xFAblico (ex.: meuapp.test)").option("--no-rewrite-redirects", "Com --host: n\xE3o troca o host local pelo p\xFAblico nos redirecionamentos").option("--auth <usuario:senha>", "Pede usu\xE1rio e senha para abrir o endere\xE7o (repet\xEDvel)").option("--permitir <faixa>", "Com --auth: IP, faixa (CIDR) ou nome (stripe, github) que entra sem senha (repet\xEDvel)").option("--no-ui", "Sem tela cheia: s\xF3 um log das requisi\xE7\xF5es (\xFAtil em scripts)").option("--no-inspect", "N\xE3o sobe o inspetor no navegador (127.0.0.1:4141)").option("--ui", "For\xE7a a tela cheia (no Windows ela vem desligada)").action((port, opts) => tunnel(port, opts));
2666
- cli.command("http <port>", "O mesmo que duto <porta> (para quem vem do ngrok)").option("--name <nome>", "Nome do endere\xE7o fixo").option("--host <host>", "Host que o servi\xE7o local recebe no lugar do p\xFAblico").option("--no-rewrite-redirects", "Com --host: mant\xE9m os redirecionamentos como o servi\xE7o mandou").option("--auth <usuario:senha>", "Pede usu\xE1rio e senha para abrir o endere\xE7o (repet\xEDvel)").option("--permitir <faixa>", "Com --auth: IP, faixa (CIDR) ou nome (stripe, github) que entra sem senha (repet\xEDvel)").option("--no-ui", "Sem tela cheia").option("--no-inspect", "N\xE3o sobe o inspetor no navegador (127.0.0.1:4141)").action((port, opts) => tunnel(port, opts));
2667
- cli.command("login <token>", "Salva o authtoken (pegue em https://duto.dev)").action(async (token) => {
2650
+ cli.command("[port]", "No port: open the tunnels in duto.yml. With a port: open a tunnel to it").option("--name <name>", "Fixed address name (e.g. web \u2192 https://web-<user>.duto.dev)").option("--host <host>", "Host the local service receives instead of the public one (e.g. myapp.test)").option("--no-rewrite-redirects", "With --host: do not swap the local host for the public one in redirects").option("--auth <user:password>", "Ask for a username and password to open the address (repeatable)").option("--allow <range>", "With --auth: IP, CIDR range or name (stripe, github) that gets in without a password (repeatable)").option("--no-ui", "No full screen: just a request log (useful in scripts)").option("--no-inspect", "Do not start the browser inspector (127.0.0.1:4141)").option("--ui", "Force full screen (off by default on Windows)").action((port, opts) => tunnel(port, opts));
2651
+ cli.command("http <port>", "Same as duto <port> (for people coming from ngrok)").option("--name <name>", "Fixed address name").option("--host <host>", "Host the local service receives instead of the public one").option("--no-rewrite-redirects", "With --host: keep redirects as the service sent them").option("--auth <user:password>", "Ask for a username and password to open the address (repeatable)").option("--allow <range>", "With --auth: IP, CIDR range or name (stripe, github) that gets in without a password (repeatable)").option("--no-ui", "No full screen").option("--no-inspect", "Do not start the browser inspector (127.0.0.1:4141)").action((port, opts) => tunnel(port, opts));
2652
+ cli.command("login <token>", "Save your authtoken (get it at https://duto.dev)").action(async (token) => {
2668
2653
  try {
2669
2654
  const me = await createApiClient({ baseUrl: apiUrl(), token: String(token) }).me();
2670
2655
  saveConfig({ ...loadConfig(), authtoken: String(token) });
2671
- console.log(ok(`Conectado como ${paint("strong", me.username ?? me.email)}`));
2656
+ console.log(ok(`Logged in as ${paint("strong", me.username ?? me.email)}`));
2672
2657
  } catch (err) {
2673
2658
  fail2(err instanceof Error ? err.message : String(err));
2674
2659
  }
2675
2660
  });
2676
- cli.command("logout", "Remove o authtoken desta m\xE1quina").action(() => {
2661
+ cli.command("logout", "Remove the authtoken from this machine").action(() => {
2677
2662
  const { authtoken: _removed, ...rest } = loadConfig();
2678
2663
  saveConfig(rest);
2679
- console.log(ok("Authtoken removido"));
2664
+ console.log(ok("Authtoken removed"));
2680
2665
  });
2681
- cli.command("status", "Mostra a conta e os t\xFAneis no ar").action(async () => {
2666
+ cli.command("status", "Show your account and live tunnels").action(async () => {
2682
2667
  const config = loadConfig();
2683
2668
  if (!config.authtoken) fail2(LOGIN_HINT);
2684
2669
  try {
@@ -2686,23 +2671,17 @@ cli.command("status", "Mostra a conta e os t\xFAneis no ar").action(async () =>
2686
2671
  const [me, endpoints] = await Promise.all([api.me(), api.endpoints()]);
2687
2672
  console.log(`${mark()} ${paint("muted", `${me.username} \xB7 ${me.email}`)}`);
2688
2673
  const live = endpoints.filter((e) => e.connected);
2689
- if (!live.length) console.log(paint("muted", "Nenhum t\xFAnel no ar."));
2674
+ if (!live.length) console.log(paint("muted", "No live tunnels."));
2690
2675
  for (const e of live)
2691
2676
  console.log(
2692
- `${paint("signal", "\u25CF")} https://${e.hostname} ${paint("muted", `\u2192 localhost:${e.port}${e.deviceId === config.deviceId ? "" : ` \xB7 ${e.deviceName ?? "outra m\xE1quina"}`}`)}`
2677
+ `${paint("signal", "\u25CF")} https://${e.hostname} ${paint("muted", `\u2192 localhost:${e.port}${e.deviceId === config.deviceId ? "" : ` \xB7 ${e.deviceName ?? "another machine"}`}`)}`
2693
2678
  );
2694
2679
  } catch (err) {
2695
2680
  fail2(err instanceof DutoApiError ? err.message : String(err));
2696
2681
  }
2697
2682
  });
2698
- cli.help(
2699
- (sections) => sections.map((s) => ({
2700
- ...s,
2701
- title: s.title === "Usage" ? "Uso" : s.title === "Commands" ? "Comandos" : s.title === "Options" ? "Op\xE7\xF5es" : s.title,
2702
- 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")
2703
- }))
2704
- );
2705
- cli.version("0.7.0");
2683
+ cli.help();
2684
+ cli.version("1.0.0");
2706
2685
  try {
2707
2686
  cli.parse(process.argv, { run: false });
2708
2687
  await cli.runMatchedCommand();