@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,52 @@
1
+ import { createRequire } from 'node:module'
2
+ import pino, { type Logger } from 'pino'
3
+
4
+ /**
5
+ * Framework logger. Pretty colored output in dev (auto-detected via
6
+ * NODE_ENV), JSON in production for log aggregators. Level controlled by
7
+ * LOG_LEVEL env (default 'info').
8
+ *
9
+ * Application code can use this module-level logger or call `child()` for
10
+ * scoped context. The framework uses scoped children for SSE events,
11
+ * fan-out, and HTTP request lines.
12
+ */
13
+ /** pino-pretty is an optional nicety, not a framework dependency — apps add
14
+ * it as a devDependency for colored dev output (the create-stator template
15
+ * does). Without it, dev logs are plain JSON rather than a crash. */
16
+ function hasPinoPretty(): boolean {
17
+ try {
18
+ createRequire(import.meta.url).resolve('pino-pretty')
19
+ return true
20
+ } catch {
21
+ return false
22
+ }
23
+ }
24
+
25
+ function buildLogger(): Logger {
26
+ const level = process.env.LOG_LEVEL ?? 'info'
27
+ const isProd = process.env.NODE_ENV === 'production'
28
+
29
+ if (isProd || !hasPinoPretty()) {
30
+ return pino({ level })
31
+ }
32
+
33
+ return pino({
34
+ level,
35
+ transport: {
36
+ target: 'pino-pretty',
37
+ options: {
38
+ colorize: true,
39
+ translateTime: 'HH:MM:ss.l',
40
+ ignore: 'pid,hostname',
41
+ singleLine: false,
42
+ },
43
+ },
44
+ })
45
+ }
46
+
47
+ export const logger: Logger = buildLogger()
48
+
49
+ /** Child logger with a `scope` tag for filtering. */
50
+ export function scopedLogger(scope: string): Logger {
51
+ return logger.child({ scope })
52
+ }
@@ -0,0 +1,355 @@
1
+ import { createActor, type EffectInvocation, type Snapshot } from '../engine/index.ts'
2
+ import { type AppStore, InMemoryAppStore } from './app-store.ts'
3
+ import type { AnyMachineDef, SubscribeEvent } from './define-machine.ts'
4
+ import { recordTouch } from './dispatch-context.ts'
5
+ import { createInstanceProxy, type InstanceHandle } from './instance-proxy.ts'
6
+ import { scopedLogger } from './logger.ts'
7
+ import { serverReadsResolver } from './reads-helpers.ts'
8
+ import type { Store } from './store.ts'
9
+
10
+ const storeLog = scopedLogger('machine-store')
11
+
12
+ /** Hard backstop for synchronous emit→subscribe cascades. Deep enough for
13
+ * any legitimate finite ping-pong; shallow enough to abort a cycle long
14
+ * before the call stack does, with a diagnosable trail. */
15
+ export const MAX_CASCADE_DEPTH = 32
16
+
17
+ /**
18
+ * Compose the event a subscriber actually receives. Order of precedence:
19
+ * 1. Payload from the source's emit selector (fields like `productId`, `items`).
20
+ * 2. Subscriber's `dispatch` declaration (`type` and any static fields it adds).
21
+ * 3. `sourceSessionId` when injected — set by the caller for cross-lifecycle
22
+ * subscriptions only.
23
+ * Subscriber-side fields override source payload fields on collision; the
24
+ * subscriber owns the shape of its own inbox.
25
+ */
26
+ export function buildDispatchEvent(
27
+ emitted: { type?: string; [k: string]: unknown } | undefined,
28
+ dispatch: SubscribeEvent,
29
+ sourceSessionId?: string,
30
+ ): { type: string; [k: string]: unknown } {
31
+ const { type: _emitType, ...payload } = emitted ?? {}
32
+ const dispatchBase = typeof dispatch === 'string' ? { type: dispatch } : dispatch
33
+ return {
34
+ ...payload,
35
+ ...dispatchBase,
36
+ ...(sourceSessionId !== undefined ? { sourceSessionId } : {}),
37
+ }
38
+ }
39
+
40
+ /**
41
+ * Machine registry + long-lived app-machine actors + persistence adapter.
42
+ *
43
+ * Session-lifecycle machine actors are *not* held here anymore — they are
44
+ * created transiently inside a SessionRuntime per request, hydrated from
45
+ * the Store, and dropped when the request completes. This keeps server
46
+ * memory bounded by (open SSE connections + active in-flight requests)
47
+ * rather than (every session ever seen).
48
+ */
49
+ export class MachineStore {
50
+ private appInstances = new Map<string, InstanceHandle>()
51
+ private defs = new Map<string, AnyMachineDef>()
52
+ /** Reverse index over `subscribes`: for each emit source, the list of
53
+ * subscribing machines + which event they care about + what to dispatch.
54
+ * Computed once at construction so SessionRuntime.wireSubscriptions is
55
+ * a single Map lookup per session-machine. */
56
+ private subscribersBySource = new Map<
57
+ string,
58
+ Array<{
59
+ targetName: string
60
+ event: string
61
+ dispatch: { type: string; [k: string]: unknown }
62
+ }>
63
+ >()
64
+
65
+ /** Default per-session TTL in seconds. Applied on every persistTouched
66
+ * so a session stays alive as long as the user keeps interacting; idle
67
+ * sessions expire as a whole. Adapters honor this on `set()`. */
68
+ readonly sessionTtlSeconds: number
69
+
70
+ /** Persistence for `persist: true` APP machines (no TTL, one blob per
71
+ * machine). Defaults to in-memory (restart-wipe) — pass RedisAppStore for
72
+ * durable app state. */
73
+ readonly appStore: AppStore
74
+
75
+ /** Host-injected scheduler for app-machine effects (see server/effects.ts
76
+ * wireAppEffects). Set after construction to avoid an import cycle;
77
+ * unwired effects are dropped loudly. */
78
+ private appEffectScheduler: ((invocation: EffectInvocation) => void) | null = null
79
+
80
+ constructor(
81
+ defs: AnyMachineDef[],
82
+ readonly persistence: Store,
83
+ opts?: { sessionTtlSeconds?: number; appStore?: AppStore },
84
+ ) {
85
+ this.sessionTtlSeconds = opts?.sessionTtlSeconds ?? 86400
86
+ this.appStore = opts?.appStore ?? new InMemoryAppStore()
87
+ for (const def of defs) {
88
+ if (this.defs.has(def.name)) {
89
+ throw new Error(`stator: duplicate machine name "${def.name}"`)
90
+ }
91
+ this.defs.set(def.name, def)
92
+ }
93
+ this.validateSubscriptions()
94
+ this.buildSubscriberIndex()
95
+ this.buildReaderIndex()
96
+ this.warnOnSubscriptionCycles()
97
+ }
98
+
99
+ /** Reverse index over `reads:`: for each machine, which machines' SELECTORS
100
+ * may derive from it. Drives touched-set expansion so display state that
101
+ * reads across machines re-diffs when its dependencies change. */
102
+ private readersBySource = new Map<string, string[]>()
103
+
104
+ private buildReaderIndex(): void {
105
+ for (const def of this.defs.values()) {
106
+ for (const r of def.reads) {
107
+ let list = this.readersBySource.get(r.name)
108
+ if (!list) {
109
+ list = []
110
+ this.readersBySource.set(r.name, list)
111
+ }
112
+ list.push(def.name)
113
+ }
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Expand a touched set through the reverse-reads graph (transitively):
119
+ * if A's selectors read B and B changed, A's bindings must re-diff even
120
+ * though A's own state didn't move. Returns the expansion (machines whose
121
+ * DERIVED views changed) separately from the direct touches — callers
122
+ * scope them differently (derived session machines re-diff for every
123
+ * connection, no rehydration; direct session touches belong to one
124
+ * session).
125
+ */
126
+ expandTouchedForRecompute(touched: ReadonlySet<string>): {
127
+ all: Set<string>
128
+ derived: Set<string>
129
+ } {
130
+ const all = new Set(touched)
131
+ const derived = new Set<string>()
132
+ const queue = [...touched]
133
+ while (queue.length > 0) {
134
+ const name = queue.shift()!
135
+ for (const reader of this.readersBySource.get(name) ?? []) {
136
+ if (!all.has(reader)) {
137
+ all.add(reader)
138
+ derived.add(reader)
139
+ queue.push(reader)
140
+ }
141
+ }
142
+ }
143
+ return { all, derived }
144
+ }
145
+
146
+ /**
147
+ * Machine-level cycles in the `subscribes:` graph, one representative path
148
+ * per cycle (`['A', 'B', 'A']`). A machine-level cycle is a *potential*
149
+ * runtime loop, not a certain one — guards or state targeting may break it
150
+ * — so construction warns rather than rejects; the runtime cascade depth
151
+ * cap (see SessionRuntime.wireSubscriptions) is the hard backstop.
152
+ */
153
+ findSubscriptionCycles(): string[][] {
154
+ const cycles: string[][] = []
155
+ const visiting = new Set<string>()
156
+ const done = new Set<string>()
157
+ const path: string[] = []
158
+
159
+ const visit = (name: string): void => {
160
+ if (done.has(name)) return
161
+ if (visiting.has(name)) {
162
+ cycles.push([...path.slice(path.indexOf(name)), name])
163
+ return
164
+ }
165
+ visiting.add(name)
166
+ path.push(name)
167
+ for (const sub of this.subscribersOf(name)) visit(sub.targetName)
168
+ path.pop()
169
+ visiting.delete(name)
170
+ done.add(name)
171
+ }
172
+
173
+ for (const name of this.defs.keys()) visit(name)
174
+ return cycles
175
+ }
176
+
177
+ private warnOnSubscriptionCycles(): void {
178
+ for (const cycle of this.findSubscriptionCycles()) {
179
+ storeLog.warn(
180
+ { cycle: cycle.join(' → ') },
181
+ 'stator: subscription cycle — emits between these machines can cascade in a loop. ' +
182
+ 'Ensure a guard breaks the chain; the runtime aborts cascades deeper than ' +
183
+ `${MAX_CASCADE_DEPTH} hops.`,
184
+ )
185
+ }
186
+ }
187
+
188
+ private validateSubscriptions(): void {
189
+ for (const def of this.defs.values()) {
190
+ for (const sub of def.subscribes) {
191
+ if (sub.from == null) {
192
+ // The classic cause: a circular import between machine modules.
193
+ // Bundler/loader interop (Vite SSR, tsx) resolves the mid-cycle
194
+ // binding to `undefined` SILENTLY — no TDZ error — so this check
195
+ // is where the mistake first becomes visible. Name it properly.
196
+ throw new Error(
197
+ `stator: machine "${def.name}" declares a subscription whose \`from\` is undefined. ` +
198
+ `This is almost always a circular import between machine modules — the module ` +
199
+ `defining "${def.name}" and the module defining its subscription source import ` +
200
+ `each other, and the loader resolved the mid-cycle binding to undefined. ` +
201
+ `Break the module cycle (e.g. move one machine or the subscription), ` +
202
+ `or define the mutually-subscribing machines in one module.`,
203
+ )
204
+ }
205
+ if (!this.defs.has(sub.from.name)) {
206
+ throw new Error(
207
+ `stator: machine "${def.name}" subscribes to unknown machine "${sub.from.name}"`,
208
+ )
209
+ }
210
+ if (sub.from.lifecycle === 'app' && def.lifecycle === 'session') {
211
+ throw new Error(
212
+ `stator: app-lifecycle source "${sub.from.name}" cannot deliver to ` +
213
+ `session-lifecycle target "${def.name}" yet — app→session needs the ` +
214
+ `inbox model (see .chisel/specs/active/app-to-session-subscriptions-via-inbox.md). ` +
215
+ `Use session→app or same-lifecycle subscriptions for now.`,
216
+ )
217
+ }
218
+ const emitNames = Object.keys(sub.from.emits)
219
+ if (emitNames.length > 0 && !(sub.event in sub.from.emits)) {
220
+ throw new Error(
221
+ `stator: machine "${def.name}" subscribes to "${sub.from.name}.${sub.event}", ` +
222
+ `but "${sub.from.name}" does not declare "${sub.event}" in its emits.`,
223
+ )
224
+ }
225
+ }
226
+ }
227
+ }
228
+
229
+ private buildSubscriberIndex(): void {
230
+ for (const def of this.defs.values()) {
231
+ for (const sub of def.subscribes) {
232
+ const dispatch = typeof sub.dispatch === 'string' ? { type: sub.dispatch } : sub.dispatch
233
+ let list = this.subscribersBySource.get(sub.from.name)
234
+ if (!list) {
235
+ list = []
236
+ this.subscribersBySource.set(sub.from.name, list)
237
+ }
238
+ list.push({ targetName: def.name, event: sub.event, dispatch })
239
+ }
240
+ }
241
+ }
242
+
243
+ async bootAppMachines(): Promise<void> {
244
+ for (const def of this.defs.values()) {
245
+ if (def.lifecycle === 'app' && !this.appInstances.has(def.name)) {
246
+ const snapshot = def.persist ? await this.loadAppSnapshot(def.name) : undefined
247
+ const actor = createActor(def, {
248
+ snapshot,
249
+ resolveHelpers: serverReadsResolver(def),
250
+ // App effects run through the host scheduler wired by
251
+ // wireAppEffects (server/effects.ts) — injected post-construction
252
+ // to avoid an import cycle. Unwired: dropped loudly rather than
253
+ // half-run without fan-out or persistence.
254
+ onEffect: (invocation) => {
255
+ if (this.appEffectScheduler) this.appEffectScheduler(invocation)
256
+ else
257
+ storeLog.warn(
258
+ { machine: invocation.machineName, effectId: invocation.effectId },
259
+ 'app-machine effect dropped — no scheduler wired (call wireAppEffects(store))',
260
+ )
261
+ },
262
+ }).start()
263
+ this.appInstances.set(
264
+ def.name,
265
+ // App machines resolve selector reads among app instances only —
266
+ // "which session?" has no answer at app scope.
267
+ createInstanceProxy(def, actor, (name) => this.appInstance(name)?.proxy),
268
+ )
269
+ }
270
+ }
271
+ this.wireAppSubscriptions()
272
+ }
273
+
274
+ /** Load + validate a persisted app snapshot. Corrupt or unloadable state
275
+ * logs loud and boots fresh — restart-fresh is the safe default. */
276
+ private async loadAppSnapshot(name: string): Promise<Snapshot<object> | undefined> {
277
+ try {
278
+ const raw = await this.appStore.loadAppMachine(name)
279
+ if (raw === null) return undefined
280
+ const snap = raw as Snapshot<object>
281
+ if (!Array.isArray(snap.value) || typeof snap.context !== 'object' || snap.context === null) {
282
+ throw new Error('snapshot shape invalid (expected { value: string[], context: object })')
283
+ }
284
+ return snap
285
+ } catch (err) {
286
+ storeLog.error(
287
+ { machine: name, err: String(err) },
288
+ 'persisted app-machine snapshot unusable — booting fresh',
289
+ )
290
+ return undefined
291
+ }
292
+ }
293
+
294
+ /** Install the app-effect scheduler (see server/effects.ts wireAppEffects). */
295
+ setAppEffectScheduler(scheduler: (invocation: EffectInvocation) => void): void {
296
+ this.appEffectScheduler = scheduler
297
+ }
298
+
299
+ /** Persist one app machine's snapshot, if it opted in. Safe no-op for
300
+ * session machines, non-persist app machines, and unknown names — callers
301
+ * pass raw touched-set entries. */
302
+ async persistAppMachine(name: string): Promise<void> {
303
+ const def = this.defs.get(name)
304
+ if (def?.lifecycle !== 'app' || !def.persist) return
305
+ const handle = this.appInstances.get(name)
306
+ if (!handle) return
307
+ await this.appStore.saveAppMachine(name, handle.actor.getPersistedSnapshot())
308
+ }
309
+
310
+ /** Wire app→app subscription listeners. Session-involved subscriptions
311
+ * (session→session, session→app) are wired per-request inside
312
+ * SessionRuntime. Same-lifecycle so no sourceSessionId injection. */
313
+ private wireAppSubscriptions(): void {
314
+ for (const targetHandle of this.appInstances.values()) {
315
+ const targetName = targetHandle.def.name
316
+ for (const sub of targetHandle.def.subscribes) {
317
+ const sourceHandle = this.appInstances.get(sub.from.name)
318
+ if (!sourceHandle) continue
319
+ sourceHandle.actor.on(sub.event as never, (emitted) => {
320
+ targetHandle.actor.send(buildDispatchEvent(emitted, sub.dispatch) as never)
321
+ recordTouch(targetName)
322
+ })
323
+ }
324
+ }
325
+ }
326
+
327
+ appInstance(name: string): InstanceHandle | undefined {
328
+ return this.appInstances.get(name)
329
+ }
330
+
331
+ getDef(name: string): AnyMachineDef | undefined {
332
+ return this.defs.get(name)
333
+ }
334
+
335
+ allDefs(): AnyMachineDef[] {
336
+ return [...this.defs.values()]
337
+ }
338
+
339
+ /** Subscribers (target machine + dispatch info) for a given source machine. */
340
+ subscribersOf(sourceName: string): ReadonlyArray<{
341
+ targetName: string
342
+ event: string
343
+ dispatch: { type: string; [k: string]: unknown }
344
+ }> {
345
+ return this.subscribersBySource.get(sourceName) ?? []
346
+ }
347
+
348
+ hasMachine(name: string): boolean {
349
+ return this.defs.has(name)
350
+ }
351
+
352
+ async disposeSession(sessionId: string): Promise<void> {
353
+ await this.persistence.deleteSession(sessionId)
354
+ }
355
+ }
@@ -0,0 +1,40 @@
1
+ import type { ActionHelpers, AnyMachineDef } from '../engine/index.ts'
2
+ import { getDispatchContext } from './dispatch-context.ts'
3
+
4
+ /**
5
+ * Server-side resolver for an action/guard's `reads` helper. The engine is
6
+ * host-agnostic (it imports no server module); the *server* supplies how reads
7
+ * are resolved — through the active dispatch context's runtime, exactly as the
8
+ * POC did. A reads-free machine never invokes this; a machine that dereferences
9
+ * reads outside an active dispatch gets a clear error (matching prior behavior).
10
+ */
11
+ export function serverReadsResolver(def: AnyMachineDef): () => ActionHelpers {
12
+ return () => {
13
+ const dc = getDispatchContext()
14
+ if (!dc) {
15
+ return {
16
+ reads: new Proxy({} as Record<string, unknown>, {
17
+ get(_t, prop) {
18
+ throw new Error(
19
+ `stator: "${def.name}" accessed reads.${String(prop)} outside an active dispatch — ` +
20
+ `actions/guards that use reads must run through store.processEvent(...) ` +
21
+ `(or actor.send() inside a subscription handler), not a bare send.`,
22
+ )
23
+ },
24
+ }),
25
+ }
26
+ }
27
+ const reads: Record<string, unknown> = {}
28
+ for (const r of def.reads) {
29
+ const proxy = dc.runtime.proxyFor(r.name)
30
+ if (!proxy) {
31
+ throw new Error(
32
+ `stator: "${def.name}" declares reads on "${r.name}" but it's not loaded ` +
33
+ `in the active runtime — loadGraph(...) should pull it in transitively.`,
34
+ )
35
+ }
36
+ reads[r.name] = proxy
37
+ }
38
+ return { reads }
39
+ }
40
+ }