@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,287 @@
|
|
|
1
|
+
import { renderBranchBody } from '../template/conditional.ts'
|
|
2
|
+
import { coerceKeys, renderKeyedItem, renderListBody } from '../template/each.ts'
|
|
3
|
+
import type { Patch } from '../wire/index.ts'
|
|
4
|
+
import {
|
|
5
|
+
keyedScopePrefix,
|
|
6
|
+
keyToken,
|
|
7
|
+
type RenderState,
|
|
8
|
+
runInRender,
|
|
9
|
+
unregisterBindingsForScope,
|
|
10
|
+
} from './render-context.ts'
|
|
11
|
+
import type { SessionRuntime } from './session-runtime.ts'
|
|
12
|
+
|
|
13
|
+
/** A patch paired with its source slot id — the slot in the binding tree
|
|
14
|
+
* this patch came from. Used internally for scope-subsumption. The wire
|
|
15
|
+
* shape drops this field. */
|
|
16
|
+
type PendingPatch = { patch: Patch; sourceSlot: string }
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Walk every binding tied to `machineName`, re-evaluate its selector against
|
|
20
|
+
* the current machine snapshot, and produce patches for any binding whose
|
|
21
|
+
* value changed.
|
|
22
|
+
*
|
|
23
|
+
* Scope subsumption: when a list (`each`) or branch (`when`/`match`) body
|
|
24
|
+
* is replaced via an 'html' patch, the new body's HTML already contains the
|
|
25
|
+
* fresh values of every descendant binding. Any text/attr patches whose
|
|
26
|
+
* source slots live inside that scope are therefore redundant and would
|
|
27
|
+
* either no-op (descendant slot IDs no longer exist in the DOM) or, worse,
|
|
28
|
+
* target unrelated elements once finer-grained patches arrive. The filter
|
|
29
|
+
* at the bottom of this function drops them explicitly.
|
|
30
|
+
*/
|
|
31
|
+
export function recompute(
|
|
32
|
+
state: RenderState,
|
|
33
|
+
machineName: string,
|
|
34
|
+
runtime: SessionRuntime,
|
|
35
|
+
): Patch[] {
|
|
36
|
+
const proxy = runtime.proxyFor(machineName)
|
|
37
|
+
if (!proxy) return []
|
|
38
|
+
|
|
39
|
+
const pending: PendingPatch[] = []
|
|
40
|
+
// Keyed-list scopes torn down this pass (removed rows) — any patch already
|
|
41
|
+
// emitted for a slot inside one targets DOM the remove op deletes.
|
|
42
|
+
const removedScopes: string[] = []
|
|
43
|
+
const slotIds = state.byMachine.get(machineName)
|
|
44
|
+
if (!slotIds) return []
|
|
45
|
+
|
|
46
|
+
for (const slotId of [...slotIds]) {
|
|
47
|
+
const binding = state.bindings.get(slotId)
|
|
48
|
+
if (!binding) continue
|
|
49
|
+
|
|
50
|
+
const newValue = binding.selector(proxy)
|
|
51
|
+
|
|
52
|
+
if (binding.kind === 'text') {
|
|
53
|
+
if (!valuesEqual(newValue, binding.lastValue)) {
|
|
54
|
+
pending.push({
|
|
55
|
+
patch: {
|
|
56
|
+
target: { kind: 'slot', id: slotId },
|
|
57
|
+
op: 'text',
|
|
58
|
+
value: stringify(newValue),
|
|
59
|
+
},
|
|
60
|
+
sourceSlot: slotId,
|
|
61
|
+
})
|
|
62
|
+
binding.lastValue = newValue
|
|
63
|
+
}
|
|
64
|
+
} else if (binding.kind === 'attr') {
|
|
65
|
+
if (!valuesEqual(newValue, binding.lastValue)) {
|
|
66
|
+
pending.push({
|
|
67
|
+
patch: {
|
|
68
|
+
target: { kind: 'element', id: binding.parentId },
|
|
69
|
+
op: 'attr',
|
|
70
|
+
name: binding.attrName,
|
|
71
|
+
value: attrWireValue(newValue),
|
|
72
|
+
},
|
|
73
|
+
sourceSlot: slotId,
|
|
74
|
+
})
|
|
75
|
+
binding.lastValue = newValue
|
|
76
|
+
}
|
|
77
|
+
} else if (binding.kind === 'list') {
|
|
78
|
+
const newArray = newValue as readonly unknown[]
|
|
79
|
+
const oldArray = binding.lastValue as readonly unknown[]
|
|
80
|
+
if (!arrayShallowEqual(newArray, oldArray)) {
|
|
81
|
+
const fn = binding.itemRenderer
|
|
82
|
+
const newInner = runInRender(state, () => renderListBody(state, slotId, newArray, fn))
|
|
83
|
+
pending.push({
|
|
84
|
+
patch: {
|
|
85
|
+
target: { kind: 'slot', id: slotId },
|
|
86
|
+
op: 'html',
|
|
87
|
+
value: newInner,
|
|
88
|
+
},
|
|
89
|
+
sourceSlot: slotId,
|
|
90
|
+
})
|
|
91
|
+
binding.lastValue = newArray
|
|
92
|
+
}
|
|
93
|
+
} else if (binding.kind === 'list-keyed') {
|
|
94
|
+
// Keyed diff: shape changes become per-item insert/remove/move ops from
|
|
95
|
+
// a replay simulation (`work` mirrors what the client's DOM will look
|
|
96
|
+
// like after each emitted op — see the wire contract). Content inside
|
|
97
|
+
// retained items updates through the items' own nested bindings; the
|
|
98
|
+
// keyed path never re-renders a retained row.
|
|
99
|
+
const newArray = newValue as readonly unknown[]
|
|
100
|
+
const newKeys = coerceKeys(newArray, binding.keyFn, slotId)
|
|
101
|
+
const oldKeys = binding.lastKeys
|
|
102
|
+
if (!stringArraysEqual(newKeys, oldKeys)) {
|
|
103
|
+
const target = { kind: 'slot', id: slotId } as const
|
|
104
|
+
const newKeySet = new Set(newKeys)
|
|
105
|
+
const work = [...oldKeys]
|
|
106
|
+
// Removals right-to-left so earlier indices stay valid within the pass.
|
|
107
|
+
for (let i = work.length - 1; i >= 0; i--) {
|
|
108
|
+
const key = work[i]!
|
|
109
|
+
if (newKeySet.has(key)) continue
|
|
110
|
+
pending.push({ patch: { target, op: 'remove', index: i }, sourceSlot: slotId })
|
|
111
|
+
const scope = keyedScopePrefix(slotId, keyToken(key))
|
|
112
|
+
removedScopes.push(`${scope}:`)
|
|
113
|
+
unregisterBindingsForScope(state, scope)
|
|
114
|
+
work.splice(i, 1)
|
|
115
|
+
}
|
|
116
|
+
// Settle each position left-to-right: an existing key moves up, a new
|
|
117
|
+
// key renders under its key scope and inserts.
|
|
118
|
+
for (let i = 0; i < newKeys.length; i++) {
|
|
119
|
+
const key = newKeys[i]!
|
|
120
|
+
if (work[i] === key) continue
|
|
121
|
+
const from = work.indexOf(key, i + 1)
|
|
122
|
+
if (from !== -1) {
|
|
123
|
+
pending.push({ patch: { target, op: 'move', from, to: i }, sourceSlot: slotId })
|
|
124
|
+
work.splice(from, 1)
|
|
125
|
+
work.splice(i, 0, key)
|
|
126
|
+
} else {
|
|
127
|
+
const itemHtml = runInRender(state, () =>
|
|
128
|
+
renderKeyedItem(state, slotId, newArray[i], i, key, binding.itemRenderer),
|
|
129
|
+
)
|
|
130
|
+
pending.push({
|
|
131
|
+
patch: { target, op: 'insert', index: i, value: itemHtml },
|
|
132
|
+
sourceSlot: slotId,
|
|
133
|
+
})
|
|
134
|
+
work.splice(i, 0, key)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
binding.lastKeys = newKeys
|
|
138
|
+
}
|
|
139
|
+
binding.lastValue = newArray
|
|
140
|
+
} else if (binding.kind === 'branch') {
|
|
141
|
+
const newKey = binding.branchKeyFn(newValue)
|
|
142
|
+
if (!Object.is(newKey, binding.lastBranchKey)) {
|
|
143
|
+
const renderer = binding.branchRenderFn(newValue)
|
|
144
|
+
const newInner = runInRender(state, () => renderBranchBody(state, slotId, newKey, renderer))
|
|
145
|
+
pending.push({
|
|
146
|
+
patch: {
|
|
147
|
+
target: { kind: 'slot', id: slotId },
|
|
148
|
+
op: 'html',
|
|
149
|
+
value: newInner,
|
|
150
|
+
},
|
|
151
|
+
sourceSlot: slotId,
|
|
152
|
+
})
|
|
153
|
+
binding.lastBranchKey = newKey
|
|
154
|
+
binding.lastValue = newValue
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return subsumeScopes(pending, removedScopes)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Positional string-array equality — the keyed diff's "did anything change". */
|
|
163
|
+
function stringArraysEqual(a: readonly string[], b: readonly string[]): boolean {
|
|
164
|
+
if (a.length !== b.length) return false
|
|
165
|
+
for (let i = 0; i < a.length; i++) {
|
|
166
|
+
if (a[i] !== b[i]) return false
|
|
167
|
+
}
|
|
168
|
+
return true
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Drop any patch whose source slot is a descendant of an 'html'-op patch's
|
|
173
|
+
* scope, or of a keyed-list scope removed this pass. Descendants share the
|
|
174
|
+
* prefix `<scope-slot-id>:` per our slot-id scheme (see allocSlotId /
|
|
175
|
+
* pushListScope / pushKeyedScope in render-context.ts).
|
|
176
|
+
*/
|
|
177
|
+
function subsumeScopes(pending: PendingPatch[], removedScopes: string[]): Patch[] {
|
|
178
|
+
const scopePrefixes: string[] = [...removedScopes]
|
|
179
|
+
for (const p of pending) {
|
|
180
|
+
if (p.patch.op === 'html') scopePrefixes.push(`${p.sourceSlot}:`)
|
|
181
|
+
}
|
|
182
|
+
if (scopePrefixes.length === 0) return pending.map((p) => p.patch)
|
|
183
|
+
|
|
184
|
+
const out: Patch[] = []
|
|
185
|
+
for (const p of pending) {
|
|
186
|
+
let inside = false
|
|
187
|
+
for (const prefix of scopePrefixes) {
|
|
188
|
+
// The html patch itself isn't a descendant of its own scope — skip
|
|
189
|
+
// the exact match.
|
|
190
|
+
if (`${p.sourceSlot}:` === prefix) continue
|
|
191
|
+
if (p.sourceSlot.startsWith(prefix)) {
|
|
192
|
+
inside = true
|
|
193
|
+
break
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (!inside) out.push(p.patch)
|
|
197
|
+
}
|
|
198
|
+
return out
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function valuesEqual(a: unknown, b: unknown): boolean {
|
|
202
|
+
if (Object.is(a, b)) return true
|
|
203
|
+
if (a == null || b == null) return false
|
|
204
|
+
if (typeof a !== typeof b) return false
|
|
205
|
+
if (typeof a !== 'object') return false
|
|
206
|
+
return JSON.stringify(a) === JSON.stringify(b)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function arrayShallowEqual(a: readonly unknown[], b: readonly unknown[]): boolean {
|
|
210
|
+
if (a === b) return true
|
|
211
|
+
if (!Array.isArray(a) || !Array.isArray(b)) return false
|
|
212
|
+
if (a.length !== b.length) return false
|
|
213
|
+
for (let i = 0; i < a.length; i++) {
|
|
214
|
+
if (a[i] !== b[i]) return false
|
|
215
|
+
}
|
|
216
|
+
return true
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function stringify(v: unknown): string {
|
|
220
|
+
if (v == null) return ''
|
|
221
|
+
if (typeof v === 'string') return v
|
|
222
|
+
return String(v)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Baseline invalidation marker — never equal to any real value or key. */
|
|
226
|
+
const NEVER_SYNCED = Symbol('stator.never-synced')
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Initial-sync patches for a freshly opened live connection.
|
|
230
|
+
*
|
|
231
|
+
* The connection's diff baseline is built by re-rendering the route AT
|
|
232
|
+
* CONNECT time, but the browser's DOM was rendered at page-GET time — and
|
|
233
|
+
* any state change in the gap (an effect settling during navigation is the
|
|
234
|
+
* classic case) would otherwise be permanently invisible: future diffs run
|
|
235
|
+
* against a baseline the DOM never received. This forces every binding to
|
|
236
|
+
* emit its CURRENT value once, so the DOM converges to the baseline; the
|
|
237
|
+
* patches are idempotent, so an already-fresh page just repaints in place.
|
|
238
|
+
*
|
|
239
|
+
* Keyed lists are reset wholesale (one `html` patch of freshly rendered
|
|
240
|
+
* rows) — positional insert/remove/move ops assume a known DOM row set,
|
|
241
|
+
* which is exactly what a possibly-stale page can't offer.
|
|
242
|
+
*/
|
|
243
|
+
export function initialSyncPatches(state: RenderState, runtime: SessionRuntime): Patch[] {
|
|
244
|
+
const patches: Patch[] = []
|
|
245
|
+
|
|
246
|
+
// Keyed lists first: re-render every row under its key scope (replacing
|
|
247
|
+
// the scope's binding registrations) and emit one wholesale reset.
|
|
248
|
+
for (const [slotId, binding] of [...state.bindings]) {
|
|
249
|
+
if (binding.kind !== 'list-keyed') continue
|
|
250
|
+
const items = (binding.lastValue ?? []) as readonly unknown[]
|
|
251
|
+
const keys = binding.lastKeys
|
|
252
|
+
for (const key of keys) {
|
|
253
|
+
unregisterBindingsForScope(state, keyedScopePrefix(slotId, keyToken(key)))
|
|
254
|
+
}
|
|
255
|
+
let rowsHtml = ''
|
|
256
|
+
for (let i = 0; i < items.length; i++) {
|
|
257
|
+
rowsHtml += runInRender(state, () =>
|
|
258
|
+
renderKeyedItem(state, slotId, items[i], i, keys[i]!, binding.itemRenderer),
|
|
259
|
+
)
|
|
260
|
+
}
|
|
261
|
+
patches.push({ target: { kind: 'slot', id: slotId }, op: 'html', value: rowsHtml })
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Everything else: invalidate baselines and let the normal recompute paths
|
|
265
|
+
// re-emit at current values (branch bodies re-render through the same code
|
|
266
|
+
// that handles real branch flips).
|
|
267
|
+
for (const binding of state.bindings.values()) {
|
|
268
|
+
if (binding.kind === 'text' || binding.kind === 'attr' || binding.kind === 'list') {
|
|
269
|
+
binding.lastValue = NEVER_SYNCED
|
|
270
|
+
} else if (binding.kind === 'branch') {
|
|
271
|
+
binding.lastBranchKey = NEVER_SYNCED
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
for (const machineName of state.byMachine.keys()) {
|
|
275
|
+
patches.push(...recompute(state, machineName, runtime))
|
|
276
|
+
}
|
|
277
|
+
return patches
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Attr-binding wire semantics, mirroring the render side: false/null/
|
|
281
|
+
* undefined → null (attribute removed), true → '' (present, empty),
|
|
282
|
+
* anything else stringified. */
|
|
283
|
+
function attrWireValue(v: unknown): string | null {
|
|
284
|
+
if (v === false || v === null || v === undefined) return null
|
|
285
|
+
if (v === true) return ''
|
|
286
|
+
return stringify(v)
|
|
287
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import Redis, { type RedisOptions } from 'ioredis'
|
|
2
|
+
import type { AppStore } from './app-store.ts'
|
|
3
|
+
import type { Store } from './store.ts'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Redis-backed Store. One hash per session, machine names as fields:
|
|
7
|
+
*
|
|
8
|
+
* stator:session:<sid> (hash)
|
|
9
|
+
* CartMachine → JSON snapshot
|
|
10
|
+
* CheckoutMachine → JSON snapshot
|
|
11
|
+
*
|
|
12
|
+
* TTL is per-session: HSET + EXPIRE are pipelined on every set, so the
|
|
13
|
+
* whole hash's expiry refreshes every time the user interacts with any of
|
|
14
|
+
* their machines. Idle sessions expire as a unit; active users never see
|
|
15
|
+
* a machine's state drop while other machines stay alive.
|
|
16
|
+
*
|
|
17
|
+
* Works with Upstash, Fly Redis, hosted Redis Cloud, or any redis:// /
|
|
18
|
+
* rediss:// URL — ioredis handles TLS automatically when the scheme is
|
|
19
|
+
* rediss://. Pass the URL directly or a fully-formed RedisOptions object.
|
|
20
|
+
*/
|
|
21
|
+
export class RedisStore implements Store {
|
|
22
|
+
private client: Redis
|
|
23
|
+
private keyPrefix: string
|
|
24
|
+
|
|
25
|
+
constructor(connection: string | RedisOptions, keyPrefix = 'stator:session') {
|
|
26
|
+
this.client = typeof connection === 'string' ? new Redis(connection) : new Redis(connection)
|
|
27
|
+
this.keyPrefix = keyPrefix
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
private key(sid: string): string {
|
|
31
|
+
return `${this.keyPrefix}:${sid}`
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async get(sid: string, name: string): Promise<unknown | null> {
|
|
35
|
+
const raw = await this.client.hget(this.key(sid), name)
|
|
36
|
+
if (raw === null) return null
|
|
37
|
+
try {
|
|
38
|
+
return JSON.parse(raw)
|
|
39
|
+
} catch {
|
|
40
|
+
// Corrupted entry — treat as missing rather than crash. Logged
|
|
41
|
+
// upstream if the caller cares.
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async set(
|
|
47
|
+
sid: string,
|
|
48
|
+
name: string,
|
|
49
|
+
snapshot: unknown,
|
|
50
|
+
opts?: { ttlSeconds?: number },
|
|
51
|
+
): Promise<void> {
|
|
52
|
+
const key = this.key(sid)
|
|
53
|
+
const payload = JSON.stringify(snapshot)
|
|
54
|
+
if (opts?.ttlSeconds) {
|
|
55
|
+
// Pipeline HSET + EXPIRE so the whole hash's TTL refreshes atomically.
|
|
56
|
+
// Any field write extends the session's idle window — see Store
|
|
57
|
+
// interface docs for the per-session TTL semantic.
|
|
58
|
+
await this.client.multi().hset(key, name, payload).expire(key, opts.ttlSeconds).exec()
|
|
59
|
+
} else {
|
|
60
|
+
await this.client.hset(key, name, payload)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async has(sid: string, name: string): Promise<boolean> {
|
|
65
|
+
return (await this.client.hexists(this.key(sid), name)) === 1
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async deleteSession(sid: string): Promise<void> {
|
|
69
|
+
await this.client.del(this.key(sid))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Close the connection. Call on graceful shutdown. */
|
|
73
|
+
async close(): Promise<void> {
|
|
74
|
+
await this.client.quit()
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Underlying ioredis client, exposed for health checks / introspection. */
|
|
78
|
+
get raw(): Redis {
|
|
79
|
+
return this.client
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Redis-backed AppStore: one plain key per app machine name, JSON snapshot,
|
|
85
|
+
* NO TTL — app state is process-equivalent persistence (see app-store.ts).
|
|
86
|
+
* Single-writer assumption; multi-replica coordination is out of scope.
|
|
87
|
+
*/
|
|
88
|
+
export class RedisAppStore implements AppStore {
|
|
89
|
+
private client: Redis
|
|
90
|
+
private keyPrefix: string
|
|
91
|
+
|
|
92
|
+
constructor(connection: string | RedisOptions, keyPrefix = 'stator:app') {
|
|
93
|
+
this.client = typeof connection === 'string' ? new Redis(connection) : new Redis(connection)
|
|
94
|
+
this.keyPrefix = keyPrefix
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async loadAppMachine(name: string): Promise<unknown | null> {
|
|
98
|
+
const raw = await this.client.get(`${this.keyPrefix}:${name}`)
|
|
99
|
+
if (raw === null) return null
|
|
100
|
+
try {
|
|
101
|
+
return JSON.parse(raw)
|
|
102
|
+
} catch {
|
|
103
|
+
// Corrupted blob — treat as missing; the boot path logs and starts fresh.
|
|
104
|
+
return null
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async saveAppMachine(name: string, snapshot: unknown): Promise<void> {
|
|
109
|
+
await this.client.set(`${this.keyPrefix}:${name}`, JSON.stringify(snapshot))
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Close the connection. Call on graceful shutdown. */
|
|
113
|
+
async close(): Promise<void> {
|
|
114
|
+
await this.client.quit()
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import type { HtmlFragment } from '../template/types.ts'
|
|
2
|
+
|
|
3
|
+
export type SlotId = string
|
|
4
|
+
export type MachineName = string
|
|
5
|
+
export type SessionId = string
|
|
6
|
+
export type ElementId = string
|
|
7
|
+
|
|
8
|
+
export type BindingKind = Binding['kind']
|
|
9
|
+
|
|
10
|
+
/** A binding-stored selector with its instance type erased. `any` (not
|
|
11
|
+
* `unknown`) is deliberate: parameters are contravariant, so a concrete
|
|
12
|
+
* `(instance: CartProxy) => T` is only assignable into an `any`-typed slot. */
|
|
13
|
+
// biome-ignore lint/suspicious/noExplicitAny: contravariant parameter — see doc comment
|
|
14
|
+
export type ErasedSelector = (instance: any) => unknown
|
|
15
|
+
|
|
16
|
+
/** A list binding's per-item renderer, item type erased (same variance argument). */
|
|
17
|
+
// biome-ignore lint/suspicious/noExplicitAny: contravariant parameter — see doc comment
|
|
18
|
+
export type ErasedItemRenderer = (item: any, index: number) => HtmlFragment
|
|
19
|
+
|
|
20
|
+
interface BindingBase {
|
|
21
|
+
slotId: SlotId
|
|
22
|
+
machineName: MachineName
|
|
23
|
+
selector: ErasedSelector
|
|
24
|
+
lastValue: unknown
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface TextBinding extends BindingBase {
|
|
28
|
+
kind: 'text'
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface AttrBinding extends BindingBase {
|
|
32
|
+
kind: 'attr'
|
|
33
|
+
attrName: string
|
|
34
|
+
parentId: ElementId
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ListBinding extends BindingBase {
|
|
38
|
+
kind: 'list'
|
|
39
|
+
/** Re-invoked per item when the list re-renders. */
|
|
40
|
+
itemRenderer: ErasedItemRenderer
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A keyed list's key selector, item type erased (same variance argument as
|
|
44
|
+
* ErasedSelector). */
|
|
45
|
+
// biome-ignore lint/suspicious/noExplicitAny: contravariant parameter — see ErasedSelector
|
|
46
|
+
export type ErasedKeyFn = (item: any) => unknown
|
|
47
|
+
|
|
48
|
+
export interface KeyedListBinding extends BindingBase {
|
|
49
|
+
kind: 'list-keyed'
|
|
50
|
+
/** Re-invoked per item for inserts and initial render. */
|
|
51
|
+
itemRenderer: ErasedItemRenderer
|
|
52
|
+
/** Derives each item's identity key (validated to string | number). */
|
|
53
|
+
keyFn: ErasedKeyFn
|
|
54
|
+
/** The previous render's keys, in order — the diff baseline. */
|
|
55
|
+
lastKeys: string[]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface BranchBinding extends BindingBase {
|
|
59
|
+
kind: 'branch'
|
|
60
|
+
/** The selector value reduced to a stable key — re-render only fires when
|
|
61
|
+
* the key changes (so `when` doesn't re-render on every truthy-to-truthy
|
|
62
|
+
* value transition). */
|
|
63
|
+
branchKeyFn: (value: unknown) => unknown
|
|
64
|
+
/** Returns the renderer for a given selector value, or null when nothing
|
|
65
|
+
* should be rendered. */
|
|
66
|
+
branchRenderFn: (value: unknown) => (() => HtmlFragment) | null
|
|
67
|
+
/** The last computed key — distinct from lastValue, which holds the raw
|
|
68
|
+
* selector result. */
|
|
69
|
+
lastBranchKey: unknown
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Discriminated on `kind`, so per-kind fields are required where they apply —
|
|
73
|
+
* recompute narrows on the discriminant instead of asserting optionals. */
|
|
74
|
+
export type Binding = TextBinding | AttrBinding | ListBinding | KeyedListBinding | BranchBinding
|
|
75
|
+
|
|
76
|
+
interface Scope {
|
|
77
|
+
prefix: string
|
|
78
|
+
counter: number
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface RenderState {
|
|
82
|
+
sessionId: SessionId
|
|
83
|
+
routeKey: string
|
|
84
|
+
bindings: Map<SlotId, Binding>
|
|
85
|
+
byMachine: Map<MachineName, Set<SlotId>>
|
|
86
|
+
scopeStack: Scope[]
|
|
87
|
+
elementIdCounter: number
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function createRenderState(sessionId: SessionId, routeKey: string): RenderState {
|
|
91
|
+
return {
|
|
92
|
+
sessionId,
|
|
93
|
+
routeKey,
|
|
94
|
+
bindings: new Map(),
|
|
95
|
+
byMachine: new Map(),
|
|
96
|
+
scopeStack: [{ prefix: '', counter: 0 }],
|
|
97
|
+
elementIdCounter: 0,
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let currentRenderState: RenderState | null = null
|
|
102
|
+
|
|
103
|
+
export function getCurrentRenderState(): RenderState | null {
|
|
104
|
+
return currentRenderState
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function requireCurrentRenderState(): RenderState {
|
|
108
|
+
if (!currentRenderState) {
|
|
109
|
+
throw new Error('stator: must be called during a template render (inside runInRender)')
|
|
110
|
+
}
|
|
111
|
+
return currentRenderState
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function runInRender<T>(state: RenderState, fn: () => T): T {
|
|
115
|
+
const prev = currentRenderState
|
|
116
|
+
currentRenderState = state
|
|
117
|
+
try {
|
|
118
|
+
return fn()
|
|
119
|
+
} finally {
|
|
120
|
+
currentRenderState = prev
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function topScope(state: RenderState): Scope {
|
|
125
|
+
const s = state.scopeStack[state.scopeStack.length - 1]
|
|
126
|
+
if (!s) throw new Error('stator: render scope stack is empty')
|
|
127
|
+
return s
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function allocSlotId(state: RenderState): SlotId {
|
|
131
|
+
const scope = topScope(state)
|
|
132
|
+
const id = `${scope.prefix ? `${scope.prefix}:` : ''}s${scope.counter++}`
|
|
133
|
+
return id
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function allocElementId(state: RenderState): ElementId {
|
|
137
|
+
return `e${state.elementIdCounter++}`
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function pushListScope(state: RenderState, listSlotId: SlotId, iterIndex: number): void {
|
|
141
|
+
state.scopeStack.push({ prefix: `${listSlotId}:i${iterIndex}`, counter: 0 })
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Keyed-list scope: descendant slot ids are derived from the item's *key*, not
|
|
146
|
+
* its position — so a row's inner slot ids survive reordering, and a patch can
|
|
147
|
+
* address "the row for p1, wherever it is now". This is the item-identity
|
|
148
|
+
* primitive keyed `each` is built on.
|
|
149
|
+
*/
|
|
150
|
+
export function pushKeyedScope(state: RenderState, listSlotId: SlotId, token: string): void {
|
|
151
|
+
state.scopeStack.push({ prefix: `${listSlotId}:k${token}`, counter: 0 })
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Keyed scope prefix for a key token (shared by render and recompute). */
|
|
155
|
+
export function keyedScopePrefix(listSlotId: SlotId, token: string): string {
|
|
156
|
+
return `${listSlotId}:k${token}`
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Encode an item key into a slot-id-safe token. Injective: only `[A-Za-z0-9-]`
|
|
161
|
+
* pass through (NOT `_`, which is the escape character); everything else
|
|
162
|
+
* becomes `_<codepoint hex>`. Keys land in `data-slot` attributes and CSS
|
|
163
|
+
* attribute selectors, so the charset must be quote- and bracket-free.
|
|
164
|
+
*/
|
|
165
|
+
export function keyToken(key: string): string {
|
|
166
|
+
return key.replace(/[^A-Za-z0-9-]/gu, (c) => `_${c.codePointAt(0)!.toString(16)}`)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function popListScope(state: RenderState): void {
|
|
170
|
+
if (state.scopeStack.length <= 1) {
|
|
171
|
+
throw new Error('stator: cannot pop root render scope')
|
|
172
|
+
}
|
|
173
|
+
state.scopeStack.pop()
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function registerBinding(state: RenderState, binding: Binding): void {
|
|
177
|
+
state.bindings.set(binding.slotId, binding)
|
|
178
|
+
let slotIds = state.byMachine.get(binding.machineName)
|
|
179
|
+
if (!slotIds) {
|
|
180
|
+
slotIds = new Set()
|
|
181
|
+
state.byMachine.set(binding.machineName, slotIds)
|
|
182
|
+
}
|
|
183
|
+
slotIds.add(binding.slotId)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Remove every binding whose slot id is a *descendant* of the given scope prefix
|
|
188
|
+
* (i.e. starts with `prefix:`). The prefix itself is not removed — useful when
|
|
189
|
+
* re-rendering a list whose own list-binding must persist.
|
|
190
|
+
*/
|
|
191
|
+
export function unregisterBindingsForScope(state: RenderState, scopePrefix: string): void {
|
|
192
|
+
const toRemove: SlotId[] = []
|
|
193
|
+
for (const slotId of state.bindings.keys()) {
|
|
194
|
+
if (slotId.startsWith(`${scopePrefix}:`)) {
|
|
195
|
+
toRemove.push(slotId)
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
for (const slotId of toRemove) {
|
|
199
|
+
const b = state.bindings.get(slotId)!
|
|
200
|
+
state.bindings.delete(slotId)
|
|
201
|
+
const slotIds = state.byMachine.get(b.machineName)
|
|
202
|
+
if (slotIds) {
|
|
203
|
+
slotIds.delete(slotId)
|
|
204
|
+
if (slotIds.size === 0) state.byMachine.delete(b.machineName)
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export type EventDescriptor = {
|
|
210
|
+
readonly __isEventDescriptor: true
|
|
211
|
+
machine: string
|
|
212
|
+
event: { type: string; [k: string]: unknown }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function createEventDescriptor(
|
|
216
|
+
machine: string,
|
|
217
|
+
event: { type: string; [k: string]: unknown },
|
|
218
|
+
): EventDescriptor {
|
|
219
|
+
return { __isEventDescriptor: true, machine, event }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function isEventDescriptor(v: unknown): v is EventDescriptor {
|
|
223
|
+
return (
|
|
224
|
+
typeof v === 'object' &&
|
|
225
|
+
v !== null &&
|
|
226
|
+
(v as Record<string, unknown>).__isEventDescriptor === true
|
|
227
|
+
)
|
|
228
|
+
}
|