@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 +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/src/server/machine-store.ts +5 -2
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) => {
|
|
@@ -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
|
|
204
|
-
`
|
|
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)) {
|