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/runtime/routes.ts CHANGED
@@ -17,7 +17,9 @@ import {
17
17
  resolveComponentContext,
18
18
  } from './islands/native-render.ts'
19
19
  import type { IslandCache } from './islands/native-render.ts'
20
- import type { ActionDef } from './actions.ts'
20
+ import type { EndpointDef } from './define-actions.ts'
21
+ import { isRespondSentinel, makeRespond } from './define-actions.ts'
22
+ import { validate } from './standard-schema.ts'
21
23
 
22
24
  // Sub-project J — island ISR cache, backed by the Rust-side store (shared across
23
25
  // the worker pool) via NAPI. napi-rs maps snake→camel: island_cache_get →
@@ -441,6 +443,8 @@ export type RouteCall =
441
443
  /** Base64-encoded binary body — present for multipart/form-data.
442
444
  * JS decodes via Buffer.from(s, 'base64') before parsing. */
443
445
  body_b64?: string
446
+ /** Path params extracted by the Rust router (e.g. {id} → "abc"). */
447
+ params?: Record<string, string>
444
448
  req: BrustRequest
445
449
  }
446
450
  | {
@@ -475,7 +479,7 @@ export interface MakeRendererOptions {
475
479
  * Both the main process and each worker call `brust.scanActions(...)` at
476
480
  * module top-level and pass the resulting array here — the wire keys (ids)
477
481
  * and the handler functions (fn) must agree across both ends. */
478
- actions?: ActionDef[]
482
+ actions?: EndpointDef[]
479
483
  /** MCP server instance — built once per worker at module top-level. */
480
484
  mcp?: import('./mcp/server.ts').McpServer
481
485
  }
@@ -491,8 +495,10 @@ export function makeRenderer(
491
495
  routes.forEach((r, i) => {
492
496
  byRouteId.set(i, r)
493
497
  })
494
- const byActionId = new Map<string, ActionDef>()
495
- for (const a of opts.actions ?? []) byActionId.set(a.id, a)
498
+ const byActionId = new Map<string, EndpointDef>()
499
+ opts.actions?.forEach((e, i) => {
500
+ byActionId.set(String(i), e)
501
+ })
496
502
 
497
503
  // napi shim for the chunk channel. The sabBytes arg is ignored by the
498
504
  // native fn (Rust reads from the pre-registered BufPtr) — the call sites
@@ -726,7 +732,7 @@ export function makeRenderer(
726
732
  if (call.kind === 'action') {
727
733
  // FAST LANE: pack the framed response into the SAB and return its length.
728
734
  // Rust reads it directly after the Promise settles — no chunk channel.
729
- const resp = await actionBranchToResponse(call, byActionId)
735
+ const resp = await dispatchAction(call, byActionId)
730
736
  return packSingleChunkResponse(view, encoder, resp)
731
737
  }
732
738
  if (call.kind === 'mcp') {
@@ -1090,82 +1096,131 @@ interface BranchResponse {
1090
1096
  headers?: Record<string, string>
1091
1097
  }
1092
1098
 
1093
- async function actionBranchToResponse(
1099
+ async function decodeActionBody(
1094
1100
  call: Extract<RouteCall, { kind: 'action' }>,
1095
- byId: Map<string, ActionDef>,
1101
+ ): Promise<{ ok: true; value: unknown } | { ok: false; status: number; body: string }> {
1102
+ const ct = (call.content_type ?? '').toLowerCase()
1103
+ // urlencoded → flat object of strings
1104
+ if (ct.startsWith('application/x-www-form-urlencoded')) {
1105
+ return { ok: true, value: Object.fromEntries(new URLSearchParams(call.body_text ?? '')) }
1106
+ }
1107
+ // multipart → object of strings + File entries (via Bun's Response.formData)
1108
+ if (ct.startsWith('multipart/form-data')) {
1109
+ try {
1110
+ const bytes = Buffer.from(call.body_b64 ?? '', 'base64')
1111
+ const fd = await new Response(bytes, {
1112
+ headers: { 'content-type': call.content_type },
1113
+ }).formData()
1114
+ return { ok: true, value: Object.fromEntries(fd.entries()) }
1115
+ } catch (err) {
1116
+ return {
1117
+ ok: false,
1118
+ status: 400,
1119
+ body: JSON.stringify({
1120
+ error: { message: `invalid multipart body: ${(err as Error).message}` },
1121
+ }),
1122
+ }
1123
+ }
1124
+ }
1125
+ // default: JSON
1126
+ try {
1127
+ return {
1128
+ ok: true,
1129
+ value:
1130
+ call.body_text != null && call.body_text !== '' ? JSON.parse(call.body_text) : undefined,
1131
+ }
1132
+ } catch (err) {
1133
+ return {
1134
+ ok: false,
1135
+ status: 400,
1136
+ body: JSON.stringify({
1137
+ error: { message: `invalid JSON body: ${(err as Error).message}` },
1138
+ }),
1139
+ }
1140
+ }
1141
+ }
1142
+
1143
+ export async function dispatchAction(
1144
+ call: Extract<RouteCall, { kind: 'action' }>,
1145
+ byId: Map<string, EndpointDef>,
1096
1146
  ): Promise<BranchResponse> {
1097
1147
  const def = byId.get(call.action_id)
1098
1148
  if (!def) {
1099
- // Rust already 404s when the id isn't registered, but a race during
1100
- // hot-reload (or a desynced worker) could land here. Log and 404.
1101
- // Action clients always expect JSON, so ship a JSON envelope even
1102
- // when Rust would have 404'd first.
1103
- console.error(`[brust] unknown action_id=${call.action_id}`)
1104
1149
  return {
1105
1150
  status: 404,
1106
1151
  body: '{"error":{"message":"unknown action"}}',
1107
1152
  contentType: 'application/json; charset=utf-8',
1108
1153
  }
1109
1154
  }
1110
- // Populate req.signal with the permanently-unaborted sentinel. Action
1111
- // handlers reading req.signal.aborted always see false; the SSE branch
1112
- // is where real disconnect lives.
1113
1155
  call.req.signal = NEVER_ABORTS
1114
1156
 
1115
- // Decode the body into the args array that will be spread into the handler.
1116
- // Three paths: multipart (body_b64), form-urlencoded (body_text), or JSON (body_text).
1117
- // Body decode happens BEFORE middleware so a malformed body 400s without running
1118
- // any user code.
1119
- let args: unknown[]
1120
- try {
1121
- if (call.body_b64 !== undefined) {
1122
- // Multipart path — base64 → bytes → Web Request.formData()
1123
- const bytes = Buffer.from(call.body_b64, 'base64')
1124
- const synthReq = new Request('http://x', {
1125
- method: 'POST',
1126
- headers: { 'Content-Type': call.content_type },
1127
- body: bytes,
1128
- })
1129
- const fd = await synthReq.formData()
1130
- args = [fd]
1131
- } else if (call.content_type.toLowerCase().startsWith('application/x-www-form-urlencoded')) {
1132
- // Form-urlencoded path — URLSearchParams → FormData
1133
- const params = new URLSearchParams(call.body_text ?? '')
1134
- const fd = new FormData()
1135
- for (const [k, v] of params) fd.append(k, v)
1136
- args = [fd]
1137
- } else {
1138
- // JSON path (default — empty or application/json content type).
1139
- const decoded = JSON.parse(call.body_text ?? '') as unknown
1140
- if (!Array.isArray(decoded)) {
1141
- return {
1142
- status: 400,
1143
- body: '{"error":{"message":"args must be a JSON array"}}',
1144
- contentType: 'application/json; charset=utf-8',
1145
- }
1157
+ // Body decode dispatch by content-type (JSON / urlencoded / multipart).
1158
+ let rawBody: unknown
1159
+ if (def.method !== 'GET' && def.method !== 'HEAD') {
1160
+ const decoded = await decodeActionBody(call)
1161
+ if (!decoded.ok) {
1162
+ return {
1163
+ status: decoded.status,
1164
+ body: decoded.body,
1165
+ contentType: 'application/json; charset=utf-8',
1146
1166
  }
1147
- args = decoded
1148
1167
  }
1149
- } catch (err) {
1150
- const msg = err instanceof Error ? err.message : String(err)
1168
+ rawBody = decoded.value
1169
+ }
1170
+
1171
+ const bodyCheck = await validate(def.body, rawBody)
1172
+ if (!bodyCheck.ok) {
1151
1173
  return {
1152
- status: 400,
1153
- body: JSON.stringify({ error: { message: `invalid request body: ${msg}` } }),
1174
+ status: 422,
1175
+ body: JSON.stringify({
1176
+ error: { message: 'body validation failed', issues: bodyCheck.issues },
1177
+ }),
1154
1178
  contentType: 'application/json; charset=utf-8',
1155
1179
  }
1156
1180
  }
1181
+ // `req.search` already arrives as a parsed key→value object from the Rust
1182
+ // envelope (BrustRequest.search: Record<string, string>) — use it directly
1183
+ // as the query object rather than re-parsing it as a string.
1184
+ const queryObj = call.req.search ?? {}
1185
+ const queryCheck = await validate(def.query, queryObj)
1186
+ if (!queryCheck.ok) {
1187
+ return {
1188
+ status: 422,
1189
+ body: JSON.stringify({
1190
+ error: { message: 'query validation failed', issues: queryCheck.issues },
1191
+ }),
1192
+ contentType: 'application/json; charset=utf-8',
1193
+ }
1194
+ }
1195
+
1196
+ const ctx = {
1197
+ req: call.req,
1198
+ body: bodyCheck.value,
1199
+ params: call.params ?? {},
1200
+ query: queryCheck.value,
1201
+ headers: call.req.headers ?? {},
1202
+ respond: makeRespond(),
1203
+ }
1157
1204
 
1158
1205
  const terminal = async (): Promise<RouteResponse> => {
1159
1206
  try {
1160
- const result = await def.fn(call.req, ...args)
1207
+ const result = await def.handler(ctx as never)
1208
+ if (isRespondSentinel(result)) {
1209
+ return {
1210
+ status: result.status,
1211
+ body: result.body === undefined ? '' : JSON.stringify(result.body),
1212
+ contentType: 'application/json; charset=utf-8',
1213
+ headers: result.headers,
1214
+ }
1215
+ }
1161
1216
  return {
1162
1217
  status: 200,
1163
1218
  body: result === undefined ? '' : JSON.stringify(result),
1164
1219
  contentType: 'application/json; charset=utf-8',
1165
1220
  }
1166
1221
  } catch (err) {
1167
- console.error(`[brust] action ${def.id} threw:`, err)
1168
1222
  const e = err instanceof Error ? err : new Error(String(err))
1223
+ console.error(`[brust] action ${def.method} ${def.path} threw:`, err)
1169
1224
  return {
1170
1225
  status: 500,
1171
1226
  body: JSON.stringify({ error: { message: e.message, name: e.name } }),
@@ -1175,15 +1230,14 @@ async function actionBranchToResponse(
1175
1230
  }
1176
1231
 
1177
1232
  const chain = composeChain(call.req, def.middleware, terminal)
1178
-
1179
1233
  let response: RouteResponse
1180
1234
  try {
1181
1235
  response = await chain()
1182
1236
  } catch (err) {
1183
- console.error(`[brust] action middleware uncaught:`, err)
1237
+ console.error('[brust] action middleware uncaught:', err)
1184
1238
  response = {
1185
1239
  status: 500,
1186
- body: JSON.stringify({ error: { message: 'internal error' } }),
1240
+ body: '{"error":{"message":"internal error"}}',
1187
1241
  contentType: 'application/json; charset=utf-8',
1188
1242
  }
1189
1243
  }
@@ -0,0 +1,29 @@
1
+ /** Minimal Standard Schema v1 surface — https://standardschema.dev */
2
+ export interface StandardSchemaV1<Input = unknown, Output = Input> {
3
+ readonly '~standard': {
4
+ readonly version: 1
5
+ readonly vendor: string
6
+ readonly validate: (value: unknown) => StandardResult<Output> | Promise<StandardResult<Output>>
7
+ }
8
+ }
9
+ type StandardResult<Output> =
10
+ | { readonly value: Output; readonly issues?: undefined }
11
+ | { readonly issues: ReadonlyArray<StandardIssue> }
12
+ export interface StandardIssue {
13
+ readonly message: string
14
+ readonly path?: ReadonlyArray<PropertyKey | { key: PropertyKey }>
15
+ }
16
+ export type InferOutput<S> = S extends StandardSchemaV1<unknown, infer O> ? O : never
17
+ export type ValidateOk<T> = { ok: true; value: T }
18
+ export type ValidateErr = { ok: false; issues: ReadonlyArray<StandardIssue> }
19
+
20
+ export async function validate<S extends StandardSchemaV1 | undefined>(
21
+ schema: S,
22
+ input: unknown,
23
+ ): Promise<ValidateOk<S extends StandardSchemaV1 ? InferOutput<S> : unknown> | ValidateErr> {
24
+ if (schema === undefined) return { ok: true, value: input as never }
25
+ let result = schema['~standard'].validate(input)
26
+ if (result instanceof Promise) result = await result
27
+ if (result.issues) return { ok: false, issues: result.issues }
28
+ return { ok: true, value: result.value as never }
29
+ }
@@ -0,0 +1,131 @@
1
+ const METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'head'])
2
+
3
+ export interface TreatyResponse<Data = unknown, Err = unknown> {
4
+ data: Data | null
5
+ error: { status: number; value: Err } | null
6
+ status: number
7
+ headers: Record<string, string>
8
+ response: Response | null
9
+ }
10
+ export interface ClientOptions {
11
+ prefix?: string
12
+ headers?: Record<string, string> | (() => Record<string, string>)
13
+ fetch?: typeof fetch
14
+ }
15
+
16
+ function resolvePrefix(opts?: ClientOptions): string {
17
+ if (opts?.prefix) return opts.prefix
18
+ const g = (globalThis as { __BRUST_ACTION_PREFIX__?: string }).__BRUST_ACTION_PREFIX__
19
+ return g ?? '/_brust/action'
20
+ }
21
+
22
+ /** Build a treaty proxy. Static segments accumulate as a path; a function call
23
+ * with an object fills the next {param}(s) positionally (in insertion order); a
24
+ * terminal method key (.get/.post/…) performs the request. URL is composed from
25
+ * the literal accumulated segments — never from any inferred type. */
26
+ export function client<App = unknown>(opts?: ClientOptions): App {
27
+ const prefix = resolvePrefix(opts)
28
+ const doFetch = opts?.fetch ?? fetch
29
+ function make(segments: string[]): any {
30
+ return new Proxy(() => {}, {
31
+ get(_t, key: string) {
32
+ if (METHODS.has(key)) {
33
+ return async (arg1?: unknown, arg2?: unknown): Promise<TreatyResponse> => {
34
+ const isBodyless = key === 'get' || key === 'head'
35
+ const options = (isBodyless ? arg1 : arg2) as
36
+ | { query?: Record<string, string>; headers?: Record<string, string> }
37
+ | undefined
38
+ const body = isBodyless ? undefined : arg1
39
+ let url = prefix + '/' + segments.join('/')
40
+ if (options?.query) {
41
+ const qs = new URLSearchParams(options.query as Record<string, string>).toString()
42
+ if (qs) url += '?' + qs
43
+ }
44
+ const baseHeaders =
45
+ typeof opts?.headers === 'function' ? opts.headers() : (opts?.headers ?? {})
46
+ const init: RequestInit = {
47
+ method: key.toUpperCase(),
48
+ headers: { ...baseHeaders, ...(options?.headers ?? {}) },
49
+ }
50
+ if (!isBodyless && body !== undefined) {
51
+ if (hasFilePart(body)) {
52
+ init.body = toFormData(body)
53
+ // Let fetch set the multipart boundary; a stale content-type
54
+ // (e.g. from caller headers) would break it.
55
+ delete (init.headers as Record<string, string>)['content-type']
56
+ } else {
57
+ init.body = JSON.stringify(body)
58
+ ;(init.headers as Record<string, string>)['content-type'] = 'application/json'
59
+ }
60
+ }
61
+ try {
62
+ const res = await doFetch(url, init)
63
+ const text = await res.text()
64
+ const parsed = text ? safeJson(text) : undefined
65
+ const headers: Record<string, string> = {}
66
+ res.headers.forEach((v, k) => {
67
+ headers[k] = v
68
+ })
69
+ if (res.ok)
70
+ return {
71
+ data: parsed ?? null,
72
+ error: null,
73
+ status: res.status,
74
+ headers,
75
+ response: res,
76
+ }
77
+ return {
78
+ data: null,
79
+ error: { status: res.status, value: parsed ?? text },
80
+ status: res.status,
81
+ headers,
82
+ response: res,
83
+ }
84
+ } catch (err) {
85
+ return {
86
+ data: null,
87
+ error: { status: 0, value: err },
88
+ status: 0,
89
+ headers: {},
90
+ response: null,
91
+ }
92
+ }
93
+ }
94
+ }
95
+ return make([...segments, key])
96
+ },
97
+ apply(_t, _this, args: unknown[]) {
98
+ const arg = args[0] as Record<string, string> | undefined
99
+ if (!arg) return make(segments)
100
+ return make([...segments, ...Object.values(arg).map(String)])
101
+ },
102
+ })
103
+ }
104
+ return make([]) as App
105
+ }
106
+
107
+ function hasFilePart(b: unknown): boolean {
108
+ if (b instanceof FormData || b instanceof Blob) return true
109
+ if (b && typeof b === 'object')
110
+ return Object.values(b as Record<string, unknown>).some((v) => v instanceof Blob)
111
+ return false
112
+ }
113
+
114
+ function toFormData(b: unknown): FormData {
115
+ if (b instanceof FormData) return b
116
+ const fd = new FormData()
117
+ for (const [k, v] of Object.entries(b as Record<string, unknown>)) {
118
+ if (v instanceof Blob) fd.append(k, v)
119
+ else if (v !== null && typeof v === 'object') fd.append(k, JSON.stringify(v))
120
+ else fd.append(k, String(v))
121
+ }
122
+ return fd
123
+ }
124
+
125
+ function safeJson(s: string): unknown {
126
+ try {
127
+ return JSON.parse(s)
128
+ } catch {
129
+ return s
130
+ }
131
+ }
@@ -1,97 +0,0 @@
1
- import { writeFile } from 'node:fs/promises'
2
- import { resolve } from 'node:path'
3
- import type { BunPlugin } from 'bun'
4
-
5
- /** Write a TypeScript file that re-exports `scanActions` as a pure function
6
- * returning a hard-coded ActionDef[]. Bundle this file via `actionsPrebuiltPlugin`
7
- * below to satisfy `import { scanActions } from './scan-actions.ts'` lookups
8
- * inside the runtime bundle without walking the filesystem at runtime.
9
- *
10
- * `idToSourcePath` maps each discovered action id → absolute path of the
11
- * `'use server'` file it was exported from. The orchestrator builds this map
12
- * by calling `collectExports(sourceFile)` for every file in `scan.sourceFiles`.
13
- *
14
- * `repoRoot` is the absolute path of the brust repo (where `runtime/` lives).
15
- * `outPath` is where to write the generated file (e.g. <outDir>/_actions-prebuilt.ts).
16
- */
17
- export async function writePrebuiltActionsFileWithMap(
18
- outPath: string,
19
- idToSourcePath: Map<string, string>,
20
- repoRoot: string,
21
- ): Promise<void> {
22
- const actionsPath = resolve(repoRoot, 'runtime/actions.ts')
23
- const scanPath = resolve(repoRoot, 'runtime/scan-actions.ts')
24
-
25
- // Stable file order = stable import order = deterministic builds.
26
- const filePaths = [...new Set(idToSourcePath.values())].sort()
27
- const fileToVar = new Map<string, string>()
28
- filePaths.forEach((p, i) => {
29
- fileToVar.set(p, `__mod${i}`)
30
- })
31
-
32
- const imports = filePaths
33
- .map((p, i) => `import * as __mod${i} from ${JSON.stringify(p)}`)
34
- .join('\n')
35
-
36
- const sortedIds = [...idToSourcePath.keys()].sort((a, b) => a.localeCompare(b))
37
- const entries = sortedIds
38
- .map((id) => {
39
- const v = fileToVar.get(idToSourcePath.get(id)!)!
40
- const key = JSON.stringify(id)
41
- return ` { id: ${key}, fn: (${v}[${key}] as any), middleware: getActionMiddleware(${v}[${key}]) }`
42
- })
43
- .join(',\n')
44
-
45
- const src = `// AUTO-GENERATED by brust build — do not edit.
46
- import { getActionMiddleware } from ${JSON.stringify(actionsPath)}
47
- import type { ActionDef } from ${JSON.stringify(actionsPath)}
48
- import type { ScanOptions, ScanActionsResult } from ${JSON.stringify(scanPath)}
49
- ${imports}
50
-
51
- // Re-export auxiliaries so any bundle import from scan-actions.ts still resolves.
52
- export { stripLeadingTrivia, hasUseServerDirective, collectExports } from ${JSON.stringify(scanPath)}
53
- export type { ScanOptions, ScanActionsResult } from ${JSON.stringify(scanPath)}
54
-
55
- const PREBUILT: ActionDef[] = [
56
- ${entries}
57
- ]
58
-
59
- export async function scanActions(_opts: ScanOptions = {}): Promise<ScanActionsResult> {
60
- return { actions: PREBUILT, sourceFiles: [] }
61
- }
62
- `
63
- await writeFile(outPath, src, 'utf-8')
64
- }
65
-
66
- /** Bun.build plugin that aliases `runtime/scan-actions.ts` to the generated
67
- * prebuilt file. Combined with `writePrebuiltActionsFileWithMap` this makes
68
- * the bundled `scanActions()` return a hard-coded list. */
69
- export function actionsPrebuiltPlugin(generatedFilePath: string, repoRoot: string): BunPlugin {
70
- const targetPath = resolve(repoRoot, 'runtime/scan-actions.ts')
71
-
72
- return {
73
- name: 'brust-actions-prebuilt',
74
- setup(build) {
75
- build.onResolve({ filter: /.*/ }, (args) => {
76
- // The generated file re-exports symbols from scan-actions.ts. Without this
77
- // guard those re-exports would self-redirect back into the generated file,
78
- // creating a circular module graph where the re-exported bindings resolve
79
- // to `undefined`.
80
- if (args.importer === generatedFilePath) return undefined
81
-
82
- // Match the canonical scan-actions.ts file regardless of how it's
83
- // imported (relative './scan-actions.ts' from runtime/index.ts, etc.).
84
- // Resolve the import to an absolute path and compare.
85
- const resolved = resolve(
86
- args.importer ? args.importer.replace(/\/[^/]*$/, '') : repoRoot,
87
- args.path,
88
- )
89
- if (resolved === targetPath || resolved + '.ts' === targetPath) {
90
- return { path: generatedFilePath }
91
- }
92
- // Default resolution for everything else.
93
- return undefined
94
- })
95
- },
96
- }
97
- }
@@ -1,172 +0,0 @@
1
- import { relative } from 'node:path'
2
- import type { ActionDef, ActionFn } from './actions.ts'
3
- import { isValidActionId, getActionMiddleware } from './actions.ts'
4
-
5
- const DIRECTIVE_HEAD_BYTES = 512
6
-
7
- /** Remove leading whitespace, line comments (`//`), and block comments
8
- * from `src` and return the rest. Stops at the first non-trivial character.
9
- * Does NOT understand string literals — fine because we only run this on a
10
- * directive prologue, which by spec contains comments and the directive only.
11
- * If a block comment never terminates, returns '' so the caller treats the
12
- * file as non-server. */
13
- export function stripLeadingTrivia(src: string): string {
14
- let i = 0
15
- while (i < src.length) {
16
- const ch = src[i]
17
- if (ch === ' ' || ch === '\t' || ch === '\r' || ch === '\n') {
18
- i++
19
- continue
20
- }
21
- if (ch === '/' && src[i + 1] === '/') {
22
- const nl = src.indexOf('\n', i)
23
- if (nl === -1) return ''
24
- i = nl + 1
25
- continue
26
- }
27
- if (ch === '/' && src[i + 1] === '*') {
28
- const end = src.indexOf('*/', i + 2)
29
- if (end === -1) return ''
30
- i = end + 2
31
- continue
32
- }
33
- break
34
- }
35
- return src.slice(i)
36
- }
37
-
38
- const USE_SERVER_PATTERN = /^(?:'use server'|"use server")\s*;?\s*(?:\r?\n|$)/
39
-
40
- /** Read the first 512 bytes of `filePath` and return true iff a file-level
41
- * `'use server'` directive sits at the prologue position (before any import
42
- * or other statement). Comments and whitespace ahead of the directive are
43
- * skipped. Mirrors the TC39 directive-prologue rule. */
44
- export async function hasUseServerDirective(filePath: string): Promise<boolean> {
45
- const f = Bun.file(filePath)
46
- const head = await f.slice(0, DIRECTIVE_HEAD_BYTES).text()
47
- const stripped = stripLeadingTrivia(head)
48
- return USE_SERVER_PATTERN.test(stripped)
49
- }
50
-
51
- /** Dynamically import `filePath` and collect every named function export as
52
- * an ActionDef. Skips non-function exports silently. Throws on:
53
- * - default export (must be named)
54
- * - class export (calling a class without `new` would 500 at dispatch)
55
- * - invalid id charset
56
- * - zero function exports (likely a bug — file marked 'use server' but
57
- * publishes nothing).
58
- * Middleware metadata installed by withMiddleware is preserved. */
59
- export async function collectExports(filePath: string): Promise<ActionDef[]> {
60
- const mod = (await import(filePath)) as Record<string, unknown>
61
- const defs: ActionDef[] = []
62
- for (const [name, value] of Object.entries(mod)) {
63
- if (typeof value !== 'function') continue
64
- // typeof class{} is 'function' in JS; reject explicitly so an accidental
65
- // `export class Foo {}` in a 'use server' file fails loudly at scan,
66
- // not with a confusing 500 at dispatch.
67
- if (Function.prototype.toString.call(value).startsWith('class ')) {
68
- throw new Error(
69
- `${filePath}: export "${name}" is a class. Actions must be plain async functions, not class constructors.`,
70
- )
71
- }
72
- if (name === 'default') {
73
- throw new Error(`${filePath}: default exports are not action-eligible. Use named export.`)
74
- }
75
- if (!isValidActionId(name)) {
76
- throw new Error(
77
- `${filePath}: export "${name}" has invalid id (must match [A-Za-z0-9_-]+, 1-128 chars).`,
78
- )
79
- }
80
- defs.push({
81
- id: name,
82
- fn: value as ActionFn,
83
- middleware: getActionMiddleware(value),
84
- })
85
- }
86
- if (defs.length === 0) {
87
- throw new Error(`${filePath}: marked 'use server' but exports no functions.`)
88
- }
89
- return defs
90
- }
91
-
92
- export interface ScanOptions {
93
- /** Glob roots to scan from. Default: ['./']. Pass an explicit root (e.g.
94
- * `import.meta.dirname`) when the project layout includes sibling subtrees
95
- * you don't want scanned — typical for example apps inside a larger repo. */
96
- roots?: string[]
97
- /** Ignore globs (matched against the path relative to each root). Override
98
- * the default array if you need a different policy — there's no merge.
99
- * Default covers build outputs and test patterns. */
100
- ignore?: string[]
101
- }
102
-
103
- const DEFAULT_IGNORE = Object.freeze([
104
- 'node_modules/**',
105
- '.brust/**',
106
- 'dist/**',
107
- 'build/**',
108
- 'tests/**',
109
- '__tests__/**',
110
- '*.test.ts',
111
- '*.test.tsx',
112
- '*.spec.ts',
113
- '*.spec.tsx',
114
- ])
115
-
116
- const FILE_PATTERN = '**/*.{ts,tsx,js,jsx,mjs,cjs}'
117
-
118
- async function findCandidateFiles(opts: ScanOptions): Promise<string[]> {
119
- const roots = opts.roots ?? ['./']
120
- const ignore = opts.ignore ?? [...DEFAULT_IGNORE]
121
- const ignoreGlobs = ignore.map((p) => new Bun.Glob(p))
122
- const out: string[] = []
123
- for (const root of roots) {
124
- const glob = new Bun.Glob(FILE_PATTERN)
125
- for await (const f of glob.scan({ cwd: root, dot: false, absolute: true })) {
126
- const rel = relative(root, f)
127
- if (ignoreGlobs.some((g) => g.match(rel))) continue
128
- out.push(f)
129
- }
130
- }
131
- return out
132
- }
133
-
134
- export interface ScanActionsResult {
135
- actions: ActionDef[]
136
- /** Absolute paths of files that had a `'use server'` directive — needed by
137
- * `brust.buildMcpManifest` to feed the TypeScript compiler API extractor. */
138
- sourceFiles: string[]
139
- }
140
-
141
- /** Walk the project, find files whose first statement is `'use server'`,
142
- * import each, and return all named function exports as ActionDef[] plus the
143
- * list of server source files.
144
- * Throws on duplicate ids across files. Always returns actions sorted by id
145
- * for deterministic logging. */
146
- export async function scanActions(opts: ScanOptions = {}): Promise<ScanActionsResult> {
147
- const candidates = await findCandidateFiles(opts)
148
- // Run directive checks in parallel — file IO scales well there.
149
- const directiveChecks = await Promise.all(
150
- candidates.map(async (p) => ({ path: p, isServer: await hasUseServerDirective(p) })),
151
- )
152
- const serverFiles = directiveChecks.filter((c) => c.isServer).map((c) => c.path)
153
-
154
- // Serial imports — heavy module side effects shouldn't all fire at once.
155
- const byId = new Map<string, string>()
156
- const all: ActionDef[] = []
157
- for (const file of serverFiles) {
158
- const defs = await collectExports(file)
159
- for (const def of defs) {
160
- const prior = byId.get(def.id)
161
- if (prior) {
162
- throw new Error(
163
- `Duplicate action "${def.id}" — defined in both ${prior} and ${file}. Rename one.`,
164
- )
165
- }
166
- byId.set(def.id, file)
167
- all.push(def)
168
- }
169
- }
170
- all.sort((a, b) => a.id.localeCompare(b.id))
171
- return { actions: all, sourceFiles: serverFiles }
172
- }