@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 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",
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.3"
34
+ "@zerotal/core": "1.7.5"
35
35
  },
36
36
  "devDependencies": {
37
37
  "typescript": "^5.8.0",
38
- "@zerotal/orm": "1.7.3"
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
- return next();
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
+ }
@@ -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 store = new Store(standalone, base);
82
- const transport = connect(base, store);
81
+ const mount = (): void => {
82
+ const store = new Store(standalone, base);
83
+ const transport = connect(base, store);
83
84
 
84
- // What the browser measured for this page load, read once after it settles.
85
- // The panel reports server duration as though it were the user's experience;
86
- // it is not, and this is the only place that knows the difference.
87
- onceLoaded(() => {
88
- store.clientMetrics = collectClientMetrics();
89
- store.changed();
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
- mountShell({
93
- base,
94
- standalone,
95
- mount: opts.mount ?? document.body,
96
- store,
97
- transport,
98
- tabs: BUILT_IN,
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