@statorjs/stator 1.0.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/LICENSE +21 -0
- package/README.md +104 -0
- package/package.json +99 -0
- package/src/build/build.ts +244 -0
- package/src/build/head.ts +43 -0
- package/src/build/index.ts +10 -0
- package/src/build/sync.ts +65 -0
- package/src/client/bind.ts +47 -0
- package/src/client/client-id.ts +10 -0
- package/src/client/dispatch.ts +62 -0
- package/src/client/element.ts +156 -0
- package/src/client/index.ts +13 -0
- package/src/client/inspector.ts +237 -0
- package/src/client/machine.ts +39 -0
- package/src/client/runtime.ts +246 -0
- package/src/client/use.ts +119 -0
- package/src/compiler/client-emit.ts +180 -0
- package/src/compiler/client-script.ts +256 -0
- package/src/compiler/compile.ts +459 -0
- package/src/compiler/diagnostics.ts +65 -0
- package/src/compiler/dts.ts +87 -0
- package/src/compiler/hash.ts +8 -0
- package/src/compiler/index.ts +48 -0
- package/src/compiler/lower.ts +0 -0
- package/src/compiler/regions.ts +79 -0
- package/src/compiler/split.ts +168 -0
- package/src/compiler/styles.ts +200 -0
- package/src/compiler/virtual-code.ts +184 -0
- package/src/components/index.ts +2 -0
- package/src/components/json-ld.ts +85 -0
- package/src/engine/actor.ts +248 -0
- package/src/engine/define-machine.ts +113 -0
- package/src/engine/index.ts +37 -0
- package/src/engine/types.ts +236 -0
- package/src/server/api-route.ts +149 -0
- package/src/server/app-dispatch.ts +52 -0
- package/src/server/app-store.ts +35 -0
- package/src/server/cached-store.ts +117 -0
- package/src/server/create-app.ts +83 -0
- package/src/server/define-machine.ts +10 -0
- package/src/server/dev.ts +255 -0
- package/src/server/discovery.ts +111 -0
- package/src/server/dispatch-context.ts +39 -0
- package/src/server/effects.ts +120 -0
- package/src/server/http.ts +475 -0
- package/src/server/index.ts +101 -0
- package/src/server/instance-proxy.ts +95 -0
- package/src/server/logger.ts +52 -0
- package/src/server/machine-store.ts +355 -0
- package/src/server/reads-helpers.ts +40 -0
- package/src/server/recompute.ts +287 -0
- package/src/server/redis-store.ts +116 -0
- package/src/server/render-context.ts +228 -0
- package/src/server/render.ts +111 -0
- package/src/server/route-discovery.ts +226 -0
- package/src/server/route-request.ts +36 -0
- package/src/server/routing.ts +175 -0
- package/src/server/session-lock.ts +33 -0
- package/src/server/session-runtime.ts +242 -0
- package/src/server/session.ts +29 -0
- package/src/server/sse.ts +175 -0
- package/src/server/store.ts +95 -0
- package/src/stator-modules.d.ts +12 -0
- package/src/template/client-shell.ts +65 -0
- package/src/template/conditional.ts +166 -0
- package/src/template/directives/core.ts +58 -0
- package/src/template/directives/list-attr.ts +183 -0
- package/src/template/directives/on.ts +22 -0
- package/src/template/each.ts +295 -0
- package/src/template/html.ts +180 -0
- package/src/template/index.ts +29 -0
- package/src/template/parser.ts +341 -0
- package/src/template/read.ts +50 -0
- package/src/template/types.ts +33 -0
- package/src/vite/index.ts +6 -0
- package/src/vite/plugin.ts +151 -0
- package/src/vite/stub.ts +79 -0
- package/src/wire/apply.ts +103 -0
- package/src/wire/index.ts +67 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { createActor, type EffectInvocation, type Snapshot } from '../engine/index.ts'
|
|
2
|
+
import type { AnyMachineDef } from './define-machine.ts'
|
|
3
|
+
import { type DispatchContext, recordTouch, withDispatchContext } from './dispatch-context.ts'
|
|
4
|
+
import { createInstanceProxy, type InstanceHandle } from './instance-proxy.ts'
|
|
5
|
+
import { buildDispatchEvent, MAX_CASCADE_DEPTH, type MachineStore } from './machine-store.ts'
|
|
6
|
+
import { serverReadsResolver } from './reads-helpers.ts'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Per-request scope for session-lifecycle machine actors. Created at the
|
|
10
|
+
* top of an HTTP handler, populated by `loadGraph` (which pulls only the
|
|
11
|
+
* machines this request needs from the Store and hydrates transient
|
|
12
|
+
* actors), and disposed at the bottom of the handler.
|
|
13
|
+
*
|
|
14
|
+
* Subscription wiring uses cross-actor listeners installed via
|
|
15
|
+
* `actor.on(...)`. Because the actors are transient, the wiring is rebuilt
|
|
16
|
+
* per request — never reused across requests.
|
|
17
|
+
*
|
|
18
|
+
* Same shape will host SSE connection lifetime in V1: the connection's
|
|
19
|
+
* runtime simply lives longer (one runtime per open connection, not one
|
|
20
|
+
* per request), and `processEvent` is invoked when an out-of-band state
|
|
21
|
+
* change touches a machine the connection's route reads.
|
|
22
|
+
*/
|
|
23
|
+
export class SessionRuntime {
|
|
24
|
+
private actors = new Map<string, InstanceHandle>()
|
|
25
|
+
private wired = false
|
|
26
|
+
/** Synchronous emit→subscribe cascade depth + trail for THIS runtime. A
|
|
27
|
+
* subscription cycle would otherwise recurse to a bare stack overflow;
|
|
28
|
+
* the cap converts it into an error that names the loop. */
|
|
29
|
+
private cascadeDepth = 0
|
|
30
|
+
private cascadeTrail: string[] = []
|
|
31
|
+
/** Effects surfaced during processEvent, queued until the entry point has
|
|
32
|
+
* persisted and released the session lock (see server/effects.ts). */
|
|
33
|
+
private pendingEffects: EffectInvocation[] = []
|
|
34
|
+
|
|
35
|
+
constructor(
|
|
36
|
+
readonly sessionId: string,
|
|
37
|
+
readonly store: MachineStore,
|
|
38
|
+
) {}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Ensure the given machines (and everything reachable via their `reads:`
|
|
42
|
+
* and `subscribes:` declarations + machines that subscribe back to them)
|
|
43
|
+
* are loaded into this runtime. Idempotent: calling twice with overlapping
|
|
44
|
+
* inputs costs only the de-duped delta.
|
|
45
|
+
*/
|
|
46
|
+
async loadGraph(seeds: ReadonlyArray<AnyMachineDef>): Promise<void> {
|
|
47
|
+
const queue = [...seeds]
|
|
48
|
+
while (queue.length > 0) {
|
|
49
|
+
const def = queue.shift()!
|
|
50
|
+
if (def.lifecycle !== 'session') continue
|
|
51
|
+
if (this.actors.has(def.name)) continue
|
|
52
|
+
await this.loadOne(def)
|
|
53
|
+
queue.push(...def.reads)
|
|
54
|
+
for (const sub of def.subscribes) queue.push(sub.from)
|
|
55
|
+
for (const sub of this.store.subscribersOf(def.name)) {
|
|
56
|
+
const targetDef = this.store.getDef(sub.targetName)
|
|
57
|
+
if (targetDef) queue.push(targetDef)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private async loadOne(def: AnyMachineDef): Promise<void> {
|
|
63
|
+
const persisted = await this.store.persistence.get(this.sessionId, def.name)
|
|
64
|
+
const actor = createActor(def, {
|
|
65
|
+
snapshot: persisted !== null ? (persisted as Snapshot<object>) : undefined,
|
|
66
|
+
resolveHelpers: serverReadsResolver(def),
|
|
67
|
+
// Server plane: never run effects inline — queue them for the entry
|
|
68
|
+
// point to schedule after persist + lock release.
|
|
69
|
+
onEffect: (invocation) => this.pendingEffects.push(invocation),
|
|
70
|
+
}).start()
|
|
71
|
+
this.actors.set(
|
|
72
|
+
def.name,
|
|
73
|
+
createInstanceProxy(def, actor, (name) => this.proxyFor(name)),
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Replace a session actor with a fresh hydration from the Store. Used by
|
|
79
|
+
* fan-out before recomputing a long-lived SSE connection's diffs: the
|
|
80
|
+
* mutation happened in ANOTHER runtime (a POST or an effect completion)
|
|
81
|
+
* and only exists in persistence — this runtime's in-memory actor is
|
|
82
|
+
* frozen at whatever the connection last saw. Subscription listeners are
|
|
83
|
+
* NOT rewired onto the new actor; connection runtimes are read-only diff
|
|
84
|
+
* targets and never dispatch.
|
|
85
|
+
*/
|
|
86
|
+
async rehydrate(name: string): Promise<void> {
|
|
87
|
+
const def = this.store.getDef(name)
|
|
88
|
+
if (def?.lifecycle !== 'session' || !this.actors.has(name)) return
|
|
89
|
+
const persisted = await this.store.persistence.get(this.sessionId, def.name)
|
|
90
|
+
const actor = createActor(def, {
|
|
91
|
+
snapshot: persisted !== null ? (persisted as Snapshot<object>) : undefined,
|
|
92
|
+
resolveHelpers: serverReadsResolver(def),
|
|
93
|
+
onEffect: (invocation) => this.pendingEffects.push(invocation),
|
|
94
|
+
}).start()
|
|
95
|
+
this.actors.get(name)?.actor.stop()
|
|
96
|
+
this.actors.set(
|
|
97
|
+
name,
|
|
98
|
+
createInstanceProxy(def, actor, (n) => this.proxyFor(n)),
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Lifecycle of a machine by name, for fan-out applicability decisions. */
|
|
103
|
+
lifecycleOf(name: string): 'session' | 'app' | undefined {
|
|
104
|
+
return this.store.getDef(name)?.lifecycle
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Hand queued effect invocations to the scheduler, clearing the queue. */
|
|
108
|
+
drainPendingEffects(): EffectInvocation[] {
|
|
109
|
+
const drained = this.pendingEffects
|
|
110
|
+
this.pendingEffects = []
|
|
111
|
+
return drained
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Install actor.on listeners for every subscription whose source is a
|
|
116
|
+
* session-machine loaded in this runtime. The target may be:
|
|
117
|
+
* - another session-machine in this runtime (session→session)
|
|
118
|
+
* - an app-machine in the long-lived appInstances (session→app)
|
|
119
|
+
* App→session is blocked at validation; app→app is wired at app boot.
|
|
120
|
+
*
|
|
121
|
+
* Cross-lifecycle (session→app) subscriptions inject `sourceSessionId`
|
|
122
|
+
* into the dispatched event so the app receiver can correlate which
|
|
123
|
+
* session emitted it.
|
|
124
|
+
*
|
|
125
|
+
* Must be called before any event is sent. Idempotent.
|
|
126
|
+
*/
|
|
127
|
+
wireSubscriptions(): void {
|
|
128
|
+
if (this.wired) return
|
|
129
|
+
this.wired = true
|
|
130
|
+
|
|
131
|
+
for (const [sourceName, sourceHandle] of this.actors) {
|
|
132
|
+
for (const sub of this.store.subscribersOf(sourceName)) {
|
|
133
|
+
const targetDef = this.store.getDef(sub.targetName)
|
|
134
|
+
if (!targetDef) continue
|
|
135
|
+
const targetHandle =
|
|
136
|
+
targetDef.lifecycle === 'app'
|
|
137
|
+
? this.store.appInstance(sub.targetName)
|
|
138
|
+
: this.actors.get(sub.targetName)
|
|
139
|
+
if (!targetHandle) continue
|
|
140
|
+
|
|
141
|
+
const crossLifecycle = sourceHandle.def.lifecycle !== targetDef.lifecycle
|
|
142
|
+
const sid = this.sessionId
|
|
143
|
+
const targetName = sub.targetName
|
|
144
|
+
|
|
145
|
+
sourceHandle.actor.on(sub.event as never, (emitted) => {
|
|
146
|
+
this.cascadeDepth += 1
|
|
147
|
+
this.cascadeTrail.push(`${sourceName} —${sub.event}→ ${targetName}`)
|
|
148
|
+
try {
|
|
149
|
+
if (this.cascadeDepth > MAX_CASCADE_DEPTH) {
|
|
150
|
+
throw new Error(
|
|
151
|
+
`stator: emit cascade exceeded ${MAX_CASCADE_DEPTH} hops — ` +
|
|
152
|
+
`subscription cycle? Last hops:\n ${this.cascadeTrail.slice(-6).join('\n ')}`,
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
const before = targetHandle.actor.getCommitCount()
|
|
156
|
+
targetHandle.actor.send(
|
|
157
|
+
buildDispatchEvent(emitted, sub.dispatch, crossLifecycle ? sid : undefined) as never,
|
|
158
|
+
)
|
|
159
|
+
if (targetHandle.actor.getCommitCount() !== before) recordTouch(targetName)
|
|
160
|
+
} finally {
|
|
161
|
+
this.cascadeDepth -= 1
|
|
162
|
+
this.cascadeTrail.pop()
|
|
163
|
+
}
|
|
164
|
+
})
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Resolve a proxy for a machine — session-scoped from this runtime,
|
|
170
|
+
* app-scoped from the long-lived app instance map. */
|
|
171
|
+
proxyFor(name: string): unknown {
|
|
172
|
+
const local = this.actors.get(name)
|
|
173
|
+
if (local) return local.proxy
|
|
174
|
+
const app = this.store.appInstance(name)
|
|
175
|
+
return app?.proxy
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
handleFor(name: string): InstanceHandle | undefined {
|
|
179
|
+
return this.actors.get(name) ?? this.store.appInstance(name)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Send an event to a loaded machine. Returns the set of machine names
|
|
184
|
+
* whose actors received any event during the dispatch (the origin plus
|
|
185
|
+
* any subscriber targets that fired via cross-machine listeners).
|
|
186
|
+
*
|
|
187
|
+
* Throws if the origin machine isn't loaded — call `loadGraph` first.
|
|
188
|
+
*/
|
|
189
|
+
processEvent(machineName: string, event: { type: string; [k: string]: unknown }): Set<string> {
|
|
190
|
+
const handle = this.actors.get(machineName)
|
|
191
|
+
if (!handle) {
|
|
192
|
+
throw new Error(
|
|
193
|
+
`stator: machine "${machineName}" is not loaded into this runtime — ` +
|
|
194
|
+
`call loadGraph(...) with the relevant defs first.`,
|
|
195
|
+
)
|
|
196
|
+
}
|
|
197
|
+
if (!this.wired) this.wireSubscriptions()
|
|
198
|
+
const ctx: DispatchContext = {
|
|
199
|
+
runtime: this,
|
|
200
|
+
touched: new Set<string>([machineName]),
|
|
201
|
+
}
|
|
202
|
+
const before = handle.actor.getCommitCount()
|
|
203
|
+
withDispatchContext(ctx, () => {
|
|
204
|
+
handle.actor.send(event as never)
|
|
205
|
+
})
|
|
206
|
+
// A guard-dropped / unhandled event committed nothing: it must not count
|
|
207
|
+
// as touched (no persist, no fan-out, and `committed: false` on the wire).
|
|
208
|
+
if (handle.actor.getCommitCount() === before) ctx.touched.delete(machineName)
|
|
209
|
+
return ctx.touched
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Write the current persisted snapshot for each touched machine back to
|
|
213
|
+
* its store: session machines to the session Store, app machines (touched
|
|
214
|
+
* via session→app subscriptions) to the AppStore when they opted in. */
|
|
215
|
+
async persistTouched(touched: ReadonlySet<string>): Promise<void> {
|
|
216
|
+
const ttlSeconds = this.store.sessionTtlSeconds
|
|
217
|
+
for (const name of touched) {
|
|
218
|
+
const handle = this.actors.get(name)
|
|
219
|
+
if (!handle) {
|
|
220
|
+
// Not a session machine in this runtime — an app machine reached via
|
|
221
|
+
// subscription. Safe no-op unless it opted into persistence.
|
|
222
|
+
await this.store.persistAppMachine(name)
|
|
223
|
+
continue
|
|
224
|
+
}
|
|
225
|
+
const snapshot = handle.actor.getPersistedSnapshot()
|
|
226
|
+
// Every set refreshes the session's whole expiry — the user is
|
|
227
|
+
// active, so all of their machines stay alive together.
|
|
228
|
+
await this.store.persistence.set(this.sessionId, name, snapshot, {
|
|
229
|
+
ttlSeconds,
|
|
230
|
+
})
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Stop every transient actor. After dispose, the runtime must not be
|
|
235
|
+
* used. Safe to call multiple times. */
|
|
236
|
+
dispose(): void {
|
|
237
|
+
for (const handle of this.actors.values()) {
|
|
238
|
+
handle.actor.stop()
|
|
239
|
+
}
|
|
240
|
+
this.actors.clear()
|
|
241
|
+
}
|
|
242
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import type { Context } from 'hono'
|
|
3
|
+
import { getCookie, setCookie } from 'hono/cookie'
|
|
4
|
+
|
|
5
|
+
export const SESSION_COOKIE = 'stator_sid'
|
|
6
|
+
|
|
7
|
+
/** Secure cookie flag — set when running behind HTTPS. Enabled by
|
|
8
|
+
* NODE_ENV=production; can be overridden via STATOR_SECURE_COOKIE. */
|
|
9
|
+
function shouldUseSecureCookie(): boolean {
|
|
10
|
+
if (process.env.STATOR_SECURE_COOKIE === '1') return true
|
|
11
|
+
if (process.env.STATOR_SECURE_COOKIE === '0') return false
|
|
12
|
+
return process.env.NODE_ENV === 'production'
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function getOrCreateSessionId(c: Context): {
|
|
16
|
+
sessionId: string
|
|
17
|
+
isNew: boolean
|
|
18
|
+
} {
|
|
19
|
+
const existing = getCookie(c, SESSION_COOKIE)
|
|
20
|
+
if (existing) return { sessionId: existing, isNew: false }
|
|
21
|
+
const sessionId = randomUUID()
|
|
22
|
+
setCookie(c, SESSION_COOKIE, sessionId, {
|
|
23
|
+
httpOnly: true,
|
|
24
|
+
sameSite: 'Lax',
|
|
25
|
+
path: '/',
|
|
26
|
+
secure: shouldUseSecureCookie(),
|
|
27
|
+
})
|
|
28
|
+
return { sessionId, isNew: true }
|
|
29
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { Patch } from '../wire/index.ts'
|
|
2
|
+
import { scopedLogger } from './logger.ts'
|
|
3
|
+
import { recompute } from './recompute.ts'
|
|
4
|
+
import type { RenderState } from './render-context.ts'
|
|
5
|
+
import type { RouteDefinition, RouteRequest } from './routing.ts'
|
|
6
|
+
import type { SessionRuntime } from './session-runtime.ts'
|
|
7
|
+
|
|
8
|
+
const sseLog = scopedLogger('sse')
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* One open SSE connection. Lifetime equals the underlying TCP connection.
|
|
12
|
+
* The runtime stays alive for the connection's duration — this is the one
|
|
13
|
+
* place per-session state outlives a single request, because the
|
|
14
|
+
* connection *is* a single (very long) request.
|
|
15
|
+
*
|
|
16
|
+
* `renderState` carries the slot bindings + `lastValue` baseline that
|
|
17
|
+
* recompute will diff against when fan-out fires. Each push updates
|
|
18
|
+
* `lastValue` for the bindings touched by the push, so subsequent pushes
|
|
19
|
+
* only emit deltas.
|
|
20
|
+
*/
|
|
21
|
+
export interface Connection {
|
|
22
|
+
id: string
|
|
23
|
+
sessionId: string
|
|
24
|
+
/** The browser page-load identity (client-generated). Lets fan-out
|
|
25
|
+
* recognize a dispatch's OWN connection: its baseline is advanced but
|
|
26
|
+
* nothing is sent — the POST response already delivered those patches. */
|
|
27
|
+
clientId?: string
|
|
28
|
+
routeKey: string
|
|
29
|
+
route: RouteDefinition
|
|
30
|
+
/** URL-derived state for this specific connection. Stored at connection
|
|
31
|
+
* open and reused by every fan-out recompute. Parameterized routes
|
|
32
|
+
* carry the resolved path params here (`/p/:id` connection knows its
|
|
33
|
+
* specific id). */
|
|
34
|
+
request: RouteRequest
|
|
35
|
+
runtime: SessionRuntime
|
|
36
|
+
renderState: RenderState
|
|
37
|
+
send: (data: string) => Promise<void>
|
|
38
|
+
closed: boolean
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const connections = new Map<string, Connection>()
|
|
42
|
+
let nextId = 0
|
|
43
|
+
|
|
44
|
+
export function registerConnection(init: Omit<Connection, 'id' | 'closed'>): Connection {
|
|
45
|
+
const id = `sse${nextId++}`
|
|
46
|
+
const conn: Connection = { ...init, id, closed: false }
|
|
47
|
+
connections.set(id, conn)
|
|
48
|
+
sseLog.info(
|
|
49
|
+
{ id, sid: conn.sessionId, route: conn.routeKey, total: connections.size },
|
|
50
|
+
'connection opened',
|
|
51
|
+
)
|
|
52
|
+
return conn
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function unregisterConnection(id: string): void {
|
|
56
|
+
const conn = connections.get(id)
|
|
57
|
+
if (!conn) return
|
|
58
|
+
conn.closed = true
|
|
59
|
+
conn.runtime.dispose()
|
|
60
|
+
connections.delete(id)
|
|
61
|
+
sseLog.info(
|
|
62
|
+
{ id, sid: conn.sessionId, route: conn.routeKey, total: connections.size },
|
|
63
|
+
'connection closed',
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function activeConnectionCount(): number {
|
|
68
|
+
return connections.size
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* After a state-changing dispatch settles, iterate every open connection
|
|
73
|
+
* whose route's `reads:` intersects `touched`, recompute against that
|
|
74
|
+
* connection's slot map, and push any resulting patches over its event
|
|
75
|
+
* stream. Recompute mutates the connection's `renderState.lastValue`s so
|
|
76
|
+
* the next push is correctly diffed.
|
|
77
|
+
*
|
|
78
|
+
* Connections from any session receive pushes — that's the point: an
|
|
79
|
+
* admin tab on session C sees updates triggered by session A's POSTs.
|
|
80
|
+
*/
|
|
81
|
+
export async function fanOut(
|
|
82
|
+
touched: ReadonlySet<string>,
|
|
83
|
+
source?: { sessionId?: string; originClientId?: string },
|
|
84
|
+
): Promise<void> {
|
|
85
|
+
if (touched.size === 0 || connections.size === 0) return
|
|
86
|
+
|
|
87
|
+
let pushedCount = 0
|
|
88
|
+
let skippedNoIntersect = 0
|
|
89
|
+
let skippedNoPatches = 0
|
|
90
|
+
let skippedOriginator = 0
|
|
91
|
+
let failed = 0
|
|
92
|
+
|
|
93
|
+
// Expand through the reverse-reads graph once per fan-out: machines whose
|
|
94
|
+
// SELECTORS derive from a touched machine must re-diff even though their
|
|
95
|
+
// own state didn't move. (Every connection shares the one MachineStore.)
|
|
96
|
+
const first = connections.values().next().value
|
|
97
|
+
const { all: expandedTouched, derived } = first
|
|
98
|
+
? first.runtime.store.expandTouchedForRecompute(touched)
|
|
99
|
+
: { all: new Set(touched), derived: new Set<string>() }
|
|
100
|
+
|
|
101
|
+
for (const conn of connections.values()) {
|
|
102
|
+
if (conn.closed) continue
|
|
103
|
+
|
|
104
|
+
// Applicability per machine, judged against the connection's actual
|
|
105
|
+
// bindings (byMachine covers transitive reads, not just declared seeds):
|
|
106
|
+
// - app machines: every connection (the shared instance IS the state).
|
|
107
|
+
// - DIRECTLY-touched session machines: only the touching session's own
|
|
108
|
+
// connections — and the connection's long-lived runtime must
|
|
109
|
+
// REHYDRATE them from the Store first, because the mutation happened
|
|
110
|
+
// in another runtime and this one's actor is frozen at connect time.
|
|
111
|
+
// - DERIVED session machines (expansion only): every connection that
|
|
112
|
+
// binds them, any session, no rehydration — their own state didn't
|
|
113
|
+
// change; their selectors' dependencies did.
|
|
114
|
+
const applicable: string[] = []
|
|
115
|
+
for (const name of expandedTouched) {
|
|
116
|
+
if (!conn.renderState.byMachine.has(name)) continue
|
|
117
|
+
if (conn.runtime.lifecycleOf(name) === 'session' && !derived.has(name)) {
|
|
118
|
+
if (source?.sessionId === undefined || source.sessionId !== conn.sessionId) continue
|
|
119
|
+
await conn.runtime.rehydrate(name)
|
|
120
|
+
}
|
|
121
|
+
applicable.push(name)
|
|
122
|
+
}
|
|
123
|
+
if (applicable.length === 0) {
|
|
124
|
+
skippedNoIntersect++
|
|
125
|
+
continue
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const patches: Patch[] = []
|
|
129
|
+
for (const name of applicable) {
|
|
130
|
+
patches.push(...recompute(conn.renderState, name, conn.runtime))
|
|
131
|
+
}
|
|
132
|
+
if (patches.length === 0) {
|
|
133
|
+
skippedNoPatches++
|
|
134
|
+
continue
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// The dispatching page's own connection: the POST response already
|
|
138
|
+
// delivered this diff. The recompute above advanced the baseline (so the
|
|
139
|
+
// NEXT push diffs correctly); sending would double-apply — and keyed
|
|
140
|
+
// insert/remove/move ops are not idempotent.
|
|
141
|
+
if (
|
|
142
|
+
source?.originClientId !== undefined &&
|
|
143
|
+
conn.clientId !== undefined &&
|
|
144
|
+
conn.clientId === source.originClientId
|
|
145
|
+
) {
|
|
146
|
+
skippedOriginator++
|
|
147
|
+
continue
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
try {
|
|
151
|
+
await conn.send(JSON.stringify({ patches }))
|
|
152
|
+
pushedCount++
|
|
153
|
+
} catch (err) {
|
|
154
|
+
// Bumped from debug to warn — silent push failures were why "30-50%
|
|
155
|
+
// delivery" was invisible in production logs.
|
|
156
|
+
failed++
|
|
157
|
+
sseLog.warn({ id: conn.id, sid: conn.sessionId, err: String(err) }, 'sse push failed')
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Log every fan-out at info so the user can see touched-machines flow and
|
|
162
|
+
// correlate against client-side inspector entries.
|
|
163
|
+
sseLog.info(
|
|
164
|
+
{
|
|
165
|
+
touched: [...touched],
|
|
166
|
+
total: connections.size,
|
|
167
|
+
pushed: pushedCount,
|
|
168
|
+
skippedNoIntersect,
|
|
169
|
+
skippedNoPatches,
|
|
170
|
+
skippedOriginator,
|
|
171
|
+
failed,
|
|
172
|
+
},
|
|
173
|
+
'fan-out',
|
|
174
|
+
)
|
|
175
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistence boundary for session-scoped machine state. Stored values are
|
|
3
|
+
* XState v5 *persisted snapshots* — serializable blobs containing the state
|
|
4
|
+
* name plus context, recoverable via `createActor(machine, { snapshot })`.
|
|
5
|
+
*
|
|
6
|
+
* The framework treats stored values as opaque JSON. App-lifecycle machine
|
|
7
|
+
* state is held in process (not persisted by this layer).
|
|
8
|
+
*
|
|
9
|
+
* ## TTL semantics — per-session, not per-entry
|
|
10
|
+
*
|
|
11
|
+
* `ttlSeconds` on `set` refreshes the **whole session's** expiry, not just
|
|
12
|
+
* the entry being written. If CartMachine is stored at T+0 and CheckoutMachine
|
|
13
|
+
* is stored at T+6h (both with ttlSeconds=86400), both expire at T+30h, not
|
|
14
|
+
* T+24h and T+30h respectively. Adapters MUST honor this — the session is
|
|
15
|
+
* the TTL unit, machines within a session are not independent.
|
|
16
|
+
*
|
|
17
|
+
* Rationale: a user actively interacting with one machine (checkout) is also
|
|
18
|
+
* an active session for their other machines (cart) by definition. Splitting
|
|
19
|
+
* TTL per-machine would drop carts under users mid-checkout. Sessions
|
|
20
|
+
* expire as a whole when fully idle.
|
|
21
|
+
*/
|
|
22
|
+
export interface Store {
|
|
23
|
+
get(sessionId: string, machineName: string): Promise<unknown | null>
|
|
24
|
+
set(
|
|
25
|
+
sessionId: string,
|
|
26
|
+
machineName: string,
|
|
27
|
+
snapshot: unknown,
|
|
28
|
+
opts?: { ttlSeconds?: number },
|
|
29
|
+
): Promise<void>
|
|
30
|
+
has(sessionId: string, machineName: string): Promise<boolean>
|
|
31
|
+
deleteSession(sessionId: string): Promise<void>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* In-memory implementation. Lazy-expiry: nothing is swept proactively, but
|
|
36
|
+
* any read of an expired session returns null and drops the session's data.
|
|
37
|
+
* Per-session expiry is tracked separately from the data map.
|
|
38
|
+
*/
|
|
39
|
+
export class InMemoryStore implements Store {
|
|
40
|
+
private data = new Map<string, Map<string, unknown>>()
|
|
41
|
+
private expiryAt = new Map<string, number>() // sessionId → epoch ms
|
|
42
|
+
|
|
43
|
+
private isExpired(sid: string): boolean {
|
|
44
|
+
const at = this.expiryAt.get(sid)
|
|
45
|
+
return at !== undefined && at <= Date.now()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
private dropSession(sid: string): void {
|
|
49
|
+
this.data.delete(sid)
|
|
50
|
+
this.expiryAt.delete(sid)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async get(sid: string, name: string): Promise<unknown | null> {
|
|
54
|
+
if (this.isExpired(sid)) {
|
|
55
|
+
this.dropSession(sid)
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
return this.data.get(sid)?.get(name) ?? null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async set(
|
|
62
|
+
sid: string,
|
|
63
|
+
name: string,
|
|
64
|
+
snapshot: unknown,
|
|
65
|
+
opts?: { ttlSeconds?: number },
|
|
66
|
+
): Promise<void> {
|
|
67
|
+
// Drop any stale data before writing — set() against an expired session
|
|
68
|
+
// is effectively a fresh session.
|
|
69
|
+
if (this.isExpired(sid)) this.dropSession(sid)
|
|
70
|
+
|
|
71
|
+
let session = this.data.get(sid)
|
|
72
|
+
if (!session) {
|
|
73
|
+
session = new Map()
|
|
74
|
+
this.data.set(sid, session)
|
|
75
|
+
}
|
|
76
|
+
session.set(name, snapshot)
|
|
77
|
+
|
|
78
|
+
if (opts?.ttlSeconds) {
|
|
79
|
+
// Refresh the whole session's expiry, not just this entry's.
|
|
80
|
+
this.expiryAt.set(sid, Date.now() + opts.ttlSeconds * 1000)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async has(sid: string, name: string): Promise<boolean> {
|
|
85
|
+
if (this.isExpired(sid)) {
|
|
86
|
+
this.dropSession(sid)
|
|
87
|
+
return false
|
|
88
|
+
}
|
|
89
|
+
return this.data.get(sid)?.has(name) ?? false
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async deleteSession(sid: string): Promise<void> {
|
|
93
|
+
this.dropSession(sid)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ambient type for `.stator` single-file components. A `.stator` module's
|
|
3
|
+
* default export is its render function — it takes the component's props and
|
|
4
|
+
* returns an HtmlFragment. Consumers reference this via the package's types.
|
|
5
|
+
*/
|
|
6
|
+
declare module '*.stator' {
|
|
7
|
+
import type { HtmlFragment } from '@statorjs/stator/template'
|
|
8
|
+
|
|
9
|
+
// biome-ignore lint/suspicious/noExplicitAny: the wildcard fallback must admit every component's props — `Record<string, unknown>` would reject interfaces without index signatures
|
|
10
|
+
const component: (props?: any) => HtmlFragment
|
|
11
|
+
export default component
|
|
12
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { allocElementId, getCurrentRenderState, registerBinding } from '../server/render-context.ts'
|
|
2
|
+
import { escapeAttribute } from './parser.ts'
|
|
3
|
+
import { isReadResult, type ReadResult } from './read.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Server-side: render a client component's declared attributes onto its
|
|
7
|
+
* custom-element root. A client component's server module calls this to map
|
|
8
|
+
* camelCase props → kebab DOM attributes (`unitPrice` → `unit-price`), per the
|
|
9
|
+
* `static attrs` coercer kinds. Booleans are presence flags; other scalars are
|
|
10
|
+
* stringified + escaped. `null`/`undefined`/`false`-boolean → omitted.
|
|
11
|
+
*
|
|
12
|
+
* A prop may be a `read(...)`: the attribute becomes a LIVE binding — the
|
|
13
|
+
* server patches it like any attr binding, and the island (which observes
|
|
14
|
+
* its declared attrs) receives `${key}Changed(next)`. This is the sanctioned
|
|
15
|
+
* channel for live server state flowing INTO an island.
|
|
16
|
+
*
|
|
17
|
+
* Returns a leading-spaced attribute string (or '') to splice into the open tag.
|
|
18
|
+
*/
|
|
19
|
+
export function clientShellAttrs(
|
|
20
|
+
props: Record<string, unknown>,
|
|
21
|
+
decl: Record<string, 'number' | 'string' | 'boolean'>,
|
|
22
|
+
): string {
|
|
23
|
+
let out = ''
|
|
24
|
+
let elementId: string | null = null
|
|
25
|
+
for (const key in decl) {
|
|
26
|
+
const v = props[key]
|
|
27
|
+
if (v == null) continue
|
|
28
|
+
const name = key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)
|
|
29
|
+
|
|
30
|
+
if (isReadResult(v)) {
|
|
31
|
+
const state = getCurrentRenderState()
|
|
32
|
+
if (!state) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`stator: read() passed as island prop "${key}" outside a render — ` +
|
|
35
|
+
`live island attrs only make sense inside a route render.`,
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
if (!elementId) {
|
|
39
|
+
elementId = allocElementId(state)
|
|
40
|
+
out += ` data-stator-id="${elementId}"`
|
|
41
|
+
}
|
|
42
|
+
const r = v as ReadResult
|
|
43
|
+
registerBinding(state, {
|
|
44
|
+
slotId: r.slotId,
|
|
45
|
+
machineName: r.machineName,
|
|
46
|
+
selector: r.selector,
|
|
47
|
+
lastValue: r.value,
|
|
48
|
+
kind: 'attr',
|
|
49
|
+
attrName: name,
|
|
50
|
+
parentId: elementId,
|
|
51
|
+
})
|
|
52
|
+
// Same value semantics as template attr bindings (boolean-aware).
|
|
53
|
+
if (r.value === false || r.value === null || r.value === undefined) continue
|
|
54
|
+
out += r.value === true ? ` ${name}` : ` ${name}="${escapeAttribute(String(r.value))}"`
|
|
55
|
+
continue
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (decl[key] === 'boolean') {
|
|
59
|
+
if (v) out += ` ${name}`
|
|
60
|
+
} else {
|
|
61
|
+
out += ` ${name}="${escapeAttribute(String(v))}"`
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return out
|
|
65
|
+
}
|