@zerotal/devtools 1.7.4 → 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/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.4",
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.4"
34
+ "@zerotal/core": "1.7.5"
35
35
  },
36
36
  "devDependencies": {
37
37
  "typescript": "^5.8.0",
38
- "@zerotal/orm": "1.7.4"
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
+ }
@@ -100,33 +100,55 @@ export const DevTools = {
100
100
  });
101
101
  };
102
102
 
103
- // Mount nothing until the server half answers.
103
+ // Mount nothing unless there is a server half to mount against.
104
104
  //
105
- // The provider is gated on the environment, so in production the endpoints are
106
- // absent — and the client took that to mean it could start anyway and simply
107
- // connect to nothing. It could not: `mountShell` pinned the panel to the page
108
- // regardless, so zerotal.dev served a floating DevTools bar to every visitor,
109
- // opening onto tabs reading `Could not read the map — HTTP 404` because the
110
- // routes behind them do not exist in production.
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
111
  //
112
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 did, with a
114
- // comment explaining why that was safe — is covered without knowing to be.
115
- void serverPresent(base).then((present) => {
116
- if (!present) return;
117
- if (document.getElementById("__zerotal_dt__")) return;
118
- mount();
119
- });
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
+ });
120
128
  },
121
129
  };
122
130
 
123
131
  /**
124
132
  * Whether the devtools routes exist on this origin.
125
133
  *
126
- * `api/channels` rather than `sse`: it answers and closes, where the stream stays
127
- * open and would leave a connection hanging on every page load just to discover
128
- * the panel should not be there. `no-store` so a 404 is not cached into a session
129
- * that later starts a dev server on the same origin.
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.
130
152
  *
131
153
  * Any failure — offline, blocked by CSP, a proxy returning HTML — is treated as
132
154
  * absent. The panel is a development convenience, and the cost of guessing wrong
@@ -134,6 +156,9 @@ export const DevTools = {
134
156
  * way is a debug surface on a production page.
135
157
  */
136
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
+
137
162
  try {
138
163
  const res = await fetch(`${base}/api/channels`, {
139
164
  method: "GET",
@@ -146,6 +171,20 @@ async function serverPresent(base: string): Promise<boolean> {
146
171
  }
147
172
  }
148
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
+
149
188
  // ── Public surface ────────────────────────────────────────────────────────────
150
189
  //
151
190
  // The panel is markup, and markup is awkward to assert on. What is exported here