@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,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
+ '&': '&amp;',
33
+ '<': '&lt;',
34
+ '>': '&gt;',
35
+ '"': '&quot;',
36
+ "'": '&apos;',
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'