@statorjs/stator 1.2.2 → 1.3.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/engine/actor.ts +59 -0
- package/src/engine/types.ts +33 -0
- package/src/server/api-route.ts +7 -1
- package/src/server/create-app.ts +3 -1
- package/src/server/dev.ts +3 -1
- package/src/server/effects.ts +36 -17
- package/src/server/http.ts +15 -1
- package/src/server/machine-store.ts +7 -0
- package/src/server/session-runtime.ts +14 -0
- package/src/server/timers.ts +77 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@statorjs/stator",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.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.3.0"
|
|
92
92
|
},
|
|
93
93
|
"scripts": {
|
|
94
94
|
"typecheck": "tsc --noEmit",
|
package/src/engine/actor.ts
CHANGED
|
@@ -63,6 +63,13 @@ export interface CreateActorOptions<C> {
|
|
|
63
63
|
* they take events straight from the untrusted wire (`/__events`), where a
|
|
64
64
|
* `@set` would be an arbitrary-context-write that bypasses every guard. */
|
|
65
65
|
internalEvents?: boolean
|
|
66
|
+
/** Host hook: a state was ENTERED (fresh start or a value-changing transition —
|
|
67
|
+
* never hydration). The server uses it to arm `after` timers from the state's
|
|
68
|
+
* config; `ctx` is the current context (for context-dependent delays). */
|
|
69
|
+
onStateEnter?: (stateKey: string, ctx: C) => void
|
|
70
|
+
/** Host hook: a state was LEFT (a value-changing transition). The server uses
|
|
71
|
+
* it to cancel that state's `after` timers. */
|
|
72
|
+
onStateExit?: (stateKey: string) => void
|
|
66
73
|
}
|
|
67
74
|
|
|
68
75
|
/** Unique per-invocation effect id — usable as an idempotency key, so it must
|
|
@@ -96,6 +103,10 @@ export function createActor<C extends object, E extends EventObject, S extends s
|
|
|
96
103
|
let context: C = opts.snapshot
|
|
97
104
|
? (structuredClone(opts.snapshot.context) as C)
|
|
98
105
|
: (structuredClone(def.context) as C)
|
|
106
|
+
// Hydrated actors have already lived: their current state's entry effect fired
|
|
107
|
+
// when it was entered, so `start()` must NOT re-fire it. Fresh actors (no
|
|
108
|
+
// snapshot) enter their initial state for the first time.
|
|
109
|
+
const hydrated = opts.snapshot !== undefined
|
|
99
110
|
|
|
100
111
|
const subscribers = new Set<(s: Snapshot<C>) => void>()
|
|
101
112
|
const emitListeners = new Map<string, Set<(e: EmittedEvent) => void>>()
|
|
@@ -112,6 +123,39 @@ export function createActor<C extends object, E extends EventObject, S extends s
|
|
|
112
123
|
for (const fn of subscribers) fn(snap)
|
|
113
124
|
}
|
|
114
125
|
|
|
126
|
+
// Schedule a state's entry effect (if declared) — the same host-scheduled,
|
|
127
|
+
// commit-time-snapshot, at-most-once path as a transition effect, minus the
|
|
128
|
+
// event. Firing does NOT bump `commits` (an entry is not a committed
|
|
129
|
+
// transition); the server persists an entry-firing machine via the host's
|
|
130
|
+
// pending-effects queue instead (see server/session-runtime.ts).
|
|
131
|
+
const fireEntryEffect = (stateKey: string): void => {
|
|
132
|
+
const entry = def.states[stateKey]?.entry
|
|
133
|
+
if (!entry) return
|
|
134
|
+
const effectId = newEffectId()
|
|
135
|
+
const ctxSnapshot = structuredClone(context)
|
|
136
|
+
const invocation: EffectInvocation = {
|
|
137
|
+
machineName: def.name,
|
|
138
|
+
effectId,
|
|
139
|
+
run: () => Promise.resolve(entry(ctxSnapshot, { effectId })),
|
|
140
|
+
}
|
|
141
|
+
if (opts.onEffect) {
|
|
142
|
+
opts.onEffect(invocation)
|
|
143
|
+
} else {
|
|
144
|
+
void invocation
|
|
145
|
+
.run()
|
|
146
|
+
.then((completion) => {
|
|
147
|
+
if (completion) actor.send(completion as E)
|
|
148
|
+
})
|
|
149
|
+
.catch((err) => {
|
|
150
|
+
console.error(
|
|
151
|
+
`stator: entry effect ${effectId} of "${def.name}" threw — effects must catch ` +
|
|
152
|
+
`and return their failure event. Dropped.`,
|
|
153
|
+
err,
|
|
154
|
+
)
|
|
155
|
+
})
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
115
159
|
const actor: Actor<C, E> = {
|
|
116
160
|
seed(partial: Partial<C>) {
|
|
117
161
|
if (!started) context = { ...context, ...partial }
|
|
@@ -120,6 +164,12 @@ export function createActor<C extends object, E extends EventObject, S extends s
|
|
|
120
164
|
if (!started) {
|
|
121
165
|
started = true
|
|
122
166
|
notify() // let subscribe-before-start consumers sync initial state
|
|
167
|
+
// Fresh initial-state entry (a hydrated actor already fired its).
|
|
168
|
+
if (!hydrated) {
|
|
169
|
+
const initial = value[value.length - 1]!
|
|
170
|
+
fireEntryEffect(initial)
|
|
171
|
+
opts.onStateEnter?.(initial, context)
|
|
172
|
+
}
|
|
123
173
|
}
|
|
124
174
|
return actor
|
|
125
175
|
},
|
|
@@ -228,6 +278,15 @@ export function createActor<C extends object, E extends EventObject, S extends s
|
|
|
228
278
|
}
|
|
229
279
|
}
|
|
230
280
|
|
|
281
|
+
// A value-changing transition leaves the old state and enters the new one:
|
|
282
|
+
// fire the new state's entry effect and let the host arm/cancel `after`
|
|
283
|
+
// timers (self-transitions and action-only transitions do neither).
|
|
284
|
+
if (config.to && config.to !== stateKey) {
|
|
285
|
+
opts.onStateExit?.(stateKey)
|
|
286
|
+
fireEntryEffect(config.to)
|
|
287
|
+
opts.onStateEnter?.(config.to, context)
|
|
288
|
+
}
|
|
289
|
+
|
|
231
290
|
notify()
|
|
232
291
|
},
|
|
233
292
|
|
package/src/engine/types.ts
CHANGED
|
@@ -83,6 +83,31 @@ export type Effect<C, E extends EventObject, EAll extends EventObject = EventObj
|
|
|
83
83
|
meta: EffectMeta,
|
|
84
84
|
) => Promise<EAll | null>
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* A state ENTRY effect: async I/O the host schedules when a state is *entered* —
|
|
88
|
+
* a fresh start at the initial state, or a transition that changes the state
|
|
89
|
+
* value; never on hydration. Same host-scheduled, off-lock, at-most-once
|
|
90
|
+
* pipeline as a transition `Effect`, minus the event argument (a state entry has
|
|
91
|
+
* no triggering event). Returns the completion event to dispatch (annotate the
|
|
92
|
+
* return with your machine's event union, exactly as for `Effect`), or null.
|
|
93
|
+
*/
|
|
94
|
+
export type EntryEffect<C, EAll extends EventObject = EventObject> = (
|
|
95
|
+
ctx: C,
|
|
96
|
+
meta: EffectMeta,
|
|
97
|
+
) => Promise<EAll | null>
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* A state timeout: after `delay` ms in the state, the host dispatches `send`.
|
|
101
|
+
* Armed on entry, cancelled on exit — host-scheduled, in-memory, non-durable in
|
|
102
|
+
* v1 (a restart drops armed timers). `delay` may depend on context. The trigger
|
|
103
|
+
* is a *described* value, not a bare-ms key, so it can grow (dynamic delays now;
|
|
104
|
+
* durable/cron schedules later) without a breaking change.
|
|
105
|
+
*/
|
|
106
|
+
export interface AfterEntry<C, EAll extends EventObject = EventObject> {
|
|
107
|
+
delay: number | ((ctx: C) => number)
|
|
108
|
+
send: EAll
|
|
109
|
+
}
|
|
110
|
+
|
|
86
111
|
/** A scheduled effect surfaced to the host: everything needed to run it and
|
|
87
112
|
* dispatch its completion. `run` closes over the commit-time snapshots. */
|
|
88
113
|
export interface EffectInvocation {
|
|
@@ -132,6 +157,14 @@ export type OnMap<C, E extends EventObject, S extends string, R = Record<string,
|
|
|
132
157
|
|
|
133
158
|
export interface StateNode<C, E extends EventObject, S extends string, R = Record<string, any>> {
|
|
134
159
|
on?: OnMap<C, E, S, R>
|
|
160
|
+
/** Async I/O run on *entering* this state (a fresh start at the initial state,
|
|
161
|
+
* or a value-changing transition — never on hydration), host-scheduled like a
|
|
162
|
+
* transition effect. `EAll` is the machine's full event union. See
|
|
163
|
+
* `EntryEffect`. */
|
|
164
|
+
entry?: EntryEffect<C, E>
|
|
165
|
+
/** State timeouts: each fires `send` after its `delay` ms in this state
|
|
166
|
+
* (armed on entry, cancelled on exit). See `AfterEntry`. */
|
|
167
|
+
after?: AfterEntry<C, E>[]
|
|
135
168
|
}
|
|
136
169
|
|
|
137
170
|
/** Payload selector runs synchronously AFTER the transition's action, so it
|
package/src/server/api-route.ts
CHANGED
|
@@ -83,8 +83,14 @@ export async function runApiRoute(
|
|
|
83
83
|
return c.text('Internal Server Error', 500)
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
// Persist committed machines plus any fresh machine that fired its initial
|
|
87
|
+
// entry effect (not in `touched` — an entry commits no transition), so it
|
|
88
|
+
// isn't re-created and re-fired next request. Fan-out stays on `touched`.
|
|
89
|
+
const toPersist = new Set([...touched, ...runtime.entryFiredMachines()])
|
|
90
|
+
if (toPersist.size > 0) {
|
|
91
|
+
await runtime.persistTouched(toPersist)
|
|
92
|
+
}
|
|
86
93
|
if (touched.size > 0) {
|
|
87
|
-
await runtime.persistTouched(touched)
|
|
88
94
|
await fanOut(touched, { sessionId })
|
|
89
95
|
}
|
|
90
96
|
|
package/src/server/create-app.ts
CHANGED
|
@@ -52,8 +52,10 @@ export async function createApp(config: CreateAppConfig): Promise<StatorApp> {
|
|
|
52
52
|
sessionTtlSeconds: config.sessionTtlSeconds,
|
|
53
53
|
appStore: config.appStore,
|
|
54
54
|
})
|
|
55
|
-
|
|
55
|
+
// Wire the effect scheduler BEFORE booting: a fresh app machine fires its
|
|
56
|
+
// initial-state entry effect during boot, and that must have somewhere to go.
|
|
56
57
|
wireAppEffects(store)
|
|
58
|
+
await store.bootAppMachines()
|
|
57
59
|
|
|
58
60
|
const routes = await discoverRoutes(routesDir)
|
|
59
61
|
const app = await buildHonoApp({
|
package/src/server/dev.ts
CHANGED
|
@@ -135,8 +135,10 @@ export async function createDevApp(config: DevAppConfig): Promise<DevApp> {
|
|
|
135
135
|
sessionTtlSeconds: config.sessionTtlSeconds,
|
|
136
136
|
appStore: config.appStore,
|
|
137
137
|
})
|
|
138
|
-
|
|
138
|
+
// Wire the effect scheduler before booting — a fresh app machine's
|
|
139
|
+
// initial-state entry effect fires during boot and needs a live scheduler.
|
|
139
140
|
runtime.wireAppEffects(store)
|
|
141
|
+
await store.bootAppMachines()
|
|
140
142
|
}
|
|
141
143
|
const rebuildRoutes = async (): Promise<void> => {
|
|
142
144
|
routes = await runtime.discoverRoutes(routesDir, loader)
|
package/src/server/effects.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AnyMachineDef, EffectInvocation } from '../engine/index.ts'
|
|
1
|
+
import type { AnyMachineDef, EffectInvocation, EventObject } from '../engine/index.ts'
|
|
2
2
|
import { dispatchToApp } from './app-dispatch.ts'
|
|
3
3
|
import { scopedLogger } from './logger.ts'
|
|
4
4
|
import type { MachineStore } from './machine-store.ts'
|
|
@@ -95,22 +95,7 @@ async function runSessionEffect(
|
|
|
95
95
|
if (!completion) return
|
|
96
96
|
|
|
97
97
|
try {
|
|
98
|
-
await
|
|
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
|
-
})
|
|
98
|
+
await reenterSessionEvent(store, sessionId, machineName, completion)
|
|
114
99
|
} catch (err) {
|
|
115
100
|
effectLog.error(
|
|
116
101
|
{ machine: machineName, effectId, err: String(err) },
|
|
@@ -118,3 +103,37 @@ async function runSessionEffect(
|
|
|
118
103
|
)
|
|
119
104
|
}
|
|
120
105
|
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Re-enter an out-of-band event (an effect completion, or an `after` timeout)
|
|
109
|
+
* through the full session event path: fresh lock, hydrate the machine (the
|
|
110
|
+
* triggering runtime is long gone — the transient-actor model working for us),
|
|
111
|
+
* process, persist (including a machine that fired an entry effect on the
|
|
112
|
+
* resulting transition), fan out to live connections, and schedule any effect
|
|
113
|
+
* the event chained. Shared by effect completions and state timeouts.
|
|
114
|
+
*/
|
|
115
|
+
export async function reenterSessionEvent(
|
|
116
|
+
store: MachineStore,
|
|
117
|
+
sessionId: string,
|
|
118
|
+
machineName: string,
|
|
119
|
+
event: EventObject,
|
|
120
|
+
): Promise<void> {
|
|
121
|
+
await withSessionLock(sessionId, async () => {
|
|
122
|
+
const runtime = new SessionRuntime(sessionId, store)
|
|
123
|
+
try {
|
|
124
|
+
const def = store.getDef(machineName)
|
|
125
|
+
if (!def) return // machine graph changed under us (dev reload) — drop
|
|
126
|
+
// loadGraph pulls reads + subscribers transitively, so emits reach
|
|
127
|
+
// cross-machine listeners like any other event.
|
|
128
|
+
await runtime.loadGraph([def])
|
|
129
|
+
runtime.wireSubscriptions()
|
|
130
|
+
const touched = runtime.processEvent(machineName, event)
|
|
131
|
+
await runtime.persistTouched(new Set([...touched, ...runtime.entryFiredMachines()]))
|
|
132
|
+
await fanOut(touched, { sessionId })
|
|
133
|
+
// A transition or entry effect chained off this event surfaces here.
|
|
134
|
+
scheduleSessionEffects(runtime, store, sessionId)
|
|
135
|
+
} finally {
|
|
136
|
+
runtime.dispose()
|
|
137
|
+
}
|
|
138
|
+
})
|
|
139
|
+
}
|
package/src/server/http.ts
CHANGED
|
@@ -333,7 +333,10 @@ export async function buildHonoApp(config: HttpConfig): Promise<Hono> {
|
|
|
333
333
|
patches.push(...recompute(renderState, name, runtime))
|
|
334
334
|
}
|
|
335
335
|
|
|
336
|
-
|
|
336
|
+
// Persist committed machines plus any fresh machine that fired its
|
|
337
|
+
// initial entry effect (an entry commits no transition, so it's not in
|
|
338
|
+
// `touched`) — so it isn't re-created and re-fired next request.
|
|
339
|
+
await runtime.persistTouched(new Set([...touched, ...runtime.entryFiredMachines()]))
|
|
337
340
|
|
|
338
341
|
await fanOut(touched, {
|
|
339
342
|
sessionId,
|
|
@@ -479,6 +482,17 @@ async function handleGet(
|
|
|
479
482
|
bodyEnd: bodyHtml.join(''),
|
|
480
483
|
})
|
|
481
484
|
applyRenderedEffects(c, result.response)
|
|
485
|
+
|
|
486
|
+
// A fresh machine that fired its initial entry effect on load must be
|
|
487
|
+
// persisted (so the next request hydrates instead of re-firing) and its
|
|
488
|
+
// effect scheduled off-lock, after the response. The common GET (no entry
|
|
489
|
+
// effect) skips both and stays a pure read.
|
|
490
|
+
const entryFired = runtime.entryFiredMachines()
|
|
491
|
+
if (entryFired.size > 0) {
|
|
492
|
+
await runtime.persistTouched(entryFired)
|
|
493
|
+
scheduleSessionEffects(runtime, store, sessionId)
|
|
494
|
+
}
|
|
495
|
+
|
|
482
496
|
return c.html(html)
|
|
483
497
|
} finally {
|
|
484
498
|
runtime.dispose()
|
|
@@ -6,6 +6,7 @@ import { createInstanceProxy, type InstanceHandle } from './instance-proxy.ts'
|
|
|
6
6
|
import { scopedLogger } from './logger.ts'
|
|
7
7
|
import { serverReadsResolver } from './reads-helpers.ts'
|
|
8
8
|
import type { Store } from './store.ts'
|
|
9
|
+
import { APP_SCOPE, armAfterTimers, cancelAfterTimers } from './timers.ts'
|
|
9
10
|
|
|
10
11
|
const storeLog = scopedLogger('machine-store')
|
|
11
12
|
|
|
@@ -259,6 +260,12 @@ export class MachineStore {
|
|
|
259
260
|
'app-machine effect dropped — no scheduler wired (call wireAppEffects(store))',
|
|
260
261
|
)
|
|
261
262
|
},
|
|
263
|
+
// `after` state timeouts, at app scope: arm on entry, cancel on exit.
|
|
264
|
+
// Timers live in the process-wide registry (server/timers.ts) and fire
|
|
265
|
+
// through dispatchToApp — no session, wall-clock only (revalidation,
|
|
266
|
+
// circuit breakers). See armAfterTimers.
|
|
267
|
+
onStateEnter: (stateKey, ctx) => armAfterTimers(this, APP_SCOPE, def, stateKey, ctx),
|
|
268
|
+
onStateExit: (stateKey) => cancelAfterTimers(APP_SCOPE, def.name, stateKey),
|
|
262
269
|
}).start()
|
|
263
270
|
this.appInstances.set(
|
|
264
271
|
def.name,
|
|
@@ -4,6 +4,7 @@ import { type DispatchContext, recordTouch, withDispatchContext } from './dispat
|
|
|
4
4
|
import { createInstanceProxy, type InstanceHandle } from './instance-proxy.ts'
|
|
5
5
|
import { buildDispatchEvent, MAX_CASCADE_DEPTH, type MachineStore } from './machine-store.ts'
|
|
6
6
|
import { serverReadsResolver } from './reads-helpers.ts'
|
|
7
|
+
import { armAfterTimers, cancelAfterTimers } from './timers.ts'
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* Per-request scope for session-lifecycle machine actors. Created at the
|
|
@@ -67,6 +68,11 @@ export class SessionRuntime {
|
|
|
67
68
|
// Server plane: never run effects inline — queue them for the entry
|
|
68
69
|
// point to schedule after persist + lock release.
|
|
69
70
|
onEffect: (invocation) => this.pendingEffects.push(invocation),
|
|
71
|
+
// `after` state timeouts: arm on entry, cancel on exit. Timers live in the
|
|
72
|
+
// process-wide registry (server/timers.ts), not this transient runtime.
|
|
73
|
+
onStateEnter: (stateKey, ctx) =>
|
|
74
|
+
armAfterTimers(this.store, this.sessionId, def, stateKey, ctx),
|
|
75
|
+
onStateExit: (stateKey) => cancelAfterTimers(this.sessionId, def.name, stateKey),
|
|
70
76
|
}).start()
|
|
71
77
|
this.actors.set(
|
|
72
78
|
def.name,
|
|
@@ -104,6 +110,14 @@ export class SessionRuntime {
|
|
|
104
110
|
return this.store.getDef(name)?.lifecycle
|
|
105
111
|
}
|
|
106
112
|
|
|
113
|
+
/** Machines whose entry effect fired on a fresh `start()` — queued in
|
|
114
|
+
* `pendingEffects` but distinct from `touched` (an entry commits no
|
|
115
|
+
* transition). Entry points persist this set so a freshly-entered state
|
|
116
|
+
* isn't re-created and re-fired next request. Read before draining. */
|
|
117
|
+
entryFiredMachines(): Set<string> {
|
|
118
|
+
return new Set(this.pendingEffects.map((e) => e.machineName))
|
|
119
|
+
}
|
|
120
|
+
|
|
107
121
|
/** Hand queued effect invocations to the scheduler, clearing the queue. */
|
|
108
122
|
drainPendingEffects(): EffectInvocation[] {
|
|
109
123
|
const drained = this.pendingEffects
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { AnyMachineDef } from '../engine/index.ts'
|
|
2
|
+
import { dispatchToApp } from './app-dispatch.ts'
|
|
3
|
+
import { reenterSessionEvent } from './effects.ts'
|
|
4
|
+
import { scopedLogger } from './logger.ts'
|
|
5
|
+
import type { MachineStore } from './machine-store.ts'
|
|
6
|
+
|
|
7
|
+
const timerLog = scopedLogger('timers')
|
|
8
|
+
|
|
9
|
+
/** Timer scope for app machines. They're process singletons (one instance per
|
|
10
|
+
* name), so they share one scope and the machine name disambiguates the key —
|
|
11
|
+
* unlike session machines, which scope by session id. */
|
|
12
|
+
export const APP_SCOPE = '@app'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Process-wide, in-memory, NON-DURABLE registry for `after` state timeouts.
|
|
16
|
+
* Session runtimes are transient (per request) and app instances live for the
|
|
17
|
+
* process, so a timer can't live on either — it lives here, keyed by the state
|
|
18
|
+
* it's counting down for, and fires across (or between) requests. A process
|
|
19
|
+
* restart drops armed timers (the 1.0 non-durable contract, same as effects).
|
|
20
|
+
*/
|
|
21
|
+
const timers = new Map<string, ReturnType<typeof setTimeout>[]>()
|
|
22
|
+
|
|
23
|
+
const key = (scope: string, machineName: string, stateKey: string): string =>
|
|
24
|
+
`${scope} ${machineName} ${stateKey}`
|
|
25
|
+
|
|
26
|
+
/** Cancel every `after` timer armed for a (scope, machine, state). `scope` is
|
|
27
|
+
* the session id for session machines, `APP_SCOPE` for app machines. */
|
|
28
|
+
export function cancelAfterTimers(scope: string, machineName: string, stateKey: string): void {
|
|
29
|
+
const k = key(scope, machineName, stateKey)
|
|
30
|
+
const handles = timers.get(k)
|
|
31
|
+
if (!handles) return
|
|
32
|
+
for (const h of handles) clearTimeout(h)
|
|
33
|
+
timers.delete(k)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 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. Each timer, on elapse,
|
|
39
|
+
* dispatches its `send` event back through the machine's normal event path —
|
|
40
|
+
* a session machine through the session re-entry (fresh lock + hydrate), an app
|
|
41
|
+
* machine through `dispatchToApp` (no lock; the long-lived instance is sent
|
|
42
|
+
* directly). Either way it's guard-dropped if the state has since moved on (the
|
|
43
|
+
* cancel-on-exit is the fast path; the guard-drop is the correctness backstop).
|
|
44
|
+
* Re-arming clears any stale timers for the key first.
|
|
45
|
+
*/
|
|
46
|
+
export function armAfterTimers(
|
|
47
|
+
store: MachineStore,
|
|
48
|
+
scope: string,
|
|
49
|
+
def: AnyMachineDef,
|
|
50
|
+
stateKey: string,
|
|
51
|
+
ctx: object,
|
|
52
|
+
): void {
|
|
53
|
+
const after = def.states[stateKey]?.after
|
|
54
|
+
if (!after || after.length === 0) return
|
|
55
|
+
cancelAfterTimers(scope, def.name, stateKey)
|
|
56
|
+
const k = key(scope, def.name, stateKey)
|
|
57
|
+
const handles = after.map((entry) => {
|
|
58
|
+
const delay = typeof entry.delay === 'function' ? entry.delay(ctx as never) : entry.delay
|
|
59
|
+
const h = setTimeout(() => {
|
|
60
|
+
timers.delete(k) // a state has one arm-set; drop the whole key on fire
|
|
61
|
+
const fire =
|
|
62
|
+
def.lifecycle === 'app'
|
|
63
|
+
? dispatchToApp(store, def, entry.send as never).then(() => {})
|
|
64
|
+
: reenterSessionEvent(store, scope, def.name, entry.send)
|
|
65
|
+
void fire.catch((err) => {
|
|
66
|
+
timerLog.error(
|
|
67
|
+
{ scope, machine: def.name, state: stateKey, err: String(err) },
|
|
68
|
+
'after-timer dispatch failed',
|
|
69
|
+
)
|
|
70
|
+
})
|
|
71
|
+
}, delay)
|
|
72
|
+
// Don't keep the process alive for a pending timer.
|
|
73
|
+
;(h as unknown as { unref?: () => void }).unref?.()
|
|
74
|
+
return h
|
|
75
|
+
})
|
|
76
|
+
timers.set(k, handles)
|
|
77
|
+
}
|