@briancray/belte 0.3.0 → 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.
Files changed (47) hide show
  1. package/package.json +1 -1
  2. package/src/bundleApp.ts +12 -2
  3. package/src/discoveryEntry.ts +58 -11
  4. package/src/lib/browser/cache.ts +29 -6
  5. package/src/lib/browser/startClient.ts +24 -1
  6. package/src/lib/bundle/disconnected.svelte +18 -19
  7. package/src/lib/bundle/onMenu.ts +20 -5
  8. package/src/lib/bundle/openWebview.ts +9 -2
  9. package/src/lib/bundle/signMacApp.ts +35 -0
  10. package/src/lib/cli/createClient.ts +65 -27
  11. package/src/lib/cli/runCli.ts +37 -15
  12. package/src/lib/cli/types/CliManifestEntry.ts +7 -2
  13. package/src/lib/mcp/annotationsForMethod.ts +29 -0
  14. package/src/lib/mcp/createMcpServer.ts +10 -8
  15. package/src/lib/mcp/dispatchMcpRequest.ts +115 -33
  16. package/src/lib/mcp/toolResultFromResponse.ts +66 -0
  17. package/src/lib/server/jsonl.ts +2 -1
  18. package/src/lib/server/rpc/defineVerb.ts +30 -17
  19. package/src/lib/server/rpc/parseArgs.ts +2 -1
  20. package/src/lib/server/rpc/types/VerbHelper.ts +19 -11
  21. package/src/lib/server/rpc/types/VerbRegistryEntry.ts +14 -6
  22. package/src/lib/server/runtime/buildOpenApiSpec.ts +17 -6
  23. package/src/lib/server/runtime/createPublicAssetServer.ts +17 -6
  24. package/src/lib/server/runtime/createServer.ts +37 -9
  25. package/src/lib/server/runtime/globToPathSet.ts +29 -0
  26. package/src/lib/server/runtime/logBrowserOnlyRoutes.ts +44 -0
  27. package/src/lib/server/sockets/createSocketDispatcher.ts +71 -8
  28. package/src/lib/server/sockets/defineSocket.ts +7 -1
  29. package/src/lib/server/sockets/recentHistory.ts +11 -0
  30. package/src/lib/server/sockets/socketOperations.ts +35 -0
  31. package/src/lib/server/sockets/types/SocketOperation.ts +22 -0
  32. package/src/lib/server/sse.ts +2 -1
  33. package/src/lib/shared/buildRpcRequest.ts +2 -1
  34. package/src/lib/shared/carriesBodyArgs.ts +13 -0
  35. package/src/lib/shared/isReadOnlyMethod.ts +14 -0
  36. package/src/lib/shared/isStreamingResponse.ts +11 -0
  37. package/src/lib/shared/jsonlErrorFrame.ts +24 -0
  38. package/src/lib/shared/keyForRemoteCall.ts +2 -1
  39. package/src/lib/shared/resolveClientFlags.ts +8 -6
  40. package/src/lib/shared/responseErrorText.ts +9 -0
  41. package/src/lib/shared/sseErrorFrame.ts +29 -0
  42. package/src/lib/shared/streamResponse.ts +168 -0
  43. package/src/lib/shared/subscribableFromResponse.ts +1 -172
  44. package/src/lib/shared/types/CacheEntry.ts +6 -0
  45. package/template/src/bundle/icon.png +0 -0
  46. package/template/src/server/rpc/getHello.ts +5 -3
  47. package/src/lib/shared/belteImportName.test.ts +0 -58
@@ -1,12 +1,17 @@
1
1
  import type { HttpVerb } from '../../server/rpc/types/HttpVerb.ts'
2
2
 
3
3
  /*
4
- Per-RPC manifest entry baked into the standalone CLI binary by the
4
+ Per-command manifest entry baked into the standalone CLI binary by the
5
5
  bundler. Carries enough info to make the right HTTP request without
6
- importing the handler module (which the thin build doesn't ship).
6
+ importing the handler module (which the thin build doesn't ship). Covers
7
+ both rpcs and socket commands — a socket `tail` is a GET against
8
+ `/__belte/sockets/<name>` with `accept: text/event-stream` so the CLI
9
+ streams it live; a socket `publish` is a POST to the same path.
7
10
  */
8
11
  export type CliManifestEntry = {
9
12
  method: HttpVerb
10
13
  url: string
11
14
  jsonSchema?: Record<string, unknown>
15
+ // Request Accept header. Socket tail sets text/event-stream to stream live frames.
16
+ accept?: string
12
17
  }
@@ -0,0 +1,29 @@
1
+ import type { HttpVerb } from '../server/rpc/types/HttpVerb.ts'
2
+
3
+ /*
4
+ Maps an HTTP verb to MCP tool annotations so a model can tell a read from
5
+ a write before calling. Belte derives these from the verb the RPC was
6
+ declared with rather than asking the author to repeat the intent:
7
+ - GET / HEAD → read-only, non-destructive
8
+ - POST → creates; not idempotent, not (necessarily) destructive
9
+ - PUT → replaces; idempotent + destructive
10
+ - PATCH → modifies; destructive, not idempotent
11
+ - DELETE → removes; idempotent + destructive
12
+ The shape matches MCP's ToolAnnotations (readOnlyHint / destructiveHint /
13
+ idempotentHint); fields a verb doesn't imply are left off.
14
+ */
15
+ export function annotationsForMethod(method: HttpVerb): Record<string, boolean> {
16
+ switch (method) {
17
+ case 'GET':
18
+ case 'HEAD':
19
+ return { readOnlyHint: true, destructiveHint: false }
20
+ case 'POST':
21
+ return { readOnlyHint: false, destructiveHint: false, idempotentHint: false }
22
+ case 'PUT':
23
+ return { readOnlyHint: false, destructiveHint: true, idempotentHint: true }
24
+ case 'PATCH':
25
+ return { readOnlyHint: false, destructiveHint: true, idempotentHint: false }
26
+ case 'DELETE':
27
+ return { readOnlyHint: false, destructiveHint: true, idempotentHint: true }
28
+ }
29
+ }
@@ -1,3 +1,4 @@
1
+ import { NO_STORE } from '../shared/cacheControlValues.ts'
1
2
  import { dispatchMcpRequest, MCP_NO_STORE_HEADERS } from './dispatchMcpRequest.ts'
2
3
  import type { McpServer } from './types/McpServer.ts'
3
4
  import type { McpServerOptions } from './types/McpServerOptions.ts'
@@ -12,13 +13,14 @@ object whose `handle(request)` is the function the bun route at
12
13
  default-constructs it; there is no user-authored server module. Server
13
14
  name/version default from package.json.
14
15
 
15
- Tools are derived from every verb with `clients.mcp: true` (auto-on when
16
- the verb carries a schema) one tool per rpc regardless of HTTP verb.
17
- Sockets are not exposed to MCP. Auth inherits from the inbound request —
18
- bearer / cookie headers
19
- flow into the synthesized Request that hits each rpc handler. An optional
20
- `authorize` hook in opts can short-circuit the request before any tool
21
- dispatches.
16
+ Tools are derived from every verb with `clients.mcp: true` (auto-on for
17
+ read-only verbs that carry a schema; mutating verbs opt in explicitly)
18
+ and from every socket with `clients.mcp: true` (a `<base>-tail` read tool
19
+ plus a `<base>-publish` tool when clientPublish is set). The HTTP verb
20
+ feeds each rpc tool's annotations. Auth inherits from the inbound request
21
+ bearer / cookie headers flow into the synthesized Request that hits
22
+ each rpc handler. An optional `authorize` hook in opts can short-circuit
23
+ the request before any tool dispatches.
22
24
  */
23
25
  export function createMcpServer(opts: McpServerOptions = {}): McpServer {
24
26
  const serverInfo = {
@@ -30,7 +32,7 @@ export function createMcpServer(opts: McpServerOptions = {}): McpServer {
30
32
  if (request.method !== 'POST') {
31
33
  return new Response('Method Not Allowed', {
32
34
  status: 405,
33
- headers: { Allow: 'POST', 'Cache-Control': 'no-store' },
35
+ headers: { Allow: 'POST', 'Cache-Control': NO_STORE },
34
36
  })
35
37
  }
36
38
  const envelope = await dispatchMcpRequest(request, opts, serverInfo)
@@ -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. Every rpc with clients.mcp=true
46
- becomes one tool named after the export's URL (folder segments joined
47
- with `-`), regardless of HTTP verb GET reads and mutating verbs alike.
48
- Sockets are never exposed to MCP.
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.schema, entry.jsonSchema)
63
- tools.push({
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. Synthesizes a Request (with forwarded auth headers) and
99
- pipes it through verb.fetch — the same code path the HTTP router uses, so
100
- validation + handler + error helpers behave identically. Every rpc is a
101
- tool regardless of verb.
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 (!entry?.clients.mcp) {
110
- throw new Error(`unknown tool: ${toolName}`)
162
+ if (entry?.clients.mcp) {
163
+ const response = await dispatchVerb(entry, args, inbound)
164
+ return toolResultFromResponse(response)
111
165
  }
112
- const response = await dispatchVerb(entry, args, inbound)
113
- if (!response.ok) {
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
- const body = await decodeResponse(response)
125
- return {
126
- content: [
127
- {
128
- type: 'text',
129
- text: typeof body === 'string' ? body : JSON.stringify(body),
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
+ }
@@ -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) => `${JSON.stringify({ $error: message })}\n`,
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
- schema?: StandardSchemaV1
37
- jsonSchema?: Record<string, unknown>
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 schema = opts?.schema
42
- const jsonSchema = opts?.jsonSchema
43
- const clients = resolveClientFlags(opts?.clients, schema !== undefined)
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 schema!['~standard'].validate(args)
82
+ const result = await inputSchema!['~standard'].validate(args)
66
83
  if (result.issues) {
67
- return new Response(JSON.stringify({ issues: result.issues }), {
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 schema ? validateThenHandle(args) : runHandler(args)
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
- schema,
100
- jsonSchema,
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 !== 'GET' && method !== 'DELETE' && method !== 'HEAD') {
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, { schema, clients? })` — `Args` infers from
10
- `InferInput<Schema>`, the handler receives `InferOutput<Schema>`.
11
- Generic order is `<Return, Schema>` so users can override `Return`
12
- while letting `Schema` infer from `opts.schema`. `clients` controls
13
- which surfaces (browser / mcp / cli) expose this verb.
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, Schema extends StandardSchemaV1 = StandardSchemaV1>(
22
- fn: RemoteHandler<StandardSchemaV1.InferOutput<Schema>, Return>,
25
+ <Return = unknown, InputSchema extends StandardSchemaV1 = StandardSchemaV1>(
26
+ fn: RemoteHandler<StandardSchemaV1.InferOutput<InputSchema>, Return>,
23
27
  opts: {
24
- schema: Schema
25
- jsonSchema?: Record<string, unknown>
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<Schema>, Return>
34
+ ): RemoteFunction<StandardSchemaV1.InferInput<InputSchema>, Return>
29
35
  <Args = undefined, Return = unknown>(
30
36
  fn: RemoteHandler<Args, Return>,
31
37
  opts: {
32
- jsonSchema?: Record<string, unknown>
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 input
8
- shape they expect (schema). The schema and resolved clients stay off the
9
- public RemoteFunction shape so the browser-side proxy doesn't need to
10
- carry server-only state.
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
- schema: StandardSchemaV1 | undefined
15
- jsonSchema: Record<string, unknown> | undefined
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.schema, entry.jsonSchema)
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': { description: 'OK' } },
59
+ responses: { '200': okResponse },
49
60
  }
50
- if (BODY_METHODS.has(method)) {
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
- const file = Bun.file(publicDir + url.pathname)
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
  }