@statorjs/stator 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +104 -0
- package/package.json +99 -0
- package/src/build/build.ts +244 -0
- package/src/build/head.ts +43 -0
- package/src/build/index.ts +10 -0
- package/src/build/sync.ts +65 -0
- package/src/client/bind.ts +47 -0
- package/src/client/client-id.ts +10 -0
- package/src/client/dispatch.ts +62 -0
- package/src/client/element.ts +156 -0
- package/src/client/index.ts +13 -0
- package/src/client/inspector.ts +237 -0
- package/src/client/machine.ts +39 -0
- package/src/client/runtime.ts +246 -0
- package/src/client/use.ts +119 -0
- package/src/compiler/client-emit.ts +180 -0
- package/src/compiler/client-script.ts +256 -0
- package/src/compiler/compile.ts +459 -0
- package/src/compiler/diagnostics.ts +65 -0
- package/src/compiler/dts.ts +87 -0
- package/src/compiler/hash.ts +8 -0
- package/src/compiler/index.ts +48 -0
- package/src/compiler/lower.ts +0 -0
- package/src/compiler/regions.ts +79 -0
- package/src/compiler/split.ts +168 -0
- package/src/compiler/styles.ts +200 -0
- package/src/compiler/virtual-code.ts +184 -0
- package/src/components/index.ts +2 -0
- package/src/components/json-ld.ts +85 -0
- package/src/engine/actor.ts +248 -0
- package/src/engine/define-machine.ts +113 -0
- package/src/engine/index.ts +37 -0
- package/src/engine/types.ts +236 -0
- package/src/server/api-route.ts +149 -0
- package/src/server/app-dispatch.ts +52 -0
- package/src/server/app-store.ts +35 -0
- package/src/server/cached-store.ts +117 -0
- package/src/server/create-app.ts +83 -0
- package/src/server/define-machine.ts +10 -0
- package/src/server/dev.ts +255 -0
- package/src/server/discovery.ts +111 -0
- package/src/server/dispatch-context.ts +39 -0
- package/src/server/effects.ts +120 -0
- package/src/server/http.ts +475 -0
- package/src/server/index.ts +101 -0
- package/src/server/instance-proxy.ts +95 -0
- package/src/server/logger.ts +52 -0
- package/src/server/machine-store.ts +355 -0
- package/src/server/reads-helpers.ts +40 -0
- package/src/server/recompute.ts +287 -0
- package/src/server/redis-store.ts +116 -0
- package/src/server/render-context.ts +228 -0
- package/src/server/render.ts +111 -0
- package/src/server/route-discovery.ts +226 -0
- package/src/server/route-request.ts +36 -0
- package/src/server/routing.ts +175 -0
- package/src/server/session-lock.ts +33 -0
- package/src/server/session-runtime.ts +242 -0
- package/src/server/session.ts +29 -0
- package/src/server/sse.ts +175 -0
- package/src/server/store.ts +95 -0
- package/src/stator-modules.d.ts +12 -0
- package/src/template/client-shell.ts +65 -0
- package/src/template/conditional.ts +166 -0
- package/src/template/directives/core.ts +58 -0
- package/src/template/directives/list-attr.ts +183 -0
- package/src/template/directives/on.ts +22 -0
- package/src/template/each.ts +295 -0
- package/src/template/html.ts +180 -0
- package/src/template/index.ts +29 -0
- package/src/template/parser.ts +341 -0
- package/src/template/read.ts +50 -0
- package/src/template/types.ts +33 -0
- package/src/vite/index.ts +6 -0
- package/src/vite/plugin.ts +151 -0
- package/src/vite/stub.ts +79 -0
- package/src/wire/apply.ts +103 -0
- package/src/wire/index.ts +67 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { Graph, Thing, WithContext } from 'schema-dts'
|
|
2
|
+
import { raw } from '../template/html.ts'
|
|
3
|
+
import type { HtmlFragment } from '../template/types.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `<JsonLd>` — render a typed schema.org JSON-LD `<script>` block.
|
|
7
|
+
*
|
|
8
|
+
* The payload is typed against `schema-dts`, serialized once, and emitted as a
|
|
9
|
+
* raw `<script type="application/ld+json">` via `raw()`. There is no literal
|
|
10
|
+
* `<script>` in any template, so it sidesteps both text auto-escaping and the
|
|
11
|
+
* inline-`<script>`-is-a-client-component rule — a server data block is neither.
|
|
12
|
+
*
|
|
13
|
+
* import { JsonLd } from '@statorjs/stator/components'
|
|
14
|
+
* <JsonLd json={{ "@type": "Product", name: "Pocket Notebook" }} />
|
|
15
|
+
*/
|
|
16
|
+
export interface JsonLdProps {
|
|
17
|
+
/** A single schema.org entity, or an array (rendered as an `@graph`). */
|
|
18
|
+
json: Thing | Thing[]
|
|
19
|
+
/** Pretty-print indent, forwarded to {@link JSON.stringify}. */
|
|
20
|
+
space?: string | number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function JsonLd(props: JsonLdProps): HtmlFragment {
|
|
24
|
+
return raw(`<script type="application/ld+json">${ldToString(props.json, props.space)}</script>`)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type JsonValueScalar = string | boolean | number
|
|
28
|
+
type JsonValue = JsonValueScalar | Array<JsonValue> | { [key: string]: JsonValue }
|
|
29
|
+
type JsonReplacer = (_: string, value: JsonValue) => JsonValue | undefined
|
|
30
|
+
|
|
31
|
+
const ESCAPE_ENTITIES = Object.freeze({
|
|
32
|
+
'&': '&',
|
|
33
|
+
'<': '<',
|
|
34
|
+
'>': '>',
|
|
35
|
+
'"': '"',
|
|
36
|
+
"'": ''',
|
|
37
|
+
})
|
|
38
|
+
const ESCAPE_REGEX = new RegExp(`[${Object.keys(ESCAPE_ENTITIES).join('')}]`, 'g')
|
|
39
|
+
const ESCAPE_REPLACER = (t: string): string => ESCAPE_ENTITIES[t as keyof typeof ESCAPE_ENTITIES]
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A `JSON.stringify` replacer that strips JSON-LD of HTML sequences illegal in a
|
|
43
|
+
* `<script>` element, per
|
|
44
|
+
* https://www.w3.org/TR/json-ld11/#restrictions-for-contents-of-json-ld-script-elements
|
|
45
|
+
* Escaping `<`/`>` is what guarantees no `</script>` breakout in the raw output.
|
|
46
|
+
*/
|
|
47
|
+
const safeJsonLdReplacer: JsonReplacer = (_: string, value: JsonValue): JsonValue | undefined => {
|
|
48
|
+
switch (typeof value) {
|
|
49
|
+
case 'object':
|
|
50
|
+
// Omit null values.
|
|
51
|
+
if (value === null) return undefined
|
|
52
|
+
return value // JSON.stringify recurses, re-applying this replacer.
|
|
53
|
+
case 'number':
|
|
54
|
+
case 'boolean':
|
|
55
|
+
case 'bigint':
|
|
56
|
+
return value // Not risky.
|
|
57
|
+
case 'string':
|
|
58
|
+
return value.replace(ESCAPE_REGEX, ESCAPE_REPLACER)
|
|
59
|
+
default: {
|
|
60
|
+
// No other types are expected; JSON.stringify drops an `undefined` return.
|
|
61
|
+
isNever(value)
|
|
62
|
+
return undefined
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isNever(_: never): void {}
|
|
68
|
+
|
|
69
|
+
function withContext<T extends Thing>(thing: T): WithContext<T> {
|
|
70
|
+
return {
|
|
71
|
+
'@context': 'https://schema.org',
|
|
72
|
+
...(thing as object),
|
|
73
|
+
} as WithContext<T>
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function asGraph(things: Thing[]): Graph {
|
|
77
|
+
return { '@context': 'https://schema.org', '@graph': things }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Serialize one entity (with `@context`) or many (as an `@graph`) to a
|
|
81
|
+
* JSON-LD string, safe to embed verbatim in a `<script>` element. */
|
|
82
|
+
export function ldToString(json: Thing | Thing[], space?: number | string): string {
|
|
83
|
+
const ld = Array.isArray(json) ? asGraph(json) : withContext(json)
|
|
84
|
+
return JSON.stringify(ld, safeJsonLdReplacer, space)
|
|
85
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ActionHelpers,
|
|
3
|
+
EffectInvocation,
|
|
4
|
+
EventObject,
|
|
5
|
+
MachineDef,
|
|
6
|
+
Snapshot,
|
|
7
|
+
TransitionConfig,
|
|
8
|
+
} from './types.ts'
|
|
9
|
+
|
|
10
|
+
/** The actor surface the rest of the framework consumes. Designed to the
|
|
11
|
+
* framework's actual needs — not XState's actor protocol. */
|
|
12
|
+
export interface Actor<C, E extends EventObject> {
|
|
13
|
+
start(): Actor<C, E>
|
|
14
|
+
stop(): void
|
|
15
|
+
send(event: E): void
|
|
16
|
+
/** Merge into the initial context before `start()`. Used for the deferred
|
|
17
|
+
* client seed (attribute values aren't available at actor creation — only at
|
|
18
|
+
* the element's connect). No-op after start. */
|
|
19
|
+
seed(partial: Partial<C>): void
|
|
20
|
+
getSnapshot(): Snapshot<C>
|
|
21
|
+
/** Monotonic count of HANDLED events (a matching transition fired or an
|
|
22
|
+
* `@set` applied). Guard-dropped and unhandled events don't count — this
|
|
23
|
+
* is how the server distinguishes "committed" from "silently dropped". */
|
|
24
|
+
getCommitCount(): number
|
|
25
|
+
subscribe(listener: (snapshot: Snapshot<C>) => void): { unsubscribe(): void }
|
|
26
|
+
/** Listen for a declared emit. Returns a remover. Used by cross-machine
|
|
27
|
+
* subscription wiring. */
|
|
28
|
+
on(
|
|
29
|
+
emitName: string,
|
|
30
|
+
listener: (event: { type: string; [k: string]: unknown }) => void,
|
|
31
|
+
): () => void
|
|
32
|
+
/** Compact snapshot for the Store / client hydration seed. */
|
|
33
|
+
getPersistedSnapshot(): Snapshot<C>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The "top type" for an actor — same variance argument as `AnyMachineDef`:
|
|
37
|
+
* `C` appears in both co- and contravariant positions, so only `any` admits
|
|
38
|
+
* every concrete actor. */
|
|
39
|
+
// biome-ignore lint/suspicious/noExplicitAny: existential slots (see doc comment)
|
|
40
|
+
export type AnyActor = Actor<any, any>
|
|
41
|
+
|
|
42
|
+
/** The shape delivered to `Actor.on` emit listeners. */
|
|
43
|
+
export type EmittedEvent = { type: string; [k: string]: unknown }
|
|
44
|
+
|
|
45
|
+
export interface CreateActorOptions<C> {
|
|
46
|
+
/** Hydrate from a persisted snapshot (Store) or a client seed. */
|
|
47
|
+
snapshot?: Snapshot<C>
|
|
48
|
+
/** Host-provided resolver for action/guard `reads` helpers. The server wires
|
|
49
|
+
* this to the active dispatch context; the client omits it (a reads-free
|
|
50
|
+
* machine never dereferences it). This injection is what keeps the engine
|
|
51
|
+
* isomorphic — it imports no server-only module. */
|
|
52
|
+
resolveHelpers?: () => ActionHelpers
|
|
53
|
+
/** Host-provided effect scheduler. When set, the actor hands each pending
|
|
54
|
+
* effect to the host instead of running it (the server queues effects and
|
|
55
|
+
* runs them after the session lock releases, dispatching completions
|
|
56
|
+
* through the full event path). When omitted, the actor schedules the
|
|
57
|
+
* effect locally on a microtask and sends its completion event to itself —
|
|
58
|
+
* the client-plane (and unit-test) behavior. */
|
|
59
|
+
onEffect?: (invocation: EffectInvocation) => void
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Unique per-invocation effect id — usable as an idempotency key, so it must
|
|
63
|
+
* be unique across process restarts (randomUUID, not a counter). */
|
|
64
|
+
function newEffectId(): string {
|
|
65
|
+
const uuid = globalThis.crypto?.randomUUID?.()
|
|
66
|
+
return uuid ?? `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Helpers that throw on any `reads` access — used when no resolver is wired
|
|
70
|
+
* (e.g. a client actor, or a direct unit-test send). A machine that ignores
|
|
71
|
+
* reads keeps working; one that dereferences them gets a clear error. */
|
|
72
|
+
function throwingHelpers(machineName: string): ActionHelpers {
|
|
73
|
+
return {
|
|
74
|
+
reads: new Proxy({} as Record<string, unknown>, {
|
|
75
|
+
get(_t, prop) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
`stator: "${machineName}" accessed reads.${String(prop)} with no reads resolver — ` +
|
|
78
|
+
`actions/guards that use reads must run through the server dispatch path.`,
|
|
79
|
+
)
|
|
80
|
+
},
|
|
81
|
+
}),
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function createActor<C extends object, E extends EventObject, S extends string>(
|
|
86
|
+
def: MachineDef<C, E, S>,
|
|
87
|
+
opts: CreateActorOptions<C> = {},
|
|
88
|
+
): Actor<C, E> {
|
|
89
|
+
let value: string[] = opts.snapshot ? [...opts.snapshot.value] : [def.initial]
|
|
90
|
+
let context: C = opts.snapshot
|
|
91
|
+
? (structuredClone(opts.snapshot.context) as C)
|
|
92
|
+
: (structuredClone(def.context) as C)
|
|
93
|
+
|
|
94
|
+
const subscribers = new Set<(s: Snapshot<C>) => void>()
|
|
95
|
+
const emitListeners = new Map<string, Set<(e: EmittedEvent) => void>>()
|
|
96
|
+
let started = false
|
|
97
|
+
|
|
98
|
+
const helpers = (): ActionHelpers => opts.resolveHelpers?.() ?? throwingHelpers(def.name)
|
|
99
|
+
|
|
100
|
+
let commits = 0
|
|
101
|
+
|
|
102
|
+
const snapshot = (): Snapshot<C> => ({ value: [...value], context })
|
|
103
|
+
|
|
104
|
+
const notify = (): void => {
|
|
105
|
+
const snap = snapshot()
|
|
106
|
+
for (const fn of subscribers) fn(snap)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const actor: Actor<C, E> = {
|
|
110
|
+
seed(partial: Partial<C>) {
|
|
111
|
+
if (!started) context = { ...context, ...partial }
|
|
112
|
+
},
|
|
113
|
+
start() {
|
|
114
|
+
if (!started) {
|
|
115
|
+
started = true
|
|
116
|
+
notify() // let subscribe-before-start consumers sync initial state
|
|
117
|
+
}
|
|
118
|
+
return actor
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
stop() {
|
|
122
|
+
started = false
|
|
123
|
+
subscribers.clear()
|
|
124
|
+
emitListeners.clear()
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
send(event: E) {
|
|
128
|
+
// Built-in `@set`: assign one context key. Powers two-way `bind:value`
|
|
129
|
+
// (DOM → state) without a per-field transition. Available in every state.
|
|
130
|
+
if ((event as { type: string }).type === '@set') {
|
|
131
|
+
const e = event as unknown as { key: string; value: unknown }
|
|
132
|
+
context = { ...context, [e.key]: e.value }
|
|
133
|
+
commits += 1
|
|
134
|
+
notify()
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
// Resolve the current leaf state (depth-1 today: value[0]).
|
|
138
|
+
const stateKey = value[value.length - 1]!
|
|
139
|
+
const node = def.states[stateKey]
|
|
140
|
+
// The `on` map narrows the event per key; internally we treat every
|
|
141
|
+
// transition uniformly against the full event union (the runtime event
|
|
142
|
+
// IS the narrowed type for this key, so the coercion is sound).
|
|
143
|
+
type RawTransition = ((ctx: C, ev: E, h: ActionHelpers) => void) | TransitionConfig<C, E, S>
|
|
144
|
+
const entry = node?.on?.[event.type as E['type']] as
|
|
145
|
+
| RawTransition
|
|
146
|
+
| RawTransition[]
|
|
147
|
+
| undefined
|
|
148
|
+
if (!entry) return
|
|
149
|
+
|
|
150
|
+
const h = helpers()
|
|
151
|
+
|
|
152
|
+
// Resolve to the first candidate whose guard passes. A bare function or
|
|
153
|
+
// a config with no `when` always matches.
|
|
154
|
+
const candidates = Array.isArray(entry) ? entry : [entry]
|
|
155
|
+
let config: TransitionConfig<C, E, S> | undefined
|
|
156
|
+
for (const candidate of candidates) {
|
|
157
|
+
const c: TransitionConfig<C, E, S> =
|
|
158
|
+
typeof candidate === 'function' ? { do: candidate } : candidate
|
|
159
|
+
if (!c.when || c.when(context, event as never, h)) {
|
|
160
|
+
config = c
|
|
161
|
+
break
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (!config) return
|
|
165
|
+
|
|
166
|
+
commits += 1 // a transition matched and fired — the event was HANDLED
|
|
167
|
+
|
|
168
|
+
if (config.do) {
|
|
169
|
+
const draft = structuredClone(context) as C
|
|
170
|
+
config.do(draft, event as never, h)
|
|
171
|
+
context = draft
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (config.to) value = [config.to]
|
|
175
|
+
|
|
176
|
+
// Emits fire after the action commits, so payload selectors see
|
|
177
|
+
// post-mutation context.
|
|
178
|
+
if (config.emit) {
|
|
179
|
+
const names = Array.isArray(config.emit) ? config.emit : [config.emit]
|
|
180
|
+
for (const name of names) {
|
|
181
|
+
const decl = def.emits[name]
|
|
182
|
+
const payload = decl?.payload ? decl.payload(context, event as never) : {}
|
|
183
|
+
const emitted = { type: name, ...payload }
|
|
184
|
+
const listeners = emitListeners.get(name)
|
|
185
|
+
if (listeners) for (const fn of listeners) fn(emitted)
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Effects surface after commit with commit-time snapshots (same clone
|
|
190
|
+
// discipline as actions). The engine never awaits them — the host
|
|
191
|
+
// schedules (server), or the local default runs on a microtask (client,
|
|
192
|
+
// unit tests) and sends the completion back to this actor.
|
|
193
|
+
if (config.effect) {
|
|
194
|
+
const effect = config.effect
|
|
195
|
+
const effectId = newEffectId()
|
|
196
|
+
const ctxSnapshot = structuredClone(context)
|
|
197
|
+
const evSnapshot = structuredClone(event)
|
|
198
|
+
const invocation: EffectInvocation = {
|
|
199
|
+
machineName: def.name,
|
|
200
|
+
effectId,
|
|
201
|
+
run: () => Promise.resolve(effect(ctxSnapshot, evSnapshot as never, { effectId })),
|
|
202
|
+
}
|
|
203
|
+
if (opts.onEffect) {
|
|
204
|
+
opts.onEffect(invocation)
|
|
205
|
+
} else {
|
|
206
|
+
void invocation
|
|
207
|
+
.run()
|
|
208
|
+
.then((completion) => {
|
|
209
|
+
if (completion) actor.send(completion as E)
|
|
210
|
+
})
|
|
211
|
+
.catch((err) => {
|
|
212
|
+
console.error(
|
|
213
|
+
`stator: effect ${effectId} of "${def.name}" threw — effects must catch and ` +
|
|
214
|
+
`return their failure event. Dropped.`,
|
|
215
|
+
err,
|
|
216
|
+
)
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
notify()
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
getSnapshot: snapshot,
|
|
225
|
+
getPersistedSnapshot: snapshot,
|
|
226
|
+
getCommitCount: () => commits,
|
|
227
|
+
|
|
228
|
+
subscribe(listener) {
|
|
229
|
+
subscribers.add(listener)
|
|
230
|
+
return {
|
|
231
|
+
unsubscribe() {
|
|
232
|
+
subscribers.delete(listener)
|
|
233
|
+
},
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
|
|
237
|
+
on(emitName, listener) {
|
|
238
|
+
const set = emitListeners.get(emitName) ?? new Set<(e: EmittedEvent) => void>()
|
|
239
|
+
emitListeners.set(emitName, set)
|
|
240
|
+
set.add(listener)
|
|
241
|
+
return () => {
|
|
242
|
+
set.delete(listener)
|
|
243
|
+
}
|
|
244
|
+
},
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
return actor
|
|
248
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AnyMachineDef,
|
|
3
|
+
Capabilities,
|
|
4
|
+
EmitDeclaration,
|
|
5
|
+
EmitsConfig,
|
|
6
|
+
EventObject,
|
|
7
|
+
Lifecycle,
|
|
8
|
+
MachineDef,
|
|
9
|
+
ReadsMap,
|
|
10
|
+
SelectorMap,
|
|
11
|
+
StateNode,
|
|
12
|
+
SubscribeEntry,
|
|
13
|
+
} from './types.ts'
|
|
14
|
+
|
|
15
|
+
export interface DefineMachineConfig<
|
|
16
|
+
C extends object,
|
|
17
|
+
E extends EventObject,
|
|
18
|
+
S extends string,
|
|
19
|
+
Name extends string,
|
|
20
|
+
TReads extends readonly AnyMachineDef[],
|
|
21
|
+
Sel extends SelectorMap<C, ReadsMap<TReads>>,
|
|
22
|
+
> {
|
|
23
|
+
name: Name
|
|
24
|
+
lifecycle: Lifecycle
|
|
25
|
+
/** Machines this one reads. Inferred as a tuple so `helpers.reads` is typed
|
|
26
|
+
* per read machine (keyed by name, selectors preserved). */
|
|
27
|
+
reads?: TReads
|
|
28
|
+
subscribes?: SubscribeEntry[]
|
|
29
|
+
/** Typed event surface. Pass `{} as MyEvents` — a phantom carrier the engine
|
|
30
|
+
* reads only for its type. Actions/guards then narrow per transition. */
|
|
31
|
+
events?: E
|
|
32
|
+
emits?: EmitsConfig<C, E>
|
|
33
|
+
context: C
|
|
34
|
+
initial: NoInfer<S>
|
|
35
|
+
/** Transitions see `helpers.reads` typed as `ReadsMap<TReads>`. */
|
|
36
|
+
states: Record<S, StateNode<C, E, S, ReadsMap<TReads>>>
|
|
37
|
+
selectors?: Sel
|
|
38
|
+
/** APP machines only: persist this machine's snapshot through the AppStore
|
|
39
|
+
* so its state survives restarts. Opt-in — caches and other
|
|
40
|
+
* reset-on-restart machines should leave it off. Session machines always
|
|
41
|
+
* persist through the session Store; setting this on one is an error. */
|
|
42
|
+
persist?: boolean
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function normalizeEmits<C, E extends EventObject>(
|
|
46
|
+
emits: EmitsConfig<C, E> | undefined,
|
|
47
|
+
): Record<string, EmitDeclaration<C, E>> {
|
|
48
|
+
if (!emits) return {}
|
|
49
|
+
const out: Record<string, EmitDeclaration<C, E>> = {}
|
|
50
|
+
if (Array.isArray(emits)) {
|
|
51
|
+
for (const name of emits) out[name] = {}
|
|
52
|
+
return out
|
|
53
|
+
}
|
|
54
|
+
for (const [name, decl] of Object.entries(
|
|
55
|
+
emits as Record<string, EmitDeclaration<C, E> | null>,
|
|
56
|
+
)) {
|
|
57
|
+
out[name] = decl ?? {}
|
|
58
|
+
}
|
|
59
|
+
return out
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Derive the machine's capability classification.
|
|
64
|
+
*
|
|
65
|
+
* Initial heuristic (refined in the full capability pass): a machine is
|
|
66
|
+
* server-pinned if it reads another machine — cross-machine reads can't be
|
|
67
|
+
* resolved in the browser. A pure context/states/selectors machine with no
|
|
68
|
+
* reads is portable (the client-model spike's reads-free counter ran
|
|
69
|
+
* client-side for exactly this reason). Secrets and cross-session emit are
|
|
70
|
+
* future inputs to this function; they're noted as TODO, not silently ignored.
|
|
71
|
+
*/
|
|
72
|
+
function computeCapabilities(reads: readonly AnyMachineDef[]): Capabilities {
|
|
73
|
+
const reasons: string[] = []
|
|
74
|
+
for (const r of reads) {
|
|
75
|
+
reasons.push(`reads machine "${r.name}" (cross-machine reads resolve server-side only)`)
|
|
76
|
+
}
|
|
77
|
+
// TODO(capability-pass): also flag secret access and cross-session emit.
|
|
78
|
+
return { serverPinned: reasons.length > 0, reasons }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function defineMachine<
|
|
82
|
+
C extends object,
|
|
83
|
+
E extends EventObject = EventObject,
|
|
84
|
+
S extends string = string,
|
|
85
|
+
Name extends string = string,
|
|
86
|
+
const TReads extends readonly AnyMachineDef[] = readonly [],
|
|
87
|
+
Sel extends SelectorMap<C, ReadsMap<TReads>> = SelectorMap<C, ReadsMap<TReads>>,
|
|
88
|
+
>(config: DefineMachineConfig<C, E, S, Name, TReads, Sel>): MachineDef<C, E, S, Sel, Name> {
|
|
89
|
+
const reads = (config.reads ?? []) as unknown as AnyMachineDef[]
|
|
90
|
+
if (config.persist && config.lifecycle === 'session') {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`stator: machine "${config.name}" sets persist: true but is session-lifecycle — ` +
|
|
93
|
+
`session machines always persist through the session Store. ` +
|
|
94
|
+
`\`persist\` opts an APP machine into AppStore persistence.`,
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
return {
|
|
98
|
+
__isStatorMachine: true,
|
|
99
|
+
name: config.name,
|
|
100
|
+
lifecycle: config.lifecycle as Lifecycle,
|
|
101
|
+
persist: config.persist ?? false,
|
|
102
|
+
reads,
|
|
103
|
+
subscribes: config.subscribes ?? [],
|
|
104
|
+
emits: normalizeEmits<C, E>(config.emits),
|
|
105
|
+
selectors: (config.selectors ?? {}) as Sel,
|
|
106
|
+
capabilities: computeCapabilities(reads),
|
|
107
|
+
initial: config.initial,
|
|
108
|
+
states: config.states as Record<string, StateNode<C, E, S>>,
|
|
109
|
+
context: config.context,
|
|
110
|
+
__context: undefined as unknown as C,
|
|
111
|
+
__event: undefined as unknown as E,
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stator's custom isomorphic state-machine engine. Public surface.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the XState-backed POC `defineMachine`. Lean feature set, fresh API
|
|
5
|
+
* (inline transitions, typed events), runs identically server- and client-side.
|
|
6
|
+
* See spec: custom-isomorphic-state-machine-engine.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export type { Actor, AnyActor, CreateActorOptions } from './actor.ts'
|
|
10
|
+
export { createActor } from './actor.ts'
|
|
11
|
+
export type { DefineMachineConfig } from './define-machine.ts'
|
|
12
|
+
export { defineMachine } from './define-machine.ts'
|
|
13
|
+
export type {
|
|
14
|
+
Action,
|
|
15
|
+
ActionHelpers,
|
|
16
|
+
AnyMachineDef,
|
|
17
|
+
Capabilities,
|
|
18
|
+
Effect,
|
|
19
|
+
EffectInvocation,
|
|
20
|
+
EffectMeta,
|
|
21
|
+
EmitDeclaration,
|
|
22
|
+
EmitsConfig,
|
|
23
|
+
EventObject,
|
|
24
|
+
EventOf,
|
|
25
|
+
Guard,
|
|
26
|
+
InstanceOf,
|
|
27
|
+
Lifecycle,
|
|
28
|
+
MachineDef,
|
|
29
|
+
ReadsMap,
|
|
30
|
+
SelectorMap,
|
|
31
|
+
Snapshot,
|
|
32
|
+
StateNode,
|
|
33
|
+
SubscribeEntry,
|
|
34
|
+
Transition,
|
|
35
|
+
TransitionConfig,
|
|
36
|
+
} from './types.ts'
|
|
37
|
+
export { isStatorMachine } from './types.ts'
|