@statorjs/stator 2.10.0-next.0 → 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/copy-set.ts +60 -19
- package/src/server/http.ts +19 -7
- package/src/server/sse.ts +195 -38
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statorjs/stator",
|
|
3
|
-
"version": "2.10.0-next.
|
|
3
|
+
"version": "2.10.0-next.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.10.0-next.
|
|
98
|
+
"@statorjs/stator": "2.10.0-next.1"
|
|
99
99
|
},
|
|
100
100
|
"scripts": {
|
|
101
101
|
"typecheck": "tsc --noEmit",
|
package/src/build/copy-set.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { readdir, readFile, stat } from 'node:fs/promises'
|
|
|
3
3
|
import { builtinModules } from 'node:module'
|
|
4
4
|
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
|
5
5
|
import { build } from 'esbuild'
|
|
6
|
+
import ts from 'typescript'
|
|
6
7
|
import { compile, regionResolverFor } from '../compiler/index.ts'
|
|
7
8
|
|
|
8
9
|
/**
|
|
@@ -55,18 +56,10 @@ const NEVER_SOURCE = new Set(['node_modules', 'dist', 'tests', 'test', '__tests_
|
|
|
55
56
|
|
|
56
57
|
const CODE = /\.(ts|tsx|mts|cts|js|mjs|cjs|jsx|stator)$/
|
|
57
58
|
const BUILTIN = new Set([...builtinModules, ...builtinModules.map((m) => `node:${m}`)])
|
|
58
|
-
/** `new URL('./x', import.meta.url)` — invisible to the bundler, so read the
|
|
59
|
-
* literal directly. Same limit as the client seam: relative string literals.
|
|
60
|
-
* (Injecting an import instead does NOT work: esbuild's TS loader elides an
|
|
61
|
-
* import whose binding is unused, so the injected one disappears.) */
|
|
62
|
-
const URL_ASSET_RE = /new\s+URL\(\s*(['"])(\.{1,2}\/[^'"\n]+)\1\s*,\s*import\.meta\.url\s*\)/g
|
|
63
59
|
/** A `node_modules` path segment, either separator. Anything under one is a
|
|
64
60
|
* dependency, never app source — and a real app's `node_modules` sits INSIDE
|
|
65
61
|
* its root, so root containment alone cannot tell them apart. */
|
|
66
62
|
const NODE_MODULES = /[\\/]node_modules[\\/]/
|
|
67
|
-
/** `import(` not followed by a string or template literal. */
|
|
68
|
-
const OPAQUE_IMPORT_RE = /(?<![.\w$])import\s*\(\s*([^'"`)\s])/g
|
|
69
|
-
|
|
70
63
|
const norm = (p: string): string => p.replace(/\\/g, '/')
|
|
71
64
|
|
|
72
65
|
export interface UntracedImport {
|
|
@@ -186,19 +179,67 @@ export async function resolveCopySet(opts: CopySetOptions): Promise<CopySet> {
|
|
|
186
179
|
const unresolved = new Set<string>()
|
|
187
180
|
const untraced: UntracedImport[] = []
|
|
188
181
|
|
|
189
|
-
/**
|
|
182
|
+
/**
|
|
183
|
+
* The two dependencies a module carries that the bundler's own graph misses,
|
|
184
|
+
* read from the SYNTAX rather than the text.
|
|
185
|
+
*
|
|
186
|
+
* Both used to be regex scans over raw source, which cannot tell code from
|
|
187
|
+
* prose: a comment explaining why the app avoids `import(name)`, or a string
|
|
188
|
+
* documenting the pattern, was picked up as a real untraceable import and
|
|
189
|
+
* failed the build. Found by dogfooding, and the reason this parses. Comments
|
|
190
|
+
* and string contents are simply not part of the tree.
|
|
191
|
+
*/
|
|
190
192
|
const scan = (text: string, file: string): string => {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
193
|
+
const sourceFile = ts.createSourceFile(
|
|
194
|
+
file,
|
|
195
|
+
text,
|
|
196
|
+
ts.ScriptTarget.Latest,
|
|
197
|
+
false,
|
|
198
|
+
/\.(tsx|jsx)$/.test(file) ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
/** `import.meta.url`. */
|
|
202
|
+
const isImportMetaUrl = (node: ts.Node): boolean =>
|
|
203
|
+
ts.isPropertyAccessExpression(node) &&
|
|
204
|
+
node.name.text === 'url' &&
|
|
205
|
+
ts.isMetaProperty(node.expression) &&
|
|
206
|
+
node.expression.keywordToken === ts.SyntaxKind.ImportKeyword
|
|
207
|
+
|
|
208
|
+
/** A specifier the bundler can follow: a string literal, or a template —
|
|
209
|
+
* esbuild glob-expands one with a fixed prefix, pulling in every match. */
|
|
210
|
+
const analysable = (node: ts.Node): boolean =>
|
|
211
|
+
ts.isStringLiteralLike(node) || ts.isTemplateExpression(node)
|
|
212
|
+
|
|
213
|
+
const visit = (node: ts.Node): void => {
|
|
214
|
+
// `new URL('./x', import.meta.url)` — an asset reference no import graph
|
|
215
|
+
// sees. String-literal specifiers only, the same limit as the client seam.
|
|
216
|
+
if (ts.isNewExpression(node) && ts.isIdentifier(node.expression)) {
|
|
217
|
+
if (node.expression.text === 'URL') {
|
|
218
|
+
const [spec, meta] = node.arguments ?? []
|
|
219
|
+
if (spec && ts.isStringLiteralLike(spec) && spec.text.startsWith('.')) {
|
|
220
|
+
if (meta && isImportMetaUrl(meta)) assets.add(resolve(dirname(file), spec.text))
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
// `import(expr)` the bundler cannot follow. It says nothing about these,
|
|
225
|
+
// so this is the only place they are caught.
|
|
226
|
+
if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) {
|
|
227
|
+
const arg = node.arguments[0]
|
|
228
|
+
if (!arg || !analysable(arg)) {
|
|
229
|
+
const start = node.getStart(sourceFile)
|
|
230
|
+
untraced.push({
|
|
231
|
+
file: norm(relative(realRoot, file)),
|
|
232
|
+
line: sourceFile.getLineAndCharacterOfPosition(start).line + 1,
|
|
233
|
+
source: `${text
|
|
234
|
+
.slice(start, start + 60)
|
|
235
|
+
.split('\n')[0]!
|
|
236
|
+
.trim()}…`,
|
|
237
|
+
})
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
ts.forEachChild(node, visit)
|
|
201
241
|
}
|
|
242
|
+
ts.forEachChild(sourceFile, visit)
|
|
202
243
|
return text
|
|
203
244
|
}
|
|
204
245
|
|
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/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
|
}
|