@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statorjs/stator",
3
- "version": "2.9.0",
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.0"
98
+ "@statorjs/stator": "2.9.1"
99
99
  },
100
100
  "scripts": {
101
101
  "typecheck": "tsc --noEmit",
@@ -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 closes
93
- * cleanly and exits 0 (Ctrl+C is a normal action, not a failure — without
94
- * this, the process dies 130 and pnpm prints an ELIFECYCLE error banner);
95
- * a second signal force-quits for anything that hangs in close().
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(close: () => Promise<void> | void, quiet = false): void {
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 = (signal: NodeJS.Signals) => {
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 close()
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)
@@ -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
- // Boot teardown runs first (unsubscribe/clear timers), then the server.
187
- installGracefulShutdown(async () => {
188
- if (teardown) await teardown()
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
@@ -589,10 +589,11 @@ export async function createNativeDevApp(config: DevAppConfig): Promise<NativeDe
589
589
  cors: resolved.cors,
590
590
  },
591
591
  })
592
- installGracefulShutdown(async () => {
593
- if (teardown) await teardown()
594
- await watcher.close()
595
- await new Promise<void>((done) => server.close(() => done()))
592
+ installGracefulShutdown(server, {
593
+ teardown: async () => {
594
+ if (teardown) await teardown()
595
+ await watcher.close()
596
+ },
596
597
  })
597
598
  resolveFn()
598
599
  })
@@ -306,10 +306,11 @@ export async function createViteDevApp(config: DevAppConfig): Promise<DevApp> {
306
306
  cors: resolved.cors,
307
307
  },
308
308
  })
309
- installGracefulShutdown(async () => {
310
- if (teardown) await teardown()
311
- await vite.close()
312
- await new Promise<void>((done) => server.close(() => done()))
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