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