@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 ADDED
@@ -0,0 +1,21 @@
1
+ # Changelog — @zerotal/devtools
2
+
3
+ All notable changes to this package are documented here. The format is
4
+ based on [Keep a Changelog](https://keepachangelog.com/); this package
5
+ follows the Zerotal monorepo's unified versioning.
6
+
7
+ **Maturity: `experimental`**
8
+
9
+ ## [Unreleased]
10
+
11
+ ## [1.0.0] — 2026-08-05
12
+
13
+ _First public release._
14
+
15
+ ### Added
16
+
17
+ - **Extensible panel — other packages can add their own tab.** The injected panel now exposes a global registry, `window.__zerotalDevtools`, that any package's browser code pushes a panel into: `window.__zerotalDevtools?.register({ id, title, badge?, render })` adds a tab alongside Queries/Logs/Request/Mail/Cache/Jobs, and `refresh(id)` pushes a live update (badge + re-render the open tab). Registration is order-independent (the registry is created by whichever runs first) and optional-peer friendly (guard with `?.`; a no-op when devtools isn't present). Panels render into the shared Shadow-DOM content area, so the devtools CSS classes/variables are available and contributed tabs match the panel without shipping styles. The `DevtoolsPanelPlugin` type is exported for TypeScript consumers. First consumer: `@zerotal/flow`'s time-travel Timeline. See [Extending the panel](/docs/devtools#extending-the-panel).
18
+
19
+ ### Changed
20
+
21
+ - Moved service provider to `src/provider/`.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Zerotal
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,111 @@
1
+ # @zerotal/devtools
2
+
3
+ > A live, in-page development panel that traces every request — SQL queries, N+1 warnings, logs, mail, cache, and jobs.
4
+
5
+ `@zerotal/devtools` injects a floating debug panel into every HTML response during development. No browser extension required: it shows per-request traces and exposes a `TraceStore` you can read programmatically. `DevtoolsProvider` is a no-op when `APP_ENV=production` or `APP_ENV=prod`.
6
+
7
+ Part of the [Zerotal](../../README.md) framework. Requires **Bun ≥ 1.3.14**.
8
+
9
+ ## Installation
10
+
11
+ ```bash
12
+ bun add @zerotal/devtools
13
+ ```
14
+
15
+ ## Setup
16
+
17
+ ### 1. Register the provider
18
+
19
+ ```typescript
20
+ // bootstrap/providers.ts
21
+ import { DatabaseProvider } from "@zerotal/orm";
22
+ import { DevtoolsProvider } from "@zerotal/devtools";
23
+
24
+ const providers = [
25
+ // …your other providers
26
+ DatabaseProvider,
27
+ DevtoolsProvider,
28
+ ];
29
+
30
+ export default providers;
31
+ ```
32
+
33
+ `DevtoolsProvider` automatically registers `DevtoolsInjectionMiddleware` — you do not need to add it to `.use([…])` manually.
34
+
35
+ ### 2. Start the client panel
36
+
37
+ In your frontend entry (e.g. `resources/js/app.js`):
38
+
39
+ ```typescript
40
+ import { DevTools } from "@zerotal/devtools/client";
41
+ DevTools.start(); // optionally { endpoint: "/__zerotal/devtools" }
42
+ ```
43
+
44
+ This connects to the SSE stream and mounts the floating panel. Press `Alt+D` (or `Cmd+D` on Mac) to toggle it.
45
+
46
+ ## Usage
47
+
48
+ ### Read traces programmatically
49
+
50
+ ```typescript
51
+ import { traceStore } from "@zerotal/devtools";
52
+
53
+ // All traces stored in memory (up to 100, most recent first)
54
+ const traces = traceStore().all();
55
+
56
+ // Find slow requests / N+1 offenders
57
+ const slow = traces.filter((t) => t.durationMs > 500);
58
+ const nplus = traces.filter((t) => t.warnings.length > 0);
59
+
60
+ // Subscribe to new traces (fn receives null on 'clear')
61
+ const unsub = traceStore().subscribe((trace) => {
62
+ if (trace === null) return;
63
+ console.log(`${trace.method} ${trace.path} → ${trace.statusCode} (${trace.durationMs}ms)`);
64
+ });
65
+ unsub();
66
+
67
+ traceStore().clear();
68
+ ```
69
+
70
+ Traces persist to `.zerotal/devtools.sqlite` and reload on restart. Configure in
71
+ `config/devtools.ts`:
72
+
73
+ ```ts
74
+ import { DevtoolsConfig } from "@zerotal/devtools";
75
+
76
+ export default DevtoolsConfig({
77
+ capacity: 250,
78
+ dbPath: ".data/devtools.sqlite", // null keeps traces in memory only
79
+ pruneHours: 48,
80
+ redact: { allow: ["email"] }, // query bindings are masked by default
81
+ });
82
+ ```
83
+
84
+ `ZT_DEVTOOLS_DB` and `ZT_DEVTOOLS_PRUNE_HOURS` still apply when no config
85
+ file is present.
86
+
87
+ ## Exports
88
+
89
+ The package exposes two subpaths:
90
+
91
+ ### `@zerotal/devtools` (`.`)
92
+
93
+ | Export | Kind | Description |
94
+ | ----------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------- |
95
+ | `DevtoolsProvider` | provider | Registers the injection middleware and internal `/__zerotal/devtools` routes (dev only). |
96
+ | `DevtoolsInjectionMiddleware` | middleware | Injects the panel into HTML responses. Type: `DevtoolsInjectionOptions`. |
97
+ | `TraceStore`, `traceStore` | class / accessor | In-memory + SQLite-backed trace store (`all`, `push`, `clear`, `subscribe`, `dispose`). |
98
+ | `DevtoolsConfig` | config | Typed `config/devtools.ts` factory. Type: `DevtoolsConfigShape`. |
99
+ | `traceSink`, `traceChannels` | sink / registry | The `devtools.trace` surface other packages contribute through. |
100
+ | `redactBindings` | function | Mask the bindings of a statement. Type: `RedactionOptions`. |
101
+ | Trace types | types | `RequestTrace`, `QuerySpan`, `NPlusOneWarning`, `MailEntry`, `CacheEntry`, `JobEntry`, `TraceChannelDescriptor`. |
102
+
103
+ ### `@zerotal/devtools/client` (`./client`)
104
+
105
+ | Export | Description |
106
+ | ---------- | --------------------------------------------------------------------- |
107
+ | `DevTools` | Browser-side panel; call `DevTools.start()` from your frontend entry. |
108
+
109
+ ## Documentation
110
+
111
+ - [DevTools](../../docs/devtools.md)
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@zerotal/devtools",
3
+ "version": "1.0.0",
4
+ "license": "MIT",
5
+ "maturity": "experimental",
6
+ "private": false,
7
+ "type": "module",
8
+ "main": "./src/index.ts",
9
+ "types": "./src/index.ts",
10
+ "exports": {
11
+ ".": "./src/index.ts",
12
+ "./client": "./src/client.ts"
13
+ },
14
+ "files": [
15
+ "CHANGELOG.md",
16
+ "src",
17
+ "!src/**/*.test.ts",
18
+ "!src/**/*.test.tsx",
19
+ "!src/**/*.spec.ts",
20
+ "!src/**/__fixtures__/**"
21
+ ],
22
+ "publishConfig": {
23
+ "access": "public"
24
+ },
25
+ "engines": {
26
+ "bun": ">=1.3.14"
27
+ },
28
+ "scripts": {
29
+ "test": "bun test",
30
+ "typecheck": "tsc --noEmit"
31
+ },
32
+ "dependencies": {
33
+ "@zerotal/core": "1.0.0"
34
+ },
35
+ "devDependencies": {
36
+ "typescript": "^5.8.0",
37
+ "@zerotal/orm": "1.0.0"
38
+ },
39
+ "description": "In-browser developer tools for Zerotal — request traces, an inspector panel, and an extensible tab registry.",
40
+ "keywords": [
41
+ "zerotal",
42
+ "bun",
43
+ "typescript",
44
+ "framework"
45
+ ],
46
+ "repository": {
47
+ "type": "git",
48
+ "url": "git+https://github.com/zerotaldev/zerotal.git",
49
+ "directory": "packages/devtools"
50
+ },
51
+ "homepage": "https://github.com/zerotaldev/zerotal/tree/main/packages/devtools#readme",
52
+ "bugs": "https://github.com/zerotaldev/zerotal/issues"
53
+ }
@@ -0,0 +1,167 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import type { NextFn, HttpContext } from "@zerotal/core";
3
+ import { BaseMiddleware } from "@zerotal/core";
4
+ import { traceStore } from "./TraceStore.ts";
5
+ import { traceChannels } from "./tracing.ts";
6
+
7
+ // ── Injected browser client bundle ────────────────────────────────────────────
8
+ // The in-page devtools panel is bundled for the browser on first request and
9
+ // cached for the process lifetime (dev only — the provider is a no-op in prod).
10
+ let _clientJs: string | null = null;
11
+
12
+ async function _buildClientJs(): Promise<string> {
13
+ if (_clientJs !== null) return _clientJs;
14
+ try {
15
+ const entry = fileURLToPath(new URL("./client-auto.ts", import.meta.url));
16
+ const result = await Bun.build({ entrypoints: [entry], target: "browser", minify: true });
17
+ const out = result.outputs[0];
18
+ _clientJs =
19
+ result.success && out ? await out.text() : "/* [Zerotal DevTools] client build failed */";
20
+ } catch (error) {
21
+ _clientJs = `/* [Zerotal DevTools] client build error: ${String(error)} */`;
22
+ }
23
+ return _clientJs;
24
+ }
25
+
26
+ /** The standalone dashboard bundle — the same panel, mounted full-window. */
27
+ let _dashboardJs: string | null = null;
28
+
29
+ async function _buildDashboardJs(): Promise<string> {
30
+ if (_dashboardJs !== null) return _dashboardJs;
31
+ try {
32
+ const entry = fileURLToPath(new URL("./dashboard-auto.ts", import.meta.url));
33
+ const result = await Bun.build({ entrypoints: [entry], target: "browser", minify: true });
34
+ const out = result.outputs[0];
35
+ _dashboardJs =
36
+ result.success && out ? await out.text() : "/* [Zerotal DevTools] dashboard build failed */";
37
+ } catch (error) {
38
+ _dashboardJs = `/* [Zerotal DevTools] dashboard build error: ${String(error)} */`;
39
+ }
40
+ return _dashboardJs;
41
+ }
42
+
43
+ export interface DevtoolsInjectionOptions {
44
+ // reserved for future use
45
+ }
46
+
47
+ // ── SSE subscribers ───────────────────────────────────────────────────────────
48
+
49
+ const _sseClients = new Set<ReadableStreamDefaultController<Uint8Array>>();
50
+ const _enc = new TextEncoder();
51
+
52
+ function _ssePublish(data: unknown): void {
53
+ if (_sseClients.size === 0) return;
54
+ const chunk = _enc.encode(`data: ${JSON.stringify(data)}\n\n`);
55
+ for (const ctrl of _sseClients) {
56
+ try {
57
+ ctrl.enqueue(chunk);
58
+ } catch {
59
+ _sseClients.delete(ctrl);
60
+ }
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Bridge the trace store to connected panels. Called by {@link DevtoolsProvider}
66
+ * on boot rather than at module scope — subscribing on import would tie the
67
+ * stream to whichever store existed at import time, before the provider has
68
+ * built the one the app's config asks for.
69
+ *
70
+ * @returns A disposer that unsubscribes and drops connected clients.
71
+ */
72
+ export function startDevtoolsStream(): () => void {
73
+ const unsubscribe = traceStore().subscribe((trace) => {
74
+ if (trace === null) _ssePublish({ type: "clear" });
75
+ else _ssePublish({ type: "trace", data: trace });
76
+ });
77
+ return () => {
78
+ unsubscribe();
79
+ _sseClients.clear();
80
+ };
81
+ }
82
+
83
+ // ── Middleware — devtools API routes only ─────────────────────────────────────
84
+ //
85
+ // All request tracing is handled by tracing.ts subscribing to FrameworkEvents.
86
+ // This middleware only serves the devtools panel endpoints.
87
+
88
+ export class DevtoolsInjectionMiddleware extends BaseMiddleware<DevtoolsInjectionOptions> {
89
+ protected options: DevtoolsInjectionOptions = {};
90
+
91
+ async handle(http: HttpContext, next: NextFn): Promise<Response | void> {
92
+ const { pathname } = http.url;
93
+
94
+ if (pathname === "/__zerotal/devtools/api/traces") {
95
+ return Response.json(traceStore().all());
96
+ }
97
+
98
+ if (pathname === "/__zerotal/devtools/api/channels") {
99
+ return Response.json(traceChannels());
100
+ }
101
+
102
+ if (pathname === "/__zerotal/devtools/api/clear" && http.request.method === "POST") {
103
+ traceStore().clear();
104
+ return new Response(null, { status: 204 });
105
+ }
106
+
107
+ if (pathname === "/__zerotal/devtools/sse") {
108
+ let ctrl!: ReadableStreamDefaultController<Uint8Array>;
109
+ const stream = new ReadableStream<Uint8Array>({
110
+ start(c) {
111
+ ctrl = c;
112
+ _sseClients.add(ctrl);
113
+ // The opening frame carries the channel descriptors alongside the
114
+ // history, so a panel can render a package's tab on first paint
115
+ // instead of waiting for that package's next entry.
116
+ ctrl.enqueue(
117
+ _enc.encode(
118
+ `data: ${JSON.stringify({
119
+ type: "history",
120
+ data: traceStore().all(),
121
+ channels: traceChannels(),
122
+ })}\n\n`,
123
+ ),
124
+ );
125
+ },
126
+ cancel() {
127
+ _sseClients.delete(ctrl);
128
+ },
129
+ });
130
+ return new Response(stream, {
131
+ headers: {
132
+ "Content-Type": "text/event-stream",
133
+ "Cache-Control": "no-cache",
134
+ Connection: "keep-alive",
135
+ "X-Accel-Buffering": "no",
136
+ },
137
+ });
138
+ }
139
+
140
+ if (pathname === "/__zerotal/devtools/client.js") {
141
+ return new Response(await _buildClientJs(), {
142
+ headers: {
143
+ "Content-Type": "text/javascript; charset=utf-8",
144
+ "Cache-Control": "no-cache",
145
+ },
146
+ });
147
+ }
148
+
149
+ if (pathname === "/__zerotal/devtools/dashboard.js") {
150
+ return new Response(await _buildDashboardJs(), {
151
+ headers: {
152
+ "Content-Type": "text/javascript; charset=utf-8",
153
+ "Cache-Control": "no-cache",
154
+ },
155
+ });
156
+ }
157
+
158
+ if (pathname === "/__zerotal/devtools" || pathname === "/__zerotal/devtools/") {
159
+ const html = await Bun.file(new URL("./panel.html", import.meta.url)).text();
160
+ return new Response(html, {
161
+ headers: { "Content-Type": "text/html; charset=utf-8" },
162
+ });
163
+ }
164
+
165
+ return next();
166
+ }
167
+ }
@@ -0,0 +1,148 @@
1
+ export interface QuerySpan {
2
+ sql: string;
3
+ bindings: unknown[];
4
+ startMs: number;
5
+ durationMs: number;
6
+ rowCount: number;
7
+ }
8
+
9
+ export interface NPlusOneWarning {
10
+ sql: string;
11
+ count: number;
12
+ }
13
+
14
+ export interface RouteInfo {
15
+ pattern: string;
16
+ controller: string;
17
+ action: string;
18
+ }
19
+
20
+ export interface AuthInfo {
21
+ id: unknown;
22
+ name?: unknown;
23
+ email?: unknown;
24
+ }
25
+
26
+ export interface LogEntry {
27
+ level: "log" | "debug" | "info" | "warn" | "error";
28
+ args: string[];
29
+ offsetMs: number;
30
+ }
31
+
32
+ export interface MailEntry {
33
+ /** Mailable class name (e.g. "WelcomeMail") */
34
+ className: string;
35
+ to: string[];
36
+ subject: string;
37
+ /** Rendered HTML — used for the mail preview panel */
38
+ html: string;
39
+ durationMs: number;
40
+ queued: boolean;
41
+ offsetMs: number;
42
+ }
43
+
44
+ export interface CacheEntry {
45
+ op: "has" | "hit" | "miss" | "write" | "forget" | "flush";
46
+ key: string;
47
+ ttl?: number | undefined;
48
+ durationMs: number;
49
+ offsetMs: number;
50
+ }
51
+
52
+ export interface JobEntry {
53
+ className: string;
54
+ queue: string;
55
+ status: "dispatched" | "completed" | "failed";
56
+ durationMs: number;
57
+ error?: string;
58
+ offsetMs: number;
59
+ }
60
+
61
+ // ── Open channels ─────────────────────────────────────────────────────────────
62
+
63
+ /**
64
+ * One entry on an open {@link TraceChannelDescriptor | channel}.
65
+ *
66
+ * The shape is deliberately a bare record: devtools does not know what a
67
+ * channel carries, only how its descriptor says to display it. `offsetMs` is
68
+ * stamped by devtools when the entry is recorded.
69
+ */
70
+ export interface TraceChannelEntry extends Record<string, unknown> {
71
+ /** Milliseconds from the start of the request. Stamped on record. */
72
+ offsetMs: number;
73
+ }
74
+
75
+ /**
76
+ * How a package's channel is displayed in the panel.
77
+ *
78
+ * The descriptor crosses the wire to the browser, so it names *fields* rather
79
+ * than carrying formatter functions — a channel is declared once on the server
80
+ * and rendered generically, which is what lets a package add a tab without
81
+ * devtools shipping a renderer for it.
82
+ *
83
+ * @example
84
+ * trace.channel({
85
+ * id: "auth",
86
+ * label: "Auth",
87
+ * badge: "event",
88
+ * title: "detail",
89
+ * meta: ["guard", "ip"],
90
+ * warn: "failed",
91
+ * });
92
+ */
93
+ export interface TraceChannelDescriptor {
94
+ /** Unique id — also the key under {@link RequestTrace.channels}. */
95
+ id: string;
96
+ /** Tab label in the panel. */
97
+ label: string;
98
+ /** Entry field rendered as the row's leading badge. */
99
+ badge?: string;
100
+ /** Entry field rendered as the row's main text. Defaults to the badge field. */
101
+ title?: string;
102
+ /** Entry fields rendered as dim metadata beneath the title. */
103
+ meta?: string[];
104
+ /** Entry field whose truthiness marks the row — and the tab's badge — as a warning. */
105
+ warn?: string;
106
+ /** Sort order among channel tabs. Lower sorts first. Defaults to 100. */
107
+ order?: number;
108
+ }
109
+
110
+ export interface RequestTrace {
111
+ id: string;
112
+ requestId: string;
113
+ method: string;
114
+ path: string;
115
+ statusCode: number;
116
+ startMs: number;
117
+ durationMs: number;
118
+ queries: QuerySpan[];
119
+ warnings: NPlusOneWarning[];
120
+ /** Heap memory used at the end of the request, in bytes */
121
+ memory: number;
122
+ /** URL query string parameters */
123
+ queryParams: Record<string, string>;
124
+ /** Filtered request headers (no auth/cookie values) */
125
+ headers: Record<string, string>;
126
+ /** Matched route pattern, controller, and action */
127
+ route: RouteInfo | null;
128
+ /** Authenticated user at the end of the request, or null for guests */
129
+ auth: AuthInfo | null;
130
+ /** Console log/debug/info/warn/error messages emitted during the request */
131
+ logs: LogEntry[];
132
+ /** Emails sent (or queued) during this request */
133
+ mail: MailEntry[];
134
+ /** Cache operations performed during this request */
135
+ cache: CacheEntry[];
136
+ /** Jobs dispatched (or processed synchronously) during this request */
137
+ jobs: JobEntry[];
138
+ /**
139
+ * Entries recorded on open channels, keyed by channel id.
140
+ *
141
+ * The fields above are the five signals devtools renders with bespoke UI they
142
+ * have earned — a query needs its bindings and duration bar, mail needs its
143
+ * preview. Everything else a package wants to show lands here and is rendered
144
+ * from its {@link TraceChannelDescriptor}, so contributing a tab takes no
145
+ * change inside devtools.
146
+ */
147
+ channels: Record<string, TraceChannelEntry[]>;
148
+ }