@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.
- package/bin/belte.ts +25 -12
- package/package.json +2 -1
- package/src/appEntry.ts +124 -0
- package/src/belteResolverPlugin.ts +217 -194
- package/src/build.ts +6 -67
- package/src/buildCli.ts +36 -63
- package/src/buildDisconnected.ts +127 -0
- package/src/bundleApp.ts +123 -0
- package/src/bundleDisconnectedEntry.ts +17 -0
- package/src/cliEntry.ts +3 -9
- package/src/compile.ts +4 -15
- package/src/controlServerWorker.ts +261 -0
- package/src/dedupeSveltePlugin.ts +66 -0
- package/src/discoveryEntry.ts +12 -11
- package/src/lib/browser/cache.ts +3 -6
- package/src/lib/browser/page.svelte.ts +19 -21
- package/src/lib/browser/socketChannel.ts +11 -1
- package/src/lib/browser/types/Pages.ts +1 -1
- package/src/lib/bundle/BundleMenu.ts +11 -0
- package/src/lib/bundle/BundleMenuItem.ts +24 -0
- package/src/lib/bundle/BundleWindow.ts +20 -0
- package/src/lib/bundle/bindConnectedFlag.ts +29 -0
- package/src/lib/bundle/bindRequestNavigate.ts +31 -0
- package/src/lib/bundle/buildWebviewLib.ts +111 -0
- package/src/lib/bundle/disconnected.css +9 -0
- package/src/lib/bundle/disconnected.svelte +192 -0
- package/src/lib/bundle/ensureWebviewLib.ts +20 -0
- package/src/lib/bundle/exitWithParent.ts +28 -0
- package/src/lib/bundle/findFreePort.ts +14 -0
- package/src/lib/bundle/infoPlist.ts +46 -0
- package/src/lib/bundle/installMacMenu.ts +39 -0
- package/src/lib/bundle/listenLocalControlServer.ts +19 -0
- package/src/lib/bundle/native/belteMenu.mm +298 -0
- package/src/lib/bundle/native/webview.h +4557 -0
- package/src/lib/bundle/onMenu.ts +26 -0
- package/src/lib/bundle/openWebview.ts +81 -0
- package/src/lib/bundle/pngToIcns.ts +47 -0
- package/src/lib/bundle/probeBelteServer.ts +34 -0
- package/src/lib/bundle/resolveServerBinary.ts +12 -0
- package/src/lib/bundle/resolveWebviewLib.ts +51 -0
- package/src/lib/bundle/serverBinaryFilename.ts +8 -0
- package/src/lib/bundle/stableLocalPort.ts +19 -0
- package/src/lib/bundle/waitForServer.ts +23 -0
- package/src/lib/bundle/webviewBuildRevision.ts +9 -0
- package/src/lib/bundle/webviewCachePath.ts +23 -0
- package/src/lib/bundle/webviewLibName.ts +11 -0
- package/src/lib/bundle/webviewVersion.ts +7 -0
- package/src/lib/cli/createClient.ts +34 -36
- package/src/lib/cli/printHelp.ts +45 -2
- package/src/lib/cli/runCli.ts +12 -3
- package/src/lib/mcp/createMcpResourceServer.ts +1 -1
- package/src/lib/mcp/dispatchMcpRequest.ts +53 -73
- package/src/lib/server/AppModule.ts +2 -2
- package/src/lib/server/cli/handleCliDownload.ts +4 -5
- package/src/lib/server/cli/handleCliInstall.ts +17 -0
- package/src/lib/server/error.ts +23 -9
- package/src/lib/server/json.ts +5 -5
- package/src/lib/server/jsonl.ts +10 -5
- package/src/lib/server/prompts/definePrompt.ts +6 -6
- package/src/lib/server/prompts/renderPromptTemplate.ts +16 -0
- package/src/lib/server/prompts/types/Prompt.ts +8 -9
- package/src/lib/server/prompts/types/PromptOptions.ts +7 -12
- package/src/lib/server/prompts/types/PromptRegistryEntry.ts +3 -5
- package/src/lib/server/prompts/types/PromptRoutes.ts +4 -4
- package/src/lib/server/redirect.ts +13 -8
- package/src/lib/server/rpc/defineVerb.ts +4 -3
- package/src/lib/server/rpc/findVerbByCommandName.ts +18 -0
- package/src/lib/server/rpc/types/RemoteFunction.ts +1 -1
- package/src/lib/server/rpc/types/RemoteHandler.ts +4 -0
- package/src/lib/server/runtime/acceptsZstd.ts +8 -0
- package/src/lib/server/runtime/buildOpenApiSpec.ts +2 -0
- package/src/lib/server/runtime/cacheControlForAsset.ts +7 -2
- package/src/lib/server/runtime/createAssetHeaderCache.ts +35 -0
- package/src/lib/server/runtime/createPublicAssetServer.ts +6 -20
- package/src/lib/server/runtime/createServer.ts +50 -58
- package/src/lib/server/runtime/registryManifests.ts +33 -15
- package/src/lib/server/runtime/types/RequestStore.ts +2 -3
- package/src/lib/server/runtime/withResponseDefaults.ts +24 -0
- package/src/lib/server/sockets/createSocketDispatcher.ts +10 -7
- package/src/lib/server/sse.ts +10 -5
- package/src/lib/shared/cacheControlValues.ts +10 -2
- package/src/lib/shared/canonicalJson.ts +1 -5
- package/src/lib/shared/createCacheStore.ts +29 -20
- package/src/lib/shared/exitOnBuildFailure.ts +17 -0
- package/src/lib/shared/fileStem.ts +9 -0
- package/src/lib/shared/jsonSchemaForPromptArguments.ts +29 -0
- package/src/lib/shared/keyForRemoteCall.ts +7 -5
- package/src/lib/shared/parsePromptMarkdown.ts +34 -0
- package/src/lib/shared/promptNameForFile.ts +5 -5
- package/src/lib/shared/subscribableFromResponse.ts +104 -215
- package/src/lib/shared/types/PromptArgument.ts +12 -0
- package/src/lib/shared/writeRoutesDts.ts +5 -7
- package/src/serverBuildPlugins.ts +25 -0
- package/src/serverEntry.ts +4 -0
- package/template/package.json +2 -1
- package/src/lib/server/prompt.ts +0 -30
- package/src/lib/server/prompts/types/PromptMessage.ts +0 -10
- 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
|
+
}
|
package/src/discoveryEntry.ts
CHANGED
|
@@ -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
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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))
|
package/src/lib/browser/cache.ts
CHANGED
|
@@ -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
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
async function safeResolveFetch(target: string): Promise<
|
|
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
|
|
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
|
|
189
|
-
if (
|
|
186
|
+
const response = await safeResolveFetch(fullTarget)
|
|
187
|
+
if (!response) {
|
|
190
188
|
window.location.href = fullTarget
|
|
191
189
|
return
|
|
192
190
|
}
|
|
193
|
-
const result = (await
|
|
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 &&
|
|
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/
|
|
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
|
+
}
|