@brust/runtime-dom 0.0.0-stage → 2.0.0-alpha.10

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.
@@ -0,0 +1,103 @@
1
+ // Browser store registry + the server-resolver hook (M3-F D3/D6/C7).
2
+ // The registry lives on globalThis (C7) so two copies of this module share one instance per name.
3
+ import { isComputed, isSignal, untracked } from '../core-api'
4
+ import { parseStoreScript } from './serialize'
5
+
6
+ // The resolved per-scope store record, shared by the browser registry and the server per-request
7
+ // map. `version` bumps on every signal write so `snapshot()` returns a referentially-stable object
8
+ // (useSyncExternalStore contract) until a change; `snap` memoizes it. `handle` is set only on
9
+ // records created for a resolver, so collectSnapshot() can serialize a touched store.
10
+ export interface StoreInstanceRecord {
11
+ instance: object
12
+ subs: Set<() => void>
13
+ version: { n: number }
14
+ snap: { value: Record<string, unknown>; version: number } | null
15
+ /** Browser only: the snapshot the SSR HTML was rendered from (script seed applied, nothing written since). Islands hydrate against it (G13). */
16
+ ssr?: Record<string, unknown>
17
+ handle?: { serialize(): Record<string, unknown> }
18
+ }
19
+
20
+ /** Plain-value view of a record (signals and computeds read, actions dropped), memoized in `rec.snap` per version. */
21
+ export function computeSnapshot(rec: StoreInstanceRecord): Record<string, unknown> {
22
+ if (rec.snap && rec.snap.version === rec.version.n) return rec.snap.value
23
+ const out: Record<string, unknown> = {}
24
+ for (const key of Object.keys(rec.instance)) {
25
+ const v = (rec.instance as Record<string, unknown>)[key]
26
+ if (isSignal(v) || isComputed(v)) out[key] = v()
27
+ }
28
+ rec.snap = { value: out, version: rec.version.n }
29
+ return out
30
+ }
31
+
32
+ export type ServerResolver = (name: string, create: () => StoreInstanceRecord) => StoreInstanceRecord
33
+
34
+ const STORES = Symbol.for('brust.stores') // C7
35
+ type Registry = Record<string, StoreInstanceRecord>
36
+
37
+ // Module-local on purpose (as in 0.1.x): a second runtime-dom copy on the SERVER has no resolver
38
+ // and throws loudly instead of leaking request state into a shared registry.
39
+ let serverResolver: ServerResolver | null = null
40
+
41
+ /** Installed once at worker boot by packages/brust/src/store/server-context.ts; null uninstalls (tests). */
42
+ export function __setServerResolver(fn: ServerResolver | null): void {
43
+ serverResolver = fn
44
+ }
45
+
46
+ function browserRegistry(): Registry {
47
+ const g = globalThis as { [STORES]?: Registry }
48
+ return (g[STORES] ??= Object.create(null) as Registry)
49
+ }
50
+
51
+ // Attribute comparison, never a built selector: the name is not escaped for CSS.
52
+ function seedScript(name: string): Element | null {
53
+ for (const el of Array.from(document.querySelectorAll('script[data-brust-store]'))) {
54
+ if (el.getAttribute('data-brust-store') === name) return el
55
+ }
56
+ return null
57
+ }
58
+
59
+ /**
60
+ * Resolution order (ruling R4): an installed resolver takes EVERY access; otherwise the browser
61
+ * registry when `document` exists (created, then lazily seeded from the page's script tag on the
62
+ * first access per name, D6); otherwise throw.
63
+ */
64
+ export function resolveRecord(
65
+ name: string,
66
+ create: (server: boolean) => StoreInstanceRecord,
67
+ seed: (rec: StoreInstanceRecord, state: Record<string, unknown>) => void,
68
+ ): StoreInstanceRecord {
69
+ if (serverResolver) return serverResolver(name, () => create(true))
70
+ if (typeof document === 'undefined') {
71
+ throw new Error(`store '${name}' accessed on the server without a request scope`)
72
+ }
73
+ const reg = browserRegistry()
74
+ let rec = reg[name]
75
+ if (!rec) {
76
+ const made = (rec = reg[name] = create(false))
77
+ const el = seedScript(name)
78
+ if (el) seed(rec, parseStoreScript(el))
79
+ // Identity-stable: the same object stays `rec.snap` until the first write.
80
+ rec.ssr = untracked(() => computeSnapshot(made)) // a first access inside an effect must not subscribe it
81
+ }
82
+ return rec
83
+ }
84
+
85
+ /** Apply a navigation-payload snapshot to live client stores. Reserved for the SPA wave (D9): no callers yet. */
86
+ export function applyStoreSnapshot(snap: Record<string, Record<string, unknown>>): void {
87
+ const reg = (globalThis as { [STORES]?: Registry })[STORES]
88
+ if (!reg) return
89
+ for (const [name, state] of Object.entries(snap)) {
90
+ const entry = reg[name]
91
+ if (!entry) continue // not defined on the client yet: the initial-load <script> covers it
92
+ const inst = entry.instance as Record<string, { set?: (x: unknown) => void } | undefined>
93
+ for (const key of Object.keys(state)) {
94
+ const v = inst[key]
95
+ if (v && typeof v.set === 'function') v.set(state[key])
96
+ }
97
+ entry.ssr = untracked(() => computeSnapshot(entry)) // R11.1: the payload is the new server truth for islands hydrating later
98
+ }
99
+ }
100
+
101
+ export function _resetStores(): void {
102
+ delete (globalThis as { [STORES]?: unknown })[STORES]
103
+ }
@@ -0,0 +1,33 @@
1
+ // Snapshot serialization for the store (M3-F D6). JSON for embedding in a <script> TEXT node (not an
2
+ // attribute): brust runs AutoEscape::None and a request-derived value can reach a serialized signal,
3
+ // so escape against </script> / <!-- breakout. C4: the Rust emitter (render.rs::inject_assets_into)
4
+ // mirrors these bytes; test 'C4 golden bytes' is the shared contract.
5
+
6
+ const ESC: Record<string, string> = {
7
+ '<': '\\u003c',
8
+ '>': '\\u003e',
9
+ '&': '\\u0026',
10
+ '\u2028': '\\u2028',
11
+ '\u2029': '\\u2029',
12
+ }
13
+
14
+ export function toScriptJson(value: unknown): string {
15
+ return JSON.stringify(value).replace(/[<>&\u2028\u2029]/g, (c) => ESC[c]!)
16
+ }
17
+
18
+ export function storeScriptTag(name: string, state: unknown): string {
19
+ // name comes from defineStore (developer literal), not request data; still
20
+ // guard the attribute against quote breakout by rejecting unexpected chars.
21
+ const safeName = String(name).replace(/[^a-zA-Z0-9_.:-]/g, '')
22
+ return `<script type="application/json" data-brust-store="${safeName}">${toScriptJson(state)}</script>`
23
+ }
24
+
25
+ export function parseStoreScript(el: { textContent: string | null }): Record<string, unknown> {
26
+ const text = el.textContent ?? '{}'
27
+ try {
28
+ return JSON.parse(text) as Record<string, unknown>
29
+ } catch (e) {
30
+ console.warn('[brust] store: invalid snapshot JSON', e)
31
+ return {}
32
+ }
33
+ }
package/src/value.ts ADDED
@@ -0,0 +1,47 @@
1
+ import { isReactive } from './signal'
2
+ export interface ParsedValue { path: string[]; bindings: string[] }
3
+ const IDENT = /^[A-Za-z_$][\w$]*$/
4
+
5
+ export function parseValue(raw: string): ParsedValue | null {
6
+ const [pathPart, bindPart, ...rest] = raw.trim().split(':')
7
+ if (rest.length || !pathPart) return null
8
+ const path = pathPart.split('.')
9
+ if (!path.every((p) => IDENT.test(p))) return null
10
+ const bindings = bindPart === undefined ? [] : bindPart.split(',').map((b) => b.trim())
11
+ if (!bindings.every((b) => IDENT.test(b))) return null
12
+ return { path, bindings }
13
+ }
14
+
15
+ export type Scope = Record<string, unknown>
16
+
17
+ /** Resolve a parsed directive value against instance members. Reads signals (tracked). */
18
+ export function resolve(members: Record<string, unknown>, v: ParsedValue, scope: Scope): { ok: true; value: unknown } | { ok: false } {
19
+ // x-for rows expose a tick that changes when the row's item/index is refreshed; reading it subscribes.
20
+ const tick = scope.__tick
21
+ if (typeof tick === 'function') tick()
22
+ let cur: unknown = members
23
+ for (const key of v.path) {
24
+ if (isReactive(cur)) cur = cur()
25
+ if (cur === null || typeof cur !== 'object') return { ok: false }
26
+ if (!(key in (cur as object))) return { ok: false }
27
+ cur = (cur as Record<string, unknown>)[key]
28
+ }
29
+ if (v.bindings.length) {
30
+ if (typeof cur !== 'function') return { ok: false }
31
+ const args = v.bindings.map((b) => scope[b])
32
+ return { ok: true, value: (cur as (...a: unknown[]) => unknown)(...args) }
33
+ }
34
+ if (isReactive(cur)) cur = cur()
35
+ return { ok: true, value: cur }
36
+ }
37
+
38
+ /** Like resolve but returns the member itself (for handlers and x-model signals). */
39
+ export function resolveMember(members: Record<string, unknown>, v: ParsedValue): unknown {
40
+ let cur: unknown = members
41
+ for (const key of v.path) {
42
+ if (isReactive(cur)) cur = cur()
43
+ if (cur === null || typeof cur !== 'object' || !(key in (cur as object))) return undefined
44
+ cur = (cur as Record<string, unknown>)[key]
45
+ }
46
+ return cur
47
+ }
package/src/warn.ts ADDED
@@ -0,0 +1,9 @@
1
+ const seen = new Set<string>()
2
+ export function warnOnce(key: string, message: string): void {
3
+ if (seen.has(key)) return
4
+ seen.add(key)
5
+ console.warn(`[brust] ${message}`)
6
+ }
7
+
8
+ /** test-only; the browser harness calls it between cases */
9
+ export function __resetWarnOnce(): void { seen.clear() }