@statorjs/stator 2.5.1 → 2.6.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.
@@ -0,0 +1,195 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { realpathSync } from 'node:fs'
3
+ import { createRequire } from 'node:module'
4
+ import { isAbsolute, relative, resolve } from 'node:path'
5
+ import { build, version as esbuildVersion } from 'esbuild'
6
+ import type { AnyMachineDef } from '../engine/index.ts'
7
+
8
+ /**
9
+ * The per-machine code hash — the fingerprint behind "sessions never outlive
10
+ * the code that made them" (spec `machine-state-is-working-state-snapshot-
11
+ * hydration-policy`).
12
+ *
13
+ * Each machine file is bundled by esbuild — tree-shaken, minified, in memory —
14
+ * and the bundle is hashed. The hash therefore changes exactly when code that
15
+ * can execute as part of the machine changes: a guard, action, effect,
16
+ * selector, state, event key or context default, in the machine file or in any
17
+ * module it reaches. Comments, whitespace and exports the machine never uses
18
+ * don't move it. The bundle is a MEASUREMENT, never written or run: what runs
19
+ * stays plain modules with shared instances.
20
+ *
21
+ * External (never bundled, never hashed by content):
22
+ * - bare specifiers — the framework and node_modules; their versions are hash
23
+ * inputs instead, so an engine or transform-engine upgrade counts once;
24
+ * - `.stator` files (a machine has no business importing a template).
25
+ *
26
+ * Sibling machines are NOT external: a machine that imports another often
27
+ * takes values from it (`weather.ts` mirrors `DEFAULT_CLOCK` from
28
+ * `settings.ts` into its own context), and a default changed in the sibling
29
+ * must reset the importer too. The cost is over-reset — a machine imported only
30
+ * for identity (`subscribes: [{ from: Sibling }]`) still resets its importers
31
+ * when it changes — which the principle permits; a missed reset it does not.
32
+ *
33
+ * One esbuild invocation hashes every machine, so build-time cost does not
34
+ * scale with the number of calls; `stator build` fails on a machine whose
35
+ * closure cannot be bundled (an import problem that belongs in CI, not at a
36
+ * production boot).
37
+ */
38
+
39
+ export interface MachineHash {
40
+ hash: string
41
+ /** Absolute paths of every source module bundled into this machine. */
42
+ inputs: string[]
43
+ }
44
+
45
+ export interface HashMachinesOptions {
46
+ /** The conventional machines directory. Kept for the stable ids of
47
+ * externalized `.stator` imports; siblings are bundled, not external. */
48
+ machinesDir: string
49
+ }
50
+
51
+ const statorVersion: string = (
52
+ createRequire(import.meta.url)('../../package.json') as { version: string }
53
+ ).version
54
+
55
+ const OUTDIR = '/__stator_machine_hash__'
56
+
57
+ /** Hash every machine file in one esbuild pass. Throws if any closure cannot
58
+ * be bundled — the error names the machine. */
59
+ export async function hashMachines(
60
+ files: readonly string[],
61
+ opts: HashMachinesOptions,
62
+ ): Promise<Map<string, MachineHash>> {
63
+ const out = new Map<string, MachineHash>()
64
+ if (files.length === 0) return out
65
+ // esbuild reports real paths (macOS: /var → /private/var); key everything by
66
+ // realpath and hand results back under the caller's own spelling.
67
+ const real = (p: string): string => {
68
+ try {
69
+ return realpathSync(p)
70
+ } catch {
71
+ return resolve(p)
72
+ }
73
+ }
74
+ const machinesDir = real(opts.machinesDir)
75
+ const wanted = new Map(files.map((f) => [real(f), f]))
76
+ const cwd = process.cwd()
77
+
78
+ const result = await build({
79
+ entryPoints: [...wanted.keys()],
80
+ absWorkingDir: cwd,
81
+ bundle: true,
82
+ // Strip whitespace and fold syntax, but do NOT rename identifiers: esbuild
83
+ // allocates minified names across the whole module set, so a dead export
84
+ // in an imported module can shift the names in the bundle — and the hash
85
+ // would move for code that never runs. Keeping source identifiers makes the
86
+ // output a function of the reachable code only.
87
+ minifyWhitespace: true,
88
+ minifySyntax: true,
89
+ minifyIdentifiers: false,
90
+ write: false,
91
+ metafile: true,
92
+ format: 'esm',
93
+ platform: 'node',
94
+ target: 'node20',
95
+ outdir: OUTDIR,
96
+ entryNames: '[dir]/[name]',
97
+ legalComments: 'none',
98
+ sourcemap: false,
99
+ logLevel: 'silent',
100
+ plugins: [
101
+ {
102
+ name: 'stator-machine-hash-externals',
103
+ setup(b) {
104
+ b.onResolve({ filter: /.*/ }, (args) => {
105
+ if (args.kind === 'entry-point') return null
106
+ // Bare specifier: a package — external, its version is a hash input.
107
+ if (!args.path.startsWith('.') && !isAbsolute(args.path)) {
108
+ return { path: args.path, external: true }
109
+ }
110
+ const abs = real(resolve(args.resolveDir, args.path))
111
+ // Externals keep their specifier in the bundle, so give a template a
112
+ // STABLE id (relative to machinesDir), never an absolute path — or the
113
+ // hash would change with the checkout directory.
114
+ if (abs.endsWith('.stator')) {
115
+ return {
116
+ path: `stator:${relative(machinesDir, abs).replace(/\\/g, '/')}`,
117
+ external: true,
118
+ }
119
+ }
120
+ return null
121
+ })
122
+ },
123
+ },
124
+ ],
125
+ }).catch((err: unknown) => {
126
+ const msg = err instanceof Error ? err.message : String(err)
127
+ throw new Error(`stator: cannot hash machine closure — ${msg}`)
128
+ })
129
+
130
+ // Map each output bundle back to its entry via the metafile.
131
+ for (const file of result.outputFiles) {
132
+ const key = relative(cwd, file.path).replace(/\\/g, '/')
133
+ const meta = result.metafile.outputs[key]
134
+ if (!meta?.entryPoint) continue
135
+ const entry = wanted.get(real(resolve(cwd, meta.entryPoint)))
136
+ if (entry === undefined) continue
137
+ const hash = createHash('sha256')
138
+ .update(file.contents)
139
+ .update('\0')
140
+ .update(statorVersion)
141
+ .update('\0')
142
+ .update(esbuildVersion)
143
+ .digest('hex')
144
+ // The closure comes from the resolved import GRAPH (`metafile.inputs`),
145
+ // not from `outputs[*].inputs` — that lists only modules that contributed
146
+ // bytes, and a module whose only used export is a constant the minifier
147
+ // inlined contributes none while still being code the machine depends on.
148
+ out.set(entry, { hash, inputs: closureOf(meta.entryPoint, result.metafile.inputs, cwd) })
149
+ }
150
+ for (const f of files) {
151
+ if (!out.has(f)) throw new Error(`stator: no bundle produced for machine ${f}`)
152
+ }
153
+ return out
154
+ }
155
+
156
+ function closureOf(
157
+ entryKey: string,
158
+ inputs: Record<string, { imports: Array<{ path: string; external?: boolean }> }>,
159
+ cwd: string,
160
+ ): string[] {
161
+ const seen = new Set<string>()
162
+ const stack = [entryKey]
163
+ while (stack.length) {
164
+ const key = stack.pop()!
165
+ if (seen.has(key)) continue
166
+ seen.add(key)
167
+ for (const imp of inputs[key]?.imports ?? []) if (!imp.external) stack.push(imp.path)
168
+ }
169
+ return [...seen].map((k) => resolve(cwd, k))
170
+ }
171
+
172
+ // ── Def registry ─────────────────────────────────────────────────────────────
173
+ // Discovery attaches each def's hash here; hydration reads it. A WeakMap keyed
174
+ // by the def object keeps the def type untouched and needs no plumbing —
175
+ // whoever has the def has the hash.
176
+ const hashes = new WeakMap<AnyMachineDef, { hash: string; inputs: readonly string[] }>()
177
+
178
+ export function setCodeHash(
179
+ def: AnyMachineDef,
180
+ hash: string,
181
+ inputs: readonly string[] = [],
182
+ ): void {
183
+ hashes.set(def, { hash, inputs })
184
+ }
185
+
186
+ export function codeHashOf(def: AnyMachineDef): string | undefined {
187
+ return hashes.get(def)?.hash
188
+ }
189
+
190
+ /** Absolute paths of the modules in the def's hashed closure (empty when the
191
+ * hash came from a manifest). The dev servers use it to decide whether an
192
+ * edit touches a machine at all. */
193
+ export function codeInputsOf(def: AnyMachineDef): readonly string[] {
194
+ return hashes.get(def)?.inputs ?? []
195
+ }
@@ -7,6 +7,7 @@ import { createInstanceProxy, type InstanceHandle } from './instance-proxy.ts'
7
7
  import { scopedLogger } from './logger.ts'
8
8
  import { serverReadsResolver } from './reads-helpers.ts'
9
9
  import { RESERVED_KEY_PREFIX } from './session.ts'
10
+ import { reconcileSnapshot, stampSnapshot } from './snapshot-policy.ts'
10
11
  import type { Store } from './store.ts'
11
12
  import { APP_SCOPE, armAfterTimers, cancelAfterTimers } from './timers.ts'
12
13
 
@@ -316,7 +317,10 @@ export class MachineStore {
316
317
  if (!Array.isArray(snap.value) || typeof snap.context !== 'object' || snap.context === null) {
317
318
  throw new Error('snapshot shape invalid (expected { value: string[], context: object })')
318
319
  }
319
- return snap
320
+ // Hydration policy: written by different code / newer format / vanished
321
+ // state ⇒ boot fresh (logged by the policy).
322
+ const def = this.defs.get(name)
323
+ return def ? reconcileSnapshot(def, snap, APP_SCOPE) : snap
320
324
  } catch (err) {
321
325
  storeLog.error(
322
326
  { machine: name, err: String(err) },
@@ -339,7 +343,10 @@ export class MachineStore {
339
343
  if (def?.lifecycle !== 'app' || !def.persist) return
340
344
  const handle = this.appInstances.get(name)
341
345
  if (!handle) return
342
- await this.appStore.saveAppMachine(name, handle.actor.getPersistedSnapshot())
346
+ await this.appStore.saveAppMachine(
347
+ name,
348
+ stampSnapshot(def, handle.actor.getPersistedSnapshot()),
349
+ )
343
350
  }
344
351
 
345
352
  /** Wire app→app subscription listeners. Session-involved subscriptions
@@ -1,10 +1,11 @@
1
- import { createActor, type EffectInvocation, type Snapshot } from '../engine/index.ts'
1
+ import { createActor, type EffectInvocation } from '../engine/index.ts'
2
2
  import type { AnyMachineDef } from './define-machine.ts'
3
3
  import { type DispatchContext, recordTouch, withDispatchContext } from './dispatch-context.ts'
4
4
  import { abortEntryEffects, isEffectInFlight } from './effects.ts'
5
5
  import { createInstanceProxy, type InstanceHandle } from './instance-proxy.ts'
6
6
  import { buildDispatchEvent, MAX_CASCADE_DEPTH, type MachineStore } from './machine-store.ts'
7
7
  import { serverReadsResolver } from './reads-helpers.ts'
8
+ import { reconcileSnapshot, stampSnapshot } from './snapshot-policy.ts'
8
9
  import { armAfterTimers, cancelAfterTimers } from './timers.ts'
9
10
 
10
11
  /**
@@ -64,7 +65,9 @@ export class SessionRuntime {
64
65
  private async loadOne(def: AnyMachineDef): Promise<void> {
65
66
  const persisted = await this.store.persistence.get(this.sessionId, def.name)
66
67
  const actor = createActor(def, {
67
- snapshot: persisted !== null ? (persisted as Snapshot<object>) : undefined,
68
+ // Hydration policy: a snapshot written by different code (or in a newer
69
+ // format, or naming a vanished state) is discarded — fresh start, logged.
70
+ snapshot: reconcileSnapshot(def, persisted, this.sessionId),
68
71
  resolveHelpers: serverReadsResolver(def),
69
72
  // Server plane: never run effects inline — queue them for the entry
70
73
  // point to schedule after persist + lock release.
@@ -114,7 +117,7 @@ export class SessionRuntime {
114
117
  if (def?.lifecycle !== 'session' || !this.actors.has(name)) return
115
118
  const persisted = await this.store.persistence.get(this.sessionId, def.name)
116
119
  const actor = createActor(def, {
117
- snapshot: persisted !== null ? (persisted as Snapshot<object>) : undefined,
120
+ snapshot: reconcileSnapshot(def, persisted, this.sessionId),
118
121
  resolveHelpers: serverReadsResolver(def),
119
122
  onEffect: (invocation) => this.pendingEffects.push(invocation),
120
123
  }).start()
@@ -263,7 +266,9 @@ export class SessionRuntime {
263
266
  await this.store.persistAppMachine(name)
264
267
  continue
265
268
  }
266
- const snapshot = handle.actor.getPersistedSnapshot()
269
+ // Stamped with the format and the writing machine's code hash, so a
270
+ // later hydration can tell whether the code that wrote it still runs.
271
+ const snapshot = stampSnapshot(this.store.getDef(name), handle.actor.getPersistedSnapshot())
267
272
  // A ttlSeconds-bearing set refreshes the session's whole expiry — the
268
273
  // user is active, so all of their machines stay alive together. TTL-less
269
274
  // sets (machine-driven work) leave the expiry untouched.
@@ -0,0 +1,110 @@
1
+ import type { AnyMachineDef, Snapshot } from '../engine/index.ts'
2
+ import { scopedLogger } from './logger.ts'
3
+ import { codeHashOf } from './machine-hash.ts'
4
+
5
+ /**
6
+ * Snapshot hydration policy — "sessions never outlive the code that made them"
7
+ * (spec `machine-state-is-working-state-snapshot-hydration-policy`).
8
+ *
9
+ * The host stamps every persisted snapshot with the snapshot `format` and the
10
+ * writing machine's code hash (`stampSnapshot`), and reconciles a persisted
11
+ * snapshot against the running def before building an actor
12
+ * (`reconcileSnapshot`): a snapshot written by different code, in a newer
13
+ * format, or naming a state the chart no longer has, is discarded and the
14
+ * machine starts fresh — identically in dev and prod, for any Store.
15
+ *
16
+ * Resets are logged at `warn`, rate-limited per machine per process (first
17
+ * occurrence, then every power of ten) so a deploy across a large store stays
18
+ * visible without flooding.
19
+ */
20
+
21
+ /** The current persisted-snapshot format. Bump when the shape changes. */
22
+ export const SNAPSHOT_FORMAT = 1
23
+
24
+ const log = scopedLogger('hydrate')
25
+
26
+ export type ResetReason = 'code-changed' | 'format-newer' | 'state-missing' | 'shape-invalid'
27
+
28
+ /** Stamp a snapshot for persistence with the format and the def's code hash.
29
+ * A def with no registered hash (a store assembled from defs directly, not
30
+ * via discovery) stamps no hash — and is never reset for it. */
31
+ export function stampSnapshot<C>(
32
+ def: AnyMachineDef | undefined,
33
+ snapshot: Snapshot<C>,
34
+ ): Snapshot<C> {
35
+ const code = def ? codeHashOf(def) : undefined
36
+ return { ...snapshot, format: SNAPSHOT_FORMAT, ...(code ? { code } : {}) }
37
+ }
38
+
39
+ /** Why a persisted snapshot cannot be hydrated under `def`, or null if it can. */
40
+ export function snapshotResetReason(def: AnyMachineDef, persisted: unknown): ResetReason | null {
41
+ const snap = persisted as Partial<Snapshot<unknown>> | null
42
+ if (
43
+ !snap ||
44
+ typeof snap !== 'object' ||
45
+ !Array.isArray(snap.value) ||
46
+ typeof snap.context !== 'object' ||
47
+ snap.context === null
48
+ ) {
49
+ return 'shape-invalid'
50
+ }
51
+ if ((snap.format ?? SNAPSHOT_FORMAT) > SNAPSHOT_FORMAT) return 'format-newer'
52
+ const expected = codeHashOf(def)
53
+ if (expected !== undefined && snap.code !== expected) return 'code-changed'
54
+ const state = snap.value[0]
55
+ if (state === undefined || !(state in def.states)) return 'state-missing'
56
+ return null
57
+ }
58
+
59
+ /**
60
+ * The persisted snapshot to hydrate from, or `undefined` to start fresh.
61
+ * `null` persisted (nothing stored) is a fresh start with no log; a stored
62
+ * snapshot that cannot be used is a reset, logged.
63
+ */
64
+ export function reconcileSnapshot(
65
+ def: AnyMachineDef,
66
+ persisted: unknown,
67
+ scope: string,
68
+ ): Snapshot<object> | undefined {
69
+ if (persisted === null || persisted === undefined) return undefined
70
+ const reason = snapshotResetReason(def, persisted)
71
+ if (reason === null) return persisted as Snapshot<object>
72
+ logReset(def, persisted as Partial<Snapshot<unknown>>, reason, scope)
73
+ return undefined
74
+ }
75
+
76
+ const resetCounts = new Map<string, number>()
77
+
78
+ function logReset(
79
+ def: AnyMachineDef,
80
+ snap: Partial<Snapshot<unknown>>,
81
+ reason: ResetReason,
82
+ scope: string,
83
+ ): void {
84
+ const n = (resetCounts.get(def.name) ?? 0) + 1
85
+ resetCounts.set(def.name, n)
86
+ // 1, 10, 100, 1000, … — the first reset is the news; the count is the scale.
87
+ if (n !== 1 && !Number.isInteger(Math.log10(n))) return
88
+ log.warn(
89
+ {
90
+ machine: def.name,
91
+ scope,
92
+ reason,
93
+ resets: n,
94
+ ...(reason === 'code-changed' ? { from: snap.code ?? null, to: codeHashOf(def) } : {}),
95
+ ...(reason === 'state-missing' ? { state: snap.value?.[0] ?? null } : {}),
96
+ },
97
+ reason === 'code-changed'
98
+ ? 'machine code changed since this snapshot was written — starting fresh'
99
+ : reason === 'format-newer'
100
+ ? 'snapshot written in a newer format than this server understands — starting fresh'
101
+ : reason === 'state-missing'
102
+ ? 'snapshot names a state the machine no longer has — starting fresh'
103
+ : 'persisted snapshot has an invalid shape — starting fresh',
104
+ )
105
+ }
106
+
107
+ /** Test hook: forget rate-limit counts. */
108
+ export function resetSnapshotPolicyCounters(): void {
109
+ resetCounts.clear()
110
+ }
package/src/vite/index.ts CHANGED
@@ -1,6 +1,12 @@
1
1
  /**
2
- * Vite integration for `.stator` single-file components.
3
- * `import { stator } from '@statorjs/stator/vite'` and add it to `plugins`.
2
+ * INTERNAL — the framework's own Vite glue: the `.stator` plugin the island
3
+ * bundler (and the transitional Vite dev server) uses and the machine-import
4
+ * stub. Not a plugin
5
+ * surface: Stator never reads a user `vite.config.*` and accepts no bundler
6
+ * plugins (a plugin would reach client islands and nothing else, silently
7
+ * splitting the app into a tier where it applies and a tier where it doesn't).
8
+ * What to do instead — global CSS, Tailwind, images, WASM — is documented under
9
+ * "Styling and assets" in the docs.
4
10
  */
5
- export { stator } from './plugin.ts'
6
- export { machineStub } from './stub.ts'
11
+ export { CLIENT_QUERY, stator } from './plugin.ts'
12
+ export { MACHINE_STUB_PREFIX, machineStub } from './stub.ts'
@@ -1,35 +1,7 @@
1
- import { readFileSync } from 'node:fs'
2
1
  import { readFile } from 'node:fs/promises'
3
- import { dirname, resolve } from 'node:path'
4
2
  import { transform } from 'esbuild'
5
3
  import type { Plugin } from 'vite'
6
- import {
7
- CompileError,
8
- type CompileResult,
9
- compile,
10
- componentImportSpecifier,
11
- declaredRegions,
12
- splitStator,
13
- } from '../compiler/index.ts'
14
-
15
- /** Build a region resolver for a file: maps a component identifier used in
16
- * `file` to the named regions its imported `.stator` declares. Reads sibling
17
- * files synchronously (resolution happens mid-compile). Returns null when the
18
- * identifier isn't a `.stator` default import or the file can't be read. */
19
- function regionResolverFor(file: string, source: string) {
20
- const { frontmatter } = splitStator(source)
21
- return (componentName: string): Set<string> | null => {
22
- const spec = componentImportSpecifier(frontmatter, componentName)
23
- if (!spec) return null
24
- const target = spec.startsWith('.') ? resolve(dirname(file), spec) : null
25
- if (!target) return null
26
- try {
27
- return declaredRegions(readFileSync(target, 'utf8'))
28
- } catch {
29
- return null
30
- }
31
- }
32
- }
4
+ import { CompileError, type CompileResult, compile, regionResolverFor } from '../compiler/index.ts'
33
5
 
34
6
  /** Map a `CompileError` to a Vite/Rollup-friendly error so the dev overlay and
35
7
  * terminal show file:line:column with a code frame. */
@@ -57,8 +29,16 @@ function toViteError(err: unknown): unknown {
57
29
  * The plugin does no CSS or JSX parsing itself — that's all in the compiler.
58
30
  */
59
31
 
32
+ /** A `.stator` module id: the extension proper, optionally followed by a query.
33
+ * Precise on purpose — `x.stator.client.ts` / `x.stator.ts` (compiled output
34
+ * in a dist tree) must NOT match, or the bundler would recompile them. */
35
+ const STATOR_ID = /\.stator(?:\?|$)/
36
+
60
37
  const STYLE_QUERY = 'stator&type=style&lang.css'
61
- const CLIENT_QUERY = 'stator&type=client'
38
+ /** Query that selects a `.stator` file's generated client entry module (the
39
+ * island's `<script>`); `bundleIslands` feeds island sources to Vite as
40
+ * `<file>?${CLIENT_QUERY}` so one plugin serves dev and build alike. */
41
+ export const CLIENT_QUERY = 'stator&type=client'
62
42
 
63
43
  export function stator(): Plugin {
64
44
  const cache = new Map<string, CompileResult>()
@@ -87,7 +67,7 @@ export function stator(): Plugin {
87
67
  name: 'vite-plugin-stator',
88
68
 
89
69
  resolveId(id, importer) {
90
- if (!id.includes('.stator')) return null
70
+ if (!STATOR_ID.test(id)) return null
91
71
  const qIdx = id.indexOf('?')
92
72
  const path = qIdx === -1 ? id : id.slice(0, qIdx)
93
73
  const query = qIdx === -1 ? '' : id.slice(qIdx)
@@ -99,7 +79,7 @@ export function stator(): Plugin {
99
79
  },
100
80
 
101
81
  async load(id) {
102
- if (!id.includes('.stator')) return null
82
+ if (!STATOR_ID.test(id)) return null
103
83
  const qIdx = id.indexOf('?')
104
84
  const file = qIdx === -1 ? id : id.slice(0, qIdx)
105
85
  const query = qIdx === -1 ? '' : id.slice(qIdx + 1)
package/src/vite/stub.ts CHANGED
@@ -20,7 +20,10 @@ import type { Plugin } from 'vite'
20
20
  * discovery already imports them all).
21
21
  */
22
22
 
23
- const STUB_PREFIX = '\0stator-machine-stub:'
23
+ /** Virtual-id prefix of a stubbed server-machine import (exported so the
24
+ * island bundler can map a stub back to the machine file it stands for). */
25
+ export const MACHINE_STUB_PREFIX = '\0stator-machine-stub:'
26
+ const STUB_PREFIX = MACHINE_STUB_PREFIX
24
27
 
25
28
  export function machineStub(opts: { machinesDir: string }): Plugin {
26
29
  const machinesDir = resolve(opts.machinesDir)