@statorjs/stator 1.5.0 → 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.0",
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.0"
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) => {
@@ -200,8 +200,11 @@ export class MachineStore {
200
200
  `This is almost always a circular import between machine modules — the module ` +
201
201
  `defining "${def.name}" and the module defining its subscription source import ` +
202
202
  `each other, and the loader resolved the mid-cycle binding to undefined. ` +
203
- `Break the module cycle (e.g. move one machine or the subscription), ` +
204
- `or define the mutually-subscribing machines in one module.`,
203
+ `Break the module cycle: move the DISPLAY selectors into a third machine ` +
204
+ `that \`reads:\` both sides (the read/write split — see the weather example's ` +
205
+ `PanelsMachine), or move one machine or the subscription. Note that defining ` +
206
+ `both machines in one module does NOT work with directory discovery, which ` +
207
+ `registers default exports only.`,
205
208
  )
206
209
  }
207
210
  if (!this.defs.has(sub.from.name)) {