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/runtime/index.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import * as native from './index.js'
2
- import type { ActionDef } from './actions.ts'
3
- import { isValidActionId } from './actions.ts'
2
+ import type { EndpointDef } from './define-actions.ts'
4
3
  import { loadConfig } from './config.ts'
5
4
  import { configureCssEnabled, configureCssHrefsForRoute } from './css.ts'
5
+ import { configureJinjaDir } from './islands/native-render.ts'
6
6
 
7
7
  export interface ServeOptions {
8
8
  /** Host/address to bind on. A hostname (e.g. `localhost`, resolved Rust-side)
@@ -12,10 +12,13 @@ export interface ServeOptions {
12
12
  workers: number
13
13
  entry: string
14
14
  bootTimeoutMs?: number
15
- /** Action definitions discovered by `brust.scanActions()`. When present,
16
- * `serve` calls the internal action registry before the listener binds.
17
- * Optional — omit if the app has no server actions. */
18
- actions?: ActionDef[]
15
+ /** Actions builder from `defineActions(...)`. When present, `serve`
16
+ * registers its endpoints (method/path, keyed by registration index)
17
+ * before the listener binds. Optional — omit if the app has no actions. */
18
+ actions?: import('./define-actions.ts').ActionsBuilder
19
+ /** URL prefix the action router mounts under (e.g. `/_actions`). Threaded
20
+ * into Rust's ServeOptions.action_prefix. */
21
+ actionPrefix?: string
19
22
  /** MCP support — pass a manifest built via brust.buildMcpManifest. brust.serve
20
23
  * does NOT auto-wire MCP into workers; the worker branch of the entry file must
21
24
  * call brust.loadMcpManifest() + makeMcpServer() itself and pass the McpServer
@@ -82,21 +85,8 @@ function loadJinjaOnce(dir: string): void {
82
85
  _jinjaLoaded = true
83
86
  }
84
87
 
85
- function registerActionsInternal(actions: Array<{ id: string }>): number {
86
- const seen = new Set<string>()
87
- for (const a of actions) {
88
- if (!isValidActionId(a.id)) {
89
- throw new Error(
90
- `action id ${JSON.stringify(a.id)} contains invalid characters; ` +
91
- `allowed: [A-Za-z0-9_-]+ (max 128 chars)`,
92
- )
93
- }
94
- if (seen.has(a.id)) {
95
- throw new Error(`action id ${JSON.stringify(a.id)} registered more than once`)
96
- }
97
- seen.add(a.id)
98
- }
99
- return (native as any).registerActions(actions.map((a) => a.id))
88
+ function registerActionsInternal(endpoints: Array<{ method: string; path: string }>): number {
89
+ return (native as any).registerActions(endpoints.map((e) => ({ method: e.method, path: e.path })))
100
90
  }
101
91
 
102
92
  /** Read and schema-validate a prebuilt mcp-manifest.json at an absolute path.
@@ -121,11 +111,10 @@ async function readManifestFromPath(
121
111
 
122
112
  export const brust = {
123
113
  async serve(opts: ServeOptions): Promise<void> {
124
- if (opts.actions && opts.actions.length > 0) {
125
- // Register action ids with Rust. registerActionsInternal validates
126
- // charset + uniqueness; throws on either. Mirrors the previous
127
- // `brust.registerActions` user-facing call exactly.
128
- registerActionsInternal(opts.actions)
114
+ if (opts.actions) {
115
+ // Register endpoint method/path with Rust. The router keys each by its
116
+ // registration index; the worker dispatches on that same index string.
117
+ registerActionsInternal(opts.actions.endpoints)
129
118
  }
130
119
  ;(native as any).beginServe({
131
120
  host: opts.host,
@@ -133,6 +122,10 @@ export const brust = {
133
122
  workers: opts.workers,
134
123
  entry: opts.entry,
135
124
  tuning: opts.tuning,
125
+ // napi-rs camelCases #[napi(object)] fields, so the addon reads
126
+ // `actionPrefix` (not snake_case). A snake_case key is silently dropped,
127
+ // leaving the prefix at its default — which broke custom-prefix routing.
128
+ actionPrefix: opts.actionPrefix,
136
129
  })
137
130
  const baseEnv = { ...process.env }
138
131
  const workersArr: Worker[] = []
@@ -217,18 +210,6 @@ export const brust = {
217
210
  configureCssDir(dir: string): void {
218
211
  ;(native as any).configureCssDir(dir)
219
212
  },
220
- /** Walk the project for files marked `'use server'`, import them, and
221
- * return all named function exports as ActionDef[] plus the list of source
222
- * files (needed by brust.buildMcpManifest). Both the main process and each
223
- * worker should call this once at module top-level and pass `actions` to
224
- * `brust.serve({ actions, ... })` (main) and `makeRenderer(..., { actions,
225
- * ... })` (worker). See ScanOptions for roots / ignore overrides. */
226
- async scanActions(
227
- opts?: import('./scan-actions.ts').ScanOptions,
228
- ): Promise<import('./scan-actions.ts').ScanActionsResult> {
229
- const { scanActions } = await import('./scan-actions.ts')
230
- return scanActions(opts)
231
- },
232
213
  /** Extract the MCP manifest from TypeScript source using the compiler API,
233
214
  * write it to `.brust/mcp-manifest.json`, and return it. Call once in the
234
215
  * main process after `brust.registerRoutes(routes)`. Workers must read the
@@ -236,10 +217,9 @@ export const brust = {
236
217
  * branch and pass an `McpServer` (built via `makeMcpServer`) to
237
218
  * `makeRenderer` via `opts.mcp`. See example/hello-world/index.ts. */
238
219
  async buildMcpManifest(opts: {
239
- serverFiles: string[]
220
+ actionsFile?: string
240
221
  routesFile: string
241
222
  sourceRoots: string[]
242
- actions: import('./actions.ts').ActionDef[]
243
223
  routes: import('./routes.ts').FlatRoute[]
244
224
  cwd?: string
245
225
  }): Promise<import('./mcp/manifest.ts').McpManifest> {
@@ -293,6 +273,11 @@ export const brust = {
293
273
  /** TCP port to bind on. Default 1337. Overridable by env/toml — see
294
274
  * `address`. */
295
275
  port?: number
276
+ /** Actions builder from `defineActions(...)`. Registered with Rust and
277
+ * threaded to each worker's renderer. Omit if the app has no actions. */
278
+ actions?: import('./define-actions.ts').ActionsBuilder
279
+ /** URL prefix the action router mounts under. Threaded to serve(). */
280
+ actionPrefix?: string
296
281
  /** Overrides merged into the underlying `serve()` call (main thread). */
297
282
  serve?: Partial<Omit<ServeOptions, 'entry' | 'actions' | 'mcp'>>
298
283
  /** Per-worker SAB size in bytes. Default 256 KB. */
@@ -324,9 +309,20 @@ export const brust = {
324
309
  ]
325
310
  configureDevClientSnippet(buildDevClientTag())
326
311
  }
327
- // scanActions is plugin-aliased in prebuilt bundles → returns pre-baked
328
- // list with sourceFiles=[]. In dev mode it walks the filesystem.
329
- const { actions, sourceFiles } = await this.scanActions({ roots: [scanRoot] })
312
+ {
313
+ const { configureActionPrefixSnippet } = await import('./render/inject-action-prefix.ts')
314
+ const ap = opts.actionPrefix
315
+ configureActionPrefixSnippet(
316
+ ap && ap !== '/_brust/action'
317
+ ? `<script>globalThis.__BRUST_ACTION_PREFIX__=${JSON.stringify(ap)}</script>`
318
+ : null,
319
+ )
320
+ }
321
+ // Actions now come from the explicit `defineActions(...)` builder passed in
322
+ // opts (the `'use server'` scanner is gone). `endpoints` is the EndpointDef[]
323
+ // threaded to serve()/makeRenderer; the worker keys dispatch by registration
324
+ // index.
325
+ const endpoints: EndpointDef[] = opts.actions?.endpoints ?? []
330
326
 
331
327
  if (!isWorker) {
332
328
  const { host, port, workers, cacheMaxEntries } = await loadConfig(process.cwd(), {
@@ -419,10 +415,20 @@ export const brust = {
419
415
  }
420
416
  }
421
417
 
422
- // Sub-project J — load .brust/jinja/*.jinja into the minijinja env so
423
- // the startup-validation warning in registerRoutes can compare against
424
- // a populated registry. Loads once at startup; dev hot reload reloads it.
425
- loadJinjaOnce(path.resolve(process.cwd(), '.brust/jinja'))
418
+ // Sub-project J — load the compiled `*.jinja` templates into the minijinja
419
+ // env so the startup-validation warning in registerRoutes can compare
420
+ // against a populated registry. Loads once at startup; dev hot reload
421
+ // reloads it. The templates ship INSIDE the build output, so a pre-built
422
+ // run reads them (and their islands/components sidecars) from
423
+ // `<distDir>/jinja`; dev compiles them to `cwd/.brust/jinja`.
424
+ const jinjaDir = prebuilt
425
+ ? path.join(distDir!, 'jinja')
426
+ : path.resolve(process.cwd(), '.brust/jinja')
427
+ configureJinjaDir(jinjaDir)
428
+ loadJinjaOnce(jinjaDir)
429
+ if (prebuilt && existsSync(jinjaDir)) {
430
+ console.log(`[brust] main: using pre-built jinja at ${jinjaDir}`)
431
+ }
426
432
 
427
433
  this.registerRoutes(routes)
428
434
  const ssePaths = routes
@@ -442,7 +448,7 @@ export const brust = {
442
448
  console.log(`[brust] main: registered ${wsPaths.length} ws path(s): ${wsPaths.join(', ')}`)
443
449
  }
444
450
  console.log(
445
- `[brust] main: scanActions found ${actions.length} action(s): ${actions.map((a) => a.id).join(', ')}`,
451
+ `[brust] main: registered ${endpoints.length} action endpoint(s): ${endpoints.map((e) => `${e.method} ${e.path}`).join(', ')}`,
446
452
  )
447
453
 
448
454
  if (dev) {
@@ -565,11 +571,11 @@ export const brust = {
565
571
  )
566
572
  }
567
573
  } else {
574
+ const actionsFile = path.join(scanRoot, 'actions.ts')
568
575
  mcpManifest = await this.buildMcpManifest({
569
- serverFiles: sourceFiles,
576
+ actionsFile: existsSync(actionsFile) ? actionsFile : undefined,
570
577
  routesFile: path.join(scanRoot, 'routes.tsx'),
571
578
  sourceRoots: [scanRoot],
572
- actions,
573
579
  routes,
574
580
  })
575
581
  console.log(
@@ -582,7 +588,8 @@ export const brust = {
582
588
  port,
583
589
  workers,
584
590
  entry: opts.entry,
585
- actions,
591
+ actions: opts.actions,
592
+ actionPrefix: opts.actionPrefix,
586
593
  ...(mcpManifest ? { mcp: { manifest: mcpManifest } } : {}),
587
594
  ...opts.serve,
588
595
  })
@@ -603,6 +610,15 @@ export const brust = {
603
610
  ...opts.routes,
604
611
  ]
605
612
  }
613
+ {
614
+ const { configureActionPrefixSnippet } = await import('./render/inject-action-prefix.ts')
615
+ const ap = opts.actionPrefix
616
+ configureActionPrefixSnippet(
617
+ ap && ap !== '/_brust/action'
618
+ ? `<script>globalThis.__BRUST_ACTION_PREFIX__=${JSON.stringify(ap)}</script>`
619
+ : null,
620
+ )
621
+ }
606
622
  // Worker: detect CSS the same way main did (no compile, no configureCssDir
607
623
  // — Rust state is shared, but the per-worker renderer needs the hrefs).
608
624
  if (prebuilt) {
@@ -644,7 +660,7 @@ export const brust = {
644
660
  let mcpServer: import('./mcp/server.ts').McpServer | undefined
645
661
  if (mcpManifest) {
646
662
  const { makeMcpServer } = await import('./mcp/server.ts')
647
- mcpServer = makeMcpServer({ manifest: mcpManifest, actions, routes: workerRoutes })
663
+ mcpServer = makeMcpServer({ manifest: mcpManifest, endpoints, routes: workerRoutes })
648
664
  console.log(`[brust] worker: mcp server ready (${mcpManifest.tools.length} tools)`)
649
665
  }
650
666
 
@@ -656,7 +672,11 @@ export const brust = {
656
672
 
657
673
  const { makeRenderer: make } = await import('./routes.ts')
658
674
  let wid: number | null = null
659
- const renderer = make(workerRoutes, view, { actions, getWorkerId: () => wid, mcp: mcpServer })
675
+ const renderer = make(workerRoutes, view, {
676
+ actions: endpoints,
677
+ getWorkerId: () => wid,
678
+ mcp: mcpServer,
679
+ })
660
680
  wid = this.registerRenderer(view, renderer)
661
681
  }
662
682
  },
@@ -674,9 +694,13 @@ export type {
674
694
  Middleware,
675
695
  } from './routes.ts'
676
696
 
677
- export { withMiddleware, isValidActionId } from './actions.ts'
678
- export type { ActionDef, ActionFn } from './actions.ts'
679
- export type { ScanOptions, ScanActionsResult } from './scan-actions.ts'
697
+ export { defineActions, isValidEndpointPath } from './define-actions.ts'
698
+ export type {
699
+ EndpointDef,
700
+ ActionContext,
701
+ EndpointOptions,
702
+ ActionsBuilder,
703
+ } from './define-actions.ts'
680
704
 
681
705
  export { loadConfig, BrustConfigError } from './config.ts'
682
706
  export type { BrustConfig } from './config.ts'
@@ -20,6 +20,23 @@ import path from 'node:path'
20
20
  import { renderToString } from 'react-dom/server.node'
21
21
  import { createElement } from 'react'
22
22
 
23
+ // Sub-project J — the directory holding the compiled `<Name>.jinja` templates
24
+ // and their `.islands.json` / `.components.json` sidecars. Configured ONCE at
25
+ // boot (`configureJinjaDir`) so it tracks the same location `loadJinjaOnce`
26
+ // loads from: `<BRUST_DIST_DIR>/jinja` for a pre-built run, `cwd/.brust/jinja`
27
+ // in dev. Falls back to the dev location when unset (tests pass an explicit dir).
28
+ let _configuredJinjaDir: string | null = null
29
+
30
+ /** Set the directory the sidecar-manifest readers resolve against. Called once
31
+ * at boot from `index.ts`, mirroring `configureIslandsDir`/`configureCssDir`. */
32
+ export function configureJinjaDir(dir: string): void {
33
+ _configuredJinjaDir = dir
34
+ }
35
+
36
+ function defaultJinjaDir(): string {
37
+ return _configuredJinjaDir ?? path.resolve(process.cwd(), '.brust/jinja')
38
+ }
39
+
23
40
  /** One entry of a `<template>.islands.json` manifest (enriched by T6). */
24
41
  export interface NativeIslandEntry {
25
42
  component: string
@@ -90,14 +107,14 @@ export function entityEncode(s: string): string {
90
107
  const manifestCache = new Map<string, NativeIslandEntry[] | null>()
91
108
 
92
109
  /** Read `<jinjaDir>/<templateName>.islands.json` and return the parsed entry
93
- * array, or `null` if the file doesn't exist. `jinjaDir` defaults to
94
- * `process.cwd()/.brust/jinja`; tests pass a temp dir. Both hits and misses
95
- * are cached by absolute path. */
110
+ * array, or `null` if the file doesn't exist. `jinjaDir` defaults to the
111
+ * boot-configured jinja dir (`configureJinjaDir`), or `cwd/.brust/jinja` when
112
+ * unset; tests pass a temp dir. Both hits and misses are cached by absolute path. */
96
113
  export function loadIslandManifest(
97
114
  templateName: string,
98
115
  jinjaDir?: string,
99
116
  ): NativeIslandEntry[] | null {
100
- const dir = jinjaDir ?? path.resolve(process.cwd(), '.brust/jinja')
117
+ const dir = jinjaDir ?? defaultJinjaDir()
101
118
  const abs = path.resolve(dir, `${templateName}.islands.json`)
102
119
  if (manifestCache.has(abs)) return manifestCache.get(abs)!
103
120
  let parsed: NativeIslandEntry[] | null
@@ -251,7 +268,7 @@ export function loadComponentManifest(
251
268
  templateName: string,
252
269
  jinjaDir?: string,
253
270
  ): NativeComponentEntry[] | null {
254
- const dir = jinjaDir ?? path.resolve(process.cwd(), '.brust/jinja')
271
+ const dir = jinjaDir ?? defaultJinjaDir()
255
272
  const abs = path.resolve(dir, `${templateName}.components.json`)
256
273
  if (componentManifestCache.has(abs)) return componentManifestCache.get(abs)!
257
274
  let parsed: NativeComponentEntry[] | null
@@ -282,7 +299,7 @@ export async function resolveComponentContext(
282
299
  const out: Record<string, string> = {}
283
300
  if (!manifest.length) return out
284
301
 
285
- const dir = jinjaDir ?? path.resolve(process.cwd(), '.brust/jinja')
302
+ const dir = jinjaDir ?? defaultJinjaDir()
286
303
  const factoryPath = path.resolve(dir, `${templateName}.factory.ts`)
287
304
 
288
305
  let factoryMod = factoryCache.get(factoryPath)