@valentinkolb/ssr 0.11.0 → 0.11.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/README.md CHANGED
@@ -21,8 +21,8 @@ Current source size in this repo (`packages/ssr-core/src`):
21
21
  | Component | Lines | Raw | Gzipped |
22
22
  | --- | ---: | ---: | ---: |
23
23
  | Core (`index`, `transform`, `build`, island ID + resolver) | ~689 | 23.8 KB | 7.2 KB |
24
- | Dev client (overlay + reload, dev only) | ~211 | 6.1 KB | 2.0 KB |
25
- | Adapters (`bun`, `hono`, `elysia`, shared utils) | ~355 | 10.4 KB | 3.3 KB |
24
+ | Dev client (overlay + reload, dev only) | ~421 | 11.9 KB | 3.5 KB |
25
+ | Adapters (`bun`, `hono`, `elysia`, shared utils) | ~465 | 14.4 KB | 4.8 KB |
26
26
 
27
27
  Important: these sizes describe framework source code that runs at build time and on the server.
28
28
  The browser receives only:
@@ -358,6 +358,12 @@ It can:
358
358
  - highlight island/client boundaries
359
359
  - show source filenames for wrapped components
360
360
 
361
+ In browsers with Web Locks support, auto-reload elects one visible tab per
362
+ origin and SSR path to hold the SSE connection. Hidden tabs suspend reload work,
363
+ leadership transfers automatically, and cached pages resume safely after a
364
+ back-forward cache restore. Browsers without Web Locks retain visibility-scoped
365
+ per-tab connections as a compatibility fallback.
366
+
361
367
  ## Limitations
362
368
 
363
369
  - islands must use default export
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@valentinkolb/ssr",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
4
4
  "description": "Minimal SSR framework for SolidJS and Bun",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -5,6 +5,7 @@
5
5
  import type { SsrConfig } from "../index";
6
6
  import {
7
7
  createAssetResponse,
8
+ createPingResponse,
8
9
  getSsrDir,
9
10
  createReloadResponse,
10
11
  } from "./utils";
@@ -32,8 +33,8 @@ export const routes = (config: SsrConfig): Routes => {
32
33
 
33
34
  const devRoutes: Routes = dev
34
35
  ? {
35
- [`${ssrPath}/_reload`]: () => createReloadResponse(),
36
- [`${ssrPath}/_ping`]: () => new Response("ok"),
36
+ [`${ssrPath}/_reload`]: (req) => createReloadResponse(req.signal),
37
+ [`${ssrPath}/_ping`]: () => createPingResponse(),
37
38
  }
38
39
  : {};
39
40
 
@@ -14,11 +14,31 @@ if (!window.__ssr_reload) {
14
14
  position: "bl",
15
15
  };
16
16
 
17
- const load = () => ({
18
- ...defaults,
19
- ...JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}"),
20
- });
21
- const save = (s) => localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
17
+ const readStorage = (key) => {
18
+ try {
19
+ return localStorage.getItem(key);
20
+ } catch {
21
+ return null;
22
+ }
23
+ };
24
+ const writeStorage = (key, value) => {
25
+ try {
26
+ localStorage.setItem(key, value);
27
+ } catch {
28
+ // Dev tools still work when storage is unavailable.
29
+ }
30
+ };
31
+ const load = () => {
32
+ try {
33
+ return {
34
+ ...defaults,
35
+ ...JSON.parse(readStorage(STORAGE_KEY) || "{}"),
36
+ };
37
+ } catch {
38
+ return { ...defaults };
39
+ }
40
+ };
41
+ const save = (s) => writeStorage(STORAGE_KEY, JSON.stringify(s));
22
42
  let settings = load();
23
43
 
24
44
  // Highlight Styles
@@ -152,10 +172,23 @@ if (!window.__ssr_reload) {
152
172
  bind("_ssr_pos", "position", applyPosition);
153
173
 
154
174
  // Live Reload (SSE)
155
- let es, reconnectInterval, animationInterval;
175
+ const reloadId = globalThis.__SSR_CONFIG?.reloadId;
176
+ const reloadIdKey = `${STORAGE_KEY}:reload-id`;
177
+ const reloadLockName = `${STORAGE_KEY}:reload-lock`;
178
+ let es, retryTimer, retryController, animationInterval;
179
+ let lockController, releaseLock;
180
+ let retryAttempt = 0;
181
+ let retryGeneration = 0;
182
+ let ownsLock = false;
183
+ let pageActive = true;
184
+ let reloadRequested = false;
156
185
  const spinFrames = ["[ / ]", "[ – ]", "[ \\ ]", "[ | ]"];
157
186
  let spinIndex = 0;
158
187
 
188
+ // A fresh document already represents this server generation. Publishing
189
+ // it first wakes stale tabs without making the fresh document reload.
190
+ if (reloadId) writeStorage(reloadIdKey, reloadId);
191
+
159
192
  const stopAnimation = () => {
160
193
  clearInterval(animationInterval);
161
194
  animationInterval = null;
@@ -169,45 +202,220 @@ if (!window.__ssr_reload) {
169
202
  }, 150);
170
203
  };
171
204
 
172
- const stop = () => {
173
- es?.close();
205
+ const shouldParticipate = () =>
206
+ pageActive &&
207
+ !reloadRequested &&
208
+ settings.autoReload &&
209
+ document.visibilityState === "visible";
210
+ const canConnect = () => shouldParticipate() && ownsLock;
211
+
212
+ const closeSource = () => {
213
+ const source = es;
174
214
  es = null;
175
- clearInterval(reconnectInterval);
176
- reconnectInterval = null;
215
+ source?.close();
216
+ };
217
+
218
+ const cancelRetry = () => {
219
+ retryGeneration += 1;
220
+ clearTimeout(retryTimer);
221
+ retryTimer = null;
222
+ const controller = retryController;
223
+ retryController = null;
224
+ controller?.abort();
225
+ };
226
+
227
+ const stopConnection = () => {
228
+ closeSource();
229
+ cancelRetry();
230
+ retryAttempt = 0;
177
231
  stopAnimation();
232
+ badge.innerText = "[ssr]";
233
+ };
234
+
235
+ const releaseLeadership = () => {
236
+ const pendingLock = lockController;
237
+ lockController = null;
238
+ pendingLock?.abort();
239
+
240
+ const release = releaseLock;
241
+ releaseLock = null;
242
+ ownsLock = false;
243
+ release?.();
244
+ stopConnection();
245
+ };
246
+
247
+ const requestReload = () => {
248
+ if (reloadRequested) return;
249
+ reloadRequested = true;
250
+ releaseLeadership();
251
+ location.reload();
252
+ };
253
+
254
+ const acceptReloadId = (nextReloadId) => {
255
+ if (!nextReloadId) return;
256
+ writeStorage(reloadIdKey, nextReloadId);
257
+ if (reloadId && nextReloadId !== reloadId) requestReload();
258
+ };
259
+
260
+ const retryDelay = () =>
261
+ Math.min(2_000, 300 * 2 ** Math.min(retryAttempt, 3));
262
+
263
+ const scheduleRetry = () => {
264
+ if (!canConnect() || retryTimer || retryController) return;
265
+
266
+ const generation = retryGeneration;
267
+ retryTimer = setTimeout(async () => {
268
+ retryTimer = null;
269
+ if (generation !== retryGeneration || !canConnect()) return;
270
+
271
+ const controller = new AbortController();
272
+ retryController = controller;
273
+ try {
274
+ const response = await fetch(`${ssrPath}/_ping`, {
275
+ cache: "no-store",
276
+ signal: controller.signal,
277
+ });
278
+ if (response.ok && canConnect()) {
279
+ acceptReloadId(response.headers.get("X-SSR-Reload-ID"));
280
+ if (!reloadRequested) requestReload();
281
+ return;
282
+ }
283
+ } catch {
284
+ // A stopped or unavailable dev server is expected during reload.
285
+ } finally {
286
+ if (retryController === controller) retryController = null;
287
+ }
288
+
289
+ if (generation !== retryGeneration || !canConnect()) return;
290
+ retryAttempt += 1;
291
+ scheduleRetry();
292
+ }, retryDelay());
293
+ };
294
+
295
+ const startRetry = () => {
296
+ if (!canConnect()) return;
297
+ startAnimation();
298
+ scheduleRetry();
178
299
  };
179
300
 
180
301
  const start = () => {
181
- if (es) return;
302
+ if (!canConnect() || es || retryTimer || retryController) return;
303
+
304
+ let source;
182
305
  try {
183
- es = new EventSource(`${ssrPath}/_reload`);
306
+ source = new EventSource(`${ssrPath}/_reload`);
307
+ es = source;
184
308
  stopAnimation();
185
309
  badge.innerText = "[ssr]";
186
310
  } catch {
311
+ startRetry();
187
312
  return;
188
313
  }
189
314
 
190
- es.onerror = (e) => {
191
- e.preventDefault();
192
- stop();
193
- startAnimation();
194
- if (!settings.autoReload) return;
195
- reconnectInterval = setInterval(() => {
196
- fetch(`${ssrPath}/_ping`)
197
- .then((r) => r.ok && location.reload())
198
- .catch(() => {});
199
- }, 300);
315
+ source.onopen = () => {
316
+ if (es !== source) return;
317
+ retryAttempt = 0;
318
+ stopAnimation();
319
+ badge.innerText = "[ssr]";
200
320
  };
321
+
322
+ source.onmessage = (event) => {
323
+ if (es !== source) return;
324
+ acceptReloadId(event.data);
325
+ };
326
+
327
+ source.onerror = (event) => {
328
+ if (es !== source) return;
329
+ event.preventDefault();
330
+ closeSource();
331
+ retryAttempt = 0;
332
+ startRetry();
333
+ };
334
+ };
335
+
336
+ const requestLeadership = () => {
337
+ if (!shouldParticipate()) return;
338
+ if (ownsLock) {
339
+ start();
340
+ return;
341
+ }
342
+ if (lockController) return;
343
+
344
+ if (!navigator.locks?.request) {
345
+ ownsLock = true;
346
+ start();
347
+ return;
348
+ }
349
+
350
+ const controller = new AbortController();
351
+ lockController = controller;
352
+ void navigator.locks
353
+ .request(
354
+ reloadLockName,
355
+ { mode: "exclusive", signal: controller.signal },
356
+ async () => {
357
+ if (lockController === controller) lockController = null;
358
+ if (controller.signal.aborted || !shouldParticipate()) return;
359
+
360
+ ownsLock = true;
361
+ start();
362
+ await new Promise((resolve) => {
363
+ releaseLock = resolve;
364
+ });
365
+ releaseLock = null;
366
+ ownsLock = false;
367
+ stopConnection();
368
+ },
369
+ )
370
+ .catch((error) => {
371
+ if (lockController === controller) lockController = null;
372
+ if (error?.name === "AbortError" || !shouldParticipate()) return;
373
+
374
+ // If Web Locks is unavailable at runtime, retain per-tab behavior.
375
+ ownsLock = true;
376
+ start();
377
+ });
201
378
  };
202
379
 
203
- if (settings.autoReload) start();
380
+ const syncConnection = () => {
381
+ const knownReloadId = readStorage(reloadIdKey);
382
+ if (
383
+ shouldParticipate() &&
384
+ reloadId &&
385
+ knownReloadId &&
386
+ knownReloadId !== reloadId
387
+ ) {
388
+ requestReload();
389
+ return;
390
+ }
391
+
392
+ if (shouldParticipate()) requestLeadership();
393
+ else releaseLeadership();
394
+ };
395
+
396
+ syncConnection();
204
397
 
205
398
  panel.querySelector("#_ssr_reload").onchange = (e) => {
206
399
  settings.autoReload = e.target.checked;
207
400
  save(settings);
208
- settings.autoReload ? start() : stop();
401
+ syncConnection();
209
402
  };
210
403
 
211
- window.addEventListener("pagehide", stop);
404
+ document.addEventListener("visibilitychange", syncConnection);
405
+ window.addEventListener("storage", (event) => {
406
+ if (event.key !== reloadIdKey || !event.newValue) return;
407
+ if (reloadId && event.newValue !== reloadId && shouldParticipate()) {
408
+ requestReload();
409
+ }
410
+ });
411
+ window.addEventListener("pagehide", () => {
412
+ pageActive = false;
413
+ releaseLeadership();
414
+ });
415
+ window.addEventListener("pageshow", () => {
416
+ pageActive = true;
417
+ reloadRequested = false;
418
+ syncConnection();
419
+ });
212
420
  })();
213
421
  }
@@ -6,6 +6,7 @@ import { Elysia } from "elysia";
6
6
  import type { SsrConfig } from "../index";
7
7
  import {
8
8
  createAssetResponse,
9
+ createPingResponse,
9
10
  getSsrDir,
10
11
  createReloadResponse,
11
12
  notFound,
@@ -28,8 +29,10 @@ export const routes = (config: SsrConfig) => {
28
29
  const ssrDir = getSsrDir(config);
29
30
 
30
31
  return new Elysia({ name: "ssr" })
31
- .get(`${ssrPath}/_reload`, () => (dev ? createReloadResponse() : notFound()))
32
- .get(`${ssrPath}/_ping`, () => (dev ? new Response("ok") : notFound()))
32
+ .get(`${ssrPath}/_reload`, ({ request }) =>
33
+ dev ? createReloadResponse(request.signal) : notFound(),
34
+ )
35
+ .get(`${ssrPath}/_ping`, () => (dev ? createPingResponse() : notFound()))
33
36
  .get(`${ssrPath}/*`, ({ request, params }) =>
34
37
  createAssetResponse(request, ssrDir, params["*"], dev),
35
38
  );
@@ -6,7 +6,7 @@ import { Hono } from "hono";
6
6
  import { createFactory } from "hono/factory";
7
7
  import type { Context, Env, Handler, MiddlewareHandler, TypedResponse } from "hono";
8
8
  import type { SsrConfig, HtmlFn, RenderFn } from "../index";
9
- import { createAssetResponse, getSsrDir, createReloadResponse } from "./utils";
9
+ import { createAssetResponse, createPingResponse, getSsrDir, createReloadResponse } from "./utils";
10
10
 
11
11
  // ============================================================================
12
12
  // Types
@@ -172,8 +172,8 @@ export const routes = (config: SsrConfig) => {
172
172
 
173
173
  // Dev mode endpoints
174
174
  if (dev) {
175
- app.get("/_reload", () => createReloadResponse());
176
- app.get("/_ping", (c) => c.text("ok"));
175
+ app.get("/_reload", (c) => createReloadResponse(c.req.raw.signal));
176
+ app.get("/_ping", () => createPingResponse());
177
177
  }
178
178
 
179
179
  const serveAsset = (c: Context) => createAssetResponse(c.req.raw, ssrDir, c.req.param("filename"), dev);
@@ -116,28 +116,63 @@ export const SSE_HEADERS = {
116
116
  Connection: "keep-alive",
117
117
  } as const;
118
118
 
119
+ const reloadId = crypto.randomUUID();
120
+ const reloadConnected = new TextEncoder().encode(`: connected\ndata: ${reloadId}\n\n`);
121
+ const reloadPing = new TextEncoder().encode(": ping\n\n");
122
+
123
+ export const getReloadId = (): string => reloadId;
124
+
125
+ export const createPingResponse = (): Response =>
126
+ new Response("ok", { headers: { "X-SSR-Reload-ID": reloadId } });
127
+
119
128
  /**
120
129
  * Creates a Server-Sent Events stream for live reload
121
130
  */
122
- export const createReloadStream = (): ReadableStream =>
123
- new ReadableStream({
131
+ export const createReloadStream = (signal?: AbortSignal): ReadableStream<Uint8Array> => {
132
+ let interval: ReturnType<typeof setInterval> | undefined;
133
+ let streamController: ReadableStreamDefaultController<Uint8Array> | undefined;
134
+ const handleAbort = () => {
135
+ stopHeartbeat();
136
+ try {
137
+ streamController?.close();
138
+ } catch {
139
+ // The consumer may already have canceled the stream.
140
+ }
141
+ };
142
+ const stopHeartbeat = () => {
143
+ if (interval === undefined) return;
144
+ clearInterval(interval);
145
+ interval = undefined;
146
+ signal?.removeEventListener("abort", handleAbort);
147
+ };
148
+
149
+ return new ReadableStream<Uint8Array>({
124
150
  start(controller) {
125
- controller.enqueue(new TextEncoder().encode(": connected\n\n"));
126
- const interval = setInterval(() => {
151
+ streamController = controller;
152
+ if (signal?.aborted) {
153
+ controller.close();
154
+ return;
155
+ }
156
+
157
+ signal?.addEventListener("abort", handleAbort, { once: true });
158
+ controller.enqueue(reloadConnected);
159
+ interval = setInterval(() => {
127
160
  try {
128
- controller.enqueue(new TextEncoder().encode(": ping\n\n"));
161
+ controller.enqueue(reloadPing);
129
162
  } catch {
130
- clearInterval(interval);
163
+ stopHeartbeat();
131
164
  }
132
165
  }, 5000);
133
166
  },
167
+ cancel: stopHeartbeat,
134
168
  });
169
+ };
135
170
 
136
171
  /**
137
172
  * Creates a Response for the SSE reload endpoint
138
173
  */
139
- export const createReloadResponse = (): Response =>
140
- new Response(createReloadStream(), { headers: SSE_HEADERS });
174
+ export const createReloadResponse = (signal?: AbortSignal): Response =>
175
+ new Response(createReloadStream(signal), { headers: SSE_HEADERS });
141
176
 
142
177
  /**
143
178
  * 404 Response
package/src/index.ts CHANGED
@@ -12,7 +12,7 @@ import { transform } from "./transform";
12
12
  import { buildIslands, type DevSourcemap } from "./build";
13
13
  import { join, dirname, resolve } from "path";
14
14
  import { resolveIslandImport } from "./island-resolve";
15
- import { normalizeBasePath, toSsrPath } from "./adapter/utils";
15
+ import { getReloadId, normalizeBasePath, toSsrPath } from "./adapter/utils";
16
16
  // @ts-ignore - Bun text import
17
17
  import devClientCode from "./adapter/client.js" with { type: "text" };
18
18
 
@@ -160,7 +160,7 @@ export const createConfig = <T extends object = object>(options: SsrOptions<T> =
160
160
 
161
161
  // Hydration script - dynamically loads island/client bundles based on DOM
162
162
  const hydrationScript = `<script type="module">const p=${JSON.stringify(ssrPath)};const v=${JSON.stringify(buildVersion)};document.querySelectorAll('solid-island,solid-client').forEach(e=>import(p+'/'+e.dataset.id+'.js'+(v?'?v='+v:'')));</script>`;
163
- const devConfigScript = `<script>globalThis.__SSR_CONFIG=${JSON.stringify({ ssrPath })}</script>`;
163
+ const devConfigScript = `<script>globalThis.__SSR_CONFIG=${JSON.stringify({ ssrPath, reloadId: getReloadId() })}</script>`;
164
164
 
165
165
  // HTML renderer
166
166
  const html: HtmlFn<T> = async (render, opts = {} as T) => {