@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,333 @@
1
+ import { HttpError } from '../server/HttpError.ts'
2
+ import { decodeResponse } from './decodeResponse.ts'
3
+ import { STREAMING_CONTENT_TYPES } from './streamingContentTypes.ts'
4
+ import type { Subscribable } from './types/Subscribable.ts'
5
+
6
+ /*
7
+ Turns a Response into an AsyncIterable of frames. Used by
8
+ `fn.stream(args)` to give callers a uniform iterator regardless of the
9
+ handler's chosen body format. Three shapes are handled:
10
+
11
+ - text/event-stream (SSE): emits the JSON-parsed `data:` payload of
12
+ each event. The `event: error\ndata: {message}` frame the `sse()`
13
+ helper emits on generator throws is mapped back to a thrown Error so
14
+ consumers see the failure mid-iteration.
15
+ - application/jsonl + application/x-ndjson: emits one JSON value per
16
+ line. The trailing `{"$error":"..."}` line the `jsonl()` helper
17
+ emits on generator throws is likewise re-thrown.
18
+ - everything else: one-shot — yields the Content-Type-decoded body
19
+ once, then completes. Lets `fn.stream(args)` work uniformly on every
20
+ rpc handler, not just the streaming ones.
21
+
22
+ Non-2xx responses surface as a thrown HttpError on the first pull,
23
+ mirroring the plain `fn(args)` decode path.
24
+ */
25
+ function streamResponse<T>(response: Response): AsyncIterable<T> {
26
+ if (!response.ok) {
27
+ return errorIterable(new HttpError(response))
28
+ }
29
+ const contentType = (response.headers.get('content-type') ?? '').toLowerCase()
30
+ if (contentType.startsWith('text/event-stream')) {
31
+ return parseSse<T>(response)
32
+ }
33
+ if (STREAMING_CONTENT_TYPES.some((type) => contentType.startsWith(type))) {
34
+ return parseJsonLines<T>(response)
35
+ }
36
+ return oneShot<T>(response)
37
+ }
38
+
39
+ function errorIterable<T>(error: Error): AsyncIterable<T> {
40
+ return {
41
+ [Symbol.asyncIterator](): AsyncIterator<T, void, undefined> {
42
+ let done = false
43
+ return {
44
+ async next() {
45
+ if (done) {
46
+ return { value: undefined, done: true }
47
+ }
48
+ done = true
49
+ throw error
50
+ },
51
+ async return() {
52
+ done = true
53
+ return { value: undefined, done: true }
54
+ },
55
+ }
56
+ },
57
+ }
58
+ }
59
+
60
+ /*
61
+ One-shot iterator over a non-streaming Response: decodes the body once
62
+ via the same Content-Type sniffing the plain call uses, yields it, then
63
+ completes. Makes `fn.stream(args)` symmetrical across streaming and
64
+ non-streaming handlers — callers can pick the iteration shape without
65
+ worrying about which body the handler returned.
66
+ */
67
+ function oneShot<T>(response: Response): AsyncIterable<T> {
68
+ return {
69
+ [Symbol.asyncIterator](): AsyncIterator<T, void, undefined> {
70
+ let yielded = false
71
+ return {
72
+ async next() {
73
+ if (yielded) {
74
+ return { value: undefined, done: true }
75
+ }
76
+ yielded = true
77
+ const value = (await decodeResponse(response)) as T
78
+ return { value, done: false }
79
+ },
80
+ async return() {
81
+ yielded = true
82
+ return { value: undefined, done: true }
83
+ },
84
+ }
85
+ },
86
+ }
87
+ }
88
+
89
+ /*
90
+ SSE parser: reads the response body as text frames separated by blank
91
+ lines, splits each frame into `event:` / `data:` lines, and yields the
92
+ JSON-parsed data payload. The `sse()` respond helper emits an
93
+ `event: error\ndata: {"message":...}` frame when the source generator
94
+ throws, which we surface as a thrown Error so consumer loops can
95
+ surface mid-stream failure rather than silently stopping.
96
+ */
97
+ function parseSse<T>(response: Response): AsyncIterable<T> {
98
+ return {
99
+ [Symbol.asyncIterator](): AsyncIterator<T, void, undefined> {
100
+ const body = response.body
101
+ if (!body) {
102
+ return emptyIterator<T>()
103
+ }
104
+ const reader = body.pipeThrough(new TextDecoderStream()).getReader()
105
+ let buffer = ''
106
+ let bufferStart = 0
107
+ const pending: Array<{ event: string; data: string }> = []
108
+ let done = false
109
+
110
+ async function pullFrames(): Promise<void> {
111
+ while (pending.length === 0 && !done) {
112
+ const { value, done: streamDone } = await reader.read()
113
+ if (streamDone) {
114
+ done = true
115
+ if (bufferStart < buffer.length) {
116
+ const frame = parseFrame(buffer.slice(bufferStart))
117
+ if (frame) {
118
+ pending.push(frame)
119
+ }
120
+ buffer = ''
121
+ bufferStart = 0
122
+ }
123
+ return
124
+ }
125
+ /*
126
+ Compact only when the unread region is small relative to
127
+ the consumed prefix — keeps amortised work O(n) instead
128
+ of quadratic slicing per frame boundary.
129
+ */
130
+ if (bufferStart > buffer.length / 2) {
131
+ buffer = buffer.slice(bufferStart) + value
132
+ bufferStart = 0
133
+ } else {
134
+ buffer += value
135
+ }
136
+ let boundary = buffer.indexOf('\n\n', bufferStart)
137
+ while (boundary !== -1) {
138
+ const raw = buffer.slice(bufferStart, boundary)
139
+ bufferStart = boundary + 2
140
+ const frame = parseFrame(raw)
141
+ if (frame) {
142
+ pending.push(frame)
143
+ }
144
+ boundary = buffer.indexOf('\n\n', bufferStart)
145
+ }
146
+ }
147
+ }
148
+
149
+ return {
150
+ async next() {
151
+ while (true) {
152
+ if (pending.length > 0) {
153
+ const next = pending.shift() as { event: string; data: string }
154
+ if (next.event === 'error') {
155
+ try {
156
+ const decoded = JSON.parse(next.data) as { message?: string }
157
+ throw new Error(decoded?.message ?? 'sse stream error')
158
+ } catch (err) {
159
+ if (err instanceof SyntaxError) {
160
+ throw new Error(next.data || 'sse stream error')
161
+ }
162
+ throw err
163
+ }
164
+ }
165
+ const value = JSON.parse(next.data) as T
166
+ return { value, done: false }
167
+ }
168
+ if (done) {
169
+ return { value: undefined, done: true }
170
+ }
171
+ await pullFrames()
172
+ }
173
+ },
174
+ async return() {
175
+ done = true
176
+ await reader.cancel().catch(() => undefined)
177
+ return { value: undefined, done: true }
178
+ },
179
+ }
180
+ },
181
+ }
182
+ }
183
+
184
+ function parseFrame(raw: string): { event: string; data: string } | undefined {
185
+ const lines = raw.split('\n').filter((line) => line.length > 0 && !line.startsWith(':'))
186
+ if (lines.length === 0) {
187
+ return undefined
188
+ }
189
+ let event = 'message'
190
+ const dataLines: string[] = []
191
+ for (const line of lines) {
192
+ const colon = line.indexOf(':')
193
+ const field = colon === -1 ? line : line.slice(0, colon)
194
+ const value = colon === -1 ? '' : line.slice(colon + 1).replace(/^ /, '')
195
+ if (field === 'event') {
196
+ event = value
197
+ } else if (field === 'data') {
198
+ dataLines.push(value)
199
+ }
200
+ }
201
+ if (dataLines.length === 0) {
202
+ return undefined
203
+ }
204
+ return { event, data: dataLines.join('\n') }
205
+ }
206
+
207
+ /*
208
+ JSONL/NDJSON parser: reads the response body as text, splits on `\n`,
209
+ parses each non-empty line as JSON, and yields the value. The `jsonl()`
210
+ respond helper emits a trailing `{"$error":"<message>"}` line when the
211
+ source generator throws — that's surfaced here as a thrown Error so
212
+ consumer loops can react to mid-stream failure.
213
+ */
214
+ function parseJsonLines<T>(response: Response): AsyncIterable<T> {
215
+ return {
216
+ [Symbol.asyncIterator](): AsyncIterator<T, void, undefined> {
217
+ const body = response.body
218
+ if (!body) {
219
+ return emptyIterator<T>()
220
+ }
221
+ const reader = body.pipeThrough(new TextDecoderStream()).getReader()
222
+ let buffer = ''
223
+ let bufferStart = 0
224
+ const pending: string[] = []
225
+ let done = false
226
+
227
+ async function pullLines(): Promise<void> {
228
+ while (pending.length === 0 && !done) {
229
+ const { value, done: streamDone } = await reader.read()
230
+ if (streamDone) {
231
+ done = true
232
+ if (bufferStart < buffer.length) {
233
+ pending.push(buffer.slice(bufferStart))
234
+ buffer = ''
235
+ bufferStart = 0
236
+ }
237
+ return
238
+ }
239
+ if (bufferStart > buffer.length / 2) {
240
+ buffer = buffer.slice(bufferStart) + value
241
+ bufferStart = 0
242
+ } else {
243
+ buffer += value
244
+ }
245
+ let newline = buffer.indexOf('\n', bufferStart)
246
+ while (newline !== -1) {
247
+ const line = buffer.slice(bufferStart, newline)
248
+ bufferStart = newline + 1
249
+ if (line.length > 0) {
250
+ pending.push(line)
251
+ }
252
+ newline = buffer.indexOf('\n', bufferStart)
253
+ }
254
+ }
255
+ }
256
+
257
+ return {
258
+ async next() {
259
+ while (true) {
260
+ if (pending.length > 0) {
261
+ const line = pending.shift() as string
262
+ const parsed = JSON.parse(line) as Record<string, unknown> & {
263
+ $error?: string
264
+ }
265
+ if (
266
+ parsed &&
267
+ typeof parsed === 'object' &&
268
+ typeof parsed.$error === 'string'
269
+ ) {
270
+ throw new Error(parsed.$error)
271
+ }
272
+ return { value: parsed as T, done: false }
273
+ }
274
+ if (done) {
275
+ return { value: undefined, done: true }
276
+ }
277
+ await pullLines()
278
+ }
279
+ },
280
+ async return() {
281
+ done = true
282
+ await reader.cancel().catch(() => undefined)
283
+ return { value: undefined, done: true }
284
+ },
285
+ }
286
+ },
287
+ }
288
+ }
289
+
290
+ function emptyIterator<T>(): AsyncIterator<T, void, undefined> {
291
+ return {
292
+ async next() {
293
+ return { value: undefined, done: true }
294
+ },
295
+ async return() {
296
+ return { value: undefined, done: true }
297
+ },
298
+ }
299
+ }
300
+
301
+ /*
302
+ Builds the Subscribable returned by `fn.stream(args)`. The carried
303
+ `name` is the cache-style key for (method, url, args) so subscribe()
304
+ dedupes multiple subscribers to identical args into one underlying
305
+ fetch. The fetch is deferred until the first iterator pull so simply
306
+ constructing the Subscribable (which happens on every $derived
307
+ re-evaluation) doesn't open a connection — subscribe()'s registry
308
+ short-circuits the second instance before it iterates.
309
+ */
310
+ export function subscribableFromResponse<T>(
311
+ name: string,
312
+ fetchResponse: () => Promise<Response>,
313
+ ): Subscribable<T> {
314
+ return {
315
+ name,
316
+ [Symbol.asyncIterator]() {
317
+ let inner: AsyncIterator<T, void, undefined> | undefined
318
+ return {
319
+ async next() {
320
+ if (!inner) {
321
+ const response = await fetchResponse()
322
+ inner = streamResponse<T>(response)[Symbol.asyncIterator]()
323
+ }
324
+ return inner.next()
325
+ },
326
+ async return() {
327
+ await inner?.return?.(undefined)
328
+ return { value: undefined, done: true }
329
+ },
330
+ }
331
+ },
332
+ }
333
+ }
@@ -0,0 +1,28 @@
1
+ import { parseRouteSegments } from './parseRouteSegments.ts'
2
+
3
+ /*
4
+ Translates a belte route URL (`/media/[id]/[...rest]`) into the pattern Bun
5
+ needs (`/media/:id/*`) for `Bun.serve({ routes })`. Returns the catch-all
6
+ segment's original name alongside so the server can rename Bun's `*` param
7
+ back to that name on the way out, keeping page-prop destructuring consistent
8
+ with the route file path.
9
+ */
10
+ export function toBunRoutePattern(routeUrl: string): {
11
+ pattern: string
12
+ catchAllName: string | undefined
13
+ } {
14
+ let catchAllName: string | undefined
15
+ const pattern = parseRouteSegments(routeUrl)
16
+ .map((segment) => {
17
+ if (segment.kind === 'literal') {
18
+ return segment.value
19
+ }
20
+ if (segment.catchAll) {
21
+ catchAllName = segment.name
22
+ return '*'
23
+ }
24
+ return `:${segment.name}`
25
+ })
26
+ .join('/')
27
+ return { pattern, catchAllName }
28
+ }
@@ -0,0 +1,16 @@
1
+ /*
2
+ Stored shape per cache key. `request` is retained so SSR snapshot
3
+ serialization can record the URL and method without re-deriving them from
4
+ the function. `ttl`/`expiresAt` drive eviction: expiresAt = undefined means
5
+ "no TTL" (lives forever); ttl = 0 means "dedupe only" (entry is pruned as
6
+ soon as the promise settles). The stored promise resolves to the raw
7
+ Response so the snapshot can read its status/headers/body; the cache
8
+ layer hands callers a decoded view derived from this same promise.
9
+ */
10
+ export type CacheEntry = {
11
+ key: string
12
+ promise: Promise<Response>
13
+ request: Request
14
+ ttl: number | undefined
15
+ expiresAt: number | undefined
16
+ }
@@ -0,0 +1,10 @@
1
+ /*
2
+ Options for cache(). `key` overrides the auto-derived WeakMap key — useful
3
+ when sharing entries across calls or stripping noisy args. `ttl` is the
4
+ milliseconds-past-resolve that the entry stays live: omitted = forever, 0 =
5
+ dedupe only (entry dropped once the promise settles), any other number = TTL.
6
+ */
7
+ export type CacheOptions = {
8
+ key?: string | unknown[] | Record<string, unknown>
9
+ ttl?: number
10
+ }
@@ -0,0 +1,15 @@
1
+ /*
2
+ Wire format for a single cached response shipped from SSR to client hydration.
3
+ Only GET/DELETE entries with a textual Content-Type are emitted — POST/PUT
4
+ bodies can't be reconstructed without shipping the original request body,
5
+ and binary bodies don't survive a JSON round-trip.
6
+ */
7
+ export type CacheSnapshotEntry = {
8
+ key: string
9
+ url: string
10
+ method: 'GET' | 'DELETE'
11
+ status: number
12
+ statusText: string
13
+ headers: Array<[string, string]>
14
+ body: string
15
+ }
@@ -0,0 +1,15 @@
1
+ import type { CacheEntry } from './CacheEntry.ts'
2
+
3
+ /*
4
+ Cache map paired with a Svelte-aware per-key subscriber. Calling
5
+ `subscribe(key)` from inside a tracking scope ($derived / $effect) registers
6
+ that scope to re-run when the entry is invalidated; called outside tracking
7
+ it's a no-op. Subscribers live for the lifetime of the store: the server
8
+ uses a fresh store per request (so subscribers die with the response), the
9
+ client uses a single module-level store (so subscribers persist for the tab).
10
+ */
11
+ export type CacheStore = {
12
+ entries: Map<string, CacheEntry>
13
+ events: EventTarget
14
+ subscribe: (key: string) => void
15
+ }
@@ -0,0 +1,11 @@
1
+ /*
2
+ Which client surfaces a verb or socket is exposed to. Browser is the
3
+ historical default; MCP and CLI flip on automatically when the
4
+ declaration carries a Standard Schema (the schema is what makes the
5
+ non-browser surfaces safe to advertise). Explicit values always win.
6
+ */
7
+ export type ClientFlags = {
8
+ browser: boolean
9
+ mcp: boolean
10
+ cli: boolean
11
+ }
@@ -0,0 +1,15 @@
1
+ /*
2
+ The thing `subscribe()` reads from: an AsyncIterable carrying a stable
3
+ `name` used as the subscription registry key. Both `Socket<T>` (the
4
+ declared broadcast primitive) and the result of `fn.stream(args)`
5
+ (per-call HTTP stream consumer) satisfy this shape, so subscribe() can
6
+ share one iterator across multiple readers regardless of source.
7
+
8
+ The name on a Socket comes from the file path under `src/server/sockets/`.
9
+ The name on an fn.stream(args) result is `keyForRemoteCall(method, url,
10
+ args)` — the same key cache() uses — so two subscribers to the same
11
+ remote-call args dedupe to one underlying fetch.
12
+ */
13
+ export interface Subscribable<T> extends AsyncIterable<T> {
14
+ readonly name: string
15
+ }
@@ -0,0 +1,64 @@
1
+ import { pageUrlForFile } from './pageUrlForFile.ts'
2
+ import { parseRouteSegments } from './parseRouteSegments.ts'
3
+
4
+ /*
5
+ Walks a `[name]` / `[...rest]` route URL and returns the param shape it
6
+ declares. Catch-all segments map to `string` under their declared name —
7
+ the server's toBunRoutePattern renames Bun's `*` key back to that name
8
+ when dispatching, so the page component sees `params.rest`, not
9
+ `params['*']`.
10
+ */
11
+ function paramsForRoute(routeUrl: string): Record<string, 'string'> {
12
+ const params: Record<string, 'string'> = {}
13
+ for (const segment of parseRouteSegments(routeUrl)) {
14
+ if (segment.kind === 'param') {
15
+ params[segment.name] = 'string'
16
+ }
17
+ }
18
+ return params
19
+ }
20
+
21
+ function renderParamsShape(shape: Record<string, 'string'>): string {
22
+ const keys = Object.keys(shape)
23
+ if (keys.length === 0) {
24
+ return 'Record<string, never>'
25
+ }
26
+ return `{ ${keys.map((key) => `${JSON.stringify(key)}: string`).join('; ')} }`
27
+ }
28
+
29
+ /*
30
+ Emits a `.d.ts` that augments belte's `Routes` interface with one entry per
31
+ page file in the project. Page picks this up as a discriminated union keyed
32
+ on `route`, so `if (page.route === '/media/[id]') page.params.id` is typed
33
+ automatically without consumers writing route types by hand.
34
+ The file is written to `src/.belte/routes.d.ts` so the consumer's existing
35
+ src tsconfig include picks it up with no extra configuration.
36
+ */
37
+ export async function writeRoutesDts({
38
+ cwd,
39
+ pageFiles,
40
+ }: {
41
+ cwd: string
42
+ pageFiles: string[]
43
+ }): Promise<void> {
44
+ const entries = pageFiles
45
+ .map((file) => ({
46
+ route: pageUrlForFile(file),
47
+ params: paramsForRoute(pageUrlForFile(file)),
48
+ }))
49
+ .toSorted((a, b) => a.route.localeCompare(b.route))
50
+ .map(
51
+ ({ route, params }) => ` ${JSON.stringify(route)}: ${renderParamsShape(params)}`,
52
+ )
53
+ .join('\n')
54
+ const contents = `// Generated by belte. Do not edit by hand.
55
+ declare module 'belte/browser/page' {
56
+ interface Routes {
57
+ ${entries}
58
+ }
59
+ }
60
+
61
+ export {}
62
+ `
63
+ await Bun.write(`${cwd}/src/.belte/routes.d.ts`, contents)
64
+ }
package/src/preload.ts ADDED
@@ -0,0 +1,20 @@
1
+ import { plugin } from 'bun'
2
+ import { belteResolverPlugin } from './belteResolverPlugin.ts'
3
+ import { loadSvelteConfig } from './lib/shared/loadSvelteConfig.ts'
4
+ import { sveltePlugin } from './sveltePlugin.ts'
5
+
6
+ const mode = (process.env.BELTE_SVELTE_MODE ?? 'server') as 'server' | 'client'
7
+ const svelteConfig = await loadSvelteConfig()
8
+
9
+ await plugin(sveltePlugin({ generate: mode, svelteConfig }))
10
+ await plugin(belteResolverPlugin({ target: mode }))
11
+
12
+ await plugin({
13
+ name: 'css-noop',
14
+ setup(build) {
15
+ build.onLoad({ filter: /\.css$/ }, () => ({
16
+ contents: 'export default {};',
17
+ loader: 'js',
18
+ }))
19
+ },
20
+ })
@@ -0,0 +1,92 @@
1
+ import { Glob } from 'bun'
2
+ import { log } from './lib/shared/log.ts'
3
+
4
+ const TEMPLATE_DIR = new URL('../template', import.meta.url).pathname
5
+
6
+ /*
7
+ Copies the bundled template directory into `${cwd}/${name}`. Refuses to write
8
+ into a non-empty directory so an accidental run doesn't overwrite real work.
9
+ */
10
+ export async function scaffold({
11
+ cwd = process.cwd(),
12
+ name,
13
+ }: {
14
+ cwd?: string
15
+ name: string
16
+ }): Promise<string> {
17
+ const trimmed = name.trim()
18
+ if (trimmed === '') {
19
+ throw new Error('[belte] project name is required: bunx belte scaffold <name>')
20
+ }
21
+ const target = resolveTarget(cwd, trimmed)
22
+ if (await targetIsNonEmpty(target)) {
23
+ throw new Error(`[belte] target directory is not empty: ${target}`)
24
+ }
25
+ if (!(await Bun.file(`${TEMPLATE_DIR}/package.json`).exists())) {
26
+ throw new Error(`[belte] template missing at ${TEMPLATE_DIR}`)
27
+ }
28
+ await copyTree(TEMPLATE_DIR, target)
29
+ log.success(`scaffolded belte project at ${target}`)
30
+ log.detail(' next steps:')
31
+ if (target !== cwd) {
32
+ log.detail(` cd ${trimmed}`)
33
+ }
34
+ log.detail(' bun install')
35
+ log.detail(' bun run dev')
36
+ return target
37
+ }
38
+
39
+ /*
40
+ Copies every file under `from` into `to`, preserving relative paths. Uses
41
+ Bun.Glob to enumerate (dotfiles included) and Bun.write to materialize each
42
+ file — Bun.write auto-creates parent directories.
43
+ */
44
+ async function copyTree(from: string, to: string): Promise<void> {
45
+ const files = await Array.fromAsync(
46
+ new Glob('**/*').scan({ cwd: from, onlyFiles: true, dot: true }),
47
+ )
48
+ await Promise.all(
49
+ files.map(async (relativePath) => {
50
+ const source = Bun.file(`${from}/${relativePath}`)
51
+ await Bun.write(`${to}/${relativePath}`, source)
52
+ }),
53
+ )
54
+ }
55
+
56
+ /*
57
+ Resolves the user-supplied name against the working directory. Absolute
58
+ paths (`/tmp/foo`) and `~`-prefixed paths are used as-is; relative names
59
+ are joined onto `cwd`.
60
+ */
61
+ function resolveTarget(cwd: string, name: string): string {
62
+ if (name === '.' || name === './') {
63
+ return cwd
64
+ }
65
+ if (name.startsWith('/')) {
66
+ return name
67
+ }
68
+ if (name.startsWith('~/')) {
69
+ const home = process.env.HOME ?? ''
70
+ return `${home}${name.slice(1)}`
71
+ }
72
+ return `${cwd}/${name}`
73
+ }
74
+
75
+ /*
76
+ True when the target exists and contains at least one entry. Uses Bun.Glob
77
+ rather than fs.readdir to honor the project's "Bun-first" rule. A missing
78
+ directory is reported as empty so first-time scaffolds proceed.
79
+ */
80
+ async function targetIsNonEmpty(target: string): Promise<boolean> {
81
+ try {
82
+ for await (const _ of new Glob('*').scan({ cwd: target, onlyFiles: false, dot: true })) {
83
+ return true
84
+ }
85
+ } catch (error) {
86
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
87
+ return false
88
+ }
89
+ throw error
90
+ }
91
+ return false
92
+ }
@@ -0,0 +1,47 @@
1
+ // @ts-expect-error virtual module resolved by belteResolverPlugin
2
+ import * as appMod from './_virtual/app.ts'
3
+ // @ts-expect-error virtual module resolved by belteResolverPlugin
4
+ import { appInfo } from './_virtual/app-info.ts'
5
+ // @ts-expect-error virtual module resolved by belteResolverPlugin
6
+ import { assets } from './_virtual/assets.ts'
7
+ // @ts-expect-error virtual module resolved by belteResolverPlugin
8
+ import cliProgramName from './_virtual/cli-name.ts'
9
+ // @ts-expect-error virtual module resolved by belteResolverPlugin
10
+ import { layouts } from './_virtual/layouts.ts'
11
+ // @ts-expect-error virtual module resolved by belteResolverPlugin
12
+ import mcp from './_virtual/mcp.ts'
13
+ // @ts-expect-error virtual module resolved by belteResolverPlugin
14
+ import { mcpResources } from './_virtual/mcp-resources.ts'
15
+ // @ts-expect-error virtual module resolved by belteResolverPlugin
16
+ import { pages } from './_virtual/pages.ts'
17
+ // @ts-expect-error virtual module resolved by belteResolverPlugin
18
+ import { prompts } from './_virtual/prompts.ts'
19
+ // @ts-expect-error virtual module resolved by belteResolverPlugin
20
+ import { publicAssets } from './_virtual/public-assets.ts'
21
+ // @ts-expect-error virtual module resolved by belteResolverPlugin
22
+ import { rpc } from './_virtual/rpc.ts'
23
+ // @ts-expect-error virtual module resolved by belteResolverPlugin
24
+ import { shell } from './_virtual/shell.ts'
25
+ // @ts-expect-error virtual module resolved by belteResolverPlugin
26
+ import { sockets } from './_virtual/sockets.ts'
27
+ import { createServer } from './lib/server/runtime/createServer.ts'
28
+ import { requestContext } from './lib/server/runtime/requestContext.ts'
29
+ import { setCacheStoreResolver } from './lib/shared/setCacheStoreResolver.ts'
30
+
31
+ setCacheStoreResolver(() => requestContext.getStore()?.cache)
32
+
33
+ await createServer({
34
+ pages,
35
+ rpc,
36
+ sockets,
37
+ prompts,
38
+ layouts,
39
+ shell,
40
+ app: appMod,
41
+ assets,
42
+ publicAssets,
43
+ mcpResources,
44
+ mcp,
45
+ cliProgramName,
46
+ appInfo,
47
+ })