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.
Files changed (63) hide show
  1. package/README.md +110 -0
  2. package/package.json +92 -0
  3. package/runtime/actions.ts +65 -0
  4. package/runtime/bun.lock +236 -0
  5. package/runtime/cli/actions-prebuilt-plugin.ts +97 -0
  6. package/runtime/cli/build.ts +252 -0
  7. package/runtime/cli/dev.ts +92 -0
  8. package/runtime/cli/index.ts +30 -0
  9. package/runtime/cli/native-routes-emit.ts +171 -0
  10. package/runtime/cli/native-shim-plugin.ts +85 -0
  11. package/runtime/cli/new.ts +208 -0
  12. package/runtime/cli/templates/minimal/README.md.tmpl +16 -0
  13. package/runtime/cli/templates/minimal/_gitignore +4 -0
  14. package/runtime/cli/templates/minimal/app.css +6 -0
  15. package/runtime/cli/templates/minimal/components/Counter.tsx +13 -0
  16. package/runtime/cli/templates/minimal/components/Layout.tsx +16 -0
  17. package/runtime/cli/templates/minimal/index.ts +4 -0
  18. package/runtime/cli/templates/minimal/package.json.tmpl +21 -0
  19. package/runtime/cli/templates/minimal/pages/Home.tsx.tmpl +16 -0
  20. package/runtime/cli/templates/minimal/routes.tsx +6 -0
  21. package/runtime/cli/templates/minimal/tsconfig.json +20 -0
  22. package/runtime/client/index.ts +121 -0
  23. package/runtime/config.ts +148 -0
  24. package/runtime/css/build.ts +54 -0
  25. package/runtime/css/component-build.ts +78 -0
  26. package/runtime/css/component-loader.ts +27 -0
  27. package/runtime/css/manifest.ts +51 -0
  28. package/runtime/css/process-modules.ts +56 -0
  29. package/runtime/css/route-deps.ts +33 -0
  30. package/runtime/css/scan-imports.ts +79 -0
  31. package/runtime/css.ts +39 -0
  32. package/runtime/dev/client.ts +49 -0
  33. package/runtime/dev/coordinator.ts +127 -0
  34. package/runtime/dev/inject.ts +17 -0
  35. package/runtime/dev/tui.ts +109 -0
  36. package/runtime/dev/watcher.ts +109 -0
  37. package/runtime/dev/worker-registry.ts +96 -0
  38. package/runtime/dev/ws-channel.ts +99 -0
  39. package/runtime/index.d.ts +199 -0
  40. package/runtime/index.js +604 -0
  41. package/runtime/index.ts +618 -0
  42. package/runtime/islands/__fixtures__/NoDefault.tsx +3 -0
  43. package/runtime/islands/__fixtures__/StubIsland.tsx +7 -0
  44. package/runtime/islands/__fixtures__/ThrowingIsland.tsx +9 -0
  45. package/runtime/islands/_entries/react-dom.ts +7 -0
  46. package/runtime/islands/_entries/react.ts +11 -0
  47. package/runtime/islands/bootstrap.ts +241 -0
  48. package/runtime/islands/build.ts +141 -0
  49. package/runtime/islands/importmap.ts +17 -0
  50. package/runtime/islands/island.tsx +58 -0
  51. package/runtime/islands/native-render.ts +153 -0
  52. package/runtime/mcp/extractor.ts +160 -0
  53. package/runtime/mcp/manifest.ts +50 -0
  54. package/runtime/mcp/schema.ts +124 -0
  55. package/runtime/mcp/server.ts +250 -0
  56. package/runtime/render/inject-css-link.ts +59 -0
  57. package/runtime/render/inject-dev-client.ts +49 -0
  58. package/runtime/render/stream.ts +304 -0
  59. package/runtime/routes.ts +1406 -0
  60. package/runtime/scan-actions.ts +172 -0
  61. package/runtime/sse/handler.ts +85 -0
  62. package/runtime/tsconfig.json +14 -0
  63. 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, '&amp;')
60
+ .replace(/</g, '&lt;')
61
+ .replace(/>/g, '&gt;')
62
+ .replace(/"/g, '&quot;')
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
+ }