@statorjs/stator 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +104 -0
  3. package/package.json +99 -0
  4. package/src/build/build.ts +244 -0
  5. package/src/build/head.ts +43 -0
  6. package/src/build/index.ts +10 -0
  7. package/src/build/sync.ts +65 -0
  8. package/src/client/bind.ts +47 -0
  9. package/src/client/client-id.ts +10 -0
  10. package/src/client/dispatch.ts +62 -0
  11. package/src/client/element.ts +156 -0
  12. package/src/client/index.ts +13 -0
  13. package/src/client/inspector.ts +237 -0
  14. package/src/client/machine.ts +39 -0
  15. package/src/client/runtime.ts +246 -0
  16. package/src/client/use.ts +119 -0
  17. package/src/compiler/client-emit.ts +180 -0
  18. package/src/compiler/client-script.ts +256 -0
  19. package/src/compiler/compile.ts +459 -0
  20. package/src/compiler/diagnostics.ts +65 -0
  21. package/src/compiler/dts.ts +87 -0
  22. package/src/compiler/hash.ts +8 -0
  23. package/src/compiler/index.ts +48 -0
  24. package/src/compiler/lower.ts +0 -0
  25. package/src/compiler/regions.ts +79 -0
  26. package/src/compiler/split.ts +168 -0
  27. package/src/compiler/styles.ts +200 -0
  28. package/src/compiler/virtual-code.ts +184 -0
  29. package/src/components/index.ts +2 -0
  30. package/src/components/json-ld.ts +85 -0
  31. package/src/engine/actor.ts +248 -0
  32. package/src/engine/define-machine.ts +113 -0
  33. package/src/engine/index.ts +37 -0
  34. package/src/engine/types.ts +236 -0
  35. package/src/server/api-route.ts +149 -0
  36. package/src/server/app-dispatch.ts +52 -0
  37. package/src/server/app-store.ts +35 -0
  38. package/src/server/cached-store.ts +117 -0
  39. package/src/server/create-app.ts +83 -0
  40. package/src/server/define-machine.ts +10 -0
  41. package/src/server/dev.ts +255 -0
  42. package/src/server/discovery.ts +111 -0
  43. package/src/server/dispatch-context.ts +39 -0
  44. package/src/server/effects.ts +120 -0
  45. package/src/server/http.ts +475 -0
  46. package/src/server/index.ts +101 -0
  47. package/src/server/instance-proxy.ts +95 -0
  48. package/src/server/logger.ts +52 -0
  49. package/src/server/machine-store.ts +355 -0
  50. package/src/server/reads-helpers.ts +40 -0
  51. package/src/server/recompute.ts +287 -0
  52. package/src/server/redis-store.ts +116 -0
  53. package/src/server/render-context.ts +228 -0
  54. package/src/server/render.ts +111 -0
  55. package/src/server/route-discovery.ts +226 -0
  56. package/src/server/route-request.ts +36 -0
  57. package/src/server/routing.ts +175 -0
  58. package/src/server/session-lock.ts +33 -0
  59. package/src/server/session-runtime.ts +242 -0
  60. package/src/server/session.ts +29 -0
  61. package/src/server/sse.ts +175 -0
  62. package/src/server/store.ts +95 -0
  63. package/src/stator-modules.d.ts +12 -0
  64. package/src/template/client-shell.ts +65 -0
  65. package/src/template/conditional.ts +166 -0
  66. package/src/template/directives/core.ts +58 -0
  67. package/src/template/directives/list-attr.ts +183 -0
  68. package/src/template/directives/on.ts +22 -0
  69. package/src/template/each.ts +295 -0
  70. package/src/template/html.ts +180 -0
  71. package/src/template/index.ts +29 -0
  72. package/src/template/parser.ts +341 -0
  73. package/src/template/read.ts +50 -0
  74. package/src/template/types.ts +33 -0
  75. package/src/vite/index.ts +6 -0
  76. package/src/vite/plugin.ts +151 -0
  77. package/src/vite/stub.ts +79 -0
  78. package/src/wire/apply.ts +103 -0
  79. package/src/wire/index.ts +67 -0
@@ -0,0 +1,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 }