@briancray/belte 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +49 -0
  3. package/bin/belte.ts +136 -0
  4. package/package.json +80 -0
  5. package/src/App.svelte +31 -0
  6. package/src/assets/app.html +14 -0
  7. package/src/belteResolverPlugin.ts +832 -0
  8. package/src/build.ts +144 -0
  9. package/src/buildCli.ts +160 -0
  10. package/src/cliEntry.ts +31 -0
  11. package/src/clientEntry.ts +7 -0
  12. package/src/compile.ts +64 -0
  13. package/src/devEntry.ts +33 -0
  14. package/src/discoveryEntry.ts +33 -0
  15. package/src/lib/browser/cache.ts +191 -0
  16. package/src/lib/browser/page.svelte.ts +215 -0
  17. package/src/lib/browser/remoteProxy.ts +44 -0
  18. package/src/lib/browser/socketChannel.ts +182 -0
  19. package/src/lib/browser/socketProxy.ts +64 -0
  20. package/src/lib/browser/startClient.ts +132 -0
  21. package/src/lib/browser/subscribe.ts +131 -0
  22. package/src/lib/browser/types/Layouts.ts +7 -0
  23. package/src/lib/browser/types/Pages.ts +7 -0
  24. package/src/lib/cli/createClient.ts +126 -0
  25. package/src/lib/cli/loadEnvFromBinaryDir.ts +44 -0
  26. package/src/lib/cli/parseArgvForRpc.ts +97 -0
  27. package/src/lib/cli/printHelp.ts +70 -0
  28. package/src/lib/cli/runCli.ts +88 -0
  29. package/src/lib/cli/types/CliManifest.ts +9 -0
  30. package/src/lib/cli/types/CliManifestEntry.ts +12 -0
  31. package/src/lib/mcp/createMcpResourceServer.ts +101 -0
  32. package/src/lib/mcp/createMcpServer.ts +40 -0
  33. package/src/lib/mcp/dispatchMcpRequest.ts +294 -0
  34. package/src/lib/mcp/mcpResourceServerSlot.ts +18 -0
  35. package/src/lib/mcp/types/JsonRpcRequest.ts +12 -0
  36. package/src/lib/mcp/types/JsonRpcResponse.ts +20 -0
  37. package/src/lib/mcp/types/McpResourceContents.ts +10 -0
  38. package/src/lib/mcp/types/McpResourceDescriptor.ts +6 -0
  39. package/src/lib/mcp/types/McpResourceServer.ts +12 -0
  40. package/src/lib/mcp/types/McpServer.ts +9 -0
  41. package/src/lib/mcp/types/McpServerOptions.ts +16 -0
  42. package/src/lib/server/AppModule.ts +25 -0
  43. package/src/lib/server/DELETE.ts +9 -0
  44. package/src/lib/server/GET.ts +9 -0
  45. package/src/lib/server/HEAD.ts +9 -0
  46. package/src/lib/server/HttpError.ts +19 -0
  47. package/src/lib/server/PATCH.ts +9 -0
  48. package/src/lib/server/POST.ts +9 -0
  49. package/src/lib/server/PUT.ts +9 -0
  50. package/src/lib/server/cli/buildEnvContent.ts +18 -0
  51. package/src/lib/server/cli/createTarGz.ts +76 -0
  52. package/src/lib/server/cli/handleCliDownload.ts +124 -0
  53. package/src/lib/server/cli/handleCliInstall.ts +20 -0
  54. package/src/lib/server/cli/installScript.ts +29 -0
  55. package/src/lib/server/cli/maxSourceMtime.ts +27 -0
  56. package/src/lib/server/error.ts +56 -0
  57. package/src/lib/server/json.ts +28 -0
  58. package/src/lib/server/jsonl.ts +40 -0
  59. package/src/lib/server/prompt.ts +30 -0
  60. package/src/lib/server/prompts/definePrompt.ts +20 -0
  61. package/src/lib/server/prompts/promptRegistry.ts +9 -0
  62. package/src/lib/server/prompts/registerPrompt.ts +6 -0
  63. package/src/lib/server/prompts/types/Prompt.ts +14 -0
  64. package/src/lib/server/prompts/types/PromptMessage.ts +10 -0
  65. package/src/lib/server/prompts/types/PromptOptions.ts +17 -0
  66. package/src/lib/server/prompts/types/PromptRegistryEntry.ts +15 -0
  67. package/src/lib/server/prompts/types/PromptRoutes.ts +10 -0
  68. package/src/lib/server/redirect.ts +37 -0
  69. package/src/lib/server/request.ts +18 -0
  70. package/src/lib/server/rpc/defineVerb.ts +103 -0
  71. package/src/lib/server/rpc/parseArgs.ts +60 -0
  72. package/src/lib/server/rpc/registerVerb.ts +6 -0
  73. package/src/lib/server/rpc/types/HttpVerb.ts +1 -0
  74. package/src/lib/server/rpc/types/RawRemoteFunction.ts +13 -0
  75. package/src/lib/server/rpc/types/RemoteFunction.ts +35 -0
  76. package/src/lib/server/rpc/types/RemoteHandler.ts +22 -0
  77. package/src/lib/server/rpc/types/RemoteRoutes.ts +13 -0
  78. package/src/lib/server/rpc/types/StandardSchemaV1.ts +57 -0
  79. package/src/lib/server/rpc/types/TypedResponse.ts +18 -0
  80. package/src/lib/server/rpc/types/VerbHelper.ts +39 -0
  81. package/src/lib/server/rpc/types/VerbRegistryEntry.ts +17 -0
  82. package/src/lib/server/rpc/unprocessed.ts +14 -0
  83. package/src/lib/server/rpc/verbRegistry.ts +11 -0
  84. package/src/lib/server/runtime/buildOpenApiSpec.ts +66 -0
  85. package/src/lib/server/runtime/cacheControlForAsset.ts +17 -0
  86. package/src/lib/server/runtime/containsTraversal.ts +37 -0
  87. package/src/lib/server/runtime/createPublicAssetServer.ts +66 -0
  88. package/src/lib/server/runtime/createServer.ts +555 -0
  89. package/src/lib/server/runtime/getActiveServer.ts +6 -0
  90. package/src/lib/server/runtime/mimeForExtension.ts +20 -0
  91. package/src/lib/server/runtime/registryManifests.ts +48 -0
  92. package/src/lib/server/runtime/requestContext.ts +5 -0
  93. package/src/lib/server/runtime/safeJsonForScript.ts +17 -0
  94. package/src/lib/server/runtime/serializeCacheSnapshot.ts +84 -0
  95. package/src/lib/server/runtime/serverSlot.ts +13 -0
  96. package/src/lib/server/runtime/setActiveServer.ts +6 -0
  97. package/src/lib/server/runtime/streamFromIterator.ts +76 -0
  98. package/src/lib/server/runtime/types/Assets.ts +1 -0
  99. package/src/lib/server/runtime/types/CompileTarget.ts +6 -0
  100. package/src/lib/server/runtime/types/RequestStore.ts +15 -0
  101. package/src/lib/server/runtime/types/SvelteConfig.ts +5 -0
  102. package/src/lib/server/server.ts +19 -0
  103. package/src/lib/server/socket.ts +31 -0
  104. package/src/lib/server/sockets/createSocketDispatcher.ts +267 -0
  105. package/src/lib/server/sockets/defineSocket.ts +160 -0
  106. package/src/lib/server/sockets/lookupSocket.ts +6 -0
  107. package/src/lib/server/sockets/registerSocket.ts +6 -0
  108. package/src/lib/server/sockets/socketRegistry.ts +9 -0
  109. package/src/lib/server/sockets/types/Socket.ts +21 -0
  110. package/src/lib/server/sockets/types/SocketClientFrame.ts +18 -0
  111. package/src/lib/server/sockets/types/SocketOptions.ts +22 -0
  112. package/src/lib/server/sockets/types/SocketRegistryEntry.ts +18 -0
  113. package/src/lib/server/sockets/types/SocketRoutes.ts +10 -0
  114. package/src/lib/server/sockets/types/SocketServerFrame.ts +15 -0
  115. package/src/lib/server/sse.ts +47 -0
  116. package/src/lib/shared/activeCacheStore.ts +20 -0
  117. package/src/lib/shared/buildRpcRequest.ts +61 -0
  118. package/src/lib/shared/cacheControlValues.ts +8 -0
  119. package/src/lib/shared/cacheStoreSlot.ts +16 -0
  120. package/src/lib/shared/canonicalJson.ts +24 -0
  121. package/src/lib/shared/commandNameForUrl.ts +17 -0
  122. package/src/lib/shared/createCacheStore.ts +42 -0
  123. package/src/lib/shared/createPushIterator.ts +77 -0
  124. package/src/lib/shared/createRemoteFunction.ts +89 -0
  125. package/src/lib/shared/decodeResponse.ts +47 -0
  126. package/src/lib/shared/detectTarget.ts +27 -0
  127. package/src/lib/shared/findExportCallSite.ts +479 -0
  128. package/src/lib/shared/forwardHeaders.ts +28 -0
  129. package/src/lib/shared/getRemoteMeta.ts +5 -0
  130. package/src/lib/shared/isDebugEnabled.ts +23 -0
  131. package/src/lib/shared/jsonSchemaForSchema.ts +38 -0
  132. package/src/lib/shared/keyForRemoteCall.ts +38 -0
  133. package/src/lib/shared/loadSvelteConfig.ts +18 -0
  134. package/src/lib/shared/log.ts +104 -0
  135. package/src/lib/shared/nearestLayoutPrefix.ts +36 -0
  136. package/src/lib/shared/normalizeTarget.ts +10 -0
  137. package/src/lib/shared/pageUrlForFile.ts +14 -0
  138. package/src/lib/shared/parseRouteSegments.ts +22 -0
  139. package/src/lib/shared/preparePromptModule.ts +36 -0
  140. package/src/lib/shared/prepareRpcModule.ts +51 -0
  141. package/src/lib/shared/prepareSocketModule.ts +37 -0
  142. package/src/lib/shared/programNameForPackage.ts +14 -0
  143. package/src/lib/shared/promptNameForFile.ts +10 -0
  144. package/src/lib/shared/recordRemoteMeta.ts +5 -0
  145. package/src/lib/shared/remoteMetaStore.ts +16 -0
  146. package/src/lib/shared/resolveClientFlags.ts +18 -0
  147. package/src/lib/shared/rpcUrlForFile.ts +19 -0
  148. package/src/lib/shared/setCacheStoreResolver.ts +6 -0
  149. package/src/lib/shared/socketNameForFile.ts +11 -0
  150. package/src/lib/shared/streamingContentTypes.ts +11 -0
  151. package/src/lib/shared/stripImport.ts +27 -0
  152. package/src/lib/shared/subscribableFromResponse.ts +333 -0
  153. package/src/lib/shared/toBunRoutePattern.ts +28 -0
  154. package/src/lib/shared/types/CacheEntry.ts +16 -0
  155. package/src/lib/shared/types/CacheOptions.ts +10 -0
  156. package/src/lib/shared/types/CacheSnapshotEntry.ts +15 -0
  157. package/src/lib/shared/types/CacheStore.ts +15 -0
  158. package/src/lib/shared/types/ClientFlags.ts +11 -0
  159. package/src/lib/shared/types/Subscribable.ts +15 -0
  160. package/src/lib/shared/writeRoutesDts.ts +64 -0
  161. package/src/preload.ts +20 -0
  162. package/src/scaffold.ts +92 -0
  163. package/src/serverEntry.ts +47 -0
  164. package/src/sveltePlugin.ts +58 -0
  165. package/src/tailwindStylePreprocessor.ts +62 -0
  166. package/template/package.json +16 -0
  167. package/template/src/app.ts +23 -0
  168. package/template/src/browser/app.css +21 -0
  169. package/template/src/browser/app.html +24 -0
  170. package/template/src/browser/pages/about/page.svelte +5 -0
  171. package/template/src/browser/pages/layout.svelte +26 -0
  172. package/template/src/browser/pages/page.svelte +20 -0
  173. package/template/src/cli/banner.txt +3 -0
  174. package/template/src/cli/footer.txt +1 -0
  175. package/template/src/server/rpc/getHello.ts +33 -0
  176. package/template/svelte.config.js +12 -0
  177. package/template/tsconfig.json +18 -0
  178. package/tsconfig.app.json +16 -0
@@ -0,0 +1,555 @@
1
+ import type { BunRequest, Server } from 'bun'
2
+ import { Glob } from 'bun'
3
+ import type { Component } from 'svelte'
4
+ import { render } from 'svelte/server'
5
+ import App from '../../../App.svelte'
6
+ import type { Layouts } from '../../browser/types/Layouts.ts'
7
+ import type { Pages } from '../../browser/types/Pages.ts'
8
+ import { createMcpResourceServer } from '../../mcp/createMcpResourceServer.ts'
9
+ import { setMcpResourceServer } from '../../mcp/mcpResourceServerSlot.ts'
10
+ import type { McpServer } from '../../mcp/types/McpServer.ts'
11
+ import { NO_STORE, SSR_CACHE_CONTROL } from '../../shared/cacheControlValues.ts'
12
+ import { createCacheStore } from '../../shared/createCacheStore.ts'
13
+ import { isDebugEnabled } from '../../shared/isDebugEnabled.ts'
14
+ import { log } from '../../shared/log.ts'
15
+ import { nearestLayoutPrefix, normalizeLayoutPrefixes } from '../../shared/nearestLayoutPrefix.ts'
16
+ import { toBunRoutePattern } from '../../shared/toBunRoutePattern.ts'
17
+ import type { AppModule } from '../AppModule.ts'
18
+ import { handleCliDownload } from '../cli/handleCliDownload.ts'
19
+ import { handleCliInstall } from '../cli/handleCliInstall.ts'
20
+ import type { PromptRoutes } from '../prompts/types/PromptRoutes.ts'
21
+ import type { HttpVerb } from '../rpc/types/HttpVerb.ts'
22
+ import type { RemoteFunction } from '../rpc/types/RemoteFunction.ts'
23
+ import type { RemoteRoutes } from '../rpc/types/RemoteRoutes.ts'
24
+ import { createSocketDispatcher } from '../sockets/createSocketDispatcher.ts'
25
+ import type { SocketRoutes } from '../sockets/types/SocketRoutes.ts'
26
+ import { buildOpenApiSpec } from './buildOpenApiSpec.ts'
27
+ import { cacheControlForAsset } from './cacheControlForAsset.ts'
28
+ import { containsTraversal } from './containsTraversal.ts'
29
+ import { createPublicAssetServer } from './createPublicAssetServer.ts'
30
+ import { mimeForExtension } from './mimeForExtension.ts'
31
+ import { ensureRegistriesLoaded, setRegistryManifests } from './registryManifests.ts'
32
+ import { requestContext } from './requestContext.ts'
33
+ import { safeJsonForScript } from './safeJsonForScript.ts'
34
+ import { serializeCacheSnapshot } from './serializeCacheSnapshot.ts'
35
+ import { setActiveServer } from './setActiveServer.ts'
36
+ import type { Assets } from './types/Assets.ts'
37
+ import type { RequestStore } from './types/RequestStore.ts'
38
+
39
+ function acceptsZstd(req: Request): boolean {
40
+ return (req.headers.get('accept-encoding') ?? '').toLowerCase().includes('zstd')
41
+ }
42
+
43
+ function wantsJson(req: Request): boolean {
44
+ return (req.headers.get('accept') ?? '').includes('application/json')
45
+ }
46
+
47
+ const SOCKETS_PATH = '/__belte/sockets'
48
+ const MCP_PATH = '/__belte/mcp'
49
+ const CLI_PATH = '/__belte/cli'
50
+ const CLI_DOWNLOAD_PREFIX = '/__belte/cli/'
51
+ const OPENAPI_PATH = '/openapi.json'
52
+
53
+ type AnyRemoteFunction = RemoteFunction<unknown, unknown>
54
+
55
+ /*
56
+ Starts a Bun HTTP server that ties together the framework conventions:
57
+ page.svelte + layout.svelte under src/browser/pages/ for views, one named export
58
+ per file under src/server/rpc/ for verb-bound remote functions, one named export
59
+ per file under src/server/sockets/ for broadcast sockets, and an optional
60
+ app.ts for boot-time setup, request middleware, and error fallback. Page
61
+ URLs and rpc URLs live in disjoint spaces — pages mount at the folder
62
+ path, rpc files mount at `/rpc/<file path>` — so each registered URL
63
+ resolves to exactly one thing. Per request, an AsyncLocalStorage
64
+ RequestStore carries the cache store and request metadata.
65
+ */
66
+ export async function createServer({
67
+ pages,
68
+ rpc,
69
+ sockets,
70
+ prompts,
71
+ layouts,
72
+ shell,
73
+ app,
74
+ assets,
75
+ publicAssets,
76
+ mcpResources,
77
+ mcp,
78
+ cliProgramName,
79
+ appInfo,
80
+ distDir = `${process.cwd()}/dist`,
81
+ publicDir = `${process.cwd()}/src/browser/public`,
82
+ resourcesDir = `${process.cwd()}/src/mcp/resources`,
83
+ port = Number(process.env.PORT ?? 3000),
84
+ }: {
85
+ pages: Pages
86
+ rpc: RemoteRoutes
87
+ sockets: SocketRoutes
88
+ prompts: PromptRoutes
89
+ layouts?: Layouts
90
+ shell: string
91
+ app?: AppModule
92
+ assets?: Assets
93
+ publicAssets?: Assets
94
+ mcpResources?: Assets
95
+ mcp?: McpServer
96
+ cliProgramName?: string
97
+ appInfo?: { name: string; version: string }
98
+ distDir?: string
99
+ publicDir?: string
100
+ resourcesDir?: string
101
+ port?: number
102
+ }): Promise<Server<unknown>> {
103
+ setRegistryManifests({ rpc, sockets, prompts })
104
+ setMcpResourceServer(createMcpResourceServer({ resourcesDir, mcpResources }))
105
+ const cliName = cliProgramName ?? 'app'
106
+ const cliCwd = process.cwd()
107
+ const servePublicAsset = createPublicAssetServer({ publicDir, publicAssets })
108
+ /*
109
+ Forward-declared so the per-request closures below can reference it. The
110
+ value is assigned by Bun.serve() further down; closures only fire after
111
+ that, so the read-before-write is safe at runtime.
112
+ */
113
+ let server!: Server<unknown>
114
+ const layoutPrefixes = layouts ? normalizeLayoutPrefixes(Object.keys(layouts)) : []
115
+
116
+ const diskZstdPaths = new Set<string>(
117
+ !assets && (await Bun.file(`${distDir}/_app`).exists())
118
+ ? (await Array.fromAsync(new Glob('**/*.zst').scan({ cwd: `${distDir}/_app` }))).map(
119
+ (file) => `/_app/${file.replace(/\.zst$/, '')}`,
120
+ )
121
+ : [],
122
+ )
123
+
124
+ const rpcModuleCache = new Map<string, Promise<AnyRemoteFunction | undefined>>()
125
+ function loadRpc(url: string): Promise<AnyRemoteFunction | undefined> | undefined {
126
+ const existing = rpcModuleCache.get(url)
127
+ if (existing) {
128
+ return existing
129
+ }
130
+ const loader = rpc[url]
131
+ if (!loader) {
132
+ return undefined
133
+ }
134
+ /*
135
+ Each $rpc module has exactly one named export, validated at build
136
+ time. Pick the first export that looks like a RemoteFunction so the
137
+ framework stays tolerant of incidental re-exports.
138
+ */
139
+ const promise = loader().then((mod) => {
140
+ for (const value of Object.values(mod)) {
141
+ if (typeof value === 'function' && 'method' in value && 'url' in value) {
142
+ return value as AnyRemoteFunction
143
+ }
144
+ }
145
+ return undefined
146
+ })
147
+ rpcModuleCache.set(url, promise)
148
+ return promise
149
+ }
150
+
151
+ const logRequests = isDebugEnabled('belte')
152
+
153
+ /*
154
+ Header objects for a pathname depend only on the pathname's extension
155
+ and the immutable HASHED test. Cache them so repeat hits on the same
156
+ chunk reuse a single frozen header bag instead of allocating per
157
+ request.
158
+ */
159
+ type AssetHeaderBundle = {
160
+ base: HeadersInit
161
+ zstd: HeadersInit
162
+ }
163
+ const assetHeaderCache = new Map<string, AssetHeaderBundle>()
164
+ function headersForAsset(pathname: string): AssetHeaderBundle {
165
+ const cached = assetHeaderCache.get(pathname)
166
+ if (cached) {
167
+ return cached
168
+ }
169
+ const base: HeadersInit = {
170
+ 'Content-Type': mimeForExtension(pathname),
171
+ Vary: 'Accept-Encoding',
172
+ 'Cache-Control': cacheControlForAsset(pathname),
173
+ }
174
+ const zstd: HeadersInit = { ...base, 'Content-Encoding': 'zstd' }
175
+ const bundle = { base, zstd }
176
+ assetHeaderCache.set(pathname, bundle)
177
+ return bundle
178
+ }
179
+
180
+ async function serveStaticAsset(req: Request, url: URL): Promise<Response> {
181
+ /*
182
+ Defence-in-depth path-traversal check against the raw request URL.
183
+ The WHATWG URL parser decodes `%2E%2E` to `..` and then normalises
184
+ dot-segments away before `url.pathname` is even visible, so an
185
+ attacker's traversal sequence would be invisible if we only looked
186
+ at the parsed pathname. Inspecting `req.url` instead catches the
187
+ encoded forms before normalization eats them; `%2F` (encoded slash)
188
+ is preserved in the pathname but still flagged here for clarity.
189
+ */
190
+ if (containsTraversal(req.url)) {
191
+ return new Response('Not Found', {
192
+ status: 404,
193
+ headers: { 'Cache-Control': NO_STORE },
194
+ })
195
+ }
196
+ const wantsZstd = acceptsZstd(req)
197
+ const { base: baseHeaders, zstd: zstdHeaders } = headersForAsset(url.pathname)
198
+ if (assets) {
199
+ const compressed = assets[url.pathname]
200
+ if (!compressed) {
201
+ return new Response('Not Found', {
202
+ status: 404,
203
+ headers: { 'Cache-Control': NO_STORE },
204
+ })
205
+ }
206
+ if (wantsZstd) {
207
+ return new Response(compressed, { headers: zstdHeaders })
208
+ }
209
+ return new Response(Bun.zstdDecompressSync(compressed), { headers: baseHeaders })
210
+ }
211
+ const diskPath = distDir + url.pathname
212
+ if (wantsZstd && diskZstdPaths.has(url.pathname)) {
213
+ return new Response(Bun.file(`${diskPath}.zst`), { headers: zstdHeaders })
214
+ }
215
+ return new Response(Bun.file(diskPath), { headers: baseHeaders })
216
+ }
217
+
218
+ async function renderPage(
219
+ routeUrl: string,
220
+ params: Record<string, string>,
221
+ store: RequestStore,
222
+ ): Promise<Response> {
223
+ const json = wantsJson(store.req)
224
+ if (json) {
225
+ return Response.json(
226
+ { route: routeUrl, params },
227
+ {
228
+ headers: {
229
+ Vary: 'Accept',
230
+ 'Cache-Control': SSR_CACHE_CONTROL,
231
+ },
232
+ },
233
+ )
234
+ }
235
+ const layoutPrefix = nearestLayoutPrefix(routeUrl, layoutPrefixes)
236
+ const [pageMod, layoutMod] = await Promise.all([
237
+ pages[routeUrl](),
238
+ layoutPrefix && layouts ? layouts[layoutPrefix]() : Promise.resolve(undefined),
239
+ ])
240
+ const Page = pageMod.default as Component
241
+ const Layout = layoutMod?.default as Component | undefined
242
+ const rendered = await render(App, {
243
+ props: {
244
+ state: {
245
+ page: {
246
+ route: routeUrl,
247
+ params,
248
+ url: store.url,
249
+ },
250
+ render: { Layout, Page },
251
+ },
252
+ },
253
+ })
254
+ const cacheSnapshot = await serializeCacheSnapshot(store.cache)
255
+ const stateTag = `<script>window.__SSR__ = ${safeJsonForScript({
256
+ route: routeUrl,
257
+ params,
258
+ cache: cacheSnapshot,
259
+ })};</script>`
260
+ const html = shell
261
+ .replace('<!--ssr:head-->', rendered.head)
262
+ .replace('<!--ssr:body-->', rendered.body)
263
+ .replace('<!--ssr:state-->', stateTag)
264
+ return new Response(html, {
265
+ headers: {
266
+ 'Content-Type': 'text/html; charset=utf-8',
267
+ Vary: 'Accept',
268
+ 'Cache-Control': SSR_CACHE_CONTROL,
269
+ },
270
+ })
271
+ }
272
+
273
+ /*
274
+ Per-route handler bound by buildRoutes(). Receives a BunRequest with
275
+ `params` filled from the route pattern (only pages use path params;
276
+ $rpc URLs are flat). Page URLs (under src/browser/pages/) serve GET/HEAD by
277
+ rendering; rpc URLs (under src/server/rpc/, prefixed with `/rpc/`) dispatch
278
+ to the single declared verb-bound handler. URLs are disjoint by
279
+ construction so each path goes to exactly one branch.
280
+ */
281
+ function buildRouteHandler(routeUrl: string) {
282
+ const hasPage = pages[routeUrl] !== undefined
283
+ const hasRpc = rpc[routeUrl] !== undefined
284
+ return async function routeHandler(
285
+ req: Request,
286
+ pathParams: Record<string, string>,
287
+ store: RequestStore,
288
+ ): Promise<Response> {
289
+ const method = req.method as HttpVerb
290
+ if (hasRpc) {
291
+ const fn = await loadRpc(routeUrl)
292
+ if (fn && fn.method === method) {
293
+ return fn.fetch(req)
294
+ }
295
+ const allow = fn ? fn.method : ''
296
+ return new Response('Method Not Allowed', {
297
+ status: 405,
298
+ headers: {
299
+ Allow: allow,
300
+ 'Cache-Control': NO_STORE,
301
+ },
302
+ })
303
+ }
304
+ if (hasPage) {
305
+ if (method !== 'GET' && method !== 'HEAD') {
306
+ return new Response('Method Not Allowed', {
307
+ status: 405,
308
+ headers: { Allow: 'GET, HEAD', 'Cache-Control': NO_STORE },
309
+ })
310
+ }
311
+ return renderPage(routeUrl, pathParams, store)
312
+ }
313
+ return new Response('Not Found', {
314
+ status: 404,
315
+ headers: { 'Cache-Control': NO_STORE },
316
+ })
317
+ }
318
+ }
319
+
320
+ /*
321
+ Page URLs (folder paths, e.g. `/media/[id]`) get translated to Bun's
322
+ pattern syntax (`/media/:id`) at registration. Bun's `*` wildcard
323
+ matches but does not capture into req.params, so for `[...rest]`
324
+ routes the catch-all value is reconstructed from the request URL by
325
+ slicing the pathname segments after the catch-all's pattern index.
326
+ The reconstructed value is set under the original name (e.g. `rest`)
327
+ so the page component's $props destructure stays consistent with the
328
+ file path. Page URLs and rpc URLs (always `/rpc/...`, flat) are
329
+ disjoint by construction, so a plain object needs no deduplication.
330
+ */
331
+ const routes: Record<string, (req: BunRequest) => Promise<Response>> = {}
332
+ for (const routeUrl of Object.keys(pages)) {
333
+ const handler = buildRouteHandler(routeUrl)
334
+ const { pattern, catchAllName } = toBunRoutePattern(routeUrl)
335
+ const catchAllIndex = catchAllName
336
+ ? routeUrl.split('/').findIndex((segment) => segment.startsWith('[...'))
337
+ : -1
338
+ routes[pattern] = (req) => {
339
+ const pathParams = { ...((req.params as Record<string, string> | undefined) ?? {}) }
340
+ if (catchAllName && catchAllIndex !== -1) {
341
+ const pathSegments = new URL(req.url).pathname.split('/')
342
+ pathParams[catchAllName] = pathSegments.slice(catchAllIndex).join('/')
343
+ }
344
+ return dispatchRequest(req, pathParams, handler)
345
+ }
346
+ }
347
+ for (const routeUrl of Object.keys(rpc)) {
348
+ const handler = buildRouteHandler(routeUrl)
349
+ routes[routeUrl] = (req) => dispatchRequest(req, {}, handler)
350
+ }
351
+
352
+ function dispatchRequest(
353
+ req: Request,
354
+ pathParams: Record<string, string>,
355
+ handler: (
356
+ req: Request,
357
+ pathParams: Record<string, string>,
358
+ store: RequestStore,
359
+ ) => Promise<Response>,
360
+ ): Promise<Response> {
361
+ return runWithStore(req, async (store) => {
362
+ if (!app?.handle) {
363
+ return handler(req, pathParams, store)
364
+ }
365
+ return app.handle(req, (next) => handler(next, pathParams, store))
366
+ })
367
+ }
368
+
369
+ function runWithStore(
370
+ req: Request,
371
+ body: (store: RequestStore) => Promise<Response>,
372
+ ): Promise<Response> {
373
+ const url = new URL(req.url)
374
+ const store: RequestStore = {
375
+ url,
376
+ req,
377
+ signal: req.signal,
378
+ cache: createCacheStore(),
379
+ server,
380
+ }
381
+ return requestContext.run(store, async () => {
382
+ const start = logRequests ? Bun.nanoseconds() : 0
383
+ let response: Response
384
+ try {
385
+ response = await body(store)
386
+ } catch (error) {
387
+ if (app?.handleError) {
388
+ response = await app.handleError(error, req)
389
+ } else {
390
+ log.error(error)
391
+ response = new Response(
392
+ `<pre>${String((error as Error)?.stack ?? error)}</pre>`,
393
+ {
394
+ status: 500,
395
+ headers: {
396
+ 'Content-Type': 'text/html; charset=utf-8',
397
+ 'Cache-Control': NO_STORE,
398
+ },
399
+ },
400
+ )
401
+ }
402
+ }
403
+ if (logRequests) {
404
+ const ms = (Bun.nanoseconds() - start) / 1e6
405
+ log.request(req.method, `${url.pathname}${url.search}`, response.status, ms)
406
+ }
407
+ return response
408
+ })
409
+ }
410
+
411
+ /*
412
+ Belte's only native WebSocket surface is the sockets hub: every Socket
413
+ declared under src/server/sockets/ multiplexes onto one framework-owned
414
+ connection per client at /__belte/sockets. The dispatcher owns the
415
+ open/message/close handlers below; user code never sees the raw ws
416
+ lifecycle. Steady-state fan-out rides Bun's native server.publish so
417
+ a busy socket doesn't iterate JS per subscriber per message.
418
+ */
419
+ const socketDispatcher = createSocketDispatcher(sockets)
420
+ server = Bun.serve({
421
+ port,
422
+
423
+ websocket: {
424
+ open(ws) {
425
+ socketDispatcher.open(ws)
426
+ },
427
+ message(ws, data) {
428
+ socketDispatcher.message(ws, data)
429
+ },
430
+ close(ws) {
431
+ socketDispatcher.close(ws)
432
+ },
433
+ },
434
+
435
+ routes,
436
+
437
+ async fetch(req, bunServer) {
438
+ const url = new URL(req.url)
439
+ if (url.pathname === SOCKETS_PATH) {
440
+ if (bunServer.upgrade(req, { data: {} })) {
441
+ return undefined as unknown as Response
442
+ }
443
+ return new Response('Upgrade failed', { status: 400 })
444
+ }
445
+ if (url.pathname === MCP_PATH && mcp) {
446
+ return dispatchRequest(req, {}, async () => mcp.handle(req))
447
+ }
448
+ if (url.pathname === CLI_PATH) {
449
+ return dispatchRequest(req, {}, async () => handleCliInstall(req, cliName))
450
+ }
451
+ if (url.pathname.startsWith(CLI_DOWNLOAD_PREFIX)) {
452
+ const platform = url.pathname.slice(CLI_DOWNLOAD_PREFIX.length)
453
+ return dispatchRequest(req, {}, async () =>
454
+ handleCliDownload(req, platform, cliName, cliCwd),
455
+ )
456
+ }
457
+ if (url.pathname === OPENAPI_PATH) {
458
+ return dispatchRequest(req, {}, async () => {
459
+ await ensureRegistriesLoaded()
460
+ const spec = buildOpenApiSpec({
461
+ title: appInfo?.name ?? cliName,
462
+ version: appInfo?.version ?? '0.0.0',
463
+ })
464
+ return Response.json(spec, { headers: { 'Cache-Control': NO_STORE } })
465
+ })
466
+ }
467
+ /*
468
+ Static assets sidestep ALS + the per-request CacheStore + the
469
+ app.handle middleware: they have no need for cache() and the
470
+ allocation overhead matters on a cold page load that pulls
471
+ dozens of chunks. The global server.error() handler still
472
+ catches anything that goes wrong inside serveStaticAsset.
473
+ */
474
+ if (url.pathname.startsWith('/_app/')) {
475
+ if (!logRequests) {
476
+ return serveStaticAsset(req, url)
477
+ }
478
+ const start = Bun.nanoseconds()
479
+ const response = await serveStaticAsset(req, url)
480
+ const ms = (Bun.nanoseconds() - start) / 1e6
481
+ log.request(req.method, `${url.pathname}${url.search}`, response.status, ms)
482
+ return response
483
+ }
484
+ /*
485
+ Files under public/ are served at the site root, sidestepping
486
+ ALS + middleware like the /_app/ assets do. A miss returns
487
+ undefined so the request falls through to the 404 / middleware
488
+ path below.
489
+ */
490
+ const publicResponse = await servePublicAsset(req, url)
491
+ if (publicResponse) {
492
+ if (logRequests) {
493
+ log.request(
494
+ req.method,
495
+ `${url.pathname}${url.search}`,
496
+ publicResponse.status,
497
+ 0,
498
+ )
499
+ }
500
+ return publicResponse
501
+ }
502
+ /*
503
+ Unknown routes still run through dispatchRequest so user-defined
504
+ app.handle middleware can rewrite the request, serve a custom
505
+ 404, or branch on the URL. The inner handler returns the
506
+ framework's default 404 when nothing intervenes.
507
+ */
508
+ return dispatchRequest(req, {}, async () => {
509
+ return new Response('Not Found', {
510
+ status: 404,
511
+ headers: { 'Cache-Control': NO_STORE },
512
+ })
513
+ })
514
+ },
515
+
516
+ error(err) {
517
+ log.error(err)
518
+ return new Response(`<pre>${String(err.stack ?? err)}</pre>`, {
519
+ status: 500,
520
+ headers: {
521
+ 'Content-Type': 'text/html; charset=utf-8',
522
+ 'Cache-Control': NO_STORE,
523
+ },
524
+ })
525
+ },
526
+ })
527
+
528
+ /*
529
+ Publishes the live server through `belte/server` before invoking the
530
+ user's init() hook. The exported `server()` function reads from this
531
+ slot and throws on access before the slot is set, so init() callers
532
+ can hold the import at module scope and still see the real instance
533
+ once boot completes.
534
+ */
535
+ setActiveServer(server)
536
+
537
+ if (app?.init) {
538
+ const cleanup = await app.init({ server })
539
+ if (typeof cleanup === 'function') {
540
+ const shutdown = async () => {
541
+ try {
542
+ await cleanup()
543
+ } catch (err) {
544
+ log.error(err)
545
+ }
546
+ process.exit(0)
547
+ }
548
+ process.once('SIGINT', shutdown)
549
+ process.once('SIGTERM', shutdown)
550
+ }
551
+ }
552
+
553
+ log.success(`ready at http://localhost:${server.port}`)
554
+ return server
555
+ }
@@ -0,0 +1,6 @@
1
+ import type { Server } from 'bun'
2
+ import { serverSlot } from './serverSlot.ts'
3
+
4
+ export function getActiveServer(): Server<unknown> | undefined {
5
+ return serverSlot.active
6
+ }
@@ -0,0 +1,20 @@
1
+ /*
2
+ Derives the MIME type from a URL pathname using Bun.file().type, which
3
+ operates on the file extension synchronously without touching the disk. The
4
+ Bun.file ref here is never read from — it exists only to reuse Bun's
5
+ extension-to-MIME table. Cache by extension so repeat hits for the same
6
+ chunk type (.js / .css / .map / .svg / …) skip the BunFile allocation.
7
+ */
8
+ const mimeByExtension = new Map<string, string>()
9
+
10
+ export function mimeForExtension(pathname: string): string {
11
+ const dot = pathname.lastIndexOf('.')
12
+ const extension = dot === -1 ? '' : pathname.slice(dot)
13
+ const cached = mimeByExtension.get(extension)
14
+ if (cached !== undefined) {
15
+ return cached
16
+ }
17
+ const type = Bun.file(pathname).type
18
+ mimeByExtension.set(extension, type)
19
+ return type
20
+ }
@@ -0,0 +1,48 @@
1
+ import type { PromptRoutes } from '../prompts/types/PromptRoutes.ts'
2
+ import type { RemoteRoutes } from '../rpc/types/RemoteRoutes.ts'
3
+ import type { SocketRoutes } from '../sockets/types/SocketRoutes.ts'
4
+
5
+ /*
6
+ Process-wide slot for the rpc + sockets + prompts manifests. createServer
7
+ assigns once at boot (right after the route table is built); the MCP
8
+ server, the OpenAPI emitter, and prompt enumeration read it on first
9
+ request so they can lazy-import every module and walk the
10
+ verb/socket/prompt registries.
11
+
12
+ The slot pattern (mirrors getActiveServer) lets the framework-generated
13
+ McpServer bind to the manifests at module scope while the loaders stay
14
+ lazy until the first enumeration request.
15
+ */
16
+ type RegistryManifests = {
17
+ rpc: RemoteRoutes
18
+ sockets: SocketRoutes
19
+ prompts: PromptRoutes
20
+ }
21
+
22
+ let manifests: RegistryManifests | undefined
23
+ let loadedAll = false
24
+
25
+ export function setRegistryManifests(value: RegistryManifests): void {
26
+ manifests = value
27
+ loadedAll = false
28
+ }
29
+
30
+ /*
31
+ On first call, eagerly imports every rpc + socket + prompt module so
32
+ defineVerb / defineSocket / definePrompt fire and populate the
33
+ registries. Idempotent — repeat calls are no-ops. Eager loading is
34
+ acceptable here because enumeration (MCP tool/resource/prompt lists,
35
+ the OpenAPI document) fundamentally requires the full surface; the
36
+ alternative of per-call lazy loading produces flaky first-call latency.
37
+ */
38
+ export async function ensureRegistriesLoaded(): Promise<void> {
39
+ if (loadedAll || !manifests) {
40
+ return
41
+ }
42
+ await Promise.all([
43
+ ...Object.values(manifests.rpc).map((loader) => loader()),
44
+ ...Object.values(manifests.sockets).map((loader) => loader()),
45
+ ...Object.values(manifests.prompts).map((loader) => loader()),
46
+ ])
47
+ loadedAll = true
48
+ }
@@ -0,0 +1,5 @@
1
+ // AsyncLocalStorage is canonical via node:async_hooks — Bun has no separate API
2
+ import { AsyncLocalStorage } from 'node:async_hooks'
3
+ import type { RequestStore } from './types/RequestStore.ts'
4
+
5
+ export const requestContext = new AsyncLocalStorage<RequestStore>()
@@ -0,0 +1,17 @@
1
+ /*
2
+ Escapes characters that could prematurely terminate the surrounding <script>
3
+ tag or be interpreted as HTML comment delimiters when a JSON literal is
4
+ inlined into an HTML document. U+2028 (LS) and U+2029 (PS) are valid in JSON
5
+ but break a `<script>` tag's inline content because the JavaScript lexer
6
+ treats them as line terminators; encode them as Unicode escapes.
7
+ */
8
+ const LINE_SEPARATOR = String.fromCharCode(0x2028)
9
+ const PARAGRAPH_SEPARATOR = String.fromCharCode(0x2029)
10
+
11
+ export function safeJsonForScript(value: unknown): string {
12
+ return JSON.stringify(value)
13
+ .replace(/</g, '\\u003c')
14
+ .replace(/-->/g, '--\\u003e')
15
+ .replaceAll(LINE_SEPARATOR, '\\u2028')
16
+ .replaceAll(PARAGRAPH_SEPARATOR, '\\u2029')
17
+ }