brustjs 0.1.11-alpha → 0.1.13-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 →
@@ -334,7 +336,7 @@ function validateRoute(r: Route, basePath: string): void {
334
336
 
335
337
  /** Walk the nested route tree, emitting one FlatRoute per leaf or index node.
336
338
  * Composes paths, middleware, errorBoundary, and cache per the rules in
337
- * the design spec (§3). */
339
+ * the design spec (S3). */
338
340
  export function flattenRoutes(routes: Route[]): FlatRoute[] {
339
341
  const out: FlatRoute[] = []
340
342
  walkRoutes(routes, [], '', out)
@@ -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
@@ -587,7 +593,7 @@ export function makeRenderer(
587
593
  // path above and works for native routes. But middleware that calls
588
594
  // next() then mutates status/headers (e.g. adds Cache-Control) is NOT
589
595
  // forwarded; napi_render_jinja hardcodes status: 200 and empty headers.
590
- // Spec §4 doesn't define post-next() mutation semantics for native;
596
+ // Spec S4 doesn't define post-next() mutation semantics for native;
591
597
  // deferred to v2.x. If your middleware needs to mutate, use a React
592
598
  // route for now.
593
599
  if (flat.nativeTemplate !== undefined) {
@@ -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') {
@@ -878,13 +884,24 @@ async function navigationBranch(
878
884
  }
879
885
 
880
886
  try {
881
- const element = await buildRenderElement(call as any, flat, getWorkerId)
882
- if (!element) throw new Error('render setup failed')
883
- // Use renderToPipeableStream + onAllReady so pages with <Suspense> emit
884
- // their RESOLVED markup, not the fallback. renderToString would only
885
- // capture the shell navigating SPA-style to a Suspense-using route
886
- // would otherwise ship "loading…" and never recover.
887
- const fullHtml = await renderToAwaitedString(element)
887
+ // Native (jinja) routes have no React tree, so we can't renderToString them.
888
+ // Render the template Rust-side (same path as a full document load) and use
889
+ // its HTML; React routes keep the renderToAwaitedString path below. Without
890
+ // this branch, a SPA navigation to a native route React-renders a component
891
+ // whose loader fields arrive undefined throws 500 → full-reload fallback
892
+ // on every internal link.
893
+ let fullHtml: string
894
+ if (flat.nativeTemplate !== undefined) {
895
+ fullHtml = await renderNativeRouteToHtml(call, flat, view, encoder, workerId)
896
+ } else {
897
+ const element = await buildRenderElement(call as any, flat, getWorkerId)
898
+ if (!element) throw new Error('render setup failed')
899
+ // Use renderToPipeableStream + onAllReady so pages with <Suspense> emit
900
+ // their RESOLVED markup, not the fallback. renderToString would only
901
+ // capture the shell — navigating SPA-style to a Suspense-using route
902
+ // would otherwise ship "loading…" and never recover.
903
+ fullHtml = await renderToAwaitedString(element)
904
+ }
888
905
 
889
906
  // Extract <main> inner content. If the page didn't render a <main>,
890
907
  // ship the full HTML — the client's no-main check will fire its
@@ -919,6 +936,65 @@ async function navigationBranch(
919
936
  }
920
937
  }
921
938
 
939
+ /** Render a native (jinja) route to its full HTML document for a SPA navigation.
940
+ *
941
+ * Mirrors the render-branch native path: run the leaf loader, merge island /
942
+ * component manifest context, then call the SYNC `napiRenderJinja`, which writes
943
+ * a framed `[meta_len u16 BE][meta JSON][body]` response into the SAB and returns
944
+ * its length. Here — unlike the render branch, which returns that length so Rust
945
+ * streams the SAB straight to the socket — we read the body back out of the SAB
946
+ * and hand it to navigationBranch, which extracts `<main>` + `<title>` from it.
947
+ *
948
+ * Throws on loader failure / oversized data / non-200 render; navigationBranch's
949
+ * catch turns that into a 500 (→ client full-reload fallback). */
950
+ async function renderNativeRouteToHtml(
951
+ call: Extract<RouteCall, { kind: 'navigation' }>,
952
+ flat: FlatRoute,
953
+ view: Uint8Array,
954
+ encoder: TextEncoder,
955
+ workerId: bigint,
956
+ ): Promise<string> {
957
+ const templateName = flat.nativeTemplate as string
958
+ const leaf = flat.chain[flat.chain.length - 1]
959
+
960
+ let data: unknown = {}
961
+ if (leaf.loader) {
962
+ data = await leaf.loader({ params: call.params, path: call.path, req: call.req } as any)
963
+ }
964
+
965
+ let ctx = (data ?? {}) as Record<string, unknown>
966
+ const manifest = loadIslandManifest(templateName)
967
+ const compManifest = loadComponentManifest(templateName)
968
+ if ((manifest && manifest.length > 0) || (compManifest && compManifest.length > 0)) {
969
+ const [islandExtra, componentExtra] = await Promise.all([
970
+ manifest && manifest.length > 0
971
+ ? resolveIslandContext(manifest, ctx, islandCache)
972
+ : Promise.resolve({} as Record<string, string>),
973
+ compManifest && compManifest.length > 0
974
+ ? resolveComponentContext(compManifest, ctx, templateName, undefined, islandCache)
975
+ : Promise.resolve({} as Record<string, string>),
976
+ ])
977
+ ctx = { ...ctx, ...islandExtra, ...componentExtra }
978
+ }
979
+
980
+ const bytes = encoder.encode(JSON.stringify(ctx))
981
+ if (bytes.length > view.length) throw new Error('native loader data too large for SAB')
982
+ view.set(bytes, 0)
983
+
984
+ const len = (native as any).napiRenderJinja(
985
+ Number(workerId),
986
+ bytes.length,
987
+ templateName,
988
+ ) as number
989
+
990
+ // Parse the framed response the SAB now holds: [meta_len u16 BE][meta][body].
991
+ const metaLen = (view[0] << 8) | view[1]
992
+ const decoder = new TextDecoder()
993
+ const meta = JSON.parse(decoder.decode(view.subarray(2, 2 + metaLen))) as { status?: number }
994
+ if (meta.status !== 200) throw new Error(`native render returned status ${meta.status}`)
995
+ return decoder.decode(view.subarray(2 + metaLen, len))
996
+ }
997
+
922
998
  /** Build the React element for a render or navigation call: runs loaders
923
999
  * top-down, builds the element bottom-up wrapping in OutletContext.Provider
924
1000
  * so nested routes receive the deeper element via <Outlet />. The caller
@@ -1090,82 +1166,131 @@ interface BranchResponse {
1090
1166
  headers?: Record<string, string>
1091
1167
  }
1092
1168
 
1093
- async function actionBranchToResponse(
1169
+ async function decodeActionBody(
1094
1170
  call: Extract<RouteCall, { kind: 'action' }>,
1095
- byId: Map<string, ActionDef>,
1171
+ ): Promise<{ ok: true; value: unknown } | { ok: false; status: number; body: string }> {
1172
+ const ct = (call.content_type ?? '').toLowerCase()
1173
+ // urlencoded → flat object of strings
1174
+ if (ct.startsWith('application/x-www-form-urlencoded')) {
1175
+ return { ok: true, value: Object.fromEntries(new URLSearchParams(call.body_text ?? '')) }
1176
+ }
1177
+ // multipart → object of strings + File entries (via Bun's Response.formData)
1178
+ if (ct.startsWith('multipart/form-data')) {
1179
+ try {
1180
+ const bytes = Buffer.from(call.body_b64 ?? '', 'base64')
1181
+ const fd = await new Response(bytes, {
1182
+ headers: { 'content-type': call.content_type },
1183
+ }).formData()
1184
+ return { ok: true, value: Object.fromEntries(fd.entries()) }
1185
+ } catch (err) {
1186
+ return {
1187
+ ok: false,
1188
+ status: 400,
1189
+ body: JSON.stringify({
1190
+ error: { message: `invalid multipart body: ${(err as Error).message}` },
1191
+ }),
1192
+ }
1193
+ }
1194
+ }
1195
+ // default: JSON
1196
+ try {
1197
+ return {
1198
+ ok: true,
1199
+ value:
1200
+ call.body_text != null && call.body_text !== '' ? JSON.parse(call.body_text) : undefined,
1201
+ }
1202
+ } catch (err) {
1203
+ return {
1204
+ ok: false,
1205
+ status: 400,
1206
+ body: JSON.stringify({
1207
+ error: { message: `invalid JSON body: ${(err as Error).message}` },
1208
+ }),
1209
+ }
1210
+ }
1211
+ }
1212
+
1213
+ export async function dispatchAction(
1214
+ call: Extract<RouteCall, { kind: 'action' }>,
1215
+ byId: Map<string, EndpointDef>,
1096
1216
  ): Promise<BranchResponse> {
1097
1217
  const def = byId.get(call.action_id)
1098
1218
  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
1219
  return {
1105
1220
  status: 404,
1106
1221
  body: '{"error":{"message":"unknown action"}}',
1107
1222
  contentType: 'application/json; charset=utf-8',
1108
1223
  }
1109
1224
  }
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
1225
  call.req.signal = NEVER_ABORTS
1114
1226
 
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
- }
1227
+ // Body decode dispatch by content-type (JSON / urlencoded / multipart).
1228
+ let rawBody: unknown
1229
+ if (def.method !== 'GET' && def.method !== 'HEAD') {
1230
+ const decoded = await decodeActionBody(call)
1231
+ if (!decoded.ok) {
1232
+ return {
1233
+ status: decoded.status,
1234
+ body: decoded.body,
1235
+ contentType: 'application/json; charset=utf-8',
1146
1236
  }
1147
- args = decoded
1148
1237
  }
1149
- } catch (err) {
1150
- const msg = err instanceof Error ? err.message : String(err)
1238
+ rawBody = decoded.value
1239
+ }
1240
+
1241
+ const bodyCheck = await validate(def.body, rawBody)
1242
+ if (!bodyCheck.ok) {
1151
1243
  return {
1152
- status: 400,
1153
- body: JSON.stringify({ error: { message: `invalid request body: ${msg}` } }),
1244
+ status: 422,
1245
+ body: JSON.stringify({
1246
+ error: { message: 'body validation failed', issues: bodyCheck.issues },
1247
+ }),
1248
+ contentType: 'application/json; charset=utf-8',
1249
+ }
1250
+ }
1251
+ // `req.search` already arrives as a parsed key→value object from the Rust
1252
+ // envelope (BrustRequest.search: Record<string, string>) — use it directly
1253
+ // as the query object rather than re-parsing it as a string.
1254
+ const queryObj = call.req.search ?? {}
1255
+ const queryCheck = await validate(def.query, queryObj)
1256
+ if (!queryCheck.ok) {
1257
+ return {
1258
+ status: 422,
1259
+ body: JSON.stringify({
1260
+ error: { message: 'query validation failed', issues: queryCheck.issues },
1261
+ }),
1154
1262
  contentType: 'application/json; charset=utf-8',
1155
1263
  }
1156
1264
  }
1157
1265
 
1266
+ const ctx = {
1267
+ req: call.req,
1268
+ body: bodyCheck.value,
1269
+ params: call.params ?? {},
1270
+ query: queryCheck.value,
1271
+ headers: call.req.headers ?? {},
1272
+ respond: makeRespond(),
1273
+ }
1274
+
1158
1275
  const terminal = async (): Promise<RouteResponse> => {
1159
1276
  try {
1160
- const result = await def.fn(call.req, ...args)
1277
+ const result = await def.handler(ctx as never)
1278
+ if (isRespondSentinel(result)) {
1279
+ return {
1280
+ status: result.status,
1281
+ body: result.body === undefined ? '' : JSON.stringify(result.body),
1282
+ contentType: 'application/json; charset=utf-8',
1283
+ headers: result.headers,
1284
+ }
1285
+ }
1161
1286
  return {
1162
1287
  status: 200,
1163
1288
  body: result === undefined ? '' : JSON.stringify(result),
1164
1289
  contentType: 'application/json; charset=utf-8',
1165
1290
  }
1166
1291
  } catch (err) {
1167
- console.error(`[brust] action ${def.id} threw:`, err)
1168
1292
  const e = err instanceof Error ? err : new Error(String(err))
1293
+ console.error(`[brust] action ${def.method} ${def.path} threw:`, err)
1169
1294
  return {
1170
1295
  status: 500,
1171
1296
  body: JSON.stringify({ error: { message: e.message, name: e.name } }),
@@ -1175,15 +1300,14 @@ async function actionBranchToResponse(
1175
1300
  }
1176
1301
 
1177
1302
  const chain = composeChain(call.req, def.middleware, terminal)
1178
-
1179
1303
  let response: RouteResponse
1180
1304
  try {
1181
1305
  response = await chain()
1182
1306
  } catch (err) {
1183
- console.error(`[brust] action middleware uncaught:`, err)
1307
+ console.error('[brust] action middleware uncaught:', err)
1184
1308
  response = {
1185
1309
  status: 500,
1186
- body: JSON.stringify({ error: { message: 'internal error' } }),
1310
+ body: '{"error":{"message":"internal error"}}',
1187
1311
  contentType: 'application/json; charset=utf-8',
1188
1312
  }
1189
1313
  }
@@ -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,156 @@
1
+ import type { ActionsBuilder } from './define-actions.ts'
2
+
3
+ const METHODS = new Set(['get', 'post', 'put', 'patch', 'delete', 'head'])
4
+
5
+ export interface TreatyResponse<Data = unknown, Err = unknown> {
6
+ data: Data | null
7
+ error: { status: number; value: Err } | null
8
+ status: number
9
+ headers: Record<string, string>
10
+ response: Response | null
11
+ }
12
+ export interface ClientOptions {
13
+ prefix?: string
14
+ headers?: Record<string, string> | (() => Record<string, string>)
15
+ fetch?: typeof fetch
16
+ }
17
+
18
+ type FirstSegment<S extends string> = S extends `/${infer Rest}`
19
+ ? FirstSegment<Rest>
20
+ : S extends `${infer T}/${infer _U}`
21
+ ? T
22
+ : S
23
+
24
+ export type PermissiveProxy = {
25
+ (arg?: any): PermissiveProxy
26
+ [key: string]: PermissiveProxy
27
+ } & {
28
+ get: (options?: any) => Promise<TreatyResponse<any, any>>
29
+ post: (body?: any, options?: any) => Promise<TreatyResponse<any, any>>
30
+ put: (body?: any, options?: any) => Promise<TreatyResponse<any, any>>
31
+ patch: (body?: any, options?: any) => Promise<TreatyResponse<any, any>>
32
+ delete: (body?: any, options?: any) => Promise<TreatyResponse<any, any>>
33
+ head: (options?: any) => Promise<TreatyResponse<any, any>>
34
+ }
35
+
36
+ export type Treaty<App> =
37
+ App extends ActionsBuilder<infer Acc>
38
+ ? { [K in FirstSegment<keyof Acc & string>]: PermissiveProxy } & PermissiveProxy
39
+ : PermissiveProxy
40
+
41
+ function resolvePrefix(opts?: ClientOptions): string {
42
+ if (opts?.prefix) return opts.prefix
43
+ const g = (globalThis as { __BRUST_ACTION_PREFIX__?: string }).__BRUST_ACTION_PREFIX__
44
+ return g ?? '/_brust/action'
45
+ }
46
+
47
+ /** Build a treaty proxy. Static segments accumulate as a path; a function call
48
+ * with an object fills the next {param}(s) positionally (in insertion order); a
49
+ * terminal method key (.get/.post/…) performs the request. URL is composed from
50
+ * the literal accumulated segments — never from any inferred type. */
51
+ export function client<App = unknown>(opts?: ClientOptions): Treaty<App> {
52
+ const prefix = resolvePrefix(opts)
53
+ const doFetch = opts?.fetch ?? fetch
54
+ function make(segments: string[]): any {
55
+ return new Proxy(() => {}, {
56
+ get(_t, key: string) {
57
+ if (METHODS.has(key)) {
58
+ return async (arg1?: unknown, arg2?: unknown): Promise<TreatyResponse> => {
59
+ const isBodyless = key === 'get' || key === 'head'
60
+ const options = (isBodyless ? arg1 : arg2) as
61
+ | { query?: Record<string, string>; headers?: Record<string, string> }
62
+ | undefined
63
+ const body = isBodyless ? undefined : arg1
64
+ let url = prefix + '/' + segments.join('/')
65
+ if (options?.query) {
66
+ const qs = new URLSearchParams(options.query as Record<string, string>).toString()
67
+ if (qs) url += '?' + qs
68
+ }
69
+ const baseHeaders =
70
+ typeof opts?.headers === 'function' ? opts.headers() : (opts?.headers ?? {})
71
+ const init: RequestInit = {
72
+ method: key.toUpperCase(),
73
+ headers: { ...baseHeaders, ...(options?.headers ?? {}) },
74
+ }
75
+ if (!isBodyless && body !== undefined) {
76
+ if (hasFilePart(body)) {
77
+ init.body = toFormData(body)
78
+ // Let fetch set the multipart boundary; a stale content-type
79
+ // (e.g. from caller headers) would break it.
80
+ delete (init.headers as Record<string, string>)['content-type']
81
+ } else {
82
+ init.body = JSON.stringify(body)
83
+ ;(init.headers as Record<string, string>)['content-type'] = 'application/json'
84
+ }
85
+ }
86
+ try {
87
+ const res = await doFetch(url, init)
88
+ const text = await res.text()
89
+ const parsed = text ? safeJson(text) : undefined
90
+ const headers: Record<string, string> = {}
91
+ res.headers.forEach((v, k) => {
92
+ headers[k] = v
93
+ })
94
+ if (res.ok)
95
+ return {
96
+ data: parsed ?? null,
97
+ error: null,
98
+ status: res.status,
99
+ headers,
100
+ response: res,
101
+ }
102
+ return {
103
+ data: null,
104
+ error: { status: res.status, value: parsed ?? text },
105
+ status: res.status,
106
+ headers,
107
+ response: res,
108
+ }
109
+ } catch (err) {
110
+ return {
111
+ data: null,
112
+ error: { status: 0, value: err },
113
+ status: 0,
114
+ headers: {},
115
+ response: null,
116
+ }
117
+ }
118
+ }
119
+ }
120
+ return make([...segments, key])
121
+ },
122
+ apply(_t, _this, args: unknown[]) {
123
+ const arg = args[0] as Record<string, string> | undefined
124
+ if (!arg) return make(segments)
125
+ return make([...segments, ...Object.values(arg).map(String)])
126
+ },
127
+ })
128
+ }
129
+ return make([]) as any as Treaty<App>
130
+ }
131
+
132
+ function hasFilePart(b: unknown): boolean {
133
+ if (b instanceof FormData || b instanceof Blob) return true
134
+ if (b && typeof b === 'object')
135
+ return Object.values(b as Record<string, unknown>).some((v) => v instanceof Blob)
136
+ return false
137
+ }
138
+
139
+ function toFormData(b: unknown): FormData {
140
+ if (b instanceof FormData) return b
141
+ const fd = new FormData()
142
+ for (const [k, v] of Object.entries(b as Record<string, unknown>)) {
143
+ if (v instanceof Blob) fd.append(k, v)
144
+ else if (v !== null && typeof v === 'object') fd.append(k, JSON.stringify(v))
145
+ else fd.append(k, String(v))
146
+ }
147
+ return fd
148
+ }
149
+
150
+ function safeJson(s: string): unknown {
151
+ try {
152
+ return JSON.parse(s)
153
+ } catch {
154
+ return s
155
+ }
156
+ }
@@ -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
- }