@zerotal/devtools 1.0.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.
@@ -0,0 +1,153 @@
1
+ import {
2
+ ServiceProvider,
3
+ isDevSurfaceAllowed,
4
+ type AppEnvironment,
5
+ type HttpContext,
6
+ } from "@zerotal/core";
7
+ import { DevReloadMiddleware, registerDevHtmlSnippet } from "@zerotal/core/dev";
8
+ import {
9
+ DevtoolsInjectionMiddleware,
10
+ startDevtoolsStream,
11
+ } from "../DevtoolsInjectionMiddleware.ts";
12
+ import { DevtoolsConfig, type DevtoolsConfigShape } from "../config.ts";
13
+ import { TraceStore, _setTraceStore } from "../TraceStore.ts";
14
+ import {
15
+ startDevtoolsTracing,
16
+ stopDevtoolsTracing,
17
+ startConsoleCapture,
18
+ stopConsoleCapture,
19
+ traceSink,
20
+ _resetChannels,
21
+ _setRedaction,
22
+ type TraceSink,
23
+ } from "../tracing.ts";
24
+
25
+ declare module "@zerotal/core" {
26
+ interface ContainerBindings {
27
+ "devtools.trace": TraceSink;
28
+ }
29
+ }
30
+
31
+ const PURPLE = "\x1b[35m";
32
+ const CYAN = "\x1b[36m";
33
+ const DIM = "\x1b[2m";
34
+ const RESET = "\x1b[0m";
35
+
36
+ /**
37
+ * Activates Zerotal DevTools for web environments.
38
+ * No-op in production.
39
+ *
40
+ * Register in `bootstrap/app.ts`:
41
+ *
42
+ * ```typescript
43
+ * import { DevtoolsProvider } from '@zerotal/devtools';
44
+ *
45
+ * Application.create()
46
+ * .register([DatabaseProvider, DevtoolsProvider])
47
+ * ```
48
+ *
49
+ * In development the floating panel is **auto-injected** into HTML responses —
50
+ * no `DevTools.start()` in your app bundle. (You can still import it manually
51
+ * from `@zerotal/devtools/client` if you prefer to control startup.)
52
+ *
53
+ * Routes served automatically:
54
+ * GET /__zerotal/devtools standalone inspector dashboard
55
+ * GET /__zerotal/devtools/client.js injected in-page panel bundle
56
+ * GET /__zerotal/devtools/sse SSE stream (EventSource)
57
+ * GET /__zerotal/devtools/api/traces recent request traces (JSON)
58
+ * GET /__zerotal/devtools/api/channels declared trace channels (JSON)
59
+ * POST /__zerotal/devtools/api/clear clear trace history
60
+ */
61
+ export class DevtoolsProvider extends ServiceProvider {
62
+ // Deliberately empty: `devtools.trace` is only bound when the dev surface is
63
+ // allowed (fail-closed outside development), and every token in `provides` is
64
+ // a boot-doctor guarantee that must hold in every environment. Satellites
65
+ // detect the sink with `tryMake`, so its absence is a supported state.
66
+ static override provides = [] as const;
67
+ static override environments: AppEnvironment[] = ["web"];
68
+
69
+ /** Set once the provider has activated, so teardown only undoes what it did. */
70
+ private _active = false;
71
+ private _stopStream: (() => void) | null = null;
72
+
73
+ override async onBooting(): Promise<void> {
74
+ const env = Bun.env["APP_ENV"] ?? "";
75
+ // Fail closed: only activate for explicitly non-prod envs. An unset or
76
+ // `staging` APP_ENV must NOT expose the unauthenticated trace inspector.
77
+ if (!isDevSurfaceAllowed(env)) return;
78
+ this._active = true;
79
+
80
+ const config = this._config();
81
+
82
+ // The store is built here rather than at import time: opening a database
83
+ // from module scope would create `.zerotal/devtools.sqlite` in the working
84
+ // directory of every process that imports this package, production included.
85
+ _setTraceStore(
86
+ new TraceStore({
87
+ capacity: config.capacity,
88
+ dbPath: config.dbPath,
89
+ pruneHours: config.pruneHours,
90
+ }),
91
+ );
92
+ _setRedaction(config.redact);
93
+
94
+ // Expose the trace sink so feature packages can contribute per-request spans
95
+ // and declare their own channels. Bound in onBooting so it is available when
96
+ // providers' onBooted run.
97
+ //
98
+ // `value`, not `singleton`: an unresolved singleton is invisible to the
99
+ // synchronous `tryMake` every satellite bridge uses to detect devtools, so
100
+ // registering it as a factory meant those bridges silently found nothing and
101
+ // no package's spans ever reached the panel in a real app.
102
+ this.app.container.value("devtools.trace", traceSink);
103
+
104
+ this.app.useOnce(DevtoolsInjectionMiddleware as never);
105
+
106
+ // Auto-inject the in-page panel into HTML responses via the core dev
107
+ // injector — no `DevTools.start()` needed in the app's own bundle. Also
108
+ // register the injector so this works under a plain `serve` (not only
109
+ // `serve --dev-worker`, where Application.enableDevWs() already adds it).
110
+ this.app.useOnce(DevReloadMiddleware as never);
111
+ registerDevHtmlSnippet("zerotal-devtools", (ctx: HttpContext) =>
112
+ ctx.url.pathname.startsWith("/__zerotal")
113
+ ? "" // don't inject the panel into the devtools' own pages
114
+ : `<script type="module" src="/__zerotal/devtools/client.js"></script>`,
115
+ );
116
+ }
117
+
118
+ override async onBooted(): Promise<void> {
119
+ if (!this._active) return;
120
+
121
+ // N+1 detection is owned by the ORM provider now (env-gated there), so
122
+ // devtools no longer imports @zerotal/orm — it only consumes FrameworkEvents.
123
+ startDevtoolsTracing();
124
+ startConsoleCapture();
125
+ this._stopStream = startDevtoolsStream();
126
+
127
+ process.stdout.write(
128
+ ` ${PURPLE}[Zerotal Inspector]${RESET} DevTools active\n` +
129
+ ` ${DIM}Panel${RESET} → auto-injected into dev pages\n` +
130
+ ` ${DIM}Dashboard${RESET} → ${CYAN}/__zerotal/devtools${RESET}\n`,
131
+ );
132
+ }
133
+
134
+ async onStopping(): Promise<void> {
135
+ if (!this._active) return;
136
+ stopDevtoolsTracing();
137
+ stopConsoleCapture();
138
+ this._stopStream?.();
139
+ this._stopStream = null;
140
+ _resetChannels();
141
+ // Flushes any pending batch and closes the database — without this a suite
142
+ // that boots several apps leaves a handle and an hourly timer per app.
143
+ _setTraceStore(null);
144
+ this._active = false;
145
+ }
146
+
147
+ /** The app's `devtools` config, falling back to defaults when none is present. */
148
+ private _config(): DevtoolsConfigShape {
149
+ const config = this.app.container.tryMake("config");
150
+ const raw = config?.get<Partial<DevtoolsConfigShape>>("devtools");
151
+ return DevtoolsConfig(raw ?? {});
152
+ }
153
+ }
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Query-binding redaction.
3
+ *
4
+ * A trace does not stay on screen: it is streamed to the browser and written to
5
+ * `.zerotal/devtools.sqlite`, where it sits for a day. Bindings are the request's
6
+ * actual values — the password on a registration, a reset token, a session
7
+ * payload, every customer email a listing selects by. An ephemeral dev panel and
8
+ * a plaintext file with a day's worth of credentials in it are different risks,
9
+ * so the values are masked by default and you opt individual columns back in.
10
+ *
11
+ * Matching is on the column each binding belongs to, recovered by pairing the
12
+ * SQL's placeholders with the identifiers around them. When a binding cannot be
13
+ * attributed to a column — a raw expression, a dialect this does not parse — it
14
+ * is masked, because guessing wrong in the other direction is what writes a
15
+ * password to disk.
16
+ */
17
+
18
+ export interface RedactionOptions {
19
+ /**
20
+ * Turn masking off entirely. Only reasonable when you are debugging the values
21
+ * themselves and nothing sensitive is in the database.
22
+ */
23
+ enabled?: boolean;
24
+ /**
25
+ * Column names whose values are safe to show in full. Matched
26
+ * case-insensitively against the column each binding belongs to.
27
+ */
28
+ allow?: string[];
29
+ /**
30
+ * Extra column names to mask, added to the built-in list.
31
+ */
32
+ deny?: string[];
33
+ }
34
+
35
+ /**
36
+ * Column names masked without being asked. Substring matches, so `password`
37
+ * covers `password_hash` and `user_password`.
38
+ */
39
+ const SENSITIVE = [
40
+ "password",
41
+ "passwd",
42
+ "secret",
43
+ "token",
44
+ "api_key",
45
+ "apikey",
46
+ "authorization",
47
+ "auth",
48
+ "credential",
49
+ "private_key",
50
+ "session",
51
+ "remember_token",
52
+ "otp",
53
+ "two_factor",
54
+ "totp",
55
+ "recovery_code",
56
+ "signature",
57
+ "cvv",
58
+ "card_number",
59
+ "iban",
60
+ "ssn",
61
+ "id_number",
62
+ ];
63
+
64
+ /** Columns always shown: structural values that make a trace readable at all. */
65
+ const STRUCTURAL = ["id", "created_at", "updated_at", "deleted_at"];
66
+
67
+ /** What a masked binding is replaced with. */
68
+ const MASK = "‹redacted›";
69
+
70
+ /**
71
+ * Mask the bindings of `sql` that belong to a sensitive column.
72
+ *
73
+ * @param sql - The statement the bindings belong to, used to attribute each one to a column.
74
+ * @param bindings - The values, positionally matched to the statement's placeholders.
75
+ * @param options - Redaction settings from the app's `devtools` config.
76
+ * @returns A new array; the input is not modified.
77
+ */
78
+ export function redactBindings(
79
+ sql: string,
80
+ bindings: unknown[],
81
+ options: RedactionOptions = {},
82
+ ): unknown[] {
83
+ if (options.enabled === false) return bindings;
84
+ if (!Array.isArray(bindings) || bindings.length === 0) return bindings;
85
+
86
+ const allow = new Set((options.allow ?? []).map((c) => c.toLowerCase()));
87
+ const deny = [...SENSITIVE, ...(options.deny ?? []).map((c) => c.toLowerCase())];
88
+ const columns = attributeBindings(sql, bindings.length);
89
+
90
+ return bindings.map((value, i) => {
91
+ if (value === null || value === undefined) return value;
92
+ const column = columns[i];
93
+ // An unattributable binding is masked: a value we cannot name is a value we
94
+ // cannot clear.
95
+ if (!column) return MASK;
96
+ if (allow.has(column)) return value;
97
+ if (STRUCTURAL.includes(column)) return value;
98
+ return deny.some((s) => column.includes(s)) ? MASK : value;
99
+ });
100
+ }
101
+
102
+ /**
103
+ * Pair each `?` placeholder in `sql` with the column it sets or compares.
104
+ *
105
+ * Handles the three shapes the query builder emits: `INSERT … (cols) VALUES (?, …)`,
106
+ * `SET col = ?`, and `WHERE col <op> ?` (including `IN (?, ?)`, where every
107
+ * placeholder belongs to the same column). Returns `undefined` at any position it
108
+ * cannot attribute.
109
+ */
110
+ export function attributeBindings(sql: string, count: number): Array<string | undefined> {
111
+ const out: Array<string | undefined> = new Array(count).fill(undefined);
112
+ if (count === 0) return out;
113
+
114
+ const insertColumns = _insertColumns(sql);
115
+ let index = 0;
116
+
117
+ // Walk the statement placeholder by placeholder, tracking the nearest
118
+ // identifier to the left of each one.
119
+ const tokens = sql.match(/"[^"]+"|`[^`]+`|\[[^\]]+\]|[A-Za-z_][\w.]*|\?|[(),=<>!]+/g) ?? [];
120
+ let lastIdentifier: string | undefined;
121
+ let inValues = false;
122
+ let valuesPosition = 0;
123
+
124
+ for (const raw of tokens) {
125
+ if (raw === "?") {
126
+ if (index >= count) break;
127
+ if (inValues && insertColumns) {
128
+ out[index] = insertColumns[valuesPosition];
129
+ valuesPosition++;
130
+ } else {
131
+ out[index] = lastIdentifier;
132
+ }
133
+ index++;
134
+ continue;
135
+ }
136
+ const token = raw.toLowerCase();
137
+ if (token === "values") {
138
+ inValues = true;
139
+ valuesPosition = 0;
140
+ continue;
141
+ }
142
+ if (/^[a-z_][\w.]*$/.test(token)) {
143
+ // An operator sits between a column and its placeholders and must leave the
144
+ // column attached — `WHERE id IN (?, ?)` belongs to `id` for both. Any other
145
+ // keyword ends that column's scope, so `AND`/`WHERE` cannot carry a stale
146
+ // column onto the next placeholder.
147
+ if (_OPERATORS.has(token)) continue;
148
+ if (_KEYWORDS.has(token)) {
149
+ lastIdentifier = undefined;
150
+ continue;
151
+ }
152
+ lastIdentifier = _bareColumn(token);
153
+ }
154
+ }
155
+
156
+ return out;
157
+ }
158
+
159
+ /** Keywords that sit between a column and its placeholders, leaving it in scope. */
160
+ const _OPERATORS = new Set(["in", "like", "ilike", "between", "is", "not", "any", "all"]);
161
+
162
+ const _KEYWORDS = new Set([
163
+ "select",
164
+ "from",
165
+ "where",
166
+ "and",
167
+ "or",
168
+ "insert",
169
+ "into",
170
+ "update",
171
+ "set",
172
+ "delete",
173
+ "values",
174
+ "limit",
175
+ "offset",
176
+ "order",
177
+ "group",
178
+ "by",
179
+ "having",
180
+ "join",
181
+ "left",
182
+ "right",
183
+ "inner",
184
+ "outer",
185
+ "on",
186
+ "as",
187
+
188
+ "null",
189
+ "returning",
190
+ "conflict",
191
+ "do",
192
+ "nothing",
193
+ "duplicate",
194
+ "key",
195
+ ]);
196
+
197
+ /** `schema.table.column` / quoted identifiers → `column`. */
198
+ function _bareColumn(identifier: string): string {
199
+ const parts = identifier.replace(/["`[\]]/g, "").split(".");
200
+ return (parts[parts.length - 1] ?? identifier).toLowerCase();
201
+ }
202
+
203
+ /** The column list of an `INSERT INTO t (a, b, c) VALUES …`, if this is one. */
204
+ function _insertColumns(sql: string): string[] | null {
205
+ const match = /insert\s+(?:or\s+\w+\s+)?into\s+[^(]+\(([^)]*)\)\s*values/i.exec(sql);
206
+ if (!match?.[1]) return null;
207
+ return match[1].split(",").map((c) => _bareColumn(c.trim()));
208
+ }