@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.
- package/CHANGELOG.md +329 -0
- package/api-surface.md +298 -0
- package/package.json +5 -4
- package/src/DevtoolsInjectionMiddleware.ts +41 -4
- package/src/RequestTrace.ts +106 -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 +127 -0
- package/src/client/metrics.ts +98 -0
- package/src/client/registry.ts +87 -0
- package/src/client/state.ts +350 -0
- package/src/client/tabs/all.ts +323 -0
- package/src/client/tabs/app.ts +293 -0
- package/src/client/tabs/cache.ts +50 -0
- package/src/client/tabs/channel.ts +264 -0
- package/src/client/tabs/exceptions.ts +69 -0
- package/src/client/tabs/jobs.ts +51 -0
- package/src/client/tabs/live.ts +66 -0
- package/src/client/tabs/logs.ts +45 -0
- package/src/client/tabs/mail.ts +60 -0
- package/src/client/tabs/queries.ts +125 -0
- package/src/client/tabs/request.ts +75 -0
- package/src/client/tabs/sections.ts +115 -0
- package/src/client/tabs/timeline.ts +133 -0
- package/src/client/tabs/types.ts +68 -0
- package/src/client/transport.ts +81 -0
- package/src/client/tree.ts +138 -0
- package/src/client/ui/format.ts +137 -0
- package/src/client/ui/render.ts +87 -0
- package/src/client/ui/shell.ts +560 -0
- package/src/client/ui/theme.ts +445 -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 +261 -29
- package/src/client.ts +0 -1048
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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);
|