@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
@@ -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
+ }
@@ -0,0 +1,168 @@
1
+ import { HttpError } from '../server/HttpError.ts'
2
+ import { decodeResponse } from './decodeResponse.ts'
3
+ import { jsonlErrorFrame } from './jsonlErrorFrame.ts'
4
+ import { sseErrorFrame } from './sseErrorFrame.ts'
5
+ import { STREAMING_CONTENT_TYPES } from './streamingContentTypes.ts'
6
+
7
+ /*
8
+ Turns a Response into an AsyncIterable of frames, regardless of the
9
+ handler's chosen body format. Shared by `fn.stream(args)` (via
10
+ subscribableFromResponse), the CLI's streaming print path, and the MCP
11
+ tool dispatcher's stream drain — so every surface consumes sse/jsonl
12
+ identically. Three shapes are handled:
13
+
14
+ - text/event-stream (SSE): emits the JSON-parsed `data:` payload of
15
+ each event. The `event: error\ndata: {message}` frame the `sse()`
16
+ helper emits on generator throws is mapped back to a thrown Error so
17
+ consumers see the failure mid-iteration.
18
+ - application/jsonl + application/x-ndjson: emits one JSON value per
19
+ line. The trailing `{"$error":"..."}` line the `jsonl()` helper
20
+ emits on generator throws is likewise re-thrown.
21
+ - everything else: one-shot — yields the Content-Type-decoded body
22
+ once, then completes. Lets callers iterate uniformly on every rpc
23
+ handler, not just the streaming ones.
24
+
25
+ Non-2xx responses surface as a thrown HttpError on the first pull,
26
+ mirroring the plain `fn(args)` decode path.
27
+ */
28
+ export function streamResponse<T>(response: Response): AsyncIterable<T> {
29
+ if (!response.ok) {
30
+ return errorIterable<T>(new HttpError(response))
31
+ }
32
+ const contentType = (response.headers.get('content-type') ?? '').toLowerCase()
33
+ if (contentType.startsWith('text/event-stream')) {
34
+ return parseSse<T>(response)
35
+ }
36
+ if (STREAMING_CONTENT_TYPES.some((type) => contentType.startsWith(type))) {
37
+ return parseJsonLines<T>(response)
38
+ }
39
+ return oneShot<T>(response)
40
+ }
41
+
42
+ /* Surfaces a non-2xx response (or any pre-stream failure) as a thrown error on the first pull. */
43
+ async function* errorIterable<T>(error: Error): AsyncGenerator<T> {
44
+ throw error
45
+ }
46
+
47
+ /*
48
+ One-shot iterator over a non-streaming Response: decodes the body once
49
+ via the same Content-Type sniffing the plain call uses, yields it, then
50
+ completes. Makes streaming symmetrical across streaming and
51
+ non-streaming handlers — callers can pick the iteration shape without
52
+ worrying about which body the handler returned.
53
+ */
54
+ async function* oneShot<T>(response: Response): AsyncGenerator<T> {
55
+ yield (await decodeResponse(response)) as T
56
+ }
57
+
58
+ /*
59
+ Reads a streaming text Response and yields raw frame strings split on
60
+ `delimiter` (`\n\n` for SSE events, `\n` for JSON lines). Owns the whole
61
+ buffering lifecycle: incremental decode, amortised-O(n) compaction, a
62
+ final flush of the trailing partial frame, and reader cancellation when
63
+ the consumer stops iterating (the generator's `finally` runs on
64
+ `return()`). The SSE and jsonl parsers layer their per-frame parsing on
65
+ top of this single machine so the two can't drift.
66
+ */
67
+ async function* frameReader(response: Response, delimiter: string): AsyncGenerator<string> {
68
+ const body = response.body
69
+ if (!body) {
70
+ return
71
+ }
72
+ const reader = body.pipeThrough(new TextDecoderStream()).getReader()
73
+ let buffer = ''
74
+ let bufferStart = 0
75
+ try {
76
+ while (true) {
77
+ const { value, done } = await reader.read()
78
+ if (done) {
79
+ if (bufferStart < buffer.length) {
80
+ yield buffer.slice(bufferStart)
81
+ }
82
+ return
83
+ }
84
+ /*
85
+ Compact only when the unread region is small relative to the
86
+ consumed prefix — keeps amortised work O(n) instead of
87
+ quadratic slicing per frame boundary.
88
+ */
89
+ if (bufferStart > buffer.length / 2) {
90
+ buffer = buffer.slice(bufferStart) + value
91
+ bufferStart = 0
92
+ } else {
93
+ buffer += value
94
+ }
95
+ let boundary = buffer.indexOf(delimiter, bufferStart)
96
+ while (boundary !== -1) {
97
+ yield buffer.slice(bufferStart, boundary)
98
+ bufferStart = boundary + delimiter.length
99
+ boundary = buffer.indexOf(delimiter, bufferStart)
100
+ }
101
+ }
102
+ } finally {
103
+ await reader.cancel().catch(() => undefined)
104
+ }
105
+ }
106
+
107
+ /*
108
+ SSE parser: yields the JSON-parsed `data` payload of each `event:`/`data:`
109
+ frame. The error sentinel (`sseErrorFrame`) the `sse()` helper emits on a
110
+ generator throw is surfaced as a thrown Error so consumer loops can react
111
+ to mid-stream failure rather than silently stopping.
112
+ */
113
+ async function* parseSse<T>(response: Response): AsyncGenerator<T> {
114
+ for await (const raw of frameReader(response, '\n\n')) {
115
+ const frame = parseFrame(raw)
116
+ if (!frame) {
117
+ continue
118
+ }
119
+ const errorMessage = sseErrorFrame.decode(frame.event, frame.data)
120
+ if (errorMessage !== undefined) {
121
+ throw new Error(errorMessage)
122
+ }
123
+ yield JSON.parse(frame.data) as T
124
+ }
125
+ }
126
+
127
+ function parseFrame(raw: string): { event: string; data: string } | undefined {
128
+ const lines = raw.split('\n').filter((line) => line.length > 0 && !line.startsWith(':'))
129
+ if (lines.length === 0) {
130
+ return undefined
131
+ }
132
+ let event = 'message'
133
+ const dataLines: string[] = []
134
+ for (const line of lines) {
135
+ const colon = line.indexOf(':')
136
+ const field = colon === -1 ? line : line.slice(0, colon)
137
+ const value = colon === -1 ? '' : line.slice(colon + 1).replace(/^ /, '')
138
+ if (field === 'event') {
139
+ event = value
140
+ } else if (field === 'data') {
141
+ dataLines.push(value)
142
+ }
143
+ }
144
+ if (dataLines.length === 0) {
145
+ return undefined
146
+ }
147
+ return { event, data: dataLines.join('\n') }
148
+ }
149
+
150
+ /*
151
+ JSONL/NDJSON parser: parses each non-empty line as JSON and yields the
152
+ value. The error sentinel (`jsonlErrorFrame`) the `jsonl()` helper emits as
153
+ a trailing line on a generator throw is surfaced here as a thrown Error so
154
+ consumer loops can react to mid-stream failure.
155
+ */
156
+ async function* parseJsonLines<T>(response: Response): AsyncGenerator<T> {
157
+ for await (const raw of frameReader(response, '\n')) {
158
+ if (raw.length === 0) {
159
+ continue
160
+ }
161
+ const parsed = JSON.parse(raw)
162
+ const errorMessage = jsonlErrorFrame.decode(parsed)
163
+ if (errorMessage !== undefined) {
164
+ throw new Error(errorMessage)
165
+ }
166
+ yield parsed as T
167
+ }
168
+ }