@statorjs/stator 2.9.1 → 2.10.0-next.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/build/artifact.ts +182 -0
- package/src/build/build.ts +87 -31
- package/src/build/copy-set.ts +390 -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 +11 -4
- package/src/server/create-app.ts +18 -1
- package/src/server/http.ts +19 -7
- package/src/server/index.ts +2 -0
- package/src/server/sse.ts +195 -38
- package/src/server/store-intent.ts +175 -0
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.
|
package/src/server/http.ts
CHANGED
|
@@ -22,7 +22,7 @@ import { isStatorQueryRoute, type RouteDefinition } from './routing.ts'
|
|
|
22
22
|
import { CLAIMS_KEY, getOrCreateSessionId, getSessionState } from './session.ts'
|
|
23
23
|
import { withSessionLock } from './session-lock.ts'
|
|
24
24
|
import { SessionRuntime } from './session-runtime.ts'
|
|
25
|
-
import { fanOut, registerConnection, unregisterConnection } from './sse.ts'
|
|
25
|
+
import { enqueue, fanOut, registerConnection, unregisterConnection } from './sse.ts'
|
|
26
26
|
|
|
27
27
|
const httpLog = scopedLogger('http')
|
|
28
28
|
|
|
@@ -423,7 +423,7 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
|
|
|
423
423
|
// otherwise never reach this page.
|
|
424
424
|
const sync = initialSyncPatches(renderState, runtime)
|
|
425
425
|
if (sync.length > 0) {
|
|
426
|
-
|
|
426
|
+
enqueue(conn, JSON.stringify({ patches: sync }))
|
|
427
427
|
}
|
|
428
428
|
|
|
429
429
|
// Heartbeat every 25s. This is a real DATA message, not a comment, on
|
|
@@ -433,9 +433,11 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
|
|
|
433
433
|
// ever arrives, no error fires) unless liveness is OBSERVABLE. The
|
|
434
434
|
// runtime tracks last-message time and reconnects when pings stop.
|
|
435
435
|
const keepAlive = setInterval(() => {
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
436
|
+
// Through the same queue fan-out uses, so it is bounded like any other
|
|
437
|
+
// write: a half-open socket never settles its writes, and the heartbeat
|
|
438
|
+
// is what reaps it when the app is otherwise idle and no dispatch would
|
|
439
|
+
// ever notice.
|
|
440
|
+
enqueue(conn, '{"ping":true}')
|
|
439
441
|
}, config.ssePingMs ?? 25_000)
|
|
440
442
|
|
|
441
443
|
try {
|
|
@@ -547,7 +549,9 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
|
|
|
547
549
|
// `touched`) — so it isn't re-created and re-fired next request.
|
|
548
550
|
await runtime.persistTouched(new Set([...touched, ...runtime.entryFiredMachines()]))
|
|
549
551
|
|
|
550
|
-
|
|
552
|
+
// Queues patches on every affected channel and returns; no socket is
|
|
553
|
+
// awaited under the lock.
|
|
554
|
+
const { originatorQueued } = await fanOut(touched, {
|
|
551
555
|
sessionId,
|
|
552
556
|
originClientId: c.req.header('X-Stator-Client'),
|
|
553
557
|
})
|
|
@@ -557,7 +561,15 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
|
|
|
557
561
|
// normal event path in server/effects.ts.
|
|
558
562
|
scheduleSessionEffects(runtime, config.store, sessionId)
|
|
559
563
|
|
|
560
|
-
|
|
564
|
+
// This page's patches normally ride the response. When its live
|
|
565
|
+
// channel already had a backlog, fan-out queued them behind it instead
|
|
566
|
+
// (two channels to one page must not reorder a positional list op), so
|
|
567
|
+
// the response carries none.
|
|
568
|
+
const envelope = {
|
|
569
|
+
patches: originatorQueued ? [] : patches,
|
|
570
|
+
directives: [],
|
|
571
|
+
committed: touched.size > 0,
|
|
572
|
+
}
|
|
561
573
|
if (body.eventId) record(sessionId, body.eventId, JSON.stringify(envelope))
|
|
562
574
|
return c.json(envelope)
|
|
563
575
|
} finally {
|
package/src/server/index.ts
CHANGED
package/src/server/sse.ts
CHANGED
|
@@ -17,13 +17,21 @@ const sseLog = scopedLogger('sse')
|
|
|
17
17
|
* recompute will diff against when fan-out fires. Each push updates
|
|
18
18
|
* `lastValue` for the bindings touched by the push, so subsequent pushes
|
|
19
19
|
* only emit deltas.
|
|
20
|
+
*
|
|
21
|
+
* Writes never happen inline. Everything outbound — fan-out patches, the
|
|
22
|
+
* initial sync, the heartbeat, the dev broadcast — goes through `enqueue`,
|
|
23
|
+
* and one drain loop per connection owns the socket. A request or a lock is
|
|
24
|
+
* therefore never held across a socket write, and per-connection order is a
|
|
25
|
+
* property of the queue rather than of who awaited what.
|
|
20
26
|
*/
|
|
21
27
|
export interface Connection {
|
|
22
28
|
id: string
|
|
23
29
|
sessionId: string
|
|
24
30
|
/** The browser page-load identity (client-generated). Lets fan-out
|
|
25
|
-
* recognize a dispatch's OWN connection: its baseline is advanced
|
|
26
|
-
* nothing is sent — the POST response
|
|
31
|
+
* recognize a dispatch's OWN connection: its baseline is advanced and,
|
|
32
|
+
* when nothing is queued for it, nothing is sent — the POST response
|
|
33
|
+
* delivers those patches. With a backlog they ride the queue instead, so
|
|
34
|
+
* the two channels cannot reorder a positional list op. */
|
|
27
35
|
clientId?: string
|
|
28
36
|
routeKey: string
|
|
29
37
|
route: RouteDefinition
|
|
@@ -39,14 +47,29 @@ export interface Connection {
|
|
|
39
47
|
* transport; absent in tests that register a bare connection. */
|
|
40
48
|
close?: () => void
|
|
41
49
|
closed: boolean
|
|
50
|
+
/** Outbound writes waiting for the drain loop, oldest first. */
|
|
51
|
+
queue: string[]
|
|
52
|
+
/** Bytes waiting in `queue`. The item being written is not counted. */
|
|
53
|
+
queuedBytes: number
|
|
54
|
+
/** True while the drain loop owns the socket — an item is in flight. */
|
|
55
|
+
draining: boolean
|
|
42
56
|
}
|
|
43
57
|
|
|
58
|
+
type ConnectionInit = Omit<Connection, 'id' | 'closed' | 'queue' | 'queuedBytes' | 'draining'>
|
|
59
|
+
|
|
44
60
|
const connections = new Map<string, Connection>()
|
|
45
61
|
let nextId = 0
|
|
46
62
|
|
|
47
|
-
export function registerConnection(init:
|
|
63
|
+
export function registerConnection(init: ConnectionInit): Connection {
|
|
48
64
|
const id = `sse${nextId++}`
|
|
49
|
-
const conn: Connection = {
|
|
65
|
+
const conn: Connection = {
|
|
66
|
+
...init,
|
|
67
|
+
id,
|
|
68
|
+
closed: false,
|
|
69
|
+
queue: [],
|
|
70
|
+
queuedBytes: 0,
|
|
71
|
+
draining: false,
|
|
72
|
+
}
|
|
50
73
|
// A page-load holds at most one channel per route, so an existing
|
|
51
74
|
// connection with the same (clientId, routeKey) is a corpse whose abort we
|
|
52
75
|
// never observed — a half-open socket, or a reconnect that raced its own
|
|
@@ -73,21 +96,16 @@ export function registerConnection(init: Omit<Connection, 'id' | 'closed'>): Con
|
|
|
73
96
|
}
|
|
74
97
|
|
|
75
98
|
/**
|
|
76
|
-
*
|
|
99
|
+
* Queue a pre-serialized envelope for every open connection, bypassing the
|
|
77
100
|
* recompute path. The dev server's rebuild/error signals ride this so a dev
|
|
78
101
|
* page holds ONE event-stream instead of two — see `dev-native.ts`. Not a
|
|
79
102
|
* fan-out: no diffing, no baseline advance, no per-connection filtering.
|
|
80
103
|
*/
|
|
81
|
-
export
|
|
104
|
+
export function broadcastEnvelope(payload: unknown): void {
|
|
82
105
|
if (connections.size === 0) return
|
|
83
106
|
const data = JSON.stringify(payload)
|
|
84
107
|
for (const conn of connections.values()) {
|
|
85
|
-
if (conn.closed)
|
|
86
|
-
try {
|
|
87
|
-
await conn.send(data)
|
|
88
|
-
} catch (err) {
|
|
89
|
-
sseLog.warn({ id: conn.id, err: String(err) }, 'broadcast failed')
|
|
90
|
-
}
|
|
108
|
+
if (!conn.closed) enqueue(conn, data)
|
|
91
109
|
}
|
|
92
110
|
}
|
|
93
111
|
|
|
@@ -95,6 +113,10 @@ export function unregisterConnection(id: string): void {
|
|
|
95
113
|
const conn = connections.get(id)
|
|
96
114
|
if (!conn) return
|
|
97
115
|
conn.closed = true
|
|
116
|
+
// Whatever was waiting is discarded with the socket: the client reconnects
|
|
117
|
+
// and resyncs. The drain loop, if mid-write, sees `closed` and exits.
|
|
118
|
+
conn.queue.length = 0
|
|
119
|
+
conn.queuedBytes = 0
|
|
98
120
|
conn.runtime.dispose()
|
|
99
121
|
connections.delete(id)
|
|
100
122
|
sseLog.debug(
|
|
@@ -107,6 +129,133 @@ export function activeConnectionCount(): number {
|
|
|
107
129
|
return connections.size
|
|
108
130
|
}
|
|
109
131
|
|
|
132
|
+
/** How long one write to one connection may take before the connection is
|
|
133
|
+
* treated as dead. `STATOR_SSE_WRITE_TIMEOUT_MS` overrides it. */
|
|
134
|
+
const DEFAULT_WRITE_TIMEOUT_MS = 5_000
|
|
135
|
+
|
|
136
|
+
function writeTimeoutMs(): number {
|
|
137
|
+
const raw = process.env.STATOR_SSE_WRITE_TIMEOUT_MS
|
|
138
|
+
if (raw === undefined || raw === '') return DEFAULT_WRITE_TIMEOUT_MS
|
|
139
|
+
const ms = Number(raw)
|
|
140
|
+
return Number.isFinite(ms) && ms > 0 ? ms : DEFAULT_WRITE_TIMEOUT_MS
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Bytes a connection may hold WAITING behind an in-flight write before it is
|
|
144
|
+
* treated as dead. The item being written is covered by the write deadline,
|
|
145
|
+
* not by this, and a single waiting item never trips it however large — an
|
|
146
|
+
* initial sync of a big table is one item. `STATOR_SSE_QUEUE_MAX_BYTES`
|
|
147
|
+
* overrides it. */
|
|
148
|
+
const DEFAULT_QUEUE_MAX_BYTES = 1_048_576
|
|
149
|
+
|
|
150
|
+
function queueMaxBytes(): number {
|
|
151
|
+
const raw = process.env.STATOR_SSE_QUEUE_MAX_BYTES
|
|
152
|
+
if (raw === undefined || raw === '') return DEFAULT_QUEUE_MAX_BYTES
|
|
153
|
+
const bytes = Number(raw)
|
|
154
|
+
return Number.isFinite(bytes) && bytes > 0 ? bytes : DEFAULT_QUEUE_MAX_BYTES
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Whether anything is queued or in flight for this connection. */
|
|
158
|
+
export function hasBacklog(conn: Connection): boolean {
|
|
159
|
+
return conn.draining || conn.queue.length > 0
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Queue one envelope for a connection and return at once. The drain loop
|
|
164
|
+
* writes it when everything ahead of it has been written, so order per
|
|
165
|
+
* connection is FIFO by construction. Overflow drops the connection, the
|
|
166
|
+
* same policy as a write that times out: the client reconnects and resyncs.
|
|
167
|
+
*/
|
|
168
|
+
export function enqueue(conn: Connection, data: string): void {
|
|
169
|
+
if (conn.closed) return
|
|
170
|
+
conn.queue.push(data)
|
|
171
|
+
conn.queuedBytes += Buffer.byteLength(data)
|
|
172
|
+
if (conn.queue.length > 1 && conn.queuedBytes > queueMaxBytes()) {
|
|
173
|
+
sseLog.warn(
|
|
174
|
+
{
|
|
175
|
+
id: conn.id,
|
|
176
|
+
sid: conn.sessionId,
|
|
177
|
+
route: conn.routeKey,
|
|
178
|
+
waiting: conn.queue.length,
|
|
179
|
+
bytes: conn.queuedBytes,
|
|
180
|
+
max: queueMaxBytes(),
|
|
181
|
+
},
|
|
182
|
+
'sse queue overflowed — dropping the connection',
|
|
183
|
+
)
|
|
184
|
+
unregisterConnection(conn.id)
|
|
185
|
+
conn.close?.()
|
|
186
|
+
return
|
|
187
|
+
}
|
|
188
|
+
if (!conn.draining) void drain(conn)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The one writer per connection. Runs until the queue is empty or the
|
|
192
|
+
* connection closes under it (a timed-out write, an overflow, an abort). */
|
|
193
|
+
async function drain(conn: Connection): Promise<void> {
|
|
194
|
+
conn.draining = true
|
|
195
|
+
try {
|
|
196
|
+
while (!conn.closed && conn.queue.length > 0) {
|
|
197
|
+
const data = conn.queue.shift()!
|
|
198
|
+
conn.queuedBytes -= Buffer.byteLength(data)
|
|
199
|
+
await pushToConnection(conn, data)
|
|
200
|
+
}
|
|
201
|
+
} finally {
|
|
202
|
+
conn.draining = false
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Write one envelope to one connection, bounded. The drain loop's single
|
|
208
|
+
* write; nothing else touches the socket.
|
|
209
|
+
*
|
|
210
|
+
* A write to a live socket resolves; a write to a closed one rejects. A write
|
|
211
|
+
* to a HALF-OPEN one does neither once its buffer is full — when a remote
|
|
212
|
+
* laptop sleeps or a NAT drops the mapping, no FIN ever arrives, the socket
|
|
213
|
+
* still looks writable, and the promise simply never settles. Fan-out used to
|
|
214
|
+
* await these serially with no deadline, so one such connection stalled the
|
|
215
|
+
* loop and every connection registered after it received nothing until the
|
|
216
|
+
* kernel gave up on the socket (minutes) or the same page reconnected and
|
|
217
|
+
* evicted it. Under the session lock the same stall ran into the lock's 30s
|
|
218
|
+
* backstop, failing the dispatching request each time.
|
|
219
|
+
*
|
|
220
|
+
* On a timeout the connection is dropped rather than retried: the client
|
|
221
|
+
* reconnects and gets a full resync, which is cheap by design. Note what the
|
|
222
|
+
* deadline measures: a write to a socket with buffer space resolves at once
|
|
223
|
+
* regardless of the peer, so this fires only when the buffer has stayed full
|
|
224
|
+
* for the whole window — not on a merely slow client. And because the clock
|
|
225
|
+
* starts when the drain loop issues the write, not when the item was queued,
|
|
226
|
+
* a burst behind a slow but healthy socket cannot trip it.
|
|
227
|
+
*/
|
|
228
|
+
export async function pushToConnection(conn: Connection, data: string): Promise<Outcome> {
|
|
229
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
230
|
+
try {
|
|
231
|
+
const outcome = await Promise.race<Outcome>([
|
|
232
|
+
conn.send(data).then((): Outcome => 'sent'),
|
|
233
|
+
new Promise<Outcome>((settle) => {
|
|
234
|
+
timer = setTimeout(() => settle('timeout'), writeTimeoutMs())
|
|
235
|
+
timer.unref?.()
|
|
236
|
+
}),
|
|
237
|
+
])
|
|
238
|
+
if (outcome === 'timeout') {
|
|
239
|
+
sseLog.warn(
|
|
240
|
+
{ id: conn.id, sid: conn.sessionId, route: conn.routeKey, ms: writeTimeoutMs() },
|
|
241
|
+
'sse write timed out — dropping the connection',
|
|
242
|
+
)
|
|
243
|
+
unregisterConnection(conn.id)
|
|
244
|
+
conn.close?.()
|
|
245
|
+
}
|
|
246
|
+
return outcome
|
|
247
|
+
} catch (err) {
|
|
248
|
+
// Bumped from debug to warn — silent push failures were why "30-50%
|
|
249
|
+
// delivery" was invisible in production logs.
|
|
250
|
+
sseLog.warn({ id: conn.id, sid: conn.sessionId, err: String(err) }, 'sse push failed')
|
|
251
|
+
return 'failed'
|
|
252
|
+
} finally {
|
|
253
|
+
clearTimeout(timer)
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
type Outcome = 'sent' | 'timeout' | 'failed'
|
|
258
|
+
|
|
110
259
|
/**
|
|
111
260
|
* Hang up every open connection — the shutdown path. A live response never ends
|
|
112
261
|
* on its own, so `server.close()` cannot complete until these do; see
|
|
@@ -125,24 +274,31 @@ export function closeLiveConnections(): number {
|
|
|
125
274
|
/**
|
|
126
275
|
* After a state-changing dispatch settles, iterate every open connection
|
|
127
276
|
* whose route's `reads:` intersects `touched`, recompute against that
|
|
128
|
-
* connection's slot map, and
|
|
277
|
+
* connection's slot map, and queue any resulting patches on its event
|
|
129
278
|
* stream. Recompute mutates the connection's `renderState.lastValue`s so
|
|
130
|
-
* the next push is correctly diffed.
|
|
279
|
+
* the next push is correctly diffed. Returns as soon as everything is
|
|
280
|
+
* queued: no socket is awaited here, so a dispatch never waits on another
|
|
281
|
+
* page's network.
|
|
131
282
|
*
|
|
132
283
|
* Connections from any session receive pushes — that's the point: an
|
|
133
284
|
* admin tab on session C sees updates triggered by session A's POSTs.
|
|
285
|
+
*
|
|
286
|
+
* `originatorQueued` tells the dispatch path whether the acting page's own
|
|
287
|
+
* patches were queued on its channel (because it already had a backlog)
|
|
288
|
+
* rather than left to the POST response. When true, the response must carry
|
|
289
|
+
* none, or the same positional list op lands twice.
|
|
134
290
|
*/
|
|
135
291
|
export async function fanOut(
|
|
136
292
|
touched: ReadonlySet<string>,
|
|
137
293
|
source?: { sessionId?: string; originClientId?: string },
|
|
138
|
-
): Promise<
|
|
139
|
-
if (touched.size === 0 || connections.size === 0) return
|
|
294
|
+
): Promise<{ originatorQueued: boolean }> {
|
|
295
|
+
if (touched.size === 0 || connections.size === 0) return { originatorQueued: false }
|
|
140
296
|
|
|
141
|
-
let
|
|
297
|
+
let enqueued = 0
|
|
142
298
|
let skippedNoIntersect = 0
|
|
143
299
|
let skippedNoPatches = 0
|
|
144
300
|
let skippedOriginator = 0
|
|
145
|
-
let
|
|
301
|
+
let originatorQueued = false
|
|
146
302
|
|
|
147
303
|
// Expand through the reverse-reads graph once per fan-out: machines whose
|
|
148
304
|
// SELECTORS derive from a touched machine must re-diff even though their
|
|
@@ -188,42 +344,43 @@ export async function fanOut(
|
|
|
188
344
|
continue
|
|
189
345
|
}
|
|
190
346
|
|
|
191
|
-
// The dispatching page's own connection
|
|
192
|
-
//
|
|
193
|
-
// NEXT push diffs correctly)
|
|
194
|
-
// insert/remove/move ops are not idempotent.
|
|
195
|
-
|
|
347
|
+
// The dispatching page's own connection. With nothing queued for it, the
|
|
348
|
+
// POST response delivers this diff: the recompute above advanced the
|
|
349
|
+
// baseline (so the NEXT push diffs correctly) and sending would
|
|
350
|
+
// double-apply — keyed insert/remove/move ops are not idempotent. With a
|
|
351
|
+
// backlog, the response would overtake what is still queued, and a
|
|
352
|
+
// positional op applied out of order corrupts a list rather than merely
|
|
353
|
+
// staling it — so the diff rides the queue and the response carries none.
|
|
354
|
+
const isOriginator =
|
|
196
355
|
source?.originClientId !== undefined &&
|
|
197
356
|
conn.clientId !== undefined &&
|
|
198
357
|
conn.clientId === source.originClientId
|
|
199
|
-
) {
|
|
200
|
-
|
|
201
|
-
|
|
358
|
+
if (isOriginator) {
|
|
359
|
+
if (!hasBacklog(conn)) {
|
|
360
|
+
skippedOriginator++
|
|
361
|
+
continue
|
|
362
|
+
}
|
|
363
|
+
originatorQueued = true
|
|
202
364
|
}
|
|
203
365
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
pushedCount++
|
|
207
|
-
} catch (err) {
|
|
208
|
-
// Bumped from debug to warn — silent push failures were why "30-50%
|
|
209
|
-
// delivery" was invisible in production logs.
|
|
210
|
-
failed++
|
|
211
|
-
sseLog.warn({ id: conn.id, sid: conn.sessionId, err: String(err) }, 'sse push failed')
|
|
212
|
-
}
|
|
366
|
+
enqueue(conn, JSON.stringify({ patches }))
|
|
367
|
+
enqueued++
|
|
213
368
|
}
|
|
214
369
|
|
|
215
370
|
// Log every fan-out at debug so the user can see touched-machines flow and
|
|
216
|
-
// correlate against client-side inspector entries — noisy for prod, so
|
|
371
|
+
// correlate against client-side inspector entries — noisy for prod, so
|
|
372
|
+
// debug. Write outcomes are logged by the drain loop as they happen.
|
|
217
373
|
sseLog.debug(
|
|
218
374
|
{
|
|
219
375
|
touched: [...touched],
|
|
220
376
|
total: connections.size,
|
|
221
|
-
|
|
377
|
+
enqueued,
|
|
222
378
|
skippedNoIntersect,
|
|
223
379
|
skippedNoPatches,
|
|
224
380
|
skippedOriginator,
|
|
225
|
-
|
|
381
|
+
originatorQueued,
|
|
226
382
|
},
|
|
227
383
|
'fan-out',
|
|
228
384
|
)
|
|
385
|
+
return { originatorQueued }
|
|
229
386
|
}
|
|
@@ -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
|
+
}
|