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 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
- - **Server actions** (`"use server"`) per-action endpoints; client helper rewrites
89
- form/`fetch` targets.
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
- forms/multipart, SPA-style navigation, in-process LRU response cache + island ISR cache, Tailwind v4 + CSS Modules.
93
- - **Agent-first** — server fns and routes expose MCP tool/resource schemas at
94
- `/_brust/mcp` so agents drive the app without scraping.
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` TS reload is a full worker-respawn reload
124
- (not state-preserving HMR), islands + `.module.css`. Tailwind
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.11-alpha",
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.11-alpha",
44
- "brustjs-darwin-arm64": "0.1.11-alpha",
45
- "brustjs-linux-x64-gnu": "0.1.11-alpha",
46
- "brustjs-linux-arm64-gnu": "0.1.11-alpha",
47
- "brustjs-linux-x64-musl": "0.1.11-alpha",
48
- "brustjs-linux-arm64-musl": "0.1.11-alpha"
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",
@@ -1,65 +1,7 @@
1
- import type { BrustRequest, Middleware } from './routes.ts'
2
-
3
- /** Server-side action handler. First arg is ALWAYS BrustRequest; the client
4
- * stub strips it from the call site. Subsequent args are JSON-decoded from
5
- * the request body (which MUST be a JSON array). */
6
- export type ActionFn<Args extends unknown[] = unknown[], R = unknown> = (
7
- req: BrustRequest,
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'
@@ -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. Scan actions + rediscover id→source mapping for the prebuilt plugin.
221
- const { scanActions, collectExports } = await import('../scan-actions.ts')
222
- const scan = await scanActions({ roots: [entryDir] })
223
- console.log(
224
- `[brust build] actions: discovered ${scan.actions.length} (${scan.actions.map((a) => a.id).join(', ') || '(none)'})`,
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
- serverFiles: scan.sourceFiles,
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. Generate the prebuilt-actions file (always empty list if no actions).
354
- const prebuiltActionsPath = path.join(outDir, '_actions-prebuilt.ts')
355
- await writePrebuiltActionsFileWithMap(prebuiltActionsPath, idToSource, REPO_ROOT)
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), actionsPrebuiltPlugin(prebuiltActionsPath, REPO_ROOT)],
383
+ plugins: [nativeShimPlugin(REPO_ROOT)],
384
384
  })
385
385
 
386
386
  if (!result.success) {
@@ -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
- /** 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
- }
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 }
@@ -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 the set of action ids that Rust will accept on
217
- * /_brust/action/<id>. Called once at boot from the main thread.
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(ids: Array<string>): NapiResult<number>
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
  /**