@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
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
- const child = Bun.spawn({
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
- const child = Bun.spawn({
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@briancray/belte",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "Isomorphic multimodal HTTP framework built for humans and machines in a single Bun runtime",
6
6
  "license": "MIT",
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
- // The worker posts its control-server origin once bound; the window points here.
56
- const origin = await new Promise<string>((resolve) => {
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} connect screen at ${origin}`)
108
+ log.info(`opening ${title} window at ${target}`)
93
109
  await openWebview({
94
- url: origin,
110
+ url: target,
95
111
  title,
96
112
  width: window.width,
97
113
  height: window.height,
@@ -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 signing, no cross-compilation. Three pieces travel together so the app
21
- runs on another machine of the same OS with nothing installed:
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
- // Init payload from the launcher, plus the per-run state the handlers close over.
43
- type Init = { disconnectedHtml: string; title: string; programName: string }
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
- await waitForServer(url)
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, and hand the launcher the origin to navigate the window at.
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
- self.postMessage({ type: 'ready', origin: controlOrigin })
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
@@ -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 = Object.fromEntries(
22
- Array.from(verbRegistry.values())
23
- .filter((entry) => entry.clients.cli)
24
- .map((entry) => [
25
- commandNameForUrl(entry.remote.url),
26
- {
27
- method: entry.remote.method,
28
- url: entry.remote.url,
29
- jsonSchema: jsonSchemaForSchema(entry.schema, entry.jsonSchema),
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))
@@ -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 responsePromise = invokeWithCache(rawFn, args, options)
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
  }