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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +66 -6
  2. package/native/index.d.ts +82 -4
  3. package/native/index.js +58 -54
  4. package/package.json +29 -15
  5. package/src/actions/client.ts +53 -7
  6. package/src/actions/elysia.ts +1 -1
  7. package/src/actions/load.ts +82 -1
  8. package/src/actions/prefix.ts +44 -2
  9. package/src/actions-bridge.ts +44 -9
  10. package/src/assets.d.ts +55 -0
  11. package/src/browser.ts +6 -1
  12. package/src/build/actions-only.ts +86 -0
  13. package/src/build/build-eval.ts +332 -0
  14. package/src/build/build-icons.ts +69 -0
  15. package/src/build/bundle.ts +466 -54
  16. package/src/build/cf-assets.ts +4 -2
  17. package/src/build/cf-entry.ts +9 -4
  18. package/src/build/cf-wrangler.ts +3 -1
  19. package/src/build/cloudflare.ts +34 -13
  20. package/src/build/compile.ts +263 -6
  21. package/src/build/config.ts +138 -11
  22. package/src/build/css.ts +353 -0
  23. package/src/build/esm-resolve.ts +170 -0
  24. package/src/build/features.ts +119 -0
  25. package/src/build/index.ts +210 -27
  26. package/src/build/manifest.ts +94 -7
  27. package/src/build/pkg-src.ts +514 -0
  28. package/src/build/recognised.ts +19 -0
  29. package/src/build/scan.ts +71 -20
  30. package/src/build/server-only.ts +62 -0
  31. package/src/build/ssg-render.ts +65 -0
  32. package/src/build/ssg.ts +146 -0
  33. package/src/build/static-worker.ts +91 -0
  34. package/src/build/static.ts +259 -0
  35. package/src/build/tailwind.ts +117 -0
  36. package/src/build/tsconfig-paths.ts +67 -0
  37. package/src/build-time.ts +10 -0
  38. package/src/cache-adapter/types.ts +1 -1
  39. package/src/cf.ts +4 -1
  40. package/src/cli.ts +40 -4
  41. package/src/config.ts +111 -25
  42. package/src/css-modules.d.ts +8 -0
  43. package/src/dev/bun.ts +378 -0
  44. package/src/dev/client.ts +92 -0
  45. package/src/dev/devtools-data.ts +23 -0
  46. package/src/dev/devtools.ts +358 -0
  47. package/src/dev/diag.ts +30 -0
  48. package/src/dev/proxy.ts +256 -0
  49. package/src/dev/tui.ts +144 -0
  50. package/src/dev/watch.ts +130 -0
  51. package/src/dev.ts +15 -28
  52. package/src/handlers.ts +116 -10
  53. package/src/index.ts +6 -1
  54. package/src/native.ts +32 -1
  55. package/src/navigate-browser.ts +19 -0
  56. package/src/navigate.ts +19 -0
  57. package/src/navigation.ts +91 -0
  58. package/src/routes.ts +31 -1
  59. package/src/run.ts +65 -13
  60. package/src/shadcn/init.ts +513 -0
  61. package/src/shadcn/jsonc.ts +129 -0
  62. package/src/shadcn/registry.ts +149 -0
  63. package/src/store/builtin.ts +4 -0
  64. package/src/worker.ts +105 -22
@@ -0,0 +1,170 @@
1
+ // F189: `Bun.resolveSync` answers the CJS `main` of a package that has both (recharts: `main` is a CJS build, `module`
2
+ // the ES build), so a package resolved from a directory the bundler does not walk (a package's own, for what its source
3
+ // imports) became a second, CJS copy beside the ESM one every other island imports. Re-implementing the bundler's
4
+ // choice drifts (conditions per target, the `browser` field, `development`, `module` before `import`), so this asks
5
+ // the bundler itself: a throwaway `Bun.build` of a virtual file placed in `from` that imports `spec`, with the same
6
+ // `target`; the first module the build loads from outside that file is the answer. Equal to the native pick by
7
+ // construction. It runs in a child process: a `Bun.build` started inside a plugin callback of another build deadlocks.
8
+ // Anything it cannot resolve falls back to `Bun.resolveSync`, which is today's answer, and says so (F222).
9
+ import { createHash } from 'node:crypto'
10
+ import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'
11
+ import { dirname, join, sep } from 'node:path'
12
+ import { BuildError } from './errors'
13
+
14
+ /** A specifier relative to its importer: `./x`, `../x` and the bare `.` / `..` (a directory). The one test every resolver path uses (F218, F244). */
15
+ export const RELATIVE_SPEC = /^\.\.?(\/|$)/
16
+
17
+ export type ResolveTarget = 'bun' | 'browser'
18
+
19
+ /** What a build besides `target` and `define` tells the bundler about resolution, and where the probe runs. */
20
+ export interface ResolveOpts {
21
+ /** The build's `conditions` (`--target cloudflare`: workerd, worker, browser). Part of the answer, so part of the key. */
22
+ conditions?: string[]
23
+ /** The app root: the probe's working directory (F221) and the home of the cross-build cache (F224). */
24
+ appRoot?: string
25
+ /** Kills a probe that does not answer; default `RESOLVE_TIMEOUT_MS`. */
26
+ timeoutMs?: number
27
+ /** Where a fallback is named (F222); default: a `warning` line on stderr. */
28
+ warn?: (message: string) => void
29
+ }
30
+
31
+ /** `BRUST_RESOLVE_TIMEOUT_MS` or 30 s: a healthy probe answers in well under a second. */
32
+ export const RESOLVE_TIMEOUT_MS = Number(process.env.BRUST_RESOLVE_TIMEOUT_MS) > 0 ? Number(process.env.BRUST_RESOLVE_TIMEOUT_MS) : 30_000
33
+
34
+ const cache = new Map<string, string>()
35
+
36
+ /** Forgets the in-process answers and warnings (tests; the disk cache stays). */
37
+ export function clearResolveCache(): void {
38
+ cache.clear()
39
+ warned.clear()
40
+ disks.clear()
41
+ }
42
+
43
+ // argv: [target, entry, spec, define JSON, conditions JSON]; prints the resolved path. The define is the build's own:
44
+ // `NODE_ENV` flips Bun's `development` condition, so a probe without it picks another file than the build does. The
45
+ // same goes for `conditions`.
46
+ const PROBE = `
47
+ const [target, entry, spec, define, conditions] = process.argv.slice(1)
48
+ const source = 'import ' + JSON.stringify(spec) + '\\n'
49
+ let found
50
+ await Bun.build({
51
+ entrypoints: [entry], target, write: false, define: JSON.parse(define), conditions: JSON.parse(conditions), files: { [entry]: source },
52
+ plugins: [{ name: 'o', setup(b) { b.onLoad({ filter: /./ }, (a) => {
53
+ if (a.path === entry) return { contents: source, loader: 'ts' }
54
+ found ??= a.path
55
+ return { contents: '', loader: 'js' }
56
+ }) } }],
57
+ })
58
+ if (found) process.stdout.write(found)
59
+ `
60
+
61
+ const defaultWarn = (m: string) => console.warn(`warning resolve-fallback ${m}`)
62
+ const warned = new Set<string>()
63
+
64
+ /** `spec` as the bundler of `target` resolves it from the directory `from`. */
65
+ export function resolveNative(spec: string, from: string, target: ResolveTarget, define: Record<string, string> = {}, opts: ResolveOpts = {}): string {
66
+ const defs = JSON.stringify(define)
67
+ const conds = JSON.stringify(opts.conditions ?? [])
68
+ const key = `${target}\0${from}\0${spec}\0${defs}\0${conds}`
69
+ const hit = cache.get(key)
70
+ if (hit !== undefined) return hit
71
+ const disk = opts.appRoot !== undefined ? diskCache(opts.appRoot, `${target}\0${defs}\0${conds}`) : undefined
72
+ const saved = disk?.get(`${from}\0${spec}`, from)
73
+ if (saved !== undefined) {
74
+ cache.set(key, saved)
75
+ return saved
76
+ }
77
+ const timeout = opts.timeoutMs ?? RESOLVE_TIMEOUT_MS
78
+ const r = Bun.spawnSync([process.execPath, '-e', PROBE, target, join(from, '__brust_resolve__.ts'), spec, defs, conds], { cwd: opts.appRoot ?? from, stdout: 'pipe', stderr: 'pipe', timeout, killSignal: 'SIGKILL' })
79
+ const out = r.exitCode === 0 ? r.stdout.toString() : ''
80
+ let path = out
81
+ if (path === '') {
82
+ // The probe did not answer: today's answer (`Bun.resolveSync`) stands, and the build output says why.
83
+ const why = r.exitCode === null || r.signalCode ? `the probe did not answer within ${timeout} ms` : r.exitCode !== 0 ? `the probe failed (${r.stderr.toString().trim().split('\n')[0] || `exit ${r.exitCode}`})` : 'the probe resolved nothing'
84
+ // F242: `Bun.resolveSync` answers with the bun/node conditions. For a browser-target build (client chunks, the Worker)
85
+ // that can be a node-only build of the package, so it is not an answer there.
86
+ if (target === 'browser') throw new BuildError('resolve-fallback', `cannot resolve "${spec}" from ${from} for a browser-target build: ${why}; the runtime resolver would pick the bun/node conditions, so there is no fallback`)
87
+ path = Bun.resolveSync(spec, from)
88
+ const msg = `${spec} from ${from} (${target}): ${why}; used Bun.resolveSync -> ${path}`
89
+ if (!warned.has(msg)) {
90
+ warned.add(msg)
91
+ ;(opts.warn ?? defaultWarn)(msg)
92
+ }
93
+ } else disk?.set(`${from}\0${spec}`, path, from)
94
+ cache.set(key, path)
95
+ return path
96
+ }
97
+
98
+ // F224: a probe is a child process plus a `Bun.build`: ~100-300 ms each, once per (package dir, specifier) in a build,
99
+ // and again in every build. The answers are kept across builds in `<appRoot>/.brust/resolve-cache.json`, valid for one
100
+ // lockfile and one Bun, per (target, define, conditions); an entry is dropped when its file is gone or the nearest
101
+ // `package.json` above it changed (a linked workspace package edits without touching the lockfile).
102
+ interface Saved { path: string; pkg: string; mtime: number; from: string; fromMtime: number }
103
+ const LOCKS = ['bun.lock', 'bun.lockb', 'package-lock.json', 'pnpm-lock.yaml', 'yarn.lock']
104
+ const disks = new Map<string, DiskCache | null>()
105
+
106
+ class DiskCache {
107
+ private entries: Record<string, Saved> = {}
108
+ constructor(private file: string, private salt: string, private appRoot: string) {
109
+ try {
110
+ const j = JSON.parse(readFileSync(file, 'utf8'))
111
+ if (j.salt === salt) this.entries = j.entries ?? {}
112
+ } catch {}
113
+ }
114
+ get(k: string, from: string): string | undefined {
115
+ const e = this.entries[k]
116
+ if (!e) return undefined
117
+ try {
118
+ // F245: the file is trusted as written, so every answer is re-validated: it must lie inside the app or in a
119
+ // `node_modules`, and still be a file; else the entry is dropped and the probe asks again.
120
+ const [fromPkg, fromMtime] = nearestPackageJson(from)
121
+ if (this.plausible(e.path) && statSync(e.path).isFile() && (e.pkg === '' || statSync(e.pkg).mtimeMs === e.mtime) && e.from === fromPkg && e.fromMtime === fromMtime) return e.path
122
+ } catch {}
123
+ delete this.entries[k]
124
+ return undefined
125
+ }
126
+ set(k: string, path: string, from: string): void {
127
+ const [pkg, mtime] = nearestPackageJson(dirname(path))
128
+ const [fromPkg, fromMtime] = nearestPackageJson(from)
129
+ this.entries[k] = { path, pkg, mtime, from: fromPkg, fromMtime }
130
+ try {
131
+ mkdirSync(dirname(this.file), { recursive: true })
132
+ const tmp = `${this.file}.${process.pid}.tmp`
133
+ writeFileSync(tmp, JSON.stringify({ salt: this.salt, entries: this.entries }))
134
+ renameSync(tmp, this.file)
135
+ } catch {}
136
+ }
137
+ private plausible(path: string): boolean {
138
+ return path.startsWith(this.appRoot + sep) || path.split(sep).includes('node_modules')
139
+ }
140
+ }
141
+
142
+ /** The nearest `package.json` at or above `dir` and its mtime (`['', 0]` when there is none). */
143
+ function nearestPackageJson(dir: string): [string, number] {
144
+ for (let d = dir; ; d = dirname(d)) {
145
+ const p = join(d, 'package.json')
146
+ if (existsSync(p)) return [p, statSync(p).mtimeMs]
147
+ if (dirname(d) === d) return ['', 0]
148
+ }
149
+ }
150
+
151
+ /** The cache for this app and (target, define, conditions), or none when no lockfile pins the install. */
152
+ function diskCache(appRoot: string, scope: string): DiskCache | undefined {
153
+ const id = `${appRoot}\0${scope}`
154
+ const have = disks.get(id)
155
+ if (have !== undefined) return have ?? undefined
156
+ let lock: string | undefined
157
+ for (let d = appRoot; lock === undefined; d = dirname(d)) {
158
+ lock = LOCKS.map((l) => join(d, l)).find((f) => existsSync(f))
159
+ if (dirname(d) === d) break
160
+ }
161
+ let made: DiskCache | null = null
162
+ if (lock !== undefined) {
163
+ try {
164
+ const salt = createHash('sha256').update(Bun.version).update(readFileSync(lock)).update(scope).digest('hex')
165
+ made = new DiskCache(join(appRoot, '.brust', `resolve-cache-${createHash('sha256').update(scope).digest('hex').slice(0, 8)}.json`), salt, appRoot)
166
+ } catch {}
167
+ }
168
+ disks.set(id, made)
169
+ return made ?? undefined
170
+ }
@@ -0,0 +1,119 @@
1
+ // Which client-runtime feature modules a component needs (L33, R35.2). The compiler's output is the source of truth:
2
+ // a native component's template carries one `x-*` attribute per directive it uses, a react-tier component is an island.
3
+ // The set is written per component into the manifest; the server unions it over a route's components and links the
4
+ // core plus exactly those modules. `packages/runtime-dom/README.md` ("Feature modules") owns the module list and the rule.
5
+ import { BuildError } from './errors'
6
+
7
+ /** Feature modules of `@brust/runtime-dom/features/*` the browser build ships, in load order. */
8
+ export const RUNTIME_FEATURES = ['basics', 'if', 'for', 'model', 'html', 'island', 'collection'] as const
9
+ export type RuntimeFeature = (typeof RUNTIME_FEATURES)[number]
10
+
11
+ /** Directive attribute → the module that binds it. `null`: handled by the core (host, props, inert-markup marker). */
12
+ export function featureOfAttr(attr: string): RuntimeFeature | null | undefined {
13
+ switch (attr) {
14
+ case 'x-data':
15
+ case 'x-props':
16
+ case 'x-props-bind':
17
+ case 'x-raw':
18
+ // F288: the caller's ref on a nested host, bound by the caller's walk (core `walkChildren`)
19
+ case 'x-pref':
20
+ return null
21
+ case 'x-text':
22
+ case 'x-show':
23
+ case 'x-ref':
24
+ return 'basics'
25
+ // F288 phase B: a branch's callback ref, attached and detached by the x-if binder's module
26
+ case 'x-cref':
27
+ return 'if'
28
+ case 'x-if':
29
+ case 'x-for':
30
+ case 'x-model':
31
+ case 'x-html':
32
+ return attr.slice(2) as RuntimeFeature
33
+ case 'x-collection-root':
34
+ case 'x-collection-item':
35
+ case 'x-collection-use':
36
+ return 'collection'
37
+ default:
38
+ return /^x-(bind|on|prop)-/.test(attr) ? 'basics' : undefined
39
+ }
40
+ }
41
+
42
+ /** The template with every jinja tag (`{{ … }}`, `{% … %}`, `{# … #}`) replaced by a space, string literals inside them
43
+ * read as strings: a printed value such as shadcn's `has-[>svg]:px-3` (a folded class, F149) holds a `>` or a quote that
44
+ * would otherwise end the element's tag early and hide the directives after it. A directive is never inside a jinja tag. */
45
+ export function withoutJinja(template: string): string {
46
+ let out = ''
47
+ let i = 0
48
+ while (i < template.length) {
49
+ const open = template.indexOf('{', i)
50
+ const kind = open < 0 ? '' : template[open + 1]
51
+ if (open < 0) {
52
+ out += template.slice(i)
53
+ break
54
+ }
55
+ if (kind !== '{' && kind !== '%' && kind !== '#') {
56
+ out += template.slice(i, open + 1)
57
+ i = open + 1
58
+ continue
59
+ }
60
+ out += template.slice(i, open)
61
+ const close = kind === '{' ? '}}' : `${kind}}`
62
+ let j = open + 2
63
+ let quote = ''
64
+ for (; j < template.length; j++) {
65
+ const ch = template[j]!
66
+ if (quote) {
67
+ if (ch === '\\') j++
68
+ else if (ch === quote) quote = ''
69
+ } else if (kind !== '#' && (ch === '"' || ch === "'")) quote = ch
70
+ else if (template.startsWith(close, j)) break
71
+ }
72
+ out += ' '
73
+ i = j + close.length
74
+ }
75
+ return out
76
+ }
77
+
78
+ /** The `x-*` attribute names on the elements of a template (tags only: prose that mentions `x-for` binds nothing). */
79
+ export function directiveAttrs(template: string): Set<string> {
80
+ const out = new Set<string>()
81
+ template = withoutJinja(template)
82
+ for (const tag of template.matchAll(/<[a-zA-Z][\w-]*((?:[^>"']|"[^"]*"|'[^']*')*)>/g))
83
+ for (const a of tag[1]!.matchAll(/(?:^|\s)(x-[a-z][a-z0-9-]*)(?==|\s|$|\/)/g)) out.add(a[1]!)
84
+ return out
85
+ }
86
+
87
+ /**
88
+ * The feature modules `template` needs, sorted in load order (`react` = the component is an island: `island`).
89
+ * An `x-*` attribute no module binds is a build error (a compiler that learned a directive before the runtime did):
90
+ * never a silent no-op in the browser.
91
+ */
92
+ export function featuresOf(id: string, template: string, react: boolean): RuntimeFeature[] {
93
+ const need = new Set<RuntimeFeature>()
94
+ if (react) need.add('island')
95
+ for (const attr of directiveAttrs(template)) {
96
+ const f = featureOfAttr(attr)
97
+ if (f === undefined) throw new BuildError('runtime-feature-missing', `${id}: the template uses ${attr}, which no client runtime feature binds`)
98
+ if (f !== null) need.add(f)
99
+ }
100
+ return RUNTIME_FEATURES.filter((f) => need.has(f))
101
+ }
102
+
103
+ /** Matches the `/_brust/<file>` URL of `file` in a built chunk's text. */
104
+ const mentions = (text: string, file: string) => text.includes(`/_brust/${file}`)
105
+
106
+ /**
107
+ * Whether the built client file `entry` (`client/<file>`) imports the store module `storeFile`, directly or through the
108
+ * shared `chunk-*.js` files it imports. A component whose chunk does gets `store` in its features: the page preloads
109
+ * the module (the chunk imports it itself, so it has no script tag).
110
+ */
111
+ export function importsStore(read: (rel: string) => string | undefined, entry: string, storeFile: string, seen = new Set<string>()): boolean {
112
+ if (seen.has(entry)) return false
113
+ seen.add(entry)
114
+ const text = read(entry)
115
+ if (text === undefined) return false
116
+ if (mentions(text, storeFile)) return true
117
+ for (const m of text.matchAll(/(?:from|import)\s*"\.\/([^"]+\.js)"/g)) if (importsStore(read, `client/${m[1]}`, storeFile, seen)) return true
118
+ return false
119
+ }
@@ -4,17 +4,26 @@
4
4
  // edited by hand. An out dir that could hold the app's own sources, or a non-empty one that is not
5
5
  // a previous brust dist (no `manifest.json` / `.brust` marker; `--force` overrides), is refused
6
6
  // before anything is written or removed.
7
+ import { createHash } from 'node:crypto'
7
8
  import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
8
9
  import { homedir } from 'node:os'
9
10
  import { basename, dirname, isAbsolute, join, parse, relative, resolve } from 'node:path'
10
- import { BrustRouteError, validateRoutes } from '../routes'
11
- import { type BrowserCtx, buildActions, buildCacheAdapter, buildClientChunks, buildJobs, buildReactChunks, buildRuntime, storeModules } from './bundle'
11
+ import { BrustRouteError, type FlatRoute, validateRoutes } from '../routes'
12
+ import { type BrowserCtx, setDevBuild, buildActions, buildCacheAdapter, buildClientChunks, buildJobs, buildReactChunks, buildRuntime, nestedOf, storeModules } from './bundle'
13
+ import { runActionsOnly } from './actions-only'
12
14
  import { type CfPrepared, buildCloudflare, prepareCloudflare } from './cloudflare'
13
- import { compileApp } from './compile'
15
+ import { renderIcons } from './build-icons'
16
+ import { evaluateBuildSlots } from './build-eval'
17
+ import { preparePackageSources } from './pkg-src'
18
+ import { type Compiled, compileApp, scanAppWrites } from './compile'
19
+ import { buildCss, CssGraph, directCss, resolveGlobals, routeCssSources } from './css'
14
20
  import { type ActionsBuildConfig, type CacheBuildConfig, loadBuildConfig } from './config'
15
21
  import { BuildError } from './errors'
16
- import { type CacheRecord, type ManifestJson, writeManifest } from './manifest'
22
+ import { importsStore } from './features'
23
+ import { type CacheRecord, type ManifestJson, needsWorkers, reachedJobs, tierOf, writeManifest } from './manifest'
17
24
  import { scanRoutes } from './scan'
25
+ import { type ExprReads, prepareStatic, writeStatic } from './static'
26
+ import { writeStaticWorker } from './static-worker'
18
27
 
19
28
  export { BuildError } from './errors'
20
29
 
@@ -80,21 +89,40 @@ export async function runBuild(opts: {
80
89
  outDir: string
81
90
  /** Replace a non-empty out dir even without a previous build's marker. */
82
91
  force?: boolean
83
- /** `cloudflare` also writes `<out>/cf/` (M4-CF); default `bun` = the unchanged build. */
84
- target?: 'bun' | 'cloudflare'
92
+ /** `cloudflare` also writes `<out>/cf/` (M4-CF); `static` also writes `<out>/static/` (gaps round
93
+ * L9); default `bun` = the unchanged build. */
94
+ target?: 'bun' | 'cloudflare' | 'static'
95
+ /** `--strict` (static): a loader that reads the request fails the build. */
96
+ strict?: boolean
97
+ /** The cache key grammar's read-set (static; tests swap it). Default: the addon. */
98
+ exprReads?: ExprReads
85
99
  /** `--wasm-from-source` (cloudflare only). */
86
100
  wasmFromSource?: boolean
101
+ /** `--actions-only` (L16 R16.3): no pages, only the `[actions]` app; with `bun` (default) or `cloudflare`. */
102
+ actionsOnly?: boolean
103
+ /** `brust build --dev` (L14, used by `brust dev`): also writes `dev.json` and builds the client runtime with `__BRUST_DEV__`. */
104
+ dev?: boolean
87
105
  log: (s: string) => void
88
106
  }): Promise<void> {
107
+ setDevBuild(opts.dev === true)
89
108
  const appRoot = resolve(opts.appRoot)
90
109
  const entry = resolve(appRoot, opts.entry)
91
110
  const dist = resolve(appRoot, opts.outDir)
92
111
  assertSafeOutDir(dist, appRoot, [entry, join(appRoot, 'public')])
93
112
  assertReplaceable(dist, opts.force)
94
- const { serverOnly, actions, cloudflare, cache } = await loadBuildConfig(appRoot)
113
+ const built = await loadBuildConfig(appRoot, { actionsOnly: opts.actionsOnly === true })
114
+ if (opts.actionsOnly)
115
+ return runActionsOnly({ appRoot, dist, config: built, target: opts.target, wasmFromSource: opts.wasmFromSource === true, statOk, assertSafeOutDir, log: opts.log })
116
+ const { serverOnly, cloudflare, cache, cssGlobal, sourceRoots } = built
117
+ // L16 R16.4b: `[actions]` with a `base_url` and no actions file (the default name, not one written in the toml)
118
+ // is a page-only build: the app is deployed elsewhere, so nothing is bundled or mounted here.
119
+ const remote =
120
+ built.actions !== null && built.actions.baseUrl !== undefined && !built.actions.entryExplicit && !statOk(resolve(appRoot, built.actions.entry)) ? built.actions : null
121
+ const actions = remote ? null : built.actions
122
+ if (actions?.prefix === '/') throw new BuildError('config', 'actions.prefix "/" is only for an actions-only build (brust build --actions-only) or a page-only build that points at one')
95
123
 
96
124
  // Everything that can fail on the app's own input runs before a byte is written.
97
- const { routes, componentFile } = await scanRoutes(entry)
125
+ const { routes, componentFile, componentIssue, appModules, appGraph } = await scanRoutes(entry, appRoot, sourceRoots)
98
126
  let leaves: ReturnType<typeof validateRoutes>
99
127
  try {
100
128
  leaves = validateRoutes(routes)
@@ -109,12 +137,16 @@ export async function runBuild(opts: {
109
137
  if (isAbsolute(actions.entry) || rel === '..' || rel.startsWith('../') || isAbsolute(rel) || !statOk(actionsEntry)) {
110
138
  throw new BuildError('actions-entry', `actions.entry ${actions.entry} is not a file inside the app`)
111
139
  }
112
- // The actions app answers every path under its prefix, so a page there could never be served.
140
+ // F470: a route under the prefix answers GET and HEAD of its own paths, the actions app everything else there. The
141
+ // prefix itself stays the actions app's mount point, and a {*rest} route would take every GET under it.
113
142
  for (const l of leaves) {
114
- if (l.pattern !== '*' && (l.pattern === actions.prefix || l.pattern.startsWith(`${actions.prefix}/`))) {
143
+ if (l.pattern === actions.prefix) {
144
+ throw new BuildError('actions-prefix', `route ${l.id} (${l.pattern}) is the actions prefix ${actions.prefix}: that path is the actions app's mount point`)
145
+ }
146
+ if (l.pattern.startsWith(`${actions.prefix}/`) && l.pattern.includes('{*')) {
115
147
  throw new BuildError(
116
148
  'actions-prefix',
117
- `route ${l.id} (${l.pattern}) lies under the actions prefix ${actions.prefix}: the actions app answers every path there`,
149
+ `route ${l.id} (${l.pattern}) is a catch-all under the actions prefix ${actions.prefix}: it would take every GET there from the actions app`,
118
150
  )
119
151
  }
120
152
  }
@@ -129,8 +161,16 @@ export async function runBuild(opts: {
129
161
  }
130
162
  assertSafeOutDir(dist, appRoot, [...componentFile.values(), ...(actionsEntry ? [actionsEntry] : []), ...(cacheModule ? [cacheModule] : [])])
131
163
 
164
+ // R9.3/R9.4: every reason to refuse a static build, before any write.
165
+ const ssgPlan =
166
+ opts.target === 'static'
167
+ ? await prepareStatic(leaves, actions !== null, opts.exprReads ?? (await import('../native')).cacheExprReads, opts.log, cache)
168
+ : null
169
+
132
170
  // Config, @brust/core-cf resolution and the user's wrangler file fail here, before any write.
133
- const cf = opts.target === 'cloudflare' ? await prepareCloudflare(appRoot, cloudflare, { fromSource: opts.wasmFromSource === true, env: process.env }) : null
171
+ // L12: a static build with `[actions]` also builds the Cloudflare worker (its Pages `_worker.js`).
172
+ const hybrid = ssgPlan !== null && actions !== null
173
+ const cf = opts.target === 'cloudflare' || hybrid ? await prepareCloudflare(appRoot, cloudflare, { fromSource: opts.wasmFromSource === true, env: process.env }) : null
134
174
 
135
175
  // Sibling of `dist` (same filesystem: the final rename is atomic), fixed name (no random bytes in
136
176
  // anything the bundler sees).
@@ -138,7 +178,13 @@ export async function runBuild(opts: {
138
178
  rmSync(tmp, { recursive: true, force: true })
139
179
  mkdirSync(tmp, { recursive: true })
140
180
  try {
141
- await buildInto(tmp, { appRoot, entry, dist, leaves, componentFile, serverOnly, actions, actionsEntry, cache, cacheModule, cf, log: opts.log })
181
+ await buildInto(tmp, { dev: opts.dev === true, appRoot, entry, dist, leaves, componentFile, componentIssue, appModules, appGraph, serverOnly, actions, actionsEntry, cache, cacheModule, cf, hybrid, cssGlobal, sourceRoots, actionsRemote: remote?.prefix ?? null, log: opts.log })
182
+ if (ssgPlan) {
183
+ // Rendered from the finished temp dist, so a failed render leaves the previous dist untouched.
184
+ const version: string = (await Bun.file(join(import.meta.dir, '../../package.json')).json()).version
185
+ await writeStatic(tmp, { entry, appRoot, plan: ssgPlan, version, strict: opts.strict === true, log: opts.log, shownDir: join(dist, 'static') })
186
+ if (hybrid && cf && actions) writeStaticWorker(tmp, actions, cf, opts.log)
187
+ }
142
188
  rmSync(join(tmp, '.stage'), { recursive: true, force: true })
143
189
  if (existsSync(dist)) {
144
190
  const old = join(dirname(dist), `.${basename(dist)}.brust-old`)
@@ -156,12 +202,19 @@ export async function runBuild(opts: {
156
202
  async function buildInto(
157
203
  out: string,
158
204
  o: {
205
+ /** Write `dev.json` (L14). */
206
+ dev: boolean
159
207
  appRoot: string
160
208
  entry: string
161
209
  /** The final dist dir (`index.js` points at the entry relative to it). */
162
210
  dist: string
163
211
  leaves: ReturnType<typeof validateRoutes>
164
212
  componentFile: Map<Function, string>
213
+ componentIssue: Map<Function, string>
214
+ /** F230: every app module of the route entry's static import graph (absolute paths). */
215
+ appModules: string[]
216
+ /** F230: each app module's in-root static imports (absolute paths). */
217
+ appGraph: Map<string, string[]>
165
218
  serverOnly: string[]
166
219
  actions: ActionsBuildConfig | null
167
220
  /** Absolute path of the actions entry (null when `[actions]` is off). */
@@ -170,35 +223,129 @@ async function buildInto(
170
223
  /** Absolute path of a custom cache adapter module. */
171
224
  cacheModule: string | null
172
225
  cf: CfPrepared | null
226
+ /** `[css] global` as written (app-root relative). */
227
+ cssGlobal: string[]
228
+ /** `[build] source_roots` (F256): normalised, app-root-relative directories outside the app a relative import may reach. */
229
+ sourceRoots: string[]
230
+ /** L16 R16.4b: the prefix of an actions app deployed elsewhere (a page-only build); absent = a local app or none. */
231
+ actionsRemote: string | null
232
+ /** `--target static` with `[actions]`: the worker is built without the cache (static pages are never cached). */
233
+ hybrid: boolean
173
234
  log: (s: string) => void
174
235
  },
175
236
  ): Promise<void> {
176
237
  const { appRoot, entry, leaves } = o
177
- const ctx: BrowserCtx = { sourceOf: new Map(), appRoot, serverOnly: o.serverOnly, serverFiles: o.actionsEntry ? [realpathSync(o.actionsEntry)] : [] }
238
+ // L19: every stylesheet first, so each JS build below reads one set of CSS Module class maps.
239
+ const graph = new CssGraph()
240
+ const chainFiles = (l: FlatRoute) => l.chain.flatMap((n) => (n.Component && o.componentFile.has(n.Component) ? [o.componentFile.get(n.Component)!] : []))
241
+ const routeCss = routeCssSources(leaves, chainFiles, resolveGlobals(appRoot, o.cssGlobal), graph)
242
+ const serverCss = [o.actionsEntry, o.cacheModule].flatMap((f) => (f ? graph.of(f) : []))
243
+ mkdirSync(join(out, 'client'), { recursive: true })
244
+ // Review N3: `brust dev` builds with BRUST_BUILD_MODE=development (as the JS bundles): readable, unminified CSS.
245
+ const css = await buildCss(out, [...new Set([...[...routeCss.values()].flat(), ...serverCss])], process.env.BRUST_BUILD_MODE !== 'development', { appRoot, log: o.log })
246
+ for (const c of directCss(entry))
247
+ o.log(`[brust] warning css ${relative(appRoot, entry)} imports ${c}: the route entry belongs to no route, so it is linked nowhere; import it from a layout, or list it in [css] global`)
248
+ const ctx: BrowserCtx = { sourceOf: new Map(), appRoot, serverOnly: o.serverOnly, serverFiles: o.actionsEntry ? [realpathSync(o.actionsEntry)] : [], cssMaps: css.maps, clientDir: join(out, 'client') }
178
249
  // The runtime first: its hashed URL is the `runtimeImport` every client chunk is compiled with.
179
250
  const runtime = await buildRuntime(out, ctx)
180
- const { compiled, routeComponent } = compileApp({
251
+ const runtimeUrls = { core: `/_brust/${runtime.core}`, store: `/_brust/${runtime.store}` }
252
+ // F230: one scan per build for load-time writes into another app module's exports; every compile reads it.
253
+ const loadWrites = scanAppWrites(appRoot, o.appModules, o.log, o.sourceRoots)
254
+ // Where a React island renders, the load-time writers run first: every one in the SSR jobs bundle (server code the
255
+ // worker runs anyway); in an island's browser chunk only a writer of a module that island's code imports (Mellow F1:
256
+ // a writer is often a server-side setup module), with a warning.
257
+ const scanned = JSON.parse(loadWrites) as { writers: [string, string[]][] }
258
+ const real = (f: string) => {
259
+ try {
260
+ return realpathSync(f)
261
+ } catch {
262
+ return f
263
+ }
264
+ }
265
+ const writes = scanned.writers.map(([w, mods]) => ({ file: real(resolve(appRoot, w)), mods: new Set(mods.map((m) => real(resolve(appRoot, m)))) }))
266
+ const writers = [...new Set(writes.map((w) => w.file))]
267
+ const appGraph = new Map([...o.appGraph].map(([f, deps]) => [real(f), deps.map(real)]))
268
+ const writersFor = (c: Compiled, files: string[]): string[] => {
269
+ if (writes.length === 0) return []
270
+ const reached = new Set<string>()
271
+ const stack = files.map(real)
272
+ while (stack.length > 0) {
273
+ const f = stack.pop()!
274
+ if (reached.has(f)) continue
275
+ reached.add(f)
276
+ stack.push(...(appGraph.get(f) ?? []))
277
+ }
278
+ const out = writes.filter((w) => [...w.mods].some((m) => reached.has(m)))
279
+ for (const w of out)
280
+ o.log(`[brust] warning load-writer-in-client ${relative(appRoot, w.file)} is imported into the browser chunk of the React island ${c.id}: it changes ${[...w.mods].filter((m) => reached.has(m)).map((m) => relative(appRoot, m)).join(', ')} when it loads, and the island reads that module (F230). Its code ships to the browser: keep secrets and server-only code out of it, or write the value in the module that declares it`)
281
+ return [...new Set(out.map((w) => w.file))]
282
+ }
283
+ const compileOpts = {
181
284
  appRoot,
182
285
  leaves,
183
286
  componentFile: o.componentFile,
184
- runtimeImport: `/_brust/${runtime}`,
287
+ componentIssue: o.componentIssue,
288
+ loadWrites,
289
+ runtimeImport: runtimeUrls.core,
185
290
  serverOnly: o.serverOnly,
186
- log: o.log,
187
- })
291
+ sourceRoots: o.sourceRoots,
292
+ cssMaps: css.maps,
293
+ }
294
+ const firstLog: string[] = []
295
+ let { compiled, routeComponent } = compileApp({ ...compileOpts, log: (l) => firstLog.push(l) })
296
+ // F153c: the packages the app imports components from are compiled from their source when they publish it (and
297
+ // it matches what they publish); the app is compiled again with those sources. A refused package stays the island.
298
+ // F197: and the packages a helper is imported from, when the compiler asked to prove one plain.
299
+ const external = [...new Set([...compiled.values()].flatMap((c) => [...(c.ir.external ? [c.ir.external.source] : []), ...((c.ir.package_requests ?? []) as string[])]))]
300
+ const packages = preparePackageSources(appRoot, external)
301
+ if (Object.keys(packages.scopes).length > 0 || Object.keys(packages.recognised).length > 0) {
302
+ firstLog.length = 0
303
+ ;({ compiled, routeComponent } = compileApp({ ...compileOpts, packages, log: (l) => firstLog.push(l) }))
304
+ }
305
+ // L23: lucide icons the compiler could inline are rendered here (react-dom/server); L2: slots that read
306
+ // nothing request- or time-dependent are computed once, here. The app is compiled again with both (an icon with
307
+ // no markup stays the L22 island; a slot with a value is a literal: no job, no worker). The first compile's
308
+ // warnings describe icons that are inline now, so when icons were rendered only the last compile's are shown.
309
+ const hasIcons = [...compiled.values()].some((c) => (c.ir.icon_requests ?? []).length > 0)
310
+ const iconSvgs = hasIcons ? await renderIcons(compiled, appRoot, o.log) : undefined
311
+ const slotValues = await evaluateBuildSlots(out, compiled, ctx.sourceOf, appRoot, o.log)
312
+ const hasSlots = Object.keys(slotValues).length > 0
313
+ if (hasIcons || hasSlots) {
314
+ if (!hasIcons) firstLog.forEach((l) => o.log(l))
315
+ ;({ compiled, routeComponent } = compileApp({ ...compileOpts, packages, iconSvgs, iconsFinal: hasIcons, slotValues, log: hasIcons ? o.log : () => {} }))
316
+ } else firstLog.forEach((l) => o.log(l))
317
+
318
+ // F158 (R34.69/70): the compiler makes a caller of a route component fall back to React, so none is a revive host;
319
+ // if one is, the compiler missed it, and the build stops rather than let request data near the revive.
320
+ const routeRoots: ReadonlySet<string> = new Set(routeComponent.values())
321
+ for (const id of routeRoots) {
322
+ if (nestedOf(compiled, id) !== undefined) throw new BuildError('revive-host-route-root', `${id} is a page or layout and also receives lowered JSX children from a caller; this is a compiler bug (F158), please report it`)
323
+ }
188
324
 
189
325
  mkdirSync(join(out, 'jinja'), { recursive: true })
190
326
  for (const c of compiled.values()) writeFileSync(join(out, 'jinja', `${c.id}.jinja`), c.jinja)
191
327
 
192
- const runtimeUrl = `/_brust/${runtime}`
193
- const chunks = new Map([...(await buildClientChunks(out, compiled, ctx, runtimeUrl)), ...(await buildReactChunks(out, compiled, ctx, runtimeUrl))])
194
- const manifest = writeManifest({ leaves, routeComponent, compiled, runtime, chunks })
195
- await buildJobs(out, compiled, ctx.sourceOf, appRoot, storeModules(manifest))
328
+ const chunks = new Map([...(await buildClientChunks(out, compiled, ctx, runtimeUrls)), ...(await buildReactChunks(out, compiled, ctx, runtimeUrls, routeRoots, writersFor))])
329
+ const manifest = writeManifest({ leaves, routeComponent, compiled, runtime: { core: runtime.core, features: { ...runtime.features, store: runtime.store }, nav: runtime.nav }, chunks })
330
+ // L33: a component whose chunk imports the store module (directly or through a shared chunk) has the page preload it.
331
+ const readClient = (rel: string) => (existsSync(join(out, rel)) ? readFileSync(join(out, rel), 'utf8') : undefined)
332
+ for (const rec of Object.values(manifest.components)) if (rec.client && importsStore(readClient, rec.client, runtime.store)) rec.features.push('store')
333
+ for (const r of manifest.routes) {
334
+ const files = [...new Set((routeCss.get(r.id) ?? []).flatMap((s) => (css.file.has(s) ? [css.file.get(s)!] : [])))]
335
+ if (files.length > 0) r.css = files
336
+ }
337
+ await buildJobs(out, compiled, ctx.sourceOf, appRoot, storeModules(manifest), css.maps, routeRoots, reachedJobs(manifest), writers)
196
338
  manifest.actions =
197
339
  o.actions && o.actionsEntry
198
- ? { entry: await buildActions(out, o.actionsEntry), prefix: o.actions.prefix, maxBodyBytes: o.actions.maxBodyBytes, maxResponseBytes: o.actions.maxResponseBytes, crossSite: o.actions.crossSite }
340
+ ? { entry: await buildActions(out, o.actionsEntry, css.maps), prefix: o.actions.prefix, maxBodyBytes: o.actions.maxBodyBytes, maxResponseBytes: o.actions.maxResponseBytes, crossSite: o.actions.crossSite, ...o.actions.stream }
199
341
  : null
200
- if (o.cache) manifest.cache = await cacheRecord(out, manifest, o.cache, o.cacheModule)
342
+ if (o.actionsRemote !== null) manifest.actions_remote = { prefix: o.actionsRemote }
343
+ if (o.cache) manifest.cache = await cacheRecord(out, manifest, o.cache, o.cacheModule, css.maps)
344
+ manifest.needs_workers = needsWorkers(manifest)
345
+ // F453: the build id is a digest of the finished manifest (its content-hashed files and routes), so it is stable across identical builds.
346
+ manifest.assets.build = createHash('sha256').update(JSON.stringify(manifest)).digest('hex').slice(0, 16)
201
347
  writeFileSync(join(out, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`)
348
+ if (o.dev) writeFileSync(join(out, 'dev.json'), `${JSON.stringify(devJson(appRoot, compiled), null, 2)}\n`)
202
349
  writeFileSync(join(out, MARKER), 'generated by `brust build`: this directory is replaced on every build\n')
203
350
 
204
351
  const pub = join(appRoot, 'public')
@@ -206,7 +353,19 @@ async function buildInto(
206
353
  if (o.cf) {
207
354
  const warnings = await buildCloudflare(
208
355
  out,
209
- { appRoot, entry, dist: o.dist, compiled, manifest, sourceOf: ctx.sourceOf, actionsEntry: o.actionsEntry, cacheModule: o.cacheModule, cacheGetTimeoutMs: o.cache?.getTimeoutMs, log: o.log },
356
+ {
357
+ appRoot,
358
+ entry,
359
+ dist: o.dist,
360
+ compiled,
361
+ manifest: o.hybrid ? { ...manifest, cache: undefined } : manifest,
362
+ sourceOf: ctx.sourceOf,
363
+ actionsEntry: o.actionsEntry,
364
+ cacheModule: o.hybrid ? null : o.cacheModule,
365
+ cacheGetTimeoutMs: o.hybrid ? undefined : o.cache?.getTimeoutMs,
366
+ cssMaps: css.maps,
367
+ log: o.log,
368
+ },
210
369
  o.cf,
211
370
  )
212
371
  for (const w of warnings) o.log(w)
@@ -227,10 +386,34 @@ async function buildInto(
227
386
  )
228
387
  }
229
388
 
389
+ /** `dev.json` (L14, R14.2): component id → source file, tier, the react reason, diagnostics. Only `brust build --dev` writes it. */
390
+ export function devJson(appRoot: string, compiled: Map<string, Compiled>): { version: 1; components: Record<string, DevComponent> } {
391
+ const components: Record<string, DevComponent> = {}
392
+ for (const c of compiled.values()) {
393
+ const tier = tierOf(c.ir)
394
+ const t = c.ir.tier
395
+ components[c.id] = {
396
+ file: relative(appRoot, c.file),
397
+ tier,
398
+ ...(tier === 'react' && t && typeof t === 'object' && 'React' in t ? { reason: String((t as { React: { reason: string } }).React.reason) } : {}),
399
+ diagnostics: (c.diagnostics ?? []).map((d) => ({ class: d.class, rule: d.rule, line: d.line, col: d.col, message: d.message, remediation: d.remediation })),
400
+ }
401
+ }
402
+ return { version: 1, components }
403
+ }
404
+
405
+ export interface DevComponent {
406
+ file: string
407
+ tier: 'static' | 'native' | 'react'
408
+ /** Why the compiler chose the react tier (the IR's `React.reason`). */
409
+ reason?: string
410
+ diagnostics: { class: string; rule: string; line: number; col: number; message: string; remediation: string }[]
411
+ }
412
+
230
413
  /** `manifest.cache` (R7, R8): the custom module bundled for the worker, the native defaults, and the
231
414
  * build id every L2 key carries. */
232
- async function cacheRecord(out: string, manifest: ManifestJson, c: CacheBuildConfig, moduleAbs: string | null): Promise<CacheRecord> {
233
- const module = moduleAbs ? await buildCacheAdapter(out, moduleAbs) : undefined
415
+ async function cacheRecord(out: string, manifest: ManifestJson, c: CacheBuildConfig, moduleAbs: string | null, cssMaps: Map<string, Record<string, string>>): Promise<CacheRecord> {
416
+ const module = moduleAbs ? await buildCacheAdapter(out, moduleAbs, cssMaps) : undefined
234
417
  return {
235
418
  adapter: c.adapter,
236
419
  ...(module ? { module } : {}),