@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,18 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Generates the `.env` file content shipped alongside the CLI binary in
|
|
3
|
+
the download tarball. APP_URL is always present (derived from the
|
|
4
|
+
inbound request's origin); APP_TOKEN is included only when the inbound
|
|
5
|
+
request carried an Authorization: Bearer header, so an authenticated
|
|
6
|
+
download bakes the caller's credential into the binary's env.
|
|
7
|
+
|
|
8
|
+
Tokens forward verbatim — the framework doesn't issue or refresh; the
|
|
9
|
+
user's auth code at the actual RPC endpoints validates whatever value
|
|
10
|
+
arrives back in subsequent calls.
|
|
11
|
+
*/
|
|
12
|
+
export function buildEnvContent(appUrl: string, bearerToken: string | undefined): string {
|
|
13
|
+
const lines = [`APP_URL=${appUrl}`]
|
|
14
|
+
if (bearerToken) {
|
|
15
|
+
lines.push(`APP_TOKEN=${bearerToken}`)
|
|
16
|
+
}
|
|
17
|
+
return `${lines.join('\n')}\n`
|
|
18
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Minimal ustar tarball writer. Each entry is a 512-byte header followed
|
|
3
|
+
by the content padded to a 512-byte boundary; the archive ends with two
|
|
4
|
+
512-byte zero blocks. After assembly the buffer is gzipped via
|
|
5
|
+
Bun.gzipSync — no external `tar` invocation, no extra deps.
|
|
6
|
+
|
|
7
|
+
Format constraints:
|
|
8
|
+
- File names ≤ 100 bytes (we never write longer paths).
|
|
9
|
+
- Numeric fields are zero-padded octal ASCII strings (POSIX rule).
|
|
10
|
+
- Checksum is the sum of all header bytes treating the checksum
|
|
11
|
+
field as spaces; encoded as 6 octal digits + NUL + space.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
type TarEntry = {
|
|
15
|
+
name: string
|
|
16
|
+
content: Uint8Array
|
|
17
|
+
mode?: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const BLOCK = 512
|
|
21
|
+
const ENC = new TextEncoder()
|
|
22
|
+
|
|
23
|
+
function writeString(buf: Uint8Array, offset: number, length: number, value: string): void {
|
|
24
|
+
const bytes = ENC.encode(value)
|
|
25
|
+
buf.set(bytes.subarray(0, Math.min(bytes.length, length)), offset)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function writeOctal(buf: Uint8Array, offset: number, length: number, value: number): void {
|
|
29
|
+
// length-1 octal digits + trailing NUL
|
|
30
|
+
const oct = value.toString(8).padStart(length - 1, '0')
|
|
31
|
+
writeString(buf, offset, length - 1, oct)
|
|
32
|
+
buf[offset + length - 1] = 0
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function buildHeader(entry: TarEntry): Uint8Array {
|
|
36
|
+
const header = new Uint8Array(BLOCK)
|
|
37
|
+
writeString(header, 0, 100, entry.name)
|
|
38
|
+
writeOctal(header, 100, 8, entry.mode ?? 0o644)
|
|
39
|
+
writeOctal(header, 108, 8, 0)
|
|
40
|
+
writeOctal(header, 116, 8, 0)
|
|
41
|
+
writeOctal(header, 124, 12, entry.content.length)
|
|
42
|
+
writeOctal(header, 136, 12, Math.floor(Date.now() / 1000))
|
|
43
|
+
header.fill(0x20, 148, 156)
|
|
44
|
+
header[156] = 0x30 // '0' = regular file
|
|
45
|
+
writeString(header, 257, 6, 'ustar\0')
|
|
46
|
+
writeString(header, 263, 2, '00')
|
|
47
|
+
// Checksum: sum of all bytes with checksum field treated as spaces.
|
|
48
|
+
let sum = 0
|
|
49
|
+
for (let index = 0; index < BLOCK; index++) {
|
|
50
|
+
sum += header[index] ?? 0
|
|
51
|
+
}
|
|
52
|
+
writeOctal(header, 148, 7, sum)
|
|
53
|
+
header[155] = 0x20 // trailing space after checksum digits
|
|
54
|
+
return header
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/*
|
|
58
|
+
Builds a gzipped tarball from the given entries and returns the bytes.
|
|
59
|
+
Sized eagerly (sum of headers + padded contents + 2 trailing blocks).
|
|
60
|
+
*/
|
|
61
|
+
export function createTarGz(entries: TarEntry[]): Uint8Array {
|
|
62
|
+
let totalSize = BLOCK * 2 // trailing zero blocks
|
|
63
|
+
for (const entry of entries) {
|
|
64
|
+
totalSize += BLOCK
|
|
65
|
+
totalSize += Math.ceil(entry.content.length / BLOCK) * BLOCK
|
|
66
|
+
}
|
|
67
|
+
const tar = new Uint8Array(totalSize)
|
|
68
|
+
let offset = 0
|
|
69
|
+
for (const entry of entries) {
|
|
70
|
+
tar.set(buildHeader(entry), offset)
|
|
71
|
+
offset += BLOCK
|
|
72
|
+
tar.set(entry.content, offset)
|
|
73
|
+
offset += Math.ceil(entry.content.length / BLOCK) * BLOCK
|
|
74
|
+
}
|
|
75
|
+
return Bun.gzipSync(tar)
|
|
76
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { existsSync, statSync } from 'node:fs'
|
|
2
|
+
import { NO_STORE } from '../../shared/cacheControlValues.ts'
|
|
3
|
+
import { log } from '../../shared/log.ts'
|
|
4
|
+
import { normalizeTarget } from '../../shared/normalizeTarget.ts'
|
|
5
|
+
import { buildEnvContent } from './buildEnvContent.ts'
|
|
6
|
+
import { createTarGz } from './createTarGz.ts'
|
|
7
|
+
import { maxSourceMtime } from './maxSourceMtime.ts'
|
|
8
|
+
|
|
9
|
+
/*
|
|
10
|
+
Process-wide per-platform build coalescing. Two concurrent curls for
|
|
11
|
+
the same /__belte/cli/<platform> share one promise; the later requests
|
|
12
|
+
await the same one the first installed. The promise both runs the
|
|
13
|
+
freshness check AND the rebuild, so the map insertion is synchronous
|
|
14
|
+
relative to the first request's entry into the function — no window
|
|
15
|
+
between an `await` and `pendingBuilds.set` for a second concurrent
|
|
16
|
+
request to slip through and fire its own buildCli against the same
|
|
17
|
+
output paths.
|
|
18
|
+
*/
|
|
19
|
+
const pendingBuilds = new Map<string, Promise<string | undefined>>()
|
|
20
|
+
|
|
21
|
+
async function ensurePlatformBinary(
|
|
22
|
+
platform: string,
|
|
23
|
+
programName: string,
|
|
24
|
+
cwd: string,
|
|
25
|
+
): Promise<string | undefined> {
|
|
26
|
+
const existing = pendingBuilds.get(platform)
|
|
27
|
+
if (existing) {
|
|
28
|
+
return existing
|
|
29
|
+
}
|
|
30
|
+
const promise = computeBinary(platform, programName, cwd)
|
|
31
|
+
pendingBuilds.set(platform, promise)
|
|
32
|
+
/*
|
|
33
|
+
Drop the entry after settlement so a later request rebuilds if the
|
|
34
|
+
source has changed again. Identity-guard so a still-pending entry
|
|
35
|
+
installed by a follow-up request isn't evicted by ours.
|
|
36
|
+
*/
|
|
37
|
+
promise.finally(() => {
|
|
38
|
+
if (pendingBuilds.get(platform) === promise) {
|
|
39
|
+
pendingBuilds.delete(platform)
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
return promise
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function computeBinary(
|
|
46
|
+
platform: string,
|
|
47
|
+
programName: string,
|
|
48
|
+
cwd: string,
|
|
49
|
+
): Promise<string | undefined> {
|
|
50
|
+
const binaryPath = `${cwd}/dist/cli-thin/${platform}/${programName}`
|
|
51
|
+
/*
|
|
52
|
+
On-disk binary is fresh when it exists AND its mtime beats the
|
|
53
|
+
newest rpc/socket source mtime. The mtime check catches the
|
|
54
|
+
common dev iteration where the user edits an rpc handler but
|
|
55
|
+
didn't run `belte cli` again. Other source paths (project lib,
|
|
56
|
+
transitive imports) fall back to manual rebuild.
|
|
57
|
+
*/
|
|
58
|
+
if (existsSync(binaryPath)) {
|
|
59
|
+
const binaryMtime = statSync(binaryPath).mtimeMs
|
|
60
|
+
const sourceMtime = await maxSourceMtime(cwd)
|
|
61
|
+
if (binaryMtime >= sourceMtime) {
|
|
62
|
+
return binaryPath
|
|
63
|
+
}
|
|
64
|
+
log.info(`thin cli for ${platform} is stale — rebuilding`)
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
log.info(`lazy-building thin cli for ${platform}…`)
|
|
68
|
+
// Lazy-import buildCli so the build pipeline isn't pulled into
|
|
69
|
+
// production processes that never serve a download.
|
|
70
|
+
const { buildCli } = await import('../../../buildCli.ts')
|
|
71
|
+
await buildCli({
|
|
72
|
+
cwd,
|
|
73
|
+
platforms: [normalizeTarget(platform)],
|
|
74
|
+
thin: true,
|
|
75
|
+
})
|
|
76
|
+
return existsSync(binaryPath) ? binaryPath : undefined
|
|
77
|
+
} catch (error) {
|
|
78
|
+
log.error(error)
|
|
79
|
+
return undefined
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/*
|
|
84
|
+
Handles GET /__belte/cli/<platform> — streams a gzipped tarball
|
|
85
|
+
containing the platform-specific thin binary + a `.env` carrying
|
|
86
|
+
APP_URL (and APP_TOKEN if the inbound request was authenticated).
|
|
87
|
+
|
|
88
|
+
Thin binaries live at `dist/cli-thin/<platform>/<programName>`
|
|
89
|
+
(produced by `belte cli` with APP_URL set). Missing platforms produce
|
|
90
|
+
404 — the install script reports it, doesn't try to fall back.
|
|
91
|
+
*/
|
|
92
|
+
export async function handleCliDownload(
|
|
93
|
+
request: Request,
|
|
94
|
+
platform: string,
|
|
95
|
+
programName: string,
|
|
96
|
+
cwd: string,
|
|
97
|
+
): Promise<Response> {
|
|
98
|
+
const binaryPath = await ensurePlatformBinary(platform, programName, cwd)
|
|
99
|
+
if (!binaryPath) {
|
|
100
|
+
return new Response(`unknown platform: ${platform}`, {
|
|
101
|
+
status: 404,
|
|
102
|
+
headers: { 'Cache-Control': NO_STORE },
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
const url = new URL(request.url)
|
|
106
|
+
const appUrl = `${url.protocol}//${url.host}`
|
|
107
|
+
const auth = request.headers.get('authorization')
|
|
108
|
+
const bearer =
|
|
109
|
+
auth && auth.toLowerCase().startsWith('bearer ') ? auth.slice('bearer '.length) : undefined
|
|
110
|
+
const envContent = buildEnvContent(appUrl, bearer)
|
|
111
|
+
|
|
112
|
+
const binaryBytes = await Bun.file(binaryPath).bytes()
|
|
113
|
+
const archive = createTarGz([
|
|
114
|
+
{ name: programName, content: binaryBytes, mode: 0o755 },
|
|
115
|
+
{ name: '.env', content: new TextEncoder().encode(envContent), mode: 0o644 },
|
|
116
|
+
])
|
|
117
|
+
return new Response(archive, {
|
|
118
|
+
headers: {
|
|
119
|
+
'Content-Type': 'application/gzip',
|
|
120
|
+
'Content-Disposition': `attachment; filename="${programName}-${platform}.tar.gz"`,
|
|
121
|
+
'Cache-Control': NO_STORE,
|
|
122
|
+
},
|
|
123
|
+
})
|
|
124
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { NO_STORE } from '../../shared/cacheControlValues.ts'
|
|
2
|
+
import { installScript } from './installScript.ts'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
Handles GET /__belte/cli — returns the platform-detecting shell script.
|
|
6
|
+
Authoritative URL for the tarball is derived from the inbound request
|
|
7
|
+
(so the script's curl line points at whatever host the user reached us
|
|
8
|
+
on). Program name is the bundler-emitted `belte:cli-name` value.
|
|
9
|
+
*/
|
|
10
|
+
export function handleCliInstall(request: Request, programName: string): Response {
|
|
11
|
+
const url = new URL(request.url)
|
|
12
|
+
const appUrl = `${url.protocol}//${url.host}`
|
|
13
|
+
const script = installScript(appUrl, programName)
|
|
14
|
+
return new Response(script, {
|
|
15
|
+
headers: {
|
|
16
|
+
'Content-Type': 'text/x-shellscript; charset=utf-8',
|
|
17
|
+
'Cache-Control': NO_STORE,
|
|
18
|
+
},
|
|
19
|
+
})
|
|
20
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/*
|
|
2
|
+
The shell script returned by `GET /__belte/cli` (no platform). Detects
|
|
3
|
+
uname OS + arch, normalises common arch aliases, then curls the right
|
|
4
|
+
platform-specific tarball and extracts it into `$BELTE_INSTALL_DIR`
|
|
5
|
+
(default `~/.local/bin`). The tarball already contains the `.env` next
|
|
6
|
+
to the binary — no separate config write step in the script.
|
|
7
|
+
|
|
8
|
+
The script is rendered server-side so `<APP_URL>` is the request's own
|
|
9
|
+
origin and the embedded curl URL needs no escaping or quoting beyond
|
|
10
|
+
basic shell hygiene.
|
|
11
|
+
*/
|
|
12
|
+
export function installScript(appUrl: string, programName: string): string {
|
|
13
|
+
return `#!/usr/bin/env sh
|
|
14
|
+
set -e
|
|
15
|
+
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
|
|
16
|
+
case "$(uname -m)" in
|
|
17
|
+
x86_64|amd64) ARCH=x64 ;;
|
|
18
|
+
aarch64|arm64) ARCH=arm64 ;;
|
|
19
|
+
*) echo "unsupported architecture: $(uname -m)" >&2 ; exit 1 ;;
|
|
20
|
+
esac
|
|
21
|
+
INSTALL_DIR="\${BELTE_INSTALL_DIR:-$HOME/.local/bin}"
|
|
22
|
+
mkdir -p "$INSTALL_DIR"
|
|
23
|
+
URL="${appUrl.replace(/\/$/, '')}/__belte/cli/\${OS}-\${ARCH}"
|
|
24
|
+
echo "installing ${programName} from $URL into $INSTALL_DIR"
|
|
25
|
+
curl -fsSL "$URL" | tar -xz -C "$INSTALL_DIR"
|
|
26
|
+
echo "installed: $INSTALL_DIR/${programName}"
|
|
27
|
+
echo "ensure $INSTALL_DIR is in your PATH"
|
|
28
|
+
`
|
|
29
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { existsSync, statSync } from 'node:fs'
|
|
2
|
+
import { Glob } from 'bun'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
Returns the most-recent mtime across every rpc + socket source file in
|
|
6
|
+
the project, or 0 when both directories are absent. The lazy CLI
|
|
7
|
+
download path compares this to the binary's mtime to decide whether to
|
|
8
|
+
rebuild — covers the common dev iteration of "user edited an rpc
|
|
9
|
+
handler" without needing to scan transitively-imported modules.
|
|
10
|
+
*/
|
|
11
|
+
export async function maxSourceMtime(cwd: string): Promise<number> {
|
|
12
|
+
const roots = [`${cwd}/src/server/rpc`, `${cwd}/src/server/sockets`]
|
|
13
|
+
let newest = 0
|
|
14
|
+
for (const root of roots) {
|
|
15
|
+
if (!existsSync(root)) {
|
|
16
|
+
continue
|
|
17
|
+
}
|
|
18
|
+
const files = new Glob('**/*.ts').scan({ cwd: root, onlyFiles: true })
|
|
19
|
+
for await (const file of files) {
|
|
20
|
+
const stat = statSync(`${root}/${file}`)
|
|
21
|
+
if (stat.mtimeMs > newest) {
|
|
22
|
+
newest = stat.mtimeMs
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return newest
|
|
27
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { NO_STORE } from '../shared/cacheControlValues.ts'
|
|
2
|
+
import type { TypedResponse } from './rpc/types/TypedResponse.ts'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
Plain-text error Response — clearer than constructing a Response by
|
|
6
|
+
hand with a status and a text body, and shaped so the client's
|
|
7
|
+
HttpError carries the message verbatim (`HttpError.response.text()`
|
|
8
|
+
returns the message, no parsing).
|
|
9
|
+
|
|
10
|
+
if (!order) return error(404, 'order not found')
|
|
11
|
+
|
|
12
|
+
`message` defaults to the status's standard reason phrase when
|
|
13
|
+
omitted (e.g. `error(404)` body = 'Not Found'). The body is
|
|
14
|
+
text/plain so intermediaries don't try to render or sniff it.
|
|
15
|
+
|
|
16
|
+
To short-circuit a handler instead of returning, `throw new Error(...)`
|
|
17
|
+
or `throw new HttpError(error(...))` — the framework's `app.handleError`
|
|
18
|
+
hook catches thrown errors. This helper deliberately returns a Response
|
|
19
|
+
rather than throwing one so a single `return error(...)` is the
|
|
20
|
+
expected pattern, with the same control flow as `return json(...)`.
|
|
21
|
+
*/
|
|
22
|
+
const STATUS_TEXT: Record<number, string> = {
|
|
23
|
+
400: 'Bad Request',
|
|
24
|
+
401: 'Unauthorized',
|
|
25
|
+
403: 'Forbidden',
|
|
26
|
+
404: 'Not Found',
|
|
27
|
+
405: 'Method Not Allowed',
|
|
28
|
+
409: 'Conflict',
|
|
29
|
+
410: 'Gone',
|
|
30
|
+
422: 'Unprocessable Content',
|
|
31
|
+
429: 'Too Many Requests',
|
|
32
|
+
500: 'Internal Server Error',
|
|
33
|
+
501: 'Not Implemented',
|
|
34
|
+
502: 'Bad Gateway',
|
|
35
|
+
503: 'Service Unavailable',
|
|
36
|
+
504: 'Gateway Timeout',
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/*
|
|
40
|
+
Body type is `never` because `error()` only travels the non-2xx path on
|
|
41
|
+
the wire — the caller's `await fn(args)` throws `HttpError` and never
|
|
42
|
+
resolves to this response's body. Returning a TypedResponse<never> lets
|
|
43
|
+
the union of branches in a handler narrow to whatever the success
|
|
44
|
+
branch carries (`TypedResponse<{user}> | TypedResponse<never>` → Return
|
|
45
|
+
= {user}).
|
|
46
|
+
*/
|
|
47
|
+
export function error(status: number, message?: string): TypedResponse<never> {
|
|
48
|
+
const body = message ?? STATUS_TEXT[status] ?? `HTTP ${status}`
|
|
49
|
+
return new Response(body, {
|
|
50
|
+
status,
|
|
51
|
+
headers: {
|
|
52
|
+
'Content-Type': 'text/plain; charset=utf-8',
|
|
53
|
+
'Cache-Control': NO_STORE,
|
|
54
|
+
},
|
|
55
|
+
}) as TypedResponse<never>
|
|
56
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { NO_STORE } from '../shared/cacheControlValues.ts'
|
|
2
|
+
import type { TypedResponse } from './rpc/types/TypedResponse.ts'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
JSON Response with rpc-friendly defaults — same shape as
|
|
6
|
+
`Response.json(data, init)`, except `Cache-Control: no-store` is set
|
|
7
|
+
unless the caller overrides it. Intermediary caches (browsers, CDNs,
|
|
8
|
+
shared proxies) shouldn't cache rpc replies by default; the framework's
|
|
9
|
+
own per-request cache handles in-process dedupe.
|
|
10
|
+
|
|
11
|
+
export const getOrder = GET<{ id: string }>(async ({ id }) =>
|
|
12
|
+
json(await db.getOrder(id)),
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
The return type carries `T` as a phantom brand so the verb helper can
|
|
16
|
+
infer the caller-facing `Return` from the handler body — no need to
|
|
17
|
+
annotate `GET<Args, Return>` just to type the response shape.
|
|
18
|
+
|
|
19
|
+
For non-default cache policy pass `init.headers`; explicit
|
|
20
|
+
`cache-control` wins over the default.
|
|
21
|
+
*/
|
|
22
|
+
export function json<T>(data: T, init?: ResponseInit): TypedResponse<T> {
|
|
23
|
+
const headers = new Headers(init?.headers)
|
|
24
|
+
if (!headers.has('cache-control')) {
|
|
25
|
+
headers.set('cache-control', NO_STORE)
|
|
26
|
+
}
|
|
27
|
+
return Response.json(data, { ...init, headers }) as TypedResponse<T>
|
|
28
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Wraps an AsyncIterable<Frame> in a Response whose body is JSON Lines
|
|
3
|
+
(application/jsonl) — one JSON value per line, terminated by `\n`. Used
|
|
4
|
+
inside an rpc handler to turn a generator into a streaming HTTP response
|
|
5
|
+
that `subscribe(fn.stream)(args)` consumes frame-by-frame on the client.
|
|
6
|
+
|
|
7
|
+
export const orderFeed = GET<Args>((args) =>
|
|
8
|
+
jsonl(async function* () {
|
|
9
|
+
for await (const order of db.watchOrders(args)) yield order
|
|
10
|
+
}())
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
Cancellation flows from the consumer through ReadableStream's `cancel`
|
|
14
|
+
into `iter.return()` so the handler's `for await` exits via its normal
|
|
15
|
+
control path (DB cursors, file handles, etc. get to release in finally).
|
|
16
|
+
|
|
17
|
+
Errors thrown by the generator are emitted as a final
|
|
18
|
+
`{"$error":"<message>"}` line before the stream closes. The convention
|
|
19
|
+
keeps the format JSON-safe and lets the consumer distinguish "stream
|
|
20
|
+
ended cleanly" from "handler threw" without a side-channel. The full
|
|
21
|
+
error is logged server-side via the framework's error handler — only the
|
|
22
|
+
message crosses the wire.
|
|
23
|
+
*/
|
|
24
|
+
import { NO_STORE } from '../shared/cacheControlValues.ts'
|
|
25
|
+
import type { TypedResponse } from './rpc/types/TypedResponse.ts'
|
|
26
|
+
import { streamFromIterator } from './runtime/streamFromIterator.ts'
|
|
27
|
+
|
|
28
|
+
export function jsonl<Frame>(iterable: AsyncIterable<Frame>): TypedResponse<Frame> {
|
|
29
|
+
const body = streamFromIterator(iterable, {
|
|
30
|
+
encodeFrame: (value) => `${JSON.stringify(value)}\n`,
|
|
31
|
+
encodeError: (message) => `${JSON.stringify({ $error: message })}\n`,
|
|
32
|
+
})
|
|
33
|
+
return new Response(body, {
|
|
34
|
+
headers: {
|
|
35
|
+
'Content-Type': 'application/jsonl; charset=utf-8',
|
|
36
|
+
'Cache-Control': NO_STORE,
|
|
37
|
+
'X-Content-Type-Options': 'nosniff',
|
|
38
|
+
},
|
|
39
|
+
}) as TypedResponse<Frame>
|
|
40
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Prompt } from './prompts/types/Prompt.ts'
|
|
2
|
+
import type { PromptOptions } from './prompts/types/PromptOptions.ts'
|
|
3
|
+
import type { StandardSchemaV1 } from './rpc/types/StandardSchemaV1.ts'
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
Declares an MCP prompt inside a file under `src/server/prompts/`. Each
|
|
7
|
+
file contains exactly one export, named after the file (e.g.
|
|
8
|
+
`summarize.ts` → `export const summarize = prompt(...)`). The bundler
|
|
9
|
+
reads the export name from the filename and the prompt name from the file
|
|
10
|
+
path under `src/server/prompts/`, then rewrites this call to bind the name
|
|
11
|
+
into definePrompt.
|
|
12
|
+
|
|
13
|
+
`render(args)` returns the messages MCP hands back for `prompts/get`:
|
|
14
|
+
either a bare string (one user message) or an explicit message array.
|
|
15
|
+
When `schema` is set, `Args` infers from `InferOutput<Schema>`, incoming
|
|
16
|
+
arguments validate against it, and MCP advertises the argument list in
|
|
17
|
+
`prompts/list`.
|
|
18
|
+
|
|
19
|
+
This function exists only for the type signature; calling it directly
|
|
20
|
+
means the bundler plugin didn't process the file, which throws.
|
|
21
|
+
*/
|
|
22
|
+
export function prompt<Schema extends StandardSchemaV1>(
|
|
23
|
+
opts: PromptOptions<StandardSchemaV1.InferOutput<Schema>> & { schema: Schema },
|
|
24
|
+
): Prompt<StandardSchemaV1.InferOutput<Schema>>
|
|
25
|
+
export function prompt<Args = Record<string, string>>(opts: PromptOptions<Args>): Prompt<Args>
|
|
26
|
+
export function prompt(_opts: PromptOptions): Prompt {
|
|
27
|
+
throw new Error(
|
|
28
|
+
'[belte] `prompt(...)` was called outside a prompts module — the prompt helper is only valid as the value of `export const <filename> = ...` inside a file under src/server/prompts/',
|
|
29
|
+
)
|
|
30
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { registerPrompt } from './registerPrompt.ts'
|
|
2
|
+
import type { Prompt } from './types/Prompt.ts'
|
|
3
|
+
import type { PromptOptions } from './types/PromptOptions.ts'
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
Builds a Prompt from a name + options. The bundler rewrites every
|
|
7
|
+
`export const NAME = prompt(opts)` inside `src/server/prompts/<file>.ts`
|
|
8
|
+
into `__belteDefinePrompt__("<name>", opts)` so the file path becomes the
|
|
9
|
+
prompt's identity. Registers itself so the MCP dispatcher can enumerate
|
|
10
|
+
and render it.
|
|
11
|
+
*/
|
|
12
|
+
export function definePrompt(name: string, opts: PromptOptions): Prompt {
|
|
13
|
+
const self: Prompt = {
|
|
14
|
+
name,
|
|
15
|
+
description: opts.description,
|
|
16
|
+
render: opts.render,
|
|
17
|
+
}
|
|
18
|
+
registerPrompt({ prompt: self, schema: opts.schema, jsonSchema: opts.jsonSchema })
|
|
19
|
+
return self
|
|
20
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { PromptRegistryEntry } from './types/PromptRegistryEntry.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Process-wide registry of every prompt declared in the app. definePrompt
|
|
5
|
+
inserts on first construction (eagerly when the registry loader walks the
|
|
6
|
+
prompts manifest at MCP boot). The MCP server reads this to build its
|
|
7
|
+
`prompts/list` + `prompts/get` responses.
|
|
8
|
+
*/
|
|
9
|
+
export const promptRegistry = new Map<string, PromptRegistryEntry>()
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { PromptMessage } from './PromptMessage.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
An MCP prompt declared once with `prompt(opts)` inside a file under
|
|
5
|
+
`src/server/prompts/`. The bundler stamps in the `name` from the file
|
|
6
|
+
path; `render(args)` produces the messages returned by `prompts/get`.
|
|
7
|
+
Prompts are MCP-only — there is no client-side counterpart, so the
|
|
8
|
+
shape carries no ClientFlags.
|
|
9
|
+
*/
|
|
10
|
+
export type Prompt<Args = Record<string, string>> = {
|
|
11
|
+
readonly name: string
|
|
12
|
+
readonly description: string | undefined
|
|
13
|
+
render(args: Args): PromptMessage[] | string | Promise<PromptMessage[] | string>
|
|
14
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/*
|
|
2
|
+
A single message in an MCP prompt's rendered output. `prompt({ render })`
|
|
3
|
+
returns either a bare string (sugar for one `user` message) or an array
|
|
4
|
+
of these. The dispatcher maps each into the MCP `prompts/get` wire shape
|
|
5
|
+
({ role, content: { type: 'text', text } }).
|
|
6
|
+
*/
|
|
7
|
+
export type PromptMessage = {
|
|
8
|
+
role: 'user' | 'assistant'
|
|
9
|
+
text: string
|
|
10
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from '../../rpc/types/StandardSchemaV1.ts'
|
|
2
|
+
import type { PromptMessage } from './PromptMessage.ts'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
Server-side options passed when declaring a prompt via `prompt(opts)`.
|
|
6
|
+
MCP prompts are read-only templates: `render(args)` turns the caller's
|
|
7
|
+
arguments into one or more chat messages. The optional Standard Schema
|
|
8
|
+
both validates incoming arguments and supplies the argument list MCP
|
|
9
|
+
advertises in `prompts/list` (top-level properties + required array).
|
|
10
|
+
All of this is server-only — prompts are never imported by client code.
|
|
11
|
+
*/
|
|
12
|
+
export type PromptOptions<Args = Record<string, string>> = {
|
|
13
|
+
description?: string
|
|
14
|
+
schema?: StandardSchemaV1
|
|
15
|
+
jsonSchema?: Record<string, unknown>
|
|
16
|
+
render: (args: Args) => PromptMessage[] | string | Promise<PromptMessage[] | string>
|
|
17
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from '../../rpc/types/StandardSchemaV1.ts'
|
|
2
|
+
import type { Prompt } from './Prompt.ts'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
Per-prompt registry record. The MCP dispatcher enumerates this to build
|
|
6
|
+
`prompts/list` (description + arguments from the schema) and to dispatch
|
|
7
|
+
`prompts/get` (validate args against the schema, then render). Schema +
|
|
8
|
+
jsonSchema stay off the public Prompt shape so the render closure isn't
|
|
9
|
+
burdened with metadata it never reads.
|
|
10
|
+
*/
|
|
11
|
+
export type PromptRegistryEntry = {
|
|
12
|
+
prompt: Prompt
|
|
13
|
+
schema: StandardSchemaV1 | undefined
|
|
14
|
+
jsonSchema: Record<string, unknown> | undefined
|
|
15
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Prompt } from './Prompt.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Manifest of prompt-name → module loader. Produced by the resolver plugin
|
|
5
|
+
from each `.ts` under src/server/prompts/. Each module has exactly one
|
|
6
|
+
named export, a Prompt whose `.name` was stamped in by the bundler
|
|
7
|
+
rewrite. The registry loader imports every module once so the MCP
|
|
8
|
+
dispatcher can enumerate the full prompt surface.
|
|
9
|
+
*/
|
|
10
|
+
export type PromptRoutes = Record<string, () => Promise<Record<string, Prompt>>>
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Redirect Response with belte-friendly ergonomics — accepts relative
|
|
3
|
+
URLs (the platform's `Response.redirect` throws on them), defaults to
|
|
4
|
+
302, and matches the helper-style call site of `json`/`error` for
|
|
5
|
+
visual consistency inside a handler.
|
|
6
|
+
|
|
7
|
+
return redirect('/login') // 302 to /login
|
|
8
|
+
return redirect('/articles/1', 301) // permanent
|
|
9
|
+
return redirect(externalUrl, 307) // preserve method (POST stays POST)
|
|
10
|
+
|
|
11
|
+
Status guidance:
|
|
12
|
+
- 301 — moved permanently (cacheable; browsers may swap method to GET)
|
|
13
|
+
- 302 — found / temporary (default; browsers may swap method to GET)
|
|
14
|
+
- 303 — "after a POST, GET this" (forces GET on the follow-up)
|
|
15
|
+
- 307 — temporary, preserve method
|
|
16
|
+
- 308 — permanent, preserve method
|
|
17
|
+
*/
|
|
18
|
+
import { NO_STORE } from '../shared/cacheControlValues.ts'
|
|
19
|
+
import type { TypedResponse } from './rpc/types/TypedResponse.ts'
|
|
20
|
+
|
|
21
|
+
type RedirectStatus = 301 | 302 | 303 | 307 | 308
|
|
22
|
+
|
|
23
|
+
/*
|
|
24
|
+
Return type is `TypedResponse<never>` for the same reason `error()` is —
|
|
25
|
+
the wire response is a 3xx with no body the caller resolves to, so it
|
|
26
|
+
must not pollute the inferred `Return` of a route that conditionally
|
|
27
|
+
redirects vs returns json.
|
|
28
|
+
*/
|
|
29
|
+
export function redirect(url: string, status: RedirectStatus = 302): TypedResponse<never> {
|
|
30
|
+
return new Response(null, {
|
|
31
|
+
status,
|
|
32
|
+
headers: {
|
|
33
|
+
Location: url,
|
|
34
|
+
'Cache-Control': NO_STORE,
|
|
35
|
+
},
|
|
36
|
+
}) as TypedResponse<never>
|
|
37
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { requestContext } from './runtime/requestContext.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Returns the inbound Request for the current SSR/RPC pass. Implemented as an
|
|
5
|
+
AsyncLocalStorage lookup over the per-request store the server installs at
|
|
6
|
+
the fetch boundary. Throws if called outside a request scope (e.g. from
|
|
7
|
+
top-level module code or from app.ts init) — silent undefined would mask
|
|
8
|
+
the misuse.
|
|
9
|
+
*/
|
|
10
|
+
export function request(): Request {
|
|
11
|
+
const store = requestContext.getStore()
|
|
12
|
+
if (!store) {
|
|
13
|
+
throw new Error(
|
|
14
|
+
'[belte] request() called outside a request scope — it only resolves while an SSR render or rpc handler is in flight',
|
|
15
|
+
)
|
|
16
|
+
}
|
|
17
|
+
return store.req
|
|
18
|
+
}
|