@brust/core 2.0.0-alpha.2 → 2.0.0-alpha.21
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -6
- package/native/index.d.ts +91 -4
- package/native/index.js +60 -54
- package/package.json +25 -9
- package/src/actions/client.ts +22 -5
- package/src/actions/load.ts +2 -1
- package/src/actions/prefix.ts +24 -2
- package/src/actions-bridge.ts +29 -11
- package/src/browser.ts +3 -1
- package/src/build/actions-only.ts +86 -0
- package/src/build/build-eval.ts +332 -0
- package/src/build/build-icons.ts +69 -0
- package/src/build/bundle.ts +462 -52
- package/src/build/cf-assets.ts +83 -0
- package/src/build/cf-core.ts +90 -0
- package/src/build/cf-entry.ts +76 -0
- package/src/build/cf-wrangler.ts +169 -0
- package/src/build/cloudflare.ts +320 -0
- package/src/build/compile.ts +116 -6
- package/src/build/config.ts +294 -11
- package/src/build/css.ts +331 -0
- package/src/build/esm-resolve.ts +170 -0
- package/src/build/features.ts +114 -0
- package/src/build/index.ts +264 -20
- package/src/build/manifest.ts +85 -7
- package/src/build/pkg-src.ts +514 -0
- package/src/build/recognised.ts +19 -0
- package/src/build/scan.ts +71 -20
- package/src/build/server-only.ts +39 -0
- package/src/build/ssg-render.ts +65 -0
- package/src/build/ssg.ts +146 -0
- package/src/build/static-worker.ts +91 -0
- package/src/build/static.ts +258 -0
- package/src/build/tailwind.ts +117 -0
- package/src/build/tsconfig-paths.ts +67 -0
- package/src/build-time.ts +10 -0
- package/src/cache-adapter/bridge.ts +91 -0
- package/src/cache-adapter/conformance.ts +226 -0
- package/src/cache-adapter/index.ts +10 -0
- package/src/cache-adapter/load.ts +144 -0
- package/src/cache-adapter/memory.ts +229 -0
- package/src/cache-adapter/runtime.ts +124 -0
- package/src/cache-adapter/types.ts +96 -0
- package/src/cache-adapter/workers.ts +118 -0
- package/src/cache.ts +4 -1
- package/src/cf.ts +19 -0
- package/src/cli.ts +71 -2
- package/src/config.ts +127 -25
- package/src/css-modules.d.ts +8 -0
- package/src/dev/bun.ts +371 -0
- package/src/dev/client.ts +92 -0
- package/src/dev/devtools-data.ts +23 -0
- package/src/dev/devtools.ts +356 -0
- package/src/dev/diag.ts +30 -0
- package/src/dev/proxy.ts +236 -0
- package/src/dev/tui.ts +144 -0
- package/src/dev/watch.ts +130 -0
- package/src/dev.ts +97 -0
- package/src/handlers.ts +282 -0
- package/src/index.ts +3 -1
- package/src/native.ts +38 -1
- package/src/routes.ts +20 -1
- package/src/run.ts +59 -12
- package/src/server-handlers.ts +32 -0
- package/src/shadcn/init.ts +513 -0
- package/src/shadcn/jsonc.ts +129 -0
- package/src/shadcn/registry.ts +149 -0
- package/src/worker.ts +127 -245
package/src/build/css.ts
ADDED
|
@@ -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
|
+
}
|