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

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