@dutodev/cli 1.4.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.
Files changed (3) hide show
  1. package/README.md +5 -0
  2. package/dist/index.js +610 -51
  3. package/package.json +1 -1
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`.
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.4.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.4.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,14 +806,14 @@ 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; }
@@ -756,7 +821,30 @@ button { font: inherit; color: inherit; background: none; border: 0; padding: 0;
756
821
  .m { font-weight: 700; }
757
822
  .mut { color: ${C.muted}; }
758
823
  .sig { color: ${C.signal}; }
759
- .redirect { margin-top: 6px; display: grid; gap: 2px; }
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; }
760
848
  .empty { padding: 12px; color: ${C.muted}; }
761
849
  .detail { display: flex; flex-direction: column; min-height: 0; min-width: 0; }
762
850
  .pane { flex: 1; }
@@ -798,14 +886,20 @@ th { color: ${JSON_KEY}; font-weight: 400; white-space: nowrap; }
798
886
  var SCRIPT = String.raw`"use strict";
799
887
  var countLabel = (${countLabel.toString()});
800
888
  var edgeLabel = (${edgeLabel.toString()});
889
+ var matchesFilter = (${matchesFilter.toString()});
801
890
  var jsonSyntaxError = (${jsonSyntaxError.toString()});
802
891
  var jsonTokens = (${jsonTokens.toString()});
803
892
  var jsonIndent = (${jsonIndent.toString()});
804
893
  var COLORS = ${JSON.stringify(C)};
805
894
  var TOKEN = new URLSearchParams(location.search).get("t") || "";
806
- 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
+ ];
807
901
 
808
- 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 };
809
903
  var listSeq = 0, detailSeq = 0, msgTimer = null;
810
904
  var es = null, lost = false, replaying = false;
811
905
  function fail(e) { if (!state.ended) console.error(e); }
@@ -857,14 +951,60 @@ function header(h, name) {
857
951
  for (var k in h) if (k.toLowerCase() === name) return h[k];
858
952
  return "";
859
953
  }
954
+ function kept() {
955
+ return state.items.filter(function (it) { return it.id > state.clearedAfter; });
956
+ }
860
957
  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;
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);
865
980
  });
866
981
  }
867
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
+
868
1008
  function renderTop() {
869
1009
  var n = state.items.length;
870
1010
  $("count").textContent = countLabel(n, "request", "requests");
@@ -883,26 +1023,29 @@ function renderList() {
883
1023
  var multi = state.tunnels.length > 1;
884
1024
  list.classList.toggle("multi", multi);
885
1025
  clear(list);
1026
+ renderChips();
1027
+ $("clear").textContent = state.clearedAfter > 0 ? "Show all" : "Clear";
886
1028
  var items = visible();
887
1029
  if (items.length === 0) {
888
- 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));
889
1032
  return;
890
1033
  }
1034
+ var flow = state.selected === null ? {} : chainOf(state.selected);
891
1035
  items.forEach(function (it) {
892
- 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" : ""));
893
1037
  row.setAttribute("role", "option");
894
1038
  row.setAttribute("aria-selected", it.id === state.selected ? "true" : "false");
895
1039
  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", ""));
1040
+ var mark = markOf(it);
1041
+ var ms = el("span", mark.cls, mark.text);
1042
+ ms.title = mark.title;
1043
+ row.appendChild(ms);
902
1044
  if (multi) row.appendChild(el("span", "mut", it.tunnel));
903
1045
  row.appendChild(el("span", "m", methodOf(it)));
904
1046
  var p = el("span", "", it.path);
905
- 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));
906
1049
  row.appendChild(p);
907
1050
  var st = statusInfo(it);
908
1051
  var s = el("span", "", st.text);
@@ -980,13 +1123,6 @@ function renderDetail() {
980
1123
  if (d.replayOf !== undefined) parts.push("↻ replay of #" + d.replayOf);
981
1124
  head.appendChild(el("span", "mut", " · " + parts.join(" · ")));
982
1125
  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
1126
 
991
1127
  var tabs = document.querySelectorAll(".tab");
992
1128
  for (var i = 0; i < tabs.length; i++) {
@@ -1003,7 +1139,8 @@ function renderContent() {
1003
1139
  var box = $("content");
1004
1140
  var top = box.scrollTop;
1005
1141
  clear(box);
1006
- 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);
1007
1144
  else if (state.tab === "Headers") box.appendChild(table(d.reqHeaders));
1008
1145
  else if (state.tab === "Response") {
1009
1146
  box.appendChild(el("div", "sect", "Headers"));
@@ -1020,6 +1157,130 @@ function renderContent() {
1020
1157
  box.scrollTop = top;
1021
1158
  }
1022
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
+
1023
1284
  function table(h) {
1024
1285
  var keys = Object.keys(h || {});
1025
1286
  if (keys.length === 0) return el("div", "note", "No headers.");
@@ -1195,11 +1456,9 @@ function connect() {
1195
1456
  banner("Disconnected from the session.");
1196
1457
  if (es.readyState === 2) probe();
1197
1458
  });
1198
- es.addEventListener("changed", function (ev) {
1199
- var ids = [];
1200
- try { ids = JSON.parse(ev.data).ids || []; } catch (e) {}
1459
+ es.addEventListener("changed", function () {
1201
1460
  loadList();
1202
- if (state.selected !== null && (ids.length === 0 || ids.indexOf(state.selected) >= 0)) loadDetail(state.selected);
1461
+ if (state.selected !== null) loadDetail(state.selected);
1203
1462
  });
1204
1463
  }
1205
1464
  function probe() {
@@ -1219,6 +1478,13 @@ function init() {
1219
1478
  });
1220
1479
  }
1221
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
+ });
1222
1488
  $("replay").addEventListener("click", replay);
1223
1489
  $("copyBody").addEventListener("click", function () {
1224
1490
  var b = state.detail ? copyable(state.detail) : { text: null };
@@ -1253,7 +1519,7 @@ function init() {
1253
1519
  init();
1254
1520
  `;
1255
1521
  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("");
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("");
1257
1523
  return `<!doctype html>
1258
1524
  <html lang="en">
1259
1525
  <head>
@@ -1269,8 +1535,21 @@ function inspectorPage(nonce) {
1269
1535
  <div class="banner" id="banner" role="status" hidden></div>
1270
1536
  <div class="main">
1271
1537
  <div class="side">
1272
- <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>
1273
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>
1274
1553
  </div>
1275
1554
  <div class="detail">
1276
1555
  <div class="empty" id="none">Select a request from the list.</div>
@@ -1320,6 +1599,25 @@ function replayHeaders(r) {
1320
1599
  return out;
1321
1600
  }
1322
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
+
1323
1621
  // src/inspector/curl.ts
1324
1622
  var SKIP = /* @__PURE__ */ new Set(["host", "content-length", "transfer-encoding", "connection", "keep-alive", "expect", "upgrade", "te", "trailer", "proxy-connection"]);
1325
1623
  var TUNNEL_ADDED = /* @__PURE__ */ new Set(["cdn-loop", "x-forwarded-for", "x-forwarded-proto", "x-real-ip", "true-client-ip"]);
@@ -1340,6 +1638,202 @@ function toCurl(r, o) {
1340
1638
  return parts.join(" \\\n ");
1341
1639
  }
1342
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
+
1343
1837
  // src/inspector/server.ts
1344
1838
  var DEFAULT_PORT = 4141;
1345
1839
  var PORT_TRIES = 20;
@@ -1350,7 +1844,30 @@ var CURL_BYTES = "The curl body may differ from the one received (non-UTF-8 byte
1350
1844
  var sha = (s) => createHash2("sha256").update(s).digest();
1351
1845
  var constantTimeEqual = (a, b) => timingSafeEqual(sha(a), sha(b));
1352
1846
  var first = (v) => Array.isArray(v) ? v[0] : v;
1353
- 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);
1354
1871
  return {
1355
1872
  id: r.id,
1356
1873
  tunnel: r.tunnel,
@@ -1361,12 +1878,35 @@ function listItem(r) {
1361
1878
  bytes: r.bytes,
1362
1879
  startedAt: r.startedAt,
1363
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 },
1364
1888
  ...r.replayOf !== void 0 && { replayOf: r.replayOf },
1365
1889
  ...r.error !== void 0 && { error: r.error },
1366
- ...r.redirect !== void 0 && { rewritten: true }
1890
+ ...r.redirect !== void 0 && { rewritten: true },
1891
+ ...r.access !== void 0 && { access: r.access }
1367
1892
  };
1368
1893
  }
1369
- 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) {
1370
1910
  const tunnel2 = session.getState().tunnels.find((t) => t.key === r.tunnel);
1371
1911
  const tunnelOpen = !!tunnel2 && (tunnel2.status === "live" || tunnel2.status === "no-listener");
1372
1912
  let curl = null;
@@ -1377,9 +1917,14 @@ function detail(r, session) {
1377
1917
  if (r.reqRaw && !Buffer.from(r.reqBody ?? "", "utf8").equals(r.reqRaw)) curlNote = CURL_BYTES;
1378
1918
  }
1379
1919
  return {
1380
- ...listItem(r),
1920
+ ...listItem(r, view),
1381
1921
  edge: edgeOf(r.reqHeaders),
1382
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),
1383
1928
  reqHeaders: r.reqHeaders,
1384
1929
  resHeaders: r.resHeaders,
1385
1930
  reqBody: r.reqBody,
@@ -1467,7 +2012,7 @@ data: ${JSON.stringify({ ids })}
1467
2012
  }
1468
2013
  const isEvents = method === "GET" && path === "/api/events";
1469
2014
  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];
2015
+ const known = method === "GET" && (path === "/api/session" || path === "/api/requests" || path === "/api/har" || detailId && !detailId[2]) || isEvents || method === "POST" && detailId && detailId[2];
1471
2016
  if (!known) return text(res, 404, "Not found.");
1472
2017
  if (!constantTimeEqual(headerToken, token) && !(isEvents && constantTimeEqual(queryToken, token))) return json(res, 403, { error: NO_TOKEN });
1473
2018
  if (isEvents) {
@@ -1479,15 +2024,25 @@ data: ${JSON.stringify({ ids })}
1479
2024
  return;
1480
2025
  }
1481
2026
  if (path === "/api/session") {
1482
- 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));
1483
2039
  }
1484
- if (path === "/api/requests") return json(res, 200, { items: session.records.list().map(listItem) });
1485
2040
  const id = Number(detailId[1]);
1486
2041
  if (!Number.isSafeInteger(id)) return json(res, 404, { error: GONE });
1487
2042
  if (method === "POST") return json(res, 200, session.replay(id));
1488
2043
  const r = session.records.get(id);
1489
2044
  if (!r) return json(res, 404, { error: GONE });
1490
- return json(res, 200, detail(r, session));
2045
+ return json(res, 200, detail(r, session, viewOf(session)));
1491
2046
  };
1492
2047
  const server = createServer((req, res) => {
1493
2048
  try {
@@ -2052,6 +2607,8 @@ async function startProxy(o) {
2052
2607
  }
2053
2608
  function trackResponse(id, started, up, reqCap) {
2054
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"];
2055
2612
  let bytes = 0;
2056
2613
  up.on("data", (c) => {
2057
2614
  bytes += c.length;
@@ -2062,8 +2619,10 @@ async function startProxy(o) {
2062
2619
  () => o.store.update(id, {
2063
2620
  status: up.statusCode ?? null,
2064
2621
  durationMs: Date.now() - started,
2622
+ ttfbMs,
2065
2623
  bytes,
2066
2624
  resHeaders: flat(up.headers),
2625
+ ...setCookies?.length && { resSetCookies: setCookies },
2067
2626
  resBody: resCap.text(),
2068
2627
  truncated: !!reqCap?.truncated || resCap.truncated,
2069
2628
  resTruncated: resCap.truncated,
@@ -2664,8 +3223,8 @@ function listFlag(raw) {
2664
3223
  }
2665
3224
  function parseAuthFlags(raw) {
2666
3225
  const out = [];
2667
- for (const entry of listFlag(raw)) {
2668
- const r = parseCredential(entry);
3226
+ for (const entry2 of listFlag(raw)) {
3227
+ const r = parseCredential(entry2);
2669
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");
2670
3229
  if (out.some((c) => c.user === r.cred.user)) fail2(`Duplicate user "${r.cred.user}"`);
2671
3230
  out.push(r.cred);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dutodev/cli",
3
- "version": "1.4.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",