@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
@@ -0,0 +1,13 @@
1
+ import type { HttpVerb } from '../server/rpc/types/HttpVerb.ts'
2
+
3
+ /*
4
+ Whether a verb carries its args in the request body (POST/PUT/PATCH) vs
5
+ on the query string (GET/DELETE/HEAD). Single source for the split so the
6
+ synthesized Request (buildRpcRequest), the handler-side parse (parseArgs),
7
+ the cache key (keyForRemoteCall), and the OpenAPI doc can't disagree.
8
+ */
9
+ const BODY_METHODS = new Set<HttpVerb>(['POST', 'PUT', 'PATCH'])
10
+
11
+ export function carriesBodyArgs(method: HttpVerb): boolean {
12
+ return BODY_METHODS.has(method)
13
+ }
@@ -0,0 +1,14 @@
1
+ import type { HttpVerb } from '../server/rpc/types/HttpVerb.ts'
2
+
3
+ /*
4
+ Read-only (safe) HTTP methods — they don't mutate server state. Belte
5
+ uses this to decide which verbs auto-expose to MCP: reads flip MCP on
6
+ when a schema is present, mutations require an explicit `clients.mcp`
7
+ opt-in so a model can't delete/overwrite data just because the handler
8
+ carries a schema. Also feeds the MCP tool `readOnlyHint` annotation.
9
+ */
10
+ const READ_ONLY_METHODS = new Set<HttpVerb>(['GET', 'HEAD'])
11
+
12
+ export function isReadOnlyMethod(method: HttpVerb): boolean {
13
+ return READ_ONLY_METHODS.has(method)
14
+ }
@@ -0,0 +1,11 @@
1
+ import { STREAMING_CONTENT_TYPES } from './streamingContentTypes.ts'
2
+
3
+ /*
4
+ Whether a Response carries a streaming body (SSE / JSONL / NDJSON) by its
5
+ Content-Type, so callers drain it frame-by-frame instead of buffering.
6
+ Shared by the CLI print path and the MCP tool dispatcher.
7
+ */
8
+ export function isStreamingResponse(response: Response): boolean {
9
+ const contentType = (response.headers.get('content-type') ?? '').toLowerCase()
10
+ return STREAMING_CONTENT_TYPES.some((type) => contentType.startsWith(type))
11
+ }
@@ -0,0 +1,24 @@
1
+ /*
2
+ The in-band error sentinel for a JSONL/NDJSON stream: when a handler's
3
+ generator throws, `jsonl()` emits a final `{"$error":"<message>"}` line,
4
+ and `streamResponse` re-throws it on the consumer side. Encoder and decoder
5
+ live together here so the sentinel field has one definition and can't drift
6
+ between the two ends of the wire.
7
+ */
8
+ export const jsonlErrorFrame = {
9
+ // Error line for a thrown message, including the trailing newline.
10
+ encode(message: string): string {
11
+ return `${JSON.stringify({ $error: message })}\n`
12
+ },
13
+ // The message carried by a parsed line, or undefined when it isn't the error sentinel.
14
+ decode(parsed: unknown): string | undefined {
15
+ if (
16
+ parsed &&
17
+ typeof parsed === 'object' &&
18
+ typeof (parsed as { $error?: unknown }).$error === 'string'
19
+ ) {
20
+ return (parsed as { $error: string }).$error
21
+ }
22
+ return undefined
23
+ },
24
+ }
@@ -1,5 +1,6 @@
1
1
  import type { HttpVerb } from '../server/rpc/types/HttpVerb.ts'
2
2
  import { canonicalJson } from './canonicalJson.ts'
3
+ import { carriesBodyArgs } from './carriesBodyArgs.ts'
3
4
 
4
5
  /*
5
6
  Derives a cache key from a verb-defined remote function and its args. The
@@ -14,7 +15,7 @@ URLSearchParams).
14
15
  */
15
16
  export function keyForRemoteCall(method: HttpVerb, url: string, args: unknown): string {
16
17
  const prefix = `${method} ${url}`
17
- if (method === 'GET' || method === 'DELETE' || method === 'HEAD') {
18
+ if (!carriesBodyArgs(method)) {
18
19
  if (args && typeof args === 'object' && !Array.isArray(args)) {
19
20
  const record = args as Record<string, unknown>
20
21
  const keys = Object.keys(record).sort()
@@ -0,0 +1,17 @@
1
+ import { readEnvFile } from './readEnvFile.ts'
2
+
3
+ /*
4
+ Reads a `.env` at `path` and merges each declared var into `process.env`
5
+ only when not already set. Fill-when-unset is the precedence rule the whole
6
+ env stack relies on: layers loaded earlier (shell/ambient, Bun's CWD `.env`)
7
+ win, and later callers back-fill only what's still missing. So a value's
8
+ source is invisible to the app — it reads one flat `process.env` (Bun.env is
9
+ the same object). Missing file is a no-op.
10
+ */
11
+ export async function loadEnvFile(path: string): Promise<void> {
12
+ for (const [key, value] of Object.entries(await readEnvFile(path))) {
13
+ if (process.env[key] === undefined) {
14
+ process.env[key] = value
15
+ }
16
+ }
17
+ }
@@ -0,0 +1,15 @@
1
+ import { join } from 'node:path'
2
+ import { appDataDir } from './appDataDir.ts'
3
+ import { loadEnvFile } from './loadEnvFile.ts'
4
+
5
+ /*
6
+ Loads the user's `.env` from the program's per-user data dir into `process.env`.
7
+ This is the cwd-independent config layer — where the connect-screen form writes
8
+ the user's answers, and where a bundle launched via `open` (cwd `/`, so Bun's
9
+ CWD `.env` autoload finds nothing) actually picks up its config. Loaded before
10
+ the binary-dir `.env`, so a user's saved config overrides the shipped default;
11
+ still loses to a shell export or a CWD `.env` (fill-when-unset, see loadEnvFile).
12
+ */
13
+ export async function loadEnvFromDataDir(programName: string): Promise<void> {
14
+ await loadEnvFile(join(appDataDir(programName), '.env'))
15
+ }
@@ -0,0 +1,30 @@
1
+ const ENV_LINE = /^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/
2
+
3
+ /*
4
+ Parses `.env` text into a key→value record. Skips blanks, comments, and
5
+ malformed lines; strips a single layer of surrounding single or double quotes.
6
+ Intentionally minimal — no variable expansion, escapes, or multi-line. The pure
7
+ counterpart to loadEnvFile (which merges into process.env) and serializeEnv
8
+ (which writes records back), so all three round-trip the same shape.
9
+ */
10
+ export function parseEnv(text: string): Record<string, string> {
11
+ const result: Record<string, string> = {}
12
+ for (const line of text.split('\n')) {
13
+ if (!line || line.startsWith('#')) {
14
+ continue
15
+ }
16
+ const match = ENV_LINE.exec(line)
17
+ if (!match) {
18
+ continue
19
+ }
20
+ const [, key, rawValue] = match
21
+ const trimmed = rawValue?.trim() ?? ''
22
+ const unquoted =
23
+ (trimmed.startsWith('"') && trimmed.endsWith('"')) ||
24
+ (trimmed.startsWith("'") && trimmed.endsWith("'"))
25
+ ? trimmed.slice(1, -1)
26
+ : trimmed
27
+ result[key as string] = unquoted
28
+ }
29
+ return result
30
+ }
@@ -0,0 +1,15 @@
1
+ import { existsSync } from 'node:fs'
2
+ import { parseEnv } from './parseEnv.ts'
3
+
4
+ /*
5
+ Reads a `.env` at `path` into a key→value record, or {} when it doesn't exist.
6
+ The shared read-and-parse primitive: loadEnvFile builds on it to merge into
7
+ process.env, and the bundle launcher uses the record directly to resolve config
8
+ form pre-fills.
9
+ */
10
+ export async function readEnvFile(path: string): Promise<Record<string, string>> {
11
+ if (!existsSync(path)) {
12
+ return {}
13
+ }
14
+ return parseEnv(await Bun.file(path).text())
15
+ }
@@ -2,17 +2,19 @@ import type { ClientFlags } from './types/ClientFlags.ts'
2
2
 
3
3
  /*
4
4
  Fills in the missing keys of a user-supplied `clients` option. Browser
5
- defaults to true (the historical surface); mcp/cli default to true only
6
- when a schema is attached, since exposing an unvalidated handler as a
7
- tool / shell command is a foot-gun.
5
+ always defaults to true (the historical surface). The mcp/cli auto-on
6
+ defaults are decided by the caller and passed in, since the safe default
7
+ differs per declaration: a read-only verb may auto-expose to MCP while a
8
+ mutating one must not, and sockets gate differently again. Explicit
9
+ values in `flags` always win over the computed defaults.
8
10
  */
9
11
  export function resolveClientFlags(
10
12
  flags: Partial<ClientFlags> | undefined,
11
- hasSchema: boolean,
13
+ defaults: { mcp: boolean; cli: boolean },
12
14
  ): ClientFlags {
13
15
  return {
14
16
  browser: flags?.browser ?? true,
15
- mcp: flags?.mcp ?? hasSchema,
16
- cli: flags?.cli ?? hasSchema,
17
+ mcp: flags?.mcp ?? defaults.mcp,
18
+ cli: flags?.cli ?? defaults.cli,
17
19
  }
18
20
  }
@@ -0,0 +1,9 @@
1
+ /*
2
+ Human-readable message for a non-2xx Response: `status statusText: body`.
3
+ Shared by the CLI error path and the MCP tool result so the two frame a
4
+ failed request identically. Consumes the body, so call only on a response
5
+ you're done with.
6
+ */
7
+ export async function responseErrorText(response: Response): Promise<string> {
8
+ return `${response.status} ${response.statusText}: ${await response.text()}`
9
+ }
@@ -0,0 +1,18 @@
1
+ // Quote only values that wouldn't round-trip bare through parseEnv — empties and
2
+ // anything carrying whitespace or a `#` (which would otherwise read as a comment).
3
+ function needsQuoting(value: string): boolean {
4
+ return value === '' || /[\s#]/.test(value)
5
+ }
6
+
7
+ /*
8
+ Serializes a key→value record to `.env` text — the inverse of parseEnv, used by
9
+ the connect-screen config form to persist the user's answers to the data-dir
10
+ `.env`. One `KEY=value` per line; values that need it are wrapped in double
11
+ quotes so parseEnv reads them back unchanged.
12
+ */
13
+ export function serializeEnv(values: Record<string, string>): string {
14
+ const lines = Object.entries(values).map(([key, value]) =>
15
+ needsQuoting(value) ? `${key}="${value}"` : `${key}=${value}`,
16
+ )
17
+ return `${lines.join('\n')}\n`
18
+ }
@@ -0,0 +1,29 @@
1
+ /*
2
+ The in-band error sentinel for an SSE stream: when a handler's generator
3
+ throws, `sse()` emits an `event: error` frame whose data is `{ message }`,
4
+ and `streamResponse` re-throws it on the consumer side. Encoder and decoder
5
+ live together here so the sentinel (event name + payload shape) has one
6
+ definition and can't drift between the two ends of the wire.
7
+ */
8
+ export const sseErrorFrame = {
9
+ // Full `event: error` frame for a thrown message, including the SSE delimiters.
10
+ encode(message: string): string {
11
+ return `event: error\ndata: ${JSON.stringify({ message })}\n\n`
12
+ },
13
+ /*
14
+ The message carried by an error frame, or undefined when `event` isn't
15
+ the error sentinel. Falls back to the raw data when it isn't the JSON
16
+ the encoder produces.
17
+ */
18
+ decode(event: string, data: string): string | undefined {
19
+ if (event !== 'error') {
20
+ return undefined
21
+ }
22
+ try {
23
+ const decoded = JSON.parse(data) as { message?: string }
24
+ return decoded?.message ?? 'sse stream error'
25
+ } catch {
26
+ return data || 'sse stream error'
27
+ }
28
+ },
29
+ }
@@ -0,0 +1,168 @@
1
+ import { HttpError } from '../server/HttpError.ts'
2
+ import { decodeResponse } from './decodeResponse.ts'
3
+ import { jsonlErrorFrame } from './jsonlErrorFrame.ts'
4
+ import { sseErrorFrame } from './sseErrorFrame.ts'
5
+ import { STREAMING_CONTENT_TYPES } from './streamingContentTypes.ts'
6
+
7
+ /*
8
+ Turns a Response into an AsyncIterable of frames, regardless of the
9
+ handler's chosen body format. Shared by `fn.stream(args)` (via
10
+ subscribableFromResponse), the CLI's streaming print path, and the MCP
11
+ tool dispatcher's stream drain — so every surface consumes sse/jsonl
12
+ identically. Three shapes are handled:
13
+
14
+ - text/event-stream (SSE): emits the JSON-parsed `data:` payload of
15
+ each event. The `event: error\ndata: {message}` frame the `sse()`
16
+ helper emits on generator throws is mapped back to a thrown Error so
17
+ consumers see the failure mid-iteration.
18
+ - application/jsonl + application/x-ndjson: emits one JSON value per
19
+ line. The trailing `{"$error":"..."}` line the `jsonl()` helper
20
+ emits on generator throws is likewise re-thrown.
21
+ - everything else: one-shot — yields the Content-Type-decoded body
22
+ once, then completes. Lets callers iterate uniformly on every rpc
23
+ handler, not just the streaming ones.
24
+
25
+ Non-2xx responses surface as a thrown HttpError on the first pull,
26
+ mirroring the plain `fn(args)` decode path.
27
+ */
28
+ export function streamResponse<T>(response: Response): AsyncIterable<T> {
29
+ if (!response.ok) {
30
+ return errorIterable<T>(new HttpError(response))
31
+ }
32
+ const contentType = (response.headers.get('content-type') ?? '').toLowerCase()
33
+ if (contentType.startsWith('text/event-stream')) {
34
+ return parseSse<T>(response)
35
+ }
36
+ if (STREAMING_CONTENT_TYPES.some((type) => contentType.startsWith(type))) {
37
+ return parseJsonLines<T>(response)
38
+ }
39
+ return oneShot<T>(response)
40
+ }
41
+
42
+ /* Surfaces a non-2xx response (or any pre-stream failure) as a thrown error on the first pull. */
43
+ async function* errorIterable<T>(error: Error): AsyncGenerator<T> {
44
+ throw error
45
+ }
46
+
47
+ /*
48
+ One-shot iterator over a non-streaming Response: decodes the body once
49
+ via the same Content-Type sniffing the plain call uses, yields it, then
50
+ completes. Makes streaming symmetrical across streaming and
51
+ non-streaming handlers — callers can pick the iteration shape without
52
+ worrying about which body the handler returned.
53
+ */
54
+ async function* oneShot<T>(response: Response): AsyncGenerator<T> {
55
+ yield (await decodeResponse(response)) as T
56
+ }
57
+
58
+ /*
59
+ Reads a streaming text Response and yields raw frame strings split on
60
+ `delimiter` (`\n\n` for SSE events, `\n` for JSON lines). Owns the whole
61
+ buffering lifecycle: incremental decode, amortised-O(n) compaction, a
62
+ final flush of the trailing partial frame, and reader cancellation when
63
+ the consumer stops iterating (the generator's `finally` runs on
64
+ `return()`). The SSE and jsonl parsers layer their per-frame parsing on
65
+ top of this single machine so the two can't drift.
66
+ */
67
+ async function* frameReader(response: Response, delimiter: string): AsyncGenerator<string> {
68
+ const body = response.body
69
+ if (!body) {
70
+ return
71
+ }
72
+ const reader = body.pipeThrough(new TextDecoderStream()).getReader()
73
+ let buffer = ''
74
+ let bufferStart = 0
75
+ try {
76
+ while (true) {
77
+ const { value, done } = await reader.read()
78
+ if (done) {
79
+ if (bufferStart < buffer.length) {
80
+ yield buffer.slice(bufferStart)
81
+ }
82
+ return
83
+ }
84
+ /*
85
+ Compact only when the unread region is small relative to the
86
+ consumed prefix — keeps amortised work O(n) instead of
87
+ quadratic slicing per frame boundary.
88
+ */
89
+ if (bufferStart > buffer.length / 2) {
90
+ buffer = buffer.slice(bufferStart) + value
91
+ bufferStart = 0
92
+ } else {
93
+ buffer += value
94
+ }
95
+ let boundary = buffer.indexOf(delimiter, bufferStart)
96
+ while (boundary !== -1) {
97
+ yield buffer.slice(bufferStart, boundary)
98
+ bufferStart = boundary + delimiter.length
99
+ boundary = buffer.indexOf(delimiter, bufferStart)
100
+ }
101
+ }
102
+ } finally {
103
+ await reader.cancel().catch(() => undefined)
104
+ }
105
+ }
106
+
107
+ /*
108
+ SSE parser: yields the JSON-parsed `data` payload of each `event:`/`data:`
109
+ frame. The error sentinel (`sseErrorFrame`) the `sse()` helper emits on a
110
+ generator throw is surfaced as a thrown Error so consumer loops can react
111
+ to mid-stream failure rather than silently stopping.
112
+ */
113
+ async function* parseSse<T>(response: Response): AsyncGenerator<T> {
114
+ for await (const raw of frameReader(response, '\n\n')) {
115
+ const frame = parseFrame(raw)
116
+ if (!frame) {
117
+ continue
118
+ }
119
+ const errorMessage = sseErrorFrame.decode(frame.event, frame.data)
120
+ if (errorMessage !== undefined) {
121
+ throw new Error(errorMessage)
122
+ }
123
+ yield JSON.parse(frame.data) as T
124
+ }
125
+ }
126
+
127
+ function parseFrame(raw: string): { event: string; data: string } | undefined {
128
+ const lines = raw.split('\n').filter((line) => line.length > 0 && !line.startsWith(':'))
129
+ if (lines.length === 0) {
130
+ return undefined
131
+ }
132
+ let event = 'message'
133
+ const dataLines: string[] = []
134
+ for (const line of lines) {
135
+ const colon = line.indexOf(':')
136
+ const field = colon === -1 ? line : line.slice(0, colon)
137
+ const value = colon === -1 ? '' : line.slice(colon + 1).replace(/^ /, '')
138
+ if (field === 'event') {
139
+ event = value
140
+ } else if (field === 'data') {
141
+ dataLines.push(value)
142
+ }
143
+ }
144
+ if (dataLines.length === 0) {
145
+ return undefined
146
+ }
147
+ return { event, data: dataLines.join('\n') }
148
+ }
149
+
150
+ /*
151
+ JSONL/NDJSON parser: parses each non-empty line as JSON and yields the
152
+ value. The error sentinel (`jsonlErrorFrame`) the `jsonl()` helper emits as
153
+ a trailing line on a generator throw is surfaced here as a thrown Error so
154
+ consumer loops can react to mid-stream failure.
155
+ */
156
+ async function* parseJsonLines<T>(response: Response): AsyncGenerator<T> {
157
+ for await (const raw of frameReader(response, '\n')) {
158
+ if (raw.length === 0) {
159
+ continue
160
+ }
161
+ const parsed = JSON.parse(raw)
162
+ const errorMessage = jsonlErrorFrame.decode(parsed)
163
+ if (errorMessage !== undefined) {
164
+ throw new Error(errorMessage)
165
+ }
166
+ yield parsed as T
167
+ }
168
+ }
@@ -1,177 +1,6 @@
1
- import { HttpError } from '../server/HttpError.ts'
2
- import { decodeResponse } from './decodeResponse.ts'
3
- import { STREAMING_CONTENT_TYPES } from './streamingContentTypes.ts'
1
+ import { streamResponse } from './streamResponse.ts'
4
2
  import type { Subscribable } from './types/Subscribable.ts'
5
3
 
6
- /*
7
- Turns a Response into an AsyncIterable of frames. Used by
8
- `fn.stream(args)` to give callers a uniform iterator regardless of the
9
- handler's chosen body format. Three shapes are handled:
10
-
11
- - text/event-stream (SSE): emits the JSON-parsed `data:` payload of
12
- each event. The `event: error\ndata: {message}` frame the `sse()`
13
- helper emits on generator throws is mapped back to a thrown Error so
14
- consumers see the failure mid-iteration.
15
- - application/jsonl + application/x-ndjson: emits one JSON value per
16
- line. The trailing `{"$error":"..."}` line the `jsonl()` helper
17
- emits on generator throws is likewise re-thrown.
18
- - everything else: one-shot — yields the Content-Type-decoded body
19
- once, then completes. Lets `fn.stream(args)` work uniformly on every
20
- rpc handler, not just the streaming ones.
21
-
22
- Non-2xx responses surface as a thrown HttpError on the first pull,
23
- mirroring the plain `fn(args)` decode path.
24
- */
25
- function streamResponse<T>(response: Response): AsyncIterable<T> {
26
- if (!response.ok) {
27
- return errorIterable<T>(new HttpError(response))
28
- }
29
- const contentType = (response.headers.get('content-type') ?? '').toLowerCase()
30
- if (contentType.startsWith('text/event-stream')) {
31
- return parseSse<T>(response)
32
- }
33
- if (STREAMING_CONTENT_TYPES.some((type) => contentType.startsWith(type))) {
34
- return parseJsonLines<T>(response)
35
- }
36
- return oneShot<T>(response)
37
- }
38
-
39
- /* Surfaces a non-2xx response (or any pre-stream failure) as a thrown error on the first pull. */
40
- async function* errorIterable<T>(error: Error): AsyncGenerator<T> {
41
- throw error
42
- }
43
-
44
- /*
45
- One-shot iterator over a non-streaming Response: decodes the body once
46
- via the same Content-Type sniffing the plain call uses, yields it, then
47
- completes. Makes `fn.stream(args)` symmetrical across streaming and
48
- non-streaming handlers — callers can pick the iteration shape without
49
- worrying about which body the handler returned.
50
- */
51
- async function* oneShot<T>(response: Response): AsyncGenerator<T> {
52
- yield (await decodeResponse(response)) as T
53
- }
54
-
55
- /*
56
- Reads a streaming text Response and yields raw frame strings split on
57
- `delimiter` (`\n\n` for SSE events, `\n` for JSON lines). Owns the whole
58
- buffering lifecycle: incremental decode, amortised-O(n) compaction, a
59
- final flush of the trailing partial frame, and reader cancellation when
60
- the consumer stops iterating (the generator's `finally` runs on
61
- `return()`). The SSE and jsonl parsers layer their per-frame parsing on
62
- top of this single machine so the two can't drift.
63
- */
64
- async function* frameReader(response: Response, delimiter: string): AsyncGenerator<string> {
65
- const body = response.body
66
- if (!body) {
67
- return
68
- }
69
- const reader = body.pipeThrough(new TextDecoderStream()).getReader()
70
- let buffer = ''
71
- let bufferStart = 0
72
- try {
73
- while (true) {
74
- const { value, done } = await reader.read()
75
- if (done) {
76
- if (bufferStart < buffer.length) {
77
- yield buffer.slice(bufferStart)
78
- }
79
- return
80
- }
81
- /*
82
- Compact only when the unread region is small relative to the
83
- consumed prefix — keeps amortised work O(n) instead of
84
- quadratic slicing per frame boundary.
85
- */
86
- if (bufferStart > buffer.length / 2) {
87
- buffer = buffer.slice(bufferStart) + value
88
- bufferStart = 0
89
- } else {
90
- buffer += value
91
- }
92
- let boundary = buffer.indexOf(delimiter, bufferStart)
93
- while (boundary !== -1) {
94
- yield buffer.slice(bufferStart, boundary)
95
- bufferStart = boundary + delimiter.length
96
- boundary = buffer.indexOf(delimiter, bufferStart)
97
- }
98
- }
99
- } finally {
100
- await reader.cancel().catch(() => undefined)
101
- }
102
- }
103
-
104
- /*
105
- SSE parser: yields the JSON-parsed `data` payload of each `event:`/`data:`
106
- frame. The `sse()` respond helper emits an `event: error\ndata:
107
- {"message":...}` frame when the source generator throws, which we surface
108
- as a thrown Error so consumer loops can react to mid-stream failure
109
- rather than silently stopping.
110
- */
111
- async function* parseSse<T>(response: Response): AsyncGenerator<T> {
112
- for await (const raw of frameReader(response, '\n\n')) {
113
- const frame = parseFrame(raw)
114
- if (!frame) {
115
- continue
116
- }
117
- if (frame.event === 'error') {
118
- try {
119
- const decoded = JSON.parse(frame.data) as { message?: string }
120
- throw new Error(decoded?.message ?? 'sse stream error')
121
- } catch (err) {
122
- if (err instanceof SyntaxError) {
123
- throw new Error(frame.data || 'sse stream error')
124
- }
125
- throw err
126
- }
127
- }
128
- yield JSON.parse(frame.data) as T
129
- }
130
- }
131
-
132
- function parseFrame(raw: string): { event: string; data: string } | undefined {
133
- const lines = raw.split('\n').filter((line) => line.length > 0 && !line.startsWith(':'))
134
- if (lines.length === 0) {
135
- return undefined
136
- }
137
- let event = 'message'
138
- const dataLines: string[] = []
139
- for (const line of lines) {
140
- const colon = line.indexOf(':')
141
- const field = colon === -1 ? line : line.slice(0, colon)
142
- const value = colon === -1 ? '' : line.slice(colon + 1).replace(/^ /, '')
143
- if (field === 'event') {
144
- event = value
145
- } else if (field === 'data') {
146
- dataLines.push(value)
147
- }
148
- }
149
- if (dataLines.length === 0) {
150
- return undefined
151
- }
152
- return { event, data: dataLines.join('\n') }
153
- }
154
-
155
- /*
156
- JSONL/NDJSON parser: parses each non-empty line as JSON and yields the
157
- value. The `jsonl()` respond helper emits a trailing
158
- `{"$error":"<message>"}` line when the source generator throws — that's
159
- surfaced here as a thrown Error so consumer loops can react to mid-stream
160
- failure.
161
- */
162
- async function* parseJsonLines<T>(response: Response): AsyncGenerator<T> {
163
- for await (const raw of frameReader(response, '\n')) {
164
- if (raw.length === 0) {
165
- continue
166
- }
167
- const parsed = JSON.parse(raw) as Record<string, unknown> & { $error?: string }
168
- if (parsed && typeof parsed === 'object' && typeof parsed.$error === 'string') {
169
- throw new Error(parsed.$error)
170
- }
171
- yield parsed as T
172
- }
173
- }
174
-
175
4
  /*
176
5
  Builds the Subscribable returned by `fn.stream(args)`. The carried
177
6
  `name` is the cache-style key for (method, url, args) so subscribe()
@@ -6,6 +6,11 @@ the function. `ttl`/`expiresAt` drive eviction: expiresAt = undefined means
6
6
  soon as the promise settles). The stored promise resolves to the raw
7
7
  Response so the snapshot can read its status/headers/body; the cache
8
8
  layer hands callers a decoded view derived from this same promise.
9
+
10
+ `value` is set only for entries hydrated from the SSR snapshot: the
11
+ snapshot body is pre-decoded synchronously so the first client render can
12
+ read it without a microtask hop and byte-match the SSR DOM. Live fetches
13
+ leave it undefined and take the async decode path.
9
14
  */
10
15
  export type CacheEntry = {
11
16
  key: string
@@ -13,4 +18,5 @@ export type CacheEntry = {
13
18
  request: Request
14
19
  ttl: number | undefined
15
20
  expiresAt: number | undefined
21
+ value?: unknown
16
22
  }
@@ -25,10 +25,22 @@ import { shell } from './_virtual/shell.ts'
25
25
  // @ts-expect-error virtual module resolved by belteResolverPlugin
26
26
  import { sockets } from './_virtual/sockets.ts'
27
27
  import { exitWithParent } from './lib/bundle/exitWithParent.ts'
28
+ import { loadEnvFromBinaryDir } from './lib/cli/loadEnvFromBinaryDir.ts'
28
29
  import { createServer } from './lib/server/runtime/createServer.ts'
29
30
  import { requestContext } from './lib/server/runtime/requestContext.ts'
31
+ import { loadEnvFromDataDir } from './lib/shared/loadEnvFromDataDir.ts'
30
32
  import { setCacheStoreResolver } from './lib/shared/setCacheStoreResolver.ts'
31
33
 
34
+ /*
35
+ Resolve config into process.env before anything reads it (createServer reads
36
+ PORT, app code reads Bun.env.*). Data-dir first so the user's saved config wins
37
+ over the binary-dir shipped default; both back-fill only what the shell or Bun's
38
+ CWD `.env` didn't already set. A bundle launched via `open` has cwd `/`, so the
39
+ data-dir `.env` is how it gets its config at all.
40
+ */
41
+ await loadEnvFromDataDir(cliProgramName)
42
+ await loadEnvFromBinaryDir()
43
+
32
44
  // In a bundle, tie this server's life to the launcher's (no-op standalone).
33
45
  exitWithParent()
34
46
 
Binary file
@@ -12,9 +12,11 @@ decoding) is inferred from the handler's return type via the
12
12
  `TypedResponse<T>` brand on `json`/`error`/`redirect`/`jsonl`/`sse`, so
13
13
  plain `GET(() => json({...}))` already types end-to-end.
14
14
 
15
- For inbound validation pass a Standard Schema-compatible schema as the
16
- second argument: `GET(fn, { schema })`. Args then infers from the schema's
17
- output type and the server replies with 422 on validation failure.
15
+ For inbound validation pass a Standard Schema as `inputSchema` in the
16
+ second argument: `GET(fn, { inputSchema })`. Args then infers from the
17
+ schema's output type and the server replies with 422 on validation
18
+ failure. An optional `outputSchema` describes the success body for the
19
+ OpenAPI 200 response and the MCP tool output.
18
20
 
19
21
  `json(...)` from `belte/server/json` is a thin wrapper over `Response.json`
20
22
  that defaults `Cache-Control: no-store`, since intermediary caches shouldn't