@zerotal/devtools 1.6.2 → 1.7.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 (43) hide show
  1. package/CHANGELOG.md +233 -1
  2. package/api-surface.md +296 -0
  3. package/package.json +5 -4
  4. package/src/DevtoolsInjectionMiddleware.ts +41 -4
  5. package/src/RequestTrace.ts +96 -1
  6. package/src/TraceStore.ts +12 -0
  7. package/src/activity.ts +116 -0
  8. package/src/callsite.ts +146 -0
  9. package/src/client/filter.ts +108 -0
  10. package/src/client/index.ts +122 -0
  11. package/src/client/metrics.ts +98 -0
  12. package/src/client/registry.ts +65 -0
  13. package/src/client/state.ts +311 -0
  14. package/src/client/tabs/all.ts +276 -0
  15. package/src/client/tabs/app.ts +292 -0
  16. package/src/client/tabs/cache.ts +49 -0
  17. package/src/client/tabs/channel.ts +263 -0
  18. package/src/client/tabs/exceptions.ts +68 -0
  19. package/src/client/tabs/jobs.ts +50 -0
  20. package/src/client/tabs/logs.ts +44 -0
  21. package/src/client/tabs/mail.ts +59 -0
  22. package/src/client/tabs/queries.ts +124 -0
  23. package/src/client/tabs/request.ts +76 -0
  24. package/src/client/tabs/timeline.ts +132 -0
  25. package/src/client/tabs/types.ts +51 -0
  26. package/src/client/transport.ts +81 -0
  27. package/src/client/tree.ts +138 -0
  28. package/src/client/ui/format.ts +118 -0
  29. package/src/client/ui/render.ts +87 -0
  30. package/src/client/ui/shell.ts +511 -0
  31. package/src/client/ui/theme.ts +389 -0
  32. package/src/client-auto.ts +1 -1
  33. package/src/config.ts +77 -2
  34. package/src/dashboard-auto.ts +1 -1
  35. package/src/editor.ts +107 -0
  36. package/src/enabled.ts +59 -0
  37. package/src/index.ts +19 -3
  38. package/src/map.ts +213 -0
  39. package/src/provider/DevtoolsProvider.ts +32 -7
  40. package/src/redaction.ts +161 -20
  41. package/src/tracing.ts +213 -24
  42. package/src/client.ts +0 -1048
  43. package/src/panel-app.js +0 -519
@@ -0,0 +1,98 @@
1
+ /**
2
+ * What the *browser* measured, alongside what the server did.
3
+ *
4
+ * The panel reports server duration as though it were the user's experience. It
5
+ * is not: a 12ms response that the browser spends 900ms parsing, laying out, and
6
+ * painting is a slow page, and nothing in the trace said so. The panel already
7
+ * runs JavaScript on the page and had never asked the one API that knows.
8
+ *
9
+ * Read once, after the load event, from the Performance timeline — no polling,
10
+ * no observer left running, nothing sampled per frame. It is a report on the
11
+ * document, so there is exactly one of it per page load.
12
+ */
13
+
14
+ /** One browser-side measurement, in the shape the panel's stat grid draws. */
15
+ export interface ClientMetric {
16
+ label: string;
17
+ /** Milliseconds. Rounded — sub-millisecond precision here is noise. */
18
+ value: number;
19
+ /** Longer text for the row's tooltip. */
20
+ detail: string;
21
+ }
22
+
23
+ /**
24
+ * The phases of a page load, as the Navigation Timing entry names them.
25
+ *
26
+ * Chosen for what a developer can act on: time to the first byte is the server
27
+ * plus the network, DOM interactive is parsing, and load is everything the page
28
+ * asked for. The rest of the entry is either derived from these or is about DNS
29
+ * and TLS, which is not what a request inspector is for.
30
+ */
31
+ function navigationMetrics(nav: PerformanceNavigationTiming): ClientMetric[] {
32
+ const out: ClientMetric[] = [];
33
+ const add = (label: string, value: number, detail: string): void => {
34
+ if (Number.isFinite(value) && value > 0) out.push({ label, value: Math.round(value), detail });
35
+ };
36
+ add("TTFB", nav.responseStart - nav.requestStart, "Request sent → first byte back");
37
+ add("Response", nav.responseEnd - nav.responseStart, "First byte → last byte");
38
+ add("DOM interactive", nav.domInteractive - nav.responseEnd, "Parsing the document");
39
+ add("DOM complete", nav.domComplete - nav.domInteractive, "Subresources and deferred scripts");
40
+ add("Load", nav.loadEventEnd - nav.startTime, "Navigation start → load event");
41
+ return out;
42
+ }
43
+
44
+ /**
45
+ * Paint timings, when the browser recorded them.
46
+ *
47
+ * First Contentful Paint is the one number that most closely tracks "did that
48
+ * feel fast", and it is the only Web Vital available without a library and
49
+ * without leaving an observer running for the life of the page.
50
+ */
51
+ function paintMetrics(): ClientMetric[] {
52
+ try {
53
+ return performance
54
+ .getEntriesByType("paint")
55
+ .filter((e) => e.name === "first-contentful-paint")
56
+ .map((e) => ({
57
+ label: "First paint",
58
+ value: Math.round(e.startTime),
59
+ detail: "Navigation start → first content on screen",
60
+ }));
61
+ } catch {
62
+ return [];
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Everything the browser can tell us about this page load, or an empty list.
68
+ *
69
+ * Empty rather than throwing on every browser that does not implement the API,
70
+ * and empty before the load event, when the numbers are not final.
71
+ */
72
+ export function collectClientMetrics(): ClientMetric[] {
73
+ try {
74
+ const [nav] = performance.getEntriesByType("navigation") as PerformanceNavigationTiming[];
75
+ // `loadEventEnd` is 0 until the load event has actually fired; reading before
76
+ // then produces negative durations rather than an error.
77
+ if (!nav || nav.loadEventEnd <= 0) return paintMetrics();
78
+ return [...navigationMetrics(nav), ...paintMetrics()];
79
+ } catch {
80
+ return [];
81
+ }
82
+ }
83
+
84
+ /**
85
+ * Run `fn` once the page has finished loading.
86
+ *
87
+ * A panel injected into a page that has already loaded — which is what happens
88
+ * on a hot reload — would otherwise wait for an event that has been and gone.
89
+ */
90
+ export function onceLoaded(fn: () => void): void {
91
+ if (document.readyState === "complete") {
92
+ // Not synchronously: `loadEventEnd` is stamped *after* the handlers run, so
93
+ // reading it in the same turn as a just-fired load event reads a zero.
94
+ setTimeout(fn, 0);
95
+ return;
96
+ }
97
+ window.addEventListener("load", () => setTimeout(fn, 0), { once: true });
98
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The extension door other packages come through.
3
+ *
4
+ * A package adds its own tab to the injected panel — a unified dev tool across
5
+ * the framework — by calling `window.__zerotalDevtools?.register(panel)` from its
6
+ * own browser code (`@zerotal/flow`'s time-travel timeline is the live consumer).
7
+ * The panel renders the extra tab and calls `panel.render(el)` when it is shown;
8
+ * `refresh(id)` lets the extension push a live update.
9
+ *
10
+ * The registry is created lazily by whichever runs first — this panel or an
11
+ * extension — so registration is order-independent. That shape is public API and
12
+ * has a shipped consumer, so it survived the client rewrite unchanged.
13
+ */
14
+
15
+ /** A panel another package contributes as a tab in the Zerotal devtools. */
16
+ export interface DevtoolsPanelPlugin {
17
+ /** Unique id — the tab is addressed internally as `plugin:<id>`. */
18
+ id: string;
19
+ /** Tab label. */
20
+ title: string;
21
+ /** Optional badge value (e.g. a count); a falsy return hides the badge. */
22
+ badge?: () => number | string | undefined;
23
+ /** Render the panel's content into `el` (the shared, persistent content area). */
24
+ render: (el: HTMLElement) => void;
25
+ }
26
+
27
+ export interface DevtoolsRegistry {
28
+ panels: DevtoolsPanelPlugin[];
29
+ /** @internal set by the host panel — called when a panel registers. */
30
+ _emit: ((p: DevtoolsPanelPlugin) => void) | null;
31
+ /** @internal set by the host panel — called on refresh(id). */
32
+ _refresh: ((id?: string) => void) | null;
33
+ register(panel: DevtoolsPanelPlugin): DevtoolsPanelPlugin;
34
+ refresh(id?: string): void;
35
+ }
36
+
37
+ // `window.__zerotalDevtools` is the documented extension point above, so it
38
+ // belongs on `Window` rather than behind a cast at each use. Declared, not
39
+ // asserted: an extension reading it from its own code gets the same type.
40
+ declare global {
41
+ interface Window {
42
+ __zerotalDevtools?: DevtoolsRegistry;
43
+ }
44
+ }
45
+
46
+ /** Get-or-create the global devtools extension registry. */
47
+ export function ensureRegistry(): DevtoolsRegistry {
48
+ const w = window;
49
+ if (!w.__zerotalDevtools) {
50
+ w.__zerotalDevtools = {
51
+ panels: [],
52
+ _emit: null,
53
+ _refresh: null,
54
+ register(panel: DevtoolsPanelPlugin) {
55
+ this.panels.push(panel);
56
+ this._emit?.(panel);
57
+ return panel;
58
+ },
59
+ refresh(id?: string) {
60
+ this._refresh?.(id);
61
+ },
62
+ };
63
+ }
64
+ return w.__zerotalDevtools;
65
+ }
@@ -0,0 +1,311 @@
1
+ /**
2
+ * Everything the panel knows, in one place, with a change signal.
3
+ *
4
+ * It used to be sixteen `let`s inside `DevTools.start()` and a scattering of
5
+ * `renderBar(); if (open) renderContent();` after each assignment — which meant
6
+ * adding a piece of state meant finding every place that had to redraw because of
7
+ * it, and forgetting one was a panel that showed something stale. Now a mutation
8
+ * calls {@link Store.changed} and the shell decides what to redraw.
9
+ */
10
+ import type { RequestTrace, TraceChannelDescriptor } from "../RequestTrace.ts";
11
+ import type { EditorName } from "../editor.ts";
12
+ import type { ClientMetric } from "./metrics.ts";
13
+ import { noFacets, traceMatches, type Facets } from "./filter.ts";
14
+ import type { ThemeChoice } from "./ui/theme.ts";
15
+
16
+ /**
17
+ * How the panel turns a captured location into a link.
18
+ *
19
+ * Sent by the server rather than configured in the browser: the paths come from
20
+ * the process that recorded them, so the process that recorded them is what
21
+ * knows how to rewrite them.
22
+ */
23
+ export interface EditorSettings {
24
+ editor: EditorName | null;
25
+ editorPathMap: Record<string, string>;
26
+ }
27
+
28
+ /**
29
+ * The slice of state that outlives a reload.
30
+ *
31
+ * Which tab you were on, what you had filtered to, how tall you dragged the
32
+ * panel, and whether it was open are answers to "where was I", and every reload
33
+ * used to throw them away — which on a page you are reloading *because* you are
34
+ * debugging it is the wrong moment to lose them.
35
+ */
36
+ export interface PersistedUi {
37
+ open: boolean;
38
+ section: Section;
39
+ tab: string;
40
+ appTab: string;
41
+ filter: string;
42
+ facets: Facets;
43
+ height: number;
44
+ theme: ThemeChoice;
45
+ }
46
+
47
+ /**
48
+ * Which half of the panel is showing.
49
+ *
50
+ * `requests` is the trace stream — what the app just did. `app` is the framework
51
+ * map — what the app *is*. Two sections rather than fifteen tabs in one strip:
52
+ * they answer different questions, and a strip you have to scroll to reach the
53
+ * routes list is one you stop reaching for.
54
+ */
55
+ export type Section = "requests" | "app";
56
+
57
+ const UI_KEY = "__zerotal_devtools_ui";
58
+
59
+ /** Panel heights outside this range are a panel you cannot use. */
60
+ export const MIN_HEIGHT = 120;
61
+ export const DEFAULT_HEIGHT = 380;
62
+
63
+ function loadUi(): Partial<PersistedUi> {
64
+ try {
65
+ const raw = localStorage.getItem(UI_KEY);
66
+ const parsed: unknown = raw ? JSON.parse(raw) : null;
67
+ return parsed && typeof parsed === "object" ? (parsed as Partial<PersistedUi>) : {};
68
+ } catch {
69
+ // Private mode, a disabled store, a half-written value — a dev panel that
70
+ // cannot remember its tab is fine; one that throws on boot is not.
71
+ return {};
72
+ }
73
+ }
74
+
75
+ function saveUi(state: PersistedUi): void {
76
+ try {
77
+ localStorage.setItem(UI_KEY, JSON.stringify(state));
78
+ } catch {
79
+ /* see above */
80
+ }
81
+ }
82
+
83
+ export class Store {
84
+ // ── Server state ────────────────────────────────────────────────────────────
85
+ traces: RequestTrace[] = [];
86
+ channels: TraceChannelDescriptor[] = [];
87
+ selected: RequestTrace | null = null;
88
+ connected = false;
89
+ /**
90
+ * How many traces to keep. Replaced by the server's real capacity when the
91
+ * history frame lands; until then this matches the store's own default rather
92
+ * than being a number of the client's own that a configured capacity could not
93
+ * move.
94
+ */
95
+ capacity = 100;
96
+ /** Replaced by the app's real settings when the history frame lands. */
97
+ editor: EditorSettings = { editor: null, editorPathMap: {} };
98
+
99
+ // ── Session state ───────────────────────────────────────────────────────────
100
+ /** Following the newest request, rather than pinned to one you picked. */
101
+ live = true;
102
+ /** Traces that arrived while pinned — offered, never jumped to. */
103
+ pending = 0;
104
+ /** Correlated-request groups opened on the All tab. */
105
+ readonly expanded = new Set<string>();
106
+ /**
107
+ * What the browser measured for this page load.
108
+ *
109
+ * Per page, not per request — which is why they sit above the waterfall
110
+ * labelled as the browser's rather than being merged into it. A 12ms response
111
+ * the browser spends 900ms painting is a slow page, and the server trace
112
+ * cannot say so.
113
+ */
114
+ clientMetrics: ClientMetric[] = [];
115
+
116
+ // ── Persisted UI ────────────────────────────────────────────────────────────
117
+ open: boolean;
118
+ section: Section;
119
+ tab: string;
120
+ /** The App section's tab, kept apart so switching sections restores each one. */
121
+ appTab: string;
122
+ filter: string;
123
+ facets: Facets;
124
+ height: number;
125
+ theme: ThemeChoice;
126
+
127
+ /**
128
+ * Bumped on every change. A tab that must redraw whenever anything moved —
129
+ * rather than only when the selected trace changed — reads this into its cache
130
+ * key, which is how the All tab stays live while the others stay still.
131
+ */
132
+ revision = 0;
133
+
134
+ private readonly listeners = new Set<() => void>();
135
+
136
+ /**
137
+ * @param standalone - The dashboard has nothing to collapse into; it starts open.
138
+ * @param base - The devtools endpoint root, for the surfaces that fetch rather
139
+ * than listen. The App section reads the framework map over it.
140
+ */
141
+ constructor(
142
+ standalone: boolean,
143
+ readonly base = "/__zerotal/devtools",
144
+ ) {
145
+ const saved = loadUi();
146
+ this.open = standalone || saved.open === true;
147
+ this.section = saved.section === "app" ? "app" : "requests";
148
+ this.tab = saved.tab ?? "queries";
149
+ this.appTab = saved.appTab ?? "app:routes";
150
+ this.filter = saved.filter ?? "";
151
+ this.facets = { ...noFacets(), ...(saved.facets ?? {}) };
152
+ this.height = Math.max(MIN_HEIGHT, saved.height ?? DEFAULT_HEIGHT);
153
+ this.theme = saved.theme ?? "auto";
154
+ }
155
+
156
+ subscribe(fn: () => void): () => void {
157
+ this.listeners.add(fn);
158
+ return () => this.listeners.delete(fn);
159
+ }
160
+
161
+ /** Announce a change. Every mutation ends here; nothing redraws without it. */
162
+ changed(): void {
163
+ this.revision++;
164
+ for (const fn of this.listeners) fn();
165
+ }
166
+
167
+ /** Write the durable slice out. Called from the mutations that touch it. */
168
+ persist(): void {
169
+ saveUi({
170
+ open: this.open,
171
+ section: this.section,
172
+ appTab: this.appTab,
173
+ tab: this.tab,
174
+ filter: this.filter,
175
+ facets: this.facets,
176
+ height: this.height,
177
+ theme: this.theme,
178
+ });
179
+ }
180
+
181
+ // ── Derived ─────────────────────────────────────────────────────────────────
182
+
183
+ /**
184
+ * The traces the All tab shows, with their index in the full list.
185
+ *
186
+ * The index rides along because a click has to select the right trace after
187
+ * filtering, and because keyboard navigation steps through *this* list rather
188
+ * than through everything recorded.
189
+ */
190
+ visible(): Array<{ trace: RequestTrace; index: number }> {
191
+ const out: Array<{ trace: RequestTrace; index: number }> = [];
192
+ this.traces.forEach((trace, index) => {
193
+ if (traceMatches(trace, this.filter, this.facets)) out.push({ trace, index });
194
+ });
195
+ return out;
196
+ }
197
+
198
+ // ── Mutations ───────────────────────────────────────────────────────────────
199
+
200
+ /** The tab showing in the current section. */
201
+ get activeTab(): string {
202
+ return this.section === "app" ? this.appTab : this.tab;
203
+ }
204
+
205
+ setTab(tab: string): void {
206
+ const key = this.section === "app" ? "appTab" : "tab";
207
+ if (this[key] === tab) return;
208
+ this[key] = tab;
209
+ this.persist();
210
+ this.changed();
211
+ }
212
+
213
+ setSection(section: Section): void {
214
+ if (this.section === section) return;
215
+ this.section = section;
216
+ this.persist();
217
+ this.changed();
218
+ }
219
+
220
+ setFilter(filter: string): void {
221
+ this.filter = filter;
222
+ this.persist();
223
+ this.changed();
224
+ }
225
+
226
+ setFacets(facets: Facets): void {
227
+ this.facets = facets;
228
+ this.persist();
229
+ this.changed();
230
+ }
231
+
232
+ setHeight(height: number): void {
233
+ this.height = Math.max(MIN_HEIGHT, Math.round(height));
234
+ this.persist();
235
+ this.changed();
236
+ }
237
+
238
+ setTheme(theme: ThemeChoice): void {
239
+ this.theme = theme;
240
+ this.persist();
241
+ this.changed();
242
+ }
243
+
244
+ setOpen(open: boolean): void {
245
+ this.open = open;
246
+ this.persist();
247
+ this.changed();
248
+ }
249
+
250
+ /** Pin a trace and stop following the newest. */
251
+ select(trace: RequestTrace | null, { switchTab = false } = {}): void {
252
+ this.selected = trace;
253
+ this.live = false;
254
+ this.pending = 0;
255
+ if (switchTab && this.tab === "all") this.tab = "queries";
256
+ this.persist();
257
+ this.changed();
258
+ }
259
+
260
+ /** Follow the newest request again, clearing the backlog offer. */
261
+ follow(): void {
262
+ this.live = true;
263
+ this.pending = 0;
264
+ this.selected = this.traces[0] ?? null;
265
+ this.changed();
266
+ }
267
+
268
+ pin(): void {
269
+ this.live = false;
270
+ this.changed();
271
+ }
272
+
273
+ toggleGroup(key: string): void {
274
+ if (!this.expanded.delete(key)) this.expanded.add(key);
275
+ this.changed();
276
+ }
277
+
278
+ /** Apply the stream's opening frame. */
279
+ loadHistory(
280
+ traces: RequestTrace[],
281
+ channels: TraceChannelDescriptor[],
282
+ capacity?: number,
283
+ editor?: Partial<EditorSettings>,
284
+ ): void {
285
+ this.traces = traces;
286
+ this.channels = channels;
287
+ if (editor) this.editor = { ...this.editor, ...editor };
288
+ // An older server sends no capacity; keeping what we have then is better than
289
+ // trimming its history to a guess.
290
+ if (typeof capacity === "number" && capacity > 0) this.capacity = capacity;
291
+ if (this.live || !this.selected) this.selected = traces[0] ?? null;
292
+ this.changed();
293
+ }
294
+
295
+ /** Take one new trace off the stream. */
296
+ addTrace(trace: RequestTrace): void {
297
+ this.traces.unshift(trace);
298
+ if (this.traces.length > this.capacity) this.traces.length = this.capacity;
299
+ if (this.live) this.selected = trace;
300
+ else this.pending++;
301
+ this.changed();
302
+ }
303
+
304
+ clear(): void {
305
+ this.traces = [];
306
+ this.selected = null;
307
+ this.pending = 0;
308
+ this.expanded.clear();
309
+ this.changed();
310
+ }
311
+ }