@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.
- package/README.md +106 -2
- package/package.json +68 -4
- package/src/core-api.ts +10 -0
- package/src/core.ts +8 -0
- package/src/directives/bind.ts +31 -0
- package/src/directives/common.ts +27 -0
- package/src/directives/for.ts +84 -0
- package/src/directives/html.ts +20 -0
- package/src/directives/if.ts +25 -0
- package/src/directives/index.ts +34 -0
- package/src/directives/model.ts +30 -0
- package/src/directives/on.ts +16 -0
- package/src/directives/ref.ts +9 -0
- package/src/directives/show.ts +11 -0
- package/src/directives/text.ts +14 -0
- package/src/features/basics.ts +6 -0
- package/src/features/for.ts +1 -0
- package/src/features/html.ts +1 -0
- package/src/features/if.ts +1 -0
- package/src/features/index.ts +8 -0
- package/src/features/island.ts +9 -0
- package/src/features/model.ts +1 -0
- package/src/features/store.ts +6 -0
- package/src/index.ts +8 -0
- package/src/instance.ts +98 -0
- package/src/island.ts +89 -0
- package/src/mount.ts +76 -0
- package/src/props-bind.ts +30 -0
- package/src/registry.ts +38 -0
- package/src/signal.ts +110 -0
- package/src/store/define-store.ts +106 -0
- package/src/store/index.ts +5 -0
- package/src/store/internal.ts +5 -0
- package/src/store/react.ts +21 -0
- package/src/store/registry.ts +103 -0
- package/src/store/serialize.ts +33 -0
- package/src/value.ts +47 -0
- package/src/warn.ts +9 -0
|
@@ -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() }
|