@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.
- package/bin/belte.ts +22 -13
- package/package.json +1 -1
- package/src/appEntry.ts +24 -8
- package/src/buildDisconnected.ts +3 -0
- package/src/bundleApp.ts +24 -2
- package/src/controlServerWorker.ts +205 -7
- 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/BundleWindow.ts +11 -0
- package/src/lib/bundle/disconnected.svelte +238 -42
- 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/loadEnvFromBinaryDir.ts +8 -38
- 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 +57 -21
- package/src/lib/server/runtime/globToPathSet.ts +29 -0
- package/src/lib/server/runtime/logBrowserOnlyRoutes.ts +44 -0
- package/src/lib/server/runtime/parsePort.ts +16 -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/appDataDir.ts +22 -0
- 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/loadEnvFile.ts +17 -0
- package/src/lib/shared/loadEnvFromDataDir.ts +15 -0
- package/src/lib/shared/parseEnv.ts +30 -0
- package/src/lib/shared/readEnvFile.ts +15 -0
- package/src/lib/shared/resolveClientFlags.ts +8 -6
- package/src/lib/shared/responseErrorText.ts +9 -0
- package/src/lib/shared/serializeEnv.ts +18 -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/src/serverEntry.ts +12 -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
|
@@ -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, {
|
|
10
|
-
`InferInput<
|
|
11
|
-
Generic order is `<Return,
|
|
12
|
-
while letting `
|
|
13
|
-
|
|
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,
|
|
22
|
-
fn: RemoteHandler<StandardSchemaV1.InferOutput<
|
|
25
|
+
<Return = unknown, InputSchema extends StandardSchemaV1 = StandardSchemaV1>(
|
|
26
|
+
fn: RemoteHandler<StandardSchemaV1.InferOutput<InputSchema>, Return>,
|
|
23
27
|
opts: {
|
|
24
|
-
|
|
25
|
-
|
|
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<
|
|
34
|
+
): RemoteFunction<StandardSchemaV1.InferInput<InputSchema>, Return>
|
|
29
35
|
<Args = undefined, Return = unknown>(
|
|
30
36
|
fn: RemoteHandler<Args, Return>,
|
|
31
37
|
opts: {
|
|
32
|
-
|
|
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
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
15
|
-
|
|
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.
|
|
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':
|
|
59
|
+
responses: { '200': okResponse },
|
|
49
60
|
}
|
|
50
|
-
if (
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
530
|
-
|
|
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
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
-
|
|
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
|
})
|
|
@@ -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
|
|
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
|
}
|