@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,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
|
+
}
|