@zerotal/devtools 1.7.3 → 1.7.5
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 +16 -0
- package/api-surface.md +0 -2
- package/package.json +3 -3
- package/src/DevtoolsInjectionMiddleware.ts +39 -1
- package/src/client/index.ts +102 -17
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,22 @@ follows the Zerotal monorepo's unified versioning.
|
|
|
8
8
|
|
|
9
9
|
## [Unreleased]
|
|
10
10
|
|
|
11
|
+
## [1.7.4] — 2026-08-21
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- **The panel no longer mounts when there is no server half to talk to.** The provider is
|
|
16
|
+
gated on the environment, so in production the devtools routes are absent — and the client
|
|
17
|
+
read that as permission to start anyway and "connect to nothing". It did not connect to
|
|
18
|
+
nothing: `DevTools.start()` mounted the panel first and discovered the absence afterwards,
|
|
19
|
+
so an app calling it unconditionally served a floating DevTools bar to every visitor, its
|
|
20
|
+
tabs reading `Could not read the map — HTTP 404`. zerotal.dev did exactly this.
|
|
21
|
+
|
|
22
|
+
`start()` now probes `api/channels` and mounts only if it answers. Nothing is constructed
|
|
23
|
+
before that resolves — no shell, no shadow root, no `EventSource`, no listeners. Any
|
|
24
|
+
failure (404, offline, CSP, a proxy answering HTML) is read as absent: a missed panel costs
|
|
25
|
+
a developer one keystroke, and a stray one is a debug surface on a production page.
|
|
26
|
+
|
|
11
27
|
## [1.7.1] — 2026-08-16
|
|
12
28
|
|
|
13
29
|
### Changed
|
package/api-surface.md
CHANGED
|
@@ -47,8 +47,6 @@ class TraceStore = {
|
|
|
47
47
|
|
|
48
48
|
const traceSink = TraceSink
|
|
49
49
|
|
|
50
|
-
function _setTraceStore = (store: TraceStore | null) => void
|
|
51
|
-
|
|
52
50
|
function attributeBindings = (sql: string, count: number) => Array<string | undefined>
|
|
53
51
|
|
|
54
52
|
function DevtoolsConfig = (options?: Partial<DevtoolsConfigShape>) => DevtoolsConfigShape
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zerotal/devtools",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.5",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"maturity": "stable",
|
|
6
6
|
"private": false,
|
|
@@ -31,11 +31,11 @@
|
|
|
31
31
|
"typecheck": "tsc --noEmit"
|
|
32
32
|
},
|
|
33
33
|
"dependencies": {
|
|
34
|
-
"@zerotal/core": "1.7.
|
|
34
|
+
"@zerotal/core": "1.7.5"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
37
37
|
"typescript": "^5.8.0",
|
|
38
|
-
"@zerotal/orm": "1.7.
|
|
38
|
+
"@zerotal/orm": "1.7.5"
|
|
39
39
|
},
|
|
40
40
|
"description": "In-browser developer tools for Zerotal — request traces, an inspector panel, and an extensible tab registry.",
|
|
41
41
|
"keywords": [
|
|
@@ -238,6 +238,44 @@ export class DevtoolsInjectionMiddleware extends BaseMiddleware<DevtoolsInjectio
|
|
|
238
238
|
});
|
|
239
239
|
}
|
|
240
240
|
|
|
241
|
-
|
|
241
|
+
// Nothing else under the prefix; fall through to the app and mark the page.
|
|
242
|
+
const response = await next();
|
|
243
|
+
return response instanceof Response ? await _markPage(http, response) : response;
|
|
242
244
|
}
|
|
243
245
|
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Tell the browser half that a server half exists, by injecting one `<meta>`.
|
|
249
|
+
*
|
|
250
|
+
* The client used to find out by asking — a `GET api/channels` on every page
|
|
251
|
+
* load, treating a 404 as "not here". That failed closed, which was the point,
|
|
252
|
+
* but it meant a production page fetched a URL that was *designed* to 404 and
|
|
253
|
+
* the browser logged it. A reviewer reading that console sees development
|
|
254
|
+
* tooling running on a live site; the honest reading of a deliberate 404 and a
|
|
255
|
+
* leaked debug endpoint look identical from the outside.
|
|
256
|
+
*
|
|
257
|
+
* Inverting it removes the request rather than hiding it. The provider is gated
|
|
258
|
+
* on the environment, so in production this middleware is not in the stack at
|
|
259
|
+
* all: no marker is written, the client finds none, and it makes **zero**
|
|
260
|
+
* requests instead of one per page load. It also saves the round trip in
|
|
261
|
+
* development, where the panel now mounts on the first frame rather than after
|
|
262
|
+
* a fetch resolves.
|
|
263
|
+
*
|
|
264
|
+
* Absence stays the safe default — an app served by something that never runs
|
|
265
|
+
* this middleware gets no panel, which is the same answer the probe gave.
|
|
266
|
+
*/
|
|
267
|
+
async function _markPage(http: HttpContext, response: Response): Promise<Response> {
|
|
268
|
+
const type = response.headers.get("content-type") ?? "";
|
|
269
|
+
if (!type.includes("text/html")) return response;
|
|
270
|
+
|
|
271
|
+
// Same gate as the endpoints: a marker on a page whose viewer could not use
|
|
272
|
+
// the endpoints anyway would only advertise that they are there.
|
|
273
|
+
if (!(await devtoolsAuthorized(http.request))) return response;
|
|
274
|
+
|
|
275
|
+
const html = await response.text();
|
|
276
|
+
const head = html.indexOf("</head>");
|
|
277
|
+
if (head === -1) return new Response(html, response);
|
|
278
|
+
|
|
279
|
+
const marker = `<meta name="zerotal-devtools" content="${DEVTOOLS_PREFIX}">`;
|
|
280
|
+
return new Response(html.slice(0, head) + marker + html.slice(head), response);
|
|
281
|
+
}
|
package/src/client/index.ts
CHANGED
|
@@ -78,28 +78,113 @@ export const DevTools = {
|
|
|
78
78
|
const base = (opts.endpoint ?? "/__zerotal/devtools").replace(/\/$/, "");
|
|
79
79
|
const standalone = opts.mode === "standalone";
|
|
80
80
|
|
|
81
|
-
const
|
|
82
|
-
|
|
81
|
+
const mount = (): void => {
|
|
82
|
+
const store = new Store(standalone, base);
|
|
83
|
+
const transport = connect(base, store);
|
|
83
84
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
85
|
+
// What the browser measured for this page load, read once after it settles.
|
|
86
|
+
// The panel reports server duration as though it were the user's experience;
|
|
87
|
+
// it is not, and this is the only place that knows the difference.
|
|
88
|
+
onceLoaded(() => {
|
|
89
|
+
store.clientMetrics = collectClientMetrics();
|
|
90
|
+
store.changed();
|
|
91
|
+
});
|
|
91
92
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
93
|
+
mountShell({
|
|
94
|
+
base,
|
|
95
|
+
standalone,
|
|
96
|
+
mount: opts.mount ?? document.body,
|
|
97
|
+
store,
|
|
98
|
+
transport,
|
|
99
|
+
tabs: BUILT_IN,
|
|
100
|
+
});
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
// Mount nothing unless there is a server half to mount against.
|
|
104
|
+
//
|
|
105
|
+
// The provider is gated on the environment, so in production the endpoints
|
|
106
|
+
// are absent — and the client took that to mean it could start anyway and
|
|
107
|
+
// simply connect to nothing. It could not: `mountShell` pinned the panel to
|
|
108
|
+
// the page regardless, so zerotal.dev served a floating DevTools bar to
|
|
109
|
+
// every visitor, opening onto tabs reading `Could not read the map — HTTP
|
|
110
|
+
// 404` because the routes behind them do not exist in production.
|
|
111
|
+
//
|
|
112
|
+
// Failing closed here rather than in each app's entry file is deliberate: an
|
|
113
|
+
// app that calls `start()` unconditionally — which the docs site does, with a
|
|
114
|
+
// comment explaining why that is safe — is covered without knowing to be.
|
|
115
|
+
void serverPresent(base)
|
|
116
|
+
.then((present) => {
|
|
117
|
+
if (!present) return;
|
|
118
|
+
if (document.getElementById("__zerotal_dt__")) return;
|
|
119
|
+
mount();
|
|
120
|
+
})
|
|
121
|
+
// A panel that cannot build itself must not become an unhandled rejection
|
|
122
|
+
// in the host application's console — or, worse, in its error reporting.
|
|
123
|
+
// This is a development convenience failing; say so and leave the page it
|
|
124
|
+
// was decorating alone.
|
|
125
|
+
.catch((error: unknown) => {
|
|
126
|
+
console.warn("[Zerotal DevTools] could not start:", error);
|
|
127
|
+
});
|
|
100
128
|
},
|
|
101
129
|
};
|
|
102
130
|
|
|
131
|
+
/**
|
|
132
|
+
* Whether the devtools routes exist on this origin.
|
|
133
|
+
*
|
|
134
|
+
* Two ways to know, and the order matters.
|
|
135
|
+
*
|
|
136
|
+
* **The marker.** `DevtoolsInjectionMiddleware` writes
|
|
137
|
+
* `<meta name="zerotal-devtools">` into the HTML it passes through, and it is
|
|
138
|
+
* only in the stack when the provider is active. When it is there, presence is
|
|
139
|
+
* already in the document: no request, and the panel mounts on the first frame.
|
|
140
|
+
*
|
|
141
|
+
* **The probe, on a development host only.** `Router.raw` bypasses the global
|
|
142
|
+
* middleware pipeline by design, so an app that serves its pages that way — the
|
|
143
|
+
* documentation site does — never receives the marker however active devtools is.
|
|
144
|
+
* Asking is the only way left for those, so the fetch survives, restricted to the
|
|
145
|
+
* hostnames a production site is never served from.
|
|
146
|
+
*
|
|
147
|
+
* That restriction is the point. The first version of this probed unconditionally
|
|
148
|
+
* and was correct — a 404 meant absent, and nothing mounted — but it left a
|
|
149
|
+
* request to a devtools URL in the console of every production page, which reads
|
|
150
|
+
* as leaked tooling no matter how deliberate it is. A real domain now makes no
|
|
151
|
+
* request at all, and the apps that need asking are the ones nobody else can see.
|
|
152
|
+
*
|
|
153
|
+
* Any failure — offline, blocked by CSP, a proxy returning HTML — is treated as
|
|
154
|
+
* absent. The panel is a development convenience, and the cost of guessing wrong
|
|
155
|
+
* is that a developer presses Alt+D twice; the cost of guessing wrong the other
|
|
156
|
+
* way is a debug surface on a production page.
|
|
157
|
+
*/
|
|
158
|
+
async function serverPresent(base: string): Promise<boolean> {
|
|
159
|
+
if (document.querySelector('meta[name="zerotal-devtools"]') !== null) return true;
|
|
160
|
+
if (!isDevelopmentHost(location.hostname)) return false;
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
const res = await fetch(`${base}/api/channels`, {
|
|
164
|
+
method: "GET",
|
|
165
|
+
cache: "no-store",
|
|
166
|
+
headers: { accept: "application/json" },
|
|
167
|
+
});
|
|
168
|
+
return res.ok;
|
|
169
|
+
} catch {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Whether `hostname` is one a development server answers on.
|
|
176
|
+
*
|
|
177
|
+
* Loopback and the private ranges — the last of those so a panel still appears
|
|
178
|
+
* when a phone on the same network is pointed at a laptop's dev server, which is
|
|
179
|
+
* most of what anyone uses a second device for.
|
|
180
|
+
*/
|
|
181
|
+
function isDevelopmentHost(hostname: string): boolean {
|
|
182
|
+
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]") return true;
|
|
183
|
+
if (hostname.endsWith(".localhost") || hostname.endsWith(".test")) return true;
|
|
184
|
+
// 10.0.0.0/8, 192.168.0.0/16, and 172.16.0.0/12.
|
|
185
|
+
return /^(10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.)/.test(hostname);
|
|
186
|
+
}
|
|
187
|
+
|
|
103
188
|
// ── Public surface ────────────────────────────────────────────────────────────
|
|
104
189
|
//
|
|
105
190
|
// The panel is markup, and markup is awkward to assert on. What is exported here
|