@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.
- package/package.json +2 -2
- package/src/build/artifact.ts +182 -0
- package/src/build/build.ts +87 -31
- package/src/build/copy-set.ts +349 -0
- package/src/build/head.ts +18 -1
- package/src/build/index.ts +4 -0
- package/src/cli/commands/build.ts +71 -1
- package/src/cli/commands/start.ts +74 -8
- package/src/config.ts +14 -0
- package/src/server/banner.ts +27 -13
- package/src/server/create-app.ts +21 -6
- package/src/server/dev-native.ts +5 -4
- package/src/server/dev-vite.ts +5 -4
- package/src/server/index.ts +2 -0
- package/src/server/shutdown.ts +127 -0
- package/src/server/sse.ts +15 -0
- package/src/server/store-intent.ts +175 -0
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
|
|
@@ -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: {
|
|
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'} · ${
|
|
82
|
-
info.routes
|
|
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
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
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(
|
|
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 = (
|
|
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
|
|
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)
|
package/src/server/create-app.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
187
|
-
|
|
188
|
-
|
|
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
|
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
|
})
|
package/src/server/index.ts
CHANGED
|
@@ -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
|
+
}
|