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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +66 -6
  2. package/native/index.d.ts +63 -4
  3. package/native/index.js +57 -54
  4. package/package.json +29 -15
  5. package/src/actions/client.ts +53 -7
  6. package/src/actions/elysia.ts +1 -1
  7. package/src/actions/load.ts +24 -1
  8. package/src/actions/prefix.ts +24 -2
  9. package/src/actions-bridge.ts +2 -2
  10. package/src/assets.d.ts +55 -0
  11. package/src/browser.ts +6 -1
  12. package/src/build/actions-only.ts +86 -0
  13. package/src/build/build-eval.ts +332 -0
  14. package/src/build/build-icons.ts +69 -0
  15. package/src/build/bundle.ts +466 -54
  16. package/src/build/cf-assets.ts +4 -2
  17. package/src/build/cf-entry.ts +7 -3
  18. package/src/build/cf-wrangler.ts +3 -1
  19. package/src/build/cloudflare.ts +21 -11
  20. package/src/build/compile.ts +263 -6
  21. package/src/build/config.ts +85 -11
  22. package/src/build/css.ts +353 -0
  23. package/src/build/esm-resolve.ts +170 -0
  24. package/src/build/features.ts +119 -0
  25. package/src/build/index.ts +210 -27
  26. package/src/build/manifest.ts +88 -7
  27. package/src/build/pkg-src.ts +514 -0
  28. package/src/build/recognised.ts +19 -0
  29. package/src/build/scan.ts +71 -20
  30. package/src/build/server-only.ts +62 -0
  31. package/src/build/ssg-render.ts +65 -0
  32. package/src/build/ssg.ts +146 -0
  33. package/src/build/static-worker.ts +91 -0
  34. package/src/build/static.ts +259 -0
  35. package/src/build/tailwind.ts +117 -0
  36. package/src/build/tsconfig-paths.ts +67 -0
  37. package/src/build-time.ts +10 -0
  38. package/src/cache-adapter/types.ts +1 -1
  39. package/src/cf.ts +4 -1
  40. package/src/cli.ts +40 -4
  41. package/src/config.ts +111 -25
  42. package/src/css-modules.d.ts +8 -0
  43. package/src/dev/bun.ts +371 -0
  44. package/src/dev/client.ts +92 -0
  45. package/src/dev/devtools-data.ts +23 -0
  46. package/src/dev/devtools.ts +358 -0
  47. package/src/dev/diag.ts +30 -0
  48. package/src/dev/proxy.ts +236 -0
  49. package/src/dev/tui.ts +144 -0
  50. package/src/dev/watch.ts +130 -0
  51. package/src/dev.ts +15 -28
  52. package/src/handlers.ts +115 -9
  53. package/src/index.ts +6 -1
  54. package/src/native.ts +18 -0
  55. package/src/navigate-browser.ts +19 -0
  56. package/src/navigate.ts +19 -0
  57. package/src/navigation.ts +91 -0
  58. package/src/routes.ts +31 -1
  59. package/src/run.ts +53 -11
  60. package/src/shadcn/init.ts +513 -0
  61. package/src/shadcn/jsonc.ts +129 -0
  62. package/src/shadcn/registry.ts +149 -0
  63. package/src/store/builtin.ts +4 -0
  64. package/src/worker.ts +98 -22
@@ -0,0 +1,117 @@
1
+ // Tailwind CSS v4 for `brust build` (L32, F135, R33.1-R33.4): brust's own Bun plugin over the app's Tailwind.
2
+ // It does what the official integrations (@tailwindcss/vite, @tailwindcss/postcss) do: `compile()` from
3
+ // `@tailwindcss/node`, a `Scanner` from `@tailwindcss/oxide` over the compiler's sources (the app root unless the
4
+ // stylesheet says `source(...)`, plus every `@source` / `@source not`), then `build(candidates)`. Both packages are
5
+ // resolved from the APP, never bundled into or depended on by brust, so the compiler is the app's pinned
6
+ // `tailwindcss` (`@tailwindcss/node` depends on its own exact `tailwindcss`; the app installs all three at one
7
+ // version, and a mismatch is a loud warning with the command that aligns them).
8
+ import { readFileSync } from 'node:fs'
9
+ import { dirname, relative } from 'node:path'
10
+ import type { BunPlugin } from 'bun'
11
+ import { BuildError } from './errors'
12
+
13
+ /** The install set (R33.2): one exact version for all three. */
14
+ export const TAILWIND_PACKAGES = ['tailwindcss', '@tailwindcss/node', '@tailwindcss/oxide'] as const
15
+
16
+ interface SourceEntry {
17
+ base: string
18
+ pattern: string
19
+ negated: boolean
20
+ }
21
+ interface Compiler {
22
+ root: 'none' | { base: string; pattern: string } | null
23
+ sources: SourceEntry[]
24
+ build(candidates: string[]): string
25
+ }
26
+ interface TailwindNode {
27
+ compile(css: string, o: { base: string; from?: string; shouldRewriteUrls?: boolean; onDependency: (path: string) => void }): Promise<Compiler>
28
+ }
29
+ interface TailwindOxide {
30
+ Scanner: new (o: { sources: SourceEntry[] }) => { scan(): string[] }
31
+ }
32
+
33
+ /** The `version` of `pkg` as resolved from `from` (a directory), or undefined when it does not resolve. */
34
+ function versionFrom(pkg: string, from: string): string | undefined {
35
+ try {
36
+ return (JSON.parse(readFileSync(Bun.resolveSync(`${pkg}/package.json`, from), 'utf8')) as { version?: string }).version
37
+ } catch {
38
+ return undefined
39
+ }
40
+ }
41
+
42
+ /** The versions that decide what compiles (R33.3): the app's `tailwindcss`, the `tailwindcss` that
43
+ * `@tailwindcss/node` imports (the compiler), and the two integration packages. */
44
+ export function tailwindVersions(appRoot: string): { app?: string; compiler?: string; node?: string; oxide?: string } {
45
+ let compiler: string | undefined
46
+ try {
47
+ compiler = versionFrom('tailwindcss', dirname(Bun.resolveSync('@tailwindcss/node/package.json', appRoot)))
48
+ } catch {}
49
+ return { app: versionFrom('tailwindcss', appRoot), compiler, node: versionFrom('@tailwindcss/node', appRoot), oxide: versionFrom('@tailwindcss/oxide', appRoot) }
50
+ }
51
+
52
+ /** The oldest Tailwind this plugin runs (review F1): 4.0's `compile()` has no `sources` (it takes globs), so its build
53
+ * would crash mid-way; 4.1.0 is the first with the API the official integrations use today. */
54
+ export const TAILWIND_FLOOR = '4.1.0'
55
+ /** The version brust is tested with (create-app pins the same); the command that lifts an app off an old Tailwind names it. */
56
+ export const TAILWIND_TESTED = '4.3.3'
57
+
58
+ /** `a < b` for plain `major.minor.patch` versions (a pre-release suffix counts as its base version). */
59
+ export function versionBelow(a: string, b: string): boolean {
60
+ const n = (v: string) => v.split('-')[0]!.split('.').map((x) => Number(x) || 0)
61
+ const [x, y] = [n(a), n(b)]
62
+ for (let i = 0; i < 3; i++) if ((x[i] ?? 0) !== (y[i] ?? 0)) return (x[i] ?? 0) < (y[i] ?? 0)
63
+ return false
64
+ }
65
+
66
+ const addCommand = (v: string) => `bun add -d --exact ${TAILWIND_PACKAGES.map((p) => `${p}@${v}`).join(' ')}`
67
+
68
+ /** The Tailwind plugin of the CSS build, resolved from the app. Missing packages → `tailwind-missing` with the
69
+ * exact install command; versions that differ → one warning through `log` with the command that aligns them.
70
+ * `uses(file)` says whether a stylesheet needs the compiler (it, or a relative `@import` of it, uses Tailwind);
71
+ * other stylesheets stay Bun's. */
72
+ export async function loadTailwind(appRoot: string, usedBy: string, uses: (file: string) => boolean, log?: (s: string) => void): Promise<BunPlugin> {
73
+ const v = tailwindVersions(appRoot)
74
+ const missing = TAILWIND_PACKAGES.filter((p) => versionFrom(p, appRoot) === undefined)
75
+ if (missing.length > 0)
76
+ throw new BuildError(
77
+ 'tailwind-missing',
78
+ `${relative(appRoot, usedBy)} uses Tailwind CSS, but ${missing.join(', ')} ${missing.length > 1 ? 'are' : 'is'} not installed in the app: run \`${v.app ? addCommand(v.app) : `bun add -d ${TAILWIND_PACKAGES.map((p) => `${p}@4`).join(' ')}`}\``,
79
+ )
80
+ const old = (
81
+ [
82
+ ['tailwindcss', v.app],
83
+ ['the compiler tailwindcss', v.compiler],
84
+ ['@tailwindcss/node', v.node],
85
+ ['@tailwindcss/oxide', v.oxide],
86
+ ] as const
87
+ ).filter(([, ver]) => ver !== undefined && versionBelow(ver, TAILWIND_FLOOR))
88
+ if (old.length > 0)
89
+ throw new BuildError(
90
+ 'tailwind-version',
91
+ `${relative(appRoot, usedBy)} uses Tailwind CSS, but ${old.map(([p, ver]) => `${p} ${ver}`).join(', ')} ${old.length > 1 ? 'are' : 'is'} older than ${TAILWIND_FLOOR}, the oldest brust builds with: run \`${addCommand(TAILWIND_TESTED)}\``,
92
+ )
93
+ if (new Set([v.app, v.compiler, v.node, v.oxide]).size > 1)
94
+ log?.(
95
+ `[brust] warning tailwind-version the app has tailwindcss ${v.app}, but the compiler is tailwindcss ${v.compiler} (@tailwindcss/node ${v.node}, @tailwindcss/oxide ${v.oxide}): install one version of all three: ${addCommand(v.app!)}`,
96
+ )
97
+ const node = (await import(Bun.resolveSync('@tailwindcss/node', appRoot))) as TailwindNode
98
+ const oxide = (await import(Bun.resolveSync('@tailwindcss/oxide', appRoot))) as TailwindOxide
99
+ return {
100
+ name: 'brust-tailwind',
101
+ setup(b) {
102
+ b.onLoad({ filter: /\.css$/ }, async (a) => {
103
+ if (!uses(a.path)) return undefined // Bun's own loader (CSS Modules included)
104
+ const css = readFileSync(a.path, 'utf8')
105
+ const compiler = await node.compile(css, { base: dirname(a.path), from: a.path, shouldRewriteUrls: true, onDependency: () => {} })
106
+ const root: SourceEntry[] =
107
+ compiler.root === 'none' ? [] : compiler.root === null ? [{ base: appRoot, pattern: '**/*', negated: false }] : [{ ...compiler.root, negated: false }]
108
+ const scanner = new oxide.Scanner({ sources: [...root, ...compiler.sources] })
109
+ let out = compiler.build(scanner.scan())
110
+ // `@reference "./app.css"` pulls in Tailwind's license banner too; only the file that imports Tailwind ships it
111
+ // (one banner per page, as before)
112
+ if (!/@import\s+(?:url\(\s*)?["']tailwindcss["']/.test(css)) out = out.replace(/\/\*! tailwindcss v[^*]*\*\/\s*/g, '')
113
+ return { contents: out, loader: 'css' }
114
+ })
115
+ },
116
+ }
117
+ }
@@ -0,0 +1,67 @@
1
+ // `tsconfig` `paths` aliases (shadcn S1, R34.76): `import … from "@/components/ui/button"` is a file of the app. The compiler
2
+ // resolves the specifiers that match an alias to the relative import of the file they name, from root-relative templates
3
+ // this module reads out of the app's own `tsconfig.json` / `jsconfig.json`. v1 reads that one file: no `extends`, no
4
+ // `references` (a named fallback below); exact and single-`*` patterns only. The bundler resolves the same specifiers
5
+ // natively; `checkAliasAgreement` fails the build if the two ever disagree.
6
+ import { existsSync, readFileSync, realpathSync } from 'node:fs'
7
+ import { dirname, isAbsolute, join, normalize, relative, sep } from 'node:path'
8
+ import { BuildError } from './errors'
9
+
10
+ export interface PathAlias {
11
+ pattern: string
12
+ targets: string[]
13
+ }
14
+
15
+ const FILES = ['tsconfig.json', 'jsconfig.json']
16
+
17
+ /** The aliases of the app's tsconfig, as root-relative `/`-separated templates; `notes` says what was left out and why. */
18
+ export function readPathAliases(appRoot: string): { aliases: PathAlias[]; notes: string[] } {
19
+ const file = FILES.map((f) => join(appRoot, f)).find((f) => existsSync(f))
20
+ if (!file) return { aliases: [], notes: [] }
21
+ let json: { extends?: unknown; references?: unknown; compilerOptions?: { baseUrl?: unknown; paths?: unknown } }
22
+ try {
23
+ json = Bun.JSONC.parse(readFileSync(file, 'utf8')) as typeof json
24
+ } catch {
25
+ return { aliases: [], notes: [`${relative(appRoot, file)} could not be parsed; its \`paths\` aliases are not followed`] }
26
+ }
27
+ const notes: string[] = []
28
+ const paths = json?.compilerOptions?.paths
29
+ if (json?.extends !== undefined || json?.references !== undefined)
30
+ notes.push(
31
+ `${relative(appRoot, file)} uses \`${json.extends !== undefined ? 'extends' : 'references'}\`, which brust does not follow: \`paths\` aliases written in another tsconfig stay package imports (put \`paths\` in ${relative(appRoot, file)})`,
32
+ )
33
+ if (!paths || typeof paths !== 'object') return { aliases: [], notes }
34
+ const base = typeof json.compilerOptions?.baseUrl === 'string' ? json.compilerOptions.baseUrl : '.'
35
+ const aliases: PathAlias[] = []
36
+ for (const [pattern, raw] of Object.entries(paths as Record<string, unknown>)) {
37
+ const stars = pattern.split('*').length - 1
38
+ if (stars > 1 || !Array.isArray(raw)) {
39
+ notes.push(`the \`paths\` pattern "${pattern}" is not an exact or single-\`*\` pattern; it is not followed`)
40
+ continue
41
+ }
42
+ const targets: string[] = []
43
+ for (const t of raw) {
44
+ if (typeof t !== 'string' || (t.split('*').length - 1) > 1 || isAbsolute(t)) continue
45
+ const rel = normalize(join(base, t)).split(sep).join('/')
46
+ // Outside the app sources, or a package: not a file the compiler reads.
47
+ if (rel.startsWith('..') || rel.includes('node_modules/')) continue
48
+ targets.push(rel)
49
+ }
50
+ if (targets.length > 0) aliases.push({ pattern, targets })
51
+ }
52
+ return { aliases, notes }
53
+ }
54
+
55
+ /** The compiler's answer for every alias import must be the file the bundler resolves the same specifier to. */
56
+ export function checkAliasAgreement(appRoot: string, imports: { from: string; spec: string; target: string }[]): void {
57
+ for (const { from, spec, target } of imports) {
58
+ let got: string
59
+ try {
60
+ got = realpathSync(Bun.resolveSync(spec, dirname(join(appRoot, from))))
61
+ } catch {
62
+ throw new BuildError('alias-mismatch', `${from}: the compiler resolves "${spec}" to ${target}, but the bundler cannot resolve it (check tsconfig \`paths\`)`)
63
+ }
64
+ if (got !== realpathSync(join(appRoot, target)))
65
+ throw new BuildError('alias-mismatch', `${from}: "${spec}" resolves to ${target} for the compiler but to ${relative(appRoot, got)} for the bundler (tsconfig \`paths\` is read differently); fix the alias or use a relative import`)
66
+ }
67
+ }
@@ -0,0 +1,10 @@
1
+ // `buildTime(() => …)`: the explicit opt-in to evaluate a pure call once, at `brust build` (L2/H1).
2
+ // The compiler recognises the call only when it is imported from `@brust/core`; the closure may be
3
+ // `async` and may use package imports. At runtime (a per-request job, the server) it just runs the
4
+ // closure, so behaviour is identical wherever it is not folded away at build. It is a build error
5
+ // in code that reaches the browser.
6
+
7
+ /** Runs `fn`. At build the compiler evaluates it once and bakes the result into the template. */
8
+ export function buildTime<T>(fn: () => T): T {
9
+ return fn()
10
+ }
@@ -1,4 +1,4 @@
1
- // The cache adapter contract (docs/design/2026-10-12-cache-adapter-design.md §3.1): a shared second
1
+ // The cache adapter contract (cache adapter design §3.1): a shared second
2
2
  // tier behind brust's in-process cache. An adapter has two optional capabilities — a `store` of
3
3
  // rendered pages and a `broadcast` of invalidations (push `subscribe` or pull `since`) — so a store
4
4
  // without a channel (KV) can be composed with another adapter's broadcast (DO).
package/src/cf.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  // `@brust/core` under the `workerd` export condition (M4-CF, ruling c30e2633): the app-facing API a Worker can run.
2
2
  // Same names as `index.ts` minus what needs a host process: no `run`, no `loadConfig`. `cache.invalidate` goes through
3
3
  // the CF bundle's napi stub to the runtime the wasm glue installed at start (`server-handlers` `install`).
4
+ export { buildTime } from './build-time'
5
+ export { navigate } from './navigate'
6
+ export type { NavErrorDetail, NavLoadDetail, NavigateOptions, NavSuccessDetail, NavTrigger } from './navigate'
4
7
  export { cache } from './cache'
5
8
  export type { ComponentCacheOptions, InvalidateArgs, InvalidateResult } from './cache'
6
9
  export {
@@ -15,4 +18,4 @@ export {
15
18
  Outlet,
16
19
  redirect,
17
20
  } from './routes'
18
- export type { ComponentType, FlatRoute, HttpErrorOpts, HttpErrorTrigger, LoaderCtx, LoaderReq, Route, RouteCacheConfig, Verdict } from './routes'
21
+ export type { ComponentType, FlatRoute, HttpErrorOpts, HttpErrorTrigger, LoaderCtx, LoaderReq, Route, RouteCacheConfig, RouteSsgConfig, Verdict } from './routes'
package/src/cli.ts CHANGED
@@ -3,13 +3,16 @@
3
3
  // Exit codes: 0 ok, 1 build/boot error (`error <rule> <message>` / `[brust] …`), 2 usage.
4
4
  import { parseArgs } from 'node:util'
5
5
  import { BuildError, runBuild } from './build'
6
+ import { runBunDev } from './dev/bun'
6
7
  import { runDev } from './dev'
7
8
  import { run } from './run'
8
9
 
9
10
  const USAGE = `usage:
10
- brust build [entry=routes.tsx] [--out-dir dist] [--force] [--target bun|cloudflare] [--wasm-from-source]
11
+ brust build [entry=routes.tsx] [--out-dir dist] [--force] [--target bun|cloudflare|static] [--actions-only] [--strict] [--wasm-from-source] [--dev]
12
+ brust dev [entry=routes.tsx] [--port N] [--workers N] (Bun target: proxy + hot reload + inline TUI)
11
13
  brust dev [entry=routes.tsx] --target cloudflare [--out-dir dist] [--port N] [--wasm-from-source] [-- <wrangler args>]
12
14
  brust start [--port N] [--workers N] [--dist-dir dist] [--entry routes.tsx]
15
+ brust shadcn init [component ...] [--force] [--css <file>] [--base-color <name>] [--shadcn-version <x.y.z>]
13
16
 
14
17
  Config precedence for start: env (BRUST_ADDR, BRUST_PORT, BRUST_WORKERS, BRUST_RENDER_SLOTS,
15
18
  BRUST_DRAIN_TIMEOUT_MS, BRUST_BOOT_TIMEOUT_MS, BRUST_DIST_DIR, BRUST_APP_ENTRY) > flags
@@ -51,8 +54,14 @@ export async function main(argv: string[]): Promise<void> {
51
54
  port: { type: 'string' },
52
55
  workers: { type: 'string' },
53
56
  force: { type: 'boolean' },
57
+ css: { type: 'string' },
58
+ 'base-color': { type: 'string' },
59
+ 'shadcn-version': { type: 'string' },
60
+ strict: { type: 'boolean' },
54
61
  target: { type: 'string' },
55
62
  'wasm-from-source': { type: 'boolean' },
63
+ 'actions-only': { type: 'boolean' },
64
+ dev: { type: 'boolean' },
56
65
  help: { type: 'boolean', short: 'h' },
57
66
  },
58
67
  })
@@ -66,7 +75,10 @@ export async function main(argv: string[]): Promise<void> {
66
75
  }
67
76
  const str = (k: string) => values[k] as string | undefined
68
77
  const target = str('target')
69
- if (target !== undefined && target !== 'bun' && target !== 'cloudflare') usage(`--target must be bun or cloudflare (got ${JSON.stringify(target)})`)
78
+ if (target !== undefined && target !== 'bun' && target !== 'cloudflare' && target !== 'static')
79
+ usage(`--target must be bun, cloudflare or static (got ${JSON.stringify(target)})`)
80
+ if (values['actions-only'] === true && cmd !== 'build') usage('--actions-only only applies to brust build')
81
+ if (values.strict === true && (cmd !== 'build' || target !== 'static')) usage('--strict only applies to brust build --target static')
70
82
 
71
83
  if (cmd === 'build') {
72
84
  if (positionals.length > 1) usage(`build takes one entry (got ${positionals.join(' ')})`)
@@ -77,8 +89,11 @@ export async function main(argv: string[]): Promise<void> {
77
89
  entry: positionals[0] ?? 'routes.tsx',
78
90
  outDir: str('out-dir') ?? 'dist',
79
91
  force: values.force === true,
80
- target: target as 'bun' | 'cloudflare' | undefined,
92
+ target: target as 'bun' | 'cloudflare' | 'static' | undefined,
93
+ strict: values.strict === true,
81
94
  wasmFromSource: values['wasm-from-source'] === true,
95
+ actionsOnly: values['actions-only'] === true,
96
+ dev: values.dev === true,
82
97
  log: (s) => console.log(s),
83
98
  })
84
99
  } catch (e) {
@@ -92,8 +107,12 @@ export async function main(argv: string[]): Promise<void> {
92
107
  }
93
108
  if (cmd === 'dev') {
94
109
  if (positionals.length > 1) usage(`dev takes one entry (got ${positionals.join(' ')})`)
95
- if (target !== 'cloudflare') usage('brust dev: only --target cloudflare in this release')
96
110
  const port = intFlag('port', str('port'), 0)
111
+ if (target !== 'cloudflare') {
112
+ const workers = intFlag('workers', str('workers'), 1)
113
+ if (passthrough.length > 0) usage('brust dev: arguments after -- are for --target cloudflare (wrangler)')
114
+ process.exit(await runBunDev({ appRoot: process.cwd(), entry: positionals[0] ?? 'routes.tsx', ...(port !== undefined && { port }), ...(workers !== undefined && { workers }) }))
115
+ }
97
116
  try {
98
117
  process.exit(
99
118
  await runDev({
@@ -112,6 +131,23 @@ export async function main(argv: string[]): Promise<void> {
112
131
  process.exit(1)
113
132
  }
114
133
  }
134
+ if (cmd === 'shadcn') {
135
+ const [sub, ...components] = positionals
136
+ if (sub !== 'init') usage(`shadcn takes a subcommand: init (got ${sub === undefined ? 'none' : JSON.stringify(sub)})`)
137
+ // F252: loaded here, so no other command pays for it (it pulls in tsconfig-paths)
138
+ const { shadcnInit, spawnRunner } = await import('./shadcn/init')
139
+ const code = await shadcnInit({
140
+ root: process.cwd(),
141
+ components,
142
+ force: values.force === true,
143
+ ...(str('css') !== undefined && { css: str('css') }),
144
+ ...(str('base-color') !== undefined && { baseColor: str('base-color') }),
145
+ ...(str('shadcn-version') !== undefined && { shadcnVersion: str('shadcn-version') }),
146
+ run: spawnRunner,
147
+ out: (s) => console.log(s),
148
+ })
149
+ process.exit(code)
150
+ }
115
151
  if (cmd === 'start') {
116
152
  if (positionals.length > 0) usage(`start takes no positional arguments (got ${positionals.join(' ')})`)
117
153
  const port = intFlag('port', str('port'), 0)
package/src/config.ts CHANGED
@@ -1,14 +1,18 @@
1
1
  // Runtime configuration (spec §8). Precedence, high → low:
2
2
  // env (BRUST_ADDR / BRUST_PORT / BRUST_WORKERS / BRUST_RENDER_SLOTS / BRUST_DRAIN_TIMEOUT_MS /
3
- // BRUST_BOOT_TIMEOUT_MS / BRUST_CALL_TIMEOUT_MS)
4
- // > CLI flags (`brust start --port/--workers`)
5
- // > brust.toml ([server] address / port, [workers] count)
6
- // > defaults (localhost, 1337, availableParallelism(), 1, 10000, 30000, 30000).
3
+ // BRUST_BOOT_TIMEOUT_MS / BRUST_CALL_TIMEOUT_MS / BRUST_SLOT_BYTES / BRUST_MAX_LOADER_RESPONSE_BYTES)
4
+ // > CLI flags (`brust start --port/--workers`) and `run({ config })`
5
+ // > brust.toml ([server] address / port / max_loader_response_bytes, [workers] count / slot_bytes, [cors], [actions] base_url / credentials)
6
+ // > defaults (localhost, 1337, availableParallelism(), 1, 10000, 30000, 30000, 262144, 8388608).
7
+ // L16 R16.1: `actionsBaseUrl` follows env (BRUST_ACTIONS_BASE_URL) > run({ config }) > toml `[actions] base_url`.
8
+ // `cors` has no env layer; `run({ config: { cors } })` replaces a toml `[cors]` as a whole (never merged), and
9
+ // `cors: null` turns a toml `[cors]` off.
7
10
  // `brust start`'s BRUST_DIST_DIR / BRUST_APP_ENTRY follow the same env > flag rule (run.ts).
8
11
  // A missing brust.toml is fine; a present one with the wrong shape is an error. Messages follow
9
12
  // 0.1.x `runtime/config.ts`.
10
13
  import { availableParallelism } from 'node:os'
11
14
  import { join } from 'node:path'
15
+ import { ACTION_CREDENTIALS, type ActionCredentials, baseUrlError } from './actions/prefix'
12
16
 
13
17
  /** `[cors]` in brust.toml; same shape and validation as the Rust `CorsConfig` (crates/brust-server/src/config.rs). */
14
18
  export interface CorsConfig {
@@ -32,10 +36,22 @@ export interface BrustConfig {
32
36
  callTimeoutMs: number
33
37
  /** tokio threads of the HTTP runtime (renders every page); absent = one per core (server default). */
34
38
  ioThreads?: number
35
- /** From brust.toml `[cors]` only (runtime config: no env / CLI layer); absent = CORS off. */
39
+ /** The SAB response slot of one render slot, bytes (`slot_bytes`, env BRUST_SLOT_BYTES); 64 KiB..16 MiB. */
40
+ slotBytes: number
41
+ /** The biggest loader/jobs reply that crosses by heap when it does not fit the slot (`max_loader_response_bytes`,
42
+ * env BRUST_MAX_LOADER_RESPONSE_BYTES); 256 KiB..64 MiB. */
43
+ maxLoaderResponseBytes: number
44
+ /** `run({ config: { cors } })` over brust.toml `[cors]` (a whole-object replace); absent = CORS off. No env layer. */
36
45
  cors?: CorsConfig
46
+ /** L16 R16.1: the origin the browser `client()` calls (page script `__BRUST_ACTION_BASE__`); absent = same origin. */
47
+ actionsBaseUrl?: string
48
+ /** L16 R16.2: the client's default `RequestInit.credentials` (`__BRUST_ACTION_CREDENTIALS__`); absent = `same-origin`. */
49
+ actionsCredentials?: ActionCredentials
37
50
  }
38
51
 
52
+ /** What `run({ config })` / `loadConfig`'s `cli` accepts: any setting, and `cors: null` to turn a toml `[cors]` off. */
53
+ export type ConfigOverrides = Omit<Partial<BrustConfig>, 'cors'> & { cors?: (Omit<CorsConfig, 'credentials'> & { credentials?: boolean }) | null }
54
+
39
55
  export class BrustConfigError extends Error {
40
56
  override name = 'BrustConfigError'
41
57
  constructor(
@@ -46,11 +62,11 @@ export class BrustConfigError extends Error {
46
62
  }
47
63
  }
48
64
 
49
- const DEFAULTS = { host: 'localhost', port: 1337, drainTimeoutMs: 10_000, bootTimeoutMs: 30_000, callTimeoutMs: 30_000 }
65
+ const DEFAULTS = { host: 'localhost', port: 1337, drainTimeoutMs: 10_000, bootTimeoutMs: 30_000, callTimeoutMs: 30_000, slotBytes: 256 * 1024, maxLoaderResponseBytes: 8_388_608 }
50
66
 
51
67
  /** Ceilings: ms values cross napi as `u32` (a larger number would wrap), more than 1024 Bun
52
- * workers is a misconfiguration, not a deployment, and each render slot is a 256 KiB shared
53
- * response buffer per worker (more than 64 would only exhaust memory). */
68
+ * workers is a misconfiguration, not a deployment, and each render slot is a `slotBytes` (default 256 KiB)
69
+ * shared response buffer per worker (more than 64 would only exhaust memory). */
54
70
  export const MAX_MS = 0xffff_ffff
55
71
  /** `max_age_seconds` crosses napi as `u32`, like the ms values. */
56
72
  const MAX_U32 = 0xffff_ffff
@@ -58,8 +74,14 @@ const MAX_U32 = 0xffff_ffff
58
74
  export const MAX_IO_THREADS = 256
59
75
  export const MAX_WORKERS = 1024
60
76
  export const MAX_RENDER_SLOTS = 64
77
+ /** `slotBytes` (L10): 64 KiB..16 MiB. */
78
+ export const MIN_SLOT_BYTES = 64 * 1024
79
+ export const MAX_SLOT_BYTES = 16 * 1024 * 1024
80
+ /** `maxLoaderResponseBytes` (L10): 256 KiB..64 MiB, like the actions limits. */
81
+ export const MIN_LOADER_RESPONSE_BYTES = 256 * 1024
82
+ export const MAX_LOADER_RESPONSE_BYTES = 64 * 1024 * 1024
61
83
 
62
- export async function loadConfig(cwd: string = process.cwd(), cli: Partial<BrustConfig> = {}): Promise<BrustConfig> {
84
+ export async function loadConfig(cwd: string = process.cwd(), cli: ConfigOverrides = {}): Promise<BrustConfig> {
63
85
  const tomlPath = join(cwd, 'brust.toml')
64
86
  let fromToml: Partial<BrustConfig> = {}
65
87
  const file = Bun.file(tomlPath)
@@ -75,6 +97,8 @@ export async function loadConfig(cwd: string = process.cwd(), cli: Partial<Brust
75
97
  }
76
98
  const env = fromEnv()
77
99
  checkCli(cli)
100
+ // cli (validated, normalised) over toml as a whole object (R10.2); `null` = off.
101
+ const cors = cli.cors === undefined ? fromToml.cors : cli.cors === null ? undefined : checkCors(cli.cors, 'config.cors', null, false)
78
102
  return {
79
103
  host: env.host ?? cli.host ?? fromToml.host ?? DEFAULTS.host,
80
104
  port: env.port ?? cli.port ?? fromToml.port ?? DEFAULTS.port,
@@ -84,11 +108,17 @@ export async function loadConfig(cwd: string = process.cwd(), cli: Partial<Brust
84
108
  drainTimeoutMs: env.drainTimeoutMs ?? cli.drainTimeoutMs ?? DEFAULTS.drainTimeoutMs,
85
109
  bootTimeoutMs: env.bootTimeoutMs ?? cli.bootTimeoutMs ?? DEFAULTS.bootTimeoutMs,
86
110
  callTimeoutMs: env.callTimeoutMs ?? cli.callTimeoutMs ?? DEFAULTS.callTimeoutMs,
111
+ slotBytes: env.slotBytes ?? cli.slotBytes ?? fromToml.slotBytes ?? DEFAULTS.slotBytes,
112
+ maxLoaderResponseBytes: env.maxLoaderResponseBytes ?? cli.maxLoaderResponseBytes ?? fromToml.maxLoaderResponseBytes ?? DEFAULTS.maxLoaderResponseBytes,
87
113
  ioThreads: env.ioThreads ?? cli.ioThreads,
88
- ...(fromToml.cors !== undefined ? { cors: fromToml.cors } : {}),
114
+ ...(cors ? { cors } : {}),
115
+ ...pick('actionsBaseUrl', env.actionsBaseUrl ?? cli.actionsBaseUrl ?? fromToml.actionsBaseUrl),
116
+ ...pick('actionsCredentials', cli.actionsCredentials ?? fromToml.actionsCredentials),
89
117
  }
90
118
  }
91
119
 
120
+ const pick = <K extends string, V>(k: K, v: V | undefined) => (v === undefined ? {} : ({ [k]: v } as { [P in K]: V }))
121
+
92
122
  /** `[cors]` of brust.toml and nothing else (no env, no cli): what the Cloudflare build ships in CONFIG.
93
123
  * Throws `BrustConfigError` on a bad table, exactly like `loadConfig`. */
94
124
  export async function loadCors(cwd: string): Promise<CorsConfig | undefined> {
@@ -106,7 +136,7 @@ export async function loadCors(cwd: string): Promise<CorsConfig | undefined> {
106
136
  }
107
137
 
108
138
  /** CLI / programmatic overrides get the same bounds as env (a CLI `--port 0` = any free port). */
109
- function checkCli(cli: Partial<BrustConfig>): void {
139
+ function checkCli(cli: ConfigOverrides): void {
110
140
  const check = (name: string, v: number | undefined, min: number, max: number) => {
111
141
  if (v !== undefined && (!Number.isInteger(v) || v < min || v > max))
112
142
  throw new BrustConfigError(`${name} must be an integer in ${min}..${max} (got ${JSON.stringify(v)})`, null)
@@ -118,6 +148,14 @@ function checkCli(cli: Partial<BrustConfig>): void {
118
148
  check('bootTimeoutMs', cli.bootTimeoutMs, 1, MAX_MS)
119
149
  check('callTimeoutMs', cli.callTimeoutMs, 1, MAX_MS)
120
150
  check('ioThreads', cli.ioThreads, 1, MAX_IO_THREADS)
151
+ check('slotBytes', cli.slotBytes, MIN_SLOT_BYTES, MAX_SLOT_BYTES)
152
+ check('maxLoaderResponseBytes', cli.maxLoaderResponseBytes, MIN_LOADER_RESPONSE_BYTES, MAX_LOADER_RESPONSE_BYTES)
153
+ if (cli.actionsBaseUrl !== undefined) {
154
+ const why = baseUrlError(cli.actionsBaseUrl)
155
+ if (why) throw new BrustConfigError(`config.actionsBaseUrl ${why}`, null)
156
+ }
157
+ if (cli.actionsCredentials !== undefined && !ACTION_CREDENTIALS.includes(cli.actionsCredentials))
158
+ throw new BrustConfigError(`config.actionsCredentials must be one of ${ACTION_CREDENTIALS.join(', ')} (got ${JSON.stringify(cli.actionsCredentials)})`, null)
121
159
  }
122
160
 
123
161
  function table(v: unknown, what: string, file: string): Record<string, unknown> {
@@ -125,12 +163,21 @@ function table(v: unknown, what: string, file: string): Record<string, unknown>
125
163
  return v as Record<string, unknown>
126
164
  }
127
165
 
166
+ function tomlInt(v: unknown, name: string, min: number, max: number, file: string): number | undefined {
167
+ if (v === undefined) return undefined
168
+ if (typeof v !== 'number' || !Number.isInteger(v) || v < min || v > max)
169
+ throw new BrustConfigError(`${file}: ${name} must be an integer in ${min}..${max} (got ${JSON.stringify(v)})`, file)
170
+ return v
171
+ }
172
+
128
173
  function fromTomlTable(parsed: unknown, file: string): Partial<BrustConfig> {
129
174
  const root = table(parsed, 'top level', file)
130
175
  const out: Partial<BrustConfig> = {}
131
176
  if ('server' in root) {
132
177
  const server = table(root.server, '[server]', file)
133
178
  const { port, address } = server
179
+ const cap = tomlInt(server.max_loader_response_bytes, 'server.max_loader_response_bytes', MIN_LOADER_RESPONSE_BYTES, MAX_LOADER_RESPONSE_BYTES, file)
180
+ if (cap !== undefined) out.maxLoaderResponseBytes = cap
134
181
  if (port !== undefined) {
135
182
  if (typeof port !== 'number' || !Number.isInteger(port) || port < 1 || port > 65535)
136
183
  throw new BrustConfigError(`${file}: server.port must be an integer in 1..65535 (got ${JSON.stringify(port)})`, file)
@@ -143,7 +190,10 @@ function fromTomlTable(parsed: unknown, file: string): Partial<BrustConfig> {
143
190
  }
144
191
  }
145
192
  if ('workers' in root) {
146
- const { count } = table(root.workers, '[workers]', file)
193
+ const w = table(root.workers, '[workers]', file)
194
+ const { count } = w
195
+ const slot = tomlInt(w.slot_bytes, 'workers.slot_bytes', MIN_SLOT_BYTES, MAX_SLOT_BYTES, file)
196
+ if (slot !== undefined) out.slotBytes = slot
147
197
  if (count !== undefined) {
148
198
  if (typeof count !== 'number' || !Number.isInteger(count) || count < 1 || count > MAX_WORKERS)
149
199
  throw new BrustConfigError(`${file}: workers.count must be an integer in 1..${MAX_WORKERS} (got ${JSON.stringify(count)})`, file)
@@ -151,34 +201,62 @@ function fromTomlTable(parsed: unknown, file: string): Partial<BrustConfig> {
151
201
  }
152
202
  }
153
203
  if ('cors' in root) out.cors = parseCors(root.cors, file)
204
+ if ('actions' in root) {
205
+ const a = table(root.actions, '[actions]', file)
206
+ if (a.base_url !== undefined) {
207
+ const why = baseUrlError(a.base_url)
208
+ if (why) throw new BrustConfigError(`${file}: actions.base_url ${why}`, file)
209
+ out.actionsBaseUrl = a.base_url as string
210
+ }
211
+ if (a.credentials !== undefined) {
212
+ if (typeof a.credentials !== 'string' || !ACTION_CREDENTIALS.includes(a.credentials as ActionCredentials))
213
+ throw new BrustConfigError(`${file}: actions.credentials must be one of ${ACTION_CREDENTIALS.join(', ')} (got ${JSON.stringify(a.credentials)})`, file)
214
+ out.actionsCredentials = a.credentials as ActionCredentials
215
+ }
216
+ }
154
217
  return out
155
218
  }
156
219
 
157
220
  function parseCors(v: unknown, file: string): CorsConfig {
158
- const t = table(v, 'cors', file)
221
+ return checkCors(corsFromToml(table(v, 'cors', file)), `${file}: cors`, file, true)
222
+ }
223
+
224
+ /** brust.toml `[cors]` (snake_case keys) → the camelCase shape [`checkCors`] validates. Unknown toml keys are ignored, as always. */
225
+ function corsFromToml(t: Record<string, unknown>): Record<string, unknown> {
226
+ return { origins: t.origins, methods: t.methods, headers: t.headers, exposeHeaders: t.expose_headers, credentials: t.credentials, maxAgeSeconds: t.max_age_seconds }
227
+ }
228
+
229
+ const CORS_KEYS = ['origins', 'methods', 'headers', 'exposeHeaders', 'credentials', 'maxAgeSeconds']
230
+
231
+ /** The one `[cors]` validator (Rust `CorsConfig::validate`, verbatim), for toml (`where` = `<file>: cors`, key names in
232
+ * snake_case) and for `run({ config: { cors } })` (`where` = `config.cors`, camelCase; an unknown key is an error). */
233
+ function checkCors(c: unknown, where: string, file: string | null, snake: boolean): CorsConfig {
234
+ const fail = (m: string) => new BrustConfigError(m, file)
235
+ if (c === null || typeof c !== 'object' || Array.isArray(c)) throw fail(`${where} must be ${file === null ? 'an object (or null)' : 'a table'} (got ${JSON.stringify(c)})`)
236
+ const t = c as Record<string, unknown>
237
+ if (!snake) {
238
+ const bad = Object.keys(t).find((k) => !CORS_KEYS.includes(k))
239
+ if (bad !== undefined) throw fail(`${where}.${bad} is not a cors option (expected ${CORS_KEYS.join(', ')})`)
240
+ }
241
+ const name = (k: string) => (snake ? k.replace(/[A-Z]/g, (m) => `_${m.toLowerCase()}`) : k)
159
242
  const strings = (val: unknown, key: string): string[] | undefined => {
160
243
  if (val === undefined) return undefined
161
- if (!Array.isArray(val) || val.some((x) => typeof x !== 'string'))
162
- throw new BrustConfigError(`${file}: cors.${key} must be an array of strings (got ${JSON.stringify(val)})`, file)
244
+ if (!Array.isArray(val) || val.some((x) => typeof x !== 'string')) throw fail(`${where}.${name(key)} must be an array of strings (got ${JSON.stringify(val)})`)
163
245
  return val as string[]
164
246
  }
165
247
  const origins = strings(t.origins, 'origins') ?? []
166
248
  const methods = strings(t.methods, 'methods')
167
249
  const headers = strings(t.headers, 'headers')
168
- const exposeHeaders = strings(t.expose_headers, 'expose_headers')
169
- if (t.credentials !== undefined && typeof t.credentials !== 'boolean')
170
- throw new BrustConfigError(`${file}: cors.credentials must be a boolean (got ${JSON.stringify(t.credentials)})`, file)
250
+ const exposeHeaders = strings(t.exposeHeaders, 'exposeHeaders')
251
+ if (t.credentials !== undefined && typeof t.credentials !== 'boolean') throw fail(`${where}.credentials must be a boolean (got ${JSON.stringify(t.credentials)})`)
171
252
  const credentials = t.credentials === true
172
- const age = t.max_age_seconds
253
+ const age = t.maxAgeSeconds
173
254
  if (age !== undefined && (typeof age !== 'number' || !Number.isInteger(age) || age < 0 || age > MAX_U32))
174
- throw new BrustConfigError(`${file}: cors.max_age_seconds must be an integer in 0..${MAX_U32} (got ${JSON.stringify(age)})`, file)
255
+ throw fail(`${where}.${name('maxAgeSeconds')} must be an integer in 0..${MAX_U32} (got ${JSON.stringify(age)})`)
175
256
  // Rust CorsConfig::validate, verbatim.
176
- if (origins.length === 0) throw new BrustConfigError(`${file}: cors.origins must be non-empty`, file)
257
+ if (origins.length === 0) throw fail(`${where}.origins must be non-empty`)
177
258
  if (credentials && origins.includes('*'))
178
- throw new BrustConfigError(
179
- `${file}: cors.credentials cannot be combined with a wildcard origin '*' (browsers reject Access-Control-Allow-Origin: * with credentials); list explicit origins instead`,
180
- file,
181
- )
259
+ throw fail(`${where}.credentials cannot be combined with a wildcard origin '*' (browsers reject Access-Control-Allow-Origin: * with credentials); list explicit origins instead`)
182
260
  return {
183
261
  origins,
184
262
  ...(methods ? { methods } : {}),
@@ -211,6 +289,14 @@ function fromEnv(): Partial<BrustConfig> {
211
289
  out.drainTimeoutMs = envInt('BRUST_DRAIN_TIMEOUT_MS', 0, MAX_MS, `an integer in 0..${MAX_MS}`)
212
290
  out.bootTimeoutMs = envInt('BRUST_BOOT_TIMEOUT_MS', 1, MAX_MS, `an integer in 1..${MAX_MS}`)
213
291
  out.callTimeoutMs = envInt('BRUST_CALL_TIMEOUT_MS', 1, MAX_MS, `an integer in 1..${MAX_MS}`)
292
+ out.slotBytes = envInt('BRUST_SLOT_BYTES', MIN_SLOT_BYTES, MAX_SLOT_BYTES, `an integer in ${MIN_SLOT_BYTES}..${MAX_SLOT_BYTES}`)
293
+ out.maxLoaderResponseBytes = envInt('BRUST_MAX_LOADER_RESPONSE_BYTES', MIN_LOADER_RESPONSE_BYTES, MAX_LOADER_RESPONSE_BYTES, `an integer in ${MIN_LOADER_RESPONSE_BYTES}..${MAX_LOADER_RESPONSE_BYTES}`)
214
294
  out.ioThreads = envInt('BRUST_WORKER_THREADS', 1, MAX_IO_THREADS, `an integer in 1..${MAX_IO_THREADS}`)
295
+ const base = process.env.BRUST_ACTIONS_BASE_URL
296
+ if (base !== undefined && base !== '') {
297
+ const why = baseUrlError(base)
298
+ if (why) throw new BrustConfigError(`BRUST_ACTIONS_BASE_URL ${why}`, null)
299
+ out.actionsBaseUrl = base
300
+ }
215
301
  return out
216
302
  }
@@ -0,0 +1,8 @@
1
+ // L19 (R19.4): a `.module.css` import is its class map (the build's `cssPlugin`); any other `.css`
2
+ // import is a side-effect import the server links (R19.3). Pulled in by a `/// <reference>` from the
3
+ // entries apps import, so both forms typecheck.
4
+ declare module '*.module.css' {
5
+ const classes: { readonly [name: string]: string }
6
+ export default classes
7
+ }
8
+ declare module '*.css' {}