@brust/core 2.0.0-alpha.1 → 2.0.0-alpha.10

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 +66 -6
  2. package/native/index.d.ts +87 -5
  3. package/native/index.js +61 -54
  4. package/package.json +22 -9
  5. package/src/actions/client.ts +22 -5
  6. package/src/actions/load.ts +2 -1
  7. package/src/actions/prefix.ts +27 -3
  8. package/src/actions-bridge.ts +76 -19
  9. package/src/browser.ts +3 -1
  10. package/src/build/actions-only.ts +86 -0
  11. package/src/build/build-eval.ts +319 -0
  12. package/src/build/build-icons.ts +69 -0
  13. package/src/build/bundle.ts +278 -43
  14. package/src/build/cf-assets.ts +83 -0
  15. package/src/build/cf-core.ts +90 -0
  16. package/src/build/cf-entry.ts +76 -0
  17. package/src/build/cf-wrangler.ts +169 -0
  18. package/src/build/cloudflare.ts +317 -0
  19. package/src/build/compile.ts +72 -4
  20. package/src/build/config.ts +274 -15
  21. package/src/build/css.ts +275 -0
  22. package/src/build/features.ts +110 -0
  23. package/src/build/index.ts +223 -20
  24. package/src/build/manifest.ts +59 -8
  25. package/src/build/pkg-src.ts +383 -0
  26. package/src/build/scan.ts +61 -20
  27. package/src/build/server-only.ts +39 -0
  28. package/src/build/ssg-render.ts +65 -0
  29. package/src/build/ssg.ts +146 -0
  30. package/src/build/static-worker.ts +91 -0
  31. package/src/build/static.ts +258 -0
  32. package/src/build/tailwind.ts +117 -0
  33. package/src/build/tsconfig-paths.ts +67 -0
  34. package/src/build-time.ts +10 -0
  35. package/src/cache-adapter/bridge.ts +91 -0
  36. package/src/cache-adapter/conformance.ts +226 -0
  37. package/src/cache-adapter/index.ts +10 -0
  38. package/src/cache-adapter/load.ts +144 -0
  39. package/src/cache-adapter/memory.ts +229 -0
  40. package/src/cache-adapter/runtime.ts +124 -0
  41. package/src/cache-adapter/types.ts +96 -0
  42. package/src/cache-adapter/workers.ts +118 -0
  43. package/src/cache.ts +4 -1
  44. package/src/cf.ts +19 -0
  45. package/src/cli.ts +50 -2
  46. package/src/config.ts +127 -25
  47. package/src/css-modules.d.ts +8 -0
  48. package/src/dev/bun.ts +365 -0
  49. package/src/dev/client.ts +92 -0
  50. package/src/dev/devtools-data.ts +23 -0
  51. package/src/dev/devtools.ts +356 -0
  52. package/src/dev/diag.ts +30 -0
  53. package/src/dev/proxy.ts +236 -0
  54. package/src/dev/tui.ts +144 -0
  55. package/src/dev/watch.ts +81 -0
  56. package/src/dev.ts +93 -0
  57. package/src/handlers.ts +282 -0
  58. package/src/index.ts +3 -1
  59. package/src/native.ts +49 -2
  60. package/src/routes.ts +20 -1
  61. package/src/run.ts +48 -11
  62. package/src/server-handlers.ts +32 -0
  63. package/src/worker.ts +129 -245
@@ -9,17 +9,29 @@
9
9
  import { createHash } from 'node:crypto'
10
10
  import { existsSync, mkdirSync, realpathSync, statSync, writeFileSync } from 'node:fs'
11
11
  import { builtinModules } from 'node:module'
12
- import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'
12
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
13
13
  import type { BunPlugin } from 'bun'
14
14
  import type { Compiled } from './compile'
15
+ import { cssPlugin } from './css'
15
16
  import { BuildError } from './errors'
17
+ import { isServerOnlyMarker, serverOnlyEmptyPlugin } from './server-only'
18
+ import { RUNTIME_FEATURES } from './features'
19
+ import { packageDirOf } from './pkg-src'
16
20
 
17
21
  const REACT_EXTERNALS = ['react', 'react/*', 'react-dom', 'react-dom/*']
18
- /** Production React (and the production JSX transform: `react/jsx-runtime`, never `jsxDEV`). */
19
- const PRODUCTION = { 'process.env.NODE_ENV': JSON.stringify('production') }
22
+ /** Production React (and the production JSX transform: `react/jsx-runtime`, never `jsxDEV`). `brust dev` builds set
23
+ * `BRUST_BUILD_MODE=development` for readable errors and hydration diffs (R13.4); `brust build` never does. */
24
+ export const PRODUCTION: Record<string, string> = { 'process.env.NODE_ENV': JSON.stringify(process.env.BRUST_BUILD_MODE === 'development' ? 'development' : 'production'), __BRUST_DEV__: 'false' }
25
+
26
+ /** `brust build --dev` (L14, R14.3): the client runtime exposes its live instances on a dev global. `__BRUST_DEV__` is a
27
+ * define, `false` in every other build, so the minifier removes the dev code from production bundles. */
28
+ export function setDevBuild(dev: boolean): void {
29
+ PRODUCTION.__BRUST_DEV__ = dev ? 'true' : 'false'
30
+ }
20
31
 
21
32
  const matchesExternal = (spec: string, external: string[]) =>
22
- external.some((p) => (p.endsWith('/*') ? spec.startsWith(p.slice(0, -1)) : spec === p))
33
+ // A trailing `*` is a prefix (`elysia/*`, `node:*`, `cloudflare:*`): the generated CF entry imports `cloudflare:workers`.
34
+ external.some((p) => (p.endsWith('*') ? spec.startsWith(p.slice(0, -1)) : spec === p))
23
35
 
24
36
  /** Staged artifact (by its absolute and its real path) → the component SOURCE file it was
25
37
  * generated from. */
@@ -33,6 +45,21 @@ export interface BrowserCtx {
33
45
  serverOnly: string[]
34
46
  /** Real paths of server-only files (the actions entry, R8): a browser import of one is refused. */
35
47
  serverFiles?: string[]
48
+ /** L19: CSS Module class maps for `cssPlugin` (absent: no CSS was built). */
49
+ cssMaps?: Map<string, Record<string, string>>
50
+ }
51
+
52
+ /** `file` sits under `root`. */
53
+ const inside = (root: string, file: string): boolean => file === root || file.startsWith(root.endsWith(sep) ? root : root + sep)
54
+
55
+ /** The package a bare specifier names is installed in a `node_modules` above `from` (node's own lookup). */
56
+ function visibleFrom(spec: string, from: string): boolean {
57
+ const parts = spec.split('/')
58
+ const name = spec.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]!
59
+ for (let d = from; ; d = dirname(d)) {
60
+ if (existsSync(join(d, 'node_modules', name, 'package.json'))) return true
61
+ if (dirname(d) === d) return false
62
+ }
36
63
  }
37
64
 
38
65
  /** Resolution for staged artifacts (`importer` = a staged file registered in `sourceOf`): relative
@@ -50,6 +77,14 @@ export function sourceDirPlugin(sourceOf: SourceOf, appRoot: string, external: s
50
77
  })
51
78
  b.onResolve({ filter: /^[^./]/ }, (a) => {
52
79
  if (!sourceOf.has(a.importer) || matchesExternal(a.path, external)) return undefined
80
+ // F153c: an artifact compiled from a package's source imports what that package sees.
81
+ const pkg = packageDirOf(sourceOf.get(a.importer)!)
82
+ if (pkg !== undefined) return { path: Bun.resolveSync(a.path, pkg) }
83
+ // A package the staged file can see from where it sits resolves natively: the bundler then picks the package's ESM
84
+ // entry (`module` / `exports.import`) and tree-shakes it. `Bun.resolveSync` answers with the CJS `main`, which
85
+ // bundles the whole package (recharts: +85 KB gzip on the chart page). Packages only the app root sees (a staged
86
+ // file under `dist/`, an `--out-dir` outside the app) keep the pinned resolution, so one copy of each package.
87
+ if (inside(appRoot, a.importer) && visibleFrom(a.path, dirname(a.importer))) return undefined
53
88
  return { path: Bun.resolveSync(a.path, appRoot) }
54
89
  })
55
90
  },
@@ -100,6 +135,7 @@ const BROWSER_POLYFILLS = new Set([
100
135
  * also checked by the file it resolves to (Bun's resolver honours tsconfig `paths`, so an alias
101
136
  * cannot bypass the `*.server.*` / path rules). */
102
137
  function isServerOnly(spec: string, importer: string, ctx: BrowserCtx): boolean {
138
+ if (isServerOnlyMarker(spec)) return true // `import 'server-only'`: the marker never reaches a browser bundle, whether or not the package throws
103
139
  if (spec.startsWith('node:') || spec.startsWith('bun:') || spec === 'bun') return true
104
140
  const from = dirname(ctx.sourceOf.get(importer) ?? importer)
105
141
  const bare = spec.split('/')[0]!
@@ -116,6 +152,10 @@ function isServerOnly(spec: string, importer: string, ctx: BrowserCtx): boolean
116
152
  return false // unresolvable: Bun reports it
117
153
  }
118
154
  if (/\.server\.[^/]*$/.test(basename(file))) return true
155
+ // Any other name (an alias, a relative path) that resolves INTO the `server-only` package is the marker too.
156
+ try {
157
+ if (/[\\/]node_modules[\\/]server-only[\\/]/.test(realpathSync(file))) return true
158
+ } catch {}
119
159
  try {
120
160
  if (ctx.serverFiles?.includes(realpathSync(file))) return true
121
161
  } catch {}
@@ -145,7 +185,7 @@ function serverOnlyPlugin(ctx: BrowserCtx, violations: string[]): BunPlugin {
145
185
  }
146
186
 
147
187
  const hex = (s: string) => createHash('sha256').update(s).digest('hex').slice(0, 10)
148
- const write = (file: string, text: string) => {
188
+ export const write = (file: string, text: string) => {
149
189
  mkdirSync(dirname(file), { recursive: true })
150
190
  writeFileSync(file, text)
151
191
  }
@@ -165,7 +205,7 @@ async function browserBuild(
165
205
  try {
166
206
  res = await Bun.build({
167
207
  ...config,
168
- plugins: [serverOnlyPlugin(ctx, violations), ...(config.plugins ?? [])],
208
+ plugins: [serverOnlyPlugin(ctx, violations), cssPlugin(ctx.cssMaps ?? new Map()), ...(config.plugins ?? [])],
169
209
  target: 'browser',
170
210
  format: 'esm',
171
211
  minify: true,
@@ -197,52 +237,86 @@ async function browserBuild(
197
237
  return entries
198
238
  }
199
239
 
200
- /** D3/C6: one runtime instance per page. Island and native client builds resolve the reactive core and
201
- * the store API to the runtime bundle URL (external), exactly as the compiler's `runtimeImport`. The
202
- * React bridge (`useStore`) is bundled (it holds no module state), but ITS imports of the core are the
203
- * runtime too. Registered after `serverOnlyPlugin`, before `sourceDirPlugin` (which would resolve the
240
+ /** The client runtime's URLs (L33): the core every page loads, the `store` module (`defineStore` + the reactive
241
+ * primitives a store's code imports), and the feature modules. Core and features are single shared ESM files, so a
242
+ * page has one signal graph and one binder registry however many features it loads. */
243
+ export interface RuntimeUrls {
244
+ core: string
245
+ store: string
246
+ }
247
+
248
+ /** D3/C6: one runtime instance per page. Island and native client builds resolve the reactive core to the core URL
249
+ * and the store API to the store module's URL (external), exactly as the compiler's `runtimeImport` (the core). The
250
+ * React bridge (`useStore`) is bundled (it holds no module state), but ITS imports of the core are the store
251
+ * module too. Registered after `serverOnlyPlugin`, before `sourceDirPlugin` (which would resolve the
204
252
  * bare specifiers to files). */
205
- export function runtimeAliasPlugin(runtimeUrl: string): BunPlugin {
253
+ export function runtimeAliasPlugin(urls: RuntimeUrls): BunPlugin {
206
254
  return {
207
255
  name: 'brust-runtime-alias',
208
256
  setup(b) {
209
- b.onResolve({ filter: /^@brust\/(runtime-dom(\/store)?|core\/store)$/ }, () => ({ path: runtimeUrl, external: true }))
257
+ b.onResolve({ filter: /^@brust\/runtime-dom(\/core)?$/ }, () => ({ path: urls.core, external: true }))
258
+ b.onResolve({ filter: /^@brust\/(runtime-dom|core)\/store$/ }, () => ({ path: urls.store, external: true }))
210
259
  let bridge: string | undefined
211
260
  b.onResolve({ filter: /^@brust\/(runtime-dom|core)\/store\/react$/ }, () => {
212
261
  bridge ??= Bun.resolveSync('@brust/runtime-dom/store/react', import.meta.dir) // core lane file; resolved lazily
213
262
  return { path: bridge }
214
263
  })
215
- b.onResolve({ filter: /^\.\.?\// }, (a) => (bridge !== undefined && a.importer === bridge ? { path: runtimeUrl, external: true } : undefined))
264
+ b.onResolve({ filter: /^\.\.?\// }, (a) => (bridge !== undefined && a.importer === bridge ? { path: urls.store, external: true } : undefined))
216
265
  },
217
266
  }
218
267
  }
219
268
 
220
- /** `dist/client/runtime-<hex>.js`: runtime-dom, re-exported (client chunks import `signal`,
221
- * `defineBehavior`, … from this URL) and mounted on `document.documentElement` (contract 6). */
222
- export async function buildRuntime(dist: string, ctx: BrowserCtx): Promise<string> {
223
- const entry = join(dist, '.stage', 'runtime.ts')
224
- write(entry, "export * from '@brust/runtime-dom'\nimport { mount } from '@brust/runtime-dom'\nmount(document.documentElement)\n")
269
+ /** What `buildRuntime` wrote: `client/<file>` paths (relative to dist). */
270
+ export interface RuntimeAssets {
271
+ /** The core: `export *` of the runtime core + `mount(document.documentElement)` (contract 6). */
272
+ core: string
273
+ store: string
274
+ /** Feature module name → file; every module is built, a route links the ones its components need. */
275
+ features: Record<string, string>
276
+ }
277
+
278
+ /** `dist/client/runtime-<hex>.js` (the core; client chunks import `signal`, `defineBehavior`, … from this URL), then
279
+ * one `rt-<feature>-<hex>.js` per feature module and `rt-store-<hex>.js`, each importing the core by that URL. */
280
+ export async function buildRuntime(dist: string, ctx: BrowserCtx): Promise<RuntimeAssets> {
225
281
  // Resolved from this package (its dependency), not from wherever `dist/` sits.
226
- const runtimeDom = Bun.resolveSync('@brust/runtime-dom', import.meta.dir)
227
- const res = await browserBuild(dist, 'runtime', ctx, {
228
- entrypoints: [entry],
282
+ const dir = dirname(Bun.resolveSync('@brust/runtime-dom', import.meta.dir))
283
+ const coreEntry = join(dist, '.stage', 'runtime.ts')
284
+ write(coreEntry, "export * from '@brust/runtime-dom/core'\nimport { mount } from '@brust/runtime-dom/core'\nmount(document.documentElement)\n")
285
+ const core = (
286
+ await browserBuild(dist, 'runtime', ctx, {
287
+ entrypoints: [coreEntry],
288
+ plugins: [{ name: 'brust-runtime-dom', setup: (b) => void b.onResolve({ filter: /^@brust\/runtime-dom\/core$/ }, () => ({ path: join(dir, 'core.ts') })) }],
289
+ })
290
+ ).get('runtime')!
291
+ // Features import the core's API (`core-api`, and `core` for the store's re-exports) from the core file by URL.
292
+ const coreUrl = `/_brust/${core}`
293
+ const coreFiles = new Set(['core-api', 'core'].map((f) => join(dir, f)))
294
+ const names = [...RUNTIME_FEATURES, 'store'] as const
295
+ const entries = names.map((n) => {
296
+ const file = join(dist, '.stage', `rt-${n}.ts`)
297
+ write(file, `import ${JSON.stringify(join(dir, 'features', n))}\nexport * from ${JSON.stringify(join(dir, 'features', n))}\n`)
298
+ return file
299
+ })
300
+ const built = await browserBuild(dist, 'runtime features', ctx, {
301
+ entrypoints: entries,
229
302
  plugins: [
230
303
  {
231
- name: 'brust-runtime-dom',
232
- setup(b) {
233
- b.onResolve({ filter: /^@brust\/runtime-dom$/ }, () => ({ path: runtimeDom }))
234
- },
304
+ name: 'brust-runtime-core-external',
305
+ setup: (b) =>
306
+ void b.onResolve({ filter: /^\.\.?\/(core-api|core)$/ }, (a) => (coreFiles.has(resolve(dirname(a.importer), a.path)) ? { path: coreUrl, external: true } : undefined)),
235
307
  },
236
308
  ],
237
309
  })
238
- return res.get('runtime')!
310
+ const features: Record<string, string> = {}
311
+ for (const n of RUNTIME_FEATURES) features[n] = built.get(`rt-${n}`)!
312
+ return { core, store: built.get('rt-store')!, features }
239
313
  }
240
314
 
241
315
  /** One entry chunk per component with client JS (`<id>-<hex>.js`), split: a module two chunks
242
316
  * import (a store) lands once in a shared `chunk-<hex>.js`, so one page has one instance of it.
243
317
  * The runtime import stays the absolute `/_brust/client/runtime-<hex>.js` URL the compiler
244
318
  * printed (external: one runtime for every chunk). */
245
- export async function buildClientChunks(dist: string, compiled: Map<string, Compiled>, ctx: BrowserCtx, runtimeUrl: string): Promise<Map<string, string>> {
319
+ export async function buildClientChunks(dist: string, compiled: Map<string, Compiled>, ctx: BrowserCtx, runtimeUrl: RuntimeUrls): Promise<Map<string, string>> {
246
320
  const entrypoints: string[] = []
247
321
  for (const c of compiled.values()) {
248
322
  if (c.clientJs === undefined) continue
@@ -261,23 +335,144 @@ export async function buildClientChunks(dist: string, compiled: Map<string, Comp
261
335
  }
262
336
 
263
337
  /** Registers a written artifact (by its path and its real path: the importer Bun reports). */
264
- function stage(sourceOf: SourceOf, file: string, c: Compiled): void {
338
+ export function stage(sourceOf: SourceOf, file: string, c: Compiled): void {
265
339
  sourceOf.set(file, c.file)
266
340
  sourceOf.set(realpathSync(file), c.file)
267
341
  }
268
342
 
269
343
  const isReact = (c: Compiled) => typeof c.ir.tier === 'object' && c.ir.tier !== null && 'React' in c.ir.tier
270
344
 
345
+ /** An import/export binding spelled as the module wrote it: bare when an identifier, else a string name. */
346
+ const bindingName = (name: string) => (/^[A-Za-z_$][\w$]*$/.test(name) ? name : JSON.stringify(name))
347
+
348
+ /** The `import` line that brings a react-tier component's code in as `local`: the binding its importer used
349
+ * (`import Foo` → the default export, `import { Foo }` / `export { A as Foo }` / `export const Foo` → `Foo`),
350
+ * or for a package export (L22) the export itself. Each component of a file with several is its own island
351
+ * entry; the bundler keeps ONE instance of the file for all of them (splitting). */
352
+ export function componentImport(c: Compiled, local: string): string {
353
+ const ext = c.ir.external
354
+ if (ext?.member?.length) return memberImport(ext as { source: string; export: string; member: string[] }, local)
355
+ const [from, binding] = ext ? [JSON.stringify(ext.source), ext.export] : [JSON.stringify(c.file), c.ir.export ?? 'default']
356
+ return binding === 'default' ? `import ${local} from ${from}` : `import { ${bindingName(binding)} as ${local} } from ${from}`
357
+ }
358
+
359
+ /** A member of a package import (`<DialogPrimitive.Root>`, S1.4 R34.35): the export (a name, `default`, or `*` for a
360
+ * namespace import) is bound once and the member is read off it. A path the package does not have is `undefined`
361
+ * (the bundler does not check member access), so it throws a named error when the island entry or the SSR job loads. */
362
+ function memberImport(ext: { source: string; export: string; member: string[] }, local: string): string {
363
+ const ns = `${local}__ns`
364
+ const from = JSON.stringify(ext.source)
365
+ const head = ext.export === 'default' ? `import ${ns} from ${from}` : ext.export === '*' ? `import * as ${ns} from ${from}` : `import { ${bindingName(ext.export)} as ${ns} } from ${from}`
366
+ const path = (ext.export === '*' ? ext.member : [ext.export, ...ext.member]).join('.')
367
+ const read = ext.member.map((m) => (/^[A-Za-z_$][\w$]*$/.test(m) ? `.${m}` : `[${JSON.stringify(m)}]`)).join('')
368
+ return `${head}\nconst ${local} = ${ns}${read}\nif (${local} === undefined) throw new Error(${JSON.stringify(`brust: ${ext.source} has no export ${path}`)})`
369
+ }
370
+
371
+ /** The component ids a React island receives inside its lowered JSX children (shadcn S1, R34.21): the union over
372
+ * every file that passes children to it. `undefined` = no file does, the island has no revive code. */
373
+ export function nestedOf(compiled: Map<string, Compiled>, id: string, routeRoots?: ReadonlySet<string>): string[] | undefined {
374
+ // F158 (R34.70): a route component's props are request data, so it is never a revive host, whatever a caller did.
375
+ if (routeRoots?.has(id)) return undefined
376
+ let out: Set<string> | undefined
377
+ for (const c of compiled.values()) {
378
+ for (const ref of c.ir.children ?? []) {
379
+ // `nested: []` (element-only children) still makes the island a host: its children are a JSON tree to revive.
380
+ if (ref.id === id && ref.nested !== undefined) {
381
+ out ??= new Set()
382
+ for (const n of ref.nested) out.add(n)
383
+ }
384
+ }
385
+ }
386
+ return out && [...out].sort()
387
+ }
388
+
389
+ /** F158 (R34.71): the element-prop names (`$x`) the compiler wrote at any call site of the island `id`: the only names
390
+ * its revive may revive. */
391
+ export function xOf(compiled: Map<string, Compiled>, id: string): string[] {
392
+ const out = new Set<string>()
393
+ for (const c of compiled.values()) for (const ref of c.ir.children ?? []) if (ref.id === id) for (const k of ref.x ?? []) out.add(k)
394
+ return [...out].sort()
395
+ }
396
+
397
+ /** Every component some island revives (`nestedOf`), whatever its tier: its module must export it. */
398
+ function revivedIds(compiled: Map<string, Compiled>): Set<string> {
399
+ const out = new Set<string>()
400
+ for (const c of compiled.values()) for (const ref of c.ir.children ?? []) for (const n of ref.nested ?? []) out.add(n)
401
+ return out
402
+ }
403
+
404
+ /** The code that turns an island's `children` prop (a JSON tree the compiler lowered, `{ $brust: 'el' | 'fr' | 'cmp' }`)
405
+ * back into React elements. One copy per entry that needs it, shared by the island chunk and the SSR job so both sides
406
+ * build the same tree. `reg` maps a component id to the component. */
407
+ export const reviveSource = (reg: string): string =>
408
+ `const __reg: Record<string, any> = Object.assign(Object.create(null), ${reg})\n` +
409
+ `const __own = Object.prototype.hasOwnProperty\n` +
410
+ // The tree is compiler output, but props travel in page markup: read it as untrusted. A tag must be a plain element
411
+ // name, a component must be in this island's registry (own keys only), and the raw-HTML prop is never revived.
412
+ `const __props = (p: any): any => { const o: any = {}; if (p && typeof p === 'object') for (const k of Object.keys(p)) if (k !== '__proto__' && k !== 'dangerouslySetInnerHTML' && k !== 'children' && k !== 'ref' && k !== 'key') o[k] = p[k]; return o }\n` +
413
+ // `x` (on a component node) and `$x` (on the island's props) are the compiler's list of props that hold an element
414
+ // tree. Only those names are revived, and only when the prop is an own key of the copy; data is never revived.
415
+ `const __xs = (o: any, x: any): any => { if (Array.isArray(x)) for (const k of x) if (typeof k === 'string' && k.charAt(0) !== '$' && __own.call(o, k)) o[k] = __rv(o[k]); return o }\n` +
416
+ `const __rv = (n: any): any => {\n` +
417
+ ` if (n === null || typeof n !== 'object') return n\n` +
418
+ ` const kids = Array.isArray(n.c) ? n.c.map(__rv) : []\n` +
419
+ ` if (n.$brust === 'fr') return createElement(Fragment, null, ...kids)\n` +
420
+ ` if (n.$brust === 'el') { if (typeof n.t !== 'string' || !/^[a-zA-Z][a-zA-Z0-9-]*$/.test(n.t)) throw new Error('brust: island children hold an invalid element name'); return createElement(n.t, __props(n.p), ...kids) }\n` +
421
+ ` if (n.$brust === 'cmp') { if (typeof n.id !== 'string' || !__own.call(__reg, n.id)) throw new Error('brust: island children name the component ' + String(n.id) + ', which this island does not import'); return createElement(__reg[n.id], __xs(__props(n.p), n.x), ...kids) }\n` +
422
+ ` throw new Error('brust: island children hold an unknown node')\n` +
423
+ `}\n` +
424
+ // `X` is the host's compiler-declared element-prop names (R34.71): a `$x` from the props can only select among them.
425
+ // A `$`-key other than `$x` is never the compiler's (R34.72): it throws instead of rendering (a 500, not a revive).
426
+ `const __mk = (C: any, props: any, X: string[] = []) => {\n` +
427
+ ` for (const k of Object.keys(props)) if (k.charAt(0) === '$' && k !== '$x') throw new Error('brust: island props hold the reserved key ' + k)\n` +
428
+ ` const { children, $x, ...rest } = props\n` +
429
+ ` __xs(rest, Array.isArray($x) ? $x.filter((k: any) => X.includes(k)) : undefined)\n` +
430
+ ` return createElement(C, rest, ...(children === undefined ? [] : Array.isArray(children) ? children : [children]).map(__rv))\n` +
431
+ `}\n`
432
+
433
+ /** A helper component its module does not export (`ir.unexported`: `function SheetOverlay() {…}` rendered by an exported
434
+ * `SheetContent`) that still renders as an island has nothing to import: the module is loaded with `export { Name }`
435
+ * appended, for the island chunks and the SSR jobs alike (one module, one instance, either way). */
436
+ export function exposePlugin(compiled: Map<string, Compiled>): BunPlugin {
437
+ const names = new Map<string, Set<string>>()
438
+ const revived = revivedIds(compiled)
439
+ for (const c of compiled.values()) {
440
+ if (!(isReact(c) || revived.has(c.id)) || !c.ir.unexported || c.ir.external || !c.ir.export) continue
441
+ for (const f of new Set([c.file, realpathSync(c.file)])) names.set(f, (names.get(f) ?? new Set()).add(c.ir.export))
442
+ }
443
+ return {
444
+ name: 'brust-expose-local-islands',
445
+ setup(b) {
446
+ b.onLoad({ filter: /\.[cm]?[jt]sx?$/ }, async (a) => {
447
+ const exposed = names.get(a.path)
448
+ if (!exposed) return undefined
449
+ const loader = /\.[cm]?ts$/.test(a.path) ? 'ts' : /\.[cm]?js$/.test(a.path) ? 'js' : a.path.endsWith('.jsx') ? 'jsx' : 'tsx'
450
+ return { contents: `${await Bun.file(a.path).text()}\nexport { ${[...exposed].join(', ')} }\n`, loader }
451
+ })
452
+ },
453
+ }
454
+ }
455
+
271
456
  /** The island entry of a react-tier component: registers `(host, props) => …` under its id. A
272
457
  * `client_only` component was never server-rendered (its host is empty), so it mounts with
273
458
  * `createRoot`; hydrating it would fail (React #418). Everything else hydrates the SSR markup. */
274
- export function islandShim(c: Compiled): string {
459
+ export function islandShim(c: Compiled, compiled?: Map<string, Compiled>, routeRoots?: ReadonlySet<string>): string {
275
460
  const clientOnly = c.ir.tier?.React?.client_only === true
461
+ const nested = compiled && nestedOf(compiled, c.id, routeRoots)
462
+ const element = nested ? `__mk(Comp, props, ${JSON.stringify(xOf(compiled!, c.id))})` : 'createElement(Comp, props)'
276
463
  const [api, mount] = clientOnly
277
- ? ['createRoot', `createRoot(host, { identifierPrefix: ${JSON.stringify(c.id)} }).render(createElement(Comp, props))`]
278
- : ['hydrateRoot', `hydrateRoot(host, createElement(Comp, props), { identifierPrefix: ${JSON.stringify(c.id)} })`]
464
+ ? ['createRoot', `createRoot(host, { identifierPrefix: ${JSON.stringify(c.id)} }).render(${element})`]
465
+ : ['hydrateRoot', `hydrateRoot(host, ${element}, { identifierPrefix: ${JSON.stringify(c.id)} })`]
466
+ let head = ''
467
+ if (nested && compiled) {
468
+ const comps = nested.map((id) => compiled.get(id)).filter((n): n is Compiled => n !== undefined)
469
+ head =
470
+ `import { Fragment } from 'react'\n` +
471
+ comps.map((n, i) => `${componentImport(n, `N${i}`)}\n`).join('') +
472
+ reviveSource(`{ ${comps.map((n, i) => `${JSON.stringify(n.id)}: N${i}`).join(', ')} }`)
473
+ }
279
474
  return (
280
- `import { ${api} } from 'react-dom/client'\nimport { createElement } from 'react'\nimport Comp from ${JSON.stringify(c.file)}\n` +
475
+ `import { ${api} } from 'react-dom/client'\nimport { createElement } from 'react'\n${componentImport(c, 'Comp')}\n${head}` +
281
476
  `;((globalThis as any).__brustIslands ||= []).push([${JSON.stringify(c.id)}, (host: Element, props: any) => { ${mount} }])\n` +
282
477
  `;(globalThis as any).__brustIslandReady?.()\n`
283
478
  )
@@ -285,12 +480,26 @@ export function islandShim(c: Compiled): string {
285
480
 
286
481
  /** One island chunk per react-tier component (`react-<id>-<hex>.js`, contract 3), React split
287
482
  * into a shared chunk the island chunks import relatively. */
288
- export async function buildReactChunks(dist: string, compiled: Map<string, Compiled>, ctx: BrowserCtx, runtimeUrl: string): Promise<Map<string, string>> {
483
+ /** The react-tier components that get an island entry: all of them, except a package export (S1.4: every member of a
484
+ * package import is one) that no static or native template places as an island. Inside a React component, or as a
485
+ * component an island revives from its lowered children (`nestedOf`, S1 R34.21: those sit in the parent's
486
+ * `ir.children` too), it is an ordinary element of that tree, not an island, and an entry for it only reshuffles the
487
+ * shared chunks (measured: +11-24 KB gzip on a page that never loads it). Placed = a `<brust-island data-id>` marker
488
+ * in the printed template, the one thing the server hydrates. */
489
+ export function islandEntries(compiled: Map<string, Compiled>): Compiled[] {
490
+ const placed = new Set<string>()
491
+ for (const p of compiled.values()) {
492
+ if (isReact(p)) continue
493
+ for (const m of p.jinja.matchAll(/<brust-island\b[^>]*\bdata-id="([^"]+)"/g)) placed.add(m[1]!)
494
+ }
495
+ return [...compiled.values()].filter((c) => isReact(c) && (!c.ir.external || placed.has(c.id)))
496
+ }
497
+
498
+ export async function buildReactChunks(dist: string, compiled: Map<string, Compiled>, ctx: BrowserCtx, runtimeUrl: RuntimeUrls, routeRoots?: ReadonlySet<string>): Promise<Map<string, string>> {
289
499
  const entrypoints: string[] = []
290
- for (const c of compiled.values()) {
291
- if (!isReact(c)) continue
500
+ for (const c of islandEntries(compiled)) {
292
501
  const file = join(dist, '.stage', 'react', `react-${c.id}.tsx`)
293
- write(file, islandShim(c))
502
+ write(file, islandShim(c, compiled, routeRoots))
294
503
  stage(ctx.sourceOf, file, c)
295
504
  entrypoints.push(file)
296
505
  }
@@ -299,7 +508,7 @@ export async function buildReactChunks(dist: string, compiled: Map<string, Compi
299
508
  entrypoints,
300
509
  splitting: true,
301
510
  external: ['/_brust/*'],
302
- plugins: [runtimeAliasPlugin(runtimeUrl), sourceDirPlugin(ctx.sourceOf, ctx.appRoot)],
511
+ plugins: [runtimeAliasPlugin(runtimeUrl), sourceDirPlugin(ctx.sourceOf, ctx.appRoot), exposePlugin(compiled)],
303
512
  })
304
513
  return new Map([...out].map(([stem, path]) => [stem.slice('react-'.length), path]))
305
514
  }
@@ -334,16 +543,33 @@ const ACTIONS_EXTERNALS = [...JOBS_EXTERNALS, 'elysia', 'elysia/*', '@elysia/*']
334
543
 
335
544
  /** `dist/actions.js` (M3-F E1): the actions entry for the worker (`target: 'bun'`). No `define`: Elysia
336
545
  * and app code read NODE_ENV at runtime (run.ts sets production). */
337
- export async function buildActions(dist: string, entry: string): Promise<string> {
338
- const res = await Bun.build({ entrypoints: [entry], target: 'bun', format: 'esm', external: ACTIONS_EXTERNALS, plugins: [workerStorePlugin] })
546
+ export async function buildActions(dist: string, entry: string, cssMaps: Map<string, Record<string, string>> = new Map()): Promise<string> {
547
+ const res = await Bun.build({ entrypoints: [entry], target: 'bun', format: 'esm', external: ACTIONS_EXTERNALS, plugins: [serverOnlyEmptyPlugin(), cssPlugin(cssMaps), workerStorePlugin] })
339
548
  if (!res.success) throw new BuildError('bundle', `actions: ${res.logs.map(String).join('\n')}`)
340
549
  write(join(dist, 'actions.js'), await res.outputs[0]!.text())
341
550
  return 'actions.js'
342
551
  }
343
552
 
553
+ /** `dist/cache-adapter.js` (cache adapters R8): a custom adapter module for the worker (`target: 'bun'`), with
554
+ * the actions externals so it shares the app's dependencies. */
555
+ export async function buildCacheAdapter(dist: string, entry: string, cssMaps: Map<string, Record<string, string>> = new Map()): Promise<string> {
556
+ const res = await Bun.build({ entrypoints: [entry], target: 'bun', format: 'esm', external: ACTIONS_EXTERNALS, plugins: [serverOnlyEmptyPlugin(), cssPlugin(cssMaps)] })
557
+ if (!res.success) throw new BuildError('bundle', `cache adapter: ${res.logs.map(String).join('\n')}`)
558
+ write(join(dist, 'cache-adapter.js'), await res.outputs[0]!.text())
559
+ return 'cache-adapter.js'
560
+ }
561
+
344
562
  /** `dist/jobs.js` (`target: 'bun'`, React and `@brust/core` external): default export
345
563
  * `{ [componentId]: { precompute?, ssr? } }`; `ssr` only for react-tier, non-client_only components. */
346
- export async function buildJobs(dist: string, compiled: Map<string, Compiled>, sourceOf: SourceOf, appRoot: string, stores: string[] = []): Promise<void> {
564
+ export async function buildJobs(
565
+ dist: string,
566
+ compiled: Map<string, Compiled>,
567
+ sourceOf: SourceOf,
568
+ appRoot: string,
569
+ stores: string[] = [],
570
+ cssMaps: Map<string, Record<string, string>> = new Map(),
571
+ routeRoots?: ReadonlySet<string>,
572
+ ): Promise<void> {
347
573
  const imports: string[] = ["import { createElement } from 'react'", "import { renderToString } from 'react-dom/server'"]
348
574
  const entries: string[] = []
349
575
  const storePaths = [...new Set(stores)].sort()
@@ -353,6 +579,15 @@ export async function buildJobs(dist: string, compiled: Map<string, Compiled>, s
353
579
  }
354
580
  imports.push(`import * as S${i} from ${JSON.stringify(resolve(appRoot, p))}`)
355
581
  }
582
+ // Components an island revives from its lowered children (shadcn S1): one registry for every SSR job.
583
+ const reviving = [...compiled.values()].filter((c) => nestedOf(compiled, c.id, routeRoots) !== undefined)
584
+ const revived = [...revivedIds(compiled)].map((id) => compiled.get(id)).filter((x): x is Compiled => x !== undefined)
585
+ if (reviving.length > 0) {
586
+ imports.push("import { Fragment } from 'react'")
587
+ for (const [i, r] of revived.entries()) imports.push(componentImport(r, `N${i}`))
588
+ imports.push(reviveSource(`{ ${revived.map((r, i) => `${JSON.stringify(r.id)}: N${i}`).join(', ')} }`))
589
+ }
590
+ const hosts = new Set(reviving.map((c) => c.id))
356
591
  let n = 0
357
592
  for (const c of compiled.values()) {
358
593
  const fns: string[] = []
@@ -364,10 +599,10 @@ export async function buildJobs(dist: string, compiled: Map<string, Compiled>, s
364
599
  fns.push(`precompute: j${n}.precompute`)
365
600
  }
366
601
  if (isReact(c) && !c.ir.tier.React.client_only) {
367
- imports.push(`import C${n} from ${JSON.stringify(c.file)}`)
602
+ imports.push(componentImport(c, `C${n}`))
368
603
  // Ruling 2bf3775a (R1): the island's component id prefixes React's useId on BOTH sides
369
604
  // (the shim's hydrateRoot passes the same prefix), so ids differ across islands.
370
- fns.push(`ssr: (props: any) => renderToString(createElement(C${n}, props), { identifierPrefix: ${JSON.stringify(c.id)} })`)
605
+ fns.push(`ssr: (props: any) => renderToString(${hosts.has(c.id) ? `__mk(C${n}, props, ${JSON.stringify(xOf(compiled, c.id))})` : `createElement(C${n}, props)`}, { identifierPrefix: ${JSON.stringify(c.id)} })`)
371
606
  }
372
607
  if (fns.length > 0) entries.push(` ${JSON.stringify(c.id)}: { ${fns.join(', ')} },`)
373
608
  n++
@@ -380,7 +615,7 @@ export async function buildJobs(dist: string, compiled: Map<string, Compiled>, s
380
615
  format: 'esm',
381
616
  define: PRODUCTION,
382
617
  external: JOBS_EXTERNALS,
383
- plugins: [workerStorePlugin, sourceDirPlugin(sourceOf, appRoot, JOBS_EXTERNALS)],
618
+ plugins: [serverOnlyEmptyPlugin(), cssPlugin(cssMaps), workerStorePlugin, sourceDirPlugin(sourceOf, appRoot, JOBS_EXTERNALS), exposePlugin(compiled)],
384
619
  })
385
620
  if (!res.success) throw new BuildError('bundle', `jobs: ${res.logs.map(String).join('\n')}`)
386
621
  write(join(dist, 'jobs.js'), await res.outputs[0]!.text())
@@ -0,0 +1,83 @@
1
+ // Static assets for Workers Static Assets (M4-CF R7): only files the native host would serve (`safe_rel` in
2
+ // `crates/brust-core/src/static_rules.rs`), laid out under the URL they are requested at, plus `_headers`.
3
+ import { cpSync, existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from 'node:fs'
4
+ import { dirname, join } from 'node:path'
5
+
6
+ const SEG = /^[A-Za-z0-9_.-]+$/
7
+ const IMMUTABLE = 'public, max-age=31536000, immutable'
8
+ const PUBLIC_DEFAULT = 'public, max-age=3600'
9
+ const MAX_RULES = 100
10
+
11
+ /** `static_rules.rs` `is_hashed`: the stem ends in `-<hex6+>` (lowercase) with at least one digit. */
12
+ export function isHashed(file: string): boolean {
13
+ const base = file.slice(file.lastIndexOf('/') + 1)
14
+ const dot = base.lastIndexOf('.')
15
+ const stem = dot > 0 ? base.slice(0, dot) : base
16
+ const i = stem.lastIndexOf('-')
17
+ if (i < 0) return false
18
+ const h = stem.slice(i + 1)
19
+ return h.length >= 6 && /^[0-9a-f]+$/.test(h) && /\d/.test(h)
20
+ }
21
+
22
+ const segOk = (s: string) => s !== '' && !s.startsWith('.') && SEG.test(s)
23
+
24
+ /** `client/<file>.js|.css`: `is_safe_island_filename` / `is_safe_css_filename`, plus the fonts and images a
25
+ * stylesheet references (`is_safe_client_asset_filename`, L19). */
26
+ const CLIENT_EXT = /\.(js|css|woff2|woff|ttf|otf|eot|png|jpg|jpeg|gif|webp|avif|svg|ico)$/
27
+ const clientOk = (f: string) => segOk(f) && !f.includes('..') && CLIENT_EXT.test(f)
28
+
29
+ function walk(dir: string, rel = ''): string[] {
30
+ const out: string[] = []
31
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
32
+ const r = rel ? `${rel}/${e.name}` : e.name
33
+ if (e.isDirectory()) out.push(...walk(join(dir, e.name), r))
34
+ else if (e.isFile() || statSync(join(dir, e.name)).isFile()) out.push(r)
35
+ }
36
+ return out.sort()
37
+ }
38
+
39
+ /** Copies the admissible files of `<out>/client` and `<out>/public` into `<cfDir>/assets/...`; returns the
40
+ * asset-relative paths (`_brust/client/x.js`, `public/img/a.png`) and writes `assets/_headers`. */
41
+ export function layoutAssets(out: string, cfDir: string, version: string): { files: string[]; warnings: string[] } {
42
+ const assets = join(cfDir, 'assets')
43
+ mkdirSync(assets, { recursive: true })
44
+ const files: string[] = []
45
+ const warnings: string[] = []
46
+ const copy = (from: string, to: string) => {
47
+ mkdirSync(dirname(join(assets, to)), { recursive: true })
48
+ cpSync(from, join(assets, to))
49
+ files.push(to)
50
+ }
51
+ const client = join(out, 'client')
52
+ if (existsSync(client))
53
+ for (const f of walk(client)) {
54
+ if (!f.includes('/') && clientOk(f)) copy(join(client, f), `_brust/client/${f}`)
55
+ else warnings.push(`[brust] assets: client/${f} is not served natively; skipped`)
56
+ }
57
+ const pub = join(out, 'public')
58
+ if (existsSync(pub))
59
+ for (const f of walk(pub)) {
60
+ if (f.split('/').every(segOk)) copy(join(pub, f), `public/${f}`)
61
+ else warnings.push(`[brust] assets: public/${f} has a name the native host would not serve; skipped`)
62
+ }
63
+ const h = headersFile(files, version)
64
+ warnings.push(...h.warnings)
65
+ writeFileSync(join(assets, '_headers'), h.text)
66
+ return { files, warnings }
67
+ }
68
+
69
+ export function headersFile(files: string[], version: string): { text: string; warnings: string[] } {
70
+ const warnings: string[] = []
71
+ const rules: string[] = [
72
+ `/*\n X-Content-Type-Options: nosniff\n X-Powered-By: brust/${version}`,
73
+ `/_brust/client/*\n Cache-Control: ${IMMUTABLE}`,
74
+ ]
75
+ const pub = files.filter((f) => f.startsWith('public/'))
76
+ if (rules.length + pub.length <= MAX_RULES)
77
+ for (const f of pub) rules.push(`/${f}\n Cache-Control: ${isHashed(f) ? IMMUTABLE : PUBLIC_DEFAULT}`)
78
+ else if (pub.length > 0) {
79
+ rules.push(`/public/*\n Cache-Control: ${PUBLIC_DEFAULT}`)
80
+ warnings.push(`[brust] assets: ${pub.length} public files exceed the 100-rule _headers limit; one /public/* max-age=3600 rule (no per-file immutable)`)
81
+ }
82
+ return { text: `${rules.join('\n\n')}\n`, warnings }
83
+ }