@belte/belte 0.19.0 → 0.19.2
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/CHANGELOG.md +16 -0
- package/README.md +419 -337
- package/package.json +1 -1
- package/src/lib/browser/page.svelte.ts +33 -9
- package/src/lib/browser/startClient.ts +11 -1
- package/src/lib/mcp/dispatchMcpRequest.ts +7 -9
- package/src/lib/mcp/mcpSurface.ts +32 -7
- package/src/lib/server/agent.ts +10 -1
- package/src/lib/server/runtime/createServer.ts +11 -8
- package/src/lib/server/runtime/types/RequestStore.ts +7 -0
- package/src/lib/shared/activePage.ts +20 -0
- package/src/lib/shared/pageSlot.ts +17 -0
- package/src/lib/shared/setPageResolver.ts +7 -0
- package/src/lib/shared/types/PageSnapshot.ts +11 -0
- package/src/serverEntry.ts +16 -0
package/README.md
CHANGED
|
@@ -11,251 +11,280 @@ bundler swaps the runtime per target.
|
|
|
11
11
|
// src/server/rpc/getPost.ts — the filename is the export, the URL, and the command name
|
|
12
12
|
import { GET } from '@belte/belte/server/GET'
|
|
13
13
|
import { json } from '@belte/belte/server/json'
|
|
14
|
-
import { z } from 'zod'
|
|
15
14
|
|
|
16
|
-
export const getPost = GET
|
|
17
|
-
|
|
18
|
-
}
|
|
15
|
+
export const getPost = GET<Post, typeof schema>(
|
|
16
|
+
async ({ id }) => json(await db.post(id)),
|
|
17
|
+
{ inputSchema: schema },
|
|
18
|
+
)
|
|
19
19
|
```
|
|
20
20
|
|
|
21
21
|
That one file is now all of this:
|
|
22
22
|
|
|
23
23
|
```text
|
|
24
|
-
src/server/rpc/getPost.ts
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
24
|
+
src/server/rpc/getPost.ts
|
|
25
|
+
│
|
|
26
|
+
┌────────────┬──────────┼───────────┬──────────────────┐
|
|
27
|
+
browser http cli mcp openapi
|
|
28
|
+
cache(getPost) GET myapp getPost GET /rpc/getPost
|
|
29
|
+
({ id }) /rpc/ getPost tool operation in
|
|
30
|
+
getPost? --id 1 (read-only) /openapi.json
|
|
31
|
+
id=1
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
what makes a machine-facing surface safe to advertise.
|
|
37
|
-
|
|
38
|
-
Don't take the diagram's word for it — belte prints the exact map at boot
|
|
39
|
-
(`DEBUG=belte`):
|
|
34
|
+
Don't take the diagram's word for it — with `DEBUG=belte`, belte prints the exact
|
|
35
|
+
map at boot:
|
|
40
36
|
|
|
41
37
|
```sh
|
|
42
38
|
pages:
|
|
43
|
-
page
|
|
44
|
-
/
|
|
45
|
-
/posts/[id]
|
|
39
|
+
page layout error
|
|
40
|
+
/ · ·
|
|
41
|
+
/posts/[id] · ·
|
|
46
42
|
sockets:
|
|
47
|
-
socket
|
|
48
|
-
chat
|
|
43
|
+
socket schema browser mcp cli publish
|
|
44
|
+
chat ✓ ✓ ✓ ✓ ✓
|
|
49
45
|
rpcs:
|
|
50
46
|
http schema browser mcp cli
|
|
51
47
|
GET /rpc/getPost ✓ ✓ ✓ ✓
|
|
52
|
-
POST /rpc/createPost ✓ ✓
|
|
53
|
-
GET /rpc/listFeed · ✓ · ·
|
|
48
|
+
POST /rpc/createPost ✓ ✓ ✓ ✓
|
|
54
49
|
```
|
|
55
50
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
place; no surface is ever exposed by accident.
|
|
51
|
+
The `schema` column is the gate: a verb or socket with no schema reddens its `·`
|
|
52
|
+
there, because that is what holds its `mcp`/`cli` columns off. Every surface a
|
|
53
|
+
function reaches is auditable in one place — no surface is ever exposed by accident.
|
|
60
54
|
|
|
61
55
|
## Why it's built this way
|
|
62
56
|
|
|
63
|
-
- **Zero runtime dependencies.**
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
`Bun.
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
strings, templates, comments, regexes, and
|
|
70
|
-
|
|
71
|
-
|
|
57
|
+
- **Zero runtime dependencies.** belte ships no `dependencies` — only optional
|
|
58
|
+
peers (`svelte`, and `bun-plugin-tailwind` / `tailwindcss` for styling). The
|
|
59
|
+
runtime is Bun (`Bun.serve`, `Bun.CookieMap`, `Bun.YAML`, `Bun.zstdDecompress`,
|
|
60
|
+
`Bun.file`) and Web standards (`Request`/`Response`, `ReadableStream`,
|
|
61
|
+
`AsyncIterable`, `structuredClone`).
|
|
62
|
+
- **No magic strings.** The bundler finds each `export const x = GET(fn)` with a
|
|
63
|
+
character-level scanner that skips strings, templates, comments, regexes, and
|
|
64
|
+
TypeScript generics (`findExportCallSite.ts`) — a `GET` inside a docstring or a
|
|
65
|
+
`GET<Map<K, V>>(` is never mistaken for the call site.
|
|
66
|
+
- **Safe by default for machines.** A schema-bearing read verb (`GET`/`HEAD`)
|
|
72
67
|
auto-exposes to MCP; a mutating verb (`POST`/`PUT`/`PATCH`/`DELETE`) never does
|
|
73
|
-
|
|
74
|
-
|
|
68
|
+
just because it carries a schema — it requires an explicit `clients: { mcp: true }`
|
|
69
|
+
(`defineVerb.ts`, `resolveClientFlags.ts`, `isReadOnlyMethod.ts`).
|
|
75
70
|
|
|
76
71
|
## Scope — read this before you adopt
|
|
77
72
|
|
|
78
|
-
- **Bun-only, by design.** `
|
|
79
|
-
|
|
73
|
+
- **Bun-only, by design.** belte targets `bun >= 1.3.0` and builds on Bun APIs with
|
|
74
|
+
no Node fallback path.
|
|
80
75
|
- **Svelte-only web surface.** Pages, layouts, and error pages are Svelte 5
|
|
81
|
-
components;
|
|
82
|
-
- **Pre-1.0.** The core (rpc,
|
|
83
|
-
|
|
84
|
-
|
|
76
|
+
components; there is no other view layer.
|
|
77
|
+
- **Pre-1.0.** The core (rpc, sockets, cache, SSR+SPA) is the mature surface; the
|
|
78
|
+
satellites (`mcp`, `cli`, `bundle`/desktop, `agent`) are newer. Expect change.
|
|
79
|
+
- **No umbrella import.** There is no `.` barrel — every public name has its own
|
|
80
|
+
path (`@belte/belte/server/GET`, `@belte/belte/shared/cache`, …), so importing one
|
|
81
|
+
name never drags in side-effecting siblings.
|
|
82
|
+
|
|
83
|
+
---
|
|
85
84
|
|
|
86
85
|
## The mental model
|
|
87
86
|
|
|
88
87
|
Three ideas carry the whole framework.
|
|
89
88
|
|
|
90
|
-
1. **One runtime
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
2. **Declare once.** A file under `src/server/rpc/`
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
89
|
+
1. **One runtime.** Dev and build run the same code through the same plugins; the
|
|
90
|
+
only thing that changes per target is which runtime the bundler swaps in behind a
|
|
91
|
+
declared name.
|
|
92
|
+
2. **Declare once.** A file under `src/server/rpc/` exports exactly one verb; its
|
|
93
|
+
filename is the export name, its path is the URL, and its schema decides which
|
|
94
|
+
surfaces it reaches. Same for `src/server/sockets/`.
|
|
95
|
+
3. **The namespace marks the side.** The first path segment tells you where a name
|
|
96
|
+
runs.
|
|
98
97
|
|
|
99
|
-
|
|
|
98
|
+
| namespace | runs on | examples |
|
|
100
99
|
| --- | --- | --- |
|
|
101
|
-
| `
|
|
102
|
-
| `
|
|
103
|
-
| `
|
|
104
|
-
| `
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
There is no umbrella `index.ts` and no `.` export. Every public name has its own
|
|
108
|
-
module path (`belte/server/json`, `belte/shared/cache`, …), so importing one name
|
|
109
|
-
never drags a side-effecting sibling into the bundle. `shared/*` names are the
|
|
110
|
-
same callable with the same behaviour on both sides; the bundler swaps only the
|
|
111
|
-
underlying runtime.
|
|
100
|
+
| `server/*` | server only | `server/GET`, `server/socket`, `server/json`, `server/request`, `server/cookies`, `server/env`, `server/agent` |
|
|
101
|
+
| `browser/*` | client only | `browser/page`, `browser/navigate`, `browser/subscribe` |
|
|
102
|
+
| `shared/*` | isomorphic — same callable, same behaviour both sides | `shared/cache`, `shared/HttpError`, `shared/withJsonSchema`, `shared/bundled` |
|
|
103
|
+
| `bundle/*` | desktop bundle | `bundle/BundleWindow`, `bundle/onMenu`, `bundle/BundleMenu` |
|
|
104
|
+
|
|
105
|
+
No `index.ts` barrels anywhere. Each import is its own module path.
|
|
112
106
|
|
|
113
107
|
## One function, every surface
|
|
114
108
|
|
|
115
|
-
A single schema-bearing verb, consumed
|
|
109
|
+
A single schema-bearing verb, consumed five ways. Declare it once:
|
|
116
110
|
|
|
117
111
|
```ts
|
|
118
|
-
// src/server/rpc/
|
|
119
|
-
import {
|
|
112
|
+
// src/server/rpc/getPost.ts
|
|
113
|
+
import { GET } from '@belte/belte/server/GET'
|
|
120
114
|
import { json } from '@belte/belte/server/json'
|
|
121
115
|
import { z } from 'zod'
|
|
122
116
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
})
|
|
117
|
+
const schema = z.object({ id: z.string() })
|
|
118
|
+
|
|
119
|
+
export const getPost = GET<Post, typeof schema>(
|
|
120
|
+
async ({ id }) => json(await db.post(id)),
|
|
121
|
+
{ inputSchema: schema },
|
|
122
|
+
)
|
|
127
123
|
```
|
|
128
124
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const result = $derived(await cache(createOrder)({ sku: 'A1', qty: 2 }))
|
|
135
|
-
</script>
|
|
125
|
+
Now consume it.
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
// browser / SSR — same callable, bundler-swapped runtime
|
|
129
|
+
const post = await cache(getPost)({ id })
|
|
136
130
|
```
|
|
137
131
|
|
|
138
132
|
```sh
|
|
139
|
-
# http
|
|
140
|
-
curl
|
|
133
|
+
# http — args on the query string for a GET
|
|
134
|
+
curl 'http://localhost:3000/rpc/getPost?id=42'
|
|
135
|
+
```
|
|
141
136
|
|
|
142
|
-
|
|
143
|
-
|
|
137
|
+
```sh
|
|
138
|
+
# cli — the thin client turns it into a subcommand with schema-derived flags
|
|
139
|
+
myapp getPost --id 42
|
|
140
|
+
```
|
|
144
141
|
|
|
145
|
-
|
|
142
|
+
```json
|
|
143
|
+
// mcp — POST /__belte/mcp, tools/call
|
|
144
|
+
{ "method": "tools/call", "params": { "name": "getPost", "arguments": { "id": "42" } } }
|
|
145
|
+
```
|
|
146
146
|
|
|
147
|
-
|
|
147
|
+
```sh
|
|
148
|
+
# openapi — the operation is in the generated document
|
|
149
|
+
curl http://localhost:3000/openapi.json
|
|
148
150
|
```
|
|
149
151
|
|
|
152
|
+
`getPost` is read-only and carries a schema, so all five light up automatically. A
|
|
153
|
+
mutating verb would expose `http`, `openapi`, and `browser` by the same rules, but
|
|
154
|
+
hold `mcp` off until you opt in.
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
150
158
|
## Server
|
|
151
159
|
|
|
152
160
|
### Server / rpc
|
|
153
161
|
|
|
154
162
|
#### Declaring
|
|
155
163
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
`.../DELETE`, `.../HEAD`.
|
|
164
|
+
A verb helper rewrites `export const x = VERB(fn, opts?)` into a server handler (or
|
|
165
|
+
a browser fetch stub). One export per file.
|
|
159
166
|
|
|
160
167
|
```ts
|
|
161
|
-
type
|
|
162
|
-
|
|
168
|
+
type VerbHelper = <Return, InputSchema, FilesSchema>(
|
|
169
|
+
fn: (args: InferOutput<InputSchema>) => Response | Promise<Response>,
|
|
163
170
|
opts?: {
|
|
164
|
-
inputSchema?: InputSchema
|
|
165
|
-
outputSchema?: StandardSchemaV1
|
|
166
|
-
filesSchema?:
|
|
167
|
-
clients?: Partial<{ browser; mcp; cli }>
|
|
171
|
+
inputSchema?: InputSchema
|
|
172
|
+
outputSchema?: StandardSchemaV1
|
|
173
|
+
filesSchema?: FilesSchema
|
|
174
|
+
clients?: Partial<{ browser: boolean; mcp: boolean; cli: boolean }>
|
|
168
175
|
},
|
|
169
176
|
) => RemoteFunction<InferInput<InputSchema>, Return>
|
|
170
177
|
```
|
|
171
178
|
|
|
172
|
-
|
|
|
179
|
+
| option | type | effect |
|
|
173
180
|
| --- | --- | --- |
|
|
174
|
-
| `inputSchema` |
|
|
175
|
-
| `outputSchema` | Standard Schema
|
|
176
|
-
| `filesSchema` |
|
|
177
|
-
| `clients` |
|
|
181
|
+
| `inputSchema` | Standard Schema | validates args (422 on failure); projected to OpenAPI / MCP / CLI. Its presence flips on `cli`, and `mcp` for read-only verbs |
|
|
182
|
+
| `outputSchema` | Standard Schema | describes the 200 body in the OpenAPI doc and the MCP tool `outputSchema` |
|
|
183
|
+
| `filesSchema` | Standard Schema | validates multipart `File` parts; kept off the JSON-Schema projection (a `File` has no honest conversion) |
|
|
184
|
+
| `clients` | partial flags | explicit per-surface override; always wins over the computed defaults |
|
|
178
185
|
|
|
179
|
-
`
|
|
180
|
-
|
|
181
|
-
response helpers, so `GET(() => json({ ... }))` types end-to-end with no annotation.
|
|
186
|
+
Helpers: `GET`, `POST`, `PUT`, `PATCH`, `DELETE`, `HEAD`. Any Standard Schema
|
|
187
|
+
library (zod, valibot, arktype) works without an adapter.
|
|
182
188
|
|
|
183
|
-
|
|
184
|
-
|
|
189
|
+
```ts
|
|
190
|
+
// a mutation must opt into MCP explicitly
|
|
191
|
+
export const createPost = POST<Post, typeof schema>(
|
|
192
|
+
async (input) => json(await db.create(input), { status: 201 }),
|
|
193
|
+
{ inputSchema: schema, clients: { mcp: true } },
|
|
194
|
+
)
|
|
195
|
+
```
|
|
185
196
|
|
|
186
|
-
|
|
197
|
+
**Response helpers** — each returns a `TypedResponse<T>` whose phantom `T` lets the
|
|
198
|
+
caller-facing return type infer from the handler body.
|
|
199
|
+
|
|
200
|
+
| helper | path | builds |
|
|
187
201
|
| --- | --- | --- |
|
|
188
|
-
| `json(data, init?)` | `
|
|
189
|
-
| `
|
|
190
|
-
| `
|
|
191
|
-
| `
|
|
192
|
-
| `
|
|
202
|
+
| `json(data, init?)` | `server/json` | `application/json`, `Cache-Control: no-store` by default |
|
|
203
|
+
| `jsonl(iterable, init?)` | `server/jsonl` | JSON Lines stream (`application/jsonl`), one value per line |
|
|
204
|
+
| `sse(iterable, init?)` | `server/sse` | Server-Sent Events stream with a 15s keepalive comment |
|
|
205
|
+
| `error(status, message?, init?)` | `server/error` | `text/plain` error; message defaults to the standard reason phrase |
|
|
206
|
+
| `redirect(url, status?, init?)` | `server/redirect` | 301/302/303/307/308; accepts relative URLs (default 302) |
|
|
207
|
+
|
|
208
|
+
`jsonl` and `sse` carry generator errors as a final frame (`{"$error": "…"}` /
|
|
209
|
+
`event: error`) with only the message on the wire — the full error is logged
|
|
210
|
+
server-side. Cancellation flows from the consumer into the generator's `for await`
|
|
211
|
+
via `iter.return()`.
|
|
193
212
|
|
|
194
213
|
**Request-scoped helpers** — resolve only while an SSR render or rpc handler is in
|
|
195
|
-
flight (they throw
|
|
214
|
+
flight (they throw outside a request scope):
|
|
215
|
+
|
|
216
|
+
- `request()` (`server/request`) — the inbound `Request`.
|
|
217
|
+
- `server()` (`server/server`) — the active `Bun.serve` instance (a no-op stand-in
|
|
218
|
+
under in-process CLI / MCP / test dispatch).
|
|
219
|
+
- `cookies()` (`server/cookies`) — Bun's `CookieMap`: a live `Map<string, string>`
|
|
220
|
+
plus `.set(name, value, options)` and `.delete(name)`, flushed to `Set-Cookie` on
|
|
221
|
+
return.
|
|
196
222
|
|
|
197
|
-
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
223
|
+
> SSR and MCP call verbs **in-process**, and that path forwards only an allowlist of
|
|
224
|
+
> inbound headers — `cookie`, `authorization`, and the `x-forwarded-*` hints. A
|
|
225
|
+
> handler that reads any other header (e.g. `accept-language`, `x-tenant-id`) during
|
|
226
|
+
> SSR or an MCP call sees nothing unless you extend the list via the `forwardHeaders`
|
|
227
|
+
> export in `src/app.ts`.
|
|
201
228
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
`x-forwarded-proto`, `x-forwarded-host`. Every other header is dropped. Extend the
|
|
205
|
-
allowlist with the `forwardHeaders` export in `src/app.ts` (e.g. `accept-language`,
|
|
206
|
-
`x-tenant-id`).
|
|
229
|
+
**Multipart uploads** — a body verb with `filesSchema` receives the validated text
|
|
230
|
+
fields merged with the `File` parts; call it with a `FormData`:
|
|
207
231
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
232
|
+
```ts
|
|
233
|
+
export const upload = POST(
|
|
234
|
+
async ({ title, file }) => json(await store(title, file)),
|
|
235
|
+
{ inputSchema: z.object({ title: z.string() }), filesSchema: z.object({ file: z.instanceof(File) }) },
|
|
236
|
+
)
|
|
237
|
+
```
|
|
212
238
|
|
|
213
|
-
|
|
214
|
-
|
|
239
|
+
**Schemas without `toJSONSchema()`** — wrap once at the declaration so the OpenAPI
|
|
240
|
+
doc, MCP tools, and CLI flags can read it:
|
|
215
241
|
|
|
216
242
|
```ts
|
|
217
243
|
import { withJsonSchema } from '@belte/belte/shared/withJsonSchema'
|
|
218
|
-
|
|
244
|
+
const schema = withJsonSchema(valibotSchema, (s) => toJsonSchema(s))
|
|
219
245
|
```
|
|
220
246
|
|
|
221
247
|
#### Consuming
|
|
222
248
|
|
|
223
|
-
A
|
|
249
|
+
A `RemoteFunction` is one callable with two siblings:
|
|
224
250
|
|
|
225
|
-
|
|
|
251
|
+
| form | resolves to | use |
|
|
226
252
|
| --- | --- | --- |
|
|
227
|
-
| `fn(args)` |
|
|
228
|
-
| `fn.raw(args)` | the underlying `Response` |
|
|
229
|
-
| `fn.stream(args
|
|
253
|
+
| `fn(args)` | decoded body (`Promise<Return>`); throws `HttpError` on non-2xx | the default call |
|
|
254
|
+
| `fn.raw(args)` | the underlying `Response` | status / headers / manual streaming |
|
|
255
|
+
| `fn.stream(args)` | a `Subscribable<Return>` | frame-by-frame consumption via `subscribe()` |
|
|
230
256
|
|
|
231
257
|
```ts
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
258
|
+
import { HttpError } from '@belte/belte/shared/HttpError'
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
const post = await getPost({ id })
|
|
262
|
+
} catch (err) {
|
|
263
|
+
if (err instanceof HttpError && err.status === 404) {
|
|
264
|
+
// err.response is the raw Response
|
|
265
|
+
}
|
|
266
|
+
}
|
|
235
267
|
```
|
|
236
268
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
**OpenAPI** — every verb is described at `/openapi.json` (OpenAPI 3.1) regardless
|
|
241
|
-
of which machine clients it advertises. `GET`/`DELETE`/`HEAD` args become query
|
|
242
|
-
parameters; `POST`/`PUT`/`PATCH` args become a JSON request body (or
|
|
243
|
-
`multipart/form-data` when `filesSchema` is set). `operationId` is the
|
|
244
|
-
folder-prefixed command name.
|
|
269
|
+
The HTTP surface is always on, independent of the other clients. The OpenAPI 3.1
|
|
270
|
+
document for every verb is served at `/openapi.json`.
|
|
245
271
|
|
|
246
272
|
### Server / sockets
|
|
247
273
|
|
|
248
|
-
A
|
|
249
|
-
|
|
274
|
+
A WebSocket-backed pub/sub topic. Every socket multiplexes onto one
|
|
275
|
+
framework-owned connection per client at `/__belte/sockets` — user code never
|
|
276
|
+
touches the raw ws lifecycle.
|
|
277
|
+
|
|
278
|
+
#### Declaring
|
|
250
279
|
|
|
251
280
|
```ts
|
|
252
|
-
type
|
|
253
|
-
history?: number
|
|
254
|
-
ttl?: number
|
|
255
|
-
clientPublish?: boolean
|
|
256
|
-
schema?: StandardSchemaV1 // validates
|
|
257
|
-
clients?: Partial<{ browser; mcp; cli }>
|
|
258
|
-
}
|
|
281
|
+
type SocketOptions = {
|
|
282
|
+
history?: number // messages replayed to a new subscriber
|
|
283
|
+
ttl?: number // ms; history entries past it are evicted lazily on read
|
|
284
|
+
clientPublish?: boolean // allow clients to publish (off by default)
|
|
285
|
+
schema?: StandardSchemaV1 // validates publishes; unlocks mcp/cli
|
|
286
|
+
clients?: Partial<{ browser: boolean; mcp: boolean; cli: boolean }>
|
|
287
|
+
}
|
|
259
288
|
```
|
|
260
289
|
|
|
261
290
|
```ts
|
|
@@ -264,296 +293,349 @@ import { socket } from '@belte/belte/server/socket'
|
|
|
264
293
|
import { z } from 'zod'
|
|
265
294
|
|
|
266
295
|
export const chat = socket({
|
|
296
|
+
schema: z.object({ user: z.string(), text: z.string() }),
|
|
267
297
|
history: 50,
|
|
268
298
|
clientPublish: true,
|
|
269
|
-
schema: z.object({ user: z.string(), text: z.string() }),
|
|
270
299
|
})
|
|
271
300
|
```
|
|
272
301
|
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
302
|
+
With a schema, `T` infers from it and publishes validate on the server.
|
|
303
|
+
Schemaless → browser-only; schema present → all surfaces.
|
|
304
|
+
|
|
305
|
+
#### Publishing
|
|
306
|
+
|
|
307
|
+
```ts
|
|
308
|
+
chat.publish({ user, text }) // server-side: notifies in-process iterators + broadcasts to ws clients
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
`publish` is isomorphic — called from the client (via the socket proxy) it sends a
|
|
312
|
+
`pub` frame the server validates and forwards.
|
|
313
|
+
|
|
314
|
+
#### Consuming
|
|
315
|
+
|
|
316
|
+
A `Socket<T>` is an `AsyncIterable`: a bare `for await` replays the full history
|
|
317
|
+
buffer then tails live; `.tail(count)` replays the last `count` items (default `0`).
|
|
318
|
+
|
|
319
|
+
```ts
|
|
320
|
+
for await (const message of chat) {
|
|
321
|
+
// history first, then live
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const recent = chat.tail(10)
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
In a Svelte component, layer `subscribe()` on top for reactivity (below).
|
|
328
|
+
|
|
329
|
+
### Server / agent
|
|
276
330
|
|
|
277
|
-
|
|
331
|
+
`agent(engine, messages)` runs a model engine against the app's own MCP surface
|
|
332
|
+
(its gated tools/prompts/resources) and returns the engine's frame stream. The
|
|
333
|
+
handler picks the transport — same as any streaming verb.
|
|
278
334
|
|
|
279
335
|
```ts
|
|
280
|
-
|
|
281
|
-
|
|
336
|
+
// src/server/rpc/chat.ts
|
|
337
|
+
import { agent } from '@belte/belte/server/agent'
|
|
338
|
+
import { jsonl } from '@belte/belte/server/jsonl'
|
|
339
|
+
import { engine } from '@belte/anthropic'
|
|
340
|
+
|
|
341
|
+
const chatEngine = engine({ model: 'claude-opus-4-8', apiKey: config.ANTHROPIC_API_KEY })
|
|
342
|
+
|
|
343
|
+
export const chat = POST(({ messages }) => jsonl(agent(chatEngine, messages)), { inputSchema })
|
|
282
344
|
```
|
|
283
345
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
346
|
+
The engine (a `@belte/<provider>` package) only sees the surface in and yields
|
|
347
|
+
frames out, so swapping providers never touches the verb or the UI. Permission is
|
|
348
|
+
decided server-side: the surface is already gated by each verb's `clients.mcp` plus
|
|
349
|
+
its own handler auth.
|
|
350
|
+
|
|
351
|
+
---
|
|
287
352
|
|
|
288
353
|
## Clients
|
|
289
354
|
|
|
290
355
|
### Shared
|
|
291
356
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
357
|
+
`cache(fn, options?)` (`shared/cache`) returns an invoker; calling it dedupes
|
|
358
|
+
against a store — a shared promise on hit, one invocation on miss. `fn` is a verb
|
|
359
|
+
helper, its `.raw`, or a plain producer returning a `Promise`.
|
|
295
360
|
|
|
296
361
|
```ts
|
|
297
|
-
type
|
|
298
|
-
ttl?: number
|
|
299
|
-
scope?: string | string[]
|
|
300
|
-
global?: boolean
|
|
301
|
-
invalidate?: { throttle?: number } | { debounce?: number } // coalesce
|
|
302
|
-
}
|
|
362
|
+
type CacheOptions = {
|
|
363
|
+
ttl?: number // ms past resolve; omitted = forever, 0 = dedupe-only
|
|
364
|
+
scope?: string | string[] // tags for grouped cache.invalidate({ scope })
|
|
365
|
+
global?: boolean // process-level store instead of per-request
|
|
366
|
+
invalidate?: { throttle?: number } | { debounce?: number } // coalesce invalidations (stale-while-revalidate)
|
|
367
|
+
}
|
|
303
368
|
```
|
|
304
369
|
|
|
305
370
|
```ts
|
|
306
|
-
// server (
|
|
307
|
-
const post = await cache(getPost)({ id })
|
|
308
|
-
|
|
309
|
-
|
|
371
|
+
// server (request-scoped store by default — per-user data never leaks across requests)
|
|
372
|
+
const post = await cache(getPost)({ id })
|
|
373
|
+
|
|
374
|
+
// browser (one tab store)
|
|
375
|
+
const post = $derived(await cache(getPost)({ id }))
|
|
310
376
|
```
|
|
311
377
|
|
|
312
378
|
`cache.invalidate(selector?)`, `cache.pending(selector?)`, and
|
|
313
|
-
`cache.refreshing(selector?)` share one selector grammar:
|
|
314
|
-
= that function's calls, `{ scope }` = a tagged group.
|
|
315
|
-
`method + url + args` (or `producer-ref + args`); arg keys distinguish `Date`,
|
|
316
|
-
`Map`, `Set`, and `bigint` from look-alike values via `canonicalJson` (a `Date`
|
|
317
|
-
never aliases its ISO string; a `Map` never aliases a plain object).
|
|
379
|
+
`cache.refreshing(selector?)` share one selector grammar: no arg = everything, a
|
|
380
|
+
function = that function's calls, `{ scope }` = a tagged group.
|
|
318
381
|
|
|
319
|
-
**SSR mode is
|
|
382
|
+
**SSR mode is decided by how you read**, per Svelte's `{#await}` rule:
|
|
320
383
|
|
|
321
384
|
```svelte
|
|
322
|
-
|
|
385
|
+
<!-- top-level await → blocks render → value baked into the initial HTML -->
|
|
386
|
+
<script>const post = await cache(getPost)({ id })</script>
|
|
323
387
|
|
|
324
|
-
{#await
|
|
325
|
-
|
|
326
|
-
{/await}
|
|
388
|
+
<!-- {#await} → shell flushes now, value streams in on the same response -->
|
|
389
|
+
{#await cache(getPost)({ id }) then post}…{/await}
|
|
327
390
|
```
|
|
328
391
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
`$derived`/`$effect` via `createSubscriber`, so `cache.invalidate` re-runs it.
|
|
333
|
-
|
|
334
|
-
**`bundled()`** (`belte/shared/bundled`) returns whether the code is running inside
|
|
335
|
-
the desktop bundle — `true` in the bundle's webview or its embedded server process,
|
|
336
|
-
`false` in a plain browser tab or a standalone server binary. Same name, same
|
|
337
|
-
meaning on both sides; each side detects it differently (client: the webview's init
|
|
338
|
-
script; server: the launcher's `BELTE_PARENT_PID`). Branch UI or behaviour on it
|
|
339
|
-
without threading a flag through your code.
|
|
392
|
+
There is no `ssr` option — the consumption form is the switch. (A top-level await
|
|
393
|
+
flips Svelte's whole component instance into await-everything mode, so isolate
|
|
394
|
+
blocking and streaming reads in separate components.)
|
|
340
395
|
|
|
341
|
-
|
|
396
|
+
Cache keys are derived with `canonicalJson.ts`, which tags types so they never
|
|
397
|
+
collide: a `Date` never equals the string of its ISO form, a `Map` never equals a
|
|
398
|
+
plain object, and `Set`/`bigint`/`-0` each key distinctly.
|
|
342
399
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
`error.svelte`. Dynamic segments use `[id]` / `[...rest]` folder names.
|
|
400
|
+
`HttpError` (`shared/HttpError`) carries `status`, `statusText`, and the raw
|
|
401
|
+
`response` for error UI without opting into `.raw`.
|
|
346
402
|
|
|
347
|
-
|
|
348
|
-
- **Error pages** (`error.svelte`) render server-side for a 404 (unknown route) or
|
|
349
|
-
a throw during a page render, nearest-only, with `{ status, message, stack }`
|
|
350
|
-
props. The document ships static (no hydration).
|
|
403
|
+
### Browser
|
|
351
404
|
|
|
352
|
-
|
|
353
|
-
|
|
405
|
+
- **Pages** are folder-based Svelte 5 components: `src/browser/pages/**/page.svelte`,
|
|
406
|
+
the URL is the directory path. `[name]` is a dynamic param, `[...rest]` a
|
|
407
|
+
catch-all; params arrive as `$props`.
|
|
408
|
+
- **Layouts** are `layout.svelte` files; the nearest ancestor wraps a page
|
|
409
|
+
(nearest-only, not nested chains).
|
|
410
|
+
- **Error pages** are `error.svelte` files (nearest-only); they render on the server
|
|
411
|
+
for an unknown route (404) or a throw during render, receiving `{ status, message }`.
|
|
354
412
|
|
|
355
413
|
```ts
|
|
356
|
-
|
|
414
|
+
// shared/cache reactivity is implicit — createSubscriber drives the lifecycle
|
|
415
|
+
const post = $derived(await cache(getPost)({ id }))
|
|
357
416
|
```
|
|
358
417
|
|
|
359
|
-
|
|
360
|
-
|
|
418
|
+
**`subscribe(subscribable)`** (`browser/subscribe`) reactively reads a streaming
|
|
419
|
+
source — a `Socket<T>` or `fn.stream(args)`. The first `$derived` read opens the
|
|
420
|
+
underlying iterator; the last to stop reading closes it; readers of the same key
|
|
421
|
+
share one subscription.
|
|
361
422
|
|
|
362
|
-
|
|
363
|
-
|
|
423
|
+
```svelte
|
|
424
|
+
<script>
|
|
425
|
+
import { subscribe } from '@belte/belte/browser/subscribe'
|
|
426
|
+
const latest = $derived(subscribe(chat)) // socket
|
|
427
|
+
const tick = $derived(subscribe(tickFeed.stream({ to: 5 }))) // rpc stream
|
|
428
|
+
const err = $derived(subscribe.error(chat)) // surfaced, never thrown
|
|
429
|
+
const status = $derived(subscribe.status(chat)) // 'pending' | 'open' | 'done' | 'error'
|
|
430
|
+
</script>
|
|
431
|
+
```
|
|
364
432
|
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
| `page.route` | the matched route key | discriminant |
|
|
368
|
-
| `page.params` | param shape for that route | typed from the generated routes |
|
|
369
|
-
| `page.url` | live `URL` | reassigned on every nav so `$derived` re-runs |
|
|
433
|
+
`subscribe` is a no-op during SSR — seed initial HTML with `cache()` against an
|
|
434
|
+
HTTP verb, then layer `subscribe()` for live updates after hydration.
|
|
370
435
|
|
|
371
|
-
**`
|
|
372
|
-
streaming source — a `Socket<T>` or `fn.stream(args)`:
|
|
436
|
+
**`navigate(href, options?)`** (`browser/navigate`) does SPA navigation:
|
|
373
437
|
|
|
374
|
-
```
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
import { chat } from '$server/sockets/chat'
|
|
378
|
-
const latest = $derived(subscribe(chat)) // T | undefined
|
|
379
|
-
const status = $derived(subscribe.status(chat)) // 'pending' | 'open' | 'done' | 'error'
|
|
380
|
-
const err = $derived(subscribe.error(chat)) // Error | undefined
|
|
381
|
-
</script>
|
|
438
|
+
```ts
|
|
439
|
+
type NavigateOptions = { replace?: boolean; scroll?: boolean }
|
|
440
|
+
await navigate('/posts/42')
|
|
382
441
|
```
|
|
383
442
|
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
443
|
+
A same-pathname change skips the network round-trip and just reassigns `page.url`;
|
|
444
|
+
a non-SPA target hard-navigates cleanly.
|
|
445
|
+
|
|
446
|
+
**`page`** (`browser/page`) is reactive `$state`. Narrowing on `page.route` narrows
|
|
447
|
+
`page.params` to the matching shape.
|
|
448
|
+
|
|
449
|
+
| field | type | meaning |
|
|
450
|
+
| --- | --- | --- |
|
|
451
|
+
| `page.route` | route key | the matched route (e.g. `/posts/[id]`) |
|
|
452
|
+
| `page.params` | params for the route | path params, typed per route |
|
|
453
|
+
| `page.url` | `URL` | live location; reassigned on every navigation |
|
|
388
454
|
|
|
389
455
|
### Mcp
|
|
390
456
|
|
|
391
|
-
The MCP server is
|
|
457
|
+
The MCP server is generated at `/__belte/mcp` (JSON-RPC over HTTP, protocol
|
|
392
458
|
`2025-06-18`) — there is no module to author.
|
|
393
459
|
|
|
394
|
-
- **Tools** come from every verb with `clients.mcp` (read-only + schema
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
`description` and an `arguments` list; the body interpolates `{{name}}`
|
|
404
|
-
placeholders at `prompts/get`.
|
|
460
|
+
- **Tools** come from every verb with `clients.mcp: true` (read-only + schema
|
|
461
|
+
auto-on; mutations opt in) and every mcp-exposed socket (a `<base>-tail` read tool,
|
|
462
|
+
plus `<base>-publish` when `clientPublish` is set). The HTTP verb feeds each tool's
|
|
463
|
+
`readOnlyHint` / `destructiveHint` / `idempotentHint` annotations. Auth inherits
|
|
464
|
+
from the inbound request.
|
|
465
|
+
- **Resources** are files under `src/mcp/resources/`, served at
|
|
466
|
+
`belte://resources/<path>` (text inline, binary as base64). No module to author.
|
|
467
|
+
- **Prompts** are `src/mcp/prompts/**.md` files: optional YAML frontmatter
|
|
468
|
+
(`description`, `arguments`) plus a body interpolated via `{{name}}` placeholders.
|
|
405
469
|
|
|
406
470
|
```md
|
|
407
471
|
---
|
|
408
|
-
description:
|
|
472
|
+
description: Summarise a thread
|
|
409
473
|
arguments:
|
|
410
474
|
- name: topic
|
|
411
|
-
description: what to focus on
|
|
412
475
|
required: true
|
|
413
476
|
---
|
|
414
|
-
|
|
477
|
+
Summarise the discussion about {{topic}}.
|
|
415
478
|
```
|
|
416
479
|
|
|
417
480
|
### Cli
|
|
418
481
|
|
|
419
|
-
`belte cli` builds a thin remote
|
|
420
|
-
|
|
421
|
-
|
|
482
|
+
`belte cli` builds a thin remote client — it carries no handler code, talks to a
|
|
483
|
+
running server over HTTP, and ships the compiled server beside it so it can spawn a
|
|
484
|
+
local instance.
|
|
485
|
+
|
|
486
|
+
- Connection state comes from `BELTE_APP_URL` / `BELTE_APP_TOKEN` (shell env >
|
|
487
|
+
data-dir `.env` > binary-dir `.env`). A downloaded binary resumes against its
|
|
488
|
+
baked default.
|
|
489
|
+
- The first positional decides the action: `/`-prefixed verbs manage the connection,
|
|
490
|
+
a bare word runs an rpc.
|
|
422
491
|
|
|
423
|
-
|
|
|
492
|
+
| command | does |
|
|
424
493
|
| --- | --- |
|
|
425
|
-
|
|
|
426
|
-
| (none) on a TTY | interactive session, resuming the saved connection |
|
|
494
|
+
| `<cmd> [--flags]` | one-shot rpc against the resumed target |
|
|
427
495
|
| `/connect <url>` | connect to a remote server, open a session |
|
|
428
496
|
| `/start` | boot a local instance, open a session |
|
|
429
497
|
| `/disconnect` | forget the saved connection |
|
|
430
|
-
|
|
|
498
|
+
| `/help [cmd]` | help, per-command with an arg |
|
|
499
|
+
| *(none)* on a TTY | interactive session resuming the saved connection |
|
|
431
500
|
|
|
432
|
-
|
|
433
|
-
> data-dir `.env` > binary-dir `.env`). The token sets a `Bearer` auth header, so an
|
|
434
|
-
authenticated server's CLI (and its authenticated binary downloads via
|
|
435
|
-
`/__belte/cli`) work the same.
|
|
501
|
+
Schema-bearing rpcs become subcommands; the JSON Schema types the flags:
|
|
436
502
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
| Schema type | Flag form |
|
|
503
|
+
| property type | flag form |
|
|
440
504
|
| --- | --- |
|
|
441
505
|
| `boolean` | `--name` / `--no-name` |
|
|
442
506
|
| `number` / `integer` | `--name <n>` (coerced) |
|
|
443
507
|
| `array` | repeated `--name <v>` |
|
|
444
|
-
| anything else | `--name <value>` |
|
|
445
|
-
| complex
|
|
508
|
+
| anything else | `--name <value>` (string) |
|
|
509
|
+
| complex shapes | `--json '<args>'`, or pipe a JSON object on stdin |
|
|
446
510
|
|
|
447
|
-
|
|
448
|
-
|
|
511
|
+
A running server hands out the client: `GET /__belte/cli` returns a POSIX install
|
|
512
|
+
script (detects OS+arch, downloads the right tarball); `GET /__belte/cli/<platform>`
|
|
513
|
+
streams a gzipped tarball of the platform binary, its sibling server, and a `.env`
|
|
514
|
+
carrying `BELTE_APP_URL` (and `BELTE_APP_TOKEN` if the request was authenticated).
|
|
515
|
+
`src/cli/banner.txt` and `src/cli/footer.txt` wrap the help output.
|
|
449
516
|
|
|
450
517
|
### Bundle
|
|
451
518
|
|
|
452
519
|
`belte bundle` assembles a movable, self-contained desktop app for the host
|
|
453
|
-
platform
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
520
|
+
platform (a `.app` on macOS, a flat directory elsewhere) — the server binary, the
|
|
521
|
+
launcher, and the native webview lib together. It boots into a connect screen that
|
|
522
|
+
can **start the embedded server** or **connect to a remote one**.
|
|
523
|
+
|
|
524
|
+
> Bundles are **unsigned** — distributing to other users still needs platform
|
|
525
|
+
> signing/notarization, and macOS Gatekeeper will warn until then.
|
|
526
|
+
|
|
527
|
+
- **`src/bundle/window.ts`** default-exports a `BundleWindow`:
|
|
528
|
+
|
|
529
|
+
```ts
|
|
530
|
+
type BundleWindow = {
|
|
531
|
+
title?: string
|
|
532
|
+
width?: number
|
|
533
|
+
height?: number
|
|
534
|
+
menu?: BundleMenu[] // custom top-level menus between Edit and Window
|
|
535
|
+
config?: StandardSchemaV1 // overrides the first-run setup form (defaults to the env schema)
|
|
536
|
+
}
|
|
537
|
+
```
|
|
538
|
+
|
|
539
|
+
- The standard App/Edit/Window menus plus a File menu (Start / Connect /
|
|
540
|
+
Disconnect) are always installed. Custom menu items are either an `emit` (a
|
|
541
|
+
`belte:menu` CustomEvent into the page) or a `navigate` (repoints the window).
|
|
542
|
+
- **`onMenu`** (`bundle/onMenu`) subscribes to those emits inside a Svelte `$effect`:
|
|
469
543
|
|
|
470
544
|
```ts
|
|
471
545
|
$effect(() => onMenu('reload', () => location.reload()))
|
|
472
546
|
```
|
|
473
547
|
|
|
474
|
-
- **`
|
|
548
|
+
- **`src/bundle/disconnected.svelte`** overrides the default connect screen.
|
|
549
|
+
- **`src/bundle/icon.png`** is the app icon.
|
|
550
|
+
- **`bundled()`** (`shared/bundled`) is `true` when running inside the desktop
|
|
551
|
+
webview (or, server-side, the embedded server process).
|
|
552
|
+
|
|
553
|
+
---
|
|
475
554
|
|
|
476
555
|
## Some details
|
|
477
556
|
|
|
478
|
-
|
|
479
|
-
(`belte/server/env`) to validate `Bun.env` against a Standard Schema at boot; a
|
|
480
|
-
missing/malformed var fails the boot with every issue listed. belte eager-imports
|
|
481
|
-
the file (no import from your code); import `config` from `$server/config`
|
|
482
|
-
server-side. `appDataDir()` (`belte/server/appDataDir`) returns the bundled app's
|
|
483
|
-
per-user data directory.
|
|
557
|
+
### Config / env
|
|
484
558
|
|
|
485
|
-
|
|
486
|
-
|
|
559
|
+
`env(schema)` (`server/env`) validates `Bun.env` against a Standard Schema at boot
|
|
560
|
+
— a missing or malformed variable fails the boot with every issue listed, instead
|
|
561
|
+
of surfacing as `undefined` in a handler. The conventional home is
|
|
562
|
+
`src/server/config.ts`, eager-imported at boot:
|
|
487
563
|
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
564
|
+
```ts
|
|
565
|
+
// src/server/config.ts
|
|
566
|
+
import { env } from '@belte/belte/server/env'
|
|
567
|
+
import { z } from 'zod'
|
|
568
|
+
export const config = env(z.object({ DATABASE_URL: z.string(), STRIPE_KEY: z.string() }))
|
|
569
|
+
```
|
|
570
|
+
|
|
571
|
+
The same schema drives the desktop bundle's first-run setup form. `appDataDir()`
|
|
572
|
+
(`server/appDataDir`) returns the running bundle's per-user data directory.
|
|
494
573
|
|
|
495
|
-
|
|
574
|
+
### App hooks
|
|
575
|
+
|
|
576
|
+
All optional, exported from `src/app.ts`:
|
|
577
|
+
|
|
578
|
+
| hook | signature | role |
|
|
579
|
+
| --- | --- | --- |
|
|
580
|
+
| `forwardHeaders` | `string[]` | extra inbound header names to forward onto in-process rpc Requests |
|
|
581
|
+
| `init` | `({ server }) => void \| (() => void)` | boot setup; an optional returned cleanup runs on SIGINT/SIGTERM |
|
|
582
|
+
| `handle` | `(req, next) => Response` | single middleware; mutate the response or branch on the URL |
|
|
583
|
+
| `handleError` | `(error, req) => Response` | catches thrown handler errors |
|
|
584
|
+
|
|
585
|
+
### Project layout
|
|
496
586
|
|
|
497
587
|
```text
|
|
498
588
|
src/
|
|
499
|
-
app.ts
|
|
589
|
+
app.ts # optional hooks
|
|
500
590
|
server/
|
|
501
|
-
config.ts
|
|
502
|
-
rpc/<name>.ts
|
|
503
|
-
sockets/<name>.ts
|
|
504
|
-
lib/ userland (declare your own aliases)
|
|
591
|
+
config.ts # env(schema)
|
|
592
|
+
rpc/<name>.ts # one verb each → /rpc/<name>
|
|
593
|
+
sockets/<name>.ts # one socket each
|
|
505
594
|
browser/
|
|
506
|
-
|
|
507
|
-
pages/**/
|
|
508
|
-
|
|
595
|
+
pages/**/page.svelte # routes
|
|
596
|
+
pages/**/layout.svelte # nearest-only layouts
|
|
597
|
+
pages/**/error.svelte # nearest-only error pages
|
|
598
|
+
public/ # static files served at the site root
|
|
509
599
|
mcp/
|
|
510
|
-
|
|
511
|
-
|
|
600
|
+
resources/ # belte://resources/<path>
|
|
601
|
+
prompts/**.md # MCP prompts
|
|
512
602
|
bundle/
|
|
513
|
-
window.ts
|
|
514
|
-
disconnected.svelte
|
|
603
|
+
window.ts # BundleWindow
|
|
604
|
+
disconnected.svelte # connect screen override
|
|
515
605
|
icon.png
|
|
516
606
|
cli/
|
|
517
|
-
banner.txt
|
|
607
|
+
banner.txt
|
|
608
|
+
footer.txt
|
|
518
609
|
```
|
|
519
610
|
|
|
520
|
-
|
|
521
|
-
project directories; `lib/` is userland.
|
|
522
|
-
|
|
523
|
-
**CLI commands**
|
|
611
|
+
### CLI commands
|
|
524
612
|
|
|
525
|
-
|
|
|
613
|
+
| command | does |
|
|
526
614
|
| --- | --- |
|
|
527
615
|
| `bunx belte scaffold <name>` | scaffold a new project |
|
|
528
|
-
| `belte dev` | build
|
|
616
|
+
| `belte dev` | build + run with hot reload |
|
|
529
617
|
| `belte build` | build the client into `dist/_app/` |
|
|
530
618
|
| `belte start` | run the production server against `dist/` |
|
|
531
619
|
| `belte run <file> [args]` | run a script under the belte preload (same runtime as the server) |
|
|
532
620
|
| `belte compile [--target] [--out]` | build a standalone server executable |
|
|
533
|
-
| `belte cli [--target] [--out] [--platforms
|
|
534
|
-
| `belte bundle` | build a movable
|
|
621
|
+
| `belte cli [--target] [--out] [--platforms]` | build the thin CLI binary |
|
|
622
|
+
| `belte bundle` | build a movable desktop app for this platform |
|
|
535
623
|
|
|
536
|
-
|
|
624
|
+
### Bundling targets
|
|
537
625
|
|
|
538
|
-
|
|
539
|
-
| --- | --- | --- |
|
|
540
|
-
| `belte build` | `dist/_app/` client assets | web (with `belte start`) |
|
|
541
|
-
| `belte compile` | one server executable | self-hosted HTTP |
|
|
542
|
-
| `belte cli` | thin client binary + sibling server | CLI / scripting |
|
|
543
|
-
| `belte bundle` | movable app (`.app` on macOS) | desktop |
|
|
626
|
+
`--target` / `--platforms` accept Bun's target triples:
|
|
544
627
|
|
|
545
|
-
|
|
546
|
-
|
|
628
|
+
| target |
|
|
629
|
+
| --- |
|
|
630
|
+
| `bun-darwin-arm64` |
|
|
631
|
+
| `bun-darwin-x64` |
|
|
632
|
+
| `bun-linux-arm64` |
|
|
633
|
+
| `bun-linux-x64` |
|
|
634
|
+
| `bun-windows-x64` |
|
|
547
635
|
|
|
548
|
-
|
|
549
|
-
and per-method/status coloring. `DEBUG` follows the `debug` convention; `DEBUG=belte`
|
|
550
|
-
turns on request logging and prints the boot surface map.
|
|
636
|
+
### Logging
|
|
551
637
|
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
| `PORT` | bind this exact port; unset scans upward from 3000 |
|
|
557
|
-
| `BELTE_IDLE_TIMEOUT` | Bun per-connection idle timeout (seconds; default 10) |
|
|
558
|
-
| `DEBUG` | `belte` enables request logs + the surface map |
|
|
559
|
-
| `BELTE_APP_URL` / `BELTE_APP_TOKEN` | CLI binary's default server + bearer token |
|
|
638
|
+
The shared logger prefixes `[belte]` and colours request lines by method/status.
|
|
639
|
+
`DEBUG=<scope>` enables scoped debug output; **`DEBUG=belte` prints the boot
|
|
640
|
+
surface map** shown at the top of this document — the auditable list of every page,
|
|
641
|
+
socket, and rpc with the surfaces it reaches.
|