@briancray/belte 0.1.0 → 0.2.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 (98) hide show
  1. package/bin/belte.ts +25 -12
  2. package/package.json +2 -1
  3. package/src/appEntry.ts +124 -0
  4. package/src/belteResolverPlugin.ts +217 -194
  5. package/src/build.ts +6 -67
  6. package/src/buildCli.ts +36 -63
  7. package/src/buildDisconnected.ts +127 -0
  8. package/src/bundleApp.ts +123 -0
  9. package/src/bundleDisconnectedEntry.ts +17 -0
  10. package/src/cliEntry.ts +3 -9
  11. package/src/compile.ts +4 -15
  12. package/src/controlServerWorker.ts +261 -0
  13. package/src/dedupeSveltePlugin.ts +66 -0
  14. package/src/discoveryEntry.ts +12 -11
  15. package/src/lib/browser/cache.ts +3 -6
  16. package/src/lib/browser/page.svelte.ts +19 -21
  17. package/src/lib/browser/socketChannel.ts +11 -1
  18. package/src/lib/browser/types/Pages.ts +1 -1
  19. package/src/lib/bundle/BundleMenu.ts +11 -0
  20. package/src/lib/bundle/BundleMenuItem.ts +24 -0
  21. package/src/lib/bundle/BundleWindow.ts +20 -0
  22. package/src/lib/bundle/bindConnectedFlag.ts +29 -0
  23. package/src/lib/bundle/bindRequestNavigate.ts +31 -0
  24. package/src/lib/bundle/buildWebviewLib.ts +111 -0
  25. package/src/lib/bundle/disconnected.css +9 -0
  26. package/src/lib/bundle/disconnected.svelte +192 -0
  27. package/src/lib/bundle/ensureWebviewLib.ts +20 -0
  28. package/src/lib/bundle/exitWithParent.ts +28 -0
  29. package/src/lib/bundle/findFreePort.ts +14 -0
  30. package/src/lib/bundle/infoPlist.ts +46 -0
  31. package/src/lib/bundle/installMacMenu.ts +39 -0
  32. package/src/lib/bundle/listenLocalControlServer.ts +19 -0
  33. package/src/lib/bundle/native/belteMenu.mm +298 -0
  34. package/src/lib/bundle/native/webview.h +4557 -0
  35. package/src/lib/bundle/onMenu.ts +26 -0
  36. package/src/lib/bundle/openWebview.ts +81 -0
  37. package/src/lib/bundle/pngToIcns.ts +47 -0
  38. package/src/lib/bundle/probeBelteServer.ts +34 -0
  39. package/src/lib/bundle/resolveServerBinary.ts +12 -0
  40. package/src/lib/bundle/resolveWebviewLib.ts +51 -0
  41. package/src/lib/bundle/serverBinaryFilename.ts +8 -0
  42. package/src/lib/bundle/stableLocalPort.ts +19 -0
  43. package/src/lib/bundle/waitForServer.ts +23 -0
  44. package/src/lib/bundle/webviewBuildRevision.ts +9 -0
  45. package/src/lib/bundle/webviewCachePath.ts +23 -0
  46. package/src/lib/bundle/webviewLibName.ts +11 -0
  47. package/src/lib/bundle/webviewVersion.ts +7 -0
  48. package/src/lib/cli/createClient.ts +34 -36
  49. package/src/lib/cli/printHelp.ts +45 -2
  50. package/src/lib/cli/runCli.ts +12 -3
  51. package/src/lib/mcp/createMcpResourceServer.ts +1 -1
  52. package/src/lib/mcp/dispatchMcpRequest.ts +53 -73
  53. package/src/lib/server/AppModule.ts +2 -2
  54. package/src/lib/server/cli/handleCliDownload.ts +4 -5
  55. package/src/lib/server/cli/handleCliInstall.ts +17 -0
  56. package/src/lib/server/error.ts +23 -9
  57. package/src/lib/server/json.ts +5 -5
  58. package/src/lib/server/jsonl.ts +10 -5
  59. package/src/lib/server/prompts/definePrompt.ts +6 -6
  60. package/src/lib/server/prompts/renderPromptTemplate.ts +16 -0
  61. package/src/lib/server/prompts/types/Prompt.ts +8 -9
  62. package/src/lib/server/prompts/types/PromptOptions.ts +7 -12
  63. package/src/lib/server/prompts/types/PromptRegistryEntry.ts +3 -5
  64. package/src/lib/server/prompts/types/PromptRoutes.ts +4 -4
  65. package/src/lib/server/redirect.ts +13 -8
  66. package/src/lib/server/rpc/defineVerb.ts +4 -3
  67. package/src/lib/server/rpc/findVerbByCommandName.ts +18 -0
  68. package/src/lib/server/rpc/types/RemoteFunction.ts +1 -1
  69. package/src/lib/server/rpc/types/RemoteHandler.ts +4 -0
  70. package/src/lib/server/runtime/acceptsZstd.ts +8 -0
  71. package/src/lib/server/runtime/buildOpenApiSpec.ts +2 -0
  72. package/src/lib/server/runtime/cacheControlForAsset.ts +7 -2
  73. package/src/lib/server/runtime/createAssetHeaderCache.ts +35 -0
  74. package/src/lib/server/runtime/createPublicAssetServer.ts +6 -20
  75. package/src/lib/server/runtime/createServer.ts +50 -58
  76. package/src/lib/server/runtime/registryManifests.ts +33 -15
  77. package/src/lib/server/runtime/types/RequestStore.ts +2 -3
  78. package/src/lib/server/runtime/withResponseDefaults.ts +24 -0
  79. package/src/lib/server/sockets/createSocketDispatcher.ts +10 -7
  80. package/src/lib/server/sse.ts +10 -5
  81. package/src/lib/shared/cacheControlValues.ts +10 -2
  82. package/src/lib/shared/canonicalJson.ts +1 -5
  83. package/src/lib/shared/createCacheStore.ts +29 -20
  84. package/src/lib/shared/exitOnBuildFailure.ts +17 -0
  85. package/src/lib/shared/fileStem.ts +9 -0
  86. package/src/lib/shared/jsonSchemaForPromptArguments.ts +29 -0
  87. package/src/lib/shared/keyForRemoteCall.ts +7 -5
  88. package/src/lib/shared/parsePromptMarkdown.ts +34 -0
  89. package/src/lib/shared/promptNameForFile.ts +5 -5
  90. package/src/lib/shared/subscribableFromResponse.ts +104 -215
  91. package/src/lib/shared/types/PromptArgument.ts +12 -0
  92. package/src/lib/shared/writeRoutesDts.ts +5 -7
  93. package/src/serverBuildPlugins.ts +25 -0
  94. package/src/serverEntry.ts +4 -0
  95. package/template/package.json +2 -1
  96. package/src/lib/server/prompt.ts +0 -30
  97. package/src/lib/server/prompts/types/PromptMessage.ts +0 -10
  98. package/src/lib/shared/preparePromptModule.ts +0 -36
@@ -1,5 +1,6 @@
1
1
  import { promptRegistry } from '../server/prompts/promptRegistry.ts'
2
- import type { HttpVerb } from '../server/rpc/types/HttpVerb.ts'
2
+ import { findVerbByCommandName } from '../server/rpc/findVerbByCommandName.ts'
3
+ import type { VerbRegistryEntry } from '../server/rpc/types/VerbRegistryEntry.ts'
3
4
  import { verbRegistry } from '../server/rpc/verbRegistry.ts'
4
5
  import { ensureRegistriesLoaded } from '../server/runtime/registryManifests.ts'
5
6
  import { buildRpcRequest } from '../shared/buildRpcRequest.ts'
@@ -28,47 +29,57 @@ function jsonRpcOk(id: string | number | null, result: unknown): JsonRpcResponse
28
29
  return { jsonrpc: '2.0', id, result }
29
30
  }
30
31
 
32
+ type ToolDescriptor = {
33
+ name: string
34
+ description: string
35
+ inputSchema: Record<string, unknown>
36
+ }
37
+
38
+ type PromptDescriptor = {
39
+ name: string
40
+ description?: string
41
+ arguments: Array<{ name: string; description?: string; required: boolean }>
42
+ }
43
+
31
44
  /*
32
45
  Builds the array of MCP tool descriptors. Every rpc with clients.mcp=true
33
46
  becomes one tool named after the export's URL (folder segments joined
34
47
  with `-`), regardless of HTTP verb — GET reads and mutating verbs alike.
35
48
  Sockets are never exposed to MCP.
36
49
  */
37
- function buildTools(): Array<{
38
- name: string
39
- description: string
40
- inputSchema: Record<string, unknown>
41
- }> {
42
- const tools: Array<{
43
- name: string
44
- description: string
45
- inputSchema: Record<string, unknown>
46
- }> = []
50
+ function buildTools(): ToolDescriptor[] {
51
+ const tools: ToolDescriptor[] = []
47
52
  for (const entry of verbRegistry.values()) {
48
53
  if (!entry.clients.mcp) {
49
54
  continue
50
55
  }
56
+ /*
57
+ Tool description favours the schema's top-level description (the
58
+ vendor's JSON Schema conversion carries `.describe(...)` through),
59
+ falling back to `method url` so the tool is still labelled when
60
+ the schema has none.
61
+ */
62
+ const inputSchema = jsonSchemaForSchema(entry.schema, entry.jsonSchema)
51
63
  tools.push({
52
64
  name: commandNameForUrl(entry.remote.url),
53
- description: `${entry.remote.method} ${entry.remote.url}`,
54
- inputSchema: jsonSchemaForSchema(entry.schema, entry.jsonSchema),
65
+ description:
66
+ (inputSchema.description as string | undefined) ??
67
+ `${entry.remote.method} ${entry.remote.url}`,
68
+ inputSchema,
55
69
  })
56
70
  }
57
71
  return tools
58
72
  }
59
73
 
60
74
  /*
61
- MCP prompts derived from src/mcp/prompts. Arguments come from the
62
- prompt's schema (top-level properties + required flags); the model fills
63
- them in and the framework validates + renders on prompts/get.
75
+ MCP prompts derived from src/mcp/prompts. Arguments come from the JSON
76
+ Schema the resolver built from each prompt's frontmatter `arguments` list
77
+ (top-level properties + required flags); the model fills them in and the
78
+ framework interpolates them into the body on prompts/get.
64
79
  */
65
- function buildPrompts(): Array<{
66
- name: string
67
- description?: string
68
- arguments: Array<{ name: string; description?: string; required: boolean }>
69
- }> {
80
+ function buildPrompts(): PromptDescriptor[] {
70
81
  return Array.from(promptRegistry.values()).map((entry) => {
71
- const jsonSchema = jsonSchemaForSchema(entry.schema, entry.jsonSchema)
82
+ const jsonSchema = entry.jsonSchema ?? {}
72
83
  const properties = (jsonSchema.properties ?? {}) as Record<string, { description?: string }>
73
84
  const required = new Set((jsonSchema.required as string[] | undefined) ?? [])
74
85
  return {
@@ -94,20 +105,11 @@ async function callTool(
94
105
  args: Record<string, unknown> | undefined,
95
106
  inbound: Request,
96
107
  ): Promise<Record<string, unknown>> {
97
- let found: ReturnType<(typeof verbRegistry)['get']> | undefined
98
- for (const entry of verbRegistry.values()) {
99
- if (!entry.clients.mcp) {
100
- continue
101
- }
102
- if (commandNameForUrl(entry.remote.url) === toolName) {
103
- found = entry
104
- break
105
- }
106
- }
107
- if (!found) {
108
+ const entry = findVerbByCommandName(toolName)
109
+ if (!entry?.clients.mcp) {
108
110
  throw new Error(`unknown tool: ${toolName}`)
109
111
  }
110
- const response = await runRpc(found.remote.method, found.remote.url, args, inbound)
112
+ const response = await dispatchVerb(entry, args, inbound)
111
113
  if (!response.ok) {
112
114
  return {
113
115
  content: [
@@ -119,7 +121,7 @@ async function callTool(
119
121
  isError: true,
120
122
  }
121
123
  }
122
- const body = await decodeResponse(response.clone())
124
+ const body = await decodeResponse(response)
123
125
  return {
124
126
  content: [
125
127
  {
@@ -131,66 +133,44 @@ async function callTool(
131
133
  }
132
134
 
133
135
  /*
134
- Synthesizes the rpc Request and dispatches through verb.fetch, forwarding
135
- the inbound MCP request's auth headers so session/bearer middleware keeps
136
- working. Shared by tool calls (non-GET) and resource reads (GET).
136
+ Synthesizes the rpc Request from a resolved registry entry and dispatches
137
+ through verb.fetch — the same code path the HTTP router uses forwarding the
138
+ inbound MCP request's auth headers so session/bearer middleware keeps working.
137
139
  */
138
- function runRpc(
139
- method: HttpVerb,
140
- url: string,
140
+ function dispatchVerb(
141
+ entry: VerbRegistryEntry,
141
142
  args: Record<string, unknown> | undefined,
142
143
  inbound: Request,
143
144
  ): Promise<Response> {
144
145
  const inboundUrl = new URL(inbound.url)
145
146
  const baseUrl = `${inboundUrl.protocol}//${inboundUrl.host}/`
146
147
  const request = buildRpcRequest({
147
- method,
148
- url,
148
+ method: entry.remote.method,
149
+ url: entry.remote.url,
149
150
  args,
150
151
  baseUrl,
151
152
  headers: forwardHeaders(inbound.headers),
152
153
  })
153
- const entry = verbRegistry.get(url)
154
- if (entry && entry.remote.method === method) {
155
- return entry.remote.fetch(request)
156
- }
157
- throw new Error(`unknown rpc: ${method} ${url}`)
154
+ return entry.remote.fetch(request)
158
155
  }
159
156
 
160
157
  /*
161
- Validates prompt arguments against the prompt's schema (when present),
162
- renders the messages, and maps them to the MCP prompts/get wire shape.
163
- A bare string render result becomes a single user message.
158
+ Interpolates the caller's arguments into the prompt body and wraps the
159
+ result in the MCP prompts/get wire shape — a markdown prompt is a single
160
+ user message whose text is the rendered template.
164
161
  */
165
- async function getPrompt(
162
+ function getPrompt(
166
163
  name: string,
167
164
  args: Record<string, unknown> | undefined,
168
- ): Promise<Record<string, unknown>> {
165
+ ): Record<string, unknown> {
169
166
  const entry = promptRegistry.get(name)
170
167
  if (!entry) {
171
168
  throw new Error(`unknown prompt: ${name}`)
172
169
  }
173
- let value: unknown = args ?? {}
174
- if (entry.schema) {
175
- const result = await entry.schema['~standard'].validate(value)
176
- if (result.issues) {
177
- throw new Error(
178
- `prompt "${name}" arguments failed validation: ${JSON.stringify(result.issues)}`,
179
- )
180
- }
181
- value = result.value
182
- }
183
- const rendered = await entry.prompt.render(value as Record<string, string>)
184
- const messages =
185
- typeof rendered === 'string'
186
- ? [{ role: 'user', content: { type: 'text', text: rendered } }]
187
- : rendered.map((message) => ({
188
- role: message.role,
189
- content: { type: 'text', text: message.text },
190
- }))
170
+ const rendered = entry.prompt.render((args ?? {}) as Record<string, string>)
191
171
  return {
192
172
  ...(entry.prompt.description ? { description: entry.prompt.description } : {}),
193
- messages,
173
+ messages: [{ role: 'user', content: { type: 'text', text: rendered } }],
194
174
  }
195
175
  }
196
176
 
@@ -277,7 +257,7 @@ export async function dispatchMcpRequest(
277
257
  if (!params?.name) {
278
258
  return jsonRpcError(id, -32602, 'Missing prompt name')
279
259
  }
280
- return jsonRpcOk(id, await getPrompt(params.name, params.arguments))
260
+ return jsonRpcOk(id, getPrompt(params.name, params.arguments))
281
261
  }
282
262
  default:
283
263
  return jsonRpcError(id, -32601, `Method not found: ${envelope.method}`)
@@ -7,10 +7,10 @@ function that runs on SIGINT/SIGTERM. handle is single-middleware with
7
7
  next so user code can mutate the response or branch on the URL.
8
8
 
9
9
  WebSockets are not exposed here — belte's only native WebSocket
10
- surface is the sockets hub (see `belte/sockets`), multiplexed onto a
10
+ surface is the sockets hub (see `belte/server/socket`), multiplexed onto a
11
11
  single framework-owned connection per client at `/__belte/sockets`.
12
12
  Inside request scopes, the live Bun.Server is reachable via the
13
- exported `server` proxy from `belte/server`; `init` receives it
13
+ exported `server()` function from `belte/server`; `init` receives it
14
14
  explicitly because it runs outside a request.
15
15
  */
16
16
  export type AppModule = {
@@ -1,4 +1,3 @@
1
- import { existsSync, statSync } from 'node:fs'
2
1
  import { NO_STORE } from '../../shared/cacheControlValues.ts'
3
2
  import { log } from '../../shared/log.ts'
4
3
  import { normalizeTarget } from '../../shared/normalizeTarget.ts'
@@ -55,8 +54,9 @@ async function computeBinary(
55
54
  didn't run `belte cli` again. Other source paths (project lib,
56
55
  transitive imports) fall back to manual rebuild.
57
56
  */
58
- if (existsSync(binaryPath)) {
59
- const binaryMtime = statSync(binaryPath).mtimeMs
57
+ const binaryFile = Bun.file(binaryPath)
58
+ if (await binaryFile.exists()) {
59
+ const binaryMtime = (await binaryFile.stat()).mtimeMs
60
60
  const sourceMtime = await maxSourceMtime(cwd)
61
61
  if (binaryMtime >= sourceMtime) {
62
62
  return binaryPath
@@ -71,9 +71,8 @@ async function computeBinary(
71
71
  await buildCli({
72
72
  cwd,
73
73
  platforms: [normalizeTarget(platform)],
74
- thin: true,
75
74
  })
76
- return existsSync(binaryPath) ? binaryPath : undefined
75
+ return (await binaryFile.exists()) ? binaryPath : undefined
77
76
  } catch (error) {
78
77
  log.error(error)
79
78
  return undefined
@@ -1,6 +1,17 @@
1
1
  import { NO_STORE } from '../../shared/cacheControlValues.ts'
2
2
  import { installScript } from './installScript.ts'
3
3
 
4
+ /*
5
+ The request host is reflected verbatim into a shell script the user pipes
6
+ to `sh`, so it's constrained to the strict authority charset: letters,
7
+ digits, `.`, `-`, `_`, `:` (port + IPv6 separators), and IPv6 `[` `]`
8
+ brackets. That set excludes every character that could break out of the
9
+ interpolated `URL="…"` line in installScript (`"`, `$`, backtick, `\`,
10
+ whitespace), neutralising shell injection via a crafted Host header
11
+ regardless of how lenient the upstream URL parser is.
12
+ */
13
+ const SAFE_HOST = /^[A-Za-z0-9._:[\]-]+$/
14
+
4
15
  /*
5
16
  Handles GET /__belte/cli — returns the platform-detecting shell script.
6
17
  Authoritative URL for the tarball is derived from the inbound request
@@ -9,6 +20,12 @@ on). Program name is the bundler-emitted `belte:cli-name` value.
9
20
  */
10
21
  export function handleCliInstall(request: Request, programName: string): Response {
11
22
  const url = new URL(request.url)
23
+ if (!SAFE_HOST.test(url.host)) {
24
+ return new Response('Bad Request', {
25
+ status: 400,
26
+ headers: { 'Cache-Control': NO_STORE },
27
+ })
28
+ }
12
29
  const appUrl = `${url.protocol}//${url.host}`
13
30
  const script = installScript(appUrl, programName)
14
31
  return new Response(script, {
@@ -1,5 +1,6 @@
1
1
  import { NO_STORE } from '../shared/cacheControlValues.ts'
2
2
  import type { TypedResponse } from './rpc/types/TypedResponse.ts'
3
+ import { withResponseDefaults } from './runtime/withResponseDefaults.ts'
3
4
 
4
5
  /*
5
6
  Plain-text error Response — clearer than constructing a Response by
@@ -11,7 +12,9 @@ returns the message, no parsing).
11
12
 
12
13
  `message` defaults to the status's standard reason phrase when
13
14
  omitted (e.g. `error(404)` body = 'Not Found'). The body is
14
- text/plain so intermediaries don't try to render or sniff it.
15
+ text/plain so intermediaries don't try to render or sniff it. A final
16
+ `ResponseInit` adds headers (e.g. `Retry-After` on a 429); the positional
17
+ `status` always wins over any `init.status`.
15
18
 
16
19
  To short-circuit a handler instead of returning, `throw new Error(...)`
17
20
  or `throw new HttpError(error(...))` — the framework's `app.handleError`
@@ -19,6 +22,13 @@ hook catches thrown errors. This helper deliberately returns a Response
19
22
  rather than throwing one so a single `return error(...)` is the
20
23
  expected pattern, with the same control flow as `return json(...)`.
21
24
  */
25
+
26
+ /*
27
+ Standard reason phrases for the statuses error() is realistically called
28
+ with. Maintained explicitly because Bun's `Response` does not populate
29
+ `statusText` from the status code, so there's no platform table to read.
30
+ Unlisted codes fall back to `HTTP <status>`.
31
+ */
22
32
  const STATUS_TEXT: Record<number, string> = {
23
33
  400: 'Bad Request',
24
34
  401: 'Unauthorized',
@@ -44,13 +54,17 @@ the union of branches in a handler narrow to whatever the success
44
54
  branch carries (`TypedResponse<{user}> | TypedResponse<never>` → Return
45
55
  = {user}).
46
56
  */
47
- export function error(status: number, message?: string): TypedResponse<never> {
57
+ export function error(status: number, message?: string, init?: ResponseInit): TypedResponse<never> {
48
58
  const body = message ?? STATUS_TEXT[status] ?? `HTTP ${status}`
49
- return new Response(body, {
50
- status,
51
- headers: {
52
- 'Content-Type': 'text/plain; charset=utf-8',
53
- 'Cache-Control': NO_STORE,
54
- },
55
- }) as TypedResponse<never>
59
+ return new Response(
60
+ body,
61
+ withResponseDefaults(
62
+ init,
63
+ {
64
+ 'Content-Type': 'text/plain; charset=utf-8',
65
+ 'Cache-Control': NO_STORE,
66
+ },
67
+ status,
68
+ ),
69
+ ) as TypedResponse<never>
56
70
  }
@@ -1,5 +1,6 @@
1
1
  import { NO_STORE } from '../shared/cacheControlValues.ts'
2
2
  import type { TypedResponse } from './rpc/types/TypedResponse.ts'
3
+ import { withResponseDefaults } from './runtime/withResponseDefaults.ts'
3
4
 
4
5
  /*
5
6
  JSON Response with rpc-friendly defaults — same shape as
@@ -20,9 +21,8 @@ For non-default cache policy pass `init.headers`; explicit
20
21
  `cache-control` wins over the default.
21
22
  */
22
23
  export function json<T>(data: T, init?: ResponseInit): TypedResponse<T> {
23
- const headers = new Headers(init?.headers)
24
- if (!headers.has('cache-control')) {
25
- headers.set('cache-control', NO_STORE)
26
- }
27
- return Response.json(data, { ...init, headers }) as TypedResponse<T>
24
+ return Response.json(
25
+ data,
26
+ withResponseDefaults(init, { 'Cache-Control': NO_STORE }),
27
+ ) as TypedResponse<T>
28
28
  }
@@ -24,17 +24,22 @@ message crosses the wire.
24
24
  import { NO_STORE } from '../shared/cacheControlValues.ts'
25
25
  import type { TypedResponse } from './rpc/types/TypedResponse.ts'
26
26
  import { streamFromIterator } from './runtime/streamFromIterator.ts'
27
+ import { withResponseDefaults } from './runtime/withResponseDefaults.ts'
27
28
 
28
- export function jsonl<Frame>(iterable: AsyncIterable<Frame>): TypedResponse<Frame> {
29
+ export function jsonl<Frame>(
30
+ iterable: AsyncIterable<Frame>,
31
+ init?: ResponseInit,
32
+ ): TypedResponse<Frame> {
29
33
  const body = streamFromIterator(iterable, {
30
34
  encodeFrame: (value) => `${JSON.stringify(value)}\n`,
31
35
  encodeError: (message) => `${JSON.stringify({ $error: message })}\n`,
32
36
  })
33
- return new Response(body, {
34
- headers: {
37
+ return new Response(
38
+ body,
39
+ withResponseDefaults(init, {
35
40
  'Content-Type': 'application/jsonl; charset=utf-8',
36
41
  'Cache-Control': NO_STORE,
37
42
  'X-Content-Type-Options': 'nosniff',
38
- },
39
- }) as TypedResponse<Frame>
43
+ }),
44
+ ) as TypedResponse<Frame>
40
45
  }
@@ -3,11 +3,11 @@ import type { Prompt } from './types/Prompt.ts'
3
3
  import type { PromptOptions } from './types/PromptOptions.ts'
4
4
 
5
5
  /*
6
- Builds a Prompt from a name + options. The bundler rewrites every
7
- `export const NAME = prompt(opts)` inside `src/server/prompts/<file>.ts`
8
- into `__belteDefinePrompt__("<name>", opts)` so the file path becomes the
9
- prompt's identity. Registers itself so the MCP dispatcher can enumerate
10
- and render it.
6
+ Builds a Prompt from a name + options. The resolver plugin parses every
7
+ `src/mcp/prompts/<file>.md` and generates a module that calls
8
+ `definePrompt("<name>", { description, jsonSchema, render })`, so the file
9
+ path becomes the prompt's identity. Registers itself so the MCP dispatcher
10
+ can enumerate and render it.
11
11
  */
12
12
  export function definePrompt(name: string, opts: PromptOptions): Prompt {
13
13
  const self: Prompt = {
@@ -15,6 +15,6 @@ export function definePrompt(name: string, opts: PromptOptions): Prompt {
15
15
  description: opts.description,
16
16
  render: opts.render,
17
17
  }
18
- registerPrompt({ prompt: self, schema: opts.schema, jsonSchema: opts.jsonSchema })
18
+ registerPrompt({ prompt: self, jsonSchema: opts.jsonSchema })
19
19
  return self
20
20
  }
@@ -0,0 +1,16 @@
1
+ // `{{name}}` placeholder, surrounding whitespace tolerated, names are
2
+ // word chars or hyphens to match valid MCP argument identifiers.
3
+ const PLACEHOLDER = /\{\{\s*([\w-]+)\s*\}\}/g
4
+
5
+ /*
6
+ Renders a markdown prompt body by substituting each `{{name}}` placeholder
7
+ with the matching argument value. Missing arguments collapse to an empty
8
+ string — MCP only enforces `required` at the client, so an optional
9
+ argument the model omits should simply vanish from the text. Called by the
10
+ render closure the resolver plugin generates for every `.md` prompt.
11
+ */
12
+ export function renderPromptTemplate(template: string, args: Record<string, string>): string {
13
+ return template.replace(PLACEHOLDER, (_match, key: string) =>
14
+ args[key] === undefined ? '' : String(args[key]),
15
+ )
16
+ }
@@ -1,14 +1,13 @@
1
- import type { PromptMessage } from './PromptMessage.ts'
2
-
3
1
  /*
4
- An MCP prompt declared once with `prompt(opts)` inside a file under
5
- `src/server/prompts/`. The bundler stamps in the `name` from the file
6
- path; `render(args)` produces the messages returned by `prompts/get`.
7
- Prompts are MCP-only there is no client-side counterpart, so the
8
- shape carries no ClientFlags.
2
+ An MCP prompt declared by a markdown file under `src/mcp/prompts/`. The
3
+ resolver plugin parses the file's frontmatter + body and generates a call
4
+ to definePrompt, stamping in the `name` from the file path; `render(args)`
5
+ interpolates the body's `{{name}}` placeholders into the single user
6
+ message returned by `prompts/get`. Prompts are MCP-only — there is no
7
+ client-side counterpart, so the shape carries no ClientFlags.
9
8
  */
10
- export type Prompt<Args = Record<string, string>> = {
9
+ export type Prompt = {
11
10
  readonly name: string
12
11
  readonly description: string | undefined
13
- render(args: Args): PromptMessage[] | string | Promise<PromptMessage[] | string>
12
+ render(args: Record<string, string>): string
14
13
  }
@@ -1,17 +1,12 @@
1
- import type { StandardSchemaV1 } from '../../rpc/types/StandardSchemaV1.ts'
2
- import type { PromptMessage } from './PromptMessage.ts'
3
-
4
1
  /*
5
- Server-side options passed when declaring a prompt via `prompt(opts)`.
6
- MCP prompts are read-only templates: `render(args)` turns the caller's
7
- arguments into one or more chat messages. The optional Standard Schema
8
- both validates incoming arguments and supplies the argument list MCP
9
- advertises in `prompts/list` (top-level properties + required array).
10
- All of this is server-only — prompts are never imported by client code.
2
+ Options definePrompt receives for one markdown prompt. The resolver plugin
3
+ generates this object from the file: `description` + `jsonSchema` come from
4
+ the frontmatter (the schema built from the `arguments` list), and `render`
5
+ closes over the parsed body. All of this is server-only — prompts are never
6
+ imported by client code.
11
7
  */
12
- export type PromptOptions<Args = Record<string, string>> = {
8
+ export type PromptOptions = {
13
9
  description?: string
14
- schema?: StandardSchemaV1
15
10
  jsonSchema?: Record<string, unknown>
16
- render: (args: Args) => PromptMessage[] | string | Promise<PromptMessage[] | string>
11
+ render: (args: Record<string, string>) => string
17
12
  }
@@ -1,15 +1,13 @@
1
- import type { StandardSchemaV1 } from '../../rpc/types/StandardSchemaV1.ts'
2
1
  import type { Prompt } from './Prompt.ts'
3
2
 
4
3
  /*
5
4
  Per-prompt registry record. The MCP dispatcher enumerates this to build
6
- `prompts/list` (description + arguments from the schema) and to dispatch
7
- `prompts/get` (validate args against the schema, then render). Schema +
8
- jsonSchema stay off the public Prompt shape so the render closure isn't
5
+ `prompts/list` (description + arguments from the JSON Schema) and to
6
+ dispatch `prompts/get` (render the body with the caller's arguments).
7
+ jsonSchema stays off the public Prompt shape so the render closure isn't
9
8
  burdened with metadata it never reads.
10
9
  */
11
10
  export type PromptRegistryEntry = {
12
11
  prompt: Prompt
13
- schema: StandardSchemaV1 | undefined
14
12
  jsonSchema: Record<string, unknown> | undefined
15
13
  }
@@ -2,9 +2,9 @@ import type { Prompt } from './Prompt.ts'
2
2
 
3
3
  /*
4
4
  Manifest of prompt-name → module loader. Produced by the resolver plugin
5
- from each `.ts` under src/server/prompts/. Each module has exactly one
6
- named export, a Prompt whose `.name` was stamped in by the bundler
7
- rewrite. The registry loader imports every module once so the MCP
8
- dispatcher can enumerate the full prompt surface.
5
+ from each `.md` under src/mcp/prompts/. Each markdown file is transformed
6
+ into a module that registers one Prompt (its `.name` stamped in by the
7
+ generated definePrompt call) on import. The registry loader imports every
8
+ module once so the MCP dispatcher can enumerate the full prompt surface.
9
9
  */
10
10
  export type PromptRoutes = Record<string, () => Promise<Record<string, Prompt>>>
@@ -14,9 +14,13 @@ Status guidance:
14
14
  - 303 — "after a POST, GET this" (forces GET on the follow-up)
15
15
  - 307 — temporary, preserve method
16
16
  - 308 — permanent, preserve method
17
+
18
+ A final `ResponseInit` adds headers (e.g. a `Set-Cookie` on the redirect);
19
+ the positional `status` always wins over any `init.status`.
17
20
  */
18
21
  import { NO_STORE } from '../shared/cacheControlValues.ts'
19
22
  import type { TypedResponse } from './rpc/types/TypedResponse.ts'
23
+ import { withResponseDefaults } from './runtime/withResponseDefaults.ts'
20
24
 
21
25
  type RedirectStatus = 301 | 302 | 303 | 307 | 308
22
26
 
@@ -26,12 +30,13 @@ the wire response is a 3xx with no body the caller resolves to, so it
26
30
  must not pollute the inferred `Return` of a route that conditionally
27
31
  redirects vs returns json.
28
32
  */
29
- export function redirect(url: string, status: RedirectStatus = 302): TypedResponse<never> {
30
- return new Response(null, {
31
- status,
32
- headers: {
33
- Location: url,
34
- 'Cache-Control': NO_STORE,
35
- },
36
- }) as TypedResponse<never>
33
+ export function redirect(
34
+ url: string,
35
+ status: RedirectStatus = 302,
36
+ init?: ResponseInit,
37
+ ): TypedResponse<never> {
38
+ return new Response(
39
+ null,
40
+ withResponseDefaults(init, { Location: url, 'Cache-Control': NO_STORE }, status),
41
+ ) as TypedResponse<never>
37
42
  }
@@ -77,9 +77,10 @@ export function defineVerb<Args, Return>(
77
77
 
78
78
  /*
79
79
  `getRequest` is unused on the server path — handlers receive parsed
80
- `args` directly. createRemoteFunction passes a thunk so the client
81
- side can lazily synthesize its Request without forcing the server
82
- to allocate one per SSR call.
80
+ `args` directly and reach the inbound Request via `request()`.
81
+ createRemoteFunction passes a thunk so the client side can lazily
82
+ synthesize its Request without forcing the server to allocate one per
83
+ SSR call.
83
84
  */
84
85
  function invoke(args: Args | undefined): Promise<Response> {
85
86
  return schema ? validateThenHandle(args) : runHandler(args)
@@ -0,0 +1,18 @@
1
+ import { commandNameForUrl } from '../../shared/commandNameForUrl.ts'
2
+ import type { VerbRegistryEntry } from './types/VerbRegistryEntry.ts'
3
+ import { verbRegistry } from './verbRegistry.ts'
4
+
5
+ /*
6
+ Finds the registered verb whose URL maps to a given command name (folder
7
+ segments joined with `-`, per commandNameForUrl). The CLI client proxy and
8
+ the MCP tool dispatcher both key off this name, so the scan lives here once
9
+ rather than being re-implemented — and reused — at each call site.
10
+ */
11
+ export function findVerbByCommandName(name: string): VerbRegistryEntry | undefined {
12
+ for (const entry of verbRegistry.values()) {
13
+ if (commandNameForUrl(entry.remote.url) === name) {
14
+ return entry
15
+ }
16
+ }
17
+ return undefined
18
+ }
@@ -19,7 +19,7 @@ stored entry — the decode just happens on the way out for callers of
19
19
  SSE / JSONL handlers yield each frame; non-streaming handlers yield the
20
20
  decoded body once then complete. The result is a Subscribable, so it
21
21
  can be passed to subscribe() and shared across reactive consumers.
22
- For sustained broadcast / pub-sub use the `belte/sockets` primitive —
22
+ For sustained broadcast / pub-sub use the `belte/server/socket` primitive —
23
23
  HTTP rpc isn't the place for long-lived multi-publisher subscriptions.
24
24
  `.fetch(req)` is the framework's request-dispatch entry point — used by
25
25
  the router to invoke the handler from an incoming HTTP request, not
@@ -16,6 +16,10 @@ verb helper can infer `Return` automatically — no need to annotate
16
16
  `GET<Args, Return>` when the handler returns one of the respond helpers.
17
17
  A bare `new Response(...)` is still acceptable: the brand is optional, so
18
18
  untagged Responses simply fall back to `Return = unknown`.
19
+
20
+ Handlers that need the inbound Request (headers, `request.signal`, …) read
21
+ it via `request()` from `belte/server` rather than a handler parameter, so
22
+ the signature stays a single parsed-`args` bag.
19
23
  */
20
24
  export type RemoteHandler<Args, Return> = (
21
25
  args: Args,
@@ -0,0 +1,8 @@
1
+ /*
2
+ Whether the client advertised zstd in Accept-Encoding. Both static-asset
3
+ servers (the `/_app/` chunk server and the public/ server) gate their
4
+ pre-compressed responses on this, so the check lives in one place.
5
+ */
6
+ export function acceptsZstd(req: Request): boolean {
7
+ return (req.headers.get('accept-encoding') ?? '').toLowerCase().includes('zstd')
8
+ }
@@ -41,8 +41,10 @@ export function buildOpenApiSpec(info: {
41
41
  const url = entry.remote.url
42
42
  const method = entry.remote.method
43
43
  const jsonSchema = jsonSchemaForSchema(entry.schema, entry.jsonSchema)
44
+ const description = jsonSchema.description as string | undefined
44
45
  const operation: Record<string, unknown> = {
45
46
  operationId: commandNameForUrl(url),
47
+ ...(description ? { description } : {}),
46
48
  responses: { '200': { description: 'OK' } },
47
49
  }
48
50
  if (BODY_METHODS.has(method)) {