@brust/core 2.0.0-alpha.1 → 2.0.0-alpha.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -6
- package/native/index.d.ts +87 -5
- package/native/index.js +61 -54
- package/package.json +22 -9
- package/src/actions/client.ts +22 -5
- package/src/actions/load.ts +2 -1
- package/src/actions/prefix.ts +27 -3
- package/src/actions-bridge.ts +76 -19
- package/src/browser.ts +3 -1
- package/src/build/actions-only.ts +86 -0
- package/src/build/build-eval.ts +330 -0
- package/src/build/build-icons.ts +69 -0
- package/src/build/bundle.ts +341 -43
- package/src/build/cf-assets.ts +83 -0
- package/src/build/cf-core.ts +90 -0
- package/src/build/cf-entry.ts +76 -0
- package/src/build/cf-wrangler.ts +169 -0
- package/src/build/cloudflare.ts +317 -0
- package/src/build/compile.ts +74 -4
- package/src/build/config.ts +274 -15
- package/src/build/css.ts +275 -0
- package/src/build/features.ts +110 -0
- package/src/build/index.ts +224 -20
- package/src/build/manifest.ts +69 -8
- package/src/build/pkg-src.ts +383 -0
- package/src/build/scan.ts +61 -20
- package/src/build/server-only.ts +39 -0
- package/src/build/ssg-render.ts +65 -0
- package/src/build/ssg.ts +146 -0
- package/src/build/static-worker.ts +91 -0
- package/src/build/static.ts +258 -0
- package/src/build/tailwind.ts +117 -0
- package/src/build/tsconfig-paths.ts +67 -0
- package/src/build-time.ts +10 -0
- package/src/cache-adapter/bridge.ts +91 -0
- package/src/cache-adapter/conformance.ts +226 -0
- package/src/cache-adapter/index.ts +10 -0
- package/src/cache-adapter/load.ts +144 -0
- package/src/cache-adapter/memory.ts +229 -0
- package/src/cache-adapter/runtime.ts +124 -0
- package/src/cache-adapter/types.ts +96 -0
- package/src/cache-adapter/workers.ts +118 -0
- package/src/cache.ts +4 -1
- package/src/cf.ts +19 -0
- package/src/cli.ts +70 -2
- package/src/config.ts +127 -25
- package/src/css-modules.d.ts +8 -0
- package/src/dev/bun.ts +365 -0
- package/src/dev/client.ts +92 -0
- package/src/dev/devtools-data.ts +23 -0
- package/src/dev/devtools.ts +356 -0
- package/src/dev/diag.ts +30 -0
- package/src/dev/proxy.ts +236 -0
- package/src/dev/tui.ts +144 -0
- package/src/dev/watch.ts +81 -0
- package/src/dev.ts +93 -0
- package/src/handlers.ts +282 -0
- package/src/index.ts +3 -1
- package/src/native.ts +49 -2
- package/src/routes.ts +20 -1
- package/src/run.ts +61 -14
- package/src/server-handlers.ts +32 -0
- package/src/shadcn/init.ts +213 -0
- package/src/shadcn/jsonc.ts +129 -0
- package/src/worker.ts +129 -245
package/src/cli.ts
CHANGED
|
@@ -3,11 +3,17 @@
|
|
|
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'
|
|
9
|
+
import { shadcnInit, spawnRunner } from './shadcn/init'
|
|
7
10
|
|
|
8
11
|
const USAGE = `usage:
|
|
9
|
-
brust build [entry=routes.tsx] [--out-dir dist] [--force]
|
|
12
|
+
brust build [entry=routes.tsx] [--out-dir dist] [--force] [--target bun|cloudflare|static] [--actions-only] [--strict] [--wasm-from-source] [--dev]
|
|
13
|
+
brust dev [entry=routes.tsx] [--port N] [--workers N] (Bun target: proxy + hot reload + inline TUI)
|
|
14
|
+
brust dev [entry=routes.tsx] --target cloudflare [--out-dir dist] [--port N] [--wasm-from-source] [-- <wrangler args>]
|
|
10
15
|
brust start [--port N] [--workers N] [--dist-dir dist] [--entry routes.tsx]
|
|
16
|
+
brust shadcn init [component ...] [--force] [--css <file>] [--base-color <name>] [--shadcn-version <x.y.z>]
|
|
11
17
|
|
|
12
18
|
Config precedence for start: env (BRUST_ADDR, BRUST_PORT, BRUST_WORKERS, BRUST_RENDER_SLOTS,
|
|
13
19
|
BRUST_DRAIN_TIMEOUT_MS, BRUST_BOOT_TIMEOUT_MS, BRUST_DIST_DIR, BRUST_APP_ENTRY) > flags
|
|
@@ -29,7 +35,10 @@ function intFlag(name: string, v: string | undefined, min: number): number | und
|
|
|
29
35
|
}
|
|
30
36
|
|
|
31
37
|
export async function main(argv: string[]): Promise<void> {
|
|
32
|
-
const [cmd, ...
|
|
38
|
+
const [cmd, ...all] = argv
|
|
39
|
+
const dd = cmd === 'dev' ? all.indexOf('--') : -1
|
|
40
|
+
const rest = dd < 0 ? all : all.slice(0, dd)
|
|
41
|
+
const passthrough = dd < 0 ? [] : all.slice(dd + 1)
|
|
33
42
|
if (cmd === undefined || cmd === '--help' || cmd === '-h' || cmd === 'help') {
|
|
34
43
|
process.stdout.write(USAGE)
|
|
35
44
|
process.exit(cmd === undefined ? 2 : 0)
|
|
@@ -46,6 +55,14 @@ export async function main(argv: string[]): Promise<void> {
|
|
|
46
55
|
port: { type: 'string' },
|
|
47
56
|
workers: { type: 'string' },
|
|
48
57
|
force: { type: 'boolean' },
|
|
58
|
+
css: { type: 'string' },
|
|
59
|
+
'base-color': { type: 'string' },
|
|
60
|
+
'shadcn-version': { type: 'string' },
|
|
61
|
+
strict: { type: 'boolean' },
|
|
62
|
+
target: { type: 'string' },
|
|
63
|
+
'wasm-from-source': { type: 'boolean' },
|
|
64
|
+
'actions-only': { type: 'boolean' },
|
|
65
|
+
dev: { type: 'boolean' },
|
|
49
66
|
help: { type: 'boolean', short: 'h' },
|
|
50
67
|
},
|
|
51
68
|
})
|
|
@@ -58,6 +75,11 @@ export async function main(argv: string[]): Promise<void> {
|
|
|
58
75
|
process.exit(0)
|
|
59
76
|
}
|
|
60
77
|
const str = (k: string) => values[k] as string | undefined
|
|
78
|
+
const target = str('target')
|
|
79
|
+
if (target !== undefined && target !== 'bun' && target !== 'cloudflare' && target !== 'static')
|
|
80
|
+
usage(`--target must be bun, cloudflare or static (got ${JSON.stringify(target)})`)
|
|
81
|
+
if (values['actions-only'] === true && cmd !== 'build') usage('--actions-only only applies to brust build')
|
|
82
|
+
if (values.strict === true && (cmd !== 'build' || target !== 'static')) usage('--strict only applies to brust build --target static')
|
|
61
83
|
|
|
62
84
|
if (cmd === 'build') {
|
|
63
85
|
if (positionals.length > 1) usage(`build takes one entry (got ${positionals.join(' ')})`)
|
|
@@ -68,6 +90,11 @@ export async function main(argv: string[]): Promise<void> {
|
|
|
68
90
|
entry: positionals[0] ?? 'routes.tsx',
|
|
69
91
|
outDir: str('out-dir') ?? 'dist',
|
|
70
92
|
force: values.force === true,
|
|
93
|
+
target: target as 'bun' | 'cloudflare' | 'static' | undefined,
|
|
94
|
+
strict: values.strict === true,
|
|
95
|
+
wasmFromSource: values['wasm-from-source'] === true,
|
|
96
|
+
actionsOnly: values['actions-only'] === true,
|
|
97
|
+
dev: values.dev === true,
|
|
71
98
|
log: (s) => console.log(s),
|
|
72
99
|
})
|
|
73
100
|
} catch (e) {
|
|
@@ -79,6 +106,47 @@ export async function main(argv: string[]): Promise<void> {
|
|
|
79
106
|
console.log(`[brust] built ${str('out-dir') ?? 'dist'} in ${Math.round(performance.now() - t0)}ms`)
|
|
80
107
|
process.exit(0)
|
|
81
108
|
}
|
|
109
|
+
if (cmd === 'dev') {
|
|
110
|
+
if (positionals.length > 1) usage(`dev takes one entry (got ${positionals.join(' ')})`)
|
|
111
|
+
const port = intFlag('port', str('port'), 0)
|
|
112
|
+
if (target !== 'cloudflare') {
|
|
113
|
+
const workers = intFlag('workers', str('workers'), 1)
|
|
114
|
+
if (passthrough.length > 0) usage('brust dev: arguments after -- are for --target cloudflare (wrangler)')
|
|
115
|
+
process.exit(await runBunDev({ appRoot: process.cwd(), entry: positionals[0] ?? 'routes.tsx', ...(port !== undefined && { port }), ...(workers !== undefined && { workers }) }))
|
|
116
|
+
}
|
|
117
|
+
try {
|
|
118
|
+
process.exit(
|
|
119
|
+
await runDev({
|
|
120
|
+
appRoot: process.cwd(),
|
|
121
|
+
entry: positionals[0] ?? 'routes.tsx',
|
|
122
|
+
outDir: str('out-dir') ?? 'dist',
|
|
123
|
+
...(port !== undefined && { port }),
|
|
124
|
+
wranglerArgs: passthrough,
|
|
125
|
+
fromSource: values['wasm-from-source'] === true,
|
|
126
|
+
log: (s) => console.log(s),
|
|
127
|
+
}),
|
|
128
|
+
)
|
|
129
|
+
} catch (e) {
|
|
130
|
+
if (e instanceof BuildError) console.error(`error ${e.rule} ${e.message}`)
|
|
131
|
+
else console.error(`error dev ${(e as Error).stack ?? String(e)}`)
|
|
132
|
+
process.exit(1)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (cmd === 'shadcn') {
|
|
136
|
+
const [sub, ...components] = positionals
|
|
137
|
+
if (sub !== 'init') usage(`shadcn takes a subcommand: init (got ${sub === undefined ? 'none' : JSON.stringify(sub)})`)
|
|
138
|
+
const code = await shadcnInit({
|
|
139
|
+
root: process.cwd(),
|
|
140
|
+
components,
|
|
141
|
+
force: values.force === true,
|
|
142
|
+
...(str('css') !== undefined && { css: str('css') }),
|
|
143
|
+
...(str('base-color') !== undefined && { baseColor: str('base-color') }),
|
|
144
|
+
...(str('shadcn-version') !== undefined && { shadcnVersion: str('shadcn-version') }),
|
|
145
|
+
run: spawnRunner,
|
|
146
|
+
out: (s) => console.log(s),
|
|
147
|
+
})
|
|
148
|
+
process.exit(code)
|
|
149
|
+
}
|
|
82
150
|
if (cmd === 'start') {
|
|
83
151
|
if (positionals.length > 0) usage(`start takes no positional arguments (got ${positionals.join(' ')})`)
|
|
84
152
|
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
|
-
/**
|
|
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
|
|
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:
|
|
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
|
-
...(
|
|
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:
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
253
|
+
const age = t.maxAgeSeconds
|
|
157
254
|
if (age !== undefined && (typeof age !== 'number' || !Number.isInteger(age) || age < 0 || age > MAX_U32))
|
|
158
|
-
throw
|
|
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
|
|
257
|
+
if (origins.length === 0) throw fail(`${where}.origins must be non-empty`)
|
|
161
258
|
if (credentials && origins.includes('*'))
|
|
162
|
-
throw
|
|
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' {}
|