@zerotal/devtools 1.7.2 → 1.7.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/devtools",
3
- "version": "1.7.2",
3
+ "version": "1.7.4",
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.2"
34
+ "@zerotal/core": "1.7.4"
35
35
  },
36
36
  "devDependencies": {
37
37
  "typescript": "^5.8.0",
38
- "@zerotal/orm": "1.7.2"
38
+ "@zerotal/orm": "1.7.4"
39
39
  },
40
40
  "description": "In-browser developer tools for Zerotal — request traces, an inspector panel, and an extensible tab registry.",
41
41
  "keywords": [
@@ -78,28 +78,74 @@ 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
+ });
92
+
93
+ mountShell({
94
+ base,
95
+ standalone,
96
+ mount: opts.mount ?? document.body,
97
+ store,
98
+ transport,
99
+ tabs: BUILT_IN,
100
+ });
101
+ };
91
102
 
92
- mountShell({
93
- base,
94
- standalone,
95
- mount: opts.mount ?? document.body,
96
- store,
97
- transport,
98
- tabs: BUILT_IN,
103
+ // Mount nothing until the server half answers.
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.
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 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();
99
119
  });
100
120
  },
101
121
  };
102
122
 
123
+ /**
124
+ * Whether the devtools routes exist on this origin.
125
+ *
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.
130
+ *
131
+ * Any failure — offline, blocked by CSP, a proxy returning HTML — is treated as
132
+ * absent. The panel is a development convenience, and the cost of guessing wrong
133
+ * is that a developer presses Alt+D twice; the cost of guessing wrong the other
134
+ * way is a debug surface on a production page.
135
+ */
136
+ async function serverPresent(base: string): Promise<boolean> {
137
+ try {
138
+ const res = await fetch(`${base}/api/channels`, {
139
+ method: "GET",
140
+ cache: "no-store",
141
+ headers: { accept: "application/json" },
142
+ });
143
+ return res.ok;
144
+ } catch {
145
+ return false;
146
+ }
147
+ }
148
+
103
149
  // ── Public surface ────────────────────────────────────────────────────────────
104
150
  //
105
151
  // The panel is markup, and markup is awkward to assert on. What is exported here