@briancray/belte 0.3.1 → 0.5.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 (61) hide show
  1. package/bin/belte.ts +22 -13
  2. package/package.json +1 -1
  3. package/src/appEntry.ts +24 -8
  4. package/src/buildDisconnected.ts +3 -0
  5. package/src/bundleApp.ts +24 -2
  6. package/src/controlServerWorker.ts +205 -7
  7. package/src/discoveryEntry.ts +58 -11
  8. package/src/lib/browser/cache.ts +29 -6
  9. package/src/lib/browser/startClient.ts +24 -1
  10. package/src/lib/bundle/BundleWindow.ts +11 -0
  11. package/src/lib/bundle/disconnected.svelte +238 -42
  12. package/src/lib/bundle/onMenu.ts +20 -5
  13. package/src/lib/bundle/openWebview.ts +9 -2
  14. package/src/lib/bundle/signMacApp.ts +35 -0
  15. package/src/lib/cli/createClient.ts +65 -27
  16. package/src/lib/cli/loadEnvFromBinaryDir.ts +8 -38
  17. package/src/lib/cli/runCli.ts +37 -15
  18. package/src/lib/cli/types/CliManifestEntry.ts +7 -2
  19. package/src/lib/mcp/annotationsForMethod.ts +29 -0
  20. package/src/lib/mcp/createMcpServer.ts +10 -8
  21. package/src/lib/mcp/dispatchMcpRequest.ts +115 -33
  22. package/src/lib/mcp/toolResultFromResponse.ts +66 -0
  23. package/src/lib/server/jsonl.ts +2 -1
  24. package/src/lib/server/rpc/defineVerb.ts +30 -17
  25. package/src/lib/server/rpc/parseArgs.ts +2 -1
  26. package/src/lib/server/rpc/types/VerbHelper.ts +19 -11
  27. package/src/lib/server/rpc/types/VerbRegistryEntry.ts +14 -6
  28. package/src/lib/server/runtime/buildOpenApiSpec.ts +17 -6
  29. package/src/lib/server/runtime/createPublicAssetServer.ts +17 -6
  30. package/src/lib/server/runtime/createServer.ts +57 -21
  31. package/src/lib/server/runtime/globToPathSet.ts +29 -0
  32. package/src/lib/server/runtime/logBrowserOnlyRoutes.ts +44 -0
  33. package/src/lib/server/runtime/parsePort.ts +16 -0
  34. package/src/lib/server/sockets/createSocketDispatcher.ts +71 -8
  35. package/src/lib/server/sockets/defineSocket.ts +7 -1
  36. package/src/lib/server/sockets/recentHistory.ts +11 -0
  37. package/src/lib/server/sockets/socketOperations.ts +35 -0
  38. package/src/lib/server/sockets/types/SocketOperation.ts +22 -0
  39. package/src/lib/server/sse.ts +2 -1
  40. package/src/lib/shared/appDataDir.ts +22 -0
  41. package/src/lib/shared/buildRpcRequest.ts +2 -1
  42. package/src/lib/shared/carriesBodyArgs.ts +13 -0
  43. package/src/lib/shared/isReadOnlyMethod.ts +14 -0
  44. package/src/lib/shared/isStreamingResponse.ts +11 -0
  45. package/src/lib/shared/jsonlErrorFrame.ts +24 -0
  46. package/src/lib/shared/keyForRemoteCall.ts +2 -1
  47. package/src/lib/shared/loadEnvFile.ts +17 -0
  48. package/src/lib/shared/loadEnvFromDataDir.ts +15 -0
  49. package/src/lib/shared/parseEnv.ts +30 -0
  50. package/src/lib/shared/readEnvFile.ts +15 -0
  51. package/src/lib/shared/resolveClientFlags.ts +8 -6
  52. package/src/lib/shared/responseErrorText.ts +9 -0
  53. package/src/lib/shared/serializeEnv.ts +18 -0
  54. package/src/lib/shared/sseErrorFrame.ts +29 -0
  55. package/src/lib/shared/streamResponse.ts +168 -0
  56. package/src/lib/shared/subscribableFromResponse.ts +1 -172
  57. package/src/lib/shared/types/CacheEntry.ts +6 -0
  58. package/src/serverEntry.ts +12 -0
  59. package/template/src/bundle/icon.png +0 -0
  60. package/template/src/server/rpc/getHello.ts +5 -3
  61. package/src/lib/shared/belteImportName.test.ts +0 -58
@@ -6,11 +6,15 @@ import type { StandardSchemaV1 } from './StandardSchemaV1.ts'
6
6
  /*
7
7
  Shared signature for every verb helper (GET / POST / …). Three overloads:
8
8
 
9
- - `Verb(fn, { schema, clients? })` — `Args` infers from
10
- `InferInput<Schema>`, the handler receives `InferOutput<Schema>`.
11
- Generic order is `<Return, Schema>` so users can override `Return`
12
- while letting `Schema` infer from `opts.schema`. `clients` controls
13
- which surfaces (browser / mcp / cli) expose this verb.
9
+ - `Verb(fn, { inputSchema, outputSchema?, clients? })` — `Args` infers
10
+ from `InferInput<InputSchema>`, the handler receives
11
+ `InferOutput<InputSchema>`. Generic order is `<Return, InputSchema>` so
12
+ users can override `Return` while letting `InputSchema` infer from
13
+ `opts.inputSchema`. `outputSchema` is an optional Standard Schema for
14
+ the success body — it feeds the OpenAPI 200 response and the MCP tool
15
+ `outputSchema`. `inputJsonSchema` / `outputJsonSchema` are optional
16
+ precomputed JSON Schema overrides. `clients` controls which surfaces
17
+ (browser / mcp / cli) expose this verb.
14
18
  - `Verb(fn, { clients })` — schemaless but with explicit client
15
19
  targeting (e.g. server-internal RPC with `clients: { browser: false }`).
16
20
  - `Verb(fn)` — bare handler. `Args` and `Return` come from the handler
@@ -18,18 +22,22 @@ Shared signature for every verb helper (GET / POST / …). Three overloads:
18
22
  `json`/`error`/`redirect`/`jsonl`/`sse`.
19
23
  */
20
24
  export type VerbHelper = {
21
- <Return = unknown, Schema extends StandardSchemaV1 = StandardSchemaV1>(
22
- fn: RemoteHandler<StandardSchemaV1.InferOutput<Schema>, Return>,
25
+ <Return = unknown, InputSchema extends StandardSchemaV1 = StandardSchemaV1>(
26
+ fn: RemoteHandler<StandardSchemaV1.InferOutput<InputSchema>, Return>,
23
27
  opts: {
24
- schema: Schema
25
- jsonSchema?: Record<string, unknown>
28
+ inputSchema: InputSchema
29
+ inputJsonSchema?: Record<string, unknown>
30
+ outputSchema?: StandardSchemaV1
31
+ outputJsonSchema?: Record<string, unknown>
26
32
  clients?: Partial<ClientFlags>
27
33
  },
28
- ): RemoteFunction<StandardSchemaV1.InferInput<Schema>, Return>
34
+ ): RemoteFunction<StandardSchemaV1.InferInput<InputSchema>, Return>
29
35
  <Args = undefined, Return = unknown>(
30
36
  fn: RemoteHandler<Args, Return>,
31
37
  opts: {
32
- jsonSchema?: Record<string, unknown>
38
+ inputJsonSchema?: Record<string, unknown>
39
+ outputSchema?: StandardSchemaV1
40
+ outputJsonSchema?: Record<string, unknown>
33
41
  clients: Partial<ClientFlags>
34
42
  },
35
43
  ): RemoteFunction<Args, Return>
@@ -4,14 +4,22 @@ import type { StandardSchemaV1 } from './StandardSchemaV1.ts'
4
4
 
5
5
  /*
6
6
  Per-verb registry record on the server side. MCP and CLI enumerate this
7
- to discover which RPCs are advertised (clients flags) and what input
8
- shape they expect (schema). The schema and resolved clients stay off the
9
- public RemoteFunction shape so the browser-side proxy doesn't need to
10
- carry server-only state.
7
+ to discover which RPCs are advertised (clients flags) and what shapes
8
+ they expect/return. The schemas and resolved clients stay off the public
9
+ RemoteFunction shape so the browser-side proxy doesn't need to carry
10
+ server-only state.
11
+
12
+ `inputSchema` validates the argument bag and feeds the MCP tool
13
+ `inputSchema` / OpenAPI parameters; `outputSchema` describes the success
14
+ body and feeds the OpenAPI 200 response + MCP tool `outputSchema`. The
15
+ `*JsonSchema` siblings are optional user-supplied JSON Schema overrides
16
+ (used verbatim when present, otherwise derived from the Standard Schema).
11
17
  */
12
18
  export type VerbRegistryEntry = {
13
19
  remote: RemoteFunction<unknown, unknown>
14
- schema: StandardSchemaV1 | undefined
15
- jsonSchema: Record<string, unknown> | undefined
20
+ inputSchema: StandardSchemaV1 | undefined
21
+ inputJsonSchema: Record<string, unknown> | undefined
22
+ outputSchema: StandardSchemaV1 | undefined
23
+ outputJsonSchema: Record<string, unknown> | undefined
16
24
  clients: ClientFlags
17
25
  }
@@ -1,10 +1,8 @@
1
+ import { carriesBodyArgs } from '../../shared/carriesBodyArgs.ts'
1
2
  import { commandNameForUrl } from '../../shared/commandNameForUrl.ts'
2
3
  import { jsonSchemaForSchema } from '../../shared/jsonSchemaForSchema.ts'
3
- import type { HttpVerb } from '../rpc/types/HttpVerb.ts'
4
4
  import { verbRegistry } from '../rpc/verbRegistry.ts'
5
5
 
6
- const BODY_METHODS = new Set<HttpVerb>(['POST', 'PUT', 'PATCH'])
7
-
8
6
  /*
9
7
  Turns a verb's resolved JSON Schema into OpenAPI query parameters — one
10
8
  per top-level property, marked required when the schema lists it. Used
@@ -40,14 +38,27 @@ export function buildOpenApiSpec(info: {
40
38
  for (const entry of verbRegistry.values()) {
41
39
  const url = entry.remote.url
42
40
  const method = entry.remote.method
43
- const jsonSchema = jsonSchemaForSchema(entry.schema, entry.jsonSchema)
41
+ const jsonSchema = jsonSchemaForSchema(entry.inputSchema, entry.inputJsonSchema)
44
42
  const description = jsonSchema.description as string | undefined
43
+ /*
44
+ When the verb declares an `outputSchema`, describe the 200 body
45
+ with it so external tooling sees the real return shape; otherwise
46
+ fall back to a bare OK.
47
+ */
48
+ const okResponse: Record<string, unknown> = { description: 'OK' }
49
+ if (entry.outputSchema) {
50
+ okResponse.content = {
51
+ 'application/json': {
52
+ schema: jsonSchemaForSchema(entry.outputSchema, entry.outputJsonSchema),
53
+ },
54
+ }
55
+ }
45
56
  const operation: Record<string, unknown> = {
46
57
  operationId: commandNameForUrl(url),
47
58
  ...(description ? { description } : {}),
48
- responses: { '200': { description: 'OK' } },
59
+ responses: { '200': okResponse },
49
60
  }
50
- if (BODY_METHODS.has(method)) {
61
+ if (carriesBodyArgs(method)) {
51
62
  operation.requestBody = {
52
63
  content: { 'application/json': { schema: jsonSchema } },
53
64
  }
@@ -2,6 +2,7 @@ import { PUBLIC_ASSET_CACHE_CONTROL } from '../../shared/cacheControlValues.ts'
2
2
  import { acceptsZstd } from './acceptsZstd.ts'
3
3
  import { containsTraversal } from './containsTraversal.ts'
4
4
  import { createAssetHeaderCache } from './createAssetHeaderCache.ts'
5
+ import { globToPathSet } from './globToPathSet.ts'
5
6
  import type { Assets } from './types/Assets.ts'
6
7
 
7
8
  /*
@@ -11,21 +12,32 @@ sources, picked at construction:
11
12
  - `publicAssets` (standalone compile): a map of root path → zstd bytes
12
13
  embedded into the binary, mirroring the `_app` asset embed.
13
14
  - `publicDir` on disk (dev + `belte start`): files read straight from
14
- `${cwd}/src/browser/public`.
15
+ `${cwd}/src/browser/public`, with the set of paths snapshotted once at
16
+ boot (see below).
15
17
 
16
18
  Returns a server fn that resolves to `undefined` when no public file
17
19
  matches the request path, so the caller falls through to its own 404 /
18
20
  middleware path. The path-traversal guard mirrors serveStaticAsset's
19
21
  defence against encoded `..` segments in the raw URL.
22
+
23
+ Async because disk mode globs `publicDir` once at construction to build a
24
+ Set of the available paths: every page nav and RPC falls through here, so
25
+ a Set lookup beats a filesystem stat per miss. A file added to public/
26
+ after boot needs a server restart to be seen — the same restart a code
27
+ change already triggers under `bun --watch`.
20
28
  */
21
- export function createPublicAssetServer({
29
+ export async function createPublicAssetServer({
22
30
  publicDir,
23
31
  publicAssets,
24
32
  }: {
25
33
  publicDir: string
26
34
  publicAssets?: Assets
27
- }): (req: Request, url: URL) => Promise<Response | undefined> {
35
+ }): Promise<(req: Request, url: URL) => Promise<Response | undefined>> {
28
36
  const headersFor = createAssetHeaderCache(() => PUBLIC_ASSET_CACHE_CONTROL)
37
+ // `dot: true` keeps dotfiles (e.g. `.well-known/…`) servable, matching a raw disk stat.
38
+ const diskPaths = publicAssets
39
+ ? new Set<string>()
40
+ : await globToPathSet(publicDir, '**/*', (file) => `/${file}`, { dot: true })
29
41
 
30
42
  return async function servePublicAsset(req, url) {
31
43
  if (containsTraversal(req.url)) {
@@ -43,10 +55,9 @@ export function createPublicAssetServer({
43
55
  }
44
56
  return new Response(await Bun.zstdDecompress(compressed), { headers: base })
45
57
  }
46
- const file = Bun.file(publicDir + url.pathname)
47
- if (!(await file.exists())) {
58
+ if (!diskPaths.has(url.pathname)) {
48
59
  return undefined
49
60
  }
50
- return new Response(file, { headers: base })
61
+ return new Response(Bun.file(publicDir + url.pathname), { headers: base })
51
62
  }
52
63
  }
@@ -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,9 @@ 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'
33
+ import { parsePort } from './parsePort.ts'
32
34
  import { ensureRegistriesLoaded, setRegistryManifests } from './registryManifests.ts'
33
35
  import { requestContext } from './requestContext.ts'
34
36
  import { safeJsonForScript } from './safeJsonForScript.ts'
@@ -58,6 +60,7 @@ function internalErrorResponse(err: unknown): Response {
58
60
 
59
61
  const IDENTITY_PATH = '/__belte/identity'
60
62
  const SOCKETS_PATH = '/__belte/sockets'
63
+ const SOCKETS_REST_PREFIX = '/__belte/sockets/'
61
64
  const MCP_PATH = '/__belte/mcp'
62
65
  const CLI_PATH = '/__belte/cli'
63
66
  const CLI_DOWNLOAD_PREFIX = '/__belte/cli/'
@@ -101,7 +104,7 @@ export async function createServer({
101
104
  distDir = `${process.cwd()}/dist`,
102
105
  publicDir = `${process.cwd()}/src/browser/public`,
103
106
  resourcesDir = `${process.cwd()}/src/mcp/resources`,
104
- port = Number(process.env.PORT ?? 3000),
107
+ port = parsePort(process.env.PORT) ?? 3000,
105
108
  }: {
106
109
  pages: Pages
107
110
  rpc: RemoteRoutes
@@ -125,16 +128,23 @@ export async function createServer({
125
128
  setMcpResourceServer(createMcpResourceServer({ resourcesDir, mcpResources }))
126
129
  const cliName = cliProgramName ?? 'app'
127
130
  const cliCwd = process.cwd()
128
- const servePublicAsset = createPublicAssetServer({ publicDir, publicAssets })
131
+ const servePublicAsset = await createPublicAssetServer({ publicDir, publicAssets })
129
132
  const layoutPrefixes = layouts ? normalizeLayoutPrefixes(Object.keys(layouts)) : []
130
133
 
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
- )
134
+ /*
135
+ Snapshot the precompressed `.zst` siblings the build wrote next to each
136
+ `_app` asset, keyed by the asset's request path, so a zstd-capable
137
+ client gets the precompressed bytes without on-the-fly compression. Only
138
+ in disk mode (`belte start` / dev); the compiled binary serves from the
139
+ embedded `assets` map instead.
140
+ */
141
+ const diskZstdPaths = assets
142
+ ? new Set<string>()
143
+ : await globToPathSet(
144
+ `${distDir}/_app`,
145
+ '**/*.zst',
146
+ (file) => `/_app/${file.replace(/\.zst$/, '')}`,
147
+ )
138
148
 
139
149
  const rpcModuleCache = new Map<string, Promise<AnyRemoteFunction | undefined>>()
140
150
  function loadRpc(url: string): Promise<AnyRemoteFunction | undefined> | undefined {
@@ -440,6 +450,17 @@ export async function createServer({
440
450
  }
441
451
  return new Response('Upgrade failed', { status: 400 })
442
452
  }
453
+ /*
454
+ HTTP face of a socket (`/__belte/sockets/<name>`) — tail over
455
+ SSE / JSON and publish — for the CLI and MCP. Runs through
456
+ dispatchRequest so app.handle auth applies, like the rpc paths.
457
+ The socket name may contain `/` (nested files), so it's the
458
+ whole remaining pathname, percent-decoded.
459
+ */
460
+ if (url.pathname.startsWith(SOCKETS_REST_PREFIX)) {
461
+ const name = decodeURIComponent(url.pathname.slice(SOCKETS_REST_PREFIX.length))
462
+ return dispatchRequest(req, {}, async () => socketDispatcher.rest(req, name))
463
+ }
443
464
  if (url.pathname === MCP_PATH && mcp) {
444
465
  return dispatchRequest(req, {}, async () => mcp.handle(req))
445
466
  }
@@ -526,22 +547,37 @@ export async function createServer({
526
547
  */
527
548
  setActiveServer(server)
528
549
 
529
- if (app?.init) {
530
- const cleanup = await app.init({ server })
550
+ const cleanup = app?.init ? await app.init({ server }) : undefined
551
+ /*
552
+ Close the listener deterministically on shutdown. Always registered (even
553
+ with no init cleanup) so the socket is released via server.stop rather than
554
+ left to abrupt process exit — which leaves the port in TIME_WAIT and races
555
+ a fast restart. A watchdog force-exits if a user cleanup hangs, so a stuck
556
+ cleanup can't keep the process (and its port) alive.
557
+ */
558
+ const shutdown = async () => {
559
+ server.stop(true)
531
560
  if (typeof cleanup === 'function') {
532
- const shutdown = async () => {
533
- try {
534
- await cleanup()
535
- } catch (err) {
536
- log.error(err)
537
- }
538
- process.exit(0)
561
+ setTimeout(() => process.exit(0), 3000).unref()
562
+ try {
563
+ await cleanup()
564
+ } catch (err) {
565
+ log.error(err)
539
566
  }
540
- process.once('SIGINT', shutdown)
541
- process.once('SIGTERM', shutdown)
542
567
  }
568
+ process.exit(0)
543
569
  }
570
+ process.once('SIGINT', shutdown)
571
+ process.once('SIGTERM', shutdown)
544
572
 
545
573
  log.success(`ready at http://localhost:${server.port}`)
574
+ /*
575
+ Diagnostic only, and only under `belte` debug logging — eager-loads the
576
+ registry to report routes that are browser-only for lack of a schema,
577
+ making the opt-in nature of the MCP/CLI surfaces visible.
578
+ */
579
+ if (logRequests) {
580
+ void logBrowserOnlyRoutes()
581
+ }
546
582
  return server
547
583
  }
@@ -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
+ }
@@ -0,0 +1,16 @@
1
+ /*
2
+ Parses a PORT env value into a usable TCP port, returning undefined for
3
+ missing, empty, or out-of-range/non-integer input so the caller can fall back
4
+ to a default. A bare Number() turns '' into 0 (a random kernel-assigned port)
5
+ and 'abc' into NaN, both silently wrong; this rejects them instead.
6
+ */
7
+ export function parsePort(value: string | undefined): number | undefined {
8
+ if (value === undefined || value.trim() === '') {
9
+ return undefined
10
+ }
11
+ const port = Number(value)
12
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
13
+ return undefined
14
+ }
15
+ return port
16
+ }
@@ -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
  })
@@ -0,0 +1,22 @@
1
+ import { homedir } from 'node:os'
2
+ import { join } from 'node:path'
3
+
4
+ /*
5
+ Platform-standard per-user data directory for a bundle, keyed by its program
6
+ name. cwd-independent on purpose: the path derives from `programName`, not the
7
+ process working directory — which the OS `open` command sets to `/`, so anything
8
+ relying on cwd (Bun's `.env` autoload, relative paths) finds nothing inside a
9
+ launched `.app`. This is where a bundle keeps what can't be baked at compile time
10
+ — the user's config, DB, and cache. macOS Application Support, Windows %APPDATA%,
11
+ XDG data home elsewhere. Pure: computes the path, never touches the filesystem.
12
+ */
13
+ export function appDataDir(programName: string): string {
14
+ const home = homedir()
15
+ if (process.platform === 'darwin') {
16
+ return join(home, 'Library', 'Application Support', programName)
17
+ }
18
+ if (process.platform === 'win32') {
19
+ return join(process.env.APPDATA ?? join(home, 'AppData', 'Roaming'), programName)
20
+ }
21
+ return join(process.env.XDG_DATA_HOME ?? join(home, '.local', 'share'), programName)
22
+ }
@@ -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
  }