@briancray/belte 0.1.0 → 0.2.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 (98) 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 +217 -194
  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/cacheControlValues.ts +10 -2
  82. package/src/lib/shared/canonicalJson.ts +1 -5
  83. package/src/lib/shared/createCacheStore.ts +29 -20
  84. package/src/lib/shared/exitOnBuildFailure.ts +17 -0
  85. package/src/lib/shared/fileStem.ts +9 -0
  86. package/src/lib/shared/jsonSchemaForPromptArguments.ts +29 -0
  87. package/src/lib/shared/keyForRemoteCall.ts +7 -5
  88. package/src/lib/shared/parsePromptMarkdown.ts +34 -0
  89. package/src/lib/shared/promptNameForFile.ts +5 -5
  90. package/src/lib/shared/subscribableFromResponse.ts +104 -215
  91. package/src/lib/shared/types/PromptArgument.ts +12 -0
  92. package/src/lib/shared/writeRoutesDts.ts +5 -7
  93. package/src/serverBuildPlugins.ts +25 -0
  94. package/src/serverEntry.ts +4 -0
  95. package/template/package.json +2 -1
  96. package/src/lib/server/prompt.ts +0 -30
  97. package/src/lib/server/prompts/types/PromptMessage.ts +0 -10
  98. package/src/lib/shared/preparePromptModule.ts +0 -36
@@ -2,9 +2,11 @@
2
2
  import { existsSync, statSync } from 'node:fs'
3
3
  import type { BunPlugin } from 'bun'
4
4
  import { Glob } from 'bun'
5
+ import { fileStem } from './lib/shared/fileStem.ts'
6
+ import { jsonSchemaForPromptArguments } from './lib/shared/jsonSchemaForPromptArguments.ts'
5
7
  import { log } from './lib/shared/log.ts'
6
8
  import { pageUrlForFile } from './lib/shared/pageUrlForFile.ts'
7
- import { preparePromptModule } from './lib/shared/preparePromptModule.ts'
9
+ import { parsePromptMarkdown } from './lib/shared/parsePromptMarkdown.ts'
8
10
  import { prepareRpcModule } from './lib/shared/prepareRpcModule.ts'
9
11
  import { prepareSocketModule } from './lib/shared/prepareSocketModule.ts'
10
12
  import { programNameForPackage } from './lib/shared/programNameForPackage.ts'
@@ -57,6 +59,17 @@ function escapeRegex(value: string): string {
57
59
  return value.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&')
58
60
  }
59
61
 
62
+ /* Memoises a zero-arg async producer so repeat calls reuse the first in-flight promise. */
63
+ function once<T>(produce: () => Promise<T>): () => Promise<T> {
64
+ let promise: Promise<T> | undefined
65
+ return () => {
66
+ if (!promise) {
67
+ promise = produce()
68
+ }
69
+ return promise
70
+ }
71
+ }
72
+
60
73
  /*
61
74
  Bun plugin that wires every virtual import belte produces at build time:
62
75
  - `belte:rpc` — { rpcUrl: () => import(rpc-module) } HTTP-verb manifest
@@ -81,12 +94,10 @@ export function belteResolverPlugin({
81
94
  cwd = process.cwd(),
82
95
  embedAssets = false,
83
96
  target = 'server',
84
- thin,
85
97
  }: {
86
98
  cwd?: string
87
99
  embedAssets?: boolean
88
100
  target?: 'server' | 'client'
89
- thin?: boolean
90
101
  } = {}): BunPlugin {
91
102
  const serverDir = `${cwd}/src/server`
92
103
  const browserDir = `${cwd}/src/browser`
@@ -107,58 +118,30 @@ export function belteResolverPlugin({
107
118
  re-globbing the trees. The shell read is memoised the same way so two
108
119
  passes don't re-read app.html from disk.
109
120
  */
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
- }
121
+ const scanPagesOnce = once(() =>
122
+ scanPages(pagesDir).then(async (scan) => {
123
+ await writeRoutesDts({ cwd, pageFiles: scan.pageFiles })
124
+ return scan
125
+ }),
126
+ )
127
+ const scanRpcOnce = once(() => scanRpc(rpcDir))
128
+ const scanSocketsOnce = once(() => scanSockets(socketsDir))
129
+ const scanPromptsOnce = once(() => scanPrompts(promptsDir))
130
+ const loadShellOnce = once(() => loadShell(cwd))
148
131
 
149
132
  const rpcFilter = new RegExp(`^${escapeRegex(rpcDir)}/.*\\.ts$`)
150
133
  const socketsFilter = new RegExp(`^${escapeRegex(socketsDir)}/.*\\.ts$`)
151
- const promptsFilter = new RegExp(`^${escapeRegex(promptsDir)}/.*\\.ts$`)
134
+ const promptsFilter = new RegExp(`^${escapeRegex(promptsDir)}/.*\\.md$`)
152
135
 
153
136
  return {
154
137
  name: 'belte-resolver',
155
138
  setup(build) {
156
139
  build.onResolve(
157
140
  {
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$/,
141
+ 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
142
  },
160
143
  (args) => {
161
- const name = args.path.split('/').pop()?.replace('.ts', '')
144
+ const name = fileStem(args.path)
162
145
  if (!name) {
163
146
  return undefined
164
147
  }
@@ -172,30 +155,19 @@ export function belteResolverPlugin({
172
155
  `$browser/pages/...`, `$mcp/prompts/...`, `$mcp/resources/...`.
173
156
  `lib/` is userland — projects declare their own lib aliases.
174
157
  */
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
- })
158
+ const dirAliases: Record<string, string> = {
159
+ $server: serverDir,
160
+ $browser: browserDir,
161
+ $shared: sharedDir,
162
+ $mcp: mcpDir,
163
+ $cli: cliDir,
164
+ }
165
+ for (const [alias, baseDir] of Object.entries(dirAliases)) {
166
+ build.onResolve({ filter: new RegExp(`^\\${alias}(\\/.*)?$`) }, (args) => {
167
+ const subpath = args.path.slice(alias.length)
168
+ return { path: resolveExtension(subpath ? `${baseDir}${subpath}` : baseDir) }
169
+ })
170
+ }
199
171
 
200
172
  build.onLoad({ filter: rpcFilter }, async (args) => {
201
173
  if (!args.path.startsWith(`${rpcDir}/`)) {
@@ -210,7 +182,7 @@ export function belteResolverPlugin({
210
182
  `[belte] src/server/rpc/${relativePath} has no \`export const <name> = <VERB>(...)\` — every $rpc module must declare exactly one remote function`,
211
183
  )
212
184
  }
213
- const expectedName = relativePath.replace(/\.ts$/, '').split('/').pop() ?? ''
185
+ const expectedName = fileStem(relativePath)
214
186
  if (prepared.exportName !== expectedName) {
215
187
  throw new Error(
216
188
  `[belte] src/server/rpc/${relativePath} exports \`${prepared.exportName}\` but the filename expects \`${expectedName}\` — the export name must match the file's stem`,
@@ -257,7 +229,7 @@ export const ${prepared.exportName} = __belteRemoteProxy__(${JSON.stringify(prep
257
229
  `[belte] src/server/sockets/${relativePath} has no \`export const <name> = socket(...)\` — every $sockets module must declare exactly one socket`,
258
230
  )
259
231
  }
260
- const expectedName = relativePath.replace(/\.ts$/, '').split('/').pop() ?? ''
232
+ const expectedName = fileStem(relativePath)
261
233
  if (prepared.exportName !== expectedName) {
262
234
  throw new Error(
263
235
  `[belte] src/server/sockets/${relativePath} exports \`${prepared.exportName}\` but the filename expects \`${expectedName}\` — the export name must match the file's stem`,
@@ -290,32 +262,40 @@ export const ${prepared.exportName} = __belteSocketProxy__(${JSON.stringify(name
290
262
  Prompts are MCP-only — no client-side counterpart. The
291
263
  client bundle never imports a prompts module, but emit an
292
264
  empty stub for the client target defensively so a stray
293
- import can't drag the render body into the browser bundle.
265
+ import can't drag the prompt body into the browser bundle.
294
266
  */
295
267
  if (target === 'client') {
296
268
  return { contents: 'export {}', loader: 'ts' }
297
269
  }
270
+ /*
271
+ Server target: a `.md` prompt is data, not code. Parse the
272
+ frontmatter (description + arguments) and body once, then
273
+ generate a module that registers the prompt via definePrompt
274
+ — the body is embedded as a string literal and the render
275
+ closure interpolates `{{name}}` placeholders at call time.
276
+ */
298
277
  const relativePath = args.path.slice(promptsDir.length + 1)
299
278
  const source = await Bun.file(args.path).text()
300
279
  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';
280
+ const parsed = parsePromptMarkdown(source)
281
+ const jsonSchema = jsonSchemaForPromptArguments(parsed.arguments)
282
+ const optionLines = [
283
+ parsed.description
284
+ ? ` description: ${JSON.stringify(parsed.description)},`
285
+ : undefined,
286
+ jsonSchema ? ` jsonSchema: ${JSON.stringify(jsonSchema)},` : undefined,
287
+ ` render: (args) => __belteRenderPromptTemplate__(__template__, args),`,
288
+ ]
289
+ .filter((line) => line !== undefined)
290
+ .join('\n')
291
+ const contents = `import { definePrompt as __belteDefinePrompt__ } from 'belte/server/prompts/definePrompt'
292
+ import { renderPromptTemplate as __belteRenderPromptTemplate__ } from 'belte/server/prompts/renderPromptTemplate'
293
+ const __template__ = ${JSON.stringify(parsed.body)}
294
+ export const prompt = __belteDefinePrompt__(${JSON.stringify(name)}, {
295
+ ${optionLines}
296
+ })
314
297
  `
315
- return {
316
- contents: `${banner}${prepared.rewriteForServer(name)}`,
317
- loader: 'ts',
318
- }
298
+ return { contents, loader: 'ts' }
319
299
  })
320
300
 
321
301
  build.onLoad({ filter: /.*/, namespace: NS }, async (args) => {
@@ -474,6 +454,71 @@ export const ${prepared.exportName} = __belteSocketProxy__(${JSON.stringify(name
474
454
  return { contents: `export default ${JSON.stringify(name)}`, loader: 'js' }
475
455
  }
476
456
 
457
+ if (args.path === 'belte:bundle-window') {
458
+ /*
459
+ Optional bundle window config (title/size/menu) baked into
460
+ the bundled launcher. Re-exports the default from
461
+ src/bundle/window.ts when present; otherwise an empty
462
+ object so the launcher falls back to its defaults.
463
+ */
464
+ const userFile = `${cwd}/src/bundle/window.ts`
465
+ if (existsSync(userFile)) {
466
+ log.info('using custom src/bundle/window.ts')
467
+ return {
468
+ contents: `export { default } from ${JSON.stringify(userFile)}`,
469
+ loader: 'js',
470
+ }
471
+ }
472
+ return { contents: 'export default {}', loader: 'js' }
473
+ }
474
+
475
+ if (args.path === 'belte:bundle-disconnected') {
476
+ /*
477
+ The connect screen HTML baked into the launcher. buildDisconnected
478
+ writes `${cwd}/dist/bundle-disconnected.html`; this virtual splices
479
+ it in as a string export. A minimal inline fallback keeps the
480
+ launcher buildable when the file is missing (the screen still loads,
481
+ just unstyled) — bundleApp always builds it first.
482
+ */
483
+ const htmlPath = `${cwd}/dist/bundle-disconnected.html`
484
+ if (!existsSync(htmlPath)) {
485
+ const fallback =
486
+ '<!doctype html><html><body><div id="app">belte</div></body></html>'
487
+ return {
488
+ contents: `export const disconnectedHtml = ${JSON.stringify(fallback)}`,
489
+ loader: 'js',
490
+ }
491
+ }
492
+ const html = await Bun.file(htmlPath).text()
493
+ return {
494
+ contents: `export const disconnectedHtml = ${JSON.stringify(html)}`,
495
+ loader: 'js',
496
+ }
497
+ }
498
+
499
+ if (args.path === 'belte:bundle-disconnected-component') {
500
+ /*
501
+ The Svelte component the connect-screen build mounts: the project's
502
+ src/bundle/disconnected.svelte override when present, otherwise the
503
+ lib default. Re-exports the default like belte:bundle-window; the
504
+ svelte loader plugin compiles the .svelte target either way.
505
+ */
506
+ const userFile = `${cwd}/src/bundle/disconnected.svelte`
507
+ if (existsSync(userFile)) {
508
+ log.info('using custom src/bundle/disconnected.svelte')
509
+ return {
510
+ contents: `export { default } from ${JSON.stringify(userFile)}`,
511
+ loader: 'js',
512
+ }
513
+ }
514
+ const defaultFile = new URL('./lib/bundle/disconnected.svelte', import.meta.url)
515
+ .pathname
516
+ return {
517
+ contents: `export { default } from ${JSON.stringify(defaultFile)}`,
518
+ loader: 'js',
519
+ }
520
+ }
521
+
477
522
  if (args.path === 'belte:cli-chrome') {
478
523
  /*
479
524
  Optional CLI help chrome baked into the binary: src/cli/
@@ -522,30 +567,6 @@ export const footer = ${JSON.stringify(footer)}
522
567
  }
523
568
  }
524
569
 
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
570
  if (args.path === 'belte:mcp') {
550
571
  /*
551
572
  The MCP server is fully framework-generated — tools from
@@ -568,29 +589,16 @@ export const footer = ${JSON.stringify(footer)}
568
589
  const files = await Array.fromAsync(
569
590
  new Glob('**/*.zst').scan({ cwd: appDir, onlyFiles: true }),
570
591
  )
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
- }
592
+ const contents = await embedZstdDir({
593
+ dir: appDir,
594
+ files,
595
+ keyFor: (file) => `/_app/${file.replace(/\.zst$/, '')}`,
596
+ precompressed: true,
597
+ exportName: 'assets',
598
+ label: 'zstd assets',
599
+ source: 'dist/_app/',
600
+ })
601
+ return { contents, loader: 'js' }
594
602
  }
595
603
 
596
604
  if (args.path === 'belte:public-assets') {
@@ -616,28 +624,16 @@ ${entries.join('\n')}
616
624
  loader: 'js',
617
625
  }
618
626
  }
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
- }
627
+ const contents = await embedZstdDir({
628
+ dir: publicDir,
629
+ files,
630
+ keyFor: (file) => `/${file}`,
631
+ precompressed: false,
632
+ exportName: 'publicAssets',
633
+ label: 'public files',
634
+ source: 'public/',
635
+ })
636
+ return { contents, loader: 'js' }
641
637
  }
642
638
 
643
639
  if (args.path === 'belte:mcp-resources') {
@@ -663,28 +659,16 @@ ${encoded.map((entry) => entry.line).join('\n')}
663
659
  loader: 'js',
664
660
  }
665
661
  }
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
- }
662
+ const contents = await embedZstdDir({
663
+ dir: resourcesDir,
664
+ files,
665
+ keyFor: (file) => file,
666
+ precompressed: false,
667
+ exportName: 'mcpResources',
668
+ label: 'mcp resources',
669
+ source: 'src/mcp/resources/',
670
+ })
671
+ return { contents, loader: 'js' }
688
672
  }
689
673
 
690
674
  if (args.path === 'belte:shell') {
@@ -701,6 +685,54 @@ ${encoded.map((entry) => entry.line).join('\n')}
701
685
  }
702
686
  }
703
687
 
688
+ /*
689
+ Encodes every file in `files` (relative to `dir`) into a base64 zstd map and
690
+ emits `export const <exportName> = { "<key>": _d("<base64>") }`. `keyFor` maps
691
+ a relative path to its lookup key; `precompressed` true means the files are
692
+ already `.zst` on disk (read + base64 as-is), false means compress here at
693
+ level 22. Shared by the belte:assets / belte:public-assets / belte:mcp-resources
694
+ virtuals, which differ only in source dir, key shape, and whether the inputs
695
+ are pre-compressed.
696
+ */
697
+ async function embedZstdDir({
698
+ dir,
699
+ files,
700
+ keyFor,
701
+ precompressed,
702
+ exportName,
703
+ label,
704
+ source,
705
+ }: {
706
+ dir: string
707
+ files: string[]
708
+ keyFor: (file: string) => string
709
+ precompressed: boolean
710
+ exportName: string
711
+ label: string
712
+ source: string
713
+ }): Promise<string> {
714
+ const encoded = await Promise.all(
715
+ files.map(async (file) => {
716
+ const raw = await Bun.file(`${dir}/${file}`).bytes()
717
+ const bytes = precompressed ? raw : await Bun.zstdCompress(raw, { level: 22 })
718
+ return {
719
+ line: ` ${JSON.stringify(keyFor(file))}: _d(${JSON.stringify(bytes.toBase64())}),`,
720
+ bytes: bytes.byteLength,
721
+ }
722
+ }),
723
+ )
724
+ const totalBytes = encoded.reduce((total, entry) => total + entry.bytes, 0)
725
+ const unit = precompressed ? 'KiB' : 'KiB zstd'
726
+ log.info(
727
+ `embedded ${encoded.length} ${label} from ${source} (${(totalBytes / 1024).toFixed(1)} ${unit})`,
728
+ )
729
+ return `const _d = (s) => Uint8Array.fromBase64(s)
730
+ export const ${exportName} = {
731
+ ${encoded.map((entry) => entry.line).join('\n')}
732
+ }
733
+ `
734
+ }
735
+
704
736
  type PagesScan = {
705
737
  pageFiles: string[]
706
738
  layoutFiles: string[]
@@ -765,15 +797,15 @@ async function scanSockets(socketsDir: string): Promise<string[]> {
765
797
  }
766
798
 
767
799
  /*
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.
800
+ Walks src/mcp/prompts once. Each `.md` file declares one MCP prompt
801
+ frontmatter for metadata, body for the template. Returns an empty list
802
+ when the directory doesn't exist so an app without prompts builds the same.
771
803
  */
772
804
  async function scanPrompts(promptsDir: string): Promise<string[]> {
773
805
  if (!existsSync(promptsDir)) {
774
806
  return []
775
807
  }
776
- return await Array.fromAsync(new Glob('**/*.ts').scan({ cwd: promptsDir }))
808
+ return await Array.fromAsync(new Glob('**/*.md').scan({ cwd: promptsDir }))
777
809
  }
778
810
 
779
811
  /*
@@ -810,17 +842,8 @@ async function rewriteHashedClientEntries(shell: string, cwd: string): Promise<s
810
842
  const entries = await Array.fromAsync(
811
843
  new Glob('client-*').scan({ cwd: appDir, onlyFiles: true }),
812
844
  )
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
- }
845
+ const jsEntry = entries.find((file) => /^client-[a-z0-9]+\.js$/i.test(file))
846
+ const cssEntry = entries.find((file) => /^client-[a-z0-9]+\.css$/i.test(file))
824
847
  let result = shell
825
848
  if (jsEntry) {
826
849
  result = result.replace('/_app/client.js', `/_app/${jsEntry}`)
package/src/build.ts CHANGED
@@ -1,68 +1,12 @@
1
1
  import type { BunPlugin } from 'bun'
2
2
  import { belteResolverPlugin } from './belteResolverPlugin.ts'
3
+ import { dedupeSveltePlugin } from './dedupeSveltePlugin.ts'
3
4
  import type { SvelteConfig } from './lib/server/runtime/types/SvelteConfig.ts'
5
+ import { exitOnBuildFailure } from './lib/shared/exitOnBuildFailure.ts'
4
6
  import { loadSvelteConfig } from './lib/shared/loadSvelteConfig.ts'
5
7
  import { log } from './lib/shared/log.ts'
6
8
  import { sveltePlugin } from './sveltePlugin.ts'
7
9
 
8
- type ExportEntry = string | { [condition: string]: ExportEntry }
9
-
10
- /*
11
- Walks a package.json `exports` entry, returning the first leaf string that
12
- matches the supplied condition list in order. Returns undefined when no
13
- branch resolves.
14
- */
15
- function pickExport(entry: ExportEntry, conditions: string[]): string | undefined {
16
- if (typeof entry === 'string') {
17
- return entry
18
- }
19
- for (const condition of conditions) {
20
- if (entry[condition]) {
21
- const resolved = pickExport(entry[condition], conditions)
22
- if (resolved) {
23
- return resolved
24
- }
25
- }
26
- }
27
- return undefined
28
- }
29
-
30
- /*
31
- Forces every `import 'svelte/...'` (from belte's own source, the consumer's
32
- source, or any transitive dep) to resolve against the consumer app's svelte
33
- install, picking the export condition that matches the build target.
34
- Without this, belte's symlinked source can pick up a second svelte from its
35
- install location, ship both runtimes, and break hydration.
36
- */
37
- function dedupeSveltePlugin({ cwd, conditions }: { cwd: string; conditions: string[] }): BunPlugin {
38
- const consumerSvelte = `${cwd}/node_modules/svelte`
39
- return {
40
- name: 'belte-dedupe-svelte',
41
- async setup(build) {
42
- const pkgFile = Bun.file(`${consumerSvelte}/package.json`)
43
- if (!(await pkgFile.exists())) {
44
- return
45
- }
46
- const consumerPackage = (await pkgFile.json()) as {
47
- exports: Record<string, ExportEntry>
48
- }
49
- build.onResolve({ filter: /^svelte(\/.*)?$/ }, (args) => {
50
- const subpath =
51
- args.path === 'svelte' ? '.' : `.${args.path.slice('svelte'.length)}`
52
- const entry = consumerPackage.exports[subpath]
53
- if (!entry) {
54
- return undefined
55
- }
56
- const resolvedFile = pickExport(entry, conditions)
57
- if (!resolvedFile) {
58
- return undefined
59
- }
60
- return { path: `${consumerSvelte}/${resolvedFile.replace(/^\.\//, '')}` }
61
- })
62
- },
63
- }
64
- }
65
-
66
10
  const CLIENT_ENTRY = new URL('./clientEntry.ts', import.meta.url).pathname
67
11
 
68
12
  /*
@@ -118,17 +62,12 @@ export async function build({
118
62
  plugins,
119
63
  })
120
64
 
121
- if (!result.success) {
122
- for (const entry of result.logs) {
123
- log.error(entry)
124
- }
125
- process.exit(1)
126
- }
65
+ exitOnBuildFailure(result)
127
66
 
128
67
  const compressedByteLengths = await Promise.all(
129
68
  result.outputs.map(async (output) => {
130
69
  const bytes = await Bun.file(output.path).bytes()
131
- const compressed = Bun.zstdCompressSync(bytes, { level: 22 })
70
+ const compressed = await Bun.zstdCompress(bytes, { level: 22 })
132
71
  await Bun.write(`${output.path}.zst`, compressed)
133
72
  return compressed.byteLength
134
73
  }),
@@ -138,7 +77,7 @@ export async function build({
138
77
  log.success(
139
78
  `wrote ${result.outputs.length} files to ${outDir} (+${result.outputs.length} .zst, ${(compressedBytes / 1024).toFixed(1)} KiB total)`,
140
79
  )
141
- for (const output of result.outputs) {
80
+ result.outputs.forEach((output) => {
142
81
  log.detail(` - ${output.path}`)
143
- }
82
+ })
144
83
  }