@statorjs/stator 2.9.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/server/banner.ts +16 -9
- package/src/server/create-app.ts +3 -5
- package/src/server/dev-native.ts +5 -4
- package/src/server/dev-vite.ts +5 -4
- package/src/server/shutdown.ts +127 -0
- package/src/server/sse.ts +15 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statorjs/stator",
|
|
3
|
-
"version": "2.9.
|
|
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.9.
|
|
98
|
+
"@statorjs/stator": "2.9.1"
|
|
99
99
|
},
|
|
100
100
|
"scripts": {
|
|
101
101
|
"typecheck": "tsc --noEmit",
|
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
|
@@ -589,10 +589,11 @@ export async function createNativeDevApp(config: DevAppConfig): Promise<NativeDe
|
|
|
589
589
|
cors: resolved.cors,
|
|
590
590
|
},
|
|
591
591
|
})
|
|
592
|
-
installGracefulShutdown(
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
592
|
+
installGracefulShutdown(server, {
|
|
593
|
+
teardown: async () => {
|
|
594
|
+
if (teardown) await teardown()
|
|
595
|
+
await watcher.close()
|
|
596
|
+
},
|
|
596
597
|
})
|
|
597
598
|
resolveFn()
|
|
598
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
|
})
|
|
@@ -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
|
@@ -107,6 +107,21 @@ export function activeConnectionCount(): number {
|
|
|
107
107
|
return connections.size
|
|
108
108
|
}
|
|
109
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
|
+
|
|
110
125
|
/**
|
|
111
126
|
* After a state-changing dispatch settles, iterate every open connection
|
|
112
127
|
* whose route's `reads:` intersects `touched`, recompute against that
|