@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,52 @@
|
|
|
1
|
+
import { createRequire } from 'node:module'
|
|
2
|
+
import pino, { type Logger } from 'pino'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Framework logger. Pretty colored output in dev (auto-detected via
|
|
6
|
+
* NODE_ENV), JSON in production for log aggregators. Level controlled by
|
|
7
|
+
* LOG_LEVEL env (default 'info').
|
|
8
|
+
*
|
|
9
|
+
* Application code can use this module-level logger or call `child()` for
|
|
10
|
+
* scoped context. The framework uses scoped children for SSE events,
|
|
11
|
+
* fan-out, and HTTP request lines.
|
|
12
|
+
*/
|
|
13
|
+
/** pino-pretty is an optional nicety, not a framework dependency — apps add
|
|
14
|
+
* it as a devDependency for colored dev output (the create-stator template
|
|
15
|
+
* does). Without it, dev logs are plain JSON rather than a crash. */
|
|
16
|
+
function hasPinoPretty(): boolean {
|
|
17
|
+
try {
|
|
18
|
+
createRequire(import.meta.url).resolve('pino-pretty')
|
|
19
|
+
return true
|
|
20
|
+
} catch {
|
|
21
|
+
return false
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function buildLogger(): Logger {
|
|
26
|
+
const level = process.env.LOG_LEVEL ?? 'info'
|
|
27
|
+
const isProd = process.env.NODE_ENV === 'production'
|
|
28
|
+
|
|
29
|
+
if (isProd || !hasPinoPretty()) {
|
|
30
|
+
return pino({ level })
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return pino({
|
|
34
|
+
level,
|
|
35
|
+
transport: {
|
|
36
|
+
target: 'pino-pretty',
|
|
37
|
+
options: {
|
|
38
|
+
colorize: true,
|
|
39
|
+
translateTime: 'HH:MM:ss.l',
|
|
40
|
+
ignore: 'pid,hostname',
|
|
41
|
+
singleLine: false,
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const logger: Logger = buildLogger()
|
|
48
|
+
|
|
49
|
+
/** Child logger with a `scope` tag for filtering. */
|
|
50
|
+
export function scopedLogger(scope: string): Logger {
|
|
51
|
+
return logger.child({ scope })
|
|
52
|
+
}
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import { createActor, type EffectInvocation, type Snapshot } from '../engine/index.ts'
|
|
2
|
+
import { type AppStore, InMemoryAppStore } from './app-store.ts'
|
|
3
|
+
import type { AnyMachineDef, SubscribeEvent } from './define-machine.ts'
|
|
4
|
+
import { recordTouch } from './dispatch-context.ts'
|
|
5
|
+
import { createInstanceProxy, type InstanceHandle } from './instance-proxy.ts'
|
|
6
|
+
import { scopedLogger } from './logger.ts'
|
|
7
|
+
import { serverReadsResolver } from './reads-helpers.ts'
|
|
8
|
+
import type { Store } from './store.ts'
|
|
9
|
+
|
|
10
|
+
const storeLog = scopedLogger('machine-store')
|
|
11
|
+
|
|
12
|
+
/** Hard backstop for synchronous emit→subscribe cascades. Deep enough for
|
|
13
|
+
* any legitimate finite ping-pong; shallow enough to abort a cycle long
|
|
14
|
+
* before the call stack does, with a diagnosable trail. */
|
|
15
|
+
export const MAX_CASCADE_DEPTH = 32
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Compose the event a subscriber actually receives. Order of precedence:
|
|
19
|
+
* 1. Payload from the source's emit selector (fields like `productId`, `items`).
|
|
20
|
+
* 2. Subscriber's `dispatch` declaration (`type` and any static fields it adds).
|
|
21
|
+
* 3. `sourceSessionId` when injected — set by the caller for cross-lifecycle
|
|
22
|
+
* subscriptions only.
|
|
23
|
+
* Subscriber-side fields override source payload fields on collision; the
|
|
24
|
+
* subscriber owns the shape of its own inbox.
|
|
25
|
+
*/
|
|
26
|
+
export function buildDispatchEvent(
|
|
27
|
+
emitted: { type?: string; [k: string]: unknown } | undefined,
|
|
28
|
+
dispatch: SubscribeEvent,
|
|
29
|
+
sourceSessionId?: string,
|
|
30
|
+
): { type: string; [k: string]: unknown } {
|
|
31
|
+
const { type: _emitType, ...payload } = emitted ?? {}
|
|
32
|
+
const dispatchBase = typeof dispatch === 'string' ? { type: dispatch } : dispatch
|
|
33
|
+
return {
|
|
34
|
+
...payload,
|
|
35
|
+
...dispatchBase,
|
|
36
|
+
...(sourceSessionId !== undefined ? { sourceSessionId } : {}),
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Machine registry + long-lived app-machine actors + persistence adapter.
|
|
42
|
+
*
|
|
43
|
+
* Session-lifecycle machine actors are *not* held here anymore — they are
|
|
44
|
+
* created transiently inside a SessionRuntime per request, hydrated from
|
|
45
|
+
* the Store, and dropped when the request completes. This keeps server
|
|
46
|
+
* memory bounded by (open SSE connections + active in-flight requests)
|
|
47
|
+
* rather than (every session ever seen).
|
|
48
|
+
*/
|
|
49
|
+
export class MachineStore {
|
|
50
|
+
private appInstances = new Map<string, InstanceHandle>()
|
|
51
|
+
private defs = new Map<string, AnyMachineDef>()
|
|
52
|
+
/** Reverse index over `subscribes`: for each emit source, the list of
|
|
53
|
+
* subscribing machines + which event they care about + what to dispatch.
|
|
54
|
+
* Computed once at construction so SessionRuntime.wireSubscriptions is
|
|
55
|
+
* a single Map lookup per session-machine. */
|
|
56
|
+
private subscribersBySource = new Map<
|
|
57
|
+
string,
|
|
58
|
+
Array<{
|
|
59
|
+
targetName: string
|
|
60
|
+
event: string
|
|
61
|
+
dispatch: { type: string; [k: string]: unknown }
|
|
62
|
+
}>
|
|
63
|
+
>()
|
|
64
|
+
|
|
65
|
+
/** Default per-session TTL in seconds. Applied on every persistTouched
|
|
66
|
+
* so a session stays alive as long as the user keeps interacting; idle
|
|
67
|
+
* sessions expire as a whole. Adapters honor this on `set()`. */
|
|
68
|
+
readonly sessionTtlSeconds: number
|
|
69
|
+
|
|
70
|
+
/** Persistence for `persist: true` APP machines (no TTL, one blob per
|
|
71
|
+
* machine). Defaults to in-memory (restart-wipe) — pass RedisAppStore for
|
|
72
|
+
* durable app state. */
|
|
73
|
+
readonly appStore: AppStore
|
|
74
|
+
|
|
75
|
+
/** Host-injected scheduler for app-machine effects (see server/effects.ts
|
|
76
|
+
* wireAppEffects). Set after construction to avoid an import cycle;
|
|
77
|
+
* unwired effects are dropped loudly. */
|
|
78
|
+
private appEffectScheduler: ((invocation: EffectInvocation) => void) | null = null
|
|
79
|
+
|
|
80
|
+
constructor(
|
|
81
|
+
defs: AnyMachineDef[],
|
|
82
|
+
readonly persistence: Store,
|
|
83
|
+
opts?: { sessionTtlSeconds?: number; appStore?: AppStore },
|
|
84
|
+
) {
|
|
85
|
+
this.sessionTtlSeconds = opts?.sessionTtlSeconds ?? 86400
|
|
86
|
+
this.appStore = opts?.appStore ?? new InMemoryAppStore()
|
|
87
|
+
for (const def of defs) {
|
|
88
|
+
if (this.defs.has(def.name)) {
|
|
89
|
+
throw new Error(`stator: duplicate machine name "${def.name}"`)
|
|
90
|
+
}
|
|
91
|
+
this.defs.set(def.name, def)
|
|
92
|
+
}
|
|
93
|
+
this.validateSubscriptions()
|
|
94
|
+
this.buildSubscriberIndex()
|
|
95
|
+
this.buildReaderIndex()
|
|
96
|
+
this.warnOnSubscriptionCycles()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Reverse index over `reads:`: for each machine, which machines' SELECTORS
|
|
100
|
+
* may derive from it. Drives touched-set expansion so display state that
|
|
101
|
+
* reads across machines re-diffs when its dependencies change. */
|
|
102
|
+
private readersBySource = new Map<string, string[]>()
|
|
103
|
+
|
|
104
|
+
private buildReaderIndex(): void {
|
|
105
|
+
for (const def of this.defs.values()) {
|
|
106
|
+
for (const r of def.reads) {
|
|
107
|
+
let list = this.readersBySource.get(r.name)
|
|
108
|
+
if (!list) {
|
|
109
|
+
list = []
|
|
110
|
+
this.readersBySource.set(r.name, list)
|
|
111
|
+
}
|
|
112
|
+
list.push(def.name)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Expand a touched set through the reverse-reads graph (transitively):
|
|
119
|
+
* if A's selectors read B and B changed, A's bindings must re-diff even
|
|
120
|
+
* though A's own state didn't move. Returns the expansion (machines whose
|
|
121
|
+
* DERIVED views changed) separately from the direct touches — callers
|
|
122
|
+
* scope them differently (derived session machines re-diff for every
|
|
123
|
+
* connection, no rehydration; direct session touches belong to one
|
|
124
|
+
* session).
|
|
125
|
+
*/
|
|
126
|
+
expandTouchedForRecompute(touched: ReadonlySet<string>): {
|
|
127
|
+
all: Set<string>
|
|
128
|
+
derived: Set<string>
|
|
129
|
+
} {
|
|
130
|
+
const all = new Set(touched)
|
|
131
|
+
const derived = new Set<string>()
|
|
132
|
+
const queue = [...touched]
|
|
133
|
+
while (queue.length > 0) {
|
|
134
|
+
const name = queue.shift()!
|
|
135
|
+
for (const reader of this.readersBySource.get(name) ?? []) {
|
|
136
|
+
if (!all.has(reader)) {
|
|
137
|
+
all.add(reader)
|
|
138
|
+
derived.add(reader)
|
|
139
|
+
queue.push(reader)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
return { all, derived }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Machine-level cycles in the `subscribes:` graph, one representative path
|
|
148
|
+
* per cycle (`['A', 'B', 'A']`). A machine-level cycle is a *potential*
|
|
149
|
+
* runtime loop, not a certain one — guards or state targeting may break it
|
|
150
|
+
* — so construction warns rather than rejects; the runtime cascade depth
|
|
151
|
+
* cap (see SessionRuntime.wireSubscriptions) is the hard backstop.
|
|
152
|
+
*/
|
|
153
|
+
findSubscriptionCycles(): string[][] {
|
|
154
|
+
const cycles: string[][] = []
|
|
155
|
+
const visiting = new Set<string>()
|
|
156
|
+
const done = new Set<string>()
|
|
157
|
+
const path: string[] = []
|
|
158
|
+
|
|
159
|
+
const visit = (name: string): void => {
|
|
160
|
+
if (done.has(name)) return
|
|
161
|
+
if (visiting.has(name)) {
|
|
162
|
+
cycles.push([...path.slice(path.indexOf(name)), name])
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
visiting.add(name)
|
|
166
|
+
path.push(name)
|
|
167
|
+
for (const sub of this.subscribersOf(name)) visit(sub.targetName)
|
|
168
|
+
path.pop()
|
|
169
|
+
visiting.delete(name)
|
|
170
|
+
done.add(name)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
for (const name of this.defs.keys()) visit(name)
|
|
174
|
+
return cycles
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
private warnOnSubscriptionCycles(): void {
|
|
178
|
+
for (const cycle of this.findSubscriptionCycles()) {
|
|
179
|
+
storeLog.warn(
|
|
180
|
+
{ cycle: cycle.join(' → ') },
|
|
181
|
+
'stator: subscription cycle — emits between these machines can cascade in a loop. ' +
|
|
182
|
+
'Ensure a guard breaks the chain; the runtime aborts cascades deeper than ' +
|
|
183
|
+
`${MAX_CASCADE_DEPTH} hops.`,
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private validateSubscriptions(): void {
|
|
189
|
+
for (const def of this.defs.values()) {
|
|
190
|
+
for (const sub of def.subscribes) {
|
|
191
|
+
if (sub.from == null) {
|
|
192
|
+
// The classic cause: a circular import between machine modules.
|
|
193
|
+
// Bundler/loader interop (Vite SSR, tsx) resolves the mid-cycle
|
|
194
|
+
// binding to `undefined` SILENTLY — no TDZ error — so this check
|
|
195
|
+
// is where the mistake first becomes visible. Name it properly.
|
|
196
|
+
throw new Error(
|
|
197
|
+
`stator: machine "${def.name}" declares a subscription whose \`from\` is undefined. ` +
|
|
198
|
+
`This is almost always a circular import between machine modules — the module ` +
|
|
199
|
+
`defining "${def.name}" and the module defining its subscription source import ` +
|
|
200
|
+
`each other, and the loader resolved the mid-cycle binding to undefined. ` +
|
|
201
|
+
`Break the module cycle (e.g. move one machine or the subscription), ` +
|
|
202
|
+
`or define the mutually-subscribing machines in one module.`,
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
if (!this.defs.has(sub.from.name)) {
|
|
206
|
+
throw new Error(
|
|
207
|
+
`stator: machine "${def.name}" subscribes to unknown machine "${sub.from.name}"`,
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
if (sub.from.lifecycle === 'app' && def.lifecycle === 'session') {
|
|
211
|
+
throw new Error(
|
|
212
|
+
`stator: app-lifecycle source "${sub.from.name}" cannot deliver to ` +
|
|
213
|
+
`session-lifecycle target "${def.name}" yet — app→session needs the ` +
|
|
214
|
+
`inbox model (see .chisel/specs/active/app-to-session-subscriptions-via-inbox.md). ` +
|
|
215
|
+
`Use session→app or same-lifecycle subscriptions for now.`,
|
|
216
|
+
)
|
|
217
|
+
}
|
|
218
|
+
const emitNames = Object.keys(sub.from.emits)
|
|
219
|
+
if (emitNames.length > 0 && !(sub.event in sub.from.emits)) {
|
|
220
|
+
throw new Error(
|
|
221
|
+
`stator: machine "${def.name}" subscribes to "${sub.from.name}.${sub.event}", ` +
|
|
222
|
+
`but "${sub.from.name}" does not declare "${sub.event}" in its emits.`,
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private buildSubscriberIndex(): void {
|
|
230
|
+
for (const def of this.defs.values()) {
|
|
231
|
+
for (const sub of def.subscribes) {
|
|
232
|
+
const dispatch = typeof sub.dispatch === 'string' ? { type: sub.dispatch } : sub.dispatch
|
|
233
|
+
let list = this.subscribersBySource.get(sub.from.name)
|
|
234
|
+
if (!list) {
|
|
235
|
+
list = []
|
|
236
|
+
this.subscribersBySource.set(sub.from.name, list)
|
|
237
|
+
}
|
|
238
|
+
list.push({ targetName: def.name, event: sub.event, dispatch })
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async bootAppMachines(): Promise<void> {
|
|
244
|
+
for (const def of this.defs.values()) {
|
|
245
|
+
if (def.lifecycle === 'app' && !this.appInstances.has(def.name)) {
|
|
246
|
+
const snapshot = def.persist ? await this.loadAppSnapshot(def.name) : undefined
|
|
247
|
+
const actor = createActor(def, {
|
|
248
|
+
snapshot,
|
|
249
|
+
resolveHelpers: serverReadsResolver(def),
|
|
250
|
+
// App effects run through the host scheduler wired by
|
|
251
|
+
// wireAppEffects (server/effects.ts) — injected post-construction
|
|
252
|
+
// to avoid an import cycle. Unwired: dropped loudly rather than
|
|
253
|
+
// half-run without fan-out or persistence.
|
|
254
|
+
onEffect: (invocation) => {
|
|
255
|
+
if (this.appEffectScheduler) this.appEffectScheduler(invocation)
|
|
256
|
+
else
|
|
257
|
+
storeLog.warn(
|
|
258
|
+
{ machine: invocation.machineName, effectId: invocation.effectId },
|
|
259
|
+
'app-machine effect dropped — no scheduler wired (call wireAppEffects(store))',
|
|
260
|
+
)
|
|
261
|
+
},
|
|
262
|
+
}).start()
|
|
263
|
+
this.appInstances.set(
|
|
264
|
+
def.name,
|
|
265
|
+
// App machines resolve selector reads among app instances only —
|
|
266
|
+
// "which session?" has no answer at app scope.
|
|
267
|
+
createInstanceProxy(def, actor, (name) => this.appInstance(name)?.proxy),
|
|
268
|
+
)
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
this.wireAppSubscriptions()
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Load + validate a persisted app snapshot. Corrupt or unloadable state
|
|
275
|
+
* logs loud and boots fresh — restart-fresh is the safe default. */
|
|
276
|
+
private async loadAppSnapshot(name: string): Promise<Snapshot<object> | undefined> {
|
|
277
|
+
try {
|
|
278
|
+
const raw = await this.appStore.loadAppMachine(name)
|
|
279
|
+
if (raw === null) return undefined
|
|
280
|
+
const snap = raw as Snapshot<object>
|
|
281
|
+
if (!Array.isArray(snap.value) || typeof snap.context !== 'object' || snap.context === null) {
|
|
282
|
+
throw new Error('snapshot shape invalid (expected { value: string[], context: object })')
|
|
283
|
+
}
|
|
284
|
+
return snap
|
|
285
|
+
} catch (err) {
|
|
286
|
+
storeLog.error(
|
|
287
|
+
{ machine: name, err: String(err) },
|
|
288
|
+
'persisted app-machine snapshot unusable — booting fresh',
|
|
289
|
+
)
|
|
290
|
+
return undefined
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Install the app-effect scheduler (see server/effects.ts wireAppEffects). */
|
|
295
|
+
setAppEffectScheduler(scheduler: (invocation: EffectInvocation) => void): void {
|
|
296
|
+
this.appEffectScheduler = scheduler
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Persist one app machine's snapshot, if it opted in. Safe no-op for
|
|
300
|
+
* session machines, non-persist app machines, and unknown names — callers
|
|
301
|
+
* pass raw touched-set entries. */
|
|
302
|
+
async persistAppMachine(name: string): Promise<void> {
|
|
303
|
+
const def = this.defs.get(name)
|
|
304
|
+
if (def?.lifecycle !== 'app' || !def.persist) return
|
|
305
|
+
const handle = this.appInstances.get(name)
|
|
306
|
+
if (!handle) return
|
|
307
|
+
await this.appStore.saveAppMachine(name, handle.actor.getPersistedSnapshot())
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Wire app→app subscription listeners. Session-involved subscriptions
|
|
311
|
+
* (session→session, session→app) are wired per-request inside
|
|
312
|
+
* SessionRuntime. Same-lifecycle so no sourceSessionId injection. */
|
|
313
|
+
private wireAppSubscriptions(): void {
|
|
314
|
+
for (const targetHandle of this.appInstances.values()) {
|
|
315
|
+
const targetName = targetHandle.def.name
|
|
316
|
+
for (const sub of targetHandle.def.subscribes) {
|
|
317
|
+
const sourceHandle = this.appInstances.get(sub.from.name)
|
|
318
|
+
if (!sourceHandle) continue
|
|
319
|
+
sourceHandle.actor.on(sub.event as never, (emitted) => {
|
|
320
|
+
targetHandle.actor.send(buildDispatchEvent(emitted, sub.dispatch) as never)
|
|
321
|
+
recordTouch(targetName)
|
|
322
|
+
})
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
appInstance(name: string): InstanceHandle | undefined {
|
|
328
|
+
return this.appInstances.get(name)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
getDef(name: string): AnyMachineDef | undefined {
|
|
332
|
+
return this.defs.get(name)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
allDefs(): AnyMachineDef[] {
|
|
336
|
+
return [...this.defs.values()]
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Subscribers (target machine + dispatch info) for a given source machine. */
|
|
340
|
+
subscribersOf(sourceName: string): ReadonlyArray<{
|
|
341
|
+
targetName: string
|
|
342
|
+
event: string
|
|
343
|
+
dispatch: { type: string; [k: string]: unknown }
|
|
344
|
+
}> {
|
|
345
|
+
return this.subscribersBySource.get(sourceName) ?? []
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
hasMachine(name: string): boolean {
|
|
349
|
+
return this.defs.has(name)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
async disposeSession(sessionId: string): Promise<void> {
|
|
353
|
+
await this.persistence.deleteSession(sessionId)
|
|
354
|
+
}
|
|
355
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ActionHelpers, AnyMachineDef } from '../engine/index.ts'
|
|
2
|
+
import { getDispatchContext } from './dispatch-context.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Server-side resolver for an action/guard's `reads` helper. The engine is
|
|
6
|
+
* host-agnostic (it imports no server module); the *server* supplies how reads
|
|
7
|
+
* are resolved — through the active dispatch context's runtime, exactly as the
|
|
8
|
+
* POC did. A reads-free machine never invokes this; a machine that dereferences
|
|
9
|
+
* reads outside an active dispatch gets a clear error (matching prior behavior).
|
|
10
|
+
*/
|
|
11
|
+
export function serverReadsResolver(def: AnyMachineDef): () => ActionHelpers {
|
|
12
|
+
return () => {
|
|
13
|
+
const dc = getDispatchContext()
|
|
14
|
+
if (!dc) {
|
|
15
|
+
return {
|
|
16
|
+
reads: new Proxy({} as Record<string, unknown>, {
|
|
17
|
+
get(_t, prop) {
|
|
18
|
+
throw new Error(
|
|
19
|
+
`stator: "${def.name}" accessed reads.${String(prop)} outside an active dispatch — ` +
|
|
20
|
+
`actions/guards that use reads must run through store.processEvent(...) ` +
|
|
21
|
+
`(or actor.send() inside a subscription handler), not a bare send.`,
|
|
22
|
+
)
|
|
23
|
+
},
|
|
24
|
+
}),
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const reads: Record<string, unknown> = {}
|
|
28
|
+
for (const r of def.reads) {
|
|
29
|
+
const proxy = dc.runtime.proxyFor(r.name)
|
|
30
|
+
if (!proxy) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`stator: "${def.name}" declares reads on "${r.name}" but it's not loaded ` +
|
|
33
|
+
`in the active runtime — loadGraph(...) should pull it in transitively.`,
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
reads[r.name] = proxy
|
|
37
|
+
}
|
|
38
|
+
return { reads }
|
|
39
|
+
}
|
|
40
|
+
}
|