@brust/core 2.0.0-alpha.3 → 2.0.0-alpha.30

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 (64) hide show
  1. package/README.md +66 -6
  2. package/native/index.d.ts +63 -4
  3. package/native/index.js +57 -54
  4. package/package.json +29 -15
  5. package/src/actions/client.ts +53 -7
  6. package/src/actions/elysia.ts +1 -1
  7. package/src/actions/load.ts +24 -1
  8. package/src/actions/prefix.ts +24 -2
  9. package/src/actions-bridge.ts +2 -2
  10. package/src/assets.d.ts +55 -0
  11. package/src/browser.ts +6 -1
  12. package/src/build/actions-only.ts +86 -0
  13. package/src/build/build-eval.ts +332 -0
  14. package/src/build/build-icons.ts +69 -0
  15. package/src/build/bundle.ts +466 -54
  16. package/src/build/cf-assets.ts +4 -2
  17. package/src/build/cf-entry.ts +7 -3
  18. package/src/build/cf-wrangler.ts +3 -1
  19. package/src/build/cloudflare.ts +21 -11
  20. package/src/build/compile.ts +263 -6
  21. package/src/build/config.ts +85 -11
  22. package/src/build/css.ts +353 -0
  23. package/src/build/esm-resolve.ts +170 -0
  24. package/src/build/features.ts +119 -0
  25. package/src/build/index.ts +210 -27
  26. package/src/build/manifest.ts +88 -7
  27. package/src/build/pkg-src.ts +514 -0
  28. package/src/build/recognised.ts +19 -0
  29. package/src/build/scan.ts +71 -20
  30. package/src/build/server-only.ts +62 -0
  31. package/src/build/ssg-render.ts +65 -0
  32. package/src/build/ssg.ts +146 -0
  33. package/src/build/static-worker.ts +91 -0
  34. package/src/build/static.ts +259 -0
  35. package/src/build/tailwind.ts +117 -0
  36. package/src/build/tsconfig-paths.ts +67 -0
  37. package/src/build-time.ts +10 -0
  38. package/src/cache-adapter/types.ts +1 -1
  39. package/src/cf.ts +4 -1
  40. package/src/cli.ts +40 -4
  41. package/src/config.ts +111 -25
  42. package/src/css-modules.d.ts +8 -0
  43. package/src/dev/bun.ts +371 -0
  44. package/src/dev/client.ts +92 -0
  45. package/src/dev/devtools-data.ts +23 -0
  46. package/src/dev/devtools.ts +358 -0
  47. package/src/dev/diag.ts +30 -0
  48. package/src/dev/proxy.ts +236 -0
  49. package/src/dev/tui.ts +144 -0
  50. package/src/dev/watch.ts +130 -0
  51. package/src/dev.ts +15 -28
  52. package/src/handlers.ts +115 -9
  53. package/src/index.ts +6 -1
  54. package/src/native.ts +18 -0
  55. package/src/navigate-browser.ts +19 -0
  56. package/src/navigate.ts +19 -0
  57. package/src/navigation.ts +91 -0
  58. package/src/routes.ts +31 -1
  59. package/src/run.ts +53 -11
  60. package/src/shadcn/init.ts +513 -0
  61. package/src/shadcn/jsonc.ts +129 -0
  62. package/src/shadcn/registry.ts +149 -0
  63. package/src/store/builtin.ts +4 -0
  64. package/src/worker.ts +98 -22
@@ -9,14 +9,27 @@
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 { RELATIVE_SPEC, type ResolveTarget, resolveNative } from './esm-resolve'
20
+ import { packageDirOf } from './pkg-src'
21
+ import { BUILTIN_STORE_MODULES } from '../store/builtin'
16
22
 
17
23
  const REACT_EXTERNALS = ['react', 'react/*', 'react-dom', 'react-dom/*']
18
- /** Production React (and the production JSX transform: `react/jsx-runtime`, never `jsxDEV`). */
19
- export const PRODUCTION = { 'process.env.NODE_ENV': JSON.stringify('production') }
24
+ /** Production React (and the production JSX transform: `react/jsx-runtime`, never `jsxDEV`). `brust dev` builds set
25
+ * `BRUST_BUILD_MODE=development` for readable errors and hydration diffs (R13.4); `brust build` never does. */
26
+ export const PRODUCTION: Record<string, string> = { 'process.env.NODE_ENV': JSON.stringify(process.env.BRUST_BUILD_MODE === 'development' ? 'development' : 'production'), __BRUST_DEV__: 'false' }
27
+
28
+ /** `brust build --dev` (L14, R14.3): the client runtime exposes its live instances on a dev global. `__BRUST_DEV__` is a
29
+ * define, `false` in every other build, so the minifier removes the dev code from production bundles. */
30
+ export function setDevBuild(dev: boolean): void {
31
+ PRODUCTION.__BRUST_DEV__ = dev ? 'true' : 'false'
32
+ }
20
33
 
21
34
  const matchesExternal = (spec: string, external: string[]) =>
22
35
  // A trailing `*` is a prefix (`elysia/*`, `node:*`, `cloudflare:*`): the generated CF entry imports `cloudflare:workers`.
@@ -34,23 +47,54 @@ export interface BrowserCtx {
34
47
  serverOnly: string[]
35
48
  /** Real paths of server-only files (the actions entry, R8): a browser import of one is refused. */
36
49
  serverFiles?: string[]
50
+ /** L19: CSS Module class maps for `cssPlugin` (absent: no CSS was built). */
51
+ cssMaps?: Map<string, Record<string, string>>
52
+ /** F411: `<dist>/client`, where an imported asset is written (see `cssPlugin`). */
53
+ clientDir?: string
54
+ }
55
+
56
+ /** `file` sits under `root`. */
57
+ const inside = (root: string, file: string): boolean => file === root || file.startsWith(root.endsWith(sep) ? root : root + sep)
58
+
59
+ /** The package a bare specifier names is installed in a `node_modules` above `from` (node's own lookup). */
60
+ function visibleFrom(spec: string, from: string): boolean {
61
+ const parts = spec.split('/')
62
+ const name = spec.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]!
63
+ for (let d = from; ; d = dirname(d)) {
64
+ if (existsSync(join(d, 'node_modules', name, 'package.json'))) return true
65
+ if (dirname(d) === d) return false
66
+ }
37
67
  }
38
68
 
39
69
  /** Resolution for staged artifacts (`importer` = a staged file registered in `sourceOf`): relative
40
70
  * imports mean the component's SOURCE directory; bare imports (`react`, `react-dom/client`, npm
41
71
  * packages) resolve from `appRoot`, never from wherever `dist/` sits (an `--out-dir` outside the
42
72
  * app has no `node_modules` above it). `external` specifiers are left to Bun so they stay
43
- * external. */
44
- export function sourceDirPlugin(sourceOf: SourceOf, appRoot: string, external: string[] = []): BunPlugin {
73
+ * external. `target`: the bundler target a package's own imports resolve for (F189). */
74
+ export function sourceDirPlugin(sourceOf: SourceOf, appRoot: string, external: string[] = [], target: ResolveTarget = 'bun', conditions?: string[]): BunPlugin {
75
+ // What the probe must share with the build it stands in for: target, define, conditions (F223); it runs from the app root (F221).
76
+ const probe = { appRoot, conditions }
45
77
  return {
46
78
  name: 'brust-source-dir',
47
79
  setup(b) {
48
- b.onResolve({ filter: /^\.\.?\// }, (a) => {
80
+ // `.` and `..` name a directory like `./x` does (F218): one filter, so none of them reaches a package branch.
81
+ b.onResolve({ filter: RELATIVE_SPEC }, (a) => {
49
82
  const src = sourceOf.get(a.importer)
50
83
  return src === undefined ? undefined : { path: Bun.resolveSync(a.path, dirname(src)) }
51
84
  })
52
85
  b.onResolve({ filter: /^[^./]/ }, (a) => {
86
+ // A package source imported as itself (a React component revived from it): its bare imports are the package's.
87
+ const own = sourceOf.has(a.importer) ? undefined : packageDirOf(a.importer)
88
+ if (own !== undefined && !matchesExternal(a.path, external)) return { path: resolveNative(a.path, own, target, PRODUCTION, probe) }
53
89
  if (!sourceOf.has(a.importer) || matchesExternal(a.path, external)) return undefined
90
+ // F153c: an artifact compiled from a package's source imports what that package sees.
91
+ const pkg = packageDirOf(sourceOf.get(a.importer)!)
92
+ if (pkg !== undefined) return { path: resolveNative(a.path, pkg, target, PRODUCTION, probe) }
93
+ // A package the staged file can see from where it sits resolves natively: the bundler then picks the package's ESM
94
+ // entry (`module` / `exports.import`) and tree-shakes it. `Bun.resolveSync` answers with the CJS `main`, which
95
+ // bundles the whole package (recharts: +85 KB gzip on the chart page). Packages only the app root sees (a staged
96
+ // file under `dist/`, an `--out-dir` outside the app) keep the pinned resolution, so one copy of each package.
97
+ if (inside(appRoot, a.importer) && visibleFrom(a.path, dirname(a.importer))) return undefined
54
98
  return { path: Bun.resolveSync(a.path, appRoot) }
55
99
  })
56
100
  },
@@ -101,6 +145,7 @@ const BROWSER_POLYFILLS = new Set([
101
145
  * also checked by the file it resolves to (Bun's resolver honours tsconfig `paths`, so an alias
102
146
  * cannot bypass the `*.server.*` / path rules). */
103
147
  function isServerOnly(spec: string, importer: string, ctx: BrowserCtx): boolean {
148
+ if (isServerOnlyMarker(spec)) return true // `import 'server-only'`: the marker never reaches a browser bundle, whether or not the package throws
104
149
  if (spec.startsWith('node:') || spec.startsWith('bun:') || spec === 'bun') return true
105
150
  const from = dirname(ctx.sourceOf.get(importer) ?? importer)
106
151
  const bare = spec.split('/')[0]!
@@ -117,6 +162,10 @@ function isServerOnly(spec: string, importer: string, ctx: BrowserCtx): boolean
117
162
  return false // unresolvable: Bun reports it
118
163
  }
119
164
  if (/\.server\.[^/]*$/.test(basename(file))) return true
165
+ // Any other name (an alias, a relative path) that resolves INTO the `server-only` package is the marker too.
166
+ try {
167
+ if (/[\\/]node_modules[\\/]server-only[\\/]/.test(realpathSync(file))) return true
168
+ } catch {}
120
169
  try {
121
170
  if (ctx.serverFiles?.includes(realpathSync(file))) return true
122
171
  } catch {}
@@ -146,7 +195,7 @@ function serverOnlyPlugin(ctx: BrowserCtx, violations: string[]): BunPlugin {
146
195
  }
147
196
 
148
197
  const hex = (s: string) => createHash('sha256').update(s).digest('hex').slice(0, 10)
149
- const write = (file: string, text: string) => {
198
+ export const write = (file: string, text: string) => {
150
199
  mkdirSync(dirname(file), { recursive: true })
151
200
  writeFileSync(file, text)
152
201
  }
@@ -166,7 +215,7 @@ async function browserBuild(
166
215
  try {
167
216
  res = await Bun.build({
168
217
  ...config,
169
- plugins: [serverOnlyPlugin(ctx, violations), ...(config.plugins ?? [])],
218
+ plugins: [serverOnlyPlugin(ctx, violations), cssPlugin(ctx.cssMaps ?? new Map(), ctx.clientDir), ...(config.plugins ?? [])],
170
219
  target: 'browser',
171
220
  format: 'esm',
172
221
  minify: true,
@@ -198,57 +247,111 @@ async function browserBuild(
198
247
  return entries
199
248
  }
200
249
 
201
- /** D3/C6: one runtime instance per page. Island and native client builds resolve the reactive core and
202
- * the store API to the runtime bundle URL (external), exactly as the compiler's `runtimeImport`. The
203
- * React bridge (`useStore`) is bundled (it holds no module state), but ITS imports of the core are the
204
- * runtime too. Registered after `serverOnlyPlugin`, before `sourceDirPlugin` (which would resolve the
250
+ /** The client runtime's URLs (L33): the core every page loads, the `store` module (`defineStore` + the reactive
251
+ * primitives a store's code imports), and the feature modules. Core and features are single shared ESM files, so a
252
+ * page has one signal graph and one binder registry however many features it loads. */
253
+ export interface RuntimeUrls {
254
+ core: string
255
+ store: string
256
+ }
257
+
258
+ /** D3/C6: one runtime instance per page. Island and native client builds resolve the reactive core to the core URL
259
+ * and the store API to the store module's URL (external), exactly as the compiler's `runtimeImport` (the core). The
260
+ * React bridge (`useStore`) is bundled (it holds no module state), but ITS imports of the core are the store
261
+ * module too. Registered after `serverOnlyPlugin`, before `sourceDirPlugin` (which would resolve the
205
262
  * bare specifiers to files). */
206
- export function runtimeAliasPlugin(runtimeUrl: string): BunPlugin {
263
+ export function runtimeAliasPlugin(urls: RuntimeUrls): BunPlugin {
207
264
  return {
208
265
  name: 'brust-runtime-alias',
209
266
  setup(b) {
210
- b.onResolve({ filter: /^@brust\/(runtime-dom(\/store)?|core\/store)$/ }, () => ({ path: runtimeUrl, external: true }))
267
+ b.onResolve({ filter: /^@brust\/runtime-dom(\/core)?$/ }, () => ({ path: urls.core, external: true }))
268
+ b.onResolve({ filter: /^@brust\/(runtime-dom|core)\/store$/ }, () => ({ path: urls.store, external: true }))
211
269
  let bridge: string | undefined
212
270
  b.onResolve({ filter: /^@brust\/(runtime-dom|core)\/store\/react$/ }, () => {
213
271
  bridge ??= Bun.resolveSync('@brust/runtime-dom/store/react', import.meta.dir) // core lane file; resolved lazily
214
272
  return { path: bridge }
215
273
  })
216
- b.onResolve({ filter: /^\.\.?\// }, (a) => (bridge !== undefined && a.importer === bridge ? { path: runtimeUrl, external: true } : undefined))
274
+ b.onResolve({ filter: /^\.\.?\// }, (a) => (bridge !== undefined && a.importer === bridge ? { path: urls.store, external: true } : undefined))
217
275
  },
218
276
  }
219
277
  }
220
278
 
221
- /** `dist/client/runtime-<hex>.js`: runtime-dom, re-exported (client chunks import `signal`,
222
- * `defineBehavior`, … from this URL) and mounted on `document.documentElement` (contract 6). */
223
- export async function buildRuntime(dist: string, ctx: BrowserCtx): Promise<string> {
224
- const entry = join(dist, '.stage', 'runtime.ts')
225
- write(entry, "export * from '@brust/runtime-dom'\nimport { mount } from '@brust/runtime-dom'\nmount(document.documentElement)\n")
279
+ /** What `buildRuntime` wrote: `client/<file>` paths (relative to dist). */
280
+ export interface RuntimeAssets {
281
+ /** The core: `export *` of the runtime core + `mount(document.documentElement)` (contract 6). */
282
+ core: string
283
+ store: string
284
+ /** Feature module name → file; every module is built, a route links the ones its components need. */
285
+ features: Record<string, string>
286
+ /** F453: the standalone SPA-navigation module (`nav-<hex>.js`); imports nothing from the core, so a page that loads no runtime still has it. */
287
+ nav: string
288
+ }
289
+
290
+ /** `dist/client/runtime-<hex>.js` (the core; client chunks import `signal`, `defineBehavior`, … from this URL), then
291
+ * one `rt-<feature>-<hex>.js` per feature module and `rt-store-<hex>.js`, each importing the core by that URL. */
292
+ export async function buildRuntime(dist: string, ctx: BrowserCtx): Promise<RuntimeAssets> {
226
293
  // Resolved from this package (its dependency), not from wherever `dist/` sits.
227
- const runtimeDom = Bun.resolveSync('@brust/runtime-dom', import.meta.dir)
228
- const res = await browserBuild(dist, 'runtime', ctx, {
229
- entrypoints: [entry],
294
+ const dir = dirname(Bun.resolveSync('@brust/runtime-dom', import.meta.dir))
295
+ const coreEntry = join(dist, '.stage', 'runtime.ts')
296
+ write(coreEntry, "export * from '@brust/runtime-dom/core'\nimport { mount } from '@brust/runtime-dom/core'\nmount(document.documentElement)\n")
297
+ const core = (
298
+ await browserBuild(dist, 'runtime', ctx, {
299
+ entrypoints: [coreEntry],
300
+ plugins: [{ name: 'brust-runtime-dom', setup: (b) => void b.onResolve({ filter: /^@brust\/runtime-dom\/core$/ }, () => ({ path: join(dir, 'core.ts') })) }],
301
+ })
302
+ ).get('runtime')!
303
+ // Features import the core's API (`core-api`, and `core` for the store's re-exports) from the core file by URL.
304
+ const coreUrl = `/_brust/${core}`
305
+ const coreFiles = new Set(['core-api', 'core'].map((f) => join(dir, f)))
306
+ const names = [...RUNTIME_FEATURES, 'store'] as const
307
+ const entries = names.map((n) => {
308
+ const file = join(dist, '.stage', `rt-${n}.ts`)
309
+ write(file, `import ${JSON.stringify(join(dir, 'features', n))}\nexport * from ${JSON.stringify(join(dir, 'features', n))}\n`)
310
+ return file
311
+ })
312
+ // F453: `nav` is its own entry (it imports nothing from the core), named `nav-<hex>.js`.
313
+ const navEntry = join(dist, '.stage', 'nav.ts')
314
+ write(navEntry, `import ${JSON.stringify(join(dir, 'nav', 'index.ts'))}\n`)
315
+ const built = await browserBuild(dist, 'runtime features', ctx, {
316
+ entrypoints: [...entries, navEntry],
230
317
  plugins: [
231
318
  {
232
- name: 'brust-runtime-dom',
233
- setup(b) {
234
- b.onResolve({ filter: /^@brust\/runtime-dom$/ }, () => ({ path: runtimeDom }))
235
- },
319
+ name: 'brust-runtime-core-external',
320
+ setup: (b) =>
321
+ void b.onResolve({ filter: /^\.\.?\/(core-api|core)$/ }, (a) => (coreFiles.has(resolve(dirname(a.importer), a.path)) ? { path: coreUrl, external: true } : undefined)),
236
322
  },
237
323
  ],
238
324
  })
239
- return res.get('runtime')!
325
+ const features: Record<string, string> = {}
326
+ for (const n of RUNTIME_FEATURES) features[n] = built.get(`rt-${n}`)!
327
+ return { core, store: built.get('rt-store')!, features, nav: built.get('nav')! }
240
328
  }
241
329
 
242
330
  /** One entry chunk per component with client JS (`<id>-<hex>.js`), split: a module two chunks
243
331
  * import (a store) lands once in a shared `chunk-<hex>.js`, so one page has one instance of it.
244
332
  * The runtime import stays the absolute `/_brust/client/runtime-<hex>.js` URL the compiler
245
333
  * printed (external: one runtime for every chunk). */
246
- export async function buildClientChunks(dist: string, compiled: Map<string, Compiled>, ctx: BrowserCtx, runtimeUrl: string): Promise<Map<string, string>> {
334
+ export async function buildClientChunks(dist: string, compiled: Map<string, Compiled>, ctx: BrowserCtx, runtimeUrl: RuntimeUrls): Promise<Map<string, string>> {
247
335
  const entrypoints: string[] = []
336
+ const shared = new Map<string, string>()
337
+ // F362 PR2: a chunk that declares an imported list projected keeps it only while its data module is out of the client
338
+ // graph: another importer (a module that pushes into the list) would hold the real one, which the copy never sees.
339
+ const whole = await projectionFallbacks(dist, compiled, ctx, runtimeUrl)
248
340
  for (const c of compiled.values()) {
249
341
  if (c.clientJs === undefined) continue
250
342
  const file = join(dist, '.stage', 'client', `${c.id}.js`)
251
- write(file, c.clientJs)
343
+ write(file, whole.has(c.id) ? c.clientJsWhole! : c.clientJs)
344
+ // R34.124: a module several chunks import (a specialised clone's factory) is written once under its content name.
345
+ for (const m of c.clientModules ?? []) {
346
+ const mf = join(dist, '.stage', 'client', `${m.name}.js`)
347
+ const seen = shared.get(m.name)
348
+ if (seen !== undefined && seen !== m.code) throw new BuildError('client-module-collision', `two different client modules are named ${m.name}`)
349
+ if (seen === undefined) {
350
+ shared.set(m.name, m.code)
351
+ write(mf, m.code)
352
+ stage(ctx.sourceOf, mf, c)
353
+ }
354
+ }
252
355
  stage(ctx.sourceOf, file, c)
253
356
  entrypoints.push(file)
254
357
  }
@@ -257,41 +360,292 @@ export async function buildClientChunks(dist: string, compiled: Map<string, Comp
257
360
  entrypoints,
258
361
  splitting: true,
259
362
  external: ['/_brust/*'],
260
- plugins: [runtimeAliasPlugin(runtimeUrl), sourceDirPlugin(ctx.sourceOf, ctx.appRoot)],
363
+ plugins: [runtimeAliasPlugin(runtimeUrl), specModulePlugin(join(dist, '.stage', 'client')), sourceDirPlugin(ctx.sourceOf, ctx.appRoot, [], 'browser')],
261
364
  })
262
365
  }
263
366
 
367
+ /** F362 PR2: the ids of the chunks that must import their lists as written. A probe build (metafile only, nothing
368
+ * written to `client/`) lists every module the client graph loads; a chunk whose projected data module is among them
369
+ * falls back to `clientJsWhole`, which brings that module in, so the probe repeats until no projected module is in the
370
+ * graph. Without a projected chunk nothing is probed. */
371
+ export async function projectionFallbacks(dist: string, compiled: Map<string, Compiled>, ctx: BrowserCtx, runtimeUrl: RuntimeUrls): Promise<Set<string>> {
372
+ const whole = new Set<string>()
373
+ const projecting = [...compiled.values()].filter((c) => c.clientJs !== undefined && c.projectedModules?.length && c.clientJsWhole !== undefined)
374
+ if (projecting.length === 0) return whole
375
+ const real = (f: string) => {
376
+ try {
377
+ return realpathSync(f)
378
+ } catch {
379
+ return resolve(f)
380
+ }
381
+ }
382
+ const probeDir = join(dist, '.stage', 'probe')
383
+ for (;;) {
384
+ const entrypoints: string[] = []
385
+ for (const c of compiled.values()) {
386
+ if (c.clientJs === undefined) continue
387
+ const file = join(probeDir, `${c.id}.js`)
388
+ write(file, whole.has(c.id) ? c.clientJsWhole! : c.clientJs)
389
+ for (const m of c.clientModules ?? []) {
390
+ const mf = join(probeDir, `${m.name}.js`)
391
+ write(mf, m.code)
392
+ stage(ctx.sourceOf, mf, c)
393
+ }
394
+ stage(ctx.sourceOf, file, c)
395
+ entrypoints.push(file)
396
+ }
397
+ const res = await Bun.build({
398
+ entrypoints,
399
+ splitting: true,
400
+ external: ['/_brust/*'],
401
+ target: 'browser',
402
+ format: 'esm',
403
+ metafile: true,
404
+ plugins: [serverOnlyPlugin(ctx, []), cssPlugin(ctx.cssMaps ?? new Map(), ctx.clientDir), runtimeAliasPlugin(runtimeUrl), specModulePlugin(probeDir), sourceDirPlugin(ctx.sourceOf, ctx.appRoot, [], 'browser')],
405
+ })
406
+ if (!res.success || !res.metafile) throw new BuildError('bundle', `client chunks (projection probe): ${res.logs.map(String).join('\n')}`)
407
+ const loaded = new Set(Object.keys(res.metafile.inputs).map((k) => real(resolve(k))))
408
+ const next = projecting.filter((c) => !whole.has(c.id) && c.projectedModules!.some((m) => loaded.has(real(join(ctx.appRoot, m)))))
409
+ if (next.length === 0) return whole
410
+ for (const c of next) whole.add(c.id)
411
+ }
412
+ }
413
+
414
+ /** R34.124: `brust-spec:<name>` is a module several chunks share (a specialised clone's factory), written beside the chunks. */
415
+ function specModulePlugin(dir: string): BunPlugin {
416
+ return {
417
+ name: 'brust-spec-modules',
418
+ setup: (b) => void b.onResolve({ filter: /^brust-spec:/ }, (a) => ({ path: join(dir, `${a.path.slice('brust-spec:'.length)}.js`) })),
419
+ }
420
+ }
421
+
264
422
  /** Registers a written artifact (by its path and its real path: the importer Bun reports). */
265
- function stage(sourceOf: SourceOf, file: string, c: Compiled): void {
423
+ export function stage(sourceOf: SourceOf, file: string, c: Compiled): void {
266
424
  sourceOf.set(file, c.file)
267
425
  sourceOf.set(realpathSync(file), c.file)
268
426
  }
269
427
 
270
428
  const isReact = (c: Compiled) => typeof c.ir.tier === 'object' && c.ir.tier !== null && 'React' in c.ir.tier
271
429
 
272
- /** The island entry of a react-tier component: registers `(host, props) => …` under its id. A
430
+ /** An import/export binding spelled as the module wrote it: bare when an identifier, else a string name. */
431
+ const bindingName = (name: string) => (/^[A-Za-z_$][\w$]*$/.test(name) ? name : JSON.stringify(name))
432
+
433
+ /** The `import` line that brings a react-tier component's code in as `local`: the binding its importer used
434
+ * (`import Foo` → the default export, `import { Foo }` / `export { A as Foo }` / `export const Foo` → `Foo`),
435
+ * or for a package export (L22) the export itself. Each component of a file with several is its own island
436
+ * entry; the bundler keeps ONE instance of the file for all of them (splitting). */
437
+ export function componentImport(c: Compiled, local: string): string {
438
+ // F188 (R34.62): a component compiled from a package source is imported as the package's own export, so the island
439
+ // and every revived component share the dist's one module instance (a `createContext` among them).
440
+ const ext = c.ir.external ?? c.ir.dist
441
+ if (ext?.member?.length) return memberImport(ext as { source: string; export: string; member: string[] }, local)
442
+ const [from, binding] = ext ? [JSON.stringify(ext.source), ext.export] : [JSON.stringify(c.file), c.ir.export ?? 'default']
443
+ return binding === 'default' ? `import ${local} from ${from}` : `import { ${bindingName(binding)} as ${local} } from ${from}`
444
+ }
445
+
446
+ /** A member of a package import (`<DialogPrimitive.Root>`, S1.4 R34.35): the export (a name, `default`, or `*` for a
447
+ * namespace import) is bound once and the member is read off it. A path the package does not have is `undefined`
448
+ * (the bundler does not check member access), so it throws a named error when the island entry or the SSR job loads. */
449
+ function memberImport(ext: { source: string; export: string; member: string[] }, local: string): string {
450
+ const ns = `${local}__ns`
451
+ const from = JSON.stringify(ext.source)
452
+ const head = ext.export === 'default' ? `import ${ns} from ${from}` : ext.export === '*' ? `import * as ${ns} from ${from}` : `import { ${bindingName(ext.export)} as ${ns} } from ${from}`
453
+ const path = (ext.export === '*' ? ext.member : [ext.export, ...ext.member]).join('.')
454
+ const read = ext.member.map((m) => (/^[A-Za-z_$][\w$]*$/.test(m) ? `.${m}` : `[${JSON.stringify(m)}]`)).join('')
455
+ return `${head}\nconst ${local} = ${ns}${read}\nif (${local} === undefined) throw new Error(${JSON.stringify(`brust: ${ext.source} has no export ${path}`)})`
456
+ }
457
+
458
+ /** The component ids a React island receives inside its lowered JSX children (shadcn S1, R34.21): the union over
459
+ * every file that passes children to it. `undefined` = no file does, the island has no revive code. */
460
+ export function nestedOf(compiled: Map<string, Compiled>, id: string, routeRoots?: ReadonlySet<string>): string[] | undefined {
461
+ // F158 (R34.70): a route component's props are request data, so it is never a revive host, whatever a caller did.
462
+ if (routeRoots?.has(id)) return undefined
463
+ let out: Set<string> | undefined
464
+ for (const c of compiled.values()) {
465
+ for (const ref of c.ir.children ?? []) {
466
+ // `nested: []` (element-only children) still makes the island a host: its children are a JSON tree to revive.
467
+ if (ref.id === id && ref.nested !== undefined) {
468
+ out ??= new Set()
469
+ for (const n of ref.nested) out.add(n)
470
+ }
471
+ }
472
+ }
473
+ // F148a (R34.121): a wrapper whose island renders `{children}` (a `$c` hole) is handed a tree by each call site; the
474
+ // components those trees name are revived by the island inside the wrapper, so its chunk and job import them.
475
+ for (const w of compiled.values()) {
476
+ if (!(w.ir.children ?? []).some((r) => r.id === id && (r.holes ?? []).some((h) => h.startsWith('$c')))) continue
477
+ for (const c of compiled.values()) {
478
+ for (const ref of c.ir.children ?? []) {
479
+ if (ref.id === w.id && ref.nested) {
480
+ out ??= new Set()
481
+ for (const n of ref.nested) out.add(n)
482
+ }
483
+ }
484
+ }
485
+ }
486
+ return out && [...out].sort()
487
+ }
488
+
489
+ /** F148a (R34.120): the island props the compiler wrote for the values its children read from props (`$p<N>`: a
490
+ * primitive, `$c<N>`: a tree a wrapper's call site bound), at any call site of the island `id`. The only `$` keys,
491
+ * besides `$x`, its revive accepts. */
492
+ export function holesOf(compiled: Map<string, Compiled>, id: string): string[] {
493
+ const out = new Set<string>()
494
+ for (const c of compiled.values()) for (const ref of c.ir.children ?? []) if (ref.id === id) for (const k of ref.holes ?? []) out.add(k)
495
+ return [...out].sort()
496
+ }
497
+
498
+ /** F158 (R34.71): the element-prop names (`$x`) the compiler wrote at any call site of the island `id`: the only names
499
+ * its revive may revive. */
500
+ export function xOf(compiled: Map<string, Compiled>, id: string): string[] {
501
+ const out = new Set<string>()
502
+ 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)
503
+ return [...out].sort()
504
+ }
505
+
506
+ /** Every component some island revives (`nestedOf`), whatever its tier: its module must export it. */
507
+ function revivedIds(compiled: Map<string, Compiled>): Set<string> {
508
+ const out = new Set<string>()
509
+ for (const c of compiled.values()) for (const ref of c.ir.children ?? []) for (const n of ref.nested ?? []) out.add(n)
510
+ return out
511
+ }
512
+
513
+ /** The code that turns an island's `children` prop (a JSON tree the compiler lowered, `{ $brust: 'el' | 'fr' | 'cmp' }`)
514
+ * back into React elements. One copy per entry that needs it, shared by the island chunk and the SSR job so both sides
515
+ * build the same tree. `reg` maps a component id to the component. */
516
+ export const reviveSource = (reg: string): string =>
517
+ `const __reg: Record<string, any> = Object.assign(Object.create(null), ${reg})\n` +
518
+ `const __own = Object.prototype.hasOwnProperty\n` +
519
+ // The tree is compiler output, but props travel in page markup: read it as untrusted. A tag must be a plain element
520
+ // name, a component must be in this island's registry (own keys only), and the raw-HTML prop is never revived.
521
+ // `hv` holds the island's hole values (F148a, R34.116): the props the compiler declared (`$p<N>` primitives, `$c<N>`
522
+ // trees). A value node reads one and renders it only when it is a string or a finite number (an attribute: also a boolean; text: also an array of them);
523
+ // anything else renders nothing, and is never walked, so request data can fill a leaf but never shape the tree.
524
+ `const __hole = (hv: any, k: any): any => hv == null || typeof k !== 'string' || !/^[$]p[0-9]+$/.test(k) || !__own.call(hv, k) ? undefined : hv[k]\n` +
525
+ `const __prim = (hv: any, k: any, bool: boolean): any => { const x = __hole(hv, k); return typeof x === 'string' || (typeof x === 'number' && Number.isFinite(x)) || (bool && typeof x === 'boolean') ? x : undefined }\n` +
526
+ `const __props = (p: any, hv: 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') { const v = p[k]; if (v !== null && typeof v === 'object' && v.$brust === 'v') { const x = __prim(hv, v.k, true); if (x !== undefined) o[k] = x } else o[k] = v } return o }\n` +
527
+ // `x` (on a component node) and `$x` (on the island's props) are the compiler's list of props that hold an element
528
+ // tree. Only those names are revived, and only when the prop is an own key of the copy; data is never revived.
529
+ `const __xs = (o: any, x: any, hv: 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], hv); return o }\n` +
530
+ `const __kids = (c: any, hv: any): any[] => Array.isArray(c) ? c.map((k: any) => __rv(k, hv)) : []\n` +
531
+ `const __rv = (n: any, hv: any): any => {\n` +
532
+ ` if (n === null || typeof n !== 'object') return n\n` +
533
+ // A text leaf is a string or a finite number; an array of them (React renders each) keeps those items. Anything else
534
+ // renders nothing and is never walked.
535
+ ` if (n.$brust === 'v') { const x = __hole(hv, n.k); if (Array.isArray(x)) return createElement(Fragment, null, ...x.filter((e: any) => typeof e === 'string' || (typeof e === 'number' && Number.isFinite(e)))); const p = __prim(hv, n.k, false); return p === undefined ? null : p }\n` +
536
+ // A condition is the truthiness of the declared own hole, as in React, without reading into the value. `a` is the
537
+ // `cond && <X/>` form: React renders a falsy number there (0) as text.
538
+ ` if (n.$brust === 'if') { const x = __hole(hv, n.k); return createElement(Fragment, null, ...(!x && n.a === true && typeof x === 'number' ? [x] : __kids(x ? n.c : n.e, hv))) }\n` +
539
+ // The caller's tree: compile-time, bound by the wrapper's call site. It holds no hole of its own, so it is revived
540
+ // with none (a tree that named one would render nothing, and cannot recurse).
541
+ ` if (n.$brust === 'slot') return typeof n.k === 'string' && /^[$]c[0-9]+$/.test(n.k) && hv != null && __own.call(hv, n.k) ? __rv(hv[n.k], null) : null\n` +
542
+ ` const kids = __kids(n.c, hv)\n` +
543
+ ` if (n.$brust === 'fr') return createElement(Fragment, null, ...kids)\n` +
544
+ ` 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, hv), ...kids) }\n` +
545
+ ` 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, hv), n.x, hv), ...kids) }\n` +
546
+ ` throw new Error('brust: island children hold an unknown node')\n` +
547
+ `}\n` +
548
+ // `X` is the host's compiler-declared element-prop names (R34.71): a `$x` from the props can only select among them.
549
+ // `H` is its declared hole names (R34.120). A `$`-key that is neither `$x` nor in `H` is never the compiler's (R34.72):
550
+ // it throws instead of rendering (a 500, not a revive). Hole props are the revive's, not the component's.
551
+ `const __mk = (C: any, props: any, X: string[] = [], H: string[] = []) => {\n` +
552
+ ` for (const k of Object.keys(props)) if (k.charAt(0) === '$' && k !== '$x' && !H.includes(k)) throw new Error('brust: island props hold the reserved key ' + k)\n` +
553
+ ` const { children, $x, ...rest } = props\n` +
554
+ ` const hv: any = Object.create(null)\n` +
555
+ ` for (const k of H) if (__own.call(rest, k)) { hv[k] = rest[k]; delete rest[k] }\n` +
556
+ ` __xs(rest, Array.isArray($x) ? $x.filter((k: any) => X.includes(k)) : undefined, hv)\n` +
557
+ ` return createElement(C, rest, ...(children === undefined ? [] : Array.isArray(children) ? children : [children]).map((k: any) => __rv(k, hv)))\n` +
558
+ `}\n`
559
+
560
+ /** A helper component its module does not export (`ir.unexported`: `function SheetOverlay() {…}` rendered by an exported
561
+ * `SheetContent`) that still renders as an island has nothing to import: the module is loaded with `export { Name }`
562
+ * appended, for the island chunks and the SSR jobs alike (one module, one instance, either way). */
563
+ export function exposePlugin(compiled: Map<string, Compiled>): BunPlugin {
564
+ const names = new Map<string, Set<string>>()
565
+ const revived = revivedIds(compiled)
566
+ for (const c of compiled.values()) {
567
+ if (!(isReact(c) || revived.has(c.id)) || !c.ir.unexported || c.ir.external || !c.ir.export) continue
568
+ for (const f of new Set([c.file, realpathSync(c.file)])) names.set(f, (names.get(f) ?? new Set()).add(c.ir.export))
569
+ }
570
+ return {
571
+ name: 'brust-expose-local-islands',
572
+ setup(b) {
573
+ b.onLoad({ filter: /\.[cm]?[jt]sx?$/ }, async (a) => {
574
+ const exposed = names.get(a.path)
575
+ if (!exposed) return undefined
576
+ const loader = /\.[cm]?ts$/.test(a.path) ? 'ts' : /\.[cm]?js$/.test(a.path) ? 'js' : a.path.endsWith('.jsx') ? 'jsx' : 'tsx'
577
+ return { contents: `${await Bun.file(a.path).text()}\nexport { ${[...exposed].join(', ')} }\n`, loader }
578
+ })
579
+ },
580
+ }
581
+ }
582
+
583
+ /** The island entry of a react-tier component: registers `(host, props) => …` under its id; the hydrate fn returns the root's unmount, which the runtime calls when the host leaves the document (SPA navigation). A
273
584
  * `client_only` component was never server-rendered (its host is empty), so it mounts with
274
585
  * `createRoot`; hydrating it would fail (React #418). Everything else hydrates the SSR markup. */
275
- export function islandShim(c: Compiled): string {
586
+ /** `writers` (F230): app modules that write another module's exports when they load; imported first, so the island
587
+ * hydrates from what the app's React render saw. */
588
+ export function islandShim(c: Compiled, compiled?: Map<string, Compiled>, routeRoots?: ReadonlySet<string>, writers: string[] = []): string {
276
589
  const clientOnly = c.ir.tier?.React?.client_only === true
590
+ const nested = compiled && nestedOf(compiled, c.id, routeRoots)
591
+ const element = nested ? `__mk(Comp, props, ${JSON.stringify(xOf(compiled!, c.id))}, ${JSON.stringify(holesOf(compiled!, c.id))})` : 'createElement(Comp, props)'
277
592
  const [api, mount] = clientOnly
278
- ? ['createRoot', `createRoot(host, { identifierPrefix: ${JSON.stringify(c.id)} }).render(createElement(Comp, props))`]
279
- : ['hydrateRoot', `hydrateRoot(host, createElement(Comp, props), { identifierPrefix: ${JSON.stringify(c.id)} })`]
593
+ ? ['createRoot', `const root = createRoot(host, { identifierPrefix: ${JSON.stringify(c.id)} }); root.render(${element}); return () => root.unmount()`]
594
+ : ['hydrateRoot', `const root = hydrateRoot(host, ${element}, { identifierPrefix: ${JSON.stringify(c.id)} }); return () => root.unmount()`]
595
+ let head = ''
596
+ if (nested && compiled) {
597
+ const comps = nested.map((id) => compiled.get(id)).filter((n): n is Compiled => n !== undefined)
598
+ head =
599
+ `import { Fragment } from 'react'\n` +
600
+ comps.map((n, i) => `${componentImport(n, `N${i}`)}\n`).join('') +
601
+ reviveSource(`{ ${comps.map((n, i) => `${JSON.stringify(n.id)}: N${i}`).join(', ')} }`)
602
+ }
280
603
  return (
281
- `import { ${api} } from 'react-dom/client'\nimport { createElement } from 'react'\nimport Comp from ${JSON.stringify(c.file)}\n` +
604
+ writerImports(writers) +
605
+ `import { ${api} } from 'react-dom/client'\nimport { createElement } from 'react'\n${componentImport(c, 'Comp')}\n${head}` +
282
606
  `;((globalThis as any).__brustIslands ||= []).push([${JSON.stringify(c.id)}, (host: Element, props: any) => { ${mount} }])\n` +
283
607
  `;(globalThis as any).__brustIslandReady?.()\n`
284
608
  )
285
609
  }
286
610
 
611
+ /** F230: side-effect imports of the load-time writer modules (absolute paths), in a stable order. */
612
+ function writerImports(writers: string[]): string {
613
+ return [...new Set(writers)].map((w) => `import ${JSON.stringify(w)}\n`).join('')
614
+ }
615
+
287
616
  /** One island chunk per react-tier component (`react-<id>-<hex>.js`, contract 3), React split
288
617
  * into a shared chunk the island chunks import relatively. */
289
- export async function buildReactChunks(dist: string, compiled: Map<string, Compiled>, ctx: BrowserCtx, runtimeUrl: string): Promise<Map<string, string>> {
618
+ /** The react-tier components that get an island entry: all of them, except a package export (S1.4: every member of a
619
+ * package import is one) that no static or native template places as an island. Inside a React component, or as a
620
+ * component an island revives from its lowered children (`nestedOf`, S1 R34.21: those sit in the parent's
621
+ * `ir.children` too), it is an ordinary element of that tree, not an island, and an entry for it only reshuffles the
622
+ * shared chunks (measured: +11-24 KB gzip on a page that never loads it). Placed = a `<brust-island data-id>` marker
623
+ * in the printed template, the one thing the server hydrates. */
624
+ export function islandEntries(compiled: Map<string, Compiled>): Compiled[] {
625
+ const placed = new Set<string>()
626
+ for (const p of compiled.values()) {
627
+ if (isReact(p)) continue
628
+ for (const m of p.jinja.matchAll(/<brust-island\b[^>]*\bdata-id="([^"]+)"/g)) placed.add(m[1]!)
629
+ }
630
+ return [...compiled.values()].filter((c) => isReact(c) && (!c.ir.external || placed.has(c.id)))
631
+ }
632
+
633
+ /** `writersFor` (F230): the load-time writer modules an island's chunk must import first, from the app files the
634
+ * island's code is (its component, and the components it revives); none when absent. */
635
+ export async function buildReactChunks(
636
+ dist: string,
637
+ compiled: Map<string, Compiled>,
638
+ ctx: BrowserCtx,
639
+ runtimeUrl: RuntimeUrls,
640
+ routeRoots?: ReadonlySet<string>,
641
+ writersFor?: (c: Compiled, files: string[]) => string[],
642
+ ): Promise<Map<string, string>> {
290
643
  const entrypoints: string[] = []
291
- for (const c of compiled.values()) {
292
- if (!isReact(c)) continue
644
+ for (const c of islandEntries(compiled)) {
293
645
  const file = join(dist, '.stage', 'react', `react-${c.id}.tsx`)
294
- write(file, islandShim(c))
646
+ const own = [c, ...(nestedOf(compiled, c.id, routeRoots) ?? []).flatMap((id) => compiled.get(id) ?? [])]
647
+ const files = own.filter((n) => !(n.ir.external ?? n.ir.dist)).map((n) => n.file)
648
+ write(file, islandShim(c, compiled, routeRoots, writersFor?.(c, files) ?? []))
295
649
  stage(ctx.sourceOf, file, c)
296
650
  entrypoints.push(file)
297
651
  }
@@ -300,7 +654,7 @@ export async function buildReactChunks(dist: string, compiled: Map<string, Compi
300
654
  entrypoints,
301
655
  splitting: true,
302
656
  external: ['/_brust/*'],
303
- plugins: [runtimeAliasPlugin(runtimeUrl), sourceDirPlugin(ctx.sourceOf, ctx.appRoot)],
657
+ plugins: [runtimeAliasPlugin(runtimeUrl), sourceDirPlugin(ctx.sourceOf, ctx.appRoot, [], 'browser'), exposePlugin(compiled)],
304
658
  })
305
659
  return new Map([...out].map(([stem, path]) => [stem.slice('react-'.length), path]))
306
660
  }
@@ -335,8 +689,8 @@ const ACTIONS_EXTERNALS = [...JOBS_EXTERNALS, 'elysia', 'elysia/*', '@elysia/*']
335
689
 
336
690
  /** `dist/actions.js` (M3-F E1): the actions entry for the worker (`target: 'bun'`). No `define`: Elysia
337
691
  * and app code read NODE_ENV at runtime (run.ts sets production). */
338
- export async function buildActions(dist: string, entry: string): Promise<string> {
339
- const res = await Bun.build({ entrypoints: [entry], target: 'bun', format: 'esm', external: ACTIONS_EXTERNALS, plugins: [workerStorePlugin] })
692
+ export async function buildActions(dist: string, entry: string, cssMaps: Map<string, Record<string, string>> = new Map()): Promise<string> {
693
+ const res = await Bun.build({ entrypoints: [entry], target: 'bun', format: 'esm', external: ACTIONS_EXTERNALS, plugins: [serverOnlyEmptyPlugin(), cssPlugin(cssMaps, join(dist, 'client')), workerStorePlugin] })
340
694
  if (!res.success) throw new BuildError('bundle', `actions: ${res.logs.map(String).join('\n')}`)
341
695
  write(join(dist, 'actions.js'), await res.outputs[0]!.text())
342
696
  return 'actions.js'
@@ -344,40 +698,88 @@ export async function buildActions(dist: string, entry: string): Promise<string>
344
698
 
345
699
  /** `dist/cache-adapter.js` (cache adapters R8): a custom adapter module for the worker (`target: 'bun'`), with
346
700
  * the actions externals so it shares the app's dependencies. */
347
- export async function buildCacheAdapter(dist: string, entry: string): Promise<string> {
348
- const res = await Bun.build({ entrypoints: [entry], target: 'bun', format: 'esm', external: ACTIONS_EXTERNALS })
701
+ export async function buildCacheAdapter(dist: string, entry: string, cssMaps: Map<string, Record<string, string>> = new Map()): Promise<string> {
702
+ const res = await Bun.build({ entrypoints: [entry], target: 'bun', format: 'esm', external: ACTIONS_EXTERNALS, plugins: [serverOnlyEmptyPlugin(), cssPlugin(cssMaps, join(dist, 'client'))] })
349
703
  if (!res.success) throw new BuildError('bundle', `cache adapter: ${res.logs.map(String).join('\n')}`)
350
704
  write(join(dist, 'cache-adapter.js'), await res.outputs[0]!.text())
351
705
  return 'cache-adapter.js'
352
706
  }
353
707
 
708
+ /** R34.96: a parent's job calls the job of each child fused into it through `brust-job:<component id>`, resolved here to
709
+ * that child's staged job module (`fileOf`). Only a compiled component's id resolves, and only for a staged job file
710
+ * (`isJobFile`, F186): any other importer (app or package source) fails the bundle, as does an unknown id. */
711
+ export function jobImportPlugin(fileOf: (id: string) => string | undefined, isJobFile: (importer: string) => boolean): BunPlugin {
712
+ return {
713
+ name: 'brust-job-import',
714
+ setup(b) {
715
+ b.onResolve({ filter: /^brust-job:/ }, (a) => {
716
+ if (!isJobFile(a.importer)) throw new BuildError('bundle', `${a.importer}: brust-job: is for generated job modules only`)
717
+ const id = a.path.slice('brust-job:'.length)
718
+ const file = /^[A-Za-z0-9_$-]+$/.test(id) ? fileOf(id) : undefined
719
+ if (file === undefined) throw new BuildError('bundle', `${a.importer}: no job module for ${JSON.stringify(id)}`)
720
+ return { path: file }
721
+ })
722
+ },
723
+ }
724
+ }
725
+
354
726
  /** `dist/jobs.js` (`target: 'bun'`, React and `@brust/core` external): default export
355
727
  * `{ [componentId]: { precompute?, ssr? } }`; `ssr` only for react-tier, non-client_only components. */
356
- export async function buildJobs(dist: string, compiled: Map<string, Compiled>, sourceOf: SourceOf, appRoot: string, stores: string[] = []): Promise<void> {
357
- const imports: string[] = ["import { createElement } from 'react'", "import { renderToString } from 'react-dom/server'"]
728
+ export async function buildJobs(
729
+ dist: string,
730
+ compiled: Map<string, Compiled>,
731
+ sourceOf: SourceOf,
732
+ appRoot: string,
733
+ stores: string[] = [],
734
+ cssMaps: Map<string, Record<string, string>> = new Map(),
735
+ routeRoots?: ReadonlySet<string>,
736
+ /** F183 (`reachedJobs`): only these entries are written; every one when absent. */
737
+ reached?: { planned: ReadonlySet<string>; ssr: ReadonlySet<string> },
738
+ /** F230: app modules that write another module's exports when they load: imported first (see `islandShim`). */
739
+ writers: string[] = [],
740
+ ): Promise<void> {
741
+ const imports: string[] = [writerImports(writers).trimEnd(), "import { createElement } from 'react'", "import { renderToString } from 'react-dom/server'"].filter((l) => l !== '')
358
742
  const entries: string[] = []
359
743
  const storePaths = [...new Set(stores)].sort()
360
744
  for (const [i, p] of storePaths.entries()) {
745
+ if (BUILTIN_STORE_MODULES.includes(p)) {
746
+ imports.push(`import * as S${i} from ${JSON.stringify(p)}`) // a package module, external in jobs.js
747
+ continue
748
+ }
361
749
  if (isAbsolute(p) || p.split('/').includes('..') || !isFile(resolve(appRoot, p))) {
362
750
  throw new BuildError('store-module', `store module ${p} is not a file inside the app (${appRoot})`)
363
751
  }
364
752
  imports.push(`import * as S${i} from ${JSON.stringify(resolve(appRoot, p))}`)
365
753
  }
754
+ // Components an island revives from its lowered children (shadcn S1): one registry for every SSR job.
755
+ const reviving = [...compiled.values()].filter((c) => nestedOf(compiled, c.id, routeRoots) !== undefined)
756
+ const revived = [...revivedIds(compiled)].map((id) => compiled.get(id)).filter((x): x is Compiled => x !== undefined)
757
+ if (reviving.length > 0) {
758
+ imports.push("import { Fragment } from 'react'")
759
+ for (const [i, r] of revived.entries()) imports.push(componentImport(r, `N${i}`))
760
+ imports.push(reviveSource(`{ ${revived.map((r, i) => `${JSON.stringify(r.id)}: N${i}`).join(', ')} }`))
761
+ }
762
+ const hosts = new Set(reviving.map((c) => c.id))
763
+ const jobFiles = new Set<string>() // the staged job modules: the only importers of `brust-job:` (F186)
366
764
  let n = 0
367
765
  for (const c of compiled.values()) {
368
766
  const fns: string[] = []
369
767
  if (c.serverTs !== undefined) {
768
+ // Staged even when unreached: a parent's fused job imports it (`brust-job:<id>`, R34.96).
370
769
  const file = join(dist, 'jobs', `${c.id}.server.ts`)
371
770
  write(file, c.serverTs)
372
771
  stage(sourceOf, file, c)
373
- imports.push(`import * as j${n} from ${JSON.stringify(file)}`)
374
- fns.push(`precompute: j${n}.precompute`)
772
+ jobFiles.add(file).add(realpathSync(file))
773
+ if (reached === undefined || reached.planned.has(c.id)) {
774
+ imports.push(`import * as j${n} from ${JSON.stringify(file)}`)
775
+ fns.push(`precompute: j${n}.precompute`)
776
+ }
375
777
  }
376
- if (isReact(c) && !c.ir.tier.React.client_only) {
377
- imports.push(`import C${n} from ${JSON.stringify(c.file)}`)
778
+ if (isReact(c) && !c.ir.tier.React.client_only && (reached === undefined || reached.ssr.has(c.id))) {
779
+ imports.push(componentImport(c, `C${n}`))
378
780
  // Ruling 2bf3775a (R1): the island's component id prefixes React's useId on BOTH sides
379
781
  // (the shim's hydrateRoot passes the same prefix), so ids differ across islands.
380
- fns.push(`ssr: (props: any) => renderToString(createElement(C${n}, props), { identifierPrefix: ${JSON.stringify(c.id)} })`)
782
+ fns.push(`ssr: (props: any) => renderToString(${hosts.has(c.id) ? `__mk(C${n}, props, ${JSON.stringify(xOf(compiled, c.id))}, ${JSON.stringify(holesOf(compiled, c.id))})` : `createElement(C${n}, props)`}, { identifierPrefix: ${JSON.stringify(c.id)} })`)
381
783
  }
382
784
  if (fns.length > 0) entries.push(` ${JSON.stringify(c.id)}: { ${fns.join(', ')} },`)
383
785
  n++
@@ -390,7 +792,17 @@ export async function buildJobs(dist: string, compiled: Map<string, Compiled>, s
390
792
  format: 'esm',
391
793
  define: PRODUCTION,
392
794
  external: JOBS_EXTERNALS,
393
- plugins: [workerStorePlugin, sourceDirPlugin(sourceOf, appRoot, JOBS_EXTERNALS)],
795
+ plugins: [
796
+ jobImportPlugin(
797
+ (id) => (compiled.get(id)?.serverTs !== undefined ? join(dist, 'jobs', `${id}.server.ts`) : undefined),
798
+ (importer) => jobFiles.has(importer),
799
+ ),
800
+ serverOnlyEmptyPlugin(),
801
+ cssPlugin(cssMaps, join(dist, 'client')),
802
+ workerStorePlugin,
803
+ sourceDirPlugin(sourceOf, appRoot, JOBS_EXTERNALS),
804
+ exposePlugin(compiled),
805
+ ],
394
806
  })
395
807
  if (!res.success) throw new BuildError('bundle', `jobs: ${res.logs.map(String).join('\n')}`)
396
808
  write(join(dist, 'jobs.js'), await res.outputs[0]!.text())