@briancray/belte 0.4.0 → 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 +12 -0
- package/src/controlServerWorker.ts +205 -7
- package/src/lib/bundle/BundleWindow.ts +11 -0
- package/src/lib/bundle/disconnected.svelte +238 -42
- package/src/lib/cli/loadEnvFromBinaryDir.ts +8 -38
- package/src/lib/server/runtime/createServer.ts +20 -12
- package/src/lib/server/runtime/parsePort.ts +16 -0
- package/src/lib/shared/appDataDir.ts +22 -0
- 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/serializeEnv.ts +18 -0
- package/src/serverEntry.ts +12 -0
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
|
@@ -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,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
|
|
@@ -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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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 (
|
|
90
|
+
if (launchAction === 'lost') {
|
|
61
91
|
error = 'The server stopped responding.'
|
|
62
92
|
return
|
|
63
93
|
}
|
|
64
|
-
if (
|
|
65
|
-
//
|
|
66
|
-
// stays
|
|
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
|
-
|
|
107
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
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
|
-
|
|
174
|
-
|
|
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
|
-
|
|
8
|
-
`process.execPath`)
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
550
|
-
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
+
}
|
package/src/serverEntry.ts
CHANGED
|
@@ -25,10 +25,22 @@ import { shell } from './_virtual/shell.ts'
|
|
|
25
25
|
// @ts-expect-error virtual module resolved by belteResolverPlugin
|
|
26
26
|
import { sockets } from './_virtual/sockets.ts'
|
|
27
27
|
import { exitWithParent } from './lib/bundle/exitWithParent.ts'
|
|
28
|
+
import { loadEnvFromBinaryDir } from './lib/cli/loadEnvFromBinaryDir.ts'
|
|
28
29
|
import { createServer } from './lib/server/runtime/createServer.ts'
|
|
29
30
|
import { requestContext } from './lib/server/runtime/requestContext.ts'
|
|
31
|
+
import { loadEnvFromDataDir } from './lib/shared/loadEnvFromDataDir.ts'
|
|
30
32
|
import { setCacheStoreResolver } from './lib/shared/setCacheStoreResolver.ts'
|
|
31
33
|
|
|
34
|
+
/*
|
|
35
|
+
Resolve config into process.env before anything reads it (createServer reads
|
|
36
|
+
PORT, app code reads Bun.env.*). Data-dir first so the user's saved config wins
|
|
37
|
+
over the binary-dir shipped default; both back-fill only what the shell or Bun's
|
|
38
|
+
CWD `.env` didn't already set. A bundle launched via `open` has cwd `/`, so the
|
|
39
|
+
data-dir `.env` is how it gets its config at all.
|
|
40
|
+
*/
|
|
41
|
+
await loadEnvFromDataDir(cliProgramName)
|
|
42
|
+
await loadEnvFromBinaryDir()
|
|
43
|
+
|
|
32
44
|
// In a bundle, tie this server's life to the launcher's (no-op standalone).
|
|
33
45
|
exitWithParent()
|
|
34
46
|
|