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/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 {
|
|
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?:
|
|
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,
|
|
495
|
-
|
|
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
|
|
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
|
|
1099
|
+
async function decodeActionBody(
|
|
1094
1100
|
call: Extract<RouteCall, { kind: 'action' }>,
|
|
1095
|
-
|
|
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
|
-
//
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
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
|
-
|
|
1150
|
-
|
|
1168
|
+
rawBody = decoded.value
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
const bodyCheck = await validate(def.body, rawBody)
|
|
1172
|
+
if (!bodyCheck.ok) {
|
|
1151
1173
|
return {
|
|
1152
|
-
status:
|
|
1153
|
-
body: JSON.stringify({
|
|
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.
|
|
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(
|
|
1237
|
+
console.error('[brust] action middleware uncaught:', err)
|
|
1184
1238
|
response = {
|
|
1185
1239
|
status: 500,
|
|
1186
|
-
body:
|
|
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
|
-
}
|
package/runtime/scan-actions.ts
DELETED
|
@@ -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
|
-
}
|