@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 +2 -2
- package/src/client/runtime.ts +20 -0
- package/src/server/create-app.ts +3 -0
- package/src/server/http.ts +11 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statorjs/stator",
|
|
3
|
-
"version": "1.5.
|
|
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.
|
|
91
|
+
"@statorjs/stator": "1.5.2"
|
|
92
92
|
},
|
|
93
93
|
"scripts": {
|
|
94
94
|
"typecheck": "tsc --noEmit",
|
package/src/client/runtime.ts
CHANGED
|
@@ -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)
|
package/src/server/create-app.ts
CHANGED
|
@@ -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 {
|
package/src/server/http.ts
CHANGED
|
@@ -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
|
-
//
|
|
253
|
-
//
|
|
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
|
-
|
|
256
|
-
// Stream closed;
|
|
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) => {
|