@briancray/belte 0.1.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/LICENSE +21 -0
- package/README.md +49 -0
- package/bin/belte.ts +136 -0
- package/package.json +80 -0
- package/src/App.svelte +31 -0
- package/src/assets/app.html +14 -0
- package/src/belteResolverPlugin.ts +832 -0
- package/src/build.ts +144 -0
- package/src/buildCli.ts +160 -0
- package/src/cliEntry.ts +31 -0
- package/src/clientEntry.ts +7 -0
- package/src/compile.ts +64 -0
- package/src/devEntry.ts +33 -0
- package/src/discoveryEntry.ts +33 -0
- package/src/lib/browser/cache.ts +191 -0
- package/src/lib/browser/page.svelte.ts +215 -0
- package/src/lib/browser/remoteProxy.ts +44 -0
- package/src/lib/browser/socketChannel.ts +182 -0
- package/src/lib/browser/socketProxy.ts +64 -0
- package/src/lib/browser/startClient.ts +132 -0
- package/src/lib/browser/subscribe.ts +131 -0
- package/src/lib/browser/types/Layouts.ts +7 -0
- package/src/lib/browser/types/Pages.ts +7 -0
- package/src/lib/cli/createClient.ts +126 -0
- package/src/lib/cli/loadEnvFromBinaryDir.ts +44 -0
- package/src/lib/cli/parseArgvForRpc.ts +97 -0
- package/src/lib/cli/printHelp.ts +70 -0
- package/src/lib/cli/runCli.ts +88 -0
- package/src/lib/cli/types/CliManifest.ts +9 -0
- package/src/lib/cli/types/CliManifestEntry.ts +12 -0
- package/src/lib/mcp/createMcpResourceServer.ts +101 -0
- package/src/lib/mcp/createMcpServer.ts +40 -0
- package/src/lib/mcp/dispatchMcpRequest.ts +294 -0
- package/src/lib/mcp/mcpResourceServerSlot.ts +18 -0
- package/src/lib/mcp/types/JsonRpcRequest.ts +12 -0
- package/src/lib/mcp/types/JsonRpcResponse.ts +20 -0
- package/src/lib/mcp/types/McpResourceContents.ts +10 -0
- package/src/lib/mcp/types/McpResourceDescriptor.ts +6 -0
- package/src/lib/mcp/types/McpResourceServer.ts +12 -0
- package/src/lib/mcp/types/McpServer.ts +9 -0
- package/src/lib/mcp/types/McpServerOptions.ts +16 -0
- package/src/lib/server/AppModule.ts +25 -0
- package/src/lib/server/DELETE.ts +9 -0
- package/src/lib/server/GET.ts +9 -0
- package/src/lib/server/HEAD.ts +9 -0
- package/src/lib/server/HttpError.ts +19 -0
- package/src/lib/server/PATCH.ts +9 -0
- package/src/lib/server/POST.ts +9 -0
- package/src/lib/server/PUT.ts +9 -0
- package/src/lib/server/cli/buildEnvContent.ts +18 -0
- package/src/lib/server/cli/createTarGz.ts +76 -0
- package/src/lib/server/cli/handleCliDownload.ts +124 -0
- package/src/lib/server/cli/handleCliInstall.ts +20 -0
- package/src/lib/server/cli/installScript.ts +29 -0
- package/src/lib/server/cli/maxSourceMtime.ts +27 -0
- package/src/lib/server/error.ts +56 -0
- package/src/lib/server/json.ts +28 -0
- package/src/lib/server/jsonl.ts +40 -0
- package/src/lib/server/prompt.ts +30 -0
- package/src/lib/server/prompts/definePrompt.ts +20 -0
- package/src/lib/server/prompts/promptRegistry.ts +9 -0
- package/src/lib/server/prompts/registerPrompt.ts +6 -0
- package/src/lib/server/prompts/types/Prompt.ts +14 -0
- package/src/lib/server/prompts/types/PromptMessage.ts +10 -0
- package/src/lib/server/prompts/types/PromptOptions.ts +17 -0
- package/src/lib/server/prompts/types/PromptRegistryEntry.ts +15 -0
- package/src/lib/server/prompts/types/PromptRoutes.ts +10 -0
- package/src/lib/server/redirect.ts +37 -0
- package/src/lib/server/request.ts +18 -0
- package/src/lib/server/rpc/defineVerb.ts +103 -0
- package/src/lib/server/rpc/parseArgs.ts +60 -0
- package/src/lib/server/rpc/registerVerb.ts +6 -0
- package/src/lib/server/rpc/types/HttpVerb.ts +1 -0
- package/src/lib/server/rpc/types/RawRemoteFunction.ts +13 -0
- package/src/lib/server/rpc/types/RemoteFunction.ts +35 -0
- package/src/lib/server/rpc/types/RemoteHandler.ts +22 -0
- package/src/lib/server/rpc/types/RemoteRoutes.ts +13 -0
- package/src/lib/server/rpc/types/StandardSchemaV1.ts +57 -0
- package/src/lib/server/rpc/types/TypedResponse.ts +18 -0
- package/src/lib/server/rpc/types/VerbHelper.ts +39 -0
- package/src/lib/server/rpc/types/VerbRegistryEntry.ts +17 -0
- package/src/lib/server/rpc/unprocessed.ts +14 -0
- package/src/lib/server/rpc/verbRegistry.ts +11 -0
- package/src/lib/server/runtime/buildOpenApiSpec.ts +66 -0
- package/src/lib/server/runtime/cacheControlForAsset.ts +17 -0
- package/src/lib/server/runtime/containsTraversal.ts +37 -0
- package/src/lib/server/runtime/createPublicAssetServer.ts +66 -0
- package/src/lib/server/runtime/createServer.ts +555 -0
- package/src/lib/server/runtime/getActiveServer.ts +6 -0
- package/src/lib/server/runtime/mimeForExtension.ts +20 -0
- package/src/lib/server/runtime/registryManifests.ts +48 -0
- package/src/lib/server/runtime/requestContext.ts +5 -0
- package/src/lib/server/runtime/safeJsonForScript.ts +17 -0
- package/src/lib/server/runtime/serializeCacheSnapshot.ts +84 -0
- package/src/lib/server/runtime/serverSlot.ts +13 -0
- package/src/lib/server/runtime/setActiveServer.ts +6 -0
- package/src/lib/server/runtime/streamFromIterator.ts +76 -0
- package/src/lib/server/runtime/types/Assets.ts +1 -0
- package/src/lib/server/runtime/types/CompileTarget.ts +6 -0
- package/src/lib/server/runtime/types/RequestStore.ts +15 -0
- package/src/lib/server/runtime/types/SvelteConfig.ts +5 -0
- package/src/lib/server/server.ts +19 -0
- package/src/lib/server/socket.ts +31 -0
- package/src/lib/server/sockets/createSocketDispatcher.ts +267 -0
- package/src/lib/server/sockets/defineSocket.ts +160 -0
- package/src/lib/server/sockets/lookupSocket.ts +6 -0
- package/src/lib/server/sockets/registerSocket.ts +6 -0
- package/src/lib/server/sockets/socketRegistry.ts +9 -0
- package/src/lib/server/sockets/types/Socket.ts +21 -0
- package/src/lib/server/sockets/types/SocketClientFrame.ts +18 -0
- package/src/lib/server/sockets/types/SocketOptions.ts +22 -0
- package/src/lib/server/sockets/types/SocketRegistryEntry.ts +18 -0
- package/src/lib/server/sockets/types/SocketRoutes.ts +10 -0
- package/src/lib/server/sockets/types/SocketServerFrame.ts +15 -0
- package/src/lib/server/sse.ts +47 -0
- package/src/lib/shared/activeCacheStore.ts +20 -0
- package/src/lib/shared/buildRpcRequest.ts +61 -0
- package/src/lib/shared/cacheControlValues.ts +8 -0
- package/src/lib/shared/cacheStoreSlot.ts +16 -0
- package/src/lib/shared/canonicalJson.ts +24 -0
- package/src/lib/shared/commandNameForUrl.ts +17 -0
- package/src/lib/shared/createCacheStore.ts +42 -0
- package/src/lib/shared/createPushIterator.ts +77 -0
- package/src/lib/shared/createRemoteFunction.ts +89 -0
- package/src/lib/shared/decodeResponse.ts +47 -0
- package/src/lib/shared/detectTarget.ts +27 -0
- package/src/lib/shared/findExportCallSite.ts +479 -0
- package/src/lib/shared/forwardHeaders.ts +28 -0
- package/src/lib/shared/getRemoteMeta.ts +5 -0
- package/src/lib/shared/isDebugEnabled.ts +23 -0
- package/src/lib/shared/jsonSchemaForSchema.ts +38 -0
- package/src/lib/shared/keyForRemoteCall.ts +38 -0
- package/src/lib/shared/loadSvelteConfig.ts +18 -0
- package/src/lib/shared/log.ts +104 -0
- package/src/lib/shared/nearestLayoutPrefix.ts +36 -0
- package/src/lib/shared/normalizeTarget.ts +10 -0
- package/src/lib/shared/pageUrlForFile.ts +14 -0
- package/src/lib/shared/parseRouteSegments.ts +22 -0
- package/src/lib/shared/preparePromptModule.ts +36 -0
- package/src/lib/shared/prepareRpcModule.ts +51 -0
- package/src/lib/shared/prepareSocketModule.ts +37 -0
- package/src/lib/shared/programNameForPackage.ts +14 -0
- package/src/lib/shared/promptNameForFile.ts +10 -0
- package/src/lib/shared/recordRemoteMeta.ts +5 -0
- package/src/lib/shared/remoteMetaStore.ts +16 -0
- package/src/lib/shared/resolveClientFlags.ts +18 -0
- package/src/lib/shared/rpcUrlForFile.ts +19 -0
- package/src/lib/shared/setCacheStoreResolver.ts +6 -0
- package/src/lib/shared/socketNameForFile.ts +11 -0
- package/src/lib/shared/streamingContentTypes.ts +11 -0
- package/src/lib/shared/stripImport.ts +27 -0
- package/src/lib/shared/subscribableFromResponse.ts +333 -0
- package/src/lib/shared/toBunRoutePattern.ts +28 -0
- package/src/lib/shared/types/CacheEntry.ts +16 -0
- package/src/lib/shared/types/CacheOptions.ts +10 -0
- package/src/lib/shared/types/CacheSnapshotEntry.ts +15 -0
- package/src/lib/shared/types/CacheStore.ts +15 -0
- package/src/lib/shared/types/ClientFlags.ts +11 -0
- package/src/lib/shared/types/Subscribable.ts +15 -0
- package/src/lib/shared/writeRoutesDts.ts +64 -0
- package/src/preload.ts +20 -0
- package/src/scaffold.ts +92 -0
- package/src/serverEntry.ts +47 -0
- package/src/sveltePlugin.ts +58 -0
- package/src/tailwindStylePreprocessor.ts +62 -0
- package/template/package.json +16 -0
- package/template/src/app.ts +23 -0
- package/template/src/browser/app.css +21 -0
- package/template/src/browser/app.html +24 -0
- package/template/src/browser/pages/about/page.svelte +5 -0
- package/template/src/browser/pages/layout.svelte +26 -0
- package/template/src/browser/pages/page.svelte +20 -0
- package/template/src/cli/banner.txt +3 -0
- package/template/src/cli/footer.txt +1 -0
- package/template/src/server/rpc/getHello.ts +33 -0
- package/template/svelte.config.js +12 -0
- package/template/tsconfig.json +18 -0
- package/tsconfig.app.json +16 -0
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { createSubscriber } from 'svelte/reactivity'
|
|
2
|
+
import type { Subscribable } from '../shared/types/Subscribable.ts'
|
|
3
|
+
|
|
4
|
+
type SubscriptionStatus = 'pending' | 'open' | 'done' | 'error'
|
|
5
|
+
|
|
6
|
+
type Entry<T> = {
|
|
7
|
+
latest: T | undefined
|
|
8
|
+
error: Error | undefined
|
|
9
|
+
status: SubscriptionStatus
|
|
10
|
+
tap: () => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const registry = new Map<string, Entry<unknown>>()
|
|
14
|
+
|
|
15
|
+
/*
|
|
16
|
+
Reactive consumer for streaming sources. Takes a Subscribable<T> — the
|
|
17
|
+
shape both `Socket<T>` (declared under src/server/sockets/) and the result of
|
|
18
|
+
`fn.stream(args)` satisfy:
|
|
19
|
+
|
|
20
|
+
const latest = $derived(subscribe(chat)) // socket
|
|
21
|
+
const latest = $derived(subscribe(tickFeed.stream())) // rpc stream (no args)
|
|
22
|
+
const latest = $derived(subscribe(countLog.stream({ to: 5 }))) // rpc stream
|
|
23
|
+
|
|
24
|
+
Lifecycle mirrors cache(): the entry's tracker is a Svelte
|
|
25
|
+
createSubscriber, so the first $derived read in a tracking scope opens
|
|
26
|
+
the underlying iterator (with history replay on a Socket, or a fresh
|
|
27
|
+
fetch on an rpc stream), and the last $derived to stop reading closes
|
|
28
|
+
it. Many $deriveds reading the same source share one underlying
|
|
29
|
+
subscription — the registry dedupes by `subscribable.name`, which is
|
|
30
|
+
the socket name for declared sockets and `keyForRemoteCall(method, url,
|
|
31
|
+
args)` for rpc streams. So passing fresh `fn.stream(args)` Subscribables
|
|
32
|
+
across re-renders is safe: same args → same key → shared subscription.
|
|
33
|
+
|
|
34
|
+
Subscribe is a no-op on the server (returns undefined) — SSR can't
|
|
35
|
+
keep a stream open across the request boundary. Pages that want a
|
|
36
|
+
seeded value in the initial HTML should fetch via cache() against an
|
|
37
|
+
HTTP rpc handler and layer subscribe() on top for live updates after
|
|
38
|
+
hydration.
|
|
39
|
+
|
|
40
|
+
Errors are surfaced through subscribe.error(x) rather than thrown, so
|
|
41
|
+
reading `latest` from a $derived can't crash the component. Status
|
|
42
|
+
distinguishes "haven't received the first frame" (pending) from
|
|
43
|
+
"stream ended cleanly" (done) and "wire layer surfaced an error"
|
|
44
|
+
(error).
|
|
45
|
+
*/
|
|
46
|
+
export function subscribe<T>(subscribable: Subscribable<T>): T | undefined {
|
|
47
|
+
return readField(subscribable, 'latest') as T | undefined
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
subscribe.error = function subscribeError<T>(subscribable: Subscribable<T>): Error | undefined {
|
|
51
|
+
return readField(subscribable, 'error') as Error | undefined
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
subscribe.status = function subscribeStatus<T>(subscribable: Subscribable<T>): SubscriptionStatus {
|
|
55
|
+
return (readField(subscribable, 'status') as SubscriptionStatus | undefined) ?? 'pending'
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function readField<T, K extends keyof Entry<T>>(
|
|
59
|
+
subscribable: Subscribable<T>,
|
|
60
|
+
field: K,
|
|
61
|
+
): Entry<T>[K] | undefined {
|
|
62
|
+
if (typeof window === 'undefined') {
|
|
63
|
+
if (field === 'status') {
|
|
64
|
+
return 'pending' as Entry<T>[K]
|
|
65
|
+
}
|
|
66
|
+
return undefined
|
|
67
|
+
}
|
|
68
|
+
const entry = getOrCreateEntry(subscribable) as Entry<T>
|
|
69
|
+
entry.tap()
|
|
70
|
+
return entry[field]
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function getOrCreateEntry<T>(subscribable: Subscribable<T>): Entry<T> {
|
|
74
|
+
const key = subscribable.name
|
|
75
|
+
const cached = registry.get(key) as Entry<T> | undefined
|
|
76
|
+
if (cached) {
|
|
77
|
+
return cached
|
|
78
|
+
}
|
|
79
|
+
const entry: Entry<T> = {
|
|
80
|
+
latest: undefined,
|
|
81
|
+
error: undefined,
|
|
82
|
+
status: 'pending',
|
|
83
|
+
tap: () => undefined,
|
|
84
|
+
}
|
|
85
|
+
entry.tap = createSubscriber((update) => {
|
|
86
|
+
entry.latest = undefined
|
|
87
|
+
entry.error = undefined
|
|
88
|
+
entry.status = 'pending'
|
|
89
|
+
const iterator = subscribable[Symbol.asyncIterator]()
|
|
90
|
+
let cancelled = false
|
|
91
|
+
;(async () => {
|
|
92
|
+
try {
|
|
93
|
+
while (!cancelled) {
|
|
94
|
+
const next = await iterator.next()
|
|
95
|
+
if (next.done) {
|
|
96
|
+
if (!cancelled) {
|
|
97
|
+
if (entry.status !== 'error') {
|
|
98
|
+
entry.status = 'done'
|
|
99
|
+
}
|
|
100
|
+
update()
|
|
101
|
+
}
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
entry.latest = next.value
|
|
105
|
+
entry.status = 'open'
|
|
106
|
+
update()
|
|
107
|
+
}
|
|
108
|
+
} catch (error) {
|
|
109
|
+
if (!cancelled) {
|
|
110
|
+
entry.error = error instanceof Error ? error : new Error(String(error))
|
|
111
|
+
entry.status = 'error'
|
|
112
|
+
update()
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
})()
|
|
116
|
+
return () => {
|
|
117
|
+
cancelled = true
|
|
118
|
+
iterator.return?.(undefined)?.catch(() => undefined)
|
|
119
|
+
/*
|
|
120
|
+
Identity-guard the eviction: if a fresh Subscribable with the
|
|
121
|
+
same name has already replaced us in the registry, this stale
|
|
122
|
+
cleanup must not nuke the new entry.
|
|
123
|
+
*/
|
|
124
|
+
if (registry.get(key) === (entry as Entry<unknown>)) {
|
|
125
|
+
registry.delete(key)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
registry.set(key, entry as Entry<unknown>)
|
|
130
|
+
return entry
|
|
131
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { HttpVerb } from '../server/rpc/types/HttpVerb.ts'
|
|
2
|
+
import { verbRegistry } from '../server/rpc/verbRegistry.ts'
|
|
3
|
+
import { buildRpcRequest } from '../shared/buildRpcRequest.ts'
|
|
4
|
+
import { commandNameForUrl } from '../shared/commandNameForUrl.ts'
|
|
5
|
+
import { decodeResponse } from '../shared/decodeResponse.ts'
|
|
6
|
+
import type { CliManifest } from './types/CliManifest.ts'
|
|
7
|
+
import type { CliManifestEntry } from './types/CliManifestEntry.ts'
|
|
8
|
+
|
|
9
|
+
type AnyApi = Record<string, (args?: unknown) => Promise<unknown>>
|
|
10
|
+
|
|
11
|
+
/*
|
|
12
|
+
Builds a typed proxy over the project's RPCs for use in scripts, tests,
|
|
13
|
+
server-to-server calls, and the standalone CLI binary. Modes are
|
|
14
|
+
decided at construction:
|
|
15
|
+
|
|
16
|
+
- With `url`: remote-mode. Each property access becomes an HTTP call
|
|
17
|
+
against `<url>/<manifest[name].url>` using the manifest's method.
|
|
18
|
+
Auth header is set from `token` when provided.
|
|
19
|
+
- Without `url`: in-process mode. Each property access looks up the
|
|
20
|
+
verb in the registry (populated by importing the project's rpc
|
|
21
|
+
modules) and calls `verb.fetch(synthesizedRequest)` — same code
|
|
22
|
+
path the HTTP router uses, no network hop.
|
|
23
|
+
|
|
24
|
+
The `manifest` is the bundler-emitted CLI manifest baked into the thin
|
|
25
|
+
binary. In in-process mode it's optional (registry is the source of
|
|
26
|
+
truth). Both can be supplied to support a binary that talks remote by
|
|
27
|
+
default but falls back to in-process when APP_URL is unset.
|
|
28
|
+
*/
|
|
29
|
+
export function createClient<Api extends AnyApi = AnyApi>(opts?: {
|
|
30
|
+
url?: string
|
|
31
|
+
token?: string
|
|
32
|
+
manifest?: CliManifest
|
|
33
|
+
}): Api {
|
|
34
|
+
const url = opts?.url
|
|
35
|
+
const token = opts?.token
|
|
36
|
+
const manifest = opts?.manifest
|
|
37
|
+
|
|
38
|
+
/*
|
|
39
|
+
Look up method + url for a given name. Manifest wins (the binary's
|
|
40
|
+
baked-in source of truth); registry is the in-process fallback for
|
|
41
|
+
use in same-project code where defineVerb has run.
|
|
42
|
+
*/
|
|
43
|
+
function resolve(name: string): { method: HttpVerb; url: string } | undefined {
|
|
44
|
+
const entry = manifest?.[name]
|
|
45
|
+
if (entry) {
|
|
46
|
+
return { method: entry.method, url: entry.url }
|
|
47
|
+
}
|
|
48
|
+
for (const value of verbRegistry.values()) {
|
|
49
|
+
if (commandNameForUrl(value.remote.url) === name) {
|
|
50
|
+
return { method: value.remote.method, url: value.remote.url }
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return undefined
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function callRemote(
|
|
57
|
+
method: HttpVerb,
|
|
58
|
+
path: string,
|
|
59
|
+
args: unknown,
|
|
60
|
+
baseUrl: string,
|
|
61
|
+
): Promise<unknown> {
|
|
62
|
+
const headers = new Headers()
|
|
63
|
+
if (token) {
|
|
64
|
+
headers.set('authorization', `Bearer ${token}`)
|
|
65
|
+
}
|
|
66
|
+
const request = buildRpcRequest({ method, url: path, args, baseUrl, headers })
|
|
67
|
+
const response = await fetch(request)
|
|
68
|
+
if (!response.ok) {
|
|
69
|
+
throw new Error(`${method} ${path} failed: ${response.status} ${response.statusText}`)
|
|
70
|
+
}
|
|
71
|
+
return decodeResponse(response)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function callInProcess(method: HttpVerb, path: string, args: unknown): Promise<unknown> {
|
|
75
|
+
const entry = verbRegistry.get(path)
|
|
76
|
+
if (!entry) {
|
|
77
|
+
throw new Error(
|
|
78
|
+
`RPC ${path} not loaded — import the module first or set APP_URL to use remote mode`,
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
const headers = new Headers()
|
|
82
|
+
if (token) {
|
|
83
|
+
headers.set('authorization', `Bearer ${token}`)
|
|
84
|
+
}
|
|
85
|
+
const request = buildRpcRequest({
|
|
86
|
+
method,
|
|
87
|
+
url: path,
|
|
88
|
+
args,
|
|
89
|
+
baseUrl: 'http://localhost/',
|
|
90
|
+
headers,
|
|
91
|
+
})
|
|
92
|
+
const response = await entry.remote.fetch(request)
|
|
93
|
+
if (!response.ok) {
|
|
94
|
+
throw new Error(`${method} ${path} failed: ${response.status} ${response.statusText}`)
|
|
95
|
+
}
|
|
96
|
+
return decodeResponse(response)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/*
|
|
100
|
+
Memoise per-name so repeated `client.foo` accesses skip both the
|
|
101
|
+
registry scan in resolve() and a fresh closure allocation. The
|
|
102
|
+
manifest + registry are fixed for a client's lifetime, so a resolved
|
|
103
|
+
invoker (or its absence) never changes.
|
|
104
|
+
*/
|
|
105
|
+
const invokerCache = new Map<string, ((args?: unknown) => Promise<unknown>) | undefined>()
|
|
106
|
+
|
|
107
|
+
return new Proxy({} as Api, {
|
|
108
|
+
get(_target, prop): ((args?: unknown) => Promise<unknown>) | undefined {
|
|
109
|
+
if (typeof prop !== 'string') {
|
|
110
|
+
return undefined
|
|
111
|
+
}
|
|
112
|
+
if (invokerCache.has(prop)) {
|
|
113
|
+
return invokerCache.get(prop)
|
|
114
|
+
}
|
|
115
|
+
const resolved = resolve(prop)
|
|
116
|
+
const invoker = resolved
|
|
117
|
+
? (args?: unknown) =>
|
|
118
|
+
url
|
|
119
|
+
? callRemote(resolved.method, resolved.url, args, url)
|
|
120
|
+
: callInProcess(resolved.method, resolved.url, args)
|
|
121
|
+
: undefined
|
|
122
|
+
invokerCache.set(prop, invoker)
|
|
123
|
+
return invoker
|
|
124
|
+
},
|
|
125
|
+
})
|
|
126
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { dirname } from 'node:path'
|
|
3
|
+
|
|
4
|
+
const ENV_LINE = /^\s*([A-Z_][A-Z0-9_]*)\s*=\s*(.*)$/
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
Reads a `.env` next to the running binary (resolved via
|
|
8
|
+
`process.execPath`) and merges each declared var into `process.env`
|
|
9
|
+
only when not already set. The binary-dir `.env` is the file the
|
|
10
|
+
install tarball ships next to the executable; per-shell exports and
|
|
11
|
+
Bun's automatic CWD `.env` loading both naturally override it.
|
|
12
|
+
|
|
13
|
+
Strips surrounding single or double quotes off values; otherwise the
|
|
14
|
+
parser is intentionally minimal — no variable expansion, no escape
|
|
15
|
+
handling, no multi-line. Matches what the install tarball writes.
|
|
16
|
+
*/
|
|
17
|
+
export async function loadEnvFromBinaryDir(): Promise<void> {
|
|
18
|
+
const binDir = dirname(process.execPath)
|
|
19
|
+
const envPath = `${binDir}/.env`
|
|
20
|
+
if (!existsSync(envPath)) {
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
const text = await Bun.file(envPath).text()
|
|
24
|
+
for (const line of text.split('\n')) {
|
|
25
|
+
if (!line || line.startsWith('#')) {
|
|
26
|
+
continue
|
|
27
|
+
}
|
|
28
|
+
const match = ENV_LINE.exec(line)
|
|
29
|
+
if (!match) {
|
|
30
|
+
continue
|
|
31
|
+
}
|
|
32
|
+
const [, key, rawValue] = match
|
|
33
|
+
if (process.env[key as string] !== undefined) {
|
|
34
|
+
continue
|
|
35
|
+
}
|
|
36
|
+
const trimmed = rawValue?.trim() ?? ''
|
|
37
|
+
const unquoted =
|
|
38
|
+
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
|
39
|
+
(trimmed.startsWith("'") && trimmed.endsWith("'"))
|
|
40
|
+
? trimmed.slice(1, -1)
|
|
41
|
+
: trimmed
|
|
42
|
+
process.env[key as string] = unquoted
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Parses an argv tail into the JSON args bag for an RPC. The JSON Schema
|
|
3
|
+
on the manifest entry (when present) drives flag typing:
|
|
4
|
+
- properties whose type is "boolean" accept `--name` / `--no-name`
|
|
5
|
+
- properties whose type is "number" / "integer" accept `--name <n>` and
|
|
6
|
+
coerce with Number()
|
|
7
|
+
- properties whose type is "array" accept repeated `--name <v>`
|
|
8
|
+
- anything else accepts `--name <value>` as a string
|
|
9
|
+
|
|
10
|
+
For complex shapes (nested objects, unions, anyOf) the CLI exposes
|
|
11
|
+
`--json <stringified-args>` as an escape hatch that supplies the whole
|
|
12
|
+
args bag verbatim. Stdin: if a JSON object arrives piped in, it's used
|
|
13
|
+
as the full args bag (flags layer on top).
|
|
14
|
+
|
|
15
|
+
Unrecognised flags throw — early loud feedback is more useful than
|
|
16
|
+
silent drops.
|
|
17
|
+
*/
|
|
18
|
+
export async function parseArgvForRpc(
|
|
19
|
+
argv: string[],
|
|
20
|
+
jsonSchema: Record<string, unknown> | undefined,
|
|
21
|
+
): Promise<Record<string, unknown> | undefined> {
|
|
22
|
+
const properties =
|
|
23
|
+
(jsonSchema?.properties as Record<string, { type?: string }> | undefined) ?? {}
|
|
24
|
+
const args: Record<string, unknown> = {}
|
|
25
|
+
|
|
26
|
+
/*
|
|
27
|
+
Stdin override: if a JSON object is piped in, treat it as the
|
|
28
|
+
starting args bag. `Bun.stdin.text()` reads the whole pipe; if
|
|
29
|
+
nothing was piped, the read resolves with an empty string.
|
|
30
|
+
*/
|
|
31
|
+
if (!process.stdin.isTTY) {
|
|
32
|
+
const text = await Bun.stdin.text()
|
|
33
|
+
if (text.trim()) {
|
|
34
|
+
try {
|
|
35
|
+
const piped = JSON.parse(text)
|
|
36
|
+
if (piped && typeof piped === 'object' && !Array.isArray(piped)) {
|
|
37
|
+
Object.assign(args, piped)
|
|
38
|
+
}
|
|
39
|
+
} catch {
|
|
40
|
+
throw new Error(`stdin is not a valid JSON object: ${text.slice(0, 80)}…`)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
for (let index = 0; index < argv.length; index++) {
|
|
46
|
+
const token = argv[index] as string
|
|
47
|
+
if (token === '--json') {
|
|
48
|
+
const next = argv[++index]
|
|
49
|
+
if (!next) {
|
|
50
|
+
throw new Error('--json requires a value')
|
|
51
|
+
}
|
|
52
|
+
const parsed = JSON.parse(next)
|
|
53
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
54
|
+
throw new Error('--json value must be a JSON object')
|
|
55
|
+
}
|
|
56
|
+
Object.assign(args, parsed)
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
if (!token.startsWith('--')) {
|
|
60
|
+
throw new Error(`unexpected positional argument: ${token}`)
|
|
61
|
+
}
|
|
62
|
+
const isNegated = token.startsWith('--no-')
|
|
63
|
+
const rawName = isNegated ? token.slice('--no-'.length) : token.slice('--'.length)
|
|
64
|
+
const [name, eqValue] = rawName.includes('=')
|
|
65
|
+
? [rawName.slice(0, rawName.indexOf('=')), rawName.slice(rawName.indexOf('=') + 1)]
|
|
66
|
+
: [rawName, undefined]
|
|
67
|
+
const prop = properties[name]
|
|
68
|
+
const propType = prop?.type
|
|
69
|
+
if (propType === 'boolean') {
|
|
70
|
+
args[name] = !isNegated
|
|
71
|
+
continue
|
|
72
|
+
}
|
|
73
|
+
if (isNegated) {
|
|
74
|
+
throw new Error(`--no-${name} is only valid on boolean flags`)
|
|
75
|
+
}
|
|
76
|
+
const value = eqValue ?? argv[++index]
|
|
77
|
+
if (value === undefined) {
|
|
78
|
+
throw new Error(`--${name} requires a value`)
|
|
79
|
+
}
|
|
80
|
+
if (propType === 'number' || propType === 'integer') {
|
|
81
|
+
const n = Number(value)
|
|
82
|
+
if (Number.isNaN(n)) {
|
|
83
|
+
throw new Error(`--${name} expects a number, got ${value}`)
|
|
84
|
+
}
|
|
85
|
+
args[name] = n
|
|
86
|
+
continue
|
|
87
|
+
}
|
|
88
|
+
if (propType === 'array') {
|
|
89
|
+
const existing = args[name]
|
|
90
|
+
args[name] = Array.isArray(existing) ? [...existing, value] : [value]
|
|
91
|
+
continue
|
|
92
|
+
}
|
|
93
|
+
args[name] = value
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return Object.keys(args).length === 0 ? undefined : args
|
|
97
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { CliManifest } from './types/CliManifest.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Top-level help (no subcommand) lists every available command with a
|
|
5
|
+
one-line summary. Per-command help (`<cmd> --help`) prints the flags
|
|
6
|
+
derived from the command's JSON Schema. Output goes to stdout in both
|
|
7
|
+
cases; the caller exits zero after printing.
|
|
8
|
+
*/
|
|
9
|
+
export function printTopLevelHelp(
|
|
10
|
+
programName: string,
|
|
11
|
+
manifest: CliManifest,
|
|
12
|
+
banner = '',
|
|
13
|
+
footer = '',
|
|
14
|
+
): void {
|
|
15
|
+
if (banner.trim()) {
|
|
16
|
+
console.log(banner.replace(/\n$/, ''))
|
|
17
|
+
console.log('')
|
|
18
|
+
}
|
|
19
|
+
const names = Object.keys(manifest).toSorted()
|
|
20
|
+
console.log(`usage: ${programName} <command> [--flags]\n`)
|
|
21
|
+
console.log('commands:')
|
|
22
|
+
for (const name of names) {
|
|
23
|
+
const entry = manifest[name]
|
|
24
|
+
if (!entry) {
|
|
25
|
+
continue
|
|
26
|
+
}
|
|
27
|
+
console.log(` ${name.padEnd(20)} ${entry.method} ${entry.url}`)
|
|
28
|
+
}
|
|
29
|
+
console.log(`\n --help, -h show this help`)
|
|
30
|
+
console.log(` <command> --help show help for a specific command`)
|
|
31
|
+
console.log(`\nenv:`)
|
|
32
|
+
console.log(` APP_URL remote server URL (unset → in-process)`)
|
|
33
|
+
console.log(` APP_TOKEN sent as Authorization: Bearer <value>`)
|
|
34
|
+
if (footer.trim()) {
|
|
35
|
+
console.log('')
|
|
36
|
+
console.log(footer.replace(/\n$/, ''))
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function printCommandHelp(programName: string, name: string, manifest: CliManifest): void {
|
|
41
|
+
const entry = manifest[name]
|
|
42
|
+
if (!entry) {
|
|
43
|
+
console.log(`unknown command: ${name}`)
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
console.log(`usage: ${programName} ${name} [--flags]\n`)
|
|
47
|
+
console.log(` ${entry.method} ${entry.url}\n`)
|
|
48
|
+
const schema = entry.jsonSchema
|
|
49
|
+
const properties =
|
|
50
|
+
(schema?.properties as
|
|
51
|
+
| Record<string, { type?: string; description?: string }>
|
|
52
|
+
| undefined) ?? {}
|
|
53
|
+
const required = new Set((schema?.required as string[] | undefined) ?? [])
|
|
54
|
+
if (Object.keys(properties).length === 0) {
|
|
55
|
+
console.log('flags: (none)')
|
|
56
|
+
} else {
|
|
57
|
+
console.log('flags:')
|
|
58
|
+
for (const [key, value] of Object.entries(properties)) {
|
|
59
|
+
const tag =
|
|
60
|
+
value.type === 'boolean'
|
|
61
|
+
? `--${key} / --no-${key}`
|
|
62
|
+
: `--${key} <${value.type ?? 'value'}>`
|
|
63
|
+
const requiredTag = required.has(key) ? ' (required)' : ''
|
|
64
|
+
const description = value.description ? ` — ${value.description}` : ''
|
|
65
|
+
console.log(` ${tag.padEnd(28)}${requiredTag}${description}`)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
console.log('\n --json <object> full args bag as JSON (overrides flags)')
|
|
69
|
+
console.log(' (stdin) pipe a JSON object as the args bag')
|
|
70
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { createClient } from './createClient.ts'
|
|
2
|
+
import { loadEnvFromBinaryDir } from './loadEnvFromBinaryDir.ts'
|
|
3
|
+
import { parseArgvForRpc } from './parseArgvForRpc.ts'
|
|
4
|
+
import { printCommandHelp, printTopLevelHelp } from './printHelp.ts'
|
|
5
|
+
import type { CliManifest } from './types/CliManifest.ts'
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
Top-level CLI driver. Loaded by the standalone binary's entry; expects
|
|
9
|
+
the bundler-emitted manifest plus the raw argv tail. Flow:
|
|
10
|
+
|
|
11
|
+
1. Read .env next to the binary so APP_URL / APP_TOKEN are picked up
|
|
12
|
+
for the common install-tarball case.
|
|
13
|
+
2. Pull the first positional as the subcommand.
|
|
14
|
+
3. --help and `<cmd> --help` print and exit zero.
|
|
15
|
+
4. Otherwise parse the rest of the argv against the manifest entry's
|
|
16
|
+
JSON Schema and dispatch via createClient.
|
|
17
|
+
|
|
18
|
+
Streaming responses aren't a thing at this layer yet — every RPC tool
|
|
19
|
+
goes through decodeResponse (text/JSON). Streaming verbs (jsonl/sse)
|
|
20
|
+
will be added when the CLI grows watch/publish subcommands for sockets.
|
|
21
|
+
*/
|
|
22
|
+
export async function runCli({
|
|
23
|
+
programName,
|
|
24
|
+
manifest,
|
|
25
|
+
banner = '',
|
|
26
|
+
footer = '',
|
|
27
|
+
argv,
|
|
28
|
+
}: {
|
|
29
|
+
programName: string
|
|
30
|
+
manifest: CliManifest
|
|
31
|
+
banner?: string
|
|
32
|
+
footer?: string
|
|
33
|
+
argv: string[]
|
|
34
|
+
}): Promise<number> {
|
|
35
|
+
await loadEnvFromBinaryDir()
|
|
36
|
+
|
|
37
|
+
const first = argv[0]
|
|
38
|
+
if (!first || first === '--help' || first === '-h') {
|
|
39
|
+
printTopLevelHelp(programName, manifest, banner, footer)
|
|
40
|
+
return 0
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (argv.includes('--help') || argv.includes('-h')) {
|
|
44
|
+
printCommandHelp(programName, first, manifest)
|
|
45
|
+
return 0
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const entry = manifest[first]
|
|
49
|
+
if (!entry) {
|
|
50
|
+
console.error(
|
|
51
|
+
`${programName}: unknown command "${first}" — run \`${programName} --help\` for the list`,
|
|
52
|
+
)
|
|
53
|
+
return 1
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let args: Record<string, unknown> | undefined
|
|
57
|
+
try {
|
|
58
|
+
args = await parseArgvForRpc(argv.slice(1), entry.jsonSchema)
|
|
59
|
+
} catch (error) {
|
|
60
|
+
console.error(`${programName}: ${error instanceof Error ? error.message : String(error)}`)
|
|
61
|
+
return 1
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const appUrl = process.env.APP_URL
|
|
65
|
+
const appToken = process.env.APP_TOKEN
|
|
66
|
+
const client = createClient({ url: appUrl, token: appToken, manifest })
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
const fn = (client as Record<string, (args?: unknown) => Promise<unknown>>)[first]
|
|
70
|
+
if (!fn) {
|
|
71
|
+
console.error(`${programName}: command "${first}" not in client`)
|
|
72
|
+
return 1
|
|
73
|
+
}
|
|
74
|
+
const result = await fn(args)
|
|
75
|
+
if (typeof result === 'string') {
|
|
76
|
+
process.stdout.write(result)
|
|
77
|
+
if (!result.endsWith('\n')) {
|
|
78
|
+
process.stdout.write('\n')
|
|
79
|
+
}
|
|
80
|
+
} else if (result !== undefined) {
|
|
81
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)
|
|
82
|
+
}
|
|
83
|
+
return 0
|
|
84
|
+
} catch (error) {
|
|
85
|
+
console.error(`${programName}: ${error instanceof Error ? error.message : String(error)}`)
|
|
86
|
+
return 1
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { CliManifestEntry } from './CliManifestEntry.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Map from rpc export-name (e.g. "getReport") to its manifest entry. Built
|
|
5
|
+
by the bundler from the same verbRegistry MCP consumes; entries are
|
|
6
|
+
emitted only for rpcs with `clients.cli: true`. The CLI binary and any
|
|
7
|
+
programmatic createClient caller read this to dispatch calls.
|
|
8
|
+
*/
|
|
9
|
+
export type CliManifest = Record<string, CliManifestEntry>
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { HttpVerb } from '../../server/rpc/types/HttpVerb.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Per-RPC manifest entry baked into the standalone CLI binary by the
|
|
5
|
+
bundler. Carries enough info to make the right HTTP request without
|
|
6
|
+
importing the handler module (which the thin build doesn't ship).
|
|
7
|
+
*/
|
|
8
|
+
export type CliManifestEntry = {
|
|
9
|
+
method: HttpVerb
|
|
10
|
+
url: string
|
|
11
|
+
jsonSchema?: Record<string, unknown>
|
|
12
|
+
}
|