@zerotal/devtools 1.8.0 → 1.8.1

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/CHANGELOG.md CHANGED
@@ -8,6 +8,40 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.8.1] — 2026-08-26
12
+
13
+ ### Fixed
14
+
15
+ - **A page keeps the panel while its own assets load.** Opening `/login` selected `/login`,
16
+ then `/favicon.ico` a few milliseconds later, then `/css/app.css`: live mode selected every
17
+ trace as it arrived, and a page's sub-resources arrive right behind it. The bar named a
18
+ request nobody asked about, the detail below described that request's headers and its empty
19
+ session, and the page under inspection had scrolled into the list. Nothing shown was wrong;
20
+ it was all about the wrong request.
21
+
22
+ Traces are classified into three kinds rather than two, because "not the document" would
23
+ have suppressed the form post and the Inertia visit — the requests most worth watching.
24
+ What is skipped over is narrower: a sub-resource the browser fetched on its own initiative.
25
+ The browser is asked rather than the URL, since an app may serve an API from a `.js` route
26
+ and a build that hashes its asset names has no extension to read; response content type is
27
+ the fallback, so a page fetched by curl still reads as a page. Anything unclassifiable is
28
+ `api`, never `asset`. An asset still takes the selection when nothing is selected, so a
29
+ panel opened mid-load shows a request rather than an empty pane, and a paused panel still
30
+ counts assets toward its pending badge.
31
+
32
+ ### Added
33
+
34
+ - **A `kind` facet on the All tab**, beside method and status. Assets were never the problem,
35
+ only their claim on the selection, so they are not hidden: pick `page` and `api` for a list
36
+ without fifty stylesheet fetches in it, or `asset` alone for what the browser pulled in,
37
+ what it cost and which of it 404'd — which was not visible anywhere before. `Facets.kinds`
38
+ is optional, because that interface is exported from `@zerotal/devtools/client` and a
39
+ required field added to a published interface breaks whoever builds one by hand.
40
+
41
+ - **`sec-fetch-dest` and `sec-fetch-mode` are recorded**, joining the safe header list. They
42
+ are where the classification above comes from, and they state what a request was for
43
+ without saying anything about who made it.
44
+
11
45
  ## [1.7.4] — 2026-08-21
12
46
 
13
47
  ### Fixed
package/api-surface.md CHANGED
@@ -276,6 +276,7 @@ interface DevtoolsPanelPlugin = {
276
276
 
277
277
  interface Facets = {
278
278
  errors: boolean
279
+ kinds?: RequestKind[]
279
280
  methods: string[]
280
281
  nPlusOne: boolean
281
282
  slow: boolean
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/devtools",
3
- "version": "1.8.0",
3
+ "version": "1.8.1",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -31,11 +31,11 @@
31
31
  "typecheck": "tsc --noEmit"
32
32
  },
33
33
  "dependencies": {
34
- "@zerotal/core": "1.8.0"
34
+ "@zerotal/core": "1.8.1"
35
35
  },
36
36
  "devDependencies": {
37
37
  "typescript": "^5.8.0",
38
- "@zerotal/orm": "1.8.0"
38
+ "@zerotal/orm": "1.8.1"
39
39
  },
40
40
  "description": "In-browser developer tools for Zerotal — request traces, an inspector panel, and an extensible tab registry.",
41
41
  "keywords": [
@@ -11,6 +11,7 @@
11
11
  * browser.
12
12
  */
13
13
  import type { RequestTrace } from "../RequestTrace.ts";
14
+ import { requestKind, type RequestKind } from "./kind.ts";
14
15
 
15
16
  /**
16
17
  * A request slower than this reads as slow.
@@ -33,16 +34,37 @@ export interface Facets {
33
34
  slow: boolean;
34
35
  /** Only requests with an N+1 warning. */
35
36
  nPlusOne: boolean;
37
+ /**
38
+ * Request kinds to show. Empty means every kind.
39
+ *
40
+ * The one facet whose usual job is *subtraction*: an app's own traffic is
41
+ * `document` and `api`, and picking those two is how you get a list without
42
+ * fifty stylesheet fetches in it. Picking `asset` alone is the other half —
43
+ * what the browser pulled in, what it cost, and which of it 404'd, which is
44
+ * not visible anywhere else.
45
+ *
46
+ * Optional because {@link Facets} is exported from `@zerotal/devtools/client`
47
+ * and a required field added to a published interface breaks whoever builds one
48
+ * by hand. `noFacets()` always sets it; reads here tolerate its absence.
49
+ */
50
+ kinds?: RequestKind[];
36
51
  }
37
52
 
38
53
  /** No narrowing at all — what a fresh panel starts with. */
39
54
  export function noFacets(): Facets {
40
- return { methods: [], statusClasses: [], errors: false, slow: false, nPlusOne: false };
55
+ return { methods: [], statusClasses: [], errors: false, slow: false, nPlusOne: false, kinds: [] };
41
56
  }
42
57
 
43
58
  /** Whether any facet is actually narrowing, for the "clear" affordance. */
44
59
  export function facetsActive(f: Facets): boolean {
45
- return f.methods.length > 0 || f.statusClasses.length > 0 || f.errors || f.slow || f.nPlusOne;
60
+ return (
61
+ f.methods.length > 0 ||
62
+ f.statusClasses.length > 0 ||
63
+ (f.kinds?.length ?? 0) > 0 ||
64
+ f.errors ||
65
+ f.slow ||
66
+ f.nPlusOne
67
+ );
46
68
  }
47
69
 
48
70
  /**
@@ -88,6 +110,7 @@ export function matchesFacets(trace: RequestTrace, f: Facets): boolean {
88
110
  if (f.errors && !trace.exception && trace.statusCode < 400) return false;
89
111
  if (f.slow && trace.durationMs <= SLOW_MS) return false;
90
112
  if (f.nPlusOne && !trace.warnings.length) return false;
113
+ if (f.kinds?.length && !f.kinds.includes(requestKind(trace))) return false;
91
114
  return true;
92
115
  }
93
116
 
@@ -0,0 +1,138 @@
1
+ /**
2
+ * What kind of request a trace is — the page, the app talking, or a file the
3
+ * browser fetched because the page said to.
4
+ *
5
+ * The panel had no such notion, and one behaviour made that expensive: live mode
6
+ * selects every trace as it arrives, so opening `/login` selected `/login` for a
7
+ * few milliseconds and then `/favicon.ico`, `/css/app.css`, and whatever else the
8
+ * page pulled. The bar named a request nobody asked about, the detail below it
9
+ * described that request's headers and its empty session, and the page you were
10
+ * actually looking at had scrolled into the list. The information was never
11
+ * wrong; it was about the wrong thing.
12
+ *
13
+ * Three kinds, because two would not do the job. Suppressing "not the document"
14
+ * would suppress the form post and the Inertia visit — the requests most worth
15
+ * watching. What deserves to be skipped over is narrower than that: a
16
+ * sub-resource the browser fetched on its own initiative.
17
+ *
18
+ * ## Why the browser is asked rather than the URL
19
+ *
20
+ * `Sec-Fetch-Dest` is the purpose-built answer — the browser states what it
21
+ * wanted the response for, and it is the only source here that cannot be fooled.
22
+ * A `.js` path can serve an API, a route with no extension can serve a
23
+ * stylesheet, and an app that hashes its asset names has neither. The extension
24
+ * rule is last and exists for the clients that send no such header at all.
25
+ */
26
+ import type { RequestTrace } from "../RequestTrace.ts";
27
+
28
+ /**
29
+ * - `document` — a navigation. The page in the address bar.
30
+ * - `api` — the app talking: form posts, Inertia visits, fetch, anything a
31
+ * script asked for on purpose.
32
+ * - `asset` — a sub-resource the browser fetched by itself.
33
+ */
34
+ export type RequestKind = "document" | "api" | "asset";
35
+
36
+ /** `Sec-Fetch-Dest` values that mean the browser wanted a file, not an answer. */
37
+ const ASSET_DESTINATIONS = new Set([
38
+ "image",
39
+ "style",
40
+ "script",
41
+ "font",
42
+ "manifest",
43
+ "audio",
44
+ "video",
45
+ "track",
46
+ "embed",
47
+ "object",
48
+ "worker",
49
+ "sharedworker",
50
+ "serviceworker",
51
+ "paintworklet",
52
+ "audioworklet",
53
+ "xslt",
54
+ "report",
55
+ ]);
56
+
57
+ /** Content types served as files. Checked as prefixes — parameters follow. */
58
+ const ASSET_TYPES = [
59
+ "text/css",
60
+ "image/",
61
+ "font/",
62
+ "audio/",
63
+ "video/",
64
+ "application/javascript",
65
+ "text/javascript",
66
+ "application/font",
67
+ "application/manifest",
68
+ ];
69
+
70
+ /** The last resort, for a client that announces nothing about itself. */
71
+ const ASSET_EXTENSIONS = new Set([
72
+ "css",
73
+ "js",
74
+ "mjs",
75
+ "map",
76
+ "ico",
77
+ "png",
78
+ "jpg",
79
+ "jpeg",
80
+ "gif",
81
+ "svg",
82
+ "webp",
83
+ "avif",
84
+ "woff",
85
+ "woff2",
86
+ "ttf",
87
+ "otf",
88
+ "eot",
89
+ "mp3",
90
+ "mp4",
91
+ "webm",
92
+ "wasm",
93
+ "webmanifest",
94
+ ]);
95
+
96
+ /** Case-insensitive header read — a trace's headers are whatever the client sent. */
97
+ function header(trace: RequestTrace, name: string): string {
98
+ const headers = trace.headers ?? {};
99
+ const hit = Object.keys(headers).find((k) => k.toLowerCase() === name);
100
+ return hit ? String(headers[hit] ?? "").toLowerCase() : "";
101
+ }
102
+
103
+ function responseType(trace: RequestTrace): string {
104
+ const headers = trace.responseHeaders ?? {};
105
+ const hit = Object.keys(headers).find((k) => k.toLowerCase() === "content-type");
106
+ return hit ? String(headers[hit] ?? "").toLowerCase() : "";
107
+ }
108
+
109
+ /** Classify one trace. Never throws, and never guesses `asset` without a reason. */
110
+ export function requestKind(trace: RequestTrace): RequestKind {
111
+ // 1. What the browser says it wanted the response for. Unfoolable, and absent
112
+ // only from clients that are not browsers.
113
+ const dest = header(trace, "sec-fetch-dest");
114
+ if (dest === "document" || dest === "iframe" || dest === "frame") return "document";
115
+ if (ASSET_DESTINATIONS.has(dest)) return "asset";
116
+ if (dest === "empty") return "api";
117
+
118
+ // 2. What was actually served. A page is a page whatever asked for it, which
119
+ // is what makes this right for curl and for a server-rendered form post.
120
+ const type = responseType(trace);
121
+ if (type.startsWith("text/html")) return "document";
122
+ if (ASSET_TYPES.some((prefix) => type.startsWith(prefix))) return "asset";
123
+
124
+ // 3. The path, for a client that announced nothing and a response that carried
125
+ // no type — a 404 for a missing stylesheet, most often.
126
+ const extension = (trace.path ?? "").split("?")[0]?.split(".").pop()?.toLowerCase() ?? "";
127
+ if (extension && ASSET_EXTENSIONS.has(extension)) return "asset";
128
+
129
+ // Unclassifiable is `api`, never `asset`. Being wrong here decides whether a
130
+ // request is skipped over, and skipping the wrong one is how the panel stops
131
+ // showing you what you came to see.
132
+ return "api";
133
+ }
134
+
135
+ /** Whether live mode should move the selection onto this trace. */
136
+ export function worthSelecting(trace: RequestTrace): boolean {
137
+ return requestKind(trace) !== "asset";
138
+ }
@@ -11,6 +11,7 @@ import type { RequestTrace, TraceChannelDescriptor } from "../RequestTrace.ts";
11
11
  import type { EditorName } from "../editor.ts";
12
12
  import type { ClientMetric } from "./metrics.ts";
13
13
  import { noFacets, traceMatches, type Facets } from "./filter.ts";
14
+ import { worthSelecting } from "./kind.ts";
14
15
  import type { ThemeChoice } from "./ui/theme.ts";
15
16
 
16
17
  /**
@@ -335,11 +336,28 @@ export class Store {
335
336
  addTrace(trace: RequestTrace): void {
336
337
  this.traces.unshift(trace);
337
338
  if (this.traces.length > this.capacity) this.traces.length = this.capacity;
338
- if (this.live) this.selected = trace;
339
- else this.pending++;
339
+ if (this.live && this._takesSelection(trace)) this.selected = trace;
340
+ else if (!this.live) this.pending++;
340
341
  this.changed();
341
342
  }
342
343
 
344
+ /**
345
+ * Whether a newly arrived trace should become the selection in live mode.
346
+ *
347
+ * Everything did, which meant a page selected itself and was then replaced by
348
+ * its own favicon a few milliseconds later. The bar named a request nobody
349
+ * asked about while the page you were looking at scrolled into the list.
350
+ *
351
+ * A sub-resource yields to whatever is selected — but only to something. With
352
+ * nothing selected an asset is still better than an empty panel, and a page
353
+ * whose document 304s while its assets do not would otherwise show nothing at
354
+ * all.
355
+ */
356
+ private _takesSelection(trace: RequestTrace): boolean {
357
+ if (worthSelecting(trace)) return true;
358
+ return this.selected === null || !worthSelecting(this.selected);
359
+ }
360
+
343
361
  clear(): void {
344
362
  this.traces = [];
345
363
  this.selected = null;
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { facetsActive, methodsPresent, noFacets, type Facets } from "../filter.ts";
10
10
  import { foldTraceRows, type TraceRow } from "../tree.ts";
11
+ import type { RequestKind } from "../kind.ts";
11
12
  import { dCls, esc, fmt, scCls } from "../ui/format.ts";
12
13
  import { el, reconcile } from "../ui/render.ts";
13
14
  import type { TabContext, TabView } from "./types.ts";
@@ -36,6 +37,16 @@ const STATUS_CLASSES: Array<[string, string]> = [
36
37
  ["5", "5xx"],
37
38
  ];
38
39
 
40
+ /**
41
+ * Labelled for what they are from the reader's side rather than by their code
42
+ * name: "page" is what someone is looking at, "asset" is what came with it.
43
+ */
44
+ const KIND_CHIPS: Array<[RequestKind, string]> = [
45
+ ["document", "page"],
46
+ ["api", "api"],
47
+ ["asset", "asset"],
48
+ ];
49
+
39
50
  // ── Skeleton ──────────────────────────────────────────────────────────────────
40
51
  //
41
52
  // Built once per visit to the tab and then only updated. The filter input in
@@ -82,11 +93,17 @@ function facetChips(store: TabContext["store"]): string {
82
93
  chip("status", digit, label, f.statusClasses.includes(digit)),
83
94
  ).join("");
84
95
 
96
+ const kinds = KIND_CHIPS.map(([kind, label]) =>
97
+ chip("kind", kind, label, f.kinds?.includes(kind) ?? false),
98
+ ).join("");
99
+
85
100
  return (
86
101
  methods +
87
102
  (methods ? `<span class="fsep"></span>` : "") +
88
103
  statuses +
89
104
  `<span class="fsep"></span>` +
105
+ kinds +
106
+ `<span class="fsep"></span>` +
90
107
  chip("errors", "", "errors", f.errors, true) +
91
108
  chip("slow", "", "slow", f.slow, true) +
92
109
  chip("nplus", "", "n+1", f.nPlusOne, true) +
@@ -103,6 +120,8 @@ export function toggleFacet(f: Facets, kind: string, value: string): Facets {
103
120
  return { ...f, methods: drop(f.methods, value) };
104
121
  case "status":
105
122
  return { ...f, statusClasses: drop(f.statusClasses, value) };
123
+ case "kind":
124
+ return { ...f, kinds: drop(f.kinds ?? [], value as RequestKind) };
106
125
  case "errors":
107
126
  return { ...f, errors: !f.errors };
108
127
  case "slow":
package/src/tracing.ts CHANGED
@@ -261,6 +261,11 @@ function _cleanupBuffers(ctx: object): void {
261
261
  */
262
262
  const SAFE_HEADERS = new Set([
263
263
  "accept",
264
+ // What the browser wanted the response for — `document`, `image`, `style`.
265
+ // The only unfoolable way to tell a page from a file the page pulled in, and
266
+ // devtools classifies every trace by it.
267
+ "sec-fetch-dest",
268
+ "sec-fetch-mode",
264
269
  "content-type",
265
270
  "content-length",
266
271
  "user-agent",