@briancray/belte 0.4.0 → 0.5.1

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 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.4.0",
3
+ "version": "0.5.1",
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
@@ -56,6 +56,18 @@ export async function bundleApp({ cwd = process.cwd() }: { cwd?: string } = {}):
56
56
  // connect-screen build that writes into dist.
57
57
  await compile({ cwd, target, outfile: `${binDir}/${serverBinaryFilename()}` })
58
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
+
59
71
  // 2. Connect screen — bake dist/bundle-disconnected.html before the launcher
60
72
  // build, which inlines it via the belte:bundle-disconnected virtual.
61
73
  await buildDisconnected({ cwd, svelteConfig })
@@ -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,11 @@ 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 { parsePort } from './lib/server/runtime/parsePort.ts'
13
+ import { appDataDir } from './lib/shared/appDataDir.ts'
10
14
  import { log } from './lib/shared/log.ts'
15
+ import { readEnvFile } from './lib/shared/readEnvFile.ts'
16
+ import { serializeEnv } from './lib/shared/serializeEnv.ts'
11
17
 
12
18
  /*
13
19
  The bundle's control server, run in a Worker so it owns its own thread.
@@ -34,15 +40,28 @@ window back to the connect screen, since a dead server (local crash or remote
34
40
  outage) otherwise leaves a frozen page and a menu that still claims connected.
35
41
 
36
42
  GET / → the connect screen (title injected at serve time)
43
+ GET /__belte/config → { schema, values } for the first-run config form
44
+ POST /__belte/config → persist the form's answers to the data-dir .env
37
45
  POST /connect {url} → record connected, reply { redirect: url }
38
46
  POST /start → spawn the server binary, reply { redirect: localUrl }
39
47
  GET /__belte/disconnect → reap the child, clear connected
40
48
  */
41
49
 
42
- // Init payload from the launcher, plus the per-run state the handlers close over.
43
- type Init = { disconnectedHtml: string; title: string; programName: string }
50
+ /*
51
+ Init payload from the launcher, plus the per-run state the handlers close over.
52
+ `configSchema` is the JSON Schema derived from the app's BundleWindow.config
53
+ (undefined when none declared), driving the connect screen's first-run form.
54
+ */
55
+ type Init = {
56
+ disconnectedHtml: string
57
+ title: string
58
+ programName: string
59
+ configSchema?: Record<string, unknown>
60
+ }
44
61
  let disconnectedHtml = ''
45
62
  let title = ''
63
+ let programName = ''
64
+ let configSchema: Record<string, unknown> | undefined
46
65
  let flag: ReturnType<typeof bindConnectedFlag> | undefined
47
66
  let server: ReturnType<typeof listenLocalControlServer> | undefined
48
67
 
@@ -142,21 +161,115 @@ Spawns the sibling server binary on a free port and waits for it to answer,
142
161
  returning the URL to point the window at. Any previous child is reaped first so
143
162
  only one embedded server runs at a time.
144
163
  */
145
- async function startEmbeddedServer(): Promise<string> {
164
+ /*
165
+ The port the embedded server binds. A `PORT` configured in the data-dir `.env`
166
+ (where the config form writes), the shipped binary-dir `.env`, or the launcher's
167
+ own env is honored — so the server answers at a fixed, known address another
168
+ machine can reliably connect to. With none set, a free port is chosen (the
169
+ historical behaviour). Precedence matches the server's own env stack: shell >
170
+ data-dir > binary-dir. A configured port is used as-is and not second-guessed —
171
+ if it's taken, the bind failure surfaces rather than silently moving.
172
+ */
173
+ async function resolveEmbeddedPort(): Promise<number> {
174
+ const [dataDirEnv, binaryDirEnv] = await Promise.all([
175
+ readEnvFile(dataDirEnvPath()),
176
+ readEnvFile(binaryDirEnvPath()),
177
+ ])
178
+ return parsePort(process.env.PORT ?? dataDirEnv.PORT ?? binaryDirEnv.PORT) ?? findFreePort()
179
+ }
180
+
181
+ async function startEmbeddedServer(timeoutMs?: number): Promise<string> {
146
182
  killServerChild()
147
- const port = findFreePort()
183
+ const port = await resolveEmbeddedPort()
148
184
  const url = `http://localhost:${port}`
149
185
  serverChild = Bun.spawn({
150
186
  cmd: [resolveServerBinary()],
151
187
  // BELTE_PARENT_PID lets the child exit if the launcher is force-quit
152
- // (a clean window close reaps it directly; see exitWithParent).
188
+ // (a clean window close reaps it directly; see exitWithParent). The
189
+ // server resolves its own config from its data-dir/binary-dir .env at
190
+ // boot (see serverEntry), so the launcher injects nothing else.
153
191
  env: { ...process.env, PORT: String(port), BELTE_PARENT_PID: String(process.pid) },
154
192
  stdio: ['inherit', 'inherit', 'inherit'],
155
193
  })
156
- await waitForServer(url)
194
+ /*
195
+ Race readiness against the child's exit. A misconfigured bundle (missing env
196
+ the server needs to bind) crashes immediately; without this the launcher
197
+ would wait out waitForServer's full timeout and report a generic stall
198
+ instead of the actual crash. The exit branch resolves (never rejects) so the
199
+ race loser still pending after a successful boot can't surface as an
200
+ unhandled rejection when the child is later reaped on disconnect.
201
+ */
202
+ const exited = serverChild.exited
203
+ const outcome = await Promise.race([
204
+ waitForServer(url, timeoutMs ? { timeoutMs } : undefined).then(() => undefined),
205
+ exited,
206
+ ])
207
+ if (outcome !== undefined) {
208
+ throw new Error(`[belte] embedded server exited (code ${outcome}) before binding`)
209
+ }
157
210
  return url
158
211
  }
159
212
 
213
+ /*
214
+ Where the window should point on launch, resolved before it ever opens so the
215
+ connect screen never flashes. Repeats the last connection from the launcher-owned
216
+ record (which survives relaunch where the embedded server's fresh port can't):
217
+
218
+ - embedded, config complete → boot it and point at the live server
219
+ - embedded, config missing → the connect screen, so the user can configure
220
+ - remote url, still alive → point straight at it
221
+ - remote url, now dead → the connect screen with a `lost` notice
222
+ - nothing recorded → the connect screen
223
+
224
+ Boot is bounded by a short ceiling: a failed or slow boot falls back to the
225
+ connect screen rather than leaving the launcher window-less, and reaps the child
226
+ so a half-started server doesn't hold its port.
227
+ */
228
+ const AUTO_START_CEILING_MS = 3000
229
+ async function resolveLaunchTarget(): Promise<string> {
230
+ const last = await readLastConnection()
231
+ if (!last) {
232
+ return controlOrigin
233
+ }
234
+ if (last.kind === 'embedded') {
235
+ if (await autoStartBlockedByConfig()) {
236
+ return controlOrigin
237
+ }
238
+ try {
239
+ const url = await startEmbeddedServer(AUTO_START_CEILING_MS)
240
+ flag?.setConnected(true)
241
+ startLivenessWatch(url)
242
+ log.info(`resumed embedded server at ${url}`)
243
+ return url
244
+ } catch (error) {
245
+ killServerChild()
246
+ log.warn(`embedded server did not resume: ${String(error)}`)
247
+ return controlOrigin
248
+ }
249
+ }
250
+ const identity = await probeBelteServer(last.url)
251
+ if (identity) {
252
+ flag?.setConnected(true)
253
+ startLivenessWatch(last.url)
254
+ log.info(`reconnected to ${identity.name} at ${last.url}`)
255
+ return last.url
256
+ }
257
+ log.warn(`saved server did not respond: ${last.url}`)
258
+ return `${controlOrigin}/?action=lost`
259
+ }
260
+
261
+ // True when the app declares required config that nothing yet supplies, so an
262
+ // embedded auto-start would only crash for the lack of it — land on the connect
263
+ // screen (and its setup modal) instead.
264
+ async function autoStartBlockedByConfig(): Promise<boolean> {
265
+ const required = (configSchema?.required as string[] | undefined) ?? []
266
+ if (required.length === 0) {
267
+ return false
268
+ }
269
+ const values = await resolveConfigValues()
270
+ return required.some((key) => !values[key])
271
+ }
272
+
160
273
  /*
161
274
  Injects the app title into the connect-screen HTML just before serving — the build
162
275
  left a `<!--belte:connect-config-->` marker in <head>.
@@ -167,6 +280,88 @@ function renderConnectScreen(): Response {
167
280
  return new Response(html, { headers: { 'content-type': 'text/html; charset=utf-8' } })
168
281
  }
169
282
 
283
+ // The data-dir `.env` the form writes and the server loads first at boot.
284
+ function dataDirEnvPath(): string {
285
+ return join(appDataDir(programName), '.env')
286
+ }
287
+
288
+ // The shipped `.env` beside the server binary (the bundle's default config layer).
289
+ function binaryDirEnvPath(): string {
290
+ return join(dirname(resolveServerBinary()), '.env')
291
+ }
292
+
293
+ /*
294
+ Resolves the value to pre-fill each config field with, following the same
295
+ precedence the server applies below the shell: the user's saved data-dir `.env`,
296
+ then the bundle's shipped binary-dir `.env`, then the schema's own `default`.
297
+ Empty string when nothing supplies it — which is how the form spots an unmet
298
+ required field.
299
+ */
300
+ async function resolveConfigValues(): Promise<Record<string, string>> {
301
+ const properties = (configSchema?.properties ?? {}) as Record<string, { default?: unknown }>
302
+ // Independent reads — fetch together; precedence is applied in the merge below.
303
+ const [dataDirEnv, binaryDirEnv] = await Promise.all([
304
+ readEnvFile(dataDirEnvPath()),
305
+ readEnvFile(binaryDirEnvPath()),
306
+ ])
307
+ return Object.fromEntries(
308
+ Object.keys(properties).map((key) => {
309
+ const fallback = properties[key]?.default
310
+ const value =
311
+ dataDirEnv[key] ??
312
+ binaryDirEnv[key] ??
313
+ (fallback === undefined ? '' : String(fallback))
314
+ return [key, value]
315
+ }),
316
+ )
317
+ }
318
+
319
+ /*
320
+ Persists the form's answers to the data-dir `.env`, merged over any existing
321
+ file so keys the form didn't touch survive. Creates the data dir on first run
322
+ (appDataDir only computes the path).
323
+ */
324
+ async function writeConfig(values: Record<string, string>): Promise<void> {
325
+ const path = dataDirEnvPath()
326
+ const merged = { ...(await readEnvFile(path)), ...values }
327
+ await mkdir(appDataDir(programName), { recursive: true })
328
+ await Bun.write(path, serializeEnv(merged))
329
+ }
330
+
331
+ /*
332
+ The launcher-owned record of the last connection, in the data dir so it survives
333
+ relaunch and is readable before the window opens — unlike the webview's
334
+ localStorage, and unlike the embedded server's URL, which can't be persisted
335
+ because it picks a fresh port each launch (so we record the intent, not the URL).
336
+ resolveLaunchTarget reads it; /connect and /start write it; /disconnect clears it.
337
+ */
338
+ type LastConnection = { kind: 'embedded' } | { kind: 'url'; url: string }
339
+
340
+ function lastConnectionPath(): string {
341
+ return join(appDataDir(programName), 'last-connection.json')
342
+ }
343
+
344
+ async function readLastConnection(): Promise<LastConnection | undefined> {
345
+ const file = Bun.file(lastConnectionPath())
346
+ if (!(await file.exists())) {
347
+ return undefined
348
+ }
349
+ try {
350
+ return (await file.json()) as LastConnection
351
+ } catch {
352
+ return undefined
353
+ }
354
+ }
355
+
356
+ async function writeLastConnection(value: LastConnection): Promise<void> {
357
+ await mkdir(appDataDir(programName), { recursive: true })
358
+ await Bun.write(lastConnectionPath(), JSON.stringify(value))
359
+ }
360
+
361
+ async function clearLastConnection(): Promise<void> {
362
+ await rm(lastConnectionPath(), { force: true })
363
+ }
364
+
170
365
  /*
171
366
  The control server's request handler. The connect screen owns localStorage +
172
367
  navigation; this worker owns the embedded-server process and the native flag.
@@ -176,6 +371,18 @@ async function handleControlRequest(request: Request): Promise<Response> {
176
371
  if (request.method === 'GET' && url.pathname === '/') {
177
372
  return renderConnectScreen()
178
373
  }
374
+ if (request.method === 'GET' && url.pathname === '/__belte/config') {
375
+ // No schema declared → null tells the form to skip the gate entirely.
376
+ if (!configSchema) {
377
+ return Response.json({ schema: null, values: {} })
378
+ }
379
+ return Response.json({ schema: configSchema, values: await resolveConfigValues() })
380
+ }
381
+ if (request.method === 'POST' && url.pathname === '/__belte/config') {
382
+ const { values } = (await request.json()) as { values: Record<string, string> }
383
+ await writeConfig(values)
384
+ return new Response(undefined, { status: 204 })
385
+ }
179
386
  if (request.method === 'POST' && url.pathname === '/connect') {
180
387
  const { url: target } = (await request.json()) as { url: string }
181
388
  // Verify it's actually a belte server before pointing the window at it.
@@ -189,6 +396,8 @@ async function handleControlRequest(request: Request): Promise<Response> {
189
396
  }
190
397
  flag?.setConnected(true)
191
398
  startLivenessWatch(target)
399
+ // Record the choice so the next launch reconnects here before opening.
400
+ await writeLastConnection({ kind: 'url', url: target })
192
401
  log.info(`connecting to ${identity.name} at ${target}`)
193
402
  return Response.json({ redirect: target })
194
403
  }
@@ -197,6 +406,8 @@ async function handleControlRequest(request: Request): Promise<Response> {
197
406
  const localUrl = await startEmbeddedServer()
198
407
  flag?.setConnected(true)
199
408
  startLivenessWatch(localUrl)
409
+ // Record the choice so the next launch boots the embedded server first.
410
+ await writeLastConnection({ kind: 'embedded' })
200
411
  log.info(`started embedded server at ${localUrl}`)
201
412
  return Response.json({ redirect: localUrl })
202
413
  } catch (error) {
@@ -208,6 +419,8 @@ async function handleControlRequest(request: Request): Promise<Response> {
208
419
  stopLivenessWatch()
209
420
  killServerChild()
210
421
  flag?.setConnected(false)
422
+ // Forget the auto-resume choice so the next launch lands on the connect screen.
423
+ await clearLastConnection()
211
424
  return new Response(undefined, { status: 204 })
212
425
  }
213
426
  return new Response('not found', { status: 404 })
@@ -216,18 +429,26 @@ async function handleControlRequest(request: Request): Promise<Response> {
216
429
  /*
217
430
  Bind the control server to 127.0.0.1 literally (not `localhost`) so the webview
218
431
  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.
432
+ handle, then resolve where the window should open before handing back. Resolving
433
+ the launch target here — booting/probing the last connection before `ready` — is
434
+ what lets the launcher open the window straight at the live server, so the
435
+ connect screen never flashes; only an unconfigured, failed, or absent resume
436
+ falls back to it. The launcher gets both `origin` (for the File-menu actions) and
437
+ `target` (where to point the window now).
220
438
  */
221
439
  async function start(init: Init): Promise<void> {
222
440
  disconnectedHtml = init.disconnectedHtml
223
441
  title = init.title
442
+ programName = init.programName
443
+ configSchema = init.configSchema
224
444
  const libPath = await resolveWebviewLib()
225
445
  flag = bindConnectedFlag(libPath)
226
446
  navigate = bindRequestNavigate(libPath)
227
447
  server = listenLocalControlServer(stableLocalPort(init.programName), handleControlRequest)
228
448
  controlOrigin = `http://127.0.0.1:${server.port}`
229
449
  log.info(`${title} control server listening at ${controlOrigin}`)
230
- self.postMessage({ type: 'ready', origin: controlOrigin })
450
+ const target = await resolveLaunchTarget()
451
+ self.postMessage({ type: 'ready', origin: controlOrigin, target })
231
452
  }
232
453
 
233
454
  // Reap the child + release the server and FFI handles, then confirm so the
@@ -1,3 +1,4 @@
1
+ import type { StandardSchemaV1 } from '../server/rpc/types/StandardSchemaV1.ts'
1
2
  import type { BundleMenu } from './BundleMenu.ts'
2
3
 
3
4
  /*
@@ -17,4 +18,14 @@ export type BundleWindow = {
17
18
  width?: number
18
19
  height?: number
19
20
  menu?: BundleMenu[]
21
+ /*
22
+ Config the embedded server needs before it can run, as a Standard Schema (the
23
+ same kind belte accepts for RPC/MCP). Its JSON Schema drives the connect
24
+ screen's first-run form, shown as a modal when Start is clicked with a required
25
+ key still unset; the user's answers persist to the data-dir `.env` the server
26
+ loads at boot. Each property maps to one env var of the same name; `title` is
27
+ the field label, `description` the hint, `format: 'password'` masks the input,
28
+ and `default` pre-fills it.
29
+ */
30
+ config?: StandardSchemaV1
20
31
  }
@@ -11,20 +11,9 @@ connection so the native File menu's enabled state stays authoritative.
11
11
  */
12
12
 
13
13
  /*
14
- localStorage key holding the last connection so a relaunch repeats it: either a
15
- remote server URL, or the START_EMBEDDED sentinel meaning "boot the embedded
16
- server". The embedded server's own URL can't be persisted — it picks a fresh
17
- port each launch — so we persist the intent and re-run start() instead.
18
- Disconnect clears it so a relaunch never auto-retries a forgotten server.
19
- */
20
- const STORAGE_KEY = 'belte:server-url'
21
- const START_EMBEDDED = 'belte:start-embedded'
22
-
23
- /*
24
- The last remote URL that successfully connected, kept separate from STORAGE_KEY
25
- so it survives disconnect (and the app quitting): it only prefills the form, it
26
- never drives an auto-reconnect, so reconnecting to the same server stays one
27
- click away even after an explicit disconnect.
14
+ The last remote URL that successfully connected only prefills the form's input
15
+ on a later visit; it never drives a reconnect (the launcher owns auto-resume now,
16
+ deciding before the window even opens). Survives disconnect and quitting.
28
17
  */
29
18
  const LAST_URL_KEY = 'belte:last-server-url'
30
19
 
@@ -36,9 +25,51 @@ const placeholder = 'https://example.com'
36
25
 
37
26
  // Prefill the form with the last server we connected to, from any prior launch.
38
27
  let url = $state(localStorage.getItem(LAST_URL_KEY) ?? '')
39
- let starting = $state(false)
40
28
  let error = $state<string | undefined>(undefined)
41
29
 
30
+ /*
31
+ `?action=` set by the File menu (Start/Disconnect) or the launcher when a live
32
+ connection dies (`lost`). Auto-resume of a saved connection now happens in the
33
+ launcher before the window opens, so this screen only ever loads as a real
34
+ destination — there's no no-action auto-resume left to handle here.
35
+ */
36
+ const launchAction = new URLSearchParams(location.search).get('action')
37
+
38
+ /*
39
+ Two phases so the screen never flashes before a redirect. A menu Start may boot
40
+ straight through, so it opens on a neutral splash; every other entry is a genuine
41
+ destination, so the connect screen shows immediately. Boot/connect re-enter the
42
+ splash so a redirect (including after saving config) never flashes the form.
43
+ */
44
+ let phase = $state<'splash' | 'connect'>(launchAction === 'start' ? 'splash' : 'connect')
45
+
46
+ /*
47
+ First-run config form, surfaced as a modal only when Start is clicked (or
48
+ auto-start fires) with a required key still unset. Fields are derived from the
49
+ app's config JSON Schema served by the launcher; answers post back to the
50
+ data-dir `.env` the embedded server loads at boot.
51
+ */
52
+ type ConfigField = {
53
+ key: string
54
+ label: string
55
+ description?: string
56
+ inputType: 'text' | 'password' | 'number' | 'checkbox'
57
+ required: boolean
58
+ }
59
+ let configFields = $state<ConfigField[]>([])
60
+ let configValues = $state<Record<string, string>>({})
61
+ let showConfig = $state(false)
62
+ let savingConfig = $state(false)
63
+ // Every required field has a value — gates the modal's Save button.
64
+ const canSaveConfig = $derived(
65
+ configFields.every(
66
+ (field) =>
67
+ !field.required ||
68
+ field.inputType === 'checkbox' ||
69
+ (configValues[field.key] ?? '').trim() !== '',
70
+ ),
71
+ )
72
+
42
73
  /*
43
74
  Interpret the boot intent once on load. `?action=` is set by the native File
44
75
  menu's navigate items (or the launcher when a live connection dies); absent it, a
@@ -51,30 +82,19 @@ remembered server reconnects automatically:
51
82
  - (none) → reconnect to the saved server if there is one.
52
83
  */
53
84
  $effect(() => {
54
- const action = new URLSearchParams(location.search).get('action')
55
- const saved = localStorage.getItem(STORAGE_KEY) ?? undefined
56
- if (action === 'start') {
85
+ if (launchAction === 'start') {
86
+ // File-menu Start Server is an explicit click → run the Start flow.
57
87
  void start()
58
88
  return
59
89
  }
60
- if (action === 'lost') {
90
+ if (launchAction === 'lost') {
61
91
  error = 'The server stopped responding.'
62
92
  return
63
93
  }
64
- if (action === 'disconnect') {
65
- // Forget the auto-reconnect intent but keep LAST_URL_KEY, so the form
66
- // stays prefilled with the server we just left for a one-click return.
67
- localStorage.removeItem(STORAGE_KEY)
94
+ if (launchAction === 'disconnect') {
95
+ // Have the launcher forget the auto-resume choice and reap any embedded
96
+ // server; LAST_URL_KEY stays so the form is still prefilled to reconnect.
68
97
  void fetch('/__belte/disconnect').catch(() => {})
69
- return
70
- }
71
- // No action: repeat the last choice — re-boot the embedded server, or reconnect.
72
- if (saved === START_EMBEDDED) {
73
- void start()
74
- return
75
- }
76
- if (saved) {
77
- void connect(saved)
78
98
  }
79
99
  })
80
100
 
@@ -92,6 +112,8 @@ async function connect(target: string = url.trim()): Promise<void> {
92
112
  return
93
113
  }
94
114
  error = undefined
115
+ // Hide the form while connecting so a successful redirect doesn't flash it.
116
+ phase = 'splash'
95
117
  try {
96
118
  const response = await fetch('/connect', {
97
119
  method: 'POST',
@@ -103,21 +125,44 @@ async function connect(target: string = url.trim()): Promise<void> {
103
125
  throw new Error(body.error ?? `connect failed (${response.status})`)
104
126
  }
105
127
  const { redirect } = (await response.json()) as { redirect: string }
106
- localStorage.setItem(STORAGE_KEY, cleaned)
107
- // Remember it separately so it outlives a later disconnect and prefills the form.
128
+ // Prefill the form with this server on a later visit (the launcher records
129
+ // the auto-resume choice itself, on the /connect it just handled).
108
130
  localStorage.setItem(LAST_URL_KEY, cleaned)
109
131
  location.href = redirect
110
132
  } catch (cause) {
111
133
  error = `Could not connect: ${String(cause)}`
134
+ // Failed — bring the form back so the error and a retry are visible.
135
+ phase = 'connect'
112
136
  }
113
137
  }
114
138
 
115
- // Boot the embedded server via the launcher, then follow it once it answers.
139
+ /*
140
+ Start, always an explicit click (button or File-menu) — auto-resume happens in
141
+ the launcher before the window opens, so this is never a launch path. Asks the
142
+ launcher what config the app needs: if it declares any, open the modal (prefilled
143
+ with the last-used values) so the user can review or change settings before
144
+ booting — re-running Start after a disconnect is how you reconfigure. With no
145
+ config schema, boot straight through. The modal's save path resumes the boot.
146
+ */
116
147
  async function start(): Promise<void> {
117
148
  error = undefined
118
- starting = true
119
- // Remember the embedded-server choice so the next launch boots it automatically.
120
- localStorage.setItem(STORAGE_KEY, START_EMBEDDED)
149
+ const config = await loadConfig().catch(() => undefined)
150
+ if (config) {
151
+ configFields = config.fields
152
+ configValues = { ...config.values }
153
+ // Reveal the connect screen as the modal's backdrop.
154
+ phase = 'connect'
155
+ showConfig = true
156
+ return
157
+ }
158
+ await boot()
159
+ }
160
+
161
+ // Boot the embedded server via the launcher, then follow it once it answers.
162
+ async function boot(): Promise<void> {
163
+ // Splash while booting so the connect screen doesn't flash before the redirect
164
+ // (including straight after saving config).
165
+ phase = 'splash'
121
166
  try {
122
167
  const response = await fetch('/start', { method: 'POST' })
123
168
  if (!response.ok) {
@@ -128,11 +173,92 @@ async function start(): Promise<void> {
128
173
  location.href = redirect
129
174
  } catch (cause) {
130
175
  error = `Could not start the server: ${String(cause)}`
131
- starting = false
176
+ // Boot failed — bring the connect screen back to show the error.
177
+ phase = 'connect'
178
+ }
179
+ }
180
+
181
+ /*
182
+ Fetches the app's config schema + resolved current values from the launcher and
183
+ turns the JSON Schema into render-ready fields. Returns undefined when no schema
184
+ is declared, so Start never gates.
185
+ */
186
+ async function loadConfig(): Promise<
187
+ { fields: ConfigField[]; values: Record<string, string> } | undefined
188
+ > {
189
+ const response = await fetch('/__belte/config')
190
+ const { schema, values } = (await response.json()) as {
191
+ schema: Record<string, unknown> | null
192
+ values: Record<string, string>
193
+ }
194
+ if (!schema) {
195
+ return undefined
196
+ }
197
+ return { fields: fieldsFromSchema(schema), values: values ?? {} }
198
+ }
199
+
200
+ // Derives one render-ready field per JSON Schema property, reusing the standard
201
+ // slots: `title` → label, `description` → hint, `format`/`type` → input kind.
202
+ function fieldsFromSchema(schema: Record<string, unknown>): ConfigField[] {
203
+ const properties = (schema.properties ?? {}) as Record<string, Record<string, unknown>>
204
+ const required = new Set((schema.required as string[]) ?? [])
205
+ return Object.entries(properties).map(([key, property]) => ({
206
+ key,
207
+ label: (property.title as string) ?? key,
208
+ description: property.description as string | undefined,
209
+ inputType: inputType(property),
210
+ required: required.has(key),
211
+ }))
212
+ }
213
+
214
+ // Maps a JSON Schema property to an HTML input kind (directory falls back to text
215
+ // until a native picker exists).
216
+ function inputType(property: Record<string, unknown>): ConfigField['inputType'] {
217
+ if (property.type === 'boolean') {
218
+ return 'checkbox'
219
+ }
220
+ if (property.type === 'number' || property.type === 'integer') {
221
+ return 'number'
222
+ }
223
+ if (property.format === 'password') {
224
+ return 'password'
225
+ }
226
+ return 'text'
227
+ }
228
+
229
+ // Persist the form's answers to the data-dir `.env`, then resume the boot.
230
+ async function saveConfig(): Promise<void> {
231
+ error = undefined
232
+ savingConfig = true
233
+ try {
234
+ const response = await fetch('/__belte/config', {
235
+ method: 'POST',
236
+ headers: { 'content-type': 'application/json' },
237
+ body: JSON.stringify({ values: configValues }),
238
+ })
239
+ if (!response.ok) {
240
+ throw new Error(`save failed (${response.status})`)
241
+ }
242
+ showConfig = false
243
+ savingConfig = false
244
+ await boot()
245
+ } catch (cause) {
246
+ error = `Could not save settings: ${String(cause)}`
247
+ savingConfig = false
132
248
  }
133
249
  }
134
250
  </script>
135
251
 
252
+ {#if phase === 'splash'}
253
+ <!-- Neutral splash shown while an auto-start/auto-reconnect resolves, so the
254
+ connect screen never flashes before it redirects. Same background as the card. -->
255
+ <div
256
+ class="flex min-h-screen items-center justify-center bg-gray-50 dark:bg-gray-950">
257
+ {#if logo}
258
+ <img src={logo} alt="" class="h-16 w-16 rounded-xl object-contain opacity-90">
259
+ {/if}
260
+ </div>
261
+ {:else}
136
262
  <main
137
263
  class="flex min-h-screen items-center justify-center bg-gray-50 p-6 text-gray-900 dark:bg-gray-950 dark:text-gray-100">
138
264
  <div
@@ -170,9 +296,8 @@ async function start(): Promise<void> {
170
296
  <button
171
297
  type="button"
172
298
  onclick={() => void start()}
173
- disabled={starting}
174
- class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium hover:bg-gray-50 disabled:opacity-60 dark:border-gray-700 dark:hover:bg-gray-800">
175
- {starting ? 'Starting…' : 'Start server'}
299
+ class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800">
300
+ Start server
176
301
  </button>
177
302
 
178
303
  {#if error}
@@ -189,3 +314,74 @@ async function start(): Promise<void> {
189
314
  </p>
190
315
  </div>
191
316
  </main>
317
+ {/if}
318
+
319
+ {#if showConfig}
320
+ <!-- First-run config modal — shown only when Start needs settings the app lacks. -->
321
+ <div
322
+ class="fixed inset-0 z-10 flex items-center justify-center bg-black/40 p-6 text-gray-900 dark:text-gray-100">
323
+ <div
324
+ class="w-full max-w-sm rounded-2xl bg-white p-8 shadow-lg ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-gray-800">
325
+ <h2 class="mb-5 text-lg font-semibold tracking-tight">Set up {heading}</h2>
326
+
327
+ <form
328
+ class="flex flex-col gap-4"
329
+ onsubmit={(event) => {
330
+ event.preventDefault()
331
+ void saveConfig()
332
+ }}>
333
+ {#each configFields as field (field.key)}
334
+ <label class="flex flex-col gap-1 text-sm">
335
+ <span class="font-medium">
336
+ {field.label}
337
+ {#if field.required}
338
+ <span class="text-red-500">*</span>
339
+ {/if}
340
+ </span>
341
+ {#if field.inputType === 'checkbox'}
342
+ <input
343
+ type="checkbox"
344
+ checked={configValues[field.key] === 'true'}
345
+ onchange={(event) =>
346
+ (configValues[field.key] = event.currentTarget.checked
347
+ ? 'true'
348
+ : 'false')}
349
+ class="mt-1 size-4 self-start rounded border-gray-300 dark:border-gray-700">
350
+ {:else}
351
+ <input
352
+ type={field.inputType}
353
+ value={configValues[field.key] ?? ''}
354
+ oninput={(event) =>
355
+ (configValues[field.key] = event.currentTarget.value)}
356
+ class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm outline-none focus:border-gray-900 focus:ring-1 focus:ring-gray-900 dark:border-gray-700 dark:bg-gray-800 dark:focus:border-gray-100 dark:focus:ring-gray-100">
357
+ {/if}
358
+ {#if field.description}
359
+ <span class="text-xs text-gray-400 dark:text-gray-500">
360
+ {field.description}
361
+ </span>
362
+ {/if}
363
+ </label>
364
+ {/each}
365
+
366
+ <div class="mt-1 flex gap-3">
367
+ <button
368
+ type="button"
369
+ onclick={() => (showConfig = false)}
370
+ class="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800">
371
+ Cancel
372
+ </button>
373
+ <button
374
+ type="submit"
375
+ disabled={!canSaveConfig || savingConfig}
376
+ class="flex-1 rounded-lg bg-gray-900 px-3 py-2 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-60 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-gray-300">
377
+ {savingConfig ? 'Saving…' : 'Save & start'}
378
+ </button>
379
+ </div>
380
+ </form>
381
+
382
+ {#if error}
383
+ <p class="mt-4 text-center text-sm text-red-600 dark:text-red-400">{error}</p>
384
+ {/if}
385
+ </div>
386
+ </div>
387
+ {/if}
@@ -1,44 +1,14 @@
1
- import { existsSync } from 'node:fs'
2
1
  import { dirname } from 'node:path'
3
-
4
- const ENV_LINE = /^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/
2
+ import { loadEnvFile } from '../shared/loadEnvFile.ts'
5
3
 
6
4
  /*
7
- Reads a `.env` next to the running binary (resolved via
8
- `process.execPath`) and merges each declared var into `process.env`
9
- only when not already set. The binary-dir `.env` is the file the
10
- install tarball ships next to the executable; per-shell exports and
11
- Bun's automatic CWD `.env` loading both naturally override it.
12
-
13
- Strips surrounding single or double quotes off values; otherwise the
14
- parser is intentionally minimal — no variable expansion, no escape
15
- handling, no multi-line. Matches what the install tarball writes.
5
+ Loads a `.env` sitting next to the running binary (resolved via
6
+ `process.execPath`) into `process.env`. This is the file the install tarball
7
+ ships beside the executable and, for a bundle, the one `bundleApp` copies
8
+ from the project's `.env.bundle`. It carries the app's shipped defaults; the
9
+ fill-when-unset merge (see loadEnvFile) lets per-shell exports, Bun's CWD
10
+ `.env`, and the user's data-dir config all override it.
16
11
  */
17
12
  export async function loadEnvFromBinaryDir(): Promise<void> {
18
- const binDir = dirname(process.execPath)
19
- const envPath = `${binDir}/.env`
20
- if (!existsSync(envPath)) {
21
- return
22
- }
23
- const text = await Bun.file(envPath).text()
24
- for (const line of text.split('\n')) {
25
- if (!line || line.startsWith('#')) {
26
- continue
27
- }
28
- const match = ENV_LINE.exec(line)
29
- if (!match) {
30
- continue
31
- }
32
- const [, key, rawValue] = match
33
- if (process.env[key as string] !== undefined) {
34
- continue
35
- }
36
- const trimmed = rawValue?.trim() ?? ''
37
- const unquoted =
38
- (trimmed.startsWith('"') && trimmed.endsWith('"')) ||
39
- (trimmed.startsWith("'") && trimmed.endsWith("'"))
40
- ? trimmed.slice(1, -1)
41
- : trimmed
42
- process.env[key as string] = unquoted
43
- }
13
+ await loadEnvFile(`${dirname(process.execPath)}/.env`)
44
14
  }
@@ -30,6 +30,7 @@ import { createAssetHeaderCache } from './createAssetHeaderCache.ts'
30
30
  import { createPublicAssetServer } from './createPublicAssetServer.ts'
31
31
  import { globToPathSet } from './globToPathSet.ts'
32
32
  import { logBrowserOnlyRoutes } from './logBrowserOnlyRoutes.ts'
33
+ import { parsePort } from './parsePort.ts'
33
34
  import { ensureRegistriesLoaded, setRegistryManifests } from './registryManifests.ts'
34
35
  import { requestContext } from './requestContext.ts'
35
36
  import { safeJsonForScript } from './safeJsonForScript.ts'
@@ -103,7 +104,7 @@ export async function createServer({
103
104
  distDir = `${process.cwd()}/dist`,
104
105
  publicDir = `${process.cwd()}/src/browser/public`,
105
106
  resourcesDir = `${process.cwd()}/src/mcp/resources`,
106
- port = Number(process.env.PORT ?? 3000),
107
+ port = parsePort(process.env.PORT) ?? 3000,
107
108
  }: {
108
109
  pages: Pages
109
110
  rpc: RemoteRoutes
@@ -546,21 +547,28 @@ export async function createServer({
546
547
  */
547
548
  setActiveServer(server)
548
549
 
549
- if (app?.init) {
550
- const cleanup = await app.init({ server })
550
+ const cleanup = app?.init ? await app.init({ server }) : undefined
551
+ /*
552
+ Close the listener deterministically on shutdown. Always registered (even
553
+ with no init cleanup) so the socket is released via server.stop rather than
554
+ left to abrupt process exit — which leaves the port in TIME_WAIT and races
555
+ a fast restart. A watchdog force-exits if a user cleanup hangs, so a stuck
556
+ cleanup can't keep the process (and its port) alive.
557
+ */
558
+ const shutdown = async () => {
559
+ server.stop(true)
551
560
  if (typeof cleanup === 'function') {
552
- const shutdown = async () => {
553
- try {
554
- await cleanup()
555
- } catch (err) {
556
- log.error(err)
557
- }
558
- process.exit(0)
561
+ setTimeout(() => process.exit(0), 3000).unref()
562
+ try {
563
+ await cleanup()
564
+ } catch (err) {
565
+ log.error(err)
559
566
  }
560
- process.once('SIGINT', shutdown)
561
- process.once('SIGTERM', shutdown)
562
567
  }
568
+ process.exit(0)
563
569
  }
570
+ process.once('SIGINT', shutdown)
571
+ process.once('SIGTERM', shutdown)
564
572
 
565
573
  log.success(`ready at http://localhost:${server.port}`)
566
574
  /*
@@ -0,0 +1,16 @@
1
+ /*
2
+ Parses a PORT env value into a usable TCP port, returning undefined for
3
+ missing, empty, or out-of-range/non-integer input so the caller can fall back
4
+ to a default. A bare Number() turns '' into 0 (a random kernel-assigned port)
5
+ and 'abc' into NaN, both silently wrong; this rejects them instead.
6
+ */
7
+ export function parsePort(value: string | undefined): number | undefined {
8
+ if (value === undefined || value.trim() === '') {
9
+ return undefined
10
+ }
11
+ const port = Number(value)
12
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
13
+ return undefined
14
+ }
15
+ return port
16
+ }
@@ -0,0 +1,22 @@
1
+ import { homedir } from 'node:os'
2
+ import { join } from 'node:path'
3
+
4
+ /*
5
+ Platform-standard per-user data directory for a bundle, keyed by its program
6
+ name. cwd-independent on purpose: the path derives from `programName`, not the
7
+ process working directory — which the OS `open` command sets to `/`, so anything
8
+ relying on cwd (Bun's `.env` autoload, relative paths) finds nothing inside a
9
+ launched `.app`. This is where a bundle keeps what can't be baked at compile time
10
+ — the user's config, DB, and cache. macOS Application Support, Windows %APPDATA%,
11
+ XDG data home elsewhere. Pure: computes the path, never touches the filesystem.
12
+ */
13
+ export function appDataDir(programName: string): string {
14
+ const home = homedir()
15
+ if (process.platform === 'darwin') {
16
+ return join(home, 'Library', 'Application Support', programName)
17
+ }
18
+ if (process.platform === 'win32') {
19
+ return join(process.env.APPDATA ?? join(home, 'AppData', 'Roaming'), programName)
20
+ }
21
+ return join(process.env.XDG_DATA_HOME ?? join(home, '.local', 'share'), programName)
22
+ }
@@ -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
+ }
@@ -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
+ }
@@ -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