@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,160 @@
|
|
|
1
|
+
import { createPushIterator } from '../../shared/createPushIterator.ts'
|
|
2
|
+
import { resolveClientFlags } from '../../shared/resolveClientFlags.ts'
|
|
3
|
+
import { getActiveServer } from '../runtime/getActiveServer.ts'
|
|
4
|
+
import { registerSocket } from './registerSocket.ts'
|
|
5
|
+
import type { Socket } from './types/Socket.ts'
|
|
6
|
+
import type { SocketOptions } from './types/SocketOptions.ts'
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
Server-side construction of a Socket. The bundler rewrites every
|
|
10
|
+
`export const NAME = socket(opts)` inside `src/server/sockets/<file>.ts` into
|
|
11
|
+
`__belteDefineSocket__("<name>", opts)` so the file path becomes the
|
|
12
|
+
socket's identity. Each subscriber gets its own queue + notifier, the
|
|
13
|
+
optional history buffer is shared, and outbound fan-out rides Bun's
|
|
14
|
+
native `server.publish` so connected ws clients are notified by the
|
|
15
|
+
runtime in C rather than per-client iteration in JS.
|
|
16
|
+
|
|
17
|
+
The Socket itself is the AsyncIterable: `for await (const m of chat)`
|
|
18
|
+
replays the full history buffer then tails live. `chat.tail(count)`
|
|
19
|
+
opens a subscription that replays the last `count` items (default `0`,
|
|
20
|
+
clamped to the configured `history` max). When `ttl` is set, history
|
|
21
|
+
entries older than `ttl` ms are evicted lazily on every read/append —
|
|
22
|
+
no timer runs in the background. `chat.publish(m)` is isomorphic —
|
|
23
|
+
called server-side it both notifies in-process iterators and broadcasts
|
|
24
|
+
to remote subscribers; called client-side (via socketProxy) it sends a
|
|
25
|
+
`pub` frame the dispatcher validates and forwards.
|
|
26
|
+
*/
|
|
27
|
+
export function defineSocket<T>(name: string, opts: SocketOptions = {}): Socket<T> {
|
|
28
|
+
const historySize = opts.history ?? 0
|
|
29
|
+
const ttl = opts.ttl
|
|
30
|
+
const schema = opts.schema
|
|
31
|
+
const jsonSchema = opts.jsonSchema
|
|
32
|
+
const clients = resolveClientFlags(opts.clients, schema !== undefined)
|
|
33
|
+
type BufferEntry = { value: T; expiresAt: number | undefined }
|
|
34
|
+
const buffer: BufferEntry[] = []
|
|
35
|
+
const subscribers = new Set<(message: T) => void>()
|
|
36
|
+
const topic = `socket:${name}`
|
|
37
|
+
|
|
38
|
+
/*
|
|
39
|
+
History entries are stored with an expiry timestamp. When `ttl` is set,
|
|
40
|
+
every read/append starts by dropping leading entries whose expiry has
|
|
41
|
+
passed — entries are appended in order so the expired prefix is
|
|
42
|
+
contiguous. No timer/setInterval is needed: expiry is lazy.
|
|
43
|
+
*/
|
|
44
|
+
function pruneExpired(now: number): void {
|
|
45
|
+
if (ttl === undefined) {
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
let drop = 0
|
|
49
|
+
for (const entry of buffer) {
|
|
50
|
+
if (entry.expiresAt !== undefined && entry.expiresAt <= now) {
|
|
51
|
+
drop++
|
|
52
|
+
} else {
|
|
53
|
+
break
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (drop > 0) {
|
|
57
|
+
buffer.splice(0, drop)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/*
|
|
62
|
+
Active server is set once per process during createServer's boot,
|
|
63
|
+
immediately after Bun.serve resolves, and never reassigned. Resolve
|
|
64
|
+
it lazily on the first publish then keep the reference so subsequent
|
|
65
|
+
publishes skip the per-call getter.
|
|
66
|
+
*/
|
|
67
|
+
let cachedServer: ReturnType<typeof getActiveServer>
|
|
68
|
+
/*
|
|
69
|
+
When a schema is attached, publish() validates synchronously and
|
|
70
|
+
throws on bad payloads. Standard Schema's validate() is generally
|
|
71
|
+
async — but for the synchronous server-side publish path we treat
|
|
72
|
+
a Promise return as a programming error (publish must be sync to
|
|
73
|
+
preserve in-process notify ordering). Schemas that need async
|
|
74
|
+
refinement should pre-validate at the call site instead.
|
|
75
|
+
*/
|
|
76
|
+
function validateSync(message: T): T {
|
|
77
|
+
if (!schema) {
|
|
78
|
+
return message
|
|
79
|
+
}
|
|
80
|
+
const result = schema['~standard'].validate(message)
|
|
81
|
+
if (result instanceof Promise) {
|
|
82
|
+
throw new Error(
|
|
83
|
+
`[belte] socket "${name}" schema returned a Promise — sockets require sync validation`,
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
if (result.issues) {
|
|
87
|
+
throw new Error(
|
|
88
|
+
`[belte] socket "${name}" publish payload failed validation: ${JSON.stringify(result.issues)}`,
|
|
89
|
+
)
|
|
90
|
+
}
|
|
91
|
+
return result.value as T
|
|
92
|
+
}
|
|
93
|
+
function publish(message: T): void {
|
|
94
|
+
const validated = validateSync(message)
|
|
95
|
+
if (historySize > 0) {
|
|
96
|
+
const now = Date.now()
|
|
97
|
+
pruneExpired(now)
|
|
98
|
+
buffer.push({ value: validated, expiresAt: ttl === undefined ? undefined : now + ttl })
|
|
99
|
+
if (buffer.length > historySize) {
|
|
100
|
+
buffer.shift()
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
for (const notify of subscribers) {
|
|
104
|
+
notify(validated)
|
|
105
|
+
}
|
|
106
|
+
const server = cachedServer ?? (cachedServer = getActiveServer())
|
|
107
|
+
if (server) {
|
|
108
|
+
server.publish(topic, JSON.stringify({ type: 'msg', socket: name, message: validated }))
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/*
|
|
113
|
+
replay === 'all' replays the entire buffer (bare `for await`);
|
|
114
|
+
a number replays the last min(count, buffer.length) items.
|
|
115
|
+
*/
|
|
116
|
+
function iterate(replay: number | 'all'): AsyncIterable<T> {
|
|
117
|
+
return {
|
|
118
|
+
[Symbol.asyncIterator](): AsyncIterator<T, void, undefined> {
|
|
119
|
+
let subscriber: ((message: T) => void) | undefined
|
|
120
|
+
const iter = createPushIterator<T>(() => {
|
|
121
|
+
if (subscriber) {
|
|
122
|
+
subscribers.delete(subscriber)
|
|
123
|
+
}
|
|
124
|
+
})
|
|
125
|
+
pruneExpired(Date.now())
|
|
126
|
+
const replayCount =
|
|
127
|
+
replay === 'all' ? buffer.length : Math.min(replay, buffer.length)
|
|
128
|
+
if (replayCount > 0) {
|
|
129
|
+
const start = buffer.length - replayCount
|
|
130
|
+
for (let index = start; index < buffer.length; index++) {
|
|
131
|
+
iter.push((buffer[index] as BufferEntry).value)
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
subscriber = (message: T) => iter.push(message)
|
|
135
|
+
subscribers.add(subscriber)
|
|
136
|
+
return iter
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const self: Socket<T> = {
|
|
142
|
+
name,
|
|
143
|
+
clients,
|
|
144
|
+
publish,
|
|
145
|
+
tail: (count = 0) => iterate(count),
|
|
146
|
+
[Symbol.asyncIterator]: () => iterate('all')[Symbol.asyncIterator](),
|
|
147
|
+
}
|
|
148
|
+
registerSocket({
|
|
149
|
+
socket: self as Socket<unknown>,
|
|
150
|
+
allowClientPublish: opts.clientPublish ?? false,
|
|
151
|
+
schema,
|
|
152
|
+
jsonSchema,
|
|
153
|
+
clients,
|
|
154
|
+
snapshotHistory: () => {
|
|
155
|
+
pruneExpired(Date.now())
|
|
156
|
+
return buffer.map((entry) => entry.value)
|
|
157
|
+
},
|
|
158
|
+
})
|
|
159
|
+
return self
|
|
160
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { SocketRegistryEntry } from './types/SocketRegistryEntry.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Process-wide registry of every Socket declared in the app. defineSocket
|
|
5
|
+
inserts on first construction; the dispatcher reads on every `sub` /
|
|
6
|
+
`pub` frame so it can find the right Socket by name and check the
|
|
7
|
+
opted-in `allowClientPublish` policy.
|
|
8
|
+
*/
|
|
9
|
+
export const socketRegistry = new Map<string, SocketRegistryEntry>()
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { ClientFlags } from '../../../shared/types/ClientFlags.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Bidirectional named broadcast primitive. Declared once with `socket<T>()`
|
|
5
|
+
inside a file under `src/server/sockets/`; the same import resolves to a server-side
|
|
6
|
+
fan-out and a client-side ws proxy by build target. Iterating the socket
|
|
7
|
+
opens a subscription with full history replay if the topic was declared
|
|
8
|
+
with `{ history: n }`. `.tail(count)` opens one that replays the last
|
|
9
|
+
`count` items (default `0`, clamped to the topic's history max) before
|
|
10
|
+
tailing live. `publish` is isomorphic: server code publishes in-process
|
|
11
|
+
and fans out to remote subscribers; client code sends a `pub` frame the
|
|
12
|
+
dispatcher validates against the topic's `clientPublish` flag. `clients`
|
|
13
|
+
exposes which adapter surfaces (browser / mcp / cli) advertise this
|
|
14
|
+
socket.
|
|
15
|
+
*/
|
|
16
|
+
export interface Socket<T> extends AsyncIterable<T> {
|
|
17
|
+
readonly name: string
|
|
18
|
+
readonly clients: ClientFlags
|
|
19
|
+
publish(message: T): void
|
|
20
|
+
tail(count?: number): AsyncIterable<T>
|
|
21
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Wire frame the browser sends over the multiplexed socket connection.
|
|
3
|
+
`sub` opens a subscription against `socket`. The optional `replay`
|
|
4
|
+
controls history: omitted = full replay (default `for await`); a
|
|
5
|
+
number = at most that many trailing items (clamped server-side to the
|
|
6
|
+
topic's history max). `unsub` closes one. `pub` publishes a message —
|
|
7
|
+
the dispatcher checks the topic's `clientPublish` flag before fanning
|
|
8
|
+
out.
|
|
9
|
+
|
|
10
|
+
`sub` is the per-subscription id minted client-side; the server treats
|
|
11
|
+
it as opaque and routes inbound `msg|err|end` frames back to the same
|
|
12
|
+
id so one ws can multiplex many subscriptions to the same or different
|
|
13
|
+
sockets.
|
|
14
|
+
*/
|
|
15
|
+
export type SocketClientFrame =
|
|
16
|
+
| { type: 'sub'; sub: string; socket: string; replay?: number }
|
|
17
|
+
| { type: 'unsub'; sub: string }
|
|
18
|
+
| { type: 'pub'; socket: string; message: unknown }
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ClientFlags } from '../../../shared/types/ClientFlags.ts'
|
|
2
|
+
import type { StandardSchemaV1 } from '../../rpc/types/StandardSchemaV1.ts'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
Server-side options passed when declaring a socket via `socket<T>(opts)`.
|
|
6
|
+
History buffer (replayed on first iteration), per-frame TTL (history
|
|
7
|
+
entries older than `ttl` ms are evicted before replay), and the client-
|
|
8
|
+
publish gate (off by default — server-only topics ignore pub frames
|
|
9
|
+
coming over the wire). Optional Standard Schema validates payloads on
|
|
10
|
+
publish and gives MCP / CLI a typed payload to describe. `clients`
|
|
11
|
+
controls which non-browser surfaces (mcp / cli) expose this socket;
|
|
12
|
+
browser is the historical default. All server-only state the bundler
|
|
13
|
+
strips out of the client stub.
|
|
14
|
+
*/
|
|
15
|
+
export type SocketOptions<Schema extends StandardSchemaV1 = StandardSchemaV1> = {
|
|
16
|
+
history?: number
|
|
17
|
+
ttl?: number
|
|
18
|
+
clientPublish?: boolean
|
|
19
|
+
schema?: Schema
|
|
20
|
+
jsonSchema?: Record<string, unknown>
|
|
21
|
+
clients?: Partial<ClientFlags>
|
|
22
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { ClientFlags } from '../../../shared/types/ClientFlags.ts'
|
|
2
|
+
import type { StandardSchemaV1 } from '../../rpc/types/StandardSchemaV1.ts'
|
|
3
|
+
import type { Socket } from './Socket.ts'
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
Per-socket registry record. The Socket itself stays uniform between
|
|
7
|
+
server and client by parking policy state (history snapshot, client
|
|
8
|
+
publish gate, payload schema, client targeting) here instead of leaking
|
|
9
|
+
into the public Socket shape.
|
|
10
|
+
*/
|
|
11
|
+
export type SocketRegistryEntry = {
|
|
12
|
+
socket: Socket<unknown>
|
|
13
|
+
allowClientPublish: boolean
|
|
14
|
+
schema: StandardSchemaV1 | undefined
|
|
15
|
+
jsonSchema: Record<string, unknown> | undefined
|
|
16
|
+
clients: ClientFlags
|
|
17
|
+
snapshotHistory(): unknown[]
|
|
18
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Socket } from './Socket.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Manifest of socket-name → module loader. Produced by the resolver
|
|
5
|
+
plugin from each `.ts` under src/server/sockets/. Each module has exactly one
|
|
6
|
+
named export, a Socket whose `.name` was stamped in by the bundler
|
|
7
|
+
rewrite. The dispatcher imports a module on first access and caches the
|
|
8
|
+
resolved Socket against its name.
|
|
9
|
+
*/
|
|
10
|
+
export type SocketRoutes = Record<string, () => Promise<Record<string, Socket<unknown>>>>
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Wire frame the server sends over the multiplexed socket connection.
|
|
3
|
+
|
|
4
|
+
`msg` is keyed by socket name (not sub id) because one publish fans
|
|
5
|
+
out to every client subscribed to the socket via Bun's native
|
|
6
|
+
publish — the client demuxes against every local sub of that socket.
|
|
7
|
+
`end` and `err` are per-sub because they're subscription-lifecycle
|
|
8
|
+
events; `err.message` is the only thrown-value field forwarded so the
|
|
9
|
+
wire stays JSON-safe and server-side stack traces never reach the
|
|
10
|
+
client.
|
|
11
|
+
*/
|
|
12
|
+
export type SocketServerFrame =
|
|
13
|
+
| { type: 'msg'; socket: string; message: unknown }
|
|
14
|
+
| { type: 'end'; sub: string }
|
|
15
|
+
| { type: 'err'; sub: string; message: string }
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Wraps an AsyncIterable<Frame> in a Response whose body is
|
|
3
|
+
Server-Sent Events (text/event-stream) — each frame becomes one
|
|
4
|
+
`data: <json>\n\n` event. Used inside an rpc handler to expose a
|
|
5
|
+
generator over plain HTTP so EventSource (or `subscribe(fn.stream)(args)`
|
|
6
|
+
on the client) can consume it frame-by-frame.
|
|
7
|
+
|
|
8
|
+
export const orderFeed = GET<Args>((args) =>
|
|
9
|
+
sse(async function* () {
|
|
10
|
+
for await (const order of db.watchOrders(args)) yield order
|
|
11
|
+
}())
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
A 15s keepalive comment (`: keepalive\n\n`) is sent between frames so
|
|
15
|
+
intermediaries (proxies, load balancers) don't drop an idle connection.
|
|
16
|
+
Comments are ignored by EventSource per the spec, so they're invisible to
|
|
17
|
+
consumers.
|
|
18
|
+
|
|
19
|
+
Cancellation flows from the consumer through ReadableStream's `cancel`
|
|
20
|
+
into `iter.return()` so the handler's `for await` exits via its normal
|
|
21
|
+
control path. Errors are emitted as an `event: error` frame carrying only
|
|
22
|
+
the message (full error logged server-side) before the stream closes;
|
|
23
|
+
EventSource surfaces this via its `error` listener and `subscribe()`
|
|
24
|
+
maps it to the entry's `error` field.
|
|
25
|
+
*/
|
|
26
|
+
import { NO_STORE } from '../shared/cacheControlValues.ts'
|
|
27
|
+
import type { TypedResponse } from './rpc/types/TypedResponse.ts'
|
|
28
|
+
import { streamFromIterator } from './runtime/streamFromIterator.ts'
|
|
29
|
+
|
|
30
|
+
const KEEPALIVE_INTERVAL_MS = 15000
|
|
31
|
+
|
|
32
|
+
export function sse<Frame>(iterable: AsyncIterable<Frame>): TypedResponse<Frame> {
|
|
33
|
+
const body = streamFromIterator(iterable, {
|
|
34
|
+
encodeFrame: (value) => `data: ${JSON.stringify(value)}\n\n`,
|
|
35
|
+
encodeError: (message) => `event: error\ndata: ${JSON.stringify({ message })}\n\n`,
|
|
36
|
+
keepaliveMs: KEEPALIVE_INTERVAL_MS,
|
|
37
|
+
keepalivePayload: ': keepalive\n\n',
|
|
38
|
+
})
|
|
39
|
+
return new Response(body, {
|
|
40
|
+
headers: {
|
|
41
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
42
|
+
'Cache-Control': NO_STORE,
|
|
43
|
+
'X-Content-Type-Options': 'nosniff',
|
|
44
|
+
Connection: 'keep-alive',
|
|
45
|
+
},
|
|
46
|
+
}) as TypedResponse<Frame>
|
|
47
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { cacheStoreSlot } from './cacheStoreSlot.ts'
|
|
2
|
+
import { createCacheStore } from './createCacheStore.ts'
|
|
3
|
+
import type { CacheStore } from './types/CacheStore.ts'
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
Resolves the active CacheStore. The runtime is registered via
|
|
7
|
+
`setCacheStoreResolver` from the server entry (request-scoped via ALS)
|
|
8
|
+
or the client entry (module-level singleton). If no resolver is registered,
|
|
9
|
+
a single fallback store is created lazily so isolated tests still work.
|
|
10
|
+
*/
|
|
11
|
+
export function activeCacheStore(): CacheStore {
|
|
12
|
+
const fromResolver = cacheStoreSlot.resolver?.()
|
|
13
|
+
if (fromResolver) {
|
|
14
|
+
return fromResolver
|
|
15
|
+
}
|
|
16
|
+
if (!cacheStoreSlot.fallback) {
|
|
17
|
+
cacheStoreSlot.fallback = createCacheStore()
|
|
18
|
+
}
|
|
19
|
+
return cacheStoreSlot.fallback
|
|
20
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { HttpVerb } from '../server/rpc/types/HttpVerb.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Builds the Request a verb helper uses to invoke its handler. Same shape on
|
|
5
|
+
both sides (server defineVerb + client remoteProxy) so the cache key
|
|
6
|
+
derivation and SSR snapshot round-trip identically. $rpc URLs are flat
|
|
7
|
+
(no `:name` segments): GET/DELETE/HEAD serialise args onto the query
|
|
8
|
+
string; POST/PUT/PATCH send them as application/json.
|
|
9
|
+
|
|
10
|
+
`baseUrl` provides the origin needed by the Request constructor — on the
|
|
11
|
+
server it's the inbound request's URL (so handlers reading `request.url` see
|
|
12
|
+
the caller's host), in the browser it's window.location. `headers` lets the
|
|
13
|
+
server pre-populate the synthetic Request with forwarded session headers;
|
|
14
|
+
the client passes nothing.
|
|
15
|
+
*/
|
|
16
|
+
export function buildRpcRequest({
|
|
17
|
+
method,
|
|
18
|
+
url,
|
|
19
|
+
args,
|
|
20
|
+
baseUrl,
|
|
21
|
+
headers,
|
|
22
|
+
}: {
|
|
23
|
+
method: HttpVerb
|
|
24
|
+
url: string
|
|
25
|
+
args: unknown
|
|
26
|
+
baseUrl: string
|
|
27
|
+
headers?: Headers
|
|
28
|
+
}): Request {
|
|
29
|
+
const requestHeaders = headers ?? new Headers()
|
|
30
|
+
if (method === 'GET' || method === 'DELETE' || method === 'HEAD') {
|
|
31
|
+
const target = appendQuery(method, url, args)
|
|
32
|
+
return new Request(new URL(target, baseUrl).href, { method, headers: requestHeaders })
|
|
33
|
+
}
|
|
34
|
+
if (args === undefined) {
|
|
35
|
+
return new Request(new URL(url, baseUrl).href, { method, headers: requestHeaders })
|
|
36
|
+
}
|
|
37
|
+
requestHeaders.set('content-type', 'application/json')
|
|
38
|
+
return new Request(new URL(url, baseUrl).href, {
|
|
39
|
+
method,
|
|
40
|
+
headers: requestHeaders,
|
|
41
|
+
body: JSON.stringify(args),
|
|
42
|
+
})
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function appendQuery(method: HttpVerb, url: string, args: unknown): string {
|
|
46
|
+
if (args === undefined) {
|
|
47
|
+
return url
|
|
48
|
+
}
|
|
49
|
+
if (typeof args !== 'object' || args === null || Array.isArray(args)) {
|
|
50
|
+
const got = Array.isArray(args) ? 'array' : typeof args
|
|
51
|
+
throw new Error(`[belte] ${method} ${url} args must be a plain object — got ${got}`)
|
|
52
|
+
}
|
|
53
|
+
const entries = Object.entries(args as Record<string, unknown>).filter(
|
|
54
|
+
([, value]) => value !== undefined,
|
|
55
|
+
)
|
|
56
|
+
if (entries.length === 0) {
|
|
57
|
+
return url
|
|
58
|
+
}
|
|
59
|
+
const suffix = new URLSearchParams(entries as Array<[string, string]>).toString()
|
|
60
|
+
return suffix === '' ? url : `${url}?${suffix}`
|
|
61
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Cache-Control values used by belte's framework responses. Centralised so
|
|
3
|
+
the framework's policy (no-store on errors and rpc dispatch helpers,
|
|
4
|
+
private/no-cache on SSR HTML) lives in one place and can't drift between
|
|
5
|
+
the server core and the respond helpers.
|
|
6
|
+
*/
|
|
7
|
+
export const NO_STORE = 'no-store'
|
|
8
|
+
export const SSR_CACHE_CONTROL = 'private, no-cache'
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { CacheStore } from './types/CacheStore.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Internal slot the runtime entries register their resolver into. The
|
|
5
|
+
server entry installs an ALS-backed resolver (request-scoped); the
|
|
6
|
+
client entry installs a module-singleton resolver. `fallback` is a
|
|
7
|
+
single lazy store used only when no resolver is registered — keeps
|
|
8
|
+
isolated tests working without forcing them to spin up the runtime.
|
|
9
|
+
*/
|
|
10
|
+
export const cacheStoreSlot: {
|
|
11
|
+
resolver: (() => CacheStore | undefined) | undefined
|
|
12
|
+
fallback: CacheStore | undefined
|
|
13
|
+
} = {
|
|
14
|
+
resolver: undefined,
|
|
15
|
+
fallback: undefined,
|
|
16
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Stable JSON stringify: object keys are sorted recursively so equivalent values
|
|
3
|
+
produce identical strings regardless of insertion order. Non-JSON values
|
|
4
|
+
(functions, symbols) are dropped the same way native JSON.stringify drops them.
|
|
5
|
+
Used to derive deterministic cache keys from explicit-key overrides and from
|
|
6
|
+
auto-keyed POST/PUT/PATCH bodies. Walks the value once using JSON.stringify's
|
|
7
|
+
replacer so no intermediate copies are allocated per level.
|
|
8
|
+
*/
|
|
9
|
+
export function canonicalJson(value: unknown): string {
|
|
10
|
+
return JSON.stringify(value, sortedKeysReplacer)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function sortedKeysReplacer(_key: string, value: unknown): unknown {
|
|
14
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
15
|
+
return value
|
|
16
|
+
}
|
|
17
|
+
const record = value as Record<string, unknown>
|
|
18
|
+
const sortedKeys = Object.keys(record).sort()
|
|
19
|
+
const sorted: Record<string, unknown> = {}
|
|
20
|
+
for (const key of sortedKeys) {
|
|
21
|
+
sorted[key] = record[key]
|
|
22
|
+
}
|
|
23
|
+
return sorted
|
|
24
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Derives the MCP tool name / CLI subcommand name from an rpc URL. Strips
|
|
3
|
+
the framework's `/rpc/` mount and joins nested folder segments with `-`
|
|
4
|
+
so `users/list.ts` (mounted at `/rpc/users/list`) becomes `users-list`
|
|
5
|
+
across both surfaces. Folder prefixing prevents collisions when two
|
|
6
|
+
files in different folders share the same stem (e.g. `users/list.ts`
|
|
7
|
+
and `posts/list.ts`); `/` is not a valid character in MCP tool names or
|
|
8
|
+
typical CLI subcommands, so the join uses `-`.
|
|
9
|
+
*/
|
|
10
|
+
const RPC_PREFIX = '/rpc/'
|
|
11
|
+
|
|
12
|
+
export function commandNameForUrl(url: string): string {
|
|
13
|
+
const trimmed = url.startsWith(RPC_PREFIX)
|
|
14
|
+
? url.slice(RPC_PREFIX.length)
|
|
15
|
+
: url.replace(/^\//, '')
|
|
16
|
+
return trimmed.replaceAll('/', '-')
|
|
17
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { createSubscriber } from 'svelte/reactivity'
|
|
2
|
+
import type { CacheEntry } from './types/CacheEntry.ts'
|
|
3
|
+
import type { CacheStore } from './types/CacheStore.ts'
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
Returns a fresh cache store. On the server, every request gets its own
|
|
7
|
+
store via the AsyncLocalStorage RequestStore. On the client, a single
|
|
8
|
+
module-level store is created at startup and shared across the tab.
|
|
9
|
+
|
|
10
|
+
Each key gets a lazily-created Svelte subscriber that lives for the
|
|
11
|
+
lifetime of the store. Reading a key from a tracking scope
|
|
12
|
+
($derived / $effect) subscribes that scope; invalidating the key dispatches
|
|
13
|
+
an 'invalidate' event whose detail is a Set of affected keys so each
|
|
14
|
+
listener's lookup is O(1). When the entry is later re-created the same
|
|
15
|
+
subscriber is reused — no listener churn, no risk of duplicate registrations
|
|
16
|
+
during entry eviction. Svelte tears down the underlying listener on its
|
|
17
|
+
own when the last tracker stops reading.
|
|
18
|
+
*/
|
|
19
|
+
export function createCacheStore(): CacheStore {
|
|
20
|
+
const entries = new Map<string, CacheEntry>()
|
|
21
|
+
const events = new EventTarget()
|
|
22
|
+
const subscribers = new Map<string, () => void>()
|
|
23
|
+
|
|
24
|
+
function subscribe(key: string): void {
|
|
25
|
+
let registered = subscribers.get(key)
|
|
26
|
+
if (!registered) {
|
|
27
|
+
registered = createSubscriber((update) => {
|
|
28
|
+
const onInvalidate = (event: Event) => {
|
|
29
|
+
if ((event as CustomEvent<Set<string>>).detail.has(key)) {
|
|
30
|
+
update()
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
events.addEventListener('invalidate', onInvalidate)
|
|
34
|
+
return () => events.removeEventListener('invalidate', onInvalidate)
|
|
35
|
+
})
|
|
36
|
+
subscribers.set(key, registered)
|
|
37
|
+
}
|
|
38
|
+
registered()
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return { entries, events, subscribe }
|
|
42
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Single-slot-mailbox AsyncIterator factory shared by the in-process
|
|
3
|
+
socket fan-out (defineSocket) and the client-side ws proxy
|
|
4
|
+
(socketProxy). Callers push values, signal end, or signal an error;
|
|
5
|
+
the iterator drains a queue then awaits the next push. Cancellation
|
|
6
|
+
runs the optional `onClose` so subscribers can drop their backref.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
type Slot<T> = { kind: 'value'; value: T } | { kind: 'end' } | { kind: 'error'; message: string }
|
|
10
|
+
|
|
11
|
+
export type PushIterator<T> = AsyncIterator<T, void, undefined> & {
|
|
12
|
+
push(value: T): void
|
|
13
|
+
end(): void
|
|
14
|
+
error(message: string): void
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function createPushIterator<T>(onClose?: () => void): PushIterator<T> {
|
|
18
|
+
const buffer: Slot<T>[] = []
|
|
19
|
+
let waiter: ((slot: Slot<T>) => void) | undefined
|
|
20
|
+
let closed = false
|
|
21
|
+
|
|
22
|
+
function deliver(slot: Slot<T>): void {
|
|
23
|
+
if (closed) {
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
if (waiter) {
|
|
27
|
+
const wake = waiter
|
|
28
|
+
waiter = undefined
|
|
29
|
+
wake(slot)
|
|
30
|
+
return
|
|
31
|
+
}
|
|
32
|
+
buffer.push(slot)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function close(): void {
|
|
36
|
+
if (closed) {
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
closed = true
|
|
40
|
+
onClose?.()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
push(value) {
|
|
45
|
+
deliver({ kind: 'value', value })
|
|
46
|
+
},
|
|
47
|
+
end() {
|
|
48
|
+
deliver({ kind: 'end' })
|
|
49
|
+
},
|
|
50
|
+
error(message) {
|
|
51
|
+
deliver({ kind: 'error', message })
|
|
52
|
+
},
|
|
53
|
+
async next() {
|
|
54
|
+
if (closed) {
|
|
55
|
+
return { value: undefined, done: true }
|
|
56
|
+
}
|
|
57
|
+
const slot = buffer.shift() ?? (await new Promise<Slot<T>>((r) => (waiter = r)))
|
|
58
|
+
if (slot.kind === 'end') {
|
|
59
|
+
close()
|
|
60
|
+
return { value: undefined, done: true }
|
|
61
|
+
}
|
|
62
|
+
if (slot.kind === 'error') {
|
|
63
|
+
close()
|
|
64
|
+
throw new Error(slot.message)
|
|
65
|
+
}
|
|
66
|
+
return { value: slot.value, done: false }
|
|
67
|
+
},
|
|
68
|
+
async return() {
|
|
69
|
+
if (!closed) {
|
|
70
|
+
close()
|
|
71
|
+
waiter?.({ kind: 'end' })
|
|
72
|
+
waiter = undefined
|
|
73
|
+
}
|
|
74
|
+
return { value: undefined, done: true }
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
}
|