@dutodev/cli 0.6.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
@@ -1,5 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
+ C,
4
+ TRUNCATED_NOTE,
5
+ bodyNote,
3
6
  formatBytes,
4
7
  formatDuration,
5
8
  formatTime,
@@ -7,7 +10,7 @@ import {
7
10
  normalizeName,
8
11
  statusColor,
9
12
  validateName
10
- } from "./chunk-26EQC6HB.js";
13
+ } from "./chunk-BPOXISIM.js";
11
14
 
12
15
  // src/ui/brand.ts
13
16
  function colorOn(stream = process.stdout) {
@@ -17,7 +20,6 @@ function colorOn(stream = process.stdout) {
17
20
  }
18
21
  var SGR = {
19
22
  signal: "38;2;45;212;191",
20
- // #2dd4bf
21
23
  muted: "38;2;138;143;139",
22
24
  strong: "1",
23
25
  ok: "38;2;74;222;128",
@@ -30,6 +32,7 @@ function paint(tone, text, stream) {
30
32
  var mark = () => `${paint("signal", "\u25B0")} ${paint("strong", "duto")}`;
31
33
  var ok = (msg) => `${paint("signal", "\u2714")} ${msg}`;
32
34
  var fail = (msg) => `${paint("err", "\u2716", process.stderr)} ${msg}`;
35
+ var warn = (msg) => `${paint("warn", "\u26A0")} ${msg}`;
33
36
 
34
37
  // src/index.ts
35
38
  import { hostname } from "os";
@@ -48,9 +51,9 @@ var DutoApiError = class extends Error {
48
51
  code;
49
52
  };
50
53
  var MESSAGES = {
51
- unauthorized: "Authtoken inv\xE1lido. Gere um novo em https://duto.dev e rode: duto login <token>",
52
- suspended: "Sua conta est\xE1 suspensa.",
53
- 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."
54
57
  };
55
58
  function createApiClient(o) {
56
59
  const doFetch = o.fetchImpl ?? fetch;
@@ -76,7 +79,7 @@ function createApiClient(o) {
76
79
  const code = data.error ?? "http_error";
77
80
  if (res.status === 401) throw new DutoApiError(MESSAGES.unauthorized, 401, code);
78
81
  if (res.status === 403 && code === "suspended") throw new DutoApiError(MESSAGES.suspended, 403, code);
79
- 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);
80
83
  }
81
84
  async function json(method, path, body) {
82
85
  const res = await call(method, path, body);
@@ -165,7 +168,7 @@ function assetFor(platform, arch) {
165
168
  case "win32/x64":
166
169
  return { asset: "cloudflared-windows-amd64.exe", kind: "exe" };
167
170
  default:
168
- throw new Error(`Plataforma n\xE3o suportada: ${key}`);
171
+ throw new Error(`Unsupported platform: ${key}`);
169
172
  }
170
173
  }
171
174
  function downloadUrl(asset, version = CLOUDFLARED_VERSION) {
@@ -180,13 +183,13 @@ async function ensureCloudflared(opts = {}) {
180
183
  opts.onDownload?.();
181
184
  mkdirSync2(binDir, { recursive: true });
182
185
  const res = await (opts.fetchImpl ?? fetch)(downloadUrl(asset));
183
- 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}).`);
184
187
  const bytes = Buffer.from(await res.arrayBuffer());
185
188
  const expected = (opts.checksums ?? CHECKSUMS)[asset];
186
189
  const actual = createHash("sha256").update(bytes).digest("hex");
187
190
  if (!expected || actual !== expected) {
188
191
  rmSync(target, { force: true });
189
- 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.");
190
193
  }
191
194
  const tmp = mkdtempSync(join2(binDir, ".download-"));
192
195
  try {
@@ -213,13 +216,13 @@ async function ensureCloudflaredWithProgress(write, opts = {}) {
213
216
  ...opts,
214
217
  onDownload: () => {
215
218
  downloading = true;
216
- write("\u2193 Baixando cloudflared (s\xF3 na primeira vez)... ");
219
+ write("\u2193 Downloading cloudflared (first run only)... ");
217
220
  }
218
221
  });
219
222
  if (downloading) write("ok\n");
220
223
  return path;
221
224
  } catch (err) {
222
- if (downloading) write("falhou\n");
225
+ if (downloading) write("failed\n");
223
226
  throw err;
224
227
  }
225
228
  }
@@ -270,8 +273,8 @@ var SOURCES = {
270
273
  github: { url: "https://api.github.com/meta", pick: (j) => j?.hooks }
271
274
  };
272
275
  function splitCidr(raw) {
273
- const [ip, p, ...resto] = raw.split("/");
274
- if (!ip || resto.length) return null;
276
+ const [ip, p, ...extra] = raw.split("/");
277
+ if (!ip || extra.length) return null;
275
278
  const v = isIP(ip);
276
279
  if (!v) return null;
277
280
  const max = v === 4 ? 32 : 128;
@@ -307,9 +310,9 @@ async function fetchRanges(name, f) {
307
310
  const res = await f(src.url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), headers: { accept: "application/json" } });
308
311
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
309
312
  const list = src.pick(await res.json());
310
- if (!Array.isArray(list)) throw new Error("formato inesperado");
313
+ if (!Array.isArray(list)) throw new Error("unexpected format");
311
314
  const out = list.filter((x) => typeof x === "string" && splitCidr(x) !== null);
312
- if (!out.length) throw new Error("lista vazia");
315
+ if (!out.length) throw new Error("empty list");
313
316
  return out.map((x) => x.includes("/") ? x : `${x}/${splitCidr(x).prefix}`);
314
317
  }
315
318
  async function resolveAllow(entries, o = {}) {
@@ -321,7 +324,8 @@ async function resolveAllow(entries, o = {}) {
321
324
  const cached = readCache(cacheFile);
322
325
  const fresh = cached && now - cached.fetchedAt >= 0 && now - cached.fetchedAt < CACHE_MS ? cached : null;
323
326
  const lists = {};
324
- 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) {
325
329
  for (const n of names) lists[n] = fresh.lists[n];
326
330
  } else {
327
331
  const net = {};
@@ -332,7 +336,8 @@ async function resolveAllow(entries, o = {}) {
332
336
  lists[name] = ALLOW_SNAPSHOT[name];
333
337
  }
334
338
  }
335
- if (names.length && Object.keys(net).length === names.length) {
339
+ const everyListFromNetwork = names.length > 0 && Object.keys(net).length === names.length;
340
+ if (everyListFromNetwork) {
336
341
  try {
337
342
  mkdirSync3(dirname(cacheFile), { recursive: true });
338
343
  writeFileSync3(cacheFile, JSON.stringify({ fetchedAt: now, lists: net }));
@@ -362,7 +367,7 @@ import { dirname as dirname2, join as join4 } from "path";
362
367
  import { isMap, isScalar, isSeq, LineCounter, parseDocument } from "yaml";
363
368
  var ConfigError = class extends Error {
364
369
  constructor(message2, line, file = CONFIG_FILES[0]) {
365
- super(!file ? message2 : line ? `${file}, linha ${line}: ${message2}` : `${file}: ${message2}`);
370
+ super(!file ? message2 : line ? `${file}, line ${line}: ${message2}` : `${file}: ${message2}`);
366
371
  this.line = line;
367
372
  this.name = "ConfigError";
368
373
  }
@@ -372,7 +377,7 @@ var CONFIG_FILES = ["duto.yml", "duto.yaml"];
372
377
  var LABEL = "[a-z0-9](?:[a-z0-9-]*[a-z0-9])?";
373
378
  var HOST_RE = new RegExp(`^${LABEL}(?:\\.${LABEL})*(?::\\d{1,5})?$`);
374
379
  var HEADER_NAME_RE = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
375
- var TUNNEL_KEYS = ["port", "host", "headers", "rewrite_redirects", "auth", "permitir"];
380
+ var TUNNEL_KEYS = ["port", "host", "headers", "rewrite_redirects", "auth", "allow"];
376
381
  var ENV_REF_RE = /\$\{([A-Z_0-9]+)\}/g;
377
382
  function parseHost(raw) {
378
383
  const host = raw.trim().toLowerCase();
@@ -398,11 +403,11 @@ function parseConfig(text, file = CONFIG_FILES[0], env = process.env) {
398
403
  const lineAt = (offset) => offset === void 0 ? void 0 : lc.linePos(offset).line;
399
404
  if (doc.errors.length) {
400
405
  const e = doc.errors[0];
401
- 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]));
402
407
  }
403
408
  const tunnels = isMap(doc.contents) ? doc.contents.get("tunnels", true) : void 0;
404
409
  if (!isMap(tunnels) || tunnels.items.length === 0) {
405
- 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)');
406
411
  }
407
412
  const specs = [];
408
413
  for (const pair of tunnels.items) {
@@ -412,40 +417,40 @@ function parseConfig(text, file = CONFIG_FILES[0], env = process.env) {
412
417
  const v = validateName(name);
413
418
  if (!v.ok) {
414
419
  throw fail3(
415
- 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)`,
416
421
  line
417
422
  );
418
423
  }
419
424
  const port = portOf(pair.value);
420
- 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);
421
426
  const spec = { name, port };
422
427
  if (isMap(pair.value)) {
423
428
  const body = pair.value;
424
429
  for (const item of body.items) {
425
430
  const key = isScalar(item.key) ? String(item.key.value) : "";
426
- 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);
427
432
  }
428
433
  const hostNode = body.get("host", true);
429
434
  if (hostNode !== void 0) {
430
435
  const host = isScalar(hostNode) && typeof hostNode.value === "string" ? parseHost(hostNode.value) : null;
431
- 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);
432
437
  spec.host = host;
433
438
  }
434
439
  const headersNode = body.get("headers", true);
435
440
  if (headersNode !== void 0) {
436
- 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);
437
442
  const headers = {};
438
443
  for (const h of headersNode.items) {
439
444
  const hName = isScalar(h.key) ? String(h.key.value) : "";
440
445
  const hLine = lineAt(h.key?.range?.[0]);
441
- if (!HEADER_NAME_RE.test(hName)) throw fail3(`cabe\xE7alho inv\xE1lido em "${raw}": "${hName}"`, hLine);
442
- 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);
443
448
  const value = h.value === null ? null : isScalar(h.value) ? h.value.value : void 0;
444
449
  if (value !== null && !["string", "number", "boolean"].includes(typeof value)) {
445
- throw fail3(`valor inv\xE1lido para o cabe\xE7alho "${hName}" em "${raw}"`, hLine);
450
+ throw fail3(`invalid value for header "${hName}" in "${raw}"`, hLine);
446
451
  }
447
452
  const str = value === null ? "" : String(value);
448
- 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);
449
454
  headers[hName.toLowerCase()] = str;
450
455
  }
451
456
  spec.headers = headers;
@@ -453,50 +458,50 @@ function parseConfig(text, file = CONFIG_FILES[0], env = process.env) {
453
458
  const rewriteNode = body.get("rewrite_redirects", true);
454
459
  if (rewriteNode !== void 0) {
455
460
  const value = isScalar(rewriteNode) ? rewriteNode.value : void 0;
456
- if (typeof value !== "boolean") throw fail3(`rewrite_redirects de "${raw}" deve ser true ou false`, line);
457
- 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);
458
463
  if (value === false) spec.rewriteRedirects = false;
459
464
  }
460
465
  const authNode = body.get("auth", true);
461
466
  if (authNode !== void 0) {
462
- 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);
463
468
  const auth = [];
464
469
  for (const item of authNode.items) {
465
470
  const iLine = lineAt(item.range?.[0]) ?? line;
466
471
  const text2 = isScalar(item) && typeof item.value === "string" ? item.value : null;
467
- 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);
468
473
  const expanded = text2.replace(ENV_REF_RE, (_m, name2) => {
469
474
  const val = env[name2];
470
- 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);
471
476
  return val;
472
477
  });
473
478
  const parsed = parseCredential(expanded);
474
479
  if (!parsed.ok) {
475
- if (parsed.reason === "format") throw fail3(`credencial inv\xE1lida em "${raw}": use usuario:senha`, iLine);
476
- if (parsed.reason === "user") throw fail3(`usu\xE1rio inv\xE1lido em "${raw}": de 1 a 128 caracteres, sem espa\xE7o`, iLine);
477
- 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);
478
483
  }
479
- 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);
480
485
  auth.push(parsed.cred);
481
486
  }
482
- 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);
483
488
  spec.auth = auth;
484
489
  }
485
- const allowNode = body.get("permitir", true);
490
+ const allowNode = body.get("allow", true);
486
491
  if (allowNode !== void 0) {
487
- if (!isSeq(allowNode)) throw fail3(`permitir de "${raw}" deve ser uma lista`, line);
488
- 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);
489
494
  const allow = [];
490
495
  for (const item of allowNode.items) {
491
496
  const iLine = lineAt(item.range?.[0]) ?? line;
492
497
  const value = isScalar(item) ? String(item.value) : "";
493
- 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);
494
499
  allow.push(value);
495
500
  }
496
501
  if (allow.length) spec.allow = allow;
497
502
  }
498
503
  }
499
- 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);
500
505
  specs.push(spec);
501
506
  }
502
507
  return specs;
@@ -505,7 +510,7 @@ function findConfig(cwd) {
505
510
  let dir = cwd;
506
511
  for (; ; ) {
507
512
  const present = CONFIG_FILES.filter((f) => existsSync3(join4(dir, f)));
508
- 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, "");
509
514
  if (present.length === 1) {
510
515
  const path = join4(dir, present[0]);
511
516
  return { path, tunnels: parseConfig(readFileSync3(path, "utf8"), present[0]) };
@@ -517,6 +522,914 @@ function findConfig(cwd) {
517
522
  }
518
523
  }
519
524
 
525
+ // src/inspector/jsonView.ts
526
+ function countLabel(n, one, many) {
527
+ return n + " " + (n === 1 ? one : many);
528
+ }
529
+ function jsonSyntaxError(text) {
530
+ try {
531
+ JSON.parse(text);
532
+ return null;
533
+ } catch (e) {
534
+ const message2 = String(e && e.message ? e.message : e);
535
+ const m = /position (\d+)/.exec(message2);
536
+ if (m) return { position: Number(m[1]), message: message2 };
537
+ const lc = /line (\d+) column (\d+)/.exec(message2);
538
+ if (lc) {
539
+ let idx = 0;
540
+ for (let ln = 1; ln < Number(lc[1]); ln++) {
541
+ const nl = text.indexOf("\n", idx);
542
+ if (nl < 0) return { position: text.length, message: message2 };
543
+ idx = nl + 1;
544
+ }
545
+ return { position: Math.min(idx + Number(lc[2]) - 1, text.length), message: message2 };
546
+ }
547
+ return { position: text.length, message: message2 };
548
+ }
549
+ }
550
+ function jsonTokens(text) {
551
+ const out = [];
552
+ let depth = 0;
553
+ let i = 0;
554
+ while (i < text.length) {
555
+ const c = text.charAt(i);
556
+ let j = i + 1;
557
+ let kind;
558
+ if (c === " " || c === "\n" || c === " " || c === "\r") {
559
+ while (j < text.length && " \n \r".indexOf(text.charAt(j)) >= 0) j++;
560
+ kind = "space";
561
+ } else if (c === '"') {
562
+ while (j < text.length && text.charAt(j) !== '"') j += text.charAt(j) === "\\" ? 2 : 1;
563
+ j = Math.min(j + 1, text.length);
564
+ let k = j;
565
+ while (k < text.length && " \n \r".indexOf(text.charAt(k)) >= 0) k++;
566
+ kind = text.charAt(k) === ":" ? "key" : "string";
567
+ } else if ("{}[],:".indexOf(c) >= 0) {
568
+ kind = "punct";
569
+ if (c === "}" || c === "]") depth = Math.max(0, depth - 1);
570
+ } else {
571
+ while (j < text.length && ' \n \r{}[],:"'.indexOf(text.charAt(j)) < 0) j++;
572
+ const word = text.slice(i, j);
573
+ kind = word === "true" || word === "false" || word === "null" ? "literal" : "number";
574
+ }
575
+ out.push({ kind, text: text.slice(i, j), depth });
576
+ if (kind === "punct" && (c === "{" || c === "[")) depth++;
577
+ i = j;
578
+ }
579
+ return out;
580
+ }
581
+ function jsonIndent(text) {
582
+ let out = "";
583
+ let depth = 0;
584
+ let i = 0;
585
+ const ws = " \n \r";
586
+ while (i < text.length) {
587
+ const c = text.charAt(i);
588
+ if (ws.indexOf(c) >= 0) {
589
+ i++;
590
+ } else if (c === '"') {
591
+ let j = i + 1;
592
+ while (j < text.length && text.charAt(j) !== '"') j += text.charAt(j) === "\\" ? 2 : 1;
593
+ out += text.slice(i, j + 1);
594
+ i = j + 1;
595
+ } else if (c === "{" || c === "[") {
596
+ let j = i + 1;
597
+ while (j < text.length && ws.indexOf(text.charAt(j)) >= 0) j++;
598
+ if (text.charAt(j) === (c === "{" ? "}" : "]")) {
599
+ out += c + text.charAt(j);
600
+ i = j + 1;
601
+ } else {
602
+ depth++;
603
+ out += c + "\n" + " ".repeat(depth);
604
+ i++;
605
+ }
606
+ } else if (c === "}" || c === "]") {
607
+ depth = Math.max(0, depth - 1);
608
+ out += "\n" + " ".repeat(depth) + c;
609
+ i++;
610
+ } else if (c === ",") {
611
+ out += ",\n" + " ".repeat(depth);
612
+ i++;
613
+ } else if (c === ":") {
614
+ out += ": ";
615
+ i++;
616
+ } else {
617
+ let j = i + 1;
618
+ while (j < text.length && (ws + '{}[],:"').indexOf(text.charAt(j)) < 0) j++;
619
+ out += text.slice(i, j);
620
+ i = j;
621
+ }
622
+ }
623
+ return out;
624
+ }
625
+
626
+ // src/inspector/page.ts
627
+ var BG = "#111413";
628
+ var BAR = "#0b0d0c";
629
+ var LINE = "#222826";
630
+ var ROW_LINE = "#1a1f1d";
631
+ var INPUT_LINE = "#2a312e";
632
+ var TEXT = "#e7e9e6";
633
+ var SELECTED = "#16201e";
634
+ var JSON_KEY = "#93c5fd";
635
+ var JSON_STRING = "#fdba74";
636
+ var JSON_VALUE = "#c4b5fd";
637
+ var MONO = '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace';
638
+ var STYLE = `
639
+ :root { color-scheme: dark; }
640
+ * { box-sizing: border-box; }
641
+ html, body { height: 100%; }
642
+ body { margin: 0; background: ${BG}; color: ${TEXT}; font-family: ${MONO}; font-size: 13px; line-height: 1.45; }
643
+ button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
644
+ :focus-visible { outline: 1px solid ${C.signal}; outline-offset: -1px; }
645
+ .app { display: flex; flex-direction: column; height: 100vh; }
646
+ .top { display: flex; justify-content: space-between; gap: 16px; padding: 8px 12px; background: ${BAR}; border-bottom: 1px solid ${LINE}; color: ${C.muted}; }
647
+ .top b { color: ${C.signal}; font-weight: 600; }
648
+ .tunnels { display: flex; flex-wrap: wrap; gap: 4px 14px; justify-content: flex-end; min-width: 0; }
649
+ .tunnels .k { color: ${C.strong}; }
650
+ .banner { padding: 6px 12px; background: ${BAR}; border-bottom: 1px solid ${C.err}; color: ${C.err}; }
651
+ .main { flex: 1; min-height: 0; display: grid; grid-template-columns: 320px 1fr; }
652
+ .side { display: flex; flex-direction: column; min-height: 0; border-right: 1px solid ${LINE}; }
653
+ .search { margin: 8px 12px; padding: 5px 8px; border: 1px solid ${INPUT_LINE}; border-radius: 5px; background: ${BG}; color: ${TEXT}; font: inherit; outline: none; }
654
+ .search:focus { border-color: ${C.signal}; }
655
+ .search::placeholder { color: ${C.muted}; }
656
+ .list { flex: 1; min-height: 0; overflow-y: auto; outline: none; }
657
+ .row { display: grid; grid-template-columns: 14px 42px minmax(0, 1fr) 34px 56px; gap: 8px; padding: 5px 12px; border-bottom: 1px solid ${ROW_LINE}; align-items: center; cursor: pointer; white-space: nowrap; }
658
+ .list.multi .row { grid-template-columns: 14px 56px 42px minmax(0, 1fr) 34px 56px; }
659
+ .row:hover { background: #141917; }
660
+ .row.sel { background: ${SELECTED}; box-shadow: inset 2px 0 0 ${C.signal}; }
661
+ .row span { overflow: hidden; text-overflow: ellipsis; }
662
+ .row .dur { text-align: right; }
663
+ .m { font-weight: 700; }
664
+ .mut { color: ${C.muted}; }
665
+ .empty { padding: 12px; color: ${C.muted}; }
666
+ .detail { display: flex; flex-direction: column; min-height: 0; min-width: 0; }
667
+ .pane { flex: 1; }
668
+ .head { padding: 8px 12px; overflow-wrap: anywhere; }
669
+ .head b { color: ${C.strong}; }
670
+ .tabs { display: flex; gap: 14px; padding: 8px 12px 0; border-bottom: 1px solid ${LINE}; }
671
+ .tab { color: ${C.muted}; padding-bottom: 6px; border-bottom: 2px solid transparent; }
672
+ .tab:hover { color: ${TEXT}; }
673
+ .tab.on { color: ${C.strong}; border-bottom-color: ${C.signal}; }
674
+ .content { flex: 1; min-height: 0; overflow: auto; }
675
+ .content pre { margin: 0; padding: 10px 12px; line-height: 1.55; white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; }
676
+ .note { padding: 10px 12px; color: ${C.muted}; }
677
+ .trunc { padding: 0 12px 10px; color: ${C.warn}; }
678
+ .jerr { padding: 10px 12px 0; color: ${C.err}; }
679
+ .bad { background: ${C.err}40; color: ${C.strong}; }
680
+ .k { color: ${JSON_KEY}; } .s { color: ${JSON_STRING}; } .n { color: ${JSON_VALUE}; }
681
+ .fold.closed > .inner { display: none; }
682
+ .fold > .sum { display: none; color: ${C.muted}; }
683
+ .fold.closed > .sum { display: inline; }
684
+ .open { cursor: pointer; }
685
+ .open:hover { color: ${C.signal}; }
686
+ table { border-collapse: collapse; margin: 6px 12px 10px; }
687
+ th, td { text-align: left; vertical-align: top; padding: 3px 16px 3px 0; border-bottom: 1px solid ${ROW_LINE}; overflow-wrap: anywhere; }
688
+ th { color: ${JSON_KEY}; font-weight: 400; white-space: nowrap; }
689
+ .sect { padding: 10px 12px 0; color: ${C.muted}; }
690
+ .btns { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 8px 12px; border-top: 1px solid ${LINE}; }
691
+ .btn { border: 1px solid ${INPUT_LINE}; border-radius: 5px; padding: 3px 8px; }
692
+ .btn:hover:not(:disabled) { border-color: ${C.muted}; }
693
+ .btn.pri { border-color: ${C.signal}; color: ${C.signal}; }
694
+ .btn:disabled { color: ${C.muted}; border-color: ${ROW_LINE}; cursor: default; }
695
+ .msg { color: ${C.muted}; }
696
+ .msg.err { color: ${C.err}; }
697
+ [hidden] { display: none !important; }
698
+ @media (max-width: 720px) {
699
+ .main { grid-template-columns: 1fr; grid-template-rows: 40vh 1fr; }
700
+ .side { border-right: 0; border-bottom: 1px solid ${LINE}; }
701
+ }
702
+ `;
703
+ var SCRIPT = String.raw`"use strict";
704
+ var countLabel = (${countLabel.toString()});
705
+ var jsonSyntaxError = (${jsonSyntaxError.toString()});
706
+ var jsonTokens = (${jsonTokens.toString()});
707
+ var jsonIndent = (${jsonIndent.toString()});
708
+ var COLORS = ${JSON.stringify(C)};
709
+ var TOKEN = new URLSearchParams(location.search).get("t") || "";
710
+ var TABS = ["Body", "Headers", "Response", "Raw"];
711
+
712
+ var state = { items: [], tunnels: [], selected: null, detail: null, detailKey: "", tab: "Body", ended: false };
713
+ var listSeq = 0, detailSeq = 0, msgTimer = null;
714
+ var es = null, lost = false, replaying = false;
715
+ function fail(e) { if (!state.ended) console.error(e); }
716
+
717
+ function $(id) { return document.getElementById(id); }
718
+ function el(tag, cls, text) {
719
+ var e = document.createElement(tag);
720
+ if (cls) e.className = cls;
721
+ if (text !== undefined && text !== null) e.textContent = String(text);
722
+ return e;
723
+ }
724
+ function clear(node) { while (node.firstChild) node.removeChild(node.firstChild); }
725
+
726
+ function banner(text) {
727
+ var b = $("banner");
728
+ b.textContent = text || "";
729
+ b.hidden = !text;
730
+ }
731
+ function ended() {
732
+ state.ended = true;
733
+ banner("Session ended. Press i in duto to open the inspector again.");
734
+ if (es) es.close();
735
+ }
736
+
737
+ function api(path, method) {
738
+ return fetch(path, { method: method || "GET", headers: { "X-Duto-Inspector": TOKEN }, cache: "no-store" }).then(function (r) {
739
+ if (r.status === 403) { ended(); throw new Error("403"); }
740
+ return r.json().then(function (body) { return { status: r.status, body: body }; });
741
+ });
742
+ }
743
+
744
+ function statusInfo(it) {
745
+ if (it.status !== null && it.status !== undefined) {
746
+ var s = it.status;
747
+ var color = s >= 500 ? COLORS.err : s >= 400 ? COLORS.warn : s >= 300 ? COLORS.signal : COLORS.ok;
748
+ return { text: String(s), color: color };
749
+ }
750
+ if (it.error) return { text: "error", color: COLORS.err };
751
+ return { text: "…", color: COLORS.muted };
752
+ }
753
+ function duration(it) { return it.durationMs === null || it.durationMs === undefined ? "" : it.durationMs + " ms"; }
754
+ function size(n) {
755
+ if (n < 1024) return n + " B";
756
+ if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
757
+ return (n / 1024 / 1024).toFixed(1) + " MB";
758
+ }
759
+ function methodOf(it) { return it.websocket ? "WS" : it.method; }
760
+ function header(h, name) {
761
+ for (var k in h) if (k.toLowerCase() === name) return h[k];
762
+ return "";
763
+ }
764
+ function visible() {
765
+ var f = $("filter").value.trim().toLowerCase();
766
+ if (!f) return state.items;
767
+ return state.items.filter(function (it) {
768
+ return (it.tunnel + " " + (it.websocket ? "ws" : it.method) + " " + it.path + " " + (it.status === null || it.status === undefined ? "" : it.status)).toLowerCase().indexOf(f) >= 0;
769
+ });
770
+ }
771
+
772
+ function renderTop() {
773
+ var n = state.items.length;
774
+ $("count").textContent = countLabel(n, "request", "requests");
775
+ var box = $("tunnels");
776
+ clear(box);
777
+ state.tunnels.forEach(function (t) {
778
+ var s = el("span");
779
+ s.appendChild(el("span", "k", t.key));
780
+ s.appendChild(document.createTextNode(" " + (t.url || "no URL yet")));
781
+ box.appendChild(s);
782
+ });
783
+ }
784
+
785
+ function renderList() {
786
+ var list = $("list");
787
+ var multi = state.tunnels.length > 1;
788
+ list.classList.toggle("multi", multi);
789
+ clear(list);
790
+ var items = visible();
791
+ if (items.length === 0) {
792
+ list.appendChild(el("div", "empty", state.items.length === 0 ? "No requests yet." : "No requests match the filter."));
793
+ return;
794
+ }
795
+ items.forEach(function (it) {
796
+ var row = el("div", "row" + (it.id === state.selected ? " sel" : ""));
797
+ row.setAttribute("role", "option");
798
+ row.setAttribute("aria-selected", it.id === state.selected ? "true" : "false");
799
+ row.dataset.id = String(it.id);
800
+ row.appendChild(el("span", "mut", it.replayOf !== undefined ? "↻" : ""));
801
+ if (multi) row.appendChild(el("span", "mut", it.tunnel));
802
+ row.appendChild(el("span", "m", methodOf(it)));
803
+ var p = el("span", "", it.path);
804
+ p.title = it.path;
805
+ row.appendChild(p);
806
+ var st = statusInfo(it);
807
+ var s = el("span", "", st.text);
808
+ s.style.color = st.color;
809
+ row.appendChild(s);
810
+ row.appendChild(el("span", "mut dur", duration(it)));
811
+ row.addEventListener("click", function () { select(it.id); });
812
+ list.appendChild(row);
813
+ });
814
+ }
815
+
816
+ function loadList() {
817
+ var seq = ++listSeq;
818
+ return Promise.all([api("/api/requests"), api("/api/session")]).then(function (rs) {
819
+ if (seq !== listSeq) return;
820
+ state.items = rs[0].body.items || [];
821
+ state.tunnels = rs[1].body.tunnels || [];
822
+ renderTop();
823
+ if (state.selected === null && state.items.length > 0) return select(state.items[0].id);
824
+ renderList();
825
+ }).catch(fail);
826
+ }
827
+
828
+ function select(id) {
829
+ if (state.selected !== id) {
830
+ state.selected = id;
831
+ state.detail = null;
832
+ state.detailKey = "";
833
+ hideMsg();
834
+ }
835
+ renderList();
836
+ var row = document.querySelector('.row[data-id="' + id + '"]');
837
+ if (row) row.scrollIntoView({ block: "nearest" });
838
+ return loadDetail(id);
839
+ }
840
+
841
+ function loadDetail(id) {
842
+ var seq = ++detailSeq;
843
+ return api("/api/requests/" + id).then(function (r) {
844
+ if (seq !== detailSeq || state.selected !== id) return;
845
+ var key = JSON.stringify(r.body);
846
+ if (key === state.detailKey) return;
847
+ state.detailKey = key;
848
+ state.detail = r.status === 200 ? r.body : null;
849
+ if (r.status !== 200) renderGone(r.body && r.body.error ? r.body.error : "That request is no longer in the list.");
850
+ else renderDetail();
851
+ }).catch(fail);
852
+ }
853
+
854
+ function renderGone(text) {
855
+ $("detail").hidden = true;
856
+ $("none").hidden = false;
857
+ $("none").textContent = text;
858
+ }
859
+
860
+ function renderDetail() {
861
+ var d = state.detail;
862
+ if (!d) return;
863
+ $("none").hidden = true;
864
+ $("detail").hidden = false;
865
+
866
+ var head = $("head");
867
+ clear(head);
868
+ head.appendChild(el("b", "", methodOf(d) + " " + d.path));
869
+ head.appendChild(document.createTextNode(" "));
870
+ var st = statusInfo(d);
871
+ var s = el("span", "", st.text);
872
+ s.style.color = st.color;
873
+ head.appendChild(s);
874
+ var parts = [];
875
+ if (duration(d)) parts.push(duration(d));
876
+ parts.push(size(d.bytes));
877
+ parts.push(d.tunnel);
878
+ if (d.replayOf !== undefined) parts.push("↻ replay of #" + d.replayOf);
879
+ head.appendChild(el("span", "mut", " · " + parts.join(" · ")));
880
+ if (d.error) head.appendChild(el("div", "msg err", d.error));
881
+
882
+ var tabs = document.querySelectorAll(".tab");
883
+ for (var i = 0; i < tabs.length; i++) {
884
+ var on = tabs[i].dataset.tab === state.tab;
885
+ tabs[i].classList.toggle("on", on);
886
+ tabs[i].setAttribute("aria-selected", on ? "true" : "false");
887
+ }
888
+ renderContent();
889
+ renderButtons();
890
+ }
891
+
892
+ function renderContent() {
893
+ var d = state.detail;
894
+ var box = $("content");
895
+ var top = box.scrollTop;
896
+ clear(box);
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"));
901
+ box.appendChild(table(d.resHeaders));
902
+ box.appendChild(el("div", "sect", "Body"));
903
+ renderBody(box, d.resBody, d.resNote, d.resTruncatedNote, d.resHeaders);
904
+ } else {
905
+ var lines = [d.method + " " + d.path + " HTTP/1.1"];
906
+ for (var k in d.reqHeaders) lines.push(k + ": " + d.reqHeaders[k]);
907
+ lines.push("");
908
+ lines.push(d.reqNote !== null ? d.reqNote : d.reqBody || "");
909
+ box.appendChild(el("pre", "", lines.join("\n")));
910
+ }
911
+ box.scrollTop = top;
912
+ }
913
+
914
+ function table(h) {
915
+ var keys = Object.keys(h || {});
916
+ if (keys.length === 0) return el("div", "note", "No headers.");
917
+ var t = el("table");
918
+ keys.forEach(function (k) {
919
+ var tr = el("tr");
920
+ tr.appendChild(el("th", "", k));
921
+ tr.appendChild(el("td", "", h[k]));
922
+ t.appendChild(tr);
923
+ });
924
+ return t;
925
+ }
926
+
927
+ function renderBody(box, body, note, truncNote, headers) {
928
+ if (note !== null) {
929
+ box.appendChild(el("div", "note", note));
930
+ return;
931
+ }
932
+ if (body === null || body === undefined) {
933
+ box.appendChild(el("div", "note", "No body."));
934
+ } else if (header(headers, "content-type").toLowerCase().indexOf("json") >= 0) {
935
+ var err = jsonSyntaxError(body);
936
+ if (err === null) box.appendChild(jsonTree(jsonIndent(body)));
937
+ else {
938
+ box.appendChild(el("div", "jerr", err.message));
939
+ var pre = el("pre");
940
+ var at = Math.min(err.position, Math.max(0, body.length - 1));
941
+ pre.appendChild(document.createTextNode(body.slice(0, at)));
942
+ pre.appendChild(el("span", "bad", body.slice(at, at + 1) || " "));
943
+ pre.appendChild(document.createTextNode(body.slice(at + 1)));
944
+ box.appendChild(pre);
945
+ }
946
+ } else {
947
+ box.appendChild(el("pre", "", body));
948
+ }
949
+ if (truncNote !== null) box.appendChild(el("div", "trunc", truncNote));
950
+ }
951
+
952
+ var KIND = { key: "k", string: "s", number: "n", literal: "n", punct: "", space: "" };
953
+ function jsonTree(text) {
954
+ var pre = el("pre");
955
+ var stack = [{ node: pre }];
956
+ jsonTokens(text).forEach(function (t) {
957
+ var top = stack[stack.length - 1];
958
+ if (top.wrap && t.kind !== "space" && !(t.kind === "punct" && (t.text === "}" || t.text === "]"))) top.has = true;
959
+ if (t.kind === "punct" && (t.text === "{" || t.text === "[")) {
960
+ var wrap = el("span", "fold");
961
+ var open = el("span", "open", t.text);
962
+ var inner = el("span", "inner");
963
+ var sum = el("span", "sum");
964
+ wrap.appendChild(open);
965
+ wrap.appendChild(inner);
966
+ wrap.appendChild(sum);
967
+ top.node.appendChild(wrap);
968
+ open.addEventListener("click", function () { if (wrap.dataset.n) wrap.classList.toggle("closed"); });
969
+ stack.push({ node: inner, wrap: wrap, open: open, sum: sum, depth: t.depth, obj: t.text === "{", count: 0, has: false });
970
+ return;
971
+ }
972
+ if (t.kind === "punct" && (t.text === "}" || t.text === "]") && stack.length > 1) {
973
+ var c = stack.pop();
974
+ var n = c.has ? c.count + 1 : 0;
975
+ if (n > 0) {
976
+ c.wrap.dataset.n = String(n);
977
+ c.open.title = "collapse or expand";
978
+ c.sum.textContent = " ▸ " + (c.obj ? countLabel(n, "field", "fields") : countLabel(n, "item", "items")) + " ";
979
+ } else c.open.className = "";
980
+ c.wrap.appendChild(document.createTextNode(t.text));
981
+ return;
982
+ }
983
+ if (top.wrap && t.kind === "punct" && t.text === "," && t.depth === top.depth + 1) top.count++;
984
+ var cls = KIND[t.kind];
985
+ top.node.appendChild(cls ? el("span", cls, t.text) : document.createTextNode(t.text));
986
+ });
987
+ return pre;
988
+ }
989
+
990
+ function renderButtons() {
991
+ var d = state.detail;
992
+ var replay = $("replay");
993
+ replay.disabled = d.replayBlocked !== null || replaying;
994
+ replay.title = d.replayBlocked || "";
995
+ replay.parentNode.title = d.replayBlocked || "";
996
+
997
+ var copyBody = $("copyBody");
998
+ var b = copyable(d);
999
+ copyBody.disabled = b.text === null;
1000
+ copyBody.title = b.text === null ? b.note || "" : "";
1001
+ copyBody.parentNode.title = copyBody.title;
1002
+
1003
+ var curl = $("copyCurl");
1004
+ curl.disabled = d.curl === null;
1005
+ curl.title = d.curl === null ? d.curlNote || "" : "";
1006
+ curl.parentNode.title = curl.title;
1007
+ var notes = [];
1008
+ if (d.curl !== null && d.curlNote) notes.push(d.curlNote);
1009
+ var tunnel = state.tunnels.filter(function (t) { return t.key === d.tunnel; })[0];
1010
+ if (d.curl !== null && tunnel && tunnel.auth) notes.push("Replace PASSWORD with the tunnel password.");
1011
+ $("curlNote").textContent = notes.join(" ");
1012
+ }
1013
+ function copyable(d) {
1014
+ return state.tab === "Response" ? { text: d.resBody, note: d.resNote } : { text: d.reqBody, note: d.reqNote };
1015
+ }
1016
+
1017
+ function showMsg(text, isErr) {
1018
+ var m = $("msg");
1019
+ m.textContent = text;
1020
+ m.className = "msg" + (isErr ? " err" : "");
1021
+ m.hidden = false;
1022
+ if (msgTimer) clearTimeout(msgTimer);
1023
+ msgTimer = null;
1024
+ }
1025
+ function hideMsg() {
1026
+ if (msgTimer) clearTimeout(msgTimer);
1027
+ msgTimer = null;
1028
+ $("msg").hidden = true;
1029
+ }
1030
+
1031
+ function copy(text) {
1032
+ navigator.clipboard.writeText(text).then(function () {
1033
+ showMsg("Copied.", false);
1034
+ msgTimer = setTimeout(hideMsg, 2000);
1035
+ }, function () {
1036
+ showMsg("Could not copy.", true);
1037
+ });
1038
+ }
1039
+
1040
+ function replay() {
1041
+ var d = state.detail;
1042
+ if (!d || state.ended) return;
1043
+ if (d.replayBlocked !== null) return showMsg(d.replayBlocked, true);
1044
+ if (replaying) return;
1045
+ replaying = true;
1046
+ $("replay").disabled = true;
1047
+ api("/api/requests/" + d.id + "/replay", "POST").then(function (r) {
1048
+ replaying = false;
1049
+ if (state.detail) renderButtons();
1050
+ if (r.body && r.body.ok) {
1051
+ hideMsg();
1052
+ return loadList().then(function () { return select(r.body.id); });
1053
+ }
1054
+ showMsg(r.body && r.body.reason ? r.body.reason : "Could not replay.", true);
1055
+ }).catch(function (e) {
1056
+ replaying = false;
1057
+ if (state.detail) renderButtons();
1058
+ fail(e);
1059
+ });
1060
+ }
1061
+
1062
+ function move(delta) {
1063
+ var items = visible();
1064
+ if (items.length === 0) return;
1065
+ var i = -1;
1066
+ for (var j = 0; j < items.length; j++) if (items[j].id === state.selected) i = j;
1067
+ var next = i < 0 ? 0 : Math.max(0, Math.min(items.length - 1, i + delta));
1068
+ if (items[next].id !== state.selected) select(items[next].id);
1069
+ }
1070
+
1071
+ function connect() {
1072
+ if (state.ended) return;
1073
+ es = new EventSource("/api/events?t=" + encodeURIComponent(TOKEN));
1074
+ es.addEventListener("open", function () {
1075
+ if (state.ended) return;
1076
+ banner("");
1077
+ if (lost) {
1078
+ lost = false;
1079
+ loadList();
1080
+ if (state.selected !== null) loadDetail(state.selected);
1081
+ }
1082
+ });
1083
+ es.addEventListener("error", function () {
1084
+ if (state.ended) return;
1085
+ lost = true;
1086
+ banner("Disconnected from the session.");
1087
+ if (es.readyState === 2) probe();
1088
+ });
1089
+ es.addEventListener("changed", function (ev) {
1090
+ var ids = [];
1091
+ try { ids = JSON.parse(ev.data).ids || []; } catch (e) {}
1092
+ loadList();
1093
+ if (state.selected !== null && (ids.length === 0 || ids.indexOf(state.selected) >= 0)) loadDetail(state.selected);
1094
+ });
1095
+ }
1096
+ function probe() {
1097
+ api("/api/session").then(function () {
1098
+ if (!state.ended) setTimeout(connect, 3000);
1099
+ }, function () {
1100
+ if (!state.ended) setTimeout(connect, 3000);
1101
+ });
1102
+ }
1103
+
1104
+ function init() {
1105
+ var tabs = document.querySelectorAll(".tab");
1106
+ for (var i = 0; i < tabs.length; i++) {
1107
+ tabs[i].addEventListener("click", function (ev) {
1108
+ state.tab = ev.currentTarget.dataset.tab;
1109
+ if (state.detail) renderDetail();
1110
+ });
1111
+ }
1112
+ $("filter").addEventListener("input", renderList);
1113
+ $("replay").addEventListener("click", replay);
1114
+ $("copyBody").addEventListener("click", function () {
1115
+ var b = state.detail ? copyable(state.detail) : { text: null };
1116
+ if (b.text !== null && b.text !== undefined) copy(b.text);
1117
+ });
1118
+ $("copyCurl").addEventListener("click", function () { if (state.detail && state.detail.curl !== null) copy(state.detail.curl); });
1119
+
1120
+ document.addEventListener("keydown", function (ev) {
1121
+ if (ev.ctrlKey || ev.metaKey || ev.altKey) return;
1122
+ var inFilter = document.activeElement === $("filter");
1123
+ if (ev.key === "ArrowDown" || ev.key === "ArrowUp") {
1124
+ ev.preventDefault();
1125
+ move(ev.key === "ArrowDown" ? 1 : -1);
1126
+ } else if (ev.key === "Escape") {
1127
+ if ($("filter").value) {
1128
+ $("filter").value = "";
1129
+ renderList();
1130
+ }
1131
+ if (inFilter) $("filter").blur();
1132
+ } else if (ev.key === "/" && !inFilter) {
1133
+ ev.preventDefault();
1134
+ $("filter").focus();
1135
+ } else if ((ev.key === "r" || ev.key === "R") && !inFilter && !ev.repeat) {
1136
+ replay();
1137
+ }
1138
+ });
1139
+
1140
+ loadList();
1141
+ connect();
1142
+ }
1143
+
1144
+ init();
1145
+ `;
1146
+ function inspectorPage(nonce) {
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("");
1148
+ return `<!doctype html>
1149
+ <html lang="en">
1150
+ <head>
1151
+ <meta charset="utf-8">
1152
+ <meta name="viewport" content="width=device-width, initial-scale=1">
1153
+ <title>duto \xB7 inspector</title>
1154
+ <link rel="icon" href="data:,">
1155
+ <style nonce="${nonce}">${STYLE}</style>
1156
+ </head>
1157
+ <body>
1158
+ <div class="app">
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>
1160
+ <div class="banner" id="banner" role="status" hidden></div>
1161
+ <div class="main">
1162
+ <div class="side">
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>
1165
+ </div>
1166
+ <div class="detail">
1167
+ <div class="empty" id="none">Select a request from the list.</div>
1168
+ <div class="detail pane" id="detail" hidden>
1169
+ <div class="head" id="head"></div>
1170
+ <div class="tabs" role="tablist">${tabs}</div>
1171
+ <div class="content" id="content"></div>
1172
+ <div class="btns">
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>
1176
+ <span class="msg" id="curlNote"></span>
1177
+ <span class="msg" id="msg" role="status" hidden></span>
1178
+ </div>
1179
+ </div>
1180
+ </div>
1181
+ </div>
1182
+ </div>
1183
+ <script nonce="${nonce}">${SCRIPT}</script>
1184
+ </body>
1185
+ </html>
1186
+ `;
1187
+ }
1188
+
1189
+ // src/inspector/server.ts
1190
+ import { createHash as createHash2, randomBytes, timingSafeEqual } from "crypto";
1191
+ import { createServer } from "http";
1192
+
1193
+ // src/session/replay.ts
1194
+ var tunnelClosedReason = (tunnel2) => `Tunnel ${tunnel2} is not open in this session.`;
1195
+ function replayBlock(r) {
1196
+ if (!r) return "That request is no longer in the list.";
1197
+ if (r.websocket) return "WebSocket requests cannot be replayed.";
1198
+ if (r.noReplay) return r.noReplay;
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.";
1203
+ return null;
1204
+ }
1205
+ var HOP_BY_HOP_AND_BODY_HEADERS = /* @__PURE__ */ new Set(["connection", "keep-alive", "transfer-encoding", "expect", "content-length", "upgrade", "te", "trailer", "proxy-connection"]);
1206
+ function replayHeaders(r) {
1207
+ const out = {};
1208
+ for (const [k, v] of Object.entries(r.reqHeaders)) if (!HOP_BY_HOP_AND_BODY_HEADERS.has(k.toLowerCase())) out[k] = v;
1209
+ const hadLength = Object.keys(r.reqHeaders).some((k) => k.toLowerCase() === "content-length");
1210
+ if (r.reqBodyBytes > 0 || hadLength) out["content-length"] = String(r.reqRaw?.length ?? 0);
1211
+ return out;
1212
+ }
1213
+
1214
+ // src/inspector/curl.ts
1215
+ var SKIP = /* @__PURE__ */ new Set(["host", "content-length", "transfer-encoding", "connection", "keep-alive", "expect", "upgrade", "te", "trailer", "proxy-connection"]);
1216
+ var TUNNEL_ADDED = /* @__PURE__ */ new Set(["cdn-loop", "x-forwarded-for", "x-forwarded-proto", "x-real-ip", "true-client-ip"]);
1217
+ var skip = (name) => {
1218
+ const k = name.toLowerCase();
1219
+ return SKIP.has(k) || TUNNEL_ADDED.has(k) || k.startsWith("cf-");
1220
+ };
1221
+ function shellQuote(s) {
1222
+ return "'" + s.replace(/'/g, "'\\''") + "'";
1223
+ }
1224
+ function toCurl(r, o) {
1225
+ const parts = [`curl -X ${shellQuote(r.method)} ${shellQuote(o.publicUrl.replace(/\/+$/, "") + r.path)}`];
1226
+ for (const [k, v] of Object.entries(r.reqHeaders)) {
1227
+ if (!skip(k)) parts.push(`-H ${shellQuote(`${k}: ${v}`)}`);
1228
+ }
1229
+ if (o.authUser !== null) parts.push(`-H ${shellQuote(`X-Duto-Auth: ${o.authUser}:PASSWORD`)}`);
1230
+ if (r.reqBody !== null) parts.push(`--data-binary ${shellQuote(r.reqBody)}`);
1231
+ return parts.join(" \\\n ");
1232
+ }
1233
+
1234
+ // src/inspector/server.ts
1235
+ var DEFAULT_PORT = 4141;
1236
+ var PORT_TRIES = 20;
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).";
1241
+ var sha = (s) => createHash2("sha256").update(s).digest();
1242
+ var constantTimeEqual = (a, b) => timingSafeEqual(sha(a), sha(b));
1243
+ var first = (v) => Array.isArray(v) ? v[0] : v;
1244
+ function listItem(r) {
1245
+ return {
1246
+ id: r.id,
1247
+ tunnel: r.tunnel,
1248
+ method: r.method,
1249
+ path: r.path,
1250
+ status: r.status,
1251
+ durationMs: r.durationMs,
1252
+ bytes: r.bytes,
1253
+ startedAt: r.startedAt,
1254
+ websocket: r.websocket,
1255
+ ...r.replayOf !== void 0 && { replayOf: r.replayOf },
1256
+ ...r.error !== void 0 && { error: r.error }
1257
+ };
1258
+ }
1259
+ function detail(r, session) {
1260
+ const tunnel2 = session.getState().tunnels.find((t) => t.key === r.tunnel);
1261
+ const tunnelOpen = !!tunnel2 && (tunnel2.status === "live" || tunnel2.status === "no-listener");
1262
+ let curl = null;
1263
+ let curlNote = null;
1264
+ if (!tunnel2?.url) curlNote = NO_URL;
1265
+ else {
1266
+ curl = toCurl(r, { publicUrl: tunnel2.url, authUser: session.authUser(r.tunnel) });
1267
+ if (r.reqRaw && !Buffer.from(r.reqBody ?? "", "utf8").equals(r.reqRaw)) curlNote = CURL_BYTES;
1268
+ }
1269
+ return {
1270
+ ...listItem(r),
1271
+ reqHeaders: r.reqHeaders,
1272
+ resHeaders: r.resHeaders,
1273
+ reqBody: r.reqBody,
1274
+ resBody: r.resBody,
1275
+ reqNote: bodyNote(r, "req"),
1276
+ resNote: bodyNote(r, "res"),
1277
+ reqTruncatedNote: r.reqTruncated ? TRUNCATED_NOTE : null,
1278
+ resTruncatedNote: r.resTruncated ? TRUNCATED_NOTE : null,
1279
+ replayBlocked: replayBlock(r) ?? (tunnelOpen ? null : tunnelClosedReason(r.tunnel)),
1280
+ curl,
1281
+ curlNote
1282
+ };
1283
+ }
1284
+ var sig = (r) => `${r.status}|${r.durationMs}|${r.bytes}|${r.resBody?.length ?? -1}|${r.reqComplete}|${r.error ?? ""}|${Object.keys(r.resHeaders).length > 0}`;
1285
+ async function startInspector(o) {
1286
+ const { session } = o;
1287
+ const eventMs = o.eventMs ?? 150;
1288
+ const pingMs = o.pingMs ?? 25e3;
1289
+ const token = randomBytes(24).toString("base64url");
1290
+ let port = 0;
1291
+ const clients = /* @__PURE__ */ new Set();
1292
+ let seen = /* @__PURE__ */ new Map();
1293
+ let timer = null;
1294
+ const prime = () => seen = new Map(session.records.list().map((r) => [r.id, sig(r)]));
1295
+ const flush = () => {
1296
+ timer = null;
1297
+ const next = /* @__PURE__ */ new Map();
1298
+ const ids = [];
1299
+ for (const r of session.records.list()) {
1300
+ const s = sig(r);
1301
+ next.set(r.id, s);
1302
+ if (seen.get(r.id) !== s) ids.push(r.id);
1303
+ }
1304
+ seen = next;
1305
+ const msg = `event: changed
1306
+ data: ${JSON.stringify({ ids })}
1307
+
1308
+ `;
1309
+ for (const c of clients) c.write(msg);
1310
+ };
1311
+ const schedule = () => {
1312
+ if (clients.size > 0 && !timer) timer = setTimeout(flush, eventMs);
1313
+ };
1314
+ const unsubs = [session.records.subscribe(schedule), session.subscribe(schedule)];
1315
+ const pinger = setInterval(() => {
1316
+ for (const c of clients) c.write(": ping\n\n");
1317
+ }, pingMs);
1318
+ pinger.unref();
1319
+ const base = (res) => {
1320
+ res.setHeader("Cache-Control", "no-store");
1321
+ res.setHeader("X-Content-Type-Options", "nosniff");
1322
+ res.setHeader("Referrer-Policy", "no-referrer");
1323
+ res.setHeader("X-Frame-Options", "DENY");
1324
+ };
1325
+ const text = (res, status, body) => {
1326
+ res.writeHead(status, { "Content-Type": "text/plain; charset=utf-8" });
1327
+ res.end(body);
1328
+ };
1329
+ const json = (res, status, body) => {
1330
+ res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
1331
+ res.end(JSON.stringify(body));
1332
+ };
1333
+ const handle = (req, res) => {
1334
+ base(res);
1335
+ const host = first(req.headers.host)?.toLowerCase();
1336
+ if (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`) return text(res, 403, "Host not allowed.");
1337
+ let url2;
1338
+ try {
1339
+ url2 = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
1340
+ } catch {
1341
+ return text(res, 404, "Not found.");
1342
+ }
1343
+ const path = url2.pathname;
1344
+ const method = req.method ?? "GET";
1345
+ const queryToken = url2.searchParams.get("t") ?? "";
1346
+ const headerToken = first(req.headers["x-duto-inspector"]) ?? "";
1347
+ if (method === "GET" && path === "/") {
1348
+ if (!constantTimeEqual(queryToken, token)) return text(res, 403, NO_TOKEN);
1349
+ const nonce = randomBytes(16).toString("base64url");
1350
+ res.writeHead(200, {
1351
+ "Content-Type": "text/html; charset=utf-8",
1352
+ "Content-Security-Policy": `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'`
1353
+ });
1354
+ return void res.end(o.page(nonce));
1355
+ }
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 });
1361
+ if (isEvents) {
1362
+ res.writeHead(200, { "Content-Type": "text/event-stream; charset=utf-8", Connection: "keep-alive" });
1363
+ res.write(": duto\n\n");
1364
+ if (clients.size === 0) prime();
1365
+ clients.add(res);
1366
+ res.on("close", () => clients.delete(res));
1367
+ return;
1368
+ }
1369
+ if (path === "/api/session") {
1370
+ return json(res, 200, { tunnels: session.getState().tunnels.map((t) => ({ key: t.key, url: t.url, auth: !!t.auth })) });
1371
+ }
1372
+ if (path === "/api/requests") return json(res, 200, { items: session.records.list().map(listItem) });
1373
+ const id = Number(detailId[1]);
1374
+ if (!Number.isSafeInteger(id)) return json(res, 404, { error: GONE });
1375
+ if (method === "POST") return json(res, 200, session.replay(id));
1376
+ const r = session.records.get(id);
1377
+ if (!r) return json(res, 404, { error: GONE });
1378
+ return json(res, 200, detail(r, session));
1379
+ };
1380
+ const server = createServer((req, res) => {
1381
+ try {
1382
+ handle(req, res);
1383
+ } catch {
1384
+ if (!res.headersSent) text(res, 500, "Internal error.");
1385
+ else res.end();
1386
+ }
1387
+ });
1388
+ const listen = (p) => new Promise((resolve, reject) => {
1389
+ const onError = (e) => reject(e);
1390
+ server.once("error", onError);
1391
+ server.listen(p, "127.0.0.1", () => {
1392
+ server.off("error", onError);
1393
+ resolve();
1394
+ });
1395
+ });
1396
+ const want = o.port ?? DEFAULT_PORT;
1397
+ try {
1398
+ let bound = false;
1399
+ if (want !== 0) {
1400
+ for (let p = want; p <= want + PORT_TRIES && !bound; p++) {
1401
+ try {
1402
+ await listen(p);
1403
+ bound = true;
1404
+ } catch (e) {
1405
+ if (e.code !== "EADDRINUSE") throw e;
1406
+ }
1407
+ }
1408
+ }
1409
+ if (!bound) await listen(0);
1410
+ } catch (e) {
1411
+ for (const u of unsubs) u();
1412
+ clearInterval(pinger);
1413
+ throw e;
1414
+ }
1415
+ port = server.address().port;
1416
+ const url = `http://127.0.0.1:${port}`;
1417
+ return {
1418
+ port,
1419
+ url,
1420
+ openUrl: `${url}/?t=${token}`,
1421
+ close: () => new Promise((resolve) => {
1422
+ for (const u of unsubs) u();
1423
+ clearInterval(pinger);
1424
+ if (timer) clearTimeout(timer);
1425
+ for (const c of clients) c.end();
1426
+ clients.clear();
1427
+ server.close(() => resolve());
1428
+ server.closeAllConnections();
1429
+ })
1430
+ };
1431
+ }
1432
+
520
1433
  // src/session/session.ts
521
1434
  import { spawn } from "child_process";
522
1435
 
@@ -540,11 +1453,12 @@ async function isPortListening(port, timeoutMs = 500) {
540
1453
  }
541
1454
 
542
1455
  // src/session/auth.ts
543
- import { createHash as createHash2, createHmac, randomBytes, timingSafeEqual } from "crypto";
544
- var TUNNEL_COOKIE = "__Host-duto_tunel";
1456
+ import { createHash as createHash3, createHmac, randomBytes as randomBytes2, timingSafeEqual as timingSafeEqual2 } from "crypto";
1457
+ var TUNNEL_COOKIE = "__Host-duto_tunnel";
1458
+ var SAME_SITE_SIBLING_TUNNEL_COOKIE_REJECTED = "same-site";
545
1459
  var QR_TTL_MS = 5 * 6e4;
546
- var sha = (s) => createHash2("sha256").update(s).digest();
547
- var same = (a, b) => timingSafeEqual(sha(a), sha(b));
1460
+ var sha2 = (s) => createHash3("sha256").update(s).digest();
1461
+ var constantTimeEqual2 = (a, b) => timingSafeEqual2(sha2(a), sha2(b));
548
1462
  function splitPair(raw) {
549
1463
  const i = raw.indexOf(":");
550
1464
  return i < 0 ? null : { user: raw.slice(0, i), password: raw.slice(i + 1) };
@@ -553,11 +1467,11 @@ function parseBasic(value) {
553
1467
  const m = /^basic +([A-Za-z0-9+/]+={0,2})$/i.exec(value?.trim() ?? "");
554
1468
  return m ? splitPair(Buffer.from(m[1], "base64").toString("utf8")) : null;
555
1469
  }
556
- var first = (v) => Array.isArray(v) ? v[0] : v;
1470
+ var first2 = (v) => Array.isArray(v) ? v[0] : v;
557
1471
  var bareHost = (s) => s.replace(/:\d+$/, "").toLowerCase();
558
1472
  function sameHostOrigin(h) {
559
- if (first(h["sec-fetch-site"])?.toLowerCase() === "same-site") return false;
560
- const origin = first(h.origin);
1473
+ if (first2(h["sec-fetch-site"])?.toLowerCase() === SAME_SITE_SIBLING_TUNNEL_COOKIE_REJECTED) return false;
1474
+ const origin = first2(h.origin);
561
1475
  if (origin === void 0) return true;
562
1476
  let host;
563
1477
  try {
@@ -580,27 +1494,27 @@ var TunnelAuth = class {
580
1494
  this.credentials = o.credentials;
581
1495
  this.allow = o.allow;
582
1496
  this.now = o.now ?? Date.now;
583
- this.secret = o.secret ?? randomBytes(32);
1497
+ this.secret = o.secret ?? randomBytes2(32);
584
1498
  this.qrUser = o.credentials[0]?.user ?? "";
585
1499
  }
586
1500
  access(h) {
587
1501
  if (this.allow) {
588
- const ip = first(h["cf-connecting-ip"]);
1502
+ const ip = first2(h["cf-connecting-ip"]);
589
1503
  const reason = this.allow.match(ip);
590
1504
  if (reason) return { kind: "allow", reason };
591
1505
  }
592
1506
  const cookieUser = this.cookieUser(h.cookie);
593
1507
  if (cookieUser && sameHostOrigin(h)) return { kind: "user", user: cookieUser };
594
- const duto = first(h["x-duto-auth"]);
595
- const cand = duto !== void 0 ? splitPair(duto) : parseBasic(first(h.authorization));
596
- 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 };
597
1511
  return { kind: "denied" };
598
1512
  }
599
1513
  checkPassword(user, password) {
600
1514
  let ok2 = false;
601
1515
  for (const c of this.credentials) {
602
- const userOk = same(c.user, user);
603
- const passOk = same(c.password, password);
1516
+ const userOk = constantTimeEqual2(c.user, user);
1517
+ const passOk = constantTimeEqual2(c.password, password);
604
1518
  if (userOk && passOk) ok2 = true;
605
1519
  }
606
1520
  return ok2;
@@ -612,19 +1526,19 @@ var TunnelAuth = class {
612
1526
  return `${TUNNEL_COOKIE}=; Max-Age=0; Secure; HttpOnly; SameSite=Lax; Path=/`;
613
1527
  }
614
1528
  issueQrToken() {
615
- this.qr = { token: randomBytes(24).toString("base64url"), expiresAt: this.now() + QR_TTL_MS };
1529
+ this.qr = { token: randomBytes2(24).toString("base64url"), expiresAt: this.now() + QR_TTL_MS };
616
1530
  return { ...this.qr };
617
1531
  }
618
1532
  consumeQrToken(token) {
619
1533
  const qr = this.qr;
620
- if (!qr || !same(qr.token, token)) return false;
1534
+ if (!qr || !constantTimeEqual2(qr.token, token)) return false;
621
1535
  this.qr = null;
622
1536
  return this.now() <= qr.expiresAt;
623
1537
  }
624
1538
  strip(h) {
625
1539
  const out = { ...h };
626
1540
  delete out["x-duto-auth"];
627
- const basic = parseBasic(first(h.authorization));
1541
+ const basic = parseBasic(first2(h.authorization));
628
1542
  if (basic && this.checkPassword(basic.user, basic.password)) delete out.authorization;
629
1543
  if (h.cookie !== void 0) {
630
1544
  const parts = (Array.isArray(h.cookie) ? h.cookie.join("; ") : h.cookie).split(";").map((p) => p.trim()).filter(Boolean);
@@ -635,7 +1549,6 @@ var TunnelAuth = class {
635
1549
  }
636
1550
  return out;
637
1551
  }
638
- // Valor: usuário em base64url + "." + HMAC(túnel, usuário).
639
1552
  cookieValue(user) {
640
1553
  return `${Buffer.from(user).toString("base64url")}.${this.sign(user)}`;
641
1554
  }
@@ -648,10 +1561,10 @@ var TunnelAuth = class {
648
1561
  for (const p of list) {
649
1562
  const item = p.trim();
650
1563
  if (!item.startsWith(`${TUNNEL_COOKIE}=`)) continue;
651
- const [b64, sig, ...resto] = item.slice(TUNNEL_COOKIE.length + 1).split(".");
652
- if (!b64 || !sig || resto.length) continue;
1564
+ const [b64, sig2, ...extra] = item.slice(TUNNEL_COOKIE.length + 1).split(".");
1565
+ if (!b64 || !sig2 || extra.length) continue;
653
1566
  const user = Buffer.from(b64, "base64url").toString("utf8");
654
- if (same(sig, 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;
655
1568
  }
656
1569
  return null;
657
1570
  }
@@ -682,7 +1595,7 @@ function cors401(origin) {
682
1595
  }
683
1596
 
684
1597
  // src/session/proxy.ts
685
- import { Agent, createServer, request } from "http";
1598
+ import { Agent, createServer as createServer2, request } from "http";
686
1599
  import { connect as connect2 } from "net";
687
1600
 
688
1601
  // src/session/errorPage.ts
@@ -703,14 +1616,14 @@ var WAIT_SCRIPT = `
703
1616
  }
704
1617
  function again() { delay = Math.min(delay * 2, 30000); poll(); }
705
1618
  if (!window.EventSource) return poll();
706
- var fails = 0, es = new EventSource("/__duto/espera");
1619
+ var fails = 0, es = new EventSource("/__duto/wait");
707
1620
  es.addEventListener("up", function () { es.close(); location.reload(); });
708
1621
  es.onerror = function () { if (++fails >= 3) { es.close(); poll(); } };
709
1622
  })();
710
1623
  </script>`;
711
1624
  function pageShell(title, body, css = "") {
712
1625
  return `<!doctype html>
713
- <html lang="pt-BR">
1626
+ <html lang="en">
714
1627
  <head>
715
1628
  <meta charset="utf-8">
716
1629
  <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -742,13 +1655,13 @@ function unavailablePage(o) {
742
1655
  const tunnel2 = esc(o.tunnel);
743
1656
  const local = `localhost:${o.port}`;
744
1657
  return pageShell(
745
- `Nada respondendo em ${local} \xB7 duto`,
1658
+ `Nothing responding on ${local} \xB7 duto`,
746
1659
  `<main>
747
1660
  <div class="mark"><span class="dot"></span>duto \xB7 ${tunnel2}</div>
748
- <h1>Nada respondendo em <code>${local}</code></h1>
749
- <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>
750
- <p>Se voc\xEA s\xF3 recebeu este link, avise quem o enviou.</p>
751
- <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>
752
1665
  </main>${o.retry ? WAIT_SCRIPT : ""}`
753
1666
  );
754
1667
  }
@@ -761,56 +1674,36 @@ button{margin-top:18px;width:100%;padding:10px;border:0;border-radius:.3rem;back
761
1674
  .error{margin:14px 0 0;color:#b42318;font-weight:600}
762
1675
  @media (prefers-color-scheme:dark){.error{color:#f97066}}
763
1676
  `;
764
- function safeVolta(raw) {
1677
+ function safeNext(raw) {
765
1678
  if (!raw || !raw.startsWith("/") || raw.startsWith("//") || raw.startsWith("/\\")) return "/";
766
1679
  return /^[\x21-\x7e]+$/.test(raw) ? raw : "/";
767
1680
  }
768
1681
  function loginPage(o) {
769
1682
  return pageShell(
770
- "Entrar \xB7 duto",
1683
+ "Sign in \xB7 duto",
771
1684
  `<main>
772
1685
  <div class="mark"><span class="dot"></span>duto \xB7 ${esc(o.tunnel)}</div>
773
- <h1>Este t\xFAnel tem senha</h1>
774
- <p>Pe\xE7a o acesso a quem enviou o link.</p>
775
- <form method="post" action="/__duto/entrar">
776
- <input type="hidden" name="volta" value="${esc(o.volta)}">
777
- <label for="usuario">Usu\xE1rio</label>
778
- <input id="usuario" name="usuario" autocomplete="username" autocapitalize="none" spellcheck="false" required autofocus>
779
- <label for="senha">Senha</label>
780
- <input id="senha" name="senha" type="password" autocomplete="current-password" required>
781
- ${o.error ? `<p class="error" role="alert">Usu\xE1rio ou senha incorretos.</p>
782
- ` : ""}<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>
783
1696
  </form>
784
- <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>
785
1698
  </main>`,
786
1699
  CSS
787
1700
  );
788
1701
  }
789
1702
 
790
- // src/session/replay.ts
791
- function replayBlock(r) {
792
- if (!r) return "Essa requisi\xE7\xE3o j\xE1 saiu da lista.";
793
- if (r.websocket) return "WebSocket n\xE3o d\xE1 para reenviar.";
794
- if (r.noReplay) return r.noReplay;
795
- if (r.access?.kind === "denied") return "Barrada pela senha do t\xFAnel; n\xE3o d\xE1 para reenviar.";
796
- if (!r.reqComplete) return "A requisi\xE7\xE3o ainda n\xE3o chegou inteira.";
797
- if (r.reqTruncated) return "Corpo acima de 64 KB; n\xE3o d\xE1 para reenviar.";
798
- if (r.reqBodyBytes > 0 && r.reqRaw === null) return "Corpo bin\xE1rio n\xE3o \xE9 guardado; n\xE3o d\xE1 para reenviar.";
799
- return null;
800
- }
801
- var DROP = /* @__PURE__ */ new Set(["connection", "keep-alive", "transfer-encoding", "expect", "content-length", "upgrade", "te", "trailer", "proxy-connection"]);
802
- function replayHeaders(r) {
803
- const out = {};
804
- for (const [k, v] of Object.entries(r.reqHeaders)) if (!DROP.has(k.toLowerCase())) out[k] = v;
805
- const hadLength = Object.keys(r.reqHeaders).some((k) => k.toLowerCase() === "content-length");
806
- if (r.reqBodyBytes > 0 || hadLength) out["content-length"] = String(r.reqRaw?.length ?? 0);
807
- return out;
808
- }
809
-
810
1703
  // src/session/proxy.ts
811
- var DENIED_NO_REPLAY = "Barrada pela senha do t\xFAnel; n\xE3o d\xE1 para reenviar.";
812
- var PREFLIGHT_NO_REPLAY = "Preflight de CORS n\xE3o d\xE1 para reenviar.";
813
- var TEXT = /^(text\/|application\/(json|xml|javascript|x-www-form-urlencoded|graphql)|[^;]*\+(json|xml))/i;
1704
+ var DENIED_NO_REPLAY = "Blocked by the tunnel password; cannot replay.";
1705
+ var PREFLIGHT_NO_REPLAY = "CORS preflight cannot be replayed.";
1706
+ var TEXT2 = /^(text\/|application\/(json|xml|javascript|x-www-form-urlencoded|graphql)|[^;]*\+(json|xml))/i;
814
1707
  var MAX_BODY = 64 * 1024;
815
1708
  var LOCAL_AGENT = new Agent({ keepAlive: false });
816
1709
  function flat(h) {
@@ -827,7 +1720,6 @@ var Capture = class {
827
1720
  max;
828
1721
  chunks = [];
829
1722
  size = 0;
830
- /** Bytes vistos, capturados ou não. */
831
1723
  total = 0;
832
1724
  truncated = false;
833
1725
  push(c) {
@@ -850,6 +1742,7 @@ var Capture = class {
850
1742
  return this.enabled && this.size > 0 ? Buffer.concat(this.chunks).toString("utf8") : null;
851
1743
  }
852
1744
  };
1745
+ var HOST_CACHE_MS = 5e3;
853
1746
  var hostCache = /* @__PURE__ */ new Map();
854
1747
  function probe(host, port) {
855
1748
  return new Promise((resolve) => {
@@ -865,7 +1758,7 @@ function probe(host, port) {
865
1758
  }
866
1759
  async function localHost(port) {
867
1760
  const hit = hostCache.get(port);
868
- if (hit && Date.now() - hit.at < 5e3) return hit.host;
1761
+ if (hit && Date.now() - hit.at < HOST_CACHE_MS) return hit.host;
869
1762
  const host = await probe("127.0.0.1", port) ? "127.0.0.1" : await probe("::1", port) ? "::1" : null;
870
1763
  if (!host) return "127.0.0.1";
871
1764
  hostCache.set(port, { host, at: Date.now() });
@@ -894,13 +1787,16 @@ function hostOf(req) {
894
1787
  return (req.headers.host ?? "").replace(/:\d+$/, "").toLowerCase();
895
1788
  }
896
1789
  var DUTO_PREFIX = "/__duto/";
897
- var WAIT_PATH = "/__duto/espera";
1790
+ var WAIT_PATH = "/__duto/wait";
898
1791
  var WAIT_MAX_MS = 10 * 6e4;
899
- var LOGIN_PATH = "/__duto/entrar";
900
- 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";
901
1797
  var QR_PREFIX = "/__duto/qr/";
902
1798
  var MAX_FORM = 8 * 1024;
903
- 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.";
904
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';
905
1801
  function readForm(req, max) {
906
1802
  return new Promise((resolve) => {
@@ -923,9 +1819,9 @@ function readForm(req, max) {
923
1819
  }
924
1820
  async function authRoute(req, res, route, auth) {
925
1821
  const url = new URL(req.url ?? "/", "http://duto");
926
- const page = (status, volta, error) => {
1822
+ const page = (status, next, error) => {
927
1823
  res.writeHead(status, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
928
- res.end(loginPage({ tunnel: route.tunnel, volta, error }));
1824
+ res.end(loginPage({ tunnel: route.tunnel, next, error }));
929
1825
  };
930
1826
  const go = (location, cookie) => {
931
1827
  res.writeHead(303, { location, "cache-control": "no-store", ...cookie && { "set-cookie": cookie } });
@@ -933,21 +1829,21 @@ async function authRoute(req, res, route, auth) {
933
1829
  };
934
1830
  const { pathname } = url;
935
1831
  if (pathname === LOGIN_PATH && req.method === "GET") {
936
- page(200, safeVolta(url.searchParams.get("volta")), false);
1832
+ page(200, safeNext(url.searchParams.get("next")), false);
937
1833
  return true;
938
1834
  }
939
1835
  if (pathname === LOGIN_PATH && req.method === "POST") {
940
1836
  const raw = await readForm(req, MAX_FORM);
941
1837
  if (raw === null) {
942
1838
  res.writeHead(413, { "content-type": "text/plain; charset=utf-8", connection: "close" });
943
- res.end("Formul\xE1rio grande demais");
1839
+ res.end("Form too large");
944
1840
  return true;
945
1841
  }
946
1842
  const form = new URLSearchParams(raw);
947
- const user = form.get("usuario") ?? "";
948
- const volta = safeVolta(form.get("volta"));
949
- if (auth.checkPassword(user, form.get("senha") ?? "")) go(volta, auth.loginCookie(user));
950
- 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);
951
1847
  return true;
952
1848
  }
953
1849
  if (pathname === LOGOUT_PATH && req.method === "GET") {
@@ -963,7 +1859,7 @@ async function authRoute(req, res, route, auth) {
963
1859
  }
964
1860
  function deny(req, res, html) {
965
1861
  const status = html ? 303 : 401;
966
- 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) };
967
1863
  const body = html || req.method === "HEAD" ? "" : DENIED_TEXT;
968
1864
  res.writeHead(status, out);
969
1865
  res.end(body);
@@ -1005,7 +1901,7 @@ function waitForService(res, port, checkMs, pingMs) {
1005
1901
  async function startProxy(o) {
1006
1902
  const max = o.maxBody ?? MAX_BODY;
1007
1903
  let nextId = 1;
1008
- const routeWaitMs = o.routeWaitMs ?? 3e3;
1904
+ const routeWaitMs = o.routeWaitMs ?? DEFAULT_ROUTE_WAIT_MS;
1009
1905
  async function routeFor(req) {
1010
1906
  const host = hostOf(req);
1011
1907
  for (let waited = 0; ; waited += 50) {
@@ -1043,7 +1939,7 @@ async function startProxy(o) {
1043
1939
  return { id, started };
1044
1940
  }
1045
1941
  function trackResponse(id, started, up, reqCap) {
1046
- const resCap = new Capture(TEXT.test(up.headers["content-type"] ?? ""), max);
1942
+ const resCap = new Capture(TEXT2.test(up.headers["content-type"] ?? ""), max);
1047
1943
  let bytes = 0;
1048
1944
  up.on("data", (c) => {
1049
1945
  bytes += c.length;
@@ -1066,9 +1962,9 @@ async function startProxy(o) {
1066
1962
  function replay(id) {
1067
1963
  const r = o.store.get(id);
1068
1964
  const blocked = replayBlock(r);
1069
- 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." };
1070
1966
  const route = [...o.routes.values()].find((x) => x.tunnel === r.tunnel);
1071
- if (!route) return { ok: false, reason: `O t\xFAnel ${r.tunnel} n\xE3o est\xE1 aberto nesta sess\xE3o.` };
1967
+ if (!route) return { ok: false, reason: tunnelClosedReason(r.tunnel) };
1072
1968
  const newId = nextId++;
1073
1969
  const started = Date.now();
1074
1970
  const headers = replayHeaders(r);
@@ -1102,22 +1998,22 @@ async function startProxy(o) {
1102
1998
  })();
1103
1999
  return { ok: true, id: newId };
1104
2000
  }
1105
- const server = createServer(async (req, res) => {
2001
+ const server = createServer2(async (req, res) => {
1106
2002
  req.pause();
1107
2003
  const route = await routeFor(req);
1108
2004
  if (!route) {
1109
2005
  res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
1110
- res.end("T\xFAnel n\xE3o encontrado nesta sess\xE3o");
2006
+ res.end("Tunnel not found in this session");
1111
2007
  return;
1112
2008
  }
1113
2009
  if ((req.url ?? "").startsWith(DUTO_PREFIX)) {
1114
2010
  if (req.url === WAIT_PATH && req.method === "GET") {
1115
2011
  if (route.auth?.access(req.headers).kind === "denied") return void deny(req, res, false);
1116
- 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);
1117
2013
  }
1118
2014
  if (route.auth && await authRoute(req, res, route, route.auth)) return;
1119
2015
  res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
1120
- res.end("Rota reservada do Duto");
2016
+ res.end("Reserved Duto route");
1121
2017
  return;
1122
2018
  }
1123
2019
  const preflight = !!route.auth && isPreflight(req.method, req.headers);
@@ -1131,7 +2027,7 @@ async function startProxy(o) {
1131
2027
  return;
1132
2028
  }
1133
2029
  const { id, started } = record(req, route, headers, false, access, preflight ? PREFLIGHT_NO_REPLAY : void 0);
1134
- const reqCap = new Capture(TEXT.test(req.headers["content-type"] ?? ""), max);
2030
+ const reqCap = new Capture(TEXT2.test(req.headers["content-type"] ?? ""), max);
1135
2031
  req.pause();
1136
2032
  const host = await localHost(route.port);
1137
2033
  const upstream = request(
@@ -1160,7 +2056,7 @@ async function startProxy(o) {
1160
2056
  req.resume();
1161
2057
  if (res.destroyed && res.writableFinished === false && !res.headersSent) return;
1162
2058
  const html = wantsHtml(req.headers.accept);
1163
- 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.`;
1164
2060
  if (!res.headersSent) {
1165
2061
  res.writeHead(503, { "content-type": html ? "text/html; charset=utf-8" : "text/plain; charset=utf-8", "cache-control": "no-store", "retry-after": "3" });
1166
2062
  res.end(req.method === "HEAD" ? void 0 : msg);
@@ -1201,8 +2097,8 @@ async function startProxy(o) {
1201
2097
  target.write(`${raw}\r
1202
2098
  `);
1203
2099
  if (head.length) target.write(head);
1204
- target.once("data", (first2) => {
1205
- const status = Number(/^HTTP\/\d\.\d (\d{3})/.exec(first2.toString("latin1"))?.[1] ?? 0) || null;
2100
+ target.once("data", (first3) => {
2101
+ const status = Number(/^HTTP\/\d\.\d (\d{3})/.exec(first3.toString("latin1"))?.[1] ?? 0) || null;
1206
2102
  o.store.update(id, { status, durationMs: Date.now() - started });
1207
2103
  });
1208
2104
  target.pipe(client);
@@ -1324,7 +2220,6 @@ var RecordStore = class {
1324
2220
  get(id) {
1325
2221
  return this.items.find((x) => x.id === id);
1326
2222
  }
1327
- /** Mais recentes primeiro. Mesma referência até a próxima mudança (serve de snapshot para a tela). */
1328
2223
  list() {
1329
2224
  this.snapshot ??= [...this.items].reverse();
1330
2225
  return this.snapshot;
@@ -1344,8 +2239,8 @@ var READY = "Registered tunnel connection";
1344
2239
  var CLOSE_TIMEOUT_MS = 3e3;
1345
2240
  var HEARTBEAT_MS = 3e4;
1346
2241
  var RESTART_CF_MS = 2e3;
1347
- var ALREADY_OPEN = "J\xE1 aberto em outra sess\xE3o nesta m\xE1quina";
1348
- var STALE_MS = 75e3;
2242
+ var ALREADY_OPEN = "Already open in another session on this machine";
2243
+ var STALE_AFTER_MISSED_HEARTBEATS_MS = 75e3;
1349
2244
  function message(err) {
1350
2245
  return err instanceof Error ? err.message : String(err);
1351
2246
  }
@@ -1353,7 +2248,7 @@ function withTimeout(p, ms) {
1353
2248
  return Promise.race([p, new Promise((r) => setTimeout(() => r(void 0), ms).unref())]);
1354
2249
  }
1355
2250
  function keyOf(spec) {
1356
- return spec.name ?? "(padr\xE3o)";
2251
+ return spec.name ?? "(default)";
1357
2252
  }
1358
2253
  var Session = class {
1359
2254
  constructor(o) {
@@ -1365,8 +2260,7 @@ var Session = class {
1365
2260
  tunnels = [];
1366
2261
  routes = /* @__PURE__ */ new Map();
1367
2262
  specs = /* @__PURE__ */ new Map();
1368
- /** Um TunnelAuth por túnel, reaproveitado nas reaberturas para não desconectar quem já entrou. */
1369
- auths = /* @__PURE__ */ new Map();
2263
+ persistentAuths = /* @__PURE__ */ new Map();
1370
2264
  proxy = null;
1371
2265
  child = null;
1372
2266
  token = null;
@@ -1377,7 +2271,6 @@ var Session = class {
1377
2271
  connection = "connecting";
1378
2272
  listeners = /* @__PURE__ */ new Set();
1379
2273
  snapshot = null;
1380
- /** Mesma referência até a próxima mudança (serve de snapshot para a tela). */
1381
2274
  getState() {
1382
2275
  this.snapshot ??= { account: this.account, source: this.o.source, connection: this.connection, tunnels: this.tunnels.map((t) => ({ ...t })) };
1383
2276
  return this.snapshot;
@@ -1386,23 +2279,18 @@ var Session = class {
1386
2279
  this.listeners.add(fn);
1387
2280
  return () => this.listeners.delete(fn);
1388
2281
  }
1389
- /** Abre os túneis em sequência; falhas ficam no estado e não interrompem os outros. */
1390
2282
  async start(specs) {
1391
2283
  if (this.o.runDir) reapOrphans(this.o.runDir);
1392
2284
  this.proxy = await startProxy({ routes: this.routes, store: this.records });
1393
2285
  this.tunnels = specs.map((s) => this.blank(s, false));
1394
2286
  this.emit();
1395
- try {
1396
- const me = await this.o.api.me();
1397
- this.account = me.username ?? me.email;
1398
- } catch {
1399
- }
2287
+ const me = await this.o.api.me().catch(() => null);
2288
+ if (me) this.account = me.username ?? me.email;
1400
2289
  const existing = await this.o.api.endpoints().catch(() => []);
1401
2290
  for (const t of this.tunnels) await this.open(t, existing);
1402
2291
  const every = this.o.heartbeatMs ?? HEARTBEAT_MS;
1403
2292
  this.timer = setInterval(() => void this.beat(), every);
1404
2293
  }
1405
- /** Túnel avulso, só nesta sessão. */
1406
2294
  async add(spec) {
1407
2295
  if (this.tunnels.some((t2) => t2.key === keyOf(spec))) return;
1408
2296
  const t = this.blank(spec, true);
@@ -1411,29 +2299,29 @@ var Session = class {
1411
2299
  const existing = await this.o.api.endpoints().catch(() => []);
1412
2300
  await this.open(t, existing);
1413
2301
  }
1414
- /** Link para o QR: com senha, um token de uso único; sem senha, só a URL. */
1415
2302
  qrLink(key) {
1416
2303
  const t = this.tunnels.find((x) => x.key === key);
1417
2304
  if (!t?.url) return null;
1418
- const auth = this.auths.get(key);
2305
+ const auth = this.persistentAuths.get(key);
1419
2306
  if (!auth) return { url: t.url, expiresAt: null };
1420
2307
  const { token, expiresAt } = auth.issueQrToken();
1421
2308
  return { url: `${t.url}/__duto/qr/${token}`, expiresAt };
1422
2309
  }
1423
- /** Cria o TunnelAuth na primeira abertura (precisa do allow resolvido) e o mantém depois. */
2310
+ authUser(key) {
2311
+ return this.persistentAuths.get(key)?.qrUser ?? null;
2312
+ }
1424
2313
  async authFor(key) {
1425
- const existing = this.auths.get(key);
2314
+ const existing = this.persistentAuths.get(key);
1426
2315
  if (existing) return existing;
1427
2316
  const spec = this.specs.get(key);
1428
2317
  if (!spec?.auth?.length) return void 0;
1429
2318
  const allow = spec.allow?.length ? await (this.o.resolveAllow ?? resolveAllow)(spec.allow) : void 0;
1430
2319
  const auth = new TunnelAuth({ tunnel: key, credentials: spec.auth, allow });
1431
- this.auths.set(key, auth);
2320
+ this.persistentAuths.set(key, auth);
1432
2321
  return auth;
1433
2322
  }
1434
- /** Reenvia uma requisição da lista ao serviço local. */
1435
2323
  replay(id) {
1436
- 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." };
1437
2325
  }
1438
2326
  async stop() {
1439
2327
  if (this.stopped) return;
@@ -1452,10 +2340,10 @@ var Session = class {
1452
2340
  }
1453
2341
  async open(t, existing, retrying = false) {
1454
2342
  if (this.stopped) return;
1455
- const taken = existing?.some(
1456
- (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
1457
2345
  );
1458
- if (taken) return this.fail(t, ALREADY_OPEN);
2346
+ if (takenByLiveSession) return this.fail(t, ALREADY_OPEN);
1459
2347
  try {
1460
2348
  const res = await this.o.api.openTunnel({
1461
2349
  deviceId: this.o.deviceId,
@@ -1631,7 +2519,7 @@ function runPlain(session, out) {
1631
2519
  }
1632
2520
 
1633
2521
  // src/index.ts
1634
- 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)";
1635
2523
  function fail2(message2) {
1636
2524
  console.error(fail(message2));
1637
2525
  process.exit(1);
@@ -1639,19 +2527,19 @@ function fail2(message2) {
1639
2527
  function parsePort(raw) {
1640
2528
  const s = String(raw ?? "");
1641
2529
  const n = Number(s);
1642
- 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)`);
1643
2531
  return n;
1644
2532
  }
1645
2533
  function parseName(raw) {
1646
2534
  if (raw === void 0 || raw === true || raw === "") return void 0;
1647
2535
  const name = normalizeName(String(raw));
1648
2536
  const v = validateName(name);
1649
- 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)");
1650
2538
  return name;
1651
2539
  }
1652
2540
  function parseHostFlag(raw) {
1653
2541
  if (raw === void 0 || raw === true || raw === "") return void 0;
1654
- 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");
1655
2543
  }
1656
2544
  function listFlag(raw) {
1657
2545
  if (raw === void 0) return [];
@@ -1661,14 +2549,14 @@ function parseAuthFlags(raw) {
1661
2549
  const out = [];
1662
2550
  for (const entry of listFlag(raw)) {
1663
2551
  const r = parseCredential(entry);
1664
- 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");
1665
- 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}"`);
1666
2554
  out.push(r.cred);
1667
2555
  }
1668
2556
  return out;
1669
2557
  }
1670
2558
  function parseAllowFlags(raw) {
1671
- 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`));
1672
2560
  }
1673
2561
  function wantsUi(flags) {
1674
2562
  if (flags.ui === false) return false;
@@ -1687,11 +2575,24 @@ async function runSession(specs, source, flags) {
1687
2575
  fail2(err instanceof Error ? err.message : String(err));
1688
2576
  }
1689
2577
  const session = new Session({ api, deviceId: config.deviceId, deviceName: hostname(), cloudflaredPath, source, runDir: join6(dutoHome(), "run") });
1690
- if (wantsUi(flags)) {
1691
- const { runFullscreen } = await import("./fullscreen-7P5GBCQX.js");
1692
- process.exit(await runFullscreen(session, specs, "0.6.0"));
2578
+ let inspector = null;
2579
+ let inspectorError;
2580
+ if (flags.inspect !== false) {
2581
+ try {
2582
+ inspector = await startInspector({ session, page: inspectorPage });
2583
+ } catch (err) {
2584
+ inspectorError = err instanceof Error ? err.message : String(err);
2585
+ }
1693
2586
  }
1694
- console.log(`${mark()} ${paint("muted", `${"0.6.0"} \xB7 ${source} \xB7 Ctrl+C para encerrar`)}`);
2587
+ if (wantsUi(flags)) {
2588
+ const { runFullscreen } = await import("./fullscreen-IWZCH3IP.js");
2589
+ const code = await runFullscreen(session, specs, "1.0.0", inspector, inspectorError);
2590
+ await inspector?.close().catch(() => void 0);
2591
+ process.exit(code);
2592
+ }
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}`));
1695
2596
  const stopLog = runPlain(session, (l) => console.log(l));
1696
2597
  const stopped = new Promise((resolve) => {
1697
2598
  const onSignal = () => {
@@ -1708,13 +2609,14 @@ async function runSession(specs, source, flags) {
1708
2609
  await stopped;
1709
2610
  stopLog();
1710
2611
  await session.stop();
2612
+ await inspector?.close().catch(() => void 0);
1711
2613
  process.exit(0);
1712
2614
  }
1713
2615
  async function tunnel(rawPort, flags) {
1714
2616
  if (rawPort === void 0) {
1715
- if (flags.host !== void 0) fail2("Use --host junto com a porta (duto 8080 --host meuapp.test) ou host: no duto.yml");
1716
- if (flags.auth !== void 0) fail2("Use --auth junto com a porta (duto 3000 --auth ana:senha) ou auth: no duto.yml");
1717
- 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");
1718
2620
  let found;
1719
2621
  try {
1720
2622
  found = findConfig(process.cwd());
@@ -1724,7 +2626,7 @@ async function tunnel(rawPort, flags) {
1724
2626
  if (!found) {
1725
2627
  cli.outputHelp();
1726
2628
  console.log(`
1727
- ${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")}:
1728
2630
 
1729
2631
  tunnels:
1730
2632
  web: 3000
@@ -1738,30 +2640,30 @@ ${paint("signal", "Dica:")} crie um duto.yml com os t\xFAneis do projeto e rode
1738
2640
  const name = parseName(flags.name) ?? null;
1739
2641
  const host = parseHostFlag(flags.host);
1740
2642
  const keepRedirects = flags.rewriteRedirects === false;
1741
- 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");
1742
2644
  const auth = parseAuthFlags(flags.auth);
1743
- const allow = parseAllowFlags(flags.permitir);
1744
- if (allow.length && !auth.length) fail2("--permitir s\xF3 vale junto com --auth");
1745
- 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);
1746
2648
  }
1747
2649
  var cli = cac("duto");
1748
- 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("--ui", "For\xE7a a tela cheia (no Windows ela vem desligada)").action((port, opts) => tunnel(port, opts));
1749
- 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").action((port, opts) => tunnel(port, opts));
1750
- 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) => {
1751
2653
  try {
1752
2654
  const me = await createApiClient({ baseUrl: apiUrl(), token: String(token) }).me();
1753
2655
  saveConfig({ ...loadConfig(), authtoken: String(token) });
1754
- console.log(ok(`Conectado como ${paint("strong", me.username ?? me.email)}`));
2656
+ console.log(ok(`Logged in as ${paint("strong", me.username ?? me.email)}`));
1755
2657
  } catch (err) {
1756
2658
  fail2(err instanceof Error ? err.message : String(err));
1757
2659
  }
1758
2660
  });
1759
- cli.command("logout", "Remove o authtoken desta m\xE1quina").action(() => {
2661
+ cli.command("logout", "Remove the authtoken from this machine").action(() => {
1760
2662
  const { authtoken: _removed, ...rest } = loadConfig();
1761
2663
  saveConfig(rest);
1762
- console.log(ok("Authtoken removido"));
2664
+ console.log(ok("Authtoken removed"));
1763
2665
  });
1764
- 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 () => {
1765
2667
  const config = loadConfig();
1766
2668
  if (!config.authtoken) fail2(LOGIN_HINT);
1767
2669
  try {
@@ -1769,23 +2671,17 @@ cli.command("status", "Mostra a conta e os t\xFAneis no ar").action(async () =>
1769
2671
  const [me, endpoints] = await Promise.all([api.me(), api.endpoints()]);
1770
2672
  console.log(`${mark()} ${paint("muted", `${me.username} \xB7 ${me.email}`)}`);
1771
2673
  const live = endpoints.filter((e) => e.connected);
1772
- if (!live.length) console.log(paint("muted", "Nenhum t\xFAnel no ar."));
2674
+ if (!live.length) console.log(paint("muted", "No live tunnels."));
1773
2675
  for (const e of live)
1774
2676
  console.log(
1775
- `${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"}`}`)}`
1776
2678
  );
1777
2679
  } catch (err) {
1778
2680
  fail2(err instanceof DutoApiError ? err.message : String(err));
1779
2681
  }
1780
2682
  });
1781
- cli.help(
1782
- (sections) => sections.map((s) => ({
1783
- ...s,
1784
- title: s.title === "Usage" ? "Uso" : s.title === "Commands" ? "Comandos" : s.title === "Options" ? "Op\xE7\xF5es" : s.title,
1785
- 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")
1786
- }))
1787
- );
1788
- cli.version("0.6.0");
2683
+ cli.help();
2684
+ cli.version("1.0.0");
1789
2685
  try {
1790
2686
  cli.parse(process.argv, { run: false });
1791
2687
  await cli.runMatchedCommand();