@briancray/belte 0.1.0 → 0.2.1

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 (103) hide show
  1. package/bin/belte.ts +25 -12
  2. package/package.json +2 -1
  3. package/src/appEntry.ts +124 -0
  4. package/src/belteResolverPlugin.ts +236 -202
  5. package/src/build.ts +6 -67
  6. package/src/buildCli.ts +36 -63
  7. package/src/buildDisconnected.ts +127 -0
  8. package/src/bundleApp.ts +123 -0
  9. package/src/bundleDisconnectedEntry.ts +17 -0
  10. package/src/cliEntry.ts +3 -9
  11. package/src/compile.ts +4 -15
  12. package/src/controlServerWorker.ts +261 -0
  13. package/src/dedupeSveltePlugin.ts +66 -0
  14. package/src/discoveryEntry.ts +12 -11
  15. package/src/lib/browser/cache.ts +3 -6
  16. package/src/lib/browser/page.svelte.ts +19 -21
  17. package/src/lib/browser/socketChannel.ts +11 -1
  18. package/src/lib/browser/types/Pages.ts +1 -1
  19. package/src/lib/bundle/BundleMenu.ts +11 -0
  20. package/src/lib/bundle/BundleMenuItem.ts +24 -0
  21. package/src/lib/bundle/BundleWindow.ts +20 -0
  22. package/src/lib/bundle/bindConnectedFlag.ts +29 -0
  23. package/src/lib/bundle/bindRequestNavigate.ts +31 -0
  24. package/src/lib/bundle/buildWebviewLib.ts +111 -0
  25. package/src/lib/bundle/disconnected.css +9 -0
  26. package/src/lib/bundle/disconnected.svelte +192 -0
  27. package/src/lib/bundle/ensureWebviewLib.ts +20 -0
  28. package/src/lib/bundle/exitWithParent.ts +28 -0
  29. package/src/lib/bundle/findFreePort.ts +14 -0
  30. package/src/lib/bundle/infoPlist.ts +46 -0
  31. package/src/lib/bundle/installMacMenu.ts +39 -0
  32. package/src/lib/bundle/listenLocalControlServer.ts +19 -0
  33. package/src/lib/bundle/native/belteMenu.mm +298 -0
  34. package/src/lib/bundle/native/webview.h +4557 -0
  35. package/src/lib/bundle/onMenu.ts +26 -0
  36. package/src/lib/bundle/openWebview.ts +81 -0
  37. package/src/lib/bundle/pngToIcns.ts +47 -0
  38. package/src/lib/bundle/probeBelteServer.ts +34 -0
  39. package/src/lib/bundle/resolveServerBinary.ts +12 -0
  40. package/src/lib/bundle/resolveWebviewLib.ts +51 -0
  41. package/src/lib/bundle/serverBinaryFilename.ts +8 -0
  42. package/src/lib/bundle/stableLocalPort.ts +19 -0
  43. package/src/lib/bundle/waitForServer.ts +23 -0
  44. package/src/lib/bundle/webviewBuildRevision.ts +9 -0
  45. package/src/lib/bundle/webviewCachePath.ts +23 -0
  46. package/src/lib/bundle/webviewLibName.ts +11 -0
  47. package/src/lib/bundle/webviewVersion.ts +7 -0
  48. package/src/lib/cli/createClient.ts +34 -36
  49. package/src/lib/cli/printHelp.ts +45 -2
  50. package/src/lib/cli/runCli.ts +12 -3
  51. package/src/lib/mcp/createMcpResourceServer.ts +1 -1
  52. package/src/lib/mcp/dispatchMcpRequest.ts +53 -73
  53. package/src/lib/server/AppModule.ts +2 -2
  54. package/src/lib/server/cli/handleCliDownload.ts +4 -5
  55. package/src/lib/server/cli/handleCliInstall.ts +17 -0
  56. package/src/lib/server/error.ts +23 -9
  57. package/src/lib/server/json.ts +5 -5
  58. package/src/lib/server/jsonl.ts +10 -5
  59. package/src/lib/server/prompts/definePrompt.ts +6 -6
  60. package/src/lib/server/prompts/renderPromptTemplate.ts +16 -0
  61. package/src/lib/server/prompts/types/Prompt.ts +8 -9
  62. package/src/lib/server/prompts/types/PromptOptions.ts +7 -12
  63. package/src/lib/server/prompts/types/PromptRegistryEntry.ts +3 -5
  64. package/src/lib/server/prompts/types/PromptRoutes.ts +4 -4
  65. package/src/lib/server/redirect.ts +13 -8
  66. package/src/lib/server/rpc/defineVerb.ts +4 -3
  67. package/src/lib/server/rpc/findVerbByCommandName.ts +18 -0
  68. package/src/lib/server/rpc/types/RemoteFunction.ts +1 -1
  69. package/src/lib/server/rpc/types/RemoteHandler.ts +4 -0
  70. package/src/lib/server/runtime/acceptsZstd.ts +8 -0
  71. package/src/lib/server/runtime/buildOpenApiSpec.ts +2 -0
  72. package/src/lib/server/runtime/cacheControlForAsset.ts +7 -2
  73. package/src/lib/server/runtime/createAssetHeaderCache.ts +35 -0
  74. package/src/lib/server/runtime/createPublicAssetServer.ts +6 -20
  75. package/src/lib/server/runtime/createServer.ts +50 -58
  76. package/src/lib/server/runtime/registryManifests.ts +33 -15
  77. package/src/lib/server/runtime/types/RequestStore.ts +2 -3
  78. package/src/lib/server/runtime/withResponseDefaults.ts +24 -0
  79. package/src/lib/server/sockets/createSocketDispatcher.ts +10 -7
  80. package/src/lib/server/sse.ts +10 -5
  81. package/src/lib/shared/belteImportName.test.ts +58 -0
  82. package/src/lib/shared/belteImportName.ts +45 -0
  83. package/src/lib/shared/beltePackageName.ts +7 -0
  84. package/src/lib/shared/cacheControlValues.ts +10 -2
  85. package/src/lib/shared/canonicalJson.ts +1 -5
  86. package/src/lib/shared/createCacheStore.ts +29 -20
  87. package/src/lib/shared/exitOnBuildFailure.ts +17 -0
  88. package/src/lib/shared/fileStem.ts +9 -0
  89. package/src/lib/shared/jsonSchemaForPromptArguments.ts +29 -0
  90. package/src/lib/shared/keyForRemoteCall.ts +7 -5
  91. package/src/lib/shared/parsePromptMarkdown.ts +34 -0
  92. package/src/lib/shared/prepareRpcModule.ts +14 -4
  93. package/src/lib/shared/prepareSocketModule.ts +16 -2
  94. package/src/lib/shared/promptNameForFile.ts +5 -5
  95. package/src/lib/shared/subscribableFromResponse.ts +104 -215
  96. package/src/lib/shared/types/PromptArgument.ts +12 -0
  97. package/src/lib/shared/writeRoutesDts.ts +5 -7
  98. package/src/serverBuildPlugins.ts +25 -0
  99. package/src/serverEntry.ts +4 -0
  100. package/template/package.json +3 -2
  101. package/src/lib/server/prompt.ts +0 -30
  102. package/src/lib/server/prompts/types/PromptMessage.ts +0 -10
  103. package/src/lib/shared/preparePromptModule.ts +0 -36
@@ -2,9 +2,12 @@
2
2
  import { existsSync, statSync } from 'node:fs'
3
3
  import type { BunPlugin } from 'bun'
4
4
  import { Glob } from 'bun'
5
+ import { belteImportName } from './lib/shared/belteImportName.ts'
6
+ import { fileStem } from './lib/shared/fileStem.ts'
7
+ import { jsonSchemaForPromptArguments } from './lib/shared/jsonSchemaForPromptArguments.ts'
5
8
  import { log } from './lib/shared/log.ts'
6
9
  import { pageUrlForFile } from './lib/shared/pageUrlForFile.ts'
7
- import { preparePromptModule } from './lib/shared/preparePromptModule.ts'
10
+ import { parsePromptMarkdown } from './lib/shared/parsePromptMarkdown.ts'
8
11
  import { prepareRpcModule } from './lib/shared/prepareRpcModule.ts'
9
12
  import { prepareSocketModule } from './lib/shared/prepareSocketModule.ts'
10
13
  import { programNameForPackage } from './lib/shared/programNameForPackage.ts'
@@ -57,6 +60,17 @@ function escapeRegex(value: string): string {
57
60
  return value.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&')
58
61
  }
59
62
 
63
+ /* Memoises a zero-arg async producer so repeat calls reuse the first in-flight promise. */
64
+ function once<T>(produce: () => Promise<T>): () => Promise<T> {
65
+ let promise: Promise<T> | undefined
66
+ return () => {
67
+ if (!promise) {
68
+ promise = produce()
69
+ }
70
+ return promise
71
+ }
72
+ }
73
+
60
74
  /*
61
75
  Bun plugin that wires every virtual import belte produces at build time:
62
76
  - `belte:rpc` — { rpcUrl: () => import(rpc-module) } HTTP-verb manifest
@@ -81,12 +95,10 @@ export function belteResolverPlugin({
81
95
  cwd = process.cwd(),
82
96
  embedAssets = false,
83
97
  target = 'server',
84
- thin,
85
98
  }: {
86
99
  cwd?: string
87
100
  embedAssets?: boolean
88
101
  target?: 'server' | 'client'
89
- thin?: boolean
90
102
  } = {}): BunPlugin {
91
103
  const serverDir = `${cwd}/src/server`
92
104
  const browserDir = `${cwd}/src/browser`
@@ -107,58 +119,37 @@ export function belteResolverPlugin({
107
119
  re-globbing the trees. The shell read is memoised the same way so two
108
120
  passes don't re-read app.html from disk.
109
121
  */
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
- }
122
+ const scanPagesOnce = once(() =>
123
+ scanPages(pagesDir).then(async (scan) => {
124
+ await writeRoutesDts({ cwd, pageFiles: scan.pageFiles })
125
+ return scan
126
+ }),
127
+ )
128
+ const scanRpcOnce = once(() => scanRpc(rpcDir))
129
+ const scanSocketsOnce = once(() => scanSockets(socketsDir))
130
+ const scanPromptsOnce = once(() => scanPrompts(promptsDir))
131
+ const loadShellOnce = once(() => loadShell(cwd))
132
+ /*
133
+ The bare specifier the project imports belte under (canonical
134
+ `@briancray/belte` or a package alias). Resolved once from the project's
135
+ package.json and threaded into every generated module so the codegen's
136
+ imports resolve regardless of which install style the project uses.
137
+ */
138
+ const belteImportNameOnce = once(() => belteImportName(cwd))
148
139
 
149
140
  const rpcFilter = new RegExp(`^${escapeRegex(rpcDir)}/.*\\.ts$`)
150
141
  const socketsFilter = new RegExp(`^${escapeRegex(socketsDir)}/.*\\.ts$`)
151
- const promptsFilter = new RegExp(`^${escapeRegex(promptsDir)}/.*\\.ts$`)
142
+ const promptsFilter = new RegExp(`^${escapeRegex(promptsDir)}/.*\\.md$`)
152
143
 
153
144
  return {
154
145
  name: 'belte-resolver',
155
146
  setup(build) {
156
147
  build.onResolve(
157
148
  {
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$/,
149
+ filter: /\/_virtual\/(rpc|sockets|prompts|pages|layouts|app|mcp-resources|mcp|assets|public-assets|shell|app-info|cli-manifest|cli-name|cli-chrome|bundle-window|bundle-disconnected-component|bundle-disconnected)\.ts$/,
159
150
  },
160
151
  (args) => {
161
- const name = args.path.split('/').pop()?.replace('.ts', '')
152
+ const name = fileStem(args.path)
162
153
  if (!name) {
163
154
  return undefined
164
155
  }
@@ -172,30 +163,19 @@ export function belteResolverPlugin({
172
163
  `$browser/pages/...`, `$mcp/prompts/...`, `$mcp/resources/...`.
173
164
  `lib/` is userland — projects declare their own lib aliases.
174
165
  */
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
- })
166
+ const dirAliases: Record<string, string> = {
167
+ $server: serverDir,
168
+ $browser: browserDir,
169
+ $shared: sharedDir,
170
+ $mcp: mcpDir,
171
+ $cli: cliDir,
172
+ }
173
+ for (const [alias, baseDir] of Object.entries(dirAliases)) {
174
+ build.onResolve({ filter: new RegExp(`^\\${alias}(\\/.*)?$`) }, (args) => {
175
+ const subpath = args.path.slice(alias.length)
176
+ return { path: resolveExtension(subpath ? `${baseDir}${subpath}` : baseDir) }
177
+ })
178
+ }
199
179
 
200
180
  build.onLoad({ filter: rpcFilter }, async (args) => {
201
181
  if (!args.path.startsWith(`${rpcDir}/`)) {
@@ -204,13 +184,14 @@ export function belteResolverPlugin({
204
184
  const relativePath = args.path.slice(rpcDir.length + 1)
205
185
  const source = await Bun.file(args.path).text()
206
186
  const url = rpcUrlForFile(relativePath)
207
- const prepared = prepareRpcModule(source)
187
+ const importName = await belteImportNameOnce()
188
+ const prepared = prepareRpcModule(source, importName)
208
189
  if (!prepared) {
209
190
  throw new Error(
210
191
  `[belte] src/server/rpc/${relativePath} has no \`export const <name> = <VERB>(...)\` — every $rpc module must declare exactly one remote function`,
211
192
  )
212
193
  }
213
- const expectedName = relativePath.replace(/\.ts$/, '').split('/').pop() ?? ''
194
+ const expectedName = fileStem(relativePath)
214
195
  if (prepared.exportName !== expectedName) {
215
196
  throw new Error(
216
197
  `[belte] src/server/rpc/${relativePath} exports \`${prepared.exportName}\` but the filename expects \`${expectedName}\` — the export name must match the file's stem`,
@@ -224,7 +205,7 @@ export function belteResolverPlugin({
224
205
  so page imports resolve identically on both sides.
225
206
  */
226
207
  if (target === 'client') {
227
- const contents = `import { remoteProxy as __belteRemoteProxy__ } from 'belte/browser/remoteProxy';
208
+ const contents = `import { remoteProxy as __belteRemoteProxy__ } from '${importName}/browser/remoteProxy';
228
209
  export const ${prepared.exportName} = __belteRemoteProxy__(${JSON.stringify(prepared.verb)}, ${JSON.stringify(url)});
229
210
  `
230
211
  return { contents, loader: 'ts' }
@@ -239,7 +220,7 @@ export const ${prepared.exportName} = __belteRemoteProxy__(${JSON.stringify(prep
239
220
  tokenizer-driven so `GET` mentions inside strings and
240
221
  comments are left alone.
241
222
  */
242
- const banner = `import { defineVerb as __belteDefineVerb__ } from 'belte/server/rpc/defineVerb';
223
+ const banner = `import { defineVerb as __belteDefineVerb__ } from '${importName}/server/rpc/defineVerb';
243
224
  `
244
225
  return { contents: `${banner}${prepared.rewriteForServer(url)}`, loader: 'ts' }
245
226
  })
@@ -251,13 +232,14 @@ export const ${prepared.exportName} = __belteRemoteProxy__(${JSON.stringify(prep
251
232
  const relativePath = args.path.slice(socketsDir.length + 1)
252
233
  const source = await Bun.file(args.path).text()
253
234
  const name = socketNameForFile(relativePath)
254
- const prepared = prepareSocketModule(source)
235
+ const importName = await belteImportNameOnce()
236
+ const prepared = prepareSocketModule(source, importName)
255
237
  if (!prepared) {
256
238
  throw new Error(
257
239
  `[belte] src/server/sockets/${relativePath} has no \`export const <name> = socket(...)\` — every $sockets module must declare exactly one socket`,
258
240
  )
259
241
  }
260
- const expectedName = relativePath.replace(/\.ts$/, '').split('/').pop() ?? ''
242
+ const expectedName = fileStem(relativePath)
261
243
  if (prepared.exportName !== expectedName) {
262
244
  throw new Error(
263
245
  `[belte] src/server/sockets/${relativePath} exports \`${prepared.exportName}\` but the filename expects \`${expectedName}\` — the export name must match the file's stem`,
@@ -269,12 +251,12 @@ export const ${prepared.exportName} = __belteRemoteProxy__(${JSON.stringify(prep
269
251
  clientPublish) are server-side state and don't
270
252
  affect the client's wire behaviour.
271
253
  */
272
- const contents = `import { socketProxy as __belteSocketProxy__ } from 'belte/browser/socketProxy';
254
+ const contents = `import { socketProxy as __belteSocketProxy__ } from '${importName}/browser/socketProxy';
273
255
  export const ${prepared.exportName} = __belteSocketProxy__(${JSON.stringify(name)});
274
256
  `
275
257
  return { contents, loader: 'ts' }
276
258
  }
277
- const banner = `import { defineSocket as __belteDefineSocket__ } from 'belte/server/sockets/defineSocket';
259
+ const banner = `import { defineSocket as __belteDefineSocket__ } from '${importName}/server/sockets/defineSocket';
278
260
  `
279
261
  return {
280
262
  contents: `${banner}${prepared.rewriteForServer(name)}`,
@@ -290,32 +272,41 @@ export const ${prepared.exportName} = __belteSocketProxy__(${JSON.stringify(name
290
272
  Prompts are MCP-only — no client-side counterpart. The
291
273
  client bundle never imports a prompts module, but emit an
292
274
  empty stub for the client target defensively so a stray
293
- import can't drag the render body into the browser bundle.
275
+ import can't drag the prompt body into the browser bundle.
294
276
  */
295
277
  if (target === 'client') {
296
278
  return { contents: 'export {}', loader: 'ts' }
297
279
  }
280
+ /*
281
+ Server target: a `.md` prompt is data, not code. Parse the
282
+ frontmatter (description + arguments) and body once, then
283
+ generate a module that registers the prompt via definePrompt
284
+ — the body is embedded as a string literal and the render
285
+ closure interpolates `{{name}}` placeholders at call time.
286
+ */
298
287
  const relativePath = args.path.slice(promptsDir.length + 1)
299
288
  const source = await Bun.file(args.path).text()
300
289
  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';
290
+ const importName = await belteImportNameOnce()
291
+ const parsed = parsePromptMarkdown(source)
292
+ const jsonSchema = jsonSchemaForPromptArguments(parsed.arguments)
293
+ const optionLines = [
294
+ parsed.description
295
+ ? ` description: ${JSON.stringify(parsed.description)},`
296
+ : undefined,
297
+ jsonSchema ? ` jsonSchema: ${JSON.stringify(jsonSchema)},` : undefined,
298
+ ` render: (args) => __belteRenderPromptTemplate__(__template__, args),`,
299
+ ]
300
+ .filter((line) => line !== undefined)
301
+ .join('\n')
302
+ const contents = `import { definePrompt as __belteDefinePrompt__ } from '${importName}/server/prompts/definePrompt'
303
+ import { renderPromptTemplate as __belteRenderPromptTemplate__ } from '${importName}/server/prompts/renderPromptTemplate'
304
+ const __template__ = ${JSON.stringify(parsed.body)}
305
+ export const prompt = __belteDefinePrompt__(${JSON.stringify(name)}, {
306
+ ${optionLines}
307
+ })
314
308
  `
315
- return {
316
- contents: `${banner}${prepared.rewriteForServer(name)}`,
317
- loader: 'ts',
318
- }
309
+ return { contents, loader: 'ts' }
319
310
  })
320
311
 
321
312
  build.onLoad({ filter: /.*/, namespace: NS }, async (args) => {
@@ -474,6 +465,71 @@ export const ${prepared.exportName} = __belteSocketProxy__(${JSON.stringify(name
474
465
  return { contents: `export default ${JSON.stringify(name)}`, loader: 'js' }
475
466
  }
476
467
 
468
+ if (args.path === 'belte:bundle-window') {
469
+ /*
470
+ Optional bundle window config (title/size/menu) baked into
471
+ the bundled launcher. Re-exports the default from
472
+ src/bundle/window.ts when present; otherwise an empty
473
+ object so the launcher falls back to its defaults.
474
+ */
475
+ const userFile = `${cwd}/src/bundle/window.ts`
476
+ if (existsSync(userFile)) {
477
+ log.info('using custom src/bundle/window.ts')
478
+ return {
479
+ contents: `export { default } from ${JSON.stringify(userFile)}`,
480
+ loader: 'js',
481
+ }
482
+ }
483
+ return { contents: 'export default {}', loader: 'js' }
484
+ }
485
+
486
+ if (args.path === 'belte:bundle-disconnected') {
487
+ /*
488
+ The connect screen HTML baked into the launcher. buildDisconnected
489
+ writes `${cwd}/dist/bundle-disconnected.html`; this virtual splices
490
+ it in as a string export. A minimal inline fallback keeps the
491
+ launcher buildable when the file is missing (the screen still loads,
492
+ just unstyled) — bundleApp always builds it first.
493
+ */
494
+ const htmlPath = `${cwd}/dist/bundle-disconnected.html`
495
+ if (!existsSync(htmlPath)) {
496
+ const fallback =
497
+ '<!doctype html><html><body><div id="app">belte</div></body></html>'
498
+ return {
499
+ contents: `export const disconnectedHtml = ${JSON.stringify(fallback)}`,
500
+ loader: 'js',
501
+ }
502
+ }
503
+ const html = await Bun.file(htmlPath).text()
504
+ return {
505
+ contents: `export const disconnectedHtml = ${JSON.stringify(html)}`,
506
+ loader: 'js',
507
+ }
508
+ }
509
+
510
+ if (args.path === 'belte:bundle-disconnected-component') {
511
+ /*
512
+ The Svelte component the connect-screen build mounts: the project's
513
+ src/bundle/disconnected.svelte override when present, otherwise the
514
+ lib default. Re-exports the default like belte:bundle-window; the
515
+ svelte loader plugin compiles the .svelte target either way.
516
+ */
517
+ const userFile = `${cwd}/src/bundle/disconnected.svelte`
518
+ if (existsSync(userFile)) {
519
+ log.info('using custom src/bundle/disconnected.svelte')
520
+ return {
521
+ contents: `export { default } from ${JSON.stringify(userFile)}`,
522
+ loader: 'js',
523
+ }
524
+ }
525
+ const defaultFile = new URL('./lib/bundle/disconnected.svelte', import.meta.url)
526
+ .pathname
527
+ return {
528
+ contents: `export { default } from ${JSON.stringify(defaultFile)}`,
529
+ loader: 'js',
530
+ }
531
+ }
532
+
477
533
  if (args.path === 'belte:cli-chrome') {
478
534
  /*
479
535
  Optional CLI help chrome baked into the binary: src/cli/
@@ -522,30 +578,6 @@ export const footer = ${JSON.stringify(footer)}
522
578
  }
523
579
  }
524
580
 
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
581
  if (args.path === 'belte:mcp') {
550
582
  /*
551
583
  The MCP server is fully framework-generated — tools from
@@ -553,9 +585,9 @@ export const footer = ${JSON.stringify(footer)}
553
585
  from src/mcp/resources. createMcpServer is internal; there
554
586
  is no user-authored server module.
555
587
  */
588
+ const importName = await belteImportNameOnce()
556
589
  return {
557
- contents:
558
- "import { createMcpServer } from 'belte/mcp/createMcpServer'\nexport default createMcpServer()\n",
590
+ contents: `import { createMcpServer } from '${importName}/mcp/createMcpServer'\nexport default createMcpServer()\n`,
559
591
  loader: 'js',
560
592
  }
561
593
  }
@@ -568,29 +600,16 @@ export const footer = ${JSON.stringify(footer)}
568
600
  const files = await Array.fromAsync(
569
601
  new Glob('**/*.zst').scan({ cwd: appDir, onlyFiles: true }),
570
602
  )
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
- }
603
+ const contents = await embedZstdDir({
604
+ dir: appDir,
605
+ files,
606
+ keyFor: (file) => `/_app/${file.replace(/\.zst$/, '')}`,
607
+ precompressed: true,
608
+ exportName: 'assets',
609
+ label: 'zstd assets',
610
+ source: 'dist/_app/',
611
+ })
612
+ return { contents, loader: 'js' }
594
613
  }
595
614
 
596
615
  if (args.path === 'belte:public-assets') {
@@ -616,28 +635,16 @@ ${entries.join('\n')}
616
635
  loader: 'js',
617
636
  }
618
637
  }
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
- }
638
+ const contents = await embedZstdDir({
639
+ dir: publicDir,
640
+ files,
641
+ keyFor: (file) => `/${file}`,
642
+ precompressed: false,
643
+ exportName: 'publicAssets',
644
+ label: 'public files',
645
+ source: 'public/',
646
+ })
647
+ return { contents, loader: 'js' }
641
648
  }
642
649
 
643
650
  if (args.path === 'belte:mcp-resources') {
@@ -663,28 +670,16 @@ ${encoded.map((entry) => entry.line).join('\n')}
663
670
  loader: 'js',
664
671
  }
665
672
  }
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
- }
673
+ const contents = await embedZstdDir({
674
+ dir: resourcesDir,
675
+ files,
676
+ keyFor: (file) => file,
677
+ precompressed: false,
678
+ exportName: 'mcpResources',
679
+ label: 'mcp resources',
680
+ source: 'src/mcp/resources/',
681
+ })
682
+ return { contents, loader: 'js' }
688
683
  }
689
684
 
690
685
  if (args.path === 'belte:shell') {
@@ -701,6 +696,54 @@ ${encoded.map((entry) => entry.line).join('\n')}
701
696
  }
702
697
  }
703
698
 
699
+ /*
700
+ Encodes every file in `files` (relative to `dir`) into a base64 zstd map and
701
+ emits `export const <exportName> = { "<key>": _d("<base64>") }`. `keyFor` maps
702
+ a relative path to its lookup key; `precompressed` true means the files are
703
+ already `.zst` on disk (read + base64 as-is), false means compress here at
704
+ level 22. Shared by the belte:assets / belte:public-assets / belte:mcp-resources
705
+ virtuals, which differ only in source dir, key shape, and whether the inputs
706
+ are pre-compressed.
707
+ */
708
+ async function embedZstdDir({
709
+ dir,
710
+ files,
711
+ keyFor,
712
+ precompressed,
713
+ exportName,
714
+ label,
715
+ source,
716
+ }: {
717
+ dir: string
718
+ files: string[]
719
+ keyFor: (file: string) => string
720
+ precompressed: boolean
721
+ exportName: string
722
+ label: string
723
+ source: string
724
+ }): Promise<string> {
725
+ const encoded = await Promise.all(
726
+ files.map(async (file) => {
727
+ const raw = await Bun.file(`${dir}/${file}`).bytes()
728
+ const bytes = precompressed ? raw : await Bun.zstdCompress(raw, { level: 22 })
729
+ return {
730
+ line: ` ${JSON.stringify(keyFor(file))}: _d(${JSON.stringify(bytes.toBase64())}),`,
731
+ bytes: bytes.byteLength,
732
+ }
733
+ }),
734
+ )
735
+ const totalBytes = encoded.reduce((total, entry) => total + entry.bytes, 0)
736
+ const unit = precompressed ? 'KiB' : 'KiB zstd'
737
+ log.info(
738
+ `embedded ${encoded.length} ${label} from ${source} (${(totalBytes / 1024).toFixed(1)} ${unit})`,
739
+ )
740
+ return `const _d = (s) => Uint8Array.fromBase64(s)
741
+ export const ${exportName} = {
742
+ ${encoded.map((entry) => entry.line).join('\n')}
743
+ }
744
+ `
745
+ }
746
+
704
747
  type PagesScan = {
705
748
  pageFiles: string[]
706
749
  layoutFiles: string[]
@@ -765,15 +808,15 @@ async function scanSockets(socketsDir: string): Promise<string[]> {
765
808
  }
766
809
 
767
810
  /*
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.
811
+ Walks src/mcp/prompts once. Each `.md` file declares one MCP prompt
812
+ frontmatter for metadata, body for the template. Returns an empty list
813
+ when the directory doesn't exist so an app without prompts builds the same.
771
814
  */
772
815
  async function scanPrompts(promptsDir: string): Promise<string[]> {
773
816
  if (!existsSync(promptsDir)) {
774
817
  return []
775
818
  }
776
- return await Array.fromAsync(new Glob('**/*.ts').scan({ cwd: promptsDir }))
819
+ return await Array.fromAsync(new Glob('**/*.md').scan({ cwd: promptsDir }))
777
820
  }
778
821
 
779
822
  /*
@@ -810,17 +853,8 @@ async function rewriteHashedClientEntries(shell: string, cwd: string): Promise<s
810
853
  const entries = await Array.fromAsync(
811
854
  new Glob('client-*').scan({ cwd: appDir, onlyFiles: true }),
812
855
  )
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
- }
856
+ const jsEntry = entries.find((file) => /^client-[a-z0-9]+\.js$/i.test(file))
857
+ const cssEntry = entries.find((file) => /^client-[a-z0-9]+\.css$/i.test(file))
824
858
  let result = shell
825
859
  if (jsEntry) {
826
860
  result = result.replace('/_app/client.js', `/_app/${jsEntry}`)