@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.
Files changed (46) 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/onMenu.ts +20 -5
  7. package/src/lib/bundle/openWebview.ts +9 -2
  8. package/src/lib/bundle/signMacApp.ts +35 -0
  9. package/src/lib/cli/createClient.ts +65 -27
  10. package/src/lib/cli/runCli.ts +37 -15
  11. package/src/lib/cli/types/CliManifestEntry.ts +7 -2
  12. package/src/lib/mcp/annotationsForMethod.ts +29 -0
  13. package/src/lib/mcp/createMcpServer.ts +10 -8
  14. package/src/lib/mcp/dispatchMcpRequest.ts +115 -33
  15. package/src/lib/mcp/toolResultFromResponse.ts +66 -0
  16. package/src/lib/server/jsonl.ts +2 -1
  17. package/src/lib/server/rpc/defineVerb.ts +30 -17
  18. package/src/lib/server/rpc/parseArgs.ts +2 -1
  19. package/src/lib/server/rpc/types/VerbHelper.ts +19 -11
  20. package/src/lib/server/rpc/types/VerbRegistryEntry.ts +14 -6
  21. package/src/lib/server/runtime/buildOpenApiSpec.ts +17 -6
  22. package/src/lib/server/runtime/createPublicAssetServer.ts +17 -6
  23. package/src/lib/server/runtime/createServer.ts +37 -9
  24. package/src/lib/server/runtime/globToPathSet.ts +29 -0
  25. package/src/lib/server/runtime/logBrowserOnlyRoutes.ts +44 -0
  26. package/src/lib/server/sockets/createSocketDispatcher.ts +71 -8
  27. package/src/lib/server/sockets/defineSocket.ts +7 -1
  28. package/src/lib/server/sockets/recentHistory.ts +11 -0
  29. package/src/lib/server/sockets/socketOperations.ts +35 -0
  30. package/src/lib/server/sockets/types/SocketOperation.ts +22 -0
  31. package/src/lib/server/sse.ts +2 -1
  32. package/src/lib/shared/buildRpcRequest.ts +2 -1
  33. package/src/lib/shared/carriesBodyArgs.ts +13 -0
  34. package/src/lib/shared/isReadOnlyMethod.ts +14 -0
  35. package/src/lib/shared/isStreamingResponse.ts +11 -0
  36. package/src/lib/shared/jsonlErrorFrame.ts +24 -0
  37. package/src/lib/shared/keyForRemoteCall.ts +2 -1
  38. package/src/lib/shared/resolveClientFlags.ts +8 -6
  39. package/src/lib/shared/responseErrorText.ts +9 -0
  40. package/src/lib/shared/sseErrorFrame.ts +29 -0
  41. package/src/lib/shared/streamResponse.ts +168 -0
  42. package/src/lib/shared/subscribableFromResponse.ts +1 -172
  43. package/src/lib/shared/types/CacheEntry.ts +6 -0
  44. package/template/src/bundle/icon.png +0 -0
  45. package/template/src/server/rpc/getHello.ts +5 -3
  46. 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. 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
  }
@@ -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
- const diskZstdPaths = new Set<string>(
132
- !assets && (await Bun.file(`${distDir}/_app`).exists())
133
- ? (await Array.fromAsync(new Glob('**/*.zst').scan({ cwd: `${distDir}/_app` }))).map(
134
- (file) => `/_app/${file.replace(/\.zst$/, '')}`,
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
  }