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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +66 -6
  2. package/native/index.d.ts +91 -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 +116 -6
  20. package/src/build/config.ts +294 -11
  21. package/src/build/css.ts +331 -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,331 @@
1
+ // CSS for `brust build` (gaps round L19, R19.1-R19.4): which stylesheets each route links, one
2
+ // minified content-hashed `client/<stem>-<hex10>.css` per source file, the class maps of CSS
3
+ // Modules, and the one plugin every JS build uses so no JS build ever emits CSS.
4
+ //
5
+ // - A route's CSS: `[css] global` first, then every `.css` import reachable from its chain files
6
+ // (root layout → page), depth-first in import order, first occurrence wins.
7
+ // - CSS Modules: one in-process Bun.build of every source (a JS entry per file), so one set of class
8
+ // names serves the server (jobs.js), the browser chunks and the Cloudflare worker alike.
9
+ // - Relative `url()` fonts and images are emitted next to the CSS as `client/<name>-<hex10>.<ext>`;
10
+ // absolute `/public/*` and `/_brust/*` stay as written.
11
+ import { createHash } from 'node:crypto'
12
+ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
13
+ import { basename, dirname, extname, isAbsolute, join, relative, resolve, sep } from 'node:path'
14
+ import type { BunPlugin } from 'bun'
15
+ import type { FlatRoute } from '../routes'
16
+ import { BuildError } from './errors'
17
+ import { loadTailwind } from './tailwind'
18
+
19
+ const hex = (b: string | Uint8Array) => createHash('sha256').update(b).digest('hex').slice(0, 10)
20
+ const real = (p: string) => {
21
+ try {
22
+ return realpathSync(p)
23
+ } catch {
24
+ return p
25
+ }
26
+ }
27
+ const isCss = (p: string) => p.endsWith('.css')
28
+ const isModule = (p: string) => p.endsWith('.module.css')
29
+ /** A name the static rules serve (`[A-Za-z0-9_.-]`, no leading dot). */
30
+ const safeName = (s: string) => s.replace(/[^A-Za-z0-9_.-]/g, '_').replace(/^\.+/, '_')
31
+
32
+ /** The stylesheets one source file reaches through static and dynamic imports, in order, real paths. Source
33
+ * files outside node_modules are followed; a `.css` import (an app file or a package's CSS) is
34
+ * collected, never followed (its `@import`s are the CSS bundler's). */
35
+ export class CssGraph {
36
+ private memo = new Map<string, string[]>()
37
+ private active = new Set<string>()
38
+ private transpilers = new Map<string, InstanceType<typeof Bun.Transpiler>>()
39
+
40
+ of(file: string): string[] {
41
+ const f = real(file)
42
+ const hit = this.memo.get(f)
43
+ if (hit) return hit
44
+ if (this.active.has(f)) return [] // an import cycle: the outer visit already lists what follows
45
+ this.active.add(f)
46
+ const out: string[] = []
47
+ const add = (c: string) => {
48
+ if (!out.includes(c)) out.push(c)
49
+ }
50
+ const loader = /\.(tsx|ts|jsx|js|mjs)$/.exec(f)?.[1]
51
+ let src = ''
52
+ try {
53
+ src = loader ? readFileSync(f, 'utf8') : ''
54
+ } catch {}
55
+ if (loader && src) {
56
+ const l = loader === 'mjs' ? 'js' : (loader as 'tsx' | 'ts' | 'jsx' | 'js')
57
+ let t = this.transpilers.get(l)
58
+ if (!t) this.transpilers.set(l, (t = new Bun.Transpiler({ loader: l })))
59
+ for (const imp of t.scanImports(src)) {
60
+ // A dynamic import() is walked too (review N4): its CSS is linked eagerly for the route, so a
61
+ // lazily loaded component is styled on first paint and its CSS Modules are built.
62
+ if (imp.kind !== 'import-statement' && imp.kind !== 'dynamic-import') continue
63
+ let dep: string
64
+ try {
65
+ dep = real(Bun.resolveSync(imp.path, dirname(f)))
66
+ } catch {
67
+ continue // unresolvable: the JS build reports it
68
+ }
69
+ if (isCss(dep)) add(dep)
70
+ else if (!dep.split(sep).includes('node_modules')) for (const c of this.of(dep)) add(c)
71
+ }
72
+ }
73
+ this.active.delete(f)
74
+ this.memo.set(f, out)
75
+ return out
76
+ }
77
+ }
78
+
79
+ /** R19.1: every route's ordered source stylesheets (real paths), by route id. `chainFiles` maps a leaf to
80
+ * its chain's component files, root layout first. */
81
+ export function routeCssSources(leaves: FlatRoute[], chainFiles: (l: FlatRoute) => string[], globals: string[], graph = new CssGraph()): Map<string, string[]> {
82
+ const out = new Map<string, string[]>()
83
+ for (const leaf of leaves) {
84
+ const list: string[] = [...globals]
85
+ for (const file of chainFiles(leaf)) for (const c of graph.of(file)) if (!list.includes(c)) list.push(c)
86
+ out.set(leaf.id, list)
87
+ }
88
+ return out
89
+ }
90
+
91
+ /** `[css] global` resolved: each a `.css` file inside the app. */
92
+ export function resolveGlobals(appRoot: string, globals: string[]): string[] {
93
+ return globals.map((g) => {
94
+ const abs = resolve(appRoot, g)
95
+ const rel = relative(appRoot, abs)
96
+ if (isAbsolute(g) || rel.startsWith('..') || isAbsolute(rel) || !existsSync(abs)) throw new BuildError('css', `css.global ${g} is not a file inside the app`)
97
+ return real(abs)
98
+ })
99
+ }
100
+
101
+ export interface CssBuild {
102
+ /** source (real path) → `client/<stem>-<hex10>.css`; absent when the file emits no CSS. */
103
+ file: Map<string, string>
104
+ /** `.module.css` source (real path) → its class map. */
105
+ maps: Map<string, Record<string, string>>
106
+ }
107
+
108
+ const CLASS = /\.(-?[_a-zA-Z][_a-zA-Z0-9-]*)/g
109
+
110
+ /** The asset types `static_rules::is_safe_client_asset_filename` serves from `/_brust/client/` (review N1). */
111
+ export const SERVED_ASSET_EXT = ['.woff2', '.woff', '.ttf', '.otf', '.eot', '.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif', '.svg', '.ico']
112
+
113
+ const SOURCE_URL = /url\(\s*(["']?)([^"')]+?)\1\s*\)/g
114
+
115
+ /** F393: a `url()` that is the absolute path of a file on disk (see the `loader` in `buildCss`) becomes a
116
+ * hashed `./<name>-<hex10>.<ext>` client asset at any size. `/public/…` and `/_brust/…` stay as written. */
117
+ function emitSourceAssets(text: string, assets: { name: string; bytes: Uint8Array }[], suffixed: Map<string, string>): string {
118
+ const emit = (path: string, suffix: string) => {
119
+ const bytes = new Uint8Array(readFileSync(path))
120
+ const name = `${safeName(basename(path, extname(path)))}-${hex(bytes)}${extname(path).toLowerCase()}`
121
+ if (!assets.some((a) => a.name === name)) assets.push({ name, bytes })
122
+ return /^[^\s"'()\\]*$/.test(suffix) ? `url(./${name}${suffix})` : `url("./${name}${suffix.replace(/["\\]/g, '\\$&')}")`
123
+ }
124
+ return text.replace(SOURCE_URL, (whole, _q: string, path: string) => {
125
+ const abs = suffixed.get(path)
126
+ if (abs !== undefined) return emit(abs, path.slice(path.search(/[?#]/)))
127
+ const ext = extname(path).toLowerCase()
128
+ if (!path.startsWith('/') || !SERVED_ASSET_EXT.includes(ext) || path.startsWith('/public/') || path.startsWith('/_brust/') || !existsSync(path)) return whole
129
+ return emit(path, '')
130
+ })
131
+ }
132
+
133
+ /** F393: a `url()` asset with a `?query` / `#fragment` (`?v=3`, `?#iefix`, an SVG font's `#id`) is not given
134
+ * Bun's path form: a plugin-resolved specifier is printed as written. This plugin resolves such a specifier
135
+ * itself and records the file it names, so `emitSourceAssets` can copy it and keep the suffix verbatim. The
136
+ * same text naming two different files (from two importers) fails the build rather than guess. */
137
+ function suffixPlugin(suffixed: Map<string, string>): BunPlugin {
138
+ return {
139
+ name: 'brust-css-asset-suffix',
140
+ setup(b) {
141
+ b.onResolve({ filter: /\.(?:woff2?|ttf|otf|eot|png|jpe?g|gif|webp|avif|svg|ico)[?#]/i }, (a) => {
142
+ const at = a.path.search(/[?#]/)
143
+ const file = a.path.slice(0, at)
144
+ let abs: string
145
+ try {
146
+ abs = file.startsWith('.') || isAbsolute(file) ? resolve(dirname(a.importer), file) : Bun.resolveSync(file, dirname(a.importer))
147
+ } catch {
148
+ return undefined
149
+ }
150
+ if (!existsSync(abs)) return undefined
151
+ const have = suffixed.get(a.path)
152
+ if (have !== undefined && have !== abs) throw new BuildError('css-asset-ambiguous', `url(${a.path}) names ${have} and ${abs}; give them different specifiers`)
153
+ suffixed.set(a.path, abs)
154
+ return { path: abs }
155
+ })
156
+ },
157
+ }
158
+ }
159
+
160
+ /** R19.2/R19.4: builds every source stylesheet (one JS entry each) into `<dist>/client/`. `minify`
161
+ * is off only for a development build (`brust dev`, review N3). */
162
+ export async function buildCss(
163
+ dist: string,
164
+ sources: string[],
165
+ minify = true,
166
+ o: { appRoot?: string; log?: (s: string) => void } = {},
167
+ ): Promise<CssBuild> {
168
+ const file = new Map<string, string>()
169
+ const maps = new Map<string, Record<string, string>>()
170
+ if (sources.length === 0) return { file, maps }
171
+ const appRoot = o.appRoot ?? process.cwd()
172
+ const tw = tailwindUse(sources)
173
+ const plugins: BunPlugin[] = []
174
+ if (tw.using.length > 0) {
175
+ // a stylesheet compiles with Tailwind when it, or a relative @import of it, uses Tailwind
176
+ const via = new Map<string, boolean>()
177
+ const uses = (f: string) => {
178
+ const k = real(f)
179
+ let hit = via.get(k)
180
+ if (hit === undefined) via.set(k, (hit = tailwindUse([k]).using.length > 0))
181
+ return hit
182
+ }
183
+ plugins.push(await loadTailwind(appRoot, tw.using[0]!, uses, o.log))
184
+ if (tw.imports.length > 1)
185
+ o.log?.(
186
+ `[brust] warning tailwind @import "tailwindcss" is in ${tw.imports.length} files (${tw.imports.map((f) => relative(appRoot, f)).join(', ')}): each one ships the whole of Tailwind; import it once (e.g. app.css) and use @reference elsewhere`,
187
+ )
188
+ }
189
+ const stage = join(dist, '.stage', 'css')
190
+ mkdirSync(stage, { recursive: true })
191
+ const entries = sources.map((src, i) => {
192
+ const e = join(stage, `s${i}.js`)
193
+ writeFileSync(e, isModule(src) ? `export { default } from ${JSON.stringify(src)}\n` : `import ${JSON.stringify(src)}\n`)
194
+ return e
195
+ })
196
+ const suffixed = new Map<string, string>()
197
+ plugins.push(suffixPlugin(suffixed))
198
+ const res = await Bun.build({
199
+ entrypoints: entries,
200
+ target: 'browser',
201
+ format: 'esm',
202
+ minify,
203
+ naming: { entry: '[name]-[hash].[ext]', chunk: '[name]-[hash].[ext]', asset: '[name]-[hash].[ext]' },
204
+ external: ['/public/*', '/_brust/*'],
205
+ root: appRoot,
206
+ plugins,
207
+ // F393, verified on Bun 1.4.4 only: its CSS bundler turns an asset under the size threshold into a `data:`
208
+ // URI whatever `loader: file` says, and `loader: dataurl` does the opposite (the name is inverted): it leaves
209
+ // the url() as the absolute path of the source file. That path is the marker `emitSourceAssets` copies and
210
+ // rewrites. Re-check on every Bun upgrade; the F393 tests in test/css.test.ts go red if the quirk changes.
211
+ // bun-types 1.4.2's `Loader` omits `dataurl`, which the runtime accepts.
212
+ loader: Object.fromEntries(SERVED_ASSET_EXT.map((e) => [e, 'dataurl'])) as unknown as Bun.BuildConfig['loader'],
213
+ })
214
+ if (!res.success) throw new BuildError('css', res.logs.map(String).join('\n'))
215
+ // Assets a stylesheet references (fonts, images): `<name>-<hex10>.<ext>`, references rewritten.
216
+ const rename = new Map<string, string>()
217
+ const assets: { name: string; bytes: Uint8Array }[] = []
218
+ const css = new Map<number, string>()
219
+ const js = new Map<number, string>()
220
+ const unserved: string[] = []
221
+ for (const o of res.outputs) {
222
+ const name = basename(o.path)
223
+ const entry = /^s(\d+)-[^-.]+\.(css|js)$/.exec(name)
224
+ if (entry) {
225
+ ;(entry[2] === 'css' ? css : js).set(Number(entry[1]), await o.text())
226
+ continue
227
+ }
228
+ const m = /^(.*)-[^-.]+(\.[^.]+)$/.exec(name)
229
+ if (!m) throw new BuildError('css', `unexpected output ${name}`)
230
+ const ext = m[2]!.toLowerCase()
231
+ if (!SERVED_ASSET_EXT.includes(ext)) {
232
+ unserved.push(name)
233
+ continue
234
+ }
235
+ const bytes = new Uint8Array(await o.arrayBuffer())
236
+ const to = `${safeName(m[1]!)}-${hex(bytes)}${ext}`
237
+ rename.set(name, to)
238
+ assets.push({ name: to, bytes })
239
+ }
240
+ // Fail closed (review N1): an asset the server would not serve from /_brust/client would 404.
241
+ if (unserved.length > 0) {
242
+ const lines = unserved.map((name) => {
243
+ const by = sources.filter((_, i) => css.get(i)?.includes(name))
244
+ const [, stem, ext] = /^(.*)-[^-.]+(\.[^.]+)$/.exec(name)!
245
+ return `${stem}${ext} (url() in ${by.join(', ') || 'a stylesheet'})`
246
+ })
247
+ throw new BuildError(
248
+ 'css-asset-type',
249
+ `${lines.join('; ')}: brust serves only ${SERVED_ASSET_EXT.join(' ')} from /_brust/client; put the file in public/ and reference it as /public/…`,
250
+ )
251
+ }
252
+ for (const [i, t] of css) css.set(i, emitSourceAssets(t, assets, suffixed))
253
+ for (const a of assets) writeFileSync(join(dist, 'client', a.name), a.bytes)
254
+ for (const [i, src] of sources.entries()) {
255
+ let text = css.get(i)
256
+ if (text !== undefined && text.trim() !== '') {
257
+ for (const [from, to] of rename) text = text.split(from).join(to)
258
+ const stem = safeName(basename(src).replace(/\.css$/, ''))
259
+ const out = `${stem}-${hex(text)}.css`
260
+ writeFileSync(join(dist, 'client', out), text)
261
+ file.set(src, `client/${out}`)
262
+ }
263
+ if (!isModule(src)) continue
264
+ const mod = join(stage, `map${i}.mjs`)
265
+ writeFileSync(mod, js.get(i) ?? 'export default {}')
266
+ const map = { ...((await import(mod)).default as Record<string, string>) }
267
+ // R19.4: every class the map hands out exists in the CSS it ships with.
268
+ const have = new Set([...(text ?? '').matchAll(CLASS)].map((x) => x[1]!))
269
+ for (const [k, v] of Object.entries(map))
270
+ for (const cls of v.split(/\s+/).filter(Boolean))
271
+ if (!have.has(cls)) throw new BuildError('css', `${src}: class ${k} maps to ${cls}, which is not in the emitted CSS`)
272
+ maps.set(src, map)
273
+ }
274
+ return { file, maps }
275
+ }
276
+
277
+ // ---- Tailwind CSS v4 (gaps round L21, R21.1) ----
278
+
279
+ /** A stylesheet that needs the Tailwind compiler. */
280
+ const TAILWIND_USE = /@import\s+(?:url\(\s*)?["']tailwindcss(?:\/[^"']*)?["']|@apply\b|@reference\b|@theme\b|@plugin\b/
281
+ const TAILWIND_IMPORT = /@import\s+(?:url\(\s*)?["']tailwindcss["']/
282
+ const CSS_IMPORT = /@import\s+(?:url\(\s*)?["'](\.{1,2}\/[^"']+)["']/g
283
+
284
+ /** Which sources (or the relative `@import`s they pull in) use Tailwind, and which `@import "tailwindcss"`. */
285
+ export function tailwindUse(sources: string[]): { using: string[]; imports: string[] } {
286
+ const using: string[] = []
287
+ const imports: string[] = []
288
+ const seen = new Set<string>()
289
+ const visit = (f: string) => {
290
+ if (seen.has(f)) return
291
+ seen.add(f)
292
+ let text = ''
293
+ try {
294
+ text = readFileSync(f, 'utf8')
295
+ } catch {
296
+ return
297
+ }
298
+ if (TAILWIND_USE.test(text)) using.push(f)
299
+ if (TAILWIND_IMPORT.test(text)) imports.push(f)
300
+ for (const m of text.matchAll(CSS_IMPORT)) visit(resolve(dirname(f), m[1]!))
301
+ }
302
+ for (const s of sources) visit(s)
303
+ return { using, imports }
304
+ }
305
+
306
+ /** R19.4: the one CSS plugin of every JS build. A `.module.css` import is its class map
307
+ * (`export default Object.freeze({…})`); any other `.css` import is an empty module, because the
308
+ * stylesheet itself is linked by the server (R19.3), never bundled into JS. */
309
+ export function cssPlugin(maps: Map<string, Record<string, string>>): BunPlugin {
310
+ return {
311
+ name: 'brust-css',
312
+ setup(b) {
313
+ b.onLoad({ filter: /\.css$/ }, (a) => {
314
+ if (!isModule(a.path)) return { contents: '', loader: 'js' }
315
+ const map = maps.get(real(a.path))
316
+ if (!map) throw new Error(`${a.path} is imported by code the build did not reach from a route; import it from a route's component files`)
317
+ return { contents: `export default Object.freeze(${JSON.stringify(map)})\n`, loader: 'js' }
318
+ })
319
+ },
320
+ }
321
+ }
322
+
323
+ /** Direct `.css` imports of `file` (the route entry: linked to no route, so the build warns). */
324
+ export function directCss(file: string): string[] {
325
+ const src = readFileSync(file, 'utf8')
326
+ const t = new Bun.Transpiler({ loader: file.endsWith('.tsx') ? 'tsx' : 'ts' })
327
+ return t
328
+ .scanImports(src)
329
+ .filter((i) => i.kind === 'import-statement' && i.path.endsWith('.css'))
330
+ .map((i) => i.path)
331
+ }
@@ -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,114 @@
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
+ return null
19
+ case 'x-text':
20
+ case 'x-show':
21
+ case 'x-ref':
22
+ return 'basics'
23
+ case 'x-if':
24
+ case 'x-for':
25
+ case 'x-model':
26
+ case 'x-html':
27
+ return attr.slice(2) as RuntimeFeature
28
+ case 'x-collection-root':
29
+ case 'x-collection-item':
30
+ case 'x-collection-use':
31
+ return 'collection'
32
+ default:
33
+ return /^x-(bind|on|prop)-/.test(attr) ? 'basics' : undefined
34
+ }
35
+ }
36
+
37
+ /** The template with every jinja tag (`{{ … }}`, `{% … %}`, `{# … #}`) replaced by a space, string literals inside them
38
+ * read as strings: a printed value such as shadcn's `has-[>svg]:px-3` (a folded class, F149) holds a `>` or a quote that
39
+ * would otherwise end the element's tag early and hide the directives after it. A directive is never inside a jinja tag. */
40
+ export function withoutJinja(template: string): string {
41
+ let out = ''
42
+ let i = 0
43
+ while (i < template.length) {
44
+ const open = template.indexOf('{', i)
45
+ const kind = open < 0 ? '' : template[open + 1]
46
+ if (open < 0) {
47
+ out += template.slice(i)
48
+ break
49
+ }
50
+ if (kind !== '{' && kind !== '%' && kind !== '#') {
51
+ out += template.slice(i, open + 1)
52
+ i = open + 1
53
+ continue
54
+ }
55
+ out += template.slice(i, open)
56
+ const close = kind === '{' ? '}}' : `${kind}}`
57
+ let j = open + 2
58
+ let quote = ''
59
+ for (; j < template.length; j++) {
60
+ const ch = template[j]!
61
+ if (quote) {
62
+ if (ch === '\\') j++
63
+ else if (ch === quote) quote = ''
64
+ } else if (kind !== '#' && (ch === '"' || ch === "'")) quote = ch
65
+ else if (template.startsWith(close, j)) break
66
+ }
67
+ out += ' '
68
+ i = j + close.length
69
+ }
70
+ return out
71
+ }
72
+
73
+ /** The `x-*` attribute names on the elements of a template (tags only: prose that mentions `x-for` binds nothing). */
74
+ export function directiveAttrs(template: string): Set<string> {
75
+ const out = new Set<string>()
76
+ template = withoutJinja(template)
77
+ for (const tag of template.matchAll(/<[a-zA-Z][\w-]*((?:[^>"']|"[^"]*"|'[^']*')*)>/g))
78
+ for (const a of tag[1]!.matchAll(/(?:^|\s)(x-[a-z][a-z0-9-]*)(?==|\s|$|\/)/g)) out.add(a[1]!)
79
+ return out
80
+ }
81
+
82
+ /**
83
+ * The feature modules `template` needs, sorted in load order (`react` = the component is an island: `island`).
84
+ * An `x-*` attribute no module binds is a build error (a compiler that learned a directive before the runtime did):
85
+ * never a silent no-op in the browser.
86
+ */
87
+ export function featuresOf(id: string, template: string, react: boolean): RuntimeFeature[] {
88
+ const need = new Set<RuntimeFeature>()
89
+ if (react) need.add('island')
90
+ for (const attr of directiveAttrs(template)) {
91
+ const f = featureOfAttr(attr)
92
+ if (f === undefined) throw new BuildError('runtime-feature-missing', `${id}: the template uses ${attr}, which no client runtime feature binds`)
93
+ if (f !== null) need.add(f)
94
+ }
95
+ return RUNTIME_FEATURES.filter((f) => need.has(f))
96
+ }
97
+
98
+ /** Matches the `/_brust/<file>` URL of `file` in a built chunk's text. */
99
+ const mentions = (text: string, file: string) => text.includes(`/_brust/${file}`)
100
+
101
+ /**
102
+ * Whether the built client file `entry` (`client/<file>`) imports the store module `storeFile`, directly or through the
103
+ * shared `chunk-*.js` files it imports. A component whose chunk does gets `store` in its features: the page preloads
104
+ * the module (the chunk imports it itself, so it has no script tag).
105
+ */
106
+ export function importsStore(read: (rel: string) => string | undefined, entry: string, storeFile: string, seen = new Set<string>()): boolean {
107
+ if (seen.has(entry)) return false
108
+ seen.add(entry)
109
+ const text = read(entry)
110
+ if (text === undefined) return false
111
+ if (mentions(text, storeFile)) return true
112
+ for (const m of text.matchAll(/(?:from|import)\s*"\.\/([^"]+\.js)"/g)) if (importsStore(read, `client/${m[1]}`, storeFile, seen)) return true
113
+ return false
114
+ }