@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.
- package/package.json +1 -1
- package/src/bundleApp.ts +12 -2
- package/src/discoveryEntry.ts +58 -11
- package/src/lib/browser/cache.ts +29 -6
- package/src/lib/browser/startClient.ts +24 -1
- package/src/lib/bundle/onMenu.ts +20 -5
- package/src/lib/bundle/openWebview.ts +9 -2
- package/src/lib/bundle/signMacApp.ts +35 -0
- package/src/lib/cli/createClient.ts +65 -27
- package/src/lib/cli/runCli.ts +37 -15
- package/src/lib/cli/types/CliManifestEntry.ts +7 -2
- package/src/lib/mcp/annotationsForMethod.ts +29 -0
- package/src/lib/mcp/createMcpServer.ts +10 -8
- package/src/lib/mcp/dispatchMcpRequest.ts +115 -33
- package/src/lib/mcp/toolResultFromResponse.ts +66 -0
- package/src/lib/server/jsonl.ts +2 -1
- package/src/lib/server/rpc/defineVerb.ts +30 -17
- package/src/lib/server/rpc/parseArgs.ts +2 -1
- package/src/lib/server/rpc/types/VerbHelper.ts +19 -11
- package/src/lib/server/rpc/types/VerbRegistryEntry.ts +14 -6
- package/src/lib/server/runtime/buildOpenApiSpec.ts +17 -6
- package/src/lib/server/runtime/createPublicAssetServer.ts +17 -6
- package/src/lib/server/runtime/createServer.ts +37 -9
- package/src/lib/server/runtime/globToPathSet.ts +29 -0
- package/src/lib/server/runtime/logBrowserOnlyRoutes.ts +44 -0
- package/src/lib/server/sockets/createSocketDispatcher.ts +71 -8
- package/src/lib/server/sockets/defineSocket.ts +7 -1
- package/src/lib/server/sockets/recentHistory.ts +11 -0
- package/src/lib/server/sockets/socketOperations.ts +35 -0
- package/src/lib/server/sockets/types/SocketOperation.ts +22 -0
- package/src/lib/server/sse.ts +2 -1
- package/src/lib/shared/buildRpcRequest.ts +2 -1
- package/src/lib/shared/carriesBodyArgs.ts +13 -0
- package/src/lib/shared/isReadOnlyMethod.ts +14 -0
- package/src/lib/shared/isStreamingResponse.ts +11 -0
- package/src/lib/shared/jsonlErrorFrame.ts +24 -0
- package/src/lib/shared/keyForRemoteCall.ts +2 -1
- package/src/lib/shared/resolveClientFlags.ts +8 -6
- package/src/lib/shared/responseErrorText.ts +9 -0
- package/src/lib/shared/sseErrorFrame.ts +29 -0
- package/src/lib/shared/streamResponse.ts +168 -0
- package/src/lib/shared/subscribableFromResponse.ts +1 -172
- package/src/lib/shared/types/CacheEntry.ts +6 -0
- package/template/src/bundle/icon.png +0 -0
- package/template/src/server/rpc/getHello.ts +5 -3
- 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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/lib/server/sse.ts
CHANGED
|
@@ -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) =>
|
|
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
|
|
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
|
|
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)
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
13
|
+
defaults: { mcp: boolean; cli: boolean },
|
|
12
14
|
): ClientFlags {
|
|
13
15
|
return {
|
|
14
16
|
browser: flags?.browser ?? true,
|
|
15
|
-
mcp: flags?.mcp ??
|
|
16
|
-
cli: flags?.cli ??
|
|
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
|
+
}
|