@zerotal/devtools 1.6.3 → 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.
- package/CHANGELOG.md +233 -1
- package/api-surface.md +296 -0
- package/package.json +5 -4
- package/src/DevtoolsInjectionMiddleware.ts +41 -4
- package/src/RequestTrace.ts +96 -1
- package/src/TraceStore.ts +12 -0
- package/src/activity.ts +116 -0
- package/src/callsite.ts +146 -0
- package/src/client/filter.ts +108 -0
- package/src/client/index.ts +122 -0
- package/src/client/metrics.ts +98 -0
- package/src/client/registry.ts +65 -0
- package/src/client/state.ts +311 -0
- package/src/client/tabs/all.ts +276 -0
- package/src/client/tabs/app.ts +292 -0
- package/src/client/tabs/cache.ts +49 -0
- package/src/client/tabs/channel.ts +263 -0
- package/src/client/tabs/exceptions.ts +68 -0
- package/src/client/tabs/jobs.ts +50 -0
- package/src/client/tabs/logs.ts +44 -0
- package/src/client/tabs/mail.ts +59 -0
- package/src/client/tabs/queries.ts +124 -0
- package/src/client/tabs/request.ts +76 -0
- package/src/client/tabs/timeline.ts +132 -0
- package/src/client/tabs/types.ts +51 -0
- package/src/client/transport.ts +81 -0
- package/src/client/tree.ts +138 -0
- package/src/client/ui/format.ts +118 -0
- package/src/client/ui/render.ts +87 -0
- package/src/client/ui/shell.ts +511 -0
- package/src/client/ui/theme.ts +389 -0
- package/src/client-auto.ts +1 -1
- package/src/config.ts +77 -2
- package/src/dashboard-auto.ts +1 -1
- package/src/editor.ts +107 -0
- package/src/enabled.ts +59 -0
- package/src/index.ts +19 -3
- package/src/map.ts +213 -0
- package/src/provider/DevtoolsProvider.ts +32 -7
- package/src/redaction.ts +161 -20
- package/src/tracing.ts +213 -24
- package/src/client.ts +0 -1048
- package/src/panel-app.js +0 -519
package/src/RequestTrace.ts
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
|
+
import type { SourceLocation } from "./editor.ts";
|
|
2
|
+
|
|
1
3
|
export interface QuerySpan {
|
|
2
4
|
sql: string;
|
|
3
5
|
bindings: unknown[];
|
|
4
6
|
startMs: number;
|
|
5
7
|
durationMs: number;
|
|
6
8
|
rowCount: number;
|
|
9
|
+
/**
|
|
10
|
+
* The application line that ran this query, when one could be found.
|
|
11
|
+
*
|
|
12
|
+
* Absent for a query with no application frame above it — a seeder, a
|
|
13
|
+
* framework-internal read — which is a truthful answer and better than
|
|
14
|
+
* pointing at a file nobody wrote.
|
|
15
|
+
*/
|
|
16
|
+
source?: SourceLocation;
|
|
7
17
|
}
|
|
8
18
|
|
|
9
19
|
export interface NPlusOneWarning {
|
|
@@ -27,6 +37,32 @@ export interface LogEntry {
|
|
|
27
37
|
level: "log" | "debug" | "info" | "warn" | "error";
|
|
28
38
|
args: string[];
|
|
29
39
|
offsetMs: number;
|
|
40
|
+
/** The application line that logged this, when one could be found. */
|
|
41
|
+
source?: SourceLocation;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The error that propagated out of the request pipeline, when one did.
|
|
46
|
+
*
|
|
47
|
+
* A failed request finalises like any other, so its status code was always on
|
|
48
|
+
* the trace — but the message that caused it was not, and a red `500` with no
|
|
49
|
+
* text next to it is the one thing a request inspector must not do.
|
|
50
|
+
*/
|
|
51
|
+
export interface ExceptionInfo {
|
|
52
|
+
/** The error's message as it left the pipeline. */
|
|
53
|
+
message: string;
|
|
54
|
+
/** The status the rendered error response used. */
|
|
55
|
+
status: number;
|
|
56
|
+
/** The error's class name, when the failure was an `Error`. */
|
|
57
|
+
type?: string;
|
|
58
|
+
/**
|
|
59
|
+
* The stack, innermost first, with framework frames kept.
|
|
60
|
+
*
|
|
61
|
+
* Unlike a query's call site this is deliberately *not* filtered to
|
|
62
|
+
* application code: you read a stack trace to find out how you got somewhere,
|
|
63
|
+
* and a trace with the middle removed does not tell you that.
|
|
64
|
+
*/
|
|
65
|
+
frames?: SourceLocation[];
|
|
30
66
|
}
|
|
31
67
|
|
|
32
68
|
export interface MailEntry {
|
|
@@ -105,6 +141,53 @@ export interface TraceChannelDescriptor {
|
|
|
105
141
|
warn?: string;
|
|
106
142
|
/** Sort order among channel tabs. Lower sorts first. Defaults to 100. */
|
|
107
143
|
order?: number;
|
|
144
|
+
|
|
145
|
+
// ── Presentation hints ──────────────────────────────────────────────────────
|
|
146
|
+
//
|
|
147
|
+
// A flat list of badge-title-meta rows is the right shape for an audit feed and
|
|
148
|
+
// the wrong one for a prop map or a route table. These pick a different
|
|
149
|
+
// presentation without breaking the property that makes channels worth having:
|
|
150
|
+
// everything here is still *data*, so devtools ships no code per package.
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* How rows are presented. Defaults to `"rows"` — badge, title, and a meta line.
|
|
154
|
+
*
|
|
155
|
+
* - `"rows"` — one block per entry.
|
|
156
|
+
* - `"tree"` — {@link treeField} holds a map of dotted paths; shared prefixes
|
|
157
|
+
* become branches.
|
|
158
|
+
* - `"table"` — one row per entry, {@link meta} as columns. For many entries
|
|
159
|
+
* with the same shape.
|
|
160
|
+
* - `"kv"` — every field of every entry as a key/value table. For a handful of
|
|
161
|
+
* entries with many fields.
|
|
162
|
+
* - `"grouped"` — entries collected under the value of {@link groupBy}.
|
|
163
|
+
*/
|
|
164
|
+
render?: "rows" | "tree" | "table" | "kv" | "grouped";
|
|
165
|
+
/**
|
|
166
|
+
* For `"tree"`: the entry field holding the tree, as a map of dotted path →
|
|
167
|
+
* a record of that node's attributes. Dotted keys become branches.
|
|
168
|
+
*/
|
|
169
|
+
treeField?: string;
|
|
170
|
+
/** For `"tree"`: the node field rendered as each leaf's leading badge. */
|
|
171
|
+
treeBadge?: string;
|
|
172
|
+
/** For `"grouped"`: the entry field rows are grouped by. */
|
|
173
|
+
groupBy?: string;
|
|
174
|
+
/**
|
|
175
|
+
* Fields rendered as a bare chip when truthy — `shared`, `deferred`, `failed`.
|
|
176
|
+
* Applies to a row's own fields and, under `"tree"`, to each node's.
|
|
177
|
+
*
|
|
178
|
+
* A flag is named by its *field*, so a `true` reads as the word rather than as
|
|
179
|
+
* `deepMerge: true`, which is how a row ends up saying nothing at a glance.
|
|
180
|
+
*/
|
|
181
|
+
flags?: string[];
|
|
182
|
+
/**
|
|
183
|
+
* The entry field whose value groups whole *traces* together on the All tab.
|
|
184
|
+
*
|
|
185
|
+
* One request can cause several — a visit and the deferred-prop loads it
|
|
186
|
+
* triggers — and listing them as unrelated siblings is how the thing you are
|
|
187
|
+
* debugging scrolls away. Traces sharing a value here collapse into one
|
|
188
|
+
* expandable entry. Read from the channel's first entry on each trace.
|
|
189
|
+
*/
|
|
190
|
+
traceGroup?: string;
|
|
108
191
|
}
|
|
109
192
|
|
|
110
193
|
export interface RequestTrace {
|
|
@@ -121,14 +204,26 @@ export interface RequestTrace {
|
|
|
121
204
|
memory: number;
|
|
122
205
|
/** URL query string parameters */
|
|
123
206
|
queryParams: Record<string, string>;
|
|
124
|
-
/** Filtered request headers (
|
|
207
|
+
/** Filtered request headers (never auth/cookie values) */
|
|
125
208
|
headers: Record<string, string>;
|
|
209
|
+
/** Filtered response headers — the other half of the exchange */
|
|
210
|
+
responseHeaders: Record<string, string>;
|
|
211
|
+
/**
|
|
212
|
+
* Session key names, never values.
|
|
213
|
+
*
|
|
214
|
+
* "Is the CSRF token there, did the flash survive the redirect, is the user id
|
|
215
|
+
* set" are all answered by the keys — and the values are the request's real
|
|
216
|
+
* state, on a trace that is written to disk for a day.
|
|
217
|
+
*/
|
|
218
|
+
session: string[];
|
|
126
219
|
/** Matched route pattern, controller, and action */
|
|
127
220
|
route: RouteInfo | null;
|
|
128
221
|
/** Authenticated user at the end of the request, or null for guests */
|
|
129
222
|
auth: AuthInfo | null;
|
|
130
223
|
/** Console log/debug/info/warn/error messages emitted during the request */
|
|
131
224
|
logs: LogEntry[];
|
|
225
|
+
/** The error that ended the request, or null when it completed normally */
|
|
226
|
+
exception: ExceptionInfo | null;
|
|
132
227
|
/** Emails sent (or queued) during this request */
|
|
133
228
|
mail: MailEntry[];
|
|
134
229
|
/** Cache operations performed during this request */
|
package/src/TraceStore.ts
CHANGED
|
@@ -138,6 +138,18 @@ export class TraceStore {
|
|
|
138
138
|
return this._db !== null;
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
+
/**
|
|
142
|
+
* How many traces this store keeps.
|
|
143
|
+
*
|
|
144
|
+
* Read by the SSE stream so the panel can trim its own list to the same depth.
|
|
145
|
+
* The client used to cap at a hardcoded 100, so an app that configured a larger
|
|
146
|
+
* capacity got the full history in the opening frame and then silently lost
|
|
147
|
+
* everything past 100 as soon as the next request arrived.
|
|
148
|
+
*/
|
|
149
|
+
get capacity(): number {
|
|
150
|
+
return this._capacity;
|
|
151
|
+
}
|
|
152
|
+
|
|
141
153
|
// ── Persistence ───────────────────────────────────────────────────────
|
|
142
154
|
|
|
143
155
|
/** Open the database and load history, once, on first use. */
|
package/src/activity.ts
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the application did when nobody was making a request.
|
|
3
|
+
*
|
|
4
|
+
* A scheduled task that fails at 03:00 leaves no trace in the tool whose job is
|
|
5
|
+
* to show you what your app did — because every surface in the panel until now
|
|
6
|
+
* hangs off an `HttpContext`, and a console command and a cron tick have none.
|
|
7
|
+
* `CommandRan`, `TaskRan`, `TaskFailed` and `TaskSkipped` were all on the bus and
|
|
8
|
+
* all went nowhere.
|
|
9
|
+
*
|
|
10
|
+
* A small ring of its own rather than a channel, for the reason channels exist:
|
|
11
|
+
* a channel entry belongs to a request. These belong to the process.
|
|
12
|
+
*/
|
|
13
|
+
import { FrameworkEvents } from "@zerotal/core";
|
|
14
|
+
import type { CommandRan } from "@zerotal/core";
|
|
15
|
+
|
|
16
|
+
/** One thing the app did outside a request. */
|
|
17
|
+
export interface ActivityEntry {
|
|
18
|
+
kind: "command" | "task";
|
|
19
|
+
name: string;
|
|
20
|
+
/** `ok`, `failed`, or why a task was skipped. */
|
|
21
|
+
outcome: string;
|
|
22
|
+
durationMs: number;
|
|
23
|
+
/** Unix milliseconds, so the panel can show when rather than only what. */
|
|
24
|
+
at: number;
|
|
25
|
+
failed: boolean;
|
|
26
|
+
detail?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* How many entries to keep.
|
|
31
|
+
*
|
|
32
|
+
* A long-lived dev server running a per-minute schedule produces 1,440 of these
|
|
33
|
+
* a day; the useful window is the last few dozen. Unbounded here would be a
|
|
34
|
+
* memory leak with a friendly name.
|
|
35
|
+
*/
|
|
36
|
+
const MAX_ENTRIES = 200;
|
|
37
|
+
|
|
38
|
+
let _entries: ActivityEntry[] = [];
|
|
39
|
+
|
|
40
|
+
/** Newest first, as the panel draws them. */
|
|
41
|
+
export function activityFeed(): ActivityEntry[] {
|
|
42
|
+
return [..._entries].reverse();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** @internal — drop everything (provider teardown, tests). */
|
|
46
|
+
export function _resetActivity(): void {
|
|
47
|
+
_entries = [];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function push(entry: ActivityEntry): void {
|
|
51
|
+
_entries.push(entry);
|
|
52
|
+
if (_entries.length > MAX_ENTRIES) _entries.shift();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Subscribe to the non-HTTP lifecycle events.
|
|
57
|
+
*
|
|
58
|
+
* The scheduler's events are subscribed **by kind string** rather than by class:
|
|
59
|
+
* `@zerotal/scheduler` is an optional package, and importing its event classes
|
|
60
|
+
* to name them would make devtools depend on it. The bus supports either door
|
|
61
|
+
* and a string subscription costs nothing when nothing ever emits.
|
|
62
|
+
*
|
|
63
|
+
* @returns A disposer that removes every subscription.
|
|
64
|
+
*/
|
|
65
|
+
export function startActivityCapture(): () => void {
|
|
66
|
+
const unsubs = [
|
|
67
|
+
FrameworkEvents.on<CommandRan>("CommandRan", (e) => {
|
|
68
|
+
push({
|
|
69
|
+
kind: "command",
|
|
70
|
+
name: e.name,
|
|
71
|
+
outcome: e.ok ? "ok" : `exit ${e.exitCode}`,
|
|
72
|
+
durationMs: e.durationMs,
|
|
73
|
+
at: Date.now(),
|
|
74
|
+
failed: !e.ok,
|
|
75
|
+
...(e.error ? { detail: e.error } : {}),
|
|
76
|
+
});
|
|
77
|
+
}),
|
|
78
|
+
FrameworkEvents.on<{ name: string; durationMs: number; ok: boolean }>("TaskRan", (e) => {
|
|
79
|
+
push({
|
|
80
|
+
kind: "task",
|
|
81
|
+
name: e.name,
|
|
82
|
+
outcome: e.ok ? "ok" : "failed",
|
|
83
|
+
durationMs: e.durationMs,
|
|
84
|
+
at: Date.now(),
|
|
85
|
+
failed: !e.ok,
|
|
86
|
+
});
|
|
87
|
+
}),
|
|
88
|
+
FrameworkEvents.on<{ name: string; durationMs: number; error: string }>("TaskFailed", (e) => {
|
|
89
|
+
push({
|
|
90
|
+
kind: "task",
|
|
91
|
+
name: e.name,
|
|
92
|
+
outcome: "failed",
|
|
93
|
+
durationMs: e.durationMs,
|
|
94
|
+
at: Date.now(),
|
|
95
|
+
failed: true,
|
|
96
|
+
detail: e.error,
|
|
97
|
+
});
|
|
98
|
+
}),
|
|
99
|
+
FrameworkEvents.on<{ name: string; reason: string }>("TaskSkipped", (e) => {
|
|
100
|
+
push({
|
|
101
|
+
kind: "task",
|
|
102
|
+
name: e.name,
|
|
103
|
+
// Why it skipped is the whole content of the event: "skipped" alone
|
|
104
|
+
// sends you looking for a bug in a task that was told not to run.
|
|
105
|
+
outcome: `skipped · ${e.reason}`,
|
|
106
|
+
durationMs: 0,
|
|
107
|
+
at: Date.now(),
|
|
108
|
+
failed: false,
|
|
109
|
+
});
|
|
110
|
+
}),
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
return () => {
|
|
114
|
+
for (const unsub of unsubs) unsub();
|
|
115
|
+
};
|
|
116
|
+
}
|
package/src/callsite.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where in *your* code this happened.
|
|
3
|
+
*
|
|
4
|
+
* A `QuerySpan` was `{ sql, bindings, startMs, durationMs, rowCount }` and a
|
|
5
|
+
* `LogEntry` was `{ level, args, offsetMs }`. Neither knew which line produced
|
|
6
|
+
* it, so "which of my forty queries is the slow one" was answerable and "where do
|
|
7
|
+
* I go to fix it" was not.
|
|
8
|
+
*
|
|
9
|
+
* The whole trick is throwing away frames. A stack captured where devtools
|
|
10
|
+
* buffers an event begins inside devtools, passes through the emitting package,
|
|
11
|
+
* and only then reaches the application — so the first frame that is *not*
|
|
12
|
+
* framework code is the answer, and every frame above it is noise.
|
|
13
|
+
*
|
|
14
|
+
* **Cost.** Measured under Bun at roughly two microseconds per capture, flat
|
|
15
|
+
* across stack depths from 5 to 80 — the engine builds the trace lazily, so
|
|
16
|
+
* depth barely registers. A request running forty queries pays about 0.08ms.
|
|
17
|
+
* That is why {@link DevtoolsConfigShape.captureSource} defaults to on: it was
|
|
18
|
+
* expected to be the expensive part of this and it is not.
|
|
19
|
+
*/
|
|
20
|
+
import type { SourceLocation } from "./editor.ts";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Path fragments that mean "not the application".
|
|
24
|
+
*
|
|
25
|
+
* Matched against the normalised path, so the separator is always `/`. The
|
|
26
|
+
* framework's own packages are here twice over — as a workspace checkout
|
|
27
|
+
* (`packages/orm/src`) and as an installed dependency (`node_modules`) — because
|
|
28
|
+
* a contributor debugging the framework and an app developer using it see
|
|
29
|
+
* different paths for the same file.
|
|
30
|
+
*/
|
|
31
|
+
const FRAMEWORK_FRAGMENTS = [
|
|
32
|
+
"node_modules/",
|
|
33
|
+
"/packages/core/",
|
|
34
|
+
"/packages/orm/",
|
|
35
|
+
"/packages/devtools/",
|
|
36
|
+
"/packages/cache/",
|
|
37
|
+
"/packages/queue/",
|
|
38
|
+
"/packages/auth/",
|
|
39
|
+
"/packages/session/",
|
|
40
|
+
"/packages/notifications/",
|
|
41
|
+
"/packages/inertia/",
|
|
42
|
+
"/packages/flow/",
|
|
43
|
+
"bun:",
|
|
44
|
+
"node:",
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
/** Frames the runtime adds that name no file at all. */
|
|
48
|
+
const NATIVE = ["[native code]", "<anonymous>", "unknown"];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* One line of a stack, as the runtimes spell it.
|
|
52
|
+
*
|
|
53
|
+
* Two shapes: `at fn (file:line:col)` and a bare `at file:line:col`. The `async`
|
|
54
|
+
* prefix rides along on continuation frames and is stripped from the name rather
|
|
55
|
+
* than being allowed to become part of it.
|
|
56
|
+
*/
|
|
57
|
+
const FRAME = /^\s*at\s+(?:(.+?)\s+\()?(.+?):(\d+):(\d+)\)?\s*$/;
|
|
58
|
+
|
|
59
|
+
/** How far down a stack to look before giving up. */
|
|
60
|
+
const MAX_FRAMES = 40;
|
|
61
|
+
|
|
62
|
+
function isFrameworkFrame(file: string): boolean {
|
|
63
|
+
const normalised = file.replace(/\\/g, "/");
|
|
64
|
+
if (NATIVE.some((n) => normalised.includes(n))) return true;
|
|
65
|
+
return FRAMEWORK_FRAGMENTS.some((f) => normalised.includes(f));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Parse one stack line into a location, or null when it is not one. */
|
|
69
|
+
export function parseFrame(line: string): SourceLocation | null {
|
|
70
|
+
const match = FRAME.exec(line);
|
|
71
|
+
if (!match) return null;
|
|
72
|
+
const [, rawName, file, lineNo, column] = match;
|
|
73
|
+
if (!file || !lineNo) return null;
|
|
74
|
+
const name = rawName?.replace(/^async\s+/, "").trim();
|
|
75
|
+
return {
|
|
76
|
+
file,
|
|
77
|
+
line: Number(lineNo),
|
|
78
|
+
column: Number(column ?? 1),
|
|
79
|
+
...(name ? { function: name } : {}),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Every frame of a stack, framework noise included.
|
|
85
|
+
*
|
|
86
|
+
* Used for an exception, where the full trace is the point — you are reading it
|
|
87
|
+
* to find out how you got somewhere, and a trace with the framework removed does
|
|
88
|
+
* not tell you that.
|
|
89
|
+
*
|
|
90
|
+
* @param stack - An `Error.stack` string.
|
|
91
|
+
* @param limit - How many frames to keep.
|
|
92
|
+
*/
|
|
93
|
+
export function parseStack(stack: string | undefined, limit = MAX_FRAMES): SourceLocation[] {
|
|
94
|
+
if (!stack) return [];
|
|
95
|
+
const out: SourceLocation[] = [];
|
|
96
|
+
for (const line of stack.split("\n")) {
|
|
97
|
+
const frame = parseFrame(line);
|
|
98
|
+
if (frame) out.push(frame);
|
|
99
|
+
if (out.length >= limit) break;
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The first application frame in a stack.
|
|
106
|
+
*
|
|
107
|
+
* Null when every frame is framework — a query run from a seeder, a log line
|
|
108
|
+
* from inside a package — which is a truthful answer and better than pointing at
|
|
109
|
+
* a file the reader did not write.
|
|
110
|
+
*
|
|
111
|
+
* Pure, and separate from {@link captureCallSite}, because this is the part with
|
|
112
|
+
* a decision in it: which frames count as yours. Taking the stack as an argument
|
|
113
|
+
* is also the only way to test it from inside this package, whose own files the
|
|
114
|
+
* filter is supposed to reject.
|
|
115
|
+
*
|
|
116
|
+
* @param stack - An `Error.stack` string.
|
|
117
|
+
* @param skip - Frames to drop before looking, for a caller that knows its own
|
|
118
|
+
* wrappers are on the stack.
|
|
119
|
+
*/
|
|
120
|
+
export function firstAppFrame(stack: string | undefined, skip = 0): SourceLocation | null {
|
|
121
|
+
if (!stack) return null;
|
|
122
|
+
|
|
123
|
+
// The first line is the "Error" header on V8 and absent on JSC; `parseFrame`
|
|
124
|
+
// returns null for it either way, so this does not need to know which runtime
|
|
125
|
+
// it is on.
|
|
126
|
+
let seen = 0;
|
|
127
|
+
const lines = stack.split("\n");
|
|
128
|
+
for (let i = 0; i < lines.length && i < MAX_FRAMES; i++) {
|
|
129
|
+
const frame = parseFrame(lines[i]!);
|
|
130
|
+
if (!frame) continue;
|
|
131
|
+
if (seen++ < skip) continue;
|
|
132
|
+
if (isFrameworkFrame(frame.file)) continue;
|
|
133
|
+
return frame;
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Where in the application this was called from.
|
|
140
|
+
*
|
|
141
|
+
* @param skip - Frames to drop before looking. The console patch passes 1,
|
|
142
|
+
* because it stands between the caller and the stack.
|
|
143
|
+
*/
|
|
144
|
+
export function captureCallSite(skip = 0): SourceLocation | null {
|
|
145
|
+
return firstAppFrame(new Error().stack, skip);
|
|
146
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which traces the All tab shows.
|
|
3
|
+
*
|
|
4
|
+
* Two independent narrowings that compose with AND: free text, and facets. Text
|
|
5
|
+
* answers "the request I am thinking of"; facets answer "the kind of request I am
|
|
6
|
+
* hunting" — and a list you can only search by name is one you cannot ask "show
|
|
7
|
+
* me the failures" of.
|
|
8
|
+
*
|
|
9
|
+
* All of it is pure, and none of it touches the DOM: this is the part of the
|
|
10
|
+
* panel that is logic rather than markup, and it is worth testing without a
|
|
11
|
+
* browser.
|
|
12
|
+
*/
|
|
13
|
+
import type { RequestTrace } from "../RequestTrace.ts";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* A request slower than this reads as slow.
|
|
17
|
+
*
|
|
18
|
+
* The same boundary the duration colour already uses, so the `slow` facet
|
|
19
|
+
* selects exactly the rows that were already amber or red — a filter that
|
|
20
|
+
* disagreed with the colouring next to it would be worse than no filter.
|
|
21
|
+
*/
|
|
22
|
+
export const SLOW_MS = 300;
|
|
23
|
+
|
|
24
|
+
/** The non-text narrowings, each empty or false meaning "do not narrow by this". */
|
|
25
|
+
export interface Facets {
|
|
26
|
+
/** Uppercase method names. Empty means every method. */
|
|
27
|
+
methods: string[];
|
|
28
|
+
/** Status classes as their leading digit — `"2"`, `"4"`, … Empty means every status. */
|
|
29
|
+
statusClasses: string[];
|
|
30
|
+
/** Only requests that threw. */
|
|
31
|
+
errors: boolean;
|
|
32
|
+
/** Only requests slower than {@link SLOW_MS}. */
|
|
33
|
+
slow: boolean;
|
|
34
|
+
/** Only requests with an N+1 warning. */
|
|
35
|
+
nPlusOne: boolean;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** No narrowing at all — what a fresh panel starts with. */
|
|
39
|
+
export function noFacets(): Facets {
|
|
40
|
+
return { methods: [], statusClasses: [], errors: false, slow: false, nPlusOne: false };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Whether any facet is actually narrowing, for the "clear" affordance. */
|
|
44
|
+
export function facetsActive(f: Facets): boolean {
|
|
45
|
+
return f.methods.length > 0 || f.statusClasses.length > 0 || f.errors || f.slow || f.nPlusOne;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Match a trace against the All tab's filter box.
|
|
50
|
+
*
|
|
51
|
+
* Every space-separated term has to match, so `posts 500` narrows twice rather
|
|
52
|
+
* than widening — a filter that ORs its terms gets less useful the more you type.
|
|
53
|
+
* The haystack covers what you would search a request list by: method, path,
|
|
54
|
+
* status, and the route it matched.
|
|
55
|
+
*/
|
|
56
|
+
export function matchesFilter(trace: RequestTrace, query: string): boolean {
|
|
57
|
+
const terms = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
58
|
+
if (!terms.length) return true;
|
|
59
|
+
const haystack = [
|
|
60
|
+
trace.method,
|
|
61
|
+
trace.path,
|
|
62
|
+
String(trace.statusCode),
|
|
63
|
+
trace.route?.pattern ?? "",
|
|
64
|
+
trace.route?.controller ?? "",
|
|
65
|
+
trace.route?.action ?? "",
|
|
66
|
+
]
|
|
67
|
+
.join(" ")
|
|
68
|
+
.toLowerCase();
|
|
69
|
+
return terms.every((term) => haystack.includes(term));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Match a trace against the facet chips.
|
|
74
|
+
*
|
|
75
|
+
* Within one facet the values are alternatives — picking `GET` and `POST` shows
|
|
76
|
+
* both. Across facets they compound, the same way the text terms do: `POST` plus
|
|
77
|
+
* `5xx` means failing writes, not writes-or-failures.
|
|
78
|
+
*/
|
|
79
|
+
export function matchesFacets(trace: RequestTrace, f: Facets): boolean {
|
|
80
|
+
if (f.methods.length && !f.methods.includes(trace.method.toUpperCase())) return false;
|
|
81
|
+
if (f.statusClasses.length) {
|
|
82
|
+
const cls = String(trace.statusCode || 0).charAt(0);
|
|
83
|
+
if (!f.statusClasses.includes(cls)) return false;
|
|
84
|
+
}
|
|
85
|
+
// A 4xx or 5xx counts as an error even when nothing threw: a rendered 404 is a
|
|
86
|
+
// failed request to anyone reading this list, and the trace only carries an
|
|
87
|
+
// `exception` when an error escaped the pipeline.
|
|
88
|
+
if (f.errors && !trace.exception && trace.statusCode < 400) return false;
|
|
89
|
+
if (f.slow && trace.durationMs <= SLOW_MS) return false;
|
|
90
|
+
if (f.nPlusOne && !trace.warnings.length) return false;
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Both narrowings at once — what the All tab actually asks. */
|
|
95
|
+
export function traceMatches(trace: RequestTrace, query: string, f: Facets): boolean {
|
|
96
|
+
return matchesFacets(trace, f) && matchesFilter(trace, query);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The method chips worth offering, from the traces actually recorded.
|
|
101
|
+
*
|
|
102
|
+
* Listing every HTTP verb would put five dead chips on screen for an app that
|
|
103
|
+
* only ever GETs. Sorted for a stable strip — chips that reorder as traffic
|
|
104
|
+
* arrives are chips you have to re-find every time you look.
|
|
105
|
+
*/
|
|
106
|
+
export function methodsPresent(traces: RequestTrace[]): string[] {
|
|
107
|
+
return [...new Set(traces.map((t) => t.method.toUpperCase()))].sort();
|
|
108
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zerotal/devtools — browser client
|
|
3
|
+
*
|
|
4
|
+
* Usage (in your app.js / frontend entry):
|
|
5
|
+
* import { DevTools } from '@zerotal/devtools/client';
|
|
6
|
+
* DevTools.start();
|
|
7
|
+
*
|
|
8
|
+
* Connects to the SSE stream served by DevtoolsInjectionMiddleware and renders a
|
|
9
|
+
* live floating panel. No script injection by the server is required.
|
|
10
|
+
*
|
|
11
|
+
* This file is only the wiring. The panel was one 1,400-line closure holding its
|
|
12
|
+
* state, its transport, its styles, eight renderers, and every helper — which
|
|
13
|
+
* made adding a tab an edit to the middle of it and made none of its logic
|
|
14
|
+
* testable. It is now a directory: {@link Store} holds the state,
|
|
15
|
+
* `transport.ts` owns the wire, `ui/shell.ts` owns the frame, and each tab is a
|
|
16
|
+
* file that exports a {@link TabView}. Both mount modes still run one set of
|
|
17
|
+
* renderers, and both extension doors are unchanged.
|
|
18
|
+
*/
|
|
19
|
+
import { Store } from "./state.ts";
|
|
20
|
+
import { collectClientMetrics, onceLoaded } from "./metrics.ts";
|
|
21
|
+
import { connect } from "./transport.ts";
|
|
22
|
+
import { mountShell } from "./ui/shell.ts";
|
|
23
|
+
import { allTab } from "./tabs/all.ts";
|
|
24
|
+
import { cacheTab } from "./tabs/cache.ts";
|
|
25
|
+
import { exceptionsTab } from "./tabs/exceptions.ts";
|
|
26
|
+
import { jobsTab } from "./tabs/jobs.ts";
|
|
27
|
+
import { logsTab } from "./tabs/logs.ts";
|
|
28
|
+
import { mailTab } from "./tabs/mail.ts";
|
|
29
|
+
import { queriesTab } from "./tabs/queries.ts";
|
|
30
|
+
import { requestTab } from "./tabs/request.ts";
|
|
31
|
+
import { timelineTab } from "./tabs/timeline.ts";
|
|
32
|
+
|
|
33
|
+
export interface DevtoolsClientOptions {
|
|
34
|
+
/** Base URL path for the devtools API. Default: '/__zerotal/devtools' */
|
|
35
|
+
endpoint?: string;
|
|
36
|
+
/**
|
|
37
|
+
* How the panel is mounted.
|
|
38
|
+
*
|
|
39
|
+
* `'floating'` (default) pins a collapsible bar to the bottom of the page.
|
|
40
|
+
* `'standalone'` fills the window and drops the collapse/close controls — the
|
|
41
|
+
* inspector dashboard. Both run the same renderers, so a tab added for one
|
|
42
|
+
* exists in the other.
|
|
43
|
+
*/
|
|
44
|
+
mode?: "floating" | "standalone";
|
|
45
|
+
/** Element to mount into. Defaults to `document.body`. */
|
|
46
|
+
mount?: HTMLElement;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The built-in tabs, in strip order.
|
|
51
|
+
*
|
|
52
|
+
* The order is also the `1`–`9` keyboard order, so it is worth being deliberate
|
|
53
|
+
* about: queries first because it is where a request explains itself, all last
|
|
54
|
+
* because it is where you go to leave the request you are on.
|
|
55
|
+
*/
|
|
56
|
+
const BUILT_IN = [
|
|
57
|
+
queriesTab,
|
|
58
|
+
timelineTab,
|
|
59
|
+
logsTab,
|
|
60
|
+
requestTab,
|
|
61
|
+
exceptionsTab,
|
|
62
|
+
mailTab,
|
|
63
|
+
cacheTab,
|
|
64
|
+
jobsTab,
|
|
65
|
+
allTab,
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
export const DevTools = {
|
|
69
|
+
start(opts: DevtoolsClientOptions = {}): void {
|
|
70
|
+
if (typeof document === "undefined") return;
|
|
71
|
+
if (document.getElementById("__zerotal_dt__")) return;
|
|
72
|
+
|
|
73
|
+
const base = (opts.endpoint ?? "/__zerotal/devtools").replace(/\/$/, "");
|
|
74
|
+
const standalone = opts.mode === "standalone";
|
|
75
|
+
|
|
76
|
+
const store = new Store(standalone, base);
|
|
77
|
+
const transport = connect(base, store);
|
|
78
|
+
|
|
79
|
+
// What the browser measured for this page load, read once after it settles.
|
|
80
|
+
// The panel reports server duration as though it were the user's experience;
|
|
81
|
+
// it is not, and this is the only place that knows the difference.
|
|
82
|
+
onceLoaded(() => {
|
|
83
|
+
store.clientMetrics = collectClientMetrics();
|
|
84
|
+
store.changed();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
mountShell({
|
|
88
|
+
base,
|
|
89
|
+
standalone,
|
|
90
|
+
mount: opts.mount ?? document.body,
|
|
91
|
+
store,
|
|
92
|
+
transport,
|
|
93
|
+
tabs: BUILT_IN,
|
|
94
|
+
});
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// ── Public surface ────────────────────────────────────────────────────────────
|
|
99
|
+
//
|
|
100
|
+
// The panel is markup, and markup is awkward to assert on. What is exported here
|
|
101
|
+
// is the part of it that is *logic*: a package contributing a channel can check
|
|
102
|
+
// how its rows will filter, fold, and nest without a browser.
|
|
103
|
+
//
|
|
104
|
+
// Deliberately not everything the directory exports. `TabView`, the theme choice,
|
|
105
|
+
// and the All tab's own mechanics are internal contracts this package reserves
|
|
106
|
+
// the right to change — the tests that cover them import them by path, which is
|
|
107
|
+
// what a same-package test should do rather than widening the API to be reachable.
|
|
108
|
+
|
|
109
|
+
export type { DevtoolsPanelPlugin } from "./registry.ts";
|
|
110
|
+
export type { Facets } from "./filter.ts";
|
|
111
|
+
export type { PathTreeNode, TraceRow } from "./tree.ts";
|
|
112
|
+
|
|
113
|
+
export {
|
|
114
|
+
matchesFilter,
|
|
115
|
+
matchesFacets,
|
|
116
|
+
traceMatches,
|
|
117
|
+
methodsPresent,
|
|
118
|
+
noFacets,
|
|
119
|
+
facetsActive,
|
|
120
|
+
SLOW_MS,
|
|
121
|
+
} from "./filter.ts";
|
|
122
|
+
export { buildPathTree, traceGroupKey, foldTraceRows } from "./tree.ts";
|