@brust/core 2.0.0-alpha.2 → 2.0.0-alpha.20

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 (68) hide show
  1. package/README.md +66 -6
  2. package/native/index.d.ts +89 -4
  3. package/native/index.js +60 -54
  4. package/package.json +25 -9
  5. package/src/actions/client.ts +22 -5
  6. package/src/actions/load.ts +2 -1
  7. package/src/actions/prefix.ts +24 -2
  8. package/src/actions-bridge.ts +29 -11
  9. package/src/browser.ts +3 -1
  10. package/src/build/actions-only.ts +86 -0
  11. package/src/build/build-eval.ts +332 -0
  12. package/src/build/build-icons.ts +69 -0
  13. package/src/build/bundle.ts +462 -52
  14. package/src/build/cf-assets.ts +83 -0
  15. package/src/build/cf-core.ts +90 -0
  16. package/src/build/cf-entry.ts +76 -0
  17. package/src/build/cf-wrangler.ts +169 -0
  18. package/src/build/cloudflare.ts +320 -0
  19. package/src/build/compile.ts +114 -5
  20. package/src/build/config.ts +294 -11
  21. package/src/build/css.ts +275 -0
  22. package/src/build/esm-resolve.ts +170 -0
  23. package/src/build/features.ts +114 -0
  24. package/src/build/index.ts +264 -20
  25. package/src/build/manifest.ts +85 -7
  26. package/src/build/pkg-src.ts +514 -0
  27. package/src/build/recognised.ts +19 -0
  28. package/src/build/scan.ts +71 -20
  29. package/src/build/server-only.ts +39 -0
  30. package/src/build/ssg-render.ts +65 -0
  31. package/src/build/ssg.ts +146 -0
  32. package/src/build/static-worker.ts +91 -0
  33. package/src/build/static.ts +258 -0
  34. package/src/build/tailwind.ts +117 -0
  35. package/src/build/tsconfig-paths.ts +67 -0
  36. package/src/build-time.ts +10 -0
  37. package/src/cache-adapter/bridge.ts +91 -0
  38. package/src/cache-adapter/conformance.ts +226 -0
  39. package/src/cache-adapter/index.ts +10 -0
  40. package/src/cache-adapter/load.ts +144 -0
  41. package/src/cache-adapter/memory.ts +229 -0
  42. package/src/cache-adapter/runtime.ts +124 -0
  43. package/src/cache-adapter/types.ts +96 -0
  44. package/src/cache-adapter/workers.ts +118 -0
  45. package/src/cache.ts +4 -1
  46. package/src/cf.ts +19 -0
  47. package/src/cli.ts +71 -2
  48. package/src/config.ts +127 -25
  49. package/src/css-modules.d.ts +8 -0
  50. package/src/dev/bun.ts +371 -0
  51. package/src/dev/client.ts +92 -0
  52. package/src/dev/devtools-data.ts +23 -0
  53. package/src/dev/devtools.ts +356 -0
  54. package/src/dev/diag.ts +30 -0
  55. package/src/dev/proxy.ts +236 -0
  56. package/src/dev/tui.ts +144 -0
  57. package/src/dev/watch.ts +130 -0
  58. package/src/dev.ts +97 -0
  59. package/src/handlers.ts +282 -0
  60. package/src/index.ts +3 -1
  61. package/src/native.ts +38 -1
  62. package/src/routes.ts +20 -1
  63. package/src/run.ts +59 -12
  64. package/src/server-handlers.ts +32 -0
  65. package/src/shadcn/init.ts +513 -0
  66. package/src/shadcn/jsonc.ts +129 -0
  67. package/src/shadcn/registry.ts +149 -0
  68. package/src/worker.ts +127 -245
@@ -0,0 +1,320 @@
1
+ // `brust build --target cloudflare` (M4-CF spec §4): after the normal build, writes `<out>/cf/`:
2
+ // worker/ the @brust/core-cf glue + wasm (verbatim) + <appModuleDir>/brust-app.js (ONE Bun bundle, R2)
3
+ // assets/ Static Assets tree + _headers (R7) wrangler.jsonc (user config merged with brust's keys, R5)
4
+ // .stage/ the generated entry + the smoke harness (kept for inspection)
5
+ import { cpSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
6
+ import { builtinModules } from 'node:module'
7
+ import { dirname, join, relative } from 'node:path'
8
+ import type { BunPlugin } from 'bun'
9
+ import { BrustConfigError, type CorsConfig, loadCors } from '../config'
10
+ import { layoutAssets } from './cf-assets'
11
+ import { serverOnlyEmptyPlugin } from './server-only'
12
+ import { type CfConfigJson, cfEntrySource } from './cf-entry'
13
+ import { type CoreCf, resolveCoreCf } from './cf-core'
14
+ import { type CacheNeed, mergeWrangler, readUserWrangler, renderWrangler, type UserWrangler, verifyCacheBindings } from './cf-wrangler'
15
+ import { type CloudflareBuildConfig, CF_MIN_COMPAT_DATE } from './config'
16
+ import { MANIFEST_VERSION } from './manifest'
17
+ import { exposePlugin, PRODUCTION, type SourceOf, sourceDirPlugin } from './bundle'
18
+ import type { Compiled } from './compile'
19
+ import { cssPlugin } from './css'
20
+ import { BuildError } from './errors'
21
+ import type { CacheRecord } from './manifest'
22
+ import { BUILTINS as CACHE_BUILTINS } from '../cache-adapter/load'
23
+
24
+ export interface CfFlags {
25
+ fromSource: boolean
26
+ env: Record<string, string | undefined>
27
+ }
28
+
29
+ export interface CfPrepared {
30
+ cfg: CloudflareBuildConfig
31
+ core: CoreCf
32
+ user: UserWrangler
33
+ coreVersion: string
34
+ pkgName: string | undefined
35
+ /** brust.toml `[cors]`, validated before any write */
36
+ cors: CorsConfig | undefined
37
+ warnings: string[]
38
+ }
39
+
40
+ const DEFAULT_CFG: CloudflareBuildConfig = { name: undefined, compatibilityDate: undefined, l1Capacity: 256, jobCacheCapacity: 512, stats: false, edgeCache: 'off', alias: {} }
41
+
42
+ /** Everything that can fail on the app's own input or install, before a byte is written (index.ts rule). */
43
+ export async function prepareCloudflare(appRoot: string, cfg: CloudflareBuildConfig | null, flags: CfFlags): Promise<CfPrepared> {
44
+ const core = resolveCoreCf({ appRoot, fromSource: flags.fromSource, env: flags.env })
45
+ const user = await readUserWrangler(appRoot)
46
+ const warnings = [...core.warnings]
47
+ try {
48
+ const toml = Bun.file(join(appRoot, 'brust.toml'))
49
+ if (await toml.exists()) {
50
+ const root = Bun.TOML.parse(await toml.text()) as Record<string, unknown>
51
+ for (const k of ['server', 'workers']) if (k in root) warnings.push(`[brust] brust.toml [${k}] is ignored on this target`)
52
+ }
53
+ } catch {
54
+ // loadBuildConfig already reported a malformed brust.toml
55
+ }
56
+ let pkgName: string | undefined
57
+ try {
58
+ pkgName = (JSON.parse(readFileSync(join(appRoot, 'package.json'), 'utf8')) as { name?: string }).name
59
+ } catch {
60
+ pkgName = undefined
61
+ }
62
+ const coreVersion = String((JSON.parse(readFileSync(join(import.meta.dir, '..', '..', 'package.json'), 'utf8')) as { version?: string }).version ?? '0.0.0')
63
+ let cors: CorsConfig | undefined
64
+ try {
65
+ cors = await loadCors(appRoot)
66
+ } catch (e) {
67
+ if (e instanceof BrustConfigError) throw new BuildError('config', e.message)
68
+ throw e
69
+ }
70
+ return { cfg: cfg ?? DEFAULT_CFG, core, user, coreVersion, pkgName, cors, warnings }
71
+ }
72
+
73
+ export interface CfBuildCtx {
74
+ appRoot: string
75
+ entry: string
76
+ /** the FINAL dist dir (`out` is a tmp sibling that is renamed to it): wrangler paths are relative to `<dist>/cf` */
77
+ dist: string
78
+ compiled: Map<string, Compiled>
79
+ manifest: { components: Record<string, { template: string }>; actions?: { prefix: string; maxBodyBytes: number; maxResponseBytes?: number; crossSite?: 'reject' | 'allow' } | null; cache?: CacheRecord }
80
+ sourceOf: SourceOf
81
+ actionsEntry: string | null
82
+ /** L16 R16.3: an actions-only Worker: no pages (no routes entry, no jobs, no templates), no Static Assets. */
83
+ actionsOnly?: boolean
84
+ /** A custom cache adapter's source module (absolute). */
85
+ cacheModule?: string | null
86
+ /** `[cache] get_timeout_ms` as written (absent → the Workers default, 100). */
87
+ cacheGetTimeoutMs?: number
88
+ /** L19: CSS Module class maps (the worker reads the same map as the browser). */
89
+ cssMaps?: Map<string, Record<string, string>>
90
+ log: (s: string) => void
91
+ }
92
+
93
+ /** The resolution conditions of the Worker bundle; the package-resolver probe (`sourceDirPlugin`) must carry the same (F223). */
94
+ export const CF_CONDITIONS = ['workerd', 'worker', 'browser']
95
+
96
+ const CORE_SRC = join(import.meta.dir, '..')
97
+ const BUILTINS = new Set(builtinModules.filter((m) => !m.startsWith('_')))
98
+
99
+ /** Violations of the Workers host surface: `bun`/`bun:*` anywhere in the graph (R2 f). */
100
+ function cfGuard(appRoot: string, violations: string[]): BunPlugin {
101
+ return {
102
+ name: 'brust-cf-guard',
103
+ setup(b) {
104
+ b.onResolve({ filter: /^bun(:.*)?$/ }, (a) => {
105
+ violations.push(`${relative(appRoot, a.importer) || a.importer} imports ${a.path}`)
106
+ return { path: a.path, external: true }
107
+ })
108
+ b.onResolve({ filter: /^[a-z_]+(\/[a-z_/]+)?$/ }, (a) => (BUILTINS.has(a.path) ? { path: `node:${a.path}`, external: true } : undefined))
109
+ },
110
+ }
111
+ }
112
+
113
+ /** `[cloudflare] alias` (spec R12): a module whose resolved path is a key is replaced by its value. */
114
+ function cfAlias(alias: Record<string, string>, sourceOf: SourceOf): BunPlugin {
115
+ return {
116
+ name: 'brust-cf-alias',
117
+ setup(b) {
118
+ if (Object.keys(alias).length === 0) return
119
+ b.onResolve({ filter: /.*/ }, (a) => {
120
+ if (a.importer === '' || a.importer.includes('/node_modules/')) return undefined
121
+ let abs: string
122
+ try {
123
+ abs = Bun.resolveSync(a.path, dirname(sourceOf.get(a.importer) ?? a.importer))
124
+ } catch {
125
+ return undefined
126
+ }
127
+ const to = alias[abs]
128
+ return to === undefined ? undefined : { path: to }
129
+ })
130
+ },
131
+ }
132
+ }
133
+
134
+ /** The CF bundle's stand-in for the napi addon (`native.ts` `require('../native/index.js')`, R8): every call
135
+ * fails loudly, since a Worker has no addon. */
136
+ function cfNativeStub(handlersPath: string): BunPlugin {
137
+ const names = ['startServer', 'registerWorker', 'untilReady', 'beginDrain', 'cacheInvalidate', 'cacheStats', 'localAddr', 'planJobs', 'planJobsBuf', 'takeFetchBody', 'putFetchReply', 'cacheReplay', 'cacheL2Count', 'cacheClear']
138
+ return {
139
+ name: 'brust-cf-native',
140
+ setup(b) {
141
+ b.onResolve({ filter: /^\.\.\/native\/index\.js$/ }, (a) => (/[\\/]src[\\/]native\.ts$/.test(a.importer) ? { path: 'brust-cf-native', namespace: 'brust-cf' } : undefined))
142
+ b.onLoad({ filter: /.*/, namespace: 'brust-cf' }, () => ({
143
+ loader: 'js',
144
+ // cacheInvalidate -> the `rt.invalidate` the glue installed at start (ruling 19753649); the rest has no Worker meaning.
145
+ contents: `import { installedRuntime } from ${JSON.stringify(handlersPath)}\nconst no = (n) => () => { throw new Error(n + ' is not available on Cloudflare Workers') }\nmodule.exports = {${names.map((n) => (n === 'cacheInvalidate' ? 'cacheInvalidate: (a) => installedRuntime().invalidate(a)' : `${n}: no(${JSON.stringify(n)})`)).join(', ')}}\n`,
146
+ }))
147
+ },
148
+ }
149
+ }
150
+
151
+ /** The @brust/core `workerStorePlugin` twin for a bundle that inlines `@brust/core` (not external). */
152
+ function cfStorePlugin(appRoot: string): BunPlugin {
153
+ return {
154
+ name: 'brust-cf-store',
155
+ setup(b) {
156
+ // The core's own re-export files (`src/store/{index,react}.ts`) must keep their real target, else they loop.
157
+ b.onResolve({ filter: /^@brust\/runtime-dom\/store(\/react)?$/ }, (a) =>
158
+ a.importer.startsWith(CORE_SRC) ? undefined : { path: Bun.resolveSync(a.path.replace('@brust/runtime-dom', '@brust/core'), appRoot) },
159
+ )
160
+ },
161
+ }
162
+ }
163
+
164
+ /** The entry's cache wiring (R8 on Workers): the custom source module or the built-in spec; an `env:`
165
+ * option cannot be resolved inside a Worker (no process env at bundle time) and is refused. */
166
+ function cfEntryCache(c: CacheRecord, customModule: string | null) {
167
+ const noEnv = (o: Record<string, unknown> | undefined) => {
168
+ for (const [k, v] of Object.entries(o ?? {}))
169
+ if (typeof v === 'string' && v.startsWith('env:')) throw new BuildError('cache', `cache option ${k}: "env:" values are not available on Cloudflare Workers (use a binding)`)
170
+ }
171
+ noEnv(c.options)
172
+ noEnv(c.broadcastOptions)
173
+ const module = c.adapter === 'custom' ? customModule : CACHE_BUILTINS[c.adapter]
174
+ if (!module) throw new BuildError('cache', `cache adapter "${c.adapter}" has no module to bundle`)
175
+ return { module, options: c.options, ...(c.broadcast ? { broadcast: CACHE_BUILTINS[c.broadcast]!, broadcastOptions: c.broadcastOptions ?? {} } : {}) }
176
+ }
177
+
178
+ /** R2/R10: which Cloudflare bindings the configured cache needs (`[cache.cloudflare]` keys land in the adapter's options), or null. */
179
+ export function cacheNeeds(c: Pick<CacheRecord, 'adapter' | 'broadcast' | 'options' | 'broadcastOptions'> | undefined): CacheNeed | null {
180
+ if (!c) return null
181
+ const opt = (o: Record<string, unknown> | undefined, k: string) => (typeof o?.[k] === 'string' ? (o[k] as string) : undefined)
182
+ const all = [c.options, c.broadcastOptions]
183
+ const names = [c.adapter, c.broadcast]
184
+ const kv = names.includes('cloudflare-kv') ? (all.map((o) => opt(o, 'kv_binding')).find((b) => b) ?? 'BRUST_CACHE') : undefined
185
+ const explicitDo = all.map((o) => opt(o, 'do_binding')).find((b) => b)
186
+ const dobind = names.includes('cloudflare-do') || explicitDo !== undefined ? (explicitDo ?? 'BRUST_CACHE_DO') : undefined
187
+ return kv === undefined && dobind === undefined ? null : { ...(kv !== undefined ? { kv } : {}), ...(dobind !== undefined ? { do: dobind } : {}) }
188
+ }
189
+
190
+ function smokeFiles(app: string, cache = false): { preload: string; smoke: string } {
191
+ const preload = `// generated: stubs the Workers-only modules so brust-app.js can be imported under Bun
192
+ import { plugin } from 'bun'
193
+ plugin({
194
+ name: 'brust-cf-smoke',
195
+ setup(b) {
196
+ b.module('cloudflare:workers', () => ({ exports: { env: {}, waitUntil() {}, DurableObject: class {} }, loader: 'object' }))
197
+ },
198
+ })
199
+ `
200
+ const smoke = `// generated: build-time smoke check of the CF bundle (child process; mock leaks are process-wide)
201
+ const m = await import(${JSON.stringify(app)})
202
+ const bad = []
203
+ for (const k of ['loader', 'jobs', 'action', 'install']) if (typeof m[k] !== 'function') bad.push(k + ' is not a function')
204
+ if (typeof m.MANIFEST !== 'string' || JSON.parse(m.MANIFEST).version !== ${MANIFEST_VERSION}) bad.push('MANIFEST.version !== ${MANIFEST_VERSION}')
205
+ if (typeof m.CONFIG !== 'string' || JSON.parse(m.CONFIG).abi !== 1) bad.push('CONFIG.abi !== 1')
206
+ if (typeof m.TEMPLATES !== 'object' || m.TEMPLATES === null) bad.push('TEMPLATES is not an object')
207
+ const r = await m.loader('{}')
208
+ if (typeof r !== 'string' || !r.includes('error')) bad.push('loader({}) did not resolve an error string: ' + String(r))${cache ? `
209
+ try {
210
+ const out = m.install({ invalidate: () => ({ l1Removed: 0, jobRemoved: 0 }), l2Count: () => true })
211
+ if (!out || !out.cache || typeof out.cache.origin !== 'string') bad.push('install() returned no cache bridge')
212
+ } catch (e) { bad.push('cache adapter: ' + (e && e.message ? e.message : String(e))) }` : ''}
213
+ if (bad.length) { console.error(bad.join('\\n')); process.exit(1) }
214
+ `
215
+ return { preload, smoke }
216
+ }
217
+
218
+ export async function buildCloudflare(out: string, ctx: CfBuildCtx, p: CfPrepared): Promise<string[]> {
219
+ const cfDir = join(out, 'cf')
220
+ const stage = join(cfDir, '.stage')
221
+ const warnings = [...p.warnings]
222
+ // R10: a Cloudflare cache built-in needs the user's own bindings; verify (never add) before anything is bundled.
223
+ const need = cacheNeeds(ctx.manifest.cache)
224
+ if (need) {
225
+ const v = verifyCacheBindings(p.user.config, need)
226
+ if (!v.ok) throw new BuildError('cf-config', v.message)
227
+ }
228
+ const doClass = need?.do !== undefined
229
+ if (doClass && existsSync(join(p.core.workerDir, 'main.js'))) throw new BuildError('cf-config', `@brust/core-cf ships its own worker/main.js; brust cannot add the BrustCacheDO re-export (R1)`)
230
+ mkdirSync(stage, { recursive: true })
231
+ cpSync(p.core.workerDir, join(cfDir, 'worker'), { recursive: true })
232
+
233
+ const templates: Record<string, string> = {}
234
+ for (const [id, c] of Object.entries(ctx.manifest.components)) {
235
+ const comp = ctx.compiled.get(id)
236
+ if (comp) templates[c.template] = comp.jinja
237
+ }
238
+ const templatesPath = join(stage, 'templates.json')
239
+ writeFileSync(templatesPath, JSON.stringify(templates))
240
+
241
+ const cors = p.cors
242
+ const a = ctx.manifest.actions
243
+ const cache = ctx.manifest.cache ? { ...ctx.manifest.cache, getTimeoutMs: ctx.cacheGetTimeoutMs ?? 100 } : null
244
+ const entryCache = cache ? cfEntryCache(cache, ctx.cacheModule ?? null) : null
245
+ const config: CfConfigJson = {
246
+ abi: 1,
247
+ generator: `brust/${p.coreVersion}`,
248
+ l1Capacity: p.cfg.l1Capacity,
249
+ jobCacheCapacity: p.cfg.jobCacheCapacity,
250
+ stats: p.cfg.stats,
251
+ edgeCache: p.cfg.edgeCache,
252
+ ...(cors ? { cors } : {}),
253
+ ...(a ? { actions: { prefix: a.prefix, maxBodyBytes: a.maxBodyBytes, maxResponseBytes: a.maxResponseBytes ?? 8_388_608, crossSite: a.crossSite ?? 'reject' } } : {}),
254
+ ...(cache ? { cache: { adapter: cache.adapter, getTimeoutMs: cache.getTimeoutMs, maxBodyBytes: cache.maxBodyBytes, namespace: cache.namespace, buildId: cache.buildId, epochIntervalMs: cache.epochIntervalMs } } : {}),
255
+ }
256
+ const entryPath = join(stage, 'brust-app.entry.js')
257
+ const jobsEntry = join(out, '.stage', 'jobs.ts')
258
+ writeFileSync(
259
+ entryPath,
260
+ cfEntrySource({ entry: ctx.entry, jobsEntry, actionsOnly: ctx.actionsOnly === true, actionsEntry: a ? ctx.actionsEntry : null, prefix: a?.prefix ?? '', manifestPath: join(out, 'manifest.json'), templatesPath, config, cache: entryCache, doClass }),
261
+ )
262
+ // Bare imports of the generated files resolve from the app root (an --out-dir outside the app has no node_modules above it).
263
+ // (registered by absolute AND real path: the bundler reports the real one, e.g. /private/var on macOS)
264
+ for (const f of ctx.actionsOnly ? [entryPath] : [entryPath, jobsEntry]) {
265
+ ctx.sourceOf.set(f, ctx.entry)
266
+ ctx.sourceOf.set(realpathSync(f), ctx.entry)
267
+ }
268
+
269
+ const glue = p.core.meta.glue
270
+ const appDir = join(cfDir, 'worker', p.core.meta.appModuleDir)
271
+ const glueRel = relative(appDir, join(cfDir, 'worker', glue)).split('\\').join('/')
272
+ const glueSpec = glueRel.startsWith('.') ? glueRel : `./${glueRel}`
273
+ const violations: string[] = []
274
+ const res = await Bun.build({
275
+ entrypoints: [entryPath],
276
+ target: 'browser',
277
+ format: 'esm',
278
+ conditions: CF_CONDITIONS,
279
+ define: PRODUCTION,
280
+ minify: false,
281
+ external: ['node:*', 'cloudflare:*', glueSpec],
282
+ plugins: [serverOnlyEmptyPlugin(), cssPlugin(ctx.cssMaps ?? new Map()), cfGuard(ctx.appRoot, violations), cfAlias(p.cfg.alias, ctx.sourceOf), cfNativeStub(Bun.resolveSync('@brust/core/server-handlers', ctx.appRoot)), cfStorePlugin(ctx.appRoot), sourceDirPlugin(ctx.sourceOf, ctx.appRoot, ['node:*', 'cloudflare:*'], 'browser', CF_CONDITIONS), exposePlugin(ctx.compiled)],
283
+ })
284
+ if (violations.length > 0) throw new BuildError('cf-unsupported', `${[...new Set(violations)].join('\n')}\nthese modules do not exist on Cloudflare Workers; swap them with [cloudflare] alias`)
285
+ if (!res.success) throw new BuildError('bundle', `cloudflare: ${res.logs.map(String).join('\n')}`)
286
+ const js = res.outputs.filter((o) => o.kind === 'entry-point')
287
+ if (js.length !== 1) throw new BuildError('bundle', `cloudflare: expected one entry-point output, got ${js.length}`)
288
+ for (const o of res.outputs) if (o.kind !== 'entry-point') warnings.push(`[brust] cloudflare bundle: dropped non-JS output ${o.path}`)
289
+ const appFile = join(appDir, 'brust-app.js')
290
+ mkdirSync(appDir, { recursive: true })
291
+ writeFileSync(appFile, await js[0]!.text())
292
+
293
+ // An actions-only Worker has no pages and so no Static Assets (no `assets` binding in its wrangler.jsonc either).
294
+ if (!ctx.actionsOnly) warnings.push(...layoutAssets(out, cfDir, p.coreVersion).warnings)
295
+ const compatibilityDate = p.cfg.compatibilityDate ?? p.core.meta.compatibilityDate
296
+ if (compatibilityDate < CF_MIN_COMPAT_DATE) throw new BuildError('config', `compatibility_date ${compatibilityDate} is older than ${CF_MIN_COMPAT_DATE}`)
297
+ if (doClass) {
298
+ // R1: the Durable Object class must be exported by the deployed main module; the glue already imports ./brust-app.js, so it is one module copy.
299
+ const appRel = relative(join(cfDir, 'worker'), appFile).split('\\').join('/')
300
+ writeFileSync(
301
+ join(cfDir, 'worker', 'main.js'),
302
+ `// generated by brust build --target cloudflare (do not edit)\nexport * from "./${p.core.meta.main}"\nexport { default } from "./${p.core.meta.main}"\nexport { BrustCacheDO } from "./${appRel}"\n`,
303
+ )
304
+ }
305
+ const merged = mergeWrangler(p.user, { appRoot: ctx.appRoot, cfDir: join(ctx.dist, 'cf'), name: p.cfg.name, pkgName: p.pkgName, compatibilityDate, main: doClass ? 'main.js' : p.core.meta.main, version: p.coreVersion, ...(ctx.actionsOnly ? { noAssets: true } : {}) })
306
+ warnings.push(...merged.warnings)
307
+ writeFileSync(join(cfDir, 'wrangler.jsonc'), renderWrangler(merged.config))
308
+
309
+ const { preload, smoke } = smokeFiles(appFile, cache !== null)
310
+ writeFileSync(join(stage, 'smoke-preload.ts'), preload)
311
+ writeFileSync(join(stage, 'smoke.ts'), smoke)
312
+ const r = Bun.spawnSync([process.execPath, '--preload', join(stage, 'smoke-preload.ts'), join(stage, 'smoke.ts')], {
313
+ cwd: ctx.appRoot,
314
+ env: { ...process.env, NODE_ENV: 'production' },
315
+ stdout: 'pipe',
316
+ stderr: 'pipe',
317
+ })
318
+ if (r.exitCode !== 0) throw new BuildError('cf-smoke', `${r.stderr.toString().trim() || r.stdout.toString().trim()}\n(open bindings lazily: module top level runs at build time with env = {})`)
319
+ return warnings
320
+ }
@@ -2,10 +2,13 @@
2
2
  // `compileTree` (analysis + lowering of its whole tree). `Err` = build error; fallback and
3
3
  // warning diagnostics are logged; `outlet-outside-layout` is raised here (contract 5).
4
4
  // A component several trees reach keeps one compile, chosen by role, not by route order (F75).
5
- import { relative } from 'node:path'
6
- import { type CompiledComponent, type CompiledTree, compileTree } from '../../native/index.js'
5
+ import { realpathSync } from 'node:fs'
6
+ import { relative, sep } from 'node:path'
7
+ import { type CompiledComponent, type CompiledTree, compileTree, scanLoadWrites } from '../../native/index.js'
7
8
  import type { FlatRoute, Route } from '../routes'
8
9
  import { BuildError } from './errors'
10
+ import { checkAliasAgreement, readPathAliases } from './tsconfig-paths'
11
+ import type { PackageSources } from './pkg-src'
9
12
 
10
13
  /** `ComponentIR` as the compiler serialises it (snake_case), with the fields that are absent
11
14
  * when default (`ir/mod.rs`) normalised. The build reads it; it never rewrites it. */
@@ -17,13 +20,44 @@ export type ComponentIR = Record<string, any> & {
17
20
  tier: any
18
21
  // biome-ignore lint/suspicious/noExplicitAny: IR JSON
19
22
  jobs: any[]
20
- instances: { child_id: string; k: number; loops: (string | null)[]; props: Record<string, string | null> }[]
23
+ instances: {
24
+ child_id: string
25
+ k: number
26
+ loops: (string | null)[]
27
+ props: Record<string, string | null>
28
+ literals?: Record<string, unknown>
29
+ exprs?: Record<string, string>
30
+ /** F142 R34.43: the rows of a constant enclosing list (`loops` is then `[null]`). */
31
+ rows_literal?: unknown[]
32
+ }[]
33
+ /** F149: inlined instances whose child job the build may run once (`decls.rs` `BuildEvalInstance`). */
34
+ build_eval_instances?: {
35
+ key: string
36
+ child_id: string
37
+ k: number
38
+ inputs: Record<string, unknown>
39
+ outputs: string[]
40
+ stubs?: { source: string; code: string }[]
41
+ /** R34.96: the job modules fused into the child's job, which it imports (`FusedEvalJob`). */
42
+ fused?: { child_id: string; stubs?: { source: string; code: string }[] }[]
43
+ line: number
44
+ col: number
45
+ }[]
21
46
  use_id_slots: number
22
47
  uses_outlet: boolean
23
48
  needs_worker: boolean
24
49
  /** `useStore(handle)` bindings (`decls.rs` `StoreUse`); `resolved` = `StoreRef`, `null` when
25
50
  * unresolved (the component falls back). `module` is root-relative (C3). */
26
51
  stores?: { handle: string; resolved: { name: string; module: string } | null }[]
52
+ /** A package export rendered as a React island (L22): `source` is then not a file. */
53
+ external?: { source: string; export: string; member?: string[] }
54
+ /** A component compiled from a package source and written in app code as a package import (F188): the published
55
+ * export a React island or revive hook imports instead of the source. */
56
+ dist?: { source: string; export: string; member?: string[] }
57
+ /** The binding a React island imports this component by from `source` (absent: the default export). */
58
+ export?: string
59
+ /** Child components; `nested` is set when this file passes lowered JSX children to that child, an island (shadcn S1). */
60
+ children?: { id: string | null; nested?: string[]; x?: string[]; holes?: string[]; [field: string]: unknown }[]
27
61
  }
28
62
 
29
63
  export interface Compiled {
@@ -33,7 +67,18 @@ export interface Compiled {
33
67
  ir: ComponentIR
34
68
  jinja: string
35
69
  serverTs?: string
70
+ /** Build-time evaluation module (L2): one thunk per `ir.build_eval` slot. */
71
+ evalTs?: string
36
72
  clientJs?: string
73
+ /** F362 PR2: `clientJs` importing every imported list as written; the build uses it when a module in
74
+ * `projectedModules` is still in the client graph through another importer (`bundle.ts` `buildClientChunks`). */
75
+ clientJsWhole?: string
76
+ /** F362 PR2: data modules (app-root-relative) whose imported list `clientJs` declares projected instead of importing. */
77
+ projectedModules?: string[]
78
+ /** Modules the chunk imports that other chunks share (R34.124): `<name>.js` beside it. */
79
+ clientModules?: { name: string; code: string }[]
80
+ /** The compiler's diagnostics for this component (`dev.json`, L14). */
81
+ diagnostics?: { class: string; rule: string; message: string; line: number; col: number; remediation: string }[]
37
82
  }
38
83
 
39
84
  type Diag = NonNullable<CompiledTree['error']>
@@ -74,15 +119,61 @@ export function replaces(kept: Seen, next: Seen): boolean {
74
119
  return next.role > kept.role
75
120
  }
76
121
 
122
+ /**
123
+ * F230 (closes F219): scans the app's modules once for writes into another app module's exports (`LABELS.a = …`,
124
+ * `list.push(…)` through an import) and returns the JSON every `compileTree` of the build reads: a component whose
125
+ * compiled code reads such an export falls back to React (`module-written`). `modules` are absolute paths.
126
+ */
127
+ export function scanAppWrites(appRoot: string, modules: string[], log: (s: string) => void, sourceRoots: string[] = []): string {
128
+ // Keyed as `compileApp` names a file to the compiler (relative to `appRoot`), plus the real-path spelling (as
129
+ // `routeRoots`), so an import resolves to the same key either way.
130
+ const real = realpathSync(appRoot)
131
+ const toKey = (base: string, f: string) => relative(base, f).split(sep).join('/')
132
+ // F276: a module under a `[build] source_roots` directory (`../shared/x.ts`) is scanned too; any other `..` key is not.
133
+ const inSourceRoot = (k: string) => sourceRoots.some((r) => k.startsWith(`${r}/`))
134
+ const rel = [...new Set(modules.flatMap((f) => [toKey(appRoot, f), toKey(real, realpathSync(f))]))].filter((k) => !k.startsWith('..') || inSourceRoot(k)).sort()
135
+ const { aliases } = readPathAliases(appRoot)
136
+ const t0 = performance.now()
137
+ const json = scanLoadWrites(appRoot, rel, aliases.length > 0 ? JSON.stringify(aliases) : undefined, sourceRoots.length > 0 ? sourceRoots : undefined)
138
+ const ms = performance.now() - t0
139
+ if (process.env.BRUST_BUILD_TIMINGS) log(`[brust] scan load-time writes: ${rel.length} modules in ${ms.toFixed(1)} ms`)
140
+ return json
141
+ }
142
+
77
143
  export function compileApp(opts: {
78
144
  appRoot: string
79
145
  leaves: FlatRoute[]
80
146
  componentFile: Map<Function, string>
147
+ /** Why a Component the scan saw has no file (named export only, outside the root); optional. */
148
+ componentIssue?: Map<Function, string>
81
149
  runtimeImport: string
82
150
  serverOnly: string[]
151
+ /** L20: every CSS Module's class map by absolute source path; `styles.x` folds to the literal class. */
152
+ cssMaps?: Map<string, Record<string, string>>
83
153
  log: (s: string) => void
154
+ /** SVG markup the build rendered for lucide icon requests (L23), by `ir.icon_requests[].key`. */
155
+ iconSvgs?: Record<string, { svg: string; base: string }>
156
+ /** The compile after icons were rendered (even if none could be): icons still components are judged as islands. */
157
+ iconsFinal?: boolean
158
+ /** Values the build computed for `ir.build_eval` slots, by key (L2): the slots become literals. */
159
+ slotValues?: Record<string, unknown>
160
+ /** F153c: the package sources this build wrote (`pkg-src.ts`); absent = package imports are islands. */
161
+ packages?: PackageSources
162
+ /** F256: `[build] source_roots`, normalised and relative to the app root (`../shared`). */
163
+ sourceRoots?: string[]
164
+ /** F230: `scanAppWrites`'s result; absent = no module is refused for a load-time write. */
165
+ loadWrites?: string
84
166
  }): { compiled: Map<string, Compiled>; routeComponent: Map<string, string> } {
85
167
  const { appRoot, leaves, componentFile } = opts
168
+ const iconSvgsJson = opts.iconsFinal ? JSON.stringify({ final: true, svgs: opts.iconSvgs ?? {} }) : undefined
169
+ const slotValuesJson = opts.slotValues && Object.keys(opts.slotValues).length > 0 ? JSON.stringify(opts.slotValues) : undefined
170
+ const packagesJson = opts.packages ? JSON.stringify(opts.packages) : undefined
171
+ const sourceRoots = opts.sourceRoots && opts.sourceRoots.length > 0 ? opts.sourceRoots : undefined
172
+ // The Rust side keys each map by its path relative to the app root (`/`-separated), like an import resolves.
173
+ const { aliases, notes } = readPathAliases(appRoot)
174
+ for (const n of notes) opts.log(`warning tsconfig-paths ${n}`)
175
+ const pathAliasesJson = aliases.length > 0 ? JSON.stringify(aliases) : undefined
176
+ const cssModules = Object.fromEntries([...(opts.cssMaps ?? [])].map(([abs, map]) => [relative(realpathSync(appRoot), abs).split(sep).join('/'), map]))
86
177
  const seen = new Map<string, Seen>()
87
178
  const rootIdOfFile = new Map<string, string>()
88
179
  const routeComponent = new Map<string, string>()
@@ -90,17 +181,22 @@ export function compileApp(opts: {
90
181
  const nodes = new Map<string, Route>()
91
182
  for (const l of leaves) l.chain.forEach((r, i) => r.Component && nodes.set(l.chainIds[i]!, r))
92
183
 
184
+ // F158 (R34.69): the route components (pages and layouts) take request data as props, so the compiler never hands
185
+ // them lowered JSX from a caller. Keyed like an import resolves: relative to the app root, `/`-separated.
186
+ const rootRel = (f: string) => relative(appRoot, f).split(sep).join('/')
187
+ const routeRoots = [...new Set([...nodes.values()].flatMap((r) => componentFile.get(r.Component!) ?? []).flatMap((f) => [rootRel(f), relative(realpathSync(appRoot), realpathSync(f)).split(sep).join('/')]))]
188
+
93
189
  for (const [routeId, route] of nodes) {
94
190
  const file = componentFile.get(route.Component!)
95
191
  if (!file)
96
192
  throw new BuildError(
97
193
  'component-source',
98
- `${route.Component!.name || '(anonymous)'} is not a default import of a .tsx file (route ${routeId})`,
194
+ `${opts.componentIssue?.get(route.Component!) ?? `${route.Component!.name || '(anonymous)'} is not the default export of a .tsx file in the import graph`} (route ${routeId})`,
99
195
  )
100
196
  let rootId = rootIdOfFile.get(file)
101
197
  if (!rootId) {
102
198
  const rel = relative(appRoot, file)
103
- const tree = compileTree(rel, appRoot, opts.runtimeImport, opts.serverOnly)
199
+ const tree = compileTree(rel, appRoot, opts.runtimeImport, opts.serverOnly, slotValuesJson, cssModules, iconSvgsJson, routeRoots, pathAliasesJson, packagesJson, sourceRoots, opts.loadWrites)
104
200
  if (tree.error) throw new BuildError(tree.error.rule, `${where(rel, tree.error)} ${tree.error.message}`)
105
201
  for (const [i, c] of tree.components.entries()) {
106
202
  // A lowering Error that came back as a diagnostic is a build error too (contract 9).
@@ -122,6 +218,7 @@ export function compileApp(opts: {
122
218
  for (const d of c.diagnostics)
123
219
  if (d.class === 'fallback' || d.class === 'warning') opts.log(`warning ${d.rule} ${where(c.source, d)} ${d.message}`)
124
220
  const ir = JSON.parse(c.ir) as ComponentIR
221
+ if (ir.alias_imports?.length) checkAliasAgreement(appRoot, ir.alias_imports)
125
222
  ir.use_id_slots ??= 0
126
223
  ir.uses_outlet ??= false
127
224
  ir.instances ??= []
@@ -132,9 +229,21 @@ export function compileApp(opts: {
132
229
  ir,
133
230
  jinja: c.jinja,
134
231
  serverTs: c.serverTs,
232
+ evalTs: c.evalTs,
135
233
  clientJs: c.clientJs,
234
+ clientJsWhole: c.clientJsWhole ?? undefined,
235
+ projectedModules: c.projectedModules?.length ? c.projectedModules : undefined,
236
+ clientModules: c.clientModules?.length ? c.clientModules : undefined,
237
+ diagnostics: c.diagnostics.map((d) => ({ class: d.class, rule: d.rule, message: d.message, line: d.line, col: d.col, remediation: d.remediation })),
136
238
  })
137
239
  }
240
+ // A package export has no file of its own: bare imports in its island entries resolve from a
241
+ // component that uses it (the first one in build order).
242
+ for (const c of compiled.values()) {
243
+ if (!c.ir.external) continue
244
+ const user = [...compiled.values()].find((p) => !p.ir.external && (p.ir.children ?? []).some((ref: { id: string | null }) => ref.id === c.id))
245
+ c.file = user?.file ?? c.file
246
+ }
138
247
  for (const [routeId, route] of nodes) {
139
248
  const rootId = routeComponent.get(routeId)!
140
249
  if (compiled.get(rootId)!.ir.uses_outlet && !route.children)