@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,84 @@
1
+ import type { CacheSnapshotEntry } from '../../shared/types/CacheSnapshotEntry.ts'
2
+ import type { CacheStore } from '../../shared/types/CacheStore.ts'
3
+
4
+ /*
5
+ Drains the request-scoped cache store and returns a wire-safe snapshot of
6
+ its entries. Only GET/DELETE entries with text/json bodies are included —
7
+ POST/PUT/PATCH bodies can't be reconstructed on the client without also
8
+ shipping the original request body, and binary bodies don't survive a JSON
9
+ round-trip. Pending promises are awaited so the snapshot is fully resolved
10
+ by the time SSR writes the document.
11
+
12
+ The Response body is read once and the cache entry's promise is replaced
13
+ with a fresh Response constructed from the text payload. Subsequent
14
+ `shareable()` clones inside cache() then operate on a string-bodied
15
+ Response instead of teeing the original streaming body twice per render.
16
+ */
17
+ export async function serializeCacheSnapshot(store: CacheStore): Promise<CacheSnapshotEntry[]> {
18
+ const entries = Array.from(store.entries.values())
19
+ await Promise.allSettled(entries.map((entry) => entry.promise))
20
+
21
+ const snapshot: CacheSnapshotEntry[] = []
22
+ for (const entry of entries) {
23
+ const method = entry.request.method.toUpperCase()
24
+ if (method !== 'GET' && method !== 'DELETE') {
25
+ continue
26
+ }
27
+ /*
28
+ Between the awaitAll above and this read, a handler that calls
29
+ cache.invalidate() (or evicts via ttl=0) may have replaced this
30
+ entry. Skip the stale one — the live snapshot already reflects the
31
+ replacement, and including this entry would mismatch the active key.
32
+ */
33
+ const settled = store.entries.get(entry.key)
34
+ if (!settled || settled !== entry) {
35
+ continue
36
+ }
37
+ const response = await readSettled(entry.promise)
38
+ if (!response) {
39
+ continue
40
+ }
41
+ const contentType = (response.headers.get('content-type') ?? '').toLowerCase()
42
+ if (!isTextual(contentType)) {
43
+ continue
44
+ }
45
+ const body = await response.text()
46
+ const responseInit: ResponseInit = {
47
+ status: response.status,
48
+ statusText: response.statusText,
49
+ headers: response.headers,
50
+ }
51
+ entry.promise = Promise.resolve(new Response(body, responseInit))
52
+ snapshot.push({
53
+ key: entry.key,
54
+ url: entry.request.url,
55
+ method,
56
+ status: response.status,
57
+ statusText: response.statusText,
58
+ headers: Array.from(response.headers.entries()),
59
+ body,
60
+ })
61
+ }
62
+ return snapshot
63
+ }
64
+
65
+ async function readSettled(promise: Promise<Response>): Promise<Response | undefined> {
66
+ try {
67
+ return await promise
68
+ } catch {
69
+ return undefined
70
+ }
71
+ }
72
+
73
+ function isTextual(contentType: string): boolean {
74
+ if (contentType.startsWith('text/')) {
75
+ return true
76
+ }
77
+ if (contentType.includes('json')) {
78
+ return true
79
+ }
80
+ if (contentType.includes('xml')) {
81
+ return true
82
+ }
83
+ return false
84
+ }
@@ -0,0 +1,13 @@
1
+ import type { Server } from 'bun'
2
+
3
+ /*
4
+ Internal holder for the active Bun.serve instance. setActiveServer is
5
+ called once from createServer after Bun.serve resolves; the public
6
+ `server()` function and any internal callers read through this slot
7
+ and throw when accessed before init completes. `Server<unknown>` matches
8
+ Bun's generic — ws.data is opaque to user code since the only ws path
9
+ is the framework-managed sockets dispatcher.
10
+ */
11
+ export const serverSlot: { active: Server<unknown> | undefined } = {
12
+ active: undefined,
13
+ }
@@ -0,0 +1,6 @@
1
+ import type { Server } from 'bun'
2
+ import { serverSlot } from './serverSlot.ts'
3
+
4
+ export function setActiveServer(server: Server<unknown>): void {
5
+ serverSlot.active = server
6
+ }
@@ -0,0 +1,76 @@
1
+ /*
2
+ Shared body builder for the streaming respond helpers (`jsonl`, `sse`).
3
+ Both flow the same shape — pull from an AsyncIterator, encode each frame
4
+ to bytes, emit a sentinel `error` frame on a generator throw, and route
5
+ ReadableStream's `cancel` into `iter.return()` so the handler's
6
+ `for await` exits via its normal control path. Only the per-frame
7
+ encoding and the optional keepalive payload differ between the two.
8
+
9
+ Keepalive is opt-in: SSE uses `: keepalive\n\n` every 15s so proxies
10
+ don't drop an idle connection; jsonl has no spec-defined comment, so it
11
+ omits keepalive entirely.
12
+ */
13
+
14
+ export type StreamEncoder<T> = {
15
+ encodeFrame: (value: T) => string
16
+ encodeError: (message: string) => string
17
+ keepaliveMs?: number
18
+ keepalivePayload?: string
19
+ }
20
+
21
+ export function streamFromIterator<T>(
22
+ iterable: AsyncIterable<T>,
23
+ encoder: StreamEncoder<T>,
24
+ ): ReadableStream<Uint8Array> {
25
+ const textEncoder = new TextEncoder()
26
+ const iterator = iterable[Symbol.asyncIterator]()
27
+ let keepalive: ReturnType<typeof setInterval> | undefined
28
+
29
+ function stopKeepalive(): void {
30
+ if (keepalive !== undefined) {
31
+ clearInterval(keepalive)
32
+ keepalive = undefined
33
+ }
34
+ }
35
+
36
+ return new ReadableStream<Uint8Array>({
37
+ start(controller) {
38
+ if (encoder.keepaliveMs !== undefined && encoder.keepalivePayload !== undefined) {
39
+ const payload = textEncoder.encode(encoder.keepalivePayload)
40
+ keepalive = setInterval(() => {
41
+ /*
42
+ Every close/cancel path clears this interval synchronously,
43
+ so a tick can't normally hit a closed controller — but
44
+ enqueue throws on a closed/errored stream, and an uncaught
45
+ throw in a timer crashes the process. Guard + self-stop.
46
+ */
47
+ try {
48
+ controller.enqueue(payload)
49
+ } catch {
50
+ stopKeepalive()
51
+ }
52
+ }, encoder.keepaliveMs)
53
+ }
54
+ },
55
+ async pull(controller) {
56
+ try {
57
+ const next = await iterator.next()
58
+ if (next.done) {
59
+ stopKeepalive()
60
+ controller.close()
61
+ return
62
+ }
63
+ controller.enqueue(textEncoder.encode(encoder.encodeFrame(next.value)))
64
+ } catch (error) {
65
+ const message = error instanceof Error ? error.message : String(error)
66
+ controller.enqueue(textEncoder.encode(encoder.encodeError(message)))
67
+ stopKeepalive()
68
+ controller.close()
69
+ }
70
+ },
71
+ cancel(reason) {
72
+ stopKeepalive()
73
+ return iterator.return?.(reason)?.then(() => undefined)
74
+ },
75
+ })
76
+ }
@@ -0,0 +1 @@
1
+ export type Assets = Record<string, Uint8Array>
@@ -0,0 +1,6 @@
1
+ export type CompileTarget =
2
+ | 'bun-darwin-arm64'
3
+ | 'bun-darwin-x64'
4
+ | 'bun-linux-arm64'
5
+ | 'bun-linux-x64'
6
+ | 'bun-windows-x64'
@@ -0,0 +1,15 @@
1
+ import type { Server } from 'bun'
2
+ import type { CacheStore } from '../../../shared/types/CacheStore.ts'
3
+
4
+ /*
5
+ Per-request state propagated through AsyncLocalStorage. Every field is
6
+ populated once at the server's fetch boundary; helpers and verb-defined
7
+ remote functions read from it without threading arguments through user code.
8
+ */
9
+ export type RequestStore = {
10
+ url: URL
11
+ req: Request
12
+ signal: AbortSignal
13
+ cache: CacheStore
14
+ server: Server<unknown>
15
+ }
@@ -0,0 +1,5 @@
1
+ import type { CompileOptions } from 'svelte/compiler'
2
+
3
+ export type SvelteConfig = {
4
+ compilerOptions?: Partial<CompileOptions>
5
+ }
@@ -0,0 +1,19 @@
1
+ import type { Server } from 'bun'
2
+ import { getActiveServer } from './runtime/getActiveServer.ts'
3
+
4
+ /*
5
+ Returns the active Bun.serve instance. Mirrors `request()`'s
6
+ function-call shape so call sites appear in stack traces (a Proxy
7
+ trap intermediates and obscures them). Throws if accessed before
8
+ Bun.serve has booted — silent undefined would mask the misuse and
9
+ strand later property reads with cryptic errors.
10
+ */
11
+ export function server(): Server<unknown> {
12
+ const active = getActiveServer()
13
+ if (!active) {
14
+ throw new Error(
15
+ '[belte] server() called before init — make sure your call happens inside or after app.ts init() resolves',
16
+ )
17
+ }
18
+ return active
19
+ }
@@ -0,0 +1,31 @@
1
+ import type { StandardSchemaV1 } from './rpc/types/StandardSchemaV1.ts'
2
+ import type { Socket } from './sockets/types/Socket.ts'
3
+ import type { SocketOptions } from './sockets/types/SocketOptions.ts'
4
+
5
+ /*
6
+ Declares a Socket inside a file under `src/server/sockets/`. Each file contains
7
+ exactly one export, named after the file (e.g. `chat.ts` →
8
+ `export const chat = socket<ChatMessage>(...)`). The bundler reads the
9
+ export name from the filename and the socket name from the file path
10
+ under `src/server/sockets/`, then rewrites this call to bind the name into the
11
+ runtime implementation (defineSocket on the server, socketProxy on the
12
+ client). Opts (history, clientPublish, schema, clients) live on the
13
+ server side only; the client target discards them.
14
+
15
+ When `schema` is set, `T` infers from `InferOutput<Schema>` and publish
16
+ payloads validate against it on the server. `clients` controls which
17
+ adapter surfaces (browser / mcp / cli) advertise the socket — defaults
18
+ to browser-only when schemaless, all surfaces when a schema is present.
19
+
20
+ This function exists only for the type signature; calling it directly
21
+ means the bundler plugin didn't process the file, which throws.
22
+ */
23
+ export function socket<Schema extends StandardSchemaV1>(
24
+ opts: SocketOptions<Schema> & { schema: Schema },
25
+ ): Socket<StandardSchemaV1.InferOutput<Schema>>
26
+ export function socket<T = unknown>(opts?: SocketOptions): Socket<T>
27
+ export function socket<T = unknown>(_opts?: SocketOptions): Socket<T> {
28
+ throw new Error(
29
+ '[belte] `socket(...)` was called outside an $sockets module — the socket helper is only valid as the value of `export const <filename> = ...` inside a file under src/server/sockets/',
30
+ )
31
+ }
@@ -0,0 +1,267 @@
1
+ import type { ServerWebSocket } from 'bun'
2
+ import { log } from '../../shared/log.ts'
3
+ import { lookupSocket } from './lookupSocket.ts'
4
+ import type { SocketClientFrame } from './types/SocketClientFrame.ts'
5
+ import type { SocketRoutes } from './types/SocketRoutes.ts'
6
+
7
+ type SocketDispatcher = {
8
+ open(ws: ServerWebSocket<unknown>): void
9
+ message(ws: ServerWebSocket<unknown>, data: string | Buffer): void
10
+ close(ws: ServerWebSocket<unknown>): void
11
+ }
12
+
13
+ /*
14
+ Per-connection state: which sockets this ws is currently subscribed to
15
+ (at the Bun-topic level), and which `sub` ids map to which socket. One
16
+ ws can hold multiple subs against the same socket (e.g. one with
17
+ history, one without); the Bun-topic subscription is reference-counted
18
+ so we only `ws.unsubscribe` when the last local sub drops.
19
+ */
20
+ type ConnectionState = {
21
+ subToSocket: Map<string, string>
22
+ socketSubs: Map<string, Set<string>>
23
+ }
24
+
25
+ /*
26
+ Bridges the framework's socket registry to a single ws per client. All
27
+ sockets multiplex over `/__belte/sockets`. Steady-state fan-out rides
28
+ Bun's native `server.publish('socket:<name>', frame)` so the dispatcher
29
+ is only on the path for sub/unsub bookkeeping and client-initiated pub
30
+ validation; the published `msg` frames go from publisher to subscribers
31
+ without touching JS per frame.
32
+
33
+ `sub` opens a subscription: history is replayed (unless the client
34
+ passed `tail: true`) directly to this ws, then the ws is added to the
35
+ Bun topic. `unsub` drops the local sub and unsubscribes the ws from
36
+ the Bun topic if no other local subs remain. `pub` validates the
37
+ socket's `allowClientPublish` policy and calls `socket.publish` —
38
+ which fans out to in-process iterators and republishes through Bun
39
+ to other connected clients.
40
+
41
+ Module-level lookups are cached per socket name: loading a socket
42
+ module triggers its `defineSocket` call, which inserts into the
43
+ registry. After that the dispatcher just reads the registry.
44
+ */
45
+ export function createSocketDispatcher(sockets: SocketRoutes): SocketDispatcher {
46
+ const moduleCache = new Map<string, Promise<void>>()
47
+ const connections = new WeakMap<ServerWebSocket<unknown>, ConnectionState>()
48
+
49
+ function ensureLoaded(name: string): Promise<void> | undefined {
50
+ const existing = moduleCache.get(name)
51
+ if (existing) {
52
+ return existing
53
+ }
54
+ const loader = sockets[name]
55
+ if (!loader) {
56
+ return undefined
57
+ }
58
+ const promise = loader().then(() => undefined)
59
+ moduleCache.set(name, promise)
60
+ return promise
61
+ }
62
+
63
+ function send(ws: ServerWebSocket<unknown>, frame: unknown): void {
64
+ if (ws.readyState !== 1) {
65
+ return
66
+ }
67
+ ws.send(JSON.stringify(frame))
68
+ }
69
+
70
+ function addSub(state: ConnectionState, name: string, sub: string): boolean {
71
+ state.subToSocket.set(sub, name)
72
+ let subs = state.socketSubs.get(name)
73
+ if (!subs) {
74
+ subs = new Set()
75
+ state.socketSubs.set(name, subs)
76
+ }
77
+ const wasEmpty = subs.size === 0
78
+ subs.add(sub)
79
+ return wasEmpty
80
+ }
81
+
82
+ function removeSub(state: ConnectionState, sub: string): string | undefined {
83
+ const name = state.subToSocket.get(sub)
84
+ if (!name) {
85
+ return undefined
86
+ }
87
+ state.subToSocket.delete(sub)
88
+ const subs = state.socketSubs.get(name)
89
+ if (!subs) {
90
+ return undefined
91
+ }
92
+ subs.delete(sub)
93
+ if (subs.size === 0) {
94
+ state.socketSubs.delete(name)
95
+ return name
96
+ }
97
+ return undefined
98
+ }
99
+
100
+ async function handleSub(
101
+ ws: ServerWebSocket<unknown>,
102
+ state: ConnectionState,
103
+ frame: Extract<SocketClientFrame, { type: 'sub' }>,
104
+ ): Promise<void> {
105
+ const loader = ensureLoaded(frame.socket)
106
+ if (!loader) {
107
+ send(ws, {
108
+ type: 'err',
109
+ sub: frame.sub,
110
+ message: `[belte] no socket registered at ${frame.socket}`,
111
+ })
112
+ send(ws, { type: 'end', sub: frame.sub })
113
+ return
114
+ }
115
+ try {
116
+ await loader
117
+ } catch (error) {
118
+ log.error(error)
119
+ send(ws, {
120
+ type: 'err',
121
+ sub: frame.sub,
122
+ message: error instanceof Error ? error.message : String(error),
123
+ })
124
+ send(ws, { type: 'end', sub: frame.sub })
125
+ return
126
+ }
127
+ const entry = lookupSocket(frame.socket)
128
+ if (!entry) {
129
+ send(ws, {
130
+ type: 'err',
131
+ sub: frame.sub,
132
+ message: `[belte] socket module at ${frame.socket} did not register a Socket export`,
133
+ })
134
+ send(ws, { type: 'end', sub: frame.sub })
135
+ return
136
+ }
137
+ const isFirstLocalSub = addSub(state, frame.socket, frame.sub)
138
+ if (isFirstLocalSub) {
139
+ ws.subscribe(`socket:${frame.socket}`)
140
+ }
141
+ /*
142
+ Replay history directly to this ws via ws.send (not
143
+ server.publish) so other connected subscribers don't see the
144
+ replay. Live messages published from now on flow through the
145
+ Bun topic the ws just joined; clients may observe live messages
146
+ interleaved with the tail of history, so user payloads should
147
+ carry an id/timestamp when ordering matters.
148
+
149
+ `replay === undefined` means full replay (bare `for await`);
150
+ a number is clamped to the buffer length so the client can ask
151
+ for "as many as available, up to N".
152
+ */
153
+ const history = entry.snapshotHistory()
154
+ const replayCount =
155
+ frame.replay === undefined ? history.length : Math.min(frame.replay, history.length)
156
+ if (replayCount > 0) {
157
+ const start = history.length - replayCount
158
+ for (let index = start; index < history.length; index++) {
159
+ send(ws, { type: 'msg', socket: frame.socket, message: history[index] })
160
+ }
161
+ }
162
+ }
163
+
164
+ function handleUnsub(
165
+ ws: ServerWebSocket<unknown>,
166
+ state: ConnectionState,
167
+ frame: Extract<SocketClientFrame, { type: 'unsub' }>,
168
+ ): void {
169
+ const emptied = removeSub(state, frame.sub)
170
+ if (emptied) {
171
+ ws.unsubscribe(`socket:${emptied}`)
172
+ }
173
+ send(ws, { type: 'end', sub: frame.sub })
174
+ }
175
+
176
+ async function handlePub(
177
+ ws: ServerWebSocket<unknown>,
178
+ frame: Extract<SocketClientFrame, { type: 'pub' }>,
179
+ ): Promise<void> {
180
+ const loader = ensureLoaded(frame.socket)
181
+ if (!loader) {
182
+ return
183
+ }
184
+ try {
185
+ await loader
186
+ } catch (error) {
187
+ log.error(error)
188
+ return
189
+ }
190
+ const entry = lookupSocket(frame.socket)
191
+ if (!entry) {
192
+ return
193
+ }
194
+ if (!entry.allowClientPublish) {
195
+ /*
196
+ Silent drop: the publish is rejected because the topic
197
+ wasn't declared `{ clientPublish: true }`. Surfacing this as
198
+ an error per-publish would tempt apps to attempt-then-handle
199
+ instead of routing through an HTTP route for auth. Log it
200
+ once per process at debug level (out of scope here) if
201
+ visibility is needed.
202
+ */
203
+ return
204
+ }
205
+ /*
206
+ publish() runs the topic's optional Standard Schema synchronously
207
+ and throws on failure (see defineSocket.validateSync). The
208
+ dispatcher invokes us via `void handlePub(...)`, so an unhandled
209
+ throw would surface as an unhandled promise rejection on every
210
+ malformed client frame. Catch + log so a buggy client can't take
211
+ the process down.
212
+ */
213
+ try {
214
+ entry.socket.publish(frame.message)
215
+ } catch (error) {
216
+ log.error(error)
217
+ }
218
+ /*
219
+ ws parameter retained for future per-ws auth context (cookies on
220
+ upgrade) the canPublish hook would consult.
221
+ */
222
+ void ws
223
+ }
224
+
225
+ return {
226
+ open(ws) {
227
+ connections.set(ws, { subToSocket: new Map(), socketSubs: new Map() })
228
+ },
229
+
230
+ message(ws, data) {
231
+ const state = connections.get(ws)
232
+ if (!state) {
233
+ return
234
+ }
235
+ const text = typeof data === 'string' ? data : data.toString('utf8')
236
+ let frame: SocketClientFrame
237
+ try {
238
+ frame = JSON.parse(text) as SocketClientFrame
239
+ } catch {
240
+ return
241
+ }
242
+ if (frame.type === 'sub') {
243
+ void handleSub(ws, state, frame)
244
+ return
245
+ }
246
+ if (frame.type === 'unsub') {
247
+ handleUnsub(ws, state, frame)
248
+ return
249
+ }
250
+ if (frame.type === 'pub') {
251
+ void handlePub(ws, frame)
252
+ return
253
+ }
254
+ },
255
+
256
+ close(ws) {
257
+ const state = connections.get(ws)
258
+ if (!state) {
259
+ return
260
+ }
261
+ connections.delete(ws)
262
+ for (const name of state.socketSubs.keys()) {
263
+ ws.unsubscribe(`socket:${name}`)
264
+ }
265
+ },
266
+ }
267
+ }