@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
package/src/build.ts ADDED
@@ -0,0 +1,144 @@
1
+ import type { BunPlugin } from 'bun'
2
+ import { belteResolverPlugin } from './belteResolverPlugin.ts'
3
+ import type { SvelteConfig } from './lib/server/runtime/types/SvelteConfig.ts'
4
+ import { loadSvelteConfig } from './lib/shared/loadSvelteConfig.ts'
5
+ import { log } from './lib/shared/log.ts'
6
+ import { sveltePlugin } from './sveltePlugin.ts'
7
+
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
+ const CLIENT_ENTRY = new URL('./clientEntry.ts', import.meta.url).pathname
67
+
68
+ /*
69
+ Builds the client-side bundle into `${cwd}/dist/_app`. Clears the dist
70
+ directory first, then runs Bun.build with the svelte-dedupe plugin, the
71
+ svelte loader, the virtual-module resolver, and (optionally) Tailwind.
72
+ Each emitted file is also written as a zstd-compressed `.zst` sibling
73
+ (level 22 — paid once at build time) so the server can stream the
74
+ precompressed bytes directly when the client supports it, and decompress
75
+ on the fly for older clients. Exits the process on build failure with
76
+ the build logs printed.
77
+ */
78
+ export async function build({
79
+ cwd = process.cwd(),
80
+ svelteConfig,
81
+ minify = true,
82
+ }: {
83
+ cwd?: string
84
+ svelteConfig?: SvelteConfig
85
+ minify?: boolean
86
+ } = {}): Promise<void> {
87
+ const distDir = `${cwd}/dist`
88
+ const outDir = `${distDir}/_app`
89
+
90
+ // shell-rm is the impure boundary for "clear dist" — Bun.$ is first-party
91
+ await Bun.$`rm -rf ${distDir}`.quiet()
92
+
93
+ const config = svelteConfig ?? (await loadSvelteConfig(cwd))
94
+ const plugins: BunPlugin[] = [
95
+ dedupeSveltePlugin({ cwd, conditions: ['browser', 'default'] }),
96
+ sveltePlugin({ generate: 'client', svelteConfig: config }),
97
+ belteResolverPlugin({ cwd, target: 'client' }),
98
+ ]
99
+ try {
100
+ const tailwind = (await import('bun-plugin-tailwind')).default
101
+ plugins.push(tailwind)
102
+ } catch {
103
+ log.warn('bun-plugin-tailwind not installed; building without Tailwind')
104
+ }
105
+
106
+ const result = await Bun.build({
107
+ entrypoints: [CLIENT_ENTRY],
108
+ outdir: outDir,
109
+ target: 'browser',
110
+ splitting: true,
111
+ minify,
112
+ sourcemap: 'linked',
113
+ naming: {
114
+ entry: 'client-[hash].[ext]',
115
+ chunk: '[name]-[hash].[ext]',
116
+ asset: '[name].[ext]',
117
+ },
118
+ plugins,
119
+ })
120
+
121
+ if (!result.success) {
122
+ for (const entry of result.logs) {
123
+ log.error(entry)
124
+ }
125
+ process.exit(1)
126
+ }
127
+
128
+ const compressedByteLengths = await Promise.all(
129
+ result.outputs.map(async (output) => {
130
+ const bytes = await Bun.file(output.path).bytes()
131
+ const compressed = Bun.zstdCompressSync(bytes, { level: 22 })
132
+ await Bun.write(`${output.path}.zst`, compressed)
133
+ return compressed.byteLength
134
+ }),
135
+ )
136
+ const compressedBytes = compressedByteLengths.reduce((total, length) => total + length, 0)
137
+
138
+ log.success(
139
+ `wrote ${result.outputs.length} files to ${outDir} (+${result.outputs.length} .zst, ${(compressedBytes / 1024).toFixed(1)} KiB total)`,
140
+ )
141
+ for (const output of result.outputs) {
142
+ log.detail(` - ${output.path}`)
143
+ }
144
+ }
@@ -0,0 +1,160 @@
1
+ import type { BunPlugin } from 'bun'
2
+ import { belteResolverPlugin } from './belteResolverPlugin.ts'
3
+ import type { CompileTarget } from './lib/server/runtime/types/CompileTarget.ts'
4
+ import { detectTarget } from './lib/shared/detectTarget.ts'
5
+ import { loadSvelteConfig } from './lib/shared/loadSvelteConfig.ts'
6
+ import { log } from './lib/shared/log.ts'
7
+ import { programNameForPackage } from './lib/shared/programNameForPackage.ts'
8
+ import { sveltePlugin } from './sveltePlugin.ts'
9
+
10
+ const DISCOVERY_ENTRY = new URL('./discoveryEntry.ts', import.meta.url).pathname
11
+ const CLI_ENTRY = new URL('./cliEntry.ts', import.meta.url).pathname
12
+
13
+ /*
14
+ Two-pass CLI binary build:
15
+
16
+ 1. Discovery: build the discovery entry into a temporary JS bundle and
17
+ run it. It imports every rpc/socket module so defineVerb /
18
+ defineSocket populate the registries, then prints the CLI manifest
19
+ to stdout. The manifest is written to `dist/cli-manifest.json`.
20
+ 2. Compile: build the CLI binary via `Bun.build({ compile })`. The
21
+ resolver plugin's `belte:cli-manifest` virtual reads the manifest
22
+ JSON written in step 1 and splices it into the bundle.
23
+
24
+ The `thin` flag decides thin vs full (default full):
25
+ - thin: empty `belte:cli-rpcs` virtual — no handlers bundled, the
26
+ manifest is the only RPC surface; requires APP_URL at runtime.
27
+ - full: `belte:cli-rpcs` emits eager imports for every rpc module so the
28
+ verbRegistry is populated and the binary runs in-process (and
29
+ still reaches a remote server when APP_URL is set at runtime).
30
+ `platforms` cross-compiles in either mode; thin per-platform binaries land
31
+ in `dist/cli-thin/<platform>/` (the layout the /__belte/cli download endpoint
32
+ serves), full ones in `dist/cli/<platform>/`.
33
+ */
34
+ export async function buildCli({
35
+ cwd = process.cwd(),
36
+ target = detectTarget(),
37
+ outfile,
38
+ platforms,
39
+ thin: thinOverride,
40
+ }: {
41
+ cwd?: string
42
+ target?: CompileTarget
43
+ outfile?: string
44
+ platforms?: CompileTarget[]
45
+ thin?: boolean
46
+ } = {}): Promise<string[]> {
47
+ const distDir = `${cwd}/dist`
48
+ await Bun.$`mkdir -p ${distDir}`.quiet()
49
+ const manifestPath = `${distDir}/cli-manifest.json`
50
+ const discoveryOut = `${distDir}/_discovery.js`
51
+
52
+ const svelteConfig = await loadSvelteConfig(cwd)
53
+ const isThin = thinOverride ?? false
54
+ const sharedPlugins = (): BunPlugin[] => [
55
+ sveltePlugin({ generate: 'server', svelteConfig }),
56
+ belteResolverPlugin({ cwd, target: 'server', thin: isThin }),
57
+ ]
58
+
59
+ /*
60
+ Step 1 — discovery. Build a runnable bundle, execute it under bun,
61
+ capture stdout. We don't `bun build --compile` here because the
62
+ discovery output is throwaway; a plain JS bundle runs faster.
63
+ */
64
+ const discoveryResult = await Bun.build({
65
+ entrypoints: [DISCOVERY_ENTRY],
66
+ target: 'bun',
67
+ outdir: distDir,
68
+ naming: '_discovery.js',
69
+ plugins: sharedPlugins(),
70
+ })
71
+ if (!discoveryResult.success) {
72
+ for (const entry of discoveryResult.logs) {
73
+ log.error(entry)
74
+ }
75
+ process.exit(1)
76
+ }
77
+
78
+ const proc = Bun.spawn({
79
+ cmd: ['bun', discoveryOut],
80
+ cwd,
81
+ stdio: ['ignore', 'pipe', 'pipe'],
82
+ })
83
+ const [stdout, stderr, exitCode] = await Promise.all([
84
+ new Response(proc.stdout).text(),
85
+ new Response(proc.stderr).text(),
86
+ proc.exited,
87
+ ])
88
+ if (exitCode !== 0) {
89
+ log.error(`discovery exited ${exitCode}:\n${stderr}`)
90
+ process.exit(1)
91
+ }
92
+ await Bun.write(manifestPath, stdout)
93
+ await Bun.$`rm -f ${discoveryOut}`.quiet()
94
+ const entryCount = Object.keys(JSON.parse(stdout) as Record<string, unknown>).length
95
+ log.info(`discovered ${entryCount} cli commands → ${manifestPath}`)
96
+
97
+ /*
98
+ Step 2 — compile. The cliEntry imports the now-populated
99
+ belte:cli-manifest virtual + the eager rpc imports (full mode only,
100
+ empty for thin). bun build --compile emits the standalone binary.
101
+ When `platforms` is set, loops once per target and writes binaries
102
+ into `dist/cli-thin/<platform>/<programName>` (thin — the layout the
103
+ download route expects) or `dist/cli/<platform>/<programName>` (full).
104
+ */
105
+ const programName = await readProgramName(cwd)
106
+
107
+ if (platforms && platforms.length > 0) {
108
+ const platformDir = isThin ? 'cli-thin' : 'cli'
109
+ const outPaths: string[] = []
110
+ for (const platformTarget of platforms) {
111
+ const shortName = platformTarget.replace(/^bun-/, '')
112
+ const suffix = platformTarget.includes('windows') ? '.exe' : ''
113
+ const platformOut = `${distDir}/${platformDir}/${shortName}/${programName}${suffix}`
114
+ await Bun.$`mkdir -p ${`${distDir}/${platformDir}/${shortName}`}`.quiet()
115
+ const result = await Bun.build({
116
+ entrypoints: [CLI_ENTRY],
117
+ target: 'bun',
118
+ compile: { target: platformTarget, outfile: platformOut },
119
+ plugins: sharedPlugins(),
120
+ })
121
+ if (!result.success) {
122
+ for (const entry of result.logs) {
123
+ log.error(entry)
124
+ }
125
+ process.exit(1)
126
+ }
127
+ log.success(`compiled ${isThin ? 'thin' : 'full'} cli binary: ${platformOut}`)
128
+ outPaths.push(platformOut)
129
+ }
130
+ return outPaths
131
+ }
132
+
133
+ const suffix = target.includes('windows') ? '.exe' : ''
134
+ const outPath = outfile ?? `${distDir}/cli${suffix}`
135
+
136
+ const cliResult = await Bun.build({
137
+ entrypoints: [CLI_ENTRY],
138
+ target: 'bun',
139
+ compile: { target, outfile: outPath },
140
+ plugins: sharedPlugins(),
141
+ })
142
+ if (!cliResult.success) {
143
+ for (const entry of cliResult.logs) {
144
+ log.error(entry)
145
+ }
146
+ process.exit(1)
147
+ }
148
+
149
+ log.success(`compiled ${isThin ? 'thin' : 'full'} cli binary: ${outPath} (target: ${target})`)
150
+ return [outPath]
151
+ }
152
+
153
+ async function readProgramName(cwd: string): Promise<string> {
154
+ const pkgFile = Bun.file(`${cwd}/package.json`)
155
+ if (!(await pkgFile.exists())) {
156
+ return 'app'
157
+ }
158
+ const pkg = (await pkgFile.json()) as { name?: string }
159
+ return programNameForPackage(pkg.name)
160
+ }
@@ -0,0 +1,31 @@
1
+ // @ts-expect-error virtual module resolved by belteResolverPlugin
2
+ import { banner, footer } from './_virtual/cli-chrome.ts'
3
+ // @ts-expect-error virtual module resolved by belteResolverPlugin
4
+ import manifest from './_virtual/cli-manifest.ts'
5
+ // @ts-expect-error virtual module resolved by belteResolverPlugin
6
+ import programName from './_virtual/cli-name.ts'
7
+ // @ts-expect-error virtual module resolved by belteResolverPlugin — side-effect import that
8
+ // populates verbRegistry for in-process mode on full builds; empty on thin builds
9
+ import './_virtual/cli-rpcs.ts'
10
+ import { runCli } from './lib/cli/runCli.ts'
11
+
12
+ /*
13
+ Standalone CLI binary entry. Compiled with `bun build --compile` into
14
+ `dist/cli` (full, default) or `dist/cli-thin` (with `--thin`). The
15
+ bundler emits:
16
+ - belte:cli-manifest — the per-rpc manifest (method, url, jsonSchema)
17
+ - belte:cli-name — the program name from package.json
18
+ - belte:cli-chrome — optional banner/footer text from src/cli/
19
+
20
+ All are virtual modules so the same source file works for thin and
21
+ full builds; what differs is whether the verbRegistry is also bundled
22
+ in (full mode → in-process fallback; thin mode → APP_URL required).
23
+ */
24
+ const exitCode = await runCli({
25
+ programName,
26
+ manifest,
27
+ banner,
28
+ footer,
29
+ argv: process.argv.slice(2),
30
+ })
31
+ process.exit(exitCode)
@@ -0,0 +1,7 @@
1
+ // @ts-expect-error virtual module resolved by belteResolverPlugin
2
+ import { layouts } from './_virtual/layouts.ts'
3
+ // @ts-expect-error virtual module resolved by belteResolverPlugin
4
+ import { pages } from './_virtual/pages.ts'
5
+ import { startClient } from './lib/browser/startClient.ts'
6
+
7
+ await startClient({ pages, layouts })
package/src/compile.ts ADDED
@@ -0,0 +1,64 @@
1
+ import type { BunPlugin } from 'bun'
2
+ import { belteResolverPlugin } from './belteResolverPlugin.ts'
3
+ import { build } from './build.ts'
4
+ import type { CompileTarget } from './lib/server/runtime/types/CompileTarget.ts'
5
+ import { detectTarget } from './lib/shared/detectTarget.ts'
6
+ import { loadSvelteConfig } from './lib/shared/loadSvelteConfig.ts'
7
+ import { log } from './lib/shared/log.ts'
8
+ import { sveltePlugin } from './sveltePlugin.ts'
9
+
10
+ const SERVER_ENTRY = new URL('./serverEntry.ts', import.meta.url).pathname
11
+
12
+ /*
13
+ Produces a standalone Bun executable for the server. Runs the client `build`
14
+ first so the resolver plugin can embed the zstd-compressed assets into
15
+ the binary, then invokes Bun.build in compile mode against the server
16
+ entry. Defaults
17
+ the target to the host platform and appends `.exe` for windows targets.
18
+ Returns the path of the emitted binary; exits the process on build failure.
19
+ */
20
+ export async function compile({
21
+ cwd = process.cwd(),
22
+ target = detectTarget(),
23
+ outfile,
24
+ }: {
25
+ cwd?: string
26
+ target?: CompileTarget
27
+ outfile?: string
28
+ } = {}): Promise<string> {
29
+ const svelteConfig = await loadSvelteConfig(cwd)
30
+ await build({ cwd, svelteConfig })
31
+
32
+ const outPath = outfile ?? `${cwd}/dist/app${target.includes('windows') ? '.exe' : ''}`
33
+
34
+ const plugins: BunPlugin[] = [
35
+ sveltePlugin({ generate: 'server', svelteConfig }),
36
+ belteResolverPlugin({ cwd, embedAssets: true, target: 'server' }),
37
+ ]
38
+
39
+ const result = await Bun.build({
40
+ entrypoints: [SERVER_ENTRY],
41
+ target: 'bun',
42
+ format: 'esm',
43
+ minify: true,
44
+ /*
45
+ Bytecode embeds precompiled JS module metadata directly into the
46
+ standalone binary, dramatically cutting cold-start time for large
47
+ apps. Requires `target: 'bun'` + an explicit `format` because the
48
+ default for `bytecode` alone is CommonJS; we need ESM bytecode.
49
+ */
50
+ bytecode: true,
51
+ compile: { target, outfile: outPath },
52
+ plugins,
53
+ })
54
+
55
+ if (!result.success) {
56
+ for (const entry of result.logs) {
57
+ log.error(entry)
58
+ }
59
+ process.exit(1)
60
+ }
61
+
62
+ log.success(`compiled standalone binary: ${outPath} (target: ${target})`)
63
+ return outPath
64
+ }
@@ -0,0 +1,33 @@
1
+ // @ts-expect-error virtual module resolved by belteResolverPlugin
2
+ import { layouts } from './_virtual/layouts.ts'
3
+ // @ts-expect-error virtual module resolved by belteResolverPlugin
4
+ import { pages } from './_virtual/pages.ts'
5
+ // @ts-expect-error virtual module resolved by belteResolverPlugin
6
+ import { prompts } from './_virtual/prompts.ts'
7
+ // @ts-expect-error virtual module resolved by belteResolverPlugin
8
+ import { rpc } from './_virtual/rpc.ts'
9
+ // @ts-expect-error virtual module resolved by belteResolverPlugin
10
+ import { sockets } from './_virtual/sockets.ts'
11
+ import { build } from './build.ts'
12
+
13
+ /*
14
+ Dev-only entry. Each `bun --watch` restart re-runs the client build (so the
15
+ browser-served bundle matches the freshly-evaluated server modules) and then
16
+ eagerly invokes every dynamic loader for pages, layouts, rpc handlers, and
17
+ sockets. That pulls those files into Bun's import graph from boot, so the
18
+ watcher sees edits to a page or component on the *first* save instead of
19
+ needing the page to be visited once to warm the dynamic import. Finally
20
+ hands off to the normal server entry, which expects the same virtual
21
+ modules — they're already cached, so it just runs createServer().
22
+ */
23
+ await build({ cwd: process.cwd(), minify: false })
24
+
25
+ await Promise.all([
26
+ ...Object.values(pages).map((loader) => (loader as () => Promise<unknown>)()),
27
+ ...Object.values(layouts).map((loader) => (loader as () => Promise<unknown>)()),
28
+ ...Object.values(rpc).map((loader) => (loader as () => Promise<unknown>)()),
29
+ ...Object.values(sockets).map((loader) => (loader as () => Promise<unknown>)()),
30
+ ...Object.values(prompts).map((loader) => (loader as () => Promise<unknown>)()),
31
+ ])
32
+
33
+ await import('./serverEntry.ts')
@@ -0,0 +1,33 @@
1
+ // @ts-expect-error virtual module resolved by belteResolverPlugin
2
+ import { rpc } from './_virtual/rpc.ts'
3
+ // @ts-expect-error virtual module resolved by belteResolverPlugin
4
+ import { sockets } from './_virtual/sockets.ts'
5
+ import { verbRegistry } from './lib/server/rpc/verbRegistry.ts'
6
+ import { commandNameForUrl } from './lib/shared/commandNameForUrl.ts'
7
+ import { jsonSchemaForSchema } from './lib/shared/jsonSchemaForSchema.ts'
8
+
9
+ /*
10
+ One-shot script that imports every rpc + socket module so defineVerb /
11
+ defineSocket populate the process-wide registries, then prints the CLI
12
+ manifest to stdout as JSON. Used by buildCli to bake the manifest into
13
+ the standalone binary at build time without resorting to static source
14
+ parsing (which can't see toJsonSchema()/toJSONSchema() at compile time).
15
+ */
16
+ await Promise.all([
17
+ ...Object.values(rpc).map((loader) => (loader as () => Promise<unknown>)()),
18
+ ...Object.values(sockets).map((loader) => (loader as () => Promise<unknown>)()),
19
+ ])
20
+
21
+ const manifest: Record<string, unknown> = {}
22
+ for (const entry of verbRegistry.values()) {
23
+ if (!entry.clients.cli) {
24
+ continue
25
+ }
26
+ manifest[commandNameForUrl(entry.remote.url)] = {
27
+ method: entry.remote.method,
28
+ url: entry.remote.url,
29
+ jsonSchema: jsonSchemaForSchema(entry.schema, entry.jsonSchema),
30
+ }
31
+ }
32
+
33
+ process.stdout.write(JSON.stringify(manifest))
@@ -0,0 +1,191 @@
1
+ import type { RawRemoteFunction } from '../server/rpc/types/RawRemoteFunction.ts'
2
+ import type { RemoteFunction } from '../server/rpc/types/RemoteFunction.ts'
3
+ import { activeCacheStore } from '../shared/activeCacheStore.ts'
4
+ import { canonicalJson } from '../shared/canonicalJson.ts'
5
+ import { decodeResponse } from '../shared/decodeResponse.ts'
6
+ import { getRemoteMeta } from '../shared/getRemoteMeta.ts'
7
+ import { keyForRemoteCall } from '../shared/keyForRemoteCall.ts'
8
+ import type { CacheOptions } from '../shared/types/CacheOptions.ts'
9
+
10
+ type AnyRemote<Args, Return> = RemoteFunction<Args, Return> | RawRemoteFunction<Args>
11
+
12
+ /*
13
+ Curries a remote-function call against the request-scoped cache store.
14
+ `cache(fn, options?)` returns an invoker; calling that invoker with args
15
+ checks the store for a prior entry (keyed by fn.method + fn.url + args) and
16
+ returns a shared promise on hit, or invokes the underlying raw call once
17
+ and stores the resulting Response promise on miss. Splitting configuration
18
+ (the outer call) from invocation (the inner call) keeps options anchored
19
+ in a fixed position so they can't collide with arg shapes. TTL = undefined
20
+ → forever; ttl = 0 → dedupe only; ttl > 0 → entry expires `ttl` ms after
21
+ the promise resolves.
22
+
23
+ The invoker's return type mirrors the function you passed:
24
+
25
+ cache(getPost)({ id }) // → Promise<Post> (decoded body)
26
+ cache(getPost.raw)({ id }) // → Promise<Response> (raw escape hatch)
27
+
28
+ Both share one stored entry — the cache only ever holds the underlying
29
+ Response promise; the decoded view is derived on the way out for callers
30
+ of the non-raw variant.
31
+
32
+ Reactivity is implicit: the invoker calls `store.subscribe(key)`, which
33
+ registers the surrounding $derived / $effect scope. Invalidating the key
34
+ then re-runs that scope, which calls cache() again and gets a fresh entry.
35
+ Outside a tracking scope subscribe() is a no-op, so cache() works the same
36
+ in server code and plain client code.
37
+ */
38
+ export function cache<Args, Return>(
39
+ fn: RemoteFunction<Args, Return>,
40
+ options?: CacheOptions,
41
+ ): (args?: Args) => Promise<Return>
42
+ export function cache<Args>(
43
+ fn: RawRemoteFunction<Args>,
44
+ options?: CacheOptions,
45
+ ): (args?: Args) => Promise<Response>
46
+ export function cache<Args, Return>(
47
+ fn: AnyRemote<Args, Return>,
48
+ options?: CacheOptions,
49
+ ): (args?: Args) => Promise<Return | Response> {
50
+ /*
51
+ The "raw" variant lacks its own `.raw` sibling; only the decoded
52
+ callable carries one. Tell them apart by that presence and dispatch the
53
+ decode step accordingly.
54
+ */
55
+ const isRaw = !('raw' in fn)
56
+ const rawFn = isRaw ? (fn as RawRemoteFunction<Args>) : (fn as RemoteFunction<Args, Return>).raw
57
+ return (args) => {
58
+ const responsePromise = invokeWithCache(rawFn, args, options)
59
+ return isRaw ? responsePromise : (responsePromise.then(decodeResponse) as Promise<Return>)
60
+ }
61
+ }
62
+
63
+ function invokeWithCache<Args>(
64
+ rawFn: RawRemoteFunction<Args>,
65
+ args: Args | undefined,
66
+ options: CacheOptions | undefined,
67
+ ): Promise<Response> {
68
+ const store = activeCacheStore()
69
+ const key = resolveKey(rawFn, args, options?.key)
70
+ store.subscribe(key)
71
+ const existing = store.entries.get(key)
72
+ if (existing) {
73
+ return shareable(existing.promise)
74
+ }
75
+ const promise = rawFn(args as Args)
76
+ const request = getRemoteMeta(promise)
77
+ if (!request) {
78
+ throw new Error(
79
+ '[belte] cache() received a function whose call did not record metadata — was it produced by a verb helper?',
80
+ )
81
+ }
82
+ const ttl = options?.ttl
83
+ const entry = {
84
+ key,
85
+ promise,
86
+ request,
87
+ ttl,
88
+ expiresAt: undefined as number | undefined,
89
+ }
90
+ store.entries.set(key, entry)
91
+ function deleteIfCurrent() {
92
+ if (store.entries.get(key) === entry) {
93
+ store.entries.delete(key)
94
+ }
95
+ }
96
+ promise.then(() => {
97
+ /*
98
+ On the server the cache store is request-scoped and gets GC'd
99
+ with the response; skip ttl=0 eviction so the SSR snapshot can
100
+ still pick the entry up. In the browser ttl=0 stays "dedupe
101
+ in-flight only" — evict the moment the promise settles.
102
+ */
103
+ if (ttl === 0) {
104
+ if (typeof window !== 'undefined') {
105
+ deleteIfCurrent()
106
+ }
107
+ return
108
+ }
109
+ if (ttl !== undefined) {
110
+ entry.expiresAt = Date.now() + ttl
111
+ setTimeout(() => {
112
+ if ((entry.expiresAt ?? 0) <= Date.now()) {
113
+ deleteIfCurrent()
114
+ }
115
+ }, ttl).unref?.()
116
+ }
117
+ }, deleteIfCurrent)
118
+ return shareable(promise)
119
+ }
120
+
121
+ /*
122
+ Returns a promise that resolves to a fresh clone of the underlying Response.
123
+ Multiple readers can each consume the body independently — the stored
124
+ promise's Response is never consumed directly, so clones always succeed.
125
+ */
126
+ function shareable(promise: Promise<Response>): Promise<Response> {
127
+ return promise.then((response) => response.clone())
128
+ }
129
+
130
+ cache.invalidate = function invalidate<Args, Return>(
131
+ arg?: AnyRemote<Args, Return> | CacheOptions['key'],
132
+ ): void {
133
+ const store = activeCacheStore()
134
+ if (arg === undefined) {
135
+ const keys = Array.from(store.entries.keys())
136
+ store.entries.clear()
137
+ emit(store, keys)
138
+ return
139
+ }
140
+ if (typeof arg === 'function') {
141
+ /*
142
+ `arg.url` is the route template; per-call args appear as `?...`
143
+ (GET/DELETE) or after a space (canonical-json body) — see
144
+ keyForRemoteCall. Passing either `fn` or `fn.raw` invalidates the
145
+ same set because they share method+url.
146
+ */
147
+ const prefix = `${arg.method} ${arg.url}`
148
+ const affected: string[] = []
149
+ for (const key of store.entries.keys()) {
150
+ if (key === prefix || key.startsWith(`${prefix}?`) || key.startsWith(`${prefix} `)) {
151
+ affected.push(key)
152
+ }
153
+ }
154
+ affected.forEach((key) => store.entries.delete(key))
155
+ emit(store, affected)
156
+ return
157
+ }
158
+ const target = canonicalKey(arg)
159
+ if (store.entries.delete(target)) {
160
+ emit(store, [target])
161
+ }
162
+ }
163
+
164
+ function resolveKey<Args>(
165
+ rawFn: RawRemoteFunction<Args>,
166
+ args: Args | undefined,
167
+ override: CacheOptions['key'],
168
+ ): string {
169
+ if (override !== undefined) {
170
+ return canonicalKey(override)
171
+ }
172
+ return keyForRemoteCall(rawFn.method, rawFn.url, args)
173
+ }
174
+
175
+ function canonicalKey(value: CacheOptions['key']): string {
176
+ if (typeof value === 'string') {
177
+ return value
178
+ }
179
+ return canonicalJson(value)
180
+ }
181
+
182
+ /*
183
+ Detail is a Set so each subscriber's `has(key)` check is O(1) regardless of
184
+ how many keys a single invalidate touches.
185
+ */
186
+ function emit(store: ReturnType<typeof activeCacheStore>, keys: string[]): void {
187
+ if (keys.length === 0) {
188
+ return
189
+ }
190
+ store.events.dispatchEvent(new CustomEvent('invalidate', { detail: new Set(keys) }))
191
+ }