@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@briancray/belte",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "Isomorphic multimodal HTTP framework built for humans and machines in a single Bun runtime",
6
6
  "license": "MIT",
package/src/bundleApp.ts CHANGED
@@ -4,6 +4,7 @@ import { ensureWebviewLib } from './lib/bundle/ensureWebviewLib.ts'
4
4
  import { infoPlist } from './lib/bundle/infoPlist.ts'
5
5
  import { pngToIcns } from './lib/bundle/pngToIcns.ts'
6
6
  import { serverBinaryFilename } from './lib/bundle/serverBinaryFilename.ts'
7
+ import { signMacApp } from './lib/bundle/signMacApp.ts'
7
8
  import { webviewLibName } from './lib/bundle/webviewLibName.ts'
8
9
  import { detectTarget } from './lib/shared/detectTarget.ts'
9
10
  import { exitOnBuildFailure } from './lib/shared/exitOnBuildFailure.ts'
@@ -17,8 +18,9 @@ const WORKER_ENTRY = new URL('./controlServerWorker.ts', import.meta.url).pathna
17
18
 
18
19
  /*
19
20
  Assembles a movable, self-contained app bundle for the host platform —
20
- no signing, no cross-compilation. Three pieces travel together so the app
21
- runs on another machine of the same OS with nothing installed:
21
+ no cross-compilation, and on macOS an ad-hoc seal so it launches on other
22
+ Macs (signMacApp). Three pieces travel together so the app runs on another
23
+ machine of the same OS with nothing installed:
22
24
 
23
25
  - the standalone server binary (`compile()`, assets embedded)
24
26
  - the launcher binary (appEntry — spawns the server, opens the webview)
@@ -106,6 +108,14 @@ export async function bundleApp({ cwd = process.cwd() }: { cwd?: string } = {}):
106
108
  `${bundleRoot}/Contents/Info.plist`,
107
109
  infoPlist({ name: programName, version, icon: hasIcon ? 'icon' : undefined }),
108
110
  )
111
+
112
+ // Seal the finished bundle so it launches on other Macs — must run last,
113
+ // after every binary, the lib, and Info.plist are in place.
114
+ await signMacApp(bundleRoot, [
115
+ `${libDir}/${webviewLibName()}`,
116
+ `${binDir}/${serverBinaryFilename()}`,
117
+ launcherPath,
118
+ ])
109
119
  }
110
120
 
111
121
  log.success(`bundled app: ${bundleRoot} (target: ${target})`)
@@ -2,7 +2,10 @@
2
2
  import { rpc } from './_virtual/rpc.ts'
3
3
  // @ts-expect-error virtual module resolved by belteResolverPlugin
4
4
  import { sockets } from './_virtual/sockets.ts'
5
+ import type { CliManifestEntry } from './lib/cli/types/CliManifestEntry.ts'
5
6
  import { verbRegistry } from './lib/server/rpc/verbRegistry.ts'
7
+ import { socketOperations } from './lib/server/sockets/socketOperations.ts'
8
+ import { socketRegistry } from './lib/server/sockets/socketRegistry.ts'
6
9
  import { commandNameForUrl } from './lib/shared/commandNameForUrl.ts'
7
10
  import { jsonSchemaForSchema } from './lib/shared/jsonSchemaForSchema.ts'
8
11
 
@@ -18,17 +21,61 @@ await Promise.all([
18
21
  ...Object.values(sockets).map((loader) => (loader as () => Promise<unknown>)()),
19
22
  ])
20
23
 
21
- const manifest = Object.fromEntries(
22
- Array.from(verbRegistry.values())
23
- .filter((entry) => entry.clients.cli)
24
- .map((entry) => [
25
- commandNameForUrl(entry.remote.url),
26
- {
27
- method: entry.remote.method,
28
- url: entry.remote.url,
29
- jsonSchema: jsonSchemaForSchema(entry.schema, entry.jsonSchema),
24
+ const manifest: Record<string, CliManifestEntry> = {}
25
+
26
+ for (const entry of verbRegistry.values()) {
27
+ if (!entry.clients.cli) {
28
+ continue
29
+ }
30
+ manifest[commandNameForUrl(entry.remote.url)] = {
31
+ method: entry.remote.method,
32
+ url: entry.remote.url,
33
+ jsonSchema: jsonSchemaForSchema(entry.inputSchema, entry.inputJsonSchema),
34
+ }
35
+ }
36
+
37
+ /*
38
+ Sockets advertised to the CLI become commands against the socket's HTTP
39
+ face (see socketOperations): `<base>-tail` streams live (GET +
40
+ text/event-stream, with an optional --tail N to replay recent history
41
+ first) and, when clientPublish is set, `<base>-publish` sends the args bag
42
+ as a message (POST).
43
+ */
44
+ for (const entry of socketRegistry.values()) {
45
+ if (!entry.clients.cli) {
46
+ continue
47
+ }
48
+ for (const operation of socketOperations(entry)) {
49
+ if (operation.kind === 'tail') {
50
+ manifest[operation.name] = {
51
+ method: operation.method,
52
+ url: operation.restUrl,
53
+ accept: 'text/event-stream',
54
+ jsonSchema: {
55
+ type: 'object',
56
+ description: `tail the "${operation.socketName}" socket`,
57
+ properties: {
58
+ tail: {
59
+ type: 'number',
60
+ description: 'replay last N messages before tailing live',
61
+ },
62
+ },
63
+ },
64
+ }
65
+ continue
66
+ }
67
+ const payloadSchema = jsonSchemaForSchema(entry.schema, entry.jsonSchema)
68
+ manifest[operation.name] = {
69
+ method: operation.method,
70
+ url: operation.restUrl,
71
+ jsonSchema: {
72
+ ...payloadSchema,
73
+ description:
74
+ (payloadSchema.description as string | undefined) ??
75
+ `publish a message to the "${operation.socketName}" socket`,
30
76
  },
31
- ]),
32
- )
77
+ }
78
+ }
79
+ }
33
80
 
34
81
  process.stdout.write(JSON.stringify(manifest))
@@ -5,6 +5,7 @@ import { canonicalJson } from '../shared/canonicalJson.ts'
5
5
  import { decodeResponse } from '../shared/decodeResponse.ts'
6
6
  import { getRemoteMeta } from '../shared/getRemoteMeta.ts'
7
7
  import { keyForRemoteCall } from '../shared/keyForRemoteCall.ts'
8
+ import type { CacheEntry } from '../shared/types/CacheEntry.ts'
8
9
  import type { CacheOptions } from '../shared/types/CacheOptions.ts'
9
10
 
10
11
  type AnyRemote<Args, Return> = RemoteFunction<Args, Return> | RawRemoteFunction<Args>
@@ -46,7 +47,7 @@ export function cache<Args>(
46
47
  export function cache<Args, Return>(
47
48
  fn: AnyRemote<Args, Return>,
48
49
  options?: CacheOptions,
49
- ): (args?: Args) => Promise<Return | Response> {
50
+ ): (args?: Args) => Promise<Return | Response> | Return {
50
51
  /*
51
52
  The "raw" variant lacks its own `.raw` sibling; only the decoded
52
53
  callable carries one. Tell them apart by that presence and dispatch the
@@ -55,20 +56,42 @@ export function cache<Args, Return>(
55
56
  const isRaw = !('raw' in fn)
56
57
  const rawFn = isRaw ? (fn as RawRemoteFunction<Args>) : (fn as RemoteFunction<Args, Return>).raw
57
58
  return (args) => {
58
- const responsePromise = invokeWithCache(rawFn, args, options)
59
+ const store = activeCacheStore()
60
+ const key = resolveKey(rawFn, args, options?.key)
61
+ store.subscribe(key)
62
+ const existing = store.entries.get(key)
63
+ /*
64
+ Snapshot warm path: hydration pre-decoded the SSR body onto the
65
+ entry, so the decoded variant returns it synchronously — the first
66
+ {#await} render resolves without a microtask suspension and matches
67
+ the SSR DOM. Raw callers always take the Response path. After an
68
+ invalidate the replacement entry carries no value and falls through
69
+ to the async fetch as before.
70
+
71
+ The public overload stays typed Promise<Return> on purpose: a
72
+ non-thenable is the only thing {#await} can render synchronously, so
73
+ the sync return is left as an internal optimization rather than
74
+ widened to `Return | Promise<Return>` (which would leak it into every
75
+ caller's types). The one cost is that `.then`/`.catch`/`.finally`
76
+ directly on a warm result throws — consume cache via `await`/`{#await}`,
77
+ never `.then`. Don't "fix" the type; see memory cache-warm-sync-tradeoff.
78
+ */
79
+ if (!isRaw && existing?.value !== undefined) {
80
+ return existing.value as Return
81
+ }
82
+ const responsePromise = invokeWithCache(store, key, existing, rawFn, args, options)
59
83
  return isRaw ? responsePromise : (responsePromise.then(decodeResponse) as Promise<Return>)
60
84
  }
61
85
  }
62
86
 
63
87
  function invokeWithCache<Args>(
88
+ store: ReturnType<typeof activeCacheStore>,
89
+ key: string,
90
+ existing: CacheEntry | undefined,
64
91
  rawFn: RawRemoteFunction<Args>,
65
92
  args: Args | undefined,
66
93
  options: CacheOptions | undefined,
67
94
  ): Promise<Response> {
68
- const store = activeCacheStore()
69
- const key = resolveKey(rawFn, args, options?.key)
70
- store.subscribe(key)
71
- const existing = store.entries.get(key)
72
95
  if (existing) {
73
96
  return shareable(existing.promise)
74
97
  }
@@ -25,10 +25,11 @@ pass finds the data via cache() without issuing a network round-trip.
25
25
  */
26
26
  function hydrateCacheFromSnapshot(store: CacheStore, snapshot: CacheSnapshotEntry[]): void {
27
27
  for (const entry of snapshot) {
28
+ const headers = new Headers(entry.headers)
28
29
  const response = new Response(entry.body, {
29
30
  status: entry.status,
30
31
  statusText: entry.statusText,
31
- headers: new Headers(entry.headers),
32
+ headers,
32
33
  })
33
34
  store.entries.set(entry.key, {
34
35
  key: entry.key,
@@ -36,10 +37,32 @@ function hydrateCacheFromSnapshot(store: CacheStore, snapshot: CacheSnapshotEntr
36
37
  request: new Request(entry.url, { method: entry.method }),
37
38
  ttl: undefined,
38
39
  expiresAt: undefined,
40
+ value: warmValueFromSnapshot(entry.status, headers, entry.body),
39
41
  })
40
42
  }
41
43
  }
42
44
 
45
+ /*
46
+ Synchronously decodes a snapshot body so the warm entry reads without a
47
+ microtask hop on first render. Mirrors decodeResponse for the textual cases
48
+ the snapshot ships; non-2xx and 204 yield no warm value and fall back to the
49
+ async path, which throws HttpError / returns undefined exactly as a live call
50
+ would. Binary/xml bodies also skip the warm path and decode asynchronously.
51
+ */
52
+ function warmValueFromSnapshot(status: number, headers: Headers, body: string): unknown {
53
+ if (status === 204 || status < 200 || status >= 300) {
54
+ return undefined
55
+ }
56
+ const contentType = (headers.get('content-type') ?? '').toLowerCase()
57
+ if (contentType.includes('json')) {
58
+ return JSON.parse(body)
59
+ }
60
+ if (contentType.startsWith('text/')) {
61
+ return body
62
+ }
63
+ return undefined
64
+ }
65
+
43
66
  function isInternalLinkEvent(event: MouseEvent): HTMLAnchorElement | undefined {
44
67
  if (event.defaultPrevented) {
45
68
  return undefined
@@ -1,25 +1,40 @@
1
1
  /*
2
2
  Subscribes to bundle menu clicks. Each custom menu item declared in the bundle
3
- window config dispatches a `belte:menu` CustomEvent into the page when clicked;
4
- this registers `handler`, called with the item's `emit` name. Returns an
5
- unsubscribe function, so it drops straight into a Svelte `$effect`:
3
+ window config dispatches a `belte:menu` CustomEvent into the page when clicked.
4
+ Two forms, both returning an unsubscribe so they drop straight into a Svelte
5
+ `$effect`:
6
6
 
7
+ // catch-all — every emit name flows through one handler
7
8
  $effect(() =>
8
9
  onMenu((name) => {
9
10
  if (name === 'reload') location.reload()
10
11
  }),
11
12
  )
12
13
 
14
+ // filtered — handler fires only for the named item
15
+ $effect(() => onMenu('reload', () => location.reload()))
16
+
13
17
  Inert during SSR and in a plain browser tab — `$effect` only runs client-side,
14
18
  the native menu that fires the event exists only in the bundled desktop app,
15
19
  and `window` is guarded so importing the module never assumes a DOM.
16
20
  */
17
- export function onMenu(handler: (name: string) => void): () => void {
21
+ export function onMenu(handler: (name: string) => void): () => void
22
+ export function onMenu(name: string, handler: () => void): () => void
23
+ export function onMenu(
24
+ nameOrHandler: string | ((name: string) => void),
25
+ maybeHandler?: () => void,
26
+ ): () => void {
18
27
  if (typeof window === 'undefined') {
19
28
  return () => {}
20
29
  }
30
+ // String first arg = filter to that emit name; otherwise a catch-all handler.
31
+ const filter = typeof nameOrHandler === 'string' ? nameOrHandler : undefined
32
+ const handler = typeof nameOrHandler === 'string' ? maybeHandler : nameOrHandler
21
33
  function listener(event: Event) {
22
- handler((event as CustomEvent<{ name: string }>).detail.name)
34
+ const name = (event as CustomEvent<{ name: string }>).detail.name
35
+ if (filter === undefined || filter === name) {
36
+ handler?.(name)
37
+ }
23
38
  }
24
39
  window.addEventListener('belte:menu', listener)
25
40
  return () => window.removeEventListener('belte:menu', listener)
@@ -62,8 +62,15 @@ export async function openWebview({
62
62
  webview_destroy: { args: [FFIType.ptr], returns: FFIType.void },
63
63
  })
64
64
 
65
- // The second arg is an optional parent window handle; null means a fresh window.
66
- const handle = symbols.webview_create(0, null)
65
+ /*
66
+ First arg is the webview's `debug` flag: 1 enables the native inspector
67
+ (WKWebView's Web Inspector, WebView2 DevTools, WebKitGTK inspector) so a JS
68
+ error on the loaded page — otherwise silent in a bare bundle window — can be
69
+ read via right-click → Inspect. Gated behind BELTE_INSPECT so release bundles
70
+ ship without it. The second arg is an optional parent handle; null = fresh window.
71
+ */
72
+ const debug = process.env.BELTE_INSPECT ? 1 : 0
73
+ const handle = symbols.webview_create(debug, null)
67
74
  symbols.webview_set_title(handle, cString(title))
68
75
  symbols.webview_set_size(handle, width, height, WEBVIEW_HINT_NONE)
69
76
  /*
@@ -0,0 +1,35 @@
1
+ import { log } from '../shared/log.ts'
2
+
3
+ /*
4
+ Ad-hoc code-signs an assembled macOS `.app` so it launches on other Macs.
5
+
6
+ Apple Silicon mandates a valid code signature for every executable. `bun
7
+ build --compile` emits an ad-hoc, linker-signed binary, but assembling the
8
+ `.app` around it (writing Info.plist, dropping in the lib) leaves the bundle
9
+ unsealed — `codesign --verify` then reports the signature as modified, and a
10
+ copy that picks up a quarantine flag (AirDrop, USB, download) gets silently
11
+ killed by Gatekeeper/AMFI: the icon bounces once and nothing opens.
12
+
13
+ Re-signing inside-out fixes that. Nested Mach-O code (the webview dylib, the
14
+ embedded server binary, the launcher) is signed first, then the bundle as a
15
+ whole, which seals Resources and binds Info.plist. The identity is `-`,
16
+ ad-hoc: no certificate, no Developer account, no network — as far as signing
17
+ goes without a paid Developer ID. Recipients copying a quarantined bundle
18
+ still need `xattr -cr <app>` once, but the app no longer fails to launch.
19
+
20
+ Best-effort: if `codesign` is missing or fails, warn and return rather than
21
+ abort the bundle, which is otherwise complete and usable on the build host.
22
+ */
23
+ export async function signMacApp(bundleRoot: string, innerPaths: string[]): Promise<void> {
24
+ try {
25
+ // Inner Mach-O code inside-out, then the bundle, which re-signs the
26
+ // main executable as part of sealing — order matters for nested seals.
27
+ for (const path of innerPaths) {
28
+ await Bun.$`codesign --force --sign - ${path}`.quiet()
29
+ }
30
+ await Bun.$`codesign --force --sign - ${bundleRoot}`.quiet()
31
+ } catch (error) {
32
+ log.warn(`could not code-sign ${bundleRoot} — it may not launch when copied to another Mac`)
33
+ log.error(error)
34
+ }
35
+ }
@@ -5,7 +5,21 @@ import { buildRpcRequest } from '../shared/buildRpcRequest.ts'
5
5
  import { decodeResponse } from '../shared/decodeResponse.ts'
6
6
  import type { CliManifest } from './types/CliManifest.ts'
7
7
 
8
- type AnyApi = Record<string, (args?: unknown) => Promise<unknown>>
8
+ /*
9
+ Each property of the client is a callable: invoking it decodes the body
10
+ (plain call), while `.raw(args)` returns the underlying Response without
11
+ decoding or throwing on non-2xx — the escape hatch the CLI uses to sniff
12
+ the Content-Type and stream sse/jsonl bodies frame-by-frame instead of
13
+ buffering through decodeResponse.
14
+ */
15
+ type ClientInvoker = ((args?: unknown) => Promise<unknown>) & {
16
+ raw: (args?: unknown) => Promise<Response>
17
+ }
18
+
19
+ type AnyApi = Record<string, ClientInvoker>
20
+
21
+ // A command resolved to its HTTP shape — the manifest/registry lookup result.
22
+ type ResolvedCommand = { method: HttpVerb; url: string; accept?: string }
9
23
 
10
24
  /*
11
25
  Builds a typed proxy over the project's RPCs for use in scripts, tests,
@@ -42,35 +56,56 @@ export function createClient<Api extends AnyApi = AnyApi>(opts?: {
42
56
  baked-in source of truth); registry is the in-process fallback for
43
57
  use in same-project code where defineVerb has run.
44
58
  */
45
- function resolve(name: string): { method: HttpVerb; url: string } | undefined {
59
+ function resolve(name: string): ResolvedCommand | undefined {
46
60
  const entry = manifest?.[name]
47
61
  if (entry) {
48
- return { method: entry.method, url: entry.url }
62
+ return { method: entry.method, url: entry.url, accept: entry.accept }
49
63
  }
50
64
  const found = findVerbByCommandName(name)
51
65
  return found ? { method: found.remote.method, url: found.remote.url } : undefined
52
66
  }
53
67
 
54
68
  /*
55
- Single call path for both modes — only the base URL and how the Request
56
- is dispatched differ. Remote mode fetches over the network; in-process
57
- mode looks the verb up in the registry and runs verb.fetch (no hop).
69
+ Single dispatch path for both modes — only the base URL and how the
70
+ Request is sent differ. Remote mode fetches over the network;
71
+ in-process mode looks the verb up in the registry and runs verb.fetch
72
+ (no hop). Returns the raw Response; callers decode or stream it.
58
73
  */
59
- async function call(
60
- method: HttpVerb,
61
- path: string,
74
+ function send(
75
+ resolved: ResolvedCommand,
62
76
  args: unknown,
63
77
  baseUrl: string,
64
78
  dispatch: (request: Request) => Promise<Response>,
65
- ): Promise<unknown> {
79
+ ): Promise<Response> {
66
80
  const headers = new Headers()
67
81
  if (token) {
68
82
  headers.set('authorization', `Bearer ${token}`)
69
83
  }
70
- const request = buildRpcRequest({ method, url: path, args, baseUrl, headers })
71
- const response = await dispatch(request)
84
+ if (resolved.accept) {
85
+ headers.set('accept', resolved.accept)
86
+ }
87
+ const request = buildRpcRequest({
88
+ method: resolved.method,
89
+ url: resolved.url,
90
+ args,
91
+ baseUrl,
92
+ headers,
93
+ })
94
+ return dispatch(request)
95
+ }
96
+
97
+ // Decoding plain-call path: throws on non-2xx, returns the decoded body.
98
+ async function call(
99
+ resolved: ResolvedCommand,
100
+ args: unknown,
101
+ baseUrl: string,
102
+ dispatch: (request: Request) => Promise<Response>,
103
+ ): Promise<unknown> {
104
+ const response = await send(resolved, args, baseUrl, dispatch)
72
105
  if (!response.ok) {
73
- throw new Error(`${method} ${path} failed: ${response.status} ${response.statusText}`)
106
+ throw new Error(
107
+ `${resolved.method} ${resolved.url} failed: ${response.status} ${response.statusText}`,
108
+ )
74
109
  }
75
110
  return decodeResponse(response)
76
111
  }
@@ -94,10 +129,24 @@ export function createClient<Api extends AnyApi = AnyApi>(opts?: {
94
129
  manifest + registry are fixed for a client's lifetime, so a resolved
95
130
  invoker (or its absence) never changes.
96
131
  */
97
- const invokerCache = new Map<string, ((args?: unknown) => Promise<unknown>) | undefined>()
132
+ const invokerCache = new Map<string, ClientInvoker | undefined>()
133
+
134
+ /*
135
+ Build a memoised invoker for a resolved command. The plain call and
136
+ `.raw` share one dispatch — remote mode hits the network, in-process
137
+ mode runs verb.fetch — so the two can't diverge on URL/headers.
138
+ */
139
+ function buildInvoker(resolved: ResolvedCommand): ClientInvoker {
140
+ const baseUrl = url ?? 'http://localhost/'
141
+ const dispatch = url ? fetch : inProcessDispatch(resolved.url)
142
+ const invoker = ((args?: unknown) =>
143
+ call(resolved, args, baseUrl, dispatch)) as ClientInvoker
144
+ invoker.raw = (args?: unknown) => send(resolved, args, baseUrl, dispatch)
145
+ return invoker
146
+ }
98
147
 
99
148
  return new Proxy({} as Api, {
100
- get(_target, prop): ((args?: unknown) => Promise<unknown>) | undefined {
149
+ get(_target, prop): ClientInvoker | undefined {
101
150
  if (typeof prop !== 'string') {
102
151
  return undefined
103
152
  }
@@ -105,18 +154,7 @@ export function createClient<Api extends AnyApi = AnyApi>(opts?: {
105
154
  return invokerCache.get(prop)
106
155
  }
107
156
  const resolved = resolve(prop)
108
- const invoker = resolved
109
- ? (args?: unknown) =>
110
- url
111
- ? call(resolved.method, resolved.url, args, url, fetch)
112
- : call(
113
- resolved.method,
114
- resolved.url,
115
- args,
116
- 'http://localhost/',
117
- inProcessDispatch(resolved.url),
118
- )
119
- : undefined
157
+ const invoker = resolved ? buildInvoker(resolved) : undefined
120
158
  invokerCache.set(prop, invoker)
121
159
  return invoker
122
160
  },
@@ -1,9 +1,24 @@
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'
1
5
  import { createClient } from './createClient.ts'
2
6
  import { loadEnvFromBinaryDir } from './loadEnvFromBinaryDir.ts'
3
7
  import { parseArgvForRpc } from './parseArgvForRpc.ts'
4
8
  import { printCommandHelp, printTopLevelHelp } from './printHelp.ts'
5
9
  import type { CliManifest } from './types/CliManifest.ts'
6
10
 
11
+ // String results print verbatim (with a trailing newline); everything else as a JSON line.
12
+ function printValue(value: unknown, pretty: boolean): void {
13
+ if (typeof value === 'string') {
14
+ process.stdout.write(value.endsWith('\n') ? value : `${value}\n`)
15
+ return
16
+ }
17
+ if (value !== undefined) {
18
+ process.stdout.write(`${JSON.stringify(value, null, pretty ? 2 : undefined)}\n`)
19
+ }
20
+ }
21
+
7
22
  /*
8
23
  Top-level CLI driver. Loaded by the standalone binary's entry; expects
9
24
  the bundler-emitted manifest plus the raw argv tail. The binary is a
@@ -18,9 +33,10 @@ running server over HTTP and APP_URL must be set. Flow:
18
33
  5. Otherwise parse the rest of the argv against the manifest entry's
19
34
  JSON Schema and dispatch via createClient against APP_URL.
20
35
 
21
- Streaming responses aren't a thing at this layer yet — every RPC tool
22
- goes through decodeResponse (text/JSON). Streaming verbs (jsonl/sse)
23
- will be added when the CLI grows watch/publish subcommands for sockets.
36
+ Streaming responses are handled by sniffing the response Content-Type:
37
+ sse/jsonl bodies (a streaming verb, or a socket `tail` command) are
38
+ printed frame-by-frame as NDJSON to stdout; everything else is decoded
39
+ and pretty-printed once.
24
40
  */
25
41
  export async function runCli({
26
42
  programName,
@@ -74,21 +90,27 @@ export async function runCli({
74
90
  const appToken = process.env.APP_TOKEN
75
91
  const client = createClient({ url: appUrl, token: appToken, manifest })
76
92
 
93
+ const fn = client[first]
94
+ if (!fn) {
95
+ console.error(`${programName}: command "${first}" not in client`)
96
+ return 1
97
+ }
77
98
  try {
78
- const fn = (client as Record<string, (args?: unknown) => Promise<unknown>>)[first]
79
- if (!fn) {
80
- console.error(`${programName}: command "${first}" not in client`)
81
- return 1
82
- }
83
- const result = await fn(args)
84
- if (typeof result === 'string') {
85
- process.stdout.write(result)
86
- if (!result.endsWith('\n')) {
87
- process.stdout.write('\n')
99
+ const response = await fn.raw(args)
100
+ if (isStreamingResponse(response)) {
101
+ /*
102
+ Stream frame-by-frame to stdout as NDJSON. streamResponse
103
+ throws a clear HttpError on a non-2xx body, caught below.
104
+ */
105
+ for await (const frame of streamResponse(response)) {
106
+ printValue(frame, false)
88
107
  }
89
- } else if (result !== undefined) {
90
- process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)
108
+ return 0
109
+ }
110
+ if (!response.ok) {
111
+ throw new Error(await responseErrorText(response))
91
112
  }
113
+ printValue(await decodeResponse(response), true)
92
114
  return 0
93
115
  } catch (error) {
94
116
  console.error(`${programName}: ${error instanceof Error ? error.message : String(error)}`)
@@ -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)