brustjs 0.1.0-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 +110 -0
- package/package.json +92 -0
- package/runtime/actions.ts +65 -0
- package/runtime/bun.lock +236 -0
- package/runtime/cli/actions-prebuilt-plugin.ts +97 -0
- package/runtime/cli/build.ts +252 -0
- package/runtime/cli/dev.ts +92 -0
- package/runtime/cli/index.ts +30 -0
- package/runtime/cli/native-routes-emit.ts +171 -0
- package/runtime/cli/native-shim-plugin.ts +85 -0
- package/runtime/cli/new.ts +208 -0
- package/runtime/cli/templates/minimal/README.md.tmpl +16 -0
- package/runtime/cli/templates/minimal/_gitignore +4 -0
- package/runtime/cli/templates/minimal/app.css +6 -0
- package/runtime/cli/templates/minimal/components/Counter.tsx +13 -0
- package/runtime/cli/templates/minimal/components/Layout.tsx +16 -0
- package/runtime/cli/templates/minimal/index.ts +4 -0
- package/runtime/cli/templates/minimal/package.json.tmpl +21 -0
- package/runtime/cli/templates/minimal/pages/Home.tsx.tmpl +16 -0
- package/runtime/cli/templates/minimal/routes.tsx +6 -0
- package/runtime/cli/templates/minimal/tsconfig.json +20 -0
- package/runtime/client/index.ts +121 -0
- package/runtime/config.ts +148 -0
- package/runtime/css/build.ts +54 -0
- package/runtime/css/component-build.ts +78 -0
- package/runtime/css/component-loader.ts +27 -0
- package/runtime/css/manifest.ts +51 -0
- package/runtime/css/process-modules.ts +56 -0
- package/runtime/css/route-deps.ts +33 -0
- package/runtime/css/scan-imports.ts +79 -0
- package/runtime/css.ts +39 -0
- package/runtime/dev/client.ts +49 -0
- package/runtime/dev/coordinator.ts +127 -0
- package/runtime/dev/inject.ts +17 -0
- package/runtime/dev/tui.ts +109 -0
- package/runtime/dev/watcher.ts +109 -0
- package/runtime/dev/worker-registry.ts +96 -0
- package/runtime/dev/ws-channel.ts +99 -0
- package/runtime/index.d.ts +199 -0
- package/runtime/index.js +604 -0
- package/runtime/index.ts +618 -0
- package/runtime/islands/__fixtures__/NoDefault.tsx +3 -0
- package/runtime/islands/__fixtures__/StubIsland.tsx +7 -0
- package/runtime/islands/__fixtures__/ThrowingIsland.tsx +9 -0
- package/runtime/islands/_entries/react-dom.ts +7 -0
- package/runtime/islands/_entries/react.ts +11 -0
- package/runtime/islands/bootstrap.ts +241 -0
- package/runtime/islands/build.ts +141 -0
- package/runtime/islands/importmap.ts +17 -0
- package/runtime/islands/island.tsx +58 -0
- package/runtime/islands/native-render.ts +153 -0
- package/runtime/mcp/extractor.ts +160 -0
- package/runtime/mcp/manifest.ts +50 -0
- package/runtime/mcp/schema.ts +124 -0
- package/runtime/mcp/server.ts +250 -0
- package/runtime/render/inject-css-link.ts +59 -0
- package/runtime/render/inject-dev-client.ts +49 -0
- package/runtime/render/stream.ts +304 -0
- package/runtime/routes.ts +1406 -0
- package/runtime/scan-actions.ts +172 -0
- package/runtime/sse/handler.ts +85 -0
- package/runtime/tsconfig.json +14 -0
- package/runtime/ws/handler.ts +151 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// Brust client-side hydration bootstrap.
|
|
2
|
+
// Built once at boot into .brust/islands/_bootstrap.js and served at
|
|
3
|
+
// /_brust/islands/_bootstrap.js. Loaded by makeRenderer-injected <script>.
|
|
4
|
+
//
|
|
5
|
+
// Responsibilities:
|
|
6
|
+
// 1. Hydrate every <... data-brust-island="<id>" ...> marker under a
|
|
7
|
+
// given root (default: document.body) — exposed as hydrateMarkersIn
|
|
8
|
+
// so the navigation interceptor can re-run it on the new <main>
|
|
9
|
+
// after a navigation swap.
|
|
10
|
+
// 2. Intercept internal <a href> clicks → fetch /_brust/page/{path} →
|
|
11
|
+
// swap <main> in place → update title → re-hydrate islands →
|
|
12
|
+
// history.pushState. Any failure falls back to a full reload.
|
|
13
|
+
// 3. Listen for popstate (back / forward) and run the same swap path
|
|
14
|
+
// without pushing a new entry.
|
|
15
|
+
|
|
16
|
+
import { createRoot, hydrateRoot, type Root } from 'react-dom/client'
|
|
17
|
+
import { createElement } from 'react'
|
|
18
|
+
|
|
19
|
+
// Track React roots created by hydrateOne so we can unmount them before
|
|
20
|
+
// removing their DOM in swapMainContent. Without this, removing the DOM
|
|
21
|
+
// out from under a live root causes React's scheduler to keep posting
|
|
22
|
+
// work to a detached subtree, which manifests as a hung tab.
|
|
23
|
+
const islandRoots = new WeakMap<HTMLElement, Root>()
|
|
24
|
+
|
|
25
|
+
type Trigger = 'load' | 'idle' | 'visible' | 'interaction'
|
|
26
|
+
|
|
27
|
+
function registerTrigger(el: HTMLElement, trigger: Trigger, fire: () => void): void {
|
|
28
|
+
switch (trigger) {
|
|
29
|
+
case 'load': {
|
|
30
|
+
fire()
|
|
31
|
+
return
|
|
32
|
+
}
|
|
33
|
+
case 'idle': {
|
|
34
|
+
const rIC = (globalThis as { requestIdleCallback?: (cb: () => void) => void })
|
|
35
|
+
.requestIdleCallback
|
|
36
|
+
if (typeof rIC === 'function') {
|
|
37
|
+
rIC(fire)
|
|
38
|
+
} else {
|
|
39
|
+
setTimeout(fire, 0)
|
|
40
|
+
}
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
case 'visible': {
|
|
44
|
+
if (typeof IntersectionObserver === 'undefined') {
|
|
45
|
+
fire()
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
const io = new IntersectionObserver((entries, obs) => {
|
|
49
|
+
for (const e of entries) {
|
|
50
|
+
if (e.isIntersecting) {
|
|
51
|
+
obs.disconnect()
|
|
52
|
+
fire()
|
|
53
|
+
return
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
io.observe(el)
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
case 'interaction': {
|
|
61
|
+
const onceFire = () => {
|
|
62
|
+
el.removeEventListener('pointerdown', onceFire)
|
|
63
|
+
el.removeEventListener('keydown', onceFire)
|
|
64
|
+
el.removeEventListener('focusin', onceFire)
|
|
65
|
+
fire()
|
|
66
|
+
}
|
|
67
|
+
el.addEventListener('pointerdown', onceFire, { once: false })
|
|
68
|
+
el.addEventListener('keydown', onceFire, { once: false })
|
|
69
|
+
el.addEventListener('focusin', onceFire, { once: false })
|
|
70
|
+
return
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Exported for unit testing — the public entry is hydrateMarkersIn, but the
|
|
76
|
+
// createRoot/hydrateRoot auto-detect branch is cleanest to assert by driving
|
|
77
|
+
// hydrateOne directly (the `load` trigger fires it as a detached microtask).
|
|
78
|
+
export async function hydrateOne(el: HTMLElement): Promise<void> {
|
|
79
|
+
const id = el.getAttribute('data-brust-island')
|
|
80
|
+
if (!id) return
|
|
81
|
+
const propsJson = el.getAttribute('data-brust-props') ?? '{}'
|
|
82
|
+
let props: Record<string, unknown>
|
|
83
|
+
try {
|
|
84
|
+
props = JSON.parse(propsJson)
|
|
85
|
+
} catch (e) {
|
|
86
|
+
console.error(`[brust] island "${id}": invalid data-brust-props JSON`, e)
|
|
87
|
+
return
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
const mod = await import(`/_brust/islands/${id}.js`)
|
|
91
|
+
const Component = (mod.default ?? mod) as React.ComponentType<Record<string, unknown>>
|
|
92
|
+
if (typeof Component !== 'function') {
|
|
93
|
+
console.error(`[brust] island "${id}": chunk has no default-exported component`)
|
|
94
|
+
return
|
|
95
|
+
}
|
|
96
|
+
let root: Root
|
|
97
|
+
if (el.hasAttribute('data-brust-csr')) {
|
|
98
|
+
// Client-only island: the native compiler emits an EMPTY mount (no
|
|
99
|
+
// server markup) tagged data-brust-csr. hydrateRoot on an empty mount
|
|
100
|
+
// is a React 19 hydration mismatch, so spin up a fresh client root.
|
|
101
|
+
root = createRoot(el)
|
|
102
|
+
root.render(createElement(Component, props))
|
|
103
|
+
} else {
|
|
104
|
+
// Server island (or React-path island): server markup is present in the
|
|
105
|
+
// mount, so hydrate it in place to attach handlers without re-rendering.
|
|
106
|
+
root = hydrateRoot(el, createElement(Component, props))
|
|
107
|
+
}
|
|
108
|
+
// createRoot and hydrateRoot both return a Root with .unmount(), so
|
|
109
|
+
// islandRoots / unmountIslandsIn handle either uniformly — no extra work.
|
|
110
|
+
islandRoots.set(el, root)
|
|
111
|
+
} catch (e) {
|
|
112
|
+
console.error(`[brust] island "${id}": hydration failed`, e)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Unmount any React roots that live inside `root`. Must run BEFORE
|
|
117
|
+
* removing or replacing their DOM, otherwise React's scheduler keeps
|
|
118
|
+
* posting work to detached nodes and the tab hangs. Exported for unit
|
|
119
|
+
* testing the createRoot/hydrateRoot unmount parity. */
|
|
120
|
+
export function unmountIslandsIn(root: ParentNode): void {
|
|
121
|
+
const markers = root.querySelectorAll<HTMLElement>('[data-brust-island]')
|
|
122
|
+
for (const el of Array.from(markers)) {
|
|
123
|
+
const r = islandRoots.get(el)
|
|
124
|
+
if (r) {
|
|
125
|
+
try {
|
|
126
|
+
r.unmount()
|
|
127
|
+
} catch (e) {
|
|
128
|
+
console.warn('[brust] island unmount failed', e)
|
|
129
|
+
}
|
|
130
|
+
islandRoots.delete(el)
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Scan `root` for un-hydrated island markers and register their hydration
|
|
136
|
+
* triggers. Exposed so the navigation interceptor can call it on the
|
|
137
|
+
* freshly-swapped <main> subtree after a SPA navigation. The
|
|
138
|
+
* `data-brust-hydrated` attribute on each marker is the idempotence guard
|
|
139
|
+
* — a second call on the same root no-ops for already-hydrated markers. */
|
|
140
|
+
export function hydrateMarkersIn(root: ParentNode = document.body): void {
|
|
141
|
+
const markers = root.querySelectorAll<HTMLElement>(
|
|
142
|
+
'[data-brust-island]:not([data-brust-hydrated])',
|
|
143
|
+
)
|
|
144
|
+
for (const el of Array.from(markers)) {
|
|
145
|
+
el.setAttribute('data-brust-hydrated', '1')
|
|
146
|
+
const trig = (el.getAttribute('data-brust-hydrate') ?? 'load') as Trigger
|
|
147
|
+
registerTrigger(el, trig, () => {
|
|
148
|
+
void hydrateOne(el)
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Replace `main`'s children with HTML from a trusted Brust server
|
|
154
|
+
* response. The trust boundary: the HTML originates from the same Brust
|
|
155
|
+
* server that produced the initial page load. We use DOMParser (not
|
|
156
|
+
* Range.createContextualFragment) because DOMParser produces an INERT
|
|
157
|
+
* document — <script> tags are parsed but never executed. */
|
|
158
|
+
export function swapMainContent(main: HTMLElement, html: string): void {
|
|
159
|
+
const parsed = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html')
|
|
160
|
+
while (main.firstChild) main.removeChild(main.firstChild)
|
|
161
|
+
// Snapshot children before iterating: importNode clones (does NOT remove
|
|
162
|
+
// from source), so iterating on parsed.body.firstChild directly would
|
|
163
|
+
// infinite-loop. Array.from gives a fixed-length live snapshot to walk.
|
|
164
|
+
for (const node of Array.from(parsed.body.childNodes)) {
|
|
165
|
+
main.appendChild(document.importNode(node, true))
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Classifier — true iff the event should be intercepted as a SPA
|
|
170
|
+
* navigation. Exported for unit testing. */
|
|
171
|
+
export function isInternalLink(a: HTMLAnchorElement, event: MouseEvent): boolean {
|
|
172
|
+
if (event.defaultPrevented) return false
|
|
173
|
+
if (event.button !== 0) return false
|
|
174
|
+
if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return false
|
|
175
|
+
if (a.target && a.target !== '_self') return false
|
|
176
|
+
if (a.hasAttribute('download')) return false
|
|
177
|
+
if (a.dataset.brustNoIntercept !== undefined) return false
|
|
178
|
+
const url = new URL(a.href, location.href)
|
|
179
|
+
if (url.origin !== location.origin) return false
|
|
180
|
+
// Same-pathname links: hash-only changes use the browser's native scroll;
|
|
181
|
+
// hash-absent same-URL clicks (e.g., logo back to current page) are
|
|
182
|
+
// redundant — let the browser handle them as no-ops (no SPA refetch).
|
|
183
|
+
if (url.pathname === location.pathname && url.search === location.search) return false
|
|
184
|
+
if (url.pathname.startsWith('/_brust/')) return false
|
|
185
|
+
return true
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
let inFlight: AbortController | null = null
|
|
189
|
+
|
|
190
|
+
async function navigate(url: URL, push: boolean): Promise<void> {
|
|
191
|
+
inFlight?.abort()
|
|
192
|
+
const ac = new AbortController()
|
|
193
|
+
inFlight = ac
|
|
194
|
+
try {
|
|
195
|
+
const resp = await fetch(`/_brust/page${url.pathname}${url.search}`, {
|
|
196
|
+
signal: ac.signal,
|
|
197
|
+
headers: { Accept: 'application/json' },
|
|
198
|
+
})
|
|
199
|
+
if (!resp.ok) throw new Error(`navigation: status ${resp.status}`)
|
|
200
|
+
const { html, title } = (await resp.json()) as { html: string; title: string }
|
|
201
|
+
const main = document.querySelector('main')
|
|
202
|
+
if (!main) throw new Error('navigation: no <main> element')
|
|
203
|
+
unmountIslandsIn(main as HTMLElement)
|
|
204
|
+
swapMainContent(main as HTMLElement, html)
|
|
205
|
+
if (title) document.title = title
|
|
206
|
+
if (push) history.pushState({}, '', url.href)
|
|
207
|
+
window.scrollTo(0, 0)
|
|
208
|
+
hydrateMarkersIn(main as HTMLElement)
|
|
209
|
+
} catch (err) {
|
|
210
|
+
if ((err as Error).name === 'AbortError') return
|
|
211
|
+
console.warn('[brust] SPA navigation failed, falling back to full reload:', err)
|
|
212
|
+
location.href = url.href
|
|
213
|
+
} finally {
|
|
214
|
+
if (inFlight === ac) inFlight = null
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function installInterceptor(): void {
|
|
219
|
+
document.addEventListener('click', (e) => {
|
|
220
|
+
const target = e.target as HTMLElement | null
|
|
221
|
+
const a = target?.closest('a') as HTMLAnchorElement | null
|
|
222
|
+
if (!a || !isInternalLink(a, e)) return
|
|
223
|
+
e.preventDefault()
|
|
224
|
+
void navigate(new URL(a.href, location.href), /* push */ true)
|
|
225
|
+
})
|
|
226
|
+
window.addEventListener('popstate', () => {
|
|
227
|
+
void navigate(new URL(location.href), /* push */ false)
|
|
228
|
+
})
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
if (typeof document !== 'undefined') {
|
|
232
|
+
if (document.readyState === 'loading') {
|
|
233
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
234
|
+
hydrateMarkersIn(document.body)
|
|
235
|
+
installInterceptor()
|
|
236
|
+
})
|
|
237
|
+
} else {
|
|
238
|
+
hydrateMarkersIn(document.body)
|
|
239
|
+
installInterceptor()
|
|
240
|
+
}
|
|
241
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { mkdir, rm } from 'node:fs/promises'
|
|
3
|
+
import { isAbsolute, resolve } from 'node:path'
|
|
4
|
+
import { scanImports } from '../cli/native-routes-emit.ts'
|
|
5
|
+
|
|
6
|
+
export interface IslandsBuildResult {
|
|
7
|
+
/** Absolute path to the output directory passed to brust's Rust side. */
|
|
8
|
+
outDir: string
|
|
9
|
+
/** Number of island chunks emitted (excludes runtime + bootstrap). */
|
|
10
|
+
islandCount: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface BuildIslandsOptions {
|
|
14
|
+
/** Override the output directory. Default: `<cwd>/.brust/islands`. */
|
|
15
|
+
outDir?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Scan a routes entry file for `<Island component={X} />` usage and derive the
|
|
19
|
+
* island chunk list (componentName → absolute source path). Replaces the old
|
|
20
|
+
* static config-file lookup — the chunk set is derived from source.
|
|
21
|
+
*
|
|
22
|
+
* 1. Resolve the entry's page imports via {@link scanImports}.
|
|
23
|
+
* 2. For each page, slice every `<Island … />` tag and capture its
|
|
24
|
+
* `component={Ident}`. A tag with no `component` is a hard error (F3:
|
|
25
|
+
* never silently skip an island).
|
|
26
|
+
* 3. Resolve each captured ident through that page's OWN imports.
|
|
27
|
+
* 4. Dedup islands that reuse the same component+path; throw on two different
|
|
28
|
+
* files whose island components share a name (ids must be app-unique).
|
|
29
|
+
*/
|
|
30
|
+
export function scanIslandChunks(routesEntryFile: string): Map<string, string> {
|
|
31
|
+
const pages = scanImports(routesEntryFile)
|
|
32
|
+
const chunks = new Map<string, string>()
|
|
33
|
+
|
|
34
|
+
for (const pagePath of pages.values()) {
|
|
35
|
+
const source = readFileSync(pagePath, 'utf8')
|
|
36
|
+
const pageImports = scanImports(pagePath)
|
|
37
|
+
|
|
38
|
+
const tags = source.match(/<Island\b[\s\S]*?\/>/g) ?? []
|
|
39
|
+
for (const tag of tags) {
|
|
40
|
+
const compMatch = tag.match(/component=\{\s*(\w+)\s*\}/)
|
|
41
|
+
if (!compMatch) {
|
|
42
|
+
throw new Error(`<Island> tag in ${pagePath} has no \`component={...}\` prop: ${tag}`)
|
|
43
|
+
}
|
|
44
|
+
const ident = compMatch[1]!
|
|
45
|
+
const src = pageImports.get(ident)
|
|
46
|
+
if (!src) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`<Island component={${ident}}> in ${pagePath} has no matching import ` +
|
|
49
|
+
`(expected \`import ${ident} from "..."\`)`,
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
const existing = chunks.get(ident)
|
|
53
|
+
if (existing === undefined) {
|
|
54
|
+
chunks.set(ident, src)
|
|
55
|
+
} else if (existing !== src) {
|
|
56
|
+
throw new Error(
|
|
57
|
+
`island component name "${ident}" is used by two different files ` +
|
|
58
|
+
`(${existing} and ${src}); island component names must be app-unique`,
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
// existing === src → same component reused; dedupe (skip).
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return chunks
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Build the runtime chunks + all island chunks + bootstrap. Returns the
|
|
69
|
+
* absolute output directory; caller passes it to `brust.configureIslandsDir`. */
|
|
70
|
+
export async function buildIslands(
|
|
71
|
+
islands: Map<string, string>,
|
|
72
|
+
options: BuildIslandsOptions = {},
|
|
73
|
+
): Promise<IslandsBuildResult> {
|
|
74
|
+
const outDir = options.outDir
|
|
75
|
+
? isAbsolute(options.outDir)
|
|
76
|
+
? options.outDir
|
|
77
|
+
: resolve(process.cwd(), options.outDir)
|
|
78
|
+
: resolve(process.cwd(), '.brust/islands')
|
|
79
|
+
await rm(outDir, { recursive: true, force: true })
|
|
80
|
+
await mkdir(outDir, { recursive: true })
|
|
81
|
+
|
|
82
|
+
// import.meta.dir points to runtime/islands/.
|
|
83
|
+
const entriesDir = resolve(import.meta.dir, '_entries')
|
|
84
|
+
|
|
85
|
+
// 1. Combined react + react/jsx-runtime (no externals — bundles React).
|
|
86
|
+
await buildOne([`${entriesDir}/react.ts`], outDir, '_react.js', [])
|
|
87
|
+
|
|
88
|
+
// 2. react-dom/client (react external; consumes _react.js via importmap).
|
|
89
|
+
await buildOne([`${entriesDir}/react-dom.ts`], outDir, '_react-dom.js', ['react'])
|
|
90
|
+
|
|
91
|
+
// 3. Per-island chunks (all 3 runtime specifiers external).
|
|
92
|
+
const externals = ['react', 'react/jsx-runtime', 'react-dom/client']
|
|
93
|
+
let count = 0
|
|
94
|
+
for (const [id, entry] of islands) {
|
|
95
|
+
if (!isValidIslandId(id)) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`island id ${JSON.stringify(id)} contains invalid characters; ` +
|
|
98
|
+
`allowed: [A-Za-z0-9_-]+ (matches the server's filename safety check)`,
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
await buildOne([entry], outDir, `${id}.js`, externals)
|
|
102
|
+
count++
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 4. Bootstrap (react + react-dom/client external; uses importmap).
|
|
106
|
+
const bootstrapSrc = resolve(import.meta.dir, 'bootstrap.ts')
|
|
107
|
+
await buildOne([bootstrapSrc], outDir, '_bootstrap.js', externals)
|
|
108
|
+
|
|
109
|
+
return { outDir, islandCount: count }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function buildOne(
|
|
113
|
+
entrypoints: string[],
|
|
114
|
+
outdir: string,
|
|
115
|
+
naming: string,
|
|
116
|
+
external: string[],
|
|
117
|
+
): Promise<void> {
|
|
118
|
+
const result = await Bun.build({
|
|
119
|
+
entrypoints,
|
|
120
|
+
outdir,
|
|
121
|
+
naming,
|
|
122
|
+
format: 'esm',
|
|
123
|
+
target: 'browser',
|
|
124
|
+
external,
|
|
125
|
+
minify: true,
|
|
126
|
+
define: {
|
|
127
|
+
'process.env.NODE_ENV': '"production"',
|
|
128
|
+
},
|
|
129
|
+
})
|
|
130
|
+
if (!result.success) {
|
|
131
|
+
const messages = result.logs.map((l) => String(l)).join('\n')
|
|
132
|
+
throw new Error(`Bun.build failed for ${entrypoints.join(', ')}:\n${messages}`)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Mirrors `is_safe_island_filename` in src/server.rs — keep in sync.
|
|
137
|
+
* Allows [A-Za-z0-9_-]+ only (no dots in the id; dot is for the extension). */
|
|
138
|
+
function isValidIslandId(id: string): boolean {
|
|
139
|
+
if (id.length === 0) return false
|
|
140
|
+
return /^[A-Za-z0-9_-]+$/.test(id)
|
|
141
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Importmap + bootstrap script tags injected into HTML responses that
|
|
2
|
+
// use <Island>. Extracted from runtime/routes.ts so renderBranchStreaming
|
|
3
|
+
// can prepend it during the buffering-sink final assembly.
|
|
4
|
+
export const ISLANDS_IMPORTMAP_AND_BOOTSTRAP =
|
|
5
|
+
'<script type="importmap">' +
|
|
6
|
+
JSON.stringify({
|
|
7
|
+
imports: {
|
|
8
|
+
// Both react and react/jsx-runtime resolve to the SAME chunk; the
|
|
9
|
+
// chunk re-exports both surfaces. Browser fetches it once and slices
|
|
10
|
+
// different named exports for each import statement.
|
|
11
|
+
react: '/_brust/islands/_react.js',
|
|
12
|
+
'react/jsx-runtime': '/_brust/islands/_react.js',
|
|
13
|
+
'react-dom/client': '/_brust/islands/_react-dom.js',
|
|
14
|
+
},
|
|
15
|
+
}) +
|
|
16
|
+
'</script>' +
|
|
17
|
+
'<script type="module" src="/_brust/islands/_bootstrap.js" defer></script>'
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { createElement, type ComponentType, type ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
/** Triggers that activate hydration of an island marker. */
|
|
4
|
+
export type HydrateTrigger = 'load' | 'idle' | 'visible' | 'interaction'
|
|
5
|
+
|
|
6
|
+
export interface IslandProps<P> {
|
|
7
|
+
/** Component rendered server-side INSIDE the marker. Same component
|
|
8
|
+
* the client chunk default-exports — SSR HTML must match the post-hydrate
|
|
9
|
+
* tree to avoid React reconciliation warnings. Its `Component.name` is the
|
|
10
|
+
* island id: it names the chunk (`<name>.js`) and the `data-brust-island`
|
|
11
|
+
* marker the client bootstrap reads, so it must be a stable, named
|
|
12
|
+
* component (no anonymous default export). */
|
|
13
|
+
component: ComponentType<P>
|
|
14
|
+
/** Props passed to the component on both server and client. Must be
|
|
15
|
+
* JSON-serializable (no functions, classes, DOM nodes, etc.). */
|
|
16
|
+
props: P
|
|
17
|
+
/** When to hydrate. Default 'load'. */
|
|
18
|
+
hydrate?: HydrateTrigger
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Module-scope flag flipped by every `<Island>` render. `makeRenderer`
|
|
22
|
+
* reads + resets it once per render to decide whether to prepend the
|
|
23
|
+
* importmap + bootstrap script. */
|
|
24
|
+
let __used = false
|
|
25
|
+
|
|
26
|
+
/** Internal — flipped by Island, read by makeRenderer. */
|
|
27
|
+
export function consumeIslandUsedFlag(): boolean {
|
|
28
|
+
const v = __used
|
|
29
|
+
__used = false
|
|
30
|
+
return v
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function Island<P extends Record<string, unknown>>({
|
|
34
|
+
component: Component,
|
|
35
|
+
props,
|
|
36
|
+
hydrate = 'load',
|
|
37
|
+
}: IslandProps<P>): ReactNode {
|
|
38
|
+
__used = true
|
|
39
|
+
const resolvedId = Component.name
|
|
40
|
+
if (!resolvedId) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
'<Island> component has no `.name`; the island id is derived from ' +
|
|
43
|
+
'`Component.name`. Use a stable named component (e.g. ' +
|
|
44
|
+
'`export default function Counter() {…}`), not an anonymous default ' +
|
|
45
|
+
'export or a minified/inlined function.',
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
const propsJson = JSON.stringify(props)
|
|
49
|
+
return createElement(
|
|
50
|
+
'div',
|
|
51
|
+
{
|
|
52
|
+
'data-brust-island': resolvedId,
|
|
53
|
+
'data-brust-props': propsJson,
|
|
54
|
+
'data-brust-hydrate': hydrate,
|
|
55
|
+
},
|
|
56
|
+
createElement(Component, props),
|
|
57
|
+
)
|
|
58
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Native-jinja island plumbing (Sub-project J / T7).
|
|
2
|
+
//
|
|
3
|
+
// When a native (minijinja) route has an islands manifest, the loader's
|
|
4
|
+
// return value is augmented with per-island context variables before the
|
|
5
|
+
// JSON is shipped into the SAB for napi_render_jinja. Each island i
|
|
6
|
+
// contributes `island_<instance>_props` — the island's props, resolved out of
|
|
7
|
+
// the loader data via a dotted path, JSON-stringified, and HTML-entity-encoded
|
|
8
|
+
// so the value is safe to substitute RAW into a double-quoted attribute (brust's
|
|
9
|
+
// minijinja env has NO autoescape).
|
|
10
|
+
//
|
|
11
|
+
// T9 SCOPE: ssr islands ALSO contribute `island_<instance>_html` — the island's
|
|
12
|
+
// SOURCE component, imported by absolute path and renderToString'd server-side.
|
|
13
|
+
// Client-only islands (ssr:false) still contribute only `_props`. This makes
|
|
14
|
+
// resolveIslandContext async (it awaits the dynamic import of each ssr source).
|
|
15
|
+
|
|
16
|
+
import { readFileSync } from 'node:fs'
|
|
17
|
+
import path from 'node:path'
|
|
18
|
+
// .node build: keep a single react-dom/server build loaded across the runtime
|
|
19
|
+
// (the streaming paths need server.node's renderToPipeableStream). See routes.ts.
|
|
20
|
+
import { renderToString } from 'react-dom/server.node'
|
|
21
|
+
import { createElement } from 'react'
|
|
22
|
+
|
|
23
|
+
/** One entry of a `<template>.islands.json` manifest (enriched by T6). */
|
|
24
|
+
export interface NativeIslandEntry {
|
|
25
|
+
component: string
|
|
26
|
+
instance: number
|
|
27
|
+
propsPath: string
|
|
28
|
+
ssr: boolean
|
|
29
|
+
hydrate: string
|
|
30
|
+
sourcePath: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Walk a dotted path into `data`. Each segment must be an OWN enumerable
|
|
34
|
+
* property — inherited keys (`constructor`, `__proto__`, `toString`, …) yield
|
|
35
|
+
* `undefined` rather than traversing the prototype chain. This blocks both
|
|
36
|
+
* prototype-pollution-style reads AND the downstream crash where a resolved
|
|
37
|
+
* function (`Object`) makes `JSON.stringify` return `undefined`. A missing
|
|
38
|
+
* segment, a nullish/primitive cursor, or a non-own key all yield `undefined`.
|
|
39
|
+
* An empty path returns `data` itself. */
|
|
40
|
+
export function pathInto(data: unknown, propsPath: string): unknown {
|
|
41
|
+
if (propsPath === '') return data
|
|
42
|
+
let cur: unknown = data
|
|
43
|
+
for (const seg of propsPath.split('.')) {
|
|
44
|
+
if (cur == null || typeof cur !== 'object' || !Object.hasOwn(cur, seg)) {
|
|
45
|
+
return undefined
|
|
46
|
+
}
|
|
47
|
+
cur = (cur as Record<string, unknown>)[seg]
|
|
48
|
+
}
|
|
49
|
+
return cur
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** HTML-entity-encode a string for a double-quoted attribute value. Order is
|
|
53
|
+
* load-bearing: `&` MUST be replaced first so the entities introduced by the
|
|
54
|
+
* later replacements aren't themselves double-encoded. Matches the compiler's
|
|
55
|
+
* `push_attr_escaped` charset (& < > ") so server-rendered markup and these
|
|
56
|
+
* props attrs stay consistent. */
|
|
57
|
+
export function entityEncode(s: string): string {
|
|
58
|
+
return s
|
|
59
|
+
.replace(/&/g, '&')
|
|
60
|
+
.replace(/</g, '<')
|
|
61
|
+
.replace(/>/g, '>')
|
|
62
|
+
.replace(/"/g, '"')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Manifests are boot-only/immutable (same as the Rust template registry), so
|
|
66
|
+
// cache by absolute path. We cache BOTH hits and misses (null): a native
|
|
67
|
+
// route with no islands would otherwise pay a throw-and-catch readFileSync on
|
|
68
|
+
// every request, and the immutability guarantee means a missing file won't
|
|
69
|
+
// appear later at runtime. `null` is a valid cache value, so the cache must be
|
|
70
|
+
// keyed on `has()`, not `get() !== undefined`.
|
|
71
|
+
const manifestCache = new Map<string, NativeIslandEntry[] | null>()
|
|
72
|
+
|
|
73
|
+
/** Read `<jinjaDir>/<templateName>.islands.json` and return the parsed entry
|
|
74
|
+
* array, or `null` if the file doesn't exist. `jinjaDir` defaults to
|
|
75
|
+
* `process.cwd()/.brust/jinja`; tests pass a temp dir. Both hits and misses
|
|
76
|
+
* are cached by absolute path. */
|
|
77
|
+
export function loadIslandManifest(
|
|
78
|
+
templateName: string,
|
|
79
|
+
jinjaDir?: string,
|
|
80
|
+
): NativeIslandEntry[] | null {
|
|
81
|
+
const dir = jinjaDir ?? path.resolve(process.cwd(), '.brust/jinja')
|
|
82
|
+
const abs = path.resolve(dir, `${templateName}.islands.json`)
|
|
83
|
+
if (manifestCache.has(abs)) return manifestCache.get(abs)!
|
|
84
|
+
let parsed: NativeIslandEntry[] | null
|
|
85
|
+
try {
|
|
86
|
+
// JSON.parse is INSIDE the try: a present-but-malformed manifest must
|
|
87
|
+
// degrade to null (cached), not throw out of the fast-lane native branch
|
|
88
|
+
// (which runs past the request try/catch — an unguarded throw there is an
|
|
89
|
+
// unhandled rejection that hangs the request instead of a clean fallback).
|
|
90
|
+
parsed = JSON.parse(readFileSync(abs, 'utf8')) as NativeIslandEntry[]
|
|
91
|
+
} catch {
|
|
92
|
+
manifestCache.set(abs, null)
|
|
93
|
+
return null
|
|
94
|
+
}
|
|
95
|
+
manifestCache.set(abs, parsed)
|
|
96
|
+
return parsed
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Cache imported island modules by sourcePath (Bun caches the import anyway;
|
|
100
|
+
// this avoids repeated default-export resolution).
|
|
101
|
+
const componentCache = new Map<string, unknown>()
|
|
102
|
+
|
|
103
|
+
/** Build the per-island context additions for a manifest. Each entry
|
|
104
|
+
* contributes `island_<instance>_props` — the resolved props, JSON-stringified
|
|
105
|
+
* (undefined → null so it stays valid JSON) and entity-encoded. SSR entries
|
|
106
|
+
* (`ssr:true`) ALSO contribute `island_<instance>_html` — the island source
|
|
107
|
+
* component imported by absolute path and renderToString'd. The `instance` is a
|
|
108
|
+
* per-occurrence integer, so it's a safe key fragment. */
|
|
109
|
+
export async function resolveIslandContext(
|
|
110
|
+
manifest: NativeIslandEntry[],
|
|
111
|
+
data: unknown,
|
|
112
|
+
): Promise<Record<string, string>> {
|
|
113
|
+
const out: Record<string, string> = {}
|
|
114
|
+
for (const entry of manifest) {
|
|
115
|
+
const props = pathInto(data, entry.propsPath)
|
|
116
|
+
// `?? null` handles undefined props; the `?? 'null'` belt-and-braces covers
|
|
117
|
+
// the case where JSON.stringify itself returns undefined (e.g. a function
|
|
118
|
+
// value), so entityEncode never receives undefined.
|
|
119
|
+
out['island_' + entry.instance + '_props'] = entityEncode(
|
|
120
|
+
JSON.stringify(props ?? null) ?? 'null',
|
|
121
|
+
)
|
|
122
|
+
if (!entry.ssr) continue
|
|
123
|
+
try {
|
|
124
|
+
let Component = componentCache.get(entry.sourcePath)
|
|
125
|
+
if (Component === undefined) {
|
|
126
|
+
const mod = await import(entry.sourcePath)
|
|
127
|
+
Component = mod.default ?? mod
|
|
128
|
+
componentCache.set(entry.sourcePath, Component)
|
|
129
|
+
}
|
|
130
|
+
if (typeof Component !== 'function') {
|
|
131
|
+
throw new Error(`island "${entry.component}" source has no default-exported component`)
|
|
132
|
+
}
|
|
133
|
+
// Render from the SAME (roundtripped) props value the client gets via
|
|
134
|
+
// JSON.parse(data-brust-props) — byte-identity guarantees no hydration
|
|
135
|
+
// mismatch. props ?? undefined: pass the actual value (or undefined) to
|
|
136
|
+
// the component, NOT the `null` sentinel used for the props string.
|
|
137
|
+
out['island_' + entry.instance + '_html'] = renderToString(
|
|
138
|
+
createElement(Component as any, (props ?? undefined) as any),
|
|
139
|
+
)
|
|
140
|
+
} catch (e) {
|
|
141
|
+
// CONTAINED FAILURE (spec invariant): a throwing ssr island degrades to
|
|
142
|
+
// an empty mount (no _html) + logged warning, rather than 500-ing the
|
|
143
|
+
// page. The mount has no data-brust-csr, so the client will client-render
|
|
144
|
+
// it via hydrateRoot's React-19 mismatch recovery (noisy but functional).
|
|
145
|
+
console.error(
|
|
146
|
+
`[brust] ssr island "${entry.component}" renderToString failed; degrading to client-only:`,
|
|
147
|
+
e,
|
|
148
|
+
)
|
|
149
|
+
// leave _html unset → empty mount
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return out
|
|
153
|
+
}
|