@zerotal/devtools 1.6.3 → 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.
- package/CHANGELOG.md +233 -1
- package/api-surface.md +296 -0
- package/package.json +5 -4
- package/src/DevtoolsInjectionMiddleware.ts +41 -4
- package/src/RequestTrace.ts +96 -1
- package/src/TraceStore.ts +12 -0
- package/src/activity.ts +116 -0
- package/src/callsite.ts +146 -0
- package/src/client/filter.ts +108 -0
- package/src/client/index.ts +122 -0
- package/src/client/metrics.ts +98 -0
- package/src/client/registry.ts +65 -0
- package/src/client/state.ts +311 -0
- package/src/client/tabs/all.ts +276 -0
- package/src/client/tabs/app.ts +292 -0
- package/src/client/tabs/cache.ts +49 -0
- package/src/client/tabs/channel.ts +263 -0
- package/src/client/tabs/exceptions.ts +68 -0
- package/src/client/tabs/jobs.ts +50 -0
- package/src/client/tabs/logs.ts +44 -0
- package/src/client/tabs/mail.ts +59 -0
- package/src/client/tabs/queries.ts +124 -0
- package/src/client/tabs/request.ts +76 -0
- package/src/client/tabs/timeline.ts +132 -0
- package/src/client/tabs/types.ts +51 -0
- package/src/client/transport.ts +81 -0
- package/src/client/tree.ts +138 -0
- package/src/client/ui/format.ts +118 -0
- package/src/client/ui/render.ts +87 -0
- package/src/client/ui/shell.ts +511 -0
- package/src/client/ui/theme.ts +389 -0
- package/src/client-auto.ts +1 -1
- package/src/config.ts +77 -2
- package/src/dashboard-auto.ts +1 -1
- package/src/editor.ts +107 -0
- package/src/enabled.ts +59 -0
- package/src/index.ts +19 -3
- package/src/map.ts +213 -0
- package/src/provider/DevtoolsProvider.ts +32 -7
- package/src/redaction.ts +161 -20
- package/src/tracing.ts +213 -24
- package/src/client.ts +0 -1048
- 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
|
-
## [
|
|
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.
|
|
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.
|
|
34
|
+
"@zerotal/core": "1.7.0"
|
|
34
35
|
},
|
|
35
36
|
"devDependencies": {
|
|
36
37
|
"typescript": "^5.8.0",
|
|
37
|
-
"@zerotal/orm": "1.
|
|
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
|
|
153
|
-
//
|
|
154
|
-
//
|
|
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
|
);
|