@statorjs/stator 2.10.0-next.0 → 2.10.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 +6 -2
- package/src/build/copy-set.ts +60 -19
- package/src/cli/commands/dev.ts +2 -0
- package/src/cli/commands/start.ts +2 -0
- package/src/compiler/lower.ts +41 -0
- package/src/compiler/virtual-code.ts +1 -1
- package/src/config.ts +16 -0
- package/src/index.ts +7 -0
- package/src/server/api-route.ts +5 -2
- package/src/server/config-compat.ts +4 -0
- package/src/server/context.ts +13 -4
- package/src/server/create-app.ts +13 -0
- package/src/server/http.ts +284 -97
- package/src/server/query-route.ts +68 -3
- package/src/server/route-discovery.ts +36 -5
- package/src/server/routing.ts +8 -0
- package/src/server/session.ts +58 -11
- package/src/server/sse.ts +195 -38
- package/src/template/html-attributes.ts +10 -0
|
@@ -35,6 +35,14 @@ export interface DiscoveredRoute {
|
|
|
35
35
|
PUT?: ApiRouteDefinition
|
|
36
36
|
PATCH?: ApiRouteDefinition
|
|
37
37
|
DELETE?: ApiRouteDefinition
|
|
38
|
+
/** True for a `404.stator`/`404.ts` file — sugar for a `[...name]`
|
|
39
|
+
* catch-all (same synthesized pattern, same matcher), except
|
|
40
|
+
* `sortRoutes` forces it to the absolute lowest priority: below even a
|
|
41
|
+
* real `[...name]` at the same depth. It's the last resort, only ever
|
|
42
|
+
* reached once nothing else — including a genuine catch-all a request
|
|
43
|
+
* matched first — could actually serve the request (a 404 cascades by
|
|
44
|
+
* default; see the GET dispatch loop in http.ts). */
|
|
45
|
+
isNotFoundFallback?: boolean
|
|
38
46
|
}
|
|
39
47
|
|
|
40
48
|
/**
|
|
@@ -47,6 +55,11 @@ export interface DiscoveredRoute {
|
|
|
47
55
|
* - `routes/[a]/[b].ts` → `/:a/:b`
|
|
48
56
|
* - `routes/foo/[...rest].ts` → `/foo/*rest` (catch-all: terminal, matches
|
|
49
57
|
* zero+ segments, param is the raw remainder incl. slashes)
|
|
58
|
+
* - `routes/foo/404.ts` → sugar for `routes/foo/[...___].ts` — a
|
|
59
|
+
* catch-all without needing to know the bracket-rest convention, EXCEPT
|
|
60
|
+
* `sortRoutes` also forces it to the absolute lowest match priority,
|
|
61
|
+
* below even a real `[...rest]` at the same depth — see
|
|
62
|
+
* `DiscoveredRoute.isNotFoundFallback`.
|
|
50
63
|
*
|
|
51
64
|
* Files may export any combination of `GET`/`POST`/`PUT`/`PATCH`/`DELETE`.
|
|
52
65
|
* GET is a `defineRoute` (page renderer) or a `defineApiRoute` declaring
|
|
@@ -123,11 +136,12 @@ export async function discoverRoutes(
|
|
|
123
136
|
continue
|
|
124
137
|
}
|
|
125
138
|
|
|
126
|
-
const { urlPath, paramNames } = filePathToRoute(absDir, filePath)
|
|
139
|
+
const { urlPath, paramNames, isNotFoundFallback } = filePathToRoute(absDir, filePath)
|
|
127
140
|
routes.push({
|
|
128
141
|
urlPath,
|
|
129
142
|
paramNames,
|
|
130
143
|
filePath,
|
|
144
|
+
...(isNotFoundFallback ? { isNotFoundFallback } : {}),
|
|
131
145
|
GET: get,
|
|
132
146
|
POST: post,
|
|
133
147
|
PUT: put,
|
|
@@ -169,6 +183,10 @@ function mergeByUrlPath(routes: DiscoveredRoute[]): DiscoveredRoute[] {
|
|
|
169
183
|
/**
|
|
170
184
|
* Sort routes most-specific-first (Astro's model). At match time the first
|
|
171
185
|
* matcher that matches wins, so order encodes priority:
|
|
186
|
+
* - a `404.stator`/`404.ts` file (`isNotFoundFallback`) ranks absolute
|
|
187
|
+
* last, below even a real `[...name]` at the same depth — it's the
|
|
188
|
+
* final resort, only reached once nothing else (a genuine catch-all
|
|
189
|
+
* included) could serve the request
|
|
172
190
|
* - routes without a rest/catch-all segment rank before those with one
|
|
173
191
|
* - per segment, left to right: static (0) > suffixed param `:id.json` (1)
|
|
174
192
|
* > bare param (2) > rest (3) — a suffixed param outranks a bare one so
|
|
@@ -186,6 +204,10 @@ export function sortRoutes(routes: DiscoveredRoute[]): DiscoveredRoute[] {
|
|
|
186
204
|
)
|
|
187
205
|
|
|
188
206
|
return [...routes].sort((a, b) => {
|
|
207
|
+
const aFallback = !!a.isNotFoundFallback
|
|
208
|
+
const bFallback = !!b.isNotFoundFallback
|
|
209
|
+
if (aFallback !== bFallback) return aFallback ? 1 : -1 // 404 fallback always last
|
|
210
|
+
|
|
189
211
|
const ka = kinds(a.urlPath)
|
|
190
212
|
const kb = kinds(b.urlPath)
|
|
191
213
|
const aRest = ka.includes(3)
|
|
@@ -230,7 +252,7 @@ async function walkRouteFiles(dir: string): Promise<string[]> {
|
|
|
230
252
|
export function filePathToRoute(
|
|
231
253
|
absDir: string,
|
|
232
254
|
filePath: string,
|
|
233
|
-
): { urlPath: string; paramNames: string[] } {
|
|
255
|
+
): { urlPath: string; paramNames: string[]; isNotFoundFallback: boolean } {
|
|
234
256
|
const ext = extname(filePath)
|
|
235
257
|
const rel = relative(absDir, filePath)
|
|
236
258
|
const dirSegments = dirname(rel)
|
|
@@ -240,9 +262,18 @@ export function filePathToRoute(
|
|
|
240
262
|
// sibling `<name>.stator.ts` files, which must map to the same URL as the
|
|
241
263
|
// `<name>.stator` source did in dev.
|
|
242
264
|
const fileBase = basename(rel, ext).replace(/\.stator$/, '')
|
|
243
|
-
|
|
265
|
+
// `404` is sugar for a synthesized `[...name]` at this exact position —
|
|
266
|
+
// reuses the rest-segment branch below verbatim, so it gets the same
|
|
267
|
+
// matcher/regex a real catch-all would. `sortRoutes` is what actually
|
|
268
|
+
// forces it below a real `[...name]` at the same depth; this alone would
|
|
269
|
+
// just be an ordinary (if oddly-named) catch-all.
|
|
270
|
+
const isNotFoundFallback = fileBase === '404'
|
|
271
|
+
const segments =
|
|
272
|
+
fileBase === 'index'
|
|
273
|
+
? dirSegments
|
|
274
|
+
: [...dirSegments, isNotFoundFallback ? '[...__stator_notfound__]' : fileBase]
|
|
244
275
|
|
|
245
|
-
if (segments.length === 0) return { urlPath: '/', paramNames: [] }
|
|
276
|
+
if (segments.length === 0) return { urlPath: '/', paramNames: [], isNotFoundFallback }
|
|
246
277
|
|
|
247
278
|
const paramNames: string[] = []
|
|
248
279
|
const urlSegments = segments.map((seg) => {
|
|
@@ -272,5 +303,5 @@ export function filePathToRoute(
|
|
|
272
303
|
return seg
|
|
273
304
|
})
|
|
274
305
|
|
|
275
|
-
return { urlPath: `/${urlSegments.join('/')}`, paramNames }
|
|
306
|
+
return { urlPath: `/${urlSegments.join('/')}`, paramNames, isNotFoundFallback }
|
|
276
307
|
}
|
package/src/server/routing.ts
CHANGED
|
@@ -198,6 +198,7 @@ export function defineApiRoute<TReads extends ReadonlyArray<AnyMachineDef>>(
|
|
|
198
198
|
__isStatorQueryRoute: true,
|
|
199
199
|
reads: config.reads ? [...config.reads] : [],
|
|
200
200
|
handler: config.handler,
|
|
201
|
+
revision: config.revision,
|
|
201
202
|
}
|
|
202
203
|
}
|
|
203
204
|
return {
|
|
@@ -248,6 +249,7 @@ export interface QueryRouteDefinition {
|
|
|
248
249
|
request: RouteRequest,
|
|
249
250
|
helpers: QueryRouteHelpers,
|
|
250
251
|
) => Promise<QueryRouteResult> | QueryRouteResult
|
|
252
|
+
revision?: () => string | number | Promise<string | number>
|
|
251
253
|
}
|
|
252
254
|
|
|
253
255
|
export interface DefineQueryRouteConfig<TReads extends ReadonlyArray<AnyMachineDef> = readonly []> {
|
|
@@ -259,6 +261,12 @@ export interface DefineQueryRouteConfig<TReads extends ReadonlyArray<AnyMachineD
|
|
|
259
261
|
request: RouteRequest,
|
|
260
262
|
helpers: QueryRouteHelpers<TReads>,
|
|
261
263
|
) => Promise<QueryRouteResult> | QueryRouteResult
|
|
264
|
+
/** The revision ledger: a CHEAP fingerprint of everything the body depends
|
|
265
|
+
* on (e.g. `() => String(maxUpdatedAt())` — one indexed SELECT). When
|
|
266
|
+
* present, the ETag derives from it and a matching `If-None-Match`
|
|
267
|
+
* answers 304 WITHOUT invoking the handler — a polling crawler costs the
|
|
268
|
+
* fingerprint, not the render. Must change whenever the body could. */
|
|
269
|
+
revision?: () => string | number | Promise<string | number>
|
|
262
270
|
}
|
|
263
271
|
|
|
264
272
|
export function isStatorQueryRoute(v: unknown): v is QueryRouteDefinition {
|
package/src/server/session.ts
CHANGED
|
@@ -23,9 +23,18 @@ export interface SessionState {
|
|
|
23
23
|
claimsDirty: boolean
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/** Layer-3 provability tracking: did this request USE the session plane
|
|
27
|
+
* (establish/dispatch/session-read) or READ claims? Either makes the
|
|
28
|
+
* response unprovable as anonymous-identical, so no derived Cache-Control. */
|
|
29
|
+
export interface SessionUse {
|
|
30
|
+
used: boolean
|
|
31
|
+
claimsRead: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
26
34
|
declare module 'hono' {
|
|
27
35
|
interface ContextVariableMap {
|
|
28
36
|
statorSession: SessionState
|
|
37
|
+
statorSessionUse: SessionUse
|
|
29
38
|
}
|
|
30
39
|
}
|
|
31
40
|
|
|
@@ -34,6 +43,40 @@ export function getSessionState(c: Context): SessionState | undefined {
|
|
|
34
43
|
return c.get('statorSession')
|
|
35
44
|
}
|
|
36
45
|
|
|
46
|
+
/** The request's session-use flags (created on first access). */
|
|
47
|
+
export function sessionUse(c: Context): SessionUse {
|
|
48
|
+
let use = c.get('statorSessionUse')
|
|
49
|
+
if (!use) {
|
|
50
|
+
use = { used: false, claimsRead: false }
|
|
51
|
+
c.set('statorSessionUse', use)
|
|
52
|
+
}
|
|
53
|
+
return use
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The session id this request WOULD have — established state or the arriving
|
|
57
|
+
* cookie — without creating anything. Null for a first-contact visitor. */
|
|
58
|
+
export function peekSessionId(c: Context): string | null {
|
|
59
|
+
return getSessionState(c)?.sid ?? getCookie(c, SESSION_COOKIE) ?? null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Resume an EXISTING session from the arriving cookie (populate the shared
|
|
63
|
+
* state, no cookie write) — or return null for first contact. Resuming is
|
|
64
|
+
* not establishment: laziness governs CREATION only. */
|
|
65
|
+
export function resumeSession(c: Context): SessionState | null {
|
|
66
|
+
const established = getSessionState(c)
|
|
67
|
+
if (established) return established
|
|
68
|
+
const cookieSid = getCookie(c, SESSION_COOKIE)
|
|
69
|
+
if (cookieSid === undefined) return null
|
|
70
|
+
const state: SessionState = {
|
|
71
|
+
sid: cookieSid,
|
|
72
|
+
isNew: false,
|
|
73
|
+
claims: undefined,
|
|
74
|
+
claimsDirty: false,
|
|
75
|
+
}
|
|
76
|
+
c.set('statorSession', state)
|
|
77
|
+
return state
|
|
78
|
+
}
|
|
79
|
+
|
|
37
80
|
/** Secure cookie flag — set when running behind HTTPS. Enabled by
|
|
38
81
|
* NODE_ENV=production; can be overridden via STATOR_SECURE_COOKIE. */
|
|
39
82
|
function shouldUseSecureCookie(): boolean {
|
|
@@ -65,22 +108,26 @@ export function setSessionCookie(c: Context, sessionId: string): void {
|
|
|
65
108
|
})
|
|
66
109
|
}
|
|
67
110
|
|
|
111
|
+
/**
|
|
112
|
+
* ESTABLISH the session — the lazy-creation trigger (dispatches, session-
|
|
113
|
+
* machine reads, SSE connects, explicit session ops call this; anonymous
|
|
114
|
+
* page/data GETs never do). Idempotent per request: the first caller
|
|
115
|
+
* establishes, everyone after reads the same state. A first-contact visitor
|
|
116
|
+
* gets a fresh sid + Set-Cookie HERE and nowhere earlier — the whole
|
|
117
|
+
* cacheable-read-path layer 1.
|
|
118
|
+
*/
|
|
68
119
|
export function getOrCreateSessionId(c: Context): {
|
|
69
120
|
sessionId: string
|
|
70
121
|
isNew: boolean
|
|
71
122
|
} {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
if (established) return { sessionId: established.sid, isNew: established.isNew }
|
|
76
|
-
|
|
77
|
-
const cookieSid = getCookie(c, SESSION_COOKIE)
|
|
78
|
-
const sessionId = cookieSid ?? randomUUID()
|
|
79
|
-
const isNew = cookieSid === undefined
|
|
80
|
-
if (isNew) setSessionCookie(c, sessionId)
|
|
123
|
+
sessionUse(c).used = true
|
|
124
|
+
const resumed = resumeSession(c)
|
|
125
|
+
if (resumed) return { sessionId: resumed.sid, isNew: resumed.isNew }
|
|
81
126
|
|
|
82
|
-
|
|
83
|
-
|
|
127
|
+
const sessionId = randomUUID()
|
|
128
|
+
setSessionCookie(c, sessionId)
|
|
129
|
+
c.set('statorSession', { sid: sessionId, isNew: true, claims: undefined, claimsDirty: false })
|
|
130
|
+
return { sessionId, isNew: true }
|
|
84
131
|
}
|
|
85
132
|
|
|
86
133
|
/**
|
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
|
}
|
|
@@ -94,6 +94,11 @@ export interface ElementSpecificAttributes {
|
|
|
94
94
|
autofocus?: boolean
|
|
95
95
|
accept?: string
|
|
96
96
|
multiple?: boolean
|
|
97
|
+
/** Associates this element with a `<form>` elsewhere in the document by
|
|
98
|
+
* id — the standard way to submit an element that can't live inside
|
|
99
|
+
* the form's own DOM subtree (e.g. a per-row control alongside a
|
|
100
|
+
* page-wide bulk-action form). `button` already carries this. */
|
|
101
|
+
form?: string
|
|
97
102
|
}
|
|
98
103
|
label: { for?: string }
|
|
99
104
|
form: {
|
|
@@ -101,6 +106,11 @@ export interface ElementSpecificAttributes {
|
|
|
101
106
|
method?: 'get' | 'post' | 'GET' | 'POST' | 'dialog'
|
|
102
107
|
enctype?: 'application/x-www-form-urlencoded' | 'multipart/form-data' | 'text/plain'
|
|
103
108
|
novalidate?: boolean
|
|
109
|
+
/** Native inline event-handler attribute — a literal JS source string
|
|
110
|
+
* (`"return confirm(...)"`), distinct from the `on:submit={...}`
|
|
111
|
+
* Stator directive. Useful for a plain, no-hydration `confirm()`
|
|
112
|
+
* guard that doesn't otherwise need an island. */
|
|
113
|
+
onsubmit?: string
|
|
104
114
|
}
|
|
105
115
|
select: {
|
|
106
116
|
name?: string
|