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/README.md +13 -8
- package/package.json +9 -8
- package/runtime/actions.ts +7 -65
- package/runtime/cli/build.ts +27 -27
- package/runtime/cli/dev.ts +2 -2
- package/runtime/cli/native-routes-emit.ts +3 -3
- package/runtime/cli/templates/minimal/tsconfig.json +1 -1
- package/runtime/client/index.ts +5 -104
- package/runtime/define-actions.ts +179 -0
- package/runtime/index.d.ts +13 -7
- package/runtime/index.js +52 -52
- package/runtime/index.ts +65 -52
- package/runtime/islands/brust-page.tsx +3 -2
- package/runtime/islands/island.tsx +7 -1
- 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/inject-css-link.ts +1 -1
- package/runtime/render/stream.ts +5 -2
- package/runtime/routes.ts +189 -65
- package/runtime/standard-schema.ts +29 -0
- package/runtime/treaty.ts +156 -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 →
|
|
@@ -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 (
|
|
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?:
|
|
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
|
|
@@ -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
|
|
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
|
|
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
|
-
|
|
882
|
-
|
|
883
|
-
//
|
|
884
|
-
//
|
|
885
|
-
//
|
|
886
|
-
//
|
|
887
|
-
|
|
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
|
|
1169
|
+
async function decodeActionBody(
|
|
1094
1170
|
call: Extract<RouteCall, { kind: 'action' }>,
|
|
1095
|
-
|
|
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
|
-
//
|
|
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
|
-
}
|
|
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
|
-
|
|
1150
|
-
|
|
1238
|
+
rawBody = decoded.value
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
const bodyCheck = await validate(def.body, rawBody)
|
|
1242
|
+
if (!bodyCheck.ok) {
|
|
1151
1243
|
return {
|
|
1152
|
-
status:
|
|
1153
|
-
body: JSON.stringify({
|
|
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.
|
|
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(
|
|
1307
|
+
console.error('[brust] action middleware uncaught:', err)
|
|
1184
1308
|
response = {
|
|
1185
1309
|
status: 500,
|
|
1186
|
-
body:
|
|
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
|
-
}
|