@dutodev/cli 1.3.0 → 1.5.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/README.md CHANGED
@@ -162,6 +162,11 @@ Outside an interactive terminal (pipe, CI) or with `--no-ui`, the CLI prints a p
162
162
 
163
163
  With every session, the CLI starts a local inspector: a browser page with requests arriving live, formatted JSON, and buttons to copy and replay. It is more comfortable than the terminal for large bodies.
164
164
 
165
+ - **Overview.** Each request shows who answered (your app or duto), total time and time to first byte, the data center, the client, the query, the cookies sent and set, and the redirect target.
166
+ - **Issues.** The inspector flags what usually breaks a tunnel: nothing listening on the port, a redirect to `localhost` or down to `http`, a cookie the browser drops (`Domain` mismatch, `SameSite=None` without `Secure`), a CORS response or preflight the browser blocks, and response bodies that link to `localhost`. Each one says what to change.
167
+ - **Flows.** A redirect is linked to the request the same browser made next, so a login or OAuth round trip reads as one chain you can step through.
168
+ - **Filters.** Type terms like `status:4xx`, `status:>=500`, `method:post`, `path:/api`, `is:redirect`, `is:issue`, `is:duto` or `duration:>500ms`. Start a term with `-` to exclude it. The buttons under the filter (Errors, Redirects, Issues, Slow) apply the common ones.
169
+ - **Clear and export.** Clear hides what is already listed, so you can reproduce a flow from a clean list. Export HAR saves the listed requests for Chrome DevTools or any HAR viewer.
165
170
  - **How to open it.** In the full screen, key `i` opens the browser at the right address, and the screen shows `inspector http://127.0.0.1:<port>`. In plain mode (pipe, CI, `--no-ui`), the CLI prints `inspector: <url>?t=<token>`; copy that whole address.
166
171
  - **Port.** The default is `127.0.0.1:4141`. If it is taken, the CLI tries the next ones up to 4161 and then any free port. The inspector listens on `127.0.0.1` only: other machines on your network cannot reach it.
167
172
  - **Token.** The address carries a session token (`?t=...`). It exists so other sites open in your browser cannot read or replay your requests. That is why the full screen does not show the token; use key `i`.
@@ -76,6 +76,17 @@ function Detail({ record }) {
76
76
  record.replayOf
77
77
  ] }),
78
78
  record.error && /* @__PURE__ */ jsx(Text, { color: C.err, children: record.error }),
79
+ record.redirect && /* @__PURE__ */ jsxs(Box, { flexDirection: "column", children: [
80
+ /* @__PURE__ */ jsx(Text, { color: C.signal, children: "\u21AA Location rewritten" }),
81
+ /* @__PURE__ */ jsxs(Text, { color: C.muted, children: [
82
+ " ",
83
+ record.redirect.from
84
+ ] }),
85
+ /* @__PURE__ */ jsxs(Text, { children: [
86
+ " \u2192 ",
87
+ record.redirect.to
88
+ ] })
89
+ ] }),
79
90
  /* @__PURE__ */ jsx(Section, { title: "Request", headers: record.reqHeaders, body: record.reqBody, truncated: record.reqTruncated, note: bodyNote(record, "req") }),
80
91
  /* @__PURE__ */ jsx(Section, { title: "Response", headers: record.resHeaders, body: record.resBody, truncated: record.resTruncated, note: bodyNote(record, "res") }),
81
92
  /* @__PURE__ */ jsxs(Text, { color: C.muted, children: [
@@ -169,6 +180,7 @@ function QrView({ tunnel, link, now }) {
169
180
  // src/ui/RequestList.tsx
170
181
  import { Box as Box3, Text as Text4 } from "ink";
171
182
  import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
183
+ var REWRITTEN = " \u21AA rewritten";
172
184
  function accessLabel(a) {
173
185
  if (!a) return null;
174
186
  if (a.kind === "user") return { text: a.user, color: C.muted };
@@ -180,7 +192,8 @@ function RequestList(props) {
180
192
  const start = Math.max(0, Math.min(selected - Math.floor(height / 2), records.length - height));
181
193
  const visible = records.slice(start, start + height);
182
194
  const accessW = Math.max(0, ...visible.map((r) => (accessLabel(r.access)?.text.length ?? -1) + 1));
183
- const pathW = Math.max(10, width - 54 - accessW);
195
+ const redirectW = visible.some((r) => r.redirect) ? REWRITTEN.length : 0;
196
+ const pathW = Math.max(10, width - 54 - accessW - redirectW);
184
197
  const title = props.filter ? `REQUESTS \xB7 filter: ${props.filter} ` : "REQUESTS ";
185
198
  return /* @__PURE__ */ jsxs4(Box3, { flexDirection: "column", children: [
186
199
  /* @__PURE__ */ jsx4(Text4, { color: C.muted, children: title + "\u2500".repeat(Math.max(0, width - title.length)) }),
@@ -204,7 +217,8 @@ function RequestList(props) {
204
217
  /* @__PURE__ */ jsx4(Text4, { color: C.muted, children: tail.padStart(7) }),
205
218
  " ",
206
219
  /* @__PURE__ */ jsx4(Text4, { color: C.muted, children: r.websocket ? "" : formatBytes(r.bytes).padStart(8) }),
207
- acc ? /* @__PURE__ */ jsx4(Text4, { color: acc.color, children: ` ${acc.text}` }) : null
220
+ acc ? /* @__PURE__ */ jsx4(Text4, { color: acc.color, children: ` ${acc.text}` }) : null,
221
+ r.redirect ? /* @__PURE__ */ jsx4(Text4, { color: C.signal, children: REWRITTEN }) : null
208
222
  ] }, r.id);
209
223
  })
210
224
  ] });
package/dist/index.js CHANGED
@@ -50,7 +50,7 @@ import { join as join5, relative } from "path";
50
50
  import cac from "cac";
51
51
 
52
52
  // src/version.ts
53
- var CLI_VERSION = "1.3.0";
53
+ var CLI_VERSION = "1.5.0";
54
54
 
55
55
  // src/api.ts
56
56
  var DutoApiError = class extends Error {
@@ -401,9 +401,9 @@ function parseConfig(text, file = CONFIG_FILES[0], env = process.env) {
401
401
  throw fail3('declare the tunnels under "tunnels:", one per line (e.g. web: 3000)');
402
402
  }
403
403
  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]);
404
+ for (const pair2 of tunnels.items) {
405
+ const raw = isScalar(pair2.key) ? String(pair2.key.value) : "";
406
+ const line = lineAt(pair2.key?.range?.[0]);
407
407
  const name = normalizeName(raw);
408
408
  const v = validateName(name);
409
409
  if (!v.ok) {
@@ -412,11 +412,11 @@ function parseConfig(text, file = CONFIG_FILES[0], env = process.env) {
412
412
  line
413
413
  );
414
414
  }
415
- const port = portOf(pair.value);
415
+ const port = portOf(pair2.value);
416
416
  if (port === null) throw fail3(`invalid port for "${raw}" (use 1\u201365535)`, line);
417
417
  const spec = { name, port };
418
- if (isMap(pair.value)) {
419
- const body = pair.value;
418
+ if (isMap(pair2.value)) {
419
+ const body = pair2.value;
420
420
  for (const item of body.items) {
421
421
  const key = isScalar(item.key) ? String(item.key.value) : "";
422
422
  if (!TUNNEL_KEYS.includes(key)) throw fail3(`unknown key in "${raw}": ${key} (use port, host, headers, rewrite_redirects, auth or allow)`, line);
@@ -523,7 +523,7 @@ async function browserLogin(o) {
523
523
  const start = await api.startLogin({
524
524
  deviceId: o.deviceId,
525
525
  deviceName: hostname(),
526
- cliVersion: "1.3.0",
526
+ cliVersion: "1.5.0",
527
527
  nodeVersion: process.version,
528
528
  os: process.platform,
529
529
  arch: process.arch
@@ -615,6 +615,71 @@ function edgeLabel(edge) {
615
615
  return place ? "via " + edge.colo + " (" + place + ")" : "via " + edge.colo;
616
616
  }
617
617
 
618
+ // src/inspector/filter.ts
619
+ function matchesFilter(item, text) {
620
+ function compare(value, spec, unit) {
621
+ const m = /^(>=|<=|>|<|=)?(.+)$/.exec(spec);
622
+ if (!m) return null;
623
+ const limit = unit(m[2] ?? "");
624
+ if (limit === null) return null;
625
+ if (value === null) return false;
626
+ const op = m[1] || "=";
627
+ if (op === ">") return value > limit;
628
+ if (op === ">=") return value >= limit;
629
+ if (op === "<") return value < limit;
630
+ if (op === "<=") return value <= limit;
631
+ return value === limit;
632
+ }
633
+ function plainNumber(raw) {
634
+ return /^\d+$/.test(raw) ? Number(raw) : null;
635
+ }
636
+ function millis(raw) {
637
+ const m = /^(\d+(?:\.\d+)?)(ms|s)?$/.exec(raw);
638
+ if (!m) return null;
639
+ return m[2] === "s" ? Number(m[1]) * 1e3 : Number(m[1]);
640
+ }
641
+ function status(spec) {
642
+ const cls = /^([1-5])xx$/.exec(spec);
643
+ if (cls) return item.status !== null && Math.floor(item.status / 100) === Number(cls[1]);
644
+ return compare(item.status, spec, plainNumber);
645
+ }
646
+ function kind(spec) {
647
+ const s = item.status;
648
+ if (spec === "error") return s !== null && s >= 400 || !!item.error;
649
+ if (spec === "redirect") return s !== null && s >= 300 && s < 400;
650
+ if (spec === "rewritten") return !!item.rewritten;
651
+ if (spec === "replay") return item.replayOf !== void 0;
652
+ if (spec === "ws") return item.websocket;
653
+ if (spec === "pending") return s === null && !item.error;
654
+ if (spec === "issue") return (item.issues ?? 0) > 0;
655
+ if (spec === "duto") return item.origin === "duto";
656
+ if (spec === "app") return item.origin === "app";
657
+ return null;
658
+ }
659
+ function field(key, value) {
660
+ const v = value.toLowerCase();
661
+ if (key === "status") return status(v);
662
+ if (key === "is") return kind(v);
663
+ if (key === "duration") return compare(item.durationMs, v, millis);
664
+ if (key === "method") return (item.websocket ? "ws" : item.method.toLowerCase()) === v;
665
+ if (key === "tunnel") return item.tunnel.toLowerCase() === v;
666
+ if (key === "path") return item.path.toLowerCase().indexOf(v) >= 0;
667
+ if (key === "client") return (item.client ?? "").toLowerCase() === v;
668
+ return null;
669
+ }
670
+ const haystack = [item.tunnel, item.websocket ? "ws" : item.method, item.path, item.status === null ? "" : String(item.status), item.client ?? ""].join(" ").toLowerCase();
671
+ const terms = text.trim().split(/\s+/).filter(Boolean);
672
+ for (const raw of terms) {
673
+ const negate = raw.length > 1 && raw.charAt(0) === "-";
674
+ const term = negate ? raw.slice(1) : raw;
675
+ const sep = term.indexOf(":");
676
+ let hit = sep > 0 ? field(term.slice(0, sep).toLowerCase(), term.slice(sep + 1)) : null;
677
+ if (hit === null) hit = haystack.indexOf(term.toLowerCase()) >= 0;
678
+ if (hit === negate) return false;
679
+ }
680
+ return true;
681
+ }
682
+
618
683
  // src/inspector/jsonView.ts
619
684
  function countLabel(n, one, many) {
620
685
  return n + " " + (n === 1 ? one : many);
@@ -741,20 +806,45 @@ button { font: inherit; color: inherit; background: none; border: 0; padding: 0;
741
806
  .tunnels { display: flex; flex-wrap: wrap; gap: 4px 14px; justify-content: flex-end; min-width: 0; }
742
807
  .tunnels .k { color: ${C.strong}; }
743
808
  .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; }
809
+ .main { flex: 1; min-height: 0; display: grid; grid-template-columns: 400px 1fr; }
745
810
  .side { display: flex; flex-direction: column; min-height: 0; border-right: 1px solid ${LINE}; }
746
811
  .search { margin: 8px 12px; padding: 5px 8px; border: 1px solid ${INPUT_LINE}; border-radius: 5px; background: ${BG}; color: ${TEXT}; font: inherit; outline: none; }
747
812
  .search:focus { border-color: ${C.signal}; }
748
813
  .search::placeholder { color: ${C.muted}; }
749
814
  .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; }
815
+ .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; }
816
+ .list.multi .row { grid-template-columns: 14px 56px 56px minmax(0, 1fr) 34px 60px; }
752
817
  .row:hover { background: #141917; }
753
818
  .row.sel { background: ${SELECTED}; box-shadow: inset 2px 0 0 ${C.signal}; }
754
819
  .row span { overflow: hidden; text-overflow: ellipsis; }
755
820
  .row .dur { text-align: right; }
756
821
  .m { font-weight: 700; }
757
822
  .mut { color: ${C.muted}; }
823
+ .sig { color: ${C.signal}; }
824
+ .warn { color: ${C.warn}; }
825
+ .errc { color: ${C.err}; }
826
+ .row.inflow { box-shadow: inset 2px 0 0 ${C.muted}; }
827
+ .row.sel.inflow { box-shadow: inset 2px 0 0 ${C.signal}; }
828
+ .to { color: ${C.muted}; }
829
+ .chips { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 12px 8px; }
830
+ .chip { border: 1px solid ${INPUT_LINE}; border-radius: 999px; padding: 1px 8px; color: ${C.muted}; }
831
+ .chip:hover { color: ${TEXT}; }
832
+ .chip.on { border-color: ${C.signal}; color: ${C.signal}; }
833
+ .chip b { font-weight: 400; color: ${TEXT}; }
834
+ .sidebar-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 8px 12px; border-top: 1px solid ${LINE}; }
835
+ .help { padding: 0 12px 8px; color: ${C.muted}; display: grid; gap: 2px; }
836
+ .help code { color: ${TEXT}; font: inherit; }
837
+ .ov { padding: 4px 0 12px; }
838
+ .issue { margin: 8px 12px 0; padding: 6px 10px; border-left: 2px solid ${C.warn}; background: #16140f; }
839
+ .issue.error { border-left-color: ${C.err}; background: #1a1112; }
840
+ .issue.info { border-left-color: ${C.muted}; background: #141716; }
841
+ .issue b { display: block; color: ${C.strong}; font-weight: 600; }
842
+ .issue span { color: ${C.muted}; }
843
+ .flow { margin: 6px 12px 0; display: grid; gap: 2px; max-width: 760px; }
844
+ .step { display: grid; grid-template-columns: 40px 52px minmax(0, 1fr) 40px; gap: 8px; text-align: left; padding: 2px 6px; border-radius: 4px; }
845
+ .step:hover { background: #141917; }
846
+ .step.cur { background: ${SELECTED}; box-shadow: inset 2px 0 0 ${C.signal}; }
847
+ .step span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
758
848
  .empty { padding: 12px; color: ${C.muted}; }
759
849
  .detail { display: flex; flex-direction: column; min-height: 0; min-width: 0; }
760
850
  .pane { flex: 1; }
@@ -796,14 +886,20 @@ th { color: ${JSON_KEY}; font-weight: 400; white-space: nowrap; }
796
886
  var SCRIPT = String.raw`"use strict";
797
887
  var countLabel = (${countLabel.toString()});
798
888
  var edgeLabel = (${edgeLabel.toString()});
889
+ var matchesFilter = (${matchesFilter.toString()});
799
890
  var jsonSyntaxError = (${jsonSyntaxError.toString()});
800
891
  var jsonTokens = (${jsonTokens.toString()});
801
892
  var jsonIndent = (${jsonIndent.toString()});
802
893
  var COLORS = ${JSON.stringify(C)};
803
894
  var TOKEN = new URLSearchParams(location.search).get("t") || "";
804
- var TABS = ["Body", "Headers", "Response", "Raw"];
895
+ var CHIPS = [
896
+ { label: "Errors", query: "is:error" },
897
+ { label: "Redirects", query: "is:redirect" },
898
+ { label: "Issues", query: "is:issue" },
899
+ { label: "Slow", query: "duration:>1s" }
900
+ ];
805
901
 
806
- var state = { items: [], tunnels: [], selected: null, detail: null, detailKey: "", tab: "Body", ended: false };
902
+ var state = { items: [], tunnels: [], selected: null, detail: null, detailKey: "", tab: "Overview", ended: false, clearedAfter: 0 };
807
903
  var listSeq = 0, detailSeq = 0, msgTimer = null;
808
904
  var es = null, lost = false, replaying = false;
809
905
  function fail(e) { if (!state.ended) console.error(e); }
@@ -855,14 +951,60 @@ function header(h, name) {
855
951
  for (var k in h) if (k.toLowerCase() === name) return h[k];
856
952
  return "";
857
953
  }
954
+ function kept() {
955
+ return state.items.filter(function (it) { return it.id > state.clearedAfter; });
956
+ }
858
957
  function visible() {
859
- var f = $("filter").value.trim().toLowerCase();
860
- if (!f) return state.items;
861
- return state.items.filter(function (it) {
862
- return (it.tunnel + " " + (it.websocket ? "ws" : it.method) + " " + it.path + " " + (it.status === null || it.status === undefined ? "" : it.status)).toLowerCase().indexOf(f) >= 0;
958
+ var f = $("filter").value;
959
+ return kept().filter(function (it) { return matchesFilter(it, f); });
960
+ }
961
+ function setFilter(text) {
962
+ $("filter").value = text;
963
+ renderList();
964
+ }
965
+
966
+ function renderChips() {
967
+ var box = $("chips");
968
+ clear(box);
969
+ var current = $("filter").value.trim();
970
+ var items = kept();
971
+ CHIPS.forEach(function (c) {
972
+ var n = items.filter(function (it) { return matchesFilter(it, c.query); }).length;
973
+ var b = el("button", "chip" + (current === c.query ? " on" : ""));
974
+ b.type = "button";
975
+ b.title = c.query;
976
+ b.appendChild(document.createTextNode(c.label + " "));
977
+ b.appendChild(el("b", "", n));
978
+ b.addEventListener("click", function () { setFilter(current === c.query ? "" : c.query); });
979
+ box.appendChild(b);
863
980
  });
864
981
  }
865
982
 
983
+ function itemById(id) {
984
+ for (var i = 0; i < state.items.length; i++) if (state.items[i].id === id) return state.items[i];
985
+ return null;
986
+ }
987
+ function chainOf(id) {
988
+ var ids = {};
989
+ var start = itemById(id);
990
+ if (!start || (start.prev === undefined && start.next === undefined)) return ids;
991
+ var cur = start, guard = 0;
992
+ while (cur && cur.prev !== undefined && guard++ < 50) cur = itemById(cur.prev);
993
+ guard = 0;
994
+ while (cur && guard++ < 50) {
995
+ ids[cur.id] = true;
996
+ cur = cur.next === undefined ? null : itemById(cur.next);
997
+ }
998
+ return ids;
999
+ }
1000
+
1001
+ function markOf(it) {
1002
+ if (it.issues > 0) return { text: "!", cls: it.origin === "duto" || it.error ? "errc" : "warn", title: it.issues === 1 ? "1 issue" : it.issues + " issues" };
1003
+ if (it.replayOf !== undefined) return { text: "↻", cls: "mut", title: "replay of #" + it.replayOf };
1004
+ if (it.rewritten) return { text: "↪", cls: "sig", title: "Location rewritten" };
1005
+ return { text: "", cls: "mut", title: "" };
1006
+ }
1007
+
866
1008
  function renderTop() {
867
1009
  var n = state.items.length;
868
1010
  $("count").textContent = countLabel(n, "request", "requests");
@@ -881,21 +1023,29 @@ function renderList() {
881
1023
  var multi = state.tunnels.length > 1;
882
1024
  list.classList.toggle("multi", multi);
883
1025
  clear(list);
1026
+ renderChips();
1027
+ $("clear").textContent = state.clearedAfter > 0 ? "Show all" : "Clear";
884
1028
  var items = visible();
885
1029
  if (items.length === 0) {
886
- list.appendChild(el("div", "empty", state.items.length === 0 ? "No requests yet." : "No requests match the filter."));
1030
+ var none = state.items.length === 0 ? "No requests yet." : kept().length === 0 ? "Cleared. New requests show up here." : "No requests match the filter.";
1031
+ list.appendChild(el("div", "empty", none));
887
1032
  return;
888
1033
  }
1034
+ var flow = state.selected === null ? {} : chainOf(state.selected);
889
1035
  items.forEach(function (it) {
890
- var row = el("div", "row" + (it.id === state.selected ? " sel" : ""));
1036
+ var row = el("div", "row" + (it.id === state.selected ? " sel" : "") + (flow[it.id] ? " inflow" : ""));
891
1037
  row.setAttribute("role", "option");
892
1038
  row.setAttribute("aria-selected", it.id === state.selected ? "true" : "false");
893
1039
  row.dataset.id = String(it.id);
894
- row.appendChild(el("span", "mut", it.replayOf !== undefined ? "↻" : ""));
1040
+ var mark = markOf(it);
1041
+ var ms = el("span", mark.cls, mark.text);
1042
+ ms.title = mark.title;
1043
+ row.appendChild(ms);
895
1044
  if (multi) row.appendChild(el("span", "mut", it.tunnel));
896
1045
  row.appendChild(el("span", "m", methodOf(it)));
897
1046
  var p = el("span", "", it.path);
898
- p.title = it.path;
1047
+ p.title = it.location ? it.path + " → " + it.location : it.path;
1048
+ if (it.location) p.appendChild(el("span", "to", " → " + it.location));
899
1049
  row.appendChild(p);
900
1050
  var st = statusInfo(it);
901
1051
  var s = el("span", "", st.text);
@@ -989,7 +1139,8 @@ function renderContent() {
989
1139
  var box = $("content");
990
1140
  var top = box.scrollTop;
991
1141
  clear(box);
992
- if (state.tab === "Body") renderBody(box, d.reqBody, d.reqNote, d.reqTruncatedNote, d.reqHeaders);
1142
+ if (state.tab === "Overview") renderOverview(box, d);
1143
+ else if (state.tab === "Body") renderBody(box, d.reqBody, d.reqNote, d.reqTruncatedNote, d.reqHeaders);
993
1144
  else if (state.tab === "Headers") box.appendChild(table(d.reqHeaders));
994
1145
  else if (state.tab === "Response") {
995
1146
  box.appendChild(el("div", "sect", "Headers"));
@@ -1006,6 +1157,130 @@ function renderContent() {
1006
1157
  box.scrollTop = top;
1007
1158
  }
1008
1159
 
1160
+ function rows(pairs) {
1161
+ var t = el("table");
1162
+ pairs.forEach(function (p) {
1163
+ if (p[1] === null || p[1] === undefined || p[1] === "") return;
1164
+ var tr = el("tr");
1165
+ tr.appendChild(el("th", "", p[0]));
1166
+ tr.appendChild(el("td", "", p[1]));
1167
+ t.appendChild(tr);
1168
+ });
1169
+ return t;
1170
+ }
1171
+ function clock(ms) {
1172
+ var d = new Date(ms);
1173
+ function two(n) { return (n < 10 ? "0" : "") + n; }
1174
+ return two(d.getHours()) + ":" + two(d.getMinutes()) + ":" + two(d.getSeconds()) + "." + String(d.getMilliseconds()).padStart(3, "0");
1175
+ }
1176
+ function answeredBy(d) {
1177
+ if (d.origin === null) return "waiting for an answer";
1178
+ if (d.origin === "duto") return d.access && d.access.kind === "denied" ? "duto (tunnel password)" : "duto (nothing answered on localhost)";
1179
+ var t = state.tunnels.filter(function (x) { return x.key === d.tunnel; })[0];
1180
+ return t && t.port ? "your app on localhost:" + t.port : "your app";
1181
+ }
1182
+ function accessText(a) {
1183
+ if (!a) return null;
1184
+ if (a.kind === "user") return "password, user " + a.user;
1185
+ if (a.kind === "allow") return "allowed: " + a.reason;
1186
+ return "denied";
1187
+ }
1188
+ function attrsText(attrs) {
1189
+ return Object.keys(attrs).map(function (k) { return attrs[k] === true ? k : k + "=" + attrs[k]; }).join("; ");
1190
+ }
1191
+
1192
+ function renderOverview(box, d) {
1193
+ var ov = el("div", "ov");
1194
+ d.issues.forEach(function (i) {
1195
+ var card = el("div", "issue " + i.level);
1196
+ card.appendChild(el("b", "", i.title));
1197
+ card.appendChild(el("span", "", i.detail));
1198
+ ov.appendChild(card);
1199
+ });
1200
+
1201
+ if (d.flow.length > 0) {
1202
+ ov.appendChild(el("div", "sect", "Flow"));
1203
+ var flow = el("div", "flow");
1204
+ d.flow.forEach(function (s) {
1205
+ var b = el("button", "step" + (s.id === d.id ? " cur" : ""));
1206
+ b.type = "button";
1207
+ b.appendChild(el("span", "mut", "#" + s.id));
1208
+ b.appendChild(el("span", "m", s.method));
1209
+ b.appendChild(el("span", "", s.path));
1210
+ var st = statusInfo(s);
1211
+ var sp = el("span", "", st.text);
1212
+ sp.style.color = st.color;
1213
+ b.appendChild(sp);
1214
+ b.addEventListener("click", function () { select(s.id); });
1215
+ flow.appendChild(b);
1216
+ });
1217
+ ov.appendChild(flow);
1218
+ }
1219
+
1220
+ ov.appendChild(el("div", "sect", "Request"));
1221
+ var timing = d.durationMs === null || d.durationMs === undefined ? null : d.durationMs + " ms";
1222
+ ov.appendChild(rows([
1223
+ ["Answered by", answeredBy(d)],
1224
+ ["Started", clock(d.startedAt)],
1225
+ ["Total time", timing],
1226
+ ["First byte", d.ttfbMs === undefined ? null : d.ttfbMs + " ms"],
1227
+ ["Size", d.status === null ? null : size(d.bytes)],
1228
+ ["Data center", d.edge ? edgeLabel(d.edge).replace(/^via /, "") : null],
1229
+ ["Access", accessText(d.access)]
1230
+ ]));
1231
+
1232
+ if (d.location || d.redirect) {
1233
+ ov.appendChild(el("div", "sect", "Redirect"));
1234
+ ov.appendChild(rows([
1235
+ ["Location", d.location || (d.redirect && d.redirect.to)],
1236
+ ["Location rewritten", d.redirect ? d.redirect.from + " → " + d.redirect.to : null]
1237
+ ]));
1238
+ }
1239
+
1240
+ ov.appendChild(el("div", "sect", "Client"));
1241
+ ov.appendChild(rows([["IP", d.client.ip], ["Country", d.client.country], ["User agent", d.client.userAgent]]));
1242
+
1243
+ if (d.query.length > 0) {
1244
+ ov.appendChild(el("div", "sect", "Query"));
1245
+ ov.appendChild(rows(d.query.map(function (q) { return [q.name, q.value || " "]; })));
1246
+ }
1247
+ if (d.cookies.sent.length > 0) {
1248
+ ov.appendChild(el("div", "sect", "Cookies sent"));
1249
+ ov.appendChild(rows(d.cookies.sent.map(function (c) { return [c.name, c.value || " "]; })));
1250
+ }
1251
+ if (d.cookies.set.length > 0) {
1252
+ ov.appendChild(el("div", "sect", "Cookies set"));
1253
+ ov.appendChild(rows(d.cookies.set.map(function (c) {
1254
+ var a = attrsText(c.attrs);
1255
+ return [c.name, (c.value || "\"\"") + (a ? " · " + a : "")];
1256
+ })));
1257
+ }
1258
+ box.appendChild(ov);
1259
+ }
1260
+
1261
+ function exportHar() {
1262
+ var ids = visible().map(function (it) { return it.id; });
1263
+ if (ids.length === 0) return showMsg("Nothing to export.", true);
1264
+ fetch("/api/har?ids=" + ids.join(","), { headers: { "X-Duto-Inspector": TOKEN }, cache: "no-store" }).then(function (r) {
1265
+ if (r.status === 403) { ended(); throw new Error("403"); }
1266
+ return r.blob();
1267
+ }).then(function (blob) {
1268
+ var a = document.createElement("a");
1269
+ a.href = URL.createObjectURL(blob);
1270
+ a.download = "duto-" + new Date().toISOString().replace(/[:.]/g, "-") + ".har";
1271
+ document.body.appendChild(a);
1272
+ a.click();
1273
+ a.remove();
1274
+ setTimeout(function () { URL.revokeObjectURL(a.href); }, 1000);
1275
+ }).catch(fail);
1276
+ }
1277
+
1278
+ function toggleClear() {
1279
+ if (state.clearedAfter > 0) state.clearedAfter = 0;
1280
+ else state.clearedAfter = state.items.reduce(function (m, it) { return Math.max(m, it.id); }, 0);
1281
+ renderList();
1282
+ }
1283
+
1009
1284
  function table(h) {
1010
1285
  var keys = Object.keys(h || {});
1011
1286
  if (keys.length === 0) return el("div", "note", "No headers.");
@@ -1181,11 +1456,9 @@ function connect() {
1181
1456
  banner("Disconnected from the session.");
1182
1457
  if (es.readyState === 2) probe();
1183
1458
  });
1184
- es.addEventListener("changed", function (ev) {
1185
- var ids = [];
1186
- try { ids = JSON.parse(ev.data).ids || []; } catch (e) {}
1459
+ es.addEventListener("changed", function () {
1187
1460
  loadList();
1188
- if (state.selected !== null && (ids.length === 0 || ids.indexOf(state.selected) >= 0)) loadDetail(state.selected);
1461
+ if (state.selected !== null) loadDetail(state.selected);
1189
1462
  });
1190
1463
  }
1191
1464
  function probe() {
@@ -1205,6 +1478,13 @@ function init() {
1205
1478
  });
1206
1479
  }
1207
1480
  $("filter").addEventListener("input", renderList);
1481
+ $("clear").addEventListener("click", toggleClear);
1482
+ $("har").addEventListener("click", exportHar);
1483
+ $("helpBtn").addEventListener("click", function () {
1484
+ var h = $("help");
1485
+ h.hidden = !h.hidden;
1486
+ $("helpBtn").setAttribute("aria-expanded", h.hidden ? "false" : "true");
1487
+ });
1208
1488
  $("replay").addEventListener("click", replay);
1209
1489
  $("copyBody").addEventListener("click", function () {
1210
1490
  var b = state.detail ? copyable(state.detail) : { text: null };
@@ -1239,7 +1519,7 @@ function init() {
1239
1519
  init();
1240
1520
  `;
1241
1521
  function inspectorPage(nonce) {
1242
- 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("");
1522
+ 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("");
1243
1523
  return `<!doctype html>
1244
1524
  <html lang="en">
1245
1525
  <head>
@@ -1255,8 +1535,21 @@ function inspectorPage(nonce) {
1255
1535
  <div class="banner" id="banner" role="status" hidden></div>
1256
1536
  <div class="main">
1257
1537
  <div class="side">
1258
- <input class="search" id="filter" type="search" placeholder="filter\u2026" aria-label="Filter requests" autocomplete="off" spellcheck="false">
1538
+ <input class="search" id="filter" type="search" placeholder="filter\u2026 (status:4xx is:redirect)" aria-label="Filter requests" autocomplete="off" spellcheck="false">
1539
+ <div class="chips" id="chips"></div>
1540
+ <div class="help" id="help" hidden>
1541
+ <span><code>status:404</code> <code>status:5xx</code> <code>status:&gt;=400</code></span>
1542
+ <span><code>method:post</code> <code>path:/api</code> <code>tunnel:web</code> <code>client:1.2.3.4</code></span>
1543
+ <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>
1544
+ <span><code>duration:&gt;500ms</code> <code>duration:&gt;=1s</code></span>
1545
+ <span>Start a term with <code>-</code> to exclude it. All terms must match.</span>
1546
+ </div>
1259
1547
  <div class="list" id="list" role="listbox" aria-label="Requests"></div>
1548
+ <div class="sidebar-foot">
1549
+ <button type="button" class="btn" id="clear">Clear</button>
1550
+ <button type="button" class="btn" id="har">Export HAR</button>
1551
+ <button type="button" class="btn" id="helpBtn" aria-expanded="false" aria-controls="help">Filter syntax</button>
1552
+ </div>
1260
1553
  </div>
1261
1554
  <div class="detail">
1262
1555
  <div class="empty" id="none">Select a request from the list.</div>
@@ -1306,6 +1599,25 @@ function replayHeaders(r) {
1306
1599
  return out;
1307
1600
  }
1308
1601
 
1602
+ // src/inspector/cookies.ts
1603
+ function pair(part) {
1604
+ const i = part.indexOf("=");
1605
+ return i < 0 ? [part.trim(), null] : [part.slice(0, i).trim(), part.slice(i + 1).trim()];
1606
+ }
1607
+ function parseSetCookie(line) {
1608
+ const [first3 = "", ...rest] = line.split(";");
1609
+ const [name, value] = pair(first3);
1610
+ const attrs = {};
1611
+ for (const part of rest) {
1612
+ const [key, v] = pair(part);
1613
+ if (key) attrs[key.toLowerCase()] = v ?? true;
1614
+ }
1615
+ return { name, value: value ?? "", attrs };
1616
+ }
1617
+ function parseCookieHeader(header) {
1618
+ return header.split(";").map((part) => pair(part)).filter(([name]) => name !== "").map(([name, value]) => ({ name, value: value ?? "" }));
1619
+ }
1620
+
1309
1621
  // src/inspector/curl.ts
1310
1622
  var SKIP = /* @__PURE__ */ new Set(["host", "content-length", "transfer-encoding", "connection", "keep-alive", "expect", "upgrade", "te", "trailer", "proxy-connection"]);
1311
1623
  var TUNNEL_ADDED = /* @__PURE__ */ new Set(["cdn-loop", "x-forwarded-for", "x-forwarded-proto", "x-real-ip", "true-client-ip"]);
@@ -1326,6 +1638,202 @@ function toCurl(r, o) {
1326
1638
  return parts.join(" \\\n ");
1327
1639
  }
1328
1640
 
1641
+ // src/inspector/diagnose.ts
1642
+ var LOCAL_HOSTS = /* @__PURE__ */ new Set(["localhost", "127.0.0.1", "0.0.0.0", "[::1]", "::1"]);
1643
+ var LINKABLE = /html|javascript|json|css|xml/i;
1644
+ var LOCAL_LINK = /https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?/gi;
1645
+ function isLocalHost(host) {
1646
+ const h = host.toLowerCase();
1647
+ return LOCAL_HOSTS.has(h) || h.endsWith(".localhost");
1648
+ }
1649
+ function absoluteUrl(value) {
1650
+ if (!/^https?:\/\//i.test(value)) return null;
1651
+ try {
1652
+ return new URL(value);
1653
+ } catch {
1654
+ return null;
1655
+ }
1656
+ }
1657
+ function originOf(r) {
1658
+ if (r.error || r.access?.kind === "denied") return "duto";
1659
+ return r.status === null ? null : "app";
1660
+ }
1661
+ function upstreamIssues(r, ctx) {
1662
+ const out = [];
1663
+ if (r.error) {
1664
+ out.push({ level: "error", title: `Nothing is listening on localhost:${ctx.port ?? "?"}`, detail: `${r.error}. Start the server, then replay this request.` });
1665
+ }
1666
+ if (r.access?.kind === "denied") {
1667
+ out.push({ level: "info", title: "Blocked by the tunnel password", detail: "duto answered before the request reached your app." });
1668
+ }
1669
+ return out;
1670
+ }
1671
+ function redirectIssues(r, ctx) {
1672
+ const location = r.resHeaders.location;
1673
+ const url = location ? absoluteUrl(location) : null;
1674
+ if (!url) return [];
1675
+ if (isLocalHost(url.hostname)) {
1676
+ return [
1677
+ {
1678
+ level: "warn",
1679
+ title: "Redirect to a local address",
1680
+ detail: `Location is ${location}, which only opens on this machine. Build URLs from the request's Host header, or run duto with --host.`
1681
+ }
1682
+ ];
1683
+ }
1684
+ if (url.protocol === "http:" && ctx.publicHost && url.hostname.toLowerCase() === ctx.publicHost) {
1685
+ return [
1686
+ {
1687
+ level: "warn",
1688
+ title: "Redirect drops to http",
1689
+ 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.`
1690
+ }
1691
+ ];
1692
+ }
1693
+ return [];
1694
+ }
1695
+ function cookieIssues(r, ctx) {
1696
+ const out = [];
1697
+ for (const line of r.resSetCookies ?? []) {
1698
+ const c = parseSetCookie(line);
1699
+ const domain = typeof c.attrs.domain === "string" ? c.attrs.domain.replace(/^\./, "").toLowerCase() : null;
1700
+ const host = ctx.publicHost;
1701
+ if (domain && host && host !== domain && !host.endsWith(`.${domain}`)) {
1702
+ 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}.` });
1703
+ } else if (typeof c.attrs.samesite === "string" && c.attrs.samesite.toLowerCase() === "none" && c.attrs.secure === void 0) {
1704
+ out.push({ level: "warn", title: `Cookie "${c.name}" is dropped`, detail: "SameSite=None needs Secure." });
1705
+ }
1706
+ }
1707
+ return out;
1708
+ }
1709
+ function corsIssues(r, ctx) {
1710
+ const origin = r.reqHeaders.origin;
1711
+ if (!origin || r.status === null || ctx.publicHost && origin.toLowerCase() === `https://${ctx.publicHost}`) return [];
1712
+ const allowed = r.resHeaders["access-control-allow-origin"];
1713
+ const ok2 = allowed === "*" || allowed === origin;
1714
+ const wanted = r.reqHeaders["access-control-request-method"];
1715
+ if (r.method === "OPTIONS" && wanted) {
1716
+ if (r.status < 400 && ok2) return [];
1717
+ 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.` }];
1718
+ }
1719
+ if (ok2) return [];
1720
+ const detail2 = allowed ? `Access-Control-Allow-Origin is ${allowed}, not ${origin}.` : `No Access-Control-Allow-Origin for ${origin}.`;
1721
+ return [{ level: "warn", title: "The browser blocks this response (CORS)", detail: detail2 }];
1722
+ }
1723
+ function bodyIssues(r) {
1724
+ if (!r.resBody || !LINKABLE.test(r.resHeaders["content-type"] ?? "")) return [];
1725
+ const links = r.resBody.match(LOCAL_LINK);
1726
+ if (!links) return [];
1727
+ const first3 = links[0];
1728
+ 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.`;
1729
+ return [{ level: "warn", title: "The response links to localhost", detail: detail2 }];
1730
+ }
1731
+ function diagnose(r, ctx) {
1732
+ return [...upstreamIssues(r, ctx), ...redirectIssues(r, ctx), ...cookieIssues(r, ctx), ...corsIssues(r, ctx), ...bodyIssues(r)];
1733
+ }
1734
+
1735
+ // src/inspector/flows.ts
1736
+ var FLOW_WINDOW_MS = 3e4;
1737
+ function clientOf(r) {
1738
+ const direct = r.reqHeaders["cf-connecting-ip"];
1739
+ if (direct) return direct;
1740
+ const forwarded = r.reqHeaders["x-forwarded-for"]?.split(",")[0]?.trim();
1741
+ return forwarded || null;
1742
+ }
1743
+ var clientKey = (r) => `${clientOf(r) ?? ""}|${r.reqHeaders["user-agent"] ?? ""}`;
1744
+ function redirectTarget(r, publicHost) {
1745
+ const location = r.resHeaders.location;
1746
+ if (!location || r.status === null || r.status < 300 || r.status >= 400) return null;
1747
+ const base = `https://${publicHost ?? "duto.invalid"}`;
1748
+ let url;
1749
+ try {
1750
+ url = new URL(location, base + r.path);
1751
+ } catch {
1752
+ return null;
1753
+ }
1754
+ if (url.origin !== base) return null;
1755
+ return url.pathname + url.search;
1756
+ }
1757
+ function linkFlows(records, publicHostOf) {
1758
+ const ordered = [...records].sort((a, b) => a.id - b.id);
1759
+ const links = /* @__PURE__ */ new Map();
1760
+ const link = (id, patch) => links.set(id, { ...links.get(id), ...patch });
1761
+ ordered.forEach((r, i) => {
1762
+ const target = redirectTarget(r, publicHostOf(r.tunnel));
1763
+ if (target === null) return;
1764
+ const key = clientKey(r);
1765
+ 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);
1766
+ if (!next) return;
1767
+ link(r.id, { next: next.id });
1768
+ link(next.id, { prev: r.id });
1769
+ });
1770
+ return links;
1771
+ }
1772
+ function flowChain(id, links) {
1773
+ let first3 = id;
1774
+ const seen = /* @__PURE__ */ new Set([id]);
1775
+ for (let p = links.get(first3)?.prev; p !== void 0 && !seen.has(p); p = links.get(first3)?.prev) {
1776
+ seen.add(p);
1777
+ first3 = p;
1778
+ }
1779
+ const chain = [first3];
1780
+ for (let n = links.get(first3)?.next; n !== void 0 && !chain.includes(n); n = links.get(n)?.next) chain.push(n);
1781
+ return chain;
1782
+ }
1783
+
1784
+ // src/inspector/har.ts
1785
+ var HTTP = "HTTP/1.1";
1786
+ var headerList = (h) => Object.entries(h).map(([name, value]) => ({ name, value }));
1787
+ function harCookie(line) {
1788
+ const c = parseSetCookie(line);
1789
+ return {
1790
+ name: c.name,
1791
+ value: c.value,
1792
+ ...typeof c.attrs.path === "string" && { path: c.attrs.path },
1793
+ ...typeof c.attrs.domain === "string" && { domain: c.attrs.domain },
1794
+ ...c.attrs.httponly !== void 0 && { httpOnly: true },
1795
+ ...c.attrs.secure !== void 0 && { secure: true }
1796
+ };
1797
+ }
1798
+ function entry(r, publicUrl) {
1799
+ const url = new URL(r.path, publicUrl ?? "http://localhost");
1800
+ const time = r.durationMs ?? 0;
1801
+ const wait = Math.min(r.ttfbMs ?? time, time);
1802
+ const reqType = r.reqHeaders["content-type"] ?? "";
1803
+ return {
1804
+ startedDateTime: new Date(r.startedAt).toISOString(),
1805
+ time,
1806
+ request: {
1807
+ method: r.websocket ? "GET" : r.method,
1808
+ url: url.toString(),
1809
+ httpVersion: HTTP,
1810
+ headers: headerList(r.reqHeaders),
1811
+ queryString: [...url.searchParams].map(([name, value]) => ({ name, value })),
1812
+ cookies: parseCookieHeader(r.reqHeaders.cookie ?? ""),
1813
+ headersSize: -1,
1814
+ bodySize: r.reqBodyBytes,
1815
+ ...r.reqBody !== null && { postData: { mimeType: reqType, text: r.reqBody } }
1816
+ },
1817
+ response: {
1818
+ status: r.status ?? 0,
1819
+ statusText: "",
1820
+ httpVersion: HTTP,
1821
+ headers: headerList(r.resHeaders),
1822
+ cookies: (r.resSetCookies ?? []).map(harCookie),
1823
+ content: { size: r.bytes, mimeType: r.resHeaders["content-type"] ?? "", ...r.resBody !== null && { text: r.resBody } },
1824
+ redirectURL: r.resHeaders.location ?? "",
1825
+ headersSize: -1,
1826
+ bodySize: r.bytes
1827
+ },
1828
+ cache: {},
1829
+ timings: { send: 0, wait, receive: time - wait }
1830
+ };
1831
+ }
1832
+ function toHar(records, publicUrlOf, version) {
1833
+ const entries = [...records].sort((a, b) => a.id - b.id).map((r) => entry(r, publicUrlOf(r.tunnel)));
1834
+ return { log: { version: "1.2", creator: { name: "duto", version }, entries } };
1835
+ }
1836
+
1329
1837
  // src/inspector/server.ts
1330
1838
  var DEFAULT_PORT = 4141;
1331
1839
  var PORT_TRIES = 20;
@@ -1336,7 +1844,30 @@ var CURL_BYTES = "The curl body may differ from the one received (non-UTF-8 byte
1336
1844
  var sha = (s) => createHash2("sha256").update(s).digest();
1337
1845
  var constantTimeEqual = (a, b) => timingSafeEqual(sha(a), sha(b));
1338
1846
  var first = (v) => Array.isArray(v) ? v[0] : v;
1339
- function listItem(r) {
1847
+ function hostOfUrl(url) {
1848
+ if (!url) return null;
1849
+ try {
1850
+ return new URL(url).hostname.toLowerCase();
1851
+ } catch {
1852
+ return null;
1853
+ }
1854
+ }
1855
+ function viewOf(session) {
1856
+ const tunnels = session.getState().tunnels;
1857
+ const find = (key) => tunnels.find((t) => t.key === key);
1858
+ const urlOf = (key) => find(key)?.url ?? null;
1859
+ const hostOf2 = (key) => hostOfUrl(urlOf(key));
1860
+ return {
1861
+ urlOf,
1862
+ context: (key) => ({ publicHost: hostOf2(key), port: find(key)?.port ?? null }),
1863
+ links: linkFlows(session.records.list(), hostOf2)
1864
+ };
1865
+ }
1866
+ function isRedirect(r) {
1867
+ return r.status !== null && r.status >= 300 && r.status < 400 && !!r.resHeaders.location;
1868
+ }
1869
+ function listItem(r, view) {
1870
+ const link = view.links.get(r.id);
1340
1871
  return {
1341
1872
  id: r.id,
1342
1873
  tunnel: r.tunnel,
@@ -1347,11 +1878,35 @@ function listItem(r) {
1347
1878
  bytes: r.bytes,
1348
1879
  startedAt: r.startedAt,
1349
1880
  websocket: r.websocket,
1881
+ origin: originOf(r),
1882
+ issues: diagnose(r, view.context(r.tunnel)).length,
1883
+ client: clientOf(r),
1884
+ ...r.ttfbMs !== void 0 && { ttfbMs: r.ttfbMs },
1885
+ ...isRedirect(r) && { location: r.resHeaders.location },
1886
+ ...link?.prev !== void 0 && { prev: link.prev },
1887
+ ...link?.next !== void 0 && { next: link.next },
1350
1888
  ...r.replayOf !== void 0 && { replayOf: r.replayOf },
1351
- ...r.error !== void 0 && { error: r.error }
1889
+ ...r.error !== void 0 && { error: r.error },
1890
+ ...r.redirect !== void 0 && { rewritten: true },
1891
+ ...r.access !== void 0 && { access: r.access }
1352
1892
  };
1353
1893
  }
1354
- function detail(r, session) {
1894
+ function queryOf(path) {
1895
+ try {
1896
+ return [...new URL(path, "http://x").searchParams].map(([name, value]) => ({ name, value }));
1897
+ } catch {
1898
+ return [];
1899
+ }
1900
+ }
1901
+ function flowOf(r, session, view) {
1902
+ const chain = flowChain(r.id, view.links);
1903
+ if (chain.length < 2) return [];
1904
+ return chain.flatMap((id) => {
1905
+ const step = session.records.get(id);
1906
+ return step ? [{ id, method: step.websocket ? "WS" : step.method, path: step.path, status: step.status }] : [];
1907
+ });
1908
+ }
1909
+ function detail(r, session, view) {
1355
1910
  const tunnel2 = session.getState().tunnels.find((t) => t.key === r.tunnel);
1356
1911
  const tunnelOpen = !!tunnel2 && (tunnel2.status === "live" || tunnel2.status === "no-listener");
1357
1912
  let curl = null;
@@ -1362,8 +1917,14 @@ function detail(r, session) {
1362
1917
  if (r.reqRaw && !Buffer.from(r.reqBody ?? "", "utf8").equals(r.reqRaw)) curlNote = CURL_BYTES;
1363
1918
  }
1364
1919
  return {
1365
- ...listItem(r),
1920
+ ...listItem(r, view),
1366
1921
  edge: edgeOf(r.reqHeaders),
1922
+ redirect: r.redirect ?? null,
1923
+ issues: diagnose(r, view.context(r.tunnel)),
1924
+ client: { ip: clientOf(r), country: r.reqHeaders["cf-ipcountry"] ?? null, userAgent: r.reqHeaders["user-agent"] ?? null },
1925
+ query: queryOf(r.path),
1926
+ cookies: { sent: parseCookieHeader(r.reqHeaders.cookie ?? ""), set: (r.resSetCookies ?? []).map(parseSetCookie) },
1927
+ flow: flowOf(r, session, view),
1367
1928
  reqHeaders: r.reqHeaders,
1368
1929
  resHeaders: r.resHeaders,
1369
1930
  reqBody: r.reqBody,
@@ -1451,7 +2012,7 @@ data: ${JSON.stringify({ ids })}
1451
2012
  }
1452
2013
  const isEvents = method === "GET" && path === "/api/events";
1453
2014
  const detailId = /^\/api\/requests\/([1-9]\d{0,15})(\/replay)?$/.exec(path);
1454
- const known = method === "GET" && (path === "/api/session" || path === "/api/requests" || detailId && !detailId[2]) || isEvents || method === "POST" && detailId && detailId[2];
2015
+ const known = method === "GET" && (path === "/api/session" || path === "/api/requests" || path === "/api/har" || detailId && !detailId[2]) || isEvents || method === "POST" && detailId && detailId[2];
1455
2016
  if (!known) return text(res, 404, "Not found.");
1456
2017
  if (!constantTimeEqual(headerToken, token) && !(isEvents && constantTimeEqual(queryToken, token))) return json(res, 403, { error: NO_TOKEN });
1457
2018
  if (isEvents) {
@@ -1463,15 +2024,25 @@ data: ${JSON.stringify({ ids })}
1463
2024
  return;
1464
2025
  }
1465
2026
  if (path === "/api/session") {
1466
- return json(res, 200, { tunnels: session.getState().tunnels.map((t) => ({ key: t.key, url: t.url, auth: !!t.auth })) });
2027
+ return json(res, 200, { tunnels: session.getState().tunnels.map((t) => ({ key: t.key, url: t.url, auth: !!t.auth, port: t.port })) });
2028
+ }
2029
+ if (path === "/api/requests") {
2030
+ const view = viewOf(session);
2031
+ return json(res, 200, { items: session.records.list().map((r2) => listItem(r2, view)) });
2032
+ }
2033
+ if (path === "/api/har") {
2034
+ const wanted = url2.searchParams.get("ids");
2035
+ const ids = wanted === null ? null : new Set(wanted.split(",").map(Number));
2036
+ const records = session.records.list().filter((r2) => !ids || ids.has(r2.id));
2037
+ res.setHeader("Content-Disposition", 'attachment; filename="duto.har"');
2038
+ return json(res, 200, toHar(records, viewOf(session).urlOf, CLI_VERSION));
1467
2039
  }
1468
- if (path === "/api/requests") return json(res, 200, { items: session.records.list().map(listItem) });
1469
2040
  const id = Number(detailId[1]);
1470
2041
  if (!Number.isSafeInteger(id)) return json(res, 404, { error: GONE });
1471
2042
  if (method === "POST") return json(res, 200, session.replay(id));
1472
2043
  const r = session.records.get(id);
1473
2044
  if (!r) return json(res, 404, { error: GONE });
1474
- return json(res, 200, detail(r, session));
2045
+ return json(res, 200, detail(r, session, viewOf(session)));
1475
2046
  };
1476
2047
  const server = createServer((req, res) => {
1477
2048
  try {
@@ -2036,6 +2607,8 @@ async function startProxy(o) {
2036
2607
  }
2037
2608
  function trackResponse(id, started, up, reqCap) {
2038
2609
  const resCap = new Capture(TEXT2.test(up.headers["content-type"] ?? ""), max);
2610
+ const ttfbMs = Date.now() - started;
2611
+ const setCookies = up.headers["set-cookie"];
2039
2612
  let bytes = 0;
2040
2613
  up.on("data", (c) => {
2041
2614
  bytes += c.length;
@@ -2046,8 +2619,10 @@ async function startProxy(o) {
2046
2619
  () => o.store.update(id, {
2047
2620
  status: up.statusCode ?? null,
2048
2621
  durationMs: Date.now() - started,
2622
+ ttfbMs,
2049
2623
  bytes,
2050
2624
  resHeaders: flat(up.headers),
2625
+ ...setCookies?.length && { resSetCookies: setCookies },
2051
2626
  resBody: resCap.text(),
2052
2627
  truncated: !!reqCap?.truncated || resCap.truncated,
2053
2628
  resTruncated: resCap.truncated,
@@ -2138,7 +2713,12 @@ async function startProxy(o) {
2138
2713
  return;
2139
2714
  }
2140
2715
  if (route.host && route.rewriteRedirects !== false && typeof up.headers.location === "string") {
2141
- up.headers.location = rewriteLocation(up.headers.location, route.host, hostOf(req));
2716
+ const from = up.headers.location;
2717
+ const to = rewriteLocation(from, route.host, hostOf(req));
2718
+ if (to !== from) {
2719
+ up.headers.location = to;
2720
+ o.store.update(id, { redirect: { from, to } });
2721
+ }
2142
2722
  }
2143
2723
  res.writeHead(up.statusCode ?? 502, up.statusMessage, up.headers);
2144
2724
  trackResponse(id, started, up, reqCap);
@@ -2601,7 +3181,7 @@ function runPlain(session, out) {
2601
3181
  printed.add(r.id);
2602
3182
  const method = r.websocket ? "WS" : r.method;
2603
3183
  out(
2604
- `${formatTime(r.startedAt)} ${r.tunnel.padEnd(8)} ${method} ${r.path} ${paint(statusColor(r.status), String(r.status))} ${formatDuration(r.durationMs)} ${formatBytes(r.bytes)}`
3184
+ `${formatTime(r.startedAt)} ${r.tunnel.padEnd(8)} ${method} ${r.path} ${paint(statusColor(r.status), String(r.status))} ${formatDuration(r.durationMs)} ${formatBytes(r.bytes)}${r.redirect ? ` ${paint("signal", `\u21AA Location rewritten to ${r.redirect.to}`)}` : ""}`
2605
3185
  );
2606
3186
  }
2607
3187
  };
@@ -2643,8 +3223,8 @@ function listFlag(raw) {
2643
3223
  }
2644
3224
  function parseAuthFlags(raw) {
2645
3225
  const out = [];
2646
- for (const entry of listFlag(raw)) {
2647
- const r = parseCredential(entry);
3226
+ for (const entry2 of listFlag(raw)) {
3227
+ const r = parseCredential(entry2);
2648
3228
  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");
2649
3229
  if (out.some((c) => c.user === r.cred.user)) fail2(`Duplicate user "${r.cred.user}"`);
2650
3230
  out.push(r.cred);
@@ -2682,7 +3262,7 @@ async function runSession(specs, source, flags) {
2682
3262
  }
2683
3263
  }
2684
3264
  if (wantsUi(flags)) {
2685
- const { runFullscreen } = await import("./fullscreen-AI6PC7RL.js");
3265
+ const { runFullscreen } = await import("./fullscreen-MZMQ6ADN.js");
2686
3266
  const code = await runFullscreen(session, specs, CLI_VERSION, inspector, inspectorError, update);
2687
3267
  await inspector?.close().catch(() => void 0);
2688
3268
  process.exit(code);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dutodev/cli",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
4
4
  "description": "Expose a local port at https://<name>-<user>.duto.dev with one command.",
5
5
  "license": "MIT",
6
6
  "type": "module",