@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,832 @@
|
|
|
1
|
+
// node:fs existsSync — Bun plugin onResolve is sync-only; Bun.file().exists() is async
|
|
2
|
+
import { existsSync, statSync } from 'node:fs'
|
|
3
|
+
import type { BunPlugin } from 'bun'
|
|
4
|
+
import { Glob } from 'bun'
|
|
5
|
+
import { log } from './lib/shared/log.ts'
|
|
6
|
+
import { pageUrlForFile } from './lib/shared/pageUrlForFile.ts'
|
|
7
|
+
import { preparePromptModule } from './lib/shared/preparePromptModule.ts'
|
|
8
|
+
import { prepareRpcModule } from './lib/shared/prepareRpcModule.ts'
|
|
9
|
+
import { prepareSocketModule } from './lib/shared/prepareSocketModule.ts'
|
|
10
|
+
import { programNameForPackage } from './lib/shared/programNameForPackage.ts'
|
|
11
|
+
import { promptNameForFile } from './lib/shared/promptNameForFile.ts'
|
|
12
|
+
import { rpcUrlForFile } from './lib/shared/rpcUrlForFile.ts'
|
|
13
|
+
import { socketNameForFile } from './lib/shared/socketNameForFile.ts'
|
|
14
|
+
import { writeRoutesDts } from './lib/shared/writeRoutesDts.ts'
|
|
15
|
+
|
|
16
|
+
/*
|
|
17
|
+
Resolves a bare directory or extensionless path to a concrete file. Mirrors
|
|
18
|
+
Node-style resolution (path.ts, path.js, path/index.ts, path/index.js) so
|
|
19
|
+
project code can use SvelteKit-style aliases like `$shared/foo/utils` that point
|
|
20
|
+
at directories with an index file. The (path → resolved) mapping is
|
|
21
|
+
deterministic per build, so cache it — every module that imports a `$shared`
|
|
22
|
+
alias hits this twice or more, and each call would otherwise do up to nine
|
|
23
|
+
filesystem stats.
|
|
24
|
+
*/
|
|
25
|
+
const resolveExtensionCache = new Map<string, string>()
|
|
26
|
+
function resolveExtension(path: string): string {
|
|
27
|
+
const cached = resolveExtensionCache.get(path)
|
|
28
|
+
if (cached !== undefined) {
|
|
29
|
+
return cached
|
|
30
|
+
}
|
|
31
|
+
const resolved = resolveExtensionUncached(path)
|
|
32
|
+
resolveExtensionCache.set(path, resolved)
|
|
33
|
+
return resolved
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function resolveExtensionUncached(path: string): string {
|
|
37
|
+
if (existsSync(path) && !statSync(path).isDirectory()) {
|
|
38
|
+
return path
|
|
39
|
+
}
|
|
40
|
+
for (const extension of ['.ts', '.js', '.tsx', '.jsx']) {
|
|
41
|
+
if (existsSync(`${path}${extension}`)) {
|
|
42
|
+
return `${path}${extension}`
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
for (const extension of ['ts', 'js', 'tsx', 'jsx']) {
|
|
46
|
+
const indexPath = `${path}/index.${extension}`
|
|
47
|
+
if (existsSync(indexPath)) {
|
|
48
|
+
return indexPath
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return path
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const NS = 'belte-virtual'
|
|
55
|
+
|
|
56
|
+
function escapeRegex(value: string): string {
|
|
57
|
+
return value.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/*
|
|
61
|
+
Bun plugin that wires every virtual import belte produces at build time:
|
|
62
|
+
- `belte:rpc` — { rpcUrl: () => import(rpc-module) } HTTP-verb manifest
|
|
63
|
+
- `belte:sockets` — { socketName: () => import(socket-module) } socket manifest
|
|
64
|
+
- `belte:pages` — { pageUrl: () => import(page.svelte) } manifest
|
|
65
|
+
- `belte:layouts` — { dirPrefix: () => import(layout.svelte) } manifest
|
|
66
|
+
- `belte:prompts` — { promptName: () => import(prompt-module) } manifest
|
|
67
|
+
- `belte:app` — { init?, handle?, handleError? } from src/app.ts
|
|
68
|
+
- `belte:assets` — zstd-compressed chunk bytes embedded for standalone compile
|
|
69
|
+
- `belte:public-assets` — zstd-embedded src/browser/public files
|
|
70
|
+
- `belte:mcp-resources` — zstd-embedded src/mcp/resources files
|
|
71
|
+
- `belte:shell` — app.html content (custom or default)
|
|
72
|
+
|
|
73
|
+
Also rewrites modules under src/server/rpc and src/server/sockets:
|
|
74
|
+
- src/server/rpc/<file>.ts: each HTTP-verb export is bound to a runtime
|
|
75
|
+
implementation — defineVerb on the server, remoteProxy on the client.
|
|
76
|
+
- src/server/sockets/<file>.ts: each `socket(opts)` export is bound to
|
|
77
|
+
defineSocket on the server (with the socket name + opts) or
|
|
78
|
+
socketProxy on the client (name only — opts are server-side).
|
|
79
|
+
*/
|
|
80
|
+
export function belteResolverPlugin({
|
|
81
|
+
cwd = process.cwd(),
|
|
82
|
+
embedAssets = false,
|
|
83
|
+
target = 'server',
|
|
84
|
+
thin,
|
|
85
|
+
}: {
|
|
86
|
+
cwd?: string
|
|
87
|
+
embedAssets?: boolean
|
|
88
|
+
target?: 'server' | 'client'
|
|
89
|
+
thin?: boolean
|
|
90
|
+
} = {}): BunPlugin {
|
|
91
|
+
const serverDir = `${cwd}/src/server`
|
|
92
|
+
const browserDir = `${cwd}/src/browser`
|
|
93
|
+
const sharedDir = `${cwd}/src/shared`
|
|
94
|
+
const mcpDir = `${cwd}/src/mcp`
|
|
95
|
+
const cliDir = `${cwd}/src/cli`
|
|
96
|
+
const rpcDir = `${serverDir}/rpc`
|
|
97
|
+
const socketsDir = `${serverDir}/sockets`
|
|
98
|
+
const pagesDir = `${browserDir}/pages`
|
|
99
|
+
const publicDir = `${browserDir}/public`
|
|
100
|
+
const promptsDir = `${mcpDir}/prompts`
|
|
101
|
+
const resourcesDir = `${mcpDir}/resources`
|
|
102
|
+
|
|
103
|
+
/*
|
|
104
|
+
The whole-tree validation + per-leaf classification only needs to run
|
|
105
|
+
once per build. Memoise the promise so the virtual manifests
|
|
106
|
+
(rpc/sockets/pages/layouts) share a single scan instead of each one
|
|
107
|
+
re-globbing the trees. The shell read is memoised the same way so two
|
|
108
|
+
passes don't re-read app.html from disk.
|
|
109
|
+
*/
|
|
110
|
+
let pagesScanPromise: Promise<PagesScan> | undefined
|
|
111
|
+
let rpcScanPromise: Promise<string[]> | undefined
|
|
112
|
+
let socketsScanPromise: Promise<string[]> | undefined
|
|
113
|
+
let promptsScanPromise: Promise<string[]> | undefined
|
|
114
|
+
let shellContentsPromise: Promise<string> | undefined
|
|
115
|
+
function scanPagesOnce(): Promise<PagesScan> {
|
|
116
|
+
if (!pagesScanPromise) {
|
|
117
|
+
pagesScanPromise = scanPages(pagesDir).then(async (scan) => {
|
|
118
|
+
await writeRoutesDts({ cwd, pageFiles: scan.pageFiles })
|
|
119
|
+
return scan
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
return pagesScanPromise
|
|
123
|
+
}
|
|
124
|
+
function scanRpcOnce(): Promise<string[]> {
|
|
125
|
+
if (!rpcScanPromise) {
|
|
126
|
+
rpcScanPromise = scanRpc(rpcDir)
|
|
127
|
+
}
|
|
128
|
+
return rpcScanPromise
|
|
129
|
+
}
|
|
130
|
+
function scanSocketsOnce(): Promise<string[]> {
|
|
131
|
+
if (!socketsScanPromise) {
|
|
132
|
+
socketsScanPromise = scanSockets(socketsDir)
|
|
133
|
+
}
|
|
134
|
+
return socketsScanPromise
|
|
135
|
+
}
|
|
136
|
+
function scanPromptsOnce(): Promise<string[]> {
|
|
137
|
+
if (!promptsScanPromise) {
|
|
138
|
+
promptsScanPromise = scanPrompts(promptsDir)
|
|
139
|
+
}
|
|
140
|
+
return promptsScanPromise
|
|
141
|
+
}
|
|
142
|
+
function loadShellOnce(): Promise<string> {
|
|
143
|
+
if (!shellContentsPromise) {
|
|
144
|
+
shellContentsPromise = loadShell(cwd)
|
|
145
|
+
}
|
|
146
|
+
return shellContentsPromise
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const rpcFilter = new RegExp(`^${escapeRegex(rpcDir)}/.*\\.ts$`)
|
|
150
|
+
const socketsFilter = new RegExp(`^${escapeRegex(socketsDir)}/.*\\.ts$`)
|
|
151
|
+
const promptsFilter = new RegExp(`^${escapeRegex(promptsDir)}/.*\\.ts$`)
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
name: 'belte-resolver',
|
|
155
|
+
setup(build) {
|
|
156
|
+
build.onResolve(
|
|
157
|
+
{
|
|
158
|
+
filter: /\/_virtual\/(rpc|sockets|prompts|pages|layouts|app|mcp-resources|mcp|assets|public-assets|shell|app-info|cli-manifest|cli-name|cli-chrome|cli-rpcs)\.ts$/,
|
|
159
|
+
},
|
|
160
|
+
(args) => {
|
|
161
|
+
const name = args.path.split('/').pop()?.replace('.ts', '')
|
|
162
|
+
if (!name) {
|
|
163
|
+
return undefined
|
|
164
|
+
}
|
|
165
|
+
return { path: `belte:${name}`, namespace: NS }
|
|
166
|
+
},
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
/*
|
|
170
|
+
User-facing aliases are the five top-level project directories.
|
|
171
|
+
Sub-paths fall out of them: `$server/rpc/getThing`,
|
|
172
|
+
`$browser/pages/...`, `$mcp/prompts/...`, `$mcp/resources/...`.
|
|
173
|
+
`lib/` is userland — projects declare their own lib aliases.
|
|
174
|
+
*/
|
|
175
|
+
build.onResolve({ filter: /^\$server(\/.*)?$/ }, (args) => {
|
|
176
|
+
const subpath = args.path.slice('$server'.length)
|
|
177
|
+
return { path: resolveExtension(subpath ? `${serverDir}${subpath}` : serverDir) }
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
build.onResolve({ filter: /^\$browser(\/.*)?$/ }, (args) => {
|
|
181
|
+
const subpath = args.path.slice('$browser'.length)
|
|
182
|
+
return { path: resolveExtension(subpath ? `${browserDir}${subpath}` : browserDir) }
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
build.onResolve({ filter: /^\$shared(\/.*)?$/ }, (args) => {
|
|
186
|
+
const subpath = args.path.slice('$shared'.length)
|
|
187
|
+
return { path: resolveExtension(subpath ? `${sharedDir}${subpath}` : sharedDir) }
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
build.onResolve({ filter: /^\$mcp(\/.*)?$/ }, (args) => {
|
|
191
|
+
const subpath = args.path.slice('$mcp'.length)
|
|
192
|
+
return { path: resolveExtension(subpath ? `${mcpDir}${subpath}` : mcpDir) }
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
build.onResolve({ filter: /^\$cli(\/.*)?$/ }, (args) => {
|
|
196
|
+
const subpath = args.path.slice('$cli'.length)
|
|
197
|
+
return { path: resolveExtension(subpath ? `${cliDir}${subpath}` : cliDir) }
|
|
198
|
+
})
|
|
199
|
+
|
|
200
|
+
build.onLoad({ filter: rpcFilter }, async (args) => {
|
|
201
|
+
if (!args.path.startsWith(`${rpcDir}/`)) {
|
|
202
|
+
return undefined
|
|
203
|
+
}
|
|
204
|
+
const relativePath = args.path.slice(rpcDir.length + 1)
|
|
205
|
+
const source = await Bun.file(args.path).text()
|
|
206
|
+
const url = rpcUrlForFile(relativePath)
|
|
207
|
+
const prepared = prepareRpcModule(source)
|
|
208
|
+
if (!prepared) {
|
|
209
|
+
throw new Error(
|
|
210
|
+
`[belte] src/server/rpc/${relativePath} has no \`export const <name> = <VERB>(...)\` — every $rpc module must declare exactly one remote function`,
|
|
211
|
+
)
|
|
212
|
+
}
|
|
213
|
+
const expectedName = relativePath.replace(/\.ts$/, '').split('/').pop() ?? ''
|
|
214
|
+
if (prepared.exportName !== expectedName) {
|
|
215
|
+
throw new Error(
|
|
216
|
+
`[belte] src/server/rpc/${relativePath} exports \`${prepared.exportName}\` but the filename expects \`${expectedName}\` — the export name must match the file's stem`,
|
|
217
|
+
)
|
|
218
|
+
}
|
|
219
|
+
/*
|
|
220
|
+
For the client bundle, replace the entire module source
|
|
221
|
+
with a single proxy stub so the handler body and any
|
|
222
|
+
server-only top-level imports never reach the browser.
|
|
223
|
+
The stub keeps the same export name the source declared,
|
|
224
|
+
so page imports resolve identically on both sides.
|
|
225
|
+
*/
|
|
226
|
+
if (target === 'client') {
|
|
227
|
+
const contents = `import { remoteProxy as __belteRemoteProxy__ } from 'belte/browser/remoteProxy';
|
|
228
|
+
export const ${prepared.exportName} = __belteRemoteProxy__(${JSON.stringify(prepared.verb)}, ${JSON.stringify(url)});
|
|
229
|
+
`
|
|
230
|
+
return { contents, loader: 'ts' }
|
|
231
|
+
}
|
|
232
|
+
/*
|
|
233
|
+
Server target: strip the user's verb import, then rewrite
|
|
234
|
+
the `<VERB>(` call so the verb (from the identifier) and
|
|
235
|
+
the URL (from the file path) are threaded into the
|
|
236
|
+
runtime constructor — defineVerb. The user's handler body
|
|
237
|
+
stays intact between the parens; any generics on the call
|
|
238
|
+
are dropped (they carry no runtime info). Rewriting is
|
|
239
|
+
tokenizer-driven so `GET` mentions inside strings and
|
|
240
|
+
comments are left alone.
|
|
241
|
+
*/
|
|
242
|
+
const banner = `import { defineVerb as __belteDefineVerb__ } from 'belte/server/rpc/defineVerb';
|
|
243
|
+
`
|
|
244
|
+
return { contents: `${banner}${prepared.rewriteForServer(url)}`, loader: 'ts' }
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
build.onLoad({ filter: socketsFilter }, async (args) => {
|
|
248
|
+
if (!args.path.startsWith(`${socketsDir}/`)) {
|
|
249
|
+
return undefined
|
|
250
|
+
}
|
|
251
|
+
const relativePath = args.path.slice(socketsDir.length + 1)
|
|
252
|
+
const source = await Bun.file(args.path).text()
|
|
253
|
+
const name = socketNameForFile(relativePath)
|
|
254
|
+
const prepared = prepareSocketModule(source)
|
|
255
|
+
if (!prepared) {
|
|
256
|
+
throw new Error(
|
|
257
|
+
`[belte] src/server/sockets/${relativePath} has no \`export const <name> = socket(...)\` — every $sockets module must declare exactly one socket`,
|
|
258
|
+
)
|
|
259
|
+
}
|
|
260
|
+
const expectedName = relativePath.replace(/\.ts$/, '').split('/').pop() ?? ''
|
|
261
|
+
if (prepared.exportName !== expectedName) {
|
|
262
|
+
throw new Error(
|
|
263
|
+
`[belte] src/server/sockets/${relativePath} exports \`${prepared.exportName}\` but the filename expects \`${expectedName}\` — the export name must match the file's stem`,
|
|
264
|
+
)
|
|
265
|
+
}
|
|
266
|
+
if (target === 'client') {
|
|
267
|
+
/*
|
|
268
|
+
Client bundle gets a name-only stub — opts (history,
|
|
269
|
+
clientPublish) are server-side state and don't
|
|
270
|
+
affect the client's wire behaviour.
|
|
271
|
+
*/
|
|
272
|
+
const contents = `import { socketProxy as __belteSocketProxy__ } from 'belte/browser/socketProxy';
|
|
273
|
+
export const ${prepared.exportName} = __belteSocketProxy__(${JSON.stringify(name)});
|
|
274
|
+
`
|
|
275
|
+
return { contents, loader: 'ts' }
|
|
276
|
+
}
|
|
277
|
+
const banner = `import { defineSocket as __belteDefineSocket__ } from 'belte/server/sockets/defineSocket';
|
|
278
|
+
`
|
|
279
|
+
return {
|
|
280
|
+
contents: `${banner}${prepared.rewriteForServer(name)}`,
|
|
281
|
+
loader: 'ts',
|
|
282
|
+
}
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
build.onLoad({ filter: promptsFilter }, async (args) => {
|
|
286
|
+
if (!args.path.startsWith(`${promptsDir}/`)) {
|
|
287
|
+
return undefined
|
|
288
|
+
}
|
|
289
|
+
/*
|
|
290
|
+
Prompts are MCP-only — no client-side counterpart. The
|
|
291
|
+
client bundle never imports a prompts module, but emit an
|
|
292
|
+
empty stub for the client target defensively so a stray
|
|
293
|
+
import can't drag the render body into the browser bundle.
|
|
294
|
+
*/
|
|
295
|
+
if (target === 'client') {
|
|
296
|
+
return { contents: 'export {}', loader: 'ts' }
|
|
297
|
+
}
|
|
298
|
+
const relativePath = args.path.slice(promptsDir.length + 1)
|
|
299
|
+
const source = await Bun.file(args.path).text()
|
|
300
|
+
const name = promptNameForFile(relativePath)
|
|
301
|
+
const prepared = preparePromptModule(source)
|
|
302
|
+
if (!prepared) {
|
|
303
|
+
throw new Error(
|
|
304
|
+
`[belte] src/mcp/prompts/${relativePath} has no \`export const <name> = prompt(...)\` — every prompts module must declare exactly one prompt`,
|
|
305
|
+
)
|
|
306
|
+
}
|
|
307
|
+
const expectedName = relativePath.replace(/\.ts$/, '').split('/').pop() ?? ''
|
|
308
|
+
if (prepared.exportName !== expectedName) {
|
|
309
|
+
throw new Error(
|
|
310
|
+
`[belte] src/mcp/prompts/${relativePath} exports \`${prepared.exportName}\` but the filename expects \`${expectedName}\` — the export name must match the file's stem`,
|
|
311
|
+
)
|
|
312
|
+
}
|
|
313
|
+
const banner = `import { definePrompt as __belteDefinePrompt__ } from 'belte/server/prompts/definePrompt';
|
|
314
|
+
`
|
|
315
|
+
return {
|
|
316
|
+
contents: `${banner}${prepared.rewriteForServer(name)}`,
|
|
317
|
+
loader: 'ts',
|
|
318
|
+
}
|
|
319
|
+
})
|
|
320
|
+
|
|
321
|
+
build.onLoad({ filter: /.*/, namespace: NS }, async (args) => {
|
|
322
|
+
if (args.path === 'belte:rpc') {
|
|
323
|
+
const files = await scanRpcOnce()
|
|
324
|
+
const byUrl = files
|
|
325
|
+
.toSorted()
|
|
326
|
+
.map((file) => ({ url: rpcUrlForFile(file), file }))
|
|
327
|
+
const entries = byUrl
|
|
328
|
+
.map(
|
|
329
|
+
({ url, file }) =>
|
|
330
|
+
` ${JSON.stringify(url)}: () => import(${JSON.stringify(`${rpcDir}/${file}`)}),`,
|
|
331
|
+
)
|
|
332
|
+
.join('\n')
|
|
333
|
+
if (byUrl.length > 0) {
|
|
334
|
+
log.info(
|
|
335
|
+
`resolved ${byUrl.length} rpc modules: ${byUrl.map((b) => b.url).join(', ')}`,
|
|
336
|
+
)
|
|
337
|
+
}
|
|
338
|
+
return {
|
|
339
|
+
contents: `export const rpc = {\n${entries}\n}\n`,
|
|
340
|
+
loader: 'js',
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (args.path === 'belte:sockets') {
|
|
345
|
+
const files = await scanSocketsOnce()
|
|
346
|
+
const byName = files
|
|
347
|
+
.toSorted()
|
|
348
|
+
.map((file) => ({ name: socketNameForFile(file), file }))
|
|
349
|
+
const entries = byName
|
|
350
|
+
.map(
|
|
351
|
+
({ name, file }) =>
|
|
352
|
+
` ${JSON.stringify(name)}: () => import(${JSON.stringify(`${socketsDir}/${file}`)}),`,
|
|
353
|
+
)
|
|
354
|
+
.join('\n')
|
|
355
|
+
if (byName.length > 0) {
|
|
356
|
+
log.info(
|
|
357
|
+
`resolved ${byName.length} socket modules: ${byName.map((b) => b.name).join(', ')}`,
|
|
358
|
+
)
|
|
359
|
+
}
|
|
360
|
+
return {
|
|
361
|
+
contents: `export const sockets = {\n${entries}\n}\n`,
|
|
362
|
+
loader: 'js',
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (args.path === 'belte:prompts') {
|
|
367
|
+
const files = await scanPromptsOnce()
|
|
368
|
+
const byName = files
|
|
369
|
+
.toSorted()
|
|
370
|
+
.map((file) => ({ name: promptNameForFile(file), file }))
|
|
371
|
+
const entries = byName
|
|
372
|
+
.map(
|
|
373
|
+
({ name, file }) =>
|
|
374
|
+
` ${JSON.stringify(name)}: () => import(${JSON.stringify(`${promptsDir}/${file}`)}),`,
|
|
375
|
+
)
|
|
376
|
+
.join('\n')
|
|
377
|
+
if (byName.length > 0) {
|
|
378
|
+
log.info(
|
|
379
|
+
`resolved ${byName.length} prompt modules: ${byName.map((b) => b.name).join(', ')}`,
|
|
380
|
+
)
|
|
381
|
+
}
|
|
382
|
+
return {
|
|
383
|
+
contents: `export const prompts = {\n${entries}\n}\n`,
|
|
384
|
+
loader: 'js',
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (args.path === 'belte:pages') {
|
|
389
|
+
const { pageFiles: files } = await scanPagesOnce()
|
|
390
|
+
const byUrl = files
|
|
391
|
+
.toSorted()
|
|
392
|
+
.map((file) => ({ url: pageUrlForFile(file), file }))
|
|
393
|
+
const entries = byUrl
|
|
394
|
+
.map(
|
|
395
|
+
({ url, file }) =>
|
|
396
|
+
` ${JSON.stringify(url)}: () => import(${JSON.stringify(`${pagesDir}/${file}`)}),`,
|
|
397
|
+
)
|
|
398
|
+
.join('\n')
|
|
399
|
+
log.info(
|
|
400
|
+
`resolved ${byUrl.length} pages: ${byUrl.map((b) => b.url).join(', ')}`,
|
|
401
|
+
)
|
|
402
|
+
return {
|
|
403
|
+
contents: `export const pages = {\n${entries}\n}\n`,
|
|
404
|
+
loader: 'js',
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (args.path === 'belte:layouts') {
|
|
409
|
+
const { layoutFiles: files } = await scanPagesOnce()
|
|
410
|
+
const byPrefix = files
|
|
411
|
+
.toSorted()
|
|
412
|
+
.map((file) => ({ prefix: pageUrlForFile(file), file }))
|
|
413
|
+
const entries = byPrefix
|
|
414
|
+
.map(
|
|
415
|
+
({ prefix, file }) =>
|
|
416
|
+
` ${JSON.stringify(prefix)}: () => import(${JSON.stringify(`${pagesDir}/${file}`)}),`,
|
|
417
|
+
)
|
|
418
|
+
.join('\n')
|
|
419
|
+
if (byPrefix.length > 0) {
|
|
420
|
+
log.info(
|
|
421
|
+
`resolved ${byPrefix.length} layouts: ${byPrefix.map((b) => b.prefix).join(', ')}`,
|
|
422
|
+
)
|
|
423
|
+
}
|
|
424
|
+
return {
|
|
425
|
+
contents: `export const layouts = {\n${entries}\n}\n`,
|
|
426
|
+
loader: 'js',
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (args.path === 'belte:app') {
|
|
431
|
+
const userApp = `${cwd}/src/app.ts`
|
|
432
|
+
if (await Bun.file(userApp).exists()) {
|
|
433
|
+
log.info('using custom src/app.ts')
|
|
434
|
+
return {
|
|
435
|
+
contents: `export * from ${JSON.stringify(userApp)}`,
|
|
436
|
+
loader: 'js',
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return { contents: 'export {};', loader: 'js' }
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
if (args.path === 'belte:cli-manifest') {
|
|
443
|
+
/*
|
|
444
|
+
The CLI binary's bake-time manifest. Discovery (a
|
|
445
|
+
one-shot script the bundler runs separately) writes
|
|
446
|
+
`${cwd}/dist/cli-manifest.json` from the populated
|
|
447
|
+
verbRegistry; this virtual splices that JSON in as a
|
|
448
|
+
default-exported object. Empty manifest when the
|
|
449
|
+
discovery file is missing — the binary still works
|
|
450
|
+
but exposes no subcommands until the user runs the
|
|
451
|
+
full `belte cli` flow.
|
|
452
|
+
*/
|
|
453
|
+
const manifestPath = `${cwd}/dist/cli-manifest.json`
|
|
454
|
+
if (!existsSync(manifestPath)) {
|
|
455
|
+
return { contents: 'export default {}', loader: 'js' }
|
|
456
|
+
}
|
|
457
|
+
const json = await Bun.file(manifestPath).text()
|
|
458
|
+
return { contents: `export default ${json}`, loader: 'js' }
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
if (args.path === 'belte:cli-name') {
|
|
462
|
+
/*
|
|
463
|
+
Program name shown in `<program> --help`. Reads the
|
|
464
|
+
project's package.json `name` field (scoped names keep
|
|
465
|
+
only the final segment), falling back to `app` when
|
|
466
|
+
missing.
|
|
467
|
+
*/
|
|
468
|
+
const pkgPath = `${cwd}/package.json`
|
|
469
|
+
if (!existsSync(pkgPath)) {
|
|
470
|
+
return { contents: 'export default "app"', loader: 'js' }
|
|
471
|
+
}
|
|
472
|
+
const pkg = (await Bun.file(pkgPath).json()) as { name?: string }
|
|
473
|
+
const name = programNameForPackage(pkg.name)
|
|
474
|
+
return { contents: `export default ${JSON.stringify(name)}`, loader: 'js' }
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (args.path === 'belte:cli-chrome') {
|
|
478
|
+
/*
|
|
479
|
+
Optional CLI help chrome baked into the binary: src/cli/
|
|
480
|
+
banner.txt prints atop top-level help, footer.txt prints
|
|
481
|
+
below it. Missing files emit empty strings (no chrome).
|
|
482
|
+
Read as plain text, like belte:shell.
|
|
483
|
+
*/
|
|
484
|
+
const bannerFile = `${cliDir}/banner.txt`
|
|
485
|
+
const footerFile = `${cliDir}/footer.txt`
|
|
486
|
+
const banner = (await Bun.file(bannerFile).exists())
|
|
487
|
+
? await Bun.file(bannerFile).text()
|
|
488
|
+
: ''
|
|
489
|
+
const footer = (await Bun.file(footerFile).exists())
|
|
490
|
+
? await Bun.file(footerFile).text()
|
|
491
|
+
: ''
|
|
492
|
+
return {
|
|
493
|
+
contents: `export const banner = ${JSON.stringify(banner)}
|
|
494
|
+
export const footer = ${JSON.stringify(footer)}
|
|
495
|
+
`,
|
|
496
|
+
loader: 'js',
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
if (args.path === 'belte:app-info') {
|
|
501
|
+
/*
|
|
502
|
+
Project identity ({ name, version }) read from
|
|
503
|
+
package.json, surfaced in the OpenAPI document's `info`
|
|
504
|
+
block. Falls back to placeholder values when the file
|
|
505
|
+
is missing so the spec still emits.
|
|
506
|
+
*/
|
|
507
|
+
const pkgPath = `${cwd}/package.json`
|
|
508
|
+
if (!existsSync(pkgPath)) {
|
|
509
|
+
return {
|
|
510
|
+
contents: 'export const appInfo = { name: "app", version: "0.0.0" }',
|
|
511
|
+
loader: 'js',
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
const pkg = (await Bun.file(pkgPath).json()) as {
|
|
515
|
+
name?: string
|
|
516
|
+
version?: string
|
|
517
|
+
}
|
|
518
|
+
const info = { name: pkg.name ?? 'app', version: pkg.version ?? '0.0.0' }
|
|
519
|
+
return {
|
|
520
|
+
contents: `export const appInfo = ${JSON.stringify(info)}`,
|
|
521
|
+
loader: 'js',
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (args.path === 'belte:cli-rpcs') {
|
|
526
|
+
/*
|
|
527
|
+
Eager-import side-effect bundle for the FULL CLI
|
|
528
|
+
binary. Importing every rpc module fires defineVerb
|
|
529
|
+
so the verbRegistry is populated and createClient's
|
|
530
|
+
in-process fallback can dispatch. Thin builds emit
|
|
531
|
+
an empty module — the binary speaks remote-only.
|
|
532
|
+
|
|
533
|
+
`thin` is set by buildCli (default full — it passes
|
|
534
|
+
`thin: false` unless `--thin`). Defaults to full here
|
|
535
|
+
too so a stray APP_URL in the build environment can't
|
|
536
|
+
silently thin the bundle.
|
|
537
|
+
*/
|
|
538
|
+
const isThin = thin ?? false
|
|
539
|
+
if (isThin) {
|
|
540
|
+
return { contents: 'export {}', loader: 'js' }
|
|
541
|
+
}
|
|
542
|
+
const files = await scanRpcOnce()
|
|
543
|
+
const lines = files.map(
|
|
544
|
+
(file) => `import ${JSON.stringify(`${rpcDir}/${file}`)}`,
|
|
545
|
+
)
|
|
546
|
+
return { contents: `${lines.join('\n')}\nexport {}`, loader: 'js' }
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (args.path === 'belte:mcp') {
|
|
550
|
+
/*
|
|
551
|
+
The MCP server is fully framework-generated — tools from
|
|
552
|
+
the verb registry, prompts from src/mcp/prompts, resources
|
|
553
|
+
from src/mcp/resources. createMcpServer is internal; there
|
|
554
|
+
is no user-authored server module.
|
|
555
|
+
*/
|
|
556
|
+
return {
|
|
557
|
+
contents:
|
|
558
|
+
"import { createMcpServer } from 'belte/mcp/createMcpServer'\nexport default createMcpServer()\n",
|
|
559
|
+
loader: 'js',
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (args.path === 'belte:assets') {
|
|
564
|
+
if (!embedAssets) {
|
|
565
|
+
return { contents: 'export const assets = undefined', loader: 'js' }
|
|
566
|
+
}
|
|
567
|
+
const appDir = `${cwd}/dist/_app`
|
|
568
|
+
const files = await Array.fromAsync(
|
|
569
|
+
new Glob('**/*.zst').scan({ cwd: appDir, onlyFiles: true }),
|
|
570
|
+
)
|
|
571
|
+
const encoded = await Promise.all(
|
|
572
|
+
files.map(async (file) => {
|
|
573
|
+
const bytes = await Bun.file(`${appDir}/${file}`).bytes()
|
|
574
|
+
const urlPath = `/_app/${file.replace(/\.zst$/, '')}`
|
|
575
|
+
return {
|
|
576
|
+
line: ` ${JSON.stringify(urlPath)}: _d(${JSON.stringify(bytes.toBase64())}),`,
|
|
577
|
+
bytes: bytes.byteLength,
|
|
578
|
+
}
|
|
579
|
+
}),
|
|
580
|
+
)
|
|
581
|
+
const entries = encoded.map((entry) => entry.line)
|
|
582
|
+
const totalBytes = encoded.reduce((total, entry) => total + entry.bytes, 0)
|
|
583
|
+
log.info(
|
|
584
|
+
`embedded ${encoded.length} zstd assets from dist/_app/ (${(totalBytes / 1024).toFixed(1)} KiB)`,
|
|
585
|
+
)
|
|
586
|
+
return {
|
|
587
|
+
contents: `const _d = (s) => Uint8Array.fromBase64(s)
|
|
588
|
+
export const assets = {
|
|
589
|
+
${entries.join('\n')}
|
|
590
|
+
}
|
|
591
|
+
`,
|
|
592
|
+
loader: 'js',
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
if (args.path === 'belte:public-assets') {
|
|
597
|
+
/*
|
|
598
|
+
Embeds every file under public/ (zstd level 22, paid
|
|
599
|
+
once at compile) keyed by its site-root path so the
|
|
600
|
+
standalone binary serves them without a public/ dir on
|
|
601
|
+
disk. Mirrors belte:assets. Empty/undefined when not
|
|
602
|
+
embedding (dev + `belte start` read public/ off disk).
|
|
603
|
+
*/
|
|
604
|
+
if (!embedAssets || !existsSync(publicDir)) {
|
|
605
|
+
return {
|
|
606
|
+
contents: 'export const publicAssets = undefined',
|
|
607
|
+
loader: 'js',
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
const files = await Array.fromAsync(
|
|
611
|
+
new Glob('**/*').scan({ cwd: publicDir, onlyFiles: true }),
|
|
612
|
+
)
|
|
613
|
+
if (files.length === 0) {
|
|
614
|
+
return {
|
|
615
|
+
contents: 'export const publicAssets = undefined',
|
|
616
|
+
loader: 'js',
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
const encoded = await Promise.all(
|
|
620
|
+
files.map(async (file) => {
|
|
621
|
+
const bytes = await Bun.file(`${publicDir}/${file}`).bytes()
|
|
622
|
+
const compressed = Bun.zstdCompressSync(bytes, { level: 22 })
|
|
623
|
+
return {
|
|
624
|
+
line: ` ${JSON.stringify(`/${file}`)}: _d(${JSON.stringify(compressed.toBase64())}),`,
|
|
625
|
+
bytes: compressed.byteLength,
|
|
626
|
+
}
|
|
627
|
+
}),
|
|
628
|
+
)
|
|
629
|
+
const totalBytes = encoded.reduce((total, entry) => total + entry.bytes, 0)
|
|
630
|
+
log.info(
|
|
631
|
+
`embedded ${encoded.length} public files from public/ (${(totalBytes / 1024).toFixed(1)} KiB zstd)`,
|
|
632
|
+
)
|
|
633
|
+
return {
|
|
634
|
+
contents: `const _d = (s) => Uint8Array.fromBase64(s)
|
|
635
|
+
export const publicAssets = {
|
|
636
|
+
${encoded.map((entry) => entry.line).join('\n')}
|
|
637
|
+
}
|
|
638
|
+
`,
|
|
639
|
+
loader: 'js',
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
if (args.path === 'belte:mcp-resources') {
|
|
644
|
+
/*
|
|
645
|
+
Embeds every file under src/mcp/resources/ (zstd level
|
|
646
|
+
22) keyed by its path relative to that dir, so the
|
|
647
|
+
standalone binary serves MCP resources without the folder
|
|
648
|
+
on disk. Mirrors belte:public-assets. Undefined when not
|
|
649
|
+
embedding (dev + `belte start` read off disk).
|
|
650
|
+
*/
|
|
651
|
+
if (!embedAssets || !existsSync(resourcesDir)) {
|
|
652
|
+
return {
|
|
653
|
+
contents: 'export const mcpResources = undefined',
|
|
654
|
+
loader: 'js',
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
const files = await Array.fromAsync(
|
|
658
|
+
new Glob('**/*').scan({ cwd: resourcesDir, onlyFiles: true }),
|
|
659
|
+
)
|
|
660
|
+
if (files.length === 0) {
|
|
661
|
+
return {
|
|
662
|
+
contents: 'export const mcpResources = undefined',
|
|
663
|
+
loader: 'js',
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
const encoded = await Promise.all(
|
|
667
|
+
files.map(async (file) => {
|
|
668
|
+
const bytes = await Bun.file(`${resourcesDir}/${file}`).bytes()
|
|
669
|
+
const compressed = Bun.zstdCompressSync(bytes, { level: 22 })
|
|
670
|
+
return {
|
|
671
|
+
line: ` ${JSON.stringify(file)}: _d(${JSON.stringify(compressed.toBase64())}),`,
|
|
672
|
+
bytes: compressed.byteLength,
|
|
673
|
+
}
|
|
674
|
+
}),
|
|
675
|
+
)
|
|
676
|
+
const totalBytes = encoded.reduce((total, entry) => total + entry.bytes, 0)
|
|
677
|
+
log.info(
|
|
678
|
+
`embedded ${encoded.length} mcp resources from src/mcp/resources/ (${(totalBytes / 1024).toFixed(1)} KiB zstd)`,
|
|
679
|
+
)
|
|
680
|
+
return {
|
|
681
|
+
contents: `const _d = (s) => Uint8Array.fromBase64(s)
|
|
682
|
+
export const mcpResources = {
|
|
683
|
+
${encoded.map((entry) => entry.line).join('\n')}
|
|
684
|
+
}
|
|
685
|
+
`,
|
|
686
|
+
loader: 'js',
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
if (args.path === 'belte:shell') {
|
|
691
|
+
const content = await loadShellOnce()
|
|
692
|
+
return {
|
|
693
|
+
contents: `export const shell = ${JSON.stringify(content)}`,
|
|
694
|
+
loader: 'js',
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
return undefined
|
|
699
|
+
})
|
|
700
|
+
},
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
type PagesScan = {
|
|
705
|
+
pageFiles: string[]
|
|
706
|
+
layoutFiles: string[]
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/*
|
|
710
|
+
Walks src/browser/pages once and partitions every `.svelte` file into pages
|
|
711
|
+
and layouts. Rejects any other file shape — pages and layouts must live in
|
|
712
|
+
their own folders (or directly under `src/browser/pages/` for the root) and the
|
|
713
|
+
basename must be `page.svelte` or `layout.svelte`. A misnamed file (e.g.
|
|
714
|
+
`about.svelte`) would otherwise be silently ignored; the explicit error
|
|
715
|
+
gives the right hint.
|
|
716
|
+
*/
|
|
717
|
+
async function scanPages(pagesDir: string): Promise<PagesScan> {
|
|
718
|
+
if (!existsSync(pagesDir)) {
|
|
719
|
+
return { pageFiles: [], layoutFiles: [] }
|
|
720
|
+
}
|
|
721
|
+
const allFiles = await Array.fromAsync(new Glob('**/*.svelte').scan({ cwd: pagesDir }))
|
|
722
|
+
const pageFiles: string[] = []
|
|
723
|
+
const layoutFiles: string[] = []
|
|
724
|
+
for (const file of allFiles) {
|
|
725
|
+
const basename = file.split('/').pop() ?? ''
|
|
726
|
+
if (basename === 'page.svelte') {
|
|
727
|
+
pageFiles.push(file)
|
|
728
|
+
continue
|
|
729
|
+
}
|
|
730
|
+
if (basename === 'layout.svelte') {
|
|
731
|
+
layoutFiles.push(file)
|
|
732
|
+
continue
|
|
733
|
+
}
|
|
734
|
+
const stem = basename.replace(/\.[^.]+$/, '')
|
|
735
|
+
const parent = file.includes('/') ? `${file.slice(0, file.lastIndexOf('/'))}/` : ''
|
|
736
|
+
throw new Error(
|
|
737
|
+
`[belte] src/browser/pages/${file} is not a recognized page file — every page must live in its own folder as page.svelte or layout.svelte (try src/browser/pages/${parent}${stem}/page.svelte)`,
|
|
738
|
+
)
|
|
739
|
+
}
|
|
740
|
+
return { pageFiles, layoutFiles }
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/*
|
|
744
|
+
Walks src/server/rpc once. Every `.ts` file is an HTTP-verb rpc handler. Returns
|
|
745
|
+
an empty list when the directory doesn't exist so a pages-only app
|
|
746
|
+
builds without an `rpc/` folder.
|
|
747
|
+
*/
|
|
748
|
+
async function scanRpc(rpcDir: string): Promise<string[]> {
|
|
749
|
+
if (!existsSync(rpcDir)) {
|
|
750
|
+
return []
|
|
751
|
+
}
|
|
752
|
+
return await Array.fromAsync(new Glob('**/*.ts').scan({ cwd: rpcDir }))
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/*
|
|
756
|
+
Walks src/server/sockets once. Each `.ts` file declares one socket; the
|
|
757
|
+
dispatcher loads modules lazily on first sub/pub frame. Returns an
|
|
758
|
+
empty list when the directory doesn't exist.
|
|
759
|
+
*/
|
|
760
|
+
async function scanSockets(socketsDir: string): Promise<string[]> {
|
|
761
|
+
if (!existsSync(socketsDir)) {
|
|
762
|
+
return []
|
|
763
|
+
}
|
|
764
|
+
return await Array.fromAsync(new Glob('**/*.ts').scan({ cwd: socketsDir }))
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/*
|
|
768
|
+
Walks src/mcp/prompts once. Each `.ts` file declares one MCP prompt.
|
|
769
|
+
Returns an empty list when the directory doesn't exist so an app without
|
|
770
|
+
prompts builds the same.
|
|
771
|
+
*/
|
|
772
|
+
async function scanPrompts(promptsDir: string): Promise<string[]> {
|
|
773
|
+
if (!existsSync(promptsDir)) {
|
|
774
|
+
return []
|
|
775
|
+
}
|
|
776
|
+
return await Array.fromAsync(new Glob('**/*.ts').scan({ cwd: promptsDir }))
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/*
|
|
780
|
+
Picks `src/browser/app.html` when it exists, otherwise the bundled default
|
|
781
|
+
shell. Reads the file once per build so the resolver's two virtual passes share
|
|
782
|
+
a single disk hit. Rewrites the literal `/_app/client.js` and `/_app/client.css`
|
|
783
|
+
references to the hashed entry filenames emitted by the client build so the
|
|
784
|
+
entry bundles can be served with `immutable` cache headers like the chunks.
|
|
785
|
+
*/
|
|
786
|
+
async function loadShell(cwd: string): Promise<string> {
|
|
787
|
+
const userShell = `${cwd}/src/browser/app.html`
|
|
788
|
+
const defaultShell = new URL('./assets/app.html', import.meta.url).pathname
|
|
789
|
+
const filepath = (await Bun.file(userShell).exists()) ? userShell : defaultShell
|
|
790
|
+
if (filepath === userShell) {
|
|
791
|
+
log.info('using custom src/browser/app.html')
|
|
792
|
+
}
|
|
793
|
+
const content = await Bun.file(filepath).text()
|
|
794
|
+
return await rewriteHashedClientEntries(content, cwd)
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
/*
|
|
798
|
+
Scans `dist/_app/` for the hashed client entry filenames produced by
|
|
799
|
+
build.ts (e.g. `client-abc12345.js`, `client-abc12345.css`) and swaps the
|
|
800
|
+
shell's literal `/_app/client.js` and `/_app/client.css` references for
|
|
801
|
+
them. When the directory is missing (someone running the server before a
|
|
802
|
+
build) the shell is returned unchanged so the existing broken-asset
|
|
803
|
+
behaviour is preserved.
|
|
804
|
+
*/
|
|
805
|
+
async function rewriteHashedClientEntries(shell: string, cwd: string): Promise<string> {
|
|
806
|
+
const appDir = `${cwd}/dist/_app`
|
|
807
|
+
if (!existsSync(appDir)) {
|
|
808
|
+
return shell
|
|
809
|
+
}
|
|
810
|
+
const entries = await Array.fromAsync(
|
|
811
|
+
new Glob('client-*').scan({ cwd: appDir, onlyFiles: true }),
|
|
812
|
+
)
|
|
813
|
+
let jsEntry: string | undefined
|
|
814
|
+
let cssEntry: string | undefined
|
|
815
|
+
for (const file of entries) {
|
|
816
|
+
if (!jsEntry && /^client-[a-z0-9]+\.js$/i.test(file)) {
|
|
817
|
+
jsEntry = file
|
|
818
|
+
continue
|
|
819
|
+
}
|
|
820
|
+
if (!cssEntry && /^client-[a-z0-9]+\.css$/i.test(file)) {
|
|
821
|
+
cssEntry = file
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
let result = shell
|
|
825
|
+
if (jsEntry) {
|
|
826
|
+
result = result.replace('/_app/client.js', `/_app/${jsEntry}`)
|
|
827
|
+
}
|
|
828
|
+
if (cssEntry) {
|
|
829
|
+
result = result.replace('/_app/client.css', `/_app/${cssEntry}`)
|
|
830
|
+
}
|
|
831
|
+
return result
|
|
832
|
+
}
|