@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.
- package/CHANGELOG.md +21 -0
- package/LICENSE +21 -0
- package/README.md +111 -0
- package/package.json +53 -0
- package/src/DevtoolsInjectionMiddleware.ts +167 -0
- package/src/RequestTrace.ts +148 -0
- package/src/TraceStore.ts +272 -0
- package/src/client-auto.ts +9 -0
- package/src/client.ts +1048 -0
- package/src/config.ts +60 -0
- package/src/dashboard-auto.ts +13 -0
- package/src/index.ts +27 -0
- package/src/panel-app.js +519 -0
- package/src/panel.html +26 -0
- package/src/provider/DevtoolsProvider.ts +153 -0
- package/src/redaction.ts +208 -0
- package/src/tracing.ts +347 -0
|
@@ -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
|
+
}
|
package/src/redaction.ts
ADDED
|
@@ -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
|
+
}
|