@briancray/belte 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/bin/belte.ts +25 -12
  2. package/package.json +2 -1
  3. package/src/appEntry.ts +124 -0
  4. package/src/belteResolverPlugin.ts +217 -194
  5. package/src/build.ts +6 -67
  6. package/src/buildCli.ts +36 -63
  7. package/src/buildDisconnected.ts +127 -0
  8. package/src/bundleApp.ts +123 -0
  9. package/src/bundleDisconnectedEntry.ts +17 -0
  10. package/src/cliEntry.ts +3 -9
  11. package/src/compile.ts +4 -15
  12. package/src/controlServerWorker.ts +261 -0
  13. package/src/dedupeSveltePlugin.ts +66 -0
  14. package/src/discoveryEntry.ts +12 -11
  15. package/src/lib/browser/cache.ts +3 -6
  16. package/src/lib/browser/page.svelte.ts +19 -21
  17. package/src/lib/browser/socketChannel.ts +11 -1
  18. package/src/lib/browser/types/Pages.ts +1 -1
  19. package/src/lib/bundle/BundleMenu.ts +11 -0
  20. package/src/lib/bundle/BundleMenuItem.ts +24 -0
  21. package/src/lib/bundle/BundleWindow.ts +20 -0
  22. package/src/lib/bundle/bindConnectedFlag.ts +29 -0
  23. package/src/lib/bundle/bindRequestNavigate.ts +31 -0
  24. package/src/lib/bundle/buildWebviewLib.ts +111 -0
  25. package/src/lib/bundle/disconnected.css +9 -0
  26. package/src/lib/bundle/disconnected.svelte +192 -0
  27. package/src/lib/bundle/ensureWebviewLib.ts +20 -0
  28. package/src/lib/bundle/exitWithParent.ts +28 -0
  29. package/src/lib/bundle/findFreePort.ts +14 -0
  30. package/src/lib/bundle/infoPlist.ts +46 -0
  31. package/src/lib/bundle/installMacMenu.ts +39 -0
  32. package/src/lib/bundle/listenLocalControlServer.ts +19 -0
  33. package/src/lib/bundle/native/belteMenu.mm +298 -0
  34. package/src/lib/bundle/native/webview.h +4557 -0
  35. package/src/lib/bundle/onMenu.ts +26 -0
  36. package/src/lib/bundle/openWebview.ts +81 -0
  37. package/src/lib/bundle/pngToIcns.ts +47 -0
  38. package/src/lib/bundle/probeBelteServer.ts +34 -0
  39. package/src/lib/bundle/resolveServerBinary.ts +12 -0
  40. package/src/lib/bundle/resolveWebviewLib.ts +51 -0
  41. package/src/lib/bundle/serverBinaryFilename.ts +8 -0
  42. package/src/lib/bundle/stableLocalPort.ts +19 -0
  43. package/src/lib/bundle/waitForServer.ts +23 -0
  44. package/src/lib/bundle/webviewBuildRevision.ts +9 -0
  45. package/src/lib/bundle/webviewCachePath.ts +23 -0
  46. package/src/lib/bundle/webviewLibName.ts +11 -0
  47. package/src/lib/bundle/webviewVersion.ts +7 -0
  48. package/src/lib/cli/createClient.ts +34 -36
  49. package/src/lib/cli/printHelp.ts +45 -2
  50. package/src/lib/cli/runCli.ts +12 -3
  51. package/src/lib/mcp/createMcpResourceServer.ts +1 -1
  52. package/src/lib/mcp/dispatchMcpRequest.ts +53 -73
  53. package/src/lib/server/AppModule.ts +2 -2
  54. package/src/lib/server/cli/handleCliDownload.ts +4 -5
  55. package/src/lib/server/cli/handleCliInstall.ts +17 -0
  56. package/src/lib/server/error.ts +23 -9
  57. package/src/lib/server/json.ts +5 -5
  58. package/src/lib/server/jsonl.ts +10 -5
  59. package/src/lib/server/prompts/definePrompt.ts +6 -6
  60. package/src/lib/server/prompts/renderPromptTemplate.ts +16 -0
  61. package/src/lib/server/prompts/types/Prompt.ts +8 -9
  62. package/src/lib/server/prompts/types/PromptOptions.ts +7 -12
  63. package/src/lib/server/prompts/types/PromptRegistryEntry.ts +3 -5
  64. package/src/lib/server/prompts/types/PromptRoutes.ts +4 -4
  65. package/src/lib/server/redirect.ts +13 -8
  66. package/src/lib/server/rpc/defineVerb.ts +4 -3
  67. package/src/lib/server/rpc/findVerbByCommandName.ts +18 -0
  68. package/src/lib/server/rpc/types/RemoteFunction.ts +1 -1
  69. package/src/lib/server/rpc/types/RemoteHandler.ts +4 -0
  70. package/src/lib/server/runtime/acceptsZstd.ts +8 -0
  71. package/src/lib/server/runtime/buildOpenApiSpec.ts +2 -0
  72. package/src/lib/server/runtime/cacheControlForAsset.ts +7 -2
  73. package/src/lib/server/runtime/createAssetHeaderCache.ts +35 -0
  74. package/src/lib/server/runtime/createPublicAssetServer.ts +6 -20
  75. package/src/lib/server/runtime/createServer.ts +50 -58
  76. package/src/lib/server/runtime/registryManifests.ts +33 -15
  77. package/src/lib/server/runtime/types/RequestStore.ts +2 -3
  78. package/src/lib/server/runtime/withResponseDefaults.ts +24 -0
  79. package/src/lib/server/sockets/createSocketDispatcher.ts +10 -7
  80. package/src/lib/server/sse.ts +10 -5
  81. package/src/lib/shared/cacheControlValues.ts +10 -2
  82. package/src/lib/shared/canonicalJson.ts +1 -5
  83. package/src/lib/shared/createCacheStore.ts +29 -20
  84. package/src/lib/shared/exitOnBuildFailure.ts +17 -0
  85. package/src/lib/shared/fileStem.ts +9 -0
  86. package/src/lib/shared/jsonSchemaForPromptArguments.ts +29 -0
  87. package/src/lib/shared/keyForRemoteCall.ts +7 -5
  88. package/src/lib/shared/parsePromptMarkdown.ts +34 -0
  89. package/src/lib/shared/promptNameForFile.ts +5 -5
  90. package/src/lib/shared/subscribableFromResponse.ts +104 -215
  91. package/src/lib/shared/types/PromptArgument.ts +12 -0
  92. package/src/lib/shared/writeRoutesDts.ts +5 -7
  93. package/src/serverBuildPlugins.ts +25 -0
  94. package/src/serverEntry.ts +4 -0
  95. package/template/package.json +2 -1
  96. package/src/lib/server/prompt.ts +0 -30
  97. package/src/lib/server/prompts/types/PromptMessage.ts +0 -10
  98. package/src/lib/shared/preparePromptModule.ts +0 -36
@@ -0,0 +1,261 @@
1
+ import { bindConnectedFlag } from './lib/bundle/bindConnectedFlag.ts'
2
+ import { bindRequestNavigate } from './lib/bundle/bindRequestNavigate.ts'
3
+ import { findFreePort } from './lib/bundle/findFreePort.ts'
4
+ import { listenLocalControlServer } from './lib/bundle/listenLocalControlServer.ts'
5
+ import { probeBelteServer } from './lib/bundle/probeBelteServer.ts'
6
+ import { resolveServerBinary } from './lib/bundle/resolveServerBinary.ts'
7
+ import { resolveWebviewLib } from './lib/bundle/resolveWebviewLib.ts'
8
+ import { stableLocalPort } from './lib/bundle/stableLocalPort.ts'
9
+ import { waitForServer } from './lib/bundle/waitForServer.ts'
10
+ import { log } from './lib/shared/log.ts'
11
+
12
+ /*
13
+ The bundle's control server, run in a Worker so it owns its own thread.
14
+
15
+ `webview_run` enters a native UI run loop that blocks the launcher's main thread
16
+ indefinitely (the window owns it until close), which freezes the main thread's
17
+ JS event loop. An in-process `Bun.serve` there can never answer a request, so the
18
+ webview pointed at it would only ever see a hung navigation — a blank window.
19
+
20
+ Running the control server on this Worker thread keeps it answering the whole time
21
+ the window is open. It owns the pieces that must live beside it: the embedded
22
+ server child it spawns, and its own FFI handle to the native menu flag (set here
23
+ because the main thread can't process a postMessage while blocked in webview_run,
24
+ yet the flag is a process-global the main-thread menu still reads).
25
+
26
+ Bun does not apply the launcher build's plugins to a worker entry, so this module
27
+ can't import belte's virtual modules (the connect-screen HTML, app title). The
28
+ launcher — which can — passes them in the `init` message; on `shutdown` it has us
29
+ reap the embedded child before the launcher exits.
30
+
31
+ Once connected it also watches the chosen server's liveness — polling its identity
32
+ endpoint — and, when it stops answering, corrects the menu flag and bounces the
33
+ window back to the connect screen, since a dead server (local crash or remote
34
+ outage) otherwise leaves a frozen page and a menu that still claims connected.
35
+
36
+ GET / → the connect screen (title injected at serve time)
37
+ POST /connect {url} → record connected, reply { redirect: url }
38
+ POST /start → spawn the server binary, reply { redirect: localUrl }
39
+ GET /__belte/disconnect → reap the child, clear connected
40
+ */
41
+
42
+ // Init payload from the launcher, plus the per-run state the handlers close over.
43
+ type Init = { disconnectedHtml: string; title: string; programName: string }
44
+ let disconnectedHtml = ''
45
+ let title = ''
46
+ let flag: ReturnType<typeof bindConnectedFlag> | undefined
47
+ let server: ReturnType<typeof listenLocalControlServer> | undefined
48
+
49
+ // The control-server origin (where the connect screen lives) and the webview
50
+ // handle, forwarded by the launcher — together they let the watch bounce a dead
51
+ // window back to the connect screen.
52
+ let controlOrigin = ''
53
+ let navigate: ReturnType<typeof bindRequestNavigate> | undefined
54
+ let webviewHandle: number | undefined
55
+
56
+ /*
57
+ Liveness watch over the currently-connected server. A recursive timer (not
58
+ setInterval, so a slow probe never overlaps the next) probes the identity endpoint;
59
+ a couple of consecutive misses — tolerating a transient blip or a quick restart —
60
+ count as a death. Cleared whenever we're not connected.
61
+ */
62
+ const LIVENESS_INTERVAL_MS = 4000
63
+ const LIVENESS_FAILURE_LIMIT = 2
64
+ let connectedUrl: string | undefined
65
+ let livenessTimer: ReturnType<typeof setTimeout> | undefined
66
+ let livenessFailures = 0
67
+
68
+ // Embedded-server child, spawned on demand by Start server; undefined when none.
69
+ let serverChild: ReturnType<typeof Bun.spawn> | undefined
70
+
71
+ // Reaps the embedded server child if one is running.
72
+ function killServerChild(): void {
73
+ if (serverChild) {
74
+ serverChild.kill()
75
+ serverChild = undefined
76
+ }
77
+ }
78
+
79
+ // Begin (or restart) watching `url` for liveness once the window points at it.
80
+ function startLivenessWatch(url: string): void {
81
+ stopLivenessWatch()
82
+ connectedUrl = url
83
+ livenessFailures = 0
84
+ livenessTimer = setTimeout(runLivenessProbe, LIVENESS_INTERVAL_MS)
85
+ }
86
+
87
+ // Stop watching — on explicit disconnect, on detected death, or at shutdown.
88
+ function stopLivenessWatch(): void {
89
+ if (livenessTimer) {
90
+ clearTimeout(livenessTimer)
91
+ livenessTimer = undefined
92
+ }
93
+ connectedUrl = undefined
94
+ livenessFailures = 0
95
+ }
96
+
97
+ /*
98
+ One liveness probe of the connected server. Successes reset the miss count;
99
+ LIVENESS_FAILURE_LIMIT consecutive misses declare it dead and hand off to
100
+ handleConnectionLost. Reschedules itself while still connected.
101
+ */
102
+ async function runLivenessProbe(): Promise<void> {
103
+ const url = connectedUrl
104
+ if (!url) {
105
+ return
106
+ }
107
+ const identity = await probeBelteServer(url)
108
+ // A disconnect or reconnect during the await may have moved us on.
109
+ if (connectedUrl !== url) {
110
+ return
111
+ }
112
+ if (identity) {
113
+ livenessFailures = 0
114
+ } else {
115
+ livenessFailures += 1
116
+ if (livenessFailures >= LIVENESS_FAILURE_LIMIT) {
117
+ handleConnectionLost(url)
118
+ return
119
+ }
120
+ }
121
+ livenessTimer = setTimeout(runLivenessProbe, LIVENESS_INTERVAL_MS)
122
+ }
123
+
124
+ /*
125
+ The connected server stopped answering. Reap any (now-dead) embedded child, clear
126
+ the connected flag so the menu stops claiming connected, and bounce the window
127
+ back to the connect screen with a `lost` notice. The flag flip alone keeps the
128
+ menu honest even when the navigate is a no-op (off macOS, or no handle yet).
129
+ */
130
+ function handleConnectionLost(url: string): void {
131
+ log.warn(`connected server stopped responding: ${url}`)
132
+ stopLivenessWatch()
133
+ killServerChild()
134
+ flag?.setConnected(false)
135
+ if (webviewHandle !== undefined) {
136
+ navigate?.requestNavigate(webviewHandle, `${controlOrigin}/?action=lost`)
137
+ }
138
+ }
139
+
140
+ /*
141
+ Spawns the sibling server binary on a free port and waits for it to answer,
142
+ returning the URL to point the window at. Any previous child is reaped first so
143
+ only one embedded server runs at a time.
144
+ */
145
+ async function startEmbeddedServer(): Promise<string> {
146
+ killServerChild()
147
+ const port = findFreePort()
148
+ const url = `http://localhost:${port}`
149
+ serverChild = Bun.spawn({
150
+ cmd: [resolveServerBinary()],
151
+ // BELTE_PARENT_PID lets the child exit if the launcher is force-quit
152
+ // (a clean window close reaps it directly; see exitWithParent).
153
+ env: { ...process.env, PORT: String(port), BELTE_PARENT_PID: String(process.pid) },
154
+ stdio: ['inherit', 'inherit', 'inherit'],
155
+ })
156
+ await waitForServer(url)
157
+ return url
158
+ }
159
+
160
+ /*
161
+ Injects the app title into the connect-screen HTML just before serving — the build
162
+ left a `<!--belte:connect-config-->` marker in <head>.
163
+ */
164
+ function renderConnectScreen(): Response {
165
+ const script = `<script>window.__BELTE_TITLE__=${JSON.stringify(title)}</script>`
166
+ const html = disconnectedHtml.replace('<!--belte:connect-config-->', script)
167
+ return new Response(html, { headers: { 'content-type': 'text/html; charset=utf-8' } })
168
+ }
169
+
170
+ /*
171
+ The control server's request handler. The connect screen owns localStorage +
172
+ navigation; this worker owns the embedded-server process and the native flag.
173
+ */
174
+ async function handleControlRequest(request: Request): Promise<Response> {
175
+ const url = new URL(request.url)
176
+ if (request.method === 'GET' && url.pathname === '/') {
177
+ return renderConnectScreen()
178
+ }
179
+ if (request.method === 'POST' && url.pathname === '/connect') {
180
+ const { url: target } = (await request.json()) as { url: string }
181
+ // Verify it's actually a belte server before pointing the window at it.
182
+ const identity = await probeBelteServer(target)
183
+ if (!identity) {
184
+ log.warn(`no belte server responded at ${target}`)
185
+ return Response.json(
186
+ { error: `No belte server responded at ${target}` },
187
+ { status: 502 },
188
+ )
189
+ }
190
+ flag?.setConnected(true)
191
+ startLivenessWatch(target)
192
+ log.info(`connecting to ${identity.name} at ${target}`)
193
+ return Response.json({ redirect: target })
194
+ }
195
+ if (request.method === 'POST' && url.pathname === '/start') {
196
+ try {
197
+ const localUrl = await startEmbeddedServer()
198
+ flag?.setConnected(true)
199
+ startLivenessWatch(localUrl)
200
+ log.info(`started embedded server at ${localUrl}`)
201
+ return Response.json({ redirect: localUrl })
202
+ } catch (error) {
203
+ killServerChild()
204
+ return Response.json({ error: String(error) }, { status: 500 })
205
+ }
206
+ }
207
+ if (request.method === 'GET' && url.pathname === '/__belte/disconnect') {
208
+ stopLivenessWatch()
209
+ killServerChild()
210
+ flag?.setConnected(false)
211
+ return new Response(undefined, { status: 204 })
212
+ }
213
+ return new Response('not found', { status: 404 })
214
+ }
215
+
216
+ /*
217
+ Bind the control server to 127.0.0.1 literally (not `localhost`) so the webview
218
+ 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.
220
+ */
221
+ async function start(init: Init): Promise<void> {
222
+ disconnectedHtml = init.disconnectedHtml
223
+ title = init.title
224
+ const libPath = await resolveWebviewLib()
225
+ flag = bindConnectedFlag(libPath)
226
+ navigate = bindRequestNavigate(libPath)
227
+ server = listenLocalControlServer(stableLocalPort(init.programName), handleControlRequest)
228
+ controlOrigin = `http://127.0.0.1:${server.port}`
229
+ log.info(`${title} control server listening at ${controlOrigin}`)
230
+ self.postMessage({ type: 'ready', origin: controlOrigin })
231
+ }
232
+
233
+ // Reap the child + release the server and FFI handles, then confirm so the
234
+ // launcher can exit cleanly.
235
+ function shutdown(): void {
236
+ stopLivenessWatch()
237
+ killServerChild()
238
+ server?.stop(true)
239
+ flag?.close()
240
+ navigate?.close()
241
+ self.postMessage({ type: 'shutdownDone' })
242
+ }
243
+
244
+ /*
245
+ The launcher drives the lifecycle: `init` (with the data this worker can't import)
246
+ starts the server, `window` forwards the webview handle the liveness watch needs to
247
+ navigate, and `shutdown` tears it all down once the window closes.
248
+ */
249
+ self.addEventListener('message', (event: MessageEvent) => {
250
+ const data = event.data as
251
+ | { type: 'init'; init: Init }
252
+ | { type: 'window'; handle: number }
253
+ | { type: 'shutdown' }
254
+ if (data.type === 'init') {
255
+ void start(data.init)
256
+ } else if (data.type === 'window') {
257
+ webviewHandle = data.handle
258
+ } else if (data.type === 'shutdown') {
259
+ shutdown()
260
+ }
261
+ })
@@ -0,0 +1,66 @@
1
+ import type { BunPlugin } from 'bun'
2
+
3
+ type ExportEntry = string | { [condition: string]: ExportEntry }
4
+
5
+ /*
6
+ Walks a package.json `exports` entry, returning the first leaf string that
7
+ matches the supplied condition list in order. Returns undefined when no
8
+ branch resolves.
9
+ */
10
+ function pickExport(entry: ExportEntry, conditions: string[]): string | undefined {
11
+ if (typeof entry === 'string') {
12
+ return entry
13
+ }
14
+ for (const condition of conditions) {
15
+ if (entry[condition]) {
16
+ const resolved = pickExport(entry[condition], conditions)
17
+ if (resolved) {
18
+ return resolved
19
+ }
20
+ }
21
+ }
22
+ return undefined
23
+ }
24
+
25
+ /*
26
+ Forces every `import 'svelte/...'` (from belte's own source, the consumer's
27
+ source, or any transitive dep) to resolve against the consumer app's svelte
28
+ install, picking the export condition that matches the build target.
29
+ Without this, belte's symlinked source can pick up a second svelte from its
30
+ install location, ship both runtimes, and break hydration. Shared by the
31
+ client build and the bundle connect-screen build.
32
+ */
33
+ export function dedupeSveltePlugin({
34
+ cwd,
35
+ conditions,
36
+ }: {
37
+ cwd: string
38
+ conditions: string[]
39
+ }): BunPlugin {
40
+ const consumerSvelte = `${cwd}/node_modules/svelte`
41
+ return {
42
+ name: 'belte-dedupe-svelte',
43
+ async setup(build) {
44
+ const pkgFile = Bun.file(`${consumerSvelte}/package.json`)
45
+ if (!(await pkgFile.exists())) {
46
+ return
47
+ }
48
+ const consumerPackage = (await pkgFile.json()) as {
49
+ exports: Record<string, ExportEntry>
50
+ }
51
+ build.onResolve({ filter: /^svelte(\/.*)?$/ }, (args) => {
52
+ const subpath =
53
+ args.path === 'svelte' ? '.' : `.${args.path.slice('svelte'.length)}`
54
+ const entry = consumerPackage.exports[subpath]
55
+ if (!entry) {
56
+ return undefined
57
+ }
58
+ const resolvedFile = pickExport(entry, conditions)
59
+ if (!resolvedFile) {
60
+ return undefined
61
+ }
62
+ return { path: `${consumerSvelte}/${resolvedFile.replace(/^\.\//, '')}` }
63
+ })
64
+ },
65
+ }
66
+ }
@@ -18,16 +18,17 @@ await Promise.all([
18
18
  ...Object.values(sockets).map((loader) => (loader as () => Promise<unknown>)()),
19
19
  ])
20
20
 
21
- const manifest: Record<string, unknown> = {}
22
- for (const entry of verbRegistry.values()) {
23
- if (!entry.clients.cli) {
24
- continue
25
- }
26
- manifest[commandNameForUrl(entry.remote.url)] = {
27
- method: entry.remote.method,
28
- url: entry.remote.url,
29
- jsonSchema: jsonSchemaForSchema(entry.schema, entry.jsonSchema),
30
- }
31
- }
21
+ const manifest = Object.fromEntries(
22
+ Array.from(verbRegistry.values())
23
+ .filter((entry) => entry.clients.cli)
24
+ .map((entry) => [
25
+ commandNameForUrl(entry.remote.url),
26
+ {
27
+ method: entry.remote.method,
28
+ url: entry.remote.url,
29
+ jsonSchema: jsonSchemaForSchema(entry.schema, entry.jsonSchema),
30
+ },
31
+ ]),
32
+ )
32
33
 
33
34
  process.stdout.write(JSON.stringify(manifest))
@@ -145,12 +145,9 @@ cache.invalidate = function invalidate<Args, Return>(
145
145
  same set because they share method+url.
146
146
  */
147
147
  const prefix = `${arg.method} ${arg.url}`
148
- const affected: string[] = []
149
- for (const key of store.entries.keys()) {
150
- if (key === prefix || key.startsWith(`${prefix}?`) || key.startsWith(`${prefix} `)) {
151
- affected.push(key)
152
- }
153
- }
148
+ const affected = Array.from(store.entries.keys()).filter(
149
+ (key) => key === prefix || key.startsWith(`${prefix}?`) || key.startsWith(`${prefix} `),
150
+ )
154
151
  affected.forEach((key) => store.entries.delete(key))
155
152
  emit(store, affected)
156
153
  return
@@ -13,8 +13,13 @@ block that fills this interface with `routePath: paramShape` pairs derived
13
13
  from the project's `src/browser/pages/**` tree. A bare belte install has no routes,
14
14
  so the fallback arm below keeps the union inhabited before the generated
15
15
  d.ts lands.
16
+
17
+ Declared as an `interface` (not a `type` alias) because the generated d.ts
18
+ augments it via `declare module … { interface Routes { … } }`, and module
19
+ augmentation only merges into interfaces.
16
20
  */
17
- export type Routes = {}
21
+ // biome-ignore lint/suspicious/noEmptyInterface: augmented by the generated routes.d.ts
22
+ export interface Routes {}
18
23
 
19
24
  type RouteKey = keyof Routes extends never ? string : keyof Routes
20
25
  type ParamsFor<R extends RouteKey> = R extends keyof Routes ? Routes[R] : Record<string, string>
@@ -121,26 +126,19 @@ function syncUrl(): void {
121
126
  mutable.url = new URL(window.location.href)
122
127
  }
123
128
 
124
- type FetchOutcome =
125
- | { kind: 'ok'; response: Response }
126
- | { kind: 'network-error' }
127
- | { kind: 'not-found' }
128
- | { kind: 'http-error'; status: number }
129
-
130
- async function safeResolveFetch(target: string): Promise<FetchOutcome> {
131
- let response: Response
129
+ /*
130
+ Resolves the JSON view payload for a target URL, or undefined when the fetch
131
+ fails for any reason (network error or non-2xx, including 404). The caller
132
+ falls back to a hard navigation in every failure case, so the failure modes
133
+ don't need to be distinguished.
134
+ */
135
+ async function safeResolveFetch(target: string): Promise<Response | undefined> {
132
136
  try {
133
- response = await fetch(target, { headers: { Accept: 'application/json' } })
137
+ const response = await fetch(target, { headers: { Accept: 'application/json' } })
138
+ return response.ok ? response : undefined
134
139
  } catch {
135
- return { kind: 'network-error' }
136
- }
137
- if (response.status === 404) {
138
- return { kind: 'not-found' }
139
- }
140
- if (!response.ok) {
141
- return { kind: 'http-error', status: response.status }
140
+ return undefined
142
141
  }
143
- return { kind: 'ok', response }
144
142
  }
145
143
 
146
144
  export type NavigateOptions = { replace?: boolean; scroll?: boolean }
@@ -185,12 +183,12 @@ async function applyTarget(
185
183
  syncUrl()
186
184
  return
187
185
  }
188
- const outcome = await safeResolveFetch(fullTarget)
189
- if (outcome.kind !== 'ok') {
186
+ const response = await safeResolveFetch(fullTarget)
187
+ if (!response) {
190
188
  window.location.href = fullTarget
191
189
  return
192
190
  }
193
- const result = (await outcome.response.json()) as SsrPayload
191
+ const result = (await response.json()) as SsrPayload
194
192
  try {
195
193
  const { Page, Layout } = await loadView(result.route)
196
194
  applyState(result.route, result.params, Page, Layout)
@@ -132,8 +132,18 @@ export function getSocketChannel(): Channel {
132
132
  for (const [, sub] of active) {
133
133
  sub.callbacks.onError('socket channel disconnected')
134
134
  }
135
+ /*
136
+ Drop any queued frames too. We've just torn down every local
137
+ sub, so replaying their `sub`/`unsub`/`pub` frames on
138
+ reconnect would open ghost subscriptions on the server that
139
+ no client object tracks (and never gets an `unsub`). This
140
+ keeps the "no silent re-subscribe across a reconnect"
141
+ contract above honest — consumers re-open fresh subs.
142
+ */
143
+ const hadPending = pendingSends.length > 0
144
+ pendingSends = []
135
145
  ws = undefined
136
- if (active.length === 0 && pendingSends.length === 0) {
146
+ if (active.length === 0 && !hadPending) {
137
147
  return
138
148
  }
139
149
  setTimeout(connect, backoffMs)
@@ -2,6 +2,6 @@ import type { Component } from 'svelte'
2
2
 
3
3
  /*
4
4
  Manifest of route URL → page.svelte module loader. Produced by the resolver
5
- plugin from `page.svelte` files anywhere under src/routes.
5
+ plugin from `page.svelte` files anywhere under src/browser/pages.
6
6
  */
7
7
  export type Pages = Record<string, () => Promise<{ default: Component }>>
@@ -0,0 +1,11 @@
1
+ import type { BundleMenuItem } from './BundleMenuItem.ts'
2
+
3
+ /*
4
+ A top-level bundle menu, inserted into the macOS menu bar between the standard
5
+ Edit and Window menus. `label` titles the menu; `items` are its entries top to
6
+ bottom.
7
+ */
8
+ export type BundleMenu = {
9
+ label: string
10
+ items: BundleMenuItem[]
11
+ }
@@ -0,0 +1,24 @@
1
+ /*
2
+ A single entry in a bundle menu. Serializable data — the native shim builds the
3
+ matching NSMenuItem. Either a divider or a clickable item that dispatches a
4
+ `belte:menu` CustomEvent into the page (detail `{ name }`); the app's own code
5
+ handles it:
6
+
7
+ window.addEventListener('belte:menu', (event) => {
8
+ if (event.detail.name === 'sync') syncNow()
9
+ })
10
+
11
+ Emitting an event (rather than calling a verb directly) is what lets a menu
12
+ drive parameterised work: a click carries no arguments, so the app computes
13
+ them and makes the call itself. `shortcut` is the key for the Cmd-based
14
+ equivalent (e.g. `'r'` → Cmd-R).
15
+
16
+ A `navigate` item moves the window instead of talking to the page: clicking it
17
+ calls `webview_navigate` with the given URL (the native side, on the UI thread).
18
+ That's how the built-in Server menu drives the connect screen — `emit` reaches
19
+ the loaded page, `navigate` repoints the window itself.
20
+ */
21
+ export type BundleMenuItem =
22
+ | { separator: true }
23
+ | { label: string; shortcut?: string; emit: string }
24
+ | { label: string; shortcut?: string; navigate: string }
@@ -0,0 +1,20 @@
1
+ import type { BundleMenu } from './BundleMenu.ts'
2
+
3
+ /*
4
+ User-authored bundle window configuration, default-exported from an
5
+ optional `src/bundle/window.ts`. Baked into the launcher at build time
6
+ (via the `belte:bundle-window` virtual) and read directly in dev. Every
7
+ field is optional — the launcher falls back to the program name for the
8
+ title and to openWebview's defaults for size.
9
+
10
+ The standard App/Edit/Window menus (Quit, copy/paste, minimize/close) plus the
11
+ built-in File menu (Start server / Connect / Disconnect) are always installed.
12
+ `menu` adds custom top-level menus between the Edit and Window menus; their items
13
+ emit `belte:menu` events the app handles. See BundleMenuItem.
14
+ */
15
+ export type BundleWindow = {
16
+ title?: string
17
+ width?: number
18
+ height?: number
19
+ menu?: BundleMenu[]
20
+ }
@@ -0,0 +1,29 @@
1
+ import { dlopen, FFIType } from 'bun:ffi'
2
+
3
+ /*
4
+ Binds belte_set_connected — the native flag the macOS Server menu's
5
+ validateMenuItem: reads to enable/disable Start/Disconnect. The symbol is
6
+ compiled only into the macOS lib (the Cocoa menu shim), so a failed lookup
7
+ degrades to a no-op setter rather than throwing on Linux/Windows.
8
+
9
+ The flag is a process-global, which is what lets the bundle's control server set
10
+ it from off the main thread: that server runs in a Worker because `webview_run`
11
+ blocks the main thread's JS event loop, yet the main-thread menu still reads the
12
+ same value through the shared dylib image. The returned close releases the handle.
13
+ */
14
+ export function bindConnectedFlag(libPath: string): {
15
+ setConnected: (connected: boolean) => void
16
+ close: () => void
17
+ } {
18
+ try {
19
+ const lib = dlopen(libPath, {
20
+ belte_set_connected: { args: [FFIType.i32], returns: FFIType.void },
21
+ })
22
+ return {
23
+ setConnected: (connected) => lib.symbols.belte_set_connected(connected ? 1 : 0),
24
+ close: () => lib.close(),
25
+ }
26
+ } catch {
27
+ return { setConnected: () => {}, close: () => {} }
28
+ }
29
+ }
@@ -0,0 +1,31 @@
1
+ import { dlopen, FFIType } from 'bun:ffi'
2
+
3
+ /*
4
+ Binds belte_request_navigate — points the live webview window at a URL from any
5
+ thread by hopping onto the UI thread via webview_dispatch. The launcher's control
6
+ server runs in a Worker (off the main thread that webview_run blocks), so when it
7
+ detects the connected server has died it uses this to bounce the window back to the
8
+ connect screen. macOS-only symbol (the Cocoa shim), so a failed lookup degrades to
9
+ a no-op — elsewhere the worker can still correct the menu flag, just not the window.
10
+
11
+ `handle` is the webview pointer created on the main thread, forwarded to the worker
12
+ as a number; bun:ffi accepts it for a ptr argument from either thread because the
13
+ pointer addresses the same process heap.
14
+ */
15
+ export function bindRequestNavigate(libPath: string): {
16
+ requestNavigate: (handle: number, url: string) => void
17
+ close: () => void
18
+ } {
19
+ try {
20
+ const lib = dlopen(libPath, {
21
+ belte_request_navigate: { args: [FFIType.ptr, FFIType.ptr], returns: FFIType.void },
22
+ })
23
+ return {
24
+ requestNavigate: (handle, url) =>
25
+ lib.symbols.belte_request_navigate(handle, new TextEncoder().encode(`${url}\0`)),
26
+ close: () => lib.close(),
27
+ }
28
+ } catch {
29
+ return { requestNavigate: () => {}, close: () => {} }
30
+ }
31
+ }