@jxsuite/server 2.0.0 → 2.2.1

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/src/watch.ts CHANGED
@@ -4,6 +4,7 @@ import { watch as chokidarWatch } from "chokidar";
4
4
  import { relative } from "node:path";
5
5
  import { rebuild } from "./build.ts";
6
6
  import { coalesceFsEvents, toFsEvent } from "./refactor/fs-events.ts";
7
+ import { invalidateReferenceCache } from "./refactor/find-refs.ts";
7
8
  import type { BuildEntry } from "./types.ts";
8
9
  import type { FsEventPayload } from "./refactor/fs-events.ts";
9
10
 
@@ -49,6 +50,15 @@ function shouldIgnore(pathname: string, ignore: string[]) {
49
50
 
50
51
  export { shouldIgnore };
51
52
 
53
+ /**
54
+ * Reconnection time advertised on the stream, in milliseconds (HTML Standard, `retry:`).
55
+ *
56
+ * The default is user-agent defined and measured in seconds, which on a dev server restart is long
57
+ * enough for a save to look like it did nothing. Both endpoints are on loopback, so a reconnect
58
+ * costs nothing and half a second lands inside a typical restart.
59
+ */
60
+ export const RECONNECT_MS = 500;
61
+
52
62
  export const SSE_SCRIPT = `\n<script>new EventSource('/__reload').onmessage=()=>location.reload()</script>`;
53
63
 
54
64
  /** @param {string} html */
@@ -71,7 +81,7 @@ export function injectSSE(html: string) {
71
81
  * }} [opts]
72
82
  * @returns {{
73
83
  * broadcast: () => void;
74
- * handleSSE: () => Response;
84
+ * handleSSE: (request?: Request) => Response;
75
85
  * watcher: import("chokidar").FSWatcher;
76
86
  * }}
77
87
  */
@@ -94,9 +104,23 @@ export function createWatcher(
94
104
  const clients = new Set<(msg: string) => void>();
95
105
  const encoder = new TextEncoder();
96
106
 
107
+ /*
108
+ * Monotonic reload counter, sent as the SSE `id:` of every reload frame.
109
+ *
110
+ * Its only job is to arm the browser: a stream that has never sent an `id:` makes the client omit
111
+ * `Last-Event-ID` on reconnect, and then the server cannot tell a first connection from a
112
+ * reconnection. **Nothing is buffered against it and nothing is replayed.** A reconnecting client
113
+ * gets exactly one reload, because the page it is holding was built before the disconnect and a
114
+ * full reload subsumes every event it missed — one is as correct as a hundred and finishes
115
+ * sooner. Do not "complete" this into a replay buffer; there is no per-event state to replay.
116
+ */
117
+ let lastEventId = 0;
118
+
97
119
  function broadcast() {
120
+ lastEventId += 1;
121
+ const frame = `id: ${lastEventId}\ndata: reload\n\n`;
98
122
  for (const send of clients) {
99
- send("data: reload\n\n");
123
+ send(frame);
100
124
  }
101
125
  }
102
126
 
@@ -111,7 +135,24 @@ export function createWatcher(
111
135
  }
112
136
  }
113
137
 
114
- function handleSSE() {
138
+ /**
139
+ * Open the live-reload stream.
140
+ *
141
+ * Two halves of the HTML Standard's EventSource contract that were missing, and are the whole of
142
+ * `gap:sse-reconnect`:
143
+ *
144
+ * - **`retry:`** sets the reconnection time. The default is user-agent defined and is measured in
145
+ * seconds; during a dev-server restart that is long enough to watch a save do nothing. 500ms is
146
+ * loopback, so the reconnect costs nothing and lands inside the restart.
147
+ * - **`Last-Event-ID`** is how the browser says "I was here before". It gets **one** reload and no
148
+ * replay — see the note on `lastEventId`. A dev server has no event log and needs none: the
149
+ * reload is idempotent and total.
150
+ *
151
+ * @param {Request} [request] - The stream request; read only for its `Last-Event-ID`
152
+ * @returns {Response}
153
+ */
154
+ function handleSSE(request?: Request) {
155
+ const resuming = (request?.headers.get("Last-Event-ID") ?? "") !== "";
115
156
  /** @type {((msg: string) => void) | undefined} */
116
157
  let send: ((msg: string) => void) | undefined;
117
158
  const stream = new ReadableStream({
@@ -127,6 +168,12 @@ export function createWatcher(
127
168
  } catch {}
128
169
  };
129
170
  clients.add(send);
171
+ // The retry interval is a stream-level field, so it is set once, before any event.
172
+ send(`retry: ${RECONNECT_MS}\n\n`);
173
+ if (resuming) {
174
+ lastEventId += 1;
175
+ send(`id: ${lastEventId}\ndata: reload\n\n`);
176
+ }
130
177
  const hb = setInterval(() => {
131
178
  try {
132
179
  c.enqueue(encoder.encode(": heartbeat\n\n"));
@@ -165,6 +212,10 @@ export function createWatcher(
165
212
  return;
166
213
  }
167
214
 
215
+ // The usage query's cache is invalidated by the filesystem, never by a timer — a delete
216
+ // Confirmation that says "used on 7 pages" must be answering about the tree as it is now.
217
+ invalidateReferenceCache(root);
218
+
168
219
  // Structured FS events for the studio sidebar (coalesced + batched), emitted as a named "fs"
169
220
  // SSE event so the preview iframe ignores them while the studio shell subscribes to them.
170
221
  const fsEvent = toFsEvent(eventType, root, changedPath);