brustjs 0.1.19-alpha → 0.1.21-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.
- package/example/pokedex/components/AddToTeamButton.tsx +11 -8
- package/example/pokedex/components/PageLayout.tsx +6 -1
- package/example/pokedex/components/TeamBuilder.tsx +20 -10
- package/example/pokedex/stores/team.ts +14 -0
- package/package.json +9 -8
- package/runtime/cli/templates/minimal/pages/Home.tsx.tmpl +7 -1
- package/runtime/client/index.ts +6 -0
- package/runtime/index.js +52 -52
- package/runtime/index.ts +9 -1
- package/runtime/islands/bootstrap.ts +7 -1
- package/runtime/islands/brust-page.tsx +44 -0
- package/runtime/render/inject-store.ts +63 -0
- package/runtime/render/stream.ts +14 -2
- package/runtime/routes.ts +66 -38
- package/runtime/store/client-hydrate.ts +16 -0
- package/runtime/store/define-store.ts +179 -0
- package/runtime/store/index.ts +8 -0
- package/runtime/store/react.ts +23 -0
- package/runtime/store/serialize.ts +32 -0
- package/runtime/store/server-context.ts +43 -0
- package/runtime/store/signal.ts +152 -0
- package/example/pokedex/components/team-bus.ts +0 -25
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { storeScriptTag } from '../store/serialize.ts'
|
|
2
|
+
|
|
3
|
+
const ENC = new TextEncoder()
|
|
4
|
+
let warned = false
|
|
5
|
+
|
|
6
|
+
/** @internal — used by tests to reset the warn-once flag. */
|
|
7
|
+
export function _resetWarnedForTests(): void {
|
|
8
|
+
warned = false
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Build the combined `<script type="application/json">` blob for every touched
|
|
12
|
+
* store. Returns '' when the snapshot is null/empty. */
|
|
13
|
+
export function buildStoreScripts(snap: Record<string, Record<string, unknown>> | null): string {
|
|
14
|
+
if (!snap) return ''
|
|
15
|
+
let out = ''
|
|
16
|
+
for (const [name, state] of Object.entries(snap)) out += storeScriptTag(name, state)
|
|
17
|
+
return out
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Splice the store snapshot `<script>`(s) into `body` immediately before the
|
|
21
|
+
* first `</head>`. Returns the original body untouched if the snapshot is
|
|
22
|
+
* null/empty or if `</head>` is absent. */
|
|
23
|
+
export function injectBrustStore(
|
|
24
|
+
body: Uint8Array,
|
|
25
|
+
snap: Record<string, Record<string, unknown>> | null,
|
|
26
|
+
): Uint8Array {
|
|
27
|
+
const scripts = buildStoreScripts(snap)
|
|
28
|
+
if (!scripts) return body
|
|
29
|
+
const pos = findHeadCloseTag(body)
|
|
30
|
+
if (pos < 0) {
|
|
31
|
+
if (!warned) {
|
|
32
|
+
console.warn('[brust] store: no </head> in first chunk; snapshot not injected')
|
|
33
|
+
warned = true
|
|
34
|
+
}
|
|
35
|
+
return body
|
|
36
|
+
}
|
|
37
|
+
const tagBytes = ENC.encode(scripts)
|
|
38
|
+
const out = new Uint8Array(body.length + tagBytes.length)
|
|
39
|
+
out.set(body.subarray(0, pos), 0)
|
|
40
|
+
out.set(tagBytes, pos)
|
|
41
|
+
out.set(body.subarray(pos), pos + tagBytes.length)
|
|
42
|
+
return out
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function findHeadCloseTag(body: Uint8Array): number {
|
|
46
|
+
const LT = 0x3c,
|
|
47
|
+
SL = 0x2f,
|
|
48
|
+
GT = 0x3e
|
|
49
|
+
for (let i = 0, max = body.length - 6; i < max; i++) {
|
|
50
|
+
if (body[i] !== LT || body[i + 1] !== SL) continue
|
|
51
|
+
if (!isLetter(body[i + 2], 0x48)) continue // H
|
|
52
|
+
if (!isLetter(body[i + 3], 0x45)) continue // E
|
|
53
|
+
if (!isLetter(body[i + 4], 0x41)) continue // A
|
|
54
|
+
if (!isLetter(body[i + 5], 0x44)) continue // D
|
|
55
|
+
if (body[i + 6] !== GT) continue
|
|
56
|
+
return i
|
|
57
|
+
}
|
|
58
|
+
return -1
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isLetter(b: number, u: number): boolean {
|
|
62
|
+
return b === u || b === (u | 0x20)
|
|
63
|
+
}
|
package/runtime/render/stream.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { injectCssLink } from './inject-css-link.ts'
|
|
|
10
10
|
import { getCssHrefs, getCssHrefsForRoute } from '../css.ts'
|
|
11
11
|
import { injectDevClient } from './inject-dev-client.ts'
|
|
12
12
|
import { injectActionPrefix, getActionPrefixSnippet } from './inject-action-prefix.ts'
|
|
13
|
+
import { injectBrustStore, buildStoreScripts } from './inject-store.ts'
|
|
13
14
|
import { getDevClientSnippet } from '../dev/inject.ts'
|
|
14
15
|
|
|
15
16
|
export interface RenderBranchStreamingArgs {
|
|
@@ -31,6 +32,10 @@ export interface RenderBranchStreamingArgs {
|
|
|
31
32
|
/** The matched route's fullPath (e.g. '/' or '/blog/{slug}'). Used to
|
|
32
33
|
* combine global CSS hrefs with per-route CSS hrefs before injection. */
|
|
33
34
|
routePath?: string
|
|
35
|
+
/** Per-request store snapshot collected after loaders run. Injected as a
|
|
36
|
+
* `<script data-brust-store="…">` blob before `</head>` (buffering) or into
|
|
37
|
+
* the streaming first-chunk prepend. Null/undefined → no injection. */
|
|
38
|
+
storeSnapshot?: Record<string, Record<string, unknown>> | null
|
|
34
39
|
}
|
|
35
40
|
|
|
36
41
|
const encoder = new TextEncoder()
|
|
@@ -150,6 +155,7 @@ export function renderBranchStreaming(args: RenderBranchStreamingArgs): Promise<
|
|
|
150
155
|
body = injectCssLink(body, [...getCssHrefs(), ...perRouteHrefs])
|
|
151
156
|
body = injectDevClient(body, getDevClientSnippet())
|
|
152
157
|
body = injectActionPrefix(body, getActionPrefixSnippet())
|
|
158
|
+
body = injectBrustStore(body, args.storeSnapshot ?? null)
|
|
153
159
|
const meta = makeMeta({
|
|
154
160
|
status: successStatus,
|
|
155
161
|
streaming: false,
|
|
@@ -211,8 +217,14 @@ export function renderBranchStreaming(args: RenderBranchStreamingArgs): Promise<
|
|
|
211
217
|
.join('')
|
|
212
218
|
const devTag = getDevClientSnippet() ?? ''
|
|
213
219
|
const prefixTag = getActionPrefixSnippet() ?? ''
|
|
214
|
-
|
|
215
|
-
|
|
220
|
+
const storeTag = buildStoreScripts(args.storeSnapshot ?? null)
|
|
221
|
+
if (
|
|
222
|
+
linkTagsStr.length > 0 ||
|
|
223
|
+
devTag.length > 0 ||
|
|
224
|
+
prefixTag.length > 0 ||
|
|
225
|
+
storeTag.length > 0
|
|
226
|
+
) {
|
|
227
|
+
const prepend = encoder.encode(linkTagsStr + prefixTag + devTag + storeTag)
|
|
216
228
|
const out = new Uint8Array(flushed.length + prepend.length)
|
|
217
229
|
out.set(flushed, 0)
|
|
218
230
|
out.set(prepend, flushed.length)
|
package/runtime/routes.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { Writable } from 'node:stream'
|
|
|
10
10
|
import { Buffer } from 'node:buffer'
|
|
11
11
|
import * as native from './index.js'
|
|
12
12
|
import { renderBranchStreaming } from './render/stream.ts'
|
|
13
|
+
import { runInStoreContext, collectSnapshot } from './store/server-context.ts'
|
|
13
14
|
import {
|
|
14
15
|
loadIslandManifest,
|
|
15
16
|
resolveIslandContext,
|
|
@@ -636,7 +637,11 @@ export function makeRenderer(
|
|
|
636
637
|
if (leaf.loader) {
|
|
637
638
|
const ctx = { params: call.params, path: call.path, req: call.req }
|
|
638
639
|
try {
|
|
639
|
-
|
|
640
|
+
// Native loaders may write to a defineStore; run them in a per-request
|
|
641
|
+
// store scope so those writes are isolated per request. No snapshot is
|
|
642
|
+
// collected and no <script> is injected on native paths — Spec B owns
|
|
643
|
+
// native store delivery (hard non-goal here).
|
|
644
|
+
data = await runInStoreContext(() => leaf.loader!(ctx as any))
|
|
640
645
|
} catch (err) {
|
|
641
646
|
console.error(`[brust] loader failed for native route ${flat.fullPath}:`, err)
|
|
642
647
|
// FAST LANE: native routes take dispatch_single_chunk (no chunk
|
|
@@ -745,36 +750,45 @@ export function makeRenderer(
|
|
|
745
750
|
}
|
|
746
751
|
}
|
|
747
752
|
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
753
|
+
// Wrap the loader run (inside buildRenderElement) AND the React render in
|
|
754
|
+
// one AsyncLocalStorage store scope so any defineStore read during loaders
|
|
755
|
+
// or render resolves the same per-request instance. Snapshot is collected
|
|
756
|
+
// after loaders (buildRenderElement resolved) — that's where Spec A stores
|
|
757
|
+
// are seeded — and threaded into the render for <script> injection.
|
|
758
|
+
return await runInStoreContext(async () => {
|
|
759
|
+
let element: ReactNode
|
|
760
|
+
let errorBoundary: ComponentType<{ error: Error }>
|
|
761
|
+
try {
|
|
762
|
+
element = await buildRenderElement(call, flat, opts.getWorkerId)
|
|
763
|
+
errorBoundary =
|
|
764
|
+
flat.errorBoundary ??
|
|
765
|
+
(({ error }) => createElement('div', null, `Internal Server Error: ${error.message}`))
|
|
766
|
+
} catch (err) {
|
|
767
|
+
// Setup failure BEFORE renderToPipeableStream — loader throw, params
|
|
768
|
+
// bind throw. Shape matches the legacy "internal error" path so
|
|
769
|
+
// existing integration tests stay green.
|
|
770
|
+
console.error(`[brust] render setup failed:`, err)
|
|
771
|
+
return await emitSingleChunkResponse(view, napi, workerId, encoder, {
|
|
772
|
+
status: 500,
|
|
773
|
+
contentType: 'text/html; charset=utf-8',
|
|
774
|
+
body: 'internal error',
|
|
775
|
+
})
|
|
776
|
+
}
|
|
777
|
+
const storeSnapshot = collectSnapshot()
|
|
778
|
+
await renderBranchStreaming({
|
|
779
|
+
element,
|
|
780
|
+
view,
|
|
781
|
+
workerId,
|
|
782
|
+
napi,
|
|
783
|
+
errorBoundary,
|
|
784
|
+
status: verdict.status,
|
|
785
|
+
headers: verdict.headers,
|
|
786
|
+
routePath: flat.fullPath,
|
|
787
|
+
storeSnapshot,
|
|
764
788
|
})
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
element,
|
|
768
|
-
view,
|
|
769
|
-
workerId,
|
|
770
|
-
napi,
|
|
771
|
-
errorBoundary,
|
|
772
|
-
status: verdict.status,
|
|
773
|
-
headers: verdict.headers,
|
|
774
|
-
routePath: flat.fullPath,
|
|
789
|
+
// renderBranchStreaming wrote via the chunk channel.
|
|
790
|
+
return 0
|
|
775
791
|
})
|
|
776
|
-
// renderBranchStreaming wrote via the chunk channel.
|
|
777
|
-
return 0
|
|
778
792
|
}
|
|
779
793
|
if (call.kind === 'navigation') {
|
|
780
794
|
await navigationBranch(call, byRouteId, view, encoder, opts.getWorkerId)
|
|
@@ -942,16 +956,26 @@ async function navigationBranch(
|
|
|
942
956
|
// whose loader fields arrive undefined → throws → 500 → full-reload fallback
|
|
943
957
|
// on every internal link.
|
|
944
958
|
let fullHtml: string
|
|
959
|
+
// React nav: snapshot of stores seeded during loader+render, shipped in the
|
|
960
|
+
// nav payload so the client can apply it to its live stores. Native nav
|
|
961
|
+
// collects no snapshot (Spec B owns native store delivery).
|
|
962
|
+
let store: Record<string, Record<string, unknown>> | null = null
|
|
945
963
|
if (flat.nativeTemplate !== undefined) {
|
|
946
964
|
fullHtml = await renderNativeRouteToHtml(call, flat, view, encoder, workerId)
|
|
947
965
|
} else {
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
966
|
+
// Wrap loader run (inside buildRenderElement) + render in one store scope so
|
|
967
|
+
// store reads resolve the per-request instance; collect after render.
|
|
968
|
+
fullHtml = await runInStoreContext(async () => {
|
|
969
|
+
const element = await buildRenderElement(call as any, flat, getWorkerId)
|
|
970
|
+
if (!element) throw new Error('render setup failed')
|
|
971
|
+
// Use renderToPipeableStream + onAllReady so pages with <Suspense> emit
|
|
972
|
+
// their RESOLVED markup, not the fallback. renderToString would only
|
|
973
|
+
// capture the shell — navigating SPA-style to a Suspense-using route
|
|
974
|
+
// would otherwise ship "loading…" and never recover.
|
|
975
|
+
const html = await renderToAwaitedString(element)
|
|
976
|
+
store = collectSnapshot()
|
|
977
|
+
return html
|
|
978
|
+
})
|
|
955
979
|
}
|
|
956
980
|
|
|
957
981
|
// Extract <main> inner content. If the page didn't render a <main>,
|
|
@@ -971,7 +995,7 @@ async function navigationBranch(
|
|
|
971
995
|
const titleMatch = fullHtml.match(/<title[^>]*>([\s\S]*?)<\/title>/i)
|
|
972
996
|
const title = titleMatch ? titleMatch[1].replace(/<!--.*?-->/g, '').trim() : ''
|
|
973
997
|
|
|
974
|
-
const body = JSON.stringify({ html: innerHtml, title })
|
|
998
|
+
const body = JSON.stringify({ html: innerHtml, title, store })
|
|
975
999
|
await emitSingleChunkResponse(view, napi, workerId, encoder, {
|
|
976
1000
|
status: 200,
|
|
977
1001
|
contentType: 'application/json; charset=utf-8',
|
|
@@ -1010,7 +1034,11 @@ async function renderNativeRouteToHtml(
|
|
|
1010
1034
|
|
|
1011
1035
|
let data: unknown = {}
|
|
1012
1036
|
if (leaf.loader) {
|
|
1013
|
-
|
|
1037
|
+
// Per-request store scope for native loader writes (isolation only). No
|
|
1038
|
+
// snapshot collected / no <script> injected — Spec B owns native delivery.
|
|
1039
|
+
data = await runInStoreContext(() =>
|
|
1040
|
+
leaf.loader!({ params: call.params, path: call.path, req: call.req } as any),
|
|
1041
|
+
)
|
|
1014
1042
|
}
|
|
1015
1043
|
|
|
1016
1044
|
if (isNativeVerdict(data)) {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// runtime/store/client-hydrate.ts — apply a nav-payload snapshot to live client stores.
|
|
2
|
+
export function applyStoreSnapshot(snap: Record<string, Record<string, unknown>>): void {
|
|
3
|
+
const w = window as unknown as {
|
|
4
|
+
__BRUST_STORES__?: Record<string, { instance: Record<string, unknown> }>
|
|
5
|
+
}
|
|
6
|
+
const reg = w.__BRUST_STORES__
|
|
7
|
+
if (!reg) return
|
|
8
|
+
for (const [name, state] of Object.entries(snap)) {
|
|
9
|
+
const entry = reg[name]
|
|
10
|
+
if (!entry) continue // handle not defined yet on client → skip (initial-load <script> covers it)
|
|
11
|
+
for (const key of Object.keys(state)) {
|
|
12
|
+
const v = entry.instance[key] as { set?: (x: unknown) => void } | undefined
|
|
13
|
+
if (v && typeof v.set === 'function') v.set(state[key])
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -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)
|