brustjs 0.1.0-alpha

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 (63) hide show
  1. package/README.md +110 -0
  2. package/package.json +92 -0
  3. package/runtime/actions.ts +65 -0
  4. package/runtime/bun.lock +236 -0
  5. package/runtime/cli/actions-prebuilt-plugin.ts +97 -0
  6. package/runtime/cli/build.ts +252 -0
  7. package/runtime/cli/dev.ts +92 -0
  8. package/runtime/cli/index.ts +30 -0
  9. package/runtime/cli/native-routes-emit.ts +171 -0
  10. package/runtime/cli/native-shim-plugin.ts +85 -0
  11. package/runtime/cli/new.ts +208 -0
  12. package/runtime/cli/templates/minimal/README.md.tmpl +16 -0
  13. package/runtime/cli/templates/minimal/_gitignore +4 -0
  14. package/runtime/cli/templates/minimal/app.css +6 -0
  15. package/runtime/cli/templates/minimal/components/Counter.tsx +13 -0
  16. package/runtime/cli/templates/minimal/components/Layout.tsx +16 -0
  17. package/runtime/cli/templates/minimal/index.ts +4 -0
  18. package/runtime/cli/templates/minimal/package.json.tmpl +21 -0
  19. package/runtime/cli/templates/minimal/pages/Home.tsx.tmpl +16 -0
  20. package/runtime/cli/templates/minimal/routes.tsx +6 -0
  21. package/runtime/cli/templates/minimal/tsconfig.json +20 -0
  22. package/runtime/client/index.ts +121 -0
  23. package/runtime/config.ts +148 -0
  24. package/runtime/css/build.ts +54 -0
  25. package/runtime/css/component-build.ts +78 -0
  26. package/runtime/css/component-loader.ts +27 -0
  27. package/runtime/css/manifest.ts +51 -0
  28. package/runtime/css/process-modules.ts +56 -0
  29. package/runtime/css/route-deps.ts +33 -0
  30. package/runtime/css/scan-imports.ts +79 -0
  31. package/runtime/css.ts +39 -0
  32. package/runtime/dev/client.ts +49 -0
  33. package/runtime/dev/coordinator.ts +127 -0
  34. package/runtime/dev/inject.ts +17 -0
  35. package/runtime/dev/tui.ts +109 -0
  36. package/runtime/dev/watcher.ts +109 -0
  37. package/runtime/dev/worker-registry.ts +96 -0
  38. package/runtime/dev/ws-channel.ts +99 -0
  39. package/runtime/index.d.ts +199 -0
  40. package/runtime/index.js +604 -0
  41. package/runtime/index.ts +618 -0
  42. package/runtime/islands/__fixtures__/NoDefault.tsx +3 -0
  43. package/runtime/islands/__fixtures__/StubIsland.tsx +7 -0
  44. package/runtime/islands/__fixtures__/ThrowingIsland.tsx +9 -0
  45. package/runtime/islands/_entries/react-dom.ts +7 -0
  46. package/runtime/islands/_entries/react.ts +11 -0
  47. package/runtime/islands/bootstrap.ts +241 -0
  48. package/runtime/islands/build.ts +141 -0
  49. package/runtime/islands/importmap.ts +17 -0
  50. package/runtime/islands/island.tsx +58 -0
  51. package/runtime/islands/native-render.ts +153 -0
  52. package/runtime/mcp/extractor.ts +160 -0
  53. package/runtime/mcp/manifest.ts +50 -0
  54. package/runtime/mcp/schema.ts +124 -0
  55. package/runtime/mcp/server.ts +250 -0
  56. package/runtime/render/inject-css-link.ts +59 -0
  57. package/runtime/render/inject-dev-client.ts +49 -0
  58. package/runtime/render/stream.ts +304 -0
  59. package/runtime/routes.ts +1406 -0
  60. package/runtime/scan-actions.ts +172 -0
  61. package/runtime/sse/handler.ts +85 -0
  62. package/runtime/tsconfig.json +14 -0
  63. package/runtime/ws/handler.ts +151 -0
@@ -0,0 +1,208 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'
3
+ import { dirname, isAbsolute, join, resolve } from 'node:path'
4
+
5
+ const TEMPLATE_DIR = join(import.meta.dir, 'templates', 'minimal')
6
+
7
+ const NAME_RE = /^[a-z0-9][a-z0-9_-]*$/
8
+ const MAX_NAME_LEN = 50
9
+
10
+ export interface ParsedNewArgs {
11
+ projectName: string
12
+ targetDir: string
13
+ }
14
+
15
+ export function parseArgs(args: string[]): ParsedNewArgs {
16
+ let name: string | undefined
17
+ let dir: string | undefined
18
+
19
+ for (let i = 0; i < args.length; i++) {
20
+ const a = args[i]!
21
+ if (a === '--dir') {
22
+ dir = args[++i]
23
+ if (!dir) throw new Error('brust new: --dir requires a value')
24
+ } else if (a.startsWith('--dir=')) {
25
+ dir = a.slice('--dir='.length)
26
+ } else if (a.startsWith('-')) {
27
+ throw new Error(`brust new: unknown flag "${a}"`)
28
+ } else if (name === undefined) {
29
+ name = a
30
+ } else {
31
+ throw new Error(`brust new: unexpected positional argument "${a}"`)
32
+ }
33
+ }
34
+
35
+ if (!name) {
36
+ throw new Error('brust new: missing project name. Usage: brust new <name> [--dir <path>]')
37
+ }
38
+ if (name.length > MAX_NAME_LEN) {
39
+ throw new Error(`brust new: project name too long (max ${MAX_NAME_LEN} chars)`)
40
+ }
41
+ if (!NAME_RE.test(name)) {
42
+ throw new Error(
43
+ `brust new: invalid project name "${name}" — use lowercase letters, digits, hyphens, underscores; must start with a letter or digit`,
44
+ )
45
+ }
46
+
47
+ const cwd = process.cwd()
48
+ const targetDir = dir ? (isAbsolute(dir) ? dir : resolve(cwd, dir)) : resolve(cwd, name)
49
+
50
+ return { projectName: name, targetDir }
51
+ }
52
+
53
+ export interface BrustRef {
54
+ kind: 'file' | 'version'
55
+ spec: string // JSON-encoded string value (e.g. "file:/abs" or "^0.1.0")
56
+ }
57
+
58
+ function hasSourceMarkers(dir: string): boolean {
59
+ // Post-2026-05-28 workspace refactor: brust source layout is
60
+ // <root>/Cargo.toml (workspace)
61
+ // <root>/crates/brust/ (the brust cdylib crate)
62
+ // <root>/runtime/cli/...
63
+ // Pre-refactor layout had `<root>/src/` instead of `<root>/crates/brust/`;
64
+ // we no longer check `<root>/src` because the workspace root never has one.
65
+ return (
66
+ existsSync(join(dir, 'Cargo.toml')) &&
67
+ existsSync(join(dir, 'crates/brust/src')) &&
68
+ existsSync(join(dir, 'runtime/cli/index.ts'))
69
+ )
70
+ }
71
+
72
+ export function resolveBrustRef(startDir: string = import.meta.dir): BrustRef {
73
+ let dir = startDir
74
+ while (true) {
75
+ const pkgPath = join(dir, 'package.json')
76
+ if (existsSync(pkgPath)) {
77
+ try {
78
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
79
+ if (pkg.name === 'brustjs') {
80
+ if (hasSourceMarkers(dir)) {
81
+ return { kind: 'file', spec: `file:${dir}` }
82
+ }
83
+ const version = typeof pkg.version === 'string' ? pkg.version : '0.0.0'
84
+ return { kind: 'version', spec: `^${version}` }
85
+ }
86
+ } catch {
87
+ // malformed package.json — keep walking
88
+ }
89
+ }
90
+ const parent = dirname(dir)
91
+ if (parent === dir) {
92
+ throw new Error('brust new: cannot locate the brust package — is your installation intact?')
93
+ }
94
+ dir = parent
95
+ }
96
+ }
97
+
98
+ export interface CopyTemplateOpts {
99
+ templateDir: string
100
+ targetDir: string
101
+ substitutions: Record<string, string>
102
+ }
103
+
104
+ export async function copyTemplate(opts: CopyTemplateOpts): Promise<void> {
105
+ if (!existsSync(opts.templateDir)) {
106
+ throw new Error(
107
+ `brust new: template directory not found at ${opts.templateDir}; this is a brust installation bug`,
108
+ )
109
+ }
110
+ await copyDir(opts.templateDir, opts.targetDir, opts.substitutions)
111
+ }
112
+
113
+ async function copyDir(src: string, dst: string, subs: Record<string, string>): Promise<void> {
114
+ await mkdir(dst, { recursive: true })
115
+ const entries = await readdir(src, { withFileTypes: true })
116
+ for (const ent of entries) {
117
+ const srcPath = join(src, ent.name)
118
+ const dstName = renameForEmit(ent.name)
119
+ const dstPath = join(dst, dstName)
120
+ if (ent.isDirectory()) {
121
+ await copyDir(srcPath, dstPath, subs)
122
+ } else if (ent.isFile()) {
123
+ const isTmpl = ent.name.endsWith('.tmpl')
124
+ if (isTmpl) {
125
+ const raw = await readFile(srcPath, 'utf8')
126
+ const out = applySubstitutions(raw, subs)
127
+ await writeFile(dstPath, out)
128
+ } else {
129
+ const buf = await readFile(srcPath)
130
+ await writeFile(dstPath, buf)
131
+ }
132
+ }
133
+ }
134
+ }
135
+
136
+ function renameForEmit(name: string): string {
137
+ if (name.endsWith('.tmpl')) return name.slice(0, -'.tmpl'.length)
138
+ if (name === '_gitignore') return '.gitignore'
139
+ return name
140
+ }
141
+
142
+ function applySubstitutions(text: string, subs: Record<string, string>): string {
143
+ let out = text
144
+ for (const [key, value] of Object.entries(subs)) {
145
+ out = out.split(key).join(value)
146
+ }
147
+ return out
148
+ }
149
+
150
+ export async function runNew(args: string[]): Promise<void> {
151
+ let parsed: ParsedNewArgs
152
+ try {
153
+ parsed = parseArgs(args)
154
+ } catch (e) {
155
+ console.error(e instanceof Error ? e.message : String(e))
156
+ process.exit(1)
157
+ }
158
+
159
+ const { projectName, targetDir } = parsed
160
+
161
+ const targetExisted = existsSync(targetDir)
162
+ if (targetExisted) {
163
+ const entries = await readdir(targetDir)
164
+ if (entries.length > 0) {
165
+ console.error(`brust new: target directory "${targetDir}" is not empty`)
166
+ process.exit(1)
167
+ }
168
+ }
169
+
170
+ let brustRef: BrustRef
171
+ try {
172
+ brustRef = resolveBrustRef()
173
+ } catch (e) {
174
+ console.error(e instanceof Error ? e.message : String(e))
175
+ process.exit(1)
176
+ }
177
+
178
+ try {
179
+ await copyTemplate({
180
+ templateDir: TEMPLATE_DIR,
181
+ targetDir,
182
+ substitutions: {
183
+ __PROJECT_NAME__: projectName,
184
+ __BRUST_DEP__: JSON.stringify(brustRef.spec),
185
+ },
186
+ })
187
+ } catch (e) {
188
+ if (!targetExisted) {
189
+ await rm(targetDir, { recursive: true, force: true }).catch(() => {})
190
+ }
191
+ console.error(`brust new: failed to scaffold (${e instanceof Error ? e.message : String(e)})`)
192
+ process.exit(1)
193
+ }
194
+
195
+ printNextSteps(projectName, targetDir)
196
+ }
197
+
198
+ function printNextSteps(name: string, targetDir: string): void {
199
+ const cwd = process.cwd()
200
+ const displayPath = targetDir.startsWith(cwd + '/')
201
+ ? './' + targetDir.slice(cwd.length + 1)
202
+ : targetDir
203
+ console.log(`Created ${name} at ${targetDir}\n`)
204
+ console.log(`Next:`)
205
+ console.log(` cd ${displayPath}`)
206
+ console.log(` bun install`)
207
+ console.log(` bun run dev`)
208
+ }
@@ -0,0 +1,16 @@
1
+ # __PROJECT_NAME__
2
+
3
+ Scaffolded with `brust new`.
4
+
5
+ ## Develop
6
+
7
+ bun install
8
+ bun run dev
9
+
10
+ Open http://127.0.0.1:3000.
11
+
12
+ ## Build
13
+
14
+ bun run build
15
+
16
+ Outputs a standalone `dist/` you can ship — `bun run dist/index.js` boots the server.
@@ -0,0 +1,4 @@
1
+ node_modules/
2
+ .brust/
3
+ dist/
4
+ brust.toml
@@ -0,0 +1,6 @@
1
+ @import "tailwindcss";
2
+ @source "./**/*.{tsx,ts}";
3
+
4
+ @theme {
5
+ --color-brand: #2563eb;
6
+ }
@@ -0,0 +1,13 @@
1
+ import { useState } from 'react'
2
+
3
+ export default function Counter({ start = 0, label = 'count' }: { start?: number; label?: string }) {
4
+ const [n, setN] = useState(start)
5
+ return (
6
+ <button
7
+ onClick={() => setN(n + 1)}
8
+ className="px-3 py-1.5 bg-brand text-white rounded text-sm hover:opacity-90 transition-opacity"
9
+ >
10
+ {label}: {n}
11
+ </button>
12
+ )
13
+ }
@@ -0,0 +1,16 @@
1
+ import type { ReactNode } from 'react'
2
+
3
+ export default function Layout({ title, children }: { title: string; children: ReactNode }) {
4
+ return (
5
+ <html lang="en">
6
+ <head>
7
+ <meta charSet="utf-8" />
8
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
9
+ <title>{title}</title>
10
+ </head>
11
+ <body className="bg-white text-gray-900 font-sans">
12
+ <main className="max-w-3xl mx-auto px-5 py-8">{children}</main>
13
+ </body>
14
+ </html>
15
+ )
16
+ }
@@ -0,0 +1,4 @@
1
+ import { brust } from 'brustjs'
2
+ import { routes } from './routes'
3
+
4
+ await brust.run({ routes, entry: import.meta.url })
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "__PROJECT_NAME__",
3
+ "version": "0.0.1",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "brustjs dev",
8
+ "build": "brustjs build"
9
+ },
10
+ "dependencies": {
11
+ "brustjs": __BRUST_DEP__,
12
+ "react": "^19.2.6",
13
+ "react-dom": "^19.2.6"
14
+ },
15
+ "devDependencies": {
16
+ "@types/bun": "latest",
17
+ "@types/react": "^19.2.15",
18
+ "@types/react-dom": "^19.2.3",
19
+ "typescript": "^6.0.3"
20
+ }
21
+ }
@@ -0,0 +1,16 @@
1
+ import { Island } from 'brustjs'
2
+ import Layout from '../components/Layout'
3
+ import Counter from '../components/Counter'
4
+
5
+ export default function Home() {
6
+ return (
7
+ <Layout title="__PROJECT_NAME__">
8
+ <h1 className="text-3xl font-bold mb-4">Welcome to brust</h1>
9
+ <p className="mb-4 text-gray-700">
10
+ Edit <code className="bg-gray-100 px-1 rounded">pages/Home.tsx</code> and save —
11
+ <code className="bg-gray-100 px-1 rounded ml-1">brustjs dev</code> will reload.
12
+ </p>
13
+ <Island component={Counter} props={{ start: 0, label: 'clicks' }} hydrate="load" />
14
+ </Layout>
15
+ )
16
+ }
@@ -0,0 +1,6 @@
1
+ import { defineRoutes } from 'brust/routes'
2
+ import Home from './pages/Home'
3
+
4
+ export const routes = defineRoutes([
5
+ { path: '/', Component: Home },
6
+ ])
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "lib": ["ESNext"],
4
+ "target": "ESNext",
5
+ "module": "Preserve",
6
+ "moduleDetection": "force",
7
+ "jsx": "react-jsx",
8
+ "allowJs": true,
9
+ "types": ["bun"],
10
+ "moduleResolution": "bundler",
11
+ "allowImportingTsExtensions": true,
12
+ "verbatimModuleSyntax": true,
13
+ "noEmit": true,
14
+ "strict": true,
15
+ "skipLibCheck": true,
16
+ "noFallthroughCasesInSwitch": true,
17
+ "noUncheckedIndexedAccess": true,
18
+ "noImplicitOverride": true
19
+ }
20
+ }
@@ -0,0 +1,121 @@
1
+ /** Browser-only client helpers. This module is loaded by hydrated island
2
+ * bundles. It intentionally does NOT import from runtime/routes.ts or
3
+ * runtime/index.ts — those pull in React and server-side surface that the
4
+ * client doesn't need.
5
+ */
6
+
7
+ export class BrustActionError extends Error {
8
+ constructor(
9
+ message: string,
10
+ public readonly status: number,
11
+ public readonly payload: unknown,
12
+ ) {
13
+ super(message)
14
+ this.name = 'BrustActionError'
15
+ }
16
+ }
17
+
18
+ /** Untyped server-fn shape used as the generic constraint. The client never
19
+ * sees BrustRequest, so we type the leading req as `any` here — the helper
20
+ * strips it from the call site via DropReq<F>. */
21
+ export type ServerFn = (req: any, ...args: any[]) => Promise<any>
22
+
23
+ /** Drop the leading `req` arg from F's parameter list. */
24
+ type DropReq<F> = F extends (req: any, ...args: infer A) => infer R ? (...args: A) => R : never
25
+
26
+ /** Build a typed RPC stub for an action.
27
+ *
28
+ * Usage:
29
+ * import type * as srv from '../actions'
30
+ * const createNote = action<typeof srv.createNote>('createNote')
31
+ * const { id } = await createNote('hello') // typed Promise<{ id: string }>
32
+ *
33
+ * @param id The action id — matches the named export from a `'use server'`
34
+ * file discovered by `brust.scanActions()`. Use `withMiddleware`
35
+ * to attach per-action middleware on the server side.
36
+ */
37
+ export function action<F extends ServerFn>(id: string): DropReq<F> {
38
+ return (async (...args: unknown[]) => {
39
+ const res = await fetch(`/_brust/action/${encodeURIComponent(id)}`, {
40
+ method: 'POST',
41
+ headers: { 'Content-Type': 'application/json' },
42
+ body: JSON.stringify(args),
43
+ })
44
+ const text = await res.text()
45
+ if (!res.ok) {
46
+ const parsed = safeParse(text)
47
+ const message =
48
+ parsed &&
49
+ typeof parsed === 'object' &&
50
+ parsed !== null &&
51
+ 'error' in parsed &&
52
+ parsed.error &&
53
+ typeof parsed.error === 'object' &&
54
+ 'message' in parsed.error &&
55
+ typeof parsed.error.message === 'string'
56
+ ? parsed.error.message
57
+ : text || 'action failed'
58
+ throw new BrustActionError(message, res.status, parsed ?? text)
59
+ }
60
+ return text ? JSON.parse(text) : undefined
61
+ }) as DropReq<F>
62
+ }
63
+
64
+ function safeParse(s: string): unknown | null {
65
+ try {
66
+ return JSON.parse(s)
67
+ } catch {
68
+ return null
69
+ }
70
+ }
71
+
72
+ type FormActionFn<F> = F extends (req: any, fd: FormData) => infer R ? (fd: FormData) => R : never
73
+
74
+ /** Build a typed RPC stub for a form-receiving action.
75
+ *
76
+ * The server handler MUST be declared with signature
77
+ * `(req: BrustRequest, fd: FormData) => Promise<R>`. The framework parses
78
+ * the request's multipart or form-urlencoded body server-side and passes
79
+ * a FormData instance to the handler.
80
+ *
81
+ * Usage:
82
+ * import type * as srv from '../actions'
83
+ * const uploadAvatar = formAction<typeof srv.uploadAvatar>('uploadAvatar')
84
+ * const result = await uploadAvatar(new FormData(form))
85
+ *
86
+ * @param id The action id — matches the named export from a `'use server'`
87
+ * file discovered by `brust.scanActions()`.
88
+ */
89
+ export function formAction<F extends (req: any, fd: FormData) => unknown>(
90
+ id: string,
91
+ ): FormActionFn<F> {
92
+ return (async (fd: FormData) => {
93
+ if (!(fd instanceof FormData)) {
94
+ throw new TypeError('formAction expects a FormData argument')
95
+ }
96
+ // DO NOT set Content-Type manually. fetch() auto-sets
97
+ // 'multipart/form-data; boundary=<random>' when body is a FormData;
98
+ // overriding loses the boundary and the server can't parse the body.
99
+ const res = await fetch(`/_brust/action/${encodeURIComponent(id)}`, {
100
+ method: 'POST',
101
+ body: fd,
102
+ })
103
+ const text = await res.text()
104
+ if (!res.ok) {
105
+ const parsed = safeParse(text)
106
+ const message =
107
+ parsed &&
108
+ typeof parsed === 'object' &&
109
+ parsed !== null &&
110
+ 'error' in parsed &&
111
+ parsed.error &&
112
+ typeof parsed.error === 'object' &&
113
+ 'message' in parsed.error &&
114
+ typeof parsed.error.message === 'string'
115
+ ? parsed.error.message
116
+ : text || 'action failed'
117
+ throw new BrustActionError(message, res.status, parsed ?? text)
118
+ }
119
+ return text ? JSON.parse(text) : undefined
120
+ }) as FormActionFn<F>
121
+ }
@@ -0,0 +1,148 @@
1
+ import os from 'node:os'
2
+ import path from 'node:path'
3
+ import { parse as parseToml } from 'smol-toml'
4
+
5
+ export interface BrustConfig {
6
+ /** TCP port to bind on. Default 3000. */
7
+ port: number
8
+ /** Bun Worker count for render dispatch. Default `availableParallelism()`. */
9
+ workers: number
10
+ /** Cache capacity (entries). Undefined → Rust default of 1000. */
11
+ cacheMaxEntries?: number
12
+ }
13
+
14
+ export class BrustConfigError extends Error {
15
+ constructor(
16
+ message: string,
17
+ public readonly file: string | null,
18
+ ) {
19
+ super(message)
20
+ this.name = 'BrustConfigError'
21
+ }
22
+ }
23
+
24
+ const DEFAULT_PORT = 3000
25
+ // One worker per CPU. Bumping the multiplier above 1 was tuned for I/O-bound
26
+ // renders; on CPU-bound React work (typical) it over-subscribes and amplifies
27
+ // p99 tail. Users with Suspense-heavy / await-heavy renders can override via
28
+ // BRUST_WORKERS or workers.count in brust.toml.
29
+ const defaultWorkers = (): number => os.availableParallelism()
30
+
31
+ const CONFIG_BASENAME = 'brust.toml'
32
+
33
+ /**
34
+ * Resolve Brust configuration. Precedence (low → high): defaults < TOML < env.
35
+ *
36
+ * - Defaults: { port: 3000, workers: availableParallelism() }
37
+ * - TOML: brust.toml at `cwd` (missing file is fine — only a present file with
38
+ * wrong shape is an error).
39
+ * - Env: BRUST_PORT and BRUST_WORKERS override either source.
40
+ */
41
+ export async function loadConfig(cwd: string = process.cwd()): Promise<BrustConfig> {
42
+ let fromToml: Partial<BrustConfig> = {}
43
+ const tomlPath = path.join(cwd, CONFIG_BASENAME)
44
+
45
+ const file = Bun.file(tomlPath)
46
+ if (await file.exists()) {
47
+ let parsed: unknown
48
+ try {
49
+ parsed = parseToml(await file.text())
50
+ } catch (e) {
51
+ throw new BrustConfigError(`failed to parse ${tomlPath}: ${(e as Error).message}`, tomlPath)
52
+ }
53
+ fromToml = extractFromToml(parsed, tomlPath)
54
+ }
55
+
56
+ const fromEnv = extractFromEnv()
57
+
58
+ const port = fromEnv.port ?? fromToml.port ?? DEFAULT_PORT
59
+ const workers = fromEnv.workers ?? fromToml.workers ?? defaultWorkers()
60
+
61
+ return { port, workers, cacheMaxEntries: fromToml.cacheMaxEntries }
62
+ }
63
+
64
+ function extractFromToml(parsed: unknown, file: string): Partial<BrustConfig> {
65
+ if (parsed === null || typeof parsed !== 'object') {
66
+ throw new BrustConfigError(`${file}: top level must be a table`, file)
67
+ }
68
+ const root = parsed as Record<string, unknown>
69
+ const out: Partial<BrustConfig> = {}
70
+
71
+ if ('server' in root) {
72
+ const server = root.server
73
+ if (server === null || typeof server !== 'object') {
74
+ throw new BrustConfigError(`${file}: [server] must be a table`, file)
75
+ }
76
+ const port = (server as Record<string, unknown>).port
77
+ if (port !== undefined) {
78
+ if (typeof port !== 'number' || !Number.isInteger(port) || port < 1 || port > 65535) {
79
+ throw new BrustConfigError(
80
+ `${file}: server.port must be an integer in 1..65535 (got ${JSON.stringify(port)})`,
81
+ file,
82
+ )
83
+ }
84
+ out.port = port
85
+ }
86
+ }
87
+
88
+ if ('workers' in root) {
89
+ const workers = root.workers
90
+ if (workers === null || typeof workers !== 'object') {
91
+ throw new BrustConfigError(`${file}: [workers] must be a table`, file)
92
+ }
93
+ const count = (workers as Record<string, unknown>).count
94
+ if (count !== undefined) {
95
+ if (typeof count !== 'number' || !Number.isInteger(count) || count < 1) {
96
+ throw new BrustConfigError(
97
+ `${file}: workers.count must be a positive integer (got ${JSON.stringify(count)})`,
98
+ file,
99
+ )
100
+ }
101
+ out.workers = count
102
+ }
103
+ }
104
+
105
+ if ('cache' in root) {
106
+ const cache = root.cache
107
+ if (cache === null || typeof cache !== 'object') {
108
+ throw new BrustConfigError(`${file}: [cache] must be a table`, file)
109
+ }
110
+ const maxEntries = (cache as Record<string, unknown>).max_entries
111
+ if (maxEntries !== undefined) {
112
+ if (typeof maxEntries !== 'number' || !Number.isInteger(maxEntries) || maxEntries < 1) {
113
+ throw new BrustConfigError(
114
+ `${file}: cache.max_entries must be a positive integer (got ${JSON.stringify(maxEntries)})`,
115
+ file,
116
+ )
117
+ }
118
+ out.cacheMaxEntries = maxEntries
119
+ }
120
+ }
121
+
122
+ return out
123
+ }
124
+
125
+ function extractFromEnv(): Partial<BrustConfig> {
126
+ const out: Partial<BrustConfig> = {}
127
+ if (process.env.BRUST_PORT) {
128
+ const n = parseInt(process.env.BRUST_PORT, 10)
129
+ if (!Number.isInteger(n) || n < 1 || n > 65535) {
130
+ throw new BrustConfigError(
131
+ `BRUST_PORT must be an integer in 1..65535 (got ${JSON.stringify(process.env.BRUST_PORT)})`,
132
+ null,
133
+ )
134
+ }
135
+ out.port = n
136
+ }
137
+ if (process.env.BRUST_WORKERS) {
138
+ const n = parseInt(process.env.BRUST_WORKERS, 10)
139
+ if (!Number.isInteger(n) || n < 1) {
140
+ throw new BrustConfigError(
141
+ `BRUST_WORKERS must be a positive integer (got ${JSON.stringify(process.env.BRUST_WORKERS)})`,
142
+ null,
143
+ )
144
+ }
145
+ out.workers = n
146
+ }
147
+ return out
148
+ }
@@ -0,0 +1,54 @@
1
+ import { mkdir, readFile, writeFile } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { compile } from '@tailwindcss/node'
4
+ import { Scanner } from '@tailwindcss/oxide'
5
+
6
+ /** The directory that contains this file — runtime/css/. Two dirname() steps
7
+ * reach the brust repo root where node_modules/tailwindcss is installed. */
8
+ const BRUST_ROOT = path.resolve(import.meta.dir, '..', '..')
9
+
10
+ /** Absolute path to tailwindcss/index.css inside brust's own node_modules.
11
+ * Used by the customCssResolver so that `@import "tailwindcss"` always
12
+ * resolves via brust's bundled copy regardless of where the entry CSS lives. */
13
+ const TAILWINDCSS_INDEX = path.join(BRUST_ROOT, 'node_modules', 'tailwindcss', 'index.css')
14
+
15
+ export interface BuildCssOptions {
16
+ /** Absolute path to the entry CSS file (typically <scanRoot>/app.css). */
17
+ entry: string
18
+ /** Absolute path to the output directory. Created if missing. */
19
+ outDir: string
20
+ }
21
+
22
+ export interface CssBuildResult {
23
+ outDir: string
24
+ files: string[]
25
+ }
26
+
27
+ export async function buildCss(opts: BuildCssOptions): Promise<CssBuildResult> {
28
+ const sourceCss = await readFile(opts.entry, 'utf-8')
29
+
30
+ const compiler = await compile(sourceCss, {
31
+ // base: the entry CSS's directory so that @source globs resolve
32
+ // relative to the entry file (e.g. @source "./**/*.tsx" scans
33
+ // the same folder the CSS lives in).
34
+ base: path.dirname(opts.entry),
35
+ onDependency: () => {}, // unused — no watch
36
+ // Redirect `@import "tailwindcss"` to brust's own bundled copy so
37
+ // resolution succeeds even when tailwindcss is not installed in the
38
+ // entry directory (e.g. a tmp dir in tests).
39
+ customCssResolver: async (id: string) => {
40
+ if (id === 'tailwindcss') return TAILWINDCSS_INDEX
41
+ return undefined
42
+ },
43
+ })
44
+
45
+ const scanner = new Scanner({ sources: compiler.sources })
46
+ const candidates = scanner.scan()
47
+
48
+ const output = compiler.build(candidates)
49
+
50
+ await mkdir(opts.outDir, { recursive: true })
51
+ await writeFile(path.join(opts.outDir, 'app.css'), output, 'utf-8')
52
+
53
+ return { outDir: opts.outDir, files: ['app.css'] }
54
+ }