brustjs 0.1.11-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 +13 -8
- package/package.json +9 -8
- package/runtime/actions.ts +7 -65
- package/runtime/cli/build.ts +24 -24
- 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 +65 -52
- 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
|
@@ -85,13 +85,18 @@ brustjs new <name> # scaffold a project (partial — see Status)
|
|
|
85
85
|
`renderToString` runs once per key, then serves a frozen pair from Rust.
|
|
86
86
|
- **`native: true` routes** — JSX compiled to a jinja template at build time and
|
|
87
87
|
rendered Rust-side (`minijinja`), skipping React on the server entirely.
|
|
88
|
-
- **
|
|
89
|
-
|
|
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.
|
|
90
93
|
- **SSE & WebSockets** as first-class route shapes.
|
|
91
94
|
- Nested routes + dynamic params, per-route typed loaders, request-scoped middleware,
|
|
92
|
-
|
|
93
|
-
- **Agent-first** —
|
|
94
|
-
`/_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.
|
|
95
100
|
|
|
96
101
|
## Performance
|
|
97
102
|
|
|
@@ -120,9 +125,9 @@ bench/ · docs/ · architecture.md
|
|
|
120
125
|
## Status
|
|
121
126
|
|
|
122
127
|
Alpha, solo-developed. Linux is tier-1 (io_uring; glibc + musl, 6 prebuilt platform
|
|
123
|
-
binaries). Known partials: `brustjs dev`
|
|
124
|
-
|
|
125
|
-
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"`
|
|
126
131
|
resolves from your own `node_modules`. Deployment note: the io_uring server needs `io_uring_*`
|
|
127
132
|
syscalls permitted — a default-seccomp container (Docker/k8s) must allow them or run
|
|
128
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
|
@@ -2,10 +2,6 @@ import { existsSync } from 'node:fs'
|
|
|
2
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
|
|
|
@@ -217,18 +213,11 @@ export async function runBuild(args: string[]): Promise<void> {
|
|
|
217
213
|
await rm(outDir, { recursive: true, force: true })
|
|
218
214
|
await mkdir(outDir, { recursive: true })
|
|
219
215
|
|
|
220
|
-
// 2.
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
)
|
|
226
|
-
|
|
227
|
-
const idToSource = new Map<string, string>()
|
|
228
|
-
for (const file of scan.sourceFiles) {
|
|
229
|
-
const defs = await collectExports(file)
|
|
230
|
-
for (const def of defs) idToSource.set(def.id, file)
|
|
231
|
-
}
|
|
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.)
|
|
232
221
|
|
|
233
222
|
// routes.tsx is the scan target for both islands (§3) and the MCP manifest
|
|
234
223
|
// (§4). Computed once here and reused below.
|
|
@@ -243,6 +232,18 @@ export async function runBuild(args: string[]): Promise<void> {
|
|
|
243
232
|
const islandsOutDir = path.join(outDir, 'islands')
|
|
244
233
|
const result = await buildIslands(islandMap, { outDir: islandsOutDir })
|
|
245
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
|
+
}
|
|
246
247
|
} else {
|
|
247
248
|
console.log('[brust build] islands: skipped (no <Island> usage)')
|
|
248
249
|
}
|
|
@@ -253,11 +254,11 @@ export async function runBuild(args: string[]): Promise<void> {
|
|
|
253
254
|
const { extractMcpManifest } = await import('../mcp/extractor.ts')
|
|
254
255
|
const { routes } = await import(routesFile)
|
|
255
256
|
loadedRoutes = routes
|
|
257
|
+
const actionsFile = path.join(entryDir, 'actions.ts')
|
|
256
258
|
const manifest = await extractMcpManifest({
|
|
257
|
-
|
|
259
|
+
actionsFile: existsSync(actionsFile) ? actionsFile : undefined,
|
|
258
260
|
routesFile,
|
|
259
261
|
sourceRoots: [entryDir],
|
|
260
|
-
actions: scan.actions,
|
|
261
262
|
routes,
|
|
262
263
|
})
|
|
263
264
|
const manifestPath = path.join(outDir, 'mcp-manifest.json')
|
|
@@ -350,11 +351,10 @@ export async function runBuild(args: string[]): Promise<void> {
|
|
|
350
351
|
}
|
|
351
352
|
}
|
|
352
353
|
|
|
353
|
-
// 5.
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
// 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.
|
|
358
358
|
const banner =
|
|
359
359
|
`process.env.BRUST_PREBUILT = '1';\n` + `process.env.BRUST_DIST_DIR = import.meta.dir;\n`
|
|
360
360
|
|
|
@@ -380,7 +380,7 @@ export async function runBuild(args: string[]): Promise<void> {
|
|
|
380
380
|
// point at non-existent files. Whitespace + syntax minification still apply.
|
|
381
381
|
minify: { whitespace: true, syntax: true, identifiers: false },
|
|
382
382
|
banner,
|
|
383
|
-
plugins: [nativeShimPlugin(REPO_ROOT)
|
|
383
|
+
plugins: [nativeShimPlugin(REPO_ROOT)],
|
|
384
384
|
})
|
|
385
385
|
|
|
386
386
|
if (!result.success) {
|
package/runtime/client/index.ts
CHANGED
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
* client doesn't need.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
// BrustActionError is kept for backward compatibility with tests/fixtures/app
|
|
8
|
+
// (AvatarUpload.tsx, NoteForm.tsx, WhoAmI.tsx) until Task M1 migrates those
|
|
9
|
+
// usages to the new treaty client.
|
|
7
10
|
export class BrustActionError extends Error {
|
|
8
11
|
constructor(
|
|
9
12
|
message: string,
|
|
@@ -15,107 +18,5 @@ export class BrustActionError extends Error {
|
|
|
15
18
|
}
|
|
16
19
|
}
|
|
17
20
|
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
}
|
|
21
|
+
export { client } from '../treaty.ts'
|
|
22
|
+
export type { TreatyResponse, ClientOptions } from '../treaty.ts'
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import type { BrustRequest, Middleware } from './routes.ts'
|
|
2
|
+
import type { StandardSchemaV1, InferOutput } from './standard-schema.ts'
|
|
3
|
+
|
|
4
|
+
const RESPOND = Symbol('brust.respond')
|
|
5
|
+
export interface ActionResponseSentinel {
|
|
6
|
+
readonly [RESPOND]: true
|
|
7
|
+
status: number
|
|
8
|
+
body: unknown
|
|
9
|
+
headers?: Record<string, string>
|
|
10
|
+
}
|
|
11
|
+
export function isRespondSentinel(v: unknown): v is ActionResponseSentinel {
|
|
12
|
+
return typeof v === 'object' && v !== null && (v as Record<symbol, unknown>)[RESPOND] === true
|
|
13
|
+
}
|
|
14
|
+
export function makeRespond() {
|
|
15
|
+
return (
|
|
16
|
+
body: unknown,
|
|
17
|
+
init?: { status?: number; headers?: Record<string, string> },
|
|
18
|
+
): ActionResponseSentinel => ({
|
|
19
|
+
[RESPOND]: true,
|
|
20
|
+
status: init?.status ?? 200,
|
|
21
|
+
body,
|
|
22
|
+
headers: init?.headers,
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ActionContext<
|
|
27
|
+
Body = unknown,
|
|
28
|
+
Params = Record<string, string>,
|
|
29
|
+
Query = Record<string, string>,
|
|
30
|
+
> {
|
|
31
|
+
req: BrustRequest
|
|
32
|
+
body: Body
|
|
33
|
+
params: Params
|
|
34
|
+
query: Query
|
|
35
|
+
headers: Record<string, string>
|
|
36
|
+
respond: (
|
|
37
|
+
body: unknown,
|
|
38
|
+
init?: { status?: number; headers?: Record<string, string> },
|
|
39
|
+
) => ActionResponseSentinel
|
|
40
|
+
}
|
|
41
|
+
type Handler<B, P, Q, R> = (ctx: ActionContext<B, P, Q>) => R | Promise<R>
|
|
42
|
+
|
|
43
|
+
export interface EndpointOptions {
|
|
44
|
+
body?: StandardSchemaV1
|
|
45
|
+
query?: StandardSchemaV1
|
|
46
|
+
middleware?: Middleware[]
|
|
47
|
+
/** Build-time MCP tool description (read by the manifest extractor). */
|
|
48
|
+
description?: string
|
|
49
|
+
}
|
|
50
|
+
export interface EndpointDef {
|
|
51
|
+
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD'
|
|
52
|
+
path: string
|
|
53
|
+
handler: (ctx: ActionContext) => unknown
|
|
54
|
+
body?: StandardSchemaV1
|
|
55
|
+
query?: StandardSchemaV1
|
|
56
|
+
middleware: Middleware[]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
type ParamKeys<P extends string> = P extends `${string}{${infer K}}${infer Rest}`
|
|
60
|
+
? (K extends `*${infer C}` ? C : K) | ParamKeys<Rest>
|
|
61
|
+
: never
|
|
62
|
+
type Params<P extends string> = [ParamKeys<P>] extends [never]
|
|
63
|
+
? Record<string, string>
|
|
64
|
+
: { [K in ParamKeys<P>]: string }
|
|
65
|
+
type BodyOf<O> = O extends { body: infer S }
|
|
66
|
+
? S extends StandardSchemaV1
|
|
67
|
+
? InferOutput<S>
|
|
68
|
+
: unknown
|
|
69
|
+
: unknown
|
|
70
|
+
type QueryOf<O> = O extends { query: infer S }
|
|
71
|
+
? S extends StandardSchemaV1
|
|
72
|
+
? InferOutput<S>
|
|
73
|
+
: unknown
|
|
74
|
+
: Record<string, string>
|
|
75
|
+
|
|
76
|
+
export type EndpointEntry = { input: unknown; output: unknown }
|
|
77
|
+
export type EndpointMap = Record<string, Partial<Record<EndpointDef['method'], EndpointEntry>>>
|
|
78
|
+
|
|
79
|
+
export function isValidEndpointPath(p: string): boolean {
|
|
80
|
+
return typeof p === 'string' && p.length > 0 && p.startsWith('/') && !/[\s?#]/.test(p)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// biome-ignore lint/complexity/noBannedTypes: `{}` is the intersection identity for `Acc & {...}` endpoint-type accumulation; Record<string,never> would poison the intersection
|
|
84
|
+
export interface ActionsBuilder<Acc extends EndpointMap = {}> {
|
|
85
|
+
endpoints: EndpointDef[]
|
|
86
|
+
use(mw: Middleware): ActionsBuilder<Acc>
|
|
87
|
+
get<P extends string, O extends EndpointOptions, R>(
|
|
88
|
+
path: P,
|
|
89
|
+
handler: Handler<BodyOf<O>, Params<P>, QueryOf<O>, R>,
|
|
90
|
+
opts?: O,
|
|
91
|
+
): ActionsBuilder<Acc & { [K in P]: { GET: { input: QueryOf<O>; output: Awaited<R> } } }>
|
|
92
|
+
post<P extends string, O extends EndpointOptions, R>(
|
|
93
|
+
path: P,
|
|
94
|
+
handler: Handler<BodyOf<O>, Params<P>, QueryOf<O>, R>,
|
|
95
|
+
opts?: O,
|
|
96
|
+
): ActionsBuilder<Acc & { [K in P]: { POST: { input: BodyOf<O>; output: Awaited<R> } } }>
|
|
97
|
+
put<P extends string, O extends EndpointOptions, R>(
|
|
98
|
+
path: P,
|
|
99
|
+
handler: Handler<BodyOf<O>, Params<P>, QueryOf<O>, R>,
|
|
100
|
+
opts?: O,
|
|
101
|
+
): ActionsBuilder<Acc & { [K in P]: { PUT: { input: BodyOf<O>; output: Awaited<R> } } }>
|
|
102
|
+
patch<P extends string, O extends EndpointOptions, R>(
|
|
103
|
+
path: P,
|
|
104
|
+
handler: Handler<BodyOf<O>, Params<P>, QueryOf<O>, R>,
|
|
105
|
+
opts?: O,
|
|
106
|
+
): ActionsBuilder<Acc & { [K in P]: { PATCH: { input: BodyOf<O>; output: Awaited<R> } } }>
|
|
107
|
+
delete<P extends string, O extends EndpointOptions, R>(
|
|
108
|
+
path: P,
|
|
109
|
+
handler: Handler<BodyOf<O>, Params<P>, QueryOf<O>, R>,
|
|
110
|
+
opts?: O,
|
|
111
|
+
): ActionsBuilder<Acc & { [K in P]: { DELETE: { input: BodyOf<O>; output: Awaited<R> } } }>
|
|
112
|
+
head<P extends string, O extends EndpointOptions, R>(
|
|
113
|
+
path: P,
|
|
114
|
+
handler: Handler<BodyOf<O>, Params<P>, QueryOf<O>, R>,
|
|
115
|
+
opts?: O,
|
|
116
|
+
): ActionsBuilder<Acc & { [K in P]: { HEAD: { input: QueryOf<O>; output: Awaited<R> } } }>
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function defineActions(): ActionsBuilder {
|
|
120
|
+
const endpoints: EndpointDef[] = []
|
|
121
|
+
const globalMw: Middleware[] = []
|
|
122
|
+
const seen = new Set<string>()
|
|
123
|
+
function add(
|
|
124
|
+
method: EndpointDef['method'],
|
|
125
|
+
path: string,
|
|
126
|
+
handler: (c: ActionContext) => unknown,
|
|
127
|
+
opts?: EndpointOptions,
|
|
128
|
+
) {
|
|
129
|
+
if (!isValidEndpointPath(path))
|
|
130
|
+
throw new Error(
|
|
131
|
+
`defineActions: invalid endpoint path ${JSON.stringify(path)} (must start with '/', no whitespace/?#)`,
|
|
132
|
+
)
|
|
133
|
+
const key = `${method} ${path}`
|
|
134
|
+
if (seen.has(key)) throw new Error(`defineActions: duplicate endpoint ${key}`)
|
|
135
|
+
seen.add(key)
|
|
136
|
+
endpoints.push({
|
|
137
|
+
method,
|
|
138
|
+
path,
|
|
139
|
+
handler,
|
|
140
|
+
body: opts?.body,
|
|
141
|
+
query: opts?.query,
|
|
142
|
+
middleware: [...globalMw, ...(opts?.middleware ?? [])],
|
|
143
|
+
})
|
|
144
|
+
}
|
|
145
|
+
const builder = {
|
|
146
|
+
endpoints,
|
|
147
|
+
use(mw: Middleware) {
|
|
148
|
+
globalMw.push(mw)
|
|
149
|
+
return builder
|
|
150
|
+
},
|
|
151
|
+
get(p: string, h: any, o?: EndpointOptions) {
|
|
152
|
+
add('GET', p, h, o)
|
|
153
|
+
return builder
|
|
154
|
+
},
|
|
155
|
+
post(p: string, h: any, o?: EndpointOptions) {
|
|
156
|
+
add('POST', p, h, o)
|
|
157
|
+
return builder
|
|
158
|
+
},
|
|
159
|
+
put(p: string, h: any, o?: EndpointOptions) {
|
|
160
|
+
add('PUT', p, h, o)
|
|
161
|
+
return builder
|
|
162
|
+
},
|
|
163
|
+
patch(p: string, h: any, o?: EndpointOptions) {
|
|
164
|
+
add('PATCH', p, h, o)
|
|
165
|
+
return builder
|
|
166
|
+
},
|
|
167
|
+
delete(p: string, h: any, o?: EndpointOptions) {
|
|
168
|
+
add('DELETE', p, h, o)
|
|
169
|
+
return builder
|
|
170
|
+
},
|
|
171
|
+
head(p: string, h: any, o?: EndpointOptions) {
|
|
172
|
+
add('HEAD', p, h, o)
|
|
173
|
+
return builder
|
|
174
|
+
},
|
|
175
|
+
}
|
|
176
|
+
return builder as unknown as ActionsBuilder
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export { RESPOND }
|
package/runtime/index.d.ts
CHANGED
|
@@ -22,6 +22,12 @@ export declare function configureDevMode(enabled: boolean): void
|
|
|
22
22
|
|
|
23
23
|
export declare function configureIslandsDir(path: string): NapiResult<undefined>
|
|
24
24
|
|
|
25
|
+
/** Per-endpoint registration descriptor passed to `register_actions`. */
|
|
26
|
+
export interface EndpointReg {
|
|
27
|
+
method: string
|
|
28
|
+
path: string
|
|
29
|
+
}
|
|
30
|
+
|
|
25
31
|
export declare function islandCacheClear(): void
|
|
26
32
|
|
|
27
33
|
export declare function islandCacheGet(key: string): CachedIslandJs | null
|
|
@@ -213,12 +219,10 @@ export declare function napiWsSend(connId: bigint, data: Buffer, isBinary: boole
|
|
|
213
219
|
export declare function napiWsSignalOpen(connId: bigint, status: number, body: Buffer, contentType: string, subprotocol: string): NapiResult<undefined>
|
|
214
220
|
|
|
215
221
|
/**
|
|
216
|
-
* Register
|
|
217
|
-
*
|
|
218
|
-
* Validates charset and rejects duplicates. Replaces any previous set
|
|
219
|
-
* (no incremental registration in MVP — register once at boot).
|
|
222
|
+
* Register action endpoints. Replaces any previous router state.
|
|
223
|
+
* Returns the number of endpoints registered.
|
|
220
224
|
*/
|
|
221
|
-
export declare function registerActions(
|
|
225
|
+
export declare function registerActions(endpoints: Array<EndpointReg>): NapiResult<number>
|
|
222
226
|
|
|
223
227
|
export declare function registerRenderer(buf: Uint8Array, f: (arg: number | string) => Promise<number>): NapiResult<number>
|
|
224
228
|
|
|
@@ -246,6 +250,8 @@ export interface ServeOptions {
|
|
|
246
250
|
entry: string
|
|
247
251
|
/** Optional performance tunables. Omit to keep framework defaults. */
|
|
248
252
|
tuning?: ServeTuning
|
|
253
|
+
/** Optional action prefix override. Defaults to `/_brust/action`. */
|
|
254
|
+
actionPrefix?: string
|
|
249
255
|
}
|
|
250
256
|
|
|
251
257
|
/**
|