@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,236 @@
|
|
|
1
|
+
// biome-ignore-all lint/suspicious/noExplicitAny: type-level plumbing — existential machine slots and loose internal defaults require `any`; see the AnyMachineDef comment for the variance argument
|
|
2
|
+
/**
|
|
3
|
+
* Stator's own state-machine engine — core types.
|
|
4
|
+
*
|
|
5
|
+
* Designed fresh for Stator's needs, NOT for XState compatibility. The shape is
|
|
6
|
+
* a declarative `states` map (so the transition graph stays statically
|
|
7
|
+
* analyzable — the framework's core value), with inline functions on
|
|
8
|
+
* transitions (Option A: per-transition event narrowing, co-located logic).
|
|
9
|
+
*
|
|
10
|
+
* Lean feature set per the engine spec: flat states, typed events, guards,
|
|
11
|
+
* mutation-style actions, declared emits, selectors, snapshot ser/de. State
|
|
12
|
+
* value is a PATH ARRAY (`['active']`) — depth-1 today, the extension point for
|
|
13
|
+
* hierarchy later without a rewrite.
|
|
14
|
+
*
|
|
15
|
+
* Reads are type-safe by inference: a machine that `reads` others gets a typed
|
|
16
|
+
* `helpers.reads` map keyed by each read machine's `name`, with that machine's
|
|
17
|
+
* selectors (and their return types) preserved. See `ReadsMap` / `InstanceOf`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Every event is a discriminated union member keyed on `type`. */
|
|
21
|
+
export type EventObject = { type: string }
|
|
22
|
+
|
|
23
|
+
/** Selectors derive views: of their own context, and — when the machine
|
|
24
|
+
* declares `reads:` — of the read machines' selectors via the same helpers
|
|
25
|
+
* object actions/guards receive. One-param selectors remain valid. */
|
|
26
|
+
export type SelectorMap<C, R = Record<string, any>> = Record<
|
|
27
|
+
string,
|
|
28
|
+
// Method syntax → bivariant helpers param, so a machine with narrowly
|
|
29
|
+
// typed reads still satisfies contexts expecting the loose default.
|
|
30
|
+
{ selector(ctx: C, helpers: ActionHelpers<R>): unknown }['selector']
|
|
31
|
+
>
|
|
32
|
+
|
|
33
|
+
/** Helpers handed to actions/guards. `reads` is the typed map of machines
|
|
34
|
+
* declared in `reads:` (see `ReadsMap`). Defaults loose for internal/runtime
|
|
35
|
+
* use where the concrete map isn't threaded. */
|
|
36
|
+
export interface ActionHelpers<R = Record<string, any>> {
|
|
37
|
+
reads: R
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** An action mutates a draft of the context in place. The engine owns the
|
|
41
|
+
* clone + commit, so user code writes plain mutation. */
|
|
42
|
+
export type Action<C, E extends EventObject, R = Record<string, any>> = (
|
|
43
|
+
ctx: C,
|
|
44
|
+
ev: E,
|
|
45
|
+
helpers: ActionHelpers<R>,
|
|
46
|
+
) => void
|
|
47
|
+
|
|
48
|
+
/** A guard decides whether a transition may fire. Pure of (ctx, ev). */
|
|
49
|
+
export type Guard<C, E extends EventObject, R = Record<string, any>> = (
|
|
50
|
+
ctx: C,
|
|
51
|
+
ev: E,
|
|
52
|
+
helpers: ActionHelpers<R>,
|
|
53
|
+
) => boolean
|
|
54
|
+
|
|
55
|
+
/** Passed to an effect alongside the snapshots. `effectId` is unique per
|
|
56
|
+
* invocation — thread it to external calls as an idempotency key (1.x
|
|
57
|
+
* durability implies at-least-once) and use it for log correlation. */
|
|
58
|
+
export interface EffectMeta {
|
|
59
|
+
effectId: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* An effect: async I/O declared on a transition, run by the HOST after the
|
|
64
|
+
* transition commits (the engine itself never performs I/O). Receives
|
|
65
|
+
* commit-time `structuredClone` snapshots of context and event — never live
|
|
66
|
+
* state — plus meta. Returns the completion event to dispatch (typed against
|
|
67
|
+
* the machine's full event union), or null for fire-and-forget.
|
|
68
|
+
*
|
|
69
|
+
* Effects are infallible by construction: catch inside and return your
|
|
70
|
+
* declared failure event. A throw is the runtime backstop — logged and
|
|
71
|
+
* dropped, never a crash. See the engine-effects spec.
|
|
72
|
+
*
|
|
73
|
+
* Authoring note: annotate the effect's return type with your machine's event
|
|
74
|
+
* union — `effect: async (ctx, ev, meta): Promise<Events | null> => …`.
|
|
75
|
+
* TypeScript defers context-sensitive arrows during `defineMachine`'s generic
|
|
76
|
+
* inference, so an unannotated return widens its event literals and fails to
|
|
77
|
+
* typecheck (loudly — never silently unchecked). The annotation restores full
|
|
78
|
+
* checking: an undeclared completion event type is a compile error.
|
|
79
|
+
*/
|
|
80
|
+
export type Effect<C, E extends EventObject, EAll extends EventObject = EventObject> = (
|
|
81
|
+
ctx: C,
|
|
82
|
+
ev: E,
|
|
83
|
+
meta: EffectMeta,
|
|
84
|
+
) => Promise<EAll | null>
|
|
85
|
+
|
|
86
|
+
/** A scheduled effect surfaced to the host: everything needed to run it and
|
|
87
|
+
* dispatch its completion. `run` closes over the commit-time snapshots. */
|
|
88
|
+
export interface EffectInvocation {
|
|
89
|
+
machineName: string
|
|
90
|
+
effectId: string
|
|
91
|
+
run: () => Promise<EventObject | null>
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Object form of a transition. A bare `Action` is sugar for `{ do: fn }`.
|
|
95
|
+
* `E` is the event narrowed to this `on` key; `EAll` is the machine's full
|
|
96
|
+
* event union (what an effect's completion event is typed against). */
|
|
97
|
+
export interface TransitionConfig<
|
|
98
|
+
C,
|
|
99
|
+
E extends EventObject,
|
|
100
|
+
S extends string,
|
|
101
|
+
R = Record<string, any>,
|
|
102
|
+
EAll extends EventObject = EventObject,
|
|
103
|
+
> {
|
|
104
|
+
/** Target state. Omit for a self-transition (action only, no state change). */
|
|
105
|
+
to?: S
|
|
106
|
+
/** Guard — transition is skipped if it returns false. */
|
|
107
|
+
when?: Guard<C, E, R>
|
|
108
|
+
/** Action — mutates the draft context. */
|
|
109
|
+
do?: Action<C, E, R>
|
|
110
|
+
/** Declared emit(s) to fire after the action commits. */
|
|
111
|
+
emit?: string | string[]
|
|
112
|
+
/** Async I/O, host-scheduled after commit. See `Effect`. */
|
|
113
|
+
effect?: Effect<C, E, EAll>
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export type Transition<
|
|
117
|
+
C,
|
|
118
|
+
E extends EventObject,
|
|
119
|
+
S extends string,
|
|
120
|
+
R = Record<string, any>,
|
|
121
|
+
EAll extends EventObject = EventObject,
|
|
122
|
+
> = Action<C, E, R> | TransitionConfig<C, E, S, R, EAll>
|
|
123
|
+
|
|
124
|
+
/** The `on` map: each event type maps to a transition (or an ordered array of
|
|
125
|
+
* guarded candidates — first whose `when` passes wins) whose action/guard see
|
|
126
|
+
* the event NARROWED to exactly that type. This is the Option A payoff. */
|
|
127
|
+
export type OnMap<C, E extends EventObject, S extends string, R = Record<string, any>> = {
|
|
128
|
+
[K in E['type']]?:
|
|
129
|
+
| Transition<C, Extract<E, { type: K }>, S, R, E>
|
|
130
|
+
| Array<Transition<C, Extract<E, { type: K }>, S, R, E>>
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface StateNode<C, E extends EventObject, S extends string, R = Record<string, any>> {
|
|
134
|
+
on?: OnMap<C, E, S, R>
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Payload selector runs synchronously AFTER the transition's action, so it
|
|
138
|
+
* sees post-mutation context. Pure of (ctx, ev). The event is typed `any`
|
|
139
|
+
* because an emit may fire from several transitions carrying different events;
|
|
140
|
+
* the originating event isn't statically pinned to one union member. (Tighter
|
|
141
|
+
* emit typing is tracked as an open question on the engine spec.) */
|
|
142
|
+
export interface EmitDeclaration<C, _E extends EventObject = EventObject> {
|
|
143
|
+
payload?: (ctx: C, ev: any) => Record<string, unknown>
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export type EmitsConfig<C, E extends EventObject> =
|
|
147
|
+
| readonly string[]
|
|
148
|
+
| Record<string, EmitDeclaration<C, E> | null>
|
|
149
|
+
|
|
150
|
+
/** A machine's capability classification. `serverPinned` means it may not be
|
|
151
|
+
* placed on the client; `reasons` explains why (surfaced in compile errors). */
|
|
152
|
+
export interface Capabilities {
|
|
153
|
+
serverPinned: boolean
|
|
154
|
+
reasons: string[]
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Compact, engine-owned snapshot. Serializes for the Store (server sessions)
|
|
158
|
+
* and seeds a client actor on custom-element upgrade (hydration). */
|
|
159
|
+
export interface Snapshot<C> {
|
|
160
|
+
/** State path. Depth-1 today (`['idle']`); extensible to hierarchy. */
|
|
161
|
+
value: string[]
|
|
162
|
+
context: C
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export type Lifecycle = 'app' | 'session'
|
|
166
|
+
|
|
167
|
+
/** Cross-machine subscription entry (carried through unchanged from the POC
|
|
168
|
+
* model; the glue layer consumes it). */
|
|
169
|
+
export interface SubscribeEntry {
|
|
170
|
+
from: AnyMachineDef
|
|
171
|
+
event: string
|
|
172
|
+
dispatch: string | { type: string; [k: string]: unknown }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export interface MachineDef<
|
|
176
|
+
C = any,
|
|
177
|
+
E extends EventObject = any,
|
|
178
|
+
S extends string = string,
|
|
179
|
+
Sel extends SelectorMap<C> = SelectorMap<C>,
|
|
180
|
+
Name extends string = string,
|
|
181
|
+
> {
|
|
182
|
+
readonly __isStatorMachine: true
|
|
183
|
+
name: Name
|
|
184
|
+
lifecycle: Lifecycle
|
|
185
|
+
/** APP machines only: snapshot persists through the AppStore across
|
|
186
|
+
* restarts (opt-in; see DefineMachineConfig.persist). */
|
|
187
|
+
persist: boolean
|
|
188
|
+
reads: AnyMachineDef[]
|
|
189
|
+
subscribes: SubscribeEntry[]
|
|
190
|
+
/** Normalized: every declared emit, possibly with a payload selector. */
|
|
191
|
+
emits: Record<string, EmitDeclaration<C, E>>
|
|
192
|
+
selectors: Sel
|
|
193
|
+
capabilities: Capabilities
|
|
194
|
+
initial: string
|
|
195
|
+
/** Engine internals — the transition graph (R erased post-construction; the
|
|
196
|
+
* reads typing that matters is enforced at the authoring callsite) and the
|
|
197
|
+
* initial context. */
|
|
198
|
+
states: Record<string, StateNode<C, E, S>>
|
|
199
|
+
context: C
|
|
200
|
+
/** Type-level carriers — never read at runtime. */
|
|
201
|
+
readonly __context: C
|
|
202
|
+
readonly __event: E
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The "top type" for a machine def — used wherever a heterogeneous collection
|
|
206
|
+
* of machines is held (e.g. `reads`). `any` in the slots is deliberate:
|
|
207
|
+
* `MachineDef` is invariant in its context (context appears in contravariant
|
|
208
|
+
* action-parameter positions), so a specific `MachineDef<{...}>` is NOT
|
|
209
|
+
* assignable to `MachineDef<unknown>` — only `any` admits every concrete def.
|
|
210
|
+
* Callsite type-safety is recovered via `ReadsMap` inference, not here. */
|
|
211
|
+
export type AnyMachineDef = MachineDef<any, any, any, any, any>
|
|
212
|
+
|
|
213
|
+
/** The instance-facing shape of a machine: each selector exposed as a property
|
|
214
|
+
* carrying that selector's return type (callable if the selector returns a
|
|
215
|
+
* function). This is what `helpers.reads.<Name>` resolves to. */
|
|
216
|
+
export type InstanceOf<TDef extends AnyMachineDef> =
|
|
217
|
+
TDef extends MachineDef<any, any, any, infer Sel, any>
|
|
218
|
+
? { readonly [K in keyof Sel]: ReturnType<Sel[K]> }
|
|
219
|
+
: never
|
|
220
|
+
|
|
221
|
+
/** Build the typed `helpers.reads` map from a tuple of read machines: keyed by
|
|
222
|
+
* each machine's literal `name`, valued by its instance type. */
|
|
223
|
+
export type ReadsMap<TReads extends readonly AnyMachineDef[]> = {
|
|
224
|
+
[M in TReads[number] as M['name']]: InstanceOf<M>
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** The event union a machine accepts — used to type machine-mediated dispatch
|
|
228
|
+
* (`dispatch(Machine, event)`) against the imported def. */
|
|
229
|
+
export type EventOf<D extends AnyMachineDef> =
|
|
230
|
+
D extends MachineDef<any, infer E, any, any, any> ? E : never
|
|
231
|
+
|
|
232
|
+
export function isStatorMachine(v: unknown): v is AnyMachineDef {
|
|
233
|
+
return (
|
|
234
|
+
typeof v === 'object' && v !== null && (v as Record<string, unknown>).__isStatorMachine === true
|
|
235
|
+
)
|
|
236
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { Context } from 'hono'
|
|
2
|
+
import { setCookie } from 'hono/cookie'
|
|
3
|
+
import { scheduleSessionEffects } from './effects.ts'
|
|
4
|
+
import { scopedLogger } from './logger.ts'
|
|
5
|
+
import type { MachineStore } from './machine-store.ts'
|
|
6
|
+
import type { RenderedResponseEffects } from './render.ts'
|
|
7
|
+
import type { DiscoveredRoute } from './route-discovery.ts'
|
|
8
|
+
import { buildRouteRequest } from './route-request.ts'
|
|
9
|
+
import type {
|
|
10
|
+
ApiRouteDefinition,
|
|
11
|
+
ApiRouteEnvelope,
|
|
12
|
+
ApiRouteHelpers,
|
|
13
|
+
Directive,
|
|
14
|
+
RouteRequest,
|
|
15
|
+
} from './routing.ts'
|
|
16
|
+
import { getOrCreateSessionId } from './session.ts'
|
|
17
|
+
import { withSessionLock } from './session-lock.ts'
|
|
18
|
+
import { SessionRuntime } from './session-runtime.ts'
|
|
19
|
+
import { fanOut } from './sse.ts'
|
|
20
|
+
|
|
21
|
+
const apiLog = scopedLogger('api')
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Run an API route handler under a fresh SessionRuntime, marshal the
|
|
25
|
+
* result into an HTTP response, fan-out to live SSE connections.
|
|
26
|
+
*
|
|
27
|
+
* The handler can return:
|
|
28
|
+
* - A raw Response (escape hatch; framework returns it verbatim).
|
|
29
|
+
* - A directives envelope `{ patches?, directives? }`. The framework
|
|
30
|
+
* synthesizes the HTTP response based on the client's Accept header:
|
|
31
|
+
* `text/html` gets a 303 redirect (or a re-render of the source page);
|
|
32
|
+
* anything else (default JSON for the client runtime) gets the
|
|
33
|
+
* envelope as JSON.
|
|
34
|
+
*/
|
|
35
|
+
export async function runApiRoute(
|
|
36
|
+
c: Context,
|
|
37
|
+
discovered: DiscoveredRoute,
|
|
38
|
+
route: ApiRouteDefinition,
|
|
39
|
+
store: MachineStore,
|
|
40
|
+
/** Path params from the framework's own matcher (the dispatch catch-all
|
|
41
|
+
* bypasses Hono's per-route param extraction). */
|
|
42
|
+
params?: Record<string, string>,
|
|
43
|
+
): Promise<Response> {
|
|
44
|
+
const { sessionId } = getOrCreateSessionId(c)
|
|
45
|
+
const request = params
|
|
46
|
+
? { ...buildRouteRequest(c, discovered.paramNames), params }
|
|
47
|
+
: buildRouteRequest(c, discovered.paramNames)
|
|
48
|
+
|
|
49
|
+
return withSessionLock(sessionId, async () => {
|
|
50
|
+
const runtime = new SessionRuntime(sessionId, store)
|
|
51
|
+
try {
|
|
52
|
+
await runtime.loadGraph(route.reads)
|
|
53
|
+
runtime.wireSubscriptions()
|
|
54
|
+
|
|
55
|
+
// Track which machines got touched so we can persist + fan-out.
|
|
56
|
+
const touched = new Set<string>()
|
|
57
|
+
|
|
58
|
+
const helpers: ApiRouteHelpers = {
|
|
59
|
+
dispatch: async (machine, event) => {
|
|
60
|
+
// API routes have no render target to diff against, so dispatches
|
|
61
|
+
// here don't produce patches. (Patches from API-route dispatches
|
|
62
|
+
// would need a target page; that's a future spec.)
|
|
63
|
+
// The target machine is addressed by its def; read the name off it.
|
|
64
|
+
const dispatchedTouched = runtime.processEvent(machine.name, event)
|
|
65
|
+
for (const name of dispatchedTouched) touched.add(name)
|
|
66
|
+
},
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let result: Response | ApiRouteEnvelope
|
|
70
|
+
try {
|
|
71
|
+
result = await route.handler(request, helpers)
|
|
72
|
+
} catch (err) {
|
|
73
|
+
apiLog.error({ err: String(err), path: c.req.path }, 'api route handler threw')
|
|
74
|
+
return c.text('Internal Server Error', 500)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (touched.size > 0) {
|
|
78
|
+
await runtime.persistTouched(touched)
|
|
79
|
+
await fanOut(touched, { sessionId })
|
|
80
|
+
}
|
|
81
|
+
// Effects queued by dispatched events run after this callback returns
|
|
82
|
+
// (never under the session lock); see server/effects.ts.
|
|
83
|
+
scheduleSessionEffects(runtime, store, sessionId)
|
|
84
|
+
|
|
85
|
+
// Escape hatch: handler returned a real Response. Pass through.
|
|
86
|
+
if (result instanceof Response) return result
|
|
87
|
+
|
|
88
|
+
const envelope = result as ApiRouteEnvelope
|
|
89
|
+
return synthesizeResponse(c, request, envelope)
|
|
90
|
+
} finally {
|
|
91
|
+
runtime.dispose()
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Content-negotiated response synthesis from a directives envelope.
|
|
98
|
+
*
|
|
99
|
+
* - HTML clients (Accept includes text/html, typical for raw browser form
|
|
100
|
+
* POSTs) get an HTTP-native equivalent. The first `navigate` directive
|
|
101
|
+
* becomes a 303 + Location. `reload` becomes a 303 back to the referer.
|
|
102
|
+
* Without a directive, return a minimal 204.
|
|
103
|
+
* - JSON clients (client runtime, default) get the envelope as JSON.
|
|
104
|
+
*/
|
|
105
|
+
function synthesizeResponse(
|
|
106
|
+
c: Context,
|
|
107
|
+
request: RouteRequest,
|
|
108
|
+
envelope: ApiRouteEnvelope,
|
|
109
|
+
): Response {
|
|
110
|
+
const accept = request.headers.get('accept') ?? ''
|
|
111
|
+
const wantsHtml = accept.includes('text/html') && !accept.includes('application/json')
|
|
112
|
+
|
|
113
|
+
if (wantsHtml) {
|
|
114
|
+
const navigate = envelope.directives?.find(
|
|
115
|
+
(d): d is Extract<Directive, { type: 'navigate' }> => d.type === 'navigate',
|
|
116
|
+
)
|
|
117
|
+
if (navigate) {
|
|
118
|
+
return c.redirect(navigate.to, 303)
|
|
119
|
+
}
|
|
120
|
+
const reload = envelope.directives?.find((d) => d.type === 'reload')
|
|
121
|
+
if (reload) {
|
|
122
|
+
const ref = request.headers.get('referer') ?? '/'
|
|
123
|
+
return c.redirect(ref, 303)
|
|
124
|
+
}
|
|
125
|
+
// No actionable directive for a no-JS client. Send a minimal 204.
|
|
126
|
+
return new Response(null, { status: 204 })
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// JSON / client-runtime path.
|
|
130
|
+
return c.json({
|
|
131
|
+
patches: envelope.patches ?? [],
|
|
132
|
+
directives: envelope.directives ?? [],
|
|
133
|
+
})
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Apply rendered response effects (cookies, headers, status) to a Hono
|
|
137
|
+
* context. Used for GET routes that wrote to the response side-effect
|
|
138
|
+
* surface during render. */
|
|
139
|
+
export function applyRenderedEffects(c: Context, effects: RenderedResponseEffects): void {
|
|
140
|
+
effects.headers.forEach((value, key) => {
|
|
141
|
+
c.header(key, value)
|
|
142
|
+
})
|
|
143
|
+
for (const cookie of effects.cookies) {
|
|
144
|
+
setCookie(c, cookie.name, cookie.value, cookie.options as never)
|
|
145
|
+
}
|
|
146
|
+
if (effects.status !== 200) {
|
|
147
|
+
c.status(effects.status as never)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { AnyMachineDef, EventOf } from '../engine/index.ts'
|
|
2
|
+
import { withDispatchContext } from './dispatch-context.ts'
|
|
3
|
+
import type { MachineStore } from './machine-store.ts'
|
|
4
|
+
import { SessionRuntime } from './session-runtime.ts'
|
|
5
|
+
import { fanOut } from './sse.ts'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Server-originated dispatch to an APP-lifecycle machine — the entry point
|
|
9
|
+
* for webhooks, cron jobs, and app-effect completions. No HTTP request, no
|
|
10
|
+
* session, no lock (app actors are long-lived and in-process; sends are
|
|
11
|
+
* atomic): send → persist opted-in touched app machines → fan out to every
|
|
12
|
+
* live SSE connection whose route reads a touched machine.
|
|
13
|
+
*
|
|
14
|
+
* Typed like client dispatch: the machine is addressed by its imported def
|
|
15
|
+
* and the event checks against its declared union.
|
|
16
|
+
*/
|
|
17
|
+
export async function dispatchToApp<D extends AnyMachineDef>(
|
|
18
|
+
store: MachineStore,
|
|
19
|
+
machine: D,
|
|
20
|
+
event: EventOf<D>,
|
|
21
|
+
): Promise<void> {
|
|
22
|
+
const name = machine.name
|
|
23
|
+
const handle = store.appInstance(name)
|
|
24
|
+
if (!handle) {
|
|
25
|
+
const known = store.hasMachine(name)
|
|
26
|
+
throw new Error(
|
|
27
|
+
known
|
|
28
|
+
? `stator: dispatchToApp("${name}") — machine is not app-lifecycle. ` +
|
|
29
|
+
`Session machines are dispatched per-session via /__events or an API route.`
|
|
30
|
+
: `stator: dispatchToApp("${name}") — unknown machine. Is it in the machines directory?`,
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const touched = new Set<string>([name])
|
|
35
|
+
// A throwaway runtime backs `reads` resolution for the dispatch context:
|
|
36
|
+
// app machines resolve via its app-instance fallback, and a stray read of a
|
|
37
|
+
// session machine throws (correct — there is no session here).
|
|
38
|
+
const runtime = new SessionRuntime(`@app-dispatch:${name}`, store)
|
|
39
|
+
try {
|
|
40
|
+
const before = handle.actor.getCommitCount()
|
|
41
|
+
withDispatchContext({ runtime, touched }, () => {
|
|
42
|
+
handle.actor.send(event as never)
|
|
43
|
+
})
|
|
44
|
+
if (handle.actor.getCommitCount() === before) touched.delete(name)
|
|
45
|
+
for (const touchedName of touched) {
|
|
46
|
+
await store.persistAppMachine(touchedName)
|
|
47
|
+
}
|
|
48
|
+
await fanOut(touched)
|
|
49
|
+
} finally {
|
|
50
|
+
runtime.dispose()
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistence boundary for APP-lifecycle machine state — a sibling of the
|
|
3
|
+
* session `Store`, deliberately not a merger (see the app-machine-state-
|
|
4
|
+
* persistence spec): app state is one blob per machine name, has NO TTL
|
|
5
|
+
* (process-equivalent persistence, not user-session storage), and no
|
|
6
|
+
* per-session sharding key.
|
|
7
|
+
*
|
|
8
|
+
* Opt-in per machine via `persist: true` in `defineMachine` — some app
|
|
9
|
+
* machines genuinely should reset on restart (caches, connection pools).
|
|
10
|
+
*
|
|
11
|
+
* Single-writer assumption: two replicas both persisting the same app
|
|
12
|
+
* machine will drift. Multi-replica coordination is out of scope for 1.x —
|
|
13
|
+
* see the spec's open questions.
|
|
14
|
+
*/
|
|
15
|
+
export interface AppStore {
|
|
16
|
+
loadAppMachine(name: string): Promise<unknown | null>
|
|
17
|
+
saveAppMachine(name: string, snapshot: unknown): Promise<void>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Process-memory implementation — same restart-wipe semantics as no
|
|
21
|
+
* persistence at all; exists so the interface is uniform and tests are
|
|
22
|
+
* trivial. */
|
|
23
|
+
export class InMemoryAppStore implements AppStore {
|
|
24
|
+
private data = new Map<string, unknown>()
|
|
25
|
+
|
|
26
|
+
async loadAppMachine(name: string): Promise<unknown | null> {
|
|
27
|
+
return this.data.has(name) ? this.data.get(name)! : null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async saveAppMachine(name: string, snapshot: unknown): Promise<void> {
|
|
31
|
+
// Clone through JSON so callers can't mutate stored state by reference —
|
|
32
|
+
// and so anything non-serializable fails here, not in a Redis swap later.
|
|
33
|
+
this.data.set(name, JSON.parse(JSON.stringify(snapshot)))
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import type { Store } from './store.ts'
|
|
2
|
+
|
|
3
|
+
interface CacheEntry {
|
|
4
|
+
snapshot: unknown
|
|
5
|
+
/** Epoch ms after which this entry is considered expired. */
|
|
6
|
+
expiresAt: number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface CachedStoreOptions {
|
|
10
|
+
/** Max number of (sessionId, machineName) entries kept in memory. LRU
|
|
11
|
+
* eviction when exceeded. Defaults to 10_000. */
|
|
12
|
+
maxEntries?: number
|
|
13
|
+
/** Memory cache TTL per entry, in seconds. Capped at the backing TTL
|
|
14
|
+
* on a per-set basis — memory never out-lives backing. Defaults to 300
|
|
15
|
+
* (5 minutes). */
|
|
16
|
+
memoryTtlSeconds?: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Write-through, read-cached `Store` decorator.
|
|
21
|
+
*
|
|
22
|
+
* Wraps any backing `Store` and keeps a small in-process LRU of recent
|
|
23
|
+
* reads to reduce backing-store traffic (Upstash command counts, Redis
|
|
24
|
+
* hops, etc.) on hot sessions.
|
|
25
|
+
*
|
|
26
|
+
* Properties:
|
|
27
|
+
* - **Writes go to backing first**, then update memory. No data loss on
|
|
28
|
+
* crash; durability matches backing.
|
|
29
|
+
* - **Memory TTL ≤ backing TTL** per entry. When `set` is called with
|
|
30
|
+
* `ttlSeconds`, the memory entry's expiry is `min(memoryTtlMs, ttlMs)`.
|
|
31
|
+
* Memory cannot hold a value that backing would have expired.
|
|
32
|
+
* - **Bounded by `maxEntries`**, LRU evicted via Map insertion-order
|
|
33
|
+
* plus delete-then-set on access (`populate` and successful `get`).
|
|
34
|
+
* - **Safe in single-replica deployments** where this process is the
|
|
35
|
+
* only writer to the backing store. Not safe across replicas — would
|
|
36
|
+
* need an invalidation channel (Redis pub/sub) for that.
|
|
37
|
+
*/
|
|
38
|
+
export class CachedStore implements Store {
|
|
39
|
+
private cache = new Map<string, CacheEntry>()
|
|
40
|
+
private readonly maxEntries: number
|
|
41
|
+
private readonly memoryTtlMs: number
|
|
42
|
+
|
|
43
|
+
constructor(
|
|
44
|
+
private readonly backing: Store,
|
|
45
|
+
opts: CachedStoreOptions = {},
|
|
46
|
+
) {
|
|
47
|
+
this.maxEntries = opts.maxEntries ?? 10_000
|
|
48
|
+
this.memoryTtlMs = (opts.memoryTtlSeconds ?? 300) * 1000
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private key(sid: string, name: string): string {
|
|
52
|
+
return `${sid}:${name}`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async get(sid: string, name: string): Promise<unknown | null> {
|
|
56
|
+
const k = this.key(sid, name)
|
|
57
|
+
const entry = this.cache.get(k)
|
|
58
|
+
if (entry) {
|
|
59
|
+
if (entry.expiresAt > Date.now()) {
|
|
60
|
+
// LRU bump: move to end of insertion order.
|
|
61
|
+
this.cache.delete(k)
|
|
62
|
+
this.cache.set(k, entry)
|
|
63
|
+
return entry.snapshot
|
|
64
|
+
}
|
|
65
|
+
// Expired — drop it and fall through to backing.
|
|
66
|
+
this.cache.delete(k)
|
|
67
|
+
}
|
|
68
|
+
const fresh = await this.backing.get(sid, name)
|
|
69
|
+
if (fresh !== null) {
|
|
70
|
+
this.populate(k, fresh, this.memoryTtlMs)
|
|
71
|
+
}
|
|
72
|
+
return fresh
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async set(
|
|
76
|
+
sid: string,
|
|
77
|
+
name: string,
|
|
78
|
+
snapshot: unknown,
|
|
79
|
+
opts?: { ttlSeconds?: number },
|
|
80
|
+
): Promise<void> {
|
|
81
|
+
// Write-through: backing first so durability matches its semantics
|
|
82
|
+
// even if the process crashes mid-set.
|
|
83
|
+
await this.backing.set(sid, name, snapshot, opts)
|
|
84
|
+
const backingTtlMs = opts?.ttlSeconds != null ? opts.ttlSeconds * 1000 : Infinity
|
|
85
|
+
this.populate(this.key(sid, name), snapshot, Math.min(this.memoryTtlMs, backingTtlMs))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async has(sid: string, name: string): Promise<boolean> {
|
|
89
|
+
const entry = this.cache.get(this.key(sid, name))
|
|
90
|
+
if (entry && entry.expiresAt > Date.now()) return true
|
|
91
|
+
return this.backing.has(sid, name)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async deleteSession(sid: string): Promise<void> {
|
|
95
|
+
const prefix = `${sid}:`
|
|
96
|
+
for (const k of [...this.cache.keys()]) {
|
|
97
|
+
if (k.startsWith(prefix)) this.cache.delete(k)
|
|
98
|
+
}
|
|
99
|
+
await this.backing.deleteSession(sid)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Insert / refresh an entry at LRU-end and evict oldest if over capacity. */
|
|
103
|
+
private populate(key: string, snapshot: unknown, ttlMs: number): void {
|
|
104
|
+
this.cache.delete(key)
|
|
105
|
+
this.cache.set(key, { snapshot, expiresAt: Date.now() + ttlMs })
|
|
106
|
+
while (this.cache.size > this.maxEntries) {
|
|
107
|
+
const oldest = this.cache.keys().next().value
|
|
108
|
+
if (!oldest) break
|
|
109
|
+
this.cache.delete(oldest)
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Diagnostic: current cached entry count. */
|
|
114
|
+
get size(): number {
|
|
115
|
+
return this.cache.size
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { resolve } from 'node:path'
|
|
2
|
+
import { serve } from '@hono/node-server'
|
|
3
|
+
import type { AppStore } from './app-store.ts'
|
|
4
|
+
import { discoverMachines } from './discovery.ts'
|
|
5
|
+
import { wireAppEffects } from './effects.ts'
|
|
6
|
+
import { buildHonoApp } from './http.ts'
|
|
7
|
+
import { logger } from './logger.ts'
|
|
8
|
+
import { MachineStore } from './machine-store.ts'
|
|
9
|
+
import { discoverRoutes } from './route-discovery.ts'
|
|
10
|
+
import { InMemoryStore, type Store } from './store.ts'
|
|
11
|
+
|
|
12
|
+
export interface CreateAppConfig {
|
|
13
|
+
machinesDir: string
|
|
14
|
+
routesDir: string
|
|
15
|
+
staticDir?: string
|
|
16
|
+
/** Persistence adapter for session-lifecycle machine state. Defaults to
|
|
17
|
+
* InMemoryStore — fine for dev, V1 adapters swap in here. */
|
|
18
|
+
store?: Store
|
|
19
|
+
/** Persistence for `persist: true` app-lifecycle machines (no TTL, one
|
|
20
|
+
* blob per machine). Defaults to in-memory (restart-wipe); pass
|
|
21
|
+
* RedisAppStore for durable app state. */
|
|
22
|
+
appStore?: AppStore
|
|
23
|
+
/** Per-session TTL in seconds. Every set to any of the session's
|
|
24
|
+
* machines refreshes this expiry. Defaults to 24h (86400). */
|
|
25
|
+
sessionTtlSeconds?: number
|
|
26
|
+
/** Extra `<head>` HTML per GET route. A production build uses this to link the
|
|
27
|
+
* prebuilt `components.css`; ignored if omitted. */
|
|
28
|
+
headExtras?: (filePath: string) => string | Promise<string>
|
|
29
|
+
/** Serve + inject the wire inspector toolbar (the dev server's on by
|
|
30
|
+
* default; production opts in — demo sites want the wire visible). */
|
|
31
|
+
inspector?: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface StatorApp {
|
|
35
|
+
listen(port: number): Promise<void>
|
|
36
|
+
/** For tests — get the underlying Hono fetch handler. */
|
|
37
|
+
fetch: (request: Request) => Response | Promise<Response>
|
|
38
|
+
/** The machine registry + app actors — pass to `dispatchToApp` for
|
|
39
|
+
* server-originated events (webhooks, cron). */
|
|
40
|
+
store: MachineStore
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function createApp(config: CreateAppConfig): Promise<StatorApp> {
|
|
44
|
+
const machinesDir = resolve(config.machinesDir)
|
|
45
|
+
const routesDir = resolve(config.routesDir)
|
|
46
|
+
const staticDir = config.staticDir ? resolve(config.staticDir) : undefined
|
|
47
|
+
|
|
48
|
+
const { defs } = await discoverMachines(machinesDir)
|
|
49
|
+
const persistence = config.store ?? new InMemoryStore()
|
|
50
|
+
const store = new MachineStore(defs, persistence, {
|
|
51
|
+
sessionTtlSeconds: config.sessionTtlSeconds,
|
|
52
|
+
appStore: config.appStore,
|
|
53
|
+
})
|
|
54
|
+
await store.bootAppMachines()
|
|
55
|
+
wireAppEffects(store)
|
|
56
|
+
|
|
57
|
+
const routes = await discoverRoutes(routesDir)
|
|
58
|
+
const app = await buildHonoApp({
|
|
59
|
+
routes,
|
|
60
|
+
store,
|
|
61
|
+
staticDir,
|
|
62
|
+
headExtras: config.inspector
|
|
63
|
+
? async (filePath) => {
|
|
64
|
+
const base = (await config.headExtras?.(filePath)) ?? ''
|
|
65
|
+
return `${base}\n<script src="/@stator/inspector.js" defer></script>`
|
|
66
|
+
}
|
|
67
|
+
: config.headExtras,
|
|
68
|
+
inspector: config.inspector,
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
listen(port: number): Promise<void> {
|
|
73
|
+
return new Promise((resolveFn) => {
|
|
74
|
+
serve({ fetch: app.fetch, port }, () => {
|
|
75
|
+
logger.info({ port, machines: defs.length, routes: routes.length }, 'listening')
|
|
76
|
+
resolveFn()
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
},
|
|
80
|
+
fetch: (request: Request) => app.fetch(request),
|
|
81
|
+
store,
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The framework's machine API now lives in `../engine` — Stator's own
|
|
3
|
+
* isomorphic state-machine engine (replacing the XState-backed POC). This
|
|
4
|
+
* module re-exports it so existing server-side imports keep resolving, and
|
|
5
|
+
* adds the small back-compat alias the glue layer still references.
|
|
6
|
+
*/
|
|
7
|
+
export * from '../engine/index.ts'
|
|
8
|
+
|
|
9
|
+
/** The dispatch shape a subscription delivers to its target. */
|
|
10
|
+
export type SubscribeEvent = string | { type: string; [k: string]: unknown }
|