brustjs 0.1.10-alpha → 0.1.12-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 +14 -8
- package/package.json +9 -8
- package/runtime/actions.ts +7 -65
- package/runtime/cli/build.ts +152 -42
- package/runtime/cli/help.ts +125 -0
- package/runtime/cli/index.ts +57 -22
- package/runtime/client/index.ts +5 -104
- package/runtime/define-actions.ts +179 -0
- package/runtime/index.d.ts +11 -5
- package/runtime/index.js +52 -52
- package/runtime/index.ts +80 -56
- package/runtime/islands/native-render.ts +23 -6
- package/runtime/mcp/extractor.ts +240 -88
- package/runtime/mcp/manifest.ts +2 -1
- package/runtime/mcp/server.ts +28 -37
- package/runtime/render/inject-action-prefix.ts +60 -0
- package/runtime/render/stream.ts +5 -2
- package/runtime/routes.ts +110 -56
- package/runtime/standard-schema.ts +29 -0
- package/runtime/treaty.ts +131 -0
- package/runtime/cli/actions-prebuilt-plugin.ts +0 -97
- package/runtime/scan-actions.ts +0 -172
package/README.md
CHANGED
|
@@ -73,6 +73,7 @@ route apiece.
|
|
|
73
73
|
```
|
|
74
74
|
brustjs dev <entry> # dev mode: watcher + WS reload + browser auto-reload
|
|
75
75
|
brustjs build <entry> --out-dir D # prebuilt ./dist/ — run from the project (bun run dist/index.js)
|
|
76
|
+
--target <auto|all|TARGET[,…]> # which native binary to bundle (default: auto = host platform)
|
|
76
77
|
brustjs new <name> # scaffold a project (partial — see Status)
|
|
77
78
|
```
|
|
78
79
|
|
|
@@ -84,13 +85,18 @@ brustjs new <name> # scaffold a project (partial — see Status)
|
|
|
84
85
|
`renderToString` runs once per key, then serves a frozen pair from Rust.
|
|
85
86
|
- **`native: true` routes** — JSX compiled to a jinja template at build time and
|
|
86
87
|
rendered Rust-side (`minijinja`), skipping React on the server entirely.
|
|
87
|
-
- **
|
|
88
|
-
|
|
88
|
+
- **Typed actions** — `defineActions().get/post/put/patch/delete/head(path, ctx => R, { body, query })`
|
|
89
|
+
on the server; `client<typeof actions>()` is an Eden-Treaty-style proxy that
|
|
90
|
+
infers the whole API from the server types (no codegen) and returns
|
|
91
|
+
`{ data, error, status, headers }` (never throws). Standard Schema (zod)
|
|
92
|
+
validation, JSON / urlencoded / multipart bodies.
|
|
89
93
|
- **SSE & WebSockets** as first-class route shapes.
|
|
90
94
|
- Nested routes + dynamic params, per-route typed loaders, request-scoped middleware,
|
|
91
|
-
|
|
92
|
-
- **Agent-first** —
|
|
93
|
-
`/_brust/mcp`
|
|
95
|
+
SPA-style navigation, in-process LRU response cache + island ISR cache, Tailwind v4 + CSS Modules.
|
|
96
|
+
- **Agent-first** — `defineActions` endpoints become MCP **tools** and route
|
|
97
|
+
loaders become **resources** at `/_brust/mcp`; `tools/call` runs through the
|
|
98
|
+
same validation + middleware as an HTTP request, so agents drive the app
|
|
99
|
+
without scraping.
|
|
94
100
|
|
|
95
101
|
## Performance
|
|
96
102
|
|
|
@@ -119,9 +125,9 @@ bench/ · docs/ · architecture.md
|
|
|
119
125
|
## Status
|
|
120
126
|
|
|
121
127
|
Alpha, solo-developed. Linux is tier-1 (io_uring; glibc + musl, 6 prebuilt platform
|
|
122
|
-
binaries). Known partials: `brustjs dev`
|
|
123
|
-
|
|
124
|
-
is opt-in — the scaffold adds it as a project dependency; `@import "tailwindcss"`
|
|
128
|
+
binaries). Known partials: `brustjs dev` reload is a full worker-respawn (not
|
|
129
|
+
state-preserving HMR) — TS, islands, and `.module.css` all reload that way.
|
|
130
|
+
Tailwind is opt-in — the scaffold adds it as a project dependency; `@import "tailwindcss"`
|
|
125
131
|
resolves from your own `node_modules`. Deployment note: the io_uring server needs `io_uring_*`
|
|
126
132
|
syscalls permitted — a default-seccomp container (Docker/k8s) must allow them or run
|
|
127
133
|
`--security-opt seccomp=unconfined`. Roadmap and limitations in
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "brustjs",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.12-alpha",
|
|
4
4
|
"description": "Bun + Rust SSR framework — React on the server, Rust everywhere else (napi cdylib + per-worker SharedArrayBuffer).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -40,12 +40,12 @@
|
|
|
40
40
|
"typescript": "^6.0.3"
|
|
41
41
|
},
|
|
42
42
|
"optionalDependencies": {
|
|
43
|
-
"brustjs-darwin-x64": "0.1.
|
|
44
|
-
"brustjs-darwin-arm64": "0.1.
|
|
45
|
-
"brustjs-linux-x64-gnu": "0.1.
|
|
46
|
-
"brustjs-linux-arm64-gnu": "0.1.
|
|
47
|
-
"brustjs-linux-x64-musl": "0.1.
|
|
48
|
-
"brustjs-linux-arm64-musl": "0.1.
|
|
43
|
+
"brustjs-darwin-x64": "0.1.12-alpha",
|
|
44
|
+
"brustjs-darwin-arm64": "0.1.12-alpha",
|
|
45
|
+
"brustjs-linux-x64-gnu": "0.1.12-alpha",
|
|
46
|
+
"brustjs-linux-arm64-gnu": "0.1.12-alpha",
|
|
47
|
+
"brustjs-linux-x64-musl": "0.1.12-alpha",
|
|
48
|
+
"brustjs-linux-arm64-musl": "0.1.12-alpha"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
51
|
"react": "^19.2.6",
|
|
@@ -59,7 +59,8 @@
|
|
|
59
59
|
"happy-dom": "^20.9.0",
|
|
60
60
|
"react": "^19.2.6",
|
|
61
61
|
"react-dom": "^19.2.6",
|
|
62
|
-
"typescript": "^6.0.3"
|
|
62
|
+
"typescript": "^6.0.3",
|
|
63
|
+
"zod": "^4.4.3"
|
|
63
64
|
},
|
|
64
65
|
"type": "module",
|
|
65
66
|
"types": "./runtime/index.d.ts",
|
package/runtime/actions.ts
CHANGED
|
@@ -1,65 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
...args: Args
|
|
9
|
-
) => Promise<R>
|
|
10
|
-
|
|
11
|
-
/** Registration shape passed to brust.registerActions. */
|
|
12
|
-
export interface ActionDef<F extends ActionFn = ActionFn> {
|
|
13
|
-
/** Stable id; must match the id used by `action<F>(id)` on the client.
|
|
14
|
-
* Charset: [A-Za-z0-9_-]+ (enforced both in TS and in Rust). */
|
|
15
|
-
id: string
|
|
16
|
-
/** Handler. Receives req + JSON-decoded args. */
|
|
17
|
-
fn: F
|
|
18
|
-
/** Per-action middleware chain. Same Middleware type used by routes. */
|
|
19
|
-
middleware?: Middleware[]
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** Mirrors is_safe_action_id in src/lib.rs and src/server.rs.
|
|
23
|
-
* Allowed: [A-Za-z0-9_-]+ only, max 128 chars. */
|
|
24
|
-
export function isValidActionId(id: string): boolean {
|
|
25
|
-
if (id.length === 0 || id.length > 128) return false
|
|
26
|
-
return /^[A-Za-z0-9_-]+$/.test(id)
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const BRUST_MW_KEY = '__brustMiddleware'
|
|
30
|
-
|
|
31
|
-
/** Attach a middleware chain to a server-action function. The function is
|
|
32
|
-
* returned unchanged so the TypeScript signature is preserved verbatim —
|
|
33
|
-
* callers can still write `typeof srv.deleteNote` on the client.
|
|
34
|
-
*
|
|
35
|
-
* Throws TypeError if `mws` isn't an array of functions.
|
|
36
|
-
* Throws Error if called twice on the same fn (compose mws in one call).
|
|
37
|
-
*/
|
|
38
|
-
export function withMiddleware<F extends (...args: never[]) => unknown>(
|
|
39
|
-
mws: readonly Middleware[],
|
|
40
|
-
fn: F,
|
|
41
|
-
): F {
|
|
42
|
-
if (!Array.isArray(mws) || mws.some((m) => typeof m !== 'function')) {
|
|
43
|
-
throw new TypeError('withMiddleware expects an array of middleware functions')
|
|
44
|
-
}
|
|
45
|
-
if ((fn as { [BRUST_MW_KEY]?: unknown })[BRUST_MW_KEY] !== undefined) {
|
|
46
|
-
throw new Error(
|
|
47
|
-
'withMiddleware called twice on the same function — compose middleware in a single call instead',
|
|
48
|
-
)
|
|
49
|
-
}
|
|
50
|
-
Object.defineProperty(fn, BRUST_MW_KEY, {
|
|
51
|
-
value: Object.freeze([...mws]),
|
|
52
|
-
enumerable: false,
|
|
53
|
-
writable: false,
|
|
54
|
-
configurable: false,
|
|
55
|
-
})
|
|
56
|
-
return fn
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** Read the middleware metadata installed by withMiddleware. Used by the
|
|
60
|
-
* scanner to populate ActionDef.middleware. Returns undefined for plain
|
|
61
|
-
* (un-wrapped) functions. */
|
|
62
|
-
export function getActionMiddleware(fn: unknown): Middleware[] | undefined {
|
|
63
|
-
if (typeof fn !== 'function') return undefined
|
|
64
|
-
return (fn as { [BRUST_MW_KEY]?: Middleware[] })[BRUST_MW_KEY]
|
|
65
|
-
}
|
|
1
|
+
export type {
|
|
2
|
+
EndpointDef,
|
|
3
|
+
ActionContext,
|
|
4
|
+
EndpointOptions,
|
|
5
|
+
ActionsBuilder,
|
|
6
|
+
} from './define-actions.ts'
|
|
7
|
+
export { defineActions, isValidEndpointPath } from './define-actions.ts'
|
package/runtime/cli/build.ts
CHANGED
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs'
|
|
2
|
-
import { copyFile, mkdir, readdir, rm } from 'node:fs/promises'
|
|
2
|
+
import { copyFile, cp, mkdir, readdir, rm } from 'node:fs/promises'
|
|
3
3
|
import { createRequire } from 'node:module'
|
|
4
4
|
import path, { isAbsolute, resolve } from 'node:path'
|
|
5
|
-
import {
|
|
6
|
-
actionsPrebuiltPlugin,
|
|
7
|
-
writePrebuiltActionsFileWithMap,
|
|
8
|
-
} from './actions-prebuilt-plugin.ts'
|
|
9
5
|
import { emitNativeTemplates } from './native-routes-emit.ts'
|
|
10
6
|
import { nativeShimPlugin } from './native-shim-plugin.ts'
|
|
11
7
|
|
|
@@ -59,14 +55,98 @@ async function collectNativeBinaries(): Promise<string[]> {
|
|
|
59
55
|
return out
|
|
60
56
|
}
|
|
61
57
|
|
|
58
|
+
/** The 6 published platform targets (root package.json optionalDependencies),
|
|
59
|
+
* keyed by the napi binary infix `<platform>-<arch>[-<libc>]`. */
|
|
60
|
+
export const VALID_TARGETS = [
|
|
61
|
+
'darwin-x64',
|
|
62
|
+
'darwin-arm64',
|
|
63
|
+
'linux-x64-gnu',
|
|
64
|
+
'linux-arm64-gnu',
|
|
65
|
+
'linux-x64-musl',
|
|
66
|
+
'linux-arm64-musl',
|
|
67
|
+
] as const
|
|
68
|
+
|
|
69
|
+
/** Host target infix — reuses the same detection as platformPackageName by
|
|
70
|
+
* stripping the `brustjs-` prefix. */
|
|
71
|
+
export function hostTargetInfix(): string {
|
|
72
|
+
return platformPackageName().replace(/^brustjs-/, '')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function basenameTarget(absPath: string): string | null {
|
|
76
|
+
const b = absPath.replace(/^.*[/\\]/, '') // basename
|
|
77
|
+
const m = /^brust\.(.+)\.node$/.exec(b)
|
|
78
|
+
return m ? m[1] : null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Select which collected `brust.*.node` paths to copy for `target`.
|
|
82
|
+
* Pure: takes the collected absolute paths, returns selected paths + errors. */
|
|
83
|
+
export function selectNativeBinaries(
|
|
84
|
+
collected: string[],
|
|
85
|
+
target: string,
|
|
86
|
+
): { selected: string[]; errors: string[] } {
|
|
87
|
+
// Dedupe tokens here so `selected` never contains duplicate paths — the
|
|
88
|
+
// function's contract is a clean selection, independent of any caller-side
|
|
89
|
+
// copy-dedup (`--target darwin-arm64,darwin-arm64` must select once).
|
|
90
|
+
const tokens = [
|
|
91
|
+
...new Set(
|
|
92
|
+
target
|
|
93
|
+
.split(',')
|
|
94
|
+
.map((t) => t.trim().toLowerCase())
|
|
95
|
+
.filter(Boolean),
|
|
96
|
+
),
|
|
97
|
+
]
|
|
98
|
+
const byTarget = new Map<string, string>() // infix → first abs path (dedupe)
|
|
99
|
+
for (const p of collected) {
|
|
100
|
+
const t = basenameTarget(p)
|
|
101
|
+
if (t && !byTarget.has(t)) byTarget.set(t, p)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (tokens.length === 0) return { selected: [], errors: ['brust build: empty --target'] }
|
|
105
|
+
|
|
106
|
+
const hasAuto = tokens.includes('auto')
|
|
107
|
+
const hasAll = tokens.includes('all')
|
|
108
|
+
if ((hasAuto || hasAll) && tokens.length > 1) {
|
|
109
|
+
return {
|
|
110
|
+
selected: [],
|
|
111
|
+
errors: [
|
|
112
|
+
`brust build: --target "${target}" — auto/all cannot be combined with other targets`,
|
|
113
|
+
],
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (hasAll) return { selected: [...byTarget.values()], errors: [] }
|
|
117
|
+
|
|
118
|
+
const wanted = hasAuto ? [hostTargetInfix()] : tokens
|
|
119
|
+
const selected: string[] = []
|
|
120
|
+
const errors: string[] = []
|
|
121
|
+
for (const t of wanted) {
|
|
122
|
+
if (!hasAuto && !VALID_TARGETS.includes(t as (typeof VALID_TARGETS)[number])) {
|
|
123
|
+
errors.push(
|
|
124
|
+
`brust build: unknown target "${t}" (valid: ${VALID_TARGETS.join(', ')}, or auto/all)`,
|
|
125
|
+
)
|
|
126
|
+
continue
|
|
127
|
+
}
|
|
128
|
+
const p = byTarget.get(t)
|
|
129
|
+
if (!p) {
|
|
130
|
+
errors.push(
|
|
131
|
+
`brust build: no native binary for target "${t}" — install brustjs-${t} or build it (bun --filter runtime run build)`,
|
|
132
|
+
)
|
|
133
|
+
continue
|
|
134
|
+
}
|
|
135
|
+
selected.push(p)
|
|
136
|
+
}
|
|
137
|
+
return { selected, errors }
|
|
138
|
+
}
|
|
139
|
+
|
|
62
140
|
interface ParsedArgs {
|
|
63
141
|
entry: string // absolute path to the entry file
|
|
64
142
|
outDir: string // absolute path to the output dir
|
|
143
|
+
target: string // --target value (default 'auto')
|
|
65
144
|
}
|
|
66
145
|
|
|
67
146
|
function parseArgs(args: string[]): ParsedArgs {
|
|
68
147
|
let entry: string | undefined
|
|
69
148
|
let outDir: string | undefined
|
|
149
|
+
let target = 'auto'
|
|
70
150
|
|
|
71
151
|
for (let i = 0; i < args.length; i++) {
|
|
72
152
|
const a = args[i]
|
|
@@ -78,6 +158,18 @@ function parseArgs(args: string[]): ParsedArgs {
|
|
|
78
158
|
}
|
|
79
159
|
} else if (a.startsWith('--out-dir=')) {
|
|
80
160
|
outDir = a.slice('--out-dir='.length)
|
|
161
|
+
} else if (a === '--target') {
|
|
162
|
+
target = args[++i]
|
|
163
|
+
if (!target) {
|
|
164
|
+
console.error('brust build: --target requires a value')
|
|
165
|
+
process.exit(1)
|
|
166
|
+
}
|
|
167
|
+
} else if (a.startsWith('--target=')) {
|
|
168
|
+
target = a.slice('--target='.length)
|
|
169
|
+
if (!target) {
|
|
170
|
+
console.error('brust build: --target= requires a value')
|
|
171
|
+
process.exit(1)
|
|
172
|
+
}
|
|
81
173
|
} else if (a.startsWith('-')) {
|
|
82
174
|
console.error(`brust build: unknown flag "${a}"`)
|
|
83
175
|
process.exit(1)
|
|
@@ -107,11 +199,11 @@ function parseArgs(args: string[]): ParsedArgs {
|
|
|
107
199
|
: resolve(cwd, outDir)
|
|
108
200
|
: resolve(cwd, 'dist')
|
|
109
201
|
|
|
110
|
-
return { entry: entryPath, outDir: outPath }
|
|
202
|
+
return { entry: entryPath, outDir: outPath, target }
|
|
111
203
|
}
|
|
112
204
|
|
|
113
205
|
export async function runBuild(args: string[]): Promise<void> {
|
|
114
|
-
const { entry, outDir } = parseArgs(args)
|
|
206
|
+
const { entry, outDir, target } = parseArgs(args)
|
|
115
207
|
const entryDir = path.dirname(entry)
|
|
116
208
|
|
|
117
209
|
console.log(`[brust build] entry: ${entry}`)
|
|
@@ -121,18 +213,11 @@ export async function runBuild(args: string[]): Promise<void> {
|
|
|
121
213
|
await rm(outDir, { recursive: true, force: true })
|
|
122
214
|
await mkdir(outDir, { recursive: true })
|
|
123
215
|
|
|
124
|
-
// 2.
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
)
|
|
130
|
-
|
|
131
|
-
const idToSource = new Map<string, string>()
|
|
132
|
-
for (const file of scan.sourceFiles) {
|
|
133
|
-
const defs = await collectExports(file)
|
|
134
|
-
for (const def of defs) idToSource.set(def.id, file)
|
|
135
|
-
}
|
|
216
|
+
// 2. Actions need no build-time codegen. With `defineActions(...)`, actions
|
|
217
|
+
// are an EXPLICIT module the app entry imports and passes to
|
|
218
|
+
// `brust.run({ actions })` — the dist boot registers them through the normal
|
|
219
|
+
// `import { actions } from './actions'` path, no scan and no `_actions-prebuilt`
|
|
220
|
+
// file. (The old `'use server'` filesystem scanner is gone.)
|
|
136
221
|
|
|
137
222
|
// routes.tsx is the scan target for both islands (§3) and the MCP manifest
|
|
138
223
|
// (§4). Computed once here and reused below.
|
|
@@ -147,6 +232,18 @@ export async function runBuild(args: string[]): Promise<void> {
|
|
|
147
232
|
const islandsOutDir = path.join(outDir, 'islands')
|
|
148
233
|
const result = await buildIslands(islandMap, { outDir: islandsOutDir })
|
|
149
234
|
console.log(`[brust build] islands: ${result.islandCount} chunk(s) → ${islandsOutDir}`)
|
|
235
|
+
|
|
236
|
+
// Mirror into cwd/.brust/islands so the NON-prebuilt source runtime
|
|
237
|
+
// (`bun run <entry>` directly, and `bun run dev`) — which reads
|
|
238
|
+
// cwd/.brust/islands (see runtime/islands/build.ts default outDir) — finds
|
|
239
|
+
// the same chunks after a build. The prebuilt dist reads <distDir>/islands;
|
|
240
|
+
// this keeps the dev/source path working without a separate bundle step.
|
|
241
|
+
// Mirrors the jinja mirror below. `.brust/` is a gitignored cache dir.
|
|
242
|
+
const localIslandsDir = path.join(process.cwd(), '.brust', 'islands')
|
|
243
|
+
if (path.resolve(localIslandsDir) !== path.resolve(islandsOutDir)) {
|
|
244
|
+
await rm(localIslandsDir, { recursive: true, force: true })
|
|
245
|
+
await cp(islandsOutDir, localIslandsDir, { recursive: true })
|
|
246
|
+
}
|
|
150
247
|
} else {
|
|
151
248
|
console.log('[brust build] islands: skipped (no <Island> usage)')
|
|
152
249
|
}
|
|
@@ -157,11 +254,11 @@ export async function runBuild(args: string[]): Promise<void> {
|
|
|
157
254
|
const { extractMcpManifest } = await import('../mcp/extractor.ts')
|
|
158
255
|
const { routes } = await import(routesFile)
|
|
159
256
|
loadedRoutes = routes
|
|
257
|
+
const actionsFile = path.join(entryDir, 'actions.ts')
|
|
160
258
|
const manifest = await extractMcpManifest({
|
|
161
|
-
|
|
259
|
+
actionsFile: existsSync(actionsFile) ? actionsFile : undefined,
|
|
162
260
|
routesFile,
|
|
163
261
|
sourceRoots: [entryDir],
|
|
164
|
-
actions: scan.actions,
|
|
165
262
|
routes,
|
|
166
263
|
})
|
|
167
264
|
const manifestPath = path.join(outDir, 'mcp-manifest.json')
|
|
@@ -177,12 +274,11 @@ export async function runBuild(args: string[]): Promise<void> {
|
|
|
177
274
|
// native: true route. Pipeline runs even if no native routes exist (writes
|
|
178
275
|
// an empty manifest) so consumers can rely on the output dir's presence.
|
|
179
276
|
{
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
|
|
185
|
-
const jinjaDir = path.join(process.cwd(), '.brust/jinja')
|
|
277
|
+
// Emit jinja templates INTO the build output dir (`<outDir>/jinja`), next to
|
|
278
|
+
// the other pre-built artifacts (islands, css, mcp-manifest), so a dist-only
|
|
279
|
+
// deploy ships the templates. The prebuilt runtime reads them from
|
|
280
|
+
// `<BRUST_DIST_DIR>/jinja` (see index.ts loadJinjaOnce / configureJinjaDir).
|
|
281
|
+
const jinjaDir = path.join(outDir, 'jinja')
|
|
186
282
|
// Spec §7 Component-source resolution: scan the routes module's source for
|
|
187
283
|
// ImportDeclarations, NOT the app entry's. The app entry only imports the
|
|
188
284
|
// routes module + brust; the page components are imported by routes.tsx.
|
|
@@ -197,6 +293,17 @@ export async function runBuild(args: string[]): Promise<void> {
|
|
|
197
293
|
})
|
|
198
294
|
const nativeCount = (loadedRoutes ?? []).filter((r: any) => r?.nativeTemplate).length
|
|
199
295
|
console.log(`[brust build] jinja: ${nativeCount} template(s) → ${jinjaDir}`)
|
|
296
|
+
|
|
297
|
+
// Also mirror into cwd/.brust/jinja so the NON-prebuilt source runtime
|
|
298
|
+
// (`bun run <entry>` directly, and `bun run dev`) — which reads
|
|
299
|
+
// cwd/.brust/jinja — finds the same templates after a build. The prebuilt
|
|
300
|
+
// dist reads <distDir>/jinja (above); this keeps the dev/source path working
|
|
301
|
+
// without a separate compile step. `.brust/` is a gitignored cache dir.
|
|
302
|
+
const localJinjaDir = path.join(process.cwd(), '.brust', 'jinja')
|
|
303
|
+
if (path.resolve(localJinjaDir) !== path.resolve(jinjaDir)) {
|
|
304
|
+
await rm(localJinjaDir, { recursive: true, force: true })
|
|
305
|
+
await cp(jinjaDir, localJinjaDir, { recursive: true })
|
|
306
|
+
}
|
|
200
307
|
}
|
|
201
308
|
|
|
202
309
|
// 4.5. CSS — Tailwind v4 if app.css is present.
|
|
@@ -244,11 +351,10 @@ export async function runBuild(args: string[]): Promise<void> {
|
|
|
244
351
|
}
|
|
245
352
|
}
|
|
246
353
|
|
|
247
|
-
// 5.
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
// 6. Bun.build the server bundle with both plugins + banner.
|
|
354
|
+
// 5. Bun.build the server bundle with the native shim plugin + banner. No
|
|
355
|
+
// actions codegen: `defineActions(...)` actions register via the app entry's
|
|
356
|
+
// `import { actions } from './actions'` → `brust.run({ actions })` path, which
|
|
357
|
+
// the bundled entry already carries.
|
|
252
358
|
const banner =
|
|
253
359
|
`process.env.BRUST_PREBUILT = '1';\n` + `process.env.BRUST_DIST_DIR = import.meta.dir;\n`
|
|
254
360
|
|
|
@@ -274,7 +380,7 @@ export async function runBuild(args: string[]): Promise<void> {
|
|
|
274
380
|
// point at non-existent files. Whitespace + syntax minification still apply.
|
|
275
381
|
minify: { whitespace: true, syntax: true, identifiers: false },
|
|
276
382
|
banner,
|
|
277
|
-
plugins: [nativeShimPlugin(REPO_ROOT)
|
|
383
|
+
plugins: [nativeShimPlugin(REPO_ROOT)],
|
|
278
384
|
})
|
|
279
385
|
|
|
280
386
|
if (!result.success) {
|
|
@@ -284,26 +390,30 @@ export async function runBuild(args: string[]): Promise<void> {
|
|
|
284
390
|
}
|
|
285
391
|
console.log(`[brust build] bundle: ${path.join(outDir, 'index.js')}`)
|
|
286
392
|
|
|
287
|
-
// 7. Copy the
|
|
393
|
+
// 7. Copy the selected-platform native binary(ies).
|
|
288
394
|
const nativeDir = path.join(outDir, 'native')
|
|
289
395
|
await mkdir(nativeDir, { recursive: true })
|
|
290
396
|
|
|
291
397
|
// napi-rs names the binary `brust.<platform>-<arch>[-libc].node` (binaryName
|
|
292
398
|
// "brust", from the root napi config). Source/CI builds leave it in runtime/;
|
|
293
|
-
// an installed project carries it in the brustjs-<platform> package.
|
|
294
|
-
//
|
|
295
|
-
// (local/installed) layouts both Just Work.
|
|
399
|
+
// an installed project carries it in the brustjs-<platform> package. The
|
|
400
|
+
// --target flag (default "auto" = host platform) selects which to copy.
|
|
296
401
|
const nativeBinaries = await collectNativeBinaries()
|
|
297
|
-
|
|
402
|
+
const { selected, errors } = selectNativeBinaries(nativeBinaries, target)
|
|
403
|
+
if (errors.length > 0) {
|
|
404
|
+
for (const e of errors) console.error(e)
|
|
405
|
+
process.exit(1)
|
|
406
|
+
}
|
|
407
|
+
if (selected.length === 0) {
|
|
298
408
|
console.error(
|
|
299
|
-
`brust build: no native binary found
|
|
300
|
-
|
|
301
|
-
`
|
|
409
|
+
`brust build: no native binary found for target "${target}". Looked in ` +
|
|
410
|
+
`${path.join(REPO_ROOT, 'runtime')} and the ${platformPackageName()} package. ` +
|
|
411
|
+
`From source run \`bun --filter runtime run build\` first.`,
|
|
302
412
|
)
|
|
303
413
|
process.exit(1)
|
|
304
414
|
}
|
|
305
415
|
const seen = new Set<string>()
|
|
306
|
-
for (const src of
|
|
416
|
+
for (const src of selected) {
|
|
307
417
|
const name = path.basename(src)
|
|
308
418
|
if (seen.has(name)) continue
|
|
309
419
|
seen.add(name)
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Dep-free CLI help/version rendering + a tiny ANSI color util. All functions
|
|
2
|
+
// return strings (no console writes) so they're unit-testable. index.ts owns
|
|
3
|
+
// the actual stdout/stderr + exit codes.
|
|
4
|
+
import { readFileSync } from 'node:fs'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
|
|
7
|
+
/** Read the brustjs package.json version. `help.ts` lives at
|
|
8
|
+
* <root>/runtime/cli/, so ../../package.json is <root>/package.json in both the
|
|
9
|
+
* source tree and an installed node_modules/brustjs layout. Never throws —
|
|
10
|
+
* returns "unknown" on any failure (version must not crash the CLI). */
|
|
11
|
+
export function readVersion(): string {
|
|
12
|
+
try {
|
|
13
|
+
const p = path.join(import.meta.dir, '..', '..', 'package.json')
|
|
14
|
+
const pkg = JSON.parse(readFileSync(p, 'utf8')) as { version?: string }
|
|
15
|
+
return pkg.version ?? 'unknown'
|
|
16
|
+
} catch {
|
|
17
|
+
return 'unknown'
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function useColor(): boolean {
|
|
22
|
+
return Boolean(process.stdout.isTTY) && !process.env.NO_COLOR
|
|
23
|
+
}
|
|
24
|
+
function wrap(open: string, s: string): string {
|
|
25
|
+
return useColor() ? `\x1b[${open}m${s}\x1b[0m` : s
|
|
26
|
+
}
|
|
27
|
+
export const style = {
|
|
28
|
+
bold: (s: string) => wrap('1', s),
|
|
29
|
+
dim: (s: string) => wrap('2', s),
|
|
30
|
+
cyan: (s: string) => wrap('36', s),
|
|
31
|
+
green: (s: string) => wrap('32', s),
|
|
32
|
+
red: (s: string) => wrap('31', s),
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface CommandDef {
|
|
36
|
+
name: string
|
|
37
|
+
summary: string
|
|
38
|
+
usage: string
|
|
39
|
+
flags: { flag: string; desc: string }[]
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const COMMANDS: CommandDef[] = [
|
|
43
|
+
{
|
|
44
|
+
name: 'build',
|
|
45
|
+
summary: 'Compile a brust app to a self-contained dist/',
|
|
46
|
+
usage: 'brust build [entry] [options]',
|
|
47
|
+
flags: [
|
|
48
|
+
{ flag: '[entry]', desc: 'Entry file (default ./index.ts)' },
|
|
49
|
+
{ flag: '--out-dir <dir>', desc: 'Output directory (default ./dist)' },
|
|
50
|
+
{
|
|
51
|
+
flag: '--target <t>',
|
|
52
|
+
desc: 'Native target(s): auto | all | <platform>-<arch>[-<libc>][,…] (default auto)',
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
name: 'dev',
|
|
58
|
+
summary: 'Run the dev server with hot reload',
|
|
59
|
+
usage: 'brust dev [entry] [options]',
|
|
60
|
+
flags: [
|
|
61
|
+
{ flag: '[entry]', desc: 'Entry file (default ./index.ts)' },
|
|
62
|
+
{ flag: '--port <n>', desc: 'Port to listen on' },
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: 'new',
|
|
67
|
+
summary: 'Scaffold a new brust project',
|
|
68
|
+
usage: 'brust new <name> [options]',
|
|
69
|
+
flags: [
|
|
70
|
+
{ flag: '<name>', desc: 'Project name (lowercase letters, digits, - _)' },
|
|
71
|
+
{ flag: '--dir <path>', desc: 'Target directory (default ./<name>)' },
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
function pad(s: string, w: number): string {
|
|
77
|
+
return s + ' '.repeat(Math.max(0, w - s.length))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function renderVersion(): string {
|
|
81
|
+
return `brustjs ${readVersion()}`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function renderRootHelp(): string {
|
|
85
|
+
const lines: string[] = []
|
|
86
|
+
lines.push(`${style.bold('brust')} ${style.dim(readVersion())} — the brust framework CLI`)
|
|
87
|
+
lines.push('')
|
|
88
|
+
lines.push(`${style.bold('Usage:')} brust <command> [options]`)
|
|
89
|
+
lines.push('')
|
|
90
|
+
const globals = [
|
|
91
|
+
{ label: '-h, --help', desc: 'Show help (brust help <command> for details)' },
|
|
92
|
+
{ label: '-v, --version', desc: 'Show the brustjs version' },
|
|
93
|
+
]
|
|
94
|
+
// One shared column width across commands AND global flags so every
|
|
95
|
+
// description lines up in a single column.
|
|
96
|
+
const w = Math.max(...COMMANDS.map((c) => c.name.length), ...globals.map((g) => g.label.length))
|
|
97
|
+
lines.push(style.bold('Commands:'))
|
|
98
|
+
for (const c of COMMANDS) {
|
|
99
|
+
lines.push(` ${style.cyan(pad(c.name, w))} ${c.summary}`)
|
|
100
|
+
}
|
|
101
|
+
lines.push('')
|
|
102
|
+
lines.push(style.bold('Global:'))
|
|
103
|
+
for (const g of globals) {
|
|
104
|
+
lines.push(` ${style.cyan(pad(g.label, w))} ${g.desc}`)
|
|
105
|
+
}
|
|
106
|
+
lines.push('')
|
|
107
|
+
lines.push(style.dim('Run `brust help <command>` for command-specific options.'))
|
|
108
|
+
return lines.join('\n')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function renderCommandHelp(name: string): string | null {
|
|
112
|
+
const c = COMMANDS.find((x) => x.name === name)
|
|
113
|
+
if (!c) return null
|
|
114
|
+
const lines: string[] = []
|
|
115
|
+
lines.push(`${style.bold('Usage:')} ${c.usage}`)
|
|
116
|
+
lines.push('')
|
|
117
|
+
lines.push(c.summary)
|
|
118
|
+
lines.push('')
|
|
119
|
+
lines.push(style.bold('Options:'))
|
|
120
|
+
const w = Math.max(...c.flags.map((f) => f.flag.length))
|
|
121
|
+
for (const f of c.flags) {
|
|
122
|
+
lines.push(` ${style.cyan(pad(f.flag, w))} ${f.desc}`)
|
|
123
|
+
}
|
|
124
|
+
return lines.join('\n')
|
|
125
|
+
}
|
package/runtime/cli/index.ts
CHANGED
|
@@ -1,30 +1,65 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
|
|
2
|
+
import { COMMANDS, renderCommandHelp, renderRootHelp, renderVersion } from './help.ts'
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
4
|
+
const argv = process.argv.slice(2)
|
|
5
|
+
const first = argv[0]
|
|
6
|
+
const second = argv[1]
|
|
7
|
+
|
|
8
|
+
// Derive from COMMANDS so the dispatch set and the help registry can never
|
|
9
|
+
// drift (a name in KNOWN but absent from COMMANDS would print `null` as help).
|
|
10
|
+
const KNOWN = new Set(COMMANDS.map((c) => c.name))
|
|
11
|
+
|
|
12
|
+
function hasHelpFlag(args: string[]): boolean {
|
|
13
|
+
return args.includes('--help') || args.includes('-h')
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (first === '--version' || first === '-v' || first === 'version') {
|
|
17
|
+
console.log(renderVersion())
|
|
18
|
+
process.exit(0)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (first === '--help' || first === '-h' || first === 'help') {
|
|
22
|
+
if (second && KNOWN.has(second)) {
|
|
23
|
+
console.log(renderCommandHelp(second))
|
|
24
|
+
process.exit(0)
|
|
9
25
|
}
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
26
|
+
if (second) {
|
|
27
|
+
console.error(`brust: unknown command "${second}".`)
|
|
28
|
+
console.error(renderRootHelp())
|
|
29
|
+
process.exit(1)
|
|
14
30
|
}
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
31
|
+
console.log(renderRootHelp())
|
|
32
|
+
process.exit(0)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (first && KNOWN.has(first)) {
|
|
36
|
+
const rest = argv.slice(1)
|
|
37
|
+
if (hasHelpFlag(rest)) {
|
|
38
|
+
console.log(renderCommandHelp(first))
|
|
39
|
+
process.exit(0)
|
|
19
40
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
41
|
+
switch (first) {
|
|
42
|
+
case 'build': {
|
|
43
|
+
const { runBuild } = await import('./build.ts')
|
|
44
|
+
await runBuild(rest)
|
|
45
|
+
break
|
|
46
|
+
}
|
|
47
|
+
case 'dev': {
|
|
48
|
+
const { runDev } = await import('./dev.ts')
|
|
49
|
+
await runDev(rest)
|
|
50
|
+
break
|
|
51
|
+
}
|
|
52
|
+
case 'new': {
|
|
53
|
+
const { runNew } = await import('./new.ts')
|
|
54
|
+
await runNew(rest)
|
|
55
|
+
break
|
|
27
56
|
}
|
|
28
|
-
process.exit(1)
|
|
29
57
|
}
|
|
58
|
+
} else if (!first) {
|
|
59
|
+
console.error(renderRootHelp())
|
|
60
|
+
process.exit(1)
|
|
61
|
+
} else {
|
|
62
|
+
console.error(`brust: unknown command "${first}".`)
|
|
63
|
+
console.error('Run `brust --help` to see available commands.')
|
|
64
|
+
process.exit(1)
|
|
30
65
|
}
|