brustjs 0.1.20-alpha → 0.1.22-alpha

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,179 @@
1
+ import { type Computed, type Signal, isComputed, isSignal } from './signal.ts'
2
+ import { parseStoreScript } from './serialize.ts'
3
+
4
+ // The resolved per-scope store record, shared by the client singleton registry
5
+ // and the server per-request map. `version` bumps on every signal write so
6
+ // `snapshot()` returns a referentially-stable object (useSyncExternalStore
7
+ // contract) until a change; `snap` memoizes it. `handle` is set only on the
8
+ // server record so collectSnapshot() can serialize a touched store.
9
+ export interface StoreInstanceRecord {
10
+ instance: object
11
+ subs: Set<() => void>
12
+ version: { n: number }
13
+ snap: { value: Record<string, unknown>; version: number } | null
14
+ handle?: { serialize(): Record<string, unknown> }
15
+ }
16
+
17
+ // Server per-request resolver, injected by server-context.ts (which imports
18
+ // node:async_hooks) via __setServerResolver. This keeps define-store — reachable
19
+ // from browser/island bundles via brustjs/store and brustjs/client — free of any
20
+ // static Node-builtin import. On the client the resolver stays null and the
21
+ // window-singleton branch is used.
22
+ type ServerResolver = (name: string, create: () => StoreInstanceRecord) => StoreInstanceRecord
23
+ let serverResolver: ServerResolver | null = null
24
+ export function __setServerResolver(fn: ServerResolver): void {
25
+ serverResolver = fn
26
+ }
27
+
28
+ // Unwrap a Signal/Computed to its value type; `never` if it is neither. Uses
29
+ // `infer` (not `extends Signal<unknown>`): `Signal<T>.set(next: T)` is
30
+ // contravariant in T, so `Signal<TeamMember[]>` is NOT assignable to
31
+ // `Signal<unknown>` — a concrete-`unknown` check wrongly drops every typed signal
32
+ // key from the snapshot. `infer` sidesteps that.
33
+ type StoreValue<X> = X extends Signal<infer T> ? T : X extends Computed<infer T> ? T : never
34
+
35
+ // Snapshot = plain-value view of a store: Signal<T>/Computed<T> → T, plain values
36
+ // kept as-is, action functions dropped. The `[…] extends [never]` tuple wrap stops
37
+ // `never` from distributing the conditional to `never`.
38
+ export type Snapshot<S> = {
39
+ [K in keyof S as [StoreValue<S[K]>] extends [never]
40
+ ? S[K] extends (...a: never[]) => unknown
41
+ ? never
42
+ : K
43
+ : K]: [StoreValue<S[K]>] extends [never] ? S[K] : StoreValue<S[K]>
44
+ }
45
+
46
+ // `then` is reserved so a store with a signal named `then` can't make the proxy
47
+ // an accidental thenable (await/Promise.resolve(handle) misbehaving).
48
+ const RESERVED = new Set(['name', 'subscribe', 'snapshot', 'serialize', 'hydrate', 'then'])
49
+
50
+ export interface StoreHandle<S extends object> {
51
+ (): S
52
+ readonly name: string
53
+ subscribe(cb: () => void): () => void
54
+ snapshot(): Snapshot<S>
55
+ serialize(): Record<string, unknown>
56
+ hydrate(state: Record<string, unknown>): void
57
+ }
58
+
59
+ interface ClientRegistry {
60
+ [name: string]: StoreInstanceRecord
61
+ }
62
+ function clientRegistry(): ClientRegistry {
63
+ const w = window as unknown as { __BRUST_STORES__?: ClientRegistry }
64
+ if (!w.__BRUST_STORES__) w.__BRUST_STORES__ = {}
65
+ return w.__BRUST_STORES__
66
+ }
67
+
68
+ // We need each instance's signals to notify the store's subscriber set on write.
69
+ // signal.ts subscribers are internal; to bridge to React's subscribe, defineStore
70
+ // wraps the instance: after factory(), for every signal property we wrap .set to
71
+ // also bump the version and fire the store-level subscriber set. (computed
72
+ // downstream of those signals recomputes lazily; React re-reads snapshot.)
73
+ function bridgeSubscribers(
74
+ instance: Record<string, unknown>,
75
+ subs: Set<() => void>,
76
+ version: { n: number },
77
+ ): void {
78
+ for (const key of Object.keys(instance)) {
79
+ const v = instance[key]
80
+ if (isSignal(v)) {
81
+ const sig = v as Signal<unknown>
82
+ const origSet = sig.set.bind(sig)
83
+ sig.set = (next) => {
84
+ // origSet is a no-op when Object.is(prev,next). Reading sig() here is
85
+ // outside any active consumer, so it registers no dependency — we can
86
+ // compare before/after and only bump+notify on a real change, avoiding
87
+ // spurious React re-renders.
88
+ const before = sig()
89
+ origSet(next as never)
90
+ const after = sig()
91
+ if (Object.is(before, after)) return
92
+ version.n += 1
93
+ for (const cb of [...subs]) cb()
94
+ }
95
+ }
96
+ }
97
+ }
98
+
99
+ export function defineStore<S extends object>(name: string, factory: () => S): StoreHandle<S> & S {
100
+ function createRecord(): StoreInstanceRecord {
101
+ const instance = factory() as object
102
+ const subs = new Set<() => void>()
103
+ const version = { n: 0 }
104
+ bridgeSubscribers(instance as Record<string, unknown>, subs, version)
105
+ return { instance, subs, version, snap: null }
106
+ }
107
+
108
+ function resolve(): StoreInstanceRecord {
109
+ if (typeof window !== 'undefined') {
110
+ const reg = clientRegistry()
111
+ if (!reg[name]) {
112
+ reg[name] = createRecord()
113
+ // hydrate from server-injected <script> if present (first access only).
114
+ const el = document.querySelector(`script[data-brust-store="${name}"]`)
115
+ if (el) hydrateRecord(reg[name], parseStoreScript(el))
116
+ }
117
+ return reg[name]
118
+ }
119
+ // server: per-request via the resolver injected by server-context.ts.
120
+ if (!serverResolver) {
121
+ throw new Error(`store '${name}' accessed on the server without a request scope`)
122
+ }
123
+ return serverResolver(name, () => ({
124
+ ...createRecord(),
125
+ handle: handle as StoreHandle<object>,
126
+ }))
127
+ }
128
+
129
+ function hydrateRecord(rec: StoreInstanceRecord, state: Record<string, unknown>): void {
130
+ const inst = rec.instance as Record<string, unknown>
131
+ for (const key of Object.keys(state)) {
132
+ const v = inst[key]
133
+ if (isSignal(v)) (v as Signal<unknown>).set(state[key])
134
+ }
135
+ }
136
+
137
+ const handle = (() => resolve().instance) as StoreHandle<S> & S
138
+ Object.defineProperty(handle, 'name', { value: name })
139
+ handle.subscribe = (cb) => {
140
+ const rec = resolve()
141
+ rec.subs.add(cb)
142
+ return () => rec.subs.delete(cb)
143
+ }
144
+ handle.snapshot = () => {
145
+ const rec = resolve()
146
+ if (rec.snap && rec.snap.version === rec.version.n) {
147
+ return rec.snap.value as Snapshot<S>
148
+ }
149
+ const out: Record<string, unknown> = {}
150
+ for (const key of Object.keys(rec.instance as object)) {
151
+ const v = (rec.instance as Record<string, unknown>)[key]
152
+ if (isSignal(v) || isComputed(v)) out[key] = (v as () => unknown)()
153
+ }
154
+ rec.snap = { value: out, version: rec.version.n }
155
+ return out as Snapshot<S>
156
+ }
157
+ handle.serialize = () => {
158
+ const rec = resolve()
159
+ const out: Record<string, unknown> = {}
160
+ for (const key of Object.keys(rec.instance as object)) {
161
+ const v = (rec.instance as Record<string, unknown>)[key]
162
+ if (isSignal(v)) out[key] = (v as () => unknown)()
163
+ }
164
+ return out
165
+ }
166
+ handle.hydrate = (state) => {
167
+ hydrateRecord(resolve(), state)
168
+ }
169
+
170
+ return new Proxy(handle, {
171
+ get(target, prop, recv) {
172
+ if (typeof prop === 'symbol' || RESERVED.has(prop)) {
173
+ return Reflect.get(target, prop, recv)
174
+ }
175
+ const rec = resolve()
176
+ return (rec.instance as Record<string | symbol, unknown>)[prop]
177
+ },
178
+ }) as StoreHandle<S> & S
179
+ }
@@ -0,0 +1,8 @@
1
+ // runtime/store/index.ts — brustjs/store. Isomorphic, framework-free, dom-free.
2
+ // No UI-framework adapter is re-exported here (the view-layer binding lives
3
+ // separately and is reachable only from the brustjs main entry).
4
+ export { signal, computed, effect, batch, isSignal, isComputed } from './signal.ts'
5
+ export type { Signal, Computed } from './signal.ts'
6
+ export { defineStore } from './define-store.ts'
7
+ export type { StoreHandle, Snapshot } from './define-store.ts'
8
+ export { toScriptJson, parseStoreScript, storeScriptTag } from './serialize.ts'
@@ -0,0 +1,23 @@
1
+ // runtime/store/react.ts — React adapter. Exported from the brustjs MAIN entry,
2
+ // never from ./store (which must stay react-free).
3
+ import { useSyncExternalStore } from 'react'
4
+ import type { Snapshot, StoreHandle } from './define-store.ts'
5
+
6
+ export function useStore<S extends object>(store: StoreHandle<S> & S): Snapshot<S> {
7
+ // An `ssr` island can render (renderToString) on the server OUTSIDE a request
8
+ // store scope — e.g. an ssr island on a native-jinja page, whose SSR runs after
9
+ // the loader's `runInStoreContext` scope has closed. `store.snapshot()` throws
10
+ // there (the S6 out-of-scope guard). Degrade to an empty snapshot for the
11
+ // server render so the island SSRs its factory-default markup; the client
12
+ // resolves the real window singleton and hydrates. Components that need a
13
+ // specific initial on first paint should drive it from props until mounted
14
+ // (Spec A does not server-seed native client state — that is Spec B).
15
+ const serverSnapshot = (): Snapshot<S> => {
16
+ try {
17
+ return store.snapshot()
18
+ } catch {
19
+ return {} as Snapshot<S>
20
+ }
21
+ }
22
+ return useSyncExternalStore(store.subscribe, store.snapshot, serverSnapshot)
23
+ }
@@ -0,0 +1,32 @@
1
+ // JSON for embedding in a <script> TEXT node (not an attribute). brust runs
2
+ // AutoEscape::None and a request-derived value can reach a serialized signal, so
3
+ // escape against </script> / <!-- breakout. See memory brust-jinja-autoescape-none.
4
+
5
+ const ESC: Record<string, string> = {
6
+ '<': '\\u003c',
7
+ '>': '\\u003e',
8
+ '&': '\\u0026',
9
+ '\u2028': '\\u2028',
10
+ '\u2029': '\\u2029',
11
+ }
12
+
13
+ export function toScriptJson(value: unknown): string {
14
+ return JSON.stringify(value).replace(/[<>&\u2028\u2029]/g, (c) => ESC[c])
15
+ }
16
+
17
+ export function storeScriptTag(name: string, state: unknown): string {
18
+ // name comes from defineStore (developer literal), not request data; still
19
+ // guard the attribute against quote breakout by rejecting unexpected chars.
20
+ const safeName = String(name).replace(/[^a-zA-Z0-9_.:-]/g, '')
21
+ return `<script type="application/json" data-brust-store="${safeName}">${toScriptJson(state)}</script>`
22
+ }
23
+
24
+ export function parseStoreScript(el: { textContent: string | null }): Record<string, unknown> {
25
+ const text = el.textContent ?? '{}'
26
+ try {
27
+ return JSON.parse(text) as Record<string, unknown>
28
+ } catch (e) {
29
+ console.warn('[brust] store: invalid snapshot JSON', e)
30
+ return {}
31
+ }
32
+ }
@@ -0,0 +1,43 @@
1
+ // This module is the ONLY one in runtime/store that imports a Node builtin
2
+ // (node:async_hooks). define-store.ts must NOT import it statically — it is
3
+ // reachable from browser/island bundles (brustjs/store, brustjs/client). Instead
4
+ // we register the per-request resolver into define-store via __setServerResolver
5
+ // at module load; the server pulls this module in via routes.ts (runInStoreContext
6
+ // / collectSnapshot), so the resolver is installed before any request runs.
7
+ import { AsyncLocalStorage } from 'node:async_hooks'
8
+ import { __setServerResolver, type StoreInstanceRecord } from './define-store.ts'
9
+
10
+ const storeContext = new AsyncLocalStorage<Map<string, StoreInstanceRecord>>()
11
+
12
+ export function runInStoreContext<T>(fn: () => T): T {
13
+ return storeContext.run(new Map(), fn)
14
+ }
15
+
16
+ // Resolve (create-once) the per-request instance for `name` in the active scope.
17
+ // Exported for direct unit testing; production code reaches it via the resolver
18
+ // registered into define-store.ts below.
19
+ export function getServerInstance(
20
+ name: string,
21
+ create: () => StoreInstanceRecord,
22
+ ): StoreInstanceRecord {
23
+ const map = storeContext.getStore()
24
+ if (!map) {
25
+ throw new Error(`store '${name}' accessed outside a request scope`)
26
+ }
27
+ let rec = map.get(name)
28
+ if (!rec) {
29
+ rec = create()
30
+ map.set(name, rec)
31
+ }
32
+ return rec
33
+ }
34
+
35
+ export function collectSnapshot(): Record<string, Record<string, unknown>> | null {
36
+ const map = storeContext.getStore()
37
+ if (!map || map.size === 0) return null
38
+ const out: Record<string, Record<string, unknown>> = {}
39
+ for (const [name, rec] of map) out[name] = rec.handle ? rec.handle.serialize() : {}
40
+ return out
41
+ }
42
+
43
+ __setServerResolver(getServerInstance)
@@ -0,0 +1,170 @@
1
+ // Minimal pull-based reactive core: push-on-write, pull-on-read, synchronous notify.
2
+ // Framework-agnostic — no react, no dom. Foundation for defineStore (Spec A) and
3
+ // the Alpine-style client runtime (Spec B).
4
+
5
+ // Symbol.for (GLOBAL registry), NOT Symbol(): every island is a SEPARATE Bun.build
6
+ // chunk that inlines its own copy of this module, so a plain `Symbol()` brand would
7
+ // be a DIFFERENT value per chunk — `isSignal` from chunk B then fails to recognize a
8
+ // signal created in chunk A. That poisons the shared store snapshot (a cross-chunk
9
+ // reader computes `{}` and caches it), so e.g. the team dock reads empty after a SPA
10
+ // nav loads a new island chunk. A global registry symbol is identical across chunks.
11
+ const SIGNAL = Symbol.for('brust.signal')
12
+ const COMPUTED = Symbol.for('brust.computed')
13
+
14
+ export interface Signal<T> {
15
+ (): T
16
+ set(next: T | ((prev: T) => T)): void
17
+ readonly [SIGNAL]: true
18
+ }
19
+ export interface Computed<T> {
20
+ (): T
21
+ readonly [COMPUTED]: true
22
+ }
23
+
24
+ export function isSignal(v: unknown): v is Signal<unknown> {
25
+ return typeof v === 'function' && (v as { [SIGNAL]?: true })[SIGNAL] === true
26
+ }
27
+ export function isComputed(v: unknown): v is Computed<unknown> {
28
+ return typeof v === 'function' && (v as { [COMPUTED]?: true })[COMPUTED] === true
29
+ }
30
+
31
+ // A reactive consumer (effect or computed) tracking its dependencies.
32
+ interface Consumer {
33
+ run(): void
34
+ deps: Set<Set<Consumer>>
35
+ running: boolean
36
+ }
37
+
38
+ // The dependency-tracking state (the "currently running consumer", the batch
39
+ // depth, the pending-notify queue) MUST be shared across chunks, for the SAME
40
+ // reason the brands use Symbol.for: every island / the directive runtime is a
41
+ // SEPARATE Bun.build that inlines its own copy of THIS module. If `activeConsumer`
42
+ // were a module-local `let`, each chunk would have its own — so an `effect` in
43
+ // chunk B reading a `signal` created in chunk A would register against chunk A's
44
+ // (always-null) activeConsumer and never subscribe. Concretely: a native directive
45
+ // button's effect (its own chunk) would never re-run when a React island (another
46
+ // chunk) mutated the shared store, even though the store value changed. Holding the
47
+ // context on `globalThis` under a Symbol.for key makes all chunks share ONE tracker.
48
+ interface ReactiveCtx {
49
+ activeConsumer: Consumer | null
50
+ batchDepth: number
51
+ pendingNotify: Set<Consumer>
52
+ }
53
+ const CTX_KEY = Symbol.for('brust.reactive.ctx')
54
+ const ctxHolder = globalThis as { [CTX_KEY]?: ReactiveCtx }
55
+ if (!ctxHolder[CTX_KEY]) {
56
+ ctxHolder[CTX_KEY] = { activeConsumer: null, batchDepth: 0, pendingNotify: new Set<Consumer>() }
57
+ }
58
+ const ctx: ReactiveCtx = ctxHolder[CTX_KEY]
59
+
60
+ function track(subscribers: Set<Consumer>): void {
61
+ if (ctx.activeConsumer) {
62
+ subscribers.add(ctx.activeConsumer)
63
+ ctx.activeConsumer.deps.add(subscribers)
64
+ }
65
+ }
66
+
67
+ function notify(subscribers: Set<Consumer>): void {
68
+ // Snapshot — a consumer re-running mutates the set.
69
+ for (const c of [...subscribers]) {
70
+ if (ctx.batchDepth > 0) ctx.pendingNotify.add(c)
71
+ else c.run()
72
+ }
73
+ }
74
+
75
+ function flush(): void {
76
+ const queued = [...ctx.pendingNotify]
77
+ ctx.pendingNotify.clear()
78
+ for (const c of queued) c.run()
79
+ }
80
+
81
+ export function batch(fn: () => void): void {
82
+ ctx.batchDepth++
83
+ try {
84
+ fn()
85
+ } finally {
86
+ ctx.batchDepth--
87
+ if (ctx.batchDepth === 0) flush()
88
+ }
89
+ }
90
+
91
+ export function signal<T>(initial: T): Signal<T> {
92
+ let value = initial
93
+ const subscribers = new Set<Consumer>()
94
+ const read = (() => {
95
+ track(subscribers)
96
+ return value
97
+ }) as Signal<T>
98
+ read.set = (next: T | ((prev: T) => T)) => {
99
+ const v = typeof next === 'function' ? (next as (p: T) => T)(value) : next
100
+ if (Object.is(v, value)) return
101
+ value = v
102
+ notify(subscribers)
103
+ }
104
+ Object.defineProperty(read, SIGNAL, { value: true })
105
+ return read
106
+ }
107
+
108
+ function clearDeps(c: Consumer): void {
109
+ for (const dep of c.deps) dep.delete(c)
110
+ c.deps.clear()
111
+ }
112
+
113
+ export function computed<T>(fn: () => T): Computed<T> {
114
+ let cached: T
115
+ let dirty = true
116
+ const subscribers = new Set<Consumer>()
117
+ const self: Consumer = {
118
+ deps: new Set(),
119
+ running: false,
120
+ run() {
121
+ if (self.running) return
122
+ self.running = true
123
+ try {
124
+ dirty = true
125
+ notify(subscribers) // downstream recomputes lazily on next read
126
+ } finally {
127
+ self.running = false
128
+ }
129
+ },
130
+ }
131
+ const read = (() => {
132
+ track(subscribers)
133
+ if (dirty) {
134
+ clearDeps(self)
135
+ const prev = ctx.activeConsumer
136
+ ctx.activeConsumer = self
137
+ try {
138
+ cached = fn()
139
+ dirty = false
140
+ } finally {
141
+ ctx.activeConsumer = prev
142
+ }
143
+ }
144
+ return cached
145
+ }) as Computed<T>
146
+ Object.defineProperty(read, COMPUTED, { value: true })
147
+ return read
148
+ }
149
+
150
+ export function effect(fn: () => void): () => void {
151
+ const self: Consumer = {
152
+ deps: new Set(),
153
+ running: false,
154
+ run() {
155
+ if (self.running) return
156
+ self.running = true
157
+ clearDeps(self)
158
+ const prev = ctx.activeConsumer
159
+ ctx.activeConsumer = self
160
+ try {
161
+ fn()
162
+ } finally {
163
+ ctx.activeConsumer = prev
164
+ self.running = false
165
+ }
166
+ },
167
+ }
168
+ self.run()
169
+ return () => clearDeps(self)
170
+ }
@@ -1,25 +0,0 @@
1
- // Cross-island sync bus.
2
- //
3
- // GAP S4: brust has no cross-island shared-state primitive. AddToTeamButton and
4
- // TeamBuilder are two SEPARATE island chunks (each its own Bun.build bundle), so
5
- // a module-scope store imported by both would be DUPLICATED — two instances that
6
- // never see each other. The one thing both chunks genuinely share is the
7
- // `window` object, so we coordinate through a window CustomEvent. This works,
8
- // but it's a hand-rolled workaround for a pattern (cart / team / selection) that
9
- // most apps need. See ../FRAMEWORK-GAPS.md S4.
10
-
11
- import type { TeamMember } from '../lib/types'
12
-
13
- export const TEAM_EVENT = 'brust-pokedex:team'
14
-
15
- export function emitTeam(team: TeamMember[]): void {
16
- if (typeof window === 'undefined') return
17
- window.dispatchEvent(new CustomEvent<TeamMember[]>(TEAM_EVENT, { detail: team }))
18
- }
19
-
20
- export function onTeam(fn: (team: TeamMember[]) => void): () => void {
21
- if (typeof window === 'undefined') return () => {}
22
- const handler = (e: Event) => fn((e as CustomEvent<TeamMember[]>).detail)
23
- window.addEventListener(TEAM_EVENT, handler)
24
- return () => window.removeEventListener(TEAM_EVENT, handler)
25
- }