@zerotal/devtools 1.6.3 → 1.7.2

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 (45) hide show
  1. package/CHANGELOG.md +329 -0
  2. package/api-surface.md +298 -0
  3. package/package.json +5 -4
  4. package/src/DevtoolsInjectionMiddleware.ts +41 -4
  5. package/src/RequestTrace.ts +106 -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 +127 -0
  11. package/src/client/metrics.ts +98 -0
  12. package/src/client/registry.ts +87 -0
  13. package/src/client/state.ts +350 -0
  14. package/src/client/tabs/all.ts +323 -0
  15. package/src/client/tabs/app.ts +293 -0
  16. package/src/client/tabs/cache.ts +50 -0
  17. package/src/client/tabs/channel.ts +264 -0
  18. package/src/client/tabs/exceptions.ts +69 -0
  19. package/src/client/tabs/jobs.ts +51 -0
  20. package/src/client/tabs/live.ts +66 -0
  21. package/src/client/tabs/logs.ts +45 -0
  22. package/src/client/tabs/mail.ts +60 -0
  23. package/src/client/tabs/queries.ts +125 -0
  24. package/src/client/tabs/request.ts +75 -0
  25. package/src/client/tabs/sections.ts +115 -0
  26. package/src/client/tabs/timeline.ts +133 -0
  27. package/src/client/tabs/types.ts +68 -0
  28. package/src/client/transport.ts +81 -0
  29. package/src/client/tree.ts +138 -0
  30. package/src/client/ui/format.ts +137 -0
  31. package/src/client/ui/render.ts +87 -0
  32. package/src/client/ui/shell.ts +560 -0
  33. package/src/client/ui/theme.ts +445 -0
  34. package/src/client-auto.ts +1 -1
  35. package/src/config.ts +77 -2
  36. package/src/dashboard-auto.ts +1 -1
  37. package/src/editor.ts +107 -0
  38. package/src/enabled.ts +59 -0
  39. package/src/index.ts +19 -3
  40. package/src/map.ts +213 -0
  41. package/src/provider/DevtoolsProvider.ts +32 -7
  42. package/src/redaction.ts +161 -20
  43. package/src/tracing.ts +261 -29
  44. package/src/client.ts +0 -1048
  45. package/src/panel-app.js +0 -519
package/src/editor.ts ADDED
@@ -0,0 +1,107 @@
1
+ /**
2
+ * `file:line` → a URL your editor opens.
3
+ *
4
+ * A repo-wide search for any editor URL scheme used to return nothing: no stack
5
+ * frame, query, log line, or prop in any Zerotal surface was clickable to source.
6
+ * Going from "this query is slow" to the line that ran it is the most frequent
7
+ * move in a debugging session, and it was two manual searches — copy the path,
8
+ * find the file, find the line.
9
+ *
10
+ * The schemes are the editors' own and are stable; what is *not* stable is where
11
+ * the file lives relative to the person reading the panel, which is what
12
+ * {@link mapEditorPath} is for.
13
+ */
14
+
15
+ /** A place in the source. The one shape every capture in the panel produces. */
16
+ export interface SourceLocation {
17
+ /** Absolute path as the server saw it. */
18
+ file: string;
19
+ line: number;
20
+ column?: number;
21
+ /** The function the frame was in, when the runtime named one. */
22
+ function?: string;
23
+ }
24
+
25
+ /** Editors that register a URL scheme for opening a file at a line. */
26
+ export type EditorName = "vscode" | "vscode-insiders" | "cursor" | "windsurf" | "zed" | "webstorm";
27
+
28
+ /**
29
+ * How each editor spells "open this file here".
30
+ *
31
+ * Two families: the VS Code line takes a query string, JetBrains takes the same
32
+ * shape under a different host, and Zed puts the position in the path. Written
33
+ * out rather than templated because there are six of them and a template that
34
+ * covers all six is harder to read than the six.
35
+ */
36
+ const SCHEMES: Record<EditorName, (file: string, line: number, column: number) => string> = {
37
+ vscode: (f, l, c) => `vscode://file/${f}:${l}:${c}`,
38
+ "vscode-insiders": (f, l, c) => `vscode-insiders://file/${f}:${l}:${c}`,
39
+ cursor: (f, l, c) => `cursor://file/${f}:${l}:${c}`,
40
+ windsurf: (f, l, c) => `windsurf://file/${f}:${l}:${c}`,
41
+ zed: (f, l, c) => `zed://file/${f}:${l}:${c}`,
42
+ webstorm: (f, l) => `webstorm://open?file=${encodeURIComponent(f)}&line=${l}`,
43
+ };
44
+
45
+ /** Every editor this understands, for a config error worth reading. */
46
+ export const EDITORS = Object.keys(SCHEMES) as EditorName[];
47
+
48
+ /**
49
+ * Rewrite a server path to where the reader's editor can find it.
50
+ *
51
+ * The process recording a trace is often not the machine reading it — a
52
+ * container reports `/app/src/Foo.ts` for a file that is `~/project/src/Foo.ts`
53
+ * on the laptop with the editor. Longest prefix wins, so a specific mapping can
54
+ * sit inside a general one.
55
+ *
56
+ * @param file - The path as captured.
57
+ * @param map - Prefix → replacement, from the app's `editorPathMap`.
58
+ */
59
+ export function mapEditorPath(file: string, map: Record<string, string>): string {
60
+ let bestPrefix = "";
61
+ let bestReplacement = "";
62
+ for (const [prefix, replacement] of Object.entries(map)) {
63
+ if (file.startsWith(prefix) && prefix.length > bestPrefix.length) {
64
+ bestPrefix = prefix;
65
+ bestReplacement = replacement;
66
+ }
67
+ }
68
+ return bestPrefix ? bestReplacement + file.slice(bestPrefix.length) : file;
69
+ }
70
+
71
+ /**
72
+ * The URL that opens `location` in `editor`, or null when there is nothing to
73
+ * link to.
74
+ *
75
+ * Null rather than a broken link for the two cases that mean "do not link":
76
+ * `editor: null` in the config, and a location with no file. The panel renders
77
+ * the location as plain text then, which is still worth showing.
78
+ *
79
+ * @param location - Where to go.
80
+ * @param editor - The configured editor, or null to disable linking.
81
+ * @param map - Path rewrites for editing on a different machine.
82
+ */
83
+ export function editorUrl(
84
+ location: SourceLocation | null | undefined,
85
+ editor: EditorName | null,
86
+ map: Record<string, string> = {},
87
+ ): string | null {
88
+ if (!editor || !location?.file) return null;
89
+ const scheme = SCHEMES[editor];
90
+ if (!scheme) return null;
91
+ // Backslashes are legal in a Windows path and illegal in a URL path segment;
92
+ // every one of these editors accepts the forward-slash form on Windows.
93
+ const file = mapEditorPath(location.file, map).replace(/\\/g, "/");
94
+ return scheme(file, Math.max(1, location.line || 1), Math.max(1, location.column ?? 1));
95
+ }
96
+
97
+ /**
98
+ * A location as the panel labels it: the last two path segments and the line.
99
+ *
100
+ * Not the whole path — an absolute path from a monorepo is sixty characters of
101
+ * which the last twenty are the part you read.
102
+ */
103
+ export function shortLocation(location: SourceLocation): string {
104
+ const parts = location.file.replace(/\\/g, "/").split("/");
105
+ const tail = parts.slice(-2).join("/");
106
+ return `${tail}:${location.line}`;
107
+ }
package/src/enabled.ts ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * One place that answers "is the inspector on, and for whom".
3
+ *
4
+ * Separated from the provider and the middleware because both need the same
5
+ * answer, and two gates that can disagree is how a dev-only surface ends up
6
+ * serving request headers in production.
7
+ *
8
+ * The panel was all-or-nothing on {@link devSurfacesEnabled} until it grew things
9
+ * worth gating: request bodies, session keys, stack traces, the resolved config.
10
+ * That default is still right — a deployed process exposes nothing — but "off
11
+ * everywhere but my laptop" is not the only shape a team needs, and without a
12
+ * supported escape hatch the way you run this on a shared staging box is to lie
13
+ * about `APP_ENV`.
14
+ */
15
+ import { config, devSurfacesEnabled } from "@zerotal/core";
16
+ import { DevtoolsConfig, type DevtoolsConfigShape } from "./config.ts";
17
+
18
+ /** The `devtools` config block, with defaults when config is not loaded. */
19
+ export function devtoolsSettings(): DevtoolsConfigShape {
20
+ return DevtoolsConfig(config.safe<Partial<DevtoolsConfigShape>>("devtools", {}));
21
+ }
22
+
23
+ /**
24
+ * Whether the inspector should run at all.
25
+ *
26
+ * `enabled: null` (the default) defers to {@link devSurfacesEnabled} — the same
27
+ * gate as the stack-trace error page — so the panel is on under `zt dev` and off
28
+ * in a production deploy without anyone configuring it. An explicit `true` or
29
+ * `false` wins, which is what makes it testable and what lets an app run it on a
30
+ * staging box behind a gate.
31
+ */
32
+ export function devtoolsEnabled(): boolean {
33
+ return devtoolsSettings().enabled ?? devSurfacesEnabled();
34
+ }
35
+
36
+ /**
37
+ * Whether this request may reach the inspector's endpoints.
38
+ *
39
+ * A dev process always may — a gate that can lock a developer out of their own
40
+ * machine gets switched off, and then nothing is gated. Anywhere else the app's
41
+ * `gate` decides, and the absence of one is a **refusal** rather than a default
42
+ * allow: an app that turned the inspector on outside development without saying
43
+ * who may read it has not made a decision this code should make for it.
44
+ *
45
+ * One function answers for every endpoint. The SSE stream, the trace JSON, the
46
+ * dashboard, and the panel bundle are the same secret.
47
+ */
48
+ export async function devtoolsAuthorized(request: Request): Promise<boolean> {
49
+ if (devSurfacesEnabled()) return true;
50
+ const gate = devtoolsSettings().gate;
51
+ if (!gate) return false;
52
+ try {
53
+ return await gate(request);
54
+ } catch {
55
+ // A gate that throws has not said yes. Failing open here would turn a typo in
56
+ // someone's authorization check into an open trace inspector.
57
+ return false;
58
+ }
59
+ }
package/src/index.ts CHANGED
@@ -1,14 +1,29 @@
1
1
  // @zerotal/devtools — public API barrel
2
2
 
3
3
  export { DevtoolsProvider } from "./provider/DevtoolsProvider.ts";
4
- export type { DevtoolsPanelPlugin } from "./client.ts";
4
+ export type { DevtoolsPanelPlugin } from "./client/registry.ts";
5
5
  export { DevtoolsInjectionMiddleware, startDevtoolsStream } from "./DevtoolsInjectionMiddleware.ts";
6
6
  export type { DevtoolsInjectionOptions } from "./DevtoolsInjectionMiddleware.ts";
7
7
  export { TraceStore, traceStore, _setTraceStore } from "./TraceStore.ts";
8
8
  export type { TraceStoreOptions } from "./TraceStore.ts";
9
9
  export { DevtoolsConfig } from "./config.ts";
10
- export type { DevtoolsConfigShape } from "./config.ts";
11
- export { redactBindings, attributeBindings } from "./redaction.ts";
10
+ export type { DevtoolsConfigShape, DevtoolsGate } from "./config.ts";
11
+ // Whether the inspector is running, for an app that wants to branch on it. The
12
+ // gate check and the settings reader beside it are plumbing for the middleware
13
+ // and the provider — an app never calls them, and exporting them only so a
14
+ // same-package test can import them is how internals become unchangeable.
15
+ export { devtoolsEnabled } from "./enabled.ts";
16
+ // Types only: both appear on shapes an app can hold (`SourceLocation` on a
17
+ // `QuerySpan`, `EditorName` in its config). The URL builders and the stack walker
18
+ // behind them are the panel's own business.
19
+ export type { EditorName, SourceLocation } from "./editor.ts";
20
+ export {
21
+ redactBindings,
22
+ redactValue,
23
+ redactCacheKey,
24
+ isSensitiveName,
25
+ attributeBindings,
26
+ } from "./redaction.ts";
12
27
  export type { RedactionOptions } from "./redaction.ts";
13
28
  export { traceSink, traceChannels } from "./tracing.ts";
14
29
  export type { TraceSink } from "./tracing.ts";
@@ -20,6 +35,7 @@ export type {
20
35
  CacheEntry,
21
36
  JobEntry,
22
37
  LogEntry,
38
+ ExceptionInfo,
23
39
  RouteInfo,
24
40
  AuthInfo,
25
41
  TraceChannelDescriptor,
package/src/map.ts ADDED
@@ -0,0 +1,213 @@
1
+ /**
2
+ * The application as it is, rather than as it just behaved.
3
+ *
4
+ * Every tab up to here reads the trace stream: what one request did. This reads
5
+ * the framework's own registries — the routes it will match, the config it
6
+ * resolved, what is in the container, which providers put it there, and what
7
+ * listens to what. All of it existed already and all of it was CLI-only or
8
+ * invisible, so the questions it answers ("is that route even registered", "who
9
+ * bound `cache`", "does anything listen to `OrderPlaced`") were answered by
10
+ * reading source.
11
+ *
12
+ * Nothing here is instrumented. It is a read of state the app is already
13
+ * keeping, taken when the panel asks — which is also why it needs no store and
14
+ * no retention: there is only ever one current answer.
15
+ */
16
+ import { Router, FrameworkEvents } from "@zerotal/core";
17
+ import type { Application, Emitter } from "@zerotal/core";
18
+ import { isSensitiveName } from "./redaction.ts";
19
+ import { redactGraph } from "@zerotal/core/security";
20
+ import type { RedactionOptions } from "./redaction.ts";
21
+
22
+ /** One registered route, flattened for display. */
23
+ export interface RouteRow {
24
+ method: string;
25
+ path: string;
26
+ name: string;
27
+ handler: string;
28
+ middleware: string;
29
+ }
30
+
31
+ /** One container binding. */
32
+ export interface BindingRow {
33
+ token: string;
34
+ kind: string;
35
+ /** The provider that bound it, when boot recorded one. */
36
+ provider: string;
37
+ }
38
+
39
+ /** One provider, in boot order. */
40
+ export interface ProviderRow {
41
+ name: string;
42
+ durationMs: number;
43
+ bindings: number;
44
+ }
45
+
46
+ /** One event and what reacts to it. */
47
+ export interface EventRow {
48
+ event: string;
49
+ /** Application listener class names, or the handler count for a framework event. */
50
+ listeners: string;
51
+ source: "application" | "framework";
52
+ }
53
+
54
+ /** Everything the App section draws. */
55
+ export interface FrameworkMap {
56
+ routes: RouteRow[];
57
+ config: Record<string, unknown>;
58
+ bindings: BindingRow[];
59
+ providers: ProviderRow[];
60
+ events: EventRow[];
61
+ /** Wall-clock boot time, so the provider list has a total to be read against. */
62
+ bootMs: number | null;
63
+ }
64
+
65
+ /** `[class PostController]` → `PostController`; a plain token passes through. */
66
+ function tokenName(token: unknown): string {
67
+ if (typeof token === "string") return token;
68
+ if (typeof token === "function") return token.name || "‹anonymous›";
69
+ return String(token);
70
+ }
71
+
72
+ /**
73
+ * Every registered route, newest framework state, sorted for reading.
74
+ *
75
+ * Sorted by path then method rather than by registration order: registration
76
+ * order is an implementation detail of which file loaded first, and a list you
77
+ * scan for "is `/posts/:id` there" wants the paths together.
78
+ */
79
+ export function routeRows(): RouteRow[] {
80
+ // `namedRoutes` is name → path; the panel wants the reverse.
81
+ const nameByPath = new Map<string, string>();
82
+ for (const [name, path] of Router.namedRoutes) nameByPath.set(path, name);
83
+
84
+ return [...Router.routes.values()]
85
+ .map((route) => ({
86
+ method: route.method,
87
+ path: route.path,
88
+ name: nameByPath.get(route.path) ?? "",
89
+ handler: `${route.controller?.name ?? "—"}@${route.action}`,
90
+ middleware: route.middleware
91
+ .map((m) => m.name)
92
+ .filter(Boolean)
93
+ .join(", "),
94
+ }))
95
+ .sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method));
96
+ }
97
+
98
+ /**
99
+ * The resolved config, with anything that looks like a secret masked.
100
+ *
101
+ * Exposing config is how a debugging tool leaks a database password, so this is
102
+ * the one surface here that is *not* a plain read. The same `isSensitiveName`
103
+ * rule the rest of the package uses decides, which means an app's `allow` and
104
+ * `deny` mean the same thing here as they do on the Queries tab — and it is a
105
+ * deny-by-default rule, so a key nobody anticipated is masked rather than shown.
106
+ */
107
+ export function configTree(
108
+ all: Record<string, unknown>,
109
+ redaction: RedactionOptions,
110
+ ): Record<string, unknown> {
111
+ // Stricter here than anywhere else in the package, deliberately. The shared
112
+ // list masks `api_key` and `private_key` but not a bare `key` — reasonable for
113
+ // a query binding, where a column called `key` is usually a lookup key, and
114
+ // wrong for config, where `app.key` is the application's encryption key. Same
115
+ // reasoning for `dsn`: a connection string is credentials with a hostname
116
+ // attached. Config is the one place secrets are *supposed* to live, so it gets
117
+ // the benefit of the doubt in the other direction.
118
+ const strict: RedactionOptions = {
119
+ ...redaction,
120
+ deny: [...(redaction.deny ?? []), "key", "dsn"],
121
+ };
122
+ return redactGraph(all, {
123
+ sensitive: (key) => isSensitiveName(key, strict),
124
+ mask: "‹redacted›",
125
+ circular: "‹circular›",
126
+ tooDeep: "‹truncated›",
127
+ // Deeper than a trace entry: config is nested by design and a namespace
128
+ // truncated three levels in is a namespace you cannot read.
129
+ maxDepth: 10,
130
+ flatten: (value) => (typeof value === "function" ? "‹fn›" : undefined),
131
+ }) as Record<string, unknown>;
132
+ }
133
+
134
+ /**
135
+ * What is in the container, and who put it there.
136
+ *
137
+ * Provenance comes from the boot report rather than from the container, which
138
+ * does not track it — see `Application.providerReport`.
139
+ */
140
+ export function bindingRows(app: Application): BindingRow[] {
141
+ const owner = new Map<string, string>();
142
+ for (const provider of app.providerReport) {
143
+ for (const token of provider.bindings) owner.set(token, provider.name);
144
+ }
145
+
146
+ return [...app.container.registry.entries()]
147
+ .map(([token, binding]) => {
148
+ const name = tokenName(token);
149
+ return {
150
+ token: name,
151
+ kind: (binding as { kind?: string }).kind ?? "unknown",
152
+ provider: owner.get(name) ?? "—",
153
+ };
154
+ })
155
+ .sort((a, b) => a.token.localeCompare(b.token));
156
+ }
157
+
158
+ /** Providers in boot order, with what each cost. */
159
+ export function providerRows(app: Application): ProviderRow[] {
160
+ return app.providerReport.map((p) => ({
161
+ name: p.name,
162
+ durationMs: p.durationMs,
163
+ bindings: p.bindings.length,
164
+ }));
165
+ }
166
+
167
+ /**
168
+ * Application listeners and framework subscribers, in one list.
169
+ *
170
+ * Two different mechanisms — `Emitter.on()` for the app's own events,
171
+ * `FrameworkEvents.on()` for the framework bus — and a developer asking "what
172
+ * reacts to this" does not care which. The `source` column keeps them
173
+ * distinguishable without splitting the answer in two.
174
+ */
175
+ export function eventRows(emitter: Emitter | undefined): EventRow[] {
176
+ const rows: EventRow[] = [];
177
+
178
+ for (const { event, listeners } of emitter?.registrations() ?? []) {
179
+ rows.push({ event, listeners: listeners.join(", "), source: "application" });
180
+ }
181
+ for (const { event, handlers } of FrameworkEvents.subscriptions()) {
182
+ rows.push({
183
+ event,
184
+ listeners: `${handlers} subscriber${handlers === 1 ? "" : "s"}`,
185
+ source: "framework",
186
+ });
187
+ }
188
+ return rows.sort((a, b) => a.event.localeCompare(b.event));
189
+ }
190
+
191
+ /**
192
+ * Read the whole map.
193
+ *
194
+ * Taken fresh on each request for it. The registries are small and static, and a
195
+ * cached map is a map that disagrees with the app the moment a provider
196
+ * registers a route late.
197
+ */
198
+ export function buildFrameworkMap(app: Application, redaction: RedactionOptions): FrameworkMap {
199
+ const config = app.container.tryMake("config");
200
+ const emitter = app.container.tryMake("events") as Emitter | undefined;
201
+
202
+ return {
203
+ routes: routeRows(),
204
+ config: configTree(
205
+ (config as { all?: () => Record<string, unknown> } | undefined)?.all?.() ?? {},
206
+ redaction,
207
+ ),
208
+ bindings: bindingRows(app),
209
+ providers: providerRows(app),
210
+ events: eventRows(emitter),
211
+ bootMs: app.bootDurationMs ?? null,
212
+ };
213
+ }
@@ -4,6 +4,8 @@ import {
4
4
  type AppEnvironment,
5
5
  type HttpContext,
6
6
  } from "@zerotal/core";
7
+ import { devtoolsEnabled } from "../enabled.ts";
8
+ import { startActivityCapture, _resetActivity } from "../activity.ts";
7
9
  import { DevReloadMiddleware, registerDevHtmlSnippet } from "@zerotal/core/dev";
8
10
  import {
9
11
  DevtoolsInjectionMiddleware,
@@ -19,6 +21,8 @@ import {
19
21
  traceSink,
20
22
  _resetChannels,
21
23
  _setRedaction,
24
+ _setCaptureSource,
25
+ _setHeaderAllowlist,
22
26
  type TraceSink,
23
27
  } from "../tracing.ts";
24
28
 
@@ -69,6 +73,7 @@ export class DevtoolsProvider extends ServiceProvider {
69
73
  /** Set once the provider has activated, so teardown only undoes what it did. */
70
74
  private _active = false;
71
75
  private _stopStream: (() => void) | null = null;
76
+ private _stopActivity: (() => void) | null = null;
72
77
 
73
78
  override async onBooting(): Promise<void> {
74
79
  // Fail closed: only activate for explicitly non-prod environments. An unset or
@@ -80,7 +85,12 @@ export class DevtoolsProvider extends ServiceProvider {
80
85
  // runtime mode, so this was asking whether `"web"` is a development environment;
81
86
  // - it is the only dev gate that did not honour `ZT_DEV`, which is what the dev
82
87
  // orchestrator sets on the server it supervises — so `zt dev` did not help either.
83
- if (!devSurfacesEnabled()) return;
88
+ // `devtoolsEnabled()` rather than `devSurfacesEnabled()` directly: the app's
89
+ // `enabled` setting wins when it is set, which is what lets the inspector run
90
+ // on a shared staging box behind a `gate`. `null` — the default — still
91
+ // defers to the dev-surface gate, so nothing changes for anyone who has not
92
+ // asked for it.
93
+ if (!devtoolsEnabled()) return;
84
94
  this._active = true;
85
95
 
86
96
  const config = this._config();
@@ -96,6 +106,8 @@ export class DevtoolsProvider extends ServiceProvider {
96
106
  }),
97
107
  );
98
108
  _setRedaction(config.redact);
109
+ _setCaptureSource(config.captureSource);
110
+ _setHeaderAllowlist(config.headers);
99
111
 
100
112
  // Expose the trace sink so feature packages can contribute per-request spans
101
113
  // and declare their own channels. Bound in onBooting so it is available when
@@ -113,12 +125,19 @@ export class DevtoolsProvider extends ServiceProvider {
113
125
  // injector — no `DevTools.start()` needed in the app's own bundle. Also
114
126
  // register the injector so this works under a plain `serve` (not only
115
127
  // `serve --dev-worker`, where Application.enableDevWs() already adds it).
116
- this.app.useOnce(DevReloadMiddleware);
117
- registerDevHtmlSnippet("zerotal-devtools", (ctx: HttpContext) =>
118
- ctx.url.pathname.startsWith("/__zerotal")
119
- ? "" // don't inject the panel into the devtools' own pages
120
- : `<script type="module" src="/__zerotal/devtools/client.js"></script>`,
121
- );
128
+ //
129
+ // Only on a development machine. Auto-injection is a convenience for the
130
+ // person running the app; on a gated environment the snippet would go into
131
+ // every visitor's HTML and then 403 in their console, so there the way in is
132
+ // the dashboard at `/__zerotal/devtools`, which the gate answers for.
133
+ if (devSurfacesEnabled()) {
134
+ this.app.useOnce(DevReloadMiddleware);
135
+ registerDevHtmlSnippet("zerotal-devtools", (ctx: HttpContext) =>
136
+ ctx.url.pathname.startsWith("/__zerotal")
137
+ ? "" // don't inject the panel into the devtools' own pages
138
+ : `<script type="module" src="/__zerotal/devtools/client.js"></script>`,
139
+ );
140
+ }
122
141
  }
123
142
 
124
143
  override async onBooted(): Promise<void> {
@@ -128,6 +147,9 @@ export class DevtoolsProvider extends ServiceProvider {
128
147
  // devtools no longer imports @zerotal/orm — it only consumes FrameworkEvents.
129
148
  startDevtoolsTracing();
130
149
  startConsoleCapture();
150
+ // Console commands and scheduled tasks, which have no request to hang off
151
+ // and so appeared nowhere at all.
152
+ this._stopActivity = startActivityCapture();
131
153
  this._stopStream = startDevtoolsStream();
132
154
 
133
155
  process.stdout.write(
@@ -141,9 +163,12 @@ export class DevtoolsProvider extends ServiceProvider {
141
163
  if (!this._active) return;
142
164
  stopDevtoolsTracing();
143
165
  stopConsoleCapture();
166
+ this._stopActivity?.();
167
+ this._stopActivity = null;
144
168
  this._stopStream?.();
145
169
  this._stopStream = null;
146
170
  _resetChannels();
171
+ _resetActivity();
147
172
  // Flushes any pending batch and closes the database — without this a suite
148
173
  // that boots several apps leaves a handle and an hourly timer per app.
149
174
  _setTraceStore(null);