brustjs 0.1.20-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/TeamBuilder.tsx +20 -10
- package/example/pokedex/stores/team.ts +14 -0
- package/package.json +9 -8
- package/runtime/client/index.ts +6 -0
- package/runtime/index.js +52 -52
- package/runtime/index.ts +8 -0
- package/runtime/islands/bootstrap.ts +7 -1
- 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
|
@@ -4,23 +4,26 @@
|
|
|
4
4
|
// so unlike the native page shells they have NO jinja constraints: hooks, inline
|
|
5
5
|
// `style={{…}}`, event handlers all work normally.
|
|
6
6
|
import { useEffect, useState } from 'react'
|
|
7
|
-
import { client } from 'brustjs/client'
|
|
7
|
+
import { client, useStore } from 'brustjs/client'
|
|
8
8
|
import type { Actions } from '../actions'
|
|
9
|
-
import type { AddToTeamProps
|
|
10
|
-
import {
|
|
9
|
+
import type { AddToTeamProps } from '../lib/types'
|
|
10
|
+
import { teamStore } from '../stores/team'
|
|
11
11
|
|
|
12
12
|
const api = client<Actions>()
|
|
13
13
|
|
|
14
14
|
export default function AddToTeamButton(p: AddToTeamProps) {
|
|
15
|
-
|
|
15
|
+
// Shared store (GAP S4): writing teamStore.members here is observed by the
|
|
16
|
+
// TeamBuilder island — they resolve the same window singleton. This is a
|
|
17
|
+
// client-only island (no `ssr`), so the store read always hits the client branch.
|
|
18
|
+
const { members } = useStore(teamStore)
|
|
19
|
+
const team = members ?? []
|
|
16
20
|
const [busy, setBusy] = useState(false)
|
|
17
21
|
const [toast, setToast] = useState<string | null>(null)
|
|
18
22
|
|
|
19
23
|
useEffect(() => {
|
|
20
24
|
api.team.get().then((r) => {
|
|
21
|
-
if (r.data)
|
|
25
|
+
if (r.data) teamStore.members.set(r.data.team)
|
|
22
26
|
})
|
|
23
|
-
return onTeam(setTeam)
|
|
24
27
|
}, [])
|
|
25
28
|
|
|
26
29
|
const inTeam = team.some((m) => m.id === p.id)
|
|
@@ -33,7 +36,7 @@ export default function AddToTeamButton(p: AddToTeamProps) {
|
|
|
33
36
|
// DELETE from the browser sends no Content-Length, and brust's action
|
|
34
37
|
// dispatch returns 411 on non-GET/HEAD without one. See GAPS S12.
|
|
35
38
|
const { data } = await api.team({ id: p.id }).delete({})
|
|
36
|
-
if (data)
|
|
39
|
+
if (data) teamStore.members.set(data.team)
|
|
37
40
|
} else {
|
|
38
41
|
const { data } = await api.team.post({
|
|
39
42
|
id: p.id,
|
|
@@ -47,7 +50,7 @@ export default function AddToTeamButton(p: AddToTeamProps) {
|
|
|
47
50
|
setToast('ทีมเต็มแล้ว · สูงสุด 6 ตัว')
|
|
48
51
|
setTimeout(() => setToast(null), 2200)
|
|
49
52
|
} else if (data) {
|
|
50
|
-
|
|
53
|
+
teamStore.members.set(data.team)
|
|
51
54
|
}
|
|
52
55
|
}
|
|
53
56
|
} finally {
|
|
@@ -2,33 +2,43 @@
|
|
|
2
2
|
//
|
|
3
3
|
// Rendered with `ssr` so the initial team (from the loader) ships in the HTML
|
|
4
4
|
// and the dock count is correct on first paint, then hydrates. Stays in sync
|
|
5
|
-
// with AddToTeamButton through the
|
|
5
|
+
// with AddToTeamButton through the shared `teamStore` (one window singleton — see
|
|
6
|
+
// ../stores/team.ts / GAPS S4; replaced the old window-event bus).
|
|
6
7
|
import { useEffect, useState } from 'react'
|
|
7
|
-
import { client } from 'brustjs/client'
|
|
8
|
+
import { client, useStore } from 'brustjs/client'
|
|
8
9
|
import type { Actions } from '../actions'
|
|
9
10
|
import type { TeamMember } from '../lib/types'
|
|
10
|
-
import {
|
|
11
|
+
import { teamStore } from '../stores/team'
|
|
11
12
|
|
|
12
13
|
const api = client<Actions>()
|
|
13
14
|
const MAX = 6
|
|
14
15
|
|
|
15
16
|
export default function TeamBuilder({ teamInitial }: { teamInitial: TeamMember[] }) {
|
|
16
|
-
|
|
17
|
+
// Shared store (GAP S4): in sync with AddToTeamButton via one window singleton.
|
|
18
|
+
const { members } = useStore(teamStore)
|
|
19
|
+
const [mounted, setMounted] = useState(false)
|
|
17
20
|
const [open, setOpen] = useState(false)
|
|
18
21
|
|
|
22
|
+
// This island is `ssr`, but Spec A does not server-seed native client state, so
|
|
23
|
+
// the store is empty during SSR. Drive the first render (server + client) from
|
|
24
|
+
// the `teamInitial` prop so the dock count is correct on first paint AND the
|
|
25
|
+
// hydration markup matches; switch to the live store once mounted. (Full
|
|
26
|
+
// server-seeded native snapshot is Spec B.)
|
|
27
|
+
const team = mounted ? (members ?? []) : (teamInitial ?? [])
|
|
28
|
+
|
|
19
29
|
useEffect(() => {
|
|
20
|
-
|
|
21
|
-
|
|
30
|
+
if (teamInitial?.length) teamStore.members.set(teamInitial)
|
|
31
|
+
setMounted(true)
|
|
32
|
+
// Re-sync from the server on mount (the SSR snapshot may be stale by now).
|
|
22
33
|
api.team.get().then((r) => {
|
|
23
|
-
if (r.data)
|
|
34
|
+
if (r.data) teamStore.members.set(r.data.team)
|
|
24
35
|
})
|
|
25
|
-
|
|
26
|
-
}, [])
|
|
36
|
+
}, [teamInitial])
|
|
27
37
|
|
|
28
38
|
async function remove(id: number) {
|
|
29
39
|
// `.delete({})` — empty body is required (bodyless DELETE → 411). See GAPS S12.
|
|
30
40
|
const { data } = await api.team({ id }).delete({})
|
|
31
|
-
if (data)
|
|
41
|
+
if (data) teamStore.members.set(data.team)
|
|
32
42
|
}
|
|
33
43
|
|
|
34
44
|
const coverage = [...new Set(team.flatMap((m) => m.types))]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// Shared team store — GAP S4 dogfood.
|
|
2
|
+
//
|
|
3
|
+
// AddToTeamButton and TeamBuilder are two SEPARATE island bundles. A plain
|
|
4
|
+
// module-scope store imported by both would be duplicated into two instances
|
|
5
|
+
// that never sync. `defineStore` resolves both bundles to ONE instance on
|
|
6
|
+
// `window.__BRUST_STORES__['pokedex.team']`, so a write in one island is seen by
|
|
7
|
+
// the other — no hand-rolled CustomEvent bus. (Replaces the old components/team-bus.ts.)
|
|
8
|
+
import { defineStore, signal } from 'brustjs/store'
|
|
9
|
+
import type { TeamMember } from '../lib/types'
|
|
10
|
+
|
|
11
|
+
export const teamStore = defineStore('pokedex.team', () => {
|
|
12
|
+
const members = signal<TeamMember[]>([])
|
|
13
|
+
return { members }
|
|
14
|
+
})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brustjs",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.21-alpha",
|
|
4
4
|
"description": "Bun + Rust SSR framework — React on the server, Rust everywhere else (napi cdylib + per-worker SharedArrayBuffer).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -40,12 +40,12 @@
|
|
|
40
40
|
"typescript": "^6.0.3"
|
|
41
41
|
},
|
|
42
42
|
"optionalDependencies": {
|
|
43
|
-
"brustjs-darwin-x64": "0.1.
|
|
44
|
-
"brustjs-darwin-arm64": "0.1.
|
|
45
|
-
"brustjs-linux-x64-gnu": "0.1.
|
|
46
|
-
"brustjs-linux-arm64-gnu": "0.1.
|
|
47
|
-
"brustjs-linux-x64-musl": "0.1.
|
|
48
|
-
"brustjs-linux-arm64-musl": "0.1.
|
|
43
|
+
"brustjs-darwin-x64": "0.1.21-alpha",
|
|
44
|
+
"brustjs-darwin-arm64": "0.1.21-alpha",
|
|
45
|
+
"brustjs-linux-x64-gnu": "0.1.21-alpha",
|
|
46
|
+
"brustjs-linux-arm64-gnu": "0.1.21-alpha",
|
|
47
|
+
"brustjs-linux-x64-musl": "0.1.21-alpha",
|
|
48
|
+
"brustjs-linux-arm64-musl": "0.1.21-alpha"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
51
|
"react": "^19.2.6",
|
|
@@ -67,7 +67,8 @@
|
|
|
67
67
|
".": "./runtime/index.ts",
|
|
68
68
|
"./routes": "./runtime/routes.ts",
|
|
69
69
|
"./client": "./runtime/client/index.ts",
|
|
70
|
-
"./create": "./runtime/create.ts"
|
|
70
|
+
"./create": "./runtime/create.ts",
|
|
71
|
+
"./store": "./runtime/store/index.ts"
|
|
71
72
|
},
|
|
72
73
|
"files": [
|
|
73
74
|
"runtime",
|
package/runtime/client/index.ts
CHANGED
|
@@ -20,3 +20,9 @@ export class BrustActionError extends Error {
|
|
|
20
20
|
|
|
21
21
|
export { client } from '../treaty.ts'
|
|
22
22
|
export type { TreatyResponse, ClientOptions } from '../treaty.ts'
|
|
23
|
+
|
|
24
|
+
// Store view-layer adapter for islands. Lives here (the browser/island entry),
|
|
25
|
+
// NOT in the brustjs main entry — that pulls the native addon + server-only
|
|
26
|
+
// surface and cannot be bundled for the browser. react.ts → define-store.ts is
|
|
27
|
+
// browser-safe (no node:async_hooks; the server resolver is injected separately).
|
|
28
|
+
export { useStore } from '../store/react.ts'
|
package/runtime/index.js
CHANGED
|
@@ -77,8 +77,8 @@ function requireNative() {
|
|
|
77
77
|
try {
|
|
78
78
|
const binding = require('brustjs-android-arm64')
|
|
79
79
|
const bindingPackageVersion = require('brustjs-android-arm64/package.json').version
|
|
80
|
-
if (bindingPackageVersion !== '0.1.
|
|
81
|
-
throw new Error(`Native binding package version mismatch, expected 0.1.
|
|
80
|
+
if (bindingPackageVersion !== '0.1.21-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
|
81
|
+
throw new Error(`Native binding package version mismatch, expected 0.1.21-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
|
82
82
|
}
|
|
83
83
|
return binding
|
|
84
84
|
} catch (e) {
|
|
@@ -93,8 +93,8 @@ function requireNative() {
|
|
|
93
93
|
try {
|
|
94
94
|
const binding = require('brustjs-android-arm-eabi')
|
|
95
95
|
const bindingPackageVersion = require('brustjs-android-arm-eabi/package.json').version
|
|
96
|
-
if (bindingPackageVersion !== '0.1.
|
|
97
|
-
throw new Error(`Native binding package version mismatch, expected 0.1.
|
|
96
|
+
if (bindingPackageVersion !== '0.1.21-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
|
97
|
+
throw new Error(`Native binding package version mismatch, expected 0.1.21-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
|
98
98
|
}
|
|
99
99
|
return binding
|
|
100
100
|
} catch (e) {
|
|
@@ -114,8 +114,8 @@ function requireNative() {
|
|
|
114
114
|
try {
|
|
115
115
|
const binding = require('brustjs-win32-x64-gnu')
|
|
116
116
|
const bindingPackageVersion = require('brustjs-win32-x64-gnu/package.json').version
|
|
117
|
-
if (bindingPackageVersion !== '0.1.
|
|
118
|
-
throw new Error(`Native binding package version mismatch, expected 0.1.
|
|
117
|
+
if (bindingPackageVersion !== '0.1.21-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
|
118
|
+
throw new Error(`Native binding package version mismatch, expected 0.1.21-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
|
119
119
|
}
|
|
120
120
|
return binding
|
|
121
121
|
} catch (e) {
|
|
@@ -130,8 +130,8 @@ function requireNative() {
|
|
|
130
130
|
try {
|
|
131
131
|
const binding = require('brustjs-win32-x64-msvc')
|
|
132
132
|
const bindingPackageVersion = require('brustjs-win32-x64-msvc/package.json').version
|
|
133
|
-
if (bindingPackageVersion !== '0.1.
|
|
134
|
-
throw new Error(`Native binding package version mismatch, expected 0.1.
|
|
133
|
+
if (bindingPackageVersion !== '0.1.21-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
|
134
|
+
throw new Error(`Native binding package version mismatch, expected 0.1.21-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
|
135
135
|
}
|
|
136
136
|
return binding
|
|
137
137
|
} catch (e) {
|
|
@@ -147,8 +147,8 @@ function requireNative() {
|
|
|
147
147
|
try {
|
|
148
148
|
const binding = require('brustjs-win32-ia32-msvc')
|
|
149
149
|
const bindingPackageVersion = require('brustjs-win32-ia32-msvc/package.json').version
|
|
150
|
-
if (bindingPackageVersion !== '0.1.
|
|
151
|
-
throw new Error(`Native binding package version mismatch, expected 0.1.
|
|
150
|
+
if (bindingPackageVersion !== '0.1.21-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
|
151
|
+
throw new Error(`Native binding package version mismatch, expected 0.1.21-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
|
152
152
|
}
|
|
153
153
|
return binding
|
|
154
154
|
} catch (e) {
|
|
@@ -163,8 +163,8 @@ function requireNative() {
|
|
|
163
163
|
try {
|
|
164
164
|
const binding = require('brustjs-win32-arm64-msvc')
|
|
165
165
|
const bindingPackageVersion = require('brustjs-win32-arm64-msvc/package.json').version
|
|
166
|
-
if (bindingPackageVersion !== '0.1.
|
|
167
|
-
throw new Error(`Native binding package version mismatch, expected 0.1.
|
|
166
|
+
if (bindingPackageVersion !== '0.1.21-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
|
167
|
+
throw new Error(`Native binding package version mismatch, expected 0.1.21-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
|
168
168
|
}
|
|
169
169
|
return binding
|
|
170
170
|
} catch (e) {
|
|
@@ -182,8 +182,8 @@ function requireNative() {
|
|
|
182
182
|
try {
|
|
183
183
|
const binding = require('brustjs-darwin-universal')
|
|
184
184
|
const bindingPackageVersion = require('brustjs-darwin-universal/package.json').version
|
|
185
|
-
if (bindingPackageVersion !== '0.1.
|
|
186
|
-
throw new Error(`Native binding package version mismatch, expected 0.1.
|
|
185
|
+
if (bindingPackageVersion !== '0.1.21-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
|
186
|
+
throw new Error(`Native binding package version mismatch, expected 0.1.21-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
|
187
187
|
}
|
|
188
188
|
return binding
|
|
189
189
|
} catch (e) {
|
|
@@ -198,8 +198,8 @@ function requireNative() {
|
|
|
198
198
|
try {
|
|
199
199
|
const binding = require('brustjs-darwin-x64')
|
|
200
200
|
const bindingPackageVersion = require('brustjs-darwin-x64/package.json').version
|
|
201
|
-
if (bindingPackageVersion !== '0.1.
|
|
202
|
-
throw new Error(`Native binding package version mismatch, expected 0.1.
|
|
201
|
+
if (bindingPackageVersion !== '0.1.21-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
|
202
|
+
throw new Error(`Native binding package version mismatch, expected 0.1.21-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
|
203
203
|
}
|
|
204
204
|
return binding
|
|
205
205
|
} catch (e) {
|
|
@@ -214,8 +214,8 @@ function requireNative() {
|
|
|
214
214
|
try {
|
|
215
215
|
const binding = require('brustjs-darwin-arm64')
|
|
216
216
|
const bindingPackageVersion = require('brustjs-darwin-arm64/package.json').version
|
|
217
|
-
if (bindingPackageVersion !== '0.1.
|
|
218
|
-
throw new Error(`Native binding package version mismatch, expected 0.1.
|
|
217
|
+
if (bindingPackageVersion !== '0.1.21-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
|
218
|
+
throw new Error(`Native binding package version mismatch, expected 0.1.21-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
|
219
219
|
}
|
|
220
220
|
return binding
|
|
221
221
|
} catch (e) {
|
|
@@ -234,8 +234,8 @@ function requireNative() {
|
|
|
234
234
|
try {
|
|
235
235
|
const binding = require('brustjs-freebsd-x64')
|
|
236
236
|
const bindingPackageVersion = require('brustjs-freebsd-x64/package.json').version
|
|
237
|
-
if (bindingPackageVersion !== '0.1.
|
|
238
|
-
throw new Error(`Native binding package version mismatch, expected 0.1.
|
|
237
|
+
if (bindingPackageVersion !== '0.1.21-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
|
238
|
+
throw new Error(`Native binding package version mismatch, expected 0.1.21-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
|
239
239
|
}
|
|
240
240
|
return binding
|
|
241
241
|
} catch (e) {
|
|
@@ -250,8 +250,8 @@ function requireNative() {
|
|
|
250
250
|
try {
|
|
251
251
|
const binding = require('brustjs-freebsd-arm64')
|
|
252
252
|
const bindingPackageVersion = require('brustjs-freebsd-arm64/package.json').version
|
|
253
|
-
if (bindingPackageVersion !== '0.1.
|
|
254
|
-
throw new Error(`Native binding package version mismatch, expected 0.1.
|
|
253
|
+
if (bindingPackageVersion !== '0.1.21-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
|
254
|
+
throw new Error(`Native binding package version mismatch, expected 0.1.21-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
|
255
255
|
}
|
|
256
256
|
return binding
|
|
257
257
|
} catch (e) {
|
|
@@ -271,8 +271,8 @@ function requireNative() {
|
|
|
271
271
|
try {
|
|
272
272
|
const binding = require('brustjs-linux-x64-musl')
|
|
273
273
|
const bindingPackageVersion = require('brustjs-linux-x64-musl/package.json').version
|
|
274
|
-
if (bindingPackageVersion !== '0.1.
|
|
275
|
-
throw new Error(`Native binding package version mismatch, expected 0.1.
|
|
274
|
+
if (bindingPackageVersion !== '0.1.21-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
|
275
|
+
throw new Error(`Native binding package version mismatch, expected 0.1.21-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
|
276
276
|
}
|
|
277
277
|
return binding
|
|
278
278
|
} catch (e) {
|
|
@@ -287,8 +287,8 @@ function requireNative() {
|
|
|
287
287
|
try {
|
|
288
288
|
const binding = require('brustjs-linux-x64-gnu')
|
|
289
289
|
const bindingPackageVersion = require('brustjs-linux-x64-gnu/package.json').version
|
|
290
|
-
if (bindingPackageVersion !== '0.1.
|
|
291
|
-
throw new Error(`Native binding package version mismatch, expected 0.1.
|
|
290
|
+
if (bindingPackageVersion !== '0.1.21-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
|
291
|
+
throw new Error(`Native binding package version mismatch, expected 0.1.21-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
|
292
292
|
}
|
|
293
293
|
return binding
|
|
294
294
|
} catch (e) {
|
|
@@ -305,8 +305,8 @@ function requireNative() {
|
|
|
305
305
|
try {
|
|
306
306
|
const binding = require('brustjs-linux-arm64-musl')
|
|
307
307
|
const bindingPackageVersion = require('brustjs-linux-arm64-musl/package.json').version
|
|
308
|
-
if (bindingPackageVersion !== '0.1.
|
|
309
|
-
throw new Error(`Native binding package version mismatch, expected 0.1.
|
|
308
|
+
if (bindingPackageVersion !== '0.1.21-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
|
309
|
+
throw new Error(`Native binding package version mismatch, expected 0.1.21-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
|
310
310
|
}
|
|
311
311
|
return binding
|
|
312
312
|
} catch (e) {
|
|
@@ -321,8 +321,8 @@ function requireNative() {
|
|
|
321
321
|
try {
|
|
322
322
|
const binding = require('brustjs-linux-arm64-gnu')
|
|
323
323
|
const bindingPackageVersion = require('brustjs-linux-arm64-gnu/package.json').version
|
|
324
|
-
if (bindingPackageVersion !== '0.1.
|
|
325
|
-
throw new Error(`Native binding package version mismatch, expected 0.1.
|
|
324
|
+
if (bindingPackageVersion !== '0.1.21-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
|
325
|
+
throw new Error(`Native binding package version mismatch, expected 0.1.21-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
|
326
326
|
}
|
|
327
327
|
return binding
|
|
328
328
|
} catch (e) {
|
|
@@ -339,8 +339,8 @@ function requireNative() {
|
|
|
339
339
|
try {
|
|
340
340
|
const binding = require('brustjs-linux-arm-musleabihf')
|
|
341
341
|
const bindingPackageVersion = require('brustjs-linux-arm-musleabihf/package.json').version
|
|
342
|
-
if (bindingPackageVersion !== '0.1.
|
|
343
|
-
throw new Error(`Native binding package version mismatch, expected 0.1.
|
|
342
|
+
if (bindingPackageVersion !== '0.1.21-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
|
343
|
+
throw new Error(`Native binding package version mismatch, expected 0.1.21-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
|
344
344
|
}
|
|
345
345
|
return binding
|
|
346
346
|
} catch (e) {
|
|
@@ -355,8 +355,8 @@ function requireNative() {
|
|
|
355
355
|
try {
|
|
356
356
|
const binding = require('brustjs-linux-arm-gnueabihf')
|
|
357
357
|
const bindingPackageVersion = require('brustjs-linux-arm-gnueabihf/package.json').version
|
|
358
|
-
if (bindingPackageVersion !== '0.1.
|
|
359
|
-
throw new Error(`Native binding package version mismatch, expected 0.1.
|
|
358
|
+
if (bindingPackageVersion !== '0.1.21-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
|
359
|
+
throw new Error(`Native binding package version mismatch, expected 0.1.21-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
|
360
360
|
}
|
|
361
361
|
return binding
|
|
362
362
|
} catch (e) {
|
|
@@ -373,8 +373,8 @@ function requireNative() {
|
|
|
373
373
|
try {
|
|
374
374
|
const binding = require('brustjs-linux-loong64-musl')
|
|
375
375
|
const bindingPackageVersion = require('brustjs-linux-loong64-musl/package.json').version
|
|
376
|
-
if (bindingPackageVersion !== '0.1.
|
|
377
|
-
throw new Error(`Native binding package version mismatch, expected 0.1.
|
|
376
|
+
if (bindingPackageVersion !== '0.1.21-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
|
377
|
+
throw new Error(`Native binding package version mismatch, expected 0.1.21-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
|
378
378
|
}
|
|
379
379
|
return binding
|
|
380
380
|
} catch (e) {
|
|
@@ -389,8 +389,8 @@ function requireNative() {
|
|
|
389
389
|
try {
|
|
390
390
|
const binding = require('brustjs-linux-loong64-gnu')
|
|
391
391
|
const bindingPackageVersion = require('brustjs-linux-loong64-gnu/package.json').version
|
|
392
|
-
if (bindingPackageVersion !== '0.1.
|
|
393
|
-
throw new Error(`Native binding package version mismatch, expected 0.1.
|
|
392
|
+
if (bindingPackageVersion !== '0.1.21-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
|
393
|
+
throw new Error(`Native binding package version mismatch, expected 0.1.21-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
|
394
394
|
}
|
|
395
395
|
return binding
|
|
396
396
|
} catch (e) {
|
|
@@ -407,8 +407,8 @@ function requireNative() {
|
|
|
407
407
|
try {
|
|
408
408
|
const binding = require('brustjs-linux-riscv64-musl')
|
|
409
409
|
const bindingPackageVersion = require('brustjs-linux-riscv64-musl/package.json').version
|
|
410
|
-
if (bindingPackageVersion !== '0.1.
|
|
411
|
-
throw new Error(`Native binding package version mismatch, expected 0.1.
|
|
410
|
+
if (bindingPackageVersion !== '0.1.21-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
|
411
|
+
throw new Error(`Native binding package version mismatch, expected 0.1.21-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
|
412
412
|
}
|
|
413
413
|
return binding
|
|
414
414
|
} catch (e) {
|
|
@@ -423,8 +423,8 @@ function requireNative() {
|
|
|
423
423
|
try {
|
|
424
424
|
const binding = require('brustjs-linux-riscv64-gnu')
|
|
425
425
|
const bindingPackageVersion = require('brustjs-linux-riscv64-gnu/package.json').version
|
|
426
|
-
if (bindingPackageVersion !== '0.1.
|
|
427
|
-
throw new Error(`Native binding package version mismatch, expected 0.1.
|
|
426
|
+
if (bindingPackageVersion !== '0.1.21-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
|
427
|
+
throw new Error(`Native binding package version mismatch, expected 0.1.21-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
|
428
428
|
}
|
|
429
429
|
return binding
|
|
430
430
|
} catch (e) {
|
|
@@ -440,8 +440,8 @@ function requireNative() {
|
|
|
440
440
|
try {
|
|
441
441
|
const binding = require('brustjs-linux-ppc64-gnu')
|
|
442
442
|
const bindingPackageVersion = require('brustjs-linux-ppc64-gnu/package.json').version
|
|
443
|
-
if (bindingPackageVersion !== '0.1.
|
|
444
|
-
throw new Error(`Native binding package version mismatch, expected 0.1.
|
|
443
|
+
if (bindingPackageVersion !== '0.1.21-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
|
444
|
+
throw new Error(`Native binding package version mismatch, expected 0.1.21-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
|
445
445
|
}
|
|
446
446
|
return binding
|
|
447
447
|
} catch (e) {
|
|
@@ -456,8 +456,8 @@ function requireNative() {
|
|
|
456
456
|
try {
|
|
457
457
|
const binding = require('brustjs-linux-s390x-gnu')
|
|
458
458
|
const bindingPackageVersion = require('brustjs-linux-s390x-gnu/package.json').version
|
|
459
|
-
if (bindingPackageVersion !== '0.1.
|
|
460
|
-
throw new Error(`Native binding package version mismatch, expected 0.1.
|
|
459
|
+
if (bindingPackageVersion !== '0.1.21-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
|
460
|
+
throw new Error(`Native binding package version mismatch, expected 0.1.21-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
|
461
461
|
}
|
|
462
462
|
return binding
|
|
463
463
|
} catch (e) {
|
|
@@ -476,8 +476,8 @@ function requireNative() {
|
|
|
476
476
|
try {
|
|
477
477
|
const binding = require('brustjs-openharmony-arm64')
|
|
478
478
|
const bindingPackageVersion = require('brustjs-openharmony-arm64/package.json').version
|
|
479
|
-
if (bindingPackageVersion !== '0.1.
|
|
480
|
-
throw new Error(`Native binding package version mismatch, expected 0.1.
|
|
479
|
+
if (bindingPackageVersion !== '0.1.21-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
|
480
|
+
throw new Error(`Native binding package version mismatch, expected 0.1.21-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
|
481
481
|
}
|
|
482
482
|
return binding
|
|
483
483
|
} catch (e) {
|
|
@@ -492,8 +492,8 @@ function requireNative() {
|
|
|
492
492
|
try {
|
|
493
493
|
const binding = require('brustjs-openharmony-x64')
|
|
494
494
|
const bindingPackageVersion = require('brustjs-openharmony-x64/package.json').version
|
|
495
|
-
if (bindingPackageVersion !== '0.1.
|
|
496
|
-
throw new Error(`Native binding package version mismatch, expected 0.1.
|
|
495
|
+
if (bindingPackageVersion !== '0.1.21-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
|
496
|
+
throw new Error(`Native binding package version mismatch, expected 0.1.21-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
|
497
497
|
}
|
|
498
498
|
return binding
|
|
499
499
|
} catch (e) {
|
|
@@ -508,8 +508,8 @@ function requireNative() {
|
|
|
508
508
|
try {
|
|
509
509
|
const binding = require('brustjs-openharmony-arm')
|
|
510
510
|
const bindingPackageVersion = require('brustjs-openharmony-arm/package.json').version
|
|
511
|
-
if (bindingPackageVersion !== '0.1.
|
|
512
|
-
throw new Error(`Native binding package version mismatch, expected 0.1.
|
|
511
|
+
if (bindingPackageVersion !== '0.1.21-alpha' && process.env.NAPI_RS_ENFORCE_VERSION_CHECK && process.env.NAPI_RS_ENFORCE_VERSION_CHECK !== '0') {
|
|
512
|
+
throw new Error(`Native binding package version mismatch, expected 0.1.21-alpha but got ${bindingPackageVersion}. You can reinstall dependencies to fix this issue.`)
|
|
513
513
|
}
|
|
514
514
|
return binding
|
|
515
515
|
} catch (e) {
|
package/runtime/index.ts
CHANGED
|
@@ -747,6 +747,14 @@ export type { IsrConfig } from './islands/isr-jsx.ts'
|
|
|
747
747
|
export { BrustPage } from './islands/brust-page.tsx'
|
|
748
748
|
export type { BrustPageProps, HeadEntry } from './islands/brust-page.tsx'
|
|
749
749
|
|
|
750
|
+
// Store reactive primitives (isomorphic, server-safe). The React adapter
|
|
751
|
+
// `useStore` is intentionally NOT exported here — it ships from `brustjs/client`
|
|
752
|
+
// because islands (the only React in the browser) bundle for the browser and
|
|
753
|
+
// cannot pull this server entry (native addon + react-dom/server). See
|
|
754
|
+
// runtime/client/index.ts.
|
|
755
|
+
export { defineStore, signal, computed, effect, batch } from './store/index.ts'
|
|
756
|
+
export type { StoreHandle, Snapshot, Signal, Computed } from './store/index.ts'
|
|
757
|
+
|
|
750
758
|
export { buildIslands } from './islands/build.ts'
|
|
751
759
|
export type { IslandsBuildResult, BuildIslandsOptions } from './islands/build.ts'
|
|
752
760
|
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
|
|
16
16
|
import { createRoot, hydrateRoot, type Root } from 'react-dom/client'
|
|
17
17
|
import { createElement } from 'react'
|
|
18
|
+
import { applyStoreSnapshot } from '../store/client-hydrate.ts'
|
|
18
19
|
|
|
19
20
|
// Track React roots created by hydrateOne so we can unmount them before
|
|
20
21
|
// removing their DOM in swapMainContent. Without this, removing the DOM
|
|
@@ -214,11 +215,16 @@ async function navigate(url: URL, push: boolean): Promise<void> {
|
|
|
214
215
|
headers: { Accept: 'application/json' },
|
|
215
216
|
})
|
|
216
217
|
if (!resp.ok) throw new Error(`navigation: status ${resp.status}`)
|
|
217
|
-
const { html, title } = (await resp.json()) as {
|
|
218
|
+
const { html, title, store } = (await resp.json()) as {
|
|
219
|
+
html: string
|
|
220
|
+
title: string
|
|
221
|
+
store?: Record<string, Record<string, unknown>>
|
|
222
|
+
}
|
|
218
223
|
const main = document.querySelector('main')
|
|
219
224
|
if (!main) throw new Error('navigation: no <main> element')
|
|
220
225
|
unmountIslandsIn(main as HTMLElement)
|
|
221
226
|
swapMainContent(main as HTMLElement, html)
|
|
227
|
+
if (store) applyStoreSnapshot(store)
|
|
222
228
|
if (title) document.title = title
|
|
223
229
|
if (push) history.pushState({}, '', url.href)
|
|
224
230
|
window.scrollTo(0, 0)
|
|
@@ -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)
|
|
@@ -0,0 +1,152 @@
|
|
|
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
|
+
let activeConsumer: Consumer | null = null
|
|
39
|
+
let batchDepth = 0
|
|
40
|
+
const pendingNotify = new Set<Consumer>()
|
|
41
|
+
|
|
42
|
+
function track(subscribers: Set<Consumer>): void {
|
|
43
|
+
if (activeConsumer) {
|
|
44
|
+
subscribers.add(activeConsumer)
|
|
45
|
+
activeConsumer.deps.add(subscribers)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function notify(subscribers: Set<Consumer>): void {
|
|
50
|
+
// Snapshot — a consumer re-running mutates the set.
|
|
51
|
+
for (const c of [...subscribers]) {
|
|
52
|
+
if (batchDepth > 0) pendingNotify.add(c)
|
|
53
|
+
else c.run()
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function flush(): void {
|
|
58
|
+
const queued = [...pendingNotify]
|
|
59
|
+
pendingNotify.clear()
|
|
60
|
+
for (const c of queued) c.run()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function batch(fn: () => void): void {
|
|
64
|
+
batchDepth++
|
|
65
|
+
try {
|
|
66
|
+
fn()
|
|
67
|
+
} finally {
|
|
68
|
+
batchDepth--
|
|
69
|
+
if (batchDepth === 0) flush()
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function signal<T>(initial: T): Signal<T> {
|
|
74
|
+
let value = initial
|
|
75
|
+
const subscribers = new Set<Consumer>()
|
|
76
|
+
const read = (() => {
|
|
77
|
+
track(subscribers)
|
|
78
|
+
return value
|
|
79
|
+
}) as Signal<T>
|
|
80
|
+
read.set = (next: T | ((prev: T) => T)) => {
|
|
81
|
+
const v = typeof next === 'function' ? (next as (p: T) => T)(value) : next
|
|
82
|
+
if (Object.is(v, value)) return
|
|
83
|
+
value = v
|
|
84
|
+
notify(subscribers)
|
|
85
|
+
}
|
|
86
|
+
Object.defineProperty(read, SIGNAL, { value: true })
|
|
87
|
+
return read
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function clearDeps(c: Consumer): void {
|
|
91
|
+
for (const dep of c.deps) dep.delete(c)
|
|
92
|
+
c.deps.clear()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function computed<T>(fn: () => T): Computed<T> {
|
|
96
|
+
let cached: T
|
|
97
|
+
let dirty = true
|
|
98
|
+
const subscribers = new Set<Consumer>()
|
|
99
|
+
const self: Consumer = {
|
|
100
|
+
deps: new Set(),
|
|
101
|
+
running: false,
|
|
102
|
+
run() {
|
|
103
|
+
if (self.running) return
|
|
104
|
+
self.running = true
|
|
105
|
+
try {
|
|
106
|
+
dirty = true
|
|
107
|
+
notify(subscribers) // downstream recomputes lazily on next read
|
|
108
|
+
} finally {
|
|
109
|
+
self.running = false
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
}
|
|
113
|
+
const read = (() => {
|
|
114
|
+
track(subscribers)
|
|
115
|
+
if (dirty) {
|
|
116
|
+
clearDeps(self)
|
|
117
|
+
const prev = activeConsumer
|
|
118
|
+
activeConsumer = self
|
|
119
|
+
try {
|
|
120
|
+
cached = fn()
|
|
121
|
+
dirty = false
|
|
122
|
+
} finally {
|
|
123
|
+
activeConsumer = prev
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return cached
|
|
127
|
+
}) as Computed<T>
|
|
128
|
+
Object.defineProperty(read, COMPUTED, { value: true })
|
|
129
|
+
return read
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function effect(fn: () => void): () => void {
|
|
133
|
+
const self: Consumer = {
|
|
134
|
+
deps: new Set(),
|
|
135
|
+
running: false,
|
|
136
|
+
run() {
|
|
137
|
+
if (self.running) return
|
|
138
|
+
self.running = true
|
|
139
|
+
clearDeps(self)
|
|
140
|
+
const prev = activeConsumer
|
|
141
|
+
activeConsumer = self
|
|
142
|
+
try {
|
|
143
|
+
fn()
|
|
144
|
+
} finally {
|
|
145
|
+
activeConsumer = prev
|
|
146
|
+
self.running = false
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
}
|
|
150
|
+
self.run()
|
|
151
|
+
return () => clearDeps(self)
|
|
152
|
+
}
|
|
@@ -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
|
-
}
|