@brust/core 2.0.0-alpha.2 → 2.0.0-alpha.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +64 -6
- package/native/index.d.ts +65 -2
- package/native/index.js +59 -54
- package/package.json +16 -8
- 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 +199 -0
- package/src/build/build-icons.ts +69 -0
- package/src/build/bundle.ts +48 -12
- 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 +315 -0
- package/src/build/compile.ts +41 -4
- package/src/build/config.ts +255 -10
- package/src/build/css.ts +286 -0
- package/src/build/index.ts +199 -16
- package/src/build/manifest.ts +41 -4
- package/src/build/scan.ts +59 -20
- 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-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 +50 -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 +38 -1
- package/src/routes.ts +20 -1
- package/src/run.ts +46 -9
- package/src/server-handlers.ts +32 -0
- package/src/worker.ts +125 -245
package/src/build/bundle.ts
CHANGED
|
@@ -12,14 +12,23 @@ import { builtinModules } from 'node:module'
|
|
|
12
12
|
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path'
|
|
13
13
|
import type { BunPlugin } from 'bun'
|
|
14
14
|
import type { Compiled } from './compile'
|
|
15
|
+
import { cssPlugin } from './css'
|
|
15
16
|
import { BuildError } from './errors'
|
|
16
17
|
|
|
17
18
|
const REACT_EXTERNALS = ['react', 'react/*', 'react-dom', 'react-dom/*']
|
|
18
|
-
/** Production React (and the production JSX transform: `react/jsx-runtime`, never `jsxDEV`).
|
|
19
|
-
|
|
19
|
+
/** Production React (and the production JSX transform: `react/jsx-runtime`, never `jsxDEV`). `brust dev` builds set
|
|
20
|
+
* `BRUST_BUILD_MODE=development` for readable errors and hydration diffs (R13.4); `brust build` never does. */
|
|
21
|
+
export const PRODUCTION: Record<string, string> = { 'process.env.NODE_ENV': JSON.stringify(process.env.BRUST_BUILD_MODE === 'development' ? 'development' : 'production'), __BRUST_DEV__: 'false' }
|
|
22
|
+
|
|
23
|
+
/** `brust build --dev` (L14, R14.3): the client runtime exposes its live instances on a dev global. `__BRUST_DEV__` is a
|
|
24
|
+
* define, `false` in every other build, so the minifier removes the dev code from production bundles. */
|
|
25
|
+
export function setDevBuild(dev: boolean): void {
|
|
26
|
+
PRODUCTION.__BRUST_DEV__ = dev ? 'true' : 'false'
|
|
27
|
+
}
|
|
20
28
|
|
|
21
29
|
const matchesExternal = (spec: string, external: string[]) =>
|
|
22
|
-
|
|
30
|
+
// A trailing `*` is a prefix (`elysia/*`, `node:*`, `cloudflare:*`): the generated CF entry imports `cloudflare:workers`.
|
|
31
|
+
external.some((p) => (p.endsWith('*') ? spec.startsWith(p.slice(0, -1)) : spec === p))
|
|
23
32
|
|
|
24
33
|
/** Staged artifact (by its absolute and its real path) → the component SOURCE file it was
|
|
25
34
|
* generated from. */
|
|
@@ -33,6 +42,8 @@ export interface BrowserCtx {
|
|
|
33
42
|
serverOnly: string[]
|
|
34
43
|
/** Real paths of server-only files (the actions entry, R8): a browser import of one is refused. */
|
|
35
44
|
serverFiles?: string[]
|
|
45
|
+
/** L19: CSS Module class maps for `cssPlugin` (absent: no CSS was built). */
|
|
46
|
+
cssMaps?: Map<string, Record<string, string>>
|
|
36
47
|
}
|
|
37
48
|
|
|
38
49
|
/** Resolution for staged artifacts (`importer` = a staged file registered in `sourceOf`): relative
|
|
@@ -145,7 +156,7 @@ function serverOnlyPlugin(ctx: BrowserCtx, violations: string[]): BunPlugin {
|
|
|
145
156
|
}
|
|
146
157
|
|
|
147
158
|
const hex = (s: string) => createHash('sha256').update(s).digest('hex').slice(0, 10)
|
|
148
|
-
const write = (file: string, text: string) => {
|
|
159
|
+
export const write = (file: string, text: string) => {
|
|
149
160
|
mkdirSync(dirname(file), { recursive: true })
|
|
150
161
|
writeFileSync(file, text)
|
|
151
162
|
}
|
|
@@ -165,7 +176,7 @@ async function browserBuild(
|
|
|
165
176
|
try {
|
|
166
177
|
res = await Bun.build({
|
|
167
178
|
...config,
|
|
168
|
-
plugins: [serverOnlyPlugin(ctx, violations), ...(config.plugins ?? [])],
|
|
179
|
+
plugins: [serverOnlyPlugin(ctx, violations), cssPlugin(ctx.cssMaps ?? new Map()), ...(config.plugins ?? [])],
|
|
169
180
|
target: 'browser',
|
|
170
181
|
format: 'esm',
|
|
171
182
|
minify: true,
|
|
@@ -261,13 +272,22 @@ export async function buildClientChunks(dist: string, compiled: Map<string, Comp
|
|
|
261
272
|
}
|
|
262
273
|
|
|
263
274
|
/** Registers a written artifact (by its path and its real path: the importer Bun reports). */
|
|
264
|
-
function stage(sourceOf: SourceOf, file: string, c: Compiled): void {
|
|
275
|
+
export function stage(sourceOf: SourceOf, file: string, c: Compiled): void {
|
|
265
276
|
sourceOf.set(file, c.file)
|
|
266
277
|
sourceOf.set(realpathSync(file), c.file)
|
|
267
278
|
}
|
|
268
279
|
|
|
269
280
|
const isReact = (c: Compiled) => typeof c.ir.tier === 'object' && c.ir.tier !== null && 'React' in c.ir.tier
|
|
270
281
|
|
|
282
|
+
/** The `import` line that brings a react-tier component's code in as `local`: its own module, or
|
|
283
|
+
* for a package export (L22) the export itself. */
|
|
284
|
+
export function componentImport(c: Compiled, local: string): string {
|
|
285
|
+
const ext = c.ir.external
|
|
286
|
+
if (!ext) return `import ${local} from ${JSON.stringify(c.file)}`
|
|
287
|
+
const from = JSON.stringify(ext.source)
|
|
288
|
+
return ext.export === 'default' ? `import ${local} from ${from}` : `import { ${JSON.stringify(ext.export).replace(/^"|"$/g, '')} as ${local} } from ${from}`
|
|
289
|
+
}
|
|
290
|
+
|
|
271
291
|
/** The island entry of a react-tier component: registers `(host, props) => …` under its id. A
|
|
272
292
|
* `client_only` component was never server-rendered (its host is empty), so it mounts with
|
|
273
293
|
* `createRoot`; hydrating it would fail (React #418). Everything else hydrates the SSR markup. */
|
|
@@ -277,7 +297,7 @@ export function islandShim(c: Compiled): string {
|
|
|
277
297
|
? ['createRoot', `createRoot(host, { identifierPrefix: ${JSON.stringify(c.id)} }).render(createElement(Comp, props))`]
|
|
278
298
|
: ['hydrateRoot', `hydrateRoot(host, createElement(Comp, props), { identifierPrefix: ${JSON.stringify(c.id)} })`]
|
|
279
299
|
return (
|
|
280
|
-
`import { ${api} } from 'react-dom/client'\nimport { createElement } from 'react'\
|
|
300
|
+
`import { ${api} } from 'react-dom/client'\nimport { createElement } from 'react'\n${componentImport(c, 'Comp')}\n` +
|
|
281
301
|
`;((globalThis as any).__brustIslands ||= []).push([${JSON.stringify(c.id)}, (host: Element, props: any) => { ${mount} }])\n` +
|
|
282
302
|
`;(globalThis as any).__brustIslandReady?.()\n`
|
|
283
303
|
)
|
|
@@ -334,16 +354,32 @@ const ACTIONS_EXTERNALS = [...JOBS_EXTERNALS, 'elysia', 'elysia/*', '@elysia/*']
|
|
|
334
354
|
|
|
335
355
|
/** `dist/actions.js` (M3-F E1): the actions entry for the worker (`target: 'bun'`). No `define`: Elysia
|
|
336
356
|
* and app code read NODE_ENV at runtime (run.ts sets production). */
|
|
337
|
-
export async function buildActions(dist: string, entry: string): Promise<string> {
|
|
338
|
-
const res = await Bun.build({ entrypoints: [entry], target: 'bun', format: 'esm', external: ACTIONS_EXTERNALS, plugins: [workerStorePlugin] })
|
|
357
|
+
export async function buildActions(dist: string, entry: string, cssMaps: Map<string, Record<string, string>> = new Map()): Promise<string> {
|
|
358
|
+
const res = await Bun.build({ entrypoints: [entry], target: 'bun', format: 'esm', external: ACTIONS_EXTERNALS, plugins: [cssPlugin(cssMaps), workerStorePlugin] })
|
|
339
359
|
if (!res.success) throw new BuildError('bundle', `actions: ${res.logs.map(String).join('\n')}`)
|
|
340
360
|
write(join(dist, 'actions.js'), await res.outputs[0]!.text())
|
|
341
361
|
return 'actions.js'
|
|
342
362
|
}
|
|
343
363
|
|
|
364
|
+
/** `dist/cache-adapter.js` (cache adapters R8): a custom adapter module for the worker (`target: 'bun'`), with
|
|
365
|
+
* the actions externals so it shares the app's dependencies. */
|
|
366
|
+
export async function buildCacheAdapter(dist: string, entry: string, cssMaps: Map<string, Record<string, string>> = new Map()): Promise<string> {
|
|
367
|
+
const res = await Bun.build({ entrypoints: [entry], target: 'bun', format: 'esm', external: ACTIONS_EXTERNALS, plugins: [cssPlugin(cssMaps)] })
|
|
368
|
+
if (!res.success) throw new BuildError('bundle', `cache adapter: ${res.logs.map(String).join('\n')}`)
|
|
369
|
+
write(join(dist, 'cache-adapter.js'), await res.outputs[0]!.text())
|
|
370
|
+
return 'cache-adapter.js'
|
|
371
|
+
}
|
|
372
|
+
|
|
344
373
|
/** `dist/jobs.js` (`target: 'bun'`, React and `@brust/core` external): default export
|
|
345
374
|
* `{ [componentId]: { precompute?, ssr? } }`; `ssr` only for react-tier, non-client_only components. */
|
|
346
|
-
export async function buildJobs(
|
|
375
|
+
export async function buildJobs(
|
|
376
|
+
dist: string,
|
|
377
|
+
compiled: Map<string, Compiled>,
|
|
378
|
+
sourceOf: SourceOf,
|
|
379
|
+
appRoot: string,
|
|
380
|
+
stores: string[] = [],
|
|
381
|
+
cssMaps: Map<string, Record<string, string>> = new Map(),
|
|
382
|
+
): Promise<void> {
|
|
347
383
|
const imports: string[] = ["import { createElement } from 'react'", "import { renderToString } from 'react-dom/server'"]
|
|
348
384
|
const entries: string[] = []
|
|
349
385
|
const storePaths = [...new Set(stores)].sort()
|
|
@@ -364,7 +400,7 @@ export async function buildJobs(dist: string, compiled: Map<string, Compiled>, s
|
|
|
364
400
|
fns.push(`precompute: j${n}.precompute`)
|
|
365
401
|
}
|
|
366
402
|
if (isReact(c) && !c.ir.tier.React.client_only) {
|
|
367
|
-
imports.push(`
|
|
403
|
+
imports.push(componentImport(c, `C${n}`))
|
|
368
404
|
// Ruling 2bf3775a (R1): the island's component id prefixes React's useId on BOTH sides
|
|
369
405
|
// (the shim's hydrateRoot passes the same prefix), so ids differ across islands.
|
|
370
406
|
fns.push(`ssr: (props: any) => renderToString(createElement(C${n}, props), { identifierPrefix: ${JSON.stringify(c.id)} })`)
|
|
@@ -380,7 +416,7 @@ export async function buildJobs(dist: string, compiled: Map<string, Compiled>, s
|
|
|
380
416
|
format: 'esm',
|
|
381
417
|
define: PRODUCTION,
|
|
382
418
|
external: JOBS_EXTERNALS,
|
|
383
|
-
plugins: [workerStorePlugin, sourceDirPlugin(sourceOf, appRoot, JOBS_EXTERNALS)],
|
|
419
|
+
plugins: [cssPlugin(cssMaps), workerStorePlugin, sourceDirPlugin(sourceOf, appRoot, JOBS_EXTERNALS)],
|
|
384
420
|
})
|
|
385
421
|
if (!res.success) throw new BuildError('bundle', `jobs: ${res.logs.map(String).join('\n')}`)
|
|
386
422
|
write(join(dist, 'jobs.js'), await res.outputs[0]!.text())
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Static assets for Workers Static Assets (M4-CF R7): only files the native host would serve (`safe_rel` in
|
|
2
|
+
// `crates/brust-core/src/static_rules.rs`), laid out under the URL they are requested at, plus `_headers`.
|
|
3
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, statSync, writeFileSync } from 'node:fs'
|
|
4
|
+
import { dirname, join } from 'node:path'
|
|
5
|
+
|
|
6
|
+
const SEG = /^[A-Za-z0-9_.-]+$/
|
|
7
|
+
const IMMUTABLE = 'public, max-age=31536000, immutable'
|
|
8
|
+
const PUBLIC_DEFAULT = 'public, max-age=3600'
|
|
9
|
+
const MAX_RULES = 100
|
|
10
|
+
|
|
11
|
+
/** `static_rules.rs` `is_hashed`: the stem ends in `-<hex6+>` (lowercase) with at least one digit. */
|
|
12
|
+
export function isHashed(file: string): boolean {
|
|
13
|
+
const base = file.slice(file.lastIndexOf('/') + 1)
|
|
14
|
+
const dot = base.lastIndexOf('.')
|
|
15
|
+
const stem = dot > 0 ? base.slice(0, dot) : base
|
|
16
|
+
const i = stem.lastIndexOf('-')
|
|
17
|
+
if (i < 0) return false
|
|
18
|
+
const h = stem.slice(i + 1)
|
|
19
|
+
return h.length >= 6 && /^[0-9a-f]+$/.test(h) && /\d/.test(h)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const segOk = (s: string) => s !== '' && !s.startsWith('.') && SEG.test(s)
|
|
23
|
+
|
|
24
|
+
/** `client/<file>.js|.css`: `is_safe_island_filename` / `is_safe_css_filename`, plus the fonts and images a
|
|
25
|
+
* stylesheet references (`is_safe_client_asset_filename`, L19). */
|
|
26
|
+
const CLIENT_EXT = /\.(js|css|woff2|woff|ttf|otf|eot|png|jpg|jpeg|gif|webp|avif|svg|ico)$/
|
|
27
|
+
const clientOk = (f: string) => segOk(f) && !f.includes('..') && CLIENT_EXT.test(f)
|
|
28
|
+
|
|
29
|
+
function walk(dir: string, rel = ''): string[] {
|
|
30
|
+
const out: string[] = []
|
|
31
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
32
|
+
const r = rel ? `${rel}/${e.name}` : e.name
|
|
33
|
+
if (e.isDirectory()) out.push(...walk(join(dir, e.name), r))
|
|
34
|
+
else if (e.isFile() || statSync(join(dir, e.name)).isFile()) out.push(r)
|
|
35
|
+
}
|
|
36
|
+
return out.sort()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Copies the admissible files of `<out>/client` and `<out>/public` into `<cfDir>/assets/...`; returns the
|
|
40
|
+
* asset-relative paths (`_brust/client/x.js`, `public/img/a.png`) and writes `assets/_headers`. */
|
|
41
|
+
export function layoutAssets(out: string, cfDir: string, version: string): { files: string[]; warnings: string[] } {
|
|
42
|
+
const assets = join(cfDir, 'assets')
|
|
43
|
+
mkdirSync(assets, { recursive: true })
|
|
44
|
+
const files: string[] = []
|
|
45
|
+
const warnings: string[] = []
|
|
46
|
+
const copy = (from: string, to: string) => {
|
|
47
|
+
mkdirSync(dirname(join(assets, to)), { recursive: true })
|
|
48
|
+
cpSync(from, join(assets, to))
|
|
49
|
+
files.push(to)
|
|
50
|
+
}
|
|
51
|
+
const client = join(out, 'client')
|
|
52
|
+
if (existsSync(client))
|
|
53
|
+
for (const f of walk(client)) {
|
|
54
|
+
if (!f.includes('/') && clientOk(f)) copy(join(client, f), `_brust/client/${f}`)
|
|
55
|
+
else warnings.push(`[brust] assets: client/${f} is not served natively; skipped`)
|
|
56
|
+
}
|
|
57
|
+
const pub = join(out, 'public')
|
|
58
|
+
if (existsSync(pub))
|
|
59
|
+
for (const f of walk(pub)) {
|
|
60
|
+
if (f.split('/').every(segOk)) copy(join(pub, f), `public/${f}`)
|
|
61
|
+
else warnings.push(`[brust] assets: public/${f} has a name the native host would not serve; skipped`)
|
|
62
|
+
}
|
|
63
|
+
const h = headersFile(files, version)
|
|
64
|
+
warnings.push(...h.warnings)
|
|
65
|
+
writeFileSync(join(assets, '_headers'), h.text)
|
|
66
|
+
return { files, warnings }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function headersFile(files: string[], version: string): { text: string; warnings: string[] } {
|
|
70
|
+
const warnings: string[] = []
|
|
71
|
+
const rules: string[] = [
|
|
72
|
+
`/*\n X-Content-Type-Options: nosniff\n X-Powered-By: brust/${version}`,
|
|
73
|
+
`/_brust/client/*\n Cache-Control: ${IMMUTABLE}`,
|
|
74
|
+
]
|
|
75
|
+
const pub = files.filter((f) => f.startsWith('public/'))
|
|
76
|
+
if (rules.length + pub.length <= MAX_RULES)
|
|
77
|
+
for (const f of pub) rules.push(`/${f}\n Cache-Control: ${isHashed(f) ? IMMUTABLE : PUBLIC_DEFAULT}`)
|
|
78
|
+
else if (pub.length > 0) {
|
|
79
|
+
rules.push(`/public/*\n Cache-Control: ${PUBLIC_DEFAULT}`)
|
|
80
|
+
warnings.push(`[brust] assets: ${pub.length} public files exceed the 100-rule _headers limit; one /public/* max-age=3600 rule (no per-file immutable)`)
|
|
81
|
+
}
|
|
82
|
+
return { text: `${rules.join('\n\n')}\n`, warnings }
|
|
83
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Locates `@brust/core-cf` (the wasm core + glue, built by lane M4-CF-3) and reads its `brust` metadata (M4-CF R3, R4).
|
|
2
|
+
// Order: `--wasm-from-source` > env BRUST_CORE_CF_DIR > the app's node_modules > next to @brust/core.
|
|
3
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
4
|
+
import { dirname, join } from 'node:path'
|
|
5
|
+
import { BuildError } from './errors'
|
|
6
|
+
|
|
7
|
+
export interface CoreCfMeta {
|
|
8
|
+
abi: number
|
|
9
|
+
/** worker-build entry, relative to `worker/` */
|
|
10
|
+
main: string
|
|
11
|
+
glue: string
|
|
12
|
+
/** dir under `worker/` that holds `brust-app.js` (next to the glue's `raw_module`) */
|
|
13
|
+
appModuleDir: string
|
|
14
|
+
compatibilityDate: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface CoreCf {
|
|
18
|
+
dir: string
|
|
19
|
+
workerDir: string
|
|
20
|
+
meta: CoreCfMeta
|
|
21
|
+
version: string
|
|
22
|
+
warnings: string[]
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ResolveOpts {
|
|
26
|
+
appRoot: string
|
|
27
|
+
fromSource: boolean
|
|
28
|
+
env: Record<string, string | undefined>
|
|
29
|
+
/** `@brust/core`'s package dir (default: this package) */
|
|
30
|
+
corePkgDir?: string
|
|
31
|
+
run?: (cmd: string[], cwd: string) => number
|
|
32
|
+
which?: (bin: string) => string | null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const HINT = (v: string) => `install it with \`bun add -d @brust/core-cf@${v}\` or pass --wasm-from-source`
|
|
36
|
+
|
|
37
|
+
function readJson(file: string): Record<string, unknown> | null {
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(readFileSync(file, 'utf8'))
|
|
40
|
+
} catch {
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function fromDir(dir: string, coreVersion: string): CoreCf {
|
|
46
|
+
const pkg = readJson(join(dir, 'package.json'))
|
|
47
|
+
const m = pkg?.brust as Partial<CoreCfMeta> | undefined
|
|
48
|
+
if (!pkg || !m) throw new BuildError('cf-wasm', `${dir}/package.json has no "brust" metadata (not a @brust/core-cf package?)`)
|
|
49
|
+
if (m.abi !== 1) throw new BuildError('cf-wasm', `@brust/core-cf has abi ${String(m.abi)}; this brust supports abi 1 — upgrade brust or core-cf`)
|
|
50
|
+
const meta: CoreCfMeta = {
|
|
51
|
+
abi: 1,
|
|
52
|
+
main: m.main ?? 'index.js',
|
|
53
|
+
glue: m.glue ?? m.main ?? 'index.js',
|
|
54
|
+
appModuleDir: m.appModuleDir ?? '.',
|
|
55
|
+
compatibilityDate: m.compatibilityDate ?? '2026-10-01',
|
|
56
|
+
}
|
|
57
|
+
const workerDir = join(dir, 'worker')
|
|
58
|
+
if (!existsSync(join(workerDir, meta.main)))
|
|
59
|
+
throw new BuildError('cf-wasm', `@brust/core-cf has no worker/${meta.main} (${dir}); ${HINT(coreVersion)}`)
|
|
60
|
+
const version = String(pkg.version ?? '')
|
|
61
|
+
const warnings: string[] = []
|
|
62
|
+
if (version !== coreVersion) warnings.push(`[brust] @brust/core-cf ${version} differs from @brust/core ${coreVersion}`)
|
|
63
|
+
return { dir, workerDir, meta, version, warnings }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function resolveCoreCf(o: ResolveOpts): CoreCf {
|
|
67
|
+
const corePkgDir = o.corePkgDir ?? join(import.meta.dir, '..', '..')
|
|
68
|
+
const coreVersion = String(readJson(join(corePkgDir, 'package.json'))?.version ?? '0.0.0')
|
|
69
|
+
if (o.fromSource) {
|
|
70
|
+
const repo = join(corePkgDir, '..', '..')
|
|
71
|
+
if (!existsSync(join(repo, 'crates', 'brust-wasm'))) throw new BuildError('cf-wasm', 'cf-wasm needs crates/brust-wasm (lane M4-CF-3): --wasm-from-source requires a brust checkout')
|
|
72
|
+
const which = o.which ?? ((b: string) => Bun.which(b))
|
|
73
|
+
for (const bin of ['cargo']) if (!which(bin)) throw new BuildError('cf-wasm', `--wasm-from-source needs \`${bin}\` on PATH`)
|
|
74
|
+
const run = o.run ?? ((cmd, cwd) => Bun.spawnSync(cmd, { cwd, stdio: ['inherit', 'inherit', 'inherit'] }).exitCode)
|
|
75
|
+
// The build script writes npm/core-cf/worker/ (lane 3 owns it; never bare `worker-build`).
|
|
76
|
+
const code = run(['bun', join('crates', 'brust-wasm', 'scripts', 'build.ts')], repo)
|
|
77
|
+
if (code !== 0) throw new BuildError('cf-wasm', `crates/brust-wasm/scripts/build.ts failed (exit ${code})`)
|
|
78
|
+
return fromDir(join(repo, 'npm', 'core-cf'), coreVersion)
|
|
79
|
+
}
|
|
80
|
+
const envDir = o.env.BRUST_CORE_CF_DIR
|
|
81
|
+
if (envDir) return fromDir(envDir, coreVersion)
|
|
82
|
+
for (const from of [o.appRoot, corePkgDir]) {
|
|
83
|
+
try {
|
|
84
|
+
return fromDir(dirname(Bun.resolveSync('@brust/core-cf/package.json', from)), coreVersion)
|
|
85
|
+
} catch (e) {
|
|
86
|
+
if (e instanceof BuildError) throw e
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
throw new BuildError('cf-wasm', `@brust/core-cf is not installed; ${HINT(coreVersion)}`)
|
|
90
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// The generated Cloudflare entry (M4-CF spec §4, R9, R13): ~25 lines that wire the user's routes, jobs and
|
|
2
|
+
// actions into the six exports the wasm glue imports from `./brust-app.js`. Pure: text in, text out.
|
|
3
|
+
export interface CfConfigJson {
|
|
4
|
+
abi: 1
|
|
5
|
+
generator: string
|
|
6
|
+
l1Capacity: number
|
|
7
|
+
jobCacheCapacity: number
|
|
8
|
+
stats: boolean
|
|
9
|
+
edgeCache: 'off'
|
|
10
|
+
cors?: unknown
|
|
11
|
+
actions?: { prefix: string; maxBodyBytes: number; maxResponseBytes: number; crossSite: 'reject' | 'allow' }
|
|
12
|
+
/** The cache adapter (optional under abi 1, cache adapters R13). */
|
|
13
|
+
cache?: { adapter: string; getTimeoutMs: number; maxBodyBytes: number; namespace: string; buildId: string; epochIntervalMs: number }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The cache adapter wiring of the entry: absent → the entry is byte-identical to the no-cache one (D3). */
|
|
17
|
+
export interface CfEntryCache {
|
|
18
|
+
/** The adapter module (an absolute path, or a built-in package spec). */
|
|
19
|
+
module: string
|
|
20
|
+
options: Record<string, unknown>
|
|
21
|
+
/** A composed built-in broadcast's package spec. */
|
|
22
|
+
broadcast?: string
|
|
23
|
+
broadcastOptions?: Record<string, unknown>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface CfEntryInput {
|
|
27
|
+
/** absolute paths; each goes through JSON.stringify */
|
|
28
|
+
entry: string
|
|
29
|
+
jobsEntry: string
|
|
30
|
+
actionsEntry: string | null
|
|
31
|
+
/** L16: no pages: the routes module and the jobs module are not imported; the handlers serve an empty route table. */
|
|
32
|
+
actionsOnly?: boolean
|
|
33
|
+
prefix: string
|
|
34
|
+
manifestPath: string
|
|
35
|
+
templatesPath: string
|
|
36
|
+
config: CfConfigJson
|
|
37
|
+
cache?: CfEntryCache | null
|
|
38
|
+
/** A Durable Object is configured: the entry re-exports `BrustCacheDO` (R1; `worker/main.js` re-exports it from this one module). */
|
|
39
|
+
doClass?: boolean
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function cfEntrySource(i: CfEntryInput): string {
|
|
43
|
+
const q = JSON.stringify
|
|
44
|
+
const actions = i.actionsEntry !== null
|
|
45
|
+
const c = i.cache ?? null
|
|
46
|
+
const pages = i.actionsOnly !== true
|
|
47
|
+
return `${[
|
|
48
|
+
'// generated by `brust build --target cloudflare` — do not edit (M4-CF spec §4)',
|
|
49
|
+
...(pages ? [`import * as R from ${q(i.entry)}`, `import J, * as JM from ${q(i.jobsEntry)}`] : []),
|
|
50
|
+
...(actions ? [`import A from ${q(i.actionsEntry)}`] : []),
|
|
51
|
+
`import M from ${q(i.manifestPath)}`,
|
|
52
|
+
`import T from ${q(i.templatesPath)}`,
|
|
53
|
+
`import { fetchInScope, flattenRoutes, jsonCall, makeHandlers, noActions } from "@brust/core/server-handlers"`,
|
|
54
|
+
...(actions ? ['import { mountActions } from "@brust/core/server-handlers/actions"'] : []),
|
|
55
|
+
pages
|
|
56
|
+
? 'const h = makeHandlers({ leaves: flattenRoutes(R.routes ?? R.default).leaves, jobs: J, manifest: M, stores: JM.stores ?? {} })'
|
|
57
|
+
: 'const h = makeHandlers({ leaves: [], jobs: {}, manifest: M, stores: {} })',
|
|
58
|
+
...(actions ? [`const app = mountActions(A, ${q(i.prefix)}).compile() // startup-phase compile (R9)`] : []),
|
|
59
|
+
'export const loader = jsonCall(h.loader)',
|
|
60
|
+
'export const jobs = jsonCall(h.jobs)',
|
|
61
|
+
actions ? 'export const action = (req) => fetchInScope(app, req)' : 'export const action = noActions',
|
|
62
|
+
'export const MANIFEST = JSON.stringify(M)',
|
|
63
|
+
'export const TEMPLATES = T',
|
|
64
|
+
...(c
|
|
65
|
+
? [
|
|
66
|
+
`import CACHE from ${q(c.module)}`,
|
|
67
|
+
...(c.broadcast ? [`import CACHE_BROADCAST from ${q(c.broadcast)}`] : []),
|
|
68
|
+
'import { waitUntil } from "cloudflare:workers"',
|
|
69
|
+
'import { install as installRuntime } from "@brust/core/server-handlers"',
|
|
70
|
+
`export const install = (rt) => installRuntime(rt, { adapter: CACHE, options: ${JSON.stringify(c.options)}, ${c.broadcast ? `broadcast: CACHE_BROADCAST, broadcastOptions: ${JSON.stringify(c.broadcastOptions ?? {})}, ` : ''}config: ${JSON.stringify(i.config.cache)}, waitUntil })`,
|
|
71
|
+
]
|
|
72
|
+
: ['export { install } from "@brust/core/server-handlers"']),
|
|
73
|
+
...(i.doClass ? ['export { BrustCacheDO } from "@brust/cache-cloudflare/do"'] : []),
|
|
74
|
+
`export const CONFIG = ${q(JSON.stringify(i.config))}`,
|
|
75
|
+
].join('\n')}\n`
|
|
76
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// `wrangler.jsonc` for `dist/cf/` (M4-CF R5, R6, R7): the user's app-root config merged with the keys brust owns.
|
|
2
|
+
// Pure apart from `readUserWrangler`. Never adds an `account_id`; copies one only if the user wrote it.
|
|
3
|
+
import { existsSync } from 'node:fs'
|
|
4
|
+
import { isAbsolute, join, relative } from 'node:path'
|
|
5
|
+
import { BuildError } from './errors'
|
|
6
|
+
|
|
7
|
+
type Json = Record<string, unknown>
|
|
8
|
+
export interface UserWrangler {
|
|
9
|
+
file: string | null
|
|
10
|
+
config: Json
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface MergeOpts {
|
|
14
|
+
appRoot: string
|
|
15
|
+
cfDir: string
|
|
16
|
+
/** `[cloudflare] name` */
|
|
17
|
+
name?: string
|
|
18
|
+
pkgName?: string
|
|
19
|
+
compatibilityDate: string
|
|
20
|
+
/** worker-build entry, relative to `worker/` (core-cf `brust.main`) */
|
|
21
|
+
main: string
|
|
22
|
+
version: string
|
|
23
|
+
/** L16: an actions-only Worker has no Static Assets (and no ASSETS binding). */
|
|
24
|
+
noAssets?: boolean
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const OWNED = ['main', 'assets', 'compatibility_date', 'no_bundle', 'find_additional_modules', 'base_dir', 'rules', 'build', 'site']
|
|
28
|
+
const CONFIG_FILES = ['wrangler.jsonc', 'wrangler.json', 'wrangler.toml']
|
|
29
|
+
|
|
30
|
+
export async function readUserWrangler(appRoot: string): Promise<UserWrangler> {
|
|
31
|
+
const found = CONFIG_FILES.filter((f) => existsSync(join(appRoot, f)))
|
|
32
|
+
if (found.length > 1) throw new BuildError('cf-config', `more than one wrangler config in ${appRoot}: ${found.join(', ')} — keep one`)
|
|
33
|
+
const f = found[0]
|
|
34
|
+
if (!f) return { file: null, config: {} }
|
|
35
|
+
const text = await Bun.file(join(appRoot, f)).text()
|
|
36
|
+
try {
|
|
37
|
+
const parsed = f.endsWith('.toml') ? Bun.TOML.parse(text) : Bun.JSONC.parse(text)
|
|
38
|
+
return { file: f, config: (parsed ?? {}) as Json }
|
|
39
|
+
} catch (e) {
|
|
40
|
+
throw new BuildError('cf-config', `failed to parse ${join(appRoot, f)}: ${(e as Error).message}`)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const isObj = (v: unknown): v is Json => v !== null && typeof v === 'object' && !Array.isArray(v)
|
|
45
|
+
|
|
46
|
+
/** package.json `name` -> a Worker name: scope dropped, lowercased, non-[a-z0-9-] -> `-`, <= 63. */
|
|
47
|
+
export function workerNameOf(pkgName: string): string {
|
|
48
|
+
return pkgName.replace(/^@[^/]*\//, '').toLowerCase().replace(/[^a-z0-9-]/g, '-').slice(0, 63)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function rebase(o: MergeOpts, p: string): string {
|
|
52
|
+
return isAbsolute(p) ? p : relative(o.cfDir, join(o.appRoot, p))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function assertNoAssetsBinding(v: unknown, path: string): void {
|
|
56
|
+
if (Array.isArray(v)) return v.forEach((x, i) => assertNoAssetsBinding(x, `${path}[${i}]`))
|
|
57
|
+
if (!isObj(v)) return
|
|
58
|
+
for (const [k, x] of Object.entries(v)) {
|
|
59
|
+
if (k === 'assets' && path === '') continue
|
|
60
|
+
if ((k === 'binding' || (k === 'name' && /durable_objects/.test(path))) && x === 'ASSETS') throw new BuildError('cf-config', `wrangler config: a binding named ASSETS (${path || 'root'}) clashes with brust's static-assets binding`)
|
|
61
|
+
if (k === 'vars' && isObj(x) && 'ASSETS' in x) throw new BuildError('cf-config', 'wrangler config: vars.ASSETS clashes with brust\'s static-assets binding')
|
|
62
|
+
assertNoAssetsBinding(x, `${path}.${k}`)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function rebasePaths(o: MergeOpts, block: Json): void {
|
|
67
|
+
if (Array.isArray(block.d1_databases))
|
|
68
|
+
for (const d of block.d1_databases)
|
|
69
|
+
if (isObj(d)) d.migrations_dir = rebase(o, typeof d.migrations_dir === 'string' ? d.migrations_dir : 'migrations')
|
|
70
|
+
if (typeof block.tsconfig === 'string') block.tsconfig = rebase(o, block.tsconfig)
|
|
71
|
+
if (isObj(block.alias))
|
|
72
|
+
for (const [k, v] of Object.entries(block.alias)) if (typeof v === 'string' && v.startsWith('.')) block.alias[k] = rebase(o, v)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function checkFlags(block: Json, where: string): void {
|
|
76
|
+
if (Array.isArray(block.compatibility_flags) && block.compatibility_flags.some((f) => typeof f === 'string' && /^no_nodejs_compat/.test(f)))
|
|
77
|
+
throw new BuildError('cf-config', `wrangler config${where}: compatibility_flags "no_nodejs_compat*" is not allowed (AsyncLocalStorage needs nodejs_compat)`)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function mergeWrangler(user: UserWrangler, o: MergeOpts): { config: Json; warnings: string[] } {
|
|
81
|
+
const warnings: string[] = []
|
|
82
|
+
const src = structuredClone(user.config)
|
|
83
|
+
assertNoAssetsBinding(src, '')
|
|
84
|
+
checkFlags(src, '')
|
|
85
|
+
const owned = (k: string, where: string) => warnings.push(`[brust] wrangler: ${where}${k} is owned by brust build --target cloudflare; ignored`)
|
|
86
|
+
const rest: Json = {}
|
|
87
|
+
for (const [k, v] of Object.entries(src)) {
|
|
88
|
+
if (OWNED.includes(k)) owned(k, '')
|
|
89
|
+
else rest[k] = v
|
|
90
|
+
}
|
|
91
|
+
const userName = typeof rest.name === 'string' ? rest.name : undefined
|
|
92
|
+
if (o.name !== undefined && userName !== undefined && userName !== o.name)
|
|
93
|
+
warnings.push(`[brust] wrangler: name "${userName}" conflicts with [cloudflare] name "${o.name}"; brust.toml wins`)
|
|
94
|
+
const name = o.name ?? userName ?? (o.pkgName ? workerNameOf(o.pkgName) : undefined)
|
|
95
|
+
rebasePaths(o, rest)
|
|
96
|
+
if (isObj(rest.env))
|
|
97
|
+
for (const [en, eb] of Object.entries(rest.env)) {
|
|
98
|
+
if (!isObj(eb)) continue
|
|
99
|
+
checkFlags(eb, ` env.${en}`)
|
|
100
|
+
for (const k of OWNED)
|
|
101
|
+
if (k in eb) {
|
|
102
|
+
owned(k, `env.${en}.`)
|
|
103
|
+
delete eb[k]
|
|
104
|
+
}
|
|
105
|
+
rebasePaths(o, eb)
|
|
106
|
+
}
|
|
107
|
+
const config: Json = {
|
|
108
|
+
...(name !== undefined ? { name } : {}),
|
|
109
|
+
main: `worker/${o.main}`,
|
|
110
|
+
compatibility_date: o.compatibilityDate,
|
|
111
|
+
...(o.noAssets ? {} : { assets: { directory: 'assets', binding: 'ASSETS', run_worker_first: false, html_handling: 'none', not_found_handling: 'none' } }),
|
|
112
|
+
...Object.fromEntries(Object.entries(rest).filter(([k]) => k !== 'name')),
|
|
113
|
+
}
|
|
114
|
+
return { config, warnings }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface CacheNeed {
|
|
118
|
+
kv?: string
|
|
119
|
+
do?: string
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const KV_SNIPPET = (b: string) => `"kv_namespaces": [{ "binding": "${b}", "id": "<wrangler kv namespace create brust-cache>" }]`
|
|
123
|
+
const DO_SNIPPET = (b: string) =>
|
|
124
|
+
`"durable_objects": { "bindings": [{ "name": "${b}", "class_name": "BrustCacheDO" }] },\n "migrations": [{ "tag": "brust-cache-v1", "new_sqlite_classes": ["BrustCacheDO"] }]`
|
|
125
|
+
|
|
126
|
+
/** Does `block` declare BrustCacheDO as a SQLite class (migrations or `exports`)? `'kv'` = declared only as a KV-backed `new_classes`. */
|
|
127
|
+
function declaresDoClass(block: Json): boolean | 'kv' {
|
|
128
|
+
if (Array.isArray(block.migrations)) {
|
|
129
|
+
let kvBacked = false
|
|
130
|
+
for (const m of block.migrations) {
|
|
131
|
+
if (!isObj(m)) continue
|
|
132
|
+
if (Array.isArray(m.new_sqlite_classes) && m.new_sqlite_classes.includes('BrustCacheDO')) return true
|
|
133
|
+
if (Array.isArray(m.new_classes) && m.new_classes.includes('BrustCacheDO')) kvBacked = true
|
|
134
|
+
}
|
|
135
|
+
if (kvBacked) return 'kv'
|
|
136
|
+
}
|
|
137
|
+
const e = isObj(block.exports) ? block.exports.BrustCacheDO : undefined
|
|
138
|
+
return isObj(e) && e.type === 'durable-object' && e.storage === 'sqlite'
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* R10: the user's wrangler config (top level and every `env.<name>`) must declare the bindings the configured Cloudflare cache needs. Nothing is
|
|
143
|
+
* added for them. A class declaration (`migrations` / `exports`) is also accepted from the top level for an env block. Pure; `config` is not mutated.
|
|
144
|
+
*/
|
|
145
|
+
export function verifyCacheBindings(config: Json, need: CacheNeed): { ok: true } | { ok: false; message: string } {
|
|
146
|
+
const problems: string[] = []
|
|
147
|
+
const blocks: [string, Json][] = [['the top level', config]]
|
|
148
|
+
if (isObj(config.env)) for (const [n, b] of Object.entries(config.env)) if (isObj(b)) blocks.push([`env.${n}`, b])
|
|
149
|
+
for (const [where, b] of blocks) {
|
|
150
|
+
if (need.kv !== undefined) {
|
|
151
|
+
const ok = Array.isArray(b.kv_namespaces) && b.kv_namespaces.some((k) => isObj(k) && k.binding === need.kv)
|
|
152
|
+
if (!ok) problems.push(`wrangler config ${where}: no kv_namespaces binding "${need.kv}". Add:\n ${KV_SNIPPET(need.kv)}`)
|
|
153
|
+
}
|
|
154
|
+
if (need.do !== undefined) {
|
|
155
|
+
const bound = isObj(b.durable_objects) && Array.isArray(b.durable_objects.bindings) && b.durable_objects.bindings.some((d) => isObj(d) && d.name === need.do && d.class_name === 'BrustCacheDO' && d.script_name === undefined)
|
|
156
|
+
if (!bound) problems.push(`wrangler config ${where}: no durable_objects binding { "name": "${need.do}", "class_name": "BrustCacheDO" } (without script_name). Add:\n ${DO_SNIPPET(need.do)}`)
|
|
157
|
+
else {
|
|
158
|
+
const decl = declaresDoClass(b) || (where === 'the top level' ? false : declaresDoClass(config))
|
|
159
|
+
if (decl === 'kv') problems.push(`wrangler config ${where}: BrustCacheDO is declared with new_classes (KV-backed, no SQL API); use new_sqlite_classes:\n "migrations": [{ "tag": "brust-cache-v1", "new_sqlite_classes": ["BrustCacheDO"] }]`)
|
|
160
|
+
else if (decl !== true) problems.push(`wrangler config ${where}: BrustCacheDO is not declared. Add:\n "migrations": [{ "tag": "brust-cache-v1", "new_sqlite_classes": ["BrustCacheDO"] }]`)
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return problems.length === 0 ? { ok: true } : { ok: false, message: problems.join('\n') }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function renderWrangler(config: Json): string {
|
|
168
|
+
return `// generated by \`brust build --target cloudflare\` — edit the app-root wrangler config instead\n${JSON.stringify(config, null, 2)}\n`
|
|
169
|
+
}
|