brustjs 0.1.20-alpha → 0.1.22-alpha
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -1
- package/example/pokedex/components/AddToTeamButton.tsx +59 -65
- package/example/pokedex/components/TeamBuilder.tsx +20 -10
- package/example/pokedex/lib/loaders.ts +13 -3
- package/example/pokedex/lib/types.ts +6 -3
- package/example/pokedex/pages/DetailPage.tsx +1 -2
- package/example/pokedex/stores/team.ts +14 -0
- package/package.json +10 -8
- package/runtime/cli/build.ts +41 -0
- package/runtime/cli/native-routes-emit.ts +28 -4
- package/runtime/client/index.ts +6 -0
- package/runtime/index.js +52 -52
- package/runtime/index.ts +32 -1
- package/runtime/islands/bootstrap.ts +7 -1
- package/runtime/islands/importmap.ts +6 -0
- package/runtime/native/build.ts +147 -0
- package/runtime/native/index.ts +3 -0
- package/runtime/native/runtime.ts +324 -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 +170 -0
- package/example/pokedex/components/team-bus.ts +0 -25
package/README.md
CHANGED
|
@@ -87,6 +87,17 @@ brustjs new <name> # scaffold a project (partial — see Status)
|
|
|
87
87
|
`renderToString` runs once per key, then serves a frozen pair from Rust.
|
|
88
88
|
- **`native: true` routes** — JSX compiled to a jinja template at build time and
|
|
89
89
|
rendered Rust-side (`minijinja`), skipping React on the server entirely.
|
|
90
|
+
- **Native interactivity without islands** — Alpine.js-style `x-*` DOM directives
|
|
91
|
+
(`x-data`/`x-text`/`x-show`/`x-bind-*`/`x-on-*`/`x-for`) on a `native` page,
|
|
92
|
+
bound to the store by a small react-free runtime. Logic lives in a co-located
|
|
93
|
+
`export const behavior` (single-file component); each component's JS is a
|
|
94
|
+
separate chunk loaded **on demand** — a page never downloads a component it
|
|
95
|
+
doesn't render.
|
|
96
|
+
- **Isomorphic store** — `brustjs/store`: `signal`/`computed`/`effect` +
|
|
97
|
+
`defineStore(name, factory)`. One `window` singleton per name on the client (so
|
|
98
|
+
separate island/directive chunks share state), a per-request `AsyncLocalStorage`
|
|
99
|
+
instance on the server. `useStore` adapter for React islands; a native directive
|
|
100
|
+
button and a React island reactively share the same store.
|
|
90
101
|
- **Typed actions** — `defineActions().get/post/put/patch/delete/head(path, ctx => R, { body, query })`
|
|
91
102
|
on the server; `client<typeof actions>()` is an Eden-Treaty-style proxy that
|
|
92
103
|
infers the whole API from the server types (no codegen) and returns
|
|
@@ -119,7 +130,7 @@ bun test tests/integration.test.ts # integration (real server)
|
|
|
119
130
|
```
|
|
120
131
|
crates/brust/ Rust: accept loop, worker pool, napi exports, SAB
|
|
121
132
|
crates/jsx-rust-compiler/ JSX → jinja compiler for native: true routes
|
|
122
|
-
runtime/ Bun-side: routing, render, actions, CLI
|
|
133
|
+
runtime/ Bun-side: routing, render, actions, store, native directives, CLI
|
|
123
134
|
example/ pokedex native-first demo
|
|
124
135
|
bench/ · docs/ · architecture.md
|
|
125
136
|
```
|
|
@@ -1,93 +1,87 @@
|
|
|
1
|
-
//
|
|
1
|
+
// NATIVE INTERACTIVE COMPONENT (Spec B dogfood) — the "Add to team / In your
|
|
2
|
+
// team" toggle on the detail page. Formerly a React island; now a single-file
|
|
3
|
+
// native directive component: a co-located `export const behavior` (client
|
|
4
|
+
// logic, react-free) + a JSX `default` export (the native template the compiler
|
|
5
|
+
// lowers to minijinja). The build bundles ONLY `behavior` into _directives.js;
|
|
6
|
+
// the JSX default is tree-shaken out so react never leaks into the client bundle.
|
|
2
7
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
import { useEffect, useState } from 'react'
|
|
8
|
+
// The behavior is react-free: `signal`/`computed` from brustjs/store (the window
|
|
9
|
+
// singleton on the client), `client` from brustjs/client (the treaty action
|
|
10
|
+
// client — also react-free), and the shared teamStore. NO react imports.
|
|
7
11
|
import { client } from 'brustjs/client'
|
|
12
|
+
import { computed, signal } from 'brustjs/store'
|
|
8
13
|
import type { Actions } from '../actions'
|
|
9
|
-
import type { AddToTeamProps
|
|
10
|
-
import {
|
|
14
|
+
import type { AddToTeamProps } from '../lib/types'
|
|
15
|
+
import { teamStore } from '../stores/team'
|
|
11
16
|
|
|
12
17
|
const api = client<Actions>()
|
|
13
18
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
19
|
+
// behavior → client bundle, registered as "addToTeamButton" (camelCase filename).
|
|
20
|
+
// `props` is the JSON parsed out of the element's x-props attribute (precomputed
|
|
21
|
+
// by the loader as a JSON string — native templates can't call JSON.stringify).
|
|
22
|
+
export const behavior = ({ props }: { props: AddToTeamProps }) => {
|
|
23
|
+
// Shared store (GAP S4): writing teamStore.members here is observed by the
|
|
24
|
+
// TeamBuilder island — they resolve the same window singleton. A native
|
|
25
|
+
// x-on-click mutation is therefore seen reactively by a React island.
|
|
26
|
+
const busy = signal(false)
|
|
27
|
+
const inTeam = computed(() => (teamStore.members() ?? []).some((m) => m.id === props.id))
|
|
28
|
+
const label = computed(() =>
|
|
29
|
+
inTeam() ? '✓ In your team' : disabled() ? 'Team Full' : '+ Add to team',
|
|
30
|
+
)
|
|
31
|
+
const btnClass = computed(() => `aa-btn aa-btn--full${inTeam() ? ' aa-btn--secondary' : ''}`)
|
|
32
|
+
const disabled = computed(() => busy() || ((teamStore.members()?.length ?? 0) >= 6 && !inTeam()))
|
|
25
33
|
|
|
26
|
-
|
|
34
|
+
async function init() {
|
|
35
|
+
const r = await api.team.get()
|
|
36
|
+
if (r.data) teamStore.members.set(r.data.team)
|
|
37
|
+
}
|
|
27
38
|
|
|
28
39
|
async function toggle() {
|
|
29
|
-
|
|
40
|
+
busy.set(true)
|
|
30
41
|
try {
|
|
31
|
-
if (inTeam) {
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const { data } = await api.team({ id: p.id }).delete({})
|
|
36
|
-
if (data) emitTeam(data.team)
|
|
42
|
+
if (inTeam()) {
|
|
43
|
+
// Bodyless DELETE is OK now (GAPS S12 fixed) — no more `.delete({})`.
|
|
44
|
+
const { data } = await api.team({ id: props.id }).delete()
|
|
45
|
+
if (data) teamStore.members.set(data.team)
|
|
37
46
|
} else {
|
|
38
47
|
const { data } = await api.team.post({
|
|
39
|
-
id:
|
|
40
|
-
name:
|
|
41
|
-
displayName:
|
|
42
|
-
num:
|
|
43
|
-
types:
|
|
44
|
-
artwork:
|
|
48
|
+
id: props.id,
|
|
49
|
+
name: props.name,
|
|
50
|
+
displayName: props.displayName,
|
|
51
|
+
num: props.num,
|
|
52
|
+
types: props.types,
|
|
53
|
+
artwork: props.artwork,
|
|
45
54
|
})
|
|
46
|
-
if (data?.full) {
|
|
47
|
-
|
|
48
|
-
setTimeout(() => setToast(null), 2200)
|
|
49
|
-
} else if (data) {
|
|
50
|
-
emitTeam(data.team)
|
|
55
|
+
if (data && !data?.full) {
|
|
56
|
+
teamStore.members.set(data.team)
|
|
51
57
|
}
|
|
52
58
|
}
|
|
53
59
|
} finally {
|
|
54
|
-
|
|
60
|
+
busy.set(false)
|
|
55
61
|
}
|
|
56
62
|
}
|
|
57
63
|
|
|
64
|
+
return { init, label, btnClass, toggle, disabled }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// default → jinja (server). The x-* directives are static string attributes the
|
|
68
|
+
// native compiler passes straight through; the directive runtime binds them to
|
|
69
|
+
// the behavior instance on the client. `data` is the loader-precomputed JSON
|
|
70
|
+
// string, emitted by the compiler as x-props="{{ (data) | e }}" (XSS-safe).
|
|
71
|
+
export default function AddToTeamButton({ data }: { data: string }) {
|
|
58
72
|
return (
|
|
59
|
-
<div style={{ position: 'relative' }}>
|
|
73
|
+
<div x-data="addToTeamButton" x-props={data} style={{ position: 'relative' }}>
|
|
60
74
|
<button
|
|
61
75
|
type="button"
|
|
62
|
-
|
|
76
|
+
x-text="label"
|
|
77
|
+
x-bind-class="btnClass"
|
|
78
|
+
x-bind-disabled="disabled"
|
|
79
|
+
x-on-click="toggle"
|
|
80
|
+
className="aa-btn aa-btn--full"
|
|
63
81
|
style={{ width: '100%' }}
|
|
64
|
-
onClick={toggle}
|
|
65
|
-
disabled={busy}
|
|
66
82
|
>
|
|
67
|
-
|
|
83
|
+
+ Add to team
|
|
68
84
|
</button>
|
|
69
|
-
{toast && (
|
|
70
|
-
<div
|
|
71
|
-
style={{
|
|
72
|
-
position: 'absolute',
|
|
73
|
-
top: 'calc(100% + 8px)',
|
|
74
|
-
left: 0,
|
|
75
|
-
right: 0,
|
|
76
|
-
zIndex: 50,
|
|
77
|
-
padding: '8px 12px',
|
|
78
|
-
borderRadius: 'var(--radius-md)',
|
|
79
|
-
background: 'var(--danger-50)',
|
|
80
|
-
color: 'var(--danger-700)',
|
|
81
|
-
border: '1px solid rgba(212,28,89,0.25)',
|
|
82
|
-
fontSize: 'var(--text-xs)',
|
|
83
|
-
fontWeight: 600,
|
|
84
|
-
textAlign: 'center',
|
|
85
|
-
boxShadow: 'var(--shadow-md)',
|
|
86
|
-
}}
|
|
87
|
-
>
|
|
88
|
-
{toast}
|
|
89
|
-
</div>
|
|
90
|
-
)}
|
|
91
85
|
</div>
|
|
92
86
|
)
|
|
93
87
|
}
|
|
@@ -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))]
|
|
@@ -163,14 +163,17 @@ export async function detailLoader({ params }: LoaderCtx): Promise<DetailData |
|
|
|
163
163
|
hasAbilities: abilities.length > 0,
|
|
164
164
|
evolution,
|
|
165
165
|
hasEvolution,
|
|
166
|
-
|
|
166
|
+
// Native templates can't call JSON.stringify, so precompute the x-props JSON
|
|
167
|
+
// here. The compiler emits it as x-props="{{ (addProps) | e }}" (XSS-safe);
|
|
168
|
+
// the directive runtime JSON.parses it back into the behavior's `props`.
|
|
169
|
+
addProps: JSON.stringify({
|
|
167
170
|
id: p.id,
|
|
168
171
|
name: p.name,
|
|
169
172
|
displayName: cap(p.name),
|
|
170
173
|
num: pad(p.id),
|
|
171
174
|
types: p.types,
|
|
172
175
|
artwork: p.artwork,
|
|
173
|
-
},
|
|
176
|
+
}),
|
|
174
177
|
teamProps: { teamInitial: teamStore.list() },
|
|
175
178
|
}
|
|
176
179
|
}
|
|
@@ -197,7 +200,14 @@ function emptyDetail(name: string): DetailData {
|
|
|
197
200
|
hasAbilities: false,
|
|
198
201
|
evolution: [],
|
|
199
202
|
hasEvolution: false,
|
|
200
|
-
addProps: {
|
|
203
|
+
addProps: JSON.stringify({
|
|
204
|
+
id: 0,
|
|
205
|
+
name,
|
|
206
|
+
displayName: cap(name),
|
|
207
|
+
num: '',
|
|
208
|
+
types: [],
|
|
209
|
+
artwork: '',
|
|
210
|
+
}),
|
|
201
211
|
teamProps: { teamInitial: teamStore.list() },
|
|
202
212
|
}
|
|
203
213
|
}
|
|
@@ -95,12 +95,15 @@ export interface DetailData {
|
|
|
95
95
|
hasAbilities: boolean
|
|
96
96
|
evolution: EvolutionStageVM[]
|
|
97
97
|
hasEvolution: boolean
|
|
98
|
-
//
|
|
99
|
-
addProps
|
|
98
|
+
// native interactive props: a single string path each (native props can't be
|
|
99
|
+
// object literals). addProps is the loader-precomputed JSON string handed to
|
|
100
|
+
// <AddToTeamButton data={addProps} /> → x-props (Spec B native directives).
|
|
101
|
+
addProps: string
|
|
100
102
|
teamProps: { teamInitial: TeamMember[] }
|
|
101
103
|
}
|
|
102
104
|
|
|
103
|
-
/**
|
|
105
|
+
/** Shape of the AddToTeamButton native behavior's `props` (JSON-parsed from
|
|
106
|
+
* x-props). Matches the action body fields so toggle() can post it directly. */
|
|
104
107
|
export interface AddToTeamProps {
|
|
105
108
|
id: number
|
|
106
109
|
name: string
|
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
// `{s.showLevel && <Level/>}` separators — no more loader-computed hide-classes.
|
|
10
10
|
// The <title> is dynamic via `<BrustPage title={pageTitle}>` (S8) and inline
|
|
11
11
|
// styles use `style={{…}}` objects (S1).
|
|
12
|
-
import { Island } from 'brustjs'
|
|
13
12
|
import AddToTeamButton from '../components/AddToTeamButton'
|
|
14
13
|
import PageLayout from '../components/PageLayout'
|
|
15
14
|
import type { DetailData } from '../lib/types'
|
|
@@ -71,7 +70,7 @@ export default function DetailPage({
|
|
|
71
70
|
</span>
|
|
72
71
|
))}
|
|
73
72
|
</div>
|
|
74
|
-
<
|
|
73
|
+
<AddToTeamButton native data={addProps} />
|
|
75
74
|
</div>
|
|
76
75
|
|
|
77
76
|
<div className="dex-detail-right">
|
|
@@ -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.22-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.22-alpha",
|
|
44
|
+
"brustjs-darwin-arm64": "0.1.22-alpha",
|
|
45
|
+
"brustjs-linux-x64-gnu": "0.1.22-alpha",
|
|
46
|
+
"brustjs-linux-arm64-gnu": "0.1.22-alpha",
|
|
47
|
+
"brustjs-linux-x64-musl": "0.1.22-alpha",
|
|
48
|
+
"brustjs-linux-arm64-musl": "0.1.22-alpha"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
51
|
"react": "^19.2.6",
|
|
@@ -67,7 +67,9 @@
|
|
|
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",
|
|
72
|
+
"./native": "./runtime/native/index.ts"
|
|
71
73
|
},
|
|
72
74
|
"files": [
|
|
73
75
|
"runtime",
|
package/runtime/cli/build.ts
CHANGED
|
@@ -248,6 +248,47 @@ export async function runBuild(args: string[]): Promise<void> {
|
|
|
248
248
|
console.log('[brust build] islands: skipped (no <Island> usage)')
|
|
249
249
|
}
|
|
250
250
|
|
|
251
|
+
// 3.5. Build the directive runtime bundle (if any native interactive component —
|
|
252
|
+
// a file with `export const behavior` — is reachable from the routes graph).
|
|
253
|
+
// MUST run AFTER buildIslands: buildIslands does `rm -rf outDir/islands`, so
|
|
254
|
+
// running this first would wipe _directives.js. This block creates the islands
|
|
255
|
+
// dir itself (the islands block is skipped when there are no <Island> usages).
|
|
256
|
+
{
|
|
257
|
+
const { scanDirectiveComponents, buildDirectives } = await import('../native/build.ts')
|
|
258
|
+
let directiveComponents = new Map<string, string>()
|
|
259
|
+
if (existsSync(routesFile)) {
|
|
260
|
+
try {
|
|
261
|
+
directiveComponents = scanDirectiveComponents(routesFile)
|
|
262
|
+
} catch (err) {
|
|
263
|
+
// e.g. two files derive the same directive register name — surface a clean
|
|
264
|
+
// message instead of an unformatted stack out of `brust build`.
|
|
265
|
+
console.error(`[brust build] directives: ${(err as Error).message}`)
|
|
266
|
+
process.exit(1)
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (directiveComponents.size > 0) {
|
|
270
|
+
const islandsOutDir = path.join(outDir, 'islands')
|
|
271
|
+
const result = await buildDirectives(directiveComponents, { outDir: islandsOutDir })
|
|
272
|
+
console.log(
|
|
273
|
+
`[brust build] directives: runtime + ${result.count} component chunk(s) → ${islandsOutDir}`,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
// Mirror every directive file (_directives.js + each <name>.directive.js) into
|
|
277
|
+
// cwd/.brust/islands for the source runtime (the islands block's whole-dir mirror
|
|
278
|
+
// ran before these existed, so copy them explicitly). Create the dir in case the
|
|
279
|
+
// islands block was skipped.
|
|
280
|
+
const localIslandsDir = path.join(process.cwd(), '.brust', 'islands')
|
|
281
|
+
if (path.resolve(localIslandsDir) !== path.resolve(islandsOutDir)) {
|
|
282
|
+
await mkdir(localIslandsDir, { recursive: true })
|
|
283
|
+
for (const f of result.files) {
|
|
284
|
+
await cp(path.join(islandsOutDir, f), path.join(localIslandsDir, f))
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
} else {
|
|
288
|
+
console.log('[brust build] directives: skipped (no export-const-behavior components)')
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
251
292
|
// 4. MCP manifest (if routes.tsx exists).
|
|
252
293
|
let loadedRoutes: any[] | undefined
|
|
253
294
|
if (existsSync(routesFile)) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
2
2
|
import { dirname, relative, resolve } from 'node:path'
|
|
3
3
|
import { buildDevClientTag } from '../dev/client.ts'
|
|
4
|
-
import { ISLANDS_IMPORTMAP_AND_BOOTSTRAP } from '../islands/importmap.ts'
|
|
4
|
+
import { DIRECTIVES_BOOTSTRAP, ISLANDS_IMPORTMAP_AND_BOOTSTRAP } from '../islands/importmap.ts'
|
|
5
5
|
|
|
6
6
|
/** Gather transitive component sources starting from a page source file.
|
|
7
7
|
*
|
|
@@ -104,6 +104,20 @@ function injectDevClientIntoTemplate(template: string): string {
|
|
|
104
104
|
return template + tag
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
/** Bake the directive runtime loader into a native template iff it uses any
|
|
108
|
+
* x-data directive. Idempotent. Wrapped in {% raw %} for symmetry with the islands
|
|
109
|
+
* bootstrap bake (the tag has no {{ }} but the wrap is harmless + consistent). */
|
|
110
|
+
export function bakeDirectivesIfUsed(template: string, force = false): string {
|
|
111
|
+
// `force` (app has ≥1 directive component) bakes on EVERY native page so the
|
|
112
|
+
// runtime is live to catch SPA-nav swaps into a directive page. Otherwise
|
|
113
|
+
// attribute-anchored (`x-data=`) so a literal "x-data" in text/content can't
|
|
114
|
+
// trigger a stray <script> that would 404 (no bundle built for that route).
|
|
115
|
+
if (!force && !/x-data=/.test(template)) return template
|
|
116
|
+
const baked = `{% raw %}${DIRECTIVES_BOOTSTRAP}{% endraw %}`
|
|
117
|
+
if (template.includes(baked)) return template
|
|
118
|
+
return template + baked
|
|
119
|
+
}
|
|
120
|
+
|
|
107
121
|
/** Sub-project J — build pass that turns user's `pages/<Name>.tsx` files into
|
|
108
122
|
* `.brust/jinja/<Name>.jinja` templates. Invoked from `brust build` and
|
|
109
123
|
* `brust dev` after the user's routes are flattened.
|
|
@@ -327,6 +341,17 @@ export async function emitNativeTemplates(opts: NativeRouteEmitOpts): Promise<vo
|
|
|
327
341
|
const importMap =
|
|
328
342
|
nativeRoutes.length > 0 ? scanImports(opts.entryFile) : new Map<string, string>()
|
|
329
343
|
|
|
344
|
+
// App-wide directive presence: if ANY native interactive component exists, the
|
|
345
|
+
// directive runtime (`_directives.js`) must load on EVERY native page — not just
|
|
346
|
+
// pages whose own template uses x-data. SPA nav (owned by the islands bootstrap)
|
|
347
|
+
// swaps <main> but does NOT execute <script> tags in the swapped HTML, so the
|
|
348
|
+
// runtime must already be live on the page you navigate FROM for its
|
|
349
|
+
// MutationObserver to mount the incoming x-data. Dynamic import = call-time
|
|
350
|
+
// (avoids a module-eval cycle with native/build.ts → scanImports here).
|
|
351
|
+
const hasDirectives =
|
|
352
|
+
nativeRoutes.length > 0 &&
|
|
353
|
+
(await import('../native/build.ts')).scanDirectiveComponents(opts.entryFile).size > 0
|
|
354
|
+
|
|
330
355
|
const built: string[] = []
|
|
331
356
|
for (const r of nativeRoutes) {
|
|
332
357
|
const name = r.nativeTemplate!
|
|
@@ -357,10 +382,9 @@ export async function emitNativeTemplates(opts: NativeRouteEmitOpts): Promise<vo
|
|
|
357
382
|
// Dev-only: native routes don't pass through the React renderer's dev-client
|
|
358
383
|
// injection, so splice the /_brust/dev WS script in here. reEmitJinja() runs
|
|
359
384
|
// this on every hot reload, so the script is always present in dev.
|
|
385
|
+
const withDirectives = bakeDirectivesIfUsed(compiled.template, hasDirectives)
|
|
360
386
|
const template =
|
|
361
|
-
process.env.BRUST_DEV === '1'
|
|
362
|
-
? injectDevClientIntoTemplate(compiled.template)
|
|
363
|
-
: compiled.template
|
|
387
|
+
process.env.BRUST_DEV === '1' ? injectDevClientIntoTemplate(withDirectives) : withDirectives
|
|
364
388
|
writeFileSync(outPath, template)
|
|
365
389
|
built.push(name)
|
|
366
390
|
|
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'
|