@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,272 @@
|
|
|
1
|
+
import { rescueSync } from "@zerotal/core";
|
|
2
|
+
import type { RequestTrace } from "./RequestTrace.ts";
|
|
3
|
+
|
|
4
|
+
type Subscriber = (trace: RequestTrace | null) => void;
|
|
5
|
+
|
|
6
|
+
/** A `bun:sqlite` database, structurally — imported lazily so this module has no hard dependency. */
|
|
7
|
+
interface Db {
|
|
8
|
+
exec(sql: string): void;
|
|
9
|
+
prepare(sql: string): {
|
|
10
|
+
run(...args: unknown[]): unknown;
|
|
11
|
+
all(...args: unknown[]): unknown[];
|
|
12
|
+
};
|
|
13
|
+
transaction(fn: (items: RequestTrace[]) => void): (items: RequestTrace[]) => void;
|
|
14
|
+
close(): void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface TraceStoreOptions {
|
|
18
|
+
/** How many traces to keep in memory and load back on open. */
|
|
19
|
+
capacity?: number;
|
|
20
|
+
/** SQLite file backing the history. Pass `null` to keep traces in memory only. */
|
|
21
|
+
dbPath?: string | null;
|
|
22
|
+
/** How long a persisted trace survives, in hours. */
|
|
23
|
+
pruneHours?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Individual per-request SQLite writes cause event-loop stalls when hundreds of
|
|
28
|
+
* deferred callbacks fire at once, so traces accumulate here and flush in one
|
|
29
|
+
* transaction — 10-50x faster than N writes, and at most one stall per interval.
|
|
30
|
+
*/
|
|
31
|
+
const BATCH_MS = 500;
|
|
32
|
+
const MAX_PENDING = 200;
|
|
33
|
+
|
|
34
|
+
const DEFAULT_DB_PATH = ".zerotal/devtools.sqlite";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The in-memory ring of recent request traces, optionally backed by SQLite so
|
|
38
|
+
* history survives a restart.
|
|
39
|
+
*
|
|
40
|
+
* Nothing happens until the store is used. Opening a database — or starting a
|
|
41
|
+
* prune timer — from a constructor would mean that merely *importing*
|
|
42
|
+
* `@zerotal/devtools` writes a file to the working directory, in every
|
|
43
|
+
* environment including production, whether or not the provider ever activates.
|
|
44
|
+
* The database is therefore opened on first write or read.
|
|
45
|
+
*/
|
|
46
|
+
export class TraceStore {
|
|
47
|
+
private readonly _capacity: number;
|
|
48
|
+
private readonly _dbPath: string | null;
|
|
49
|
+
private readonly _pruneMs: number;
|
|
50
|
+
|
|
51
|
+
private _traces: RequestTrace[] = [];
|
|
52
|
+
private _subscribers = new Set<Subscriber>();
|
|
53
|
+
|
|
54
|
+
// Persistence state is per-instance: two stores in one process must not share
|
|
55
|
+
// a handle, a prepared statement, or a pending batch.
|
|
56
|
+
private _db: Db | null = null;
|
|
57
|
+
private _opened = false;
|
|
58
|
+
private _insert: ((items: RequestTrace[]) => void) | null = null;
|
|
59
|
+
private _pending: RequestTrace[] = [];
|
|
60
|
+
private _batchTimer: ReturnType<typeof setTimeout> | null = null;
|
|
61
|
+
private _pruneTimer: ReturnType<typeof setInterval> | null = null;
|
|
62
|
+
|
|
63
|
+
constructor(options: TraceStoreOptions | number = {}) {
|
|
64
|
+
// A bare number is the old capacity-only form.
|
|
65
|
+
const opts = typeof options === "number" ? { capacity: options } : options;
|
|
66
|
+
this._capacity = opts.capacity ?? 100;
|
|
67
|
+
this._dbPath =
|
|
68
|
+
opts.dbPath === null ? null : (opts.dbPath ?? Bun.env["ZT_DEVTOOLS_DB"] ?? DEFAULT_DB_PATH);
|
|
69
|
+
this._pruneMs =
|
|
70
|
+
(opts.pruneHours ?? Number(Bun.env["ZT_DEVTOOLS_PRUNE_HOURS"] ?? 24)) * 3_600_000;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── Public API ────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
push(trace: RequestTrace): void {
|
|
76
|
+
this._open();
|
|
77
|
+
this._traces.unshift(trace);
|
|
78
|
+
if (this._traces.length > this._capacity) this._traces.length = this._capacity;
|
|
79
|
+
this._schedulePersist(trace);
|
|
80
|
+
for (const fn of this._subscribers) fn(trace);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
all(): RequestTrace[] {
|
|
84
|
+
this._open();
|
|
85
|
+
return [...this._traces];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
clear(): void {
|
|
89
|
+
this._open();
|
|
90
|
+
this._traces = [];
|
|
91
|
+
this._pending.length = 0;
|
|
92
|
+
if (this._batchTimer) {
|
|
93
|
+
clearTimeout(this._batchTimer);
|
|
94
|
+
this._batchTimer = null;
|
|
95
|
+
}
|
|
96
|
+
if (this._db) {
|
|
97
|
+
try {
|
|
98
|
+
this._db.exec("DELETE FROM zerotal_devtools_entries");
|
|
99
|
+
} catch {
|
|
100
|
+
/* ignore */
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
for (const fn of this._subscribers) fn(null);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Subscribing does not open the database — a listener is not a read. */
|
|
107
|
+
subscribe(fn: Subscriber): () => void {
|
|
108
|
+
this._subscribers.add(fn);
|
|
109
|
+
return () => this._subscribers.delete(fn);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Flush anything pending, stop the timers, and close the database. */
|
|
113
|
+
dispose(): void {
|
|
114
|
+
if (this._pruneTimer) {
|
|
115
|
+
clearInterval(this._pruneTimer);
|
|
116
|
+
this._pruneTimer = null;
|
|
117
|
+
}
|
|
118
|
+
if (this._batchTimer) {
|
|
119
|
+
clearTimeout(this._batchTimer);
|
|
120
|
+
this._batchTimer = null;
|
|
121
|
+
}
|
|
122
|
+
if (this._pending.length > 0) this._flush();
|
|
123
|
+
if (this._db) {
|
|
124
|
+
try {
|
|
125
|
+
this._db.close();
|
|
126
|
+
} catch {
|
|
127
|
+
/* ignore */
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
this._db = null;
|
|
131
|
+
this._insert = null;
|
|
132
|
+
this._opened = false;
|
|
133
|
+
this._subscribers.clear();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Whether this store is persisting to SQLite. Exposed for tests and diagnostics. */
|
|
137
|
+
get persisting(): boolean {
|
|
138
|
+
return this._db !== null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ── Persistence ───────────────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
/** Open the database and load history, once, on first use. */
|
|
144
|
+
private _open(): void {
|
|
145
|
+
if (this._opened) return;
|
|
146
|
+
this._opened = true;
|
|
147
|
+
if (this._dbPath === null) return;
|
|
148
|
+
|
|
149
|
+
this._db = this._openDb(this._dbPath);
|
|
150
|
+
if (!this._db) return;
|
|
151
|
+
|
|
152
|
+
this._prune();
|
|
153
|
+
this._traces = this._loadHistory();
|
|
154
|
+
this._pruneTimer = setInterval(() => this._prune(), 3_600_000);
|
|
155
|
+
this._pruneTimer.unref?.();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
private _openDb(path: string): Db | null {
|
|
159
|
+
try {
|
|
160
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
161
|
+
const { Database } = require("bun:sqlite") as typeof import("bun:sqlite");
|
|
162
|
+
const dir = path.replace(/[\\/][^\\/]+$/, "");
|
|
163
|
+
if (dir !== path) {
|
|
164
|
+
try {
|
|
165
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
166
|
+
(require("node:fs") as typeof import("node:fs")).mkdirSync(dir, { recursive: true });
|
|
167
|
+
} catch {
|
|
168
|
+
/* ok */
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const db = new Database(path) as unknown as Db;
|
|
172
|
+
db.exec(`
|
|
173
|
+
CREATE TABLE IF NOT EXISTS zerotal_devtools_entries (
|
|
174
|
+
id TEXT PRIMARY KEY,
|
|
175
|
+
created_at INTEGER NOT NULL,
|
|
176
|
+
payload TEXT NOT NULL
|
|
177
|
+
);
|
|
178
|
+
CREATE INDEX IF NOT EXISTS idx_rdt_created ON zerotal_devtools_entries(created_at);
|
|
179
|
+
`);
|
|
180
|
+
return db;
|
|
181
|
+
} catch {
|
|
182
|
+
// bun:sqlite unavailable, or the path is unwritable — degrade to memory only.
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private _schedulePersist(trace: RequestTrace): void {
|
|
188
|
+
if (!this._db) return;
|
|
189
|
+
if (this._pending.length >= MAX_PENDING) this._pending.shift(); // cap memory
|
|
190
|
+
this._pending.push(trace);
|
|
191
|
+
if (this._batchTimer) return;
|
|
192
|
+
this._batchTimer = setTimeout(() => this._flush(), BATCH_MS);
|
|
193
|
+
// Never hold the process open waiting to flush.
|
|
194
|
+
this._batchTimer.unref?.();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private _flush(): void {
|
|
198
|
+
this._batchTimer = null;
|
|
199
|
+
if (!this._db || this._pending.length === 0) return;
|
|
200
|
+
const batch = this._pending.splice(0);
|
|
201
|
+
try {
|
|
202
|
+
if (!this._insert) {
|
|
203
|
+
const stmt = this._db.prepare(
|
|
204
|
+
"INSERT OR REPLACE INTO zerotal_devtools_entries (id, created_at, payload) VALUES (?,?,?)",
|
|
205
|
+
);
|
|
206
|
+
this._insert = this._db.transaction((items: RequestTrace[]) => {
|
|
207
|
+
for (const t of items) stmt.run(t.id, t.startMs, JSON.stringify(t));
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
this._insert(batch);
|
|
211
|
+
} catch {
|
|
212
|
+
// Rebuild the prepared statement on the next flush.
|
|
213
|
+
this._insert = null;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
private _prune(): void {
|
|
218
|
+
if (!this._db) return;
|
|
219
|
+
try {
|
|
220
|
+
this._db
|
|
221
|
+
.prepare("DELETE FROM zerotal_devtools_entries WHERE created_at < ?")
|
|
222
|
+
.run(Date.now() - this._pruneMs);
|
|
223
|
+
} catch {
|
|
224
|
+
/* ignore */
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private _loadHistory(): RequestTrace[] {
|
|
229
|
+
const db = this._db;
|
|
230
|
+
if (!db) return [];
|
|
231
|
+
return rescueSync(
|
|
232
|
+
() =>
|
|
233
|
+
(
|
|
234
|
+
db
|
|
235
|
+
.prepare(
|
|
236
|
+
"SELECT payload FROM zerotal_devtools_entries ORDER BY created_at DESC LIMIT ?",
|
|
237
|
+
)
|
|
238
|
+
.all(this._capacity) as Array<{ payload: string }>
|
|
239
|
+
).map((r) => JSON.parse(r.payload) as RequestTrace),
|
|
240
|
+
[],
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ── Process-wide store ────────────────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
let _store: TraceStore | null = null;
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The store devtools records into, created on first call.
|
|
251
|
+
*
|
|
252
|
+
* Lazy on purpose: the module-level `new TraceStore()` this replaced opened a
|
|
253
|
+
* SQLite file and started an hourly timer at import time, so a production boot
|
|
254
|
+
* that merely imported `DevtoolsProvider` created `.zerotal/devtools.sqlite` in
|
|
255
|
+
* its working directory even though the provider itself is a no-op there.
|
|
256
|
+
*/
|
|
257
|
+
export function traceStore(): TraceStore {
|
|
258
|
+
if (!_store) _store = new TraceStore();
|
|
259
|
+
return _store;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Install a pre-built store — used by {@link DevtoolsProvider} to apply the
|
|
264
|
+
* app's `devtools` config, and by tests to substitute a memory-only store.
|
|
265
|
+
* Disposes the store it replaces.
|
|
266
|
+
*
|
|
267
|
+
* @internal
|
|
268
|
+
*/
|
|
269
|
+
export function _setTraceStore(store: TraceStore | null): void {
|
|
270
|
+
if (_store && _store !== store) _store.dispose();
|
|
271
|
+
_store = store;
|
|
272
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-start entry for the server-injected devtools client. This is bundled for
|
|
3
|
+
* the browser on demand and served at `GET /__zerotal/devtools/client.js`, then
|
|
4
|
+
* injected into dev HTML responses by the core dev injector — so apps get the
|
|
5
|
+
* floating panel with no `DevTools.start()` call in their own bundle.
|
|
6
|
+
*/
|
|
7
|
+
import { DevTools } from "./client.ts";
|
|
8
|
+
|
|
9
|
+
DevTools.start();
|