@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
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { HttpVerb } from '../server/rpc/types/HttpVerb.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Whether a verb carries its args in the request body (POST/PUT/PATCH) vs
|
|
5
|
+
on the query string (GET/DELETE/HEAD). Single source for the split so the
|
|
6
|
+
synthesized Request (buildRpcRequest), the handler-side parse (parseArgs),
|
|
7
|
+
the cache key (keyForRemoteCall), and the OpenAPI doc can't disagree.
|
|
8
|
+
*/
|
|
9
|
+
const BODY_METHODS = new Set<HttpVerb>(['POST', 'PUT', 'PATCH'])
|
|
10
|
+
|
|
11
|
+
export function carriesBodyArgs(method: HttpVerb): boolean {
|
|
12
|
+
return BODY_METHODS.has(method)
|
|
13
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { HttpVerb } from '../server/rpc/types/HttpVerb.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Read-only (safe) HTTP methods — they don't mutate server state. Belte
|
|
5
|
+
uses this to decide which verbs auto-expose to MCP: reads flip MCP on
|
|
6
|
+
when a schema is present, mutations require an explicit `clients.mcp`
|
|
7
|
+
opt-in so a model can't delete/overwrite data just because the handler
|
|
8
|
+
carries a schema. Also feeds the MCP tool `readOnlyHint` annotation.
|
|
9
|
+
*/
|
|
10
|
+
const READ_ONLY_METHODS = new Set<HttpVerb>(['GET', 'HEAD'])
|
|
11
|
+
|
|
12
|
+
export function isReadOnlyMethod(method: HttpVerb): boolean {
|
|
13
|
+
return READ_ONLY_METHODS.has(method)
|
|
14
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { STREAMING_CONTENT_TYPES } from './streamingContentTypes.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Whether a Response carries a streaming body (SSE / JSONL / NDJSON) by its
|
|
5
|
+
Content-Type, so callers drain it frame-by-frame instead of buffering.
|
|
6
|
+
Shared by the CLI print path and the MCP tool dispatcher.
|
|
7
|
+
*/
|
|
8
|
+
export function isStreamingResponse(response: Response): boolean {
|
|
9
|
+
const contentType = (response.headers.get('content-type') ?? '').toLowerCase()
|
|
10
|
+
return STREAMING_CONTENT_TYPES.some((type) => contentType.startsWith(type))
|
|
11
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/*
|
|
2
|
+
The in-band error sentinel for a JSONL/NDJSON stream: when a handler's
|
|
3
|
+
generator throws, `jsonl()` emits a final `{"$error":"<message>"}` line,
|
|
4
|
+
and `streamResponse` re-throws it on the consumer side. Encoder and decoder
|
|
5
|
+
live together here so the sentinel field has one definition and can't drift
|
|
6
|
+
between the two ends of the wire.
|
|
7
|
+
*/
|
|
8
|
+
export const jsonlErrorFrame = {
|
|
9
|
+
// Error line for a thrown message, including the trailing newline.
|
|
10
|
+
encode(message: string): string {
|
|
11
|
+
return `${JSON.stringify({ $error: message })}\n`
|
|
12
|
+
},
|
|
13
|
+
// The message carried by a parsed line, or undefined when it isn't the error sentinel.
|
|
14
|
+
decode(parsed: unknown): string | undefined {
|
|
15
|
+
if (
|
|
16
|
+
parsed &&
|
|
17
|
+
typeof parsed === 'object' &&
|
|
18
|
+
typeof (parsed as { $error?: unknown }).$error === 'string'
|
|
19
|
+
) {
|
|
20
|
+
return (parsed as { $error: string }).$error
|
|
21
|
+
}
|
|
22
|
+
return undefined
|
|
23
|
+
},
|
|
24
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { HttpVerb } from '../server/rpc/types/HttpVerb.ts'
|
|
2
2
|
import { canonicalJson } from './canonicalJson.ts'
|
|
3
|
+
import { carriesBodyArgs } from './carriesBodyArgs.ts'
|
|
3
4
|
|
|
4
5
|
/*
|
|
5
6
|
Derives a cache key from a verb-defined remote function and its args. The
|
|
@@ -14,7 +15,7 @@ URLSearchParams).
|
|
|
14
15
|
*/
|
|
15
16
|
export function keyForRemoteCall(method: HttpVerb, url: string, args: unknown): string {
|
|
16
17
|
const prefix = `${method} ${url}`
|
|
17
|
-
if (method
|
|
18
|
+
if (!carriesBodyArgs(method)) {
|
|
18
19
|
if (args && typeof args === 'object' && !Array.isArray(args)) {
|
|
19
20
|
const record = args as Record<string, unknown>
|
|
20
21
|
const keys = Object.keys(record).sort()
|
|
@@ -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)
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
always defaults to true (the historical surface). The mcp/cli auto-on
|
|
6
|
+
defaults are decided by the caller and passed in, since the safe default
|
|
7
|
+
differs per declaration: a read-only verb may auto-expose to MCP while a
|
|
8
|
+
mutating one must not, and sockets gate differently again. Explicit
|
|
9
|
+
values in `flags` always win over the computed defaults.
|
|
8
10
|
*/
|
|
9
11
|
export function resolveClientFlags(
|
|
10
12
|
flags: Partial<ClientFlags> | undefined,
|
|
11
|
-
|
|
13
|
+
defaults: { mcp: boolean; cli: boolean },
|
|
12
14
|
): ClientFlags {
|
|
13
15
|
return {
|
|
14
16
|
browser: flags?.browser ?? true,
|
|
15
|
-
mcp: flags?.mcp ??
|
|
16
|
-
cli: flags?.cli ??
|
|
17
|
+
mcp: flags?.mcp ?? defaults.mcp,
|
|
18
|
+
cli: flags?.cli ?? defaults.cli,
|
|
17
19
|
}
|
|
18
20
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Human-readable message for a non-2xx Response: `status statusText: body`.
|
|
3
|
+
Shared by the CLI error path and the MCP tool result so the two frame a
|
|
4
|
+
failed request identically. Consumes the body, so call only on a response
|
|
5
|
+
you're done with.
|
|
6
|
+
*/
|
|
7
|
+
export async function responseErrorText(response: Response): Promise<string> {
|
|
8
|
+
return `${response.status} ${response.statusText}: ${await response.text()}`
|
|
9
|
+
}
|
|
@@ -0,0 +1,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 {
|
|
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
|
}
|
package/src/serverEntry.ts
CHANGED
|
@@ -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
|
|
16
|
-
second argument: `GET(fn, {
|
|
17
|
-
output type and the server replies with 422 on validation
|
|
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
|