@statorjs/stator 1.4.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/build/sync.ts +37 -10
- package/src/compiler/dts.ts +28 -2
- package/src/compiler/virtual-code.ts +126 -2
- package/src/engine/actor.ts +65 -12
- package/src/engine/types.ts +31 -8
- package/src/server/dev-lint.ts +112 -0
- package/src/server/dev.ts +11 -0
- package/src/server/effects.ts +180 -41
- package/src/server/index.ts +2 -0
- package/src/server/machine-store.ts +19 -1
- package/src/server/session-runtime.ts +39 -8
- package/src/server/timers.ts +23 -3
- package/src/template/directives/list-attr.ts +34 -19
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statorjs/stator",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "Server-canonical web framework: business logic in composable state machines, UI as a thin renderer binding machine outputs to DOM positions. Ships TypeScript source (Vite/tsx-native by design).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -88,7 +88,7 @@
|
|
|
88
88
|
"pino-pretty": "^13.1.3",
|
|
89
89
|
"vite": "^6.0.0",
|
|
90
90
|
"vitest": "^2.1.0",
|
|
91
|
-
"@statorjs/stator": "1.
|
|
91
|
+
"@statorjs/stator": "1.5.0"
|
|
92
92
|
},
|
|
93
93
|
"scripts": {
|
|
94
94
|
"typecheck": "tsc --noEmit",
|
package/src/build/sync.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { Dirent } from 'node:fs'
|
|
|
2
2
|
import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'
|
|
3
3
|
import { dirname, join, relative, sep } from 'node:path'
|
|
4
4
|
import { generateDts } from '../compiler/dts.ts'
|
|
5
|
+
import { toVirtualCode } from '../compiler/virtual-code.ts'
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Type sync: generate a `<name>.stator.d.ts` for each component so editors and
|
|
@@ -16,35 +17,61 @@ import { generateDts } from '../compiler/dts.ts'
|
|
|
16
17
|
*
|
|
17
18
|
* Route pages (`routes/*.stator`) are skipped — they export `GET`, not a render
|
|
18
19
|
* function.
|
|
20
|
+
*
|
|
21
|
+
* Sync ALSO emits each template's virtual TSX (the same code the language
|
|
22
|
+
* server typechecks in-editor) under `.stator/check/`, so plain `tsc --noEmit`
|
|
23
|
+
* covers TEMPLATE INTERNALS in CI — frontmatter/prop mismatches otherwise
|
|
24
|
+
* surface only as runtime ReferenceErrors. Opt-in per app: add
|
|
25
|
+
* ".stator/check" to `rootDirs` and its .tsx files to `include` (see the
|
|
26
|
+
* example tsconfigs); apps that don't are unaffected (the files sit ignored).
|
|
19
27
|
*/
|
|
20
28
|
export interface SyncResult {
|
|
21
29
|
/** Number of `.stator.d.ts` files written. */
|
|
22
30
|
written: number
|
|
31
|
+
/** Number of `.check.tsx` virtual files written. */
|
|
32
|
+
checks: number
|
|
23
33
|
/** The generated-types directory. */
|
|
24
34
|
outDir: string
|
|
25
35
|
}
|
|
26
36
|
|
|
27
37
|
const TYPES_DIR = join('.stator', 'types')
|
|
38
|
+
const CHECK_DIR = join('.stator', 'check')
|
|
28
39
|
|
|
29
40
|
export async function syncTypes(root: string): Promise<SyncResult> {
|
|
30
41
|
const outDir = join(root, TYPES_DIR)
|
|
42
|
+
const checkDir = join(root, CHECK_DIR)
|
|
31
43
|
await rm(outDir, { recursive: true, force: true })
|
|
44
|
+
await rm(checkDir, { recursive: true, force: true })
|
|
32
45
|
|
|
33
46
|
const files = await walk(root)
|
|
34
47
|
let written = 0
|
|
48
|
+
let checks = 0
|
|
35
49
|
for (const file of files) {
|
|
36
|
-
const
|
|
37
|
-
const dts = generateDts(await readFile(file, 'utf8'), { kind })
|
|
38
|
-
if (dts === null) continue
|
|
39
|
-
// Mirror the source path under .stator/types: templates/x.stator →
|
|
40
|
-
// .stator/types/templates/x.stator.d.ts.
|
|
50
|
+
const source = await readFile(file, 'utf8')
|
|
41
51
|
const rel = relative(root, file)
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
52
|
+
|
|
53
|
+
const kind = file.split(sep).includes('routes') ? 'route' : 'component'
|
|
54
|
+
const dts = generateDts(source, { kind })
|
|
55
|
+
if (dts !== null) {
|
|
56
|
+
// Mirror the source path under .stator/types: templates/x.stator →
|
|
57
|
+
// .stator/types/templates/x.stator.d.ts.
|
|
58
|
+
const target = join(outDir, `${rel}.d.ts`)
|
|
59
|
+
await mkdir(dirname(target), { recursive: true })
|
|
60
|
+
await writeFile(target, dts)
|
|
61
|
+
written++
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Same mirroring for the virtual TSX — the exact code the editor
|
|
65
|
+
// typechecks, HTML→TSX compatibility included (see compiler/virtual-code
|
|
66
|
+
// appendTemplateTsx). Relative imports resolve through the app's
|
|
67
|
+
// `rootDirs`; ambient Stator/JSX scaffolding is self-contained per file.
|
|
68
|
+
const virtual = toVirtualCode(source)
|
|
69
|
+
const checkTarget = join(checkDir, `${rel}.check.tsx`)
|
|
70
|
+
await mkdir(dirname(checkTarget), { recursive: true })
|
|
71
|
+
await writeFile(checkTarget, virtual.tsx.code)
|
|
72
|
+
checks++
|
|
46
73
|
}
|
|
47
|
-
return { written, outDir }
|
|
74
|
+
return { written, checks, outDir }
|
|
48
75
|
}
|
|
49
76
|
|
|
50
77
|
async function walk(dir: string): Promise<string[]> {
|
package/src/compiler/dts.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import ts from 'typescript'
|
|
2
|
+
import { analyzeScriptClasses } from './client-script.ts'
|
|
2
3
|
import { splitStator } from './split.ts'
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -19,11 +20,36 @@ export function generateDts(
|
|
|
19
20
|
): string | null {
|
|
20
21
|
if ((opts.kind ?? 'component') === 'route') return null
|
|
21
22
|
|
|
22
|
-
const { frontmatter, template } = splitStator(source)
|
|
23
|
+
const { frontmatter, template, scripts } = splitStator(source)
|
|
23
24
|
const { hoisted, propsType } = extractFrontmatterTypes(frontmatter)
|
|
24
|
-
|
|
25
|
+
let propsT = componentPropsType(propsType, template)
|
|
26
|
+
let needsReadResult = false
|
|
27
|
+
|
|
28
|
+
// Client islands declare their surface with `static attrs`, not
|
|
29
|
+
// `Stator.props`. Each attr accepts its coerced kind OR a live `read()`
|
|
30
|
+
// binding — the compiler lowers read() island props to live attr bindings,
|
|
31
|
+
// so the type surface must admit both.
|
|
32
|
+
if (!propsType && scripts.length > 0) {
|
|
33
|
+
const attrs = new Map<string, string>()
|
|
34
|
+
for (const cls of analyzeScriptClasses(scripts.join('\n'))) {
|
|
35
|
+
for (const [key, kind] of cls.staticAttrs) attrs.set(key, kind)
|
|
36
|
+
}
|
|
37
|
+
if (attrs.size > 0) {
|
|
38
|
+
needsReadResult = true
|
|
39
|
+
const fields = [...attrs]
|
|
40
|
+
.map(([key, kind]) => `${key}?: ${kind} | __SReadResult<${kind}>`)
|
|
41
|
+
.join('; ')
|
|
42
|
+
// Declared attrs are typed strictly; the open tail admits the richer
|
|
43
|
+
// props an island's SERVER-rendered markup may consume (they never
|
|
44
|
+
// cross the attr wire, so `static attrs` can't know about them).
|
|
45
|
+
propsT = `{ ${fields} } & { [prop: string]: unknown }`
|
|
46
|
+
}
|
|
47
|
+
}
|
|
25
48
|
|
|
26
49
|
const lines = ["import type { HtmlFragment } from '@statorjs/stator/template'"]
|
|
50
|
+
if (needsReadResult) {
|
|
51
|
+
lines.push("import type { ReadResult as __SReadResult } from '@statorjs/stator/template'")
|
|
52
|
+
}
|
|
27
53
|
if (hoisted) lines.push(hoisted)
|
|
28
54
|
lines.push('')
|
|
29
55
|
lines.push(`declare const _default: (props: ${propsT}) => HtmlFragment`)
|
|
@@ -180,13 +180,137 @@ function buildServerTsx(regions: ScannedRegions): VirtualFile {
|
|
|
180
180
|
code += `${seg.text}\n`
|
|
181
181
|
}
|
|
182
182
|
code += ' return (<>'
|
|
183
|
-
|
|
184
|
-
code += tpl
|
|
183
|
+
code = appendTemplateTsx(code, mappings, tpl, tplOffset)
|
|
185
184
|
code += '</>);\n}\n'
|
|
186
185
|
|
|
187
186
|
return { lang: 'tsx', code, mappings }
|
|
188
187
|
}
|
|
189
188
|
|
|
189
|
+
/** HTML void elements — legal unclosed in templates, not in TSX. */
|
|
190
|
+
const VOID_TAGS = new Set([
|
|
191
|
+
'area',
|
|
192
|
+
'base',
|
|
193
|
+
'br',
|
|
194
|
+
'col',
|
|
195
|
+
'embed',
|
|
196
|
+
'hr',
|
|
197
|
+
'img',
|
|
198
|
+
'input',
|
|
199
|
+
'link',
|
|
200
|
+
'meta',
|
|
201
|
+
'source',
|
|
202
|
+
'track',
|
|
203
|
+
'wbr',
|
|
204
|
+
])
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Append the template to the virtual TSX with HTML→TSX compatibility applied
|
|
208
|
+
* AND offset mappings kept exact around every edit:
|
|
209
|
+
* - HTML comments: dropped (not JSX),
|
|
210
|
+
* - `is:inline` scripts: body blanked to `<script />` (raw JS can't parse
|
|
211
|
+
* as JSX children, and typechecking it as TSX would be meaningless),
|
|
212
|
+
* - void elements: self-closed (`<input>` → `<input />`).
|
|
213
|
+
* The walk is brace- and quote-aware so attribute expressions
|
|
214
|
+
* (`checked={(t) => t.done}`, `title="a > b"`) are never rewritten. Verbatim
|
|
215
|
+
* runs get 1:1 mappings; inserted text maps to nothing; dropped source simply
|
|
216
|
+
* has no generated counterpart. Both the editor (Volar) and the emitted
|
|
217
|
+
* `.stator/check` files share this one surface.
|
|
218
|
+
*/
|
|
219
|
+
function appendTemplateTsx(
|
|
220
|
+
code: string,
|
|
221
|
+
mappings: VirtualMapping[],
|
|
222
|
+
tpl: string,
|
|
223
|
+
tplOffset: number,
|
|
224
|
+
): string {
|
|
225
|
+
let out = code
|
|
226
|
+
let i = 0
|
|
227
|
+
let runStart = 0
|
|
228
|
+
|
|
229
|
+
const flush = (end: number): void => {
|
|
230
|
+
if (end > runStart) {
|
|
231
|
+
push(mappings, tplOffset + runStart, out.length, end - runStart)
|
|
232
|
+
out += tpl.slice(runStart, end)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
while (i < tpl.length) {
|
|
237
|
+
if (tpl.startsWith('<!--', i)) {
|
|
238
|
+
flush(i)
|
|
239
|
+
const end = tpl.indexOf('-->', i)
|
|
240
|
+
i = end === -1 ? tpl.length : end + 3
|
|
241
|
+
runStart = i
|
|
242
|
+
continue
|
|
243
|
+
}
|
|
244
|
+
if (/^<script\b/i.test(tpl.slice(i, i + 8))) {
|
|
245
|
+
flush(i)
|
|
246
|
+
const end = tpl.toLowerCase().indexOf('</script>', i)
|
|
247
|
+
out += '<script />'
|
|
248
|
+
i = end === -1 ? tpl.length : end + '</script>'.length
|
|
249
|
+
runStart = i
|
|
250
|
+
continue
|
|
251
|
+
}
|
|
252
|
+
if (tpl[i] === '{') {
|
|
253
|
+
i = expressionEnd(tpl, i)
|
|
254
|
+
continue
|
|
255
|
+
}
|
|
256
|
+
if (tpl[i] === '<' && /[a-zA-Z]/.test(tpl[i + 1] ?? '')) {
|
|
257
|
+
const tag = /^[a-zA-Z][\w-]*/.exec(tpl.slice(i + 1))![0]
|
|
258
|
+
let j = i + 1 + tag.length
|
|
259
|
+
while (j < tpl.length) {
|
|
260
|
+
const ch = tpl[j]!
|
|
261
|
+
if (ch === '"' || ch === "'") {
|
|
262
|
+
const q = tpl.indexOf(ch, j + 1)
|
|
263
|
+
j = q === -1 ? tpl.length : q + 1
|
|
264
|
+
continue
|
|
265
|
+
}
|
|
266
|
+
if (ch === '{') {
|
|
267
|
+
j = expressionEnd(tpl, j)
|
|
268
|
+
continue
|
|
269
|
+
}
|
|
270
|
+
if (ch === '>') break
|
|
271
|
+
j++
|
|
272
|
+
}
|
|
273
|
+
if (VOID_TAGS.has(tag.toLowerCase()) && j < tpl.length) {
|
|
274
|
+
// Self-close unless the author already did.
|
|
275
|
+
let k = j - 1
|
|
276
|
+
while (k > i && /\s/.test(tpl[k]!)) k--
|
|
277
|
+
if (tpl[k] !== '/') {
|
|
278
|
+
flush(j)
|
|
279
|
+
out += ' /'
|
|
280
|
+
runStart = j // continue the run at the original `>`
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
i = j + 1
|
|
284
|
+
continue
|
|
285
|
+
}
|
|
286
|
+
i++
|
|
287
|
+
}
|
|
288
|
+
flush(tpl.length)
|
|
289
|
+
return out
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Index just past the `}` closing the expression opened at `start` (which
|
|
293
|
+
* must point at `{`), respecting nested braces and string/template literals. */
|
|
294
|
+
function expressionEnd(tpl: string, start: number): number {
|
|
295
|
+
let depth = 0
|
|
296
|
+
let quote: string | null = null
|
|
297
|
+
for (let i = start; i < tpl.length; i++) {
|
|
298
|
+
const ch = tpl[i]!
|
|
299
|
+
if (quote !== null) {
|
|
300
|
+
if (ch === '\\') i++
|
|
301
|
+
else if (ch === quote) quote = null
|
|
302
|
+
continue
|
|
303
|
+
}
|
|
304
|
+
if (ch === '"' || ch === "'" || ch === '`') quote = ch
|
|
305
|
+
else if (ch === '{') depth++
|
|
306
|
+
else if (ch === '}') {
|
|
307
|
+
depth--
|
|
308
|
+
if (depth === 0) return i + 1
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return tpl.length
|
|
312
|
+
}
|
|
313
|
+
|
|
190
314
|
interface FmSegment {
|
|
191
315
|
sourceOffset: number
|
|
192
316
|
text: string
|
package/src/engine/actor.ts
CHANGED
|
@@ -31,6 +31,12 @@ export interface Actor<C, E extends EventObject> {
|
|
|
31
31
|
): () => void
|
|
32
32
|
/** Compact snapshot for the Store / client hydration seed. */
|
|
33
33
|
getPersistedSnapshot(): Snapshot<C>
|
|
34
|
+
/** Host acknowledgment that the entry effect `effectId` SETTLED (completion
|
|
35
|
+
* delivered or null-resolved): clears the snapshot's pendingEntry marker so
|
|
36
|
+
* hydration stops re-invoking it. No-op on mismatch (the state moved on and
|
|
37
|
+
* the marker already belongs to a different invocation). Returns whether a
|
|
38
|
+
* marker was cleared — the host persists when true. */
|
|
39
|
+
settleEntry(effectId: string): boolean
|
|
34
40
|
}
|
|
35
41
|
|
|
36
42
|
/** The "top type" for an actor — same variance argument as `AnyMachineDef`:
|
|
@@ -68,8 +74,19 @@ export interface CreateActorOptions<C> {
|
|
|
68
74
|
* config; `ctx` is the current context (for context-dependent delays). */
|
|
69
75
|
onStateEnter?: (stateKey: string, ctx: C) => void
|
|
70
76
|
/** Host hook: a state was LEFT (a value-changing transition). The server uses
|
|
71
|
-
* it to cancel that state's `after` timers. */
|
|
77
|
+
* it to cancel that state's `after` timers and abort its entry effect. */
|
|
72
78
|
onStateExit?: (stateKey: string) => void
|
|
79
|
+
/** Host hook: a HYDRATED actor started — the state was restored, not entered.
|
|
80
|
+
* The server uses it to re-arm `after` timers with elapsed credit
|
|
81
|
+
* (`enteredAt`) and, when `pendingEntry` reports an unsettled entry effect,
|
|
82
|
+
* to re-invoke it via `refireEntry` (same effectId — idempotency keys hold
|
|
83
|
+
* across the retry). `pendingEntry` is only reported when the restored state
|
|
84
|
+
* still declares an entry effect. */
|
|
85
|
+
onStateRestore?: (
|
|
86
|
+
stateKey: string,
|
|
87
|
+
ctx: C,
|
|
88
|
+
info: { enteredAt?: number; pendingEntry?: { effectId: string }; refireEntry: () => void },
|
|
89
|
+
) => void
|
|
73
90
|
}
|
|
74
91
|
|
|
75
92
|
/** Unique per-invocation effect id — usable as an idempotency key, so it must
|
|
@@ -104,9 +121,12 @@ export function createActor<C extends object, E extends EventObject, S extends s
|
|
|
104
121
|
? (structuredClone(opts.snapshot.context) as C)
|
|
105
122
|
: (structuredClone(def.context) as C)
|
|
106
123
|
// Hydrated actors have already lived: their current state's entry effect fired
|
|
107
|
-
// when it was entered, so `start()` must NOT re-fire it
|
|
108
|
-
//
|
|
124
|
+
// when it was entered, so `start()` must NOT blanket-re-fire it — the host
|
|
125
|
+
// decides via `onStateRestore` (re-invoke only while `pendingEntry` reports
|
|
126
|
+
// an unsettled completion). Fresh actors enter their initial state for real.
|
|
109
127
|
const hydrated = opts.snapshot !== undefined
|
|
128
|
+
let enteredAt: number | undefined = opts.snapshot?.enteredAt
|
|
129
|
+
let pendingEntry: { effectId: string } | undefined = opts.snapshot?.pendingEntry
|
|
110
130
|
|
|
111
131
|
const subscribers = new Set<(s: Snapshot<C>) => void>()
|
|
112
132
|
const emitListeners = new Map<string, Set<(e: EmittedEvent) => void>>()
|
|
@@ -116,7 +136,12 @@ export function createActor<C extends object, E extends EventObject, S extends s
|
|
|
116
136
|
|
|
117
137
|
let commits = 0
|
|
118
138
|
|
|
119
|
-
const snapshot = (): Snapshot<C> => ({
|
|
139
|
+
const snapshot = (): Snapshot<C> => ({
|
|
140
|
+
value: [...value],
|
|
141
|
+
context,
|
|
142
|
+
...(enteredAt !== undefined ? { enteredAt } : {}),
|
|
143
|
+
...(pendingEntry !== undefined ? { pendingEntry } : {}),
|
|
144
|
+
})
|
|
120
145
|
|
|
121
146
|
const notify = (): void => {
|
|
122
147
|
const snap = snapshot()
|
|
@@ -128,15 +153,20 @@ export function createActor<C extends object, E extends EventObject, S extends s
|
|
|
128
153
|
// event. Firing does NOT bump `commits` (an entry is not a committed
|
|
129
154
|
// transition); the server persists an entry-firing machine via the host's
|
|
130
155
|
// pending-effects queue instead (see server/session-runtime.ts).
|
|
131
|
-
const fireEntryEffect = (stateKey: string): void => {
|
|
156
|
+
const fireEntryEffect = (stateKey: string, reuseEffectId?: string): void => {
|
|
132
157
|
const entry = def.states[stateKey]?.entry
|
|
133
158
|
if (!entry) return
|
|
134
|
-
|
|
159
|
+
// A re-invocation keeps its logical id, so idempotency keys threaded to
|
|
160
|
+
// external calls hold across the retry.
|
|
161
|
+
const effectId = reuseEffectId ?? newEffectId()
|
|
162
|
+
pendingEntry = { effectId }
|
|
135
163
|
const ctxSnapshot = structuredClone(context)
|
|
136
164
|
const invocation: EffectInvocation = {
|
|
137
165
|
machineName: def.name,
|
|
138
166
|
effectId,
|
|
139
|
-
|
|
167
|
+
kind: 'entry',
|
|
168
|
+
stateKey,
|
|
169
|
+
run: (signal) => Promise.resolve(entry(ctxSnapshot, { effectId, signal })),
|
|
140
170
|
}
|
|
141
171
|
if (opts.onEffect) {
|
|
142
172
|
opts.onEffect(invocation)
|
|
@@ -144,6 +174,7 @@ export function createActor<C extends object, E extends EventObject, S extends s
|
|
|
144
174
|
void invocation
|
|
145
175
|
.run()
|
|
146
176
|
.then((completion) => {
|
|
177
|
+
actor.settleEntry(effectId)
|
|
147
178
|
if (completion) actor.send(completion as E)
|
|
148
179
|
})
|
|
149
180
|
.catch((err) => {
|
|
@@ -164,11 +195,23 @@ export function createActor<C extends object, E extends EventObject, S extends s
|
|
|
164
195
|
if (!started) {
|
|
165
196
|
started = true
|
|
166
197
|
notify() // let subscribe-before-start consumers sync initial state
|
|
167
|
-
|
|
198
|
+
const current = value[value.length - 1]!
|
|
168
199
|
if (!hydrated) {
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
200
|
+
// Fresh initial-state entry.
|
|
201
|
+
enteredAt = Date.now()
|
|
202
|
+
fireEntryEffect(current)
|
|
203
|
+
opts.onStateEnter?.(current, context)
|
|
204
|
+
} else if (opts.onStateRestore) {
|
|
205
|
+
// Restored, not entered: hand the host what it needs to re-arm
|
|
206
|
+
// timers (elapsed credit) and recover an unsettled entry effect.
|
|
207
|
+
const pending = pendingEntry && def.states[current]?.entry ? pendingEntry : undefined
|
|
208
|
+
opts.onStateRestore(current, context, {
|
|
209
|
+
enteredAt,
|
|
210
|
+
pendingEntry: pending,
|
|
211
|
+
refireEntry: () => {
|
|
212
|
+
if (pending) fireEntryEffect(current, pending.effectId)
|
|
213
|
+
},
|
|
214
|
+
})
|
|
172
215
|
}
|
|
173
216
|
}
|
|
174
217
|
return actor
|
|
@@ -258,7 +301,9 @@ export function createActor<C extends object, E extends EventObject, S extends s
|
|
|
258
301
|
const invocation: EffectInvocation = {
|
|
259
302
|
machineName: def.name,
|
|
260
303
|
effectId,
|
|
261
|
-
|
|
304
|
+
kind: 'transition',
|
|
305
|
+
run: (signal) =>
|
|
306
|
+
Promise.resolve(effect(ctxSnapshot, evSnapshot as never, { effectId, signal })),
|
|
262
307
|
}
|
|
263
308
|
if (opts.onEffect) {
|
|
264
309
|
opts.onEffect(invocation)
|
|
@@ -283,6 +328,8 @@ export function createActor<C extends object, E extends EventObject, S extends s
|
|
|
283
328
|
// timers (self-transitions and action-only transitions do neither).
|
|
284
329
|
if (config.to && config.to !== stateKey) {
|
|
285
330
|
opts.onStateExit?.(stateKey)
|
|
331
|
+
enteredAt = Date.now()
|
|
332
|
+
pendingEntry = undefined // the old state's marker dies with it
|
|
286
333
|
fireEntryEffect(config.to)
|
|
287
334
|
opts.onStateEnter?.(config.to, context)
|
|
288
335
|
}
|
|
@@ -294,6 +341,12 @@ export function createActor<C extends object, E extends EventObject, S extends s
|
|
|
294
341
|
getPersistedSnapshot: snapshot,
|
|
295
342
|
getCommitCount: () => commits,
|
|
296
343
|
|
|
344
|
+
settleEntry(effectId: string) {
|
|
345
|
+
if (pendingEntry?.effectId !== effectId) return false
|
|
346
|
+
pendingEntry = undefined
|
|
347
|
+
return true
|
|
348
|
+
},
|
|
349
|
+
|
|
297
350
|
subscribe(listener) {
|
|
298
351
|
subscribers.add(listener)
|
|
299
352
|
return {
|
package/src/engine/types.ts
CHANGED
|
@@ -53,10 +53,14 @@ export type Guard<C, E extends EventObject, R = Record<string, any>> = (
|
|
|
53
53
|
) => boolean
|
|
54
54
|
|
|
55
55
|
/** Passed to an effect alongside the snapshots. `effectId` is unique per
|
|
56
|
-
* invocation —
|
|
57
|
-
*
|
|
56
|
+
* LOGICAL invocation — a re-invoked entry effect keeps its id, so threading it
|
|
57
|
+
* to external calls as an idempotency key holds across retries. `signal`
|
|
58
|
+
* aborts when the owning state is exited (entry effects only — command-role
|
|
59
|
+
* transition effects are never aborted); wire it into fetch/etc. to stop
|
|
60
|
+
* wasted work at the source. */
|
|
58
61
|
export interface EffectMeta {
|
|
59
62
|
effectId: string
|
|
63
|
+
signal?: AbortSignal
|
|
60
64
|
}
|
|
61
65
|
|
|
62
66
|
/**
|
|
@@ -86,10 +90,14 @@ export type Effect<C, E extends EventObject, EAll extends EventObject = EventObj
|
|
|
86
90
|
/**
|
|
87
91
|
* A state ENTRY effect: async I/O the host schedules when a state is *entered* —
|
|
88
92
|
* a fresh start at the initial state, or a transition that changes the state
|
|
89
|
-
* value
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
+
* value. Entry effects are the LOAD role: how a state gets its data. The host
|
|
94
|
+
* may RE-INVOKE one on hydration when its completion never settled (a process
|
|
95
|
+
* died mid-flight), and aborts its `meta.signal` when the state is exited — so
|
|
96
|
+
* write them as re-runnable reads and put non-idempotent external writes in
|
|
97
|
+
* transition effects (the command role) instead. Same host-scheduled, off-lock
|
|
98
|
+
* pipeline as a transition `Effect`, minus the event argument (a state entry
|
|
99
|
+
* has no triggering event). Returns the completion event to dispatch (annotate
|
|
100
|
+
* the return with your machine's event union, exactly as for `Effect`), or null.
|
|
93
101
|
*/
|
|
94
102
|
export type EntryEffect<C, EAll extends EventObject = EventObject> = (
|
|
95
103
|
ctx: C,
|
|
@@ -109,11 +117,17 @@ export interface AfterEntry<C, EAll extends EventObject = EventObject> {
|
|
|
109
117
|
}
|
|
110
118
|
|
|
111
119
|
/** A scheduled effect surfaced to the host: everything needed to run it and
|
|
112
|
-
* dispatch its completion. `run` closes over the commit-time snapshots
|
|
120
|
+
* dispatch its completion. `run` closes over the commit-time snapshots; the
|
|
121
|
+
* host may pass an AbortSignal, forwarded to the effect's meta. `kind`
|
|
122
|
+
* carries the role split — entry = re-runnable load (abortable on state
|
|
123
|
+
* exit), transition = at-most-once command (never aborted, never re-run). */
|
|
113
124
|
export interface EffectInvocation {
|
|
114
125
|
machineName: string
|
|
115
126
|
effectId: string
|
|
116
|
-
|
|
127
|
+
kind: 'entry' | 'transition'
|
|
128
|
+
/** The owning state — set for entry effects, for exit-abort targeting. */
|
|
129
|
+
stateKey?: string
|
|
130
|
+
run: (signal?: AbortSignal) => Promise<EventObject | null>
|
|
117
131
|
}
|
|
118
132
|
|
|
119
133
|
/** Object form of a transition. A bare `Action` is sugar for `{ do: fn }`.
|
|
@@ -193,6 +207,15 @@ export interface Snapshot<C> {
|
|
|
193
207
|
/** State path. Depth-1 today (`['idle']`); extensible to hierarchy. */
|
|
194
208
|
value: string[]
|
|
195
209
|
context: C
|
|
210
|
+
/** Epoch ms when the current state was entered. Lets a hydrating host re-arm
|
|
211
|
+
* `after` timers with elapsed credit (deadline = enteredAt + delay). Absent
|
|
212
|
+
* in pre-existing snapshots — hosts treat unknown as "restart full delay". */
|
|
213
|
+
enteredAt?: number
|
|
214
|
+
/** Present while the current state's entry effect has fired but its
|
|
215
|
+
* completion has not settled. A hydrating host uses it to re-invoke the
|
|
216
|
+
* effect (the load role is re-runnable by contract) instead of leaving the
|
|
217
|
+
* machine wedged waiting for a completion that died with another process. */
|
|
218
|
+
pendingEntry?: { effectId: string }
|
|
196
219
|
}
|
|
197
220
|
|
|
198
221
|
export type Lifecycle = 'app' | 'session'
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { TransitionConfig } from '../engine/index.ts'
|
|
2
|
+
import type { AnyMachineDef } from './define-machine.ts'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Dev-plane lints over the machine graph. Pure analysis — the dev server logs
|
|
6
|
+
* the findings; production builds never run this.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export interface PollLoopFinding {
|
|
10
|
+
machine: string
|
|
11
|
+
state: string
|
|
12
|
+
event: string
|
|
13
|
+
/** One representative cycle, e.g. ['ready', 'revalidating', 'ready']. */
|
|
14
|
+
cycle: string[]
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Detect self-rescheduling poll loops on SESSION machines: a state's `after`
|
|
19
|
+
* timer sends an event whose transitions leave the state and eventually return
|
|
20
|
+
* to it, with an entry effect somewhere on the loop. That shape re-arms its
|
|
21
|
+
* own timer on every lap — server-side polling that runs for sessions nobody
|
|
22
|
+
* is watching (and, before the TTL guard, kept them alive doing it).
|
|
23
|
+
*
|
|
24
|
+
* Deliberately NOT flagged, by construction:
|
|
25
|
+
* - after-rescue (the timer fires once into an error/terminal state — no
|
|
26
|
+
* path back, no cycle),
|
|
27
|
+
* - action-only or same-state `after` handlers (no state change means no
|
|
28
|
+
* re-entry, so the timer never re-arms — not a loop),
|
|
29
|
+
* - app machines (process housekeeping is the legitimate home for
|
|
30
|
+
* non-durable server clocks).
|
|
31
|
+
*
|
|
32
|
+
* The steer for a real finding: put the clock on the client and the staleness
|
|
33
|
+
* policy in a guard — see the effects guide, "Who owns the clock".
|
|
34
|
+
*/
|
|
35
|
+
export function findPollLoops(defs: readonly AnyMachineDef[]): PollLoopFinding[] {
|
|
36
|
+
const findings: PollLoopFinding[] = []
|
|
37
|
+
|
|
38
|
+
for (const def of defs) {
|
|
39
|
+
if (def.lifecycle !== 'session') continue
|
|
40
|
+
|
|
41
|
+
// State graph over value-changing transitions only (engine semantics: a
|
|
42
|
+
// missing or same-state `to` never re-enters, so it can't re-arm).
|
|
43
|
+
const edges = new Map<string, Set<string>>()
|
|
44
|
+
for (const [stateKey, node] of Object.entries(def.states)) {
|
|
45
|
+
const targets = new Set<string>()
|
|
46
|
+
for (const raw of Object.values(node.on ?? {})) {
|
|
47
|
+
const candidates = Array.isArray(raw) ? raw : [raw]
|
|
48
|
+
for (const c of candidates) {
|
|
49
|
+
const to =
|
|
50
|
+
typeof c === 'function' ? undefined : (c as TransitionConfig<object, never, string>).to
|
|
51
|
+
if (to && to !== stateKey) targets.add(to)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
edges.set(stateKey, targets)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const hasEntry = (s: string): boolean => def.states[s]?.entry !== undefined
|
|
58
|
+
|
|
59
|
+
for (const [stateKey, node] of Object.entries(def.states)) {
|
|
60
|
+
for (const after of node.after ?? []) {
|
|
61
|
+
const event = (after.send as { type: string }).type
|
|
62
|
+
const raw = node.on?.[event]
|
|
63
|
+
if (!raw) continue
|
|
64
|
+
const candidates = Array.isArray(raw) ? raw : [raw]
|
|
65
|
+
for (const c of candidates) {
|
|
66
|
+
const to =
|
|
67
|
+
typeof c === 'function' ? undefined : (c as TransitionConfig<object, never, string>).to
|
|
68
|
+
if (!to || to === stateKey) continue
|
|
69
|
+
|
|
70
|
+
// BFS from the after-target: can we get back to stateKey?
|
|
71
|
+
const parent = new Map<string, string>()
|
|
72
|
+
const queue = [to]
|
|
73
|
+
const seen = new Set([to])
|
|
74
|
+
let found = false
|
|
75
|
+
while (queue.length > 0 && !found) {
|
|
76
|
+
const cur = queue.shift()!
|
|
77
|
+
for (const next of edges.get(cur) ?? []) {
|
|
78
|
+
if (next === stateKey) {
|
|
79
|
+
parent.set(next, cur)
|
|
80
|
+
found = true
|
|
81
|
+
break
|
|
82
|
+
}
|
|
83
|
+
if (!seen.has(next)) {
|
|
84
|
+
seen.add(next)
|
|
85
|
+
parent.set(next, cur)
|
|
86
|
+
queue.push(next)
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
if (!found) continue
|
|
91
|
+
|
|
92
|
+
// Reconstruct one lap and require an entry effect somewhere on it —
|
|
93
|
+
// a data-load somewhere in the loop is what makes it a POLL.
|
|
94
|
+
const path: string[] = []
|
|
95
|
+
let cursor: string | undefined = stateKey
|
|
96
|
+
while (cursor !== undefined) {
|
|
97
|
+
path.push(cursor)
|
|
98
|
+
if (cursor === to) break
|
|
99
|
+
cursor = parent.get(cursor)
|
|
100
|
+
}
|
|
101
|
+
path.reverse() // [to, ..., stateKey]
|
|
102
|
+
const lap = [stateKey, ...path]
|
|
103
|
+
if (!lap.some(hasEntry)) continue
|
|
104
|
+
|
|
105
|
+
findings.push({ machine: def.name, state: stateKey, event, cycle: lap })
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return findings
|
|
112
|
+
}
|
package/src/server/dev.ts
CHANGED
|
@@ -139,6 +139,17 @@ export async function createDevApp(config: DevAppConfig): Promise<DevApp> {
|
|
|
139
139
|
// initial-state entry effect fires during boot and needs a live scheduler.
|
|
140
140
|
runtime.wireAppEffects(store)
|
|
141
141
|
await store.bootAppMachines()
|
|
142
|
+
// Dev-only lint: a session machine whose `after` timer drives a loop with
|
|
143
|
+
// a data-loading entry effect is server-side polling that runs for
|
|
144
|
+
// sessions nobody is watching. Warn with the steer, don't block.
|
|
145
|
+
for (const f of runtime.findPollLoops(defs)) {
|
|
146
|
+
console.warn(
|
|
147
|
+
`stator: session machine "${f.machine}" self-reschedules through \`after\` ` +
|
|
148
|
+
`(${f.cycle.join(' → ')} via ${f.event}) with a data-loading entry effect on the loop. ` +
|
|
149
|
+
`This polls upstream for sessions nobody is watching. Prefer a client-owned clock ` +
|
|
150
|
+
`with a staleness guard — see the effects guide, "Who owns the clock".`,
|
|
151
|
+
)
|
|
152
|
+
}
|
|
142
153
|
}
|
|
143
154
|
const rebuildRoutes = async (): Promise<void> => {
|
|
144
155
|
routes = await runtime.discoverRoutes(routesDir, loader)
|
package/src/server/effects.ts
CHANGED
|
@@ -8,6 +8,46 @@ import { fanOut } from './sse.ts'
|
|
|
8
8
|
|
|
9
9
|
const effectLog = scopedLogger('effect')
|
|
10
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Process-wide registry of IN-FLIGHT effect invocations. Two consumers:
|
|
13
|
+
* - hydration re-invoke dampening: a restore hook only re-fires a pending
|
|
14
|
+
* entry effect when its id is NOT already running in this process (normal
|
|
15
|
+
* request traffic hydrates constantly; only a post-crash hydration finds a
|
|
16
|
+
* pending marker with no live invocation),
|
|
17
|
+
* - exit-abort: leaving a state aborts its entry effect's signal, stopping
|
|
18
|
+
* wasted upstream work at the source (command-role transition effects are
|
|
19
|
+
* never aborted).
|
|
20
|
+
* In-memory and non-durable by design — it dies with the process, exactly like
|
|
21
|
+
* the work it tracks.
|
|
22
|
+
*/
|
|
23
|
+
interface InFlightEffect {
|
|
24
|
+
controller: AbortController
|
|
25
|
+
scope: string
|
|
26
|
+
machineName: string
|
|
27
|
+
kind: 'entry' | 'transition'
|
|
28
|
+
stateKey?: string
|
|
29
|
+
}
|
|
30
|
+
const inFlight = new Map<string, InFlightEffect>()
|
|
31
|
+
|
|
32
|
+
export function isEffectInFlight(effectId: string): boolean {
|
|
33
|
+
return inFlight.has(effectId)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Abort the entry effect(s) owned by (scope, machine, state) — called from the
|
|
37
|
+
* host's state-exit hook alongside timer cancellation. */
|
|
38
|
+
export function abortEntryEffects(scope: string, machineName: string, stateKey: string): void {
|
|
39
|
+
for (const fx of inFlight.values()) {
|
|
40
|
+
if (
|
|
41
|
+
fx.kind === 'entry' &&
|
|
42
|
+
fx.scope === scope &&
|
|
43
|
+
fx.machineName === machineName &&
|
|
44
|
+
fx.stateKey === stateKey
|
|
45
|
+
) {
|
|
46
|
+
fx.controller.abort()
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
11
51
|
/**
|
|
12
52
|
* Install the APP-plane effect scheduler on a MachineStore (injected
|
|
13
53
|
* post-construction — see MachineStore.setAppEffectScheduler). App
|
|
@@ -26,26 +66,48 @@ export function wireAppEffects(store: MachineStore): void {
|
|
|
26
66
|
|
|
27
67
|
async function runAppEffect(invocation: EffectInvocation, store: MachineStore): Promise<void> {
|
|
28
68
|
const { machineName, effectId } = invocation
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
69
|
+
const controller = new AbortController()
|
|
70
|
+
inFlight.set(effectId, {
|
|
71
|
+
controller,
|
|
72
|
+
scope: '@app',
|
|
73
|
+
machineName,
|
|
74
|
+
kind: invocation.kind,
|
|
75
|
+
stateKey: invocation.stateKey,
|
|
76
|
+
})
|
|
77
|
+
// The registry entry lives until the LOGICAL invocation settles — including
|
|
78
|
+
// completion delivery — so a hydration racing the completion sees it in
|
|
79
|
+
// flight and does not re-invoke.
|
|
40
80
|
try {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
81
|
+
let completion: Awaited<ReturnType<EffectInvocation['run']>>
|
|
82
|
+
try {
|
|
83
|
+
completion = await invocation.run(controller.signal)
|
|
84
|
+
} catch (err) {
|
|
85
|
+
effectLog.error(
|
|
86
|
+
{ machine: machineName, effectId, err: String(err) },
|
|
87
|
+
'effect threw — effects must catch and return their failure event; dropped',
|
|
88
|
+
)
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
// Settle the resident app actor's pendingEntry marker directly (persisted
|
|
92
|
+
// when the machine opted in), then dispatch any completion.
|
|
93
|
+
if (invocation.kind === 'entry') {
|
|
94
|
+
if (store.appInstance(machineName)?.actor.settleEntry(effectId)) {
|
|
95
|
+
await store.persistAppMachine(machineName)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (!completion) return
|
|
99
|
+
try {
|
|
100
|
+
const def = store.getDef(machineName)
|
|
101
|
+
if (!def) return // graph changed under us (dev reload) — drop
|
|
102
|
+
await dispatchToApp(store, def as AnyMachineDef, completion as never)
|
|
103
|
+
} catch (err) {
|
|
104
|
+
effectLog.error(
|
|
105
|
+
{ machine: machineName, effectId, err: String(err) },
|
|
106
|
+
'effect completion dispatch failed',
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
} finally {
|
|
110
|
+
inFlight.delete(effectId)
|
|
49
111
|
}
|
|
50
112
|
}
|
|
51
113
|
|
|
@@ -80,30 +142,86 @@ async function runSessionEffect(
|
|
|
80
142
|
sessionId: string,
|
|
81
143
|
): Promise<void> {
|
|
82
144
|
const { machineName, effectId } = invocation
|
|
83
|
-
|
|
145
|
+
const controller = new AbortController()
|
|
146
|
+
inFlight.set(effectId, {
|
|
147
|
+
controller,
|
|
148
|
+
scope: sessionId,
|
|
149
|
+
machineName,
|
|
150
|
+
kind: invocation.kind,
|
|
151
|
+
stateKey: invocation.stateKey,
|
|
152
|
+
})
|
|
153
|
+
// The registry entry lives until the LOGICAL invocation settles — including
|
|
154
|
+
// completion delivery — so a hydration racing the completion (e.g. the
|
|
155
|
+
// completion's own re-entry) sees it in flight and does not re-invoke.
|
|
84
156
|
try {
|
|
85
|
-
completion
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
157
|
+
let completion: Awaited<ReturnType<EffectInvocation['run']>>
|
|
158
|
+
try {
|
|
159
|
+
completion = await invocation.run(controller.signal)
|
|
160
|
+
} catch (err) {
|
|
161
|
+
// Backstop only — the type contract asks effects to catch and return
|
|
162
|
+
// their failure event. Never crashes the host.
|
|
163
|
+
effectLog.error(
|
|
164
|
+
{ machine: machineName, effectId, err: String(err) },
|
|
165
|
+
'effect threw — effects must catch and return their failure event; dropped',
|
|
166
|
+
)
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
// An entry effect that settled with no completion still clears its marker —
|
|
170
|
+
// otherwise every later hydration would re-invoke a fire-and-forget load.
|
|
171
|
+
const settles = invocation.kind === 'entry' ? effectId : undefined
|
|
172
|
+
if (!completion) {
|
|
173
|
+
if (settles) {
|
|
174
|
+
try {
|
|
175
|
+
await settleSessionEntry(store, sessionId, machineName, settles)
|
|
176
|
+
} catch (err) {
|
|
177
|
+
effectLog.error(
|
|
178
|
+
{ machine: machineName, effectId, err: String(err) },
|
|
179
|
+
'entry-effect settle failed',
|
|
180
|
+
)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return
|
|
184
|
+
}
|
|
96
185
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
186
|
+
try {
|
|
187
|
+
await reenterSessionEvent(store, sessionId, machineName, completion, { settles })
|
|
188
|
+
} catch (err) {
|
|
189
|
+
effectLog.error(
|
|
190
|
+
{ machine: machineName, effectId, err: String(err) },
|
|
191
|
+
'effect completion dispatch failed',
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
} finally {
|
|
195
|
+
inFlight.delete(effectId)
|
|
104
196
|
}
|
|
105
197
|
}
|
|
106
198
|
|
|
199
|
+
/** Clear a session machine's pendingEntry marker with no event to deliver —
|
|
200
|
+
* the null-completion settle path. Lock + hydrate + settle + persist (no TTL
|
|
201
|
+
* refresh: this is machine activity, not user activity). */
|
|
202
|
+
async function settleSessionEntry(
|
|
203
|
+
store: MachineStore,
|
|
204
|
+
sessionId: string,
|
|
205
|
+
machineName: string,
|
|
206
|
+
effectId: string,
|
|
207
|
+
): Promise<void> {
|
|
208
|
+
await withSessionLock(sessionId, async () => {
|
|
209
|
+
const def = store.getDef(machineName)
|
|
210
|
+
if (!def) return
|
|
211
|
+
if ((await store.persistence.get(sessionId, machineName)) === null) return
|
|
212
|
+
const runtime = new SessionRuntime(sessionId, store)
|
|
213
|
+
try {
|
|
214
|
+
await runtime.loadGraph([def])
|
|
215
|
+
const handle = runtime.handleFor(machineName)
|
|
216
|
+
if (handle?.actor.settleEntry(effectId)) {
|
|
217
|
+
await runtime.persistTouched(new Set([machineName]), { refreshTtl: false })
|
|
218
|
+
}
|
|
219
|
+
} finally {
|
|
220
|
+
runtime.dispose()
|
|
221
|
+
}
|
|
222
|
+
})
|
|
223
|
+
}
|
|
224
|
+
|
|
107
225
|
/**
|
|
108
226
|
* Re-enter an out-of-band event (an effect completion, or an `after` timeout)
|
|
109
227
|
* through the full session event path: fresh lock, hydrate the machine (the
|
|
@@ -117,18 +235,39 @@ export async function reenterSessionEvent(
|
|
|
117
235
|
sessionId: string,
|
|
118
236
|
machineName: string,
|
|
119
237
|
event: EventObject,
|
|
238
|
+
opts?: { settles?: string },
|
|
120
239
|
): Promise<void> {
|
|
121
240
|
await withSessionLock(sessionId, async () => {
|
|
241
|
+
const def = store.getDef(machineName)
|
|
242
|
+
if (!def) return // machine graph changed under us (dev reload) — drop
|
|
243
|
+
// Resurrection guard: a timer or completion outliving its session must not
|
|
244
|
+
// birth a fresh machine (whose initial entry effect would fire and persist
|
|
245
|
+
// a zombie). No snapshot ⇒ the session is gone ⇒ drop.
|
|
246
|
+
if ((await store.persistence.get(sessionId, machineName)) === null) {
|
|
247
|
+
effectLog.debug(
|
|
248
|
+
{ sid: sessionId, machine: machineName, event: event.type },
|
|
249
|
+
'out-of-band event for expired session dropped',
|
|
250
|
+
)
|
|
251
|
+
return
|
|
252
|
+
}
|
|
122
253
|
const runtime = new SessionRuntime(sessionId, store)
|
|
123
254
|
try {
|
|
124
|
-
const def = store.getDef(machineName)
|
|
125
|
-
if (!def) return // machine graph changed under us (dev reload) — drop
|
|
126
255
|
// loadGraph pulls reads + subscribers transitively, so emits reach
|
|
127
256
|
// cross-machine listeners like any other event.
|
|
128
257
|
await runtime.loadGraph([def])
|
|
129
258
|
runtime.wireSubscriptions()
|
|
130
259
|
const touched = runtime.processEvent(machineName, event)
|
|
131
|
-
|
|
260
|
+
// Settle the entry-effect marker this completion belongs to (no-op if
|
|
261
|
+
// the state moved on and the marker died with it).
|
|
262
|
+
const settled =
|
|
263
|
+
opts?.settles !== undefined &&
|
|
264
|
+
(runtime.handleFor(machineName)?.actor.settleEntry(opts.settles) ?? false)
|
|
265
|
+
const toPersist = new Set([...touched, ...runtime.entryFiredMachines()])
|
|
266
|
+
if (settled) toPersist.add(machineName)
|
|
267
|
+
// Machine activity must not extend the session's life — only real user
|
|
268
|
+
// requests refresh the TTL. Without this, a self-rescheduling machine
|
|
269
|
+
// keeps its own session immortal.
|
|
270
|
+
await runtime.persistTouched(toPersist, { refreshTtl: false })
|
|
132
271
|
await fanOut(touched, { sessionId })
|
|
133
272
|
// A transition or entry effect chained off this event surfaces here.
|
|
134
273
|
scheduleSessionEffects(runtime, store, sessionId)
|
package/src/server/index.ts
CHANGED
|
@@ -22,6 +22,8 @@ export type {
|
|
|
22
22
|
SubscribeEvent,
|
|
23
23
|
} from './define-machine.ts'
|
|
24
24
|
export { defineMachine, isStatorMachine } from './define-machine.ts'
|
|
25
|
+
export type { PollLoopFinding } from './dev-lint.ts'
|
|
26
|
+
export { findPollLoops } from './dev-lint.ts'
|
|
25
27
|
export type { DiscoveryResult } from './discovery.ts'
|
|
26
28
|
export { discoverMachines } from './discovery.ts'
|
|
27
29
|
export type { DispatchContext } from './dispatch-context.ts'
|
|
@@ -2,6 +2,7 @@ import { createActor, type EffectInvocation, type Snapshot } from '../engine/ind
|
|
|
2
2
|
import { type AppStore, InMemoryAppStore } from './app-store.ts'
|
|
3
3
|
import type { AnyMachineDef, SubscribeEvent } from './define-machine.ts'
|
|
4
4
|
import { recordTouch } from './dispatch-context.ts'
|
|
5
|
+
import { abortEntryEffects, isEffectInFlight } from './effects.ts'
|
|
5
6
|
import { createInstanceProxy, type InstanceHandle } from './instance-proxy.ts'
|
|
6
7
|
import { scopedLogger } from './logger.ts'
|
|
7
8
|
import { serverReadsResolver } from './reads-helpers.ts'
|
|
@@ -265,7 +266,24 @@ export class MachineStore {
|
|
|
265
266
|
// through dispatchToApp — no session, wall-clock only (revalidation,
|
|
266
267
|
// circuit breakers). See armAfterTimers.
|
|
267
268
|
onStateEnter: (stateKey, ctx) => armAfterTimers(this, APP_SCOPE, def, stateKey, ctx),
|
|
268
|
-
onStateExit: (stateKey) =>
|
|
269
|
+
onStateExit: (stateKey) => {
|
|
270
|
+
cancelAfterTimers(APP_SCOPE, def.name, stateKey)
|
|
271
|
+
abortEntryEffects(APP_SCOPE, def.name, stateKey)
|
|
272
|
+
},
|
|
273
|
+
// A persisted app machine restoring at boot gets the same work-
|
|
274
|
+
// lifetime semantics as a hydrating session machine: timers re-arm
|
|
275
|
+
// with elapsed credit, and an unsettled entry effect re-invokes
|
|
276
|
+
// (fresh process ⇒ nothing can be in flight, but the check keeps the
|
|
277
|
+
// contract uniform).
|
|
278
|
+
onStateRestore: (stateKey, ctx, info) => {
|
|
279
|
+
armAfterTimers(this, APP_SCOPE, def, stateKey, ctx, {
|
|
280
|
+
restore: true,
|
|
281
|
+
enteredAt: info.enteredAt,
|
|
282
|
+
})
|
|
283
|
+
if (info.pendingEntry && !isEffectInFlight(info.pendingEntry.effectId)) {
|
|
284
|
+
info.refireEntry()
|
|
285
|
+
}
|
|
286
|
+
},
|
|
269
287
|
}).start()
|
|
270
288
|
this.appInstances.set(
|
|
271
289
|
def.name,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createActor, type EffectInvocation, type Snapshot } from '../engine/index.ts'
|
|
2
2
|
import type { AnyMachineDef } from './define-machine.ts'
|
|
3
3
|
import { type DispatchContext, recordTouch, withDispatchContext } from './dispatch-context.ts'
|
|
4
|
+
import { abortEntryEffects, isEffectInFlight } from './effects.ts'
|
|
4
5
|
import { createInstanceProxy, type InstanceHandle } from './instance-proxy.ts'
|
|
5
6
|
import { buildDispatchEvent, MAX_CASCADE_DEPTH, type MachineStore } from './machine-store.ts'
|
|
6
7
|
import { serverReadsResolver } from './reads-helpers.ts'
|
|
@@ -72,7 +73,26 @@ export class SessionRuntime {
|
|
|
72
73
|
// process-wide registry (server/timers.ts), not this transient runtime.
|
|
73
74
|
onStateEnter: (stateKey, ctx) =>
|
|
74
75
|
armAfterTimers(this.store, this.sessionId, def, stateKey, ctx),
|
|
75
|
-
|
|
76
|
+
// Exiting a state also aborts its in-flight entry effect (load role) —
|
|
77
|
+
// the completion would be guard-dropped anyway; this stops the work.
|
|
78
|
+
onStateExit: (stateKey) => {
|
|
79
|
+
cancelAfterTimers(this.sessionId, def.name, stateKey)
|
|
80
|
+
abortEntryEffects(this.sessionId, def.name, stateKey)
|
|
81
|
+
},
|
|
82
|
+
// Restored (hydrated) state: re-arm timers with elapsed credit — the
|
|
83
|
+
// deadline is enteredAt + delay, so per-request re-arms are idempotent
|
|
84
|
+
// and a deadline that passed while no process was alive fires promptly.
|
|
85
|
+
// A pendingEntry marker with no live invocation in this process is a
|
|
86
|
+
// crashed load: re-invoke it (same effectId; load role is re-runnable).
|
|
87
|
+
onStateRestore: (stateKey, ctx, info) => {
|
|
88
|
+
armAfterTimers(this.store, this.sessionId, def, stateKey, ctx, {
|
|
89
|
+
restore: true,
|
|
90
|
+
enteredAt: info.enteredAt,
|
|
91
|
+
})
|
|
92
|
+
if (info.pendingEntry && !isEffectInFlight(info.pendingEntry.effectId)) {
|
|
93
|
+
info.refireEntry()
|
|
94
|
+
}
|
|
95
|
+
},
|
|
76
96
|
}).start()
|
|
77
97
|
this.actors.set(
|
|
78
98
|
def.name,
|
|
@@ -225,8 +245,15 @@ export class SessionRuntime {
|
|
|
225
245
|
|
|
226
246
|
/** Write the current persisted snapshot for each touched machine back to
|
|
227
247
|
* its store: session machines to the session Store, app machines (touched
|
|
228
|
-
* via session→app subscriptions) to the AppStore when they opted in.
|
|
229
|
-
|
|
248
|
+
* via session→app subscriptions) to the AppStore when they opted in.
|
|
249
|
+
* `refreshTtl: false` (machine-driven re-entries: timers, effect
|
|
250
|
+
* completions) persists without extending the session's expiry — only real
|
|
251
|
+
* user requests count as user activity. */
|
|
252
|
+
async persistTouched(
|
|
253
|
+
touched: ReadonlySet<string>,
|
|
254
|
+
opts?: { refreshTtl?: boolean },
|
|
255
|
+
): Promise<void> {
|
|
256
|
+
const refreshTtl = opts?.refreshTtl !== false
|
|
230
257
|
const ttlSeconds = this.store.sessionTtlSeconds
|
|
231
258
|
for (const name of touched) {
|
|
232
259
|
const handle = this.actors.get(name)
|
|
@@ -237,11 +264,15 @@ export class SessionRuntime {
|
|
|
237
264
|
continue
|
|
238
265
|
}
|
|
239
266
|
const snapshot = handle.actor.getPersistedSnapshot()
|
|
240
|
-
//
|
|
241
|
-
// active, so all of their machines stay alive together.
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
267
|
+
// A ttlSeconds-bearing set refreshes the session's whole expiry — the
|
|
268
|
+
// user is active, so all of their machines stay alive together. TTL-less
|
|
269
|
+
// sets (machine-driven work) leave the expiry untouched.
|
|
270
|
+
await this.store.persistence.set(
|
|
271
|
+
this.sessionId,
|
|
272
|
+
name,
|
|
273
|
+
snapshot,
|
|
274
|
+
refreshTtl ? { ttlSeconds } : undefined,
|
|
275
|
+
)
|
|
245
276
|
}
|
|
246
277
|
}
|
|
247
278
|
|
package/src/server/timers.ts
CHANGED
|
@@ -33,9 +33,17 @@ export function cancelAfterTimers(scope: string, machineName: string, stateKey:
|
|
|
33
33
|
timers.delete(k)
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
+
/** Floor for a re-armed timer whose deadline already passed while no process
|
|
37
|
+
* was watching — fires promptly, but after the arming request's lock work. */
|
|
38
|
+
const OVERDUE_FIRE_MS = 40
|
|
39
|
+
|
|
36
40
|
/**
|
|
37
41
|
* Arm the `after` timers declared on `stateKey` (a no-op if it declares none).
|
|
38
|
-
* The host calls this when a machine ENTERS the state
|
|
42
|
+
* The host calls this when a machine ENTERS the state — or, with
|
|
43
|
+
* `opts.enteredAt`, when a hydrating runtime RESTORES it: the delay is then
|
|
44
|
+
* credited with time already served (deadline = enteredAt + delay, so re-arms
|
|
45
|
+
* across requests are deadline-idempotent, and a deadline that passed while no
|
|
46
|
+
* process was alive fires promptly instead of never). Each timer, on elapse,
|
|
39
47
|
* dispatches its `send` event back through the machine's normal event path —
|
|
40
48
|
* a session machine through the session re-entry (fresh lock + hydrate), an app
|
|
41
49
|
* machine through `dispatchToApp` (no lock; the long-lived instance is sent
|
|
@@ -49,13 +57,25 @@ export function armAfterTimers(
|
|
|
49
57
|
def: AnyMachineDef,
|
|
50
58
|
stateKey: string,
|
|
51
59
|
ctx: object,
|
|
60
|
+
opts?: { restore: true; enteredAt?: number },
|
|
52
61
|
): void {
|
|
53
62
|
const after = def.states[stateKey]?.after
|
|
54
63
|
if (!after || after.length === 0) return
|
|
55
|
-
cancelAfterTimers(scope, def.name, stateKey)
|
|
56
64
|
const k = key(scope, def.name, stateKey)
|
|
65
|
+
// Restore mode: an arm-set already ticking in this process is authoritative
|
|
66
|
+
// (same deadline by construction) — re-arming here would RESET an overdue
|
|
67
|
+
// timer's short fuse on every hydration, and a hot session could starve it
|
|
68
|
+
// forever. Restore only fills the void after a restart or lost timer. This
|
|
69
|
+
// holds even for pre-upgrade snapshots with no enteredAt (unknown elapsed
|
|
70
|
+
// time restarts the full delay — once, not per request).
|
|
71
|
+
if (opts?.restore && timers.has(k)) return
|
|
72
|
+
cancelAfterTimers(scope, def.name, stateKey)
|
|
57
73
|
const handles = after.map((entry) => {
|
|
58
|
-
const
|
|
74
|
+
const declared = typeof entry.delay === 'function' ? entry.delay(ctx as never) : entry.delay
|
|
75
|
+
const delay =
|
|
76
|
+
opts?.restore && opts.enteredAt !== undefined
|
|
77
|
+
? Math.max(declared - (Date.now() - opts.enteredAt), OVERDUE_FIRE_MS)
|
|
78
|
+
: declared
|
|
59
79
|
const h = setTimeout(() => {
|
|
60
80
|
timers.delete(k) // a state has one arm-set; drop the whole key on fire
|
|
61
81
|
const fire =
|
|
@@ -21,6 +21,15 @@ import { type DirectiveInvocation, defineDirective, invoke } from './core.ts'
|
|
|
21
21
|
|
|
22
22
|
type ConditionalEntry<TValue> = TValue | ReadResult<TValue>
|
|
23
23
|
|
|
24
|
+
/** Resolve the machine instance a spec read evaluates against. During a
|
|
25
|
+
* fan-out recompute this returns the CURRENT proxy — the ReadResult's captured
|
|
26
|
+
* `.instance` was frozen at first render, and `rehydrate()` has since replaced
|
|
27
|
+
* the connection's actor (FINDINGS #3, one composition boundary further out:
|
|
28
|
+
* the spec object outlives the render that built it). Null runtime (initial
|
|
29
|
+
* render, request-scoped recompute) falls back to the captured instance,
|
|
30
|
+
* which is current there by construction. */
|
|
31
|
+
type InstanceResolver = (r: ReadResult<unknown>) => unknown
|
|
32
|
+
|
|
24
33
|
/** Recursive shape accepted by class:list. */
|
|
25
34
|
export type ClassListSpec =
|
|
26
35
|
| string
|
|
@@ -41,8 +50,8 @@ export type StyleListSpec =
|
|
|
41
50
|
| Record<string, ConditionalEntry<string | number | null | undefined>>
|
|
42
51
|
| StyleListSpec[]
|
|
43
52
|
|
|
44
|
-
function evalRead<T>(value: ConditionalEntry<T
|
|
45
|
-
return isReadResult(value) ? (value.selector(value
|
|
53
|
+
function evalRead<T>(value: ConditionalEntry<T>, resolve: InstanceResolver): T {
|
|
54
|
+
return isReadResult(value) ? (value.selector(resolve(value)) as T) : value
|
|
46
55
|
}
|
|
47
56
|
|
|
48
57
|
/** Backstop for hand-written templates (the compiler rejects this at build
|
|
@@ -78,42 +87,42 @@ function collectMachines(spec: unknown, out: Set<string>): void {
|
|
|
78
87
|
}
|
|
79
88
|
}
|
|
80
89
|
|
|
81
|
-
function composeClass(spec: ClassListSpec): string {
|
|
90
|
+
function composeClass(spec: ClassListSpec, resolve: InstanceResolver): string {
|
|
82
91
|
const parts: string[] = []
|
|
83
|
-
walkClass(spec, parts)
|
|
92
|
+
walkClass(spec, parts, resolve)
|
|
84
93
|
return parts.join(' ')
|
|
85
94
|
}
|
|
86
95
|
|
|
87
|
-
function walkClass(spec: ClassListSpec, out: string[]): void {
|
|
96
|
+
function walkClass(spec: ClassListSpec, out: string[], resolve: InstanceResolver): void {
|
|
88
97
|
if (spec == null || spec === false) return
|
|
89
98
|
if (typeof spec === 'string') {
|
|
90
99
|
if (spec.length > 0) out.push(spec)
|
|
91
100
|
return
|
|
92
101
|
}
|
|
93
102
|
if (isReadResult(spec)) {
|
|
94
|
-
const v = spec.selector(spec
|
|
103
|
+
const v = spec.selector(resolve(spec))
|
|
95
104
|
if (typeof v === 'string' && v.length > 0) out.push(v)
|
|
96
105
|
return
|
|
97
106
|
}
|
|
98
107
|
if (Array.isArray(spec)) {
|
|
99
|
-
for (const item of spec) walkClass(item, out)
|
|
108
|
+
for (const item of spec) walkClass(item, out, resolve)
|
|
100
109
|
return
|
|
101
110
|
}
|
|
102
111
|
if (typeof spec === 'object') {
|
|
103
112
|
for (const [name, cond] of Object.entries(spec)) {
|
|
104
|
-
const truthy = evalRead(cond)
|
|
113
|
+
const truthy = evalRead(cond, resolve)
|
|
105
114
|
if (truthy) out.push(name)
|
|
106
115
|
}
|
|
107
116
|
}
|
|
108
117
|
}
|
|
109
118
|
|
|
110
|
-
function composeStyle(spec: StyleListSpec): string {
|
|
119
|
+
function composeStyle(spec: StyleListSpec, resolve: InstanceResolver): string {
|
|
111
120
|
const decls: string[] = []
|
|
112
|
-
walkStyle(spec, decls)
|
|
121
|
+
walkStyle(spec, decls, resolve)
|
|
113
122
|
return decls.join('; ')
|
|
114
123
|
}
|
|
115
124
|
|
|
116
|
-
function walkStyle(spec: StyleListSpec, out: string[]): void {
|
|
125
|
+
function walkStyle(spec: StyleListSpec, out: string[], resolve: InstanceResolver): void {
|
|
117
126
|
if (spec == null || spec === false) return
|
|
118
127
|
if (typeof spec === 'string') {
|
|
119
128
|
const trimmed = spec.trim()
|
|
@@ -121,19 +130,19 @@ function walkStyle(spec: StyleListSpec, out: string[]): void {
|
|
|
121
130
|
return
|
|
122
131
|
}
|
|
123
132
|
if (isReadResult(spec)) {
|
|
124
|
-
const v = spec.selector(spec
|
|
133
|
+
const v = spec.selector(resolve(spec))
|
|
125
134
|
if (typeof v === 'string' && v.trim().length > 0) {
|
|
126
135
|
out.push(v.trim().replace(/;\s*$/, ''))
|
|
127
136
|
}
|
|
128
137
|
return
|
|
129
138
|
}
|
|
130
139
|
if (Array.isArray(spec)) {
|
|
131
|
-
for (const item of spec) walkStyle(item, out)
|
|
140
|
+
for (const item of spec) walkStyle(item, out, resolve)
|
|
132
141
|
return
|
|
133
142
|
}
|
|
134
143
|
if (typeof spec === 'object') {
|
|
135
144
|
for (const [prop, raw] of Object.entries(spec)) {
|
|
136
|
-
const value = evalRead(raw)
|
|
145
|
+
const value = evalRead(raw, resolve)
|
|
137
146
|
if (value == null || value === '') continue
|
|
138
147
|
// A single property's value must not carry `;` — a `read()`-sourced value
|
|
139
148
|
// like `red; position: fixed; …` would otherwise inject extra
|
|
@@ -156,26 +165,32 @@ function cssValue(v: string): string {
|
|
|
156
165
|
/**
|
|
157
166
|
* Register one attr-binding per unique machine the spec depends on. Each
|
|
158
167
|
* binding's selector ignores its arg and recomputes the entire attribute
|
|
159
|
-
* by re-walking the spec — every ReadResult inside re-evaluates its
|
|
160
|
-
*
|
|
168
|
+
* by re-walking the spec — every ReadResult inside re-evaluates its selector
|
|
169
|
+
* against the RESOLVED instance (current proxy under a fan-out recompute,
|
|
170
|
+
* the captured one otherwise), returning fresh state.
|
|
161
171
|
*/
|
|
162
172
|
function bindListAttr<TSpec>(
|
|
163
173
|
state: RenderState,
|
|
164
174
|
spec: TSpec,
|
|
165
175
|
attrName: string,
|
|
166
176
|
elementId: string,
|
|
167
|
-
compose: (spec: TSpec) => string,
|
|
177
|
+
compose: (spec: TSpec, resolve: InstanceResolver) => string,
|
|
168
178
|
): string {
|
|
169
179
|
const machines = new Set<string>()
|
|
170
180
|
collectMachines(spec, machines)
|
|
171
181
|
|
|
172
|
-
|
|
182
|
+
// Resolution is per-evaluation, through the render state the binding was
|
|
183
|
+
// registered under — recompute sets `currentRuntime` for the fan-out pass.
|
|
184
|
+
const resolve: InstanceResolver = (r) =>
|
|
185
|
+
state.currentRuntime?.proxyFor(r.machineName) ?? r.instance
|
|
186
|
+
|
|
187
|
+
const initial = compose(spec, resolve)
|
|
173
188
|
for (const machineName of machines) {
|
|
174
189
|
const slotId = allocSlotId(state)
|
|
175
190
|
registerBinding(state, {
|
|
176
191
|
slotId,
|
|
177
192
|
machineName,
|
|
178
|
-
selector: () => compose(spec),
|
|
193
|
+
selector: () => compose(spec, resolve),
|
|
179
194
|
lastValue: initial,
|
|
180
195
|
kind: 'attr',
|
|
181
196
|
attrName,
|