@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.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +104 -0
  3. package/package.json +99 -0
  4. package/src/build/build.ts +244 -0
  5. package/src/build/head.ts +43 -0
  6. package/src/build/index.ts +10 -0
  7. package/src/build/sync.ts +65 -0
  8. package/src/client/bind.ts +47 -0
  9. package/src/client/client-id.ts +10 -0
  10. package/src/client/dispatch.ts +62 -0
  11. package/src/client/element.ts +156 -0
  12. package/src/client/index.ts +13 -0
  13. package/src/client/inspector.ts +237 -0
  14. package/src/client/machine.ts +39 -0
  15. package/src/client/runtime.ts +246 -0
  16. package/src/client/use.ts +119 -0
  17. package/src/compiler/client-emit.ts +180 -0
  18. package/src/compiler/client-script.ts +256 -0
  19. package/src/compiler/compile.ts +459 -0
  20. package/src/compiler/diagnostics.ts +65 -0
  21. package/src/compiler/dts.ts +87 -0
  22. package/src/compiler/hash.ts +8 -0
  23. package/src/compiler/index.ts +48 -0
  24. package/src/compiler/lower.ts +0 -0
  25. package/src/compiler/regions.ts +79 -0
  26. package/src/compiler/split.ts +168 -0
  27. package/src/compiler/styles.ts +200 -0
  28. package/src/compiler/virtual-code.ts +184 -0
  29. package/src/components/index.ts +2 -0
  30. package/src/components/json-ld.ts +85 -0
  31. package/src/engine/actor.ts +248 -0
  32. package/src/engine/define-machine.ts +113 -0
  33. package/src/engine/index.ts +37 -0
  34. package/src/engine/types.ts +236 -0
  35. package/src/server/api-route.ts +149 -0
  36. package/src/server/app-dispatch.ts +52 -0
  37. package/src/server/app-store.ts +35 -0
  38. package/src/server/cached-store.ts +117 -0
  39. package/src/server/create-app.ts +83 -0
  40. package/src/server/define-machine.ts +10 -0
  41. package/src/server/dev.ts +255 -0
  42. package/src/server/discovery.ts +111 -0
  43. package/src/server/dispatch-context.ts +39 -0
  44. package/src/server/effects.ts +120 -0
  45. package/src/server/http.ts +475 -0
  46. package/src/server/index.ts +101 -0
  47. package/src/server/instance-proxy.ts +95 -0
  48. package/src/server/logger.ts +52 -0
  49. package/src/server/machine-store.ts +355 -0
  50. package/src/server/reads-helpers.ts +40 -0
  51. package/src/server/recompute.ts +287 -0
  52. package/src/server/redis-store.ts +116 -0
  53. package/src/server/render-context.ts +228 -0
  54. package/src/server/render.ts +111 -0
  55. package/src/server/route-discovery.ts +226 -0
  56. package/src/server/route-request.ts +36 -0
  57. package/src/server/routing.ts +175 -0
  58. package/src/server/session-lock.ts +33 -0
  59. package/src/server/session-runtime.ts +242 -0
  60. package/src/server/session.ts +29 -0
  61. package/src/server/sse.ts +175 -0
  62. package/src/server/store.ts +95 -0
  63. package/src/stator-modules.d.ts +12 -0
  64. package/src/template/client-shell.ts +65 -0
  65. package/src/template/conditional.ts +166 -0
  66. package/src/template/directives/core.ts +58 -0
  67. package/src/template/directives/list-attr.ts +183 -0
  68. package/src/template/directives/on.ts +22 -0
  69. package/src/template/each.ts +295 -0
  70. package/src/template/html.ts +180 -0
  71. package/src/template/index.ts +29 -0
  72. package/src/template/parser.ts +341 -0
  73. package/src/template/read.ts +50 -0
  74. package/src/template/types.ts +33 -0
  75. package/src/vite/index.ts +6 -0
  76. package/src/vite/plugin.ts +151 -0
  77. package/src/vite/stub.ts +79 -0
  78. package/src/wire/apply.ts +103 -0
  79. package/src/wire/index.ts +67 -0
@@ -0,0 +1,255 @@
1
+ import { readFile } from 'node:fs/promises'
2
+ import { createServer as createHttpServer } from 'node:http'
3
+ import { relative, resolve } from 'node:path'
4
+ import { getRequestListener } from '@hono/node-server'
5
+ import type { Hono } from 'hono'
6
+ import { createServer as createViteServer, type ViteDevServer } from 'vite'
7
+ import { compile } from '../compiler/index.ts'
8
+ import { machineStub, stator } from '../vite/index.ts'
9
+ import type { AppStore } from './app-store.ts'
10
+ import { logger } from './logger.ts'
11
+ import type { MachineStore } from './machine-store.ts'
12
+ import type { DiscoveredRoute } from './route-discovery.ts'
13
+ import type { Store } from './store.ts'
14
+
15
+ /**
16
+ * Dev server: embeds Vite in middleware mode so `.stator` (and TS) modules are
17
+ * compiled on the way in, loads machines + routes through `vite.ssrLoadModule`,
18
+ * and injects each route's scoped component CSS into `<head>` at render time
19
+ * (SSR head injection — independent of the client JS bundle).
20
+ *
21
+ * Critically, the framework runtime itself is loaded *through Vite*
22
+ * (`ssrLoadModule('@statorjs/stator/server')`), not imported natively — so the
23
+ * render-context, machine defs, routes, and templates all share one module
24
+ * instance. Importing the runtime natively here would create a second instance
25
+ * whose render-context doesn't match the one the templates resolve against, and
26
+ * `read()` would throw. (This mirrors how Astro/SvelteKit run SSR through Vite.)
27
+ *
28
+ * The production serve path (pre-built assets + manifest, no Vite) is a separate
29
+ * follow-up; this is the dev half of Phase 3a.
30
+ */
31
+
32
+ export interface DevAppConfig {
33
+ /** Vite root — the app directory (must reach node_modules for resolution). */
34
+ root: string
35
+ machinesDir: string
36
+ routesDir: string
37
+ staticDir?: string
38
+ store?: Store
39
+ /** Persistence for `persist: true` app machines. Defaults to in-memory. */
40
+ appStore?: AppStore
41
+ sessionTtlSeconds?: number
42
+ /** Auto-inject the dev inspector toolbar. On by default; set false to disable. */
43
+ inspector?: boolean
44
+ }
45
+
46
+ export interface DevApp {
47
+ fetch: (request: Request) => Response | Promise<Response>
48
+ vite: ViteDevServer
49
+ listen: (port: number) => Promise<void>
50
+ close: () => Promise<void>
51
+ }
52
+
53
+ export async function createDevApp(config: DevAppConfig): Promise<DevApp> {
54
+ const vite = await createViteServer({
55
+ root: resolve(config.root),
56
+ appType: 'custom',
57
+ server: { middlewareMode: true },
58
+ // machineStub keeps server machines out of browser module graphs: island
59
+ // imports of a machine resolve to a `{ name }` stub (SSR loads are
60
+ // untouched — `options.ssr` gates it).
61
+ plugins: [stator(), machineStub({ machinesDir: resolve(config.machinesDir) })],
62
+ // The framework's own source is TS that Vite must transform, not externalize.
63
+ ssr: { noExternal: [/@statorjs\/stator/] },
64
+ logLevel: 'warn',
65
+ })
66
+
67
+ // Load the runtime through Vite so it shares an instance with the templates.
68
+ // (Type-only: the static import type is erased, so no second instance.)
69
+ const runtime = (await vite.ssrLoadModule(
70
+ '@statorjs/stator/server',
71
+ )) as typeof import('./index.ts')
72
+ const loader = (file: string) => vite.ssrLoadModule(file) as Promise<Record<string, unknown>>
73
+
74
+ const root = resolve(config.root)
75
+ const machinesDir = resolve(config.machinesDir)
76
+ const routesDir = resolve(config.routesDir)
77
+ const inspectorOn = config.inspector ?? true
78
+
79
+ const resultCache = new Map<string, ReturnType<typeof compile>>()
80
+ const compiledFor = async (file: string) => {
81
+ let r = resultCache.get(file)
82
+ if (r === undefined) {
83
+ // Match the plugin's kind detection so route-page frontmatter
84
+ // (Stator.reads etc.) compiles under the route capability set.
85
+ const kind = /[\\/]routes[\\/].*\.stator$/.test(file) ? 'route' : 'component'
86
+ r = compile(await readFile(file, 'utf8'), { id: file, kind })
87
+ resultCache.set(file, r)
88
+ }
89
+ return r
90
+ }
91
+
92
+ const headExtras = async (routeFile: string) => {
93
+ const files = reachableStatorFiles(vite, routeFile)
94
+ let css = ''
95
+ const scripts: string[] = []
96
+ for (const f of files) {
97
+ const r = await compiledFor(f)
98
+ if (r.css) css += `/* ${f} */\n${r.css}\n`
99
+ if (r.isClient) {
100
+ // Vite's transform middleware compiles this URL because browsers send
101
+ // `Sec-Fetch-Dest: script` for module scripts (the `.stator` extension
102
+ // alone wouldn't match its JS-request check). curl-style probes must
103
+ // send that header or they'll see raw source and cry wolf.
104
+ const url = `/${relative(root, f).replace(/\\/g, '/')}?stator&type=client`
105
+ scripts.push(`<script type="module" src="${url}"></script>`)
106
+ }
107
+ }
108
+ const head: string[] = []
109
+ // Vite's HMR client — pages are rendered by Hono, not Vite's
110
+ // transformIndexHtml, so it isn't injected for us. Without it the browser
111
+ // has no socket to receive the full-reload signal on a source change.
112
+ head.push('<script type="module" src="/@vite/client"></script>')
113
+ // The dev inspector toolbar (off in production — only the dev server injects it).
114
+ if (inspectorOn) head.push('<script src="/@stator/inspector.js" defer></script>')
115
+ if (css.trim()) head.push(`<style data-stator-dev>\n${css.trim()}\n</style>`)
116
+ head.push(...scripts)
117
+ return head.join('\n')
118
+ }
119
+
120
+ // The app graph is rebuilt on a source change so edits don't need a restart.
121
+ let store: MachineStore
122
+ let routes: DiscoveredRoute[] = []
123
+ let machineCount = 0
124
+ let app: Hono
125
+
126
+ const rebuildStore = async (): Promise<void> => {
127
+ const { defs } = await runtime.discoverMachines(machinesDir, loader)
128
+ machineCount = defs.length
129
+ store = new runtime.MachineStore(defs, config.store ?? new runtime.InMemoryStore(), {
130
+ sessionTtlSeconds: config.sessionTtlSeconds,
131
+ appStore: config.appStore,
132
+ })
133
+ await store.bootAppMachines()
134
+ runtime.wireAppEffects(store)
135
+ }
136
+ const rebuildRoutes = async (): Promise<void> => {
137
+ routes = await runtime.discoverRoutes(routesDir, loader)
138
+ }
139
+ const rebuildServer = async (): Promise<void> => {
140
+ app = await runtime.buildHonoApp({
141
+ routes,
142
+ store,
143
+ staticDir: config.staticDir,
144
+ headExtras,
145
+ inspector: inspectorOn,
146
+ })
147
+ }
148
+
149
+ await rebuildStore()
150
+ await rebuildRoutes()
151
+ await rebuildServer()
152
+
153
+ // Live reload: on a relevant source change, re-discover and rebuild the app,
154
+ // then tell the browser to reload. A template/route edit keeps the store (and
155
+ // your session — cart contents and all) intact; only a machine edit resets it,
156
+ // since route `reads:` bind to machine defs by identity and must re-bind as a
157
+ // set. Rebuilds are serialized so overlapping saves can't race.
158
+ const isAppFile = (file: string): boolean => {
159
+ if (!/\.(stator|ts|js)$/.test(file)) return false
160
+ if (file.includes('/node_modules/') || file.includes('/dist/')) return false
161
+ if (file.startsWith(machinesDir) || file.startsWith(routesDir)) return true
162
+ return (vite.moduleGraph.getModulesByFile(file)?.size ?? 0) > 0
163
+ }
164
+ let reloadChain: Promise<void> = Promise.resolve()
165
+ const onChange = (file: string): void => {
166
+ const abs = resolve(file)
167
+ if (!isAppFile(abs)) return
168
+ reloadChain = reloadChain.then(async () => {
169
+ invalidateModuleTree(vite, abs)
170
+ resultCache.delete(abs)
171
+ try {
172
+ if (abs.startsWith(machinesDir)) await rebuildStore()
173
+ await rebuildRoutes()
174
+ await rebuildServer()
175
+ vite.ws.send({ type: 'full-reload' })
176
+ logger.info({ file: relative(root, abs) }, 'reloaded')
177
+ } catch (err) {
178
+ logger.error({ err: (err as Error).message, file: relative(root, abs) }, 'reload failed')
179
+ }
180
+ })
181
+ }
182
+ vite.watcher.on('change', onChange)
183
+ vite.watcher.on('add', onChange)
184
+ vite.watcher.on('unlink', onChange)
185
+
186
+ return {
187
+ fetch: (request) => app.fetch(request),
188
+ vite,
189
+ listen(port: number): Promise<void> {
190
+ // Vite's middlewares serve client modules + HMR to the browser; anything
191
+ // it doesn't handle (routes, /__events, /__sse) falls through to Hono.
192
+ // The arrow re-reads `app` each request so a rebuild swaps in seamlessly.
193
+ const honoListener = getRequestListener((req) => app.fetch(req))
194
+ const server = createHttpServer((req, res) => {
195
+ vite.middlewares(req, res, () => honoListener(req, res))
196
+ })
197
+ return new Promise((resolveFn) => {
198
+ server.listen(port, () => {
199
+ logger.info(
200
+ {
201
+ port,
202
+ mode: 'dev',
203
+ machines: machineCount,
204
+ routes: routes.length,
205
+ },
206
+ 'listening',
207
+ )
208
+ resolveFn()
209
+ })
210
+ })
211
+ },
212
+ close: () => vite.close(),
213
+ }
214
+ }
215
+
216
+ /** Invalidate a changed file and everything that (transitively) imports it, so
217
+ * the next `ssrLoadModule` re-executes them with fresh code. */
218
+ function invalidateModuleTree(vite: ViteDevServer, file: string): void {
219
+ const seen = new Set<unknown>()
220
+ const stack: Array<{ importers: Set<unknown> }> = [
221
+ ...((vite.moduleGraph.getModulesByFile(file) ?? []) as Set<never>),
222
+ ]
223
+ while (stack.length) {
224
+ const mod = stack.pop()
225
+ if (!mod || seen.has(mod)) continue
226
+ seen.add(mod)
227
+ vite.moduleGraph.invalidateModule(mod as never)
228
+ for (const imp of mod.importers) stack.push(imp as never)
229
+ }
230
+ }
231
+
232
+ /**
233
+ * Walk the SSR module graph from a route's file and return every `.stator` file
234
+ * reachable from it (route page + the components it renders). The caller compiles
235
+ * each to collect scoped CSS and client-component module scripts.
236
+ */
237
+ function reachableStatorFiles(vite: ViteDevServer, entryFile: string): string[] {
238
+ const seen = new Set<string>()
239
+ const statorFiles = new Set<string>()
240
+
241
+ const visit = (node: { id: string | null; importedModules: Set<unknown> } | undefined): void => {
242
+ if (!node) return
243
+ const id = node.id ?? ''
244
+ if (seen.has(id)) return
245
+ seen.add(id)
246
+ const file = id.split('?')[0]!
247
+ if (/\.stator$/.test(file)) statorFiles.add(file)
248
+ for (const dep of node.importedModules) visit(dep as never)
249
+ }
250
+
251
+ for (const node of vite.moduleGraph.getModulesByFile(entryFile) ?? []) {
252
+ visit(node as never)
253
+ }
254
+ return [...statorFiles]
255
+ }
@@ -0,0 +1,111 @@
1
+ import { readdir } from 'node:fs/promises'
2
+ import { extname, resolve } from 'node:path'
3
+ import { pathToFileURL } from 'node:url'
4
+ import { type AnyMachineDef, isStatorMachine } from './define-machine.ts'
5
+
6
+ export interface DiscoveryResult {
7
+ defs: AnyMachineDef[]
8
+ }
9
+
10
+ /** How a discovered file is turned into a module. Defaults to native dynamic
11
+ * import; the dev server injects Vite's `ssrLoadModule` so `.stator` imports
12
+ * (and TS) are compiled on the way in. */
13
+ export type ModuleLoader = (file: string) => Promise<Record<string, unknown>>
14
+
15
+ const nativeLoader: ModuleLoader = (file) => import(/* @vite-ignore */ pathToFileURL(file).href)
16
+
17
+ export async function discoverMachines(
18
+ dir: string,
19
+ load: ModuleLoader = nativeLoader,
20
+ ): Promise<DiscoveryResult> {
21
+ const absDir = resolve(dir)
22
+ // A missing conventional dir means "no machines yet" (e.g. early in a fresh
23
+ // project), not an error. A *present* file that isn't a machine still throws
24
+ // below — that's a real mistake, not an absence.
25
+ const entries = await readdir(absDir, { withFileTypes: true }).catch(
26
+ (e: NodeJS.ErrnoException) => {
27
+ if (e.code === 'ENOENT') return []
28
+ throw e
29
+ },
30
+ )
31
+
32
+ const files: string[] = []
33
+ for (const e of entries) {
34
+ if (e.isFile() && (extname(e.name) === '.ts' || extname(e.name) === '.js')) {
35
+ files.push(resolve(absDir, e.name))
36
+ }
37
+ }
38
+
39
+ const defs: AnyMachineDef[] = []
40
+ const seenNames = new Set<string>()
41
+
42
+ for (const file of files) {
43
+ const mod = await load(file)
44
+ const def = mod.default
45
+ if (!isStatorMachine(def)) {
46
+ throw new Error(
47
+ `stator: ${file} default export is not a stator machine. ` +
48
+ `Did you forget to call defineMachine()?`,
49
+ )
50
+ }
51
+ if (seenNames.has(def.name)) {
52
+ throw new Error(`stator: duplicate machine name "${def.name}" in ${file}`)
53
+ }
54
+ seenNames.add(def.name)
55
+ defs.push(def)
56
+ }
57
+
58
+ validateReads(defs, absDir)
59
+
60
+ return { defs: topoSort(defs) }
61
+ }
62
+
63
+ function validateReads(defs: AnyMachineDef[], dir: string): void {
64
+ const byName = new Map(defs.map((d) => [d.name, d]))
65
+ for (const def of defs) {
66
+ for (const dep of def.reads) {
67
+ if (!byName.has(dep.name)) {
68
+ throw new Error(
69
+ `stator: machine "${def.name}" reads from "${dep.name}", which was not ` +
70
+ `discovered in ${dir}. Both must live in the machines directory.`,
71
+ )
72
+ }
73
+ if (def.lifecycle === 'app' && dep.lifecycle === 'session') {
74
+ throw new Error(
75
+ `stator: app-lifecycle machine "${def.name}" cannot read session-lifecycle ` +
76
+ `machine "${dep.name}". App machines exist before any session.`,
77
+ )
78
+ }
79
+ }
80
+ }
81
+ }
82
+
83
+ function topoSort(defs: AnyMachineDef[]): AnyMachineDef[] {
84
+ const sorted: AnyMachineDef[] = []
85
+ const WHITE = 0
86
+ const GRAY = 1
87
+ const BLACK = 2
88
+ const color = new Map<AnyMachineDef, number>()
89
+ for (const d of defs) color.set(d, WHITE)
90
+
91
+ const path: AnyMachineDef[] = []
92
+
93
+ const visit = (def: AnyMachineDef): void => {
94
+ const c = color.get(def) ?? WHITE
95
+ if (c === BLACK) return
96
+ if (c === GRAY) {
97
+ const startIdx = path.indexOf(def)
98
+ const cycle = [...path.slice(startIdx), def].map((d) => d.name).join(' -> ')
99
+ throw new Error(`stator: dependency cycle detected: ${cycle}`)
100
+ }
101
+ color.set(def, GRAY)
102
+ path.push(def)
103
+ for (const dep of def.reads) visit(dep)
104
+ path.pop()
105
+ color.set(def, BLACK)
106
+ sorted.push(def)
107
+ }
108
+
109
+ for (const def of defs) visit(def)
110
+ return sorted
111
+ }
@@ -0,0 +1,39 @@
1
+ import type { SessionRuntime } from './session-runtime.ts'
2
+
3
+ /**
4
+ * The slice of runtime context an action or guard needs while a single
5
+ * event is being processed:
6
+ * - `runtime` so it can resolve `reads:` proxies on demand (both session
7
+ * and app-scoped) via the active SessionRuntime
8
+ * - `touched` for cross-machine subscription listeners to record which
9
+ * machines need recomputed patches after the event settles
10
+ *
11
+ * Populated by `SessionRuntime.processEvent`. A single dispatch may fan
12
+ * out through subscription listeners that synchronously call
13
+ * `actor.send` on other machines; all of those nested calls share this
14
+ * same context, so actions running in any of them see the same runtime.
15
+ */
16
+ export interface DispatchContext {
17
+ runtime: SessionRuntime
18
+ touched: Set<string>
19
+ }
20
+
21
+ let current: DispatchContext | null = null
22
+
23
+ export function getDispatchContext(): DispatchContext | null {
24
+ return current
25
+ }
26
+
27
+ export function withDispatchContext<T>(ctx: DispatchContext, fn: () => T): T {
28
+ const prev = current
29
+ current = ctx
30
+ try {
31
+ return fn()
32
+ } finally {
33
+ current = prev
34
+ }
35
+ }
36
+
37
+ export function recordTouch(machineName: string): void {
38
+ if (current) current.touched.add(machineName)
39
+ }
@@ -0,0 +1,120 @@
1
+ import type { AnyMachineDef, EffectInvocation } from '../engine/index.ts'
2
+ import { dispatchToApp } from './app-dispatch.ts'
3
+ import { scopedLogger } from './logger.ts'
4
+ import type { MachineStore } from './machine-store.ts'
5
+ import { withSessionLock } from './session-lock.ts'
6
+ import { SessionRuntime } from './session-runtime.ts'
7
+ import { fanOut } from './sse.ts'
8
+
9
+ const effectLog = scopedLogger('effect')
10
+
11
+ /**
12
+ * Install the APP-plane effect scheduler on a MachineStore (injected
13
+ * post-construction — see MachineStore.setAppEffectScheduler). App
14
+ * completions are simpler than session ones: the actor is long-lived and
15
+ * in-process, so the completion goes through `dispatchToApp` — atomic send,
16
+ * persist opted-in machines, fan out. No lock involved.
17
+ *
18
+ * `createApp` and the dev server call this; a hand-rolled server that
19
+ * constructs MachineStore directly should too.
20
+ */
21
+ export function wireAppEffects(store: MachineStore): void {
22
+ store.setAppEffectScheduler((invocation) => {
23
+ void runAppEffect(invocation, store)
24
+ })
25
+ }
26
+
27
+ async function runAppEffect(invocation: EffectInvocation, store: MachineStore): Promise<void> {
28
+ const { machineName, effectId } = invocation
29
+ let completion: Awaited<ReturnType<EffectInvocation['run']>>
30
+ try {
31
+ completion = await invocation.run()
32
+ } catch (err) {
33
+ effectLog.error(
34
+ { machine: machineName, effectId, err: String(err) },
35
+ 'effect threw — effects must catch and return their failure event; dropped',
36
+ )
37
+ return
38
+ }
39
+ if (!completion) return
40
+ try {
41
+ const def = store.getDef(machineName)
42
+ if (!def) return // graph changed under us (dev reload) — drop
43
+ await dispatchToApp(store, def as AnyMachineDef, completion as never)
44
+ } catch (err) {
45
+ effectLog.error(
46
+ { machine: machineName, effectId, err: String(err) },
47
+ 'effect completion dispatch failed',
48
+ )
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Server-plane effect scheduling for SESSION machines.
54
+ *
55
+ * The session runtime queues invocations during `processEvent` (the actor's
56
+ * `onEffect` hook); an entry point (POST /__events, API route) calls
57
+ * `scheduleSessionEffects` after it has persisted — the effect's I/O then runs
58
+ * with **no session lock held**. The completion event re-enters through the
59
+ * full event path: fresh lock, fresh runtime hydrate (the triggering actor is
60
+ * long gone — the transient-actor model working for us), process, persist,
61
+ * fan out to live SSE connections. Non-live pages simply see the new state on
62
+ * their next request.
63
+ *
64
+ * At-most-once, non-durable (1.0 contract): a crash between commit and
65
+ * completion loses the effect; the machine stays in its pending state.
66
+ */
67
+ export function scheduleSessionEffects(
68
+ runtime: SessionRuntime,
69
+ store: MachineStore,
70
+ sessionId: string,
71
+ ): void {
72
+ for (const invocation of runtime.drainPendingEffects()) {
73
+ void runSessionEffect(invocation, store, sessionId)
74
+ }
75
+ }
76
+
77
+ async function runSessionEffect(
78
+ invocation: EffectInvocation,
79
+ store: MachineStore,
80
+ sessionId: string,
81
+ ): Promise<void> {
82
+ const { machineName, effectId } = invocation
83
+ let completion: Awaited<ReturnType<EffectInvocation['run']>>
84
+ try {
85
+ completion = await invocation.run()
86
+ } catch (err) {
87
+ // Backstop only — the type contract asks effects to catch and return
88
+ // their failure event. Never crashes the host.
89
+ effectLog.error(
90
+ { machine: machineName, effectId, err: String(err) },
91
+ 'effect threw — effects must catch and return their failure event; dropped',
92
+ )
93
+ return
94
+ }
95
+ if (!completion) return
96
+
97
+ try {
98
+ await withSessionLock(sessionId, async () => {
99
+ const runtime = new SessionRuntime(sessionId, store)
100
+ try {
101
+ const def = store.getDef(machineName)
102
+ if (!def) return // machine graph changed under us (dev reload) — drop
103
+ // loadGraph pulls reads + subscribers transitively, so completion
104
+ // emits reach cross-machine listeners like any other event.
105
+ await runtime.loadGraph([def])
106
+ runtime.wireSubscriptions()
107
+ const touched = runtime.processEvent(machineName, completion)
108
+ await runtime.persistTouched(touched)
109
+ await fanOut(touched, { sessionId })
110
+ } finally {
111
+ runtime.dispose()
112
+ }
113
+ })
114
+ } catch (err) {
115
+ effectLog.error(
116
+ { machine: machineName, effectId, err: String(err) },
117
+ 'effect completion dispatch failed',
118
+ )
119
+ }
120
+ }