spark-ssr 0.3.0 → 0.3.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spark-ssr",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Zero-config SSR for spark-html on Bun. The HTML template infers everything: filesystem routing, layouts, <spark-ssr> declarative data (SQL, URLs, globs, modules), auto CRUD with validation, guards, no-JS forms, schema + seeds, live updates, SEO. No build step.",
5
5
  "homepage": "https://wilkinnovo.github.io/spark-html",
6
6
  "type": "module",
package/src/hydrate.js CHANGED
@@ -155,9 +155,20 @@ export function clientScript({ analysis, plan, table, cols, key, live }) {
155
155
  // live (§9): every write the server sees on this table pings the channel;
156
156
  // every open tab refetches through its own session. Realtime as one
157
157
  // attribute — no socket code, no pub/sub setup.
158
+ // Close on pagehide so the channel's HTTP/1.1 socket frees the instant we
159
+ // navigate away — a live EventSource that outlives its page eats one of the
160
+ // browser's ~6 per-host connections, and enough of them (rapid navigation
161
+ // across live pages) starve the next page's own request until the tab hangs.
162
+ // Reopen and refetch on a back/forward-cache restore.
158
163
  if (live && listVar) {
159
- L.push(`const __live = new EventSource('/__spark/live');`);
160
- L.push(`__live.onmessage = (e) => { if (e.data === '${table}') __refresh(); };`);
164
+ L.push(`let __live;`);
165
+ L.push(`function __openLive() {`);
166
+ L.push(` __live = new EventSource('/__spark/live');`);
167
+ L.push(` __live.onmessage = (e) => { if (e.data === '${table}') __refresh(); };`);
168
+ L.push(`}`);
169
+ L.push(`__openLive();`);
170
+ L.push(`addEventListener('pagehide', () => { if (__live) __live.close(); });`);
171
+ L.push(`addEventListener('pageshow', (e) => { if (e.persisted) { __openLive(); __refresh(); } });`);
161
172
  }
162
173
  return L.join('\n') + '\n';
163
174
  }
package/src/server.js CHANGED
@@ -297,8 +297,29 @@ export async function serve(options = {}) {
297
297
  }
298
298
  // Reconnect-then-reload: after a server restart the EventSource reconnects,
299
299
  // and a fresh open following an error means "the server came back" — reload.
300
- const RELOAD_CLIENT = '<script>(()=>{const e=new EventSource("/__spark/reload");let d=0;'
301
- + 'e.onmessage=()=>location.reload();e.onerror=()=>{d=1};e.onopen=()=>{if(d)location.reload()}})()</script>';
300
+ // Close on pagehide: a live EventSource holds one of the browser's ~6
301
+ // per-host HTTP/1.1 sockets, and one that outlives its page starves the
302
+ // next navigation — rapid link-clicking (or a service worker that keeps a
303
+ // controlled client's sockets around) piles them up until the tab hangs
304
+ // loading. Freeing the socket the instant we leave keeps the pool clear;
305
+ // reopen if the page is restored from the back/forward cache.
306
+ const RELOAD_CLIENT = '<script>(()=>{let e,d=0;const open=()=>{e=new EventSource("/__spark/reload");'
307
+ + 'e.onmessage=()=>location.reload();e.onerror=()=>{d=1};e.onopen=()=>{if(d)location.reload()}};open();'
308
+ + 'addEventListener("pagehide",()=>{if(e)e.close()});'
309
+ + 'addEventListener("pageshow",v=>{if(v.persisted)open()})})()</script>';
310
+
311
+ // Heartbeats keep every SSE socket outside Bun's idleTimeout (the default
312
+ // would kill them at 10 s — and a killed reload socket reconnects, which
313
+ // the client reads as "the server came back": a spurious reload). The ping
314
+ // also flushes dead clients (enqueue throws → drop).
315
+ const heartbeat = setInterval(() => {
316
+ for (const set of [sseClients, liveClients]) {
317
+ for (const c of set) {
318
+ try { c.enqueue(sseEnc.encode(': ping\n\n')); } catch { set.delete(c); }
319
+ }
320
+ }
321
+ }, 25000);
322
+ heartbeat.unref?.();
302
323
 
303
324
  // ── live data channel (§9) — a production feature, unlike dev reload ──
304
325
  // Any write through the server pings /__spark/live with the table name;
@@ -1007,7 +1028,9 @@ export async function serve(options = {}) {
1007
1028
  }
1008
1029
 
1009
1030
  async function buildScope(pd, req) {
1010
- const scope = { ...req.query, ...req.params, session: req.session };
1031
+ // `path` is ambient like `session` the layout's nav highlights the
1032
+ // current page with it. Query/params may shadow it deliberately.
1033
+ const scope = { path: req.path, ...req.query, ...req.params, session: req.session };
1011
1034
  if (pd.code) Object.assign(scope, await runPageScript(pd.code, req));
1012
1035
  for (const p of pd.plan) {
1013
1036
  if (scope[p.var] !== undefined) continue; // the page <script> won
@@ -1216,6 +1239,9 @@ code{color:#fdba74}em{color:#a8a29e}</style></head><body>
1216
1239
  // ── the server ──
1217
1240
  const server = Bun.serve({
1218
1241
  port: options.port ?? 3000,
1242
+ // SSE channels idle between events (heartbeat every 25 s keeps them
1243
+ // alive); slow queries and big uploads get headroom too.
1244
+ idleTimeout: 60,
1219
1245
  async fetch(request, srv) {
1220
1246
  const url = new URL(request.url);
1221
1247
  let pathname;
@@ -1417,6 +1443,7 @@ code{color:#fdba74}em{color:#a8a29e}</style></head><body>
1417
1443
  db,
1418
1444
  stop(force) {
1419
1445
  if (watchTimer) clearInterval(watchTimer);
1446
+ clearInterval(heartbeat);
1420
1447
  for (const c of sseClients) { try { c.close(); } catch { /* gone */ } }
1421
1448
  sseClients.clear();
1422
1449
  for (const c of liveClients) { try { c.close(); } catch { /* gone */ } }