@statorjs/stator 2.8.0 → 2.9.0

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": "2.8.0",
3
+ "version": "2.9.0",
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, loaded natively by its own toolchain — no build step.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -95,7 +95,7 @@
95
95
  "pino-pretty": "^13.1.3",
96
96
  "vite": "^6.0.0",
97
97
  "vitest": "^2.1.0",
98
- "@statorjs/stator": "2.8.0"
98
+ "@statorjs/stator": "2.9.0"
99
99
  },
100
100
  "scripts": {
101
101
  "typecheck": "tsc --noEmit",
@@ -67,7 +67,17 @@ function init(): void {
67
67
  initLiveChannel()
68
68
  }
69
69
 
70
+ /** The page's one live channel. `initLiveChannel` is NOT idempotent on its
71
+ * own — each call mints an EventSource and a watchdog interval — so the
72
+ * handle is parked here and a second call tears the first one down. Without
73
+ * this a re-entered `init()` stacked channels that nothing could reach,
74
+ * each holding a socket against the browser's per-origin cap. */
75
+ let liveChannel: { close(): void } | undefined
76
+
70
77
  function initLiveChannel(): { close(): void } | undefined {
78
+ liveChannel?.close()
79
+ liveChannel = undefined
80
+
71
81
  const meta = document.querySelector('meta[name="stator-live"][content="true"]')
72
82
  if (!meta) return
73
83
 
@@ -83,8 +93,12 @@ function initLiveChannel(): { close(): void } | undefined {
83
93
  // (CSS hook for offline banners etc.) plus a `stator:connection-state`
84
94
  // event. Change-guarded — EventSource fires `error` on every failed
85
95
  // auto-reconnect attempt, and only transitions are worth announcing.
96
+ //
97
+ // `idle` is a DELIBERATE release, not a fault, and is deliberately its own
98
+ // state: apps hang offline banners off `disconnected`/`stale`, and reusing
99
+ // either would flash "you're offline" every time a tab loses focus.
86
100
  let connectionState: string | undefined
87
- const setConnectionState = (state: 'connected' | 'disconnected' | 'stale'): void => {
101
+ const setConnectionState = (state: 'connected' | 'disconnected' | 'stale' | 'idle'): void => {
88
102
  if (state === connectionState) return
89
103
  connectionState = state
90
104
  document.documentElement.setAttribute('data-stator-connection', state)
@@ -103,6 +117,10 @@ function initLiveChannel(): { close(): void } | undefined {
103
117
  console.error('stator: malformed SSE message', err)
104
118
  return
105
119
  }
120
+ // Raw envelope, before interpretation — the observability seam for
121
+ // inspectors and for the dev client, which reads its rebuild/error
122
+ // signals off this instead of opening a second event-stream.
123
+ emit('stator:live-message', { envelope: data, timestamp: Date.now() })
106
124
  if (data.patches) {
107
125
  // SSE is server-pushed — no client round-trip to time — so report the
108
126
  // apply duration (still the useful "how expensive was this update?").
@@ -142,33 +160,118 @@ function initLiveChannel(): { close(): void } | undefined {
142
160
  })
143
161
  }
144
162
 
163
+ /** Hand the socket back. `es.close()` also stops EventSource's own
164
+ * auto-reconnect, so a released channel stays released until we say
165
+ * otherwise. */
166
+ const release = (): void => {
167
+ if (!sse) return
168
+ sse.close()
169
+ sse = null
170
+ setConnectionState('idle')
171
+ }
172
+
145
173
  // Zombie watchdog. A half-open connection (device sleep, silent NAT drop)
146
174
  // never fires `error` — EventSource just sits "open" and silent forever,
147
175
  // and the page quietly stops being live. The server sends an observable
148
176
  // ping every 25s; two missed pings while the page is VISIBLE means the
149
- // channel is dead, and a rebuilt connection re-syncs. Hidden tabs wait for
150
- // visibility — no churn for pages nobody is watching.
177
+ // channel is dead, and a rebuilt connection re-syncs. Hidden tabs are
178
+ // either still in their grace period or already released — neither wants a
179
+ // staleness reconnect.
151
180
  const STALE_MS = 65_000
152
181
  const checkStale = (): void => {
153
- if (document.hidden) return
182
+ if (document.hidden || !sse) return
154
183
  if (Date.now() - lastSeen > STALE_MS) {
155
184
  console.warn('stator: SSE channel stale — reconnecting to re-sync')
156
185
  setConnectionState('stale')
157
186
  connect()
158
187
  }
159
188
  }
189
+
190
+ // ── Proactive release ────────────────────────────────────────────────────
191
+ // A live page holds one HTTP/1.1 socket for as long as it is open, and the
192
+ // browser's per-origin pool (6 in Chrome) is shared across every tab in the
193
+ // profile — so background tabs spend the foreground tab's budget, and
194
+ // enough of them wedge every request to the origin. Browsers never reclaim
195
+ // these on their own: hiding a tab throttles timers but leaves sockets
196
+ // untouched.
197
+ //
198
+ // Releasing is cheap here because reconnect is a full resync: a fresh
199
+ // /__sse gets the server's initial sync, so a released page converges in
200
+ // place on return. Nothing durable rides the connection — session state
201
+ // lives in the Store, `after` timers in a process-wide registry, in-flight
202
+ // effects in a process-wide map — so dropping one costs a re-render, not a
203
+ // fact.
204
+ const HIDDEN_GRACE_MS = 30_000
205
+ let releaseTimer: ReturnType<typeof setTimeout> | undefined
206
+
207
+ const cancelRelease = (): void => {
208
+ if (releaseTimer === undefined) return
209
+ clearTimeout(releaseTimer)
210
+ releaseTimer = undefined
211
+ }
212
+
213
+ const onVisibility = (): void => {
214
+ if (document.hidden) {
215
+ // Grace period, not an immediate drop: alt-tabbing away for a moment is
216
+ // not abandonment, and every reconnect costs a full route render server
217
+ // side. Hidden-tab timer throttling is 1/s, so this fires ~on time; the
218
+ // 1/min intensive throttling only starts at 5 minutes, long after.
219
+ cancelRelease()
220
+ releaseTimer = setTimeout(release, HIDDEN_GRACE_MS)
221
+ } else {
222
+ cancelRelease()
223
+ if (!sse) connect()
224
+ else checkStale()
225
+ }
226
+ }
227
+
228
+ // A frozen tab runs no timers at all, so the grace period would never
229
+ // elapse — release synchronously on the way in. `resume` fires when the
230
+ // browser thaws the page without a navigation.
231
+ const onFreeze = (): void => {
232
+ cancelRelease()
233
+ release()
234
+ }
235
+ const onResume = (): void => {
236
+ if (!document.hidden && !sse) connect()
237
+ }
238
+
239
+ // pagehide covers both exits: `persisted` means bfcache (the document
240
+ // survives, and would otherwise sit in the cache still holding a socket and
241
+ // pinning a SessionRuntime), plain unload means it's going away. Either way
242
+ // the connection has no further use. pageshow with `persisted` is the
243
+ // bfcache restore — reconnect and resync.
244
+ const onPageHide = (): void => {
245
+ cancelRelease()
246
+ release()
247
+ }
248
+ const onPageShow = (e: PageTransitionEvent): void => {
249
+ if (e.persisted && !document.hidden && !sse) connect()
250
+ }
251
+
160
252
  const watchdog = setInterval(checkStale, 10_000)
161
- document.addEventListener('visibilitychange', checkStale)
253
+ document.addEventListener('visibilitychange', onVisibility)
254
+ document.addEventListener('freeze', onFreeze)
255
+ document.addEventListener('resume', onResume)
256
+ window.addEventListener('pagehide', onPageHide)
257
+ window.addEventListener('pageshow', onPageShow)
162
258
 
163
259
  connect()
164
260
 
165
- return {
261
+ liveChannel = {
166
262
  close(): void {
167
263
  clearInterval(watchdog)
168
- document.removeEventListener('visibilitychange', checkStale)
264
+ cancelRelease()
265
+ document.removeEventListener('visibilitychange', onVisibility)
266
+ document.removeEventListener('freeze', onFreeze)
267
+ document.removeEventListener('resume', onResume)
268
+ window.removeEventListener('pagehide', onPageHide)
269
+ window.removeEventListener('pageshow', onPageShow)
169
270
  sse?.close()
271
+ sse = null
170
272
  },
171
273
  }
274
+ return liveChannel
172
275
  }
173
276
 
174
277
  function handleEvent(e: Event): void {
@@ -323,5 +426,6 @@ if (document.readyState === 'loading') {
323
426
 
324
427
  // Exported for tests only — the runtime self-initializes above, and the
325
428
  // client bundle (esbuild IIFE) ignores exports. Re-calling init() is safe:
326
- // addEventListener dedupes an identical listener reference.
429
+ // addEventListener dedupes an identical listener reference, and the live
430
+ // channel closes its predecessor before opening a replacement.
327
431
  export { init, initLiveChannel }
@@ -33,7 +33,10 @@ import { logger, setLogLevel } from './logger.ts'
33
33
  import { codeHashOf, codeInputsOf } from './machine-hash.ts'
34
34
  import { MachineStore } from './machine-store.ts'
35
35
  import { discoverMiddleware } from './middleware.ts'
36
+ import type { DiscoveredRoute } from './route-discovery.ts'
36
37
  import { discoverRoutes } from './route-discovery.ts'
38
+ import { isStatorQueryRoute } from './routing.ts'
39
+ import { broadcastEnvelope } from './sse.ts'
37
40
  import { InMemoryStore } from './store.ts'
38
41
 
39
42
  /**
@@ -71,10 +74,29 @@ export interface NativeDevApp {
71
74
  close: () => Promise<void>
72
75
  }
73
76
 
74
- // The dev browser client (replaces Vite's `/@vite/client`): reconnecting-EventSource
75
- // that reloads on a successful rebuild and renders a full-screen overlay with the
76
- // message on a build failure, so a compile error is visible instead of a frozen page.
77
- const DEV_CLIENT_SCRIPT = `(()=>{let o;const show=(m)=>{if(o)o.remove();o=document.createElement('div');o.style.cssText='position:fixed;inset:0;z-index:2147483647;margin:0;padding:24px;overflow:auto;background:#1a1015;color:#f8d7da;font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;white-space:pre-wrap';o.textContent='stator \\u2014 build failed\\n\\n'+m;document.body.appendChild(o)};const es=new EventSource('/__stator_dev');es.onmessage=(e)=>{let m;try{m=JSON.parse(e.data)}catch{return}if(m.type==='reload')location.reload();else if(m.type==='error')show(m.message)}})()`
77
+ // The dev browser client (replaces Vite's `/@vite/client`): reloads on a successful
78
+ // rebuild and renders a full-screen overlay with the message on a build failure, so
79
+ // a compile error is visible instead of a frozen page.
80
+ //
81
+ // Transport depends on the page. A `live: true` page already holds a /__sse stream,
82
+ // so the dev signals ride it and this script opens NOTHING — a second event-stream
83
+ // would take a second slot from the browser's six-per-origin pool (shared across
84
+ // every tab in the profile), and two-per-tab is what wedges a dev session at three
85
+ // tabs. Reload arrives there as the wire protocol's own directive, applied by the
86
+ // page runtime; only the dev-only error overlay needs listening for. Pages without
87
+ // a live channel keep their own /__stator_dev stream — still one per tab.
88
+ const DEV_OVERLAY = `let o;const show=(m)=>{if(o)o.remove();o=document.createElement('div');o.style.cssText='position:fixed;inset:0;z-index:2147483647;margin:0;padding:24px;overflow:auto;background:#1a1015;color:#f8d7da;font:13px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;white-space:pre-wrap';o.textContent='stator \\u2014 build failed\\n\\n'+m;document.body.appendChild(o)};const go=(m)=>{if(m.type==='reload')location.reload();else if(m.type==='error')show(m.message)};`
89
+
90
+ /** A page route (not a data route) declared `live: true` — the pages that hold
91
+ * a /__sse stream, and so need no second one for dev signals. */
92
+ const isLiveRoute = (def: DiscoveredRoute['GET']): boolean =>
93
+ def !== undefined && !isStatorQueryRoute(def) && def.live === true
94
+
95
+ /** `live` pages piggyback on /__sse; everything else opens /__stator_dev. */
96
+ const devClientScript = (live: boolean): string =>
97
+ live
98
+ ? `(()=>{${DEV_OVERLAY}window.addEventListener('stator:live-message',(e)=>{const d=e.detail&&e.detail.envelope&&e.detail.envelope.dev;if(d)go(d)})})()`
99
+ : `(()=>{${DEV_OVERLAY}const es=new EventSource('/__stator_dev');es.onmessage=(e)=>{let m;try{m=JSON.parse(e.data)}catch{return}go(m)}})()`
78
100
 
79
101
  const SKIP_DIRS = new Set(['node_modules', 'dist', 'tests', 'test', '__tests__'])
80
102
  const APP_EXT = /\.(stator|ts|tsx|mts|cts|js|mjs)$/
@@ -111,9 +133,13 @@ export async function createNativeDevApp(config: DevAppConfig): Promise<NativeDe
111
133
  const machinesDir = real(resolve(config.machinesDir))
112
134
  const routesDir = real(resolve(config.routesDir))
113
135
  const staticDir = config.staticDir ? real(resolve(config.staticDir)) : resolve(root, 'static')
114
- // One build-id per dev process, as the Vite dev server did — rebuilds reload
115
- // through the dev channel, a restart is a new id for the SSE reconnect handshake.
116
- const buildId = randomUUID()
136
+ // One build-id per successful BUILD, not per process. Connected pages reload
137
+ // through the dev signal, but a page whose channel was released while it sat
138
+ // in the background (or dropped over a blip) never sees that signal — it
139
+ // reconnects later carrying the id it was rendered against, and the /__sse
140
+ // handshake is the only thing that can tell it its slot-ID map is stale. A
141
+ // per-process id would answer "same build" to a page from three rebuilds ago.
142
+ let buildId = randomUUID()
117
143
 
118
144
  // Loaders, BEFORE the first app import. The CLI's TS loader first — a no-op
119
145
  // extra link when the CLI already registered it, required when the dev server
@@ -166,6 +192,11 @@ export async function createNativeDevApp(config: DevAppConfig): Promise<NativeDe
166
192
  import(`${pathToFileURL(file).href}?v=${versions.get(file) ?? 1}`)
167
193
 
168
194
  // ── Dev live-reload channel (replaces Vite's `/@vite/client` HMR socket) ────
195
+ // Only pages WITHOUT a live channel open this. A `live: true` page already
196
+ // holds a /__sse stream, and its dev signals ride that one instead — a
197
+ // second event-stream would spend another slot from the browser's six-per-
198
+ // origin pool (shared across every tab in the profile), which is what wedges
199
+ // a multi-tab dev session at three tabs.
169
200
  const reloadClients = new Set<ReadableStreamDefaultController<Uint8Array>>()
170
201
  const enc = new TextEncoder()
171
202
  type DevMessage = { type: 'reload' } | { type: 'error'; message: string }
@@ -178,15 +209,29 @@ export async function createNativeDevApp(config: DevAppConfig): Promise<NativeDe
178
209
  reloadClients.delete(c)
179
210
  }
180
211
  }
212
+ // Live pages: a reload rides the wire protocol's own directive (the page
213
+ // runtime applies it with no dev-specific code), an error rides a `dev`
214
+ // key the production runtime ignores and the dev client picks up off
215
+ // `stator:live-message`.
216
+ void broadcastEnvelope(
217
+ msg.type === 'reload' ? { directives: [{ type: 'reload' }] } : { dev: msg },
218
+ )
181
219
  }
182
- const devReloadResponse = (): Response =>
183
- new Response(
220
+ const devReloadResponse = (): Response => {
221
+ let mine: ReadableStreamDefaultController<Uint8Array> | undefined
222
+ return new Response(
184
223
  new ReadableStream<Uint8Array>({
185
224
  start(controller) {
225
+ mine = controller
186
226
  controller.enqueue(enc.encode(': connected\n\n'))
187
227
  reloadClients.add(controller)
188
228
  },
189
- // A closed stream's controller throws on the next enqueue and is pruned then.
229
+ // Disconnect arrives as a stream cancel; without this the controller
230
+ // lingers until the next broadcast's enqueue throws, and this channel
231
+ // has no heartbeat to provoke one.
232
+ cancel() {
233
+ if (mine) reloadClients.delete(mine)
234
+ },
190
235
  }),
191
236
  {
192
237
  headers: {
@@ -197,6 +242,7 @@ export async function createNativeDevApp(config: DevAppConfig): Promise<NativeDe
197
242
  },
198
243
  },
199
244
  )
245
+ }
200
246
 
201
247
  // ── Source scan: scoped CSS + island-ness of every `.stator` ───────────────
202
248
  // The loader compiles each `.stator` into its server module on import; this
@@ -304,7 +350,12 @@ export async function createNativeDevApp(config: DevAppConfig): Promise<NativeDe
304
350
  if (url) head.push(`<script type="module" src="${url}"></script>`)
305
351
  }
306
352
  if (inspectorOn) head.push('<script src="/@stator/inspector.js" defer></script>')
307
- head.push(`<script>${DEV_CLIENT_SCRIPT}</script>`)
353
+ // Liveness is decided HERE, not sniffed from the DOM: headExtras is injected
354
+ // ahead of the `stator-live` meta, so a script querying for it would always
355
+ // find nothing and open a redundant stream.
356
+ const abs = resolve(routeFile)
357
+ const live = routes.some((r) => resolve(r.filePath) === abs && isLiveRoute(r.GET))
358
+ head.push(`<script>${devClientScript(live)}</script>`)
308
359
  return head.join('\n')
309
360
  }
310
361
 
@@ -435,6 +486,7 @@ export async function createNativeDevApp(config: DevAppConfig): Promise<NativeDe
435
486
  machinesChanged = await rebuildStore()
436
487
  storeRebuilt = true
437
488
  }
489
+ buildId = randomUUID()
438
490
  await rebuildServer()
439
491
  broadcast({ type: 'reload' })
440
492
  logger.info(
@@ -376,6 +376,26 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
376
376
  const runtime = new SessionRuntime(sessionId, config.store)
377
377
  await runtime.loadGraph(route.reads)
378
378
  const { renderState } = await renderRoute(route, routeKey, sessionId, runtime, request)
379
+
380
+ // The handler parks on `finished` until the connection ends. Both ways
381
+ // out resolve it: the client aborting, and the server hanging up on a
382
+ // superseded connection. A server-side `stream.close()` does NOT raise
383
+ // onAbort, so without the second path an evicted connection would leave
384
+ // its heartbeat interval running for the life of the process.
385
+ //
386
+ // Subscribe BEFORE registering — and before the ': open' flush and the
387
+ // initial sync below. Hono fires its abort subscribers exactly once, at
388
+ // abort time (utils/stream.ts), so a listener added after the client has
389
+ // already gone is never called: the connection would sit in the registry
390
+ // for the life of the process, pinning its SessionRuntime and taking a
391
+ // slice of every fan-out. A page that opens the channel and immediately
392
+ // navigates away hits that window every time.
393
+ let finish!: () => void
394
+ const finished = new Promise<void>((resolveFn) => {
395
+ finish = resolveFn
396
+ })
397
+ stream.onAbort(() => finish())
398
+
379
399
  const conn = registerConnection({
380
400
  sessionId,
381
401
  clientId: c.req.query('client'),
@@ -387,6 +407,10 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
387
407
  send: async (data: string) => {
388
408
  await stream.writeSSE({ data })
389
409
  },
410
+ close: () => {
411
+ void stream.close()
412
+ finish()
413
+ },
390
414
  })
391
415
 
392
416
  // Force an immediate flush so edge proxies commit response headers
@@ -415,9 +439,7 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
415
439
  }, config.ssePingMs ?? 25_000)
416
440
 
417
441
  try {
418
- await new Promise<void>((resolveFn) => {
419
- stream.onAbort(() => resolveFn())
420
- })
442
+ await finished
421
443
  } finally {
422
444
  clearInterval(keepAlive)
423
445
  unregisterConnection(conn.id)
package/src/server/sse.ts CHANGED
@@ -35,6 +35,9 @@ export interface Connection {
35
35
  runtime: SessionRuntime
36
36
  renderState: RenderState
37
37
  send: (data: string) => Promise<void>
38
+ /** Tear the underlying stream down from the server side. Supplied by the
39
+ * transport; absent in tests that register a bare connection. */
40
+ close?: () => void
38
41
  closed: boolean
39
42
  }
40
43
 
@@ -44,6 +47,23 @@ let nextId = 0
44
47
  export function registerConnection(init: Omit<Connection, 'id' | 'closed'>): Connection {
45
48
  const id = `sse${nextId++}`
46
49
  const conn: Connection = { ...init, id, closed: false }
50
+ // A page-load holds at most one channel per route, so an existing
51
+ // connection with the same (clientId, routeKey) is a corpse whose abort we
52
+ // never observed — a half-open socket, or a reconnect that raced its own
53
+ // teardown. Left registered it would pin a SessionRuntime forever and take
54
+ // a slice of every fan-out. Evict it before the new one lands.
55
+ if (init.clientId !== undefined) {
56
+ for (const prior of connections.values()) {
57
+ if (prior.clientId === init.clientId && prior.routeKey === init.routeKey) {
58
+ sseLog.debug(
59
+ { id: prior.id, sid: prior.sessionId, route: prior.routeKey, replacedBy: id },
60
+ 'evicting superseded connection',
61
+ )
62
+ unregisterConnection(prior.id)
63
+ prior.close?.()
64
+ }
65
+ }
66
+ }
47
67
  connections.set(id, conn)
48
68
  sseLog.debug(
49
69
  { id, sid: conn.sessionId, route: conn.routeKey, total: connections.size },
@@ -52,6 +72,25 @@ export function registerConnection(init: Omit<Connection, 'id' | 'closed'>): Con
52
72
  return conn
53
73
  }
54
74
 
75
+ /**
76
+ * Push a pre-serialized envelope to every open connection, bypassing the
77
+ * recompute path. The dev server's rebuild/error signals ride this so a dev
78
+ * page holds ONE event-stream instead of two — see `dev-native.ts`. Not a
79
+ * fan-out: no diffing, no baseline advance, no per-connection filtering.
80
+ */
81
+ export async function broadcastEnvelope(payload: unknown): Promise<void> {
82
+ if (connections.size === 0) return
83
+ const data = JSON.stringify(payload)
84
+ for (const conn of connections.values()) {
85
+ if (conn.closed) continue
86
+ try {
87
+ await conn.send(data)
88
+ } catch (err) {
89
+ sseLog.warn({ id: conn.id, err: String(err) }, 'broadcast failed')
90
+ }
91
+ }
92
+ }
93
+
55
94
  export function unregisterConnection(id: string): void {
56
95
  const conn = connections.get(id)
57
96
  if (!conn) return
@@ -92,11 +92,14 @@ export interface ElementSpecificAttributes {
92
92
  autocomplete?: string
93
93
  inputmode?: string
94
94
  autofocus?: boolean
95
+ accept?: string
96
+ multiple?: boolean
95
97
  }
96
98
  label: { for?: string }
97
99
  form: {
98
100
  action?: string
99
101
  method?: 'get' | 'post' | 'GET' | 'POST' | 'dialog'
102
+ enctype?: 'application/x-www-form-urlencoded' | 'multipart/form-data' | 'text/plain'
100
103
  novalidate?: boolean
101
104
  }
102
105
  select: {
@@ -123,7 +126,11 @@ export interface ElementSpecificAttributes {
123
126
  alt?: string
124
127
  width?: number | string
125
128
  height?: number | string
129
+ srcset?: string
130
+ sizes?: string
126
131
  loading?: 'eager' | 'lazy'
132
+ decoding?: 'sync' | 'async' | 'auto'
133
+ fetchpriority?: 'high' | 'low' | 'auto'
127
134
  }
128
135
  }
129
136