@briancray/belte 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/package.json +1 -1
  2. package/src/bundleApp.ts +12 -2
  3. package/src/discoveryEntry.ts +58 -11
  4. package/src/lib/browser/cache.ts +29 -6
  5. package/src/lib/browser/startClient.ts +24 -1
  6. package/src/lib/bundle/disconnected.svelte +18 -19
  7. package/src/lib/bundle/onMenu.ts +20 -5
  8. package/src/lib/bundle/openWebview.ts +9 -2
  9. package/src/lib/bundle/signMacApp.ts +35 -0
  10. package/src/lib/cli/createClient.ts +65 -27
  11. package/src/lib/cli/runCli.ts +37 -15
  12. package/src/lib/cli/types/CliManifestEntry.ts +7 -2
  13. package/src/lib/mcp/annotationsForMethod.ts +29 -0
  14. package/src/lib/mcp/createMcpServer.ts +10 -8
  15. package/src/lib/mcp/dispatchMcpRequest.ts +115 -33
  16. package/src/lib/mcp/toolResultFromResponse.ts +66 -0
  17. package/src/lib/server/jsonl.ts +2 -1
  18. package/src/lib/server/rpc/defineVerb.ts +30 -17
  19. package/src/lib/server/rpc/parseArgs.ts +2 -1
  20. package/src/lib/server/rpc/types/VerbHelper.ts +19 -11
  21. package/src/lib/server/rpc/types/VerbRegistryEntry.ts +14 -6
  22. package/src/lib/server/runtime/buildOpenApiSpec.ts +17 -6
  23. package/src/lib/server/runtime/createPublicAssetServer.ts +17 -6
  24. package/src/lib/server/runtime/createServer.ts +37 -9
  25. package/src/lib/server/runtime/globToPathSet.ts +29 -0
  26. package/src/lib/server/runtime/logBrowserOnlyRoutes.ts +44 -0
  27. package/src/lib/server/sockets/createSocketDispatcher.ts +71 -8
  28. package/src/lib/server/sockets/defineSocket.ts +7 -1
  29. package/src/lib/server/sockets/recentHistory.ts +11 -0
  30. package/src/lib/server/sockets/socketOperations.ts +35 -0
  31. package/src/lib/server/sockets/types/SocketOperation.ts +22 -0
  32. package/src/lib/server/sse.ts +2 -1
  33. package/src/lib/shared/buildRpcRequest.ts +2 -1
  34. package/src/lib/shared/carriesBodyArgs.ts +13 -0
  35. package/src/lib/shared/isReadOnlyMethod.ts +14 -0
  36. package/src/lib/shared/isStreamingResponse.ts +11 -0
  37. package/src/lib/shared/jsonlErrorFrame.ts +24 -0
  38. package/src/lib/shared/keyForRemoteCall.ts +2 -1
  39. package/src/lib/shared/resolveClientFlags.ts +8 -6
  40. package/src/lib/shared/responseErrorText.ts +9 -0
  41. package/src/lib/shared/sseErrorFrame.ts +29 -0
  42. package/src/lib/shared/streamResponse.ts +168 -0
  43. package/src/lib/shared/subscribableFromResponse.ts +1 -172
  44. package/src/lib/shared/types/CacheEntry.ts +6 -0
  45. package/template/src/bundle/icon.png +0 -0
  46. package/template/src/server/rpc/getHello.ts +5 -3
  47. package/src/lib/shared/belteImportName.test.ts +0 -58
@@ -1,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
  }
@@ -0,0 +1,29 @@
1
+ import { Glob } from 'bun'
2
+
3
+ /*
4
+ Scans `cwd` for files matching `pattern` and returns their request paths as
5
+ a Set, mapping each relative file path to a root-relative URL via `keyFor`.
6
+ Used to snapshot the on-disk asset trees (the `public/` files, the `_app`
7
+ precompressed `.zst` siblings) once at boot so the request path is a Set
8
+ lookup instead of a filesystem stat.
9
+
10
+ A missing directory makes scan throw ENOENT — swallowed to an empty Set so
11
+ the caller just falls through. This scan-and-catch is also the reliable
12
+ directory existence test: `Bun.file(dir).exists()` returns false for a
13
+ directory, so guarding the scan with it silently yields an empty Set.
14
+ */
15
+ export async function globToPathSet(
16
+ cwd: string,
17
+ pattern: string,
18
+ keyFor: (file: string) => string,
19
+ options?: { dot?: boolean },
20
+ ): Promise<Set<string>> {
21
+ try {
22
+ const files = await Array.fromAsync(
23
+ new Glob(pattern).scan({ cwd, dot: options?.dot ?? false }),
24
+ )
25
+ return new Set(files.map(keyFor))
26
+ } catch {
27
+ return new Set()
28
+ }
29
+ }
@@ -0,0 +1,44 @@
1
+ import { commandNameForUrl } from '../../shared/commandNameForUrl.ts'
2
+ import { log } from '../../shared/log.ts'
3
+ import { verbRegistry } from '../rpc/verbRegistry.ts'
4
+ import { socketRegistry } from '../sockets/socketRegistry.ts'
5
+ import { ensureRegistriesLoaded } from './registryManifests.ts'
6
+
7
+ /*
8
+ Surfaces the otherwise-silent consequence of belte's multimodal-by-default
9
+ rule: a verb or socket with no schema never reaches MCP or the CLI, since
10
+ the schema is what makes those surfaces safe to advertise. Loads the full
11
+ registry, then logs (once at boot, only when `belte` debug logging is on
12
+ so it doesn't force eager imports in production) the routes that stay
13
+ browser-only purely for lack of a schema — so the missing matrix cells
14
+ are visible rather than surprising. Best-effort: enumeration failures are
15
+ swallowed since this is diagnostic only.
16
+ */
17
+ export async function logBrowserOnlyRoutes(): Promise<void> {
18
+ try {
19
+ await ensureRegistriesLoaded()
20
+ } catch {
21
+ return
22
+ }
23
+ const names: string[] = []
24
+ for (const entry of verbRegistry.values()) {
25
+ if (
26
+ entry.clients.browser &&
27
+ !entry.clients.mcp &&
28
+ !entry.clients.cli &&
29
+ !entry.inputSchema
30
+ ) {
31
+ names.push(commandNameForUrl(entry.remote.url))
32
+ }
33
+ }
34
+ for (const entry of socketRegistry.values()) {
35
+ if (entry.clients.browser && !entry.clients.mcp && !entry.clients.cli && !entry.schema) {
36
+ names.push(entry.socket.name)
37
+ }
38
+ }
39
+ if (names.length > 0) {
40
+ log.detail(
41
+ `browser-only (no schema → not on MCP/CLI): ${names.sort().join(', ')} — add a schema to expose them`,
42
+ )
43
+ }
44
+ }
@@ -1,6 +1,10 @@
1
1
  import type { ServerWebSocket } from 'bun'
2
2
  import { log } from '../../shared/log.ts'
3
+ import { error } from '../error.ts'
4
+ import { json } from '../json.ts'
5
+ import { sse } from '../sse.ts'
3
6
  import { lookupSocket } from './lookupSocket.ts'
7
+ import { recentHistory } from './recentHistory.ts'
4
8
  import type { SocketClientFrame } from './types/SocketClientFrame.ts'
5
9
  import type { SocketRoutes } from './types/SocketRoutes.ts'
6
10
  import type { SocketServerFrame } from './types/SocketServerFrame.ts'
@@ -12,6 +16,7 @@ type SocketDispatcher = {
12
16
  open(ws: ServerWebSocket<unknown>): void
13
17
  message(ws: ServerWebSocket<unknown>, data: string | Buffer): void
14
18
  close(ws: ServerWebSocket<unknown>): void
19
+ rest(req: Request, name: string): Promise<Response>
15
20
  }
16
21
 
17
22
  /*
@@ -154,14 +159,9 @@ export function createSocketDispatcher(sockets: SocketRoutes): SocketDispatcher
154
159
  a number is clamped to the buffer length so the client can ask
155
160
  for "as many as available, up to N".
156
161
  */
157
- const history = entry.snapshotHistory()
158
- const replayCount =
159
- frame.replay === undefined ? history.length : Math.min(frame.replay, history.length)
160
- if (replayCount > 0) {
161
- history.slice(history.length - replayCount).forEach((message) => {
162
- send(ws, { type: 'msg', socket: frame.socket, message })
163
- })
164
- }
162
+ recentHistory(entry, frame.replay).forEach((message) => {
163
+ send(ws, { type: 'msg', socket: frame.socket, message })
164
+ })
165
165
  }
166
166
 
167
167
  function handleUnsub(
@@ -225,7 +225,70 @@ export function createSocketDispatcher(sockets: SocketRoutes): SocketDispatcher
225
225
  void ws
226
226
  }
227
227
 
228
+ /*
229
+ HTTP face of the sockets hub at `/__belte/sockets/<name>`, for the CLI
230
+ and MCP (which can't speak the ws multiplex protocol):
231
+
232
+ GET text/event-stream → live SSE stream; `?tail=N` replays the last
233
+ N buffered messages before tailing live (default 0 = live only).
234
+ GET otherwise → JSON array of the recent history buffer
235
+ (`?tail=N` caps it; default all).
236
+ POST → publish the JSON body, gated by the socket's
237
+ clientPublish policy and validated against its schema.
238
+
239
+ Loads the socket module on first hit (same cache the ws path uses) so
240
+ its defineSocket call populates the registry.
241
+ */
242
+ async function rest(req: Request, name: string): Promise<Response> {
243
+ const loader = ensureLoaded(name)
244
+ if (!loader) {
245
+ return error(404)
246
+ }
247
+ try {
248
+ await loader
249
+ } catch (loadError) {
250
+ log.error(loadError)
251
+ return error(500, 'socket failed to load')
252
+ }
253
+ const entry = lookupSocket(name)
254
+ if (!entry) {
255
+ return error(404)
256
+ }
257
+ const tailParam = new URL(req.url).searchParams.get('tail')
258
+ const count = tailParam !== null ? Number(tailParam) : undefined
259
+ if (req.method === 'GET' || req.method === 'HEAD') {
260
+ if ((req.headers.get('accept') ?? '').includes('text/event-stream')) {
261
+ return sse(entry.socket.tail(count ?? 0))
262
+ }
263
+ return json(recentHistory(entry, count))
264
+ }
265
+ if (req.method === 'POST') {
266
+ if (!entry.allowClientPublish) {
267
+ return error(403, 'publishing not allowed')
268
+ }
269
+ let message: unknown
270
+ try {
271
+ message = await req.json()
272
+ } catch {
273
+ return error(400, 'body must be JSON')
274
+ }
275
+ try {
276
+ // publish() validates against the socket schema and throws on a bad payload.
277
+ entry.socket.publish(message)
278
+ } catch (publishError) {
279
+ return error(
280
+ 422,
281
+ publishError instanceof Error ? publishError.message : String(publishError),
282
+ )
283
+ }
284
+ return json({ ok: true })
285
+ }
286
+ return error(405, undefined, { headers: { Allow: 'GET, POST' } })
287
+ }
288
+
228
289
  return {
290
+ rest,
291
+
229
292
  open(ws) {
230
293
  connections.set(ws, { subToSocket: new Map(), socketSubs: new Map() })
231
294
  },
@@ -29,7 +29,13 @@ export function defineSocket<T>(name: string, opts: SocketOptions = {}): Socket<
29
29
  const ttl = opts.ttl
30
30
  const schema = opts.schema
31
31
  const jsonSchema = opts.jsonSchema
32
- const clients = resolveClientFlags(opts.clients, schema !== undefined)
32
+ /*
33
+ A schema makes the socket's payload safe to advertise to non-browser
34
+ surfaces, so it flips mcp/cli on by default — exposing the `tail` read
35
+ tool (and `publish` when clientPublish is set). Explicit `clients` wins.
36
+ */
37
+ const hasSchema = schema !== undefined
38
+ const clients = resolveClientFlags(opts.clients, { mcp: hasSchema, cli: hasSchema })
33
39
  type BufferEntry = { value: T; expiresAt: number | undefined }
34
40
  const buffer: BufferEntry[] = []
35
41
  const subscribers = new Set<(message: T) => void>()
@@ -0,0 +1,11 @@
1
+ import type { SocketRegistryEntry } from './types/SocketRegistryEntry.ts'
2
+
3
+ /*
4
+ Recent slice of a socket's history buffer: the last `count` messages, or
5
+ the whole buffer when `count` is undefined. Shared by the sockets HTTP
6
+ `rest()` face and the MCP `<base>-tail` tool so the two can't drift.
7
+ */
8
+ export function recentHistory(entry: SocketRegistryEntry, count: number | undefined): unknown[] {
9
+ const history = entry.snapshotHistory()
10
+ return count === undefined ? history : history.slice(Math.max(0, history.length - count))
11
+ }
@@ -0,0 +1,35 @@
1
+ import { commandNameForUrl } from '../../shared/commandNameForUrl.ts'
2
+ import type { SocketOperation } from './types/SocketOperation.ts'
3
+ import type { SocketRegistryEntry } from './types/SocketRegistryEntry.ts'
4
+
5
+ /*
6
+ Projects a socket registry entry into the operations it exposes to the
7
+ CLI and MCP. Single source for the naming convention (`<base>-tail` /
8
+ `<base>-publish`), the existence rule (tail always; publish only when the
9
+ socket allows client publishing), and each operation's HTTP face — so the
10
+ CLI manifest builder, the MCP tool list, and the MCP tool dispatcher can't
11
+ disagree about which operations a socket has or what they're called.
12
+ */
13
+ export function socketOperations(entry: SocketRegistryEntry): SocketOperation[] {
14
+ const base = commandNameForUrl(entry.socket.name)
15
+ const restUrl = `/__belte/sockets/${entry.socket.name}`
16
+ const operations: SocketOperation[] = [
17
+ {
18
+ kind: 'tail',
19
+ name: `${base}-tail`,
20
+ socketName: entry.socket.name,
21
+ restUrl,
22
+ method: 'GET',
23
+ },
24
+ ]
25
+ if (entry.allowClientPublish) {
26
+ operations.push({
27
+ kind: 'publish',
28
+ name: `${base}-publish`,
29
+ socketName: entry.socket.name,
30
+ restUrl,
31
+ method: 'POST',
32
+ })
33
+ }
34
+ return operations
35
+ }
@@ -0,0 +1,22 @@
1
+ import type { HttpVerb } from '../../rpc/types/HttpVerb.ts'
2
+
3
+ /*
4
+ One operation a socket exposes to the non-browser surfaces. A socket
5
+ always offers a `tail` (read recent / stream live) and, when
6
+ `clientPublish` is set, a `publish` (send a message). This is the shared
7
+ skeleton — name, kind, HTTP face — that the CLI manifest, the MCP tool
8
+ list, and the MCP dispatcher all read instead of re-deriving the naming
9
+ convention and existence rule independently. Each surface dresses it with
10
+ its own presentation (descriptions, input schema, annotations).
11
+ */
12
+ export type SocketOperation = {
13
+ kind: 'tail' | 'publish'
14
+ // Command/tool name: the socket's command-name base plus `-tail` / `-publish`.
15
+ name: string
16
+ // Raw socket name, for the HTTP path and human-facing descriptions.
17
+ socketName: string
18
+ // HTTP face of the operation: `/__belte/sockets/<name>`.
19
+ restUrl: string
20
+ // GET for tail, POST for publish.
21
+ method: HttpVerb
22
+ }
@@ -24,6 +24,7 @@ EventSource surfaces this via its `error` listener and `subscribe()`
24
24
  maps it to the entry's `error` field.
25
25
  */
26
26
  import { NO_STORE } from '../shared/cacheControlValues.ts'
27
+ import { sseErrorFrame } from '../shared/sseErrorFrame.ts'
27
28
  import type { TypedResponse } from './rpc/types/TypedResponse.ts'
28
29
  import { streamFromIterator } from './runtime/streamFromIterator.ts'
29
30
  import { withResponseDefaults } from './runtime/withResponseDefaults.ts'
@@ -36,7 +37,7 @@ export function sse<Frame>(
36
37
  ): TypedResponse<Frame> {
37
38
  const body = streamFromIterator(iterable, {
38
39
  encodeFrame: (value) => `data: ${JSON.stringify(value)}\n\n`,
39
- encodeError: (message) => `event: error\ndata: ${JSON.stringify({ message })}\n\n`,
40
+ encodeError: (message) => sseErrorFrame.encode(message),
40
41
  keepaliveMs: KEEPALIVE_INTERVAL_MS,
41
42
  keepalivePayload: ': keepalive\n\n',
42
43
  })
@@ -1,4 +1,5 @@
1
1
  import type { HttpVerb } from '../server/rpc/types/HttpVerb.ts'
2
+ import { carriesBodyArgs } from './carriesBodyArgs.ts'
2
3
 
3
4
  /*
4
5
  Builds the Request a verb helper uses to invoke its handler. Same shape on
@@ -27,7 +28,7 @@ export function buildRpcRequest({
27
28
  headers?: Headers
28
29
  }): Request {
29
30
  const requestHeaders = headers ?? new Headers()
30
- if (method === 'GET' || method === 'DELETE' || method === 'HEAD') {
31
+ if (!carriesBodyArgs(method)) {
31
32
  const target = appendQuery(method, url, args)
32
33
  return new Request(new URL(target, baseUrl).href, { method, headers: requestHeaders })
33
34
  }
@@ -0,0 +1,13 @@
1
+ import type { HttpVerb } from '../server/rpc/types/HttpVerb.ts'
2
+
3
+ /*
4
+ Whether a verb carries its args in the request body (POST/PUT/PATCH) vs
5
+ on the query string (GET/DELETE/HEAD). Single source for the split so the
6
+ synthesized Request (buildRpcRequest), the handler-side parse (parseArgs),
7
+ the cache key (keyForRemoteCall), and the OpenAPI doc can't disagree.
8
+ */
9
+ const BODY_METHODS = new Set<HttpVerb>(['POST', 'PUT', 'PATCH'])
10
+
11
+ export function carriesBodyArgs(method: HttpVerb): boolean {
12
+ return BODY_METHODS.has(method)
13
+ }
@@ -0,0 +1,14 @@
1
+ import type { HttpVerb } from '../server/rpc/types/HttpVerb.ts'
2
+
3
+ /*
4
+ Read-only (safe) HTTP methods — they don't mutate server state. Belte
5
+ uses this to decide which verbs auto-expose to MCP: reads flip MCP on
6
+ when a schema is present, mutations require an explicit `clients.mcp`
7
+ opt-in so a model can't delete/overwrite data just because the handler
8
+ carries a schema. Also feeds the MCP tool `readOnlyHint` annotation.
9
+ */
10
+ const READ_ONLY_METHODS = new Set<HttpVerb>(['GET', 'HEAD'])
11
+
12
+ export function isReadOnlyMethod(method: HttpVerb): boolean {
13
+ return READ_ONLY_METHODS.has(method)
14
+ }
@@ -0,0 +1,11 @@
1
+ import { STREAMING_CONTENT_TYPES } from './streamingContentTypes.ts'
2
+
3
+ /*
4
+ Whether a Response carries a streaming body (SSE / JSONL / NDJSON) by its
5
+ Content-Type, so callers drain it frame-by-frame instead of buffering.
6
+ Shared by the CLI print path and the MCP tool dispatcher.
7
+ */
8
+ export function isStreamingResponse(response: Response): boolean {
9
+ const contentType = (response.headers.get('content-type') ?? '').toLowerCase()
10
+ return STREAMING_CONTENT_TYPES.some((type) => contentType.startsWith(type))
11
+ }
@@ -0,0 +1,24 @@
1
+ /*
2
+ The in-band error sentinel for a JSONL/NDJSON stream: when a handler's
3
+ generator throws, `jsonl()` emits a final `{"$error":"<message>"}` line,
4
+ and `streamResponse` re-throws it on the consumer side. Encoder and decoder
5
+ live together here so the sentinel field has one definition and can't drift
6
+ between the two ends of the wire.
7
+ */
8
+ export const jsonlErrorFrame = {
9
+ // Error line for a thrown message, including the trailing newline.
10
+ encode(message: string): string {
11
+ return `${JSON.stringify({ $error: message })}\n`
12
+ },
13
+ // The message carried by a parsed line, or undefined when it isn't the error sentinel.
14
+ decode(parsed: unknown): string | undefined {
15
+ if (
16
+ parsed &&
17
+ typeof parsed === 'object' &&
18
+ typeof (parsed as { $error?: unknown }).$error === 'string'
19
+ ) {
20
+ return (parsed as { $error: string }).$error
21
+ }
22
+ return undefined
23
+ },
24
+ }
@@ -1,5 +1,6 @@
1
1
  import type { HttpVerb } from '../server/rpc/types/HttpVerb.ts'
2
2
  import { canonicalJson } from './canonicalJson.ts'
3
+ import { carriesBodyArgs } from './carriesBodyArgs.ts'
3
4
 
4
5
  /*
5
6
  Derives a cache key from a verb-defined remote function and its args. The
@@ -14,7 +15,7 @@ URLSearchParams).
14
15
  */
15
16
  export function keyForRemoteCall(method: HttpVerb, url: string, args: unknown): string {
16
17
  const prefix = `${method} ${url}`
17
- if (method === 'GET' || method === 'DELETE' || method === 'HEAD') {
18
+ if (!carriesBodyArgs(method)) {
18
19
  if (args && typeof args === 'object' && !Array.isArray(args)) {
19
20
  const record = args as Record<string, unknown>
20
21
  const keys = Object.keys(record).sort()
@@ -2,17 +2,19 @@ import type { ClientFlags } from './types/ClientFlags.ts'
2
2
 
3
3
  /*
4
4
  Fills in the missing keys of a user-supplied `clients` option. Browser
5
- defaults to true (the historical surface); mcp/cli default to true only
6
- when a schema is attached, since exposing an unvalidated handler as a
7
- tool / shell command is a foot-gun.
5
+ always defaults to true (the historical surface). The mcp/cli auto-on
6
+ defaults are decided by the caller and passed in, since the safe default
7
+ differs per declaration: a read-only verb may auto-expose to MCP while a
8
+ mutating one must not, and sockets gate differently again. Explicit
9
+ values in `flags` always win over the computed defaults.
8
10
  */
9
11
  export function resolveClientFlags(
10
12
  flags: Partial<ClientFlags> | undefined,
11
- hasSchema: boolean,
13
+ defaults: { mcp: boolean; cli: boolean },
12
14
  ): ClientFlags {
13
15
  return {
14
16
  browser: flags?.browser ?? true,
15
- mcp: flags?.mcp ?? hasSchema,
16
- cli: flags?.cli ?? hasSchema,
17
+ mcp: flags?.mcp ?? defaults.mcp,
18
+ cli: flags?.cli ?? defaults.cli,
17
19
  }
18
20
  }
@@ -0,0 +1,9 @@
1
+ /*
2
+ Human-readable message for a non-2xx Response: `status statusText: body`.
3
+ Shared by the CLI error path and the MCP tool result so the two frame a
4
+ failed request identically. Consumes the body, so call only on a response
5
+ you're done with.
6
+ */
7
+ export async function responseErrorText(response: Response): Promise<string> {
8
+ return `${response.status} ${response.statusText}: ${await response.text()}`
9
+ }
@@ -0,0 +1,29 @@
1
+ /*
2
+ The in-band error sentinel for an SSE stream: when a handler's generator
3
+ throws, `sse()` emits an `event: error` frame whose data is `{ message }`,
4
+ and `streamResponse` re-throws it on the consumer side. Encoder and decoder
5
+ live together here so the sentinel (event name + payload shape) has one
6
+ definition and can't drift between the two ends of the wire.
7
+ */
8
+ export const sseErrorFrame = {
9
+ // Full `event: error` frame for a thrown message, including the SSE delimiters.
10
+ encode(message: string): string {
11
+ return `event: error\ndata: ${JSON.stringify({ message })}\n\n`
12
+ },
13
+ /*
14
+ The message carried by an error frame, or undefined when `event` isn't
15
+ the error sentinel. Falls back to the raw data when it isn't the JSON
16
+ the encoder produces.
17
+ */
18
+ decode(event: string, data: string): string | undefined {
19
+ if (event !== 'error') {
20
+ return undefined
21
+ }
22
+ try {
23
+ const decoded = JSON.parse(data) as { message?: string }
24
+ return decoded?.message ?? 'sse stream error'
25
+ } catch {
26
+ return data || 'sse stream error'
27
+ }
28
+ },
29
+ }