@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 +34 -0
- package/api-surface.md +1 -0
- package/package.json +3 -3
- package/src/client/filter.ts +25 -2
- package/src/client/kind.ts +138 -0
- package/src/client/state.ts +20 -2
- package/src/client/tabs/all.ts +19 -0
- package/src/tracing.ts +5 -0
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zerotal/devtools",
|
|
3
|
-
"version": "1.8.
|
|
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.
|
|
34
|
+
"@zerotal/core": "1.8.1"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"typescript": "^5.8.0",
|
|
38
|
-
"@zerotal/orm": "1.8.
|
|
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": [
|
package/src/client/filter.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/src/client/state.ts
CHANGED
|
@@ -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;
|
package/src/client/tabs/all.ts
CHANGED
|
@@ -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",
|