@zerotal/devtools 1.6.1 → 1.6.3

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,28 @@ follows the Zerotal monorepo's unified versioning.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [1.6.2] — 2026-08-15
12
+
13
+ ### Fixed
14
+
15
+ - **The panel's event stream was abandoned rather than closed on shutdown**, so the browser
16
+ was left holding a truncated chunked response and logged
17
+ `GET /__zerotal/devtools/sse net::ERR_INCOMPLETE_CHUNKED_ENCODING` — under `serve --dev`,
18
+ on every save. Nothing was broken by it (`EventSource` reconnects on its own), but a
19
+ devtools panel that fills the console with network errors is working against its own
20
+ purpose. Shutdown now closes each open stream, which writes the terminating chunk and
21
+ ends the request the way a reader expects.
22
+
23
+ On Windows the fix needed [`@zerotal/core`](../core/CHANGELOG.md)'s side too — a worker
24
+ that is terminated outright never reaches this code.
25
+
26
+ ### Added
27
+
28
+ - **A heartbeat on the event stream.** A comment frame every 25 seconds, which readers
29
+ ignore. Without one an idle stream can be dropped by an intermediary with nothing written
30
+ for either end to notice it by, and the panel goes on reporting a connection it no longer
31
+ has. The timer is unref'd, so it never holds a process open.
32
+
11
33
  ## [1.5.1] — 2026-08-15
12
34
 
13
35
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/devtools",
3
- "version": "1.6.1",
3
+ "version": "1.6.3",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -30,11 +30,11 @@
30
30
  "typecheck": "tsc --noEmit"
31
31
  },
32
32
  "dependencies": {
33
- "@zerotal/core": "1.6.1"
33
+ "@zerotal/core": "1.6.3"
34
34
  },
35
35
  "devDependencies": {
36
36
  "typescript": "^5.8.0",
37
- "@zerotal/orm": "1.6.1"
37
+ "@zerotal/orm": "1.6.3"
38
38
  },
39
39
  "description": "In-browser developer tools for Zerotal — request traces, an inspector panel, and an extensible tab registry.",
40
40
  "keywords": [
@@ -49,9 +49,9 @@ export interface DevtoolsInjectionOptions {
49
49
  const _sseClients = new Set<ReadableStreamDefaultController<Uint8Array>>();
50
50
  const _enc = new TextEncoder();
51
51
 
52
- function _ssePublish(data: unknown): void {
52
+ function _ssePublishRaw(frame: string): void {
53
53
  if (_sseClients.size === 0) return;
54
- const chunk = _enc.encode(`data: ${JSON.stringify(data)}\n\n`);
54
+ const chunk = _enc.encode(frame);
55
55
  for (const ctrl of _sseClients) {
56
56
  try {
57
57
  ctrl.enqueue(chunk);
@@ -61,22 +61,61 @@ function _ssePublish(data: unknown): void {
61
61
  }
62
62
  }
63
63
 
64
+ function _ssePublish(data: unknown): void {
65
+ _ssePublishRaw(`data: ${JSON.stringify(data)}\n\n`);
66
+ }
67
+
68
+ /**
69
+ * End every open stream, in an order the browser recognises as an ending.
70
+ *
71
+ * Dropping the set is not enough: the response body is a chunked stream, so a
72
+ * process that exits with one half-written leaves the browser holding a
73
+ * truncated body and logging `ERR_INCOMPLETE_CHUNKED_ENCODING`. Under
74
+ * `serve --dev` that is every save. Closing the controller writes the
75
+ * terminating chunk, and `EventSource` then reconnects on its own timer without
76
+ * an error in the console.
77
+ */
78
+ function _sseCloseAll(): void {
79
+ for (const ctrl of _sseClients) {
80
+ try {
81
+ ctrl.close();
82
+ } catch {
83
+ // Already closed or errored — the stream is over either way.
84
+ }
85
+ }
86
+ _sseClients.clear();
87
+ }
88
+
89
+ /** How often to write a comment frame keeping an idle stream alive. */
90
+ const SSE_HEARTBEAT_MS = 25_000;
91
+
64
92
  /**
65
93
  * Bridge the trace store to connected panels. Called by {@link DevtoolsProvider}
66
94
  * on boot rather than at module scope — subscribing on import would tie the
67
95
  * stream to whichever store existed at import time, before the provider has
68
96
  * built the one the app's config asks for.
69
97
  *
70
- * @returns A disposer that unsubscribes and drops connected clients.
98
+ * @returns A disposer that unsubscribes and closes connected clients.
71
99
  */
72
100
  export function startDevtoolsStream(): () => void {
73
101
  const unsubscribe = traceStore().subscribe((trace) => {
74
102
  if (trace === null) _ssePublish({ type: "clear" });
75
103
  else _ssePublish({ type: "trace", data: trace });
76
104
  });
105
+
106
+ // A comment frame, which SSE readers ignore. Without it a stream that goes
107
+ // quiet can be dropped by an intermediary with nothing written to notice it
108
+ // by, and the panel keeps reporting a connection it no longer has. Unref'd so
109
+ // it never holds the process open.
110
+ const heartbeat = setInterval(() => {
111
+ _ssePublishRaw(":\n\n");
112
+ }, SSE_HEARTBEAT_MS);
113
+ heartbeat.unref?.();
114
+
77
115
  return () => {
78
116
  unsubscribe();
79
- _sseClients.clear();
117
+ clearInterval(heartbeat);
118
+ _sseCloseAll();
80
119
  };
81
120
  }
82
121