@statorjs/stator 1.5.1 → 1.5.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": "@statorjs/stator",
3
- "version": "1.5.1",
3
+ "version": "1.5.2",
4
4
  "description": "Server-canonical web framework: business logic in composable state machines, UI as a thin renderer binding machine outputs to DOM positions. Ships TypeScript source (Vite/tsx-native by design).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -88,7 +88,7 @@
88
88
  "pino-pretty": "^13.1.3",
89
89
  "vite": "^6.0.0",
90
90
  "vitest": "^2.1.0",
91
- "@statorjs/stator": "1.5.1"
91
+ "@statorjs/stator": "1.5.2"
92
92
  },
93
93
  "scripts": {
94
94
  "typecheck": "tsc --noEmit",
@@ -51,6 +51,7 @@ function initLiveChannel(): void {
51
51
  const sse = new EventSource(url, { withCredentials: true })
52
52
 
53
53
  let everOpened = false
54
+ let lastSeen = Date.now()
54
55
  sse.addEventListener('open', () => {
55
56
  if (everOpened) {
56
57
  // Reconnect — reload rather than risk stale state.
@@ -60,7 +61,26 @@ function initLiveChannel(): void {
60
61
  everOpened = true
61
62
  })
62
63
 
64
+ // Zombie watchdog. A half-open connection (device sleep, silent NAT drop)
65
+ // never fires `error` — EventSource just sits "open" and silent forever,
66
+ // and the page quietly stops being live. The server sends an observable
67
+ // ping every 25s; two missed pings while the page is VISIBLE means the
68
+ // channel is dead, and a reload re-syncs (same strategy as reconnect).
69
+ // Hidden tabs wait for visibility — no reloading pages nobody is watching.
70
+ const STALE_MS = 65_000
71
+ const staleReload = (): void => {
72
+ if (!everOpened || document.hidden) return
73
+ if (Date.now() - lastSeen > STALE_MS) {
74
+ console.warn('stator: SSE channel stale — reloading to re-sync')
75
+ sse.close()
76
+ location.reload()
77
+ }
78
+ }
79
+ setInterval(staleReload, 10_000)
80
+ document.addEventListener('visibilitychange', staleReload)
81
+
63
82
  sse.addEventListener('message', (e) => {
83
+ lastSeen = Date.now()
64
84
  let data: WireEnvelope
65
85
  try {
66
86
  data = JSON.parse(e.data)
@@ -24,6 +24,8 @@ export interface CreateAppConfig {
24
24
  /** Per-session TTL in seconds. Every set to any of the session's
25
25
  * machines refreshes this expiry. Defaults to 24h (86400). */
26
26
  sessionTtlSeconds?: number
27
+ /** SSE heartbeat interval in ms (default 25s). Tests shorten it. */
28
+ ssePingMs?: number
27
29
  /** Extra `<head>` HTML per GET route. A production build uses this to link the
28
30
  * prebuilt `components.css`; ignored if omitted. */
29
31
  headExtras?: (filePath: string) => string | Promise<string>
@@ -69,6 +71,7 @@ export async function createApp(config: CreateAppConfig): Promise<StatorApp> {
69
71
  }
70
72
  : config.headExtras,
71
73
  inspector: config.inspector,
74
+ ssePingMs: config.ssePingMs,
72
75
  })
73
76
 
74
77
  return {
@@ -33,6 +33,8 @@ export interface HttpConfig {
33
33
  /** Serve the dev inspector asset at `/@stator/inspector.js`. The dev server
34
34
  * sets this and injects the script tag; production leaves it off. */
35
35
  inspector?: boolean
36
+ /** SSE heartbeat interval in ms (default 25s). Tests shorten it. */
37
+ ssePingMs?: number
36
38
  }
37
39
 
38
40
  const eventSchema = z.object({
@@ -249,13 +251,17 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
249
251
  await conn.send(JSON.stringify({ patches: sync }))
250
252
  }
251
253
 
252
- // Keep-alive every 25s so proxy idle timeouts don't close the
253
- // connection between real events.
254
+ // Heartbeat every 25s. This is a real DATA message, not a comment, on
255
+ // purpose: SSE comments keep proxies from reaping the connection but
256
+ // are invisible to the browser's EventSource API — a client can't
257
+ // detect a half-open zombie (device sleep, silent NAT drop: no FIN
258
+ // ever arrives, no error fires) unless liveness is OBSERVABLE. The
259
+ // runtime tracks last-message time and reconnects when pings stop.
254
260
  const keepAlive = setInterval(() => {
255
- stream.write(': keep-alive\n\n').catch(() => {
256
- // Stream closed; will be cleaned up by abort handler.
261
+ void conn.send('{"ping":true}').catch(() => {
262
+ // Stream closed; the abort handler cleans up.
257
263
  })
258
- }, 25_000)
264
+ }, config.ssePingMs ?? 25_000)
259
265
 
260
266
  try {
261
267
  await new Promise<void>((resolveFn) => {