@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,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,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,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,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
|
+
}
|