@statorjs/stator 2.5.1 → 2.7.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/README.md +1 -1
- package/package.json +5 -5
- package/src/build/build.ts +93 -115
- package/src/build/head.ts +11 -4
- package/src/build/index.ts +9 -0
- package/src/build/islands.ts +228 -0
- package/src/build/source-id.ts +15 -0
- package/src/cli/commands/build.ts +11 -1
- package/src/cli/commands/dev.ts +7 -3
- package/src/cli/commands/start.ts +2 -1
- package/src/cli/run.ts +1 -1
- package/src/cli/stator.js +9 -0
- package/src/client/inspector.css +198 -0
- package/src/client/inspector.ts +330 -7
- package/src/compiler/diagnostics.ts +10 -0
- package/src/compiler/index.ts +2 -1
- package/src/compiler/regions.ts +30 -0
- package/src/engine/actor.ts +10 -3
- package/src/engine/describe.ts +129 -0
- package/src/engine/index.ts +7 -0
- package/src/engine/types.ts +24 -1
- package/src/server/create-app.ts +7 -1
- package/src/server/dev-loader.mjs +124 -0
- package/src/server/dev-native.ts +562 -0
- package/src/server/dev-vite.ts +362 -0
- package/src/server/dev.ts +44 -318
- package/src/server/discovery.ts +45 -1
- package/src/server/effects.ts +10 -1
- package/src/server/http.ts +29 -1
- package/src/server/index.ts +3 -0
- package/src/server/inspect.ts +101 -0
- package/src/server/machine-hash.ts +195 -0
- package/src/server/machine-store.ts +9 -2
- package/src/server/session-runtime.ts +9 -4
- package/src/server/snapshot-policy.ts +110 -0
- package/src/vite/index.ts +10 -4
- package/src/vite/plugin.ts +12 -32
- package/src/vite/stub.ts +4 -1
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dev state inspection — the data feed for the inspector's Machines view.
|
|
3
|
+
*
|
|
4
|
+
* Serves the caller's OWN session: the payload is scoped to the session the
|
|
5
|
+
* request's cookie addresses (app-lifecycle machines are process-global and
|
|
6
|
+
* labeled as such by their lifecycle). Registered only by the dev servers —
|
|
7
|
+
* machine context is working state and may hold anything, so this endpoint
|
|
8
|
+
* never exists in production.
|
|
9
|
+
*
|
|
10
|
+
* Read-only by construction: no actors are instantiated, no session lock is
|
|
11
|
+
* taken, nothing is dispatched. Session snapshots come straight from the
|
|
12
|
+
* persistence store — a machine never touched by this session reads as null
|
|
13
|
+
* (it would start from the def's initial context on first dispatch).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { describeMachine, type MachineDescription } from '../engine/describe.ts'
|
|
17
|
+
import type { Snapshot } from '../engine/types.ts'
|
|
18
|
+
import { codeHashOf } from './machine-hash.ts'
|
|
19
|
+
import type { MachineStore } from './machine-store.ts'
|
|
20
|
+
import { type DiscoveredRoute, HTTP_METHODS } from './route-discovery.ts'
|
|
21
|
+
import { isStatorQueryRoute, isStatorRoute } from './routing.ts'
|
|
22
|
+
|
|
23
|
+
export interface InspectRouteMethod {
|
|
24
|
+
/** `page` = defineRoute, `data` = defineApiRoute({method:'GET'}), `api` = the rest. */
|
|
25
|
+
kind: 'page' | 'data' | 'api'
|
|
26
|
+
reads: string[]
|
|
27
|
+
/** Page routes only: whether the route opened an SSE live channel. */
|
|
28
|
+
live?: boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface InspectRoute {
|
|
32
|
+
urlPath: string
|
|
33
|
+
methods: Record<string, InspectRouteMethod>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface InspectPayload {
|
|
37
|
+
buildId?: string
|
|
38
|
+
/** All registered machines, described + their running code hash. */
|
|
39
|
+
machines: Array<MachineDescription & { hash?: string }>
|
|
40
|
+
/** The caller's session's persisted snapshots (null = never touched). */
|
|
41
|
+
session: Record<string, Snapshot<unknown> | null>
|
|
42
|
+
/** App-lifecycle live snapshots — process-global, same for every caller. */
|
|
43
|
+
app: Record<string, Snapshot<unknown>>
|
|
44
|
+
routes: InspectRoute[]
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function buildInspectPayload(opts: {
|
|
48
|
+
store: MachineStore
|
|
49
|
+
routes: DiscoveredRoute[]
|
|
50
|
+
sessionId?: string
|
|
51
|
+
buildId?: string
|
|
52
|
+
}): Promise<InspectPayload> {
|
|
53
|
+
const defs = [...opts.store.allDefs()].sort((a, b) => a.name.localeCompare(b.name))
|
|
54
|
+
|
|
55
|
+
const machines = defs.map((def) => {
|
|
56
|
+
const hash = codeHashOf(def)
|
|
57
|
+
return { ...describeMachine(def), ...(hash !== undefined ? { hash } : {}) }
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const session: Record<string, Snapshot<unknown> | null> = {}
|
|
61
|
+
if (opts.sessionId !== undefined) {
|
|
62
|
+
for (const def of defs) {
|
|
63
|
+
if (def.lifecycle !== 'session') continue
|
|
64
|
+
session[def.name] =
|
|
65
|
+
((await opts.store.persistence.get(
|
|
66
|
+
opts.sessionId,
|
|
67
|
+
def.name,
|
|
68
|
+
)) as Snapshot<unknown> | null) ?? null
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const app: Record<string, Snapshot<unknown>> = {}
|
|
73
|
+
for (const def of defs) {
|
|
74
|
+
if (def.lifecycle !== 'app') continue
|
|
75
|
+
const handle = opts.store.appInstance(def.name)
|
|
76
|
+
if (handle) app[def.name] = handle.actor.getSnapshot()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const routes = opts.routes.map((route): InspectRoute => {
|
|
80
|
+
const methods: Record<string, InspectRouteMethod> = {}
|
|
81
|
+
for (const method of HTTP_METHODS) {
|
|
82
|
+
const def = route[method]
|
|
83
|
+
if (!def) continue
|
|
84
|
+
const reads = def.reads.map((d) => d.name)
|
|
85
|
+
if (isStatorRoute(def)) {
|
|
86
|
+
methods[method] = { kind: 'page', reads, live: def.live }
|
|
87
|
+
} else {
|
|
88
|
+
methods[method] = { kind: isStatorQueryRoute(def) ? 'data' : 'api', reads }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { urlPath: route.urlPath, methods }
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
...(opts.buildId !== undefined ? { buildId: opts.buildId } : {}),
|
|
96
|
+
machines,
|
|
97
|
+
session,
|
|
98
|
+
app,
|
|
99
|
+
routes,
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
3
|
-
*
|
|
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'
|
package/src/vite/plugin.ts
CHANGED
|
@@ -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
|
-
|
|
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 (!
|
|
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 (!
|
|
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
|
-
|
|
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)
|