brustjs 0.1.0-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.
Files changed (63) hide show
  1. package/README.md +110 -0
  2. package/package.json +92 -0
  3. package/runtime/actions.ts +65 -0
  4. package/runtime/bun.lock +236 -0
  5. package/runtime/cli/actions-prebuilt-plugin.ts +97 -0
  6. package/runtime/cli/build.ts +252 -0
  7. package/runtime/cli/dev.ts +92 -0
  8. package/runtime/cli/index.ts +30 -0
  9. package/runtime/cli/native-routes-emit.ts +171 -0
  10. package/runtime/cli/native-shim-plugin.ts +85 -0
  11. package/runtime/cli/new.ts +208 -0
  12. package/runtime/cli/templates/minimal/README.md.tmpl +16 -0
  13. package/runtime/cli/templates/minimal/_gitignore +4 -0
  14. package/runtime/cli/templates/minimal/app.css +6 -0
  15. package/runtime/cli/templates/minimal/components/Counter.tsx +13 -0
  16. package/runtime/cli/templates/minimal/components/Layout.tsx +16 -0
  17. package/runtime/cli/templates/minimal/index.ts +4 -0
  18. package/runtime/cli/templates/minimal/package.json.tmpl +21 -0
  19. package/runtime/cli/templates/minimal/pages/Home.tsx.tmpl +16 -0
  20. package/runtime/cli/templates/minimal/routes.tsx +6 -0
  21. package/runtime/cli/templates/minimal/tsconfig.json +20 -0
  22. package/runtime/client/index.ts +121 -0
  23. package/runtime/config.ts +148 -0
  24. package/runtime/css/build.ts +54 -0
  25. package/runtime/css/component-build.ts +78 -0
  26. package/runtime/css/component-loader.ts +27 -0
  27. package/runtime/css/manifest.ts +51 -0
  28. package/runtime/css/process-modules.ts +56 -0
  29. package/runtime/css/route-deps.ts +33 -0
  30. package/runtime/css/scan-imports.ts +79 -0
  31. package/runtime/css.ts +39 -0
  32. package/runtime/dev/client.ts +49 -0
  33. package/runtime/dev/coordinator.ts +127 -0
  34. package/runtime/dev/inject.ts +17 -0
  35. package/runtime/dev/tui.ts +109 -0
  36. package/runtime/dev/watcher.ts +109 -0
  37. package/runtime/dev/worker-registry.ts +96 -0
  38. package/runtime/dev/ws-channel.ts +99 -0
  39. package/runtime/index.d.ts +199 -0
  40. package/runtime/index.js +604 -0
  41. package/runtime/index.ts +618 -0
  42. package/runtime/islands/__fixtures__/NoDefault.tsx +3 -0
  43. package/runtime/islands/__fixtures__/StubIsland.tsx +7 -0
  44. package/runtime/islands/__fixtures__/ThrowingIsland.tsx +9 -0
  45. package/runtime/islands/_entries/react-dom.ts +7 -0
  46. package/runtime/islands/_entries/react.ts +11 -0
  47. package/runtime/islands/bootstrap.ts +241 -0
  48. package/runtime/islands/build.ts +141 -0
  49. package/runtime/islands/importmap.ts +17 -0
  50. package/runtime/islands/island.tsx +58 -0
  51. package/runtime/islands/native-render.ts +153 -0
  52. package/runtime/mcp/extractor.ts +160 -0
  53. package/runtime/mcp/manifest.ts +50 -0
  54. package/runtime/mcp/schema.ts +124 -0
  55. package/runtime/mcp/server.ts +250 -0
  56. package/runtime/render/inject-css-link.ts +59 -0
  57. package/runtime/render/inject-dev-client.ts +49 -0
  58. package/runtime/render/stream.ts +304 -0
  59. package/runtime/routes.ts +1406 -0
  60. package/runtime/scan-actions.ts +172 -0
  61. package/runtime/sse/handler.ts +85 -0
  62. package/runtime/tsconfig.json +14 -0
  63. package/runtime/ws/handler.ts +151 -0
@@ -0,0 +1,160 @@
1
+ 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
+ import type { FlatRoute } from '../routes.ts'
6
+
7
+ 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). */
11
+ 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. */
14
+ sourceRoots: string[]
15
+ /** Result of `await brust.scanActions(...)` — maps action ids to ActionDef. */
16
+ actions: ActionDef[]
17
+ /** Result of `defineRoutes(...)`. */
18
+ routes: FlatRoute[]
19
+ }
20
+
21
+ export async function extractMcpManifest(opts: ExtractOptions): Promise<McpManifest> {
22
+ const program = ts.createProgram({
23
+ rootNames: [...opts.serverFiles, opts.routesFile],
24
+ options: {
25
+ target: ts.ScriptTarget.ES2022,
26
+ module: ts.ModuleKind.ESNext,
27
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
28
+ jsx: ts.JsxEmit.Preserve,
29
+ allowJs: false,
30
+ noEmit: true,
31
+ skipLibCheck: true,
32
+ // Required: TS 5.x collapses string | null to plain string without strict
33
+ // null checks, which would silently drop the null variant from the schema.
34
+ strictNullChecks: true,
35
+ },
36
+ })
37
+ const checker = program.getTypeChecker()
38
+
39
+ 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
+ })
49
+ }
50
+
51
+ const resources = extractResources(checker, opts.routes, program.getSourceFile(opts.routesFile))
52
+
53
+ // Sort tools/resources alphabetically for stable manifest.
54
+ tools.sort((a, b) => a.name.localeCompare(b.name))
55
+ resources.sort((a, b) => a.uriTemplate.localeCompare(b.uriTemplate))
56
+
57
+ return { version: 1, tools, resources }
58
+ }
59
+
60
+ function extractToolFromNode(
61
+ 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))
74
+ }
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
+ }
92
+ }
93
+ }
94
+ return null
95
+ }
96
+
97
+ function toolFromSignature(
98
+ checker: ts.TypeChecker,
99
+ name: string,
100
+ parameters: ts.NodeArray<ts.ParameterDeclaration>,
101
+ returnTypeNode: ts.TypeNode | undefined,
102
+ description: string | undefined,
103
+ ): 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)
118
+ }
119
+ const inputSchema: JsonSchema = { type: 'object', properties }
120
+ if (required.length > 0) inputSchema.required = required
121
+
122
+ let outputSchema: JsonSchema | undefined
123
+ if (returnTypeNode) {
124
+ const returnType = checker.getTypeFromTypeNode(returnTypeNode)
125
+ outputSchema = tsTypeToJsonSchema(returnType, { checker, unwrapPromise: true })
126
+ }
127
+
128
+ return { name, description, inputSchema, outputSchema, paramOrder }
129
+ }
130
+
131
+ function extractResources(
132
+ _checker: ts.TypeChecker,
133
+ routes: FlatRoute[],
134
+ _routesSourceFile: ts.SourceFile | undefined,
135
+ ): ResourceSchema[] {
136
+ const out: ResourceSchema[] = []
137
+ for (let i = 0; i < routes.length; i++) {
138
+ const fr = routes[i]
139
+ const leaf = fr.chain[fr.chain.length - 1]
140
+ if (!leaf.loader) continue
141
+ // outputSchema extraction is best-effort — look up the loader's type via the source file.
142
+ // Skipped for MVP — leaves outputSchema undefined; MCP still works.
143
+ out.push({
144
+ uriTemplate: `brust://${fr.fullPath}`,
145
+ name: `loader for ${fr.fullPath}`,
146
+ routeIndex: i,
147
+ })
148
+ }
149
+ return out
150
+ }
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
+ }
@@ -0,0 +1,50 @@
1
+ import { mkdir } from 'node:fs/promises'
2
+ import { join } from 'node:path'
3
+ import type { JsonSchema } from './schema.ts'
4
+
5
+ export interface ToolSchema {
6
+ name: string
7
+ description?: string
8
+ inputSchema: JsonSchema
9
+ outputSchema?: JsonSchema
10
+ paramOrder: string[]
11
+ }
12
+
13
+ export interface ResourceSchema {
14
+ uriTemplate: string
15
+ name: string
16
+ description?: string
17
+ outputSchema?: JsonSchema
18
+ routeIndex: number
19
+ }
20
+
21
+ export interface McpManifest {
22
+ version: 1
23
+ tools: ToolSchema[]
24
+ resources: ResourceSchema[]
25
+ }
26
+
27
+ const MANIFEST_PATH = '.brust/mcp-manifest.json'
28
+
29
+ export async function writeManifest(cwd: string, m: McpManifest): Promise<void> {
30
+ const path = join(cwd, MANIFEST_PATH)
31
+ await mkdir(join(cwd, '.brust'), { recursive: true })
32
+ await Bun.write(path, JSON.stringify(m, null, 2))
33
+ }
34
+
35
+ export async function readManifest(cwd: string): Promise<McpManifest | null> {
36
+ const path = join(cwd, MANIFEST_PATH)
37
+ const f = Bun.file(path)
38
+ if (!(await f.exists())) return null
39
+ const text = await f.text()
40
+ let parsed: unknown
41
+ try {
42
+ parsed = JSON.parse(text)
43
+ } catch (e) {
44
+ throw new Error(`mcp-manifest.json is malformed: ${e instanceof Error ? e.message : String(e)}`)
45
+ }
46
+ if (!parsed || typeof parsed !== 'object' || (parsed as McpManifest).version !== 1) {
47
+ throw new Error(`mcp-manifest.json version mismatch (expected 1)`)
48
+ }
49
+ return parsed as McpManifest
50
+ }
@@ -0,0 +1,124 @@
1
+ import ts from 'typescript'
2
+
3
+ export interface JsonSchema {
4
+ type?: string | string[]
5
+ properties?: Record<string, JsonSchema>
6
+ required?: string[]
7
+ items?: JsonSchema
8
+ prefixItems?: JsonSchema[]
9
+ minItems?: number
10
+ maxItems?: number
11
+ additionalProperties?: JsonSchema | boolean
12
+ anyOf?: JsonSchema[]
13
+ enum?: unknown[]
14
+ format?: string
15
+ }
16
+
17
+ export interface ToJsonSchemaOptions {
18
+ unwrapPromise?: boolean
19
+ checker?: ts.TypeChecker // required for object/property iteration
20
+ }
21
+
22
+ export function tsTypeToJsonSchema(
23
+ type: ts.Type,
24
+ opts: ToJsonSchemaOptions = {},
25
+ ): JsonSchema | undefined {
26
+ const flags = type.flags
27
+ // Unwrap Promise<T> at the top level when asked.
28
+ if (opts.unwrapPromise) {
29
+ const inner = unwrapPromise(type, opts.checker)
30
+ if (inner === null) return undefined // Promise<void>
31
+ if (inner !== undefined) return tsTypeToJsonSchema(inner, { ...opts, unwrapPromise: false })
32
+ }
33
+ // void → undefined (caller treats as "no schema")
34
+ if (flags & ts.TypeFlags.Void) return undefined
35
+ // any/unknown
36
+ if (flags & ts.TypeFlags.Any || flags & ts.TypeFlags.Unknown) return {}
37
+ // null
38
+ if (flags & ts.TypeFlags.Null || flags & ts.TypeFlags.Undefined) return { type: 'null' }
39
+ // string literal
40
+ if (flags & ts.TypeFlags.StringLiteral)
41
+ return { type: 'string', enum: [(type as ts.StringLiteralType).value] }
42
+ // number literal
43
+ if (flags & ts.TypeFlags.NumberLiteral)
44
+ return { type: 'number', enum: [(type as ts.NumberLiteralType).value] }
45
+ // boolean literal
46
+ if (flags & ts.TypeFlags.BooleanLiteral) {
47
+ const v = (type as any).intrinsicName === 'true'
48
+ return { type: 'boolean', enum: [v] }
49
+ }
50
+ if (flags & ts.TypeFlags.String) return { type: 'string' }
51
+ if (flags & ts.TypeFlags.Number) return { type: 'number' }
52
+ if (flags & ts.TypeFlags.Boolean) return { type: 'boolean' }
53
+ // Union
54
+ if (type.isUnion()) {
55
+ return {
56
+ anyOf: type.types
57
+ .map((t) => tsTypeToJsonSchema(t, opts))
58
+ .filter((x): x is JsonSchema => x !== undefined),
59
+ }
60
+ }
61
+ // Date special case
62
+ const symbol = type.getSymbol()
63
+ if (symbol?.name === 'Date') return { type: 'string', format: 'date-time' }
64
+ // Array / Tuple / Object
65
+ if (opts.checker) {
66
+ if (opts.checker.isArrayType?.(type)) {
67
+ const args = opts.checker.getTypeArguments(type as ts.TypeReference)
68
+ const inner = args[0]
69
+ return { type: 'array', items: tsTypeToJsonSchema(inner, opts) ?? {} }
70
+ }
71
+ // Tuple
72
+ if (opts.checker.isTupleType?.(type)) {
73
+ const args = opts.checker.getTypeArguments(type as ts.TypeReference) ?? []
74
+ const target = (type as any).target
75
+ return {
76
+ type: 'array',
77
+ prefixItems: args.map((t: ts.Type) => tsTypeToJsonSchema(t, opts) ?? {}),
78
+ minItems: target?.minLength ?? args.length,
79
+ maxItems: args.length,
80
+ }
81
+ }
82
+ // Object: enumerate properties (or Record-like via string index signature)
83
+ if (flags & ts.TypeFlags.Object) {
84
+ const props = type.getProperties()
85
+ const stringIndex = type.getStringIndexType()
86
+ if (props.length === 0 && stringIndex) {
87
+ return { type: 'object', additionalProperties: tsTypeToJsonSchema(stringIndex, opts) ?? {} }
88
+ }
89
+ const properties: Record<string, JsonSchema> = {}
90
+ const required: string[] = []
91
+ for (const p of props) {
92
+ const decl = p.declarations?.[0]
93
+ if (!decl) continue
94
+ const propType = opts.checker.getTypeOfSymbolAtLocation(p, decl)
95
+ const propSchema = tsTypeToJsonSchema(propType, opts)
96
+ if (propSchema) {
97
+ properties[p.name] = propSchema
98
+ if (!(p.flags & ts.SymbolFlags.Optional)) {
99
+ required.push(p.name)
100
+ }
101
+ }
102
+ }
103
+ const out: JsonSchema = { type: 'object', properties }
104
+ if (required.length > 0) out.required = required
105
+ return out
106
+ }
107
+ }
108
+ // Fallback: any (loses info but agent still works)
109
+ return {}
110
+ }
111
+
112
+ function unwrapPromise(type: ts.Type, checker?: ts.TypeChecker): ts.Type | null | undefined {
113
+ // Returns: ts.Type unwrapped; null = unwrap result is void; undefined = not a Promise.
114
+ // getPromisedTypeOfPromise exists at runtime on every supported TS 5.x but is
115
+ // absent from the public TypeChecker typings — cast to access without losing
116
+ // strict-mode coverage elsewhere.
117
+ if (!checker) return undefined
118
+ const inner = (
119
+ checker as unknown as { getPromisedTypeOfPromise(t: ts.Type): ts.Type | undefined }
120
+ ).getPromisedTypeOfPromise(type)
121
+ if (inner === undefined) return undefined
122
+ if (inner.flags & ts.TypeFlags.Void) return null
123
+ return inner
124
+ }
@@ -0,0 +1,250 @@
1
+ import type { ActionDef } from '../actions.ts'
2
+ import type { FlatRoute, BrustRequest } from '../routes.ts'
3
+ import type { McpManifest } from './manifest.ts'
4
+
5
+ export interface McpServerOptions {
6
+ manifest: McpManifest
7
+ actions: ActionDef[]
8
+ routes: FlatRoute[]
9
+ packageVersion?: string
10
+ }
11
+
12
+ interface JsonRpcRequest {
13
+ jsonrpc: '2.0'
14
+ id?: number | string | null
15
+ method: string
16
+ params?: unknown
17
+ }
18
+
19
+ interface JsonRpcSuccess<T> {
20
+ jsonrpc: '2.0'
21
+ id: number | string | null
22
+ result: T
23
+ }
24
+
25
+ interface JsonRpcError {
26
+ jsonrpc: '2.0'
27
+ id: number | string | null
28
+ error: { code: number; message: string; data?: unknown }
29
+ }
30
+
31
+ export interface McpServer {
32
+ handleRequest(jsonRpcBody: string, req: BrustRequest): Promise<string>
33
+ }
34
+
35
+ export function makeMcpServer(opts: McpServerOptions): McpServer {
36
+ return {
37
+ async handleRequest(jsonRpcBody, req): Promise<string> {
38
+ let rpc: JsonRpcRequest
39
+ try {
40
+ rpc = JSON.parse(jsonRpcBody)
41
+ } catch {
42
+ return makeError(null, -32700, 'Parse error')
43
+ }
44
+ if (rpc.jsonrpc !== '2.0' || typeof rpc.method !== 'string') {
45
+ return makeError(rpc.id ?? null, -32600, 'Invalid Request')
46
+ }
47
+ switch (rpc.method) {
48
+ case 'initialize':
49
+ return handleInitialize(rpc, opts)
50
+ case 'notifications/initialized':
51
+ return '' // No response for notifications.
52
+ case 'tools/list':
53
+ return handleToolsList(rpc, opts)
54
+ case 'tools/call':
55
+ return handleToolsCall(rpc, opts, req)
56
+ case 'resources/list':
57
+ return handleResourcesList(rpc, opts)
58
+ case 'resources/read':
59
+ return handleResourcesRead(rpc, opts, req)
60
+ case 'prompts/list':
61
+ return makeResult(rpc.id ?? null, { prompts: [] })
62
+ case 'prompts/get':
63
+ return makeError(rpc.id ?? null, -32601, 'no prompts configured')
64
+ case 'logging/setLevel':
65
+ // Accept the level; no notifications emitted (SSE deferred).
66
+ return makeResult(rpc.id ?? null, {})
67
+ case 'notifications/roots/list_changed':
68
+ // No-op accept (it's a notification, no response).
69
+ return ''
70
+ default:
71
+ return makeError(rpc.id ?? null, -32601, `method not found: ${rpc.method}`)
72
+ }
73
+ },
74
+ }
75
+ }
76
+
77
+ function handleInitialize(rpc: JsonRpcRequest, opts: McpServerOptions): string {
78
+ return makeResult(rpc.id ?? null, {
79
+ protocolVersion: '2025-06-18',
80
+ capabilities: {
81
+ tools: {},
82
+ resources: {},
83
+ prompts: {},
84
+ logging: {},
85
+ // roots is a client capability; not declared by server.
86
+ },
87
+ serverInfo: {
88
+ name: 'brust',
89
+ version: opts.packageVersion ?? '0.1.0',
90
+ },
91
+ })
92
+ }
93
+
94
+ async function handleToolsList(rpc: JsonRpcRequest, opts: McpServerOptions): Promise<string> {
95
+ const tools = opts.manifest.tools.map((t) => ({
96
+ name: t.name,
97
+ description: t.description,
98
+ inputSchema: t.inputSchema,
99
+ outputSchema: t.outputSchema,
100
+ }))
101
+ return makeResult(rpc.id ?? null, { tools })
102
+ }
103
+
104
+ async function handleToolsCall(
105
+ rpc: JsonRpcRequest,
106
+ opts: McpServerOptions,
107
+ req: BrustRequest,
108
+ ): Promise<string> {
109
+ const params = rpc.params as { name?: string; arguments?: Record<string, unknown> } | undefined
110
+ if (!params || typeof params.name !== 'string') {
111
+ return makeError(rpc.id ?? null, -32602, 'tools/call: name required')
112
+ }
113
+ const tool = opts.manifest.tools.find((t) => t.name === params.name)
114
+ if (!tool) {
115
+ return makeError(rpc.id ?? null, -32601, `unknown tool: ${params.name}`)
116
+ }
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}`)
120
+ }
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
+ })
150
+ }
151
+ return makeResult(rpc.id ?? null, {
152
+ content: [{ type: 'text', text: response.body || '' }],
153
+ isError: false,
154
+ })
155
+ }
156
+
157
+ async function handleResourcesList(rpc: JsonRpcRequest, opts: McpServerOptions): Promise<string> {
158
+ const resources = opts.manifest.resources.map((r) => ({
159
+ uri: r.uriTemplate,
160
+ name: r.name,
161
+ description: r.description,
162
+ mimeType: 'application/json',
163
+ }))
164
+ return makeResult(rpc.id ?? null, { resources })
165
+ }
166
+
167
+ async function handleResourcesRead(
168
+ rpc: JsonRpcRequest,
169
+ opts: McpServerOptions,
170
+ req: BrustRequest,
171
+ ): Promise<string> {
172
+ const params = rpc.params as { uri?: string } | undefined
173
+ if (!params || typeof params.uri !== 'string') {
174
+ return makeError(rpc.id ?? null, -32602, 'resources/read: uri required')
175
+ }
176
+ // Strip the brust:// scheme.
177
+ const prefix = 'brust://'
178
+ if (!params.uri.startsWith(prefix)) {
179
+ return makeError(rpc.id ?? null, -32602, `unsupported URI scheme: ${params.uri}`)
180
+ }
181
+ const requestedPath = params.uri.slice(prefix.length)
182
+
183
+ // Match against the routes. matchUriPath does the {param} capture that the
184
+ // manifest's pre-computed ResourceSchema.routeIndex cannot do alone — both
185
+ // index spaces stay aligned because the extractor builds resources in the
186
+ // same opts.routes order the server consumes, so a divergence would mean
187
+ // routes were rebuilt without a matching brust.buildMcpManifest call.
188
+ const match = matchUriPath(requestedPath, opts.routes)
189
+ if (!match) {
190
+ return makeError(rpc.id ?? null, -32601, `no route matches URI ${params.uri}`)
191
+ }
192
+ const route = opts.routes[match.routeIndex]
193
+ const leaf = route.chain[route.chain.length - 1]
194
+ if (!leaf.loader) {
195
+ return makeError(rpc.id ?? null, -32603, `route ${route.fullPath} has no loader`)
196
+ }
197
+ try {
198
+ const data = await leaf.loader({ params: match.params, path: requestedPath, req })
199
+ return makeResult(rpc.id ?? null, {
200
+ contents: [
201
+ {
202
+ uri: params.uri,
203
+ mimeType: 'application/json',
204
+ text: JSON.stringify(data),
205
+ },
206
+ ],
207
+ })
208
+ } catch (err) {
209
+ const msg = err instanceof Error ? err.message : String(err)
210
+ return makeError(rpc.id ?? null, -32603, `loader error: ${msg}`)
211
+ }
212
+ }
213
+
214
+ interface UriMatch {
215
+ routeIndex: number
216
+ params: Record<string, string>
217
+ }
218
+
219
+ function matchUriPath(requestedPath: string, routes: FlatRoute[]): UriMatch | null {
220
+ // Simple param matching: split both into segments and match.
221
+ const reqSegs = requestedPath.split('/').filter((s) => s.length > 0)
222
+ for (let i = 0; i < routes.length; i++) {
223
+ const fr = routes[i]
224
+ const routeSegs = fr.fullPath.split('/').filter((s) => s.length > 0)
225
+ if (reqSegs.length !== routeSegs.length) continue
226
+ const params: Record<string, string> = {}
227
+ let matched = true
228
+ for (let j = 0; j < reqSegs.length; j++) {
229
+ const rseg = routeSegs[j]
230
+ if (rseg.startsWith('{') && rseg.endsWith('}')) {
231
+ params[rseg.slice(1, -1)] = reqSegs[j]
232
+ } else if (rseg !== reqSegs[j]) {
233
+ matched = false
234
+ break
235
+ }
236
+ }
237
+ if (matched) return { routeIndex: i, params }
238
+ }
239
+ return null
240
+ }
241
+
242
+ export function makeResult(id: number | string | null, result: unknown): string {
243
+ const resp: JsonRpcSuccess<unknown> = { jsonrpc: '2.0', id, result }
244
+ return JSON.stringify(resp)
245
+ }
246
+
247
+ export function makeError(id: number | string | null, code: number, message: string): string {
248
+ const resp: JsonRpcError = { jsonrpc: '2.0', id, error: { code, message } }
249
+ return JSON.stringify(resp)
250
+ }
@@ -0,0 +1,59 @@
1
+ const ENC = new TextEncoder()
2
+
3
+ /** Set to true on the first miss; suppresses subsequent warnings so a
4
+ * misconfigured Layout doesn't flood logs. Test helper resets this. */
5
+ let warned = false
6
+
7
+ /** @internal — used by the unit test suite to reset the warn-once flag. */
8
+ export function _resetWarnedForTests(): void {
9
+ warned = false
10
+ }
11
+
12
+ /** Splice `<link rel="stylesheet" href="...">` tags into `body` immediately
13
+ * before the first occurrence of `</head>` (case-insensitive). Returns the
14
+ * original body untouched if `hrefs` is empty or if `</head>` is absent
15
+ * (warns once in the latter case). Renderer calls this on the first chunk
16
+ * only — see spec §"SSR <link> injection". */
17
+ export function injectCssLink(body: Uint8Array, hrefs: readonly string[]): Uint8Array {
18
+ if (hrefs.length === 0) return body
19
+ const pos = findHeadCloseTag(body)
20
+ if (pos < 0) {
21
+ if (!warned) {
22
+ console.warn('[brust] css: no </head> in first chunk; <link> not injected')
23
+ warned = true
24
+ }
25
+ return body
26
+ }
27
+ const tags = hrefs.map((h) => `<link rel="stylesheet" href="${h}">`).join('')
28
+ const tagsBytes = ENC.encode(tags)
29
+ const out = new Uint8Array(body.length + tagsBytes.length)
30
+ out.set(body.subarray(0, pos), 0)
31
+ out.set(tagsBytes, pos)
32
+ out.set(body.subarray(pos), pos + tagsBytes.length)
33
+ return out
34
+ }
35
+
36
+ /** Byte-level scan for `</head>` (case-insensitive on the four letters).
37
+ * Returns the byte offset of the `<` or -1 if not found. */
38
+ function findHeadCloseTag(body: Uint8Array): number {
39
+ // Target bytes: `<` `/` H E A D `>` — 7 bytes total.
40
+ // We only case-fold the four ASCII letters; the angle/slash bytes are exact.
41
+ const LT = 0x3c // <
42
+ const SL = 0x2f // /
43
+ const GT = 0x3e // >
44
+ for (let i = 0, max = body.length - 6; i < max; i++) {
45
+ if (body[i] !== LT || body[i + 1] !== SL) continue
46
+ if (!isLetter(body[i + 2], 0x48)) continue // H/h
47
+ if (!isLetter(body[i + 3], 0x45)) continue // E/e
48
+ if (!isLetter(body[i + 4], 0x41)) continue // A/a
49
+ if (!isLetter(body[i + 5], 0x44)) continue // D/d
50
+ if (body[i + 6] !== GT) continue
51
+ return i
52
+ }
53
+ return -1
54
+ }
55
+
56
+ /** Returns true if `b` matches the upper-case letter `u` (b === u || b === u|0x20). */
57
+ function isLetter(b: number, u: number): boolean {
58
+ return b === u || b === (u | 0x20)
59
+ }
@@ -0,0 +1,49 @@
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` into `body` immediately before the first `</head>`
11
+ * (case-insensitive on the four ASCII letters only). Returns the original body
12
+ * untouched if `snippet` is null/empty or if `</head>` is absent. */
13
+ export function injectDevClient(body: Uint8Array, snippet: string | null): Uint8Array {
14
+ if (!snippet) return body
15
+ const pos = findHeadCloseTag(body)
16
+ if (pos < 0) {
17
+ if (!warned) {
18
+ console.warn('[brust] dev: no </head> in first chunk; dev-client <script> not injected')
19
+ warned = true
20
+ }
21
+ return body
22
+ }
23
+ const tagBytes = ENC.encode(snippet)
24
+ const out = new Uint8Array(body.length + tagBytes.length)
25
+ out.set(body.subarray(0, pos), 0)
26
+ out.set(tagBytes, pos)
27
+ out.set(body.subarray(pos), pos + tagBytes.length)
28
+ return out
29
+ }
30
+
31
+ function findHeadCloseTag(body: Uint8Array): number {
32
+ const LT = 0x3c,
33
+ SL = 0x2f,
34
+ GT = 0x3e
35
+ for (let i = 0, max = body.length - 6; i < max; i++) {
36
+ if (body[i] !== LT || body[i + 1] !== SL) continue
37
+ if (!isLetter(body[i + 2], 0x48)) continue
38
+ if (!isLetter(body[i + 3], 0x45)) continue
39
+ if (!isLetter(body[i + 4], 0x41)) continue
40
+ if (!isLetter(body[i + 5], 0x44)) continue
41
+ if (body[i + 6] !== GT) continue
42
+ return i
43
+ }
44
+ return -1
45
+ }
46
+
47
+ function isLetter(b: number, u: number): boolean {
48
+ return b === u || b === (u | 0x20)
49
+ }