@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.
- package/LICENSE +21 -0
- package/README.md +49 -0
- package/bin/belte.ts +136 -0
- package/package.json +80 -0
- package/src/App.svelte +31 -0
- package/src/assets/app.html +14 -0
- package/src/belteResolverPlugin.ts +832 -0
- package/src/build.ts +144 -0
- package/src/buildCli.ts +160 -0
- package/src/cliEntry.ts +31 -0
- package/src/clientEntry.ts +7 -0
- package/src/compile.ts +64 -0
- package/src/devEntry.ts +33 -0
- package/src/discoveryEntry.ts +33 -0
- package/src/lib/browser/cache.ts +191 -0
- package/src/lib/browser/page.svelte.ts +215 -0
- package/src/lib/browser/remoteProxy.ts +44 -0
- package/src/lib/browser/socketChannel.ts +182 -0
- package/src/lib/browser/socketProxy.ts +64 -0
- package/src/lib/browser/startClient.ts +132 -0
- package/src/lib/browser/subscribe.ts +131 -0
- package/src/lib/browser/types/Layouts.ts +7 -0
- package/src/lib/browser/types/Pages.ts +7 -0
- package/src/lib/cli/createClient.ts +126 -0
- package/src/lib/cli/loadEnvFromBinaryDir.ts +44 -0
- package/src/lib/cli/parseArgvForRpc.ts +97 -0
- package/src/lib/cli/printHelp.ts +70 -0
- package/src/lib/cli/runCli.ts +88 -0
- package/src/lib/cli/types/CliManifest.ts +9 -0
- package/src/lib/cli/types/CliManifestEntry.ts +12 -0
- package/src/lib/mcp/createMcpResourceServer.ts +101 -0
- package/src/lib/mcp/createMcpServer.ts +40 -0
- package/src/lib/mcp/dispatchMcpRequest.ts +294 -0
- package/src/lib/mcp/mcpResourceServerSlot.ts +18 -0
- package/src/lib/mcp/types/JsonRpcRequest.ts +12 -0
- package/src/lib/mcp/types/JsonRpcResponse.ts +20 -0
- package/src/lib/mcp/types/McpResourceContents.ts +10 -0
- package/src/lib/mcp/types/McpResourceDescriptor.ts +6 -0
- package/src/lib/mcp/types/McpResourceServer.ts +12 -0
- package/src/lib/mcp/types/McpServer.ts +9 -0
- package/src/lib/mcp/types/McpServerOptions.ts +16 -0
- package/src/lib/server/AppModule.ts +25 -0
- package/src/lib/server/DELETE.ts +9 -0
- package/src/lib/server/GET.ts +9 -0
- package/src/lib/server/HEAD.ts +9 -0
- package/src/lib/server/HttpError.ts +19 -0
- package/src/lib/server/PATCH.ts +9 -0
- package/src/lib/server/POST.ts +9 -0
- package/src/lib/server/PUT.ts +9 -0
- package/src/lib/server/cli/buildEnvContent.ts +18 -0
- package/src/lib/server/cli/createTarGz.ts +76 -0
- package/src/lib/server/cli/handleCliDownload.ts +124 -0
- package/src/lib/server/cli/handleCliInstall.ts +20 -0
- package/src/lib/server/cli/installScript.ts +29 -0
- package/src/lib/server/cli/maxSourceMtime.ts +27 -0
- package/src/lib/server/error.ts +56 -0
- package/src/lib/server/json.ts +28 -0
- package/src/lib/server/jsonl.ts +40 -0
- package/src/lib/server/prompt.ts +30 -0
- package/src/lib/server/prompts/definePrompt.ts +20 -0
- package/src/lib/server/prompts/promptRegistry.ts +9 -0
- package/src/lib/server/prompts/registerPrompt.ts +6 -0
- package/src/lib/server/prompts/types/Prompt.ts +14 -0
- package/src/lib/server/prompts/types/PromptMessage.ts +10 -0
- package/src/lib/server/prompts/types/PromptOptions.ts +17 -0
- package/src/lib/server/prompts/types/PromptRegistryEntry.ts +15 -0
- package/src/lib/server/prompts/types/PromptRoutes.ts +10 -0
- package/src/lib/server/redirect.ts +37 -0
- package/src/lib/server/request.ts +18 -0
- package/src/lib/server/rpc/defineVerb.ts +103 -0
- package/src/lib/server/rpc/parseArgs.ts +60 -0
- package/src/lib/server/rpc/registerVerb.ts +6 -0
- package/src/lib/server/rpc/types/HttpVerb.ts +1 -0
- package/src/lib/server/rpc/types/RawRemoteFunction.ts +13 -0
- package/src/lib/server/rpc/types/RemoteFunction.ts +35 -0
- package/src/lib/server/rpc/types/RemoteHandler.ts +22 -0
- package/src/lib/server/rpc/types/RemoteRoutes.ts +13 -0
- package/src/lib/server/rpc/types/StandardSchemaV1.ts +57 -0
- package/src/lib/server/rpc/types/TypedResponse.ts +18 -0
- package/src/lib/server/rpc/types/VerbHelper.ts +39 -0
- package/src/lib/server/rpc/types/VerbRegistryEntry.ts +17 -0
- package/src/lib/server/rpc/unprocessed.ts +14 -0
- package/src/lib/server/rpc/verbRegistry.ts +11 -0
- package/src/lib/server/runtime/buildOpenApiSpec.ts +66 -0
- package/src/lib/server/runtime/cacheControlForAsset.ts +17 -0
- package/src/lib/server/runtime/containsTraversal.ts +37 -0
- package/src/lib/server/runtime/createPublicAssetServer.ts +66 -0
- package/src/lib/server/runtime/createServer.ts +555 -0
- package/src/lib/server/runtime/getActiveServer.ts +6 -0
- package/src/lib/server/runtime/mimeForExtension.ts +20 -0
- package/src/lib/server/runtime/registryManifests.ts +48 -0
- package/src/lib/server/runtime/requestContext.ts +5 -0
- package/src/lib/server/runtime/safeJsonForScript.ts +17 -0
- package/src/lib/server/runtime/serializeCacheSnapshot.ts +84 -0
- package/src/lib/server/runtime/serverSlot.ts +13 -0
- package/src/lib/server/runtime/setActiveServer.ts +6 -0
- package/src/lib/server/runtime/streamFromIterator.ts +76 -0
- package/src/lib/server/runtime/types/Assets.ts +1 -0
- package/src/lib/server/runtime/types/CompileTarget.ts +6 -0
- package/src/lib/server/runtime/types/RequestStore.ts +15 -0
- package/src/lib/server/runtime/types/SvelteConfig.ts +5 -0
- package/src/lib/server/server.ts +19 -0
- package/src/lib/server/socket.ts +31 -0
- package/src/lib/server/sockets/createSocketDispatcher.ts +267 -0
- package/src/lib/server/sockets/defineSocket.ts +160 -0
- package/src/lib/server/sockets/lookupSocket.ts +6 -0
- package/src/lib/server/sockets/registerSocket.ts +6 -0
- package/src/lib/server/sockets/socketRegistry.ts +9 -0
- package/src/lib/server/sockets/types/Socket.ts +21 -0
- package/src/lib/server/sockets/types/SocketClientFrame.ts +18 -0
- package/src/lib/server/sockets/types/SocketOptions.ts +22 -0
- package/src/lib/server/sockets/types/SocketRegistryEntry.ts +18 -0
- package/src/lib/server/sockets/types/SocketRoutes.ts +10 -0
- package/src/lib/server/sockets/types/SocketServerFrame.ts +15 -0
- package/src/lib/server/sse.ts +47 -0
- package/src/lib/shared/activeCacheStore.ts +20 -0
- package/src/lib/shared/buildRpcRequest.ts +61 -0
- package/src/lib/shared/cacheControlValues.ts +8 -0
- package/src/lib/shared/cacheStoreSlot.ts +16 -0
- package/src/lib/shared/canonicalJson.ts +24 -0
- package/src/lib/shared/commandNameForUrl.ts +17 -0
- package/src/lib/shared/createCacheStore.ts +42 -0
- package/src/lib/shared/createPushIterator.ts +77 -0
- package/src/lib/shared/createRemoteFunction.ts +89 -0
- package/src/lib/shared/decodeResponse.ts +47 -0
- package/src/lib/shared/detectTarget.ts +27 -0
- package/src/lib/shared/findExportCallSite.ts +479 -0
- package/src/lib/shared/forwardHeaders.ts +28 -0
- package/src/lib/shared/getRemoteMeta.ts +5 -0
- package/src/lib/shared/isDebugEnabled.ts +23 -0
- package/src/lib/shared/jsonSchemaForSchema.ts +38 -0
- package/src/lib/shared/keyForRemoteCall.ts +38 -0
- package/src/lib/shared/loadSvelteConfig.ts +18 -0
- package/src/lib/shared/log.ts +104 -0
- package/src/lib/shared/nearestLayoutPrefix.ts +36 -0
- package/src/lib/shared/normalizeTarget.ts +10 -0
- package/src/lib/shared/pageUrlForFile.ts +14 -0
- package/src/lib/shared/parseRouteSegments.ts +22 -0
- package/src/lib/shared/preparePromptModule.ts +36 -0
- package/src/lib/shared/prepareRpcModule.ts +51 -0
- package/src/lib/shared/prepareSocketModule.ts +37 -0
- package/src/lib/shared/programNameForPackage.ts +14 -0
- package/src/lib/shared/promptNameForFile.ts +10 -0
- package/src/lib/shared/recordRemoteMeta.ts +5 -0
- package/src/lib/shared/remoteMetaStore.ts +16 -0
- package/src/lib/shared/resolveClientFlags.ts +18 -0
- package/src/lib/shared/rpcUrlForFile.ts +19 -0
- package/src/lib/shared/setCacheStoreResolver.ts +6 -0
- package/src/lib/shared/socketNameForFile.ts +11 -0
- package/src/lib/shared/streamingContentTypes.ts +11 -0
- package/src/lib/shared/stripImport.ts +27 -0
- package/src/lib/shared/subscribableFromResponse.ts +333 -0
- package/src/lib/shared/toBunRoutePattern.ts +28 -0
- package/src/lib/shared/types/CacheEntry.ts +16 -0
- package/src/lib/shared/types/CacheOptions.ts +10 -0
- package/src/lib/shared/types/CacheSnapshotEntry.ts +15 -0
- package/src/lib/shared/types/CacheStore.ts +15 -0
- package/src/lib/shared/types/ClientFlags.ts +11 -0
- package/src/lib/shared/types/Subscribable.ts +15 -0
- package/src/lib/shared/writeRoutesDts.ts +64 -0
- package/src/preload.ts +20 -0
- package/src/scaffold.ts +92 -0
- package/src/serverEntry.ts +47 -0
- package/src/sveltePlugin.ts +58 -0
- package/src/tailwindStylePreprocessor.ts +62 -0
- package/template/package.json +16 -0
- package/template/src/app.ts +23 -0
- package/template/src/browser/app.css +21 -0
- package/template/src/browser/app.html +24 -0
- package/template/src/browser/pages/about/page.svelte +5 -0
- package/template/src/browser/pages/layout.svelte +26 -0
- package/template/src/browser/pages/page.svelte +20 -0
- package/template/src/cli/banner.txt +3 -0
- package/template/src/cli/footer.txt +1 -0
- package/template/src/server/rpc/getHello.ts +33 -0
- package/template/svelte.config.js +12 -0
- package/template/tsconfig.json +18 -0
- 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
|
+
}
|