@statorjs/stator 2.9.0 → 2.10.0-next.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.
@@ -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
@@ -76,11 +77,18 @@ export function printDevBanner(info: {
76
77
  * "did it start, and where" is the one thing an operator always wants, distinct
77
78
  * from the per-request logs that `warn` intentionally suppresses.
78
79
  */
79
- export function printStartupNotice(info: { port: number; machines: number; routes: number }): void {
80
+ export function printStartupNotice(info: {
81
+ port: number
82
+ machines: number
83
+ routes: number
84
+ /** Whether state survives a restart. Always shown: a deploy log should not
85
+ * have to infer the persistence posture from the absence of a warning. */
86
+ persistence?: string
87
+ }): void {
80
88
  const v = statorVersion()
81
- const inv = `${info.machines} machine${info.machines === 1 ? '' : 's'} · ${info.routes} route${
82
- info.routes === 1 ? '' : 's'
83
- }`
89
+ const inv = `${info.machines} machine${info.machines === 1 ? '' : 's'} · ${
90
+ info.routes
91
+ } route${info.routes === 1 ? '' : 's'}${info.persistence ? ` · ${info.persistence}` : ''}`
84
92
  process.stdout.write(
85
93
  ` ${c.copper(c.bold('stator'))}${v ? c.dim(` v${v}`) : ''} ${c.dim('·')} ${c.cyan(
86
94
  `http://localhost:${info.port}/`,
@@ -89,26 +97,32 @@ export function printStartupNotice(info: { port: number; machines: number; route
89
97
  }
90
98
 
91
99
  /**
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().
100
+ * Exit a server process like a well-mannered CLI: first signal drains and exits
101
+ * 0 (Ctrl+C is a normal action, not a failure — without this, the process dies
102
+ * 130 and pnpm prints an ELIFECYCLE error banner); a second signal force-quits.
103
+ *
104
+ * The drain itself is `drainAndClose` — the server stops accepting, `teardown`
105
+ * runs (boot teardown, watchers), live connections are hung up, and in-flight
106
+ * requests get a bounded deadline. Waiting on `server.close()` alone would hang
107
+ * forever with a single live page open.
96
108
  */
97
- export function installGracefulShutdown(close: () => Promise<void> | void, quiet = false): void {
109
+ export function installGracefulShutdown(
110
+ server: ClosableServer,
111
+ opts: { teardown?: () => Promise<void> | void; quiet?: boolean } = {},
112
+ ): void {
98
113
  let closing = false
99
- const handler = (signal: NodeJS.Signals) => {
114
+ const handler = () => {
100
115
  if (closing) process.exit(130)
101
116
  closing = true
102
- if (!quiet) process.stdout.write(`\n${c.dim(' stopping…')}\n`)
117
+ if (!opts.quiet) process.stdout.write(`\n${c.dim(' stopping…')}\n`)
103
118
  void (async () => {
104
119
  try {
105
- await close()
120
+ await drainAndClose(server, { teardown: opts.teardown })
106
121
  } catch (err) {
107
122
  logger.warn({ err: String(err) }, 'error during shutdown')
108
123
  }
109
124
  process.exit(0)
110
125
  })()
111
- void signal
112
126
  }
113
127
  process.on('SIGINT', handler)
114
128
  process.on('SIGTERM', handler)
@@ -18,6 +18,7 @@ import { discoverMiddleware } from './middleware.ts'
18
18
  import { discoverRoutes } from './route-discovery.ts'
19
19
  import { setSessionSameSite } from './session.ts'
20
20
  import { InMemoryStore, type Store } from './store.ts'
21
+ import { persistencePosture } from './store-intent.ts'
21
22
 
22
23
  export interface CreateAppConfig extends DeprecatedFlatConfig {
23
24
  machinesDir: string
@@ -169,7 +170,23 @@ export async function createApp(config: CreateAppConfig): Promise<StatorApp> {
169
170
  return new Promise((resolveFn) => {
170
171
  const server = serve({ fetch: app.fetch, port, hostname: resolved.host }, async () => {
171
172
  // Always-on (level-independent) so `warn` prod still confirms boot.
172
- printStartupNotice({ port, machines: defs.length, routes: routes.length })
173
+ // Persistence posture in the notice, and a warning for anything
174
+ // actually at risk. Silence about in-memory storage is the defect
175
+ // this closes: the app runs, looks healthy, and loses every session
176
+ // on restart.
177
+ const posture = persistencePosture({
178
+ session: sessionStore,
179
+ app: store.appStore,
180
+ machines: defs,
181
+ production: process.env.NODE_ENV === 'production',
182
+ })
183
+ printStartupNotice({
184
+ port,
185
+ machines: defs.length,
186
+ routes: routes.length,
187
+ persistence: posture.label,
188
+ })
189
+ for (const warning of posture.warnings) logger.warn({}, `stator: ${warning}`)
173
190
  // Run boot.ts once the server is up — its long-lived work (a poll, a
174
191
  // subscription) starts here, not during createApp, so tests that only
175
192
  // `app.fetch` never trigger it.
@@ -183,11 +200,9 @@ export async function createApp(config: CreateAppConfig): Promise<StatorApp> {
183
200
  },
184
201
  })
185
202
  // 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)
203
+ // The server stops accepting before boot teardown runs, so nothing
204
+ // new arrives while its sources are being unsubscribed.
205
+ installGracefulShutdown(server, { teardown, quiet: true })
191
206
  resolveFn()
192
207
  })
193
208
  // 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
  })
@@ -136,3 +136,5 @@ export {
136
136
  } from './sse.ts'
137
137
  export type { Store } from './store.ts'
138
138
  export { InMemoryStore } from './store.ts'
139
+ export type { StoreFromEnvOptions } from './store-intent.ts'
140
+ export { appStore, sessionStore } from './store-intent.ts'
@@ -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
@@ -0,0 +1,175 @@
1
+ import type { AppStore } from './app-store.ts'
2
+ import { InMemoryAppStore } from './app-store.ts'
3
+ import { CachedStore, type CachedStoreOptions } from './cached-store.ts'
4
+ import { RedisAppStore, RedisStore } from './redis-store.ts'
5
+ import { InMemoryStore, type Store } from './store.ts'
6
+
7
+ /**
8
+ * Picking a store from the environment, without the silent downgrade.
9
+ *
10
+ * Every app that keeps state in Redis writes the same conditional: a URL from
11
+ * the environment, a durable store when it's there, in-memory when it isn't.
12
+ * That fallback is right for dev and for CI — a build machine has no business
13
+ * holding production credentials — and wrong in exactly one place, production,
14
+ * where it means the app runs, looks healthy, and quietly loses every session
15
+ * on restart. Written in userland the framework cannot tell that outcome from a
16
+ * deliberate choice, so it cannot say anything about it.
17
+ *
18
+ * Moving the conditional in here changes only that: the returned store carries
19
+ * whether in-memory was *chosen* or *fallen back to*, so startup can be silent
20
+ * in development and loud in production, naming the empty variable instead of
21
+ * reporting a generic posture.
22
+ *
23
+ * The environment stays in charge. A URL present means durable, wherever it
24
+ * comes from — so pointing CI at a test Redis is nothing special, it is just
25
+ * the variable being set. An empty or whitespace-only value counts as absent,
26
+ * which is what a CI system that defines every variable as `""` produces.
27
+ */
28
+
29
+ /** Recorded on a fallback store so startup can tell it from a deliberate one.
30
+ * Non-enumerable and internal: the value still satisfies `Store` exactly. */
31
+ const INTENT = Symbol.for('stator.store.intent')
32
+
33
+ interface StoreIntent {
34
+ /** What the app asked for. */
35
+ wanted: 'durable'
36
+ /** The config field that was empty, for the message. */
37
+ from: string
38
+ }
39
+
40
+ function mark<T extends object>(store: T, intent: StoreIntent): T {
41
+ Object.defineProperty(store, INTENT, { value: intent, enumerable: false })
42
+ return store
43
+ }
44
+
45
+ /** The recorded intent of a store, if it was built by these helpers. */
46
+ export function storeIntent(store: unknown): StoreIntent | undefined {
47
+ if (store === null || typeof store !== 'object') return undefined
48
+ return (store as Record<symbol, StoreIntent | undefined>)[INTENT]
49
+ }
50
+
51
+ /** True for the framework's own in-memory adapters — the ones that do not
52
+ * survive a restart. A store the app supplied is assumed durable; the
53
+ * framework has no way to ask, and no business guessing. */
54
+ export function isEphemeral(store: unknown): boolean {
55
+ return store instanceof InMemoryStore || store instanceof InMemoryAppStore
56
+ }
57
+
58
+ const blank = (url: string | undefined): boolean => url === undefined || url.trim() === ''
59
+
60
+ export interface StoreFromEnvOptions {
61
+ /** Redis connection string, typically `process.env.REDIS_URL`. Absent, empty
62
+ * or whitespace-only selects in-memory — normal in dev and CI, reported at
63
+ * startup in production. */
64
+ redisUrl?: string
65
+ /** Wrap Redis in a write-through memory cache. Off by default: caching is a
66
+ * performance decision, and this helper's job is the durable-or-not one. */
67
+ cache?: CachedStoreOptions | true
68
+ /** Key prefix, passed through to the Redis adapter. */
69
+ keyPrefix?: string
70
+ /** Name used in the startup report when the URL is empty. Default `REDIS_URL`. */
71
+ envName?: string
72
+ }
73
+
74
+ /**
75
+ * Session-machine storage from the environment: Redis when a URL is set,
76
+ * in-memory otherwise.
77
+ *
78
+ * persistence: { session: sessionStore({ redisUrl: process.env.REDIS_URL }) }
79
+ *
80
+ * Call it with nothing (or omit `persistence.session` entirely) to choose
81
+ * in-memory deliberately — that is a real choice and is never reported.
82
+ */
83
+ export function sessionStore(opts: StoreFromEnvOptions = {}): Store {
84
+ if (blank(opts.redisUrl)) {
85
+ // In-memory either way, but for two different reasons. Passing the key at
86
+ // all — `redisUrl: process.env.REDIS_URL` — says the app wants durability
87
+ // from the environment, so an empty value is worth reporting in production.
88
+ // Passing no key says in-memory was the choice, which is never reported.
89
+ return 'redisUrl' in opts
90
+ ? mark(new InMemoryStore(), { wanted: 'durable', from: opts.envName ?? 'REDIS_URL' })
91
+ : new InMemoryStore()
92
+ }
93
+ const redis = opts.keyPrefix
94
+ ? new RedisStore(opts.redisUrl as string, opts.keyPrefix)
95
+ : new RedisStore(opts.redisUrl as string)
96
+ if (!opts.cache) return redis
97
+ return new CachedStore(redis, opts.cache === true ? {} : opts.cache)
98
+ }
99
+
100
+ /**
101
+ * App-machine storage (`persist: true`) from the environment. Same rule as
102
+ * `sessionStore`.
103
+ */
104
+ export function appStore(opts: StoreFromEnvOptions = {}): AppStore {
105
+ if (blank(opts.redisUrl)) {
106
+ return 'redisUrl' in opts
107
+ ? mark(new InMemoryAppStore(), { wanted: 'durable', from: opts.envName ?? 'REDIS_URL' })
108
+ : new InMemoryAppStore()
109
+ }
110
+ return opts.keyPrefix
111
+ ? new RedisAppStore(opts.redisUrl as string, opts.keyPrefix)
112
+ : new RedisAppStore(opts.redisUrl as string)
113
+ }
114
+
115
+ /** One machine's persistence-relevant shape. */
116
+ export interface MachinePersistence {
117
+ lifecycle: string
118
+ persist: boolean
119
+ }
120
+
121
+ /**
122
+ * What to say about persistence at startup. The posture goes in the notice —
123
+ * printed at every level, so a deploy log always states whether state survives
124
+ * a restart — and anything worth alerting on comes back as a warning.
125
+ *
126
+ * Warnings fire in production only, and only when something is actually at
127
+ * risk: an app with no session machines has no session state to lose, and an
128
+ * app that deliberately chose in-memory made a real choice. Nothing here ever
129
+ * refuses to start; persistent storage is assumed to be wanted, never required.
130
+ */
131
+ export function persistencePosture(input: {
132
+ session: unknown
133
+ app: unknown
134
+ machines: readonly MachinePersistence[]
135
+ production: boolean
136
+ }): { label: string; warnings: string[] } {
137
+ const name = (store: unknown): string =>
138
+ isEphemeral(store)
139
+ ? 'in-memory'
140
+ : ((store as { constructor?: { name?: string } })?.constructor?.name ?? 'custom')
141
+
142
+ const sessionMachines = input.machines.filter((m) => m.lifecycle === 'session').length
143
+ const persistedApp = input.machines.filter((m) => m.lifecycle === 'app' && m.persist).length
144
+
145
+ let label = `sessions ${name(input.session)}`
146
+ if (persistedApp > 0) label += ` · app ${name(input.app)}`
147
+
148
+ const warnings: string[] = []
149
+ if (input.production) {
150
+ const sessionIntent = storeIntent(input.session)
151
+ if (isEphemeral(input.session) && sessionIntent) {
152
+ // The app configured a durable store and the variable was empty — the
153
+ // one case the framework can name precisely.
154
+ warnings.push(
155
+ `${sessionIntent.from} is empty, so session state is in memory and will not survive a restart`,
156
+ )
157
+ } else if (isEphemeral(input.session) && sessionMachines > 0) {
158
+ warnings.push(
159
+ `no durable session store is configured, so the state of ${sessionMachines} session machine${
160
+ sessionMachines === 1 ? '' : 's'
161
+ } will not survive a restart`,
162
+ )
163
+ }
164
+
165
+ const appIntent = storeIntent(input.app)
166
+ if (isEphemeral(input.app) && persistedApp > 0) {
167
+ warnings.push(
168
+ appIntent
169
+ ? `${appIntent.from} is empty, so ${persistedApp} persisted app machine${persistedApp === 1 ? '' : 's'} will not survive a restart`
170
+ : `no durable app store is configured, so ${persistedApp} persisted app machine${persistedApp === 1 ? '' : 's'} will not survive a restart`,
171
+ )
172
+ }
173
+ }
174
+ return { label, warnings }
175
+ }