@zerotal/devtools 1.6.2 → 1.7.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +233 -1
  2. package/api-surface.md +296 -0
  3. package/package.json +5 -4
  4. package/src/DevtoolsInjectionMiddleware.ts +41 -4
  5. package/src/RequestTrace.ts +96 -1
  6. package/src/TraceStore.ts +12 -0
  7. package/src/activity.ts +116 -0
  8. package/src/callsite.ts +146 -0
  9. package/src/client/filter.ts +108 -0
  10. package/src/client/index.ts +122 -0
  11. package/src/client/metrics.ts +98 -0
  12. package/src/client/registry.ts +65 -0
  13. package/src/client/state.ts +311 -0
  14. package/src/client/tabs/all.ts +276 -0
  15. package/src/client/tabs/app.ts +292 -0
  16. package/src/client/tabs/cache.ts +49 -0
  17. package/src/client/tabs/channel.ts +263 -0
  18. package/src/client/tabs/exceptions.ts +68 -0
  19. package/src/client/tabs/jobs.ts +50 -0
  20. package/src/client/tabs/logs.ts +44 -0
  21. package/src/client/tabs/mail.ts +59 -0
  22. package/src/client/tabs/queries.ts +124 -0
  23. package/src/client/tabs/request.ts +76 -0
  24. package/src/client/tabs/timeline.ts +132 -0
  25. package/src/client/tabs/types.ts +51 -0
  26. package/src/client/transport.ts +81 -0
  27. package/src/client/tree.ts +138 -0
  28. package/src/client/ui/format.ts +118 -0
  29. package/src/client/ui/render.ts +87 -0
  30. package/src/client/ui/shell.ts +511 -0
  31. package/src/client/ui/theme.ts +389 -0
  32. package/src/client-auto.ts +1 -1
  33. package/src/config.ts +77 -2
  34. package/src/dashboard-auto.ts +1 -1
  35. package/src/editor.ts +107 -0
  36. package/src/enabled.ts +59 -0
  37. package/src/index.ts +19 -3
  38. package/src/map.ts +213 -0
  39. package/src/provider/DevtoolsProvider.ts +32 -7
  40. package/src/redaction.ts +161 -20
  41. package/src/tracing.ts +213 -24
  42. package/src/client.ts +0 -1048
  43. package/src/panel-app.js +0 -519
package/CHANGELOG.md CHANGED
@@ -6,7 +6,239 @@ follows the Zerotal monorepo's unified versioning.
6
6
 
7
7
  **Maturity: `stable`**
8
8
 
9
- ## [Unreleased]
9
+ ## [1.7.0] — 2026-08-16
10
+
11
+ ### Added
12
+
13
+ - **An App section: the framework as it _is_, not only as it just behaved.** Every surface
14
+ until now read the trace stream — what one request did. The framework's own registries
15
+ were CLI-only or invisible, so "is that route even registered", "who bound `cache`",
16
+ "which provider is costing 200ms of boot" and "does anything actually listen to
17
+ `OrderPlaced`" were all answered by reading source.
18
+
19
+ Six tabs behind a **Requests | App** switch in the tab strip — two sections rather than
20
+ fifteen tabs in one scrolling strip, because they answer different questions:
21
+
22
+ - **Routes** — method, path, name, handler, middleware. GETs are clickable.
23
+ - **Config** — the resolved tree, flattened to dotted paths, with secrets masked.
24
+ - **Container** — every binding, its kind, and **which provider bound it**.
25
+ - **Providers** — boot order and per-provider cost.
26
+ - **Events** — application listeners and framework subscribers in one list.
27
+ - **Commands** — console commands and scheduled tasks. A task that fails at 03:00 used
28
+ to leave no trace in the tool whose job is to show you what your app did.
29
+
30
+ One read of one map shared by all six, since six requests for it would be six answers
31
+ that can disagree. Behind the same gate as everything else.
32
+
33
+ - **Every location in the panel is a link into your editor.** A repo-wide search for any
34
+ editor URL scheme used to return nothing: no stack frame, query, log line, or prop in any
35
+ Zerotal surface was clickable to source. Going from "this query is slow" to the line that
36
+ ran it is the most frequent move in a debugging session, and it was two manual searches.
37
+
38
+ `editor` in `config/devtools.ts` takes `vscode`, `vscode-insiders`, `cursor`, `windsurf`,
39
+ `zed`, or `webstorm`; `editorPathMap` rewrites paths for editing on a machine that is not
40
+ the one running the code.
41
+
42
+ - **Queries and log lines know where they came from.** One stack walk per recorded event,
43
+ filtered to application frames — the first frame that is _not_ framework code is the
44
+ answer, and every frame above it is noise.
45
+
46
+ This was the plan's one item of genuinely unknown viability, so it was measured before it
47
+ was built: **~2µs per capture, flat from stack depth 5 to 80**, because the engine builds
48
+ the trace lazily. A request running forty queries pays about 0.08ms. On by default;
49
+ `captureSource: false` turns it off.
50
+
51
+ - **An Exception tab.** Type, message, and the full stack with every frame a link. Framework
52
+ frames are kept and dimmed rather than dropped — you read a stack trace to find out how you
53
+ got somewhere, and a trace with the middle removed does not tell you that.
54
+
55
+ - **Three more tabs, from events already on the bus and going nowhere.** **Models**
56
+ (`ModelChanged`, grouped per model — a request that wrote four rows and one that wrote none
57
+ looked identical), **Transactions** (`TransactionCommitted` / `RolledBack` — a rollback
58
+ showed only as queries that appeared to succeed), and **Outgoing** (`OutgoingRequestCompleted`).
59
+
60
+ - **The Request tab shows the whole exchange.** Response headers and the status line
61
+ alongside the request, plus **session key names** — "is the CSRF token there, did the flash
62
+ survive the redirect" are answered by the keys, and the values are the request's real state
63
+ on a trace kept for a day. `headers: ["x-tenant"]` (or `["*"]`) opens up the request headers
64
+ the built-in allowlist withholds; `cookie` and `authorization` are never recorded whatever
65
+ you ask for.
66
+
67
+ - **What the browser measured, on the Timeline.** The panel reported server duration as
68
+ though it were the user's experience. A 12ms response the browser spends 900ms painting is
69
+ a slow page, and nothing in the trace said so — so TTFB, parse, load, and first paint now
70
+ sit above the waterfall, labelled as the page's rather than this request's.
71
+
72
+ ### Security
73
+
74
+ - **The Config tab masks a bare `key`, which the shared rule does not.** `app.key` is the
75
+ application's encryption key; the package-wide list covers `api_key` and `private_key` but
76
+ not `key` alone, which is right for a query binding — a column called `key` is usually a
77
+ lookup key — and wrong for config, where secrets are _supposed_ to live. Config gets the
78
+ stricter rule, `dsn` with it, on top of whatever the app's own `allow`/`deny` say.
79
+
80
+ - **An access gate, so running the inspector outside development is a supported thing to do
81
+ rather than a lie about `APP_ENV`.** It was all-or-nothing on the dev-surface check, which
82
+ is the right default and the only option — and Phase 4 widens what it exposes considerably.
83
+
84
+ ```ts
85
+ export default DevtoolsConfig({
86
+ enabled: null, // null → follow the dev-surface gate (unchanged default)
87
+ gate: async (req) => …, // required anywhere else; absent is a refusal
88
+ });
89
+ ```
90
+
91
+ A development process always passes — a gate that can lock a developer out of their own
92
+ machine gets switched off, and then nothing is gated. Anywhere else the absence of a gate
93
+ is a **refusal**, a throwing gate is a refusal, and one gate answers for every endpoint:
94
+ the stream, the trace JSON, the dashboard, and the panel bundle are the same secret.
95
+ Unauthorised requests get 404, not 403 — outside development the honest answer to a
96
+ stranger is that there is nothing here.
97
+
98
+ Auto-injection of the panel script is now dev-only. On a gated environment it would go into
99
+ every visitor's HTML and then 404 in their console; there the way in is the dashboard.
100
+
101
+ - **The panel remembers, follows, and gets out of the way.** Five things it could not do:
102
+
103
+ - **Resize.** The height was 380px, which is either too little to read a stack trace in or
104
+ too much to see the page under. Drag the strip above the tab row; the height is kept.
105
+ - **Light theme.** Follows `prefers-color-scheme` by default, with a toggle in the bar
106
+ cycling auto → dark → light for when the panel and the page you are debugging disagree.
107
+ - **Facets.** Method chips (only the verbs actually recorded), status-class chips, and
108
+ `errors` / `slow` / `n+1` toggles, composing with the text box rather than replacing it.
109
+ `POST` plus `5xx` means failing writes, not writes-or-failures.
110
+ - **Keyboard navigation.** `j`/`k` through the filtered list, `1`–`9` for tabs, `/` to
111
+ focus the filter, `Esc` to close. Only while the panel has focus — it is an overlay on
112
+ somebody's application, and binding `j` globally would navigate the trace list every time
113
+ a developer typed into their own form.
114
+ - **Copy buttons** on every SQL statement (and the whole statement list), log line, header
115
+ block, channel entry, and prop path.
116
+
117
+ - **New traces are offered, not forced.** While pinned, the bar shows `⤒ N new` instead of
118
+ silently accumulating. A list that scrolls away from what you were reading is the one
119
+ thing a request inspector must not do.
120
+
121
+ ### Changed
122
+
123
+ - **The browser client is a directory, not a 1,400-line closure.** `client.ts` held its
124
+ state, its transport, its styles, eight renderers, and every helper in one function scope
125
+ — which made adding a tab an edit to the middle of it, and made none of its logic reachable
126
+ from a test. It is now `src/client/`: a store, a transport, a shell, and one file per tab.
127
+ Adding a tab is adding a file.
128
+
129
+ No behaviour was dropped. Both mount modes still run one set of renderers, both extension
130
+ doors are unchanged, and `@zerotal/devtools/client` still resolves — the subpath now points
131
+ at `src/client/index.ts`.
132
+
133
+ - **Rows are reconciled rather than rebuilt.** Every arriving request used to replace the
134
+ whole content pane, which threw away the scroll position, every open `<details>`, every
135
+ loaded mail-preview iframe, and the caret in the filter box — the last of which the old
136
+ panel worked around by re-focusing and re-selecting the field after each keystroke. A keyed
137
+ list diff (about sixty lines, no dependency) now inserts one node and touches nothing else,
138
+ and tabs that read a single trace are not redrawn at all until that trace changes.
139
+
140
+ - **The request list is windowed above 200 rows**, so `DevtoolsConfig({ capacity: 5000 })` is
141
+ a list you can scroll rather than five thousand nodes.
142
+
143
+ ### Fixed
144
+
145
+ - **A negative render window.** Whenever the request list shrank under a scrolled viewport —
146
+ which is every "clear" — the windowing arithmetic asked for a slice starting past the end
147
+ of the list. Found by the test that could finally be written for it.
148
+
149
+ - **An Inertia tab, with the prop tree.** `@zerotal/inertia` has always resolved the richest
150
+ data in the framework — per-prop metadata, request classification, batch correlation — and
151
+ shipped no UI for it, while this panel shipped the UI and knew nothing about Inertia. A
152
+ developer got a panel that could not show a prop and an extension that could not show a
153
+ query, and neither could answer "this page is slow — is it the query or the deferred prop?"
154
+
155
+ Inertia now contributes a channel, and one row shows the component, its props, **and** the
156
+ SQL that produced them. Nothing matches a key: the entry is recorded against the same
157
+ request context as the queries, so the two cannot disagree about which request they
158
+ describe.
159
+
160
+ - **Channels choose how they are drawn.** A flat list of rows is right for an audit feed and
161
+ wrong for a prop map or a route table, and a package that needs a tree should not have to
162
+ ship a renderer into devtools to get one. `TraceChannelDescriptor` gains `render` —
163
+ `"rows"` (the default, unchanged), `"tree"`, `"table"`, `"kv"`, `"grouped"` — plus
164
+ `treeField`, `treeBadge`, `groupBy`, and `flags`. All of it crosses the wire as data, so
165
+ this remains the only channel-rendering code in the panel however many packages contribute.
166
+
167
+ - **Correlated requests fold together on the All tab.** A channel names the field that
168
+ relates traces (`traceGroup`); everything sharing a value there collapses under the oldest
169
+ member with a `+N` toggle. One thing you did is often several requests — a visit and the
170
+ deferred props that follow it — and listing them as unrelated siblings is how the request
171
+ you are reading gets pushed off the top.
172
+
173
+ - **Badge chips are accented by hashing their own text**, so `partial` and `deferred` are
174
+ tellable apart at a glance without devtools holding a list of every value any package
175
+ might use. Stable per value, so a badge keeps its colour between requests.
176
+
177
+ - `buildPathTree` and `traceGroupKey` are exported from `@zerotal/devtools/client`, for the
178
+ same reason `matchesFilter` is: they are the parts of the panel that are logic rather than
179
+ markup, and they are worth testing without a DOM.
180
+
181
+ ### Changed
182
+
183
+ - **The redaction walk moved to `redactGraph` in `@zerotal/core/security`**, and the Inertia
184
+ recorder runs the same one. Both had independently solved the same three problems — cycles,
185
+ depth, and values that read better flat than walked — and only one of them had to.
186
+ Markers are still each caller's own: a panel's `‹redacted›` is a display choice, while an
187
+ adapter implementing a published protocol has `[REDACTED]` specified for it. Sharing the
188
+ traversal does not mean agreeing on the words. No behaviour change.
189
+
190
+ - **Failed requests say what failed.** `RequestFailed` has always carried the error
191
+ message; the trace dropped it, so a request that threw showed as a red status code with
192
+ nothing to read beside it. Traces now carry an `exception` (`message` and `status`), the
193
+ Queries tab leads with it, and the All tab marks the row and shows the message inline —
194
+ so you can find the request that broke without opening each one in turn.
195
+
196
+ - **Mail previews.** The rendered HTML of every captured email has crossed the wire on
197
+ every request since mail capture landed, and has never been shown. Each mail now has a
198
+ **Preview**, collapsed by default, rendered in a fully sandboxed frame — no scripts, no
199
+ same-origin access, no navigation. The sandbox is not optional hardening: the panel lives
200
+ in a shadow root on the app's own origin, so inserting a template's markup inline would
201
+ make any user input inside a mail a self-XSS on every dev machine.
202
+
203
+ - **N+1 warnings say what to do about them again.** Each warning now carries the eager-load
204
+ that removes it and the `DB.allowNPlusOne(…)` call that suppresses it when the repetition
205
+ is intended. A warning without a remedy is half a feature.
206
+
207
+ - **The panel remembers where you were.** Whether it was open, which tab you were on, and
208
+ what you had filtered to survive a reload. On a page you are reloading _because_ you are
209
+ debugging it, that was the wrong moment to lose your place.
210
+
211
+ ### Fixed
212
+
213
+ - **`capacity` did not do what it said.** The browser panel trimmed its list at a hardcoded
214
+ 100 regardless of config, so an app with `DevtoolsConfig({ capacity: 250 })` received 250
215
+ traces in the opening frame and then silently lost everything past 100 as soon as the next
216
+ request arrived. The stream now sends the store's capacity and the panel trims to it.
217
+
218
+ - **A circular argument to `console.log` threw out of the log capture** and into the
219
+ caller. The capture ran `JSON.stringify` on the raw argument, so anything
220
+ self-referential — a model with a loaded relation back to its parent, a request object —
221
+ raised `Converting circular structure to JSON` from inside a patched `console.log`.
222
+
223
+ ### Security
224
+
225
+ - **Redaction covered query bindings only.** Console log arguments, channel entries, and
226
+ cache keys were streamed to the browser and written to `.zerotal/devtools.sqlite`
227
+ unredacted, where they sat for a day — so one `console.log(user)` during a debug session
228
+ wrote a full user record, password hash included, to disk.
229
+
230
+ All four are now masked at the sink, where the value enters the trace rather than where
231
+ the panel draws it: redacting in a renderer protects nothing, because the unredacted copy
232
+ is already persisted by then. One rule decides all four, so `allow` and `deny` in
233
+ `config/devtools.ts` mean the same thing everywhere. A cache key keeps its name and loses
234
+ only what follows it, so the Cache tab stays legible.
235
+
236
+ New exports: `redactValue`, `redactCacheKey`, and `isSensitiveName`.
237
+
238
+ ### Removed
239
+
240
+ - **`src/panel-app.js`** — ~500 lines of the pre-`client.ts` panel, unimported, unbundled,
241
+ and unserved since the rewrite.
10
242
 
11
243
  ## [1.6.2] — 2026-08-15
12
244
 
package/api-surface.md ADDED
@@ -0,0 +1,296 @@
1
+ # @zerotal/devtools — public API surface
2
+
3
+ <!-- AUTO-GENERATED by scripts/api-surface.ts. Do not edit by hand.
4
+ Run `bun run api:surface` to regenerate after an intentional API change. -->
5
+
6
+ ## . `(./src/index.ts)`
7
+
8
+ class DevtoolsInjectionMiddleware = {
9
+ new (): DevtoolsInjectionMiddleware
10
+ static with: <T extends new (...args: any[]) => BaseMiddleware<any>, Opts = T extends new (...args: any[]) => BaseMiddleware<infer U> ? U : object>(this: T, options: DeepPartial<NoInfer<Opts>>) => new () => InstanceType<T>
11
+ afterResponse?: (ctx: HttpContext) => Promise<void>
12
+ handle: (http: HttpContext, next: NextFn) => Promise<Response | void>
13
+ onError?: (ctx: HttpContext, error: Error) => Promise<void>
14
+ }
15
+
16
+ class DevtoolsProvider = {
17
+ new (app: Application): DevtoolsProvider
18
+ static dependsOn?: (new (app: Application) => ServiceProvider)[]
19
+ static environments: AppEnvironment[]
20
+ static priority?: number
21
+ static provides: readonly []
22
+ devProcesses: () => DevProcessDefinition[]
23
+ doctorChecks: () => DoctorCheck[]
24
+ onBooted: () => Promise<void>
25
+ onBooting: () => Promise<void>
26
+ onRegister: () => void
27
+ onRequestProcessed: (_ctx: HttpContext) => Promise<void>
28
+ onRequestReceived: (_ctx: HttpContext) => Promise<void>
29
+ onResponseSent: (_ctx: HttpContext) => Promise<void>
30
+ onStarted: () => Promise<void>
31
+ onStarting: () => Promise<void>
32
+ onStopped: () => Promise<void>
33
+ onStopping: () => Promise<void>
34
+ replContext: () => Record<string, unknown>
35
+ }
36
+
37
+ class TraceStore = {
38
+ new (options?: TraceStoreOptions | number): TraceStore
39
+ all: () => RequestTrace[]
40
+ capacity: number
41
+ clear: () => void
42
+ dispose: () => void
43
+ persisting: boolean
44
+ push: (trace: RequestTrace) => void
45
+ subscribe: (fn: Subscriber) => () => void
46
+ }
47
+
48
+ const traceSink = TraceSink
49
+
50
+ function _setTraceStore = (store: TraceStore | null) => void
51
+
52
+ function attributeBindings = (sql: string, count: number) => Array<string | undefined>
53
+
54
+ function DevtoolsConfig = (options?: Partial<DevtoolsConfigShape>) => DevtoolsConfigShape
55
+
56
+ function devtoolsEnabled = () => boolean
57
+
58
+ function isSensitiveName = (name: string, options?: RedactionOptions) => boolean
59
+
60
+ function redactBindings = (sql: string, bindings: unknown[], options?: RedactionOptions) => unknown[]
61
+
62
+ function redactCacheKey = (key: string, options?: RedactionOptions) => string
63
+
64
+ function redactValue = (value: unknown, options?: RedactionOptions) => unknown
65
+
66
+ function startDevtoolsStream = () => () => void
67
+
68
+ function traceChannels = () => TraceChannelDescriptor[]
69
+
70
+ function traceStore = () => TraceStore
71
+
72
+ interface AuthInfo = {
73
+ email?: unknown
74
+ id: unknown
75
+ name?: unknown
76
+ }
77
+
78
+ interface CacheEntry = {
79
+ durationMs: number
80
+ key: string
81
+ offsetMs: number
82
+ op: 'hit' | 'miss' | 'write' | 'forget' | 'flush' | 'has'
83
+ ttl?: number | undefined
84
+ }
85
+
86
+ interface DevtoolsConfigShape = {
87
+ capacity: number
88
+ captureSource: boolean
89
+ dbPath: string | null
90
+ editor: EditorName | null
91
+ editorPathMap: Record<string, string>
92
+ enabled: boolean | null
93
+ gate: DevtoolsGate | null
94
+ headers: string[]
95
+ pruneHours: number
96
+ redact: RedactionOptions
97
+ }
98
+
99
+ interface DevtoolsInjectionOptions = {}
100
+
101
+ interface DevtoolsPanelPlugin = {
102
+ badge?: () => number | string | undefined
103
+ id: string
104
+ render: (el: HTMLElement) => void
105
+ title: string
106
+ }
107
+
108
+ interface ExceptionInfo = {
109
+ frames?: SourceLocation[]
110
+ message: string
111
+ status: number
112
+ type?: string
113
+ }
114
+
115
+ interface JobEntry = {
116
+ className: string
117
+ durationMs: number
118
+ error?: string
119
+ offsetMs: number
120
+ queue: string
121
+ status: 'dispatched' | 'completed' | 'failed'
122
+ }
123
+
124
+ interface LogEntry = {
125
+ args: string[]
126
+ level: 'error' | 'info' | 'log' | 'warn' | 'debug'
127
+ offsetMs: number
128
+ source?: SourceLocation
129
+ }
130
+
131
+ interface MailEntry = {
132
+ className: string
133
+ durationMs: number
134
+ html: string
135
+ offsetMs: number
136
+ queued: boolean
137
+ subject: string
138
+ to: string[]
139
+ }
140
+
141
+ interface NPlusOneWarning = {
142
+ count: number
143
+ sql: string
144
+ }
145
+
146
+ interface QuerySpan = {
147
+ bindings: unknown[]
148
+ durationMs: number
149
+ rowCount: number
150
+ source?: SourceLocation
151
+ sql: string
152
+ startMs: number
153
+ }
154
+
155
+ interface RedactionOptions = {
156
+ allow?: string[]
157
+ deny?: string[]
158
+ enabled?: boolean
159
+ }
160
+
161
+ interface RequestTrace = {
162
+ auth: AuthInfo | null
163
+ cache: CacheEntry[]
164
+ channels: Record<string, TraceChannelEntry[]>
165
+ durationMs: number
166
+ exception: ExceptionInfo | null
167
+ headers: Record<string, string>
168
+ id: string
169
+ jobs: JobEntry[]
170
+ logs: LogEntry[]
171
+ mail: MailEntry[]
172
+ memory: number
173
+ method: string
174
+ path: string
175
+ queries: QuerySpan[]
176
+ queryParams: Record<string, string>
177
+ requestId: string
178
+ responseHeaders: Record<string, string>
179
+ route: RouteInfo | null
180
+ session: string[]
181
+ startMs: number
182
+ statusCode: number
183
+ warnings: NPlusOneWarning[]
184
+ }
185
+
186
+ interface RouteInfo = {
187
+ action: string
188
+ controller: string
189
+ pattern: string
190
+ }
191
+
192
+ interface SourceLocation = {
193
+ column?: number
194
+ file: string
195
+ function?: string
196
+ line: number
197
+ }
198
+
199
+ interface TraceChannelDescriptor = {
200
+ badge?: string
201
+ flags?: string[]
202
+ groupBy?: string
203
+ id: string
204
+ label: string
205
+ meta?: string[]
206
+ order?: number
207
+ render?: 'table' | 'rows' | 'tree' | 'kv' | 'grouped'
208
+ title?: string
209
+ traceGroup?: string
210
+ treeBadge?: string
211
+ treeField?: string
212
+ warn?: string
213
+ }
214
+
215
+ interface TraceChannelEntry = {
216
+ [key: string]: unknown
217
+ offsetMs: number
218
+ }
219
+
220
+ interface TraceSink = {
221
+ bufferCache: (ctx: object, c: Omit<CacheEntry, 'offsetMs'>) => void
222
+ bufferJob: (ctx: object, j: Omit<JobEntry, 'offsetMs'>) => void
223
+ bufferMail: (ctx: object, m: Omit<MailEntry, 'offsetMs'>) => void
224
+ bufferQuery: (ctx: object, q: QuerySpan) => void
225
+ bufferWarning: (ctx: object, w: NPlusOneWarning) => void
226
+ channel: (descriptor: TraceChannelDescriptor) => void
227
+ record: (ctx: object, channel: string, entry: Record<string, unknown>) => void
228
+ }
229
+
230
+ interface TraceStoreOptions = {
231
+ capacity?: number
232
+ dbPath?: string | null
233
+ pruneHours?: number
234
+ }
235
+
236
+ type DevtoolsGate = (request: Request) => boolean | Promise<boolean>
237
+
238
+ type EditorName = 'vscode' | 'vscode-insiders' | 'cursor' | 'windsurf' | 'zed' | 'webstorm'
239
+
240
+ ## ./client `(./src/client/index.ts)`
241
+
242
+ const DevTools = { start(opts?: DevtoolsClientOptions): void;}
243
+
244
+ const SLOW_MS = 300
245
+
246
+ function buildPathTree = (paths: Array<[string, unknown]>) => Map<string, PathTreeNode>
247
+
248
+ function facetsActive = (f: Facets) => boolean
249
+
250
+ function foldTraceRows = (matches: Array<{ trace: RequestTrace; index: number;}>, channels: TraceChannelDescriptor[], expanded: ReadonlySet<string>) => TraceRow[]
251
+
252
+ function matchesFacets = (trace: RequestTrace, f: Facets) => boolean
253
+
254
+ function matchesFilter = (trace: RequestTrace, query: string) => boolean
255
+
256
+ function methodsPresent = (traces: RequestTrace[]) => string[]
257
+
258
+ function noFacets = () => Facets
259
+
260
+ function traceGroupKey = (trace: RequestTrace, channels: TraceChannelDescriptor[]) => string | null
261
+
262
+ function traceMatches = (trace: RequestTrace, query: string, f: Facets) => boolean
263
+
264
+ interface DevtoolsClientOptions = {
265
+ endpoint?: string
266
+ mode?: 'floating' | 'standalone'
267
+ mount?: HTMLElement
268
+ }
269
+
270
+ interface DevtoolsPanelPlugin = {
271
+ badge?: () => number | string | undefined
272
+ id: string
273
+ render: (el: HTMLElement) => void
274
+ title: string
275
+ }
276
+
277
+ interface Facets = {
278
+ errors: boolean
279
+ methods: string[]
280
+ nPlusOne: boolean
281
+ slow: boolean
282
+ statusClasses: string[]
283
+ }
284
+
285
+ interface PathTreeNode = {
286
+ attrs: Record<string, unknown> | null
287
+ children: Map<string, PathTreeNode>
288
+ }
289
+
290
+ interface TraceRow = {
291
+ child: boolean
292
+ groupKey?: string
293
+ groupSize?: number
294
+ index: number
295
+ trace: RequestTrace
296
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/devtools",
3
- "version": "1.6.2",
3
+ "version": "1.7.0",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -9,10 +9,11 @@
9
9
  "types": "./src/index.ts",
10
10
  "exports": {
11
11
  ".": "./src/index.ts",
12
- "./client": "./src/client.ts"
12
+ "./client": "./src/client/index.ts"
13
13
  },
14
14
  "files": [
15
15
  "CHANGELOG.md",
16
+ "api-surface.md",
16
17
  "src",
17
18
  "!src/**/*.test.ts",
18
19
  "!src/**/*.test.tsx",
@@ -30,11 +31,11 @@
30
31
  "typecheck": "tsc --noEmit"
31
32
  },
32
33
  "dependencies": {
33
- "@zerotal/core": "1.6.2"
34
+ "@zerotal/core": "1.7.0"
34
35
  },
35
36
  "devDependencies": {
36
37
  "typescript": "^5.8.0",
37
- "@zerotal/orm": "1.6.2"
38
+ "@zerotal/orm": "1.7.0"
38
39
  },
39
40
  "description": "In-browser developer tools for Zerotal — request traces, an inspector panel, and an extensible tab registry.",
40
41
  "keywords": [
@@ -1,8 +1,11 @@
1
1
  import { fileURLToPath } from "node:url";
2
2
  import type { NextFn, HttpContext } from "@zerotal/core";
3
- import { BaseMiddleware } from "@zerotal/core";
3
+ import { BaseMiddleware, tryCurrentApp } from "@zerotal/core";
4
4
  import { traceStore } from "./TraceStore.ts";
5
5
  import { traceChannels } from "./tracing.ts";
6
+ import { devtoolsAuthorized, devtoolsSettings } from "./enabled.ts";
7
+ import { buildFrameworkMap } from "./map.ts";
8
+ import { activityFeed } from "./activity.ts";
6
9
 
7
10
  // ── Injected browser client bundle ────────────────────────────────────────────
8
11
  // The in-page devtools panel is bundled for the browser on first request and
@@ -44,6 +47,9 @@ export interface DevtoolsInjectionOptions {
44
47
  // reserved for future use
45
48
  }
46
49
 
50
+ /** Everything the inspector serves lives under here, and is gated as one thing. */
51
+ const DEVTOOLS_PREFIX = "/__zerotal/devtools";
52
+
47
53
  // ── SSE subscribers ───────────────────────────────────────────────────────────
48
54
 
49
55
  const _sseClients = new Set<ReadableStreamDefaultController<Uint8Array>>();
@@ -130,10 +136,34 @@ export class DevtoolsInjectionMiddleware extends BaseMiddleware<DevtoolsInjectio
130
136
  async handle(http: HttpContext, next: NextFn): Promise<Response | void> {
131
137
  const { pathname } = http.url;
132
138
 
139
+ // Everything under the prefix is one secret. The stream, the trace JSON, the
140
+ // dashboard, and the panel bundle all expose the same request data, so they
141
+ // are gated together and before anything is read — checking per endpoint is
142
+ // how one of them ends up ungated.
143
+ if (pathname.startsWith(DEVTOOLS_PREFIX)) {
144
+ if (!(await devtoolsAuthorized(http.request))) {
145
+ // 404, not 403: outside development the honest answer to an
146
+ // unauthenticated stranger is that there is nothing here.
147
+ return new Response("Not Found", { status: 404 });
148
+ }
149
+ }
150
+
133
151
  if (pathname === "/__zerotal/devtools/api/traces") {
134
152
  return Response.json(traceStore().all());
135
153
  }
136
154
 
155
+ // The framework map: read fresh, never cached. The registries are small and
156
+ // static, and a cached map is one that disagrees with the app the moment a
157
+ // provider registers a route late.
158
+ if (pathname === "/__zerotal/devtools/api/map") {
159
+ const app = tryCurrentApp();
160
+ if (!app) return Response.json({ error: "No application in scope" }, { status: 503 });
161
+ return Response.json({
162
+ ...buildFrameworkMap(app, devtoolsSettings().redact),
163
+ activity: activityFeed(),
164
+ });
165
+ }
166
+
137
167
  if (pathname === "/__zerotal/devtools/api/channels") {
138
168
  return Response.json(traceChannels());
139
169
  }
@@ -149,15 +179,22 @@ export class DevtoolsInjectionMiddleware extends BaseMiddleware<DevtoolsInjectio
149
179
  start(c) {
150
180
  ctrl = c;
151
181
  _sseClients.add(ctrl);
152
- // The opening frame carries the channel descriptors alongside the
153
- // history, so a panel can render a package's tab on first paint
154
- // instead of waiting for that package's next entry.
182
+ // The opening frame carries everything the panel needs at first paint
183
+ // rather than making it ask three more times: the channel descriptors,
184
+ // so a package's tab is there before that package's next entry; the
185
+ // store's capacity, so the list trims to the depth the app asked for;
186
+ // and the editor settings, so a `file:line` is a link on the first
187
+ // trace rather than the second.
188
+ const settings = devtoolsSettings();
155
189
  ctrl.enqueue(
156
190
  _enc.encode(
157
191
  `data: ${JSON.stringify({
158
192
  type: "history",
159
193
  data: traceStore().all(),
160
194
  channels: traceChannels(),
195
+ capacity: traceStore().capacity,
196
+ editor: settings.editor,
197
+ editorPathMap: settings.editorPathMap,
161
198
  })}\n\n`,
162
199
  ),
163
200
  );