@brust/core 2.0.0-alpha.1 → 2.0.0-alpha.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -6
- package/native/index.d.ts +87 -5
- package/native/index.js +61 -54
- package/package.json +22 -9
- package/src/actions/client.ts +22 -5
- package/src/actions/load.ts +2 -1
- package/src/actions/prefix.ts +27 -3
- package/src/actions-bridge.ts +76 -19
- package/src/browser.ts +3 -1
- package/src/build/actions-only.ts +86 -0
- package/src/build/build-eval.ts +330 -0
- package/src/build/build-icons.ts +69 -0
- package/src/build/bundle.ts +341 -43
- package/src/build/cf-assets.ts +83 -0
- package/src/build/cf-core.ts +90 -0
- package/src/build/cf-entry.ts +76 -0
- package/src/build/cf-wrangler.ts +169 -0
- package/src/build/cloudflare.ts +317 -0
- package/src/build/compile.ts +74 -4
- package/src/build/config.ts +274 -15
- package/src/build/css.ts +275 -0
- package/src/build/features.ts +110 -0
- package/src/build/index.ts +224 -20
- package/src/build/manifest.ts +69 -8
- package/src/build/pkg-src.ts +383 -0
- package/src/build/scan.ts +61 -20
- package/src/build/server-only.ts +39 -0
- package/src/build/ssg-render.ts +65 -0
- package/src/build/ssg.ts +146 -0
- package/src/build/static-worker.ts +91 -0
- package/src/build/static.ts +258 -0
- package/src/build/tailwind.ts +117 -0
- package/src/build/tsconfig-paths.ts +67 -0
- package/src/build-time.ts +10 -0
- package/src/cache-adapter/bridge.ts +91 -0
- package/src/cache-adapter/conformance.ts +226 -0
- package/src/cache-adapter/index.ts +10 -0
- package/src/cache-adapter/load.ts +144 -0
- package/src/cache-adapter/memory.ts +229 -0
- package/src/cache-adapter/runtime.ts +124 -0
- package/src/cache-adapter/types.ts +96 -0
- package/src/cache-adapter/workers.ts +118 -0
- package/src/cache.ts +4 -1
- package/src/cf.ts +19 -0
- package/src/cli.ts +70 -2
- package/src/config.ts +127 -25
- package/src/css-modules.d.ts +8 -0
- package/src/dev/bun.ts +365 -0
- package/src/dev/client.ts +92 -0
- package/src/dev/devtools-data.ts +23 -0
- package/src/dev/devtools.ts +356 -0
- package/src/dev/diag.ts +30 -0
- package/src/dev/proxy.ts +236 -0
- package/src/dev/tui.ts +144 -0
- package/src/dev/watch.ts +81 -0
- package/src/dev.ts +93 -0
- package/src/handlers.ts +282 -0
- package/src/index.ts +3 -1
- package/src/native.ts +49 -2
- package/src/routes.ts +20 -1
- package/src/run.ts +61 -14
- package/src/server-handlers.ts +32 -0
- package/src/shadcn/init.ts +213 -0
- package/src/shadcn/jsonc.ts +129 -0
- package/src/worker.ts +129 -245
package/src/build/compile.ts
CHANGED
|
@@ -2,10 +2,13 @@
|
|
|
2
2
|
// `compileTree` (analysis + lowering of its whole tree). `Err` = build error; fallback and
|
|
3
3
|
// warning diagnostics are logged; `outlet-outside-layout` is raised here (contract 5).
|
|
4
4
|
// A component several trees reach keeps one compile, chosen by role, not by route order (F75).
|
|
5
|
-
import {
|
|
5
|
+
import { realpathSync } from 'node:fs'
|
|
6
|
+
import { relative, sep } from 'node:path'
|
|
6
7
|
import { type CompiledComponent, type CompiledTree, compileTree } from '../../native/index.js'
|
|
7
8
|
import type { FlatRoute, Route } from '../routes'
|
|
8
9
|
import { BuildError } from './errors'
|
|
10
|
+
import { checkAliasAgreement, readPathAliases } from './tsconfig-paths'
|
|
11
|
+
import type { PackageSources } from './pkg-src'
|
|
9
12
|
|
|
10
13
|
/** `ComponentIR` as the compiler serialises it (snake_case), with the fields that are absent
|
|
11
14
|
* when default (`ir/mod.rs`) normalised. The build reads it; it never rewrites it. */
|
|
@@ -17,13 +20,41 @@ export type ComponentIR = Record<string, any> & {
|
|
|
17
20
|
tier: any
|
|
18
21
|
// biome-ignore lint/suspicious/noExplicitAny: IR JSON
|
|
19
22
|
jobs: any[]
|
|
20
|
-
instances: {
|
|
23
|
+
instances: {
|
|
24
|
+
child_id: string
|
|
25
|
+
k: number
|
|
26
|
+
loops: (string | null)[]
|
|
27
|
+
props: Record<string, string | null>
|
|
28
|
+
literals?: Record<string, unknown>
|
|
29
|
+
exprs?: Record<string, string>
|
|
30
|
+
/** F142 R34.43: the rows of a constant enclosing list (`loops` is then `[null]`). */
|
|
31
|
+
rows_literal?: unknown[]
|
|
32
|
+
}[]
|
|
33
|
+
/** F149: inlined instances whose child job the build may run once (`decls.rs` `BuildEvalInstance`). */
|
|
34
|
+
build_eval_instances?: {
|
|
35
|
+
key: string
|
|
36
|
+
child_id: string
|
|
37
|
+
k: number
|
|
38
|
+
inputs: Record<string, unknown>
|
|
39
|
+
outputs: string[]
|
|
40
|
+
stubs?: { source: string; code: string }[]
|
|
41
|
+
/** R34.96: the job modules fused into the child's job, which it imports (`FusedEvalJob`). */
|
|
42
|
+
fused?: { child_id: string; stubs?: { source: string; code: string }[] }[]
|
|
43
|
+
line: number
|
|
44
|
+
col: number
|
|
45
|
+
}[]
|
|
21
46
|
use_id_slots: number
|
|
22
47
|
uses_outlet: boolean
|
|
23
48
|
needs_worker: boolean
|
|
24
49
|
/** `useStore(handle)` bindings (`decls.rs` `StoreUse`); `resolved` = `StoreRef`, `null` when
|
|
25
50
|
* unresolved (the component falls back). `module` is root-relative (C3). */
|
|
26
51
|
stores?: { handle: string; resolved: { name: string; module: string } | null }[]
|
|
52
|
+
/** A package export rendered as a React island (L22): `source` is then not a file. */
|
|
53
|
+
external?: { source: string; export: string; member?: string[] }
|
|
54
|
+
/** The binding a React island imports this component by from `source` (absent: the default export). */
|
|
55
|
+
export?: string
|
|
56
|
+
/** Child components; `nested` is set when this file passes lowered JSX children to that child, an island (shadcn S1). */
|
|
57
|
+
children?: { id: string | null; nested?: string[]; x?: string[]; holes?: string[]; [field: string]: unknown }[]
|
|
27
58
|
}
|
|
28
59
|
|
|
29
60
|
export interface Compiled {
|
|
@@ -33,7 +64,11 @@ export interface Compiled {
|
|
|
33
64
|
ir: ComponentIR
|
|
34
65
|
jinja: string
|
|
35
66
|
serverTs?: string
|
|
67
|
+
/** Build-time evaluation module (L2): one thunk per `ir.build_eval` slot. */
|
|
68
|
+
evalTs?: string
|
|
36
69
|
clientJs?: string
|
|
70
|
+
/** The compiler's diagnostics for this component (`dev.json`, L14). */
|
|
71
|
+
diagnostics?: { class: string; rule: string; message: string; line: number; col: number; remediation: string }[]
|
|
37
72
|
}
|
|
38
73
|
|
|
39
74
|
type Diag = NonNullable<CompiledTree['error']>
|
|
@@ -78,11 +113,31 @@ export function compileApp(opts: {
|
|
|
78
113
|
appRoot: string
|
|
79
114
|
leaves: FlatRoute[]
|
|
80
115
|
componentFile: Map<Function, string>
|
|
116
|
+
/** Why a Component the scan saw has no file (named export only, outside the root); optional. */
|
|
117
|
+
componentIssue?: Map<Function, string>
|
|
81
118
|
runtimeImport: string
|
|
82
119
|
serverOnly: string[]
|
|
120
|
+
/** L20: every CSS Module's class map by absolute source path; `styles.x` folds to the literal class. */
|
|
121
|
+
cssMaps?: Map<string, Record<string, string>>
|
|
83
122
|
log: (s: string) => void
|
|
123
|
+
/** SVG markup the build rendered for lucide icon requests (L23), by `ir.icon_requests[].key`. */
|
|
124
|
+
iconSvgs?: Record<string, { svg: string; base: string }>
|
|
125
|
+
/** The compile after icons were rendered (even if none could be): icons still components are judged as islands. */
|
|
126
|
+
iconsFinal?: boolean
|
|
127
|
+
/** Values the build computed for `ir.build_eval` slots, by key (L2): the slots become literals. */
|
|
128
|
+
slotValues?: Record<string, unknown>
|
|
129
|
+
/** F153c: the package sources this build wrote (`pkg-src.ts`); absent = package imports are islands. */
|
|
130
|
+
packages?: PackageSources
|
|
84
131
|
}): { compiled: Map<string, Compiled>; routeComponent: Map<string, string> } {
|
|
85
132
|
const { appRoot, leaves, componentFile } = opts
|
|
133
|
+
const iconSvgsJson = opts.iconsFinal ? JSON.stringify({ final: true, svgs: opts.iconSvgs ?? {} }) : undefined
|
|
134
|
+
const slotValuesJson = opts.slotValues && Object.keys(opts.slotValues).length > 0 ? JSON.stringify(opts.slotValues) : undefined
|
|
135
|
+
const packagesJson = opts.packages ? JSON.stringify(opts.packages) : undefined
|
|
136
|
+
// The Rust side keys each map by its path relative to the app root (`/`-separated), like an import resolves.
|
|
137
|
+
const { aliases, notes } = readPathAliases(appRoot)
|
|
138
|
+
for (const n of notes) opts.log(`warning tsconfig-paths ${n}`)
|
|
139
|
+
const pathAliasesJson = aliases.length > 0 ? JSON.stringify(aliases) : undefined
|
|
140
|
+
const cssModules = Object.fromEntries([...(opts.cssMaps ?? [])].map(([abs, map]) => [relative(realpathSync(appRoot), abs).split(sep).join('/'), map]))
|
|
86
141
|
const seen = new Map<string, Seen>()
|
|
87
142
|
const rootIdOfFile = new Map<string, string>()
|
|
88
143
|
const routeComponent = new Map<string, string>()
|
|
@@ -90,17 +145,22 @@ export function compileApp(opts: {
|
|
|
90
145
|
const nodes = new Map<string, Route>()
|
|
91
146
|
for (const l of leaves) l.chain.forEach((r, i) => r.Component && nodes.set(l.chainIds[i]!, r))
|
|
92
147
|
|
|
148
|
+
// F158 (R34.69): the route components (pages and layouts) take request data as props, so the compiler never hands
|
|
149
|
+
// them lowered JSX from a caller. Keyed like an import resolves: relative to the app root, `/`-separated.
|
|
150
|
+
const rootRel = (f: string) => relative(appRoot, f).split(sep).join('/')
|
|
151
|
+
const routeRoots = [...new Set([...nodes.values()].flatMap((r) => componentFile.get(r.Component!) ?? []).flatMap((f) => [rootRel(f), relative(realpathSync(appRoot), realpathSync(f)).split(sep).join('/')]))]
|
|
152
|
+
|
|
93
153
|
for (const [routeId, route] of nodes) {
|
|
94
154
|
const file = componentFile.get(route.Component!)
|
|
95
155
|
if (!file)
|
|
96
156
|
throw new BuildError(
|
|
97
157
|
'component-source',
|
|
98
|
-
`${route.Component!.name || '(anonymous)'} is not
|
|
158
|
+
`${opts.componentIssue?.get(route.Component!) ?? `${route.Component!.name || '(anonymous)'} is not the default export of a .tsx file in the import graph`} (route ${routeId})`,
|
|
99
159
|
)
|
|
100
160
|
let rootId = rootIdOfFile.get(file)
|
|
101
161
|
if (!rootId) {
|
|
102
162
|
const rel = relative(appRoot, file)
|
|
103
|
-
const tree = compileTree(rel, appRoot, opts.runtimeImport, opts.serverOnly)
|
|
163
|
+
const tree = compileTree(rel, appRoot, opts.runtimeImport, opts.serverOnly, slotValuesJson, cssModules, iconSvgsJson, routeRoots, pathAliasesJson, packagesJson)
|
|
104
164
|
if (tree.error) throw new BuildError(tree.error.rule, `${where(rel, tree.error)} ${tree.error.message}`)
|
|
105
165
|
for (const [i, c] of tree.components.entries()) {
|
|
106
166
|
// A lowering Error that came back as a diagnostic is a build error too (contract 9).
|
|
@@ -122,6 +182,7 @@ export function compileApp(opts: {
|
|
|
122
182
|
for (const d of c.diagnostics)
|
|
123
183
|
if (d.class === 'fallback' || d.class === 'warning') opts.log(`warning ${d.rule} ${where(c.source, d)} ${d.message}`)
|
|
124
184
|
const ir = JSON.parse(c.ir) as ComponentIR
|
|
185
|
+
if (ir.alias_imports?.length) checkAliasAgreement(appRoot, ir.alias_imports)
|
|
125
186
|
ir.use_id_slots ??= 0
|
|
126
187
|
ir.uses_outlet ??= false
|
|
127
188
|
ir.instances ??= []
|
|
@@ -132,9 +193,18 @@ export function compileApp(opts: {
|
|
|
132
193
|
ir,
|
|
133
194
|
jinja: c.jinja,
|
|
134
195
|
serverTs: c.serverTs,
|
|
196
|
+
evalTs: c.evalTs,
|
|
135
197
|
clientJs: c.clientJs,
|
|
198
|
+
diagnostics: c.diagnostics.map((d) => ({ class: d.class, rule: d.rule, message: d.message, line: d.line, col: d.col, remediation: d.remediation })),
|
|
136
199
|
})
|
|
137
200
|
}
|
|
201
|
+
// A package export has no file of its own: bare imports in its island entries resolve from a
|
|
202
|
+
// component that uses it (the first one in build order).
|
|
203
|
+
for (const c of compiled.values()) {
|
|
204
|
+
if (!c.ir.external) continue
|
|
205
|
+
const user = [...compiled.values()].find((p) => !p.ir.external && (p.ir.children ?? []).some((ref: { id: string | null }) => ref.id === c.id))
|
|
206
|
+
c.file = user?.file ?? c.file
|
|
207
|
+
}
|
|
138
208
|
for (const [routeId, route] of nodes) {
|
|
139
209
|
const rootId = routeComponent.get(routeId)!
|
|
140
210
|
if (compiled.get(rootId)!.ir.uses_outlet && !route.children)
|
package/src/build/config.ts
CHANGED
|
@@ -4,26 +4,245 @@
|
|
|
4
4
|
// `server_only` is the app config `serverOnly` of the compiler spec (§8.2): the compiler refuses
|
|
5
5
|
// such an import in client code, and every browser bundle refuses to include it.
|
|
6
6
|
import { join } from 'node:path'
|
|
7
|
-
import { DEFAULT_ACTION_PREFIX, DEFAULT_MAX_BODY_BYTES, prefixError } from '../actions/prefix'
|
|
7
|
+
import { ACTION_CREDENTIALS, type ActionCredentials, DEFAULT_ACTION_PREFIX, DEFAULT_MAX_BODY_BYTES, DEFAULT_MAX_RESPONSE_BYTES, MAX_ACTION_BYTES, baseUrlError, prefixError } from '../actions/prefix'
|
|
8
|
+
import { ADAPTER_NAMES, BUILTINS, installHint } from '../cache-adapter/load'
|
|
8
9
|
import { BuildError } from './errors'
|
|
9
10
|
|
|
10
|
-
// [actions] (opt-in, ruling R6): present table => actions on.
|
|
11
|
+
// [actions] (opt-in, ruling R6): present table => actions on. Both byte caps cross napi as u32 (F82: 1..64 MiB / 256 KiB..64 MiB).
|
|
11
12
|
// [actions]
|
|
12
|
-
// entry = "actions.ts" prefix = "/_brust/action" max_body_bytes =
|
|
13
|
-
|
|
13
|
+
// entry = "actions.ts" prefix = "/_brust/action" max_body_bytes = 1048576 max_response_bytes = 8388608 cross_site = "reject" | "allow" (F87)
|
|
14
|
+
// base_url = "https://api.example.com" credentials = "same-origin" | "include" | "omit" (L16: the browser client's target; runtime settings, validated here too)
|
|
15
|
+
// `prefix = "/"` is accepted only for an actions-only build (L16 R16.4).
|
|
16
|
+
// `ACTION_INLINE_BODY_BYTES` (Rust): the inline/heap threshold of an action body, independent of the response slot size (`slotBytes`).
|
|
17
|
+
const MIN_RESPONSE_BYTES = 262_144
|
|
14
18
|
|
|
15
19
|
export interface ActionsBuildConfig {
|
|
16
20
|
entry: string
|
|
17
21
|
prefix: string
|
|
18
22
|
maxBodyBytes: number
|
|
23
|
+
maxResponseBytes: number
|
|
24
|
+
crossSite: 'reject' | 'allow'
|
|
25
|
+
/** `[actions] base_url`; with no actions file present the build is page-only (L16 R16.4b). */
|
|
26
|
+
baseUrl: string | undefined
|
|
27
|
+
credentials: ActionCredentials
|
|
28
|
+
/** `entry` was written in brust.toml (a missing file is then an error, never a page-only build). */
|
|
29
|
+
entryExplicit: boolean
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// [cloudflare] (M4-CF; only read by `--target cloudflare`, validated always so errors surface early):
|
|
33
|
+
// name = "my-worker" compatibility_date = "2026-10-01" l1_capacity = 256 job_cache_capacity = 512 stats = false
|
|
34
|
+
// edge_cache = "off" [cloudflare.alias] "lib/db.ts" = "lib/db.cf.ts" # app-root-relative module swaps (spec R12)
|
|
35
|
+
const CF_KEYS = new Set(['name', 'compatibility_date', 'l1_capacity', 'job_cache_capacity', 'stats', 'edge_cache', 'alias'])
|
|
36
|
+
export const CF_MIN_COMPAT_DATE = '2026-08-04'
|
|
37
|
+
const U32_MAX = 4_294_967_295
|
|
38
|
+
|
|
39
|
+
export interface CloudflareBuildConfig {
|
|
40
|
+
name: string | undefined
|
|
41
|
+
compatibilityDate: string | undefined
|
|
42
|
+
l1Capacity: number
|
|
43
|
+
jobCacheCapacity: number
|
|
44
|
+
stats: boolean
|
|
45
|
+
edgeCache: 'off'
|
|
46
|
+
/** absolute module path -> absolute replacement path */
|
|
47
|
+
alias: Record<string, string>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// [cache] (cache adapters, opt-in; spec §6): a shared second tier behind the in-process cache.
|
|
51
|
+
// adapter = "redis" | "dragonfly" | "cloudflare-kv" | "cloudflare-do" | "custom" module = "lib/cache.ts" (custom)
|
|
52
|
+
// broadcast = "<built-in>" namespace = "<package name>" get_timeout_ms = 50 (native) / 100 (Workers)
|
|
53
|
+
// max_body_bytes = 1048576 epoch_interval_ms = 1000 [cache.redis] url = "env:REDIS_URL" [cache.cloudflare] [cache.options]
|
|
54
|
+
const CACHE_KEYS = new Set(['adapter', 'broadcast', 'module', 'namespace', 'get_timeout_ms', 'max_body_bytes', 'epoch_interval_ms', 'redis', 'cloudflare', 'options'])
|
|
55
|
+
export const MAX_L2_BODY_BYTES = 67_108_864
|
|
56
|
+
|
|
57
|
+
export interface CacheBuildConfig {
|
|
58
|
+
adapter: (typeof ADAPTER_NAMES)[number]
|
|
59
|
+
/** `custom`: the module, app-root-relative as written. */
|
|
60
|
+
module: string | undefined
|
|
61
|
+
broadcast: string | undefined
|
|
62
|
+
/** The factory's options: `[cache.redis]` / `[cache.cloudflare]` for built-ins, `[cache.options]` for custom. */
|
|
63
|
+
options: Record<string, unknown>
|
|
64
|
+
broadcastOptions: Record<string, unknown> | undefined
|
|
65
|
+
namespace: string
|
|
66
|
+
/** Absent = the runtime default (native 50, Workers 100). */
|
|
67
|
+
getTimeoutMs: number | undefined
|
|
68
|
+
maxBodyBytes: number
|
|
69
|
+
epochIntervalMs: number
|
|
19
70
|
}
|
|
20
71
|
|
|
21
72
|
export interface BuildConfig {
|
|
22
73
|
serverOnly: string[]
|
|
23
74
|
actions: ActionsBuildConfig | null
|
|
75
|
+
cloudflare: CloudflareBuildConfig | null
|
|
76
|
+
cache: CacheBuildConfig | null
|
|
77
|
+
/** `[css] global` (L19, R19.1): app-root-relative stylesheets every route links first, in order. */
|
|
78
|
+
cssGlobal: string[]
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const isTable = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object' && !Array.isArray(v)
|
|
82
|
+
|
|
83
|
+
function rangeInt(file: string, key: string, v: unknown, lo: number, hi: number, dflt: number): number {
|
|
84
|
+
if (v === undefined) return dflt
|
|
85
|
+
if (typeof v !== 'number' || !Number.isInteger(v) || v < lo || v > hi)
|
|
86
|
+
throw new BuildError('cache', `${file}: cache.${key} must be an integer in ${lo}..${hi} (got ${String(v)})`)
|
|
87
|
+
return v
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** `[cache.redis]`'s keys and ranges (cache-redis R10); `@brust/cache-redis` validates the same list at worker boot. */
|
|
91
|
+
const REDIS_INT_KEYS: ReadonlyArray<readonly [string, number, number]> = [
|
|
92
|
+
['connect_timeout_ms', 100, 60_000],
|
|
93
|
+
['max_body_bytes', 1, MAX_L2_BODY_BYTES],
|
|
94
|
+
['marker_ttl_ms', 60_000, 604_800_000],
|
|
95
|
+
['unlink_batch', 1, 10_000],
|
|
96
|
+
['reconnect_min_ms', 10, 60_000],
|
|
97
|
+
['reconnect_max_ms', 10, 600_000],
|
|
98
|
+
]
|
|
99
|
+
const REDIS_TLS_KEYS: Record<string, string> = { reject_unauthorized: 'boolean', server_name: 'string', ca_file: 'string' }
|
|
100
|
+
|
|
101
|
+
function checkRedisTable(file: string, r: Record<string, unknown>): void {
|
|
102
|
+
const known = new Set(['url', 'tls', ...REDIS_INT_KEYS.map(([k]) => k)])
|
|
103
|
+
for (const k of Object.keys(r)) if (!known.has(k)) throw new BuildError('cache', `${file}: cache.redis.${k} is not a known key`)
|
|
104
|
+
for (const [k, lo, hi] of REDIS_INT_KEYS) rangeInt(file, `redis.${k}`, r[k], lo, hi, lo)
|
|
105
|
+
if (typeof r.reconnect_min_ms === 'number' && typeof r.reconnect_max_ms === 'number' && r.reconnect_max_ms < r.reconnect_min_ms)
|
|
106
|
+
throw new BuildError('cache', `${file}: cache.redis.reconnect_max_ms must be >= cache.redis.reconnect_min_ms`)
|
|
107
|
+
const tls = r.tls
|
|
108
|
+
if (tls === undefined || typeof tls === 'boolean') return
|
|
109
|
+
if (!isTable(tls)) throw new BuildError('cache', `${file}: cache.redis.tls must be a boolean or a table`)
|
|
110
|
+
for (const [k, v] of Object.entries(tls)) {
|
|
111
|
+
const want = REDIS_TLS_KEYS[k]
|
|
112
|
+
if (!want) throw new BuildError('cache', `${file}: cache.redis.tls.${k} is not a known key`)
|
|
113
|
+
if (typeof v !== want || v === '') throw new BuildError('cache', `${file}: cache.redis.tls.${k} must be a ${want === 'string' ? 'non-empty string' : want}`)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** The option table a built-in reads. */
|
|
118
|
+
function builtinOptions(name: string, t: Record<string, unknown>): Record<string, unknown> {
|
|
119
|
+
const key = name === 'redis' || name === 'dragonfly' ? 'redis' : 'cloudflare'
|
|
120
|
+
return { ...((t[key] as Record<string, unknown> | undefined) ?? {}) }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async function packageName(appRoot: string): Promise<string | undefined> {
|
|
124
|
+
const f = Bun.file(join(appRoot, 'package.json'))
|
|
125
|
+
if (!(await f.exists())) return undefined
|
|
126
|
+
try {
|
|
127
|
+
const n = ((await f.json()) as { name?: unknown }).name
|
|
128
|
+
return typeof n === 'string' && n.trim() ? n.trim() : undefined
|
|
129
|
+
} catch {
|
|
130
|
+
return undefined
|
|
131
|
+
}
|
|
24
132
|
}
|
|
25
133
|
|
|
26
|
-
function
|
|
134
|
+
async function parseCache(file: string, appRoot: string, raw: unknown): Promise<CacheBuildConfig | null> {
|
|
135
|
+
if (raw === undefined) return null
|
|
136
|
+
if (!isTable(raw)) throw new BuildError('cache', `${file}: [cache] must be a table`)
|
|
137
|
+
const t = raw
|
|
138
|
+
for (const k of Object.keys(t)) if (!CACHE_KEYS.has(k)) throw new BuildError('cache', `${file}: cache.${k} is not a known key`)
|
|
139
|
+
for (const k of ['redis', 'cloudflare', 'options'] as const)
|
|
140
|
+
if (t[k] !== undefined && !isTable(t[k])) throw new BuildError('cache', `${file}: [cache.${k}] must be a table`)
|
|
141
|
+
const adapter = t.adapter
|
|
142
|
+
if (typeof adapter !== 'string' || !(ADAPTER_NAMES as readonly string[]).includes(adapter))
|
|
143
|
+
throw new BuildError('cache', `${file}: cache.adapter must be one of ${ADAPTER_NAMES.map((n) => `"${n}"`).join(', ')} (got ${JSON.stringify(adapter)})`)
|
|
144
|
+
const name = adapter as CacheBuildConfig['adapter']
|
|
145
|
+
let module: string | undefined
|
|
146
|
+
if (name === 'custom') {
|
|
147
|
+
if (typeof t.module !== 'string' || t.module.trim() === '') throw new BuildError('cache', `${file}: cache.module is required for adapter "custom"`)
|
|
148
|
+
module = t.module.trim()
|
|
149
|
+
} else if (t.module !== undefined) throw new BuildError('cache', `${file}: cache.module is only for adapter "custom"`)
|
|
150
|
+
let broadcast: string | undefined
|
|
151
|
+
if (t.broadcast !== undefined) {
|
|
152
|
+
if (typeof t.broadcast !== 'string' || !(t.broadcast in BUILTINS))
|
|
153
|
+
throw new BuildError('cache', `${file}: cache.broadcast must name a built-in adapter (${Object.keys(BUILTINS).map((n) => `"${n}"`).join(', ')})`)
|
|
154
|
+
broadcast = t.broadcast
|
|
155
|
+
}
|
|
156
|
+
// `env:` indirection only: a literal URL may carry a password, so the error never echoes the value.
|
|
157
|
+
const redis = t.redis as Record<string, unknown> | undefined
|
|
158
|
+
if (redis?.url !== undefined && (typeof redis.url !== 'string' || !/^env:[A-Za-z_][A-Za-z0-9_]*$/.test(redis.url)))
|
|
159
|
+
throw new BuildError('cache', `${file}: cache.redis.url must be "env:NAME" (a literal URL is refused: it may hold a password)`)
|
|
160
|
+
if (redis) checkRedisTable(file, redis)
|
|
161
|
+
for (const n of [name, broadcast]) {
|
|
162
|
+
if (n === undefined || n === 'custom') continue
|
|
163
|
+
try {
|
|
164
|
+
Bun.resolveSync(BUILTINS[n]!, appRoot)
|
|
165
|
+
} catch {
|
|
166
|
+
throw new BuildError('cache', `${file}: ${installHint(n)}`)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const options = name === 'custom' ? { ...((t.options as Record<string, unknown> | undefined) ?? {}) } : builtinOptions(name, t)
|
|
170
|
+
let namespace = (await packageName(appRoot)) ?? 'brust'
|
|
171
|
+
if (t.namespace !== undefined) {
|
|
172
|
+
if (typeof t.namespace !== 'string' || !/^[A-Za-z0-9@._/-]{1,64}$/.test(t.namespace))
|
|
173
|
+
throw new BuildError('cache', `${file}: cache.namespace must be 1..64 of [A-Za-z0-9@._/-]`)
|
|
174
|
+
namespace = t.namespace
|
|
175
|
+
}
|
|
176
|
+
return {
|
|
177
|
+
adapter: name,
|
|
178
|
+
module,
|
|
179
|
+
broadcast,
|
|
180
|
+
options,
|
|
181
|
+
broadcastOptions: broadcast ? builtinOptions(broadcast, t) : undefined,
|
|
182
|
+
namespace,
|
|
183
|
+
getTimeoutMs: t.get_timeout_ms === undefined ? undefined : rangeInt(file, 'get_timeout_ms', t.get_timeout_ms, 1, 1000, 50),
|
|
184
|
+
maxBodyBytes: rangeInt(file, 'max_body_bytes', t.max_body_bytes, 1, MAX_L2_BODY_BYTES, 1_048_576),
|
|
185
|
+
epochIntervalMs: rangeInt(file, 'epoch_interval_ms', t.epoch_interval_ms, 250, 60_000, 1000),
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function cfCapacity(file: string, key: string, v: unknown, dflt: number): number {
|
|
190
|
+
if (v === undefined) return dflt
|
|
191
|
+
if (typeof v !== 'number' || !Number.isInteger(v) || v < 1 || v > U32_MAX)
|
|
192
|
+
throw new BuildError('config', `${file}: cloudflare.${key} must be an integer in 1..${U32_MAX} (got ${String(v)})`)
|
|
193
|
+
return v
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function parseCloudflare(file: string, appRoot: string, raw: unknown): CloudflareBuildConfig | null {
|
|
197
|
+
if (raw === undefined) return null
|
|
198
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) throw new BuildError('config', `${file}: [cloudflare] must be a table`)
|
|
199
|
+
const t = raw as Record<string, unknown>
|
|
200
|
+
for (const k of Object.keys(t)) if (!CF_KEYS.has(k)) throw new BuildError('config', `${file}: cloudflare.${k} is not a known key`)
|
|
201
|
+
if (t.name !== undefined && (typeof t.name !== 'string' || t.name.trim() === ''))
|
|
202
|
+
throw new BuildError('config', `${file}: cloudflare.name must be a non-empty string`)
|
|
203
|
+
let compatibilityDate: string | undefined
|
|
204
|
+
if (t.compatibility_date !== undefined) {
|
|
205
|
+
const d = t.compatibility_date
|
|
206
|
+
if (typeof d !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(d)) throw new BuildError('config', `${file}: cloudflare.compatibility_date must be YYYY-MM-DD`)
|
|
207
|
+
if (d < CF_MIN_COMPAT_DATE)
|
|
208
|
+
throw new BuildError('config', `${file}: cloudflare.compatibility_date must be >= ${CF_MIN_COMPAT_DATE} (nodejs_compat is default from then)`)
|
|
209
|
+
compatibilityDate = d
|
|
210
|
+
}
|
|
211
|
+
if (t.stats !== undefined && typeof t.stats !== 'boolean') throw new BuildError('config', `${file}: cloudflare.stats must be a boolean`)
|
|
212
|
+
if (t.edge_cache !== undefined) {
|
|
213
|
+
if (t.edge_cache === 'workers-cache') throw new BuildError('config', `${file}: cloudflare.edge_cache = "workers-cache" lands with M4-CF-7`)
|
|
214
|
+
if (t.edge_cache !== 'off') throw new BuildError('config', `${file}: cloudflare.edge_cache must be "off"`)
|
|
215
|
+
}
|
|
216
|
+
const alias: Record<string, string> = {}
|
|
217
|
+
if (t.alias !== undefined) {
|
|
218
|
+
if (t.alias === null || typeof t.alias !== 'object' || Array.isArray(t.alias))
|
|
219
|
+
throw new BuildError('config', `${file}: cloudflare.alias must be a table of module paths`)
|
|
220
|
+
for (const [from, to] of Object.entries(t.alias as Record<string, unknown>)) {
|
|
221
|
+
if (typeof to !== 'string' || to.trim() === '') throw new BuildError('config', `${file}: cloudflare.alias["${from}"] must be a non-empty string`)
|
|
222
|
+
alias[resolveAlias(file, appRoot, from)] = resolveAlias(file, appRoot, to)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return {
|
|
226
|
+
name: t.name === undefined ? undefined : (t.name as string).trim(),
|
|
227
|
+
compatibilityDate,
|
|
228
|
+
l1Capacity: cfCapacity(file, 'l1_capacity', t.l1_capacity, 256),
|
|
229
|
+
jobCacheCapacity: cfCapacity(file, 'job_cache_capacity', t.job_cache_capacity, 512),
|
|
230
|
+
stats: (t.stats as boolean | undefined) ?? false,
|
|
231
|
+
edgeCache: 'off',
|
|
232
|
+
alias,
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function resolveAlias(file: string, appRoot: string, p: string): string {
|
|
237
|
+
const rel = p.trim().replace(/^\.\//, '')
|
|
238
|
+
try {
|
|
239
|
+
return Bun.resolveSync(`./${rel}`, appRoot)
|
|
240
|
+
} catch {
|
|
241
|
+
throw new BuildError('config', `${file}: cloudflare.alias: cannot resolve "${p}" from the app root`)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function parseActions(file: string, raw: unknown, actionsOnly: boolean): ActionsBuildConfig | null {
|
|
27
246
|
if (raw === undefined) return null
|
|
28
247
|
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) throw new BuildError('config', `${file}: [actions] must be a table`)
|
|
29
248
|
const t = raw as Record<string, unknown>
|
|
@@ -34,26 +253,63 @@ function parseActions(file: string, raw: unknown): ActionsBuildConfig | null {
|
|
|
34
253
|
}
|
|
35
254
|
let prefix = DEFAULT_ACTION_PREFIX
|
|
36
255
|
if (t.prefix !== undefined) {
|
|
37
|
-
|
|
256
|
+
// `/` is the prefix of an actions-only deployment; a page-only build (base_url, no local app) may name it too.
|
|
257
|
+
const why = prefixError(t.prefix, actionsOnly || t.base_url !== undefined)
|
|
38
258
|
if (why) throw new BuildError('config', `${file}: actions.prefix ${why}`)
|
|
39
259
|
prefix = t.prefix as string
|
|
40
260
|
}
|
|
41
261
|
let maxBodyBytes = DEFAULT_MAX_BODY_BYTES
|
|
42
262
|
if (t.max_body_bytes !== undefined) {
|
|
43
263
|
const n = t.max_body_bytes
|
|
44
|
-
if (typeof n !== 'number' || !Number.isInteger(n) || n < 1 || n >
|
|
45
|
-
throw new BuildError('config', `${file}: actions.max_body_bytes must be an integer in 1..${
|
|
264
|
+
if (typeof n !== 'number' || !Number.isInteger(n) || n < 1 || n > MAX_ACTION_BYTES)
|
|
265
|
+
throw new BuildError('config', `${file}: actions.max_body_bytes must be an integer in 1..${MAX_ACTION_BYTES} (got ${String(n)})`)
|
|
46
266
|
maxBodyBytes = n
|
|
47
267
|
}
|
|
48
|
-
|
|
268
|
+
let maxResponseBytes = DEFAULT_MAX_RESPONSE_BYTES
|
|
269
|
+
if (t.max_response_bytes !== undefined) {
|
|
270
|
+
const n = t.max_response_bytes
|
|
271
|
+
if (typeof n !== 'number' || !Number.isInteger(n) || n < MIN_RESPONSE_BYTES || n > MAX_ACTION_BYTES)
|
|
272
|
+
throw new BuildError('config', `${file}: actions.max_response_bytes must be an integer in ${MIN_RESPONSE_BYTES}..${MAX_ACTION_BYTES} (got ${String(n)})`)
|
|
273
|
+
maxResponseBytes = n
|
|
274
|
+
}
|
|
275
|
+
let crossSite: 'reject' | 'allow' = 'reject'
|
|
276
|
+
if (t.cross_site !== undefined) {
|
|
277
|
+
if (t.cross_site !== 'reject' && t.cross_site !== 'allow') throw new BuildError('config', `${file}: actions.cross_site must be "reject" or "allow"`)
|
|
278
|
+
crossSite = t.cross_site
|
|
279
|
+
}
|
|
280
|
+
let baseUrl: string | undefined
|
|
281
|
+
if (t.base_url !== undefined) {
|
|
282
|
+
const why = baseUrlError(t.base_url)
|
|
283
|
+
if (why) throw new BuildError('config', `${file}: actions.base_url ${why}`)
|
|
284
|
+
baseUrl = t.base_url as string
|
|
285
|
+
}
|
|
286
|
+
let credentials: ActionCredentials = 'same-origin'
|
|
287
|
+
if (t.credentials !== undefined) {
|
|
288
|
+
if (typeof t.credentials !== 'string' || !ACTION_CREDENTIALS.includes(t.credentials as ActionCredentials))
|
|
289
|
+
throw new BuildError('config', `${file}: actions.credentials must be one of ${ACTION_CREDENTIALS.join(', ')} (got ${JSON.stringify(t.credentials)})`)
|
|
290
|
+
credentials = t.credentials as ActionCredentials
|
|
291
|
+
}
|
|
292
|
+
return { entry, prefix, maxBodyBytes, maxResponseBytes, crossSite, baseUrl, credentials, entryExplicit: t.entry !== undefined }
|
|
49
293
|
}
|
|
50
294
|
|
|
51
295
|
const DEFAULT_ENTRY = 'actions.ts'
|
|
52
296
|
|
|
53
|
-
|
|
297
|
+
/** `[css]`: only `global`, a list of `.css` files relative to the app root (checked to exist by the build). */
|
|
298
|
+
function parseCss(file: string, raw: unknown): string[] {
|
|
299
|
+
if (raw === undefined) return []
|
|
300
|
+
if (!isTable(raw)) throw new BuildError('config', `${file}: [css] must be a table`)
|
|
301
|
+
for (const k of Object.keys(raw)) if (k !== 'global') throw new BuildError('config', `${file}: unknown key css.${k} (allowed: global)`)
|
|
302
|
+
const g = raw.global
|
|
303
|
+
if (g === undefined) return []
|
|
304
|
+
if (!Array.isArray(g) || g.some((p) => typeof p !== 'string' || !p.trim().endsWith('.css')))
|
|
305
|
+
throw new BuildError('config', `${file}: css.global must be an array of .css paths (relative to the app root)`)
|
|
306
|
+
return g.map((p: string) => p.trim())
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export async function loadBuildConfig(appRoot: string, o: { actionsOnly?: boolean } = {}): Promise<BuildConfig> {
|
|
54
310
|
const file = join(appRoot, 'brust.toml')
|
|
55
311
|
const f = Bun.file(file)
|
|
56
|
-
if (!(await f.exists())) return { serverOnly: [], actions: null }
|
|
312
|
+
if (!(await f.exists())) return { serverOnly: [], actions: null, cloudflare: null, cache: null, cssGlobal: [] }
|
|
57
313
|
let parsed: unknown
|
|
58
314
|
try {
|
|
59
315
|
parsed = Bun.TOML.parse(await f.text())
|
|
@@ -61,13 +317,16 @@ export async function loadBuildConfig(appRoot: string): Promise<BuildConfig> {
|
|
|
61
317
|
throw new BuildError('config', `failed to parse ${file}: ${(e as Error).message}`)
|
|
62
318
|
}
|
|
63
319
|
const root = (parsed ?? {}) as Record<string, unknown>
|
|
64
|
-
const actions = parseActions(file, root.actions)
|
|
320
|
+
const actions = parseActions(file, root.actions, o.actionsOnly === true)
|
|
321
|
+
const cloudflare = parseCloudflare(file, appRoot, root.cloudflare)
|
|
322
|
+
const cache = await parseCache(file, appRoot, root.cache)
|
|
323
|
+
const cssGlobal = parseCss(file, root.css)
|
|
65
324
|
const build = root.build
|
|
66
|
-
if (build === undefined) return { serverOnly: [], actions }
|
|
325
|
+
if (build === undefined) return { serverOnly: [], actions, cloudflare, cache, cssGlobal }
|
|
67
326
|
if (build === null || typeof build !== 'object' || Array.isArray(build)) throw new BuildError('config', `${file}: [build] must be a table`)
|
|
68
327
|
const so = (build as Record<string, unknown>).server_only
|
|
69
|
-
if (so === undefined) return { serverOnly: [], actions }
|
|
328
|
+
if (so === undefined) return { serverOnly: [], actions, cloudflare, cache, cssGlobal }
|
|
70
329
|
if (!Array.isArray(so) || so.some((p) => typeof p !== 'string' || p.trim() === ''))
|
|
71
330
|
throw new BuildError('config', `${file}: build.server_only must be an array of non-empty strings`)
|
|
72
|
-
return { serverOnly: so.map((p: string) => p.trim().replace(/^\.\//, '')), actions }
|
|
331
|
+
return { serverOnly: so.map((p: string) => p.trim().replace(/^\.\//, '')), actions, cloudflare, cache, cssGlobal }
|
|
73
332
|
}
|