@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
package/bin/belte.ts
CHANGED
|
@@ -26,6 +26,26 @@ function parseFlag(name: string): string | undefined {
|
|
|
26
26
|
return undefined
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
/*
|
|
30
|
+
Runs the server as a child and owns its shutdown. Ctrl+C delivers SIGINT to
|
|
31
|
+
the whole foreground process group, so without a parent handler the parent's
|
|
32
|
+
default action kills it instantly — abandoning the `await child.exited` and
|
|
33
|
+
orphaning the child, which can then linger holding the port. Forwarding the
|
|
34
|
+
signal and awaiting the child's exit (with a SIGKILL watchdog for a wedged
|
|
35
|
+
child) guarantees the child is reaped before the parent leaves. Mirrors the
|
|
36
|
+
child's exit code so callers and CI see the real result.
|
|
37
|
+
*/
|
|
38
|
+
async function runServerChild(cmd: string[]): Promise<never> {
|
|
39
|
+
const child = Bun.spawn({ cmd, cwd, stdio: ['inherit', 'inherit', 'inherit'] })
|
|
40
|
+
const forward = (signal: NodeJS.Signals) => {
|
|
41
|
+
child.kill(signal)
|
|
42
|
+
setTimeout(() => child.kill('SIGKILL'), 3000).unref()
|
|
43
|
+
}
|
|
44
|
+
process.on('SIGINT', () => forward('SIGINT'))
|
|
45
|
+
process.on('SIGTERM', () => forward('SIGTERM'))
|
|
46
|
+
process.exit(await child.exited)
|
|
47
|
+
}
|
|
48
|
+
|
|
29
49
|
/*
|
|
30
50
|
Spawns the server under `bun --watch` against the dev entry. The dev entry
|
|
31
51
|
re-runs the client build and eagerly imports every page/layout/rpc/socket
|
|
@@ -34,12 +54,7 @@ restarts the whole process whenever any file in the graph changes. The
|
|
|
34
54
|
browser is not auto-reloaded — refresh manually after the server is back.
|
|
35
55
|
*/
|
|
36
56
|
async function dev(): Promise<void> {
|
|
37
|
-
|
|
38
|
-
cmd: ['bun', '--watch', '--preload', PRELOAD, DEV_ENTRY],
|
|
39
|
-
cwd,
|
|
40
|
-
stdio: ['inherit', 'inherit', 'inherit'],
|
|
41
|
-
})
|
|
42
|
-
process.exit(await child.exited)
|
|
57
|
+
await runServerChild(['bun', '--watch', '--preload', PRELOAD, DEV_ENTRY])
|
|
43
58
|
}
|
|
44
59
|
|
|
45
60
|
// Performs a single client build with no server attached (for CI / static deploys).
|
|
@@ -48,14 +63,8 @@ async function buildOnce(): Promise<void> {
|
|
|
48
63
|
}
|
|
49
64
|
|
|
50
65
|
// Starts the production server against an already-built dist directory.
|
|
51
|
-
// Awaits the child process so the parent's exit code mirrors the server's.
|
|
52
66
|
async function start(): Promise<void> {
|
|
53
|
-
|
|
54
|
-
cmd: ['bun', '--preload', PRELOAD, SERVER_ENTRY],
|
|
55
|
-
cwd,
|
|
56
|
-
stdio: ['inherit', 'inherit', 'inherit'],
|
|
57
|
-
})
|
|
58
|
-
process.exit(await child.exited)
|
|
67
|
+
await runServerChild(['bun', '--preload', PRELOAD, SERVER_ENTRY])
|
|
59
68
|
}
|
|
60
69
|
|
|
61
70
|
// Parses the --target and --out flags and produces a standalone executable.
|
package/package.json
CHANGED
package/src/appEntry.ts
CHANGED
|
@@ -7,6 +7,7 @@ import programName from './_virtual/cli-name.ts'
|
|
|
7
7
|
import type { BundleMenu } from './lib/bundle/BundleMenu.ts'
|
|
8
8
|
import type { BundleWindow } from './lib/bundle/BundleWindow.ts'
|
|
9
9
|
import { openWebview } from './lib/bundle/openWebview.ts'
|
|
10
|
+
import { jsonSchemaForSchema } from './lib/shared/jsonSchemaForSchema.ts'
|
|
10
11
|
import { log } from './lib/shared/log.ts'
|
|
11
12
|
|
|
12
13
|
/*
|
|
@@ -31,6 +32,14 @@ The window owns the main thread; on close we tell the worker to reap its child.
|
|
|
31
32
|
const window = bundleWindow as BundleWindow
|
|
32
33
|
const title = window.title ?? programName
|
|
33
34
|
|
|
35
|
+
/*
|
|
36
|
+
Derive the config form's JSON Schema here (the worker can't import the virtual
|
|
37
|
+
that carries window.config) and pass the plain object through init — the
|
|
38
|
+
validator itself isn't serializable, but its JSON Schema is. Undefined when the
|
|
39
|
+
app declares no config, so the connect screen never gates Start.
|
|
40
|
+
*/
|
|
41
|
+
const configSchema = window.config ? jsonSchemaForSchema(window.config, undefined) : undefined
|
|
42
|
+
|
|
34
43
|
/*
|
|
35
44
|
Spawn the control server worker. `__BELTE_WORKER_ENTRY__` is the worker's absolute
|
|
36
45
|
path, injected by bundleApp via Bun's `define` so the specifier is a static literal
|
|
@@ -49,15 +58,22 @@ worker.addEventListener('error', (event: ErrorEvent) => {
|
|
|
49
58
|
// Hand the worker the plugin-resolved data it can't import itself, then start it.
|
|
50
59
|
worker.postMessage({
|
|
51
60
|
type: 'init',
|
|
52
|
-
init: { disconnectedHtml: disconnectedHtml as string, title, programName },
|
|
61
|
+
init: { disconnectedHtml: disconnectedHtml as string, title, programName, configSchema },
|
|
53
62
|
})
|
|
54
63
|
|
|
55
|
-
|
|
56
|
-
|
|
64
|
+
/*
|
|
65
|
+
The worker, once bound, resolves where the window should open and posts both the
|
|
66
|
+
control-server `origin` (for the File-menu action URLs) and the `target` to point
|
|
67
|
+
the window at now: the live server when it could resume the last connection, else
|
|
68
|
+
the connect screen. Resolving before this means a successful resume opens straight
|
|
69
|
+
at the app — no connect-screen flash — at the cost of a brief window-less moment
|
|
70
|
+
while it boots/probes (the OS shows the launching dock icon meanwhile).
|
|
71
|
+
*/
|
|
72
|
+
const { origin, target } = await new Promise<{ origin: string; target: string }>((resolve) => {
|
|
57
73
|
worker.addEventListener('message', (event: MessageEvent) => {
|
|
58
|
-
const data = event.data as { type: string; origin?: string }
|
|
59
|
-
if (data.type === 'ready' && data.origin) {
|
|
60
|
-
resolve(data.origin)
|
|
74
|
+
const data = event.data as { type: string; origin?: string; target?: string }
|
|
75
|
+
if (data.type === 'ready' && data.origin && data.target) {
|
|
76
|
+
resolve({ origin: data.origin, target: data.target })
|
|
61
77
|
}
|
|
62
78
|
})
|
|
63
79
|
})
|
|
@@ -89,9 +105,9 @@ const fileMenu: { label: string; items: FileMenuItem[] } = {
|
|
|
89
105
|
],
|
|
90
106
|
}
|
|
91
107
|
|
|
92
|
-
log.info(`opening ${title}
|
|
108
|
+
log.info(`opening ${title} window at ${target}`)
|
|
93
109
|
await openWebview({
|
|
94
|
-
url:
|
|
110
|
+
url: target,
|
|
95
111
|
title,
|
|
96
112
|
width: window.width,
|
|
97
113
|
height: window.height,
|
package/src/buildDisconnected.ts
CHANGED
|
@@ -116,6 +116,9 @@ function composeHtml({ js, css, logo }: { js: string; css: string; logo: string
|
|
|
116
116
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
117
117
|
<!--belte:connect-config-->
|
|
118
118
|
<script>${escapeScriptBody(logoScript)}</script>
|
|
119
|
+
<!-- Paint the screen background before the client mounts, so there's no white
|
|
120
|
+
flash ahead of the splash (gray-50 / gray-950 to match the rendered screen). -->
|
|
121
|
+
<style>html,body{margin:0;background:#f9fafb}@media (prefers-color-scheme:dark){html,body{background:#030712}}</style>
|
|
119
122
|
<style>${css}</style>
|
|
120
123
|
</head>
|
|
121
124
|
<body>
|
package/src/bundleApp.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { ensureWebviewLib } from './lib/bundle/ensureWebviewLib.ts'
|
|
|
4
4
|
import { infoPlist } from './lib/bundle/infoPlist.ts'
|
|
5
5
|
import { pngToIcns } from './lib/bundle/pngToIcns.ts'
|
|
6
6
|
import { serverBinaryFilename } from './lib/bundle/serverBinaryFilename.ts'
|
|
7
|
+
import { signMacApp } from './lib/bundle/signMacApp.ts'
|
|
7
8
|
import { webviewLibName } from './lib/bundle/webviewLibName.ts'
|
|
8
9
|
import { detectTarget } from './lib/shared/detectTarget.ts'
|
|
9
10
|
import { exitOnBuildFailure } from './lib/shared/exitOnBuildFailure.ts'
|
|
@@ -17,8 +18,9 @@ const WORKER_ENTRY = new URL('./controlServerWorker.ts', import.meta.url).pathna
|
|
|
17
18
|
|
|
18
19
|
/*
|
|
19
20
|
Assembles a movable, self-contained app bundle for the host platform —
|
|
20
|
-
no
|
|
21
|
-
|
|
21
|
+
no cross-compilation, and on macOS an ad-hoc seal so it launches on other
|
|
22
|
+
Macs (signMacApp). Three pieces travel together so the app runs on another
|
|
23
|
+
machine of the same OS with nothing installed:
|
|
22
24
|
|
|
23
25
|
- the standalone server binary (`compile()`, assets embedded)
|
|
24
26
|
- the launcher binary (appEntry — spawns the server, opens the webview)
|
|
@@ -54,6 +56,18 @@ export async function bundleApp({ cwd = process.cwd() }: { cwd?: string } = {}):
|
|
|
54
56
|
// connect-screen build that writes into dist.
|
|
55
57
|
await compile({ cwd, target, outfile: `${binDir}/${serverBinaryFilename()}` })
|
|
56
58
|
|
|
59
|
+
/*
|
|
60
|
+
Opt-in: ship the project's `.env.bundle` as the binary-dir `.env`, which the
|
|
61
|
+
server loads at boot (loadEnvFromBinaryDir) as its default config layer. A
|
|
62
|
+
dedicated file, never the working `.env` — a compiled bundle is extractable,
|
|
63
|
+
so only ship-safe defaults belong here; user-specific/secret values come from
|
|
64
|
+
the data-dir `.env` instead. Skipped when absent.
|
|
65
|
+
*/
|
|
66
|
+
const bundleEnv = Bun.file(`${cwd}/.env.bundle`)
|
|
67
|
+
if (await bundleEnv.exists()) {
|
|
68
|
+
await Bun.write(`${binDir}/.env`, bundleEnv)
|
|
69
|
+
}
|
|
70
|
+
|
|
57
71
|
// 2. Connect screen — bake dist/bundle-disconnected.html before the launcher
|
|
58
72
|
// build, which inlines it via the belte:bundle-disconnected virtual.
|
|
59
73
|
await buildDisconnected({ cwd, svelteConfig })
|
|
@@ -106,6 +120,14 @@ export async function bundleApp({ cwd = process.cwd() }: { cwd?: string } = {}):
|
|
|
106
120
|
`${bundleRoot}/Contents/Info.plist`,
|
|
107
121
|
infoPlist({ name: programName, version, icon: hasIcon ? 'icon' : undefined }),
|
|
108
122
|
)
|
|
123
|
+
|
|
124
|
+
// Seal the finished bundle so it launches on other Macs — must run last,
|
|
125
|
+
// after every binary, the lib, and Info.plist are in place.
|
|
126
|
+
await signMacApp(bundleRoot, [
|
|
127
|
+
`${libDir}/${webviewLibName()}`,
|
|
128
|
+
`${binDir}/${serverBinaryFilename()}`,
|
|
129
|
+
launcherPath,
|
|
130
|
+
])
|
|
109
131
|
}
|
|
110
132
|
|
|
111
133
|
log.success(`bundled app: ${bundleRoot} (target: ${target})`)
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { mkdir, rm } from 'node:fs/promises'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
1
3
|
import { bindConnectedFlag } from './lib/bundle/bindConnectedFlag.ts'
|
|
2
4
|
import { bindRequestNavigate } from './lib/bundle/bindRequestNavigate.ts'
|
|
3
5
|
import { findFreePort } from './lib/bundle/findFreePort.ts'
|
|
@@ -7,7 +9,10 @@ import { resolveServerBinary } from './lib/bundle/resolveServerBinary.ts'
|
|
|
7
9
|
import { resolveWebviewLib } from './lib/bundle/resolveWebviewLib.ts'
|
|
8
10
|
import { stableLocalPort } from './lib/bundle/stableLocalPort.ts'
|
|
9
11
|
import { waitForServer } from './lib/bundle/waitForServer.ts'
|
|
12
|
+
import { appDataDir } from './lib/shared/appDataDir.ts'
|
|
10
13
|
import { log } from './lib/shared/log.ts'
|
|
14
|
+
import { readEnvFile } from './lib/shared/readEnvFile.ts'
|
|
15
|
+
import { serializeEnv } from './lib/shared/serializeEnv.ts'
|
|
11
16
|
|
|
12
17
|
/*
|
|
13
18
|
The bundle's control server, run in a Worker so it owns its own thread.
|
|
@@ -34,15 +39,28 @@ window back to the connect screen, since a dead server (local crash or remote
|
|
|
34
39
|
outage) otherwise leaves a frozen page and a menu that still claims connected.
|
|
35
40
|
|
|
36
41
|
GET / → the connect screen (title injected at serve time)
|
|
42
|
+
GET /__belte/config → { schema, values } for the first-run config form
|
|
43
|
+
POST /__belte/config → persist the form's answers to the data-dir .env
|
|
37
44
|
POST /connect {url} → record connected, reply { redirect: url }
|
|
38
45
|
POST /start → spawn the server binary, reply { redirect: localUrl }
|
|
39
46
|
GET /__belte/disconnect → reap the child, clear connected
|
|
40
47
|
*/
|
|
41
48
|
|
|
42
|
-
|
|
43
|
-
|
|
49
|
+
/*
|
|
50
|
+
Init payload from the launcher, plus the per-run state the handlers close over.
|
|
51
|
+
`configSchema` is the JSON Schema derived from the app's BundleWindow.config
|
|
52
|
+
(undefined when none declared), driving the connect screen's first-run form.
|
|
53
|
+
*/
|
|
54
|
+
type Init = {
|
|
55
|
+
disconnectedHtml: string
|
|
56
|
+
title: string
|
|
57
|
+
programName: string
|
|
58
|
+
configSchema?: Record<string, unknown>
|
|
59
|
+
}
|
|
44
60
|
let disconnectedHtml = ''
|
|
45
61
|
let title = ''
|
|
62
|
+
let programName = ''
|
|
63
|
+
let configSchema: Record<string, unknown> | undefined
|
|
46
64
|
let flag: ReturnType<typeof bindConnectedFlag> | undefined
|
|
47
65
|
let server: ReturnType<typeof listenLocalControlServer> | undefined
|
|
48
66
|
|
|
@@ -142,21 +160,98 @@ Spawns the sibling server binary on a free port and waits for it to answer,
|
|
|
142
160
|
returning the URL to point the window at. Any previous child is reaped first so
|
|
143
161
|
only one embedded server runs at a time.
|
|
144
162
|
*/
|
|
145
|
-
async function startEmbeddedServer(): Promise<string> {
|
|
163
|
+
async function startEmbeddedServer(timeoutMs?: number): Promise<string> {
|
|
146
164
|
killServerChild()
|
|
147
165
|
const port = findFreePort()
|
|
148
166
|
const url = `http://localhost:${port}`
|
|
149
167
|
serverChild = Bun.spawn({
|
|
150
168
|
cmd: [resolveServerBinary()],
|
|
151
169
|
// BELTE_PARENT_PID lets the child exit if the launcher is force-quit
|
|
152
|
-
// (a clean window close reaps it directly; see exitWithParent).
|
|
170
|
+
// (a clean window close reaps it directly; see exitWithParent). The
|
|
171
|
+
// server resolves its own config from its data-dir/binary-dir .env at
|
|
172
|
+
// boot (see serverEntry), so the launcher injects nothing else.
|
|
153
173
|
env: { ...process.env, PORT: String(port), BELTE_PARENT_PID: String(process.pid) },
|
|
154
174
|
stdio: ['inherit', 'inherit', 'inherit'],
|
|
155
175
|
})
|
|
156
|
-
|
|
176
|
+
/*
|
|
177
|
+
Race readiness against the child's exit. A misconfigured bundle (missing env
|
|
178
|
+
the server needs to bind) crashes immediately; without this the launcher
|
|
179
|
+
would wait out waitForServer's full timeout and report a generic stall
|
|
180
|
+
instead of the actual crash. The exit branch resolves (never rejects) so the
|
|
181
|
+
race loser still pending after a successful boot can't surface as an
|
|
182
|
+
unhandled rejection when the child is later reaped on disconnect.
|
|
183
|
+
*/
|
|
184
|
+
const exited = serverChild.exited
|
|
185
|
+
const outcome = await Promise.race([
|
|
186
|
+
waitForServer(url, timeoutMs ? { timeoutMs } : undefined).then(() => undefined),
|
|
187
|
+
exited,
|
|
188
|
+
])
|
|
189
|
+
if (outcome !== undefined) {
|
|
190
|
+
throw new Error(`[belte] embedded server exited (code ${outcome}) before binding`)
|
|
191
|
+
}
|
|
157
192
|
return url
|
|
158
193
|
}
|
|
159
194
|
|
|
195
|
+
/*
|
|
196
|
+
Where the window should point on launch, resolved before it ever opens so the
|
|
197
|
+
connect screen never flashes. Repeats the last connection from the launcher-owned
|
|
198
|
+
record (which survives relaunch where the embedded server's fresh port can't):
|
|
199
|
+
|
|
200
|
+
- embedded, config complete → boot it and point at the live server
|
|
201
|
+
- embedded, config missing → the connect screen, so the user can configure
|
|
202
|
+
- remote url, still alive → point straight at it
|
|
203
|
+
- remote url, now dead → the connect screen with a `lost` notice
|
|
204
|
+
- nothing recorded → the connect screen
|
|
205
|
+
|
|
206
|
+
Boot is bounded by a short ceiling: a failed or slow boot falls back to the
|
|
207
|
+
connect screen rather than leaving the launcher window-less, and reaps the child
|
|
208
|
+
so a half-started server doesn't hold its port.
|
|
209
|
+
*/
|
|
210
|
+
const AUTO_START_CEILING_MS = 3000
|
|
211
|
+
async function resolveLaunchTarget(): Promise<string> {
|
|
212
|
+
const last = await readLastConnection()
|
|
213
|
+
if (!last) {
|
|
214
|
+
return controlOrigin
|
|
215
|
+
}
|
|
216
|
+
if (last.kind === 'embedded') {
|
|
217
|
+
if (await autoStartBlockedByConfig()) {
|
|
218
|
+
return controlOrigin
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
const url = await startEmbeddedServer(AUTO_START_CEILING_MS)
|
|
222
|
+
flag?.setConnected(true)
|
|
223
|
+
startLivenessWatch(url)
|
|
224
|
+
log.info(`resumed embedded server at ${url}`)
|
|
225
|
+
return url
|
|
226
|
+
} catch (error) {
|
|
227
|
+
killServerChild()
|
|
228
|
+
log.warn(`embedded server did not resume: ${String(error)}`)
|
|
229
|
+
return controlOrigin
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
const identity = await probeBelteServer(last.url)
|
|
233
|
+
if (identity) {
|
|
234
|
+
flag?.setConnected(true)
|
|
235
|
+
startLivenessWatch(last.url)
|
|
236
|
+
log.info(`reconnected to ${identity.name} at ${last.url}`)
|
|
237
|
+
return last.url
|
|
238
|
+
}
|
|
239
|
+
log.warn(`saved server did not respond: ${last.url}`)
|
|
240
|
+
return `${controlOrigin}/?action=lost`
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// True when the app declares required config that nothing yet supplies, so an
|
|
244
|
+
// embedded auto-start would only crash for the lack of it — land on the connect
|
|
245
|
+
// screen (and its setup modal) instead.
|
|
246
|
+
async function autoStartBlockedByConfig(): Promise<boolean> {
|
|
247
|
+
const required = (configSchema?.required as string[] | undefined) ?? []
|
|
248
|
+
if (required.length === 0) {
|
|
249
|
+
return false
|
|
250
|
+
}
|
|
251
|
+
const values = await resolveConfigValues()
|
|
252
|
+
return required.some((key) => !values[key])
|
|
253
|
+
}
|
|
254
|
+
|
|
160
255
|
/*
|
|
161
256
|
Injects the app title into the connect-screen HTML just before serving — the build
|
|
162
257
|
left a `<!--belte:connect-config-->` marker in <head>.
|
|
@@ -167,6 +262,83 @@ function renderConnectScreen(): Response {
|
|
|
167
262
|
return new Response(html, { headers: { 'content-type': 'text/html; charset=utf-8' } })
|
|
168
263
|
}
|
|
169
264
|
|
|
265
|
+
// The data-dir `.env` the form writes and the server loads first at boot.
|
|
266
|
+
function dataDirEnvPath(): string {
|
|
267
|
+
return join(appDataDir(programName), '.env')
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/*
|
|
271
|
+
Resolves the value to pre-fill each config field with, following the same
|
|
272
|
+
precedence the server applies below the shell: the user's saved data-dir `.env`,
|
|
273
|
+
then the bundle's shipped binary-dir `.env`, then the schema's own `default`.
|
|
274
|
+
Empty string when nothing supplies it — which is how the form spots an unmet
|
|
275
|
+
required field.
|
|
276
|
+
*/
|
|
277
|
+
async function resolveConfigValues(): Promise<Record<string, string>> {
|
|
278
|
+
const properties = (configSchema?.properties ?? {}) as Record<string, { default?: unknown }>
|
|
279
|
+
// Independent reads — fetch together; precedence is applied in the merge below.
|
|
280
|
+
const [dataDirEnv, binaryDirEnv] = await Promise.all([
|
|
281
|
+
readEnvFile(dataDirEnvPath()),
|
|
282
|
+
readEnvFile(join(dirname(resolveServerBinary()), '.env')),
|
|
283
|
+
])
|
|
284
|
+
return Object.fromEntries(
|
|
285
|
+
Object.keys(properties).map((key) => {
|
|
286
|
+
const fallback = properties[key]?.default
|
|
287
|
+
const value =
|
|
288
|
+
dataDirEnv[key] ??
|
|
289
|
+
binaryDirEnv[key] ??
|
|
290
|
+
(fallback === undefined ? '' : String(fallback))
|
|
291
|
+
return [key, value]
|
|
292
|
+
}),
|
|
293
|
+
)
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/*
|
|
297
|
+
Persists the form's answers to the data-dir `.env`, merged over any existing
|
|
298
|
+
file so keys the form didn't touch survive. Creates the data dir on first run
|
|
299
|
+
(appDataDir only computes the path).
|
|
300
|
+
*/
|
|
301
|
+
async function writeConfig(values: Record<string, string>): Promise<void> {
|
|
302
|
+
const path = dataDirEnvPath()
|
|
303
|
+
const merged = { ...(await readEnvFile(path)), ...values }
|
|
304
|
+
await mkdir(appDataDir(programName), { recursive: true })
|
|
305
|
+
await Bun.write(path, serializeEnv(merged))
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/*
|
|
309
|
+
The launcher-owned record of the last connection, in the data dir so it survives
|
|
310
|
+
relaunch and is readable before the window opens — unlike the webview's
|
|
311
|
+
localStorage, and unlike the embedded server's URL, which can't be persisted
|
|
312
|
+
because it picks a fresh port each launch (so we record the intent, not the URL).
|
|
313
|
+
resolveLaunchTarget reads it; /connect and /start write it; /disconnect clears it.
|
|
314
|
+
*/
|
|
315
|
+
type LastConnection = { kind: 'embedded' } | { kind: 'url'; url: string }
|
|
316
|
+
|
|
317
|
+
function lastConnectionPath(): string {
|
|
318
|
+
return join(appDataDir(programName), 'last-connection.json')
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
async function readLastConnection(): Promise<LastConnection | undefined> {
|
|
322
|
+
const file = Bun.file(lastConnectionPath())
|
|
323
|
+
if (!(await file.exists())) {
|
|
324
|
+
return undefined
|
|
325
|
+
}
|
|
326
|
+
try {
|
|
327
|
+
return (await file.json()) as LastConnection
|
|
328
|
+
} catch {
|
|
329
|
+
return undefined
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function writeLastConnection(value: LastConnection): Promise<void> {
|
|
334
|
+
await mkdir(appDataDir(programName), { recursive: true })
|
|
335
|
+
await Bun.write(lastConnectionPath(), JSON.stringify(value))
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
async function clearLastConnection(): Promise<void> {
|
|
339
|
+
await rm(lastConnectionPath(), { force: true })
|
|
340
|
+
}
|
|
341
|
+
|
|
170
342
|
/*
|
|
171
343
|
The control server's request handler. The connect screen owns localStorage +
|
|
172
344
|
navigation; this worker owns the embedded-server process and the native flag.
|
|
@@ -176,6 +348,18 @@ async function handleControlRequest(request: Request): Promise<Response> {
|
|
|
176
348
|
if (request.method === 'GET' && url.pathname === '/') {
|
|
177
349
|
return renderConnectScreen()
|
|
178
350
|
}
|
|
351
|
+
if (request.method === 'GET' && url.pathname === '/__belte/config') {
|
|
352
|
+
// No schema declared → null tells the form to skip the gate entirely.
|
|
353
|
+
if (!configSchema) {
|
|
354
|
+
return Response.json({ schema: null, values: {} })
|
|
355
|
+
}
|
|
356
|
+
return Response.json({ schema: configSchema, values: await resolveConfigValues() })
|
|
357
|
+
}
|
|
358
|
+
if (request.method === 'POST' && url.pathname === '/__belte/config') {
|
|
359
|
+
const { values } = (await request.json()) as { values: Record<string, string> }
|
|
360
|
+
await writeConfig(values)
|
|
361
|
+
return new Response(undefined, { status: 204 })
|
|
362
|
+
}
|
|
179
363
|
if (request.method === 'POST' && url.pathname === '/connect') {
|
|
180
364
|
const { url: target } = (await request.json()) as { url: string }
|
|
181
365
|
// Verify it's actually a belte server before pointing the window at it.
|
|
@@ -189,6 +373,8 @@ async function handleControlRequest(request: Request): Promise<Response> {
|
|
|
189
373
|
}
|
|
190
374
|
flag?.setConnected(true)
|
|
191
375
|
startLivenessWatch(target)
|
|
376
|
+
// Record the choice so the next launch reconnects here before opening.
|
|
377
|
+
await writeLastConnection({ kind: 'url', url: target })
|
|
192
378
|
log.info(`connecting to ${identity.name} at ${target}`)
|
|
193
379
|
return Response.json({ redirect: target })
|
|
194
380
|
}
|
|
@@ -197,6 +383,8 @@ async function handleControlRequest(request: Request): Promise<Response> {
|
|
|
197
383
|
const localUrl = await startEmbeddedServer()
|
|
198
384
|
flag?.setConnected(true)
|
|
199
385
|
startLivenessWatch(localUrl)
|
|
386
|
+
// Record the choice so the next launch boots the embedded server first.
|
|
387
|
+
await writeLastConnection({ kind: 'embedded' })
|
|
200
388
|
log.info(`started embedded server at ${localUrl}`)
|
|
201
389
|
return Response.json({ redirect: localUrl })
|
|
202
390
|
} catch (error) {
|
|
@@ -208,6 +396,8 @@ async function handleControlRequest(request: Request): Promise<Response> {
|
|
|
208
396
|
stopLivenessWatch()
|
|
209
397
|
killServerChild()
|
|
210
398
|
flag?.setConnected(false)
|
|
399
|
+
// Forget the auto-resume choice so the next launch lands on the connect screen.
|
|
400
|
+
await clearLastConnection()
|
|
211
401
|
return new Response(undefined, { status: 204 })
|
|
212
402
|
}
|
|
213
403
|
return new Response('not found', { status: 404 })
|
|
@@ -216,18 +406,26 @@ async function handleControlRequest(request: Request): Promise<Response> {
|
|
|
216
406
|
/*
|
|
217
407
|
Bind the control server to 127.0.0.1 literally (not `localhost`) so the webview
|
|
218
408
|
reaches it without any IPv4/IPv6 name-resolution ambiguity, open the native flag
|
|
219
|
-
handle,
|
|
409
|
+
handle, then resolve where the window should open before handing back. Resolving
|
|
410
|
+
the launch target here — booting/probing the last connection before `ready` — is
|
|
411
|
+
what lets the launcher open the window straight at the live server, so the
|
|
412
|
+
connect screen never flashes; only an unconfigured, failed, or absent resume
|
|
413
|
+
falls back to it. The launcher gets both `origin` (for the File-menu actions) and
|
|
414
|
+
`target` (where to point the window now).
|
|
220
415
|
*/
|
|
221
416
|
async function start(init: Init): Promise<void> {
|
|
222
417
|
disconnectedHtml = init.disconnectedHtml
|
|
223
418
|
title = init.title
|
|
419
|
+
programName = init.programName
|
|
420
|
+
configSchema = init.configSchema
|
|
224
421
|
const libPath = await resolveWebviewLib()
|
|
225
422
|
flag = bindConnectedFlag(libPath)
|
|
226
423
|
navigate = bindRequestNavigate(libPath)
|
|
227
424
|
server = listenLocalControlServer(stableLocalPort(init.programName), handleControlRequest)
|
|
228
425
|
controlOrigin = `http://127.0.0.1:${server.port}`
|
|
229
426
|
log.info(`${title} control server listening at ${controlOrigin}`)
|
|
230
|
-
|
|
427
|
+
const target = await resolveLaunchTarget()
|
|
428
|
+
self.postMessage({ type: 'ready', origin: controlOrigin, target })
|
|
231
429
|
}
|
|
232
430
|
|
|
233
431
|
// Reap the child + release the server and FFI handles, then confirm so the
|
package/src/discoveryEntry.ts
CHANGED
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
import { rpc } from './_virtual/rpc.ts'
|
|
3
3
|
// @ts-expect-error virtual module resolved by belteResolverPlugin
|
|
4
4
|
import { sockets } from './_virtual/sockets.ts'
|
|
5
|
+
import type { CliManifestEntry } from './lib/cli/types/CliManifestEntry.ts'
|
|
5
6
|
import { verbRegistry } from './lib/server/rpc/verbRegistry.ts'
|
|
7
|
+
import { socketOperations } from './lib/server/sockets/socketOperations.ts'
|
|
8
|
+
import { socketRegistry } from './lib/server/sockets/socketRegistry.ts'
|
|
6
9
|
import { commandNameForUrl } from './lib/shared/commandNameForUrl.ts'
|
|
7
10
|
import { jsonSchemaForSchema } from './lib/shared/jsonSchemaForSchema.ts'
|
|
8
11
|
|
|
@@ -18,17 +21,61 @@ await Promise.all([
|
|
|
18
21
|
...Object.values(sockets).map((loader) => (loader as () => Promise<unknown>)()),
|
|
19
22
|
])
|
|
20
23
|
|
|
21
|
-
const manifest =
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
24
|
+
const manifest: Record<string, CliManifestEntry> = {}
|
|
25
|
+
|
|
26
|
+
for (const entry of verbRegistry.values()) {
|
|
27
|
+
if (!entry.clients.cli) {
|
|
28
|
+
continue
|
|
29
|
+
}
|
|
30
|
+
manifest[commandNameForUrl(entry.remote.url)] = {
|
|
31
|
+
method: entry.remote.method,
|
|
32
|
+
url: entry.remote.url,
|
|
33
|
+
jsonSchema: jsonSchemaForSchema(entry.inputSchema, entry.inputJsonSchema),
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/*
|
|
38
|
+
Sockets advertised to the CLI become commands against the socket's HTTP
|
|
39
|
+
face (see socketOperations): `<base>-tail` streams live (GET +
|
|
40
|
+
text/event-stream, with an optional --tail N to replay recent history
|
|
41
|
+
first) and, when clientPublish is set, `<base>-publish` sends the args bag
|
|
42
|
+
as a message (POST).
|
|
43
|
+
*/
|
|
44
|
+
for (const entry of socketRegistry.values()) {
|
|
45
|
+
if (!entry.clients.cli) {
|
|
46
|
+
continue
|
|
47
|
+
}
|
|
48
|
+
for (const operation of socketOperations(entry)) {
|
|
49
|
+
if (operation.kind === 'tail') {
|
|
50
|
+
manifest[operation.name] = {
|
|
51
|
+
method: operation.method,
|
|
52
|
+
url: operation.restUrl,
|
|
53
|
+
accept: 'text/event-stream',
|
|
54
|
+
jsonSchema: {
|
|
55
|
+
type: 'object',
|
|
56
|
+
description: `tail the "${operation.socketName}" socket`,
|
|
57
|
+
properties: {
|
|
58
|
+
tail: {
|
|
59
|
+
type: 'number',
|
|
60
|
+
description: 'replay last N messages before tailing live',
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
const payloadSchema = jsonSchemaForSchema(entry.schema, entry.jsonSchema)
|
|
68
|
+
manifest[operation.name] = {
|
|
69
|
+
method: operation.method,
|
|
70
|
+
url: operation.restUrl,
|
|
71
|
+
jsonSchema: {
|
|
72
|
+
...payloadSchema,
|
|
73
|
+
description:
|
|
74
|
+
(payloadSchema.description as string | undefined) ??
|
|
75
|
+
`publish a message to the "${operation.socketName}" socket`,
|
|
30
76
|
},
|
|
31
|
-
|
|
32
|
-
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
33
80
|
|
|
34
81
|
process.stdout.write(JSON.stringify(manifest))
|
package/src/lib/browser/cache.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { canonicalJson } from '../shared/canonicalJson.ts'
|
|
|
5
5
|
import { decodeResponse } from '../shared/decodeResponse.ts'
|
|
6
6
|
import { getRemoteMeta } from '../shared/getRemoteMeta.ts'
|
|
7
7
|
import { keyForRemoteCall } from '../shared/keyForRemoteCall.ts'
|
|
8
|
+
import type { CacheEntry } from '../shared/types/CacheEntry.ts'
|
|
8
9
|
import type { CacheOptions } from '../shared/types/CacheOptions.ts'
|
|
9
10
|
|
|
10
11
|
type AnyRemote<Args, Return> = RemoteFunction<Args, Return> | RawRemoteFunction<Args>
|
|
@@ -46,7 +47,7 @@ export function cache<Args>(
|
|
|
46
47
|
export function cache<Args, Return>(
|
|
47
48
|
fn: AnyRemote<Args, Return>,
|
|
48
49
|
options?: CacheOptions,
|
|
49
|
-
): (args?: Args) => Promise<Return | Response> {
|
|
50
|
+
): (args?: Args) => Promise<Return | Response> | Return {
|
|
50
51
|
/*
|
|
51
52
|
The "raw" variant lacks its own `.raw` sibling; only the decoded
|
|
52
53
|
callable carries one. Tell them apart by that presence and dispatch the
|
|
@@ -55,20 +56,42 @@ export function cache<Args, Return>(
|
|
|
55
56
|
const isRaw = !('raw' in fn)
|
|
56
57
|
const rawFn = isRaw ? (fn as RawRemoteFunction<Args>) : (fn as RemoteFunction<Args, Return>).raw
|
|
57
58
|
return (args) => {
|
|
58
|
-
const
|
|
59
|
+
const store = activeCacheStore()
|
|
60
|
+
const key = resolveKey(rawFn, args, options?.key)
|
|
61
|
+
store.subscribe(key)
|
|
62
|
+
const existing = store.entries.get(key)
|
|
63
|
+
/*
|
|
64
|
+
Snapshot warm path: hydration pre-decoded the SSR body onto the
|
|
65
|
+
entry, so the decoded variant returns it synchronously — the first
|
|
66
|
+
{#await} render resolves without a microtask suspension and matches
|
|
67
|
+
the SSR DOM. Raw callers always take the Response path. After an
|
|
68
|
+
invalidate the replacement entry carries no value and falls through
|
|
69
|
+
to the async fetch as before.
|
|
70
|
+
|
|
71
|
+
The public overload stays typed Promise<Return> on purpose: a
|
|
72
|
+
non-thenable is the only thing {#await} can render synchronously, so
|
|
73
|
+
the sync return is left as an internal optimization rather than
|
|
74
|
+
widened to `Return | Promise<Return>` (which would leak it into every
|
|
75
|
+
caller's types). The one cost is that `.then`/`.catch`/`.finally`
|
|
76
|
+
directly on a warm result throws — consume cache via `await`/`{#await}`,
|
|
77
|
+
never `.then`. Don't "fix" the type; see memory cache-warm-sync-tradeoff.
|
|
78
|
+
*/
|
|
79
|
+
if (!isRaw && existing?.value !== undefined) {
|
|
80
|
+
return existing.value as Return
|
|
81
|
+
}
|
|
82
|
+
const responsePromise = invokeWithCache(store, key, existing, rawFn, args, options)
|
|
59
83
|
return isRaw ? responsePromise : (responsePromise.then(decodeResponse) as Promise<Return>)
|
|
60
84
|
}
|
|
61
85
|
}
|
|
62
86
|
|
|
63
87
|
function invokeWithCache<Args>(
|
|
88
|
+
store: ReturnType<typeof activeCacheStore>,
|
|
89
|
+
key: string,
|
|
90
|
+
existing: CacheEntry | undefined,
|
|
64
91
|
rawFn: RawRemoteFunction<Args>,
|
|
65
92
|
args: Args | undefined,
|
|
66
93
|
options: CacheOptions | undefined,
|
|
67
94
|
): Promise<Response> {
|
|
68
|
-
const store = activeCacheStore()
|
|
69
|
-
const key = resolveKey(rawFn, args, options?.key)
|
|
70
|
-
store.subscribe(key)
|
|
71
|
-
const existing = store.entries.get(key)
|
|
72
95
|
if (existing) {
|
|
73
96
|
return shareable(existing.promise)
|
|
74
97
|
}
|