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.
- package/README.md +110 -0
- package/package.json +92 -0
- package/runtime/actions.ts +65 -0
- package/runtime/bun.lock +236 -0
- package/runtime/cli/actions-prebuilt-plugin.ts +97 -0
- package/runtime/cli/build.ts +252 -0
- package/runtime/cli/dev.ts +92 -0
- package/runtime/cli/index.ts +30 -0
- package/runtime/cli/native-routes-emit.ts +171 -0
- package/runtime/cli/native-shim-plugin.ts +85 -0
- package/runtime/cli/new.ts +208 -0
- package/runtime/cli/templates/minimal/README.md.tmpl +16 -0
- package/runtime/cli/templates/minimal/_gitignore +4 -0
- package/runtime/cli/templates/minimal/app.css +6 -0
- package/runtime/cli/templates/minimal/components/Counter.tsx +13 -0
- package/runtime/cli/templates/minimal/components/Layout.tsx +16 -0
- package/runtime/cli/templates/minimal/index.ts +4 -0
- package/runtime/cli/templates/minimal/package.json.tmpl +21 -0
- package/runtime/cli/templates/minimal/pages/Home.tsx.tmpl +16 -0
- package/runtime/cli/templates/minimal/routes.tsx +6 -0
- package/runtime/cli/templates/minimal/tsconfig.json +20 -0
- package/runtime/client/index.ts +121 -0
- package/runtime/config.ts +148 -0
- package/runtime/css/build.ts +54 -0
- package/runtime/css/component-build.ts +78 -0
- package/runtime/css/component-loader.ts +27 -0
- package/runtime/css/manifest.ts +51 -0
- package/runtime/css/process-modules.ts +56 -0
- package/runtime/css/route-deps.ts +33 -0
- package/runtime/css/scan-imports.ts +79 -0
- package/runtime/css.ts +39 -0
- package/runtime/dev/client.ts +49 -0
- package/runtime/dev/coordinator.ts +127 -0
- package/runtime/dev/inject.ts +17 -0
- package/runtime/dev/tui.ts +109 -0
- package/runtime/dev/watcher.ts +109 -0
- package/runtime/dev/worker-registry.ts +96 -0
- package/runtime/dev/ws-channel.ts +99 -0
- package/runtime/index.d.ts +199 -0
- package/runtime/index.js +604 -0
- package/runtime/index.ts +618 -0
- package/runtime/islands/__fixtures__/NoDefault.tsx +3 -0
- package/runtime/islands/__fixtures__/StubIsland.tsx +7 -0
- package/runtime/islands/__fixtures__/ThrowingIsland.tsx +9 -0
- package/runtime/islands/_entries/react-dom.ts +7 -0
- package/runtime/islands/_entries/react.ts +11 -0
- package/runtime/islands/bootstrap.ts +241 -0
- package/runtime/islands/build.ts +141 -0
- package/runtime/islands/importmap.ts +17 -0
- package/runtime/islands/island.tsx +58 -0
- package/runtime/islands/native-render.ts +153 -0
- package/runtime/mcp/extractor.ts +160 -0
- package/runtime/mcp/manifest.ts +50 -0
- package/runtime/mcp/schema.ts +124 -0
- package/runtime/mcp/server.ts +250 -0
- package/runtime/render/inject-css-link.ts +59 -0
- package/runtime/render/inject-dev-client.ts +49 -0
- package/runtime/render/stream.ts +304 -0
- package/runtime/routes.ts +1406 -0
- package/runtime/scan-actions.ts +172 -0
- package/runtime/sse/handler.ts +85 -0
- package/runtime/tsconfig.json +14 -0
- 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,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,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,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
|
+
}
|