@dutodev/cli 1.4.0 → 1.6.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
@@ -3,24 +3,31 @@ import {
3
3
  C,
4
4
  CLI_USER_AGENT,
5
5
  CLI_VERSION_HEADER,
6
+ FRAMING_HEADERS,
6
7
  MAX_NAME,
7
8
  TRUNCATED_NOTE,
9
+ UNTRUSTED_VISITOR_HEADERS,
8
10
  apiUrl,
9
11
  bodyNote,
10
12
  checkForUpdate,
13
+ describeChange,
11
14
  dutoHome,
12
15
  formatBytes,
13
16
  formatDuration,
14
17
  formatTime,
18
+ headerLabel,
19
+ isCredentialLike,
20
+ isValidHeaderValue,
15
21
  loadConfig,
16
22
  localLabel,
17
23
  normalizeName,
18
24
  placeOf,
25
+ reservedHeaderError,
19
26
  saveConfig,
20
27
  statusColor,
21
28
  updateLine,
22
29
  validateName
23
- } from "./chunk-4SIBC52A.js";
30
+ } from "./chunk-DUC4RSBW.js";
24
31
 
25
32
  // src/ui/brand.ts
26
33
  function colorOn(stream = process.stdout) {
@@ -36,8 +43,8 @@ var SGR = {
36
43
  warn: "38;2;250;204;21",
37
44
  err: "38;2;248;113;113"
38
45
  };
39
- function paint(tone, text, stream) {
40
- return colorOn(stream) ? `\x1B[${SGR[tone]}m${text}\x1B[0m` : text;
46
+ function paint(tone, text2, stream) {
47
+ return colorOn(stream) ? `\x1B[${SGR[tone]}m${text2}\x1B[0m` : text2;
41
48
  }
42
49
  var mark = () => `${paint("signal", "\u25B0")} ${paint("strong", "duto")}`;
43
50
  var ok = (msg) => `${paint("signal", "\u2714")} ${msg}`;
@@ -50,7 +57,7 @@ import { join as join5, relative } from "path";
50
57
  import cac from "cac";
51
58
 
52
59
  // src/version.ts
53
- var CLI_VERSION = "1.4.0";
60
+ var CLI_VERSION = "1.6.0";
54
61
 
55
62
  // src/api.ts
56
63
  var DutoApiError = class extends Error {
@@ -368,7 +375,7 @@ var CONFIG_FILES = ["duto.yml", "duto.yaml"];
368
375
  var LABEL = "[a-z0-9](?:[a-z0-9-]*[a-z0-9])?";
369
376
  var HOST_RE = new RegExp(`^${LABEL}(?:\\.${LABEL})*(?::\\d{1,5})?$`);
370
377
  var HEADER_NAME_RE = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
371
- var TUNNEL_KEYS = ["port", "host", "headers", "rewrite_redirects", "auth", "allow"];
378
+ var TUNNEL_KEYS = ["port", "host", "headers", "rewrite_redirects", "trust_forwarded", "auth", "allow"];
372
379
  var ENV_REF_RE = /\$\{([A-Z_0-9]+)\}/g;
373
380
  function parseHost(raw) {
374
381
  const host = raw.trim().toLowerCase();
@@ -383,14 +390,29 @@ function parseCredential(raw) {
383
390
  if (password.length < 8 || password.length > 128) return { ok: false, reason: "password" };
384
391
  return { ok: true, cred: { user, password } };
385
392
  }
393
+ function expandEnv(text2, env, missing) {
394
+ return text2.replace(ENV_REF_RE, (_m, name) => {
395
+ const val = env[name];
396
+ if (val === void 0) throw missing(name);
397
+ return val;
398
+ });
399
+ }
400
+ function headerText(node) {
401
+ if (node === null) return "";
402
+ if (!isScalar(node)) return void 0;
403
+ if (node.value === null) return "";
404
+ if (typeof node.value === "string") return node.value;
405
+ if (typeof node.value === "number" || typeof node.value === "boolean") return node.source ?? String(node.value);
406
+ return void 0;
407
+ }
386
408
  function portOf(value) {
387
409
  const v = isMap(value) ? value.get("port") : isScalar(value) ? value.value : value;
388
410
  return typeof v === "number" && Number.isInteger(v) && v >= 1 && v <= 65535 ? v : null;
389
411
  }
390
- function parseConfig(text, file = CONFIG_FILES[0], env = process.env) {
412
+ function parseConfig(text2, file = CONFIG_FILES[0], env = process.env) {
391
413
  const fail3 = (message2, line) => new ConfigError(message2, line, file);
392
414
  const lc = new LineCounter();
393
- const doc = parseDocument(text, { lineCounter: lc, prettyErrors: false });
415
+ const doc = parseDocument(text2, { lineCounter: lc, prettyErrors: false });
394
416
  const lineAt = (offset) => offset === void 0 ? void 0 : lc.linePos(offset).line;
395
417
  if (doc.errors.length) {
396
418
  const e = doc.errors[0];
@@ -401,9 +423,9 @@ function parseConfig(text, file = CONFIG_FILES[0], env = process.env) {
401
423
  throw fail3('declare the tunnels under "tunnels:", one per line (e.g. web: 3000)');
402
424
  }
403
425
  const specs = [];
404
- for (const pair of tunnels.items) {
405
- const raw = isScalar(pair.key) ? String(pair.key.value) : "";
406
- const line = lineAt(pair.key?.range?.[0]);
426
+ for (const pair2 of tunnels.items) {
427
+ const raw = isScalar(pair2.key) ? String(pair2.key.value) : "";
428
+ const line = lineAt(pair2.key?.range?.[0]);
407
429
  const name = normalizeName(raw);
408
430
  const v = validateName(name);
409
431
  if (!v.ok) {
@@ -412,14 +434,14 @@ function parseConfig(text, file = CONFIG_FILES[0], env = process.env) {
412
434
  line
413
435
  );
414
436
  }
415
- const port = portOf(pair.value);
437
+ const port = portOf(pair2.value);
416
438
  if (port === null) throw fail3(`invalid port for "${raw}" (use 1\u201365535)`, line);
417
439
  const spec = { name, port };
418
- if (isMap(pair.value)) {
419
- const body = pair.value;
440
+ if (isMap(pair2.value)) {
441
+ const body = pair2.value;
420
442
  for (const item of body.items) {
421
443
  const key = isScalar(item.key) ? String(item.key.value) : "";
422
- if (!TUNNEL_KEYS.includes(key)) throw fail3(`unknown key in "${raw}": ${key} (use port, host, headers, rewrite_redirects, auth or allow)`, line);
444
+ if (!TUNNEL_KEYS.includes(key)) throw fail3(`unknown key in "${raw}": ${key} (use port, host, headers, rewrite_redirects, trust_forwarded, auth or allow)`, line);
423
445
  }
424
446
  const hostNode = body.get("host", true);
425
447
  if (hostNode !== void 0) {
@@ -430,21 +452,22 @@ function parseConfig(text, file = CONFIG_FILES[0], env = process.env) {
430
452
  const headersNode = body.get("headers", true);
431
453
  if (headersNode !== void 0) {
432
454
  if (!isMap(headersNode)) throw fail3(`headers in "${raw}" must be a list of Name: value`, line);
433
- const headers = {};
455
+ const headers = /* @__PURE__ */ new Map();
434
456
  for (const h of headersNode.items) {
435
457
  const hName = isScalar(h.key) ? String(h.key.value) : "";
436
458
  const hLine = lineAt(h.key?.range?.[0]);
437
- if (!HEADER_NAME_RE.test(hName)) throw fail3(`invalid header in "${raw}": "${hName}"`, hLine);
459
+ if (!HEADER_NAME_RE.test(hName) || hName === "__proto__") throw fail3(`invalid header in "${raw}": "${hName}"`, hLine);
438
460
  if (hName.toLowerCase() === "host") throw fail3(`to change the Host of "${raw}", use host: instead of headers`, hLine);
439
- const value = h.value === null ? null : isScalar(h.value) ? h.value.value : void 0;
440
- if (value !== null && !["string", "number", "boolean"].includes(typeof value)) {
441
- throw fail3(`invalid value for header "${hName}" in "${raw}"`, hLine);
442
- }
443
- const str = value === null ? "" : String(value);
444
- if (/[\r\n]/.test(str)) throw fail3(`invalid value for header "${hName}" in "${raw}"`, hLine);
445
- headers[hName.toLowerCase()] = str;
461
+ const reserved = reservedHeaderError(hName);
462
+ if (reserved) throw fail3(reserved, hLine);
463
+ if (headers.has(hName.toLowerCase())) throw fail3(`header ${hName} appears twice in "${raw}"`, hLine);
464
+ const text3 = headerText(h.value);
465
+ if (text3 === void 0) throw fail3(`invalid value for header "${hName}" in "${raw}"`, hLine);
466
+ const value = expandEnv(text3, env, (name2) => fail3(`environment variable ${name2} is not set (used in headers of "${raw}")`, hLine));
467
+ if (!isValidHeaderValue(value)) throw fail3(`invalid character in the value of ${hName} in "${raw}". Use ASCII or Latin-1 text.`, hLine);
468
+ headers.set(hName.toLowerCase(), value);
446
469
  }
447
- spec.headers = headers;
470
+ spec.headers = Object.fromEntries(headers);
448
471
  }
449
472
  const rewriteNode = body.get("rewrite_redirects", true);
450
473
  if (rewriteNode !== void 0) {
@@ -453,19 +476,21 @@ function parseConfig(text, file = CONFIG_FILES[0], env = process.env) {
453
476
  if (!spec.host) throw fail3(`rewrite_redirects in "${raw}" only works together with host:`, line);
454
477
  if (value === false) spec.rewriteRedirects = false;
455
478
  }
479
+ const trustNode = body.get("trust_forwarded", true);
480
+ if (trustNode !== void 0) {
481
+ const value = isScalar(trustNode) ? trustNode.value : void 0;
482
+ if (typeof value !== "boolean") throw fail3(`trust_forwarded in "${raw}" must be true or false`, line);
483
+ if (value) spec.trustForwarded = true;
484
+ }
456
485
  const authNode = body.get("auth", true);
457
486
  if (authNode !== void 0) {
458
487
  if (!isSeq(authNode)) throw fail3(`auth in "${raw}" must be a list of user:password`, line);
459
488
  const auth = [];
460
489
  for (const item of authNode.items) {
461
490
  const iLine = lineAt(item.range?.[0]) ?? line;
462
- const text2 = isScalar(item) && typeof item.value === "string" ? item.value : null;
463
- if (text2 === null) throw fail3(`invalid credential in "${raw}": use user:password`, iLine);
464
- const expanded = text2.replace(ENV_REF_RE, (_m, name2) => {
465
- const val = env[name2];
466
- if (val === void 0) throw fail3(`environment variable ${name2} is not set (used in auth of "${raw}")`, iLine);
467
- return val;
468
- });
491
+ const text3 = isScalar(item) && typeof item.value === "string" ? item.value : null;
492
+ if (text3 === null) throw fail3(`invalid credential in "${raw}": use user:password`, iLine);
493
+ const expanded = expandEnv(text3, env, (name2) => fail3(`environment variable ${name2} is not set (used in auth of "${raw}")`, iLine));
469
494
  const parsed = parseCredential(expanded);
470
495
  if (!parsed.ok) {
471
496
  if (parsed.reason === "format") throw fail3(`invalid credential in "${raw}": use user:password`, iLine);
@@ -513,6 +538,19 @@ function findConfig(cwd) {
513
538
  }
514
539
  }
515
540
 
541
+ // src/session/warnings.ts
542
+ function joinNames(names) {
543
+ return names.length < 2 ? names.join("") : `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`;
544
+ }
545
+ function credentialWarnings(specs) {
546
+ return specs.flatMap((spec) => {
547
+ if (spec.auth?.length) return [];
548
+ const names = Object.entries(spec.headers ?? {}).filter(([name, value]) => value !== "" && isCredentialLike(name)).map(([name]) => headerLabel(name));
549
+ if (!names.length) return [];
550
+ return [`${spec.name ?? `localhost:${spec.port}`} injects ${joinNames(names)} and has no auth: anyone with the URL is signed in.`];
551
+ });
552
+ }
553
+
516
554
  // src/login.ts
517
555
  import { spawn } from "child_process";
518
556
  import { hostname } from "os";
@@ -523,7 +561,7 @@ async function browserLogin(o) {
523
561
  const start = await api.startLogin({
524
562
  deviceId: o.deviceId,
525
563
  deviceName: hostname(),
526
- cliVersion: "1.4.0",
564
+ cliVersion: "1.6.0",
527
565
  nodeVersion: process.version,
528
566
  os: process.platform,
529
567
  arch: process.arch
@@ -615,13 +653,78 @@ function edgeLabel(edge) {
615
653
  return place ? "via " + edge.colo + " (" + place + ")" : "via " + edge.colo;
616
654
  }
617
655
 
656
+ // src/inspector/filter.ts
657
+ function matchesFilter(item, text2) {
658
+ function compare(value, spec, unit) {
659
+ const m = /^(>=|<=|>|<|=)?(.+)$/.exec(spec);
660
+ if (!m) return null;
661
+ const limit = unit(m[2] ?? "");
662
+ if (limit === null) return null;
663
+ if (value === null) return false;
664
+ const op = m[1] || "=";
665
+ if (op === ">") return value > limit;
666
+ if (op === ">=") return value >= limit;
667
+ if (op === "<") return value < limit;
668
+ if (op === "<=") return value <= limit;
669
+ return value === limit;
670
+ }
671
+ function plainNumber(raw) {
672
+ return /^\d+$/.test(raw) ? Number(raw) : null;
673
+ }
674
+ function millis(raw) {
675
+ const m = /^(\d+(?:\.\d+)?)(ms|s)?$/.exec(raw);
676
+ if (!m) return null;
677
+ return m[2] === "s" ? Number(m[1]) * 1e3 : Number(m[1]);
678
+ }
679
+ function status(spec) {
680
+ const cls = /^([1-5])xx$/.exec(spec);
681
+ if (cls) return item.status !== null && Math.floor(item.status / 100) === Number(cls[1]);
682
+ return compare(item.status, spec, plainNumber);
683
+ }
684
+ function kind(spec) {
685
+ const s = item.status;
686
+ if (spec === "error") return s !== null && s >= 400 || !!item.error;
687
+ if (spec === "redirect") return s !== null && s >= 300 && s < 400;
688
+ if (spec === "rewritten") return !!item.rewritten;
689
+ if (spec === "replay") return item.replayOf !== void 0;
690
+ if (spec === "ws") return item.websocket;
691
+ if (spec === "pending") return s === null && !item.error;
692
+ if (spec === "issue") return (item.issues ?? 0) > 0;
693
+ if (spec === "duto") return item.origin === "duto";
694
+ if (spec === "app") return item.origin === "app";
695
+ return null;
696
+ }
697
+ function field(key, value) {
698
+ const v = value.toLowerCase();
699
+ if (key === "status") return status(v);
700
+ if (key === "is") return kind(v);
701
+ if (key === "duration") return compare(item.durationMs, v, millis);
702
+ if (key === "method") return (item.websocket ? "ws" : item.method.toLowerCase()) === v;
703
+ if (key === "tunnel") return item.tunnel.toLowerCase() === v;
704
+ if (key === "path") return item.path.toLowerCase().indexOf(v) >= 0;
705
+ if (key === "client") return (item.client ?? "").toLowerCase() === v;
706
+ return null;
707
+ }
708
+ const haystack = [item.tunnel, item.websocket ? "ws" : item.method, item.path, item.status === null ? "" : String(item.status), item.client ?? ""].join(" ").toLowerCase();
709
+ const terms = text2.trim().split(/\s+/).filter(Boolean);
710
+ for (const raw of terms) {
711
+ const negate = raw.length > 1 && raw.charAt(0) === "-";
712
+ const term = negate ? raw.slice(1) : raw;
713
+ const sep = term.indexOf(":");
714
+ let hit = sep > 0 ? field(term.slice(0, sep).toLowerCase(), term.slice(sep + 1)) : null;
715
+ if (hit === null) hit = haystack.indexOf(term.toLowerCase()) >= 0;
716
+ if (hit === negate) return false;
717
+ }
718
+ return true;
719
+ }
720
+
618
721
  // src/inspector/jsonView.ts
619
722
  function countLabel(n, one, many) {
620
723
  return n + " " + (n === 1 ? one : many);
621
724
  }
622
- function jsonSyntaxError(text) {
725
+ function jsonSyntaxError(text2) {
623
726
  try {
624
- JSON.parse(text);
727
+ JSON.parse(text2);
625
728
  return null;
626
729
  } catch (e) {
627
730
  const message2 = String(e && e.message ? e.message : e);
@@ -631,65 +734,65 @@ function jsonSyntaxError(text) {
631
734
  if (lc) {
632
735
  let idx = 0;
633
736
  for (let ln = 1; ln < Number(lc[1]); ln++) {
634
- const nl = text.indexOf("\n", idx);
635
- if (nl < 0) return { position: text.length, message: message2 };
737
+ const nl = text2.indexOf("\n", idx);
738
+ if (nl < 0) return { position: text2.length, message: message2 };
636
739
  idx = nl + 1;
637
740
  }
638
- return { position: Math.min(idx + Number(lc[2]) - 1, text.length), message: message2 };
741
+ return { position: Math.min(idx + Number(lc[2]) - 1, text2.length), message: message2 };
639
742
  }
640
- return { position: text.length, message: message2 };
743
+ return { position: text2.length, message: message2 };
641
744
  }
642
745
  }
643
- function jsonTokens(text) {
746
+ function jsonTokens(text2) {
644
747
  const out = [];
645
748
  let depth = 0;
646
749
  let i = 0;
647
- while (i < text.length) {
648
- const c = text.charAt(i);
750
+ while (i < text2.length) {
751
+ const c = text2.charAt(i);
649
752
  let j = i + 1;
650
753
  let kind;
651
754
  if (c === " " || c === "\n" || c === " " || c === "\r") {
652
- while (j < text.length && " \n \r".indexOf(text.charAt(j)) >= 0) j++;
755
+ while (j < text2.length && " \n \r".indexOf(text2.charAt(j)) >= 0) j++;
653
756
  kind = "space";
654
757
  } else if (c === '"') {
655
- while (j < text.length && text.charAt(j) !== '"') j += text.charAt(j) === "\\" ? 2 : 1;
656
- j = Math.min(j + 1, text.length);
758
+ while (j < text2.length && text2.charAt(j) !== '"') j += text2.charAt(j) === "\\" ? 2 : 1;
759
+ j = Math.min(j + 1, text2.length);
657
760
  let k = j;
658
- while (k < text.length && " \n \r".indexOf(text.charAt(k)) >= 0) k++;
659
- kind = text.charAt(k) === ":" ? "key" : "string";
761
+ while (k < text2.length && " \n \r".indexOf(text2.charAt(k)) >= 0) k++;
762
+ kind = text2.charAt(k) === ":" ? "key" : "string";
660
763
  } else if ("{}[],:".indexOf(c) >= 0) {
661
764
  kind = "punct";
662
765
  if (c === "}" || c === "]") depth = Math.max(0, depth - 1);
663
766
  } else {
664
- while (j < text.length && ' \n \r{}[],:"'.indexOf(text.charAt(j)) < 0) j++;
665
- const word = text.slice(i, j);
767
+ while (j < text2.length && ' \n \r{}[],:"'.indexOf(text2.charAt(j)) < 0) j++;
768
+ const word = text2.slice(i, j);
666
769
  kind = word === "true" || word === "false" || word === "null" ? "literal" : "number";
667
770
  }
668
- out.push({ kind, text: text.slice(i, j), depth });
771
+ out.push({ kind, text: text2.slice(i, j), depth });
669
772
  if (kind === "punct" && (c === "{" || c === "[")) depth++;
670
773
  i = j;
671
774
  }
672
775
  return out;
673
776
  }
674
- function jsonIndent(text) {
777
+ function jsonIndent(text2) {
675
778
  let out = "";
676
779
  let depth = 0;
677
780
  let i = 0;
678
781
  const ws = " \n \r";
679
- while (i < text.length) {
680
- const c = text.charAt(i);
782
+ while (i < text2.length) {
783
+ const c = text2.charAt(i);
681
784
  if (ws.indexOf(c) >= 0) {
682
785
  i++;
683
786
  } else if (c === '"') {
684
787
  let j = i + 1;
685
- while (j < text.length && text.charAt(j) !== '"') j += text.charAt(j) === "\\" ? 2 : 1;
686
- out += text.slice(i, j + 1);
788
+ while (j < text2.length && text2.charAt(j) !== '"') j += text2.charAt(j) === "\\" ? 2 : 1;
789
+ out += text2.slice(i, j + 1);
687
790
  i = j + 1;
688
791
  } else if (c === "{" || c === "[") {
689
792
  let j = i + 1;
690
- while (j < text.length && ws.indexOf(text.charAt(j)) >= 0) j++;
691
- if (text.charAt(j) === (c === "{" ? "}" : "]")) {
692
- out += c + text.charAt(j);
793
+ while (j < text2.length && ws.indexOf(text2.charAt(j)) >= 0) j++;
794
+ if (text2.charAt(j) === (c === "{" ? "}" : "]")) {
795
+ out += c + text2.charAt(j);
693
796
  i = j + 1;
694
797
  } else {
695
798
  depth++;
@@ -708,8 +811,8 @@ function jsonIndent(text) {
708
811
  i++;
709
812
  } else {
710
813
  let j = i + 1;
711
- while (j < text.length && (ws + '{}[],:"').indexOf(text.charAt(j)) < 0) j++;
712
- out += text.slice(i, j);
814
+ while (j < text2.length && (ws + '{}[],:"').indexOf(text2.charAt(j)) < 0) j++;
815
+ out += text2.slice(i, j);
713
816
  i = j;
714
817
  }
715
818
  }
@@ -741,14 +844,14 @@ button { font: inherit; color: inherit; background: none; border: 0; padding: 0;
741
844
  .tunnels { display: flex; flex-wrap: wrap; gap: 4px 14px; justify-content: flex-end; min-width: 0; }
742
845
  .tunnels .k { color: ${C.strong}; }
743
846
  .banner { padding: 6px 12px; background: ${BAR}; border-bottom: 1px solid ${C.err}; color: ${C.err}; }
744
- .main { flex: 1; min-height: 0; display: grid; grid-template-columns: 320px 1fr; }
847
+ .main { flex: 1; min-height: 0; display: grid; grid-template-columns: 400px 1fr; }
745
848
  .side { display: flex; flex-direction: column; min-height: 0; border-right: 1px solid ${LINE}; }
746
849
  .search { margin: 8px 12px; padding: 5px 8px; border: 1px solid ${INPUT_LINE}; border-radius: 5px; background: ${BG}; color: ${TEXT}; font: inherit; outline: none; }
747
850
  .search:focus { border-color: ${C.signal}; }
748
851
  .search::placeholder { color: ${C.muted}; }
749
852
  .list { flex: 1; min-height: 0; overflow-y: auto; outline: none; }
750
- .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; }
751
- .list.multi .row { grid-template-columns: 14px 56px 42px minmax(0, 1fr) 34px 56px; }
853
+ .row { display: grid; grid-template-columns: 14px 56px minmax(0, 1fr) 34px 60px; gap: 8px; padding: 5px 12px; border-bottom: 1px solid ${ROW_LINE}; align-items: center; cursor: pointer; white-space: nowrap; }
854
+ .list.multi .row { grid-template-columns: 14px 56px 56px minmax(0, 1fr) 34px 60px; }
752
855
  .row:hover { background: #141917; }
753
856
  .row.sel { background: ${SELECTED}; box-shadow: inset 2px 0 0 ${C.signal}; }
754
857
  .row span { overflow: hidden; text-overflow: ellipsis; }
@@ -756,7 +859,30 @@ button { font: inherit; color: inherit; background: none; border: 0; padding: 0;
756
859
  .m { font-weight: 700; }
757
860
  .mut { color: ${C.muted}; }
758
861
  .sig { color: ${C.signal}; }
759
- .redirect { margin-top: 6px; display: grid; gap: 2px; }
862
+ .warn { color: ${C.warn}; }
863
+ .errc { color: ${C.err}; }
864
+ .row.inflow { box-shadow: inset 2px 0 0 ${C.muted}; }
865
+ .row.sel.inflow { box-shadow: inset 2px 0 0 ${C.signal}; }
866
+ .to { color: ${C.muted}; }
867
+ .chips { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 12px 8px; }
868
+ .chip { border: 1px solid ${INPUT_LINE}; border-radius: 999px; padding: 1px 8px; color: ${C.muted}; }
869
+ .chip:hover { color: ${TEXT}; }
870
+ .chip.on { border-color: ${C.signal}; color: ${C.signal}; }
871
+ .chip b { font-weight: 400; color: ${TEXT}; }
872
+ .sidebar-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 8px 12px; border-top: 1px solid ${LINE}; }
873
+ .help { padding: 0 12px 8px; color: ${C.muted}; display: grid; gap: 2px; }
874
+ .help code { color: ${TEXT}; font: inherit; }
875
+ .ov { padding: 4px 0 12px; }
876
+ .issue { margin: 8px 12px 0; padding: 6px 10px; border-left: 2px solid ${C.warn}; background: #16140f; }
877
+ .issue.error { border-left-color: ${C.err}; background: #1a1112; }
878
+ .issue.info { border-left-color: ${C.muted}; background: #141716; }
879
+ .issue b { display: block; color: ${C.strong}; font-weight: 600; }
880
+ .issue span { color: ${C.muted}; }
881
+ .flow { margin: 6px 12px 0; display: grid; gap: 2px; max-width: 760px; }
882
+ .step { display: grid; grid-template-columns: 40px 52px minmax(0, 1fr) 40px; gap: 8px; text-align: left; padding: 2px 6px; border-radius: 4px; }
883
+ .step:hover { background: #141917; }
884
+ .step.cur { background: ${SELECTED}; box-shadow: inset 2px 0 0 ${C.signal}; }
885
+ .step span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
760
886
  .empty { padding: 12px; color: ${C.muted}; }
761
887
  .detail { display: flex; flex-direction: column; min-height: 0; min-width: 0; }
762
888
  .pane { flex: 1; }
@@ -798,14 +924,20 @@ th { color: ${JSON_KEY}; font-weight: 400; white-space: nowrap; }
798
924
  var SCRIPT = String.raw`"use strict";
799
925
  var countLabel = (${countLabel.toString()});
800
926
  var edgeLabel = (${edgeLabel.toString()});
927
+ var matchesFilter = (${matchesFilter.toString()});
801
928
  var jsonSyntaxError = (${jsonSyntaxError.toString()});
802
929
  var jsonTokens = (${jsonTokens.toString()});
803
930
  var jsonIndent = (${jsonIndent.toString()});
804
931
  var COLORS = ${JSON.stringify(C)};
805
932
  var TOKEN = new URLSearchParams(location.search).get("t") || "";
806
- var TABS = ["Body", "Headers", "Response", "Raw"];
933
+ var CHIPS = [
934
+ { label: "Errors", query: "is:error" },
935
+ { label: "Redirects", query: "is:redirect" },
936
+ { label: "Issues", query: "is:issue" },
937
+ { label: "Slow", query: "duration:>1s" }
938
+ ];
807
939
 
808
- var state = { items: [], tunnels: [], selected: null, detail: null, detailKey: "", tab: "Body", ended: false };
940
+ var state = { items: [], tunnels: [], selected: null, detail: null, detailKey: "", tab: "Overview", ended: false, clearedAfter: 0 };
809
941
  var listSeq = 0, detailSeq = 0, msgTimer = null;
810
942
  var es = null, lost = false, replaying = false;
811
943
  function fail(e) { if (!state.ended) console.error(e); }
@@ -857,14 +989,60 @@ function header(h, name) {
857
989
  for (var k in h) if (k.toLowerCase() === name) return h[k];
858
990
  return "";
859
991
  }
992
+ function kept() {
993
+ return state.items.filter(function (it) { return it.id > state.clearedAfter; });
994
+ }
860
995
  function visible() {
861
- var f = $("filter").value.trim().toLowerCase();
862
- if (!f) return state.items;
863
- return state.items.filter(function (it) {
864
- return (it.tunnel + " " + (it.websocket ? "ws" : it.method) + " " + it.path + " " + (it.status === null || it.status === undefined ? "" : it.status)).toLowerCase().indexOf(f) >= 0;
996
+ var f = $("filter").value;
997
+ return kept().filter(function (it) { return matchesFilter(it, f); });
998
+ }
999
+ function setFilter(text) {
1000
+ $("filter").value = text;
1001
+ renderList();
1002
+ }
1003
+
1004
+ function renderChips() {
1005
+ var box = $("chips");
1006
+ clear(box);
1007
+ var current = $("filter").value.trim();
1008
+ var items = kept();
1009
+ CHIPS.forEach(function (c) {
1010
+ var n = items.filter(function (it) { return matchesFilter(it, c.query); }).length;
1011
+ var b = el("button", "chip" + (current === c.query ? " on" : ""));
1012
+ b.type = "button";
1013
+ b.title = c.query;
1014
+ b.appendChild(document.createTextNode(c.label + " "));
1015
+ b.appendChild(el("b", "", n));
1016
+ b.addEventListener("click", function () { setFilter(current === c.query ? "" : c.query); });
1017
+ box.appendChild(b);
865
1018
  });
866
1019
  }
867
1020
 
1021
+ function itemById(id) {
1022
+ for (var i = 0; i < state.items.length; i++) if (state.items[i].id === id) return state.items[i];
1023
+ return null;
1024
+ }
1025
+ function chainOf(id) {
1026
+ var ids = {};
1027
+ var start = itemById(id);
1028
+ if (!start || (start.prev === undefined && start.next === undefined)) return ids;
1029
+ var cur = start, guard = 0;
1030
+ while (cur && cur.prev !== undefined && guard++ < 50) cur = itemById(cur.prev);
1031
+ guard = 0;
1032
+ while (cur && guard++ < 50) {
1033
+ ids[cur.id] = true;
1034
+ cur = cur.next === undefined ? null : itemById(cur.next);
1035
+ }
1036
+ return ids;
1037
+ }
1038
+
1039
+ function markOf(it) {
1040
+ if (it.issues > 0) return { text: "!", cls: it.origin === "duto" || it.error ? "errc" : "warn", title: it.issues === 1 ? "1 issue" : it.issues + " issues" };
1041
+ if (it.replayOf !== undefined) return { text: "↻", cls: "mut", title: "replay of #" + it.replayOf };
1042
+ if (it.rewritten) return { text: "↪", cls: "sig", title: "Location rewritten" };
1043
+ return { text: "", cls: "mut", title: "" };
1044
+ }
1045
+
868
1046
  function renderTop() {
869
1047
  var n = state.items.length;
870
1048
  $("count").textContent = countLabel(n, "request", "requests");
@@ -883,26 +1061,29 @@ function renderList() {
883
1061
  var multi = state.tunnels.length > 1;
884
1062
  list.classList.toggle("multi", multi);
885
1063
  clear(list);
1064
+ renderChips();
1065
+ $("clear").textContent = state.clearedAfter > 0 ? "Show all" : "Clear";
886
1066
  var items = visible();
887
1067
  if (items.length === 0) {
888
- list.appendChild(el("div", "empty", state.items.length === 0 ? "No requests yet." : "No requests match the filter."));
1068
+ var none = state.items.length === 0 ? "No requests yet." : kept().length === 0 ? "Cleared. New requests show up here." : "No requests match the filter.";
1069
+ list.appendChild(el("div", "empty", none));
889
1070
  return;
890
1071
  }
1072
+ var flow = state.selected === null ? {} : chainOf(state.selected);
891
1073
  items.forEach(function (it) {
892
- var row = el("div", "row" + (it.id === state.selected ? " sel" : ""));
1074
+ var row = el("div", "row" + (it.id === state.selected ? " sel" : "") + (flow[it.id] ? " inflow" : ""));
893
1075
  row.setAttribute("role", "option");
894
1076
  row.setAttribute("aria-selected", it.id === state.selected ? "true" : "false");
895
1077
  row.dataset.id = String(it.id);
896
- if (it.replayOf !== undefined) row.appendChild(el("span", "mut", "↻"));
897
- else if (it.rewritten) {
898
- var mark = el("span", "sig", "↪");
899
- mark.title = "Location rewritten";
900
- row.appendChild(mark);
901
- } else row.appendChild(el("span", "mut", ""));
1078
+ var mark = markOf(it);
1079
+ var ms = el("span", mark.cls, mark.text);
1080
+ ms.title = mark.title;
1081
+ row.appendChild(ms);
902
1082
  if (multi) row.appendChild(el("span", "mut", it.tunnel));
903
1083
  row.appendChild(el("span", "m", methodOf(it)));
904
1084
  var p = el("span", "", it.path);
905
- p.title = it.path;
1085
+ p.title = it.location ? it.path + " → " + it.location : it.path;
1086
+ if (it.location) p.appendChild(el("span", "to", " → " + it.location));
906
1087
  row.appendChild(p);
907
1088
  var st = statusInfo(it);
908
1089
  var s = el("span", "", st.text);
@@ -980,13 +1161,6 @@ function renderDetail() {
980
1161
  if (d.replayOf !== undefined) parts.push("↻ replay of #" + d.replayOf);
981
1162
  head.appendChild(el("span", "mut", " · " + parts.join(" · ")));
982
1163
  if (d.error) head.appendChild(el("div", "msg err", d.error));
983
- if (d.redirect) {
984
- var moved = el("div", "redirect");
985
- moved.appendChild(el("div", "sig", "↪ Location rewritten"));
986
- moved.appendChild(el("div", "mut", d.redirect.from));
987
- moved.appendChild(el("div", "", "→ " + d.redirect.to));
988
- head.appendChild(moved);
989
- }
990
1164
 
991
1165
  var tabs = document.querySelectorAll(".tab");
992
1166
  for (var i = 0; i < tabs.length; i++) {
@@ -1003,7 +1177,8 @@ function renderContent() {
1003
1177
  var box = $("content");
1004
1178
  var top = box.scrollTop;
1005
1179
  clear(box);
1006
- if (state.tab === "Body") renderBody(box, d.reqBody, d.reqNote, d.reqTruncatedNote, d.reqHeaders);
1180
+ if (state.tab === "Overview") renderOverview(box, d);
1181
+ else if (state.tab === "Body") renderBody(box, d.reqBody, d.reqNote, d.reqTruncatedNote, d.reqHeaders);
1007
1182
  else if (state.tab === "Headers") box.appendChild(table(d.reqHeaders));
1008
1183
  else if (state.tab === "Response") {
1009
1184
  box.appendChild(el("div", "sect", "Headers"));
@@ -1020,6 +1195,135 @@ function renderContent() {
1020
1195
  box.scrollTop = top;
1021
1196
  }
1022
1197
 
1198
+ function rows(pairs) {
1199
+ var t = el("table");
1200
+ pairs.forEach(function (p) {
1201
+ if (p[1] === null || p[1] === undefined || p[1] === "") return;
1202
+ var tr = el("tr");
1203
+ tr.appendChild(el("th", "", p[0]));
1204
+ tr.appendChild(el("td", "", p[1]));
1205
+ t.appendChild(tr);
1206
+ });
1207
+ return t;
1208
+ }
1209
+ function clock(ms) {
1210
+ var d = new Date(ms);
1211
+ function two(n) { return (n < 10 ? "0" : "") + n; }
1212
+ return two(d.getHours()) + ":" + two(d.getMinutes()) + ":" + two(d.getSeconds()) + "." + String(d.getMilliseconds()).padStart(3, "0");
1213
+ }
1214
+ function answeredBy(d) {
1215
+ if (d.origin === null) return "waiting for an answer";
1216
+ if (d.origin === "duto") return d.access && d.access.kind === "denied" ? "duto (tunnel password)" : "duto (nothing answered on localhost)";
1217
+ var t = state.tunnels.filter(function (x) { return x.key === d.tunnel; })[0];
1218
+ return t && t.port ? "your app on localhost:" + t.port : "your app";
1219
+ }
1220
+ function accessText(a) {
1221
+ if (!a) return null;
1222
+ if (a.kind === "user") return "password, user " + a.user;
1223
+ if (a.kind === "allow") return "allowed: " + a.reason;
1224
+ return "denied";
1225
+ }
1226
+ function attrsText(attrs) {
1227
+ return Object.keys(attrs).map(function (k) { return attrs[k] === true ? k : k + "=" + attrs[k]; }).join("; ");
1228
+ }
1229
+
1230
+ function renderOverview(box, d) {
1231
+ var ov = el("div", "ov");
1232
+ d.issues.forEach(function (i) {
1233
+ var card = el("div", "issue " + i.level);
1234
+ card.appendChild(el("b", "", i.title));
1235
+ card.appendChild(el("span", "", i.detail));
1236
+ ov.appendChild(card);
1237
+ });
1238
+
1239
+ if (d.flow.length > 0) {
1240
+ ov.appendChild(el("div", "sect", "Flow"));
1241
+ var flow = el("div", "flow");
1242
+ d.flow.forEach(function (s) {
1243
+ var b = el("button", "step" + (s.id === d.id ? " cur" : ""));
1244
+ b.type = "button";
1245
+ b.appendChild(el("span", "mut", "#" + s.id));
1246
+ b.appendChild(el("span", "m", s.method));
1247
+ b.appendChild(el("span", "", s.path));
1248
+ var st = statusInfo(s);
1249
+ var sp = el("span", "", st.text);
1250
+ sp.style.color = st.color;
1251
+ b.appendChild(sp);
1252
+ b.addEventListener("click", function () { select(s.id); });
1253
+ flow.appendChild(b);
1254
+ });
1255
+ ov.appendChild(flow);
1256
+ }
1257
+
1258
+ ov.appendChild(el("div", "sect", "Request"));
1259
+ var timing = d.durationMs === null || d.durationMs === undefined ? null : d.durationMs + " ms";
1260
+ ov.appendChild(rows([
1261
+ ["Answered by", answeredBy(d)],
1262
+ ["Started", clock(d.startedAt)],
1263
+ ["Total time", timing],
1264
+ ["First byte", d.ttfbMs === undefined ? null : d.ttfbMs + " ms"],
1265
+ ["Size", d.status === null ? null : size(d.bytes)],
1266
+ ["Data center", d.edge ? edgeLabel(d.edge).replace(/^via /, "") : null]
1267
+ ]));
1268
+
1269
+ var changes = (d.changes || []).map(function (c) { return [c.label, c.value + " · " + c.reason]; });
1270
+ if (changes.length > 0 || d.redirect || d.access) {
1271
+ ov.appendChild(el("div", "sect", "Changed by Duto"));
1272
+ ov.appendChild(rows(changes.concat([
1273
+ ["Location rewritten", d.redirect ? d.redirect.from + " → " + d.redirect.to : null],
1274
+ ["Access", accessText(d.access)]
1275
+ ])));
1276
+ }
1277
+
1278
+ if (d.location || d.redirect) {
1279
+ ov.appendChild(el("div", "sect", "Redirect"));
1280
+ ov.appendChild(rows([["Location", d.location || (d.redirect && d.redirect.to)]]));
1281
+ }
1282
+
1283
+ ov.appendChild(el("div", "sect", "Client"));
1284
+ ov.appendChild(rows([["IP", d.client.ip], ["Country", d.client.country], ["User agent", d.client.userAgent]]));
1285
+
1286
+ if (d.query.length > 0) {
1287
+ ov.appendChild(el("div", "sect", "Query"));
1288
+ ov.appendChild(rows(d.query.map(function (q) { return [q.name, q.value || " "]; })));
1289
+ }
1290
+ if (d.cookies.sent.length > 0) {
1291
+ ov.appendChild(el("div", "sect", "Cookies sent"));
1292
+ ov.appendChild(rows(d.cookies.sent.map(function (c) { return [c.name, c.value || " "]; })));
1293
+ }
1294
+ if (d.cookies.set.length > 0) {
1295
+ ov.appendChild(el("div", "sect", "Cookies set"));
1296
+ ov.appendChild(rows(d.cookies.set.map(function (c) {
1297
+ var a = attrsText(c.attrs);
1298
+ return [c.name, (c.value || "\"\"") + (a ? " · " + a : "")];
1299
+ })));
1300
+ }
1301
+ box.appendChild(ov);
1302
+ }
1303
+
1304
+ function exportHar() {
1305
+ var ids = visible().map(function (it) { return it.id; });
1306
+ if (ids.length === 0) return showMsg("Nothing to export.", true);
1307
+ fetch("/api/har?ids=" + ids.join(","), { headers: { "X-Duto-Inspector": TOKEN }, cache: "no-store" }).then(function (r) {
1308
+ if (r.status === 403) { ended(); throw new Error("403"); }
1309
+ return r.blob();
1310
+ }).then(function (blob) {
1311
+ var a = document.createElement("a");
1312
+ a.href = URL.createObjectURL(blob);
1313
+ a.download = "duto-" + new Date().toISOString().replace(/[:.]/g, "-") + ".har";
1314
+ document.body.appendChild(a);
1315
+ a.click();
1316
+ a.remove();
1317
+ setTimeout(function () { URL.revokeObjectURL(a.href); }, 1000);
1318
+ }).catch(fail);
1319
+ }
1320
+
1321
+ function toggleClear() {
1322
+ if (state.clearedAfter > 0) state.clearedAfter = 0;
1323
+ else state.clearedAfter = state.items.reduce(function (m, it) { return Math.max(m, it.id); }, 0);
1324
+ renderList();
1325
+ }
1326
+
1023
1327
  function table(h) {
1024
1328
  var keys = Object.keys(h || {});
1025
1329
  if (keys.length === 0) return el("div", "note", "No headers.");
@@ -1195,11 +1499,9 @@ function connect() {
1195
1499
  banner("Disconnected from the session.");
1196
1500
  if (es.readyState === 2) probe();
1197
1501
  });
1198
- es.addEventListener("changed", function (ev) {
1199
- var ids = [];
1200
- try { ids = JSON.parse(ev.data).ids || []; } catch (e) {}
1502
+ es.addEventListener("changed", function () {
1201
1503
  loadList();
1202
- if (state.selected !== null && (ids.length === 0 || ids.indexOf(state.selected) >= 0)) loadDetail(state.selected);
1504
+ if (state.selected !== null) loadDetail(state.selected);
1203
1505
  });
1204
1506
  }
1205
1507
  function probe() {
@@ -1219,6 +1521,13 @@ function init() {
1219
1521
  });
1220
1522
  }
1221
1523
  $("filter").addEventListener("input", renderList);
1524
+ $("clear").addEventListener("click", toggleClear);
1525
+ $("har").addEventListener("click", exportHar);
1526
+ $("helpBtn").addEventListener("click", function () {
1527
+ var h = $("help");
1528
+ h.hidden = !h.hidden;
1529
+ $("helpBtn").setAttribute("aria-expanded", h.hidden ? "false" : "true");
1530
+ });
1222
1531
  $("replay").addEventListener("click", replay);
1223
1532
  $("copyBody").addEventListener("click", function () {
1224
1533
  var b = state.detail ? copyable(state.detail) : { text: null };
@@ -1253,7 +1562,7 @@ function init() {
1253
1562
  init();
1254
1563
  `;
1255
1564
  function inspectorPage(nonce) {
1256
- 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("");
1565
+ const tabs = ["Overview", "Body", "Headers", "Response", "Raw"].map((t, i) => `<button type="button" class="tab${i === 0 ? " on" : ""}" role="tab" data-tab="${t}">${t}</button>`).join("");
1257
1566
  return `<!doctype html>
1258
1567
  <html lang="en">
1259
1568
  <head>
@@ -1269,8 +1578,21 @@ function inspectorPage(nonce) {
1269
1578
  <div class="banner" id="banner" role="status" hidden></div>
1270
1579
  <div class="main">
1271
1580
  <div class="side">
1272
- <input class="search" id="filter" type="search" placeholder="filter\u2026" aria-label="Filter requests" autocomplete="off" spellcheck="false">
1581
+ <input class="search" id="filter" type="search" placeholder="filter\u2026 (status:4xx is:redirect)" aria-label="Filter requests" autocomplete="off" spellcheck="false">
1582
+ <div class="chips" id="chips"></div>
1583
+ <div class="help" id="help" hidden>
1584
+ <span><code>status:404</code> <code>status:5xx</code> <code>status:&gt;=400</code></span>
1585
+ <span><code>method:post</code> <code>path:/api</code> <code>tunnel:web</code> <code>client:1.2.3.4</code></span>
1586
+ <span><code>is:error</code> <code>is:redirect</code> <code>is:rewritten</code> <code>is:issue</code> <code>is:duto</code> <code>is:ws</code> <code>is:pending</code></span>
1587
+ <span><code>duration:&gt;500ms</code> <code>duration:&gt;=1s</code></span>
1588
+ <span>Start a term with <code>-</code> to exclude it. All terms must match.</span>
1589
+ </div>
1273
1590
  <div class="list" id="list" role="listbox" aria-label="Requests"></div>
1591
+ <div class="sidebar-foot">
1592
+ <button type="button" class="btn" id="clear">Clear</button>
1593
+ <button type="button" class="btn" id="har">Export HAR</button>
1594
+ <button type="button" class="btn" id="helpBtn" aria-expanded="false" aria-controls="help">Filter syntax</button>
1595
+ </div>
1274
1596
  </div>
1275
1597
  <div class="detail">
1276
1598
  <div class="empty" id="none">Select a request from the list.</div>
@@ -1311,7 +1633,7 @@ function replayBlock(r) {
1311
1633
  if (r.reqBodyBytes > 0 && r.reqRaw === null) return "Binary body is not stored; cannot replay.";
1312
1634
  return null;
1313
1635
  }
1314
- var HOP_BY_HOP_AND_BODY_HEADERS = /* @__PURE__ */ new Set(["connection", "keep-alive", "transfer-encoding", "expect", "content-length", "upgrade", "te", "trailer", "proxy-connection"]);
1636
+ var HOP_BY_HOP_AND_BODY_HEADERS = new Set(FRAMING_HEADERS);
1315
1637
  function replayHeaders(r) {
1316
1638
  const out = {};
1317
1639
  for (const [k, v] of Object.entries(r.reqHeaders)) if (!HOP_BY_HOP_AND_BODY_HEADERS.has(k.toLowerCase())) out[k] = v;
@@ -1320,8 +1642,27 @@ function replayHeaders(r) {
1320
1642
  return out;
1321
1643
  }
1322
1644
 
1645
+ // src/inspector/cookies.ts
1646
+ function pair(part) {
1647
+ const i = part.indexOf("=");
1648
+ return i < 0 ? [part.trim(), null] : [part.slice(0, i).trim(), part.slice(i + 1).trim()];
1649
+ }
1650
+ function parseSetCookie(line) {
1651
+ const [first3 = "", ...rest] = line.split(";");
1652
+ const [name, value] = pair(first3);
1653
+ const attrs = {};
1654
+ for (const part of rest) {
1655
+ const [key, v] = pair(part);
1656
+ if (key) attrs[key.toLowerCase()] = v ?? true;
1657
+ }
1658
+ return { name, value: value ?? "", attrs };
1659
+ }
1660
+ function parseCookieHeader(header) {
1661
+ return header.split(";").map((part) => pair(part)).filter(([name]) => name !== "").map(([name, value]) => ({ name, value: value ?? "" }));
1662
+ }
1663
+
1323
1664
  // src/inspector/curl.ts
1324
- var SKIP = /* @__PURE__ */ new Set(["host", "content-length", "transfer-encoding", "connection", "keep-alive", "expect", "upgrade", "te", "trailer", "proxy-connection"]);
1665
+ var SKIP = /* @__PURE__ */ new Set(["host", ...FRAMING_HEADERS]);
1325
1666
  var TUNNEL_ADDED = /* @__PURE__ */ new Set(["cdn-loop", "x-forwarded-for", "x-forwarded-proto", "x-real-ip", "true-client-ip"]);
1326
1667
  var skip = (name) => {
1327
1668
  const k = name.toLowerCase();
@@ -1340,6 +1681,209 @@ function toCurl(r, o) {
1340
1681
  return parts.join(" \\\n ");
1341
1682
  }
1342
1683
 
1684
+ // src/inspector/diagnose.ts
1685
+ var LOCAL_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "0.0.0.0", "[::1]", "::1"]);
1686
+ var LINKABLE = /html|javascript|json|css|xml/i;
1687
+ var LOCAL_LINK = /https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?/gi;
1688
+ function isLocalHost(host) {
1689
+ const h = host.toLowerCase();
1690
+ return LOCAL_HOSTS.has(h) || h.endsWith(".localhost");
1691
+ }
1692
+ function absoluteUrl(value) {
1693
+ if (!/^https?:\/\//i.test(value)) return null;
1694
+ try {
1695
+ return new URL(value);
1696
+ } catch {
1697
+ return null;
1698
+ }
1699
+ }
1700
+ function originOf(r) {
1701
+ if (r.error || r.access?.kind === "denied") return "duto";
1702
+ return r.status === null ? null : "app";
1703
+ }
1704
+ function upstreamIssues(r, ctx) {
1705
+ const out = [];
1706
+ if (r.error) {
1707
+ out.push({ level: "error", title: `Nothing is listening on localhost:${ctx.port ?? "?"}`, detail: `${r.error}. Start the server, then replay this request.` });
1708
+ }
1709
+ if (r.access?.kind === "denied") {
1710
+ out.push({ level: "info", title: "Blocked by the tunnel password", detail: "duto answered before the request reached your app." });
1711
+ }
1712
+ return out;
1713
+ }
1714
+ function redirectIssues(r, ctx) {
1715
+ const location = r.resHeaders.location;
1716
+ const url = location ? absoluteUrl(location) : null;
1717
+ if (!url) return [];
1718
+ if (isLocalHost(url.hostname)) {
1719
+ return [
1720
+ {
1721
+ level: "warn",
1722
+ title: "Redirect to a local address",
1723
+ detail: `Location is ${location}, which only opens on this machine. Build URLs from the request's Host header, or run duto with --host.`
1724
+ }
1725
+ ];
1726
+ }
1727
+ if (url.protocol === "http:" && ctx.publicHost && url.hostname.toLowerCase() === ctx.publicHost) {
1728
+ return [
1729
+ {
1730
+ level: "warn",
1731
+ title: "Redirect drops to http",
1732
+ detail: `Location is ${location}. duto.dev only serves https, so the browser takes an extra hop and loses POST bodies. Have the app trust X-Forwarded-Proto.`
1733
+ }
1734
+ ];
1735
+ }
1736
+ return [];
1737
+ }
1738
+ function cookieIssues(r, ctx) {
1739
+ const out = [];
1740
+ for (const line of r.resSetCookies ?? []) {
1741
+ const c = parseSetCookie(line);
1742
+ const domain = typeof c.attrs.domain === "string" ? c.attrs.domain.replace(/^\./, "").toLowerCase() : null;
1743
+ const host = ctx.publicHost;
1744
+ if (domain && host && host !== domain && !host.endsWith(`.${domain}`)) {
1745
+ out.push({ level: "warn", title: `Cookie "${c.name}" is dropped`, detail: `Domain=${c.attrs.domain} does not match ${host}. Remove Domain, or set it to ${host}.` });
1746
+ } else if (typeof c.attrs.samesite === "string" && c.attrs.samesite.toLowerCase() === "none" && c.attrs.secure === void 0) {
1747
+ out.push({ level: "warn", title: `Cookie "${c.name}" is dropped`, detail: "SameSite=None needs Secure." });
1748
+ }
1749
+ }
1750
+ return out;
1751
+ }
1752
+ function corsIssues(r, ctx) {
1753
+ const origin = r.reqHeaders.origin;
1754
+ if (!origin || r.status === null || ctx.publicHost && origin.toLowerCase() === `https://${ctx.publicHost}`) return [];
1755
+ const allowed = r.resHeaders["access-control-allow-origin"];
1756
+ const ok2 = allowed === "*" || allowed === origin;
1757
+ const wanted = r.reqHeaders["access-control-request-method"];
1758
+ if (r.method === "OPTIONS" && wanted) {
1759
+ if (r.status < 400 && ok2) return [];
1760
+ return [{ level: "error", title: "CORS preflight failed", detail: `The browser will not send the ${wanted} from ${origin}. Answer OPTIONS with 2xx and Access-Control-Allow-Origin.` }];
1761
+ }
1762
+ if (ok2) return [];
1763
+ const detail2 = allowed ? `Access-Control-Allow-Origin is ${allowed}, not ${origin}.` : `No Access-Control-Allow-Origin for ${origin}.`;
1764
+ return [{ level: "warn", title: "The browser blocks this response (CORS)", detail: detail2 }];
1765
+ }
1766
+ function bodyIssues(r) {
1767
+ if (!r.resBody || !LINKABLE.test(r.resHeaders["content-type"] ?? "")) return [];
1768
+ const links = r.resBody.match(LOCAL_LINK);
1769
+ if (!links) return [];
1770
+ const first3 = links[0];
1771
+ const detail2 = links.length === 1 ? `1 link points to ${first3}. It only opens on this machine.` : `${links.length} links point to ${first3}. They only open on this machine.`;
1772
+ return [{ level: "warn", title: "The response links to localhost", detail: detail2 }];
1773
+ }
1774
+ function diagnose(r, ctx) {
1775
+ return [...upstreamIssues(r, ctx), ...redirectIssues(r, ctx), ...cookieIssues(r, ctx), ...corsIssues(r, ctx), ...bodyIssues(r)];
1776
+ }
1777
+
1778
+ // src/inspector/flows.ts
1779
+ var FLOW_WINDOW_MS = 3e4;
1780
+ function clientOf(r) {
1781
+ const direct = r.reqHeaders["cf-connecting-ip"];
1782
+ if (direct) return direct;
1783
+ const forwarded = r.reqHeaders["x-forwarded-for"]?.split(",")[0]?.trim();
1784
+ return forwarded || null;
1785
+ }
1786
+ var clientKey = (r) => `${clientOf(r) ?? ""}|${r.reqHeaders["user-agent"] ?? ""}`;
1787
+ function redirectTarget(r, publicHost) {
1788
+ const location = r.resHeaders.location;
1789
+ if (!location || r.status === null || r.status < 300 || r.status >= 400) return null;
1790
+ const base = `https://${publicHost ?? "duto.invalid"}`;
1791
+ let url;
1792
+ try {
1793
+ url = new URL(location, base + r.path);
1794
+ } catch {
1795
+ return null;
1796
+ }
1797
+ if (url.origin !== base) return null;
1798
+ return url.pathname + url.search;
1799
+ }
1800
+ function linkFlows(records, publicHostOf) {
1801
+ const ordered = [...records].sort((a, b) => a.id - b.id);
1802
+ const links = /* @__PURE__ */ new Map();
1803
+ const link = (id, patch) => links.set(id, { ...links.get(id), ...patch });
1804
+ ordered.forEach((r, i) => {
1805
+ const target = redirectTarget(r, publicHostOf(r.tunnel));
1806
+ if (target === null) return;
1807
+ const key = clientKey(r);
1808
+ const next = ordered.slice(i + 1).find((n) => n.tunnel === r.tunnel && n.path === target && clientKey(n) === key && n.startedAt - r.startedAt <= FLOW_WINDOW_MS && links.get(n.id)?.prev === void 0);
1809
+ if (!next) return;
1810
+ link(r.id, { next: next.id });
1811
+ link(next.id, { prev: r.id });
1812
+ });
1813
+ return links;
1814
+ }
1815
+ function flowChain(id, links) {
1816
+ let first3 = id;
1817
+ const seen = /* @__PURE__ */ new Set([id]);
1818
+ for (let p = links.get(first3)?.prev; p !== void 0 && !seen.has(p); p = links.get(first3)?.prev) {
1819
+ seen.add(p);
1820
+ first3 = p;
1821
+ }
1822
+ const chain = [first3];
1823
+ for (let n = links.get(first3)?.next; n !== void 0 && !chain.includes(n); n = links.get(n)?.next) chain.push(n);
1824
+ return chain;
1825
+ }
1826
+
1827
+ // src/inspector/har.ts
1828
+ var HTTP = "HTTP/1.1";
1829
+ var HAR_MASK = "\u2022\u2022\u2022 (duto.yml)";
1830
+ function maskedNames(r) {
1831
+ return new Set((r.headerChanges ?? []).filter((c) => c.source === "headers" && c.kind !== "remove").map((c) => c.name));
1832
+ }
1833
+ var masked = (list, names) => list.map((h) => names.has(h.name.toLowerCase()) ? { ...h, value: HAR_MASK } : h);
1834
+ var headerList = (h) => Object.entries(h).map(([name, value]) => ({ name, value }));
1835
+ function harCookie(line) {
1836
+ const c = parseSetCookie(line);
1837
+ return {
1838
+ name: c.name,
1839
+ value: c.value,
1840
+ ...typeof c.attrs.path === "string" && { path: c.attrs.path },
1841
+ ...typeof c.attrs.domain === "string" && { domain: c.attrs.domain },
1842
+ ...c.attrs.httponly !== void 0 && { httpOnly: true },
1843
+ ...c.attrs.secure !== void 0 && { secure: true }
1844
+ };
1845
+ }
1846
+ function entry(r, publicUrl) {
1847
+ const url = new URL(r.path, publicUrl ?? "http://localhost");
1848
+ const time = r.durationMs ?? 0;
1849
+ const wait = Math.min(r.ttfbMs ?? time, time);
1850
+ const reqType = r.reqHeaders["content-type"] ?? "";
1851
+ const secret = maskedNames(r);
1852
+ const cookies = parseCookieHeader(r.reqHeaders.cookie ?? "");
1853
+ return {
1854
+ startedDateTime: new Date(r.startedAt).toISOString(),
1855
+ time,
1856
+ request: {
1857
+ method: r.websocket ? "GET" : r.method,
1858
+ url: url.toString(),
1859
+ httpVersion: HTTP,
1860
+ headers: masked(headerList(r.reqHeaders), secret),
1861
+ queryString: [...url.searchParams].map(([name, value]) => ({ name, value })),
1862
+ cookies: secret.has("cookie") ? cookies.map((c) => ({ ...c, value: HAR_MASK })) : cookies,
1863
+ headersSize: -1,
1864
+ bodySize: r.reqBodyBytes,
1865
+ ...r.reqBody !== null && { postData: { mimeType: reqType, text: r.reqBody } }
1866
+ },
1867
+ response: {
1868
+ status: r.status ?? 0,
1869
+ statusText: "",
1870
+ httpVersion: HTTP,
1871
+ headers: headerList(r.resHeaders),
1872
+ cookies: (r.resSetCookies ?? []).map(harCookie),
1873
+ content: { size: r.bytes, mimeType: r.resHeaders["content-type"] ?? "", ...r.resBody !== null && { text: r.resBody } },
1874
+ redirectURL: r.resHeaders.location ?? "",
1875
+ headersSize: -1,
1876
+ bodySize: r.bytes
1877
+ },
1878
+ cache: {},
1879
+ timings: { send: 0, wait, receive: time - wait }
1880
+ };
1881
+ }
1882
+ function toHar(records, publicUrlOf, version) {
1883
+ const entries = [...records].sort((a, b) => a.id - b.id).map((r) => entry(r, publicUrlOf(r.tunnel)));
1884
+ return { log: { version: "1.2", creator: { name: "duto", version }, entries } };
1885
+ }
1886
+
1343
1887
  // src/inspector/server.ts
1344
1888
  var DEFAULT_PORT = 4141;
1345
1889
  var PORT_TRIES = 20;
@@ -1350,7 +1894,30 @@ var CURL_BYTES = "The curl body may differ from the one received (non-UTF-8 byte
1350
1894
  var sha = (s) => createHash2("sha256").update(s).digest();
1351
1895
  var constantTimeEqual = (a, b) => timingSafeEqual(sha(a), sha(b));
1352
1896
  var first = (v) => Array.isArray(v) ? v[0] : v;
1353
- function listItem(r) {
1897
+ function hostOfUrl(url) {
1898
+ if (!url) return null;
1899
+ try {
1900
+ return new URL(url).hostname.toLowerCase();
1901
+ } catch {
1902
+ return null;
1903
+ }
1904
+ }
1905
+ function viewOf(session) {
1906
+ const tunnels = session.getState().tunnels;
1907
+ const find = (key) => tunnels.find((t) => t.key === key);
1908
+ const urlOf = (key) => find(key)?.url ?? null;
1909
+ const hostOf2 = (key) => hostOfUrl(urlOf(key));
1910
+ return {
1911
+ urlOf,
1912
+ context: (key) => ({ publicHost: hostOf2(key), port: find(key)?.port ?? null }),
1913
+ links: linkFlows(session.records.list(), hostOf2)
1914
+ };
1915
+ }
1916
+ function isRedirect(r) {
1917
+ return r.status !== null && r.status >= 300 && r.status < 400 && !!r.resHeaders.location;
1918
+ }
1919
+ function listItem(r, view) {
1920
+ const link = view.links.get(r.id);
1354
1921
  return {
1355
1922
  id: r.id,
1356
1923
  tunnel: r.tunnel,
@@ -1361,12 +1928,35 @@ function listItem(r) {
1361
1928
  bytes: r.bytes,
1362
1929
  startedAt: r.startedAt,
1363
1930
  websocket: r.websocket,
1931
+ origin: originOf(r),
1932
+ issues: diagnose(r, view.context(r.tunnel)).length,
1933
+ client: clientOf(r),
1934
+ ...r.ttfbMs !== void 0 && { ttfbMs: r.ttfbMs },
1935
+ ...isRedirect(r) && { location: r.resHeaders.location },
1936
+ ...link?.prev !== void 0 && { prev: link.prev },
1937
+ ...link?.next !== void 0 && { next: link.next },
1364
1938
  ...r.replayOf !== void 0 && { replayOf: r.replayOf },
1365
1939
  ...r.error !== void 0 && { error: r.error },
1366
- ...r.redirect !== void 0 && { rewritten: true }
1940
+ ...r.redirect !== void 0 && { rewritten: true },
1941
+ ...r.access !== void 0 && { access: r.access }
1367
1942
  };
1368
1943
  }
1369
- function detail(r, session) {
1944
+ function queryOf(path) {
1945
+ try {
1946
+ return [...new URL(path, "http://x").searchParams].map(([name, value]) => ({ name, value }));
1947
+ } catch {
1948
+ return [];
1949
+ }
1950
+ }
1951
+ function flowOf(r, session, view) {
1952
+ const chain = flowChain(r.id, view.links);
1953
+ if (chain.length < 2) return [];
1954
+ return chain.flatMap((id) => {
1955
+ const step = session.records.get(id);
1956
+ return step ? [{ id, method: step.websocket ? "WS" : step.method, path: step.path, status: step.status }] : [];
1957
+ });
1958
+ }
1959
+ function detail(r, session, view) {
1370
1960
  const tunnel2 = session.getState().tunnels.find((t) => t.key === r.tunnel);
1371
1961
  const tunnelOpen = !!tunnel2 && (tunnel2.status === "live" || tunnel2.status === "no-listener");
1372
1962
  let curl = null;
@@ -1377,9 +1967,15 @@ function detail(r, session) {
1377
1967
  if (r.reqRaw && !Buffer.from(r.reqBody ?? "", "utf8").equals(r.reqRaw)) curlNote = CURL_BYTES;
1378
1968
  }
1379
1969
  return {
1380
- ...listItem(r),
1970
+ ...listItem(r, view),
1381
1971
  edge: edgeOf(r.reqHeaders),
1382
1972
  redirect: r.redirect ?? null,
1973
+ changes: (r.headerChanges ?? []).map(describeChange),
1974
+ issues: diagnose(r, view.context(r.tunnel)),
1975
+ client: { ip: clientOf(r), country: r.reqHeaders["cf-ipcountry"] ?? null, userAgent: r.reqHeaders["user-agent"] ?? null },
1976
+ query: queryOf(r.path),
1977
+ cookies: { sent: parseCookieHeader(r.reqHeaders.cookie ?? ""), set: (r.resSetCookies ?? []).map(parseSetCookie) },
1978
+ flow: flowOf(r, session, view),
1383
1979
  reqHeaders: r.reqHeaders,
1384
1980
  resHeaders: r.resHeaders,
1385
1981
  reqBody: r.reqBody,
@@ -1434,7 +2030,7 @@ data: ${JSON.stringify({ ids })}
1434
2030
  res.setHeader("Referrer-Policy", "no-referrer");
1435
2031
  res.setHeader("X-Frame-Options", "DENY");
1436
2032
  };
1437
- const text = (res, status, body) => {
2033
+ const text2 = (res, status, body) => {
1438
2034
  res.writeHead(status, { "Content-Type": "text/plain; charset=utf-8" });
1439
2035
  res.end(body);
1440
2036
  };
@@ -1445,19 +2041,19 @@ data: ${JSON.stringify({ ids })}
1445
2041
  const handle = (req, res) => {
1446
2042
  base(res);
1447
2043
  const host = first(req.headers.host)?.toLowerCase();
1448
- if (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`) return text(res, 403, "Host not allowed.");
2044
+ if (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`) return text2(res, 403, "Host not allowed.");
1449
2045
  let url2;
1450
2046
  try {
1451
2047
  url2 = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
1452
2048
  } catch {
1453
- return text(res, 404, "Not found.");
2049
+ return text2(res, 404, "Not found.");
1454
2050
  }
1455
2051
  const path = url2.pathname;
1456
2052
  const method = req.method ?? "GET";
1457
2053
  const queryToken = url2.searchParams.get("t") ?? "";
1458
2054
  const headerToken = first(req.headers["x-duto-inspector"]) ?? "";
1459
2055
  if (method === "GET" && path === "/") {
1460
- if (!constantTimeEqual(queryToken, token)) return text(res, 403, NO_TOKEN);
2056
+ if (!constantTimeEqual(queryToken, token)) return text2(res, 403, NO_TOKEN);
1461
2057
  const nonce = randomBytes(16).toString("base64url");
1462
2058
  res.writeHead(200, {
1463
2059
  "Content-Type": "text/html; charset=utf-8",
@@ -1467,8 +2063,8 @@ data: ${JSON.stringify({ ids })}
1467
2063
  }
1468
2064
  const isEvents = method === "GET" && path === "/api/events";
1469
2065
  const detailId = /^\/api\/requests\/([1-9]\d{0,15})(\/replay)?$/.exec(path);
1470
- const known = method === "GET" && (path === "/api/session" || path === "/api/requests" || detailId && !detailId[2]) || isEvents || method === "POST" && detailId && detailId[2];
1471
- if (!known) return text(res, 404, "Not found.");
2066
+ const known = method === "GET" && (path === "/api/session" || path === "/api/requests" || path === "/api/har" || detailId && !detailId[2]) || isEvents || method === "POST" && detailId && detailId[2];
2067
+ if (!known) return text2(res, 404, "Not found.");
1472
2068
  if (!constantTimeEqual(headerToken, token) && !(isEvents && constantTimeEqual(queryToken, token))) return json(res, 403, { error: NO_TOKEN });
1473
2069
  if (isEvents) {
1474
2070
  res.writeHead(200, { "Content-Type": "text/event-stream; charset=utf-8", Connection: "keep-alive" });
@@ -1479,21 +2075,31 @@ data: ${JSON.stringify({ ids })}
1479
2075
  return;
1480
2076
  }
1481
2077
  if (path === "/api/session") {
1482
- return json(res, 200, { tunnels: session.getState().tunnels.map((t) => ({ key: t.key, url: t.url, auth: !!t.auth })) });
2078
+ return json(res, 200, { tunnels: session.getState().tunnels.map((t) => ({ key: t.key, url: t.url, auth: !!t.auth, port: t.port })) });
2079
+ }
2080
+ if (path === "/api/requests") {
2081
+ const view = viewOf(session);
2082
+ return json(res, 200, { items: session.records.list().map((r2) => listItem(r2, view)) });
2083
+ }
2084
+ if (path === "/api/har") {
2085
+ const wanted = url2.searchParams.get("ids");
2086
+ const ids = wanted === null ? null : new Set(wanted.split(",").map(Number));
2087
+ const records = session.records.list().filter((r2) => !ids || ids.has(r2.id));
2088
+ res.setHeader("Content-Disposition", 'attachment; filename="duto.har"');
2089
+ return json(res, 200, toHar(records, viewOf(session).urlOf, CLI_VERSION));
1483
2090
  }
1484
- if (path === "/api/requests") return json(res, 200, { items: session.records.list().map(listItem) });
1485
2091
  const id = Number(detailId[1]);
1486
2092
  if (!Number.isSafeInteger(id)) return json(res, 404, { error: GONE });
1487
2093
  if (method === "POST") return json(res, 200, session.replay(id));
1488
2094
  const r = session.records.get(id);
1489
2095
  if (!r) return json(res, 404, { error: GONE });
1490
- return json(res, 200, detail(r, session));
2096
+ return json(res, 200, detail(r, session, viewOf(session)));
1491
2097
  };
1492
2098
  const server = createServer((req, res) => {
1493
2099
  try {
1494
2100
  handle(req, res);
1495
2101
  } catch {
1496
- if (!res.headersSent) text(res, 500, "Internal error.");
2102
+ if (!res.headersSent) text2(res, 500, "Internal error.");
1497
2103
  else res.end();
1498
2104
  }
1499
2105
  });
@@ -1710,6 +2316,62 @@ function cors401(origin) {
1710
2316
  import { Agent, createServer as createServer2, request } from "http";
1711
2317
  import { connect as connect2 } from "net";
1712
2318
 
2319
+ // src/session/delivery.ts
2320
+ function text(h, name) {
2321
+ if (!Object.hasOwn(h, name)) return void 0;
2322
+ const v = h[name];
2323
+ return Array.isArray(v) ? v.join(", ") : v;
2324
+ }
2325
+ function dropUntrustedForwarded(out, changes) {
2326
+ for (const name of UNTRUSTED_VISITOR_HEADERS) {
2327
+ const from = text(out, name);
2328
+ if (from === void 0) continue;
2329
+ delete out[name];
2330
+ changes.push({ kind: "remove", name, from, source: "visitor" });
2331
+ }
2332
+ }
2333
+ function swapHost(out, host, changes) {
2334
+ changes.push({ kind: "replace", name: "host", from: out.host ?? "", to: host, source: "host" });
2335
+ out.host = host;
2336
+ const forwarded = text(out, "x-forwarded-host");
2337
+ if (forwarded === void 0) return;
2338
+ delete out["x-forwarded-host"];
2339
+ changes.push({ kind: "remove", name: "x-forwarded-host", from: forwarded, source: "host" });
2340
+ }
2341
+ function applyHeaders(out, headers, changes) {
2342
+ for (const [name, to] of Object.entries(headers)) {
2343
+ const from = text(out, name);
2344
+ if (to === "") {
2345
+ if (from === void 0) continue;
2346
+ delete out[name];
2347
+ changes.push({ kind: "remove", name, from, source: "headers" });
2348
+ } else {
2349
+ out[name] = to;
2350
+ changes.push(from === void 0 ? { kind: "add", name, to, source: "headers" } : { kind: "replace", name, from, to, source: "headers" });
2351
+ }
2352
+ }
2353
+ }
2354
+ function stripDutoCredentials(out, stripped, changes) {
2355
+ for (const name of ["x-duto-auth", "authorization"]) {
2356
+ const from = text(out, name);
2357
+ if (from !== void 0 && text(stripped, name) === void 0) changes.push({ kind: "remove", name, from, source: "duto" });
2358
+ }
2359
+ if (text(out, "cookie") !== text(stripped, "cookie")) changes.push({ kind: "remove", name: "cookie", from: TUNNEL_COOKIE, source: "duto" });
2360
+ }
2361
+ function deliver(h, route) {
2362
+ const changes = [];
2363
+ let out = { ...h };
2364
+ if (!route.trustForwarded) dropUntrustedForwarded(out, changes);
2365
+ if (route.host) swapHost(out, route.host, changes);
2366
+ if (route.headers) applyHeaders(out, route.headers, changes);
2367
+ if (route.auth) {
2368
+ const stripped = route.auth.strip(out);
2369
+ stripDutoCredentials(out, stripped, changes);
2370
+ out = stripped;
2371
+ }
2372
+ return changes.length ? { headers: out, changes } : { headers: h, changes };
2373
+ }
2374
+
1713
2375
  // src/session/errorPage.ts
1714
2376
  var esc = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c]);
1715
2377
  function wantsHtml(accept) {
@@ -1818,6 +2480,7 @@ var PREFLIGHT_NO_REPLAY = "CORS preflight cannot be replayed.";
1818
2480
  var TEXT2 = /^(text\/|application\/(json|xml|javascript|x-www-form-urlencoded|graphql)|[^;]*\+(json|xml))/i;
1819
2481
  var MAX_BODY = 64 * 1024;
1820
2482
  var LOCAL_AGENT = new Agent({ keepAlive: false });
2483
+ var errorText = (err) => err instanceof Error ? err.message || err.code || err.name : String(err);
1821
2484
  function flat(h) {
1822
2485
  const out = {};
1823
2486
  for (const [k, v] of Object.entries(h)) if (v !== void 0) out[k] = Array.isArray(v) ? v.join(", ") : v;
@@ -1876,19 +2539,6 @@ async function localHost(port) {
1876
2539
  hostCache.set(port, { host, at: Date.now() });
1877
2540
  return host;
1878
2541
  }
1879
- function rewrite(h, route) {
1880
- if (!route.host && !route.headers) return h;
1881
- const out = { ...h };
1882
- if (route.host) {
1883
- out.host = route.host;
1884
- delete out["x-forwarded-host"];
1885
- }
1886
- for (const [k, v] of Object.entries(route.headers ?? {})) {
1887
- if (v === "") delete out[k];
1888
- else out[k] = v;
1889
- }
1890
- return out;
1891
- }
1892
2542
  var escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1893
2543
  function rewriteLocation(location, localHost2, publicHost) {
1894
2544
  const host = escapeRe(localHost2.replace(/:\d+$/, ""));
@@ -1977,10 +2627,6 @@ function deny(req, res, html) {
1977
2627
  res.end(body);
1978
2628
  return { status, out, body };
1979
2629
  }
1980
- function delivered(h, route) {
1981
- const out = rewrite(h, route);
1982
- return route.auth ? route.auth.strip(out) : out;
1983
- }
1984
2630
  async function isUp(port) {
1985
2631
  return await probe("127.0.0.1", port) || await probe("::1", port);
1986
2632
  }
@@ -2022,7 +2668,7 @@ async function startProxy(o) {
2022
2668
  await new Promise((r) => setTimeout(r, 50));
2023
2669
  }
2024
2670
  }
2025
- function record(req, route, headers, websocket, access, noReplay) {
2671
+ function record(req, route, headers, changes, websocket, access, noReplay) {
2026
2672
  const id = nextId++;
2027
2673
  const started = Date.now();
2028
2674
  o.store.add({
@@ -2046,12 +2692,15 @@ async function startProxy(o) {
2046
2692
  truncated: false,
2047
2693
  resTruncated: false,
2048
2694
  ...access && { access },
2049
- ...noReplay && { noReplay }
2695
+ ...noReplay && { noReplay },
2696
+ ...changes.length && { headerChanges: changes }
2050
2697
  });
2051
2698
  return { id, started };
2052
2699
  }
2053
2700
  function trackResponse(id, started, up, reqCap) {
2054
2701
  const resCap = new Capture(TEXT2.test(up.headers["content-type"] ?? ""), max);
2702
+ const ttfbMs = Date.now() - started;
2703
+ const setCookies = up.headers["set-cookie"];
2055
2704
  let bytes = 0;
2056
2705
  up.on("data", (c) => {
2057
2706
  bytes += c.length;
@@ -2062,8 +2711,10 @@ async function startProxy(o) {
2062
2711
  () => o.store.update(id, {
2063
2712
  status: up.statusCode ?? null,
2064
2713
  durationMs: Date.now() - started,
2714
+ ttfbMs,
2065
2715
  bytes,
2066
2716
  resHeaders: flat(up.headers),
2717
+ ...setCookies?.length && { resSetCookies: setCookies },
2067
2718
  resBody: resCap.text(),
2068
2719
  truncated: !!reqCap?.truncated || resCap.truncated,
2069
2720
  resTruncated: resCap.truncated,
@@ -2100,11 +2751,18 @@ async function startProxy(o) {
2100
2751
  reqComplete: true,
2101
2752
  truncated: false,
2102
2753
  resTruncated: false,
2103
- replayOf: r.id
2754
+ replayOf: r.id,
2755
+ ...r.headerChanges && { headerChanges: r.headerChanges }
2104
2756
  });
2105
2757
  void (async () => {
2106
2758
  const host = await localHost(route.port);
2107
- const upstream = request({ host, port: route.port, agent: LOCAL_AGENT, method: r.method, path: r.path, headers }, (up) => trackResponse(newId, started, up));
2759
+ let upstream;
2760
+ try {
2761
+ upstream = request({ host, port: route.port, agent: LOCAL_AGENT, method: r.method, path: r.path, headers }, (up) => trackResponse(newId, started, up));
2762
+ } catch (err) {
2763
+ o.store.update(newId, { status: 502, durationMs: Date.now() - started, error: errorText(err) });
2764
+ return;
2765
+ }
2108
2766
  upstream.on("error", (err) => o.store.update(newId, { status: 503, durationMs: Date.now() - started, error: err.message || err.code || err.name }));
2109
2767
  upstream.end(r.reqRaw ?? void 0);
2110
2768
  })();
@@ -2130,42 +2788,55 @@ async function startProxy(o) {
2130
2788
  }
2131
2789
  const preflight = !!route.auth && isPreflight(req.method, req.headers);
2132
2790
  const access = route.auth && !preflight ? route.auth.access(req.headers) : void 0;
2133
- const headers = delivered(req.headers, route);
2791
+ const { headers, changes } = deliver(req.headers, route);
2134
2792
  if (access?.kind === "denied") {
2135
2793
  const html = wantsHtml(req.headers.accept);
2136
- const { id: id2, started: started2 } = record(req, route, headers, false, html ? void 0 : access, DENIED_NO_REPLAY);
2794
+ const { id: id2, started: started2 } = record(req, route, headers, changes, false, html ? void 0 : access, DENIED_NO_REPLAY);
2137
2795
  const { status, out, body } = deny(req, res, html);
2138
2796
  o.store.update(id2, { status, durationMs: Date.now() - started2, bytes: Buffer.byteLength(body), resHeaders: out });
2139
2797
  return;
2140
2798
  }
2141
- const { id, started } = record(req, route, headers, false, access, preflight ? PREFLIGHT_NO_REPLAY : void 0);
2799
+ const { id, started } = record(req, route, headers, changes, false, access, preflight ? PREFLIGHT_NO_REPLAY : void 0);
2142
2800
  const reqCap = new Capture(TEXT2.test(req.headers["content-type"] ?? ""), max);
2801
+ const recordArrival = () => o.store.update(id, { reqComplete: true, reqRaw: reqCap.truncated ? null : reqCap.raw(), reqBodyBytes: reqCap.total, reqTruncated: reqCap.truncated, reqBody: reqCap.text() });
2143
2802
  req.pause();
2144
2803
  const host = await localHost(route.port);
2145
- const upstream = request(
2146
- { host, port: route.port, agent: LOCAL_AGENT, method: req.method, path: req.url, headers },
2147
- (up) => {
2148
- if (preflight) {
2149
- const out = preflightHeaders(up.headers);
2150
- res.writeHead(up.statusCode ?? 502, out);
2151
- res.end();
2152
- up.on("end", () => o.store.update(id, { status: up.statusCode ?? null, durationMs: Date.now() - started, bytes: 0, resHeaders: flat(out) }));
2153
- up.resume();
2154
- return;
2155
- }
2156
- if (route.host && route.rewriteRedirects !== false && typeof up.headers.location === "string") {
2157
- const from = up.headers.location;
2158
- const to = rewriteLocation(from, route.host, hostOf(req));
2159
- if (to !== from) {
2160
- up.headers.location = to;
2161
- o.store.update(id, { redirect: { from, to } });
2804
+ let upstream;
2805
+ try {
2806
+ upstream = request(
2807
+ { host, port: route.port, agent: LOCAL_AGENT, method: req.method, path: req.url, headers },
2808
+ (up) => {
2809
+ if (preflight) {
2810
+ const out = preflightHeaders(up.headers);
2811
+ res.writeHead(up.statusCode ?? 502, out);
2812
+ res.end();
2813
+ up.on("end", () => o.store.update(id, { status: up.statusCode ?? null, durationMs: Date.now() - started, bytes: 0, resHeaders: flat(out) }));
2814
+ up.resume();
2815
+ return;
2816
+ }
2817
+ if (route.host && route.rewriteRedirects !== false && typeof up.headers.location === "string") {
2818
+ const from = up.headers.location;
2819
+ const to = rewriteLocation(from, route.host, hostOf(req));
2820
+ if (to !== from) {
2821
+ up.headers.location = to;
2822
+ o.store.update(id, { redirect: { from, to } });
2823
+ }
2162
2824
  }
2825
+ res.writeHead(up.statusCode ?? 502, up.statusMessage, up.headers);
2826
+ trackResponse(id, started, up, reqCap);
2827
+ up.pipe(res);
2163
2828
  }
2164
- res.writeHead(up.statusCode ?? 502, up.statusMessage, up.headers);
2165
- trackResponse(id, started, up, reqCap);
2166
- up.pipe(res);
2167
- }
2168
- );
2829
+ );
2830
+ } catch (err) {
2831
+ req.on("data", (c) => reqCap.push(c));
2832
+ req.on("end", recordArrival);
2833
+ req.resume();
2834
+ const msg = `Duto could not forward this request: ${errorText(err)}`;
2835
+ res.writeHead(502, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" });
2836
+ res.end(req.method === "HEAD" ? void 0 : msg);
2837
+ o.store.update(id, { status: 502, durationMs: Date.now() - started, bytes: Buffer.byteLength(msg), error: errorText(err) });
2838
+ return;
2839
+ }
2169
2840
  res.on("close", () => {
2170
2841
  if (!res.writableFinished) upstream.destroy();
2171
2842
  });
@@ -2180,7 +2851,7 @@ async function startProxy(o) {
2180
2851
  } else res.destroy();
2181
2852
  o.store.update(id, { status: 503, durationMs: Date.now() - started, bytes: res.headersSent ? 0 : Buffer.byteLength(msg), error: err.message || err.code || err.name, reqBody: reqCap.text() });
2182
2853
  });
2183
- req.on("end", () => o.store.update(id, { reqComplete: true, reqRaw: reqCap.truncated ? null : reqCap.raw(), reqBodyBytes: reqCap.total, reqTruncated: reqCap.truncated, reqBody: reqCap.text() }));
2854
+ req.on("end", recordArrival);
2184
2855
  if (preflight) {
2185
2856
  req.resume();
2186
2857
  upstream.end();
@@ -2194,8 +2865,8 @@ async function startProxy(o) {
2194
2865
  const route = await routeFor(req);
2195
2866
  if (!route) return void client.end("HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n");
2196
2867
  const access = route.auth?.access(req.headers);
2197
- const headers = delivered(req.headers, route);
2198
- const { id, started } = record(req, route, headers, true, access, access?.kind === "denied" ? DENIED_NO_REPLAY : void 0);
2868
+ const { headers, changes } = deliver(req.headers, route);
2869
+ const { id, started } = record(req, route, headers, changes, true, access, access?.kind === "denied" ? DENIED_NO_REPLAY : void 0);
2199
2870
  if (access?.kind === "denied") {
2200
2871
  client.end(WS_DENIED);
2201
2872
  o.store.update(id, { status: 401, durationMs: Date.now() - started });
@@ -2453,7 +3124,7 @@ var Session = class {
2453
3124
  }
2454
3125
  blank(spec, adHoc) {
2455
3126
  this.specs.set(keyOf(spec), spec);
2456
- return { key: keyOf(spec), name: spec.name, port: spec.port, ...spec.host ? { host: spec.host } : {}, ...spec.headers ? { headers: spec.headers } : {}, ...spec.rewriteRedirects === false ? { rewriteRedirects: false } : {}, ...spec.auth?.length ? { auth: true } : {}, url: null, hostname: null, endpointId: null, status: "opening", error: null, adHoc, requests: 0, errors: 0 };
3127
+ return { key: keyOf(spec), name: spec.name, port: spec.port, ...spec.host ? { host: spec.host } : {}, ...spec.headers ? { headers: spec.headers } : {}, ...spec.rewriteRedirects === false ? { rewriteRedirects: false } : {}, ...spec.trustForwarded ? { trustForwarded: true } : {}, ...spec.auth?.length ? { auth: true } : {}, url: null, hostname: null, endpointId: null, status: "opening", error: null, adHoc, requests: 0, errors: 0 };
2457
3128
  }
2458
3129
  async open(t, existing, retrying = false) {
2459
3130
  if (this.stopped) return;
@@ -2482,7 +3153,7 @@ var Session = class {
2482
3153
  t.hostname = res.hostname;
2483
3154
  t.endpointId = res.endpointId;
2484
3155
  t.error = null;
2485
- this.routes.set(res.hostname.toLowerCase(), { tunnel: t.key, port: t.port, host: t.host, headers: t.headers, rewriteRedirects: t.rewriteRedirects, auth });
3156
+ this.routes.set(res.hostname.toLowerCase(), { tunnel: t.key, port: t.port, host: t.host, headers: t.headers, rewriteRedirects: t.rewriteRedirects, trustForwarded: t.trustForwarded, auth });
2486
3157
  t.status = await isPortListening(t.port) ? "live" : "no-listener";
2487
3158
  this.ensureCloudflared(res.tunnelToken);
2488
3159
  this.emit();
@@ -2664,8 +3335,8 @@ function listFlag(raw) {
2664
3335
  }
2665
3336
  function parseAuthFlags(raw) {
2666
3337
  const out = [];
2667
- for (const entry of listFlag(raw)) {
2668
- const r = parseCredential(entry);
3338
+ for (const entry2 of listFlag(raw)) {
3339
+ const r = parseCredential(entry2);
2669
3340
  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");
2670
3341
  if (out.some((c) => c.user === r.cred.user)) fail2(`Duplicate user "${r.cred.user}"`);
2671
3342
  out.push(r.cred);
@@ -2702,15 +3373,17 @@ async function runSession(specs, source, flags) {
2702
3373
  inspectorError = err instanceof Error ? err.message : String(err);
2703
3374
  }
2704
3375
  }
3376
+ const warnings = credentialWarnings(specs);
2705
3377
  if (wantsUi(flags)) {
2706
- const { runFullscreen } = await import("./fullscreen-MZMQ6ADN.js");
2707
- const code = await runFullscreen(session, specs, CLI_VERSION, inspector, inspectorError, update);
3378
+ const { runFullscreen } = await import("./fullscreen-MSVJF43W.js");
3379
+ const code = await runFullscreen(session, specs, CLI_VERSION, inspector, inspectorError, update, warnings);
2708
3380
  await inspector?.close().catch(() => void 0);
2709
3381
  process.exit(code);
2710
3382
  }
2711
3383
  console.log(`${mark()} ${paint("muted", `${CLI_VERSION} \xB7 ${source} \xB7 Ctrl+C to stop`)}`);
2712
3384
  if (inspector) console.log(paint("muted", `inspector: ${inspector.openUrl}`));
2713
3385
  if (inspectorError) console.log(warn(`inspector failed to start: ${inspectorError}`));
3386
+ for (const w of warnings) console.log(warn(w));
2714
3387
  void update.then((latest) => latest && console.log(warn(updateLine(latest))));
2715
3388
  const stopLog = runPlain(session, (l) => console.log(l));
2716
3389
  const stopped = new Promise((resolve) => {