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.
@@ -1,26 +1,44 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
1
4
  import ts from 'typescript'
2
- import { tsTypeToJsonSchema, type JsonSchema } from './schema.ts'
3
- import type { McpManifest, ToolSchema, ResourceSchema } from './manifest.ts'
4
- import type { ActionDef } from '../actions.ts'
5
5
  import type { FlatRoute } from '../routes.ts'
6
+ import type { McpManifest, ResourceSchema, ToolSchema } from './manifest.ts'
7
+ import { type JsonSchema, tsTypeToJsonSchema } from './schema.ts'
6
8
 
7
9
  export interface ExtractOptions {
8
- /** Files that have 'use server' directive — provided by scanActions. */
9
- serverFiles: string[]
10
- /** The routes module file (e.g. example/hello-world/routes.tsx). */
10
+ /** Module exporting `defineActions(...)`. Convention `<scanRoot>/actions.ts`.
11
+ * Absent → zero tools (resources still extracted). */
12
+ actionsFile?: string
13
+ /** The routes module file. */
11
14
  routesFile: string
12
- /** The user's source roots. Reserved for future tsconfig/baseUrl/paths
13
- * resolution; currently ignored — pass at least one for forward compat. */
15
+ /** User source roots. Reserved for future tsconfig resolution. */
14
16
  sourceRoots: string[]
15
- /** Result of `await brust.scanActions(...)` — maps action ids to ActionDef. */
16
- actions: ActionDef[]
17
17
  /** Result of `defineRoutes(...)`. */
18
18
  routes: FlatRoute[]
19
19
  }
20
20
 
21
+ const METHODS: Record<string, ToolSchema['method']> = {
22
+ get: 'GET',
23
+ post: 'POST',
24
+ put: 'PUT',
25
+ patch: 'PATCH',
26
+ delete: 'DELETE',
27
+ head: 'HEAD',
28
+ }
29
+
21
30
  export async function extractMcpManifest(opts: ExtractOptions): Promise<McpManifest> {
31
+ const rootNames = [opts.routesFile]
32
+ if (opts.actionsFile) rootNames.unshift(opts.actionsFile)
33
+ // Resolve bare specifiers (zod, etc.) that the actions file imports. Normal
34
+ // walk-up resolution finds the user's own node_modules; the wildcard fallback
35
+ // points at the brust runtime's node_modules so inferred schema types resolve
36
+ // even when the actions file lives outside a node_modules tree (e.g. tests).
37
+ const runtimeNodeModules = findRuntimeNodeModules()
38
+ const paths: Record<string, string[]> = { '*': ['*'] }
39
+ if (runtimeNodeModules) paths['*'].push(join(runtimeNodeModules, '*'))
22
40
  const program = ts.createProgram({
23
- rootNames: [...opts.serverFiles, opts.routesFile],
41
+ rootNames,
24
42
  options: {
25
43
  target: ts.ScriptTarget.ES2022,
26
44
  module: ts.ModuleKind.ESNext,
@@ -29,6 +47,10 @@ export async function extractMcpManifest(opts: ExtractOptions): Promise<McpManif
29
47
  allowJs: false,
30
48
  noEmit: true,
31
49
  skipLibCheck: true,
50
+ // The brust runtime imports modules with explicit `.ts` extensions.
51
+ allowImportingTsExtensions: true,
52
+ baseUrl: opts.sourceRoots[0] ?? dirname(rootNames[0]),
53
+ paths,
32
54
  // Required: TS 5.x collapses string | null to plain string without strict
33
55
  // null checks, which would silently drop the null variant from the schema.
34
56
  strictNullChecks: true,
@@ -37,95 +59,235 @@ export async function extractMcpManifest(opts: ExtractOptions): Promise<McpManif
37
59
  const checker = program.getTypeChecker()
38
60
 
39
61
  const tools: ToolSchema[] = []
40
- const actionIds = new Set(opts.actions.map((a) => a.id))
41
-
42
- for (const serverFile of opts.serverFiles) {
43
- const sf = program.getSourceFile(serverFile)
44
- if (!sf) continue
45
- ts.forEachChild(sf, (node) => {
46
- const tool = extractToolFromNode(checker, node, actionIds)
47
- if (tool) tools.push(tool)
48
- })
62
+ if (opts.actionsFile) {
63
+ const sf = program.getSourceFile(opts.actionsFile)
64
+ if (sf) extractToolsFromActions(checker, sf, tools)
49
65
  }
50
66
 
51
67
  const resources = extractResources(checker, opts.routes, program.getSourceFile(opts.routesFile))
52
68
 
53
- // Sort tools/resources alphabetically for stable manifest.
54
69
  tools.sort((a, b) => a.name.localeCompare(b.name))
55
70
  resources.sort((a, b) => a.uriTemplate.localeCompare(b.uriTemplate))
56
-
57
71
  return { version: 1, tools, resources }
58
72
  }
59
73
 
60
- function extractToolFromNode(
74
+ // Walk up from this module's directory to the nearest node_modules that has a
75
+ // resolvable schema dep (zod). Returns the node_modules path or undefined.
76
+ function findRuntimeNodeModules(): string | undefined {
77
+ let cur = dirname(fileURLToPath(import.meta.url))
78
+ for (let i = 0; i < 12; i++) {
79
+ const nm = join(cur, 'node_modules')
80
+ if (existsSync(join(nm, 'zod'))) return nm
81
+ const parent = dirname(cur)
82
+ if (parent === cur) break
83
+ cur = parent
84
+ }
85
+ return undefined
86
+ }
87
+
88
+ function extractToolsFromActions(
61
89
  checker: ts.TypeChecker,
62
- node: ts.Node,
63
- actionIds: Set<string>,
64
- ): ToolSchema | null {
65
- // Case 1: export async function name(req, ...args)
66
- if (
67
- ts.isFunctionDeclaration(node) &&
68
- node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) &&
69
- node.name
70
- ) {
71
- const name = node.name.text
72
- if (!actionIds.has(name)) return null
73
- return toolFromSignature(checker, name, node.parameters, node.type, getJsdoc(node))
90
+ sf: ts.SourceFile,
91
+ out: ToolSchema[],
92
+ ): void {
93
+ // Find any `defineActions()` method chain anywhere in the file (export const
94
+ // actions = defineActions()... — but tolerate other shapes).
95
+ let chainTip: ts.CallExpression | undefined
96
+ const visit = (node: ts.Node) => {
97
+ if (!chainTip && ts.isCallExpression(node) && isDefineActionsChain(node)) {
98
+ chainTip = node
99
+ return
100
+ }
101
+ ts.forEachChild(node, visit)
74
102
  }
75
- // Case 2: export const name = withMiddleware([...], async (req, ...args) => ...)
76
- if (
77
- ts.isVariableStatement(node) &&
78
- node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)
79
- ) {
80
- for (const decl of node.declarationList.declarations) {
81
- if (!ts.isIdentifier(decl.name)) continue
82
- const name = decl.name.text
83
- if (!actionIds.has(name)) continue
84
- // Find the function expression inside withMiddleware(...).
85
- const init = decl.initializer
86
- if (init && ts.isCallExpression(init)) {
87
- const fnArg = init.arguments[1]
88
- if (fnArg && (ts.isArrowFunction(fnArg) || ts.isFunctionExpression(fnArg))) {
89
- return toolFromSignature(checker, name, fnArg.parameters, fnArg.type, getJsdoc(node))
90
- }
91
- }
103
+ ts.forEachChild(sf, visit)
104
+ if (!chainTip) return
105
+
106
+ const calls = collectChainCalls(chainTip) // registration order
107
+ const seen = new Set<string>()
108
+ for (const call of calls) {
109
+ // safe: collectChainCalls only enqueues nodes with a PropertyAccessExpression receiver
110
+ const prop = call.expression as ts.PropertyAccessExpression
111
+ const method = METHODS[prop.name.text.toLowerCase()]
112
+ if (!method) continue // .use(...) etc.
113
+ const pathArg = call.arguments[0]
114
+ if (!pathArg || !ts.isStringLiteralLike(pathArg)) {
115
+ console.warn(`[brust mcp] skipping endpoint with non-literal path (.${prop.name.text})`)
116
+ continue
117
+ }
118
+ const path = pathArg.text
119
+ const handler = call.arguments[1]
120
+ const optsArg = call.arguments[2]
121
+ const tool = buildTool(checker, method, path, call, handler, optsArg)
122
+ if (seen.has(tool.name)) {
123
+ throw new Error(
124
+ `[brust mcp] duplicate tool name "${tool.name}" from ${method} ${path}; ` +
125
+ `another endpoint slugs to the same name. Rename a path.`,
126
+ )
92
127
  }
128
+ seen.add(tool.name)
129
+ out.push(tool)
93
130
  }
94
- return null
95
131
  }
96
132
 
97
- function toolFromSignature(
133
+ // True when the innermost receiver of this call chain is `defineActions(...)`.
134
+ function isDefineActionsChain(node: ts.CallExpression): boolean {
135
+ let cur: ts.Expression = node
136
+ while (ts.isCallExpression(cur) && ts.isPropertyAccessExpression(cur.expression)) {
137
+ cur = cur.expression.expression
138
+ }
139
+ return (
140
+ ts.isCallExpression(cur) &&
141
+ ts.isIdentifier(cur.expression) &&
142
+ cur.expression.text === 'defineActions'
143
+ )
144
+ }
145
+
146
+ // Collect each `.method(...)` CallExpression, outermost→inward, then reverse to
147
+ // registration order.
148
+ function collectChainCalls(node: ts.CallExpression): ts.CallExpression[] {
149
+ const acc: ts.CallExpression[] = []
150
+ let cur: ts.Expression = node
151
+ while (ts.isCallExpression(cur) && ts.isPropertyAccessExpression(cur.expression)) {
152
+ acc.push(cur)
153
+ cur = cur.expression.expression
154
+ }
155
+ return acc.reverse()
156
+ }
157
+
158
+ function buildTool(
98
159
  checker: ts.TypeChecker,
99
- name: string,
100
- parameters: ts.NodeArray<ts.ParameterDeclaration>,
101
- returnTypeNode: ts.TypeNode | undefined,
102
- description: string | undefined,
160
+ method: ToolSchema['method'],
161
+ path: string,
162
+ call: ts.CallExpression,
163
+ handler: ts.Expression | undefined,
164
+ optsArg: ts.Expression | undefined,
103
165
  ): ToolSchema {
104
- // Drop the first parameter (req: BrustRequest).
105
- const argParams = parameters.slice(1)
106
- const properties: Record<string, JsonSchema> = {}
107
- const required: string[] = []
108
- const paramOrder: string[] = []
109
- for (const p of argParams) {
110
- if (!ts.isIdentifier(p.name)) continue
111
- const pname = p.name.text
112
- paramOrder.push(pname)
113
- const t = checker.getTypeAtLocation(p)
114
- const schema = tsTypeToJsonSchema(t, { checker }) ?? {}
115
- properties[pname] = schema
116
- // Rest params (...args) are always optional at the call site — never required.
117
- if (!p.questionToken && !p.dotDotDotToken) required.push(pname)
166
+ const props: Record<string, JsonSchema> = {}
167
+
168
+ // params from path {x}/{*x} segments (always string, required).
169
+ const paramNames = parsePathParams(path)
170
+ if (paramNames.length > 0) {
171
+ const pprops: Record<string, JsonSchema> = {}
172
+ for (const p of paramNames) pprops[p] = { type: 'string' }
173
+ props.params = { type: 'object', properties: pprops, required: [...paramNames] }
118
174
  }
119
- const inputSchema: JsonSchema = { type: 'object', properties }
120
- if (required.length > 0) inputSchema.required = required
175
+
176
+ // opts literal detect body/query presence + read description.
177
+ const optsLit = optsArg && ts.isObjectLiteralExpression(optsArg) ? optsArg : undefined
178
+ const hasBody = optsLit ? hasProp(optsLit, 'body') : false
179
+ const hasQuery = optsLit ? hasProp(optsLit, 'query') : false
180
+ const description = optsLit ? readStringProp(optsLit, 'description') : undefined
181
+
182
+ // handler ctx type — for body/query inference. Derived from the builder
183
+ // call's resolved signature (not the user's arrow) so it works regardless of
184
+ // whether the handler destructures its ctx param.
185
+ const ctxType = callCtxType(checker, call)
186
+ if (hasBody && ctxType) {
187
+ const s = ctxMemberSchema(checker, ctxType, 'body')
188
+ if (s) props.body = s
189
+ }
190
+ if (hasQuery && ctxType) {
191
+ const s = ctxMemberSchema(checker, ctxType, 'query')
192
+ if (s) props.query = s
193
+ }
194
+
195
+ const inputSchema: JsonSchema = { type: 'object', properties: props }
121
196
 
122
197
  let outputSchema: JsonSchema | undefined
123
- if (returnTypeNode) {
124
- const returnType = checker.getTypeFromTypeNode(returnTypeNode)
125
- outputSchema = tsTypeToJsonSchema(returnType, { checker, unwrapPromise: true })
198
+ if (handler) {
199
+ const ret = handlerReturnType(checker, handler)
200
+ if (ret) outputSchema = tsTypeToJsonSchema(ret, { checker, unwrapPromise: true })
126
201
  }
127
202
 
128
- return { name, description, inputSchema, outputSchema, paramOrder }
203
+ return { name: toolName(method, path), description, method, path, inputSchema, outputSchema }
204
+ }
205
+
206
+ function toolName(method: ToolSchema['method'], path: string): string {
207
+ const slug = path
208
+ .replace(/^\//, '')
209
+ .split('/')
210
+ .filter((s) => s.length > 0)
211
+ .map((seg) =>
212
+ seg.startsWith('{') && seg.endsWith('}') ? `by_${seg.slice(1, -1).replace(/^\*/, '')}` : seg,
213
+ )
214
+ .join('_')
215
+ .replace(/[^a-zA-Z0-9_-]/g, '_')
216
+ // Root path '/' yields an empty slug — name it '<method>_root' so it stays
217
+ // descriptive and distinct rather than a bare '<method>'.
218
+ if (slug === '') return `${method.toLowerCase()}_root`
219
+ return `${method.toLowerCase()}_${slug}`.replace(/_+$/, '')
220
+ }
221
+
222
+ function parsePathParams(path: string): string[] {
223
+ const out: string[] = []
224
+ for (const m of path.matchAll(/\{(\*?)([^}]+)\}/g)) out.push(m[2])
225
+ return out
226
+ }
227
+
228
+ function hasProp(lit: ts.ObjectLiteralExpression, name: string): boolean {
229
+ return lit.properties.some(
230
+ (p) =>
231
+ (ts.isPropertyAssignment(p) || ts.isShorthandPropertyAssignment(p)) &&
232
+ p.name &&
233
+ ts.isIdentifier(p.name) &&
234
+ p.name.text === name,
235
+ )
236
+ }
237
+
238
+ function readStringProp(lit: ts.ObjectLiteralExpression, name: string): string | undefined {
239
+ for (const p of lit.properties) {
240
+ if (
241
+ ts.isPropertyAssignment(p) &&
242
+ ts.isIdentifier(p.name) &&
243
+ p.name.text === name &&
244
+ ts.isStringLiteralLike(p.initializer)
245
+ ) {
246
+ return p.initializer.text
247
+ }
248
+ }
249
+ return undefined
250
+ }
251
+
252
+ // The handler's ctx (first param) type, via the RESOLVED signature of the
253
+ // builder call (param[1] = handler). Reading the builder's parameter type —
254
+ // not the user's arrow — makes body/query inference independent of whether the
255
+ // handler actually destructures its ctx: a `() => ...` handler that declares a
256
+ // `body` schema still yields a rich body schema.
257
+ function callCtxType(checker: ts.TypeChecker, call: ts.CallExpression): ts.Type | undefined {
258
+ const rs = checker.getResolvedSignature(call)
259
+ const handlerParam = rs?.getParameters()[1]
260
+ if (!handlerParam) return undefined
261
+ const hd = handlerParam.valueDeclaration ?? handlerParam.declarations?.[0]
262
+ if (!hd) return undefined
263
+ const ht = checker.getTypeOfSymbolAtLocation(handlerParam, hd)
264
+ const sig = ht.getCallSignatures()[0]
265
+ const p0 = sig?.getParameters()[0]
266
+ if (!p0) return undefined
267
+ const pd = p0.valueDeclaration ?? p0.declarations?.[0]
268
+ if (!pd) return undefined
269
+ return checker.getTypeOfSymbolAtLocation(p0, pd)
270
+ }
271
+
272
+ function handlerReturnType(checker: ts.TypeChecker, handler: ts.Expression): ts.Type | undefined {
273
+ const t = checker.getTypeAtLocation(handler)
274
+ const sig = t.getCallSignatures()[0]
275
+ return sig ? checker.getReturnTypeOfSignature(sig) : undefined
276
+ }
277
+
278
+ function ctxMemberSchema(
279
+ checker: ts.TypeChecker,
280
+ ctxType: ts.Type,
281
+ member: 'body' | 'query',
282
+ ): JsonSchema | undefined {
283
+ const sym = ctxType.getProperty(member)
284
+ if (!sym) return undefined
285
+ const decl = sym.valueDeclaration ?? sym.declarations?.[0]
286
+ const t = decl
287
+ ? checker.getTypeOfSymbolAtLocation(sym, decl)
288
+ : (checker as unknown as { getTypeOfSymbol?: (s: ts.Symbol) => ts.Type }).getTypeOfSymbol?.(sym)
289
+ if (!t) return undefined
290
+ return tsTypeToJsonSchema(t, { checker })
129
291
  }
130
292
 
131
293
  function extractResources(
@@ -148,13 +310,3 @@ function extractResources(
148
310
  }
149
311
  return out
150
312
  }
151
-
152
- function getJsdoc(node: ts.Node): string | undefined {
153
- const tags = ts.getJSDocCommentsAndTags(node)
154
- if (tags.length === 0) return undefined
155
- const first = tags[0]
156
- // JSDoc `comment` can be either a string or a NodeArray<JSDocComment> when
157
- // inline tags like {@link} are present — ts.getTextOfJSDocComment normalises.
158
- if (ts.isJSDoc(first)) return ts.getTextOfJSDocComment(first.comment) ?? undefined
159
- return undefined
160
- }
@@ -5,9 +5,10 @@ import type { JsonSchema } from './schema.ts'
5
5
  export interface ToolSchema {
6
6
  name: string
7
7
  description?: string
8
+ method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD'
9
+ path: string
8
10
  inputSchema: JsonSchema
9
11
  outputSchema?: JsonSchema
10
- paramOrder: string[]
11
12
  }
12
13
 
13
14
  export interface ResourceSchema {
@@ -1,10 +1,11 @@
1
- import type { ActionDef } from '../actions.ts'
1
+ import { dispatchAction } from '../routes.ts'
2
2
  import type { FlatRoute, BrustRequest } from '../routes.ts'
3
+ import type { EndpointDef } from '../define-actions.ts'
3
4
  import type { McpManifest } from './manifest.ts'
4
5
 
5
6
  export interface McpServerOptions {
6
7
  manifest: McpManifest
7
- actions: ActionDef[]
8
+ endpoints: EndpointDef[]
8
9
  routes: FlatRoute[]
9
10
  packageVersion?: string
10
11
  }
@@ -33,6 +34,8 @@ export interface McpServer {
33
34
  }
34
35
 
35
36
  export function makeMcpServer(opts: McpServerOptions): McpServer {
37
+ const byId = new Map<string, EndpointDef>()
38
+ for (const e of opts.endpoints) byId.set(`${e.method} ${e.path}`, e)
36
39
  return {
37
40
  async handleRequest(jsonRpcBody, req): Promise<string> {
38
41
  let rpc: JsonRpcRequest
@@ -52,7 +55,7 @@ export function makeMcpServer(opts: McpServerOptions): McpServer {
52
55
  case 'tools/list':
53
56
  return handleToolsList(rpc, opts)
54
57
  case 'tools/call':
55
- return handleToolsCall(rpc, opts, req)
58
+ return handleToolsCall(rpc, opts, req, byId)
56
59
  case 'resources/list':
57
60
  return handleResourcesList(rpc, opts)
58
61
  case 'resources/read':
@@ -105,6 +108,7 @@ async function handleToolsCall(
105
108
  rpc: JsonRpcRequest,
106
109
  opts: McpServerOptions,
107
110
  req: BrustRequest,
111
+ byId: Map<string, EndpointDef>,
108
112
  ): Promise<string> {
109
113
  const params = rpc.params as { name?: string; arguments?: Record<string, unknown> } | undefined
110
114
  if (!params || typeof params.name !== 'string') {
@@ -114,43 +118,30 @@ async function handleToolsCall(
114
118
  if (!tool) {
115
119
  return makeError(rpc.id ?? null, -32601, `unknown tool: ${params.name}`)
116
120
  }
117
- const action = opts.actions.find((a) => a.id === params.name)
118
- if (!action) {
119
- return makeError(rpc.id ?? null, -32603, `action not registered: ${params.name}`)
121
+ const def = byId.get(`${tool.method} ${tool.path}`)
122
+ if (!def) {
123
+ return makeError(rpc.id ?? null, -32603, `endpoint not registered: ${tool.name}`)
120
124
  }
121
- // Map { argsObject } → positional args via paramOrder.
122
- const args = (tool.paramOrder ?? []).map((k) => params.arguments?.[k])
123
-
124
- // Run through middleware chain (same shape as actionBranch).
125
- const { composeChain } = await import('../routes.ts')
126
- const terminal = async () => {
127
- try {
128
- const result = await action.fn(req, ...args)
129
- return {
130
- status: 200,
131
- body: result === undefined ? '' : JSON.stringify(result),
132
- contentType: 'application/json; charset=utf-8',
133
- }
134
- } catch (err) {
135
- const e = err instanceof Error ? err : new Error(String(err))
136
- return {
137
- status: 500,
138
- body: JSON.stringify({ error: { message: e.message, name: e.name } }),
139
- contentType: 'application/json; charset=utf-8',
140
- }
141
- }
142
- }
143
- const chain = composeChain(req, action.middleware, terminal)
144
- const response = await chain()
145
- if (response.status >= 400) {
146
- return makeResult(rpc.id ?? null, {
147
- content: [{ type: 'text', text: response.body }],
148
- isError: true,
149
- })
125
+
126
+ const args = (params.arguments ?? {}) as {
127
+ params?: Record<string, string>
128
+ query?: Record<string, string>
129
+ body?: unknown
150
130
  }
131
+ const resp = await dispatchAction(
132
+ {
133
+ kind: 'action',
134
+ action_id: `${tool.method} ${tool.path}`,
135
+ content_type: 'application/json',
136
+ params: args.params ?? {},
137
+ body_text: args.body === undefined ? '' : JSON.stringify(args.body),
138
+ req: { ...req, search: args.query ?? {} } as BrustRequest,
139
+ } as never,
140
+ byId,
141
+ )
151
142
  return makeResult(rpc.id ?? null, {
152
- content: [{ type: 'text', text: response.body || '' }],
153
- isError: false,
143
+ content: [{ type: 'text', text: resp.body || '' }],
144
+ isError: resp.status >= 400,
154
145
  })
155
146
  }
156
147
 
@@ -0,0 +1,60 @@
1
+ const ENC = new TextEncoder()
2
+
3
+ let warned = false
4
+
5
+ /** @internal — used by tests to reset the warn-once flag. */
6
+ export function _resetWarnedForTests(): void {
7
+ warned = false
8
+ }
9
+
10
+ /** Splice `snippet` (a full `<script>…</script>`) into `body` immediately
11
+ * before the first `</head>` (case-insensitive on the four ASCII letters
12
+ * only). Returns the original body untouched if `snippet` is null/empty or if
13
+ * `</head>` is absent. */
14
+ export function injectActionPrefix(body: Uint8Array, snippet: string | null): Uint8Array {
15
+ if (!snippet) return body
16
+ const pos = findHeadCloseTag(body)
17
+ if (pos < 0) {
18
+ if (!warned) {
19
+ console.warn('[brust] action-prefix: no </head> in first chunk; global not injected')
20
+ warned = true
21
+ }
22
+ return body
23
+ }
24
+ const tagBytes = ENC.encode(snippet)
25
+ const out = new Uint8Array(body.length + tagBytes.length)
26
+ out.set(body.subarray(0, pos), 0)
27
+ out.set(tagBytes, pos)
28
+ out.set(body.subarray(pos), pos + tagBytes.length)
29
+ return out
30
+ }
31
+
32
+ // Module-scope snippet config: set once by run() when an app uses a custom
33
+ // (non-default) actionPrefix; read by the render stream on both paths.
34
+ let snippet: string | null = null
35
+ export function configureActionPrefixSnippet(s: string | null): void {
36
+ snippet = s
37
+ }
38
+ export function getActionPrefixSnippet(): string | null {
39
+ return snippet
40
+ }
41
+
42
+ function findHeadCloseTag(body: Uint8Array): number {
43
+ const LT = 0x3c,
44
+ SL = 0x2f,
45
+ GT = 0x3e
46
+ for (let i = 0, max = body.length - 6; i < max; i++) {
47
+ if (body[i] !== LT || body[i + 1] !== SL) continue
48
+ if (!isLetter(body[i + 2], 0x48)) continue
49
+ if (!isLetter(body[i + 3], 0x45)) continue
50
+ if (!isLetter(body[i + 4], 0x41)) continue
51
+ if (!isLetter(body[i + 5], 0x44)) continue
52
+ if (body[i + 6] !== GT) continue
53
+ return i
54
+ }
55
+ return -1
56
+ }
57
+
58
+ function isLetter(b: number, u: number): boolean {
59
+ return b === u || b === (u | 0x20)
60
+ }
@@ -13,7 +13,7 @@ export function _resetWarnedForTests(): void {
13
13
  * before the first occurrence of `</head>` (case-insensitive). Returns the
14
14
  * original body untouched if `hrefs` is empty or if `</head>` is absent
15
15
  * (warns once in the latter case). Renderer calls this on the first chunk
16
- * only — see spec §"SSR <link> injection". */
16
+ * only — see spec S"SSR <link> injection". */
17
17
  export function injectCssLink(body: Uint8Array, hrefs: readonly string[]): Uint8Array {
18
18
  if (hrefs.length === 0) return body
19
19
  const pos = findHeadCloseTag(body)
@@ -9,6 +9,7 @@ import { ISLANDS_IMPORTMAP_AND_BOOTSTRAP } from '../islands/importmap.ts'
9
9
  import { injectCssLink } from './inject-css-link.ts'
10
10
  import { getCssHrefs, getCssHrefsForRoute } from '../css.ts'
11
11
  import { injectDevClient } from './inject-dev-client.ts'
12
+ import { injectActionPrefix, getActionPrefixSnippet } from './inject-action-prefix.ts'
12
13
  import { getDevClientSnippet } from '../dev/inject.ts'
13
14
 
14
15
  export interface RenderBranchStreamingArgs {
@@ -148,6 +149,7 @@ export function renderBranchStreaming(args: RenderBranchStreamingArgs): Promise<
148
149
  const perRouteHrefs = args.routePath ? getCssHrefsForRoute(args.routePath) : []
149
150
  body = injectCssLink(body, [...getCssHrefs(), ...perRouteHrefs])
150
151
  body = injectDevClient(body, getDevClientSnippet())
152
+ body = injectActionPrefix(body, getActionPrefixSnippet())
151
153
  const meta = makeMeta({
152
154
  status: successStatus,
153
155
  streaming: false,
@@ -208,8 +210,9 @@ export function renderBranchStreaming(args: RenderBranchStreamingArgs): Promise<
208
210
  .map((h) => `<link rel="stylesheet" href="${h}">`)
209
211
  .join('')
210
212
  const devTag = getDevClientSnippet() ?? ''
211
- if (linkTagsStr.length > 0 || devTag.length > 0) {
212
- const prepend = encoder.encode(linkTagsStr + devTag)
213
+ const prefixTag = getActionPrefixSnippet() ?? ''
214
+ if (linkTagsStr.length > 0 || devTag.length > 0 || prefixTag.length > 0) {
215
+ const prepend = encoder.encode(linkTagsStr + prefixTag + devTag)
213
216
  const out = new Uint8Array(flushed.length + prepend.length)
214
217
  out.set(flushed, 0)
215
218
  out.set(prepend, flushed.length)