@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,242 @@
1
+ import { createActor, type EffectInvocation, type Snapshot } from '../engine/index.ts'
2
+ import type { AnyMachineDef } from './define-machine.ts'
3
+ import { type DispatchContext, recordTouch, withDispatchContext } from './dispatch-context.ts'
4
+ import { createInstanceProxy, type InstanceHandle } from './instance-proxy.ts'
5
+ import { buildDispatchEvent, MAX_CASCADE_DEPTH, type MachineStore } from './machine-store.ts'
6
+ import { serverReadsResolver } from './reads-helpers.ts'
7
+
8
+ /**
9
+ * Per-request scope for session-lifecycle machine actors. Created at the
10
+ * top of an HTTP handler, populated by `loadGraph` (which pulls only the
11
+ * machines this request needs from the Store and hydrates transient
12
+ * actors), and disposed at the bottom of the handler.
13
+ *
14
+ * Subscription wiring uses cross-actor listeners installed via
15
+ * `actor.on(...)`. Because the actors are transient, the wiring is rebuilt
16
+ * per request — never reused across requests.
17
+ *
18
+ * Same shape will host SSE connection lifetime in V1: the connection's
19
+ * runtime simply lives longer (one runtime per open connection, not one
20
+ * per request), and `processEvent` is invoked when an out-of-band state
21
+ * change touches a machine the connection's route reads.
22
+ */
23
+ export class SessionRuntime {
24
+ private actors = new Map<string, InstanceHandle>()
25
+ private wired = false
26
+ /** Synchronous emit→subscribe cascade depth + trail for THIS runtime. A
27
+ * subscription cycle would otherwise recurse to a bare stack overflow;
28
+ * the cap converts it into an error that names the loop. */
29
+ private cascadeDepth = 0
30
+ private cascadeTrail: string[] = []
31
+ /** Effects surfaced during processEvent, queued until the entry point has
32
+ * persisted and released the session lock (see server/effects.ts). */
33
+ private pendingEffects: EffectInvocation[] = []
34
+
35
+ constructor(
36
+ readonly sessionId: string,
37
+ readonly store: MachineStore,
38
+ ) {}
39
+
40
+ /**
41
+ * Ensure the given machines (and everything reachable via their `reads:`
42
+ * and `subscribes:` declarations + machines that subscribe back to them)
43
+ * are loaded into this runtime. Idempotent: calling twice with overlapping
44
+ * inputs costs only the de-duped delta.
45
+ */
46
+ async loadGraph(seeds: ReadonlyArray<AnyMachineDef>): Promise<void> {
47
+ const queue = [...seeds]
48
+ while (queue.length > 0) {
49
+ const def = queue.shift()!
50
+ if (def.lifecycle !== 'session') continue
51
+ if (this.actors.has(def.name)) continue
52
+ await this.loadOne(def)
53
+ queue.push(...def.reads)
54
+ for (const sub of def.subscribes) queue.push(sub.from)
55
+ for (const sub of this.store.subscribersOf(def.name)) {
56
+ const targetDef = this.store.getDef(sub.targetName)
57
+ if (targetDef) queue.push(targetDef)
58
+ }
59
+ }
60
+ }
61
+
62
+ private async loadOne(def: AnyMachineDef): Promise<void> {
63
+ const persisted = await this.store.persistence.get(this.sessionId, def.name)
64
+ const actor = createActor(def, {
65
+ snapshot: persisted !== null ? (persisted as Snapshot<object>) : undefined,
66
+ resolveHelpers: serverReadsResolver(def),
67
+ // Server plane: never run effects inline — queue them for the entry
68
+ // point to schedule after persist + lock release.
69
+ onEffect: (invocation) => this.pendingEffects.push(invocation),
70
+ }).start()
71
+ this.actors.set(
72
+ def.name,
73
+ createInstanceProxy(def, actor, (name) => this.proxyFor(name)),
74
+ )
75
+ }
76
+
77
+ /**
78
+ * Replace a session actor with a fresh hydration from the Store. Used by
79
+ * fan-out before recomputing a long-lived SSE connection's diffs: the
80
+ * mutation happened in ANOTHER runtime (a POST or an effect completion)
81
+ * and only exists in persistence — this runtime's in-memory actor is
82
+ * frozen at whatever the connection last saw. Subscription listeners are
83
+ * NOT rewired onto the new actor; connection runtimes are read-only diff
84
+ * targets and never dispatch.
85
+ */
86
+ async rehydrate(name: string): Promise<void> {
87
+ const def = this.store.getDef(name)
88
+ if (def?.lifecycle !== 'session' || !this.actors.has(name)) return
89
+ const persisted = await this.store.persistence.get(this.sessionId, def.name)
90
+ const actor = createActor(def, {
91
+ snapshot: persisted !== null ? (persisted as Snapshot<object>) : undefined,
92
+ resolveHelpers: serverReadsResolver(def),
93
+ onEffect: (invocation) => this.pendingEffects.push(invocation),
94
+ }).start()
95
+ this.actors.get(name)?.actor.stop()
96
+ this.actors.set(
97
+ name,
98
+ createInstanceProxy(def, actor, (n) => this.proxyFor(n)),
99
+ )
100
+ }
101
+
102
+ /** Lifecycle of a machine by name, for fan-out applicability decisions. */
103
+ lifecycleOf(name: string): 'session' | 'app' | undefined {
104
+ return this.store.getDef(name)?.lifecycle
105
+ }
106
+
107
+ /** Hand queued effect invocations to the scheduler, clearing the queue. */
108
+ drainPendingEffects(): EffectInvocation[] {
109
+ const drained = this.pendingEffects
110
+ this.pendingEffects = []
111
+ return drained
112
+ }
113
+
114
+ /**
115
+ * Install actor.on listeners for every subscription whose source is a
116
+ * session-machine loaded in this runtime. The target may be:
117
+ * - another session-machine in this runtime (session→session)
118
+ * - an app-machine in the long-lived appInstances (session→app)
119
+ * App→session is blocked at validation; app→app is wired at app boot.
120
+ *
121
+ * Cross-lifecycle (session→app) subscriptions inject `sourceSessionId`
122
+ * into the dispatched event so the app receiver can correlate which
123
+ * session emitted it.
124
+ *
125
+ * Must be called before any event is sent. Idempotent.
126
+ */
127
+ wireSubscriptions(): void {
128
+ if (this.wired) return
129
+ this.wired = true
130
+
131
+ for (const [sourceName, sourceHandle] of this.actors) {
132
+ for (const sub of this.store.subscribersOf(sourceName)) {
133
+ const targetDef = this.store.getDef(sub.targetName)
134
+ if (!targetDef) continue
135
+ const targetHandle =
136
+ targetDef.lifecycle === 'app'
137
+ ? this.store.appInstance(sub.targetName)
138
+ : this.actors.get(sub.targetName)
139
+ if (!targetHandle) continue
140
+
141
+ const crossLifecycle = sourceHandle.def.lifecycle !== targetDef.lifecycle
142
+ const sid = this.sessionId
143
+ const targetName = sub.targetName
144
+
145
+ sourceHandle.actor.on(sub.event as never, (emitted) => {
146
+ this.cascadeDepth += 1
147
+ this.cascadeTrail.push(`${sourceName} —${sub.event}→ ${targetName}`)
148
+ try {
149
+ if (this.cascadeDepth > MAX_CASCADE_DEPTH) {
150
+ throw new Error(
151
+ `stator: emit cascade exceeded ${MAX_CASCADE_DEPTH} hops — ` +
152
+ `subscription cycle? Last hops:\n ${this.cascadeTrail.slice(-6).join('\n ')}`,
153
+ )
154
+ }
155
+ const before = targetHandle.actor.getCommitCount()
156
+ targetHandle.actor.send(
157
+ buildDispatchEvent(emitted, sub.dispatch, crossLifecycle ? sid : undefined) as never,
158
+ )
159
+ if (targetHandle.actor.getCommitCount() !== before) recordTouch(targetName)
160
+ } finally {
161
+ this.cascadeDepth -= 1
162
+ this.cascadeTrail.pop()
163
+ }
164
+ })
165
+ }
166
+ }
167
+ }
168
+
169
+ /** Resolve a proxy for a machine — session-scoped from this runtime,
170
+ * app-scoped from the long-lived app instance map. */
171
+ proxyFor(name: string): unknown {
172
+ const local = this.actors.get(name)
173
+ if (local) return local.proxy
174
+ const app = this.store.appInstance(name)
175
+ return app?.proxy
176
+ }
177
+
178
+ handleFor(name: string): InstanceHandle | undefined {
179
+ return this.actors.get(name) ?? this.store.appInstance(name)
180
+ }
181
+
182
+ /**
183
+ * Send an event to a loaded machine. Returns the set of machine names
184
+ * whose actors received any event during the dispatch (the origin plus
185
+ * any subscriber targets that fired via cross-machine listeners).
186
+ *
187
+ * Throws if the origin machine isn't loaded — call `loadGraph` first.
188
+ */
189
+ processEvent(machineName: string, event: { type: string; [k: string]: unknown }): Set<string> {
190
+ const handle = this.actors.get(machineName)
191
+ if (!handle) {
192
+ throw new Error(
193
+ `stator: machine "${machineName}" is not loaded into this runtime — ` +
194
+ `call loadGraph(...) with the relevant defs first.`,
195
+ )
196
+ }
197
+ if (!this.wired) this.wireSubscriptions()
198
+ const ctx: DispatchContext = {
199
+ runtime: this,
200
+ touched: new Set<string>([machineName]),
201
+ }
202
+ const before = handle.actor.getCommitCount()
203
+ withDispatchContext(ctx, () => {
204
+ handle.actor.send(event as never)
205
+ })
206
+ // A guard-dropped / unhandled event committed nothing: it must not count
207
+ // as touched (no persist, no fan-out, and `committed: false` on the wire).
208
+ if (handle.actor.getCommitCount() === before) ctx.touched.delete(machineName)
209
+ return ctx.touched
210
+ }
211
+
212
+ /** Write the current persisted snapshot for each touched machine back to
213
+ * its store: session machines to the session Store, app machines (touched
214
+ * via session→app subscriptions) to the AppStore when they opted in. */
215
+ async persistTouched(touched: ReadonlySet<string>): Promise<void> {
216
+ const ttlSeconds = this.store.sessionTtlSeconds
217
+ for (const name of touched) {
218
+ const handle = this.actors.get(name)
219
+ if (!handle) {
220
+ // Not a session machine in this runtime — an app machine reached via
221
+ // subscription. Safe no-op unless it opted into persistence.
222
+ await this.store.persistAppMachine(name)
223
+ continue
224
+ }
225
+ const snapshot = handle.actor.getPersistedSnapshot()
226
+ // Every set refreshes the session's whole expiry — the user is
227
+ // active, so all of their machines stay alive together.
228
+ await this.store.persistence.set(this.sessionId, name, snapshot, {
229
+ ttlSeconds,
230
+ })
231
+ }
232
+ }
233
+
234
+ /** Stop every transient actor. After dispose, the runtime must not be
235
+ * used. Safe to call multiple times. */
236
+ dispose(): void {
237
+ for (const handle of this.actors.values()) {
238
+ handle.actor.stop()
239
+ }
240
+ this.actors.clear()
241
+ }
242
+ }
@@ -0,0 +1,29 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import type { Context } from 'hono'
3
+ import { getCookie, setCookie } from 'hono/cookie'
4
+
5
+ export const SESSION_COOKIE = 'stator_sid'
6
+
7
+ /** Secure cookie flag — set when running behind HTTPS. Enabled by
8
+ * NODE_ENV=production; can be overridden via STATOR_SECURE_COOKIE. */
9
+ function shouldUseSecureCookie(): boolean {
10
+ if (process.env.STATOR_SECURE_COOKIE === '1') return true
11
+ if (process.env.STATOR_SECURE_COOKIE === '0') return false
12
+ return process.env.NODE_ENV === 'production'
13
+ }
14
+
15
+ export function getOrCreateSessionId(c: Context): {
16
+ sessionId: string
17
+ isNew: boolean
18
+ } {
19
+ const existing = getCookie(c, SESSION_COOKIE)
20
+ if (existing) return { sessionId: existing, isNew: false }
21
+ const sessionId = randomUUID()
22
+ setCookie(c, SESSION_COOKIE, sessionId, {
23
+ httpOnly: true,
24
+ sameSite: 'Lax',
25
+ path: '/',
26
+ secure: shouldUseSecureCookie(),
27
+ })
28
+ return { sessionId, isNew: true }
29
+ }
@@ -0,0 +1,175 @@
1
+ import type { Patch } from '../wire/index.ts'
2
+ import { scopedLogger } from './logger.ts'
3
+ import { recompute } from './recompute.ts'
4
+ import type { RenderState } from './render-context.ts'
5
+ import type { RouteDefinition, RouteRequest } from './routing.ts'
6
+ import type { SessionRuntime } from './session-runtime.ts'
7
+
8
+ const sseLog = scopedLogger('sse')
9
+
10
+ /**
11
+ * One open SSE connection. Lifetime equals the underlying TCP connection.
12
+ * The runtime stays alive for the connection's duration — this is the one
13
+ * place per-session state outlives a single request, because the
14
+ * connection *is* a single (very long) request.
15
+ *
16
+ * `renderState` carries the slot bindings + `lastValue` baseline that
17
+ * recompute will diff against when fan-out fires. Each push updates
18
+ * `lastValue` for the bindings touched by the push, so subsequent pushes
19
+ * only emit deltas.
20
+ */
21
+ export interface Connection {
22
+ id: string
23
+ sessionId: string
24
+ /** The browser page-load identity (client-generated). Lets fan-out
25
+ * recognize a dispatch's OWN connection: its baseline is advanced but
26
+ * nothing is sent — the POST response already delivered those patches. */
27
+ clientId?: string
28
+ routeKey: string
29
+ route: RouteDefinition
30
+ /** URL-derived state for this specific connection. Stored at connection
31
+ * open and reused by every fan-out recompute. Parameterized routes
32
+ * carry the resolved path params here (`/p/:id` connection knows its
33
+ * specific id). */
34
+ request: RouteRequest
35
+ runtime: SessionRuntime
36
+ renderState: RenderState
37
+ send: (data: string) => Promise<void>
38
+ closed: boolean
39
+ }
40
+
41
+ const connections = new Map<string, Connection>()
42
+ let nextId = 0
43
+
44
+ export function registerConnection(init: Omit<Connection, 'id' | 'closed'>): Connection {
45
+ const id = `sse${nextId++}`
46
+ const conn: Connection = { ...init, id, closed: false }
47
+ connections.set(id, conn)
48
+ sseLog.info(
49
+ { id, sid: conn.sessionId, route: conn.routeKey, total: connections.size },
50
+ 'connection opened',
51
+ )
52
+ return conn
53
+ }
54
+
55
+ export function unregisterConnection(id: string): void {
56
+ const conn = connections.get(id)
57
+ if (!conn) return
58
+ conn.closed = true
59
+ conn.runtime.dispose()
60
+ connections.delete(id)
61
+ sseLog.info(
62
+ { id, sid: conn.sessionId, route: conn.routeKey, total: connections.size },
63
+ 'connection closed',
64
+ )
65
+ }
66
+
67
+ export function activeConnectionCount(): number {
68
+ return connections.size
69
+ }
70
+
71
+ /**
72
+ * After a state-changing dispatch settles, iterate every open connection
73
+ * whose route's `reads:` intersects `touched`, recompute against that
74
+ * connection's slot map, and push any resulting patches over its event
75
+ * stream. Recompute mutates the connection's `renderState.lastValue`s so
76
+ * the next push is correctly diffed.
77
+ *
78
+ * Connections from any session receive pushes — that's the point: an
79
+ * admin tab on session C sees updates triggered by session A's POSTs.
80
+ */
81
+ export async function fanOut(
82
+ touched: ReadonlySet<string>,
83
+ source?: { sessionId?: string; originClientId?: string },
84
+ ): Promise<void> {
85
+ if (touched.size === 0 || connections.size === 0) return
86
+
87
+ let pushedCount = 0
88
+ let skippedNoIntersect = 0
89
+ let skippedNoPatches = 0
90
+ let skippedOriginator = 0
91
+ let failed = 0
92
+
93
+ // Expand through the reverse-reads graph once per fan-out: machines whose
94
+ // SELECTORS derive from a touched machine must re-diff even though their
95
+ // own state didn't move. (Every connection shares the one MachineStore.)
96
+ const first = connections.values().next().value
97
+ const { all: expandedTouched, derived } = first
98
+ ? first.runtime.store.expandTouchedForRecompute(touched)
99
+ : { all: new Set(touched), derived: new Set<string>() }
100
+
101
+ for (const conn of connections.values()) {
102
+ if (conn.closed) continue
103
+
104
+ // Applicability per machine, judged against the connection's actual
105
+ // bindings (byMachine covers transitive reads, not just declared seeds):
106
+ // - app machines: every connection (the shared instance IS the state).
107
+ // - DIRECTLY-touched session machines: only the touching session's own
108
+ // connections — and the connection's long-lived runtime must
109
+ // REHYDRATE them from the Store first, because the mutation happened
110
+ // in another runtime and this one's actor is frozen at connect time.
111
+ // - DERIVED session machines (expansion only): every connection that
112
+ // binds them, any session, no rehydration — their own state didn't
113
+ // change; their selectors' dependencies did.
114
+ const applicable: string[] = []
115
+ for (const name of expandedTouched) {
116
+ if (!conn.renderState.byMachine.has(name)) continue
117
+ if (conn.runtime.lifecycleOf(name) === 'session' && !derived.has(name)) {
118
+ if (source?.sessionId === undefined || source.sessionId !== conn.sessionId) continue
119
+ await conn.runtime.rehydrate(name)
120
+ }
121
+ applicable.push(name)
122
+ }
123
+ if (applicable.length === 0) {
124
+ skippedNoIntersect++
125
+ continue
126
+ }
127
+
128
+ const patches: Patch[] = []
129
+ for (const name of applicable) {
130
+ patches.push(...recompute(conn.renderState, name, conn.runtime))
131
+ }
132
+ if (patches.length === 0) {
133
+ skippedNoPatches++
134
+ continue
135
+ }
136
+
137
+ // The dispatching page's own connection: the POST response already
138
+ // delivered this diff. The recompute above advanced the baseline (so the
139
+ // NEXT push diffs correctly); sending would double-apply — and keyed
140
+ // insert/remove/move ops are not idempotent.
141
+ if (
142
+ source?.originClientId !== undefined &&
143
+ conn.clientId !== undefined &&
144
+ conn.clientId === source.originClientId
145
+ ) {
146
+ skippedOriginator++
147
+ continue
148
+ }
149
+
150
+ try {
151
+ await conn.send(JSON.stringify({ patches }))
152
+ pushedCount++
153
+ } catch (err) {
154
+ // Bumped from debug to warn — silent push failures were why "30-50%
155
+ // delivery" was invisible in production logs.
156
+ failed++
157
+ sseLog.warn({ id: conn.id, sid: conn.sessionId, err: String(err) }, 'sse push failed')
158
+ }
159
+ }
160
+
161
+ // Log every fan-out at info so the user can see touched-machines flow and
162
+ // correlate against client-side inspector entries.
163
+ sseLog.info(
164
+ {
165
+ touched: [...touched],
166
+ total: connections.size,
167
+ pushed: pushedCount,
168
+ skippedNoIntersect,
169
+ skippedNoPatches,
170
+ skippedOriginator,
171
+ failed,
172
+ },
173
+ 'fan-out',
174
+ )
175
+ }
@@ -0,0 +1,95 @@
1
+ /**
2
+ * Persistence boundary for session-scoped machine state. Stored values are
3
+ * XState v5 *persisted snapshots* — serializable blobs containing the state
4
+ * name plus context, recoverable via `createActor(machine, { snapshot })`.
5
+ *
6
+ * The framework treats stored values as opaque JSON. App-lifecycle machine
7
+ * state is held in process (not persisted by this layer).
8
+ *
9
+ * ## TTL semantics — per-session, not per-entry
10
+ *
11
+ * `ttlSeconds` on `set` refreshes the **whole session's** expiry, not just
12
+ * the entry being written. If CartMachine is stored at T+0 and CheckoutMachine
13
+ * is stored at T+6h (both with ttlSeconds=86400), both expire at T+30h, not
14
+ * T+24h and T+30h respectively. Adapters MUST honor this — the session is
15
+ * the TTL unit, machines within a session are not independent.
16
+ *
17
+ * Rationale: a user actively interacting with one machine (checkout) is also
18
+ * an active session for their other machines (cart) by definition. Splitting
19
+ * TTL per-machine would drop carts under users mid-checkout. Sessions
20
+ * expire as a whole when fully idle.
21
+ */
22
+ export interface Store {
23
+ get(sessionId: string, machineName: string): Promise<unknown | null>
24
+ set(
25
+ sessionId: string,
26
+ machineName: string,
27
+ snapshot: unknown,
28
+ opts?: { ttlSeconds?: number },
29
+ ): Promise<void>
30
+ has(sessionId: string, machineName: string): Promise<boolean>
31
+ deleteSession(sessionId: string): Promise<void>
32
+ }
33
+
34
+ /**
35
+ * In-memory implementation. Lazy-expiry: nothing is swept proactively, but
36
+ * any read of an expired session returns null and drops the session's data.
37
+ * Per-session expiry is tracked separately from the data map.
38
+ */
39
+ export class InMemoryStore implements Store {
40
+ private data = new Map<string, Map<string, unknown>>()
41
+ private expiryAt = new Map<string, number>() // sessionId → epoch ms
42
+
43
+ private isExpired(sid: string): boolean {
44
+ const at = this.expiryAt.get(sid)
45
+ return at !== undefined && at <= Date.now()
46
+ }
47
+
48
+ private dropSession(sid: string): void {
49
+ this.data.delete(sid)
50
+ this.expiryAt.delete(sid)
51
+ }
52
+
53
+ async get(sid: string, name: string): Promise<unknown | null> {
54
+ if (this.isExpired(sid)) {
55
+ this.dropSession(sid)
56
+ return null
57
+ }
58
+ return this.data.get(sid)?.get(name) ?? null
59
+ }
60
+
61
+ async set(
62
+ sid: string,
63
+ name: string,
64
+ snapshot: unknown,
65
+ opts?: { ttlSeconds?: number },
66
+ ): Promise<void> {
67
+ // Drop any stale data before writing — set() against an expired session
68
+ // is effectively a fresh session.
69
+ if (this.isExpired(sid)) this.dropSession(sid)
70
+
71
+ let session = this.data.get(sid)
72
+ if (!session) {
73
+ session = new Map()
74
+ this.data.set(sid, session)
75
+ }
76
+ session.set(name, snapshot)
77
+
78
+ if (opts?.ttlSeconds) {
79
+ // Refresh the whole session's expiry, not just this entry's.
80
+ this.expiryAt.set(sid, Date.now() + opts.ttlSeconds * 1000)
81
+ }
82
+ }
83
+
84
+ async has(sid: string, name: string): Promise<boolean> {
85
+ if (this.isExpired(sid)) {
86
+ this.dropSession(sid)
87
+ return false
88
+ }
89
+ return this.data.get(sid)?.has(name) ?? false
90
+ }
91
+
92
+ async deleteSession(sid: string): Promise<void> {
93
+ this.dropSession(sid)
94
+ }
95
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Ambient type for `.stator` single-file components. A `.stator` module's
3
+ * default export is its render function — it takes the component's props and
4
+ * returns an HtmlFragment. Consumers reference this via the package's types.
5
+ */
6
+ declare module '*.stator' {
7
+ import type { HtmlFragment } from '@statorjs/stator/template'
8
+
9
+ // biome-ignore lint/suspicious/noExplicitAny: the wildcard fallback must admit every component's props — `Record<string, unknown>` would reject interfaces without index signatures
10
+ const component: (props?: any) => HtmlFragment
11
+ export default component
12
+ }
@@ -0,0 +1,65 @@
1
+ import { allocElementId, getCurrentRenderState, registerBinding } from '../server/render-context.ts'
2
+ import { escapeAttribute } from './parser.ts'
3
+ import { isReadResult, type ReadResult } from './read.ts'
4
+
5
+ /**
6
+ * Server-side: render a client component's declared attributes onto its
7
+ * custom-element root. A client component's server module calls this to map
8
+ * camelCase props → kebab DOM attributes (`unitPrice` → `unit-price`), per the
9
+ * `static attrs` coercer kinds. Booleans are presence flags; other scalars are
10
+ * stringified + escaped. `null`/`undefined`/`false`-boolean → omitted.
11
+ *
12
+ * A prop may be a `read(...)`: the attribute becomes a LIVE binding — the
13
+ * server patches it like any attr binding, and the island (which observes
14
+ * its declared attrs) receives `${key}Changed(next)`. This is the sanctioned
15
+ * channel for live server state flowing INTO an island.
16
+ *
17
+ * Returns a leading-spaced attribute string (or '') to splice into the open tag.
18
+ */
19
+ export function clientShellAttrs(
20
+ props: Record<string, unknown>,
21
+ decl: Record<string, 'number' | 'string' | 'boolean'>,
22
+ ): string {
23
+ let out = ''
24
+ let elementId: string | null = null
25
+ for (const key in decl) {
26
+ const v = props[key]
27
+ if (v == null) continue
28
+ const name = key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)
29
+
30
+ if (isReadResult(v)) {
31
+ const state = getCurrentRenderState()
32
+ if (!state) {
33
+ throw new Error(
34
+ `stator: read() passed as island prop "${key}" outside a render — ` +
35
+ `live island attrs only make sense inside a route render.`,
36
+ )
37
+ }
38
+ if (!elementId) {
39
+ elementId = allocElementId(state)
40
+ out += ` data-stator-id="${elementId}"`
41
+ }
42
+ const r = v as ReadResult
43
+ registerBinding(state, {
44
+ slotId: r.slotId,
45
+ machineName: r.machineName,
46
+ selector: r.selector,
47
+ lastValue: r.value,
48
+ kind: 'attr',
49
+ attrName: name,
50
+ parentId: elementId,
51
+ })
52
+ // Same value semantics as template attr bindings (boolean-aware).
53
+ if (r.value === false || r.value === null || r.value === undefined) continue
54
+ out += r.value === true ? ` ${name}` : ` ${name}="${escapeAttribute(String(r.value))}"`
55
+ continue
56
+ }
57
+
58
+ if (decl[key] === 'boolean') {
59
+ if (v) out += ` ${name}`
60
+ } else {
61
+ out += ` ${name}="${escapeAttribute(String(v))}"`
62
+ }
63
+ }
64
+ return out
65
+ }