@briancray/belte 0.3.1 → 0.4.0
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/package.json +1 -1
- package/src/bundleApp.ts +12 -2
- package/src/discoveryEntry.ts +58 -11
- package/src/lib/browser/cache.ts +29 -6
- package/src/lib/browser/startClient.ts +24 -1
- package/src/lib/bundle/onMenu.ts +20 -5
- package/src/lib/bundle/openWebview.ts +9 -2
- package/src/lib/bundle/signMacApp.ts +35 -0
- package/src/lib/cli/createClient.ts +65 -27
- package/src/lib/cli/runCli.ts +37 -15
- package/src/lib/cli/types/CliManifestEntry.ts +7 -2
- package/src/lib/mcp/annotationsForMethod.ts +29 -0
- package/src/lib/mcp/createMcpServer.ts +10 -8
- package/src/lib/mcp/dispatchMcpRequest.ts +115 -33
- package/src/lib/mcp/toolResultFromResponse.ts +66 -0
- package/src/lib/server/jsonl.ts +2 -1
- package/src/lib/server/rpc/defineVerb.ts +30 -17
- package/src/lib/server/rpc/parseArgs.ts +2 -1
- package/src/lib/server/rpc/types/VerbHelper.ts +19 -11
- package/src/lib/server/rpc/types/VerbRegistryEntry.ts +14 -6
- package/src/lib/server/runtime/buildOpenApiSpec.ts +17 -6
- package/src/lib/server/runtime/createPublicAssetServer.ts +17 -6
- package/src/lib/server/runtime/createServer.ts +37 -9
- package/src/lib/server/runtime/globToPathSet.ts +29 -0
- package/src/lib/server/runtime/logBrowserOnlyRoutes.ts +44 -0
- package/src/lib/server/sockets/createSocketDispatcher.ts +71 -8
- package/src/lib/server/sockets/defineSocket.ts +7 -1
- package/src/lib/server/sockets/recentHistory.ts +11 -0
- package/src/lib/server/sockets/socketOperations.ts +35 -0
- package/src/lib/server/sockets/types/SocketOperation.ts +22 -0
- package/src/lib/server/sse.ts +2 -1
- package/src/lib/shared/buildRpcRequest.ts +2 -1
- package/src/lib/shared/carriesBodyArgs.ts +13 -0
- package/src/lib/shared/isReadOnlyMethod.ts +14 -0
- package/src/lib/shared/isStreamingResponse.ts +11 -0
- package/src/lib/shared/jsonlErrorFrame.ts +24 -0
- package/src/lib/shared/keyForRemoteCall.ts +2 -1
- package/src/lib/shared/resolveClientFlags.ts +8 -6
- package/src/lib/shared/responseErrorText.ts +9 -0
- package/src/lib/shared/sseErrorFrame.ts +29 -0
- package/src/lib/shared/streamResponse.ts +168 -0
- package/src/lib/shared/subscribableFromResponse.ts +1 -172
- package/src/lib/shared/types/CacheEntry.ts +6 -0
- package/template/src/bundle/icon.png +0 -0
- package/template/src/server/rpc/getHello.ts +5 -3
- package/src/lib/shared/belteImportName.test.ts +0 -58
|
@@ -3,13 +3,17 @@ import { findVerbByCommandName } from '../server/rpc/findVerbByCommandName.ts'
|
|
|
3
3
|
import type { VerbRegistryEntry } from '../server/rpc/types/VerbRegistryEntry.ts'
|
|
4
4
|
import { verbRegistry } from '../server/rpc/verbRegistry.ts'
|
|
5
5
|
import { ensureRegistriesLoaded } from '../server/runtime/registryManifests.ts'
|
|
6
|
+
import { recentHistory } from '../server/sockets/recentHistory.ts'
|
|
7
|
+
import { socketOperations } from '../server/sockets/socketOperations.ts'
|
|
8
|
+
import { socketRegistry } from '../server/sockets/socketRegistry.ts'
|
|
6
9
|
import { buildRpcRequest } from '../shared/buildRpcRequest.ts'
|
|
7
10
|
import { NO_STORE } from '../shared/cacheControlValues.ts'
|
|
8
11
|
import { commandNameForUrl } from '../shared/commandNameForUrl.ts'
|
|
9
|
-
import { decodeResponse } from '../shared/decodeResponse.ts'
|
|
10
12
|
import { forwardHeaders } from '../shared/forwardHeaders.ts'
|
|
11
13
|
import { jsonSchemaForSchema } from '../shared/jsonSchemaForSchema.ts'
|
|
14
|
+
import { annotationsForMethod } from './annotationsForMethod.ts'
|
|
12
15
|
import { getMcpResourceServer } from './mcpResourceServerSlot.ts'
|
|
16
|
+
import { toolResultFromResponse } from './toolResultFromResponse.ts'
|
|
13
17
|
import type { JsonRpcRequest } from './types/JsonRpcRequest.ts'
|
|
14
18
|
import type { JsonRpcResponse } from './types/JsonRpcResponse.ts'
|
|
15
19
|
import type { McpServerOptions } from './types/McpServerOptions.ts'
|
|
@@ -33,6 +37,8 @@ type ToolDescriptor = {
|
|
|
33
37
|
name: string
|
|
34
38
|
description: string
|
|
35
39
|
inputSchema: Record<string, unknown>
|
|
40
|
+
outputSchema?: Record<string, unknown>
|
|
41
|
+
annotations?: Record<string, boolean>
|
|
36
42
|
}
|
|
37
43
|
|
|
38
44
|
type PromptDescriptor = {
|
|
@@ -42,10 +48,18 @@ type PromptDescriptor = {
|
|
|
42
48
|
}
|
|
43
49
|
|
|
44
50
|
/*
|
|
45
|
-
Builds the array of MCP tool descriptors.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
51
|
+
Builds the array of MCP tool descriptors.
|
|
52
|
+
|
|
53
|
+
RPCs: every verb with clients.mcp=true becomes one tool named after the
|
|
54
|
+
export's URL (folder segments joined with `-`). The HTTP verb feeds the
|
|
55
|
+
tool's annotations (readOnlyHint / destructiveHint / idempotentHint) so
|
|
56
|
+
a model can tell a read from a write; reads auto-expose while mutating
|
|
57
|
+
verbs require an explicit clients.mcp (see resolveClientFlags). When the
|
|
58
|
+
verb declares an `outputSchema` it's advertised as the tool outputSchema.
|
|
59
|
+
|
|
60
|
+
Sockets: every socket with clients.mcp=true contributes a `<base>-tail`
|
|
61
|
+
read tool (recent buffered messages) and, when clientPublish is set, a
|
|
62
|
+
`<base>-publish` tool.
|
|
49
63
|
*/
|
|
50
64
|
function buildTools(): ToolDescriptor[] {
|
|
51
65
|
const tools: ToolDescriptor[] = []
|
|
@@ -59,14 +73,51 @@ function buildTools(): ToolDescriptor[] {
|
|
|
59
73
|
falling back to `method url` so the tool is still labelled when
|
|
60
74
|
the schema has none.
|
|
61
75
|
*/
|
|
62
|
-
const inputSchema = jsonSchemaForSchema(entry.
|
|
63
|
-
|
|
76
|
+
const inputSchema = jsonSchemaForSchema(entry.inputSchema, entry.inputJsonSchema)
|
|
77
|
+
const tool: ToolDescriptor = {
|
|
64
78
|
name: commandNameForUrl(entry.remote.url),
|
|
65
79
|
description:
|
|
66
80
|
(inputSchema.description as string | undefined) ??
|
|
67
81
|
`${entry.remote.method} ${entry.remote.url}`,
|
|
68
82
|
inputSchema,
|
|
69
|
-
|
|
83
|
+
annotations: annotationsForMethod(entry.remote.method),
|
|
84
|
+
}
|
|
85
|
+
if (entry.outputSchema) {
|
|
86
|
+
tool.outputSchema = jsonSchemaForSchema(entry.outputSchema, entry.outputJsonSchema)
|
|
87
|
+
}
|
|
88
|
+
tools.push(tool)
|
|
89
|
+
}
|
|
90
|
+
for (const entry of socketRegistry.values()) {
|
|
91
|
+
if (!entry.clients.mcp) {
|
|
92
|
+
continue
|
|
93
|
+
}
|
|
94
|
+
const payloadSchema = jsonSchemaForSchema(entry.schema, entry.jsonSchema)
|
|
95
|
+
for (const operation of socketOperations(entry)) {
|
|
96
|
+
if (operation.kind === 'tail') {
|
|
97
|
+
tools.push({
|
|
98
|
+
name: operation.name,
|
|
99
|
+
description: `Read recent messages from the "${operation.socketName}" socket`,
|
|
100
|
+
inputSchema: {
|
|
101
|
+
type: 'object',
|
|
102
|
+
properties: {
|
|
103
|
+
count: { type: 'number', description: 'max recent messages to return' },
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
outputSchema: {
|
|
107
|
+
type: 'object',
|
|
108
|
+
properties: { frames: { type: 'array', items: payloadSchema } },
|
|
109
|
+
},
|
|
110
|
+
annotations: { readOnlyHint: true, destructiveHint: false },
|
|
111
|
+
})
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
tools.push({
|
|
115
|
+
name: operation.name,
|
|
116
|
+
description: `Publish a message to the "${operation.socketName}" socket`,
|
|
117
|
+
inputSchema: payloadSchema,
|
|
118
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false },
|
|
119
|
+
})
|
|
120
|
+
}
|
|
70
121
|
}
|
|
71
122
|
return tools
|
|
72
123
|
}
|
|
@@ -95,10 +146,12 @@ function buildPrompts(): PromptDescriptor[] {
|
|
|
95
146
|
}
|
|
96
147
|
|
|
97
148
|
/*
|
|
98
|
-
Tool dispatch.
|
|
99
|
-
|
|
100
|
-
validation + handler + error helpers behave identically
|
|
101
|
-
|
|
149
|
+
Tool dispatch. RPC tools synthesize a Request (with forwarded auth
|
|
150
|
+
headers) and pipe it through verb.fetch — the same code path the HTTP
|
|
151
|
+
router uses, so validation + handler + error helpers behave identically;
|
|
152
|
+
the response (buffered or streaming) is framed by toolResultFromResponse.
|
|
153
|
+
Socket tools (`<base>-tail` / `<base>-publish`) fall through to the
|
|
154
|
+
socket dispatcher.
|
|
102
155
|
*/
|
|
103
156
|
async function callTool(
|
|
104
157
|
toolName: string,
|
|
@@ -106,30 +159,59 @@ async function callTool(
|
|
|
106
159
|
inbound: Request,
|
|
107
160
|
): Promise<Record<string, unknown>> {
|
|
108
161
|
const entry = findVerbByCommandName(toolName)
|
|
109
|
-
if (
|
|
110
|
-
|
|
162
|
+
if (entry?.clients.mcp) {
|
|
163
|
+
const response = await dispatchVerb(entry, args, inbound)
|
|
164
|
+
return toolResultFromResponse(response)
|
|
111
165
|
}
|
|
112
|
-
const
|
|
113
|
-
if (
|
|
114
|
-
return
|
|
115
|
-
content: [
|
|
116
|
-
{
|
|
117
|
-
type: 'text',
|
|
118
|
-
text: `${response.status} ${response.statusText}: ${await response.text()}`,
|
|
119
|
-
},
|
|
120
|
-
],
|
|
121
|
-
isError: true,
|
|
122
|
-
}
|
|
166
|
+
const socketResult = callSocketTool(toolName, args)
|
|
167
|
+
if (socketResult) {
|
|
168
|
+
return socketResult
|
|
123
169
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
170
|
+
throw new Error(`unknown tool: ${toolName}`)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function textResult(text: string, isError = false): Record<string, unknown> {
|
|
174
|
+
return { content: [{ type: 'text', text }], ...(isError ? { isError: true } : {}) }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/*
|
|
178
|
+
Dispatches the socket tail / publish tools by matching the tool name
|
|
179
|
+
against each mcp-exposed socket's operations (socketOperations is the same
|
|
180
|
+
projection tools/list advertised, so the publish op only exists when the
|
|
181
|
+
socket allows it). tail returns the recent history buffer (request/response
|
|
182
|
+
can't hold a live subscription); publish validates against the socket
|
|
183
|
+
schema and fans out. Returns undefined when the name isn't a known socket
|
|
184
|
+
tool so callTool can fall through to "unknown tool".
|
|
185
|
+
*/
|
|
186
|
+
function callSocketTool(
|
|
187
|
+
toolName: string,
|
|
188
|
+
args: Record<string, unknown> | undefined,
|
|
189
|
+
): Record<string, unknown> | undefined {
|
|
190
|
+
for (const entry of socketRegistry.values()) {
|
|
191
|
+
if (!entry.clients.mcp) {
|
|
192
|
+
continue
|
|
193
|
+
}
|
|
194
|
+
const operation = socketOperations(entry).find((op) => op.name === toolName)
|
|
195
|
+
if (!operation) {
|
|
196
|
+
continue
|
|
197
|
+
}
|
|
198
|
+
if (operation.kind === 'tail') {
|
|
199
|
+
const count = typeof args?.count === 'number' ? args.count : undefined
|
|
200
|
+
const frames = recentHistory(entry, count)
|
|
201
|
+
return {
|
|
202
|
+
content: [{ type: 'text', text: frames.map((f) => JSON.stringify(f)).join('\n') }],
|
|
203
|
+
structuredContent: { frames },
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
try {
|
|
207
|
+
// publish() validates the payload against the socket schema and throws on failure.
|
|
208
|
+
entry.socket.publish(args)
|
|
209
|
+
} catch (error) {
|
|
210
|
+
return textResult(error instanceof Error ? error.message : String(error), true)
|
|
211
|
+
}
|
|
212
|
+
return textResult('ok')
|
|
132
213
|
}
|
|
214
|
+
return undefined
|
|
133
215
|
}
|
|
134
216
|
|
|
135
217
|
/*
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { decodeResponse } from '../shared/decodeResponse.ts'
|
|
2
|
+
import { isStreamingResponse } from '../shared/isStreamingResponse.ts'
|
|
3
|
+
import { responseErrorText } from '../shared/responseErrorText.ts'
|
|
4
|
+
import { streamResponse } from '../shared/streamResponse.ts'
|
|
5
|
+
|
|
6
|
+
// Frames a value as MCP text content — strings verbatim, everything else as JSON.
|
|
7
|
+
function asText(value: unknown): string {
|
|
8
|
+
return typeof value === 'string' ? value : JSON.stringify(value)
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/*
|
|
12
|
+
Turns an rpc/socket Response into an MCP `tools/call` result. Always
|
|
13
|
+
carries a `text` content block for backward compatibility; adds
|
|
14
|
+
`structuredContent` (an object, per the MCP spec) so models that
|
|
15
|
+
understand structured output get the typed value instead of a stringified
|
|
16
|
+
blob.
|
|
17
|
+
|
|
18
|
+
- non-2xx → { content:[text], isError:true }
|
|
19
|
+
- sse/jsonl body → drained frame-by-frame; structuredContent = { frames }.
|
|
20
|
+
A mid-stream error surfaces as isError with the
|
|
21
|
+
frames collected so far.
|
|
22
|
+
- object body → structuredContent = the object.
|
|
23
|
+
- array/primitive → text only (structuredContent must be an object).
|
|
24
|
+
*/
|
|
25
|
+
export async function toolResultFromResponse(response: Response): Promise<Record<string, unknown>> {
|
|
26
|
+
if (!response.ok) {
|
|
27
|
+
return {
|
|
28
|
+
content: [{ type: 'text', text: await responseErrorText(response) }],
|
|
29
|
+
isError: true,
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (isStreamingResponse(response)) {
|
|
34
|
+
const frames: unknown[] = []
|
|
35
|
+
try {
|
|
36
|
+
for await (const frame of streamResponse(response)) {
|
|
37
|
+
frames.push(frame)
|
|
38
|
+
}
|
|
39
|
+
} catch (error) {
|
|
40
|
+
return {
|
|
41
|
+
content: [
|
|
42
|
+
{ type: 'text', text: frames.map(asText).join('\n') },
|
|
43
|
+
{
|
|
44
|
+
type: 'text',
|
|
45
|
+
text: `stream error: ${error instanceof Error ? error.message : String(error)}`,
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
structuredContent: { frames },
|
|
49
|
+
isError: true,
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return {
|
|
53
|
+
content: [{ type: 'text', text: frames.map(asText).join('\n') }],
|
|
54
|
+
structuredContent: { frames },
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const body = await decodeResponse(response)
|
|
59
|
+
const result: Record<string, unknown> = {
|
|
60
|
+
content: [{ type: 'text', text: asText(body) }],
|
|
61
|
+
}
|
|
62
|
+
if (body !== null && typeof body === 'object' && !Array.isArray(body)) {
|
|
63
|
+
result.structuredContent = body
|
|
64
|
+
}
|
|
65
|
+
return result
|
|
66
|
+
}
|
package/src/lib/server/jsonl.ts
CHANGED
|
@@ -22,6 +22,7 @@ error is logged server-side via the framework's error handler — only the
|
|
|
22
22
|
message crosses the wire.
|
|
23
23
|
*/
|
|
24
24
|
import { NO_STORE } from '../shared/cacheControlValues.ts'
|
|
25
|
+
import { jsonlErrorFrame } from '../shared/jsonlErrorFrame.ts'
|
|
25
26
|
import type { TypedResponse } from './rpc/types/TypedResponse.ts'
|
|
26
27
|
import { streamFromIterator } from './runtime/streamFromIterator.ts'
|
|
27
28
|
import { withResponseDefaults } from './runtime/withResponseDefaults.ts'
|
|
@@ -32,7 +33,7 @@ export function jsonl<Frame>(
|
|
|
32
33
|
): TypedResponse<Frame> {
|
|
33
34
|
const body = streamFromIterator(iterable, {
|
|
34
35
|
encodeFrame: (value) => `${JSON.stringify(value)}\n`,
|
|
35
|
-
encodeError: (message) =>
|
|
36
|
+
encodeError: (message) => jsonlErrorFrame.encode(message),
|
|
36
37
|
})
|
|
37
38
|
return new Response(
|
|
38
39
|
body,
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { buildRpcRequest } from '../../shared/buildRpcRequest.ts'
|
|
2
|
-
import { NO_STORE } from '../../shared/cacheControlValues.ts'
|
|
3
2
|
import { createRemoteFunction } from '../../shared/createRemoteFunction.ts'
|
|
4
3
|
import { forwardHeaders } from '../../shared/forwardHeaders.ts'
|
|
4
|
+
import { isReadOnlyMethod } from '../../shared/isReadOnlyMethod.ts'
|
|
5
5
|
import { resolveClientFlags } from '../../shared/resolveClientFlags.ts'
|
|
6
6
|
import type { ClientFlags } from '../../shared/types/ClientFlags.ts'
|
|
7
|
+
import { json } from '../json.ts'
|
|
7
8
|
import { requestContext } from '../runtime/requestContext.ts'
|
|
8
9
|
import { parseArgs } from './parseArgs.ts'
|
|
9
10
|
import { registerVerb } from './registerVerb.ts'
|
|
@@ -33,14 +34,30 @@ export function defineVerb<Args, Return>(
|
|
|
33
34
|
url: string,
|
|
34
35
|
handler: RemoteHandler<Args, Return>,
|
|
35
36
|
opts?: {
|
|
36
|
-
|
|
37
|
-
|
|
37
|
+
inputSchema?: StandardSchemaV1
|
|
38
|
+
inputJsonSchema?: Record<string, unknown>
|
|
39
|
+
outputSchema?: StandardSchemaV1
|
|
40
|
+
outputJsonSchema?: Record<string, unknown>
|
|
38
41
|
clients?: Partial<ClientFlags>
|
|
39
42
|
},
|
|
40
43
|
): RemoteFunction<Args, Return> {
|
|
41
|
-
const
|
|
42
|
-
const
|
|
43
|
-
const
|
|
44
|
+
const inputSchema = opts?.inputSchema
|
|
45
|
+
const inputJsonSchema = opts?.inputJsonSchema
|
|
46
|
+
const outputSchema = opts?.outputSchema
|
|
47
|
+
const outputJsonSchema = opts?.outputJsonSchema
|
|
48
|
+
/*
|
|
49
|
+
An input schema makes the handler safe to advertise to non-browser
|
|
50
|
+
surfaces. CLI flips on for any verb with one (a human/script invokes it
|
|
51
|
+
deliberately). MCP only auto-exposes read-only verbs (GET/HEAD) — a
|
|
52
|
+
model shouldn't be able to mutate/delete just because the handler
|
|
53
|
+
carries a schema, so mutating verbs require an explicit clients.mcp.
|
|
54
|
+
Explicit `clients` always wins.
|
|
55
|
+
*/
|
|
56
|
+
const hasSchema = inputSchema !== undefined
|
|
57
|
+
const clients = resolveClientFlags(opts?.clients, {
|
|
58
|
+
mcp: hasSchema && isReadOnlyMethod(method),
|
|
59
|
+
cli: hasSchema,
|
|
60
|
+
})
|
|
44
61
|
|
|
45
62
|
function buildRequest(args: Args | undefined): Request {
|
|
46
63
|
const store = requestContext.getStore()
|
|
@@ -62,15 +79,9 @@ export function defineVerb<Args, Return>(
|
|
|
62
79
|
}
|
|
63
80
|
|
|
64
81
|
async function validateThenHandle(args: Args | undefined): Promise<Response> {
|
|
65
|
-
const result = await
|
|
82
|
+
const result = await inputSchema!['~standard'].validate(args)
|
|
66
83
|
if (result.issues) {
|
|
67
|
-
return
|
|
68
|
-
status: 422,
|
|
69
|
-
headers: {
|
|
70
|
-
'Content-Type': 'application/json',
|
|
71
|
-
'Cache-Control': NO_STORE,
|
|
72
|
-
},
|
|
73
|
-
})
|
|
84
|
+
return json({ issues: result.issues }, { status: 422 })
|
|
74
85
|
}
|
|
75
86
|
return runHandler(result.value as Args)
|
|
76
87
|
}
|
|
@@ -83,7 +94,7 @@ export function defineVerb<Args, Return>(
|
|
|
83
94
|
SSR call.
|
|
84
95
|
*/
|
|
85
96
|
function invoke(args: Args | undefined): Promise<Response> {
|
|
86
|
-
return
|
|
97
|
+
return inputSchema ? validateThenHandle(args) : runHandler(args)
|
|
87
98
|
}
|
|
88
99
|
|
|
89
100
|
const remote = createRemoteFunction<Args, Return>({
|
|
@@ -96,8 +107,10 @@ export function defineVerb<Args, Return>(
|
|
|
96
107
|
})
|
|
97
108
|
registerVerb({
|
|
98
109
|
remote: remote as RemoteFunction<unknown, unknown>,
|
|
99
|
-
|
|
100
|
-
|
|
110
|
+
inputSchema,
|
|
111
|
+
inputJsonSchema,
|
|
112
|
+
outputSchema,
|
|
113
|
+
outputJsonSchema,
|
|
101
114
|
clients,
|
|
102
115
|
})
|
|
103
116
|
return remote
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { carriesBodyArgs } from '../../shared/carriesBodyArgs.ts'
|
|
1
2
|
import type { HttpVerb } from './types/HttpVerb.ts'
|
|
2
3
|
|
|
3
4
|
/*
|
|
@@ -24,7 +25,7 @@ export async function parseArgs(method: HttpVerb, request: Request): Promise<unk
|
|
|
24
25
|
const url = hasQuery ? new URL(request.url) : undefined
|
|
25
26
|
|
|
26
27
|
let body: unknown
|
|
27
|
-
if (method
|
|
28
|
+
if (carriesBodyArgs(method)) {
|
|
28
29
|
const contentType = (request.headers.get('content-type') ?? '').toLowerCase()
|
|
29
30
|
if (contentType.includes('application/json')) {
|
|
30
31
|
const text = await request.text()
|
|
@@ -6,11 +6,15 @@ import type { StandardSchemaV1 } from './StandardSchemaV1.ts'
|
|
|
6
6
|
/*
|
|
7
7
|
Shared signature for every verb helper (GET / POST / …). Three overloads:
|
|
8
8
|
|
|
9
|
-
- `Verb(fn, {
|
|
10
|
-
`InferInput<
|
|
11
|
-
Generic order is `<Return,
|
|
12
|
-
while letting `
|
|
13
|
-
|
|
9
|
+
- `Verb(fn, { inputSchema, outputSchema?, clients? })` — `Args` infers
|
|
10
|
+
from `InferInput<InputSchema>`, the handler receives
|
|
11
|
+
`InferOutput<InputSchema>`. Generic order is `<Return, InputSchema>` so
|
|
12
|
+
users can override `Return` while letting `InputSchema` infer from
|
|
13
|
+
`opts.inputSchema`. `outputSchema` is an optional Standard Schema for
|
|
14
|
+
the success body — it feeds the OpenAPI 200 response and the MCP tool
|
|
15
|
+
`outputSchema`. `inputJsonSchema` / `outputJsonSchema` are optional
|
|
16
|
+
precomputed JSON Schema overrides. `clients` controls which surfaces
|
|
17
|
+
(browser / mcp / cli) expose this verb.
|
|
14
18
|
- `Verb(fn, { clients })` — schemaless but with explicit client
|
|
15
19
|
targeting (e.g. server-internal RPC with `clients: { browser: false }`).
|
|
16
20
|
- `Verb(fn)` — bare handler. `Args` and `Return` come from the handler
|
|
@@ -18,18 +22,22 @@ Shared signature for every verb helper (GET / POST / …). Three overloads:
|
|
|
18
22
|
`json`/`error`/`redirect`/`jsonl`/`sse`.
|
|
19
23
|
*/
|
|
20
24
|
export type VerbHelper = {
|
|
21
|
-
<Return = unknown,
|
|
22
|
-
fn: RemoteHandler<StandardSchemaV1.InferOutput<
|
|
25
|
+
<Return = unknown, InputSchema extends StandardSchemaV1 = StandardSchemaV1>(
|
|
26
|
+
fn: RemoteHandler<StandardSchemaV1.InferOutput<InputSchema>, Return>,
|
|
23
27
|
opts: {
|
|
24
|
-
|
|
25
|
-
|
|
28
|
+
inputSchema: InputSchema
|
|
29
|
+
inputJsonSchema?: Record<string, unknown>
|
|
30
|
+
outputSchema?: StandardSchemaV1
|
|
31
|
+
outputJsonSchema?: Record<string, unknown>
|
|
26
32
|
clients?: Partial<ClientFlags>
|
|
27
33
|
},
|
|
28
|
-
): RemoteFunction<StandardSchemaV1.InferInput<
|
|
34
|
+
): RemoteFunction<StandardSchemaV1.InferInput<InputSchema>, Return>
|
|
29
35
|
<Args = undefined, Return = unknown>(
|
|
30
36
|
fn: RemoteHandler<Args, Return>,
|
|
31
37
|
opts: {
|
|
32
|
-
|
|
38
|
+
inputJsonSchema?: Record<string, unknown>
|
|
39
|
+
outputSchema?: StandardSchemaV1
|
|
40
|
+
outputJsonSchema?: Record<string, unknown>
|
|
33
41
|
clients: Partial<ClientFlags>
|
|
34
42
|
},
|
|
35
43
|
): RemoteFunction<Args, Return>
|
|
@@ -4,14 +4,22 @@ import type { StandardSchemaV1 } from './StandardSchemaV1.ts'
|
|
|
4
4
|
|
|
5
5
|
/*
|
|
6
6
|
Per-verb registry record on the server side. MCP and CLI enumerate this
|
|
7
|
-
to discover which RPCs are advertised (clients flags) and what
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
7
|
+
to discover which RPCs are advertised (clients flags) and what shapes
|
|
8
|
+
they expect/return. The schemas and resolved clients stay off the public
|
|
9
|
+
RemoteFunction shape so the browser-side proxy doesn't need to carry
|
|
10
|
+
server-only state.
|
|
11
|
+
|
|
12
|
+
`inputSchema` validates the argument bag and feeds the MCP tool
|
|
13
|
+
`inputSchema` / OpenAPI parameters; `outputSchema` describes the success
|
|
14
|
+
body and feeds the OpenAPI 200 response + MCP tool `outputSchema`. The
|
|
15
|
+
`*JsonSchema` siblings are optional user-supplied JSON Schema overrides
|
|
16
|
+
(used verbatim when present, otherwise derived from the Standard Schema).
|
|
11
17
|
*/
|
|
12
18
|
export type VerbRegistryEntry = {
|
|
13
19
|
remote: RemoteFunction<unknown, unknown>
|
|
14
|
-
|
|
15
|
-
|
|
20
|
+
inputSchema: StandardSchemaV1 | undefined
|
|
21
|
+
inputJsonSchema: Record<string, unknown> | undefined
|
|
22
|
+
outputSchema: StandardSchemaV1 | undefined
|
|
23
|
+
outputJsonSchema: Record<string, unknown> | undefined
|
|
16
24
|
clients: ClientFlags
|
|
17
25
|
}
|
|
@@ -1,10 +1,8 @@
|
|
|
1
|
+
import { carriesBodyArgs } from '../../shared/carriesBodyArgs.ts'
|
|
1
2
|
import { commandNameForUrl } from '../../shared/commandNameForUrl.ts'
|
|
2
3
|
import { jsonSchemaForSchema } from '../../shared/jsonSchemaForSchema.ts'
|
|
3
|
-
import type { HttpVerb } from '../rpc/types/HttpVerb.ts'
|
|
4
4
|
import { verbRegistry } from '../rpc/verbRegistry.ts'
|
|
5
5
|
|
|
6
|
-
const BODY_METHODS = new Set<HttpVerb>(['POST', 'PUT', 'PATCH'])
|
|
7
|
-
|
|
8
6
|
/*
|
|
9
7
|
Turns a verb's resolved JSON Schema into OpenAPI query parameters — one
|
|
10
8
|
per top-level property, marked required when the schema lists it. Used
|
|
@@ -40,14 +38,27 @@ export function buildOpenApiSpec(info: {
|
|
|
40
38
|
for (const entry of verbRegistry.values()) {
|
|
41
39
|
const url = entry.remote.url
|
|
42
40
|
const method = entry.remote.method
|
|
43
|
-
const jsonSchema = jsonSchemaForSchema(entry.
|
|
41
|
+
const jsonSchema = jsonSchemaForSchema(entry.inputSchema, entry.inputJsonSchema)
|
|
44
42
|
const description = jsonSchema.description as string | undefined
|
|
43
|
+
/*
|
|
44
|
+
When the verb declares an `outputSchema`, describe the 200 body
|
|
45
|
+
with it so external tooling sees the real return shape; otherwise
|
|
46
|
+
fall back to a bare OK.
|
|
47
|
+
*/
|
|
48
|
+
const okResponse: Record<string, unknown> = { description: 'OK' }
|
|
49
|
+
if (entry.outputSchema) {
|
|
50
|
+
okResponse.content = {
|
|
51
|
+
'application/json': {
|
|
52
|
+
schema: jsonSchemaForSchema(entry.outputSchema, entry.outputJsonSchema),
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
}
|
|
45
56
|
const operation: Record<string, unknown> = {
|
|
46
57
|
operationId: commandNameForUrl(url),
|
|
47
58
|
...(description ? { description } : {}),
|
|
48
|
-
responses: { '200':
|
|
59
|
+
responses: { '200': okResponse },
|
|
49
60
|
}
|
|
50
|
-
if (
|
|
61
|
+
if (carriesBodyArgs(method)) {
|
|
51
62
|
operation.requestBody = {
|
|
52
63
|
content: { 'application/json': { schema: jsonSchema } },
|
|
53
64
|
}
|
|
@@ -2,6 +2,7 @@ import { PUBLIC_ASSET_CACHE_CONTROL } from '../../shared/cacheControlValues.ts'
|
|
|
2
2
|
import { acceptsZstd } from './acceptsZstd.ts'
|
|
3
3
|
import { containsTraversal } from './containsTraversal.ts'
|
|
4
4
|
import { createAssetHeaderCache } from './createAssetHeaderCache.ts'
|
|
5
|
+
import { globToPathSet } from './globToPathSet.ts'
|
|
5
6
|
import type { Assets } from './types/Assets.ts'
|
|
6
7
|
|
|
7
8
|
/*
|
|
@@ -11,21 +12,32 @@ sources, picked at construction:
|
|
|
11
12
|
- `publicAssets` (standalone compile): a map of root path → zstd bytes
|
|
12
13
|
embedded into the binary, mirroring the `_app` asset embed.
|
|
13
14
|
- `publicDir` on disk (dev + `belte start`): files read straight from
|
|
14
|
-
`${cwd}/src/browser/public
|
|
15
|
+
`${cwd}/src/browser/public`, with the set of paths snapshotted once at
|
|
16
|
+
boot (see below).
|
|
15
17
|
|
|
16
18
|
Returns a server fn that resolves to `undefined` when no public file
|
|
17
19
|
matches the request path, so the caller falls through to its own 404 /
|
|
18
20
|
middleware path. The path-traversal guard mirrors serveStaticAsset's
|
|
19
21
|
defence against encoded `..` segments in the raw URL.
|
|
22
|
+
|
|
23
|
+
Async because disk mode globs `publicDir` once at construction to build a
|
|
24
|
+
Set of the available paths: every page nav and RPC falls through here, so
|
|
25
|
+
a Set lookup beats a filesystem stat per miss. A file added to public/
|
|
26
|
+
after boot needs a server restart to be seen — the same restart a code
|
|
27
|
+
change already triggers under `bun --watch`.
|
|
20
28
|
*/
|
|
21
|
-
export function createPublicAssetServer({
|
|
29
|
+
export async function createPublicAssetServer({
|
|
22
30
|
publicDir,
|
|
23
31
|
publicAssets,
|
|
24
32
|
}: {
|
|
25
33
|
publicDir: string
|
|
26
34
|
publicAssets?: Assets
|
|
27
|
-
}): (req: Request, url: URL) => Promise<Response | undefined
|
|
35
|
+
}): Promise<(req: Request, url: URL) => Promise<Response | undefined>> {
|
|
28
36
|
const headersFor = createAssetHeaderCache(() => PUBLIC_ASSET_CACHE_CONTROL)
|
|
37
|
+
// `dot: true` keeps dotfiles (e.g. `.well-known/…`) servable, matching a raw disk stat.
|
|
38
|
+
const diskPaths = publicAssets
|
|
39
|
+
? new Set<string>()
|
|
40
|
+
: await globToPathSet(publicDir, '**/*', (file) => `/${file}`, { dot: true })
|
|
29
41
|
|
|
30
42
|
return async function servePublicAsset(req, url) {
|
|
31
43
|
if (containsTraversal(req.url)) {
|
|
@@ -43,10 +55,9 @@ export function createPublicAssetServer({
|
|
|
43
55
|
}
|
|
44
56
|
return new Response(await Bun.zstdDecompress(compressed), { headers: base })
|
|
45
57
|
}
|
|
46
|
-
|
|
47
|
-
if (!(await file.exists())) {
|
|
58
|
+
if (!diskPaths.has(url.pathname)) {
|
|
48
59
|
return undefined
|
|
49
60
|
}
|
|
50
|
-
return new Response(file, { headers: base })
|
|
61
|
+
return new Response(Bun.file(publicDir + url.pathname), { headers: base })
|
|
51
62
|
}
|
|
52
63
|
}
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { BunRequest, Server } from 'bun'
|
|
2
|
-
import { Glob } from 'bun'
|
|
3
2
|
import type { Component } from 'svelte'
|
|
4
3
|
import { render } from 'svelte/server'
|
|
5
4
|
import App from '../../../App.svelte'
|
|
@@ -29,6 +28,8 @@ import { cacheControlForAsset } from './cacheControlForAsset.ts'
|
|
|
29
28
|
import { containsTraversal } from './containsTraversal.ts'
|
|
30
29
|
import { createAssetHeaderCache } from './createAssetHeaderCache.ts'
|
|
31
30
|
import { createPublicAssetServer } from './createPublicAssetServer.ts'
|
|
31
|
+
import { globToPathSet } from './globToPathSet.ts'
|
|
32
|
+
import { logBrowserOnlyRoutes } from './logBrowserOnlyRoutes.ts'
|
|
32
33
|
import { ensureRegistriesLoaded, setRegistryManifests } from './registryManifests.ts'
|
|
33
34
|
import { requestContext } from './requestContext.ts'
|
|
34
35
|
import { safeJsonForScript } from './safeJsonForScript.ts'
|
|
@@ -58,6 +59,7 @@ function internalErrorResponse(err: unknown): Response {
|
|
|
58
59
|
|
|
59
60
|
const IDENTITY_PATH = '/__belte/identity'
|
|
60
61
|
const SOCKETS_PATH = '/__belte/sockets'
|
|
62
|
+
const SOCKETS_REST_PREFIX = '/__belte/sockets/'
|
|
61
63
|
const MCP_PATH = '/__belte/mcp'
|
|
62
64
|
const CLI_PATH = '/__belte/cli'
|
|
63
65
|
const CLI_DOWNLOAD_PREFIX = '/__belte/cli/'
|
|
@@ -125,16 +127,23 @@ export async function createServer({
|
|
|
125
127
|
setMcpResourceServer(createMcpResourceServer({ resourcesDir, mcpResources }))
|
|
126
128
|
const cliName = cliProgramName ?? 'app'
|
|
127
129
|
const cliCwd = process.cwd()
|
|
128
|
-
const servePublicAsset = createPublicAssetServer({ publicDir, publicAssets })
|
|
130
|
+
const servePublicAsset = await createPublicAssetServer({ publicDir, publicAssets })
|
|
129
131
|
const layoutPrefixes = layouts ? normalizeLayoutPrefixes(Object.keys(layouts)) : []
|
|
130
132
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
133
|
+
/*
|
|
134
|
+
Snapshot the precompressed `.zst` siblings the build wrote next to each
|
|
135
|
+
`_app` asset, keyed by the asset's request path, so a zstd-capable
|
|
136
|
+
client gets the precompressed bytes without on-the-fly compression. Only
|
|
137
|
+
in disk mode (`belte start` / dev); the compiled binary serves from the
|
|
138
|
+
embedded `assets` map instead.
|
|
139
|
+
*/
|
|
140
|
+
const diskZstdPaths = assets
|
|
141
|
+
? new Set<string>()
|
|
142
|
+
: await globToPathSet(
|
|
143
|
+
`${distDir}/_app`,
|
|
144
|
+
'**/*.zst',
|
|
145
|
+
(file) => `/_app/${file.replace(/\.zst$/, '')}`,
|
|
146
|
+
)
|
|
138
147
|
|
|
139
148
|
const rpcModuleCache = new Map<string, Promise<AnyRemoteFunction | undefined>>()
|
|
140
149
|
function loadRpc(url: string): Promise<AnyRemoteFunction | undefined> | undefined {
|
|
@@ -440,6 +449,17 @@ export async function createServer({
|
|
|
440
449
|
}
|
|
441
450
|
return new Response('Upgrade failed', { status: 400 })
|
|
442
451
|
}
|
|
452
|
+
/*
|
|
453
|
+
HTTP face of a socket (`/__belte/sockets/<name>`) — tail over
|
|
454
|
+
SSE / JSON and publish — for the CLI and MCP. Runs through
|
|
455
|
+
dispatchRequest so app.handle auth applies, like the rpc paths.
|
|
456
|
+
The socket name may contain `/` (nested files), so it's the
|
|
457
|
+
whole remaining pathname, percent-decoded.
|
|
458
|
+
*/
|
|
459
|
+
if (url.pathname.startsWith(SOCKETS_REST_PREFIX)) {
|
|
460
|
+
const name = decodeURIComponent(url.pathname.slice(SOCKETS_REST_PREFIX.length))
|
|
461
|
+
return dispatchRequest(req, {}, async () => socketDispatcher.rest(req, name))
|
|
462
|
+
}
|
|
443
463
|
if (url.pathname === MCP_PATH && mcp) {
|
|
444
464
|
return dispatchRequest(req, {}, async () => mcp.handle(req))
|
|
445
465
|
}
|
|
@@ -543,5 +563,13 @@ export async function createServer({
|
|
|
543
563
|
}
|
|
544
564
|
|
|
545
565
|
log.success(`ready at http://localhost:${server.port}`)
|
|
566
|
+
/*
|
|
567
|
+
Diagnostic only, and only under `belte` debug logging — eager-loads the
|
|
568
|
+
registry to report routes that are browser-only for lack of a schema,
|
|
569
|
+
making the opt-in nature of the MCP/CLI surfaces visible.
|
|
570
|
+
*/
|
|
571
|
+
if (logRequests) {
|
|
572
|
+
void logBrowserOnlyRoutes()
|
|
573
|
+
}
|
|
546
574
|
return server
|
|
547
575
|
}
|