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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +66 -6
  2. package/native/index.d.ts +89 -4
  3. package/native/index.js +60 -54
  4. package/package.json +25 -9
  5. package/src/actions/client.ts +22 -5
  6. package/src/actions/load.ts +2 -1
  7. package/src/actions/prefix.ts +24 -2
  8. package/src/actions-bridge.ts +29 -11
  9. package/src/browser.ts +3 -1
  10. package/src/build/actions-only.ts +86 -0
  11. package/src/build/build-eval.ts +332 -0
  12. package/src/build/build-icons.ts +69 -0
  13. package/src/build/bundle.ts +462 -52
  14. package/src/build/cf-assets.ts +83 -0
  15. package/src/build/cf-core.ts +90 -0
  16. package/src/build/cf-entry.ts +76 -0
  17. package/src/build/cf-wrangler.ts +169 -0
  18. package/src/build/cloudflare.ts +320 -0
  19. package/src/build/compile.ts +114 -5
  20. package/src/build/config.ts +294 -11
  21. package/src/build/css.ts +275 -0
  22. package/src/build/esm-resolve.ts +170 -0
  23. package/src/build/features.ts +114 -0
  24. package/src/build/index.ts +264 -20
  25. package/src/build/manifest.ts +85 -7
  26. package/src/build/pkg-src.ts +514 -0
  27. package/src/build/recognised.ts +19 -0
  28. package/src/build/scan.ts +71 -20
  29. package/src/build/server-only.ts +39 -0
  30. package/src/build/ssg-render.ts +65 -0
  31. package/src/build/ssg.ts +146 -0
  32. package/src/build/static-worker.ts +91 -0
  33. package/src/build/static.ts +258 -0
  34. package/src/build/tailwind.ts +117 -0
  35. package/src/build/tsconfig-paths.ts +67 -0
  36. package/src/build-time.ts +10 -0
  37. package/src/cache-adapter/bridge.ts +91 -0
  38. package/src/cache-adapter/conformance.ts +226 -0
  39. package/src/cache-adapter/index.ts +10 -0
  40. package/src/cache-adapter/load.ts +144 -0
  41. package/src/cache-adapter/memory.ts +229 -0
  42. package/src/cache-adapter/runtime.ts +124 -0
  43. package/src/cache-adapter/types.ts +96 -0
  44. package/src/cache-adapter/workers.ts +118 -0
  45. package/src/cache.ts +4 -1
  46. package/src/cf.ts +19 -0
  47. package/src/cli.ts +71 -2
  48. package/src/config.ts +127 -25
  49. package/src/css-modules.d.ts +8 -0
  50. package/src/dev/bun.ts +371 -0
  51. package/src/dev/client.ts +92 -0
  52. package/src/dev/devtools-data.ts +23 -0
  53. package/src/dev/devtools.ts +356 -0
  54. package/src/dev/diag.ts +30 -0
  55. package/src/dev/proxy.ts +236 -0
  56. package/src/dev/tui.ts +144 -0
  57. package/src/dev/watch.ts +130 -0
  58. package/src/dev.ts +97 -0
  59. package/src/handlers.ts +282 -0
  60. package/src/index.ts +3 -1
  61. package/src/native.ts +38 -1
  62. package/src/routes.ts +20 -1
  63. package/src/run.ts +59 -12
  64. package/src/server-handlers.ts +32 -0
  65. package/src/shadcn/init.ts +513 -0
  66. package/src/shadcn/jsonc.ts +129 -0
  67. package/src/shadcn/registry.ts +149 -0
  68. package/src/worker.ts +127 -245
package/src/cli.ts CHANGED
@@ -3,11 +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'
7
+ import { runDev } from './dev'
6
8
  import { run } from './run'
7
9
 
8
10
  const USAGE = `usage:
9
- brust build [entry=routes.tsx] [--out-dir dist] [--force]
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)
13
+ brust dev [entry=routes.tsx] --target cloudflare [--out-dir dist] [--port N] [--wasm-from-source] [-- <wrangler args>]
10
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>]
11
16
 
12
17
  Config precedence for start: env (BRUST_ADDR, BRUST_PORT, BRUST_WORKERS, BRUST_RENDER_SLOTS,
13
18
  BRUST_DRAIN_TIMEOUT_MS, BRUST_BOOT_TIMEOUT_MS, BRUST_DIST_DIR, BRUST_APP_ENTRY) > flags
@@ -29,7 +34,10 @@ function intFlag(name: string, v: string | undefined, min: number): number | und
29
34
  }
30
35
 
31
36
  export async function main(argv: string[]): Promise<void> {
32
- const [cmd, ...rest] = argv
37
+ const [cmd, ...all] = argv
38
+ const dd = cmd === 'dev' ? all.indexOf('--') : -1
39
+ const rest = dd < 0 ? all : all.slice(0, dd)
40
+ const passthrough = dd < 0 ? [] : all.slice(dd + 1)
33
41
  if (cmd === undefined || cmd === '--help' || cmd === '-h' || cmd === 'help') {
34
42
  process.stdout.write(USAGE)
35
43
  process.exit(cmd === undefined ? 2 : 0)
@@ -46,6 +54,14 @@ export async function main(argv: string[]): Promise<void> {
46
54
  port: { type: 'string' },
47
55
  workers: { type: 'string' },
48
56
  force: { type: 'boolean' },
57
+ css: { type: 'string' },
58
+ 'base-color': { type: 'string' },
59
+ 'shadcn-version': { type: 'string' },
60
+ strict: { type: 'boolean' },
61
+ target: { type: 'string' },
62
+ 'wasm-from-source': { type: 'boolean' },
63
+ 'actions-only': { type: 'boolean' },
64
+ dev: { type: 'boolean' },
49
65
  help: { type: 'boolean', short: 'h' },
50
66
  },
51
67
  })
@@ -58,6 +74,11 @@ export async function main(argv: string[]): Promise<void> {
58
74
  process.exit(0)
59
75
  }
60
76
  const str = (k: string) => values[k] as string | undefined
77
+ const target = str('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')
61
82
 
62
83
  if (cmd === 'build') {
63
84
  if (positionals.length > 1) usage(`build takes one entry (got ${positionals.join(' ')})`)
@@ -68,6 +89,11 @@ export async function main(argv: string[]): Promise<void> {
68
89
  entry: positionals[0] ?? 'routes.tsx',
69
90
  outDir: str('out-dir') ?? 'dist',
70
91
  force: values.force === true,
92
+ target: target as 'bun' | 'cloudflare' | 'static' | undefined,
93
+ strict: values.strict === true,
94
+ wasmFromSource: values['wasm-from-source'] === true,
95
+ actionsOnly: values['actions-only'] === true,
96
+ dev: values.dev === true,
71
97
  log: (s) => console.log(s),
72
98
  })
73
99
  } catch (e) {
@@ -79,6 +105,49 @@ export async function main(argv: string[]): Promise<void> {
79
105
  console.log(`[brust] built ${str('out-dir') ?? 'dist'} in ${Math.round(performance.now() - t0)}ms`)
80
106
  process.exit(0)
81
107
  }
108
+ if (cmd === 'dev') {
109
+ if (positionals.length > 1) usage(`dev takes one entry (got ${positionals.join(' ')})`)
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
+ }
116
+ try {
117
+ process.exit(
118
+ await runDev({
119
+ appRoot: process.cwd(),
120
+ entry: positionals[0] ?? 'routes.tsx',
121
+ outDir: str('out-dir') ?? 'dist',
122
+ ...(port !== undefined && { port }),
123
+ wranglerArgs: passthrough,
124
+ fromSource: values['wasm-from-source'] === true,
125
+ log: (s) => console.log(s),
126
+ }),
127
+ )
128
+ } catch (e) {
129
+ if (e instanceof BuildError) console.error(`error ${e.rule} ${e.message}`)
130
+ else console.error(`error dev ${(e as Error).stack ?? String(e)}`)
131
+ process.exit(1)
132
+ }
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
+ }
82
151
  if (cmd === 'start') {
83
152
  if (positionals.length > 0) usage(`start takes no positional arguments (got ${positionals.join(' ')})`)
84
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,13 +108,35 @@ 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),
117
+ }
118
+ }
119
+
120
+ const pick = <K extends string, V>(k: K, v: V | undefined) => (v === undefined ? {} : ({ [k]: v } as { [P in K]: V }))
121
+
122
+ /** `[cors]` of brust.toml and nothing else (no env, no cli): what the Cloudflare build ships in CONFIG.
123
+ * Throws `BrustConfigError` on a bad table, exactly like `loadConfig`. */
124
+ export async function loadCors(cwd: string): Promise<CorsConfig | undefined> {
125
+ const tomlPath = join(cwd, 'brust.toml')
126
+ const file = Bun.file(tomlPath)
127
+ if (!(await file.exists())) return undefined
128
+ let parsed: unknown
129
+ try {
130
+ parsed = Bun.TOML.parse(await file.text())
131
+ } catch (e) {
132
+ throw new BrustConfigError(`failed to parse ${tomlPath}: ${(e as Error).message}`, tomlPath)
89
133
  }
134
+ const root = (parsed ?? {}) as Record<string, unknown>
135
+ return 'cors' in root ? parseCors(root.cors, tomlPath) : undefined
90
136
  }
91
137
 
92
138
  /** CLI / programmatic overrides get the same bounds as env (a CLI `--port 0` = any free port). */
93
- function checkCli(cli: Partial<BrustConfig>): void {
139
+ function checkCli(cli: ConfigOverrides): void {
94
140
  const check = (name: string, v: number | undefined, min: number, max: number) => {
95
141
  if (v !== undefined && (!Number.isInteger(v) || v < min || v > max))
96
142
  throw new BrustConfigError(`${name} must be an integer in ${min}..${max} (got ${JSON.stringify(v)})`, null)
@@ -102,6 +148,14 @@ function checkCli(cli: Partial<BrustConfig>): void {
102
148
  check('bootTimeoutMs', cli.bootTimeoutMs, 1, MAX_MS)
103
149
  check('callTimeoutMs', cli.callTimeoutMs, 1, MAX_MS)
104
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)
105
159
  }
106
160
 
107
161
  function table(v: unknown, what: string, file: string): Record<string, unknown> {
@@ -109,12 +163,21 @@ function table(v: unknown, what: string, file: string): Record<string, unknown>
109
163
  return v as Record<string, unknown>
110
164
  }
111
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
+
112
173
  function fromTomlTable(parsed: unknown, file: string): Partial<BrustConfig> {
113
174
  const root = table(parsed, 'top level', file)
114
175
  const out: Partial<BrustConfig> = {}
115
176
  if ('server' in root) {
116
177
  const server = table(root.server, '[server]', file)
117
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
118
181
  if (port !== undefined) {
119
182
  if (typeof port !== 'number' || !Number.isInteger(port) || port < 1 || port > 65535)
120
183
  throw new BrustConfigError(`${file}: server.port must be an integer in 1..65535 (got ${JSON.stringify(port)})`, file)
@@ -127,7 +190,10 @@ function fromTomlTable(parsed: unknown, file: string): Partial<BrustConfig> {
127
190
  }
128
191
  }
129
192
  if ('workers' in root) {
130
- 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
131
197
  if (count !== undefined) {
132
198
  if (typeof count !== 'number' || !Number.isInteger(count) || count < 1 || count > MAX_WORKERS)
133
199
  throw new BrustConfigError(`${file}: workers.count must be an integer in 1..${MAX_WORKERS} (got ${JSON.stringify(count)})`, file)
@@ -135,34 +201,62 @@ function fromTomlTable(parsed: unknown, file: string): Partial<BrustConfig> {
135
201
  }
136
202
  }
137
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
+ }
138
217
  return out
139
218
  }
140
219
 
141
220
  function parseCors(v: unknown, file: string): CorsConfig {
142
- 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)
143
242
  const strings = (val: unknown, key: string): string[] | undefined => {
144
243
  if (val === undefined) return undefined
145
- if (!Array.isArray(val) || val.some((x) => typeof x !== 'string'))
146
- 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)})`)
147
245
  return val as string[]
148
246
  }
149
247
  const origins = strings(t.origins, 'origins') ?? []
150
248
  const methods = strings(t.methods, 'methods')
151
249
  const headers = strings(t.headers, 'headers')
152
- const exposeHeaders = strings(t.expose_headers, 'expose_headers')
153
- if (t.credentials !== undefined && typeof t.credentials !== 'boolean')
154
- 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)})`)
155
252
  const credentials = t.credentials === true
156
- const age = t.max_age_seconds
253
+ const age = t.maxAgeSeconds
157
254
  if (age !== undefined && (typeof age !== 'number' || !Number.isInteger(age) || age < 0 || age > MAX_U32))
158
- 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)})`)
159
256
  // Rust CorsConfig::validate, verbatim.
160
- 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`)
161
258
  if (credentials && origins.includes('*'))
162
- throw new BrustConfigError(
163
- `${file}: cors.credentials cannot be combined with a wildcard origin '*' (browsers reject Access-Control-Allow-Origin: * with credentials); list explicit origins instead`,
164
- file,
165
- )
259
+ throw fail(`${where}.credentials cannot be combined with a wildcard origin '*' (browsers reject Access-Control-Allow-Origin: * with credentials); list explicit origins instead`)
166
260
  return {
167
261
  origins,
168
262
  ...(methods ? { methods } : {}),
@@ -195,6 +289,14 @@ function fromEnv(): Partial<BrustConfig> {
195
289
  out.drainTimeoutMs = envInt('BRUST_DRAIN_TIMEOUT_MS', 0, MAX_MS, `an integer in 0..${MAX_MS}`)
196
290
  out.bootTimeoutMs = envInt('BRUST_BOOT_TIMEOUT_MS', 1, MAX_MS, `an integer in 1..${MAX_MS}`)
197
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}`)
198
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
+ }
199
301
  return out
200
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' {}