@statorjs/stator 2.8.0 → 2.9.1
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 +112 -8
- package/src/server/banner.ts +16 -9
- package/src/server/create-app.ts +3 -5
- package/src/server/dev-native.ts +68 -15
- package/src/server/dev-vite.ts +5 -4
- package/src/server/http.ts +25 -3
- package/src/server/shutdown.ts +127 -0
- package/src/server/sse.ts +54 -0
- package/src/template/html-attributes.ts +7 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statorjs/stator",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.9.1",
|
|
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.
|
|
98
|
+
"@statorjs/stator": "2.9.1"
|
|
99
99
|
},
|
|
100
100
|
"scripts": {
|
|
101
101
|
"typecheck": "tsc --noEmit",
|
package/src/client/runtime.ts
CHANGED
|
@@ -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
|
|
150
|
-
//
|
|
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',
|
|
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
|
-
|
|
261
|
+
liveChannel = {
|
|
166
262
|
close(): void {
|
|
167
263
|
clearInterval(watchdog)
|
|
168
|
-
|
|
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 }
|
package/src/server/banner.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs'
|
|
2
2
|
import { networkInterfaces } from 'node:os'
|
|
3
3
|
import { logger } from './logger.ts'
|
|
4
|
+
import { type ClosableServer, drainAndClose } from './shutdown.ts'
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Human-facing startup/exit output for the DEV plane. Production keeps
|
|
@@ -89,26 +90,32 @@ export function printStartupNotice(info: { port: number; machines: number; route
|
|
|
89
90
|
}
|
|
90
91
|
|
|
91
92
|
/**
|
|
92
|
-
* Exit a server process like a well-mannered CLI: first signal
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
93
|
+
* Exit a server process like a well-mannered CLI: first signal drains and exits
|
|
94
|
+
* 0 (Ctrl+C is a normal action, not a failure — without this, the process dies
|
|
95
|
+
* 130 and pnpm prints an ELIFECYCLE error banner); a second signal force-quits.
|
|
96
|
+
*
|
|
97
|
+
* The drain itself is `drainAndClose` — the server stops accepting, `teardown`
|
|
98
|
+
* runs (boot teardown, watchers), live connections are hung up, and in-flight
|
|
99
|
+
* requests get a bounded deadline. Waiting on `server.close()` alone would hang
|
|
100
|
+
* forever with a single live page open.
|
|
96
101
|
*/
|
|
97
|
-
export function installGracefulShutdown(
|
|
102
|
+
export function installGracefulShutdown(
|
|
103
|
+
server: ClosableServer,
|
|
104
|
+
opts: { teardown?: () => Promise<void> | void; quiet?: boolean } = {},
|
|
105
|
+
): void {
|
|
98
106
|
let closing = false
|
|
99
|
-
const handler = (
|
|
107
|
+
const handler = () => {
|
|
100
108
|
if (closing) process.exit(130)
|
|
101
109
|
closing = true
|
|
102
|
-
if (!quiet) process.stdout.write(`\n${c.dim(' stopping…')}\n`)
|
|
110
|
+
if (!opts.quiet) process.stdout.write(`\n${c.dim(' stopping…')}\n`)
|
|
103
111
|
void (async () => {
|
|
104
112
|
try {
|
|
105
|
-
await
|
|
113
|
+
await drainAndClose(server, { teardown: opts.teardown })
|
|
106
114
|
} catch (err) {
|
|
107
115
|
logger.warn({ err: String(err) }, 'error during shutdown')
|
|
108
116
|
}
|
|
109
117
|
process.exit(0)
|
|
110
118
|
})()
|
|
111
|
-
void signal
|
|
112
119
|
}
|
|
113
120
|
process.on('SIGINT', handler)
|
|
114
121
|
process.on('SIGTERM', handler)
|
package/src/server/create-app.ts
CHANGED
|
@@ -183,11 +183,9 @@ export async function createApp(config: CreateAppConfig): Promise<StatorApp> {
|
|
|
183
183
|
},
|
|
184
184
|
})
|
|
185
185
|
// Ctrl+C / SIGTERM (deploy rollover) exits 0, not 130 — quiet in prod.
|
|
186
|
-
//
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
await new Promise<void>((done) => server.close(() => done()))
|
|
190
|
-
}, true)
|
|
186
|
+
// The server stops accepting before boot teardown runs, so nothing
|
|
187
|
+
// new arrives while its sources are being unsubscribed.
|
|
188
|
+
installGracefulShutdown(server, { teardown, quiet: true })
|
|
191
189
|
resolveFn()
|
|
192
190
|
})
|
|
193
191
|
// Production is strict about its port (a collision is a deploy
|
package/src/server/dev-native.ts
CHANGED
|
@@ -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`):
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
|
|
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
|
|
115
|
-
// through the dev
|
|
116
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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(
|
|
@@ -537,10 +589,11 @@ export async function createNativeDevApp(config: DevAppConfig): Promise<NativeDe
|
|
|
537
589
|
cors: resolved.cors,
|
|
538
590
|
},
|
|
539
591
|
})
|
|
540
|
-
installGracefulShutdown(
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
592
|
+
installGracefulShutdown(server, {
|
|
593
|
+
teardown: async () => {
|
|
594
|
+
if (teardown) await teardown()
|
|
595
|
+
await watcher.close()
|
|
596
|
+
},
|
|
544
597
|
})
|
|
545
598
|
resolveFn()
|
|
546
599
|
})
|
package/src/server/dev-vite.ts
CHANGED
|
@@ -306,10 +306,11 @@ export async function createViteDevApp(config: DevAppConfig): Promise<DevApp> {
|
|
|
306
306
|
cors: resolved.cors,
|
|
307
307
|
},
|
|
308
308
|
})
|
|
309
|
-
installGracefulShutdown(
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
309
|
+
installGracefulShutdown(server, {
|
|
310
|
+
teardown: async () => {
|
|
311
|
+
if (teardown) await teardown()
|
|
312
|
+
await vite.close()
|
|
313
|
+
},
|
|
313
314
|
})
|
|
314
315
|
resolveFn()
|
|
315
316
|
})
|
package/src/server/http.ts
CHANGED
|
@@ -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
|
|
419
|
-
stream.onAbort(() => resolveFn())
|
|
420
|
-
})
|
|
442
|
+
await finished
|
|
421
443
|
} finally {
|
|
422
444
|
clearInterval(keepAlive)
|
|
423
445
|
unregisterConnection(conn.id)
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { scopedLogger } from './logger.ts'
|
|
2
|
+
import { closeLiveConnections } from './sse.ts'
|
|
3
|
+
|
|
4
|
+
const log = scopedLogger('shutdown')
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Draining a Stator process, which is not the same as closing its server.
|
|
8
|
+
*
|
|
9
|
+
* `server.close()` stops accepting new connections and then resolves only when
|
|
10
|
+
* every existing connection has ended — so it can never be the gate for an app
|
|
11
|
+
* with live routes: an SSE response is a single request that lasts as long as
|
|
12
|
+
* the page is open. Waiting on it means the process hangs until the platform
|
|
13
|
+
* loses patience (systemd `TimeoutStopSec`, Kubernetes
|
|
14
|
+
* `terminationGracePeriodSeconds`, `docker stop`) and SIGKILLs it, turning an
|
|
15
|
+
* ordinary deploy rollover into a 90-second failed stop.
|
|
16
|
+
*
|
|
17
|
+
* So long-lived streams are HUNG UP, never waited on — the same conclusion Go's
|
|
18
|
+
* `http.Server.Shutdown` documents ("does not attempt to close nor wait for
|
|
19
|
+
* hijacked connections... the caller should separately notify such long-lived
|
|
20
|
+
* connections") and every Node graceful-shutdown library reaches. The deadline
|
|
21
|
+
* that remains covers real request work, which is why it is short by default.
|
|
22
|
+
*
|
|
23
|
+
* Dropping a live channel costs a re-render, not a fact: session state is in the
|
|
24
|
+
* Store, `after` timers re-arm on hydration, and a reconnecting page re-syncs
|
|
25
|
+
* from the server's baseline (or reloads, if it comes back to a newer build via
|
|
26
|
+
* the build-id handshake).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/** Deadline for in-flight request work once the streams are hung up. Short on
|
|
30
|
+
* purpose: closing live connections is immediate, so this only ever covers
|
|
31
|
+
* real requests, and it must stay well under the platform's own kill grace
|
|
32
|
+
* (`docker stop` allows 10s). */
|
|
33
|
+
const DEFAULT_TIMEOUT_MS = 5_000
|
|
34
|
+
|
|
35
|
+
/** `STATOR_SHUTDOWN_TIMEOUT_MS`, or the default. An unparseable value warns and
|
|
36
|
+
* falls back rather than shortening the drain to zero. */
|
|
37
|
+
export function shutdownTimeoutMs(): number {
|
|
38
|
+
const raw = process.env.STATOR_SHUTDOWN_TIMEOUT_MS
|
|
39
|
+
if (raw === undefined || raw === '') return DEFAULT_TIMEOUT_MS
|
|
40
|
+
const ms = Number(raw)
|
|
41
|
+
if (!Number.isFinite(ms) || ms < 0) {
|
|
42
|
+
log.warn({ value: raw }, 'ignoring invalid STATOR_SHUTDOWN_TIMEOUT_MS')
|
|
43
|
+
return DEFAULT_TIMEOUT_MS
|
|
44
|
+
}
|
|
45
|
+
return ms
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** What the drain needs of a listening server — structurally satisfied by
|
|
49
|
+
* `node:http`'s `Server` and by `@hono/node-server`'s `serve()` return.
|
|
50
|
+
* `closeIdleConnections`/`closeAllConnections` are Node ≥18.2; optional so a
|
|
51
|
+
* non-http server (or a test double) still drains. */
|
|
52
|
+
export interface ClosableServer {
|
|
53
|
+
close(callback?: (err?: Error) => void): unknown
|
|
54
|
+
closeIdleConnections?: () => void
|
|
55
|
+
closeAllConnections?: () => void
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface DrainResult {
|
|
59
|
+
/** Live (SSE) connections hung up by the drain. */
|
|
60
|
+
connections: number
|
|
61
|
+
/** True when the deadline expired and the remaining sockets were destroyed. */
|
|
62
|
+
forced: boolean
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Stop accepting, tear down inbound sources, hang up live connections, and wait
|
|
67
|
+
* for in-flight requests up to a deadline — then destroy whatever is left.
|
|
68
|
+
* Never exits the process: the signal handler owns that, and tests own this.
|
|
69
|
+
*/
|
|
70
|
+
export async function drainAndClose(
|
|
71
|
+
server: ClosableServer,
|
|
72
|
+
opts: { teardown?: () => Promise<void> | void; timeoutMs?: number } = {},
|
|
73
|
+
): Promise<DrainResult> {
|
|
74
|
+
const timeoutMs = opts.timeoutMs ?? shutdownTimeoutMs()
|
|
75
|
+
|
|
76
|
+
// Stop accepting FIRST, so nothing new arrives while sources are torn down.
|
|
77
|
+
const closed = new Promise<void>((done) => server.close(() => done()))
|
|
78
|
+
|
|
79
|
+
let connections = 0
|
|
80
|
+
const drain = (async () => {
|
|
81
|
+
// Inbound sources off before the streams they feed: a boot.ts poll or
|
|
82
|
+
// subscription can raise events, and a dev watcher can rebuild, so both
|
|
83
|
+
// stop before anything is hung up. A teardown that throws must not keep the
|
|
84
|
+
// sockets open — log it and carry on closing.
|
|
85
|
+
if (opts.teardown) {
|
|
86
|
+
try {
|
|
87
|
+
await opts.teardown()
|
|
88
|
+
} catch (err) {
|
|
89
|
+
log.warn({ err: String(err) }, 'teardown failed during shutdown')
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// The reason close() can complete at all.
|
|
93
|
+
connections = closeLiveConnections()
|
|
94
|
+
// Then reap keep-alive sockets parked between requests — on a tick, not
|
|
95
|
+
// once. A hung-up stream's socket only becomes idle a moment later, and
|
|
96
|
+
// until something closes it, `close()` stays pending for as long as the
|
|
97
|
+
// CLIENT's keep-alive timeout (undici's is 4s) — measured, and the whole
|
|
98
|
+
// reason a one-shot call here isn't enough. A socket with a request in
|
|
99
|
+
// flight is never idle, so this never cuts real work short.
|
|
100
|
+
server.closeIdleConnections?.()
|
|
101
|
+
const reaper = setInterval(() => server.closeIdleConnections?.(), 50)
|
|
102
|
+
reaper.unref?.()
|
|
103
|
+
try {
|
|
104
|
+
await closed
|
|
105
|
+
} finally {
|
|
106
|
+
clearInterval(reaper)
|
|
107
|
+
}
|
|
108
|
+
})()
|
|
109
|
+
|
|
110
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
111
|
+
const expired = new Promise<'timeout'>((done) => {
|
|
112
|
+
timer = setTimeout(() => done('timeout'), timeoutMs)
|
|
113
|
+
timer.unref?.()
|
|
114
|
+
})
|
|
115
|
+
const outcome = await Promise.race([drain.then(() => 'drained' as const), expired])
|
|
116
|
+
clearTimeout(timer)
|
|
117
|
+
|
|
118
|
+
if (outcome === 'timeout') {
|
|
119
|
+
// Real request work (or a wedged teardown) outlasted the deadline. Destroy
|
|
120
|
+
// what's left rather than hand the process to the platform's SIGKILL.
|
|
121
|
+
server.closeAllConnections?.()
|
|
122
|
+
log.warn({ timeoutMs }, 'shutdown deadline expired — remaining connections destroyed')
|
|
123
|
+
return { connections, forced: true }
|
|
124
|
+
}
|
|
125
|
+
log.debug({ connections }, 'drained')
|
|
126
|
+
return { connections, forced: false }
|
|
127
|
+
}
|
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
|
|
@@ -68,6 +107,21 @@ export function activeConnectionCount(): number {
|
|
|
68
107
|
return connections.size
|
|
69
108
|
}
|
|
70
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Hang up every open connection — the shutdown path. A live response never ends
|
|
112
|
+
* on its own, so `server.close()` cannot complete until these do; see
|
|
113
|
+
* `shutdown.ts` for why they are closed rather than waited on. Returns how many
|
|
114
|
+
* were open. Each handler's own `finally` unregisters again, harmlessly.
|
|
115
|
+
*/
|
|
116
|
+
export function closeLiveConnections(): number {
|
|
117
|
+
const open = [...connections.values()]
|
|
118
|
+
for (const conn of open) {
|
|
119
|
+
unregisterConnection(conn.id)
|
|
120
|
+
conn.close?.()
|
|
121
|
+
}
|
|
122
|
+
return open.length
|
|
123
|
+
}
|
|
124
|
+
|
|
71
125
|
/**
|
|
72
126
|
* After a state-changing dispatch settles, iterate every open connection
|
|
73
127
|
* whose route's `reads:` intersects `touched`, recompute against that
|
|
@@ -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
|
|