@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.
Files changed (178) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +49 -0
  3. package/bin/belte.ts +136 -0
  4. package/package.json +80 -0
  5. package/src/App.svelte +31 -0
  6. package/src/assets/app.html +14 -0
  7. package/src/belteResolverPlugin.ts +832 -0
  8. package/src/build.ts +144 -0
  9. package/src/buildCli.ts +160 -0
  10. package/src/cliEntry.ts +31 -0
  11. package/src/clientEntry.ts +7 -0
  12. package/src/compile.ts +64 -0
  13. package/src/devEntry.ts +33 -0
  14. package/src/discoveryEntry.ts +33 -0
  15. package/src/lib/browser/cache.ts +191 -0
  16. package/src/lib/browser/page.svelte.ts +215 -0
  17. package/src/lib/browser/remoteProxy.ts +44 -0
  18. package/src/lib/browser/socketChannel.ts +182 -0
  19. package/src/lib/browser/socketProxy.ts +64 -0
  20. package/src/lib/browser/startClient.ts +132 -0
  21. package/src/lib/browser/subscribe.ts +131 -0
  22. package/src/lib/browser/types/Layouts.ts +7 -0
  23. package/src/lib/browser/types/Pages.ts +7 -0
  24. package/src/lib/cli/createClient.ts +126 -0
  25. package/src/lib/cli/loadEnvFromBinaryDir.ts +44 -0
  26. package/src/lib/cli/parseArgvForRpc.ts +97 -0
  27. package/src/lib/cli/printHelp.ts +70 -0
  28. package/src/lib/cli/runCli.ts +88 -0
  29. package/src/lib/cli/types/CliManifest.ts +9 -0
  30. package/src/lib/cli/types/CliManifestEntry.ts +12 -0
  31. package/src/lib/mcp/createMcpResourceServer.ts +101 -0
  32. package/src/lib/mcp/createMcpServer.ts +40 -0
  33. package/src/lib/mcp/dispatchMcpRequest.ts +294 -0
  34. package/src/lib/mcp/mcpResourceServerSlot.ts +18 -0
  35. package/src/lib/mcp/types/JsonRpcRequest.ts +12 -0
  36. package/src/lib/mcp/types/JsonRpcResponse.ts +20 -0
  37. package/src/lib/mcp/types/McpResourceContents.ts +10 -0
  38. package/src/lib/mcp/types/McpResourceDescriptor.ts +6 -0
  39. package/src/lib/mcp/types/McpResourceServer.ts +12 -0
  40. package/src/lib/mcp/types/McpServer.ts +9 -0
  41. package/src/lib/mcp/types/McpServerOptions.ts +16 -0
  42. package/src/lib/server/AppModule.ts +25 -0
  43. package/src/lib/server/DELETE.ts +9 -0
  44. package/src/lib/server/GET.ts +9 -0
  45. package/src/lib/server/HEAD.ts +9 -0
  46. package/src/lib/server/HttpError.ts +19 -0
  47. package/src/lib/server/PATCH.ts +9 -0
  48. package/src/lib/server/POST.ts +9 -0
  49. package/src/lib/server/PUT.ts +9 -0
  50. package/src/lib/server/cli/buildEnvContent.ts +18 -0
  51. package/src/lib/server/cli/createTarGz.ts +76 -0
  52. package/src/lib/server/cli/handleCliDownload.ts +124 -0
  53. package/src/lib/server/cli/handleCliInstall.ts +20 -0
  54. package/src/lib/server/cli/installScript.ts +29 -0
  55. package/src/lib/server/cli/maxSourceMtime.ts +27 -0
  56. package/src/lib/server/error.ts +56 -0
  57. package/src/lib/server/json.ts +28 -0
  58. package/src/lib/server/jsonl.ts +40 -0
  59. package/src/lib/server/prompt.ts +30 -0
  60. package/src/lib/server/prompts/definePrompt.ts +20 -0
  61. package/src/lib/server/prompts/promptRegistry.ts +9 -0
  62. package/src/lib/server/prompts/registerPrompt.ts +6 -0
  63. package/src/lib/server/prompts/types/Prompt.ts +14 -0
  64. package/src/lib/server/prompts/types/PromptMessage.ts +10 -0
  65. package/src/lib/server/prompts/types/PromptOptions.ts +17 -0
  66. package/src/lib/server/prompts/types/PromptRegistryEntry.ts +15 -0
  67. package/src/lib/server/prompts/types/PromptRoutes.ts +10 -0
  68. package/src/lib/server/redirect.ts +37 -0
  69. package/src/lib/server/request.ts +18 -0
  70. package/src/lib/server/rpc/defineVerb.ts +103 -0
  71. package/src/lib/server/rpc/parseArgs.ts +60 -0
  72. package/src/lib/server/rpc/registerVerb.ts +6 -0
  73. package/src/lib/server/rpc/types/HttpVerb.ts +1 -0
  74. package/src/lib/server/rpc/types/RawRemoteFunction.ts +13 -0
  75. package/src/lib/server/rpc/types/RemoteFunction.ts +35 -0
  76. package/src/lib/server/rpc/types/RemoteHandler.ts +22 -0
  77. package/src/lib/server/rpc/types/RemoteRoutes.ts +13 -0
  78. package/src/lib/server/rpc/types/StandardSchemaV1.ts +57 -0
  79. package/src/lib/server/rpc/types/TypedResponse.ts +18 -0
  80. package/src/lib/server/rpc/types/VerbHelper.ts +39 -0
  81. package/src/lib/server/rpc/types/VerbRegistryEntry.ts +17 -0
  82. package/src/lib/server/rpc/unprocessed.ts +14 -0
  83. package/src/lib/server/rpc/verbRegistry.ts +11 -0
  84. package/src/lib/server/runtime/buildOpenApiSpec.ts +66 -0
  85. package/src/lib/server/runtime/cacheControlForAsset.ts +17 -0
  86. package/src/lib/server/runtime/containsTraversal.ts +37 -0
  87. package/src/lib/server/runtime/createPublicAssetServer.ts +66 -0
  88. package/src/lib/server/runtime/createServer.ts +555 -0
  89. package/src/lib/server/runtime/getActiveServer.ts +6 -0
  90. package/src/lib/server/runtime/mimeForExtension.ts +20 -0
  91. package/src/lib/server/runtime/registryManifests.ts +48 -0
  92. package/src/lib/server/runtime/requestContext.ts +5 -0
  93. package/src/lib/server/runtime/safeJsonForScript.ts +17 -0
  94. package/src/lib/server/runtime/serializeCacheSnapshot.ts +84 -0
  95. package/src/lib/server/runtime/serverSlot.ts +13 -0
  96. package/src/lib/server/runtime/setActiveServer.ts +6 -0
  97. package/src/lib/server/runtime/streamFromIterator.ts +76 -0
  98. package/src/lib/server/runtime/types/Assets.ts +1 -0
  99. package/src/lib/server/runtime/types/CompileTarget.ts +6 -0
  100. package/src/lib/server/runtime/types/RequestStore.ts +15 -0
  101. package/src/lib/server/runtime/types/SvelteConfig.ts +5 -0
  102. package/src/lib/server/server.ts +19 -0
  103. package/src/lib/server/socket.ts +31 -0
  104. package/src/lib/server/sockets/createSocketDispatcher.ts +267 -0
  105. package/src/lib/server/sockets/defineSocket.ts +160 -0
  106. package/src/lib/server/sockets/lookupSocket.ts +6 -0
  107. package/src/lib/server/sockets/registerSocket.ts +6 -0
  108. package/src/lib/server/sockets/socketRegistry.ts +9 -0
  109. package/src/lib/server/sockets/types/Socket.ts +21 -0
  110. package/src/lib/server/sockets/types/SocketClientFrame.ts +18 -0
  111. package/src/lib/server/sockets/types/SocketOptions.ts +22 -0
  112. package/src/lib/server/sockets/types/SocketRegistryEntry.ts +18 -0
  113. package/src/lib/server/sockets/types/SocketRoutes.ts +10 -0
  114. package/src/lib/server/sockets/types/SocketServerFrame.ts +15 -0
  115. package/src/lib/server/sse.ts +47 -0
  116. package/src/lib/shared/activeCacheStore.ts +20 -0
  117. package/src/lib/shared/buildRpcRequest.ts +61 -0
  118. package/src/lib/shared/cacheControlValues.ts +8 -0
  119. package/src/lib/shared/cacheStoreSlot.ts +16 -0
  120. package/src/lib/shared/canonicalJson.ts +24 -0
  121. package/src/lib/shared/commandNameForUrl.ts +17 -0
  122. package/src/lib/shared/createCacheStore.ts +42 -0
  123. package/src/lib/shared/createPushIterator.ts +77 -0
  124. package/src/lib/shared/createRemoteFunction.ts +89 -0
  125. package/src/lib/shared/decodeResponse.ts +47 -0
  126. package/src/lib/shared/detectTarget.ts +27 -0
  127. package/src/lib/shared/findExportCallSite.ts +479 -0
  128. package/src/lib/shared/forwardHeaders.ts +28 -0
  129. package/src/lib/shared/getRemoteMeta.ts +5 -0
  130. package/src/lib/shared/isDebugEnabled.ts +23 -0
  131. package/src/lib/shared/jsonSchemaForSchema.ts +38 -0
  132. package/src/lib/shared/keyForRemoteCall.ts +38 -0
  133. package/src/lib/shared/loadSvelteConfig.ts +18 -0
  134. package/src/lib/shared/log.ts +104 -0
  135. package/src/lib/shared/nearestLayoutPrefix.ts +36 -0
  136. package/src/lib/shared/normalizeTarget.ts +10 -0
  137. package/src/lib/shared/pageUrlForFile.ts +14 -0
  138. package/src/lib/shared/parseRouteSegments.ts +22 -0
  139. package/src/lib/shared/preparePromptModule.ts +36 -0
  140. package/src/lib/shared/prepareRpcModule.ts +51 -0
  141. package/src/lib/shared/prepareSocketModule.ts +37 -0
  142. package/src/lib/shared/programNameForPackage.ts +14 -0
  143. package/src/lib/shared/promptNameForFile.ts +10 -0
  144. package/src/lib/shared/recordRemoteMeta.ts +5 -0
  145. package/src/lib/shared/remoteMetaStore.ts +16 -0
  146. package/src/lib/shared/resolveClientFlags.ts +18 -0
  147. package/src/lib/shared/rpcUrlForFile.ts +19 -0
  148. package/src/lib/shared/setCacheStoreResolver.ts +6 -0
  149. package/src/lib/shared/socketNameForFile.ts +11 -0
  150. package/src/lib/shared/streamingContentTypes.ts +11 -0
  151. package/src/lib/shared/stripImport.ts +27 -0
  152. package/src/lib/shared/subscribableFromResponse.ts +333 -0
  153. package/src/lib/shared/toBunRoutePattern.ts +28 -0
  154. package/src/lib/shared/types/CacheEntry.ts +16 -0
  155. package/src/lib/shared/types/CacheOptions.ts +10 -0
  156. package/src/lib/shared/types/CacheSnapshotEntry.ts +15 -0
  157. package/src/lib/shared/types/CacheStore.ts +15 -0
  158. package/src/lib/shared/types/ClientFlags.ts +11 -0
  159. package/src/lib/shared/types/Subscribable.ts +15 -0
  160. package/src/lib/shared/writeRoutesDts.ts +64 -0
  161. package/src/preload.ts +20 -0
  162. package/src/scaffold.ts +92 -0
  163. package/src/serverEntry.ts +47 -0
  164. package/src/sveltePlugin.ts +58 -0
  165. package/src/tailwindStylePreprocessor.ts +62 -0
  166. package/template/package.json +16 -0
  167. package/template/src/app.ts +23 -0
  168. package/template/src/browser/app.css +21 -0
  169. package/template/src/browser/app.html +24 -0
  170. package/template/src/browser/pages/about/page.svelte +5 -0
  171. package/template/src/browser/pages/layout.svelte +26 -0
  172. package/template/src/browser/pages/page.svelte +20 -0
  173. package/template/src/cli/banner.txt +3 -0
  174. package/template/src/cli/footer.txt +1 -0
  175. package/template/src/server/rpc/getHello.ts +33 -0
  176. package/template/svelte.config.js +12 -0
  177. package/template/tsconfig.json +18 -0
  178. 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
+ }