@brust/core 2.0.0-alpha.1 → 2.0.0-alpha.11
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 +87 -5
- package/native/index.js +61 -54
- package/package.json +22 -9
- package/src/actions/client.ts +22 -5
- package/src/actions/load.ts +2 -1
- package/src/actions/prefix.ts +27 -3
- package/src/actions-bridge.ts +76 -19
- package/src/browser.ts +3 -1
- package/src/build/actions-only.ts +86 -0
- package/src/build/build-eval.ts +330 -0
- package/src/build/build-icons.ts +69 -0
- package/src/build/bundle.ts +341 -43
- 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 +317 -0
- package/src/build/compile.ts +74 -4
- package/src/build/config.ts +274 -15
- package/src/build/css.ts +275 -0
- package/src/build/features.ts +110 -0
- package/src/build/index.ts +224 -20
- package/src/build/manifest.ts +69 -8
- package/src/build/pkg-src.ts +383 -0
- package/src/build/scan.ts +61 -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 +70 -2
- package/src/config.ts +127 -25
- package/src/css-modules.d.ts +8 -0
- package/src/dev/bun.ts +365 -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 +81 -0
- package/src/dev.ts +93 -0
- package/src/handlers.ts +282 -0
- package/src/index.ts +3 -1
- package/src/native.ts +49 -2
- package/src/routes.ts +20 -1
- package/src/run.ts +61 -14
- package/src/server-handlers.ts +32 -0
- package/src/shadcn/init.ts +213 -0
- package/src/shadcn/jsonc.ts +129 -0
- package/src/worker.ts +129 -245
package/src/build/index.ts
CHANGED
|
@@ -4,16 +4,25 @@
|
|
|
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 { cpSync, existsSync, mkdirSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
7
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
8
8
|
import { homedir } from 'node:os'
|
|
9
9
|
import { basename, dirname, isAbsolute, join, parse, relative, resolve } from 'node:path'
|
|
10
|
-
import { BrustRouteError, validateRoutes } from '../routes'
|
|
11
|
-
import { type BrowserCtx, buildActions, buildClientChunks, buildJobs, buildReactChunks, buildRuntime, storeModules } from './bundle'
|
|
12
|
-
import {
|
|
13
|
-
import { type
|
|
10
|
+
import { BrustRouteError, type FlatRoute, validateRoutes } from '../routes'
|
|
11
|
+
import { type BrowserCtx, setDevBuild, buildActions, buildCacheAdapter, buildClientChunks, buildJobs, buildReactChunks, buildRuntime, nestedOf, storeModules } from './bundle'
|
|
12
|
+
import { runActionsOnly } from './actions-only'
|
|
13
|
+
import { type CfPrepared, buildCloudflare, prepareCloudflare } from './cloudflare'
|
|
14
|
+
import { renderIcons } from './build-icons'
|
|
15
|
+
import { evaluateBuildSlots } from './build-eval'
|
|
16
|
+
import { preparePackageSources } from './pkg-src'
|
|
17
|
+
import { type Compiled, compileApp } from './compile'
|
|
18
|
+
import { buildCss, CssGraph, directCss, resolveGlobals, routeCssSources } from './css'
|
|
19
|
+
import { type ActionsBuildConfig, type CacheBuildConfig, loadBuildConfig } from './config'
|
|
14
20
|
import { BuildError } from './errors'
|
|
15
|
-
import {
|
|
21
|
+
import { importsStore } from './features'
|
|
22
|
+
import { type CacheRecord, type ManifestJson, needsWorkers, tierOf, writeManifest } from './manifest'
|
|
16
23
|
import { scanRoutes } from './scan'
|
|
24
|
+
import { type ExprReads, prepareStatic, writeStatic } from './static'
|
|
25
|
+
import { writeStaticWorker } from './static-worker'
|
|
17
26
|
|
|
18
27
|
export { BuildError } from './errors'
|
|
19
28
|
|
|
@@ -79,17 +88,40 @@ export async function runBuild(opts: {
|
|
|
79
88
|
outDir: string
|
|
80
89
|
/** Replace a non-empty out dir even without a previous build's marker. */
|
|
81
90
|
force?: boolean
|
|
91
|
+
/** `cloudflare` also writes `<out>/cf/` (M4-CF); `static` also writes `<out>/static/` (gaps round
|
|
92
|
+
* L9); default `bun` = the unchanged build. */
|
|
93
|
+
target?: 'bun' | 'cloudflare' | 'static'
|
|
94
|
+
/** `--strict` (static): a loader that reads the request fails the build. */
|
|
95
|
+
strict?: boolean
|
|
96
|
+
/** The cache key grammar's read-set (static; tests swap it). Default: the addon. */
|
|
97
|
+
exprReads?: ExprReads
|
|
98
|
+
/** `--wasm-from-source` (cloudflare only). */
|
|
99
|
+
wasmFromSource?: boolean
|
|
100
|
+
/** `--actions-only` (L16 R16.3): no pages, only the `[actions]` app; with `bun` (default) or `cloudflare`. */
|
|
101
|
+
actionsOnly?: boolean
|
|
102
|
+
/** `brust build --dev` (L14, used by `brust dev`): also writes `dev.json` and builds the client runtime with `__BRUST_DEV__`. */
|
|
103
|
+
dev?: boolean
|
|
82
104
|
log: (s: string) => void
|
|
83
105
|
}): Promise<void> {
|
|
106
|
+
setDevBuild(opts.dev === true)
|
|
84
107
|
const appRoot = resolve(opts.appRoot)
|
|
85
108
|
const entry = resolve(appRoot, opts.entry)
|
|
86
109
|
const dist = resolve(appRoot, opts.outDir)
|
|
87
110
|
assertSafeOutDir(dist, appRoot, [entry, join(appRoot, 'public')])
|
|
88
111
|
assertReplaceable(dist, opts.force)
|
|
89
|
-
const
|
|
112
|
+
const built = await loadBuildConfig(appRoot, { actionsOnly: opts.actionsOnly === true })
|
|
113
|
+
if (opts.actionsOnly)
|
|
114
|
+
return runActionsOnly({ appRoot, dist, config: built, target: opts.target, wasmFromSource: opts.wasmFromSource === true, statOk, assertSafeOutDir, log: opts.log })
|
|
115
|
+
const { serverOnly, cloudflare, cache, cssGlobal } = built
|
|
116
|
+
// L16 R16.4b: `[actions]` with a `base_url` and no actions file (the default name, not one written in the toml)
|
|
117
|
+
// is a page-only build: the app is deployed elsewhere, so nothing is bundled or mounted here.
|
|
118
|
+
const remote =
|
|
119
|
+
built.actions !== null && built.actions.baseUrl !== undefined && !built.actions.entryExplicit && !statOk(resolve(appRoot, built.actions.entry)) ? built.actions : null
|
|
120
|
+
const actions = remote ? null : built.actions
|
|
121
|
+
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')
|
|
90
122
|
|
|
91
123
|
// Everything that can fail on the app's own input runs before a byte is written.
|
|
92
|
-
const { routes, componentFile } = await scanRoutes(entry)
|
|
124
|
+
const { routes, componentFile, componentIssue } = await scanRoutes(entry, appRoot)
|
|
93
125
|
let leaves: ReturnType<typeof validateRoutes>
|
|
94
126
|
try {
|
|
95
127
|
leaves = validateRoutes(routes)
|
|
@@ -114,7 +146,26 @@ export async function runBuild(opts: {
|
|
|
114
146
|
}
|
|
115
147
|
}
|
|
116
148
|
}
|
|
117
|
-
|
|
149
|
+
let cacheModule: string | null = null
|
|
150
|
+
if (cache?.module !== undefined) {
|
|
151
|
+
cacheModule = resolve(appRoot, cache.module)
|
|
152
|
+
const rel = relative(appRoot, cacheModule)
|
|
153
|
+
if (isAbsolute(cache.module) || rel === '..' || rel.startsWith('../') || isAbsolute(rel) || !statOk(cacheModule)) {
|
|
154
|
+
throw new BuildError('cache', `cache.module ${cache.module} is not a file inside the app`)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
assertSafeOutDir(dist, appRoot, [...componentFile.values(), ...(actionsEntry ? [actionsEntry] : []), ...(cacheModule ? [cacheModule] : [])])
|
|
158
|
+
|
|
159
|
+
// R9.3/R9.4: every reason to refuse a static build, before any write.
|
|
160
|
+
const ssgPlan =
|
|
161
|
+
opts.target === 'static'
|
|
162
|
+
? await prepareStatic(leaves, actions !== null, opts.exprReads ?? (await import('../native')).cacheExprReads, opts.log, cache)
|
|
163
|
+
: null
|
|
164
|
+
|
|
165
|
+
// Config, @brust/core-cf resolution and the user's wrangler file fail here, before any write.
|
|
166
|
+
// L12: a static build with `[actions]` also builds the Cloudflare worker (its Pages `_worker.js`).
|
|
167
|
+
const hybrid = ssgPlan !== null && actions !== null
|
|
168
|
+
const cf = opts.target === 'cloudflare' || hybrid ? await prepareCloudflare(appRoot, cloudflare, { fromSource: opts.wasmFromSource === true, env: process.env }) : null
|
|
118
169
|
|
|
119
170
|
// Sibling of `dist` (same filesystem: the final rename is atomic), fixed name (no random bytes in
|
|
120
171
|
// anything the bundler sees).
|
|
@@ -122,7 +173,13 @@ export async function runBuild(opts: {
|
|
|
122
173
|
rmSync(tmp, { recursive: true, force: true })
|
|
123
174
|
mkdirSync(tmp, { recursive: true })
|
|
124
175
|
try {
|
|
125
|
-
await buildInto(tmp, { appRoot, entry, dist, leaves, componentFile, serverOnly, actions, actionsEntry, log: opts.log })
|
|
176
|
+
await buildInto(tmp, { dev: opts.dev === true, appRoot, entry, dist, leaves, componentFile, componentIssue, serverOnly, actions, actionsEntry, cache, cacheModule, cf, hybrid, cssGlobal, actionsRemote: remote?.prefix ?? null, log: opts.log })
|
|
177
|
+
if (ssgPlan) {
|
|
178
|
+
// Rendered from the finished temp dist, so a failed render leaves the previous dist untouched.
|
|
179
|
+
const version: string = (await Bun.file(join(import.meta.dir, '../../package.json')).json()).version
|
|
180
|
+
await writeStatic(tmp, { entry, appRoot, plan: ssgPlan, version, strict: opts.strict === true, log: opts.log, shownDir: join(dist, 'static') })
|
|
181
|
+
if (hybrid && cf && actions) writeStaticWorker(tmp, actions, cf, opts.log)
|
|
182
|
+
}
|
|
126
183
|
rmSync(join(tmp, '.stage'), { recursive: true, force: true })
|
|
127
184
|
if (existsSync(dist)) {
|
|
128
185
|
const old = join(dirname(dist), `.${basename(dist)}.brust-old`)
|
|
@@ -140,48 +197,132 @@ export async function runBuild(opts: {
|
|
|
140
197
|
async function buildInto(
|
|
141
198
|
out: string,
|
|
142
199
|
o: {
|
|
200
|
+
/** Write `dev.json` (L14). */
|
|
201
|
+
dev: boolean
|
|
143
202
|
appRoot: string
|
|
144
203
|
entry: string
|
|
145
204
|
/** The final dist dir (`index.js` points at the entry relative to it). */
|
|
146
205
|
dist: string
|
|
147
206
|
leaves: ReturnType<typeof validateRoutes>
|
|
148
207
|
componentFile: Map<Function, string>
|
|
208
|
+
componentIssue: Map<Function, string>
|
|
149
209
|
serverOnly: string[]
|
|
150
210
|
actions: ActionsBuildConfig | null
|
|
151
211
|
/** Absolute path of the actions entry (null when `[actions]` is off). */
|
|
152
212
|
actionsEntry: string | null
|
|
213
|
+
cache: CacheBuildConfig | null
|
|
214
|
+
/** Absolute path of a custom cache adapter module. */
|
|
215
|
+
cacheModule: string | null
|
|
216
|
+
cf: CfPrepared | null
|
|
217
|
+
/** `[css] global` as written (app-root relative). */
|
|
218
|
+
cssGlobal: string[]
|
|
219
|
+
/** L16 R16.4b: the prefix of an actions app deployed elsewhere (a page-only build); absent = a local app or none. */
|
|
220
|
+
actionsRemote: string | null
|
|
221
|
+
/** `--target static` with `[actions]`: the worker is built without the cache (static pages are never cached). */
|
|
222
|
+
hybrid: boolean
|
|
153
223
|
log: (s: string) => void
|
|
154
224
|
},
|
|
155
225
|
): Promise<void> {
|
|
156
226
|
const { appRoot, entry, leaves } = o
|
|
157
|
-
|
|
227
|
+
// L19: every stylesheet first, so each JS build below reads one set of CSS Module class maps.
|
|
228
|
+
const graph = new CssGraph()
|
|
229
|
+
const chainFiles = (l: FlatRoute) => l.chain.flatMap((n) => (n.Component && o.componentFile.has(n.Component) ? [o.componentFile.get(n.Component)!] : []))
|
|
230
|
+
const routeCss = routeCssSources(leaves, chainFiles, resolveGlobals(appRoot, o.cssGlobal), graph)
|
|
231
|
+
const serverCss = [o.actionsEntry, o.cacheModule].flatMap((f) => (f ? graph.of(f) : []))
|
|
232
|
+
mkdirSync(join(out, 'client'), { recursive: true })
|
|
233
|
+
// Review N3: `brust dev` builds with BRUST_BUILD_MODE=development (as the JS bundles): readable, unminified CSS.
|
|
234
|
+
const css = await buildCss(out, [...new Set([...[...routeCss.values()].flat(), ...serverCss])], process.env.BRUST_BUILD_MODE !== 'development', { appRoot, log: o.log })
|
|
235
|
+
for (const c of directCss(entry))
|
|
236
|
+
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`)
|
|
237
|
+
const ctx: BrowserCtx = { sourceOf: new Map(), appRoot, serverOnly: o.serverOnly, serverFiles: o.actionsEntry ? [realpathSync(o.actionsEntry)] : [], cssMaps: css.maps }
|
|
158
238
|
// The runtime first: its hashed URL is the `runtimeImport` every client chunk is compiled with.
|
|
159
239
|
const runtime = await buildRuntime(out, ctx)
|
|
160
|
-
const {
|
|
240
|
+
const runtimeUrls = { core: `/_brust/${runtime.core}`, store: `/_brust/${runtime.store}` }
|
|
241
|
+
const compileOpts = {
|
|
161
242
|
appRoot,
|
|
162
243
|
leaves,
|
|
163
244
|
componentFile: o.componentFile,
|
|
164
|
-
|
|
245
|
+
componentIssue: o.componentIssue,
|
|
246
|
+
runtimeImport: runtimeUrls.core,
|
|
165
247
|
serverOnly: o.serverOnly,
|
|
166
|
-
|
|
167
|
-
}
|
|
248
|
+
cssMaps: css.maps,
|
|
249
|
+
}
|
|
250
|
+
const firstLog: string[] = []
|
|
251
|
+
let { compiled, routeComponent } = compileApp({ ...compileOpts, log: (l) => firstLog.push(l) })
|
|
252
|
+
// F153c: the packages the app imports components from are compiled from their source when they publish it (and
|
|
253
|
+
// it matches what they publish); the app is compiled again with those sources. A refused package stays the island.
|
|
254
|
+
const external = [...new Set([...compiled.values()].flatMap((c) => (c.ir.external ? [c.ir.external.source] : [])))]
|
|
255
|
+
const packages = preparePackageSources(appRoot, external)
|
|
256
|
+
if (Object.keys(packages.scopes).length > 0) {
|
|
257
|
+
firstLog.length = 0
|
|
258
|
+
;({ compiled, routeComponent } = compileApp({ ...compileOpts, packages, log: (l) => firstLog.push(l) }))
|
|
259
|
+
}
|
|
260
|
+
// L23: lucide icons the compiler could inline are rendered here (react-dom/server); L2: slots that read
|
|
261
|
+
// nothing request- or time-dependent are computed once, here. The app is compiled again with both (an icon with
|
|
262
|
+
// no markup stays the L22 island; a slot with a value is a literal: no job, no worker). The first compile's
|
|
263
|
+
// warnings describe icons that are inline now, so when icons were rendered only the last compile's are shown.
|
|
264
|
+
const hasIcons = [...compiled.values()].some((c) => (c.ir.icon_requests ?? []).length > 0)
|
|
265
|
+
const iconSvgs = hasIcons ? await renderIcons(compiled, appRoot, o.log) : undefined
|
|
266
|
+
const slotValues = await evaluateBuildSlots(out, compiled, ctx.sourceOf, appRoot, o.log)
|
|
267
|
+
const hasSlots = Object.keys(slotValues).length > 0
|
|
268
|
+
if (hasIcons || hasSlots) {
|
|
269
|
+
if (!hasIcons) firstLog.forEach((l) => o.log(l))
|
|
270
|
+
;({ compiled, routeComponent } = compileApp({ ...compileOpts, packages, iconSvgs, iconsFinal: hasIcons, slotValues, log: hasIcons ? o.log : () => {} }))
|
|
271
|
+
} else firstLog.forEach((l) => o.log(l))
|
|
272
|
+
|
|
273
|
+
// F158 (R34.69/70): the compiler makes a caller of a route component fall back to React, so none is a revive host;
|
|
274
|
+
// if one is, the compiler missed it, and the build stops rather than let request data near the revive.
|
|
275
|
+
const routeRoots: ReadonlySet<string> = new Set(routeComponent.values())
|
|
276
|
+
for (const id of routeRoots) {
|
|
277
|
+
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`)
|
|
278
|
+
}
|
|
168
279
|
|
|
169
280
|
mkdirSync(join(out, 'jinja'), { recursive: true })
|
|
170
281
|
for (const c of compiled.values()) writeFileSync(join(out, 'jinja', `${c.id}.jinja`), c.jinja)
|
|
171
282
|
|
|
172
|
-
const
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
283
|
+
const chunks = new Map([...(await buildClientChunks(out, compiled, ctx, runtimeUrls)), ...(await buildReactChunks(out, compiled, ctx, runtimeUrls, routeRoots))])
|
|
284
|
+
const manifest = writeManifest({ leaves, routeComponent, compiled, runtime: { core: runtime.core, features: { ...runtime.features, store: runtime.store } }, chunks })
|
|
285
|
+
// L33: a component whose chunk imports the store module (directly or through a shared chunk) has the page preload it.
|
|
286
|
+
const readClient = (rel: string) => (existsSync(join(out, rel)) ? readFileSync(join(out, rel), 'utf8') : undefined)
|
|
287
|
+
for (const rec of Object.values(manifest.components)) if (rec.client && importsStore(readClient, rec.client, runtime.store)) rec.features.push('store')
|
|
288
|
+
for (const r of manifest.routes) {
|
|
289
|
+
const files = [...new Set((routeCss.get(r.id) ?? []).flatMap((s) => (css.file.has(s) ? [css.file.get(s)!] : [])))]
|
|
290
|
+
if (files.length > 0) r.css = files
|
|
291
|
+
}
|
|
292
|
+
await buildJobs(out, compiled, ctx.sourceOf, appRoot, storeModules(manifest), css.maps, routeRoots)
|
|
176
293
|
manifest.actions =
|
|
177
294
|
o.actions && o.actionsEntry
|
|
178
|
-
? { entry: await buildActions(out, o.actionsEntry), prefix: o.actions.prefix, maxBodyBytes: o.actions.maxBodyBytes }
|
|
295
|
+
? { entry: await buildActions(out, o.actionsEntry, css.maps), prefix: o.actions.prefix, maxBodyBytes: o.actions.maxBodyBytes, maxResponseBytes: o.actions.maxResponseBytes, crossSite: o.actions.crossSite }
|
|
179
296
|
: null
|
|
297
|
+
if (o.actionsRemote !== null) manifest.actions_remote = { prefix: o.actionsRemote }
|
|
298
|
+
if (o.cache) manifest.cache = await cacheRecord(out, manifest, o.cache, o.cacheModule, css.maps)
|
|
299
|
+
manifest.needs_workers = needsWorkers(manifest)
|
|
180
300
|
writeFileSync(join(out, 'manifest.json'), `${JSON.stringify(manifest, null, 2)}\n`)
|
|
301
|
+
if (o.dev) writeFileSync(join(out, 'dev.json'), `${JSON.stringify(devJson(appRoot, compiled), null, 2)}\n`)
|
|
181
302
|
writeFileSync(join(out, MARKER), 'generated by `brust build`: this directory is replaced on every build\n')
|
|
182
303
|
|
|
183
304
|
const pub = join(appRoot, 'public')
|
|
184
305
|
if (existsSync(pub)) cpSync(pub, join(out, 'public'), { recursive: true })
|
|
306
|
+
if (o.cf) {
|
|
307
|
+
const warnings = await buildCloudflare(
|
|
308
|
+
out,
|
|
309
|
+
{
|
|
310
|
+
appRoot,
|
|
311
|
+
entry,
|
|
312
|
+
dist: o.dist,
|
|
313
|
+
compiled,
|
|
314
|
+
manifest: o.hybrid ? { ...manifest, cache: undefined } : manifest,
|
|
315
|
+
sourceOf: ctx.sourceOf,
|
|
316
|
+
actionsEntry: o.actionsEntry,
|
|
317
|
+
cacheModule: o.hybrid ? null : o.cacheModule,
|
|
318
|
+
cacheGetTimeoutMs: o.hybrid ? undefined : o.cache?.getTimeoutMs,
|
|
319
|
+
cssMaps: css.maps,
|
|
320
|
+
log: o.log,
|
|
321
|
+
},
|
|
322
|
+
o.cf,
|
|
323
|
+
)
|
|
324
|
+
for (const w of warnings) o.log(w)
|
|
325
|
+
}
|
|
185
326
|
const entryRel = relative(o.dist, entry)
|
|
186
327
|
writeFileSync(
|
|
187
328
|
join(out, 'index.js'),
|
|
@@ -197,3 +338,66 @@ async function buildInto(
|
|
|
197
338
|
].join('\n'),
|
|
198
339
|
)
|
|
199
340
|
}
|
|
341
|
+
|
|
342
|
+
/** `dev.json` (L14, R14.2): component id → source file, tier, the react reason, diagnostics. Only `brust build --dev` writes it. */
|
|
343
|
+
export function devJson(appRoot: string, compiled: Map<string, Compiled>): { version: 1; components: Record<string, DevComponent> } {
|
|
344
|
+
const components: Record<string, DevComponent> = {}
|
|
345
|
+
for (const c of compiled.values()) {
|
|
346
|
+
const tier = tierOf(c.ir)
|
|
347
|
+
const t = c.ir.tier
|
|
348
|
+
components[c.id] = {
|
|
349
|
+
file: relative(appRoot, c.file),
|
|
350
|
+
tier,
|
|
351
|
+
...(tier === 'react' && t && typeof t === 'object' && 'React' in t ? { reason: String((t as { React: { reason: string } }).React.reason) } : {}),
|
|
352
|
+
diagnostics: (c.diagnostics ?? []).map((d) => ({ class: d.class, rule: d.rule, line: d.line, col: d.col, message: d.message, remediation: d.remediation })),
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return { version: 1, components }
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export interface DevComponent {
|
|
359
|
+
file: string
|
|
360
|
+
tier: 'static' | 'native' | 'react'
|
|
361
|
+
/** Why the compiler chose the react tier (the IR's `React.reason`). */
|
|
362
|
+
reason?: string
|
|
363
|
+
diagnostics: { class: string; rule: string; line: number; col: number; message: string; remediation: string }[]
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** `manifest.cache` (R7, R8): the custom module bundled for the worker, the native defaults, and the
|
|
367
|
+
* build id every L2 key carries. */
|
|
368
|
+
async function cacheRecord(out: string, manifest: ManifestJson, c: CacheBuildConfig, moduleAbs: string | null, cssMaps: Map<string, Record<string, string>>): Promise<CacheRecord> {
|
|
369
|
+
const module = moduleAbs ? await buildCacheAdapter(out, moduleAbs, cssMaps) : undefined
|
|
370
|
+
return {
|
|
371
|
+
adapter: c.adapter,
|
|
372
|
+
...(module ? { module } : {}),
|
|
373
|
+
...(c.broadcast ? { broadcast: c.broadcast, broadcastOptions: c.broadcastOptions ?? {} } : {}),
|
|
374
|
+
options: c.options,
|
|
375
|
+
namespace: c.namespace,
|
|
376
|
+
getTimeoutMs: c.getTimeoutMs ?? 50,
|
|
377
|
+
maxBodyBytes: c.maxBodyBytes,
|
|
378
|
+
epochIntervalMs: c.epochIntervalMs,
|
|
379
|
+
buildId: computeBuildId(out, manifest),
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** R7: the first 16 hex chars of sha256 over the manifest (without `cache`), every `jinja/*.jinja`
|
|
384
|
+
* (by name), `jobs.js` and `actions.js` when present: a rebuild of the same app keeps it, any change
|
|
385
|
+
* to what renders a page moves it (a deploy never serves an older build's L2 bodies). */
|
|
386
|
+
export function computeBuildId(out: string, manifest: ManifestJson): string {
|
|
387
|
+
const h = new Bun.CryptoHasher('sha256')
|
|
388
|
+
const { cache: _, ...rest } = manifest
|
|
389
|
+
h.update(JSON.stringify(rest))
|
|
390
|
+
const jinja = join(out, 'jinja')
|
|
391
|
+
for (const f of (existsSync(jinja) ? readdirSync(jinja) : []).filter((f) => f.endsWith('.jinja')).sort()) {
|
|
392
|
+
h.update(`\0${f}\0`)
|
|
393
|
+
h.update(readFileSync(join(jinja, f)))
|
|
394
|
+
}
|
|
395
|
+
for (const f of ['jobs.js', 'actions.js']) {
|
|
396
|
+
const p = join(out, f)
|
|
397
|
+
if (existsSync(p)) {
|
|
398
|
+
h.update(`\0${f}\0`)
|
|
399
|
+
h.update(readFileSync(p))
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return h.digest('hex').slice(0, 16)
|
|
403
|
+
}
|
package/src/build/manifest.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import type { FlatRoute } from '../routes'
|
|
7
7
|
import type { Compiled, ComponentIR } from './compile'
|
|
8
8
|
import { BuildError } from './errors'
|
|
9
|
+
import { featuresOf } from './features'
|
|
9
10
|
|
|
10
11
|
export interface JobCache {
|
|
11
12
|
key: string | null
|
|
@@ -29,6 +30,12 @@ export interface ChildRecord {
|
|
|
29
30
|
id: string
|
|
30
31
|
instances: string
|
|
31
32
|
props: Record<string, string>
|
|
33
|
+
/** F105: constant props; part of the child's job keys. */
|
|
34
|
+
literals?: Record<string, unknown>
|
|
35
|
+
/** F105: jinja over `__props` (and `__row`/`__idx` per row) the server evaluates. */
|
|
36
|
+
exprs?: Record<string, string>
|
|
37
|
+
/** F142 R34.43: the list of a `per-row-literal` instance (a constant the compiler read). */
|
|
38
|
+
rows?: unknown[]
|
|
32
39
|
}
|
|
33
40
|
|
|
34
41
|
export interface ComponentRecord {
|
|
@@ -40,6 +47,8 @@ export interface ComponentRecord {
|
|
|
40
47
|
needs_worker: boolean
|
|
41
48
|
use_id_slots: number
|
|
42
49
|
stores: string[]
|
|
50
|
+
/** L33: the client runtime feature modules (`assets.features` keys) this component's output needs, in load order. */
|
|
51
|
+
features: string[]
|
|
43
52
|
}
|
|
44
53
|
|
|
45
54
|
export interface RouteRecord {
|
|
@@ -49,22 +58,62 @@ export interface RouteRecord {
|
|
|
49
58
|
loaders: string[]
|
|
50
59
|
cache: { ttl_seconds: number; prefix: string | null; bypass: true | string | null; tags: string[] } | null
|
|
51
60
|
catch_all: boolean
|
|
61
|
+
/** L19 (R19.3): `client/<file>.css` in cascade order; absent when the route has none. */
|
|
62
|
+
css?: string[]
|
|
52
63
|
}
|
|
53
64
|
|
|
54
|
-
/** `manifest.actions` (A3): `entry` is relative to dist; `maxBodyBytes`
|
|
65
|
+
/** `manifest.actions` (A3): `entry` is relative to dist; `maxBodyBytes`/`maxResponseBytes` are camelCase by contract. */
|
|
55
66
|
export interface ActionsRecord {
|
|
56
67
|
entry: string
|
|
57
68
|
prefix: string
|
|
58
69
|
maxBodyBytes: number
|
|
70
|
+
maxResponseBytes: number
|
|
71
|
+
crossSite: 'reject' | 'allow'
|
|
59
72
|
}
|
|
60
73
|
|
|
74
|
+
/** The manifest format the Rust loader accepts (`MANIFEST_VERSION` in brust-core `manifest.rs`): the one place TS names it. */
|
|
75
|
+
export const MANIFEST_VERSION = 3 as const
|
|
76
|
+
|
|
61
77
|
export interface ManifestJson {
|
|
62
|
-
version:
|
|
78
|
+
version: typeof MANIFEST_VERSION
|
|
63
79
|
routes: RouteRecord[]
|
|
64
80
|
components: Record<string, ComponentRecord>
|
|
65
|
-
|
|
81
|
+
/** `runtime` is the core; `features` maps each feature module name to its file (L33). */
|
|
82
|
+
assets: { runtime: string; features: Record<string, string> }
|
|
66
83
|
jobs_module: string
|
|
67
84
|
actions: ActionsRecord | null
|
|
85
|
+
/** L16 R16.3: an actions-only build (no routes, no templates); everything but the prefix is a 404 problem+json. */
|
|
86
|
+
actions_only?: true
|
|
87
|
+
/** L16 R16.4b: a page-only build: no local actions app, `base_url` points at the deployment that has it. */
|
|
88
|
+
actions_remote?: { prefix: string }
|
|
89
|
+
/** `[cache]` (cache adapters); absent without one (the manifest is then byte-identical). */
|
|
90
|
+
cache?: CacheRecord
|
|
91
|
+
/** F165 (R40.1): `brust start` spawns its worker pool (`needsWorkers`). Read by `run.ts` only; an
|
|
92
|
+
* absent value (an older or actions-only manifest) means the pool is needed. */
|
|
93
|
+
needs_workers?: boolean
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** F165 (R40.1): whether any request can call a worker. Workers answer four kinds of call
|
|
97
|
+
* (worker.ts): `loader`, `jobs` (precompute and SSR), `fetch` (the local actions app) and `cache`
|
|
98
|
+
* (the adapter). Every component's jobs count, reached by a route or not (F166 prunes those later). */
|
|
99
|
+
export function needsWorkers(m: Pick<ManifestJson, 'routes' | 'components' | 'actions' | 'cache'>): boolean {
|
|
100
|
+
return Object.values(m.components).some((c) => c.jobs.length > 0) || m.routes.some((r) => r.loaders.length > 0) || m.actions !== null || m.cache !== undefined
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** `manifest.cache` / `CONFIG.cache`: the adapter the runtime loads (`cache-adapter/load.ts`). */
|
|
104
|
+
export interface CacheRecord {
|
|
105
|
+
adapter: string
|
|
106
|
+
/** `custom`: the bundled module, relative to dist. */
|
|
107
|
+
module?: string
|
|
108
|
+
broadcast?: string
|
|
109
|
+
options: Record<string, unknown>
|
|
110
|
+
broadcastOptions?: Record<string, unknown>
|
|
111
|
+
namespace: string
|
|
112
|
+
getTimeoutMs: number
|
|
113
|
+
maxBodyBytes: number
|
|
114
|
+
epochIntervalMs: number
|
|
115
|
+
/** 16 hex chars of sha256 over the build output (R7): every L2 key carries it. */
|
|
116
|
+
buildId: string
|
|
68
117
|
}
|
|
69
118
|
|
|
70
119
|
// biome-ignore lint/suspicious/noExplicitAny: IR JSON (externally tagged enums)
|
|
@@ -206,15 +255,23 @@ export function childRecords(c: Compiled, clientOnly: string[] = []): ChildRecor
|
|
|
206
255
|
if (inst.loops.length > 1)
|
|
207
256
|
throw new BuildError('nested-instance', `${ir.id}: child ${inst.child_id} sits in nested lists (M3)`)
|
|
208
257
|
const list = inst.loops[0]
|
|
209
|
-
|
|
258
|
+
const literal = list === null && Array.isArray(inst.rows_literal)
|
|
259
|
+
if (list === null && !literal)
|
|
210
260
|
throw new BuildError('instance-list-path', `${ir.id}: child ${inst.child_id} sits in a list that is not a props path`)
|
|
211
261
|
const props: Record<string, string> = {}
|
|
212
262
|
for (const [name, path] of Object.entries(inst.props)) {
|
|
213
263
|
if (path === null)
|
|
214
|
-
throw new BuildError(
|
|
264
|
+
throw new BuildError(
|
|
265
|
+
'instance-prop-path',
|
|
266
|
+
`${ir.id}: child ${inst.child_id} prop ${name} is not a props path, a JSON literal or a template expression over props; compute it in a loader, or wrap the child's derived value in cache()`,
|
|
267
|
+
)
|
|
215
268
|
props[name] = path
|
|
216
269
|
}
|
|
217
|
-
|
|
270
|
+
const rec: ChildRecord = { id: inst.child_id, instances: literal ? 'per-row-literal' : list === undefined ? 'static' : `per-row:${list}`, props }
|
|
271
|
+
if (inst.literals && Object.keys(inst.literals).length > 0) rec.literals = { ...inst.literals }
|
|
272
|
+
if (inst.exprs && Object.keys(inst.exprs).length > 0) rec.exprs = { ...inst.exprs }
|
|
273
|
+
if (literal) rec.rows = inst.rows_literal
|
|
274
|
+
return rec
|
|
218
275
|
})
|
|
219
276
|
for (const id of clientOnly) out.push({ id, instances: 'static', props: {} })
|
|
220
277
|
return out
|
|
@@ -241,7 +298,8 @@ export function writeManifest(opts: {
|
|
|
241
298
|
leaves: FlatRoute[]
|
|
242
299
|
routeComponent: Map<string, string>
|
|
243
300
|
compiled: Map<string, Compiled>
|
|
244
|
-
runtime:
|
|
301
|
+
/** The runtime files `buildRuntime` wrote: the core and the feature modules. */
|
|
302
|
+
runtime: { core: string; features: Record<string, string> }
|
|
245
303
|
/** component id → `client/<file>` (client chunk or react island chunk). */
|
|
246
304
|
chunks: Map<string, string>
|
|
247
305
|
actions?: ActionsRecord | null
|
|
@@ -260,7 +318,10 @@ export function writeManifest(opts: {
|
|
|
260
318
|
needs_worker: jobs.length > 0,
|
|
261
319
|
use_id_slots: c.ir.use_id_slots ?? 0,
|
|
262
320
|
stores: storesOf(compiled, c.id),
|
|
321
|
+
features: featuresOf(c.id, c.jinja, tierOf(c.ir) === 'react'),
|
|
263
322
|
}
|
|
323
|
+
for (const f of components[c.id]!.features)
|
|
324
|
+
if (!(f in opts.runtime.features)) throw new BuildError('runtime-feature-missing', `${c.id}: needs the client runtime feature ${f}, which the build did not ship`)
|
|
264
325
|
}
|
|
265
326
|
const routes: RouteRecord[] = opts.leaves.map((l) => {
|
|
266
327
|
// A node without a Component (a pure path/loader group) renders nothing: not a chain entry.
|
|
@@ -282,7 +343,7 @@ export function writeManifest(opts: {
|
|
|
282
343
|
}
|
|
283
344
|
})
|
|
284
345
|
linkNativeChunks(routes, compiled, components)
|
|
285
|
-
return { version:
|
|
346
|
+
return { version: MANIFEST_VERSION, routes, components, assets: { runtime: opts.runtime.core, features: opts.runtime.features }, jobs_module: 'jobs.js', actions: opts.actions ?? null }
|
|
286
347
|
}
|
|
287
348
|
|
|
288
349
|
/** S6 amendment (F66): the server's asset injection walks only each chain component's direct
|