@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,215 @@
1
+ import type { Component } from 'svelte'
2
+ import {
3
+ type NormalizedLayoutPrefix,
4
+ nearestLayoutPrefix,
5
+ normalizeLayoutPrefixes,
6
+ } from '../shared/nearestLayoutPrefix.ts'
7
+ import type { Layouts } from './types/Layouts.ts'
8
+ import type { Pages } from './types/Pages.ts'
9
+
10
+ /*
11
+ Augmentable route table. The codegen step emits a `declare module 'belte/browser/page'`
12
+ block that fills this interface with `routePath: paramShape` pairs derived
13
+ from the project's `src/browser/pages/**` tree. A bare belte install has no routes,
14
+ so the fallback arm below keeps the union inhabited before the generated
15
+ d.ts lands.
16
+ */
17
+ export type Routes = {}
18
+
19
+ type RouteKey = keyof Routes extends never ? string : keyof Routes
20
+ type ParamsFor<R extends RouteKey> = R extends keyof Routes ? Routes[R] : Record<string, string>
21
+
22
+ type PageStateFor<R extends RouteKey> = {
23
+ route: R
24
+ params: ParamsFor<R>
25
+ url: URL
26
+ }
27
+
28
+ /*
29
+ Discriminated union keyed on `route`, so consumers that narrow on `page.route`
30
+ get the matching `page.params` shape automatically. `url` is the live
31
+ WHATWG URL for the currently-displayed location; navigation reassigns the
32
+ reference so $derived subscribers re-run on every nav (not just on the
33
+ fields they happen to touch).
34
+ */
35
+ export type Page = keyof Routes extends never
36
+ ? PageStateFor<string>
37
+ : { [R in keyof Routes]: PageStateFor<R> }[keyof Routes]
38
+
39
+ // biome-ignore lint/suspicious/noExplicitAny: discriminated-union init needs a single arm
40
+ export const page: Page = $state<any>({
41
+ route: '',
42
+ params: {},
43
+ url: new URL('http://localhost/'),
44
+ })
45
+
46
+ /*
47
+ Internal renderer state — the Layout/Page components App.svelte mounts.
48
+ Kept on a separate $state object so it doesn't leak into the public `page`
49
+ shape; users only ever see route/params/url.
50
+ */
51
+ export const renderState = $state<{
52
+ Layout: Component | undefined
53
+ Page: Component | undefined
54
+ }>({
55
+ Layout: undefined,
56
+ Page: undefined,
57
+ })
58
+
59
+ let boundPages: Pages | undefined
60
+ let boundLayouts: Layouts | undefined
61
+ let layoutPrefixes: NormalizedLayoutPrefix[] = []
62
+
63
+ type SsrPayload = { route: string; params: Record<string, string> }
64
+
65
+ /*
66
+ Wires the route + layout tables produced by the bundler's virtual manifests
67
+ and seeds page state from the SSR payload. Called once from startClient
68
+ before `hydrate(App)` so the first render sees Page/Layout/params already
69
+ populated. Subsequent `navigate()` calls reuse `boundPages` / `boundLayouts`.
70
+ */
71
+ export async function bindPage({
72
+ pages,
73
+ layouts,
74
+ ssr,
75
+ }: {
76
+ pages: Pages
77
+ layouts?: Layouts
78
+ ssr: SsrPayload
79
+ }): Promise<void> {
80
+ boundPages = pages
81
+ boundLayouts = layouts
82
+ layoutPrefixes = layouts ? normalizeLayoutPrefixes(Object.keys(layouts)) : []
83
+ const { Page, Layout } = await loadView(ssr.route)
84
+ applyState(ssr.route, ssr.params, Page, Layout)
85
+ }
86
+
87
+ async function loadView(
88
+ route: string,
89
+ ): Promise<{ Page: Component; Layout: Component | undefined }> {
90
+ if (!boundPages) {
91
+ throw new Error('[belte] page is not initialized — call bindPage first')
92
+ }
93
+ const pageLoader = boundPages[route]
94
+ if (!pageLoader) {
95
+ throw new Error(`[belte] unknown route: ${route}`)
96
+ }
97
+ const layoutPrefix = nearestLayoutPrefix(route, layoutPrefixes)
98
+ const [pageMod, layoutMod] = await Promise.all([
99
+ pageLoader(),
100
+ layoutPrefix && boundLayouts ? boundLayouts[layoutPrefix]() : Promise.resolve(undefined),
101
+ ])
102
+ return { Page: pageMod.default, Layout: layoutMod?.default }
103
+ }
104
+
105
+ function applyState(
106
+ route: string,
107
+ params: Record<string, string>,
108
+ Page: Component,
109
+ Layout: Component | undefined,
110
+ ): void {
111
+ renderState.Layout = Layout
112
+ renderState.Page = Page
113
+ const mutable = page as PageStateFor<string>
114
+ mutable.route = route
115
+ mutable.params = params
116
+ mutable.url = new URL(window.location.href)
117
+ }
118
+
119
+ function syncUrl(): void {
120
+ const mutable = page as PageStateFor<string>
121
+ mutable.url = new URL(window.location.href)
122
+ }
123
+
124
+ type FetchOutcome =
125
+ | { kind: 'ok'; response: Response }
126
+ | { kind: 'network-error' }
127
+ | { kind: 'not-found' }
128
+ | { kind: 'http-error'; status: number }
129
+
130
+ async function safeResolveFetch(target: string): Promise<FetchOutcome> {
131
+ let response: Response
132
+ try {
133
+ response = await fetch(target, { headers: { Accept: 'application/json' } })
134
+ } catch {
135
+ return { kind: 'network-error' }
136
+ }
137
+ if (response.status === 404) {
138
+ return { kind: 'not-found' }
139
+ }
140
+ if (!response.ok) {
141
+ return { kind: 'http-error', status: response.status }
142
+ }
143
+ return { kind: 'ok', response }
144
+ }
145
+
146
+ export type NavigateOptions = { replace?: boolean; scroll?: boolean }
147
+
148
+ /*
149
+ SPA navigation entrypoint. Writes history (push by default, replace on
150
+ request), then resolves the new view. When only `search` or `hash` changes
151
+ (same pathname), the JSON resolve fetch + loadView are skipped — only
152
+ `page.url` is reassigned, so $derived consumers re-run without paying a
153
+ network round-trip or remounting the page component. Falls back to a hard
154
+ navigation if the resolve fetch or page-module import fails, mirroring the
155
+ behaviour of the original click handler.
156
+ */
157
+ export async function navigate(href: string, options: NavigateOptions = {}): Promise<void> {
158
+ const { replace = false, scroll = true } = options
159
+ const target = new URL(href, window.location.href)
160
+ if (target.origin !== window.location.origin) {
161
+ window.location.href = href
162
+ return
163
+ }
164
+ const fullTarget = `${target.pathname}${target.search}${target.hash}`
165
+ if (replace) {
166
+ window.history.replaceState(undefined, '', fullTarget)
167
+ } else {
168
+ window.history.pushState(undefined, '', fullTarget)
169
+ }
170
+ await applyTarget(target.pathname, fullTarget, scroll && !replace)
171
+ }
172
+
173
+ /*
174
+ Called by both navigate() (after writing history) and the popstate handler
175
+ (history is already current). When the pathname hasn't changed, the route
176
+ + params + Page are the same; we just refresh `page.url`. A true pathname
177
+ change triggers the JSON resolve fetch and a page swap.
178
+ */
179
+ async function applyTarget(
180
+ pathname: string,
181
+ fullTarget: string,
182
+ resetScroll: boolean,
183
+ ): Promise<void> {
184
+ if (pathname === page.url.pathname) {
185
+ syncUrl()
186
+ return
187
+ }
188
+ const outcome = await safeResolveFetch(fullTarget)
189
+ if (outcome.kind !== 'ok') {
190
+ window.location.href = fullTarget
191
+ return
192
+ }
193
+ const result = (await outcome.response.json()) as SsrPayload
194
+ try {
195
+ const { Page, Layout } = await loadView(result.route)
196
+ applyState(result.route, result.params, Page, Layout)
197
+ if (resetScroll) {
198
+ window.scrollTo(0, 0)
199
+ }
200
+ } catch (err) {
201
+ console.error('[belte] navigation failed', err)
202
+ window.location.href = fullTarget
203
+ }
204
+ }
205
+
206
+ /*
207
+ popstate fires after the browser has already restored the URL, so this just
208
+ applies the current location without writing history again. Scroll position
209
+ is left alone — the browser's built-in history scroll restoration wins for
210
+ back/forward.
211
+ */
212
+ export function handlePopstate(): void {
213
+ const fullTarget = `${window.location.pathname}${window.location.search}${window.location.hash}`
214
+ void applyTarget(window.location.pathname, fullTarget, false)
215
+ }
@@ -0,0 +1,44 @@
1
+ import type { HttpVerb } from '../server/rpc/types/HttpVerb.ts'
2
+ import type { RemoteFunction } from '../server/rpc/types/RemoteFunction.ts'
3
+ import { buildRpcRequest } from '../shared/buildRpcRequest.ts'
4
+ import { createRemoteFunction } from '../shared/createRemoteFunction.ts'
5
+
6
+ /*
7
+ The browser stub is only emitted when the verb has `clients.browser:
8
+ true`, so the value is always true here. mcp/cli flags are server-only
9
+ discovery state and the browser bundle has no use for them; default
10
+ false so the public RemoteFunction shape stays the same on both sides.
11
+ */
12
+ const BROWSER_CLIENT_FLAGS = { browser: true, mcp: false, cli: false } as const
13
+
14
+ /*
15
+ Client-side substitute for a verb-defined handler. The bundler emits one
16
+ call per verb export inside an `$rpc/**` module (GET / POST / …): server
17
+ target uses defineVerb (real handler), browser target uses remoteProxy
18
+ (fetch over the network). Both paths produce identical RemoteFunction
19
+ shapes and identical WeakMap metadata so cache() works the same on either
20
+ side.
21
+
22
+ `url` is the flat rpc route. Args go in the JSON body (POST/PUT/PATCH) or
23
+ the query string (GET/DELETE/HEAD). Plain `fn(args)` decodes the Response
24
+ by Content-Type and throws HttpError on non-2xx; `.raw(args)` is the
25
+ escape hatch that returns the Response untouched.
26
+ */
27
+ export function remoteProxy<Args, Return>(
28
+ method: HttpVerb,
29
+ url: string,
30
+ ): RemoteFunction<Args, Return> {
31
+ return createRemoteFunction<Args, Return>({
32
+ method,
33
+ url,
34
+ clients: BROWSER_CLIENT_FLAGS,
35
+ buildRequest: (args) =>
36
+ buildRpcRequest({ method, url, args, baseUrl: window.location.href }),
37
+ /*
38
+ Forcing `getRequest()` once builds the Request and seeds the
39
+ cache meta thunk in createRemoteFunction with the same instance,
40
+ so cache() readers don't reconstruct it.
41
+ */
42
+ invoke: (_args, getRequest) => fetch(getRequest()),
43
+ })
44
+ }
@@ -0,0 +1,182 @@
1
+ import type { SocketClientFrame } from '../server/sockets/types/SocketClientFrame.ts'
2
+ import type { SocketServerFrame } from '../server/sockets/types/SocketServerFrame.ts'
3
+
4
+ type SubCallbacks = {
5
+ onMessage(message: unknown): void
6
+ onError(message: string): void
7
+ onEnd(): void
8
+ }
9
+
10
+ type Channel = {
11
+ subscribe(
12
+ sub: string,
13
+ socket: string,
14
+ replay: number | undefined,
15
+ callbacks: SubCallbacks,
16
+ ): void
17
+ unsubscribe(sub: string): void
18
+ publish(socket: string, message: unknown): void
19
+ }
20
+
21
+ const SOCKETS_PATH = '/__belte/sockets'
22
+
23
+ let singleton: Channel | undefined
24
+
25
+ /*
26
+ Lazily opens the single multiplexed ws used by every socket proxy on
27
+ the page. Routes inbound frames:
28
+ `msg` → all local subs of that socket
29
+ `end` → the matching sub
30
+ `err` → the matching sub
31
+
32
+ `msg` frames carry no sub id: one publish from the server fans out to
33
+ every connected ws via Bun's native publish, and each ws delivers the
34
+ message to every local sub of that socket. `end`/`err` are per-sub
35
+ because they're subscription-lifecycle events, not data.
36
+
37
+ Outbound frames sent before `ws.onopen` fires are queued and flushed
38
+ on open. The channel reconnects on close with bounded backoff;
39
+ in-flight subs are torn down with a synthetic error so consumers'
40
+ `for await` loops can surface the disconnect, then the connection
41
+ comes back up and fresh subs can be opened. We intentionally do not
42
+ silently re-subscribe across a reconnect — most socket consumers need
43
+ to reconcile state on a fresh connection (e.g. re-fetch a snapshot
44
+ before reapplying deltas), so the framework hands the disconnect to
45
+ user code instead of papering over it.
46
+ */
47
+ export function getSocketChannel(): Channel {
48
+ if (singleton) {
49
+ return singleton
50
+ }
51
+ const subs = new Map<string, { socket: string; callbacks: SubCallbacks }>()
52
+ const subsBySocket = new Map<string, Set<string>>()
53
+ let ws: WebSocket | undefined
54
+ let pendingSends: string[] = []
55
+ let backoffMs = 250
56
+
57
+ function flushPending(): void {
58
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
59
+ return
60
+ }
61
+ for (const message of pendingSends) {
62
+ ws.send(message)
63
+ }
64
+ pendingSends = []
65
+ }
66
+
67
+ function send(frame: SocketClientFrame): void {
68
+ const message = JSON.stringify(frame)
69
+ if (ws && ws.readyState === WebSocket.OPEN) {
70
+ ws.send(message)
71
+ return
72
+ }
73
+ pendingSends.push(message)
74
+ connect()
75
+ }
76
+
77
+ function connect(): void {
78
+ if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
79
+ return
80
+ }
81
+ const scheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
82
+ ws = new WebSocket(`${scheme}//${window.location.host}${SOCKETS_PATH}`)
83
+ ws.addEventListener('open', () => {
84
+ backoffMs = 250
85
+ flushPending()
86
+ })
87
+ ws.addEventListener('message', (event) => {
88
+ let frame: SocketServerFrame
89
+ try {
90
+ frame = JSON.parse(event.data) as SocketServerFrame
91
+ } catch {
92
+ return
93
+ }
94
+ if (frame.type === 'msg') {
95
+ /*
96
+ One Bun-published frame fans out to every local sub of
97
+ that socket on this ws — addressed by socket name, not
98
+ per-sub id.
99
+ */
100
+ const targets = subsBySocket.get(frame.socket)
101
+ if (!targets) {
102
+ return
103
+ }
104
+ for (const subId of targets) {
105
+ subs.get(subId)?.callbacks.onMessage(frame.message)
106
+ }
107
+ return
108
+ }
109
+ if (frame.type === 'end') {
110
+ const sub = subs.get(frame.sub)
111
+ if (!sub) {
112
+ return
113
+ }
114
+ dropSub(frame.sub)
115
+ sub.callbacks.onEnd()
116
+ return
117
+ }
118
+ if (frame.type === 'err') {
119
+ const sub = subs.get(frame.sub)
120
+ if (!sub) {
121
+ return
122
+ }
123
+ dropSub(frame.sub)
124
+ sub.callbacks.onError(frame.message)
125
+ return
126
+ }
127
+ })
128
+ ws.addEventListener('close', () => {
129
+ const active = [...subs.entries()]
130
+ subs.clear()
131
+ subsBySocket.clear()
132
+ for (const [, sub] of active) {
133
+ sub.callbacks.onError('socket channel disconnected')
134
+ }
135
+ ws = undefined
136
+ if (active.length === 0 && pendingSends.length === 0) {
137
+ return
138
+ }
139
+ setTimeout(connect, backoffMs)
140
+ backoffMs = Math.min(backoffMs * 2, 5000)
141
+ })
142
+ }
143
+
144
+ function dropSub(id: string): void {
145
+ const entry = subs.get(id)
146
+ if (!entry) {
147
+ return
148
+ }
149
+ subs.delete(id)
150
+ const set = subsBySocket.get(entry.socket)
151
+ if (set) {
152
+ set.delete(id)
153
+ if (set.size === 0) {
154
+ subsBySocket.delete(entry.socket)
155
+ }
156
+ }
157
+ }
158
+
159
+ singleton = {
160
+ subscribe(id, socket, replay, callbacks) {
161
+ subs.set(id, { socket, callbacks })
162
+ let set = subsBySocket.get(socket)
163
+ if (!set) {
164
+ set = new Set()
165
+ subsBySocket.set(socket, set)
166
+ }
167
+ set.add(id)
168
+ send({ type: 'sub', sub: id, socket, replay })
169
+ },
170
+ unsubscribe(id) {
171
+ if (!subs.has(id)) {
172
+ return
173
+ }
174
+ dropSub(id)
175
+ send({ type: 'unsub', sub: id })
176
+ },
177
+ publish(socket, message) {
178
+ send({ type: 'pub', socket, message })
179
+ },
180
+ }
181
+ return singleton
182
+ }
@@ -0,0 +1,64 @@
1
+ import type { Socket } from '../server/sockets/types/Socket.ts'
2
+ import { createPushIterator } from '../shared/createPushIterator.ts'
3
+ import { getSocketChannel } from './socketChannel.ts'
4
+
5
+ let nextId = 0
6
+
7
+ /*
8
+ Browser stub is only emitted when `clients.browser: true`, so the value
9
+ is always true here. mcp/cli flags are server-only discovery state; the
10
+ browser bundle has no use for them. Default false so the public Socket
11
+ shape stays consistent on both sides.
12
+ */
13
+ const BROWSER_CLIENT_FLAGS = { browser: true, mcp: false, cli: false } as const
14
+
15
+ /*
16
+ Client-side substitute for a server-declared Socket. The bundler emits
17
+ one call per socket export under `src/server/sockets/`: server target uses
18
+ defineSocket (real fan-out), browser target uses socketProxy (subscribe
19
+ over the multiplexed ws channel). Both paths produce identical Socket
20
+ shapes so user code reads the same on either side.
21
+
22
+ Bare iteration opens a subscription with full history replay; `.tail(n)`
23
+ opens one that replays the last `n` items (default `0`, clamped server-
24
+ side to the topic's history max). Each subscription mints its own id
25
+ used to route lifecycle frames (`end`, `err`). Calling `.publish` sends
26
+ a `pub` frame the server validates against the topic's
27
+ `allowClientPublish` policy — there is no client-side enforcement, so a
28
+ publish attempt on a server-only topic is silently dropped server-side.
29
+
30
+ Backpressure is unbounded — a slow consumer with a chatty socket will
31
+ grow the per-iterator buffer; bounded policies belong in a future
32
+ socketProxy API, not the wire layer.
33
+ */
34
+ export function socketProxy<T>(name: string): Socket<T> {
35
+ /*
36
+ replay === undefined → full history replay (bare for-await);
37
+ replay: number → trailing-n replay, clamped by the server.
38
+ */
39
+ function iterate(replay: number | undefined): AsyncIterable<T> {
40
+ return {
41
+ [Symbol.asyncIterator](): AsyncIterator<T, void, undefined> {
42
+ const id = `s${++nextId}`
43
+ const channel = getSocketChannel()
44
+ const iter = createPushIterator<T>(() => channel.unsubscribe(id))
45
+ channel.subscribe(id, name, replay, {
46
+ onMessage: (value) => iter.push(value as T),
47
+ onEnd: () => iter.end(),
48
+ onError: (message) => iter.error(message),
49
+ })
50
+ return iter
51
+ },
52
+ }
53
+ }
54
+
55
+ return {
56
+ name,
57
+ clients: BROWSER_CLIENT_FLAGS,
58
+ publish(message: T) {
59
+ getSocketChannel().publish(name, message)
60
+ },
61
+ tail: (count = 0) => iterate(count),
62
+ [Symbol.asyncIterator]: () => iterate(undefined)[Symbol.asyncIterator](),
63
+ }
64
+ }
@@ -0,0 +1,132 @@
1
+ import { hydrate } from 'svelte'
2
+ import App from '../../App.svelte'
3
+ import { createCacheStore } from '../shared/createCacheStore.ts'
4
+ import { setCacheStoreResolver } from '../shared/setCacheStoreResolver.ts'
5
+ import type { CacheSnapshotEntry } from '../shared/types/CacheSnapshotEntry.ts'
6
+ import type { CacheStore } from '../shared/types/CacheStore.ts'
7
+ import { bindPage, handlePopstate, navigate, page, renderState } from './page.svelte.ts'
8
+ import type { Layouts } from './types/Layouts.ts'
9
+ import type { Pages } from './types/Pages.ts'
10
+
11
+ declare global {
12
+ interface Window {
13
+ __SSR__: {
14
+ route: string
15
+ params: Record<string, string>
16
+ cache?: CacheSnapshotEntry[]
17
+ }
18
+ }
19
+ }
20
+
21
+ /*
22
+ Pre-populates the client cache store with response entries captured during
23
+ SSR. Each entry becomes an already-resolved Response so the first hydration
24
+ pass finds the data via cache() without issuing a network round-trip.
25
+ */
26
+ function hydrateCacheFromSnapshot(store: CacheStore, snapshot: CacheSnapshotEntry[]): void {
27
+ for (const entry of snapshot) {
28
+ const response = new Response(entry.body, {
29
+ status: entry.status,
30
+ statusText: entry.statusText,
31
+ headers: new Headers(entry.headers),
32
+ })
33
+ store.entries.set(entry.key, {
34
+ key: entry.key,
35
+ promise: Promise.resolve(response),
36
+ request: new Request(entry.url, { method: entry.method }),
37
+ ttl: undefined,
38
+ expiresAt: undefined,
39
+ })
40
+ }
41
+ }
42
+
43
+ function isInternalLinkEvent(event: MouseEvent): HTMLAnchorElement | undefined {
44
+ if (event.defaultPrevented) {
45
+ return undefined
46
+ }
47
+ if (event.button !== 0) {
48
+ return undefined
49
+ }
50
+ if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) {
51
+ return undefined
52
+ }
53
+ const anchor = (event.target as HTMLElement | null)?.closest?.('a')
54
+ if (!anchor) {
55
+ return undefined
56
+ }
57
+ if (anchor.target && anchor.target !== '_self') {
58
+ return undefined
59
+ }
60
+ if (anchor.hasAttribute('download')) {
61
+ return undefined
62
+ }
63
+ if (anchor.getAttribute('rel')?.includes('external')) {
64
+ return undefined
65
+ }
66
+ const href = anchor.getAttribute('href')
67
+ if (!href || href.startsWith('#')) {
68
+ return undefined
69
+ }
70
+ const url = new URL(href, window.location.href)
71
+ if (url.origin !== window.location.origin) {
72
+ return undefined
73
+ }
74
+ return anchor
75
+ }
76
+
77
+ /*
78
+ Hydrates the SSR'd document against the SSR payload on `window.__SSR__`,
79
+ then intercepts internal link clicks (delegating to navigate) and popstate
80
+ events. The page module owns the route/Page/Layout state and the
81
+ URL-resolution logic; this entry just wires the cache store, runs the
82
+ initial bind, and attaches the global listeners. App.svelte receives the
83
+ public `page` proxy plus the internal renderState so the same reactive
84
+ objects update across navigations.
85
+ */
86
+ export async function startClient({
87
+ pages,
88
+ layouts,
89
+ }: {
90
+ pages: Pages
91
+ layouts?: Layouts
92
+ }): Promise<void> {
93
+ const target = document.getElementById('app')
94
+ if (!target) {
95
+ throw new Error('[belte] missing #app target')
96
+ }
97
+
98
+ const cacheStore = createCacheStore()
99
+ setCacheStoreResolver(() => cacheStore)
100
+ if (window.__SSR__.cache) {
101
+ hydrateCacheFromSnapshot(cacheStore, window.__SSR__.cache)
102
+ }
103
+
104
+ try {
105
+ await bindPage({ pages, layouts, ssr: window.__SSR__ })
106
+ hydrate(App, { target, props: { state: { page, render: renderState } } })
107
+ } catch (err) {
108
+ console.error('[belte] initial hydration failed', err)
109
+ }
110
+
111
+ document.addEventListener('click', (event) => {
112
+ const anchor = isInternalLinkEvent(event)
113
+ if (!anchor) {
114
+ return
115
+ }
116
+ const url = new URL(anchor.href, window.location.href)
117
+ /*
118
+ Hash-only same-page navigations fall through to the browser so the
119
+ native scroll-into-view for `#anchor` targets keeps working.
120
+ Anything else (pathname, search, or pathname+hash combo) goes
121
+ through navigate() — it pushes history, refreshes page state, and
122
+ short-circuits the JSON resolve when only search/hash differ.
123
+ */
124
+ if (url.pathname === window.location.pathname && url.search === window.location.search) {
125
+ return
126
+ }
127
+ event.preventDefault()
128
+ void navigate(`${url.pathname}${url.search}${url.hash}`)
129
+ })
130
+
131
+ window.addEventListener('popstate', handlePopstate)
132
+ }