@briancray/belte 0.3.0 → 0.4.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/package.json +1 -1
- package/src/bundleApp.ts +12 -2
- package/src/discoveryEntry.ts +58 -11
- package/src/lib/browser/cache.ts +29 -6
- package/src/lib/browser/startClient.ts +24 -1
- package/src/lib/bundle/disconnected.svelte +18 -19
- package/src/lib/bundle/onMenu.ts +20 -5
- package/src/lib/bundle/openWebview.ts +9 -2
- package/src/lib/bundle/signMacApp.ts +35 -0
- package/src/lib/cli/createClient.ts +65 -27
- package/src/lib/cli/runCli.ts +37 -15
- package/src/lib/cli/types/CliManifestEntry.ts +7 -2
- package/src/lib/mcp/annotationsForMethod.ts +29 -0
- package/src/lib/mcp/createMcpServer.ts +10 -8
- package/src/lib/mcp/dispatchMcpRequest.ts +115 -33
- package/src/lib/mcp/toolResultFromResponse.ts +66 -0
- package/src/lib/server/jsonl.ts +2 -1
- package/src/lib/server/rpc/defineVerb.ts +30 -17
- package/src/lib/server/rpc/parseArgs.ts +2 -1
- package/src/lib/server/rpc/types/VerbHelper.ts +19 -11
- package/src/lib/server/rpc/types/VerbRegistryEntry.ts +14 -6
- package/src/lib/server/runtime/buildOpenApiSpec.ts +17 -6
- package/src/lib/server/runtime/createPublicAssetServer.ts +17 -6
- package/src/lib/server/runtime/createServer.ts +37 -9
- package/src/lib/server/runtime/globToPathSet.ts +29 -0
- package/src/lib/server/runtime/logBrowserOnlyRoutes.ts +44 -0
- package/src/lib/server/sockets/createSocketDispatcher.ts +71 -8
- package/src/lib/server/sockets/defineSocket.ts +7 -1
- package/src/lib/server/sockets/recentHistory.ts +11 -0
- package/src/lib/server/sockets/socketOperations.ts +35 -0
- package/src/lib/server/sockets/types/SocketOperation.ts +22 -0
- package/src/lib/server/sse.ts +2 -1
- package/src/lib/shared/buildRpcRequest.ts +2 -1
- package/src/lib/shared/carriesBodyArgs.ts +13 -0
- package/src/lib/shared/isReadOnlyMethod.ts +14 -0
- package/src/lib/shared/isStreamingResponse.ts +11 -0
- package/src/lib/shared/jsonlErrorFrame.ts +24 -0
- package/src/lib/shared/keyForRemoteCall.ts +2 -1
- package/src/lib/shared/resolveClientFlags.ts +8 -6
- package/src/lib/shared/responseErrorText.ts +9 -0
- package/src/lib/shared/sseErrorFrame.ts +29 -0
- package/src/lib/shared/streamResponse.ts +168 -0
- package/src/lib/shared/subscribableFromResponse.ts +1 -172
- package/src/lib/shared/types/CacheEntry.ts +6 -0
- package/template/src/bundle/icon.png +0 -0
- package/template/src/server/rpc/getHello.ts +5 -3
- package/src/lib/shared/belteImportName.test.ts +0 -58
package/package.json
CHANGED
package/src/bundleApp.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { ensureWebviewLib } from './lib/bundle/ensureWebviewLib.ts'
|
|
|
4
4
|
import { infoPlist } from './lib/bundle/infoPlist.ts'
|
|
5
5
|
import { pngToIcns } from './lib/bundle/pngToIcns.ts'
|
|
6
6
|
import { serverBinaryFilename } from './lib/bundle/serverBinaryFilename.ts'
|
|
7
|
+
import { signMacApp } from './lib/bundle/signMacApp.ts'
|
|
7
8
|
import { webviewLibName } from './lib/bundle/webviewLibName.ts'
|
|
8
9
|
import { detectTarget } from './lib/shared/detectTarget.ts'
|
|
9
10
|
import { exitOnBuildFailure } from './lib/shared/exitOnBuildFailure.ts'
|
|
@@ -17,8 +18,9 @@ const WORKER_ENTRY = new URL('./controlServerWorker.ts', import.meta.url).pathna
|
|
|
17
18
|
|
|
18
19
|
/*
|
|
19
20
|
Assembles a movable, self-contained app bundle for the host platform —
|
|
20
|
-
no
|
|
21
|
-
|
|
21
|
+
no cross-compilation, and on macOS an ad-hoc seal so it launches on other
|
|
22
|
+
Macs (signMacApp). Three pieces travel together so the app runs on another
|
|
23
|
+
machine of the same OS with nothing installed:
|
|
22
24
|
|
|
23
25
|
- the standalone server binary (`compile()`, assets embedded)
|
|
24
26
|
- the launcher binary (appEntry — spawns the server, opens the webview)
|
|
@@ -106,6 +108,14 @@ export async function bundleApp({ cwd = process.cwd() }: { cwd?: string } = {}):
|
|
|
106
108
|
`${bundleRoot}/Contents/Info.plist`,
|
|
107
109
|
infoPlist({ name: programName, version, icon: hasIcon ? 'icon' : undefined }),
|
|
108
110
|
)
|
|
111
|
+
|
|
112
|
+
// Seal the finished bundle so it launches on other Macs — must run last,
|
|
113
|
+
// after every binary, the lib, and Info.plist are in place.
|
|
114
|
+
await signMacApp(bundleRoot, [
|
|
115
|
+
`${libDir}/${webviewLibName()}`,
|
|
116
|
+
`${binDir}/${serverBinaryFilename()}`,
|
|
117
|
+
launcherPath,
|
|
118
|
+
])
|
|
109
119
|
}
|
|
110
120
|
|
|
111
121
|
log.success(`bundled app: ${bundleRoot} (target: ${target})`)
|
package/src/discoveryEntry.ts
CHANGED
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
import { rpc } from './_virtual/rpc.ts'
|
|
3
3
|
// @ts-expect-error virtual module resolved by belteResolverPlugin
|
|
4
4
|
import { sockets } from './_virtual/sockets.ts'
|
|
5
|
+
import type { CliManifestEntry } from './lib/cli/types/CliManifestEntry.ts'
|
|
5
6
|
import { verbRegistry } from './lib/server/rpc/verbRegistry.ts'
|
|
7
|
+
import { socketOperations } from './lib/server/sockets/socketOperations.ts'
|
|
8
|
+
import { socketRegistry } from './lib/server/sockets/socketRegistry.ts'
|
|
6
9
|
import { commandNameForUrl } from './lib/shared/commandNameForUrl.ts'
|
|
7
10
|
import { jsonSchemaForSchema } from './lib/shared/jsonSchemaForSchema.ts'
|
|
8
11
|
|
|
@@ -18,17 +21,61 @@ await Promise.all([
|
|
|
18
21
|
...Object.values(sockets).map((loader) => (loader as () => Promise<unknown>)()),
|
|
19
22
|
])
|
|
20
23
|
|
|
21
|
-
const manifest =
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
24
|
+
const manifest: Record<string, CliManifestEntry> = {}
|
|
25
|
+
|
|
26
|
+
for (const entry of verbRegistry.values()) {
|
|
27
|
+
if (!entry.clients.cli) {
|
|
28
|
+
continue
|
|
29
|
+
}
|
|
30
|
+
manifest[commandNameForUrl(entry.remote.url)] = {
|
|
31
|
+
method: entry.remote.method,
|
|
32
|
+
url: entry.remote.url,
|
|
33
|
+
jsonSchema: jsonSchemaForSchema(entry.inputSchema, entry.inputJsonSchema),
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/*
|
|
38
|
+
Sockets advertised to the CLI become commands against the socket's HTTP
|
|
39
|
+
face (see socketOperations): `<base>-tail` streams live (GET +
|
|
40
|
+
text/event-stream, with an optional --tail N to replay recent history
|
|
41
|
+
first) and, when clientPublish is set, `<base>-publish` sends the args bag
|
|
42
|
+
as a message (POST).
|
|
43
|
+
*/
|
|
44
|
+
for (const entry of socketRegistry.values()) {
|
|
45
|
+
if (!entry.clients.cli) {
|
|
46
|
+
continue
|
|
47
|
+
}
|
|
48
|
+
for (const operation of socketOperations(entry)) {
|
|
49
|
+
if (operation.kind === 'tail') {
|
|
50
|
+
manifest[operation.name] = {
|
|
51
|
+
method: operation.method,
|
|
52
|
+
url: operation.restUrl,
|
|
53
|
+
accept: 'text/event-stream',
|
|
54
|
+
jsonSchema: {
|
|
55
|
+
type: 'object',
|
|
56
|
+
description: `tail the "${operation.socketName}" socket`,
|
|
57
|
+
properties: {
|
|
58
|
+
tail: {
|
|
59
|
+
type: 'number',
|
|
60
|
+
description: 'replay last N messages before tailing live',
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
const payloadSchema = jsonSchemaForSchema(entry.schema, entry.jsonSchema)
|
|
68
|
+
manifest[operation.name] = {
|
|
69
|
+
method: operation.method,
|
|
70
|
+
url: operation.restUrl,
|
|
71
|
+
jsonSchema: {
|
|
72
|
+
...payloadSchema,
|
|
73
|
+
description:
|
|
74
|
+
(payloadSchema.description as string | undefined) ??
|
|
75
|
+
`publish a message to the "${operation.socketName}" socket`,
|
|
30
76
|
},
|
|
31
|
-
|
|
32
|
-
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
33
80
|
|
|
34
81
|
process.stdout.write(JSON.stringify(manifest))
|
package/src/lib/browser/cache.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { canonicalJson } from '../shared/canonicalJson.ts'
|
|
|
5
5
|
import { decodeResponse } from '../shared/decodeResponse.ts'
|
|
6
6
|
import { getRemoteMeta } from '../shared/getRemoteMeta.ts'
|
|
7
7
|
import { keyForRemoteCall } from '../shared/keyForRemoteCall.ts'
|
|
8
|
+
import type { CacheEntry } from '../shared/types/CacheEntry.ts'
|
|
8
9
|
import type { CacheOptions } from '../shared/types/CacheOptions.ts'
|
|
9
10
|
|
|
10
11
|
type AnyRemote<Args, Return> = RemoteFunction<Args, Return> | RawRemoteFunction<Args>
|
|
@@ -46,7 +47,7 @@ export function cache<Args>(
|
|
|
46
47
|
export function cache<Args, Return>(
|
|
47
48
|
fn: AnyRemote<Args, Return>,
|
|
48
49
|
options?: CacheOptions,
|
|
49
|
-
): (args?: Args) => Promise<Return | Response> {
|
|
50
|
+
): (args?: Args) => Promise<Return | Response> | Return {
|
|
50
51
|
/*
|
|
51
52
|
The "raw" variant lacks its own `.raw` sibling; only the decoded
|
|
52
53
|
callable carries one. Tell them apart by that presence and dispatch the
|
|
@@ -55,20 +56,42 @@ export function cache<Args, Return>(
|
|
|
55
56
|
const isRaw = !('raw' in fn)
|
|
56
57
|
const rawFn = isRaw ? (fn as RawRemoteFunction<Args>) : (fn as RemoteFunction<Args, Return>).raw
|
|
57
58
|
return (args) => {
|
|
58
|
-
const
|
|
59
|
+
const store = activeCacheStore()
|
|
60
|
+
const key = resolveKey(rawFn, args, options?.key)
|
|
61
|
+
store.subscribe(key)
|
|
62
|
+
const existing = store.entries.get(key)
|
|
63
|
+
/*
|
|
64
|
+
Snapshot warm path: hydration pre-decoded the SSR body onto the
|
|
65
|
+
entry, so the decoded variant returns it synchronously — the first
|
|
66
|
+
{#await} render resolves without a microtask suspension and matches
|
|
67
|
+
the SSR DOM. Raw callers always take the Response path. After an
|
|
68
|
+
invalidate the replacement entry carries no value and falls through
|
|
69
|
+
to the async fetch as before.
|
|
70
|
+
|
|
71
|
+
The public overload stays typed Promise<Return> on purpose: a
|
|
72
|
+
non-thenable is the only thing {#await} can render synchronously, so
|
|
73
|
+
the sync return is left as an internal optimization rather than
|
|
74
|
+
widened to `Return | Promise<Return>` (which would leak it into every
|
|
75
|
+
caller's types). The one cost is that `.then`/`.catch`/`.finally`
|
|
76
|
+
directly on a warm result throws — consume cache via `await`/`{#await}`,
|
|
77
|
+
never `.then`. Don't "fix" the type; see memory cache-warm-sync-tradeoff.
|
|
78
|
+
*/
|
|
79
|
+
if (!isRaw && existing?.value !== undefined) {
|
|
80
|
+
return existing.value as Return
|
|
81
|
+
}
|
|
82
|
+
const responsePromise = invokeWithCache(store, key, existing, rawFn, args, options)
|
|
59
83
|
return isRaw ? responsePromise : (responsePromise.then(decodeResponse) as Promise<Return>)
|
|
60
84
|
}
|
|
61
85
|
}
|
|
62
86
|
|
|
63
87
|
function invokeWithCache<Args>(
|
|
88
|
+
store: ReturnType<typeof activeCacheStore>,
|
|
89
|
+
key: string,
|
|
90
|
+
existing: CacheEntry | undefined,
|
|
64
91
|
rawFn: RawRemoteFunction<Args>,
|
|
65
92
|
args: Args | undefined,
|
|
66
93
|
options: CacheOptions | undefined,
|
|
67
94
|
): Promise<Response> {
|
|
68
|
-
const store = activeCacheStore()
|
|
69
|
-
const key = resolveKey(rawFn, args, options?.key)
|
|
70
|
-
store.subscribe(key)
|
|
71
|
-
const existing = store.entries.get(key)
|
|
72
95
|
if (existing) {
|
|
73
96
|
return shareable(existing.promise)
|
|
74
97
|
}
|
|
@@ -25,10 +25,11 @@ pass finds the data via cache() without issuing a network round-trip.
|
|
|
25
25
|
*/
|
|
26
26
|
function hydrateCacheFromSnapshot(store: CacheStore, snapshot: CacheSnapshotEntry[]): void {
|
|
27
27
|
for (const entry of snapshot) {
|
|
28
|
+
const headers = new Headers(entry.headers)
|
|
28
29
|
const response = new Response(entry.body, {
|
|
29
30
|
status: entry.status,
|
|
30
31
|
statusText: entry.statusText,
|
|
31
|
-
headers
|
|
32
|
+
headers,
|
|
32
33
|
})
|
|
33
34
|
store.entries.set(entry.key, {
|
|
34
35
|
key: entry.key,
|
|
@@ -36,10 +37,32 @@ function hydrateCacheFromSnapshot(store: CacheStore, snapshot: CacheSnapshotEntr
|
|
|
36
37
|
request: new Request(entry.url, { method: entry.method }),
|
|
37
38
|
ttl: undefined,
|
|
38
39
|
expiresAt: undefined,
|
|
40
|
+
value: warmValueFromSnapshot(entry.status, headers, entry.body),
|
|
39
41
|
})
|
|
40
42
|
}
|
|
41
43
|
}
|
|
42
44
|
|
|
45
|
+
/*
|
|
46
|
+
Synchronously decodes a snapshot body so the warm entry reads without a
|
|
47
|
+
microtask hop on first render. Mirrors decodeResponse for the textual cases
|
|
48
|
+
the snapshot ships; non-2xx and 204 yield no warm value and fall back to the
|
|
49
|
+
async path, which throws HttpError / returns undefined exactly as a live call
|
|
50
|
+
would. Binary/xml bodies also skip the warm path and decode asynchronously.
|
|
51
|
+
*/
|
|
52
|
+
function warmValueFromSnapshot(status: number, headers: Headers, body: string): unknown {
|
|
53
|
+
if (status === 204 || status < 200 || status >= 300) {
|
|
54
|
+
return undefined
|
|
55
|
+
}
|
|
56
|
+
const contentType = (headers.get('content-type') ?? '').toLowerCase()
|
|
57
|
+
if (contentType.includes('json')) {
|
|
58
|
+
return JSON.parse(body)
|
|
59
|
+
}
|
|
60
|
+
if (contentType.startsWith('text/')) {
|
|
61
|
+
return body
|
|
62
|
+
}
|
|
63
|
+
return undefined
|
|
64
|
+
}
|
|
65
|
+
|
|
43
66
|
function isInternalLinkEvent(event: MouseEvent): HTMLAnchorElement | undefined {
|
|
44
67
|
if (event.defaultPrevented) {
|
|
45
68
|
return undefined
|
|
@@ -29,8 +29,7 @@ click away even after an explicit disconnect.
|
|
|
29
29
|
const LAST_URL_KEY = 'belte:last-server-url'
|
|
30
30
|
|
|
31
31
|
// Injected globals: app title from the launcher, logo data URI from the build.
|
|
32
|
-
const heading =
|
|
33
|
-
(globalThis as { __BELTE_TITLE__?: string }).__BELTE_TITLE__ ?? 'belte app'
|
|
32
|
+
const heading = (globalThis as { __BELTE_TITLE__?: string }).__BELTE_TITLE__ ?? 'belte app'
|
|
34
33
|
const logo = (globalThis as { __BELTE_LOGO__?: string }).__BELTE_LOGO__
|
|
35
34
|
|
|
36
35
|
const placeholder = 'https://example.com'
|
|
@@ -134,10 +133,12 @@ async function start(): Promise<void> {
|
|
|
134
133
|
}
|
|
135
134
|
</script>
|
|
136
135
|
|
|
137
|
-
<main
|
|
138
|
-
|
|
136
|
+
<main
|
|
137
|
+
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
|
+
<div
|
|
139
|
+
class="w-full max-w-sm rounded-2xl bg-white p-8 shadow-sm ring-1 ring-gray-200 dark:bg-gray-900 dark:ring-gray-800">
|
|
139
140
|
{#if logo}
|
|
140
|
-
<img src={logo} alt="" class="mx-auto mb-5 h-16 w-16 rounded-xl object-contain"
|
|
141
|
+
<img src={logo} alt="" class="mx-auto mb-5 h-16 w-16 rounded-xl object-contain">
|
|
141
142
|
{/if}
|
|
142
143
|
<h1 class="mb-6 text-center text-xl font-semibold tracking-tight">{heading}</h1>
|
|
143
144
|
|
|
@@ -146,45 +147,43 @@ async function start(): Promise<void> {
|
|
|
146
147
|
onsubmit={(event) => {
|
|
147
148
|
event.preventDefault()
|
|
148
149
|
void connect()
|
|
149
|
-
}}
|
|
150
|
-
>
|
|
150
|
+
}}>
|
|
151
151
|
<input
|
|
152
152
|
type="url"
|
|
153
153
|
bind:value={url}
|
|
154
154
|
{placeholder}
|
|
155
155
|
autocomplete="url"
|
|
156
|
-
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"
|
|
157
|
-
/>
|
|
156
|
+
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">
|
|
158
157
|
<button
|
|
159
158
|
type="submit"
|
|
160
|
-
class="w-full rounded-lg bg-gray-900 px-3 py-2 text-sm font-medium text-white hover:bg-gray-700"
|
|
161
|
-
>
|
|
159
|
+
class="w-full rounded-lg bg-gray-900 px-3 py-2 text-sm font-medium text-white hover:bg-gray-700 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-gray-300">
|
|
162
160
|
Connect
|
|
163
161
|
</button>
|
|
164
162
|
</form>
|
|
165
163
|
|
|
166
|
-
<div class="my-5 flex items-center gap-3 text-xs text-gray-400">
|
|
167
|
-
<span class="h-px flex-1 bg-gray-200"></span>
|
|
164
|
+
<div class="my-5 flex items-center gap-3 text-xs text-gray-400 dark:text-gray-500">
|
|
165
|
+
<span class="h-px flex-1 bg-gray-200 dark:bg-gray-800"></span>
|
|
168
166
|
or
|
|
169
|
-
<span class="h-px flex-1 bg-gray-200"></span>
|
|
167
|
+
<span class="h-px flex-1 bg-gray-200 dark:bg-gray-800"></span>
|
|
170
168
|
</div>
|
|
171
169
|
|
|
172
170
|
<button
|
|
173
171
|
type="button"
|
|
174
172
|
onclick={() => void start()}
|
|
175
173
|
disabled={starting}
|
|
176
|
-
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium hover:bg-gray-50 disabled:opacity-60"
|
|
177
|
-
>
|
|
174
|
+
class="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium hover:bg-gray-50 disabled:opacity-60 dark:border-gray-700 dark:hover:bg-gray-800">
|
|
178
175
|
{starting ? 'Starting…' : 'Start server'}
|
|
179
176
|
</button>
|
|
180
177
|
|
|
181
178
|
{#if error}
|
|
182
|
-
<p class="mt-4 text-center text-sm text-red-600">{error}</p>
|
|
179
|
+
<p class="mt-4 text-center text-sm text-red-600 dark:text-red-400">{error}</p>
|
|
183
180
|
{/if}
|
|
184
181
|
|
|
185
|
-
<p class="mt-8 text-center text-xs text-gray-400">
|
|
182
|
+
<p class="mt-8 text-center text-xs text-gray-400 dark:text-gray-500">
|
|
186
183
|
made with
|
|
187
|
-
<a
|
|
184
|
+
<a
|
|
185
|
+
href="https://github.com/briancray/belte"
|
|
186
|
+
class="underline hover:text-gray-600 dark:hover:text-gray-300">
|
|
188
187
|
belte
|
|
189
188
|
</a>
|
|
190
189
|
</p>
|
package/src/lib/bundle/onMenu.ts
CHANGED
|
@@ -1,25 +1,40 @@
|
|
|
1
1
|
/*
|
|
2
2
|
Subscribes to bundle menu clicks. Each custom menu item declared in the bundle
|
|
3
|
-
window config dispatches a `belte:menu` CustomEvent into the page when clicked
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
window config dispatches a `belte:menu` CustomEvent into the page when clicked.
|
|
4
|
+
Two forms, both returning an unsubscribe so they drop straight into a Svelte
|
|
5
|
+
`$effect`:
|
|
6
6
|
|
|
7
|
+
// catch-all — every emit name flows through one handler
|
|
7
8
|
$effect(() =>
|
|
8
9
|
onMenu((name) => {
|
|
9
10
|
if (name === 'reload') location.reload()
|
|
10
11
|
}),
|
|
11
12
|
)
|
|
12
13
|
|
|
14
|
+
// filtered — handler fires only for the named item
|
|
15
|
+
$effect(() => onMenu('reload', () => location.reload()))
|
|
16
|
+
|
|
13
17
|
Inert during SSR and in a plain browser tab — `$effect` only runs client-side,
|
|
14
18
|
the native menu that fires the event exists only in the bundled desktop app,
|
|
15
19
|
and `window` is guarded so importing the module never assumes a DOM.
|
|
16
20
|
*/
|
|
17
|
-
export function onMenu(handler: (name: string) => void): () => void
|
|
21
|
+
export function onMenu(handler: (name: string) => void): () => void
|
|
22
|
+
export function onMenu(name: string, handler: () => void): () => void
|
|
23
|
+
export function onMenu(
|
|
24
|
+
nameOrHandler: string | ((name: string) => void),
|
|
25
|
+
maybeHandler?: () => void,
|
|
26
|
+
): () => void {
|
|
18
27
|
if (typeof window === 'undefined') {
|
|
19
28
|
return () => {}
|
|
20
29
|
}
|
|
30
|
+
// String first arg = filter to that emit name; otherwise a catch-all handler.
|
|
31
|
+
const filter = typeof nameOrHandler === 'string' ? nameOrHandler : undefined
|
|
32
|
+
const handler = typeof nameOrHandler === 'string' ? maybeHandler : nameOrHandler
|
|
21
33
|
function listener(event: Event) {
|
|
22
|
-
|
|
34
|
+
const name = (event as CustomEvent<{ name: string }>).detail.name
|
|
35
|
+
if (filter === undefined || filter === name) {
|
|
36
|
+
handler?.(name)
|
|
37
|
+
}
|
|
23
38
|
}
|
|
24
39
|
window.addEventListener('belte:menu', listener)
|
|
25
40
|
return () => window.removeEventListener('belte:menu', listener)
|
|
@@ -62,8 +62,15 @@ export async function openWebview({
|
|
|
62
62
|
webview_destroy: { args: [FFIType.ptr], returns: FFIType.void },
|
|
63
63
|
})
|
|
64
64
|
|
|
65
|
-
|
|
66
|
-
|
|
65
|
+
/*
|
|
66
|
+
First arg is the webview's `debug` flag: 1 enables the native inspector
|
|
67
|
+
(WKWebView's Web Inspector, WebView2 DevTools, WebKitGTK inspector) so a JS
|
|
68
|
+
error on the loaded page — otherwise silent in a bare bundle window — can be
|
|
69
|
+
read via right-click → Inspect. Gated behind BELTE_INSPECT so release bundles
|
|
70
|
+
ship without it. The second arg is an optional parent handle; null = fresh window.
|
|
71
|
+
*/
|
|
72
|
+
const debug = process.env.BELTE_INSPECT ? 1 : 0
|
|
73
|
+
const handle = symbols.webview_create(debug, null)
|
|
67
74
|
symbols.webview_set_title(handle, cString(title))
|
|
68
75
|
symbols.webview_set_size(handle, width, height, WEBVIEW_HINT_NONE)
|
|
69
76
|
/*
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { log } from '../shared/log.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Ad-hoc code-signs an assembled macOS `.app` so it launches on other Macs.
|
|
5
|
+
|
|
6
|
+
Apple Silicon mandates a valid code signature for every executable. `bun
|
|
7
|
+
build --compile` emits an ad-hoc, linker-signed binary, but assembling the
|
|
8
|
+
`.app` around it (writing Info.plist, dropping in the lib) leaves the bundle
|
|
9
|
+
unsealed — `codesign --verify` then reports the signature as modified, and a
|
|
10
|
+
copy that picks up a quarantine flag (AirDrop, USB, download) gets silently
|
|
11
|
+
killed by Gatekeeper/AMFI: the icon bounces once and nothing opens.
|
|
12
|
+
|
|
13
|
+
Re-signing inside-out fixes that. Nested Mach-O code (the webview dylib, the
|
|
14
|
+
embedded server binary, the launcher) is signed first, then the bundle as a
|
|
15
|
+
whole, which seals Resources and binds Info.plist. The identity is `-`,
|
|
16
|
+
ad-hoc: no certificate, no Developer account, no network — as far as signing
|
|
17
|
+
goes without a paid Developer ID. Recipients copying a quarantined bundle
|
|
18
|
+
still need `xattr -cr <app>` once, but the app no longer fails to launch.
|
|
19
|
+
|
|
20
|
+
Best-effort: if `codesign` is missing or fails, warn and return rather than
|
|
21
|
+
abort the bundle, which is otherwise complete and usable on the build host.
|
|
22
|
+
*/
|
|
23
|
+
export async function signMacApp(bundleRoot: string, innerPaths: string[]): Promise<void> {
|
|
24
|
+
try {
|
|
25
|
+
// Inner Mach-O code inside-out, then the bundle, which re-signs the
|
|
26
|
+
// main executable as part of sealing — order matters for nested seals.
|
|
27
|
+
for (const path of innerPaths) {
|
|
28
|
+
await Bun.$`codesign --force --sign - ${path}`.quiet()
|
|
29
|
+
}
|
|
30
|
+
await Bun.$`codesign --force --sign - ${bundleRoot}`.quiet()
|
|
31
|
+
} catch (error) {
|
|
32
|
+
log.warn(`could not code-sign ${bundleRoot} — it may not launch when copied to another Mac`)
|
|
33
|
+
log.error(error)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -5,7 +5,21 @@ import { buildRpcRequest } from '../shared/buildRpcRequest.ts'
|
|
|
5
5
|
import { decodeResponse } from '../shared/decodeResponse.ts'
|
|
6
6
|
import type { CliManifest } from './types/CliManifest.ts'
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
/*
|
|
9
|
+
Each property of the client is a callable: invoking it decodes the body
|
|
10
|
+
(plain call), while `.raw(args)` returns the underlying Response without
|
|
11
|
+
decoding or throwing on non-2xx — the escape hatch the CLI uses to sniff
|
|
12
|
+
the Content-Type and stream sse/jsonl bodies frame-by-frame instead of
|
|
13
|
+
buffering through decodeResponse.
|
|
14
|
+
*/
|
|
15
|
+
type ClientInvoker = ((args?: unknown) => Promise<unknown>) & {
|
|
16
|
+
raw: (args?: unknown) => Promise<Response>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type AnyApi = Record<string, ClientInvoker>
|
|
20
|
+
|
|
21
|
+
// A command resolved to its HTTP shape — the manifest/registry lookup result.
|
|
22
|
+
type ResolvedCommand = { method: HttpVerb; url: string; accept?: string }
|
|
9
23
|
|
|
10
24
|
/*
|
|
11
25
|
Builds a typed proxy over the project's RPCs for use in scripts, tests,
|
|
@@ -42,35 +56,56 @@ export function createClient<Api extends AnyApi = AnyApi>(opts?: {
|
|
|
42
56
|
baked-in source of truth); registry is the in-process fallback for
|
|
43
57
|
use in same-project code where defineVerb has run.
|
|
44
58
|
*/
|
|
45
|
-
function resolve(name: string):
|
|
59
|
+
function resolve(name: string): ResolvedCommand | undefined {
|
|
46
60
|
const entry = manifest?.[name]
|
|
47
61
|
if (entry) {
|
|
48
|
-
return { method: entry.method, url: entry.url }
|
|
62
|
+
return { method: entry.method, url: entry.url, accept: entry.accept }
|
|
49
63
|
}
|
|
50
64
|
const found = findVerbByCommandName(name)
|
|
51
65
|
return found ? { method: found.remote.method, url: found.remote.url } : undefined
|
|
52
66
|
}
|
|
53
67
|
|
|
54
68
|
/*
|
|
55
|
-
Single
|
|
56
|
-
is
|
|
57
|
-
mode looks the verb up in the registry and runs verb.fetch
|
|
69
|
+
Single dispatch path for both modes — only the base URL and how the
|
|
70
|
+
Request is sent differ. Remote mode fetches over the network;
|
|
71
|
+
in-process mode looks the verb up in the registry and runs verb.fetch
|
|
72
|
+
(no hop). Returns the raw Response; callers decode or stream it.
|
|
58
73
|
*/
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
path: string,
|
|
74
|
+
function send(
|
|
75
|
+
resolved: ResolvedCommand,
|
|
62
76
|
args: unknown,
|
|
63
77
|
baseUrl: string,
|
|
64
78
|
dispatch: (request: Request) => Promise<Response>,
|
|
65
|
-
): Promise<
|
|
79
|
+
): Promise<Response> {
|
|
66
80
|
const headers = new Headers()
|
|
67
81
|
if (token) {
|
|
68
82
|
headers.set('authorization', `Bearer ${token}`)
|
|
69
83
|
}
|
|
70
|
-
|
|
71
|
-
|
|
84
|
+
if (resolved.accept) {
|
|
85
|
+
headers.set('accept', resolved.accept)
|
|
86
|
+
}
|
|
87
|
+
const request = buildRpcRequest({
|
|
88
|
+
method: resolved.method,
|
|
89
|
+
url: resolved.url,
|
|
90
|
+
args,
|
|
91
|
+
baseUrl,
|
|
92
|
+
headers,
|
|
93
|
+
})
|
|
94
|
+
return dispatch(request)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Decoding plain-call path: throws on non-2xx, returns the decoded body.
|
|
98
|
+
async function call(
|
|
99
|
+
resolved: ResolvedCommand,
|
|
100
|
+
args: unknown,
|
|
101
|
+
baseUrl: string,
|
|
102
|
+
dispatch: (request: Request) => Promise<Response>,
|
|
103
|
+
): Promise<unknown> {
|
|
104
|
+
const response = await send(resolved, args, baseUrl, dispatch)
|
|
72
105
|
if (!response.ok) {
|
|
73
|
-
throw new Error(
|
|
106
|
+
throw new Error(
|
|
107
|
+
`${resolved.method} ${resolved.url} failed: ${response.status} ${response.statusText}`,
|
|
108
|
+
)
|
|
74
109
|
}
|
|
75
110
|
return decodeResponse(response)
|
|
76
111
|
}
|
|
@@ -94,10 +129,24 @@ export function createClient<Api extends AnyApi = AnyApi>(opts?: {
|
|
|
94
129
|
manifest + registry are fixed for a client's lifetime, so a resolved
|
|
95
130
|
invoker (or its absence) never changes.
|
|
96
131
|
*/
|
|
97
|
-
const invokerCache = new Map<string,
|
|
132
|
+
const invokerCache = new Map<string, ClientInvoker | undefined>()
|
|
133
|
+
|
|
134
|
+
/*
|
|
135
|
+
Build a memoised invoker for a resolved command. The plain call and
|
|
136
|
+
`.raw` share one dispatch — remote mode hits the network, in-process
|
|
137
|
+
mode runs verb.fetch — so the two can't diverge on URL/headers.
|
|
138
|
+
*/
|
|
139
|
+
function buildInvoker(resolved: ResolvedCommand): ClientInvoker {
|
|
140
|
+
const baseUrl = url ?? 'http://localhost/'
|
|
141
|
+
const dispatch = url ? fetch : inProcessDispatch(resolved.url)
|
|
142
|
+
const invoker = ((args?: unknown) =>
|
|
143
|
+
call(resolved, args, baseUrl, dispatch)) as ClientInvoker
|
|
144
|
+
invoker.raw = (args?: unknown) => send(resolved, args, baseUrl, dispatch)
|
|
145
|
+
return invoker
|
|
146
|
+
}
|
|
98
147
|
|
|
99
148
|
return new Proxy({} as Api, {
|
|
100
|
-
get(_target, prop):
|
|
149
|
+
get(_target, prop): ClientInvoker | undefined {
|
|
101
150
|
if (typeof prop !== 'string') {
|
|
102
151
|
return undefined
|
|
103
152
|
}
|
|
@@ -105,18 +154,7 @@ export function createClient<Api extends AnyApi = AnyApi>(opts?: {
|
|
|
105
154
|
return invokerCache.get(prop)
|
|
106
155
|
}
|
|
107
156
|
const resolved = resolve(prop)
|
|
108
|
-
const invoker = resolved
|
|
109
|
-
? (args?: unknown) =>
|
|
110
|
-
url
|
|
111
|
-
? call(resolved.method, resolved.url, args, url, fetch)
|
|
112
|
-
: call(
|
|
113
|
-
resolved.method,
|
|
114
|
-
resolved.url,
|
|
115
|
-
args,
|
|
116
|
-
'http://localhost/',
|
|
117
|
-
inProcessDispatch(resolved.url),
|
|
118
|
-
)
|
|
119
|
-
: undefined
|
|
157
|
+
const invoker = resolved ? buildInvoker(resolved) : undefined
|
|
120
158
|
invokerCache.set(prop, invoker)
|
|
121
159
|
return invoker
|
|
122
160
|
},
|
package/src/lib/cli/runCli.ts
CHANGED
|
@@ -1,9 +1,24 @@
|
|
|
1
|
+
import { decodeResponse } from '../shared/decodeResponse.ts'
|
|
2
|
+
import { isStreamingResponse } from '../shared/isStreamingResponse.ts'
|
|
3
|
+
import { responseErrorText } from '../shared/responseErrorText.ts'
|
|
4
|
+
import { streamResponse } from '../shared/streamResponse.ts'
|
|
1
5
|
import { createClient } from './createClient.ts'
|
|
2
6
|
import { loadEnvFromBinaryDir } from './loadEnvFromBinaryDir.ts'
|
|
3
7
|
import { parseArgvForRpc } from './parseArgvForRpc.ts'
|
|
4
8
|
import { printCommandHelp, printTopLevelHelp } from './printHelp.ts'
|
|
5
9
|
import type { CliManifest } from './types/CliManifest.ts'
|
|
6
10
|
|
|
11
|
+
// String results print verbatim (with a trailing newline); everything else as a JSON line.
|
|
12
|
+
function printValue(value: unknown, pretty: boolean): void {
|
|
13
|
+
if (typeof value === 'string') {
|
|
14
|
+
process.stdout.write(value.endsWith('\n') ? value : `${value}\n`)
|
|
15
|
+
return
|
|
16
|
+
}
|
|
17
|
+
if (value !== undefined) {
|
|
18
|
+
process.stdout.write(`${JSON.stringify(value, null, pretty ? 2 : undefined)}\n`)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
7
22
|
/*
|
|
8
23
|
Top-level CLI driver. Loaded by the standalone binary's entry; expects
|
|
9
24
|
the bundler-emitted manifest plus the raw argv tail. The binary is a
|
|
@@ -18,9 +33,10 @@ running server over HTTP and APP_URL must be set. Flow:
|
|
|
18
33
|
5. Otherwise parse the rest of the argv against the manifest entry's
|
|
19
34
|
JSON Schema and dispatch via createClient against APP_URL.
|
|
20
35
|
|
|
21
|
-
Streaming responses
|
|
22
|
-
|
|
23
|
-
|
|
36
|
+
Streaming responses are handled by sniffing the response Content-Type:
|
|
37
|
+
sse/jsonl bodies (a streaming verb, or a socket `tail` command) are
|
|
38
|
+
printed frame-by-frame as NDJSON to stdout; everything else is decoded
|
|
39
|
+
and pretty-printed once.
|
|
24
40
|
*/
|
|
25
41
|
export async function runCli({
|
|
26
42
|
programName,
|
|
@@ -74,21 +90,27 @@ export async function runCli({
|
|
|
74
90
|
const appToken = process.env.APP_TOKEN
|
|
75
91
|
const client = createClient({ url: appUrl, token: appToken, manifest })
|
|
76
92
|
|
|
93
|
+
const fn = client[first]
|
|
94
|
+
if (!fn) {
|
|
95
|
+
console.error(`${programName}: command "${first}" not in client`)
|
|
96
|
+
return 1
|
|
97
|
+
}
|
|
77
98
|
try {
|
|
78
|
-
const
|
|
79
|
-
if (
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
if (!result.endsWith('\n')) {
|
|
87
|
-
process.stdout.write('\n')
|
|
99
|
+
const response = await fn.raw(args)
|
|
100
|
+
if (isStreamingResponse(response)) {
|
|
101
|
+
/*
|
|
102
|
+
Stream frame-by-frame to stdout as NDJSON. streamResponse
|
|
103
|
+
throws a clear HttpError on a non-2xx body, caught below.
|
|
104
|
+
*/
|
|
105
|
+
for await (const frame of streamResponse(response)) {
|
|
106
|
+
printValue(frame, false)
|
|
88
107
|
}
|
|
89
|
-
|
|
90
|
-
|
|
108
|
+
return 0
|
|
109
|
+
}
|
|
110
|
+
if (!response.ok) {
|
|
111
|
+
throw new Error(await responseErrorText(response))
|
|
91
112
|
}
|
|
113
|
+
printValue(await decodeResponse(response), true)
|
|
92
114
|
return 0
|
|
93
115
|
} catch (error) {
|
|
94
116
|
console.error(`${programName}: ${error instanceof Error ? error.message : String(error)}`)
|