@zerotal/devtools 1.8.0 → 1.9.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/CHANGELOG.md CHANGED
@@ -8,6 +8,50 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.9.0] — 2026-08-29
12
+
13
+ ### Documented
14
+
15
+ - **Every promised export is documented.** The `docs-coverage` gate reads `maturity: stable` as a
16
+ promise about a package's exports, and measures how much of that promise is written down. It
17
+ was 798 gaps across the suite; it is now zero. This package's share is covered on its own
18
+ pages — types named, options shapes described, and the decisions behind them recorded where
19
+ somebody looking for them will find them.
20
+
21
+ ## [1.8.1] — 2026-08-26
22
+
23
+ ### Fixed
24
+
25
+ - **A page keeps the panel while its own assets load.** Opening `/login` selected `/login`,
26
+ then `/favicon.ico` a few milliseconds later, then `/css/app.css`: live mode selected every
27
+ trace as it arrived, and a page's sub-resources arrive right behind it. The bar named a
28
+ request nobody asked about, the detail below described that request's headers and its empty
29
+ session, and the page under inspection had scrolled into the list. Nothing shown was wrong;
30
+ it was all about the wrong request.
31
+
32
+ Traces are classified into three kinds rather than two, because "not the document" would
33
+ have suppressed the form post and the Inertia visit — the requests most worth watching.
34
+ What is skipped over is narrower: a sub-resource the browser fetched on its own initiative.
35
+ The browser is asked rather than the URL, since an app may serve an API from a `.js` route
36
+ and a build that hashes its asset names has no extension to read; response content type is
37
+ the fallback, so a page fetched by curl still reads as a page. Anything unclassifiable is
38
+ `api`, never `asset`. An asset still takes the selection when nothing is selected, so a
39
+ panel opened mid-load shows a request rather than an empty pane, and a paused panel still
40
+ counts assets toward its pending badge.
41
+
42
+ ### Added
43
+
44
+ - **A `kind` facet on the All tab**, beside method and status. Assets were never the problem,
45
+ only their claim on the selection, so they are not hidden: pick `page` and `api` for a list
46
+ without fifty stylesheet fetches in it, or `asset` alone for what the browser pulled in,
47
+ what it cost and which of it 404'd — which was not visible anywhere before. `Facets.kinds`
48
+ is optional, because that interface is exported from `@zerotal/devtools/client` and a
49
+ required field added to a published interface breaks whoever builds one by hand.
50
+
51
+ - **`sec-fetch-dest` and `sec-fetch-mode` are recorded**, joining the safe header list. They
52
+ are where the classification above comes from, and they state what a request was for
53
+ without saying anything about who made it.
54
+
11
55
  ## [1.7.4] — 2026-08-21
12
56
 
13
57
  ### Fixed
package/api-surface.md CHANGED
@@ -45,10 +45,6 @@ class TraceStore = {
45
45
  subscribe: (fn: Subscriber) => () => void
46
46
  }
47
47
 
48
- const traceSink = TraceSink
49
-
50
- function attributeBindings = (sql: string, count: number) => Array<string | undefined>
51
-
52
48
  function DevtoolsConfig = (options?: Partial<DevtoolsConfigShape>) => DevtoolsConfigShape
53
49
 
54
50
  function devtoolsEnabled = () => boolean
@@ -61,10 +57,6 @@ function redactCacheKey = (key: string, options?: RedactionOptions) => string
61
57
 
62
58
  function redactValue = (value: unknown, options?: RedactionOptions) => unknown
63
59
 
64
- function startDevtoolsStream = () => () => void
65
-
66
- function traceChannels = () => TraceChannelDescriptor[]
67
-
68
60
  function traceStore = () => TraceStore
69
61
 
70
62
  interface AuthInfo = {
@@ -94,8 +86,6 @@ interface DevtoolsConfigShape = {
94
86
  redact: RedactionOptions
95
87
  }
96
88
 
97
- interface DevtoolsInjectionOptions = {}
98
-
99
89
  interface DevtoolsPanelPlugin = {
100
90
  badge?: () => number | string | undefined
101
91
  id: string
@@ -276,6 +266,7 @@ interface DevtoolsPanelPlugin = {
276
266
 
277
267
  interface Facets = {
278
268
  errors: boolean
269
+ kinds?: RequestKind[]
279
270
  methods: string[]
280
271
  nPlusOne: boolean
281
272
  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.9.0",
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.9.0"
35
35
  },
36
36
  "devDependencies": {
37
37
  "typescript": "^5.8.0",
38
- "@zerotal/orm": "1.8.0"
38
+ "@zerotal/orm": "1.9.0"
39
39
  },
40
40
  "description": "In-browser developer tools for Zerotal — request traces, an inspector panel, and an extensible tab registry.",
41
41
  "keywords": [
@@ -43,6 +43,7 @@ async function _buildDashboardJs(): Promise<string> {
43
43
  return _dashboardJs;
44
44
  }
45
45
 
46
+ /** @internal */
46
47
  export interface DevtoolsInjectionOptions {
47
48
  // reserved for future use
48
49
  }
@@ -102,6 +103,8 @@ const SSE_HEARTBEAT_MS = 25_000;
102
103
  * built the one the app's config asks for.
103
104
  *
104
105
  * @returns A disposer that unsubscribes and closes connected clients.
106
+ *
107
+ * @internal
105
108
  */
106
109
  export function startDevtoolsStream(): () => void {
107
110
  const unsubscribe = traceStore().subscribe((trace) => {
@@ -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/redaction.ts CHANGED
@@ -247,6 +247,8 @@ export function redactBindings(
247
247
  * `SET col = ?`, and `WHERE col <op> ?` (including `IN (?, ?)`, where every
248
248
  * placeholder belongs to the same column). Returns `undefined` at any position it
249
249
  * cannot attribute.
250
+ *
251
+ * @internal
250
252
  */
251
253
  export function attributeBindings(sql: string, count: number): Array<string | undefined> {
252
254
  const out: Array<string | undefined> = new Array(count).fill(undefined);
package/src/tracing.ts CHANGED
@@ -99,6 +99,8 @@ const _channels = new Map<string, TraceChannelDescriptor>();
99
99
  *
100
100
  * A `hidden` channel is left out: its entries are still recorded and still reach
101
101
  * the panel on the trace, but whatever renders them is not this generic row list.
102
+ *
103
+ * @internal
102
104
  */
103
105
  export function traceChannels(): TraceChannelDescriptor[] {
104
106
  return [...(_channels.values() as Iterable<TraceChannelDescriptor>)]
@@ -199,6 +201,7 @@ export interface TraceSink {
199
201
  // nothing: by the time a panel draws a row, the unredacted copy has already been
200
202
  // streamed to the browser and written to `.zerotal/devtools.sqlite`, where it
201
203
  // sits for a day. The sink is the last point where "not recorded" is still true.
204
+ /** @internal */
202
205
  export const traceSink: TraceSink = {
203
206
  channel(descriptor: TraceChannelDescriptor): void {
204
207
  _channels.set(descriptor.id, descriptor);
@@ -261,6 +264,11 @@ function _cleanupBuffers(ctx: object): void {
261
264
  */
262
265
  const SAFE_HEADERS = new Set([
263
266
  "accept",
267
+ // What the browser wanted the response for — `document`, `image`, `style`.
268
+ // The only unfoolable way to tell a page from a file the page pulled in, and
269
+ // devtools classifies every trace by it.
270
+ "sec-fetch-dest",
271
+ "sec-fetch-mode",
264
272
  "content-type",
265
273
  "content-length",
266
274
  "user-agent",