@briancray/belte 0.1.0 → 0.2.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/bin/belte.ts +25 -12
- package/package.json +2 -1
- package/src/appEntry.ts +124 -0
- package/src/belteResolverPlugin.ts +217 -194
- package/src/build.ts +6 -67
- package/src/buildCli.ts +36 -63
- package/src/buildDisconnected.ts +127 -0
- package/src/bundleApp.ts +123 -0
- package/src/bundleDisconnectedEntry.ts +17 -0
- package/src/cliEntry.ts +3 -9
- package/src/compile.ts +4 -15
- package/src/controlServerWorker.ts +261 -0
- package/src/dedupeSveltePlugin.ts +66 -0
- package/src/discoveryEntry.ts +12 -11
- package/src/lib/browser/cache.ts +3 -6
- package/src/lib/browser/page.svelte.ts +19 -21
- package/src/lib/browser/socketChannel.ts +11 -1
- package/src/lib/browser/types/Pages.ts +1 -1
- package/src/lib/bundle/BundleMenu.ts +11 -0
- package/src/lib/bundle/BundleMenuItem.ts +24 -0
- package/src/lib/bundle/BundleWindow.ts +20 -0
- package/src/lib/bundle/bindConnectedFlag.ts +29 -0
- package/src/lib/bundle/bindRequestNavigate.ts +31 -0
- package/src/lib/bundle/buildWebviewLib.ts +111 -0
- package/src/lib/bundle/disconnected.css +9 -0
- package/src/lib/bundle/disconnected.svelte +192 -0
- package/src/lib/bundle/ensureWebviewLib.ts +20 -0
- package/src/lib/bundle/exitWithParent.ts +28 -0
- package/src/lib/bundle/findFreePort.ts +14 -0
- package/src/lib/bundle/infoPlist.ts +46 -0
- package/src/lib/bundle/installMacMenu.ts +39 -0
- package/src/lib/bundle/listenLocalControlServer.ts +19 -0
- package/src/lib/bundle/native/belteMenu.mm +298 -0
- package/src/lib/bundle/native/webview.h +4557 -0
- package/src/lib/bundle/onMenu.ts +26 -0
- package/src/lib/bundle/openWebview.ts +81 -0
- package/src/lib/bundle/pngToIcns.ts +47 -0
- package/src/lib/bundle/probeBelteServer.ts +34 -0
- package/src/lib/bundle/resolveServerBinary.ts +12 -0
- package/src/lib/bundle/resolveWebviewLib.ts +51 -0
- package/src/lib/bundle/serverBinaryFilename.ts +8 -0
- package/src/lib/bundle/stableLocalPort.ts +19 -0
- package/src/lib/bundle/waitForServer.ts +23 -0
- package/src/lib/bundle/webviewBuildRevision.ts +9 -0
- package/src/lib/bundle/webviewCachePath.ts +23 -0
- package/src/lib/bundle/webviewLibName.ts +11 -0
- package/src/lib/bundle/webviewVersion.ts +7 -0
- package/src/lib/cli/createClient.ts +34 -36
- package/src/lib/cli/printHelp.ts +45 -2
- package/src/lib/cli/runCli.ts +12 -3
- package/src/lib/mcp/createMcpResourceServer.ts +1 -1
- package/src/lib/mcp/dispatchMcpRequest.ts +53 -73
- package/src/lib/server/AppModule.ts +2 -2
- package/src/lib/server/cli/handleCliDownload.ts +4 -5
- package/src/lib/server/cli/handleCliInstall.ts +17 -0
- package/src/lib/server/error.ts +23 -9
- package/src/lib/server/json.ts +5 -5
- package/src/lib/server/jsonl.ts +10 -5
- package/src/lib/server/prompts/definePrompt.ts +6 -6
- package/src/lib/server/prompts/renderPromptTemplate.ts +16 -0
- package/src/lib/server/prompts/types/Prompt.ts +8 -9
- package/src/lib/server/prompts/types/PromptOptions.ts +7 -12
- package/src/lib/server/prompts/types/PromptRegistryEntry.ts +3 -5
- package/src/lib/server/prompts/types/PromptRoutes.ts +4 -4
- package/src/lib/server/redirect.ts +13 -8
- package/src/lib/server/rpc/defineVerb.ts +4 -3
- package/src/lib/server/rpc/findVerbByCommandName.ts +18 -0
- package/src/lib/server/rpc/types/RemoteFunction.ts +1 -1
- package/src/lib/server/rpc/types/RemoteHandler.ts +4 -0
- package/src/lib/server/runtime/acceptsZstd.ts +8 -0
- package/src/lib/server/runtime/buildOpenApiSpec.ts +2 -0
- package/src/lib/server/runtime/cacheControlForAsset.ts +7 -2
- package/src/lib/server/runtime/createAssetHeaderCache.ts +35 -0
- package/src/lib/server/runtime/createPublicAssetServer.ts +6 -20
- package/src/lib/server/runtime/createServer.ts +50 -58
- package/src/lib/server/runtime/registryManifests.ts +33 -15
- package/src/lib/server/runtime/types/RequestStore.ts +2 -3
- package/src/lib/server/runtime/withResponseDefaults.ts +24 -0
- package/src/lib/server/sockets/createSocketDispatcher.ts +10 -7
- package/src/lib/server/sse.ts +10 -5
- package/src/lib/shared/cacheControlValues.ts +10 -2
- package/src/lib/shared/canonicalJson.ts +1 -5
- package/src/lib/shared/createCacheStore.ts +29 -20
- package/src/lib/shared/exitOnBuildFailure.ts +17 -0
- package/src/lib/shared/fileStem.ts +9 -0
- package/src/lib/shared/jsonSchemaForPromptArguments.ts +29 -0
- package/src/lib/shared/keyForRemoteCall.ts +7 -5
- package/src/lib/shared/parsePromptMarkdown.ts +34 -0
- package/src/lib/shared/promptNameForFile.ts +5 -5
- package/src/lib/shared/subscribableFromResponse.ts +104 -215
- package/src/lib/shared/types/PromptArgument.ts +12 -0
- package/src/lib/shared/writeRoutesDts.ts +5 -7
- package/src/serverBuildPlugins.ts +25 -0
- package/src/serverEntry.ts +4 -0
- package/template/package.json +2 -1
- package/src/lib/server/prompt.ts +0 -30
- package/src/lib/server/prompts/types/PromptMessage.ts +0 -10
- package/src/lib/shared/preparePromptModule.ts +0 -36
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import {
|
|
2
|
+
IMMUTABLE_ASSET_CACHE_CONTROL,
|
|
3
|
+
REVALIDATE_ASSET_CACHE_CONTROL,
|
|
4
|
+
} from '../../shared/cacheControlValues.ts'
|
|
5
|
+
|
|
1
6
|
/*
|
|
2
7
|
Bun.build emits `[name]-[hash].[ext]` for chunks; hash is alnum and >=8 chars.
|
|
3
8
|
Source maps inherit the same name (e.g. foo-abc12345.js.map), so the suffix may be `.map`.
|
|
@@ -11,7 +16,7 @@ Hashed chunk filenames are content-addressed and immutable; everything else
|
|
|
11
16
|
*/
|
|
12
17
|
export function cacheControlForAsset(pathname: string): string {
|
|
13
18
|
if (HASHED.test(pathname)) {
|
|
14
|
-
return
|
|
19
|
+
return IMMUTABLE_ASSET_CACHE_CONTROL
|
|
15
20
|
}
|
|
16
|
-
return
|
|
21
|
+
return REVALIDATE_ASSET_CACHE_CONTROL
|
|
17
22
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { mimeForExtension } from './mimeForExtension.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
A static-asset response's headers depend only on its pathname (extension →
|
|
5
|
+
Content-Type, path → Cache-Control), so each distinct pathname's header bundle
|
|
6
|
+
is built once and reused across every hit on that chunk — avoiding a per-request
|
|
7
|
+
allocation on a cold page load that pulls dozens of files. Each bundle carries
|
|
8
|
+
the plain `base` headers plus a `zstd` variant with `Content-Encoding: zstd`.
|
|
9
|
+
`cacheControlFor` lets callers vary the policy: hashed-aware for `/_app/`,
|
|
10
|
+
fixed for public/.
|
|
11
|
+
*/
|
|
12
|
+
type AssetHeaderBundle = {
|
|
13
|
+
base: HeadersInit
|
|
14
|
+
zstd: HeadersInit
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function createAssetHeaderCache(
|
|
18
|
+
cacheControlFor: (pathname: string) => string,
|
|
19
|
+
): (pathname: string) => AssetHeaderBundle {
|
|
20
|
+
const cache = new Map<string, AssetHeaderBundle>()
|
|
21
|
+
return function headersFor(pathname) {
|
|
22
|
+
const cached = cache.get(pathname)
|
|
23
|
+
if (cached) {
|
|
24
|
+
return cached
|
|
25
|
+
}
|
|
26
|
+
const base: HeadersInit = {
|
|
27
|
+
'Content-Type': mimeForExtension(pathname),
|
|
28
|
+
Vary: 'Accept-Encoding',
|
|
29
|
+
'Cache-Control': cacheControlFor(pathname),
|
|
30
|
+
}
|
|
31
|
+
const bundle: AssetHeaderBundle = { base, zstd: { ...base, 'Content-Encoding': 'zstd' } }
|
|
32
|
+
cache.set(pathname, bundle)
|
|
33
|
+
return bundle
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
+
import { PUBLIC_ASSET_CACHE_CONTROL } from '../../shared/cacheControlValues.ts'
|
|
2
|
+
import { acceptsZstd } from './acceptsZstd.ts'
|
|
1
3
|
import { containsTraversal } from './containsTraversal.ts'
|
|
2
|
-
import {
|
|
4
|
+
import { createAssetHeaderCache } from './createAssetHeaderCache.ts'
|
|
3
5
|
import type { Assets } from './types/Assets.ts'
|
|
4
6
|
|
|
5
|
-
const PUBLIC_CACHE_CONTROL = 'public, max-age=3600'
|
|
6
|
-
|
|
7
7
|
/*
|
|
8
8
|
Serves files from the project's `public/` folder at the site root. Two
|
|
9
9
|
sources, picked at construction:
|
|
@@ -25,27 +25,13 @@ export function createPublicAssetServer({
|
|
|
25
25
|
publicDir: string
|
|
26
26
|
publicAssets?: Assets
|
|
27
27
|
}): (req: Request, url: URL) => Promise<Response | undefined> {
|
|
28
|
-
const
|
|
29
|
-
function headersFor(pathname: string): { base: HeadersInit; zstd: HeadersInit } {
|
|
30
|
-
const cached = headerCache.get(pathname)
|
|
31
|
-
if (cached) {
|
|
32
|
-
return cached
|
|
33
|
-
}
|
|
34
|
-
const base: HeadersInit = {
|
|
35
|
-
'Content-Type': mimeForExtension(pathname),
|
|
36
|
-
Vary: 'Accept-Encoding',
|
|
37
|
-
'Cache-Control': PUBLIC_CACHE_CONTROL,
|
|
38
|
-
}
|
|
39
|
-
const bundle = { base, zstd: { ...base, 'Content-Encoding': 'zstd' } }
|
|
40
|
-
headerCache.set(pathname, bundle)
|
|
41
|
-
return bundle
|
|
42
|
-
}
|
|
28
|
+
const headersFor = createAssetHeaderCache(() => PUBLIC_ASSET_CACHE_CONTROL)
|
|
43
29
|
|
|
44
30
|
return async function servePublicAsset(req, url) {
|
|
45
31
|
if (containsTraversal(req.url)) {
|
|
46
32
|
return undefined
|
|
47
33
|
}
|
|
48
|
-
const wantsZstd = (req
|
|
34
|
+
const wantsZstd = acceptsZstd(req)
|
|
49
35
|
const { base, zstd } = headersFor(url.pathname)
|
|
50
36
|
if (publicAssets) {
|
|
51
37
|
const compressed = publicAssets[url.pathname]
|
|
@@ -55,7 +41,7 @@ export function createPublicAssetServer({
|
|
|
55
41
|
if (wantsZstd) {
|
|
56
42
|
return new Response(compressed, { headers: zstd })
|
|
57
43
|
}
|
|
58
|
-
return new Response(Bun.
|
|
44
|
+
return new Response(await Bun.zstdDecompress(compressed), { headers: base })
|
|
59
45
|
}
|
|
60
46
|
const file = Bun.file(publicDir + url.pathname)
|
|
61
47
|
if (!(await file.exists())) {
|
|
@@ -23,11 +23,12 @@ import type { RemoteFunction } from '../rpc/types/RemoteFunction.ts'
|
|
|
23
23
|
import type { RemoteRoutes } from '../rpc/types/RemoteRoutes.ts'
|
|
24
24
|
import { createSocketDispatcher } from '../sockets/createSocketDispatcher.ts'
|
|
25
25
|
import type { SocketRoutes } from '../sockets/types/SocketRoutes.ts'
|
|
26
|
+
import { acceptsZstd } from './acceptsZstd.ts'
|
|
26
27
|
import { buildOpenApiSpec } from './buildOpenApiSpec.ts'
|
|
27
28
|
import { cacheControlForAsset } from './cacheControlForAsset.ts'
|
|
28
29
|
import { containsTraversal } from './containsTraversal.ts'
|
|
30
|
+
import { createAssetHeaderCache } from './createAssetHeaderCache.ts'
|
|
29
31
|
import { createPublicAssetServer } from './createPublicAssetServer.ts'
|
|
30
|
-
import { mimeForExtension } from './mimeForExtension.ts'
|
|
31
32
|
import { ensureRegistriesLoaded, setRegistryManifests } from './registryManifests.ts'
|
|
32
33
|
import { requestContext } from './requestContext.ts'
|
|
33
34
|
import { safeJsonForScript } from './safeJsonForScript.ts'
|
|
@@ -36,18 +37,38 @@ import { setActiveServer } from './setActiveServer.ts'
|
|
|
36
37
|
import type { Assets } from './types/Assets.ts'
|
|
37
38
|
import type { RequestStore } from './types/RequestStore.ts'
|
|
38
39
|
|
|
39
|
-
function acceptsZstd(req: Request): boolean {
|
|
40
|
-
return (req.headers.get('accept-encoding') ?? '').toLowerCase().includes('zstd')
|
|
41
|
-
}
|
|
42
|
-
|
|
43
40
|
function wantsJson(req: Request): boolean {
|
|
44
41
|
return (req.headers.get('accept') ?? '').includes('application/json')
|
|
45
42
|
}
|
|
46
43
|
|
|
44
|
+
/*
|
|
45
|
+
The framework's default 500 response — a `<pre>` stack dump. Shared by the
|
|
46
|
+
per-request catch and Bun.serve's global error() fallback so the two can't
|
|
47
|
+
drift. Only reached when the app supplies no `handleError` hook.
|
|
48
|
+
*/
|
|
49
|
+
function internalErrorResponse(err: unknown): Response {
|
|
50
|
+
return new Response(`<pre>${String((err as Error)?.stack ?? err)}</pre>`, {
|
|
51
|
+
status: 500,
|
|
52
|
+
headers: {
|
|
53
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
54
|
+
'Cache-Control': NO_STORE,
|
|
55
|
+
},
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const IDENTITY_PATH = '/__belte/identity'
|
|
47
60
|
const SOCKETS_PATH = '/__belte/sockets'
|
|
48
61
|
const MCP_PATH = '/__belte/mcp'
|
|
49
62
|
const CLI_PATH = '/__belte/cli'
|
|
50
63
|
const CLI_DOWNLOAD_PREFIX = '/__belte/cli/'
|
|
64
|
+
/*
|
|
65
|
+
Unlike the framework's own plumbing routes above (the socket multiplex, MCP
|
|
66
|
+
endpoint, CLI download), the OpenAPI document describes the app's public HTTP
|
|
67
|
+
surface — the /rpc/* verbs — rather than belte internals, so it sits at the
|
|
68
|
+
conventional root path where external tooling and scanners expect to find it
|
|
69
|
+
(/openapi.json, alongside /swagger.json, /.well-known/*) rather than under the
|
|
70
|
+
/__belte/ namespace.
|
|
71
|
+
*/
|
|
51
72
|
const OPENAPI_PATH = '/openapi.json'
|
|
52
73
|
|
|
53
74
|
type AnyRemoteFunction = RemoteFunction<unknown, unknown>
|
|
@@ -105,12 +126,6 @@ export async function createServer({
|
|
|
105
126
|
const cliName = cliProgramName ?? 'app'
|
|
106
127
|
const cliCwd = process.cwd()
|
|
107
128
|
const servePublicAsset = createPublicAssetServer({ publicDir, publicAssets })
|
|
108
|
-
/*
|
|
109
|
-
Forward-declared so the per-request closures below can reference it. The
|
|
110
|
-
value is assigned by Bun.serve() further down; closures only fire after
|
|
111
|
-
that, so the read-before-write is safe at runtime.
|
|
112
|
-
*/
|
|
113
|
-
let server!: Server<unknown>
|
|
114
129
|
const layoutPrefixes = layouts ? normalizeLayoutPrefixes(Object.keys(layouts)) : []
|
|
115
130
|
|
|
116
131
|
const diskZstdPaths = new Set<string>(
|
|
@@ -150,32 +165,8 @@ export async function createServer({
|
|
|
150
165
|
|
|
151
166
|
const logRequests = isDebugEnabled('belte')
|
|
152
167
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
and the immutable HASHED test. Cache them so repeat hits on the same
|
|
156
|
-
chunk reuse a single frozen header bag instead of allocating per
|
|
157
|
-
request.
|
|
158
|
-
*/
|
|
159
|
-
type AssetHeaderBundle = {
|
|
160
|
-
base: HeadersInit
|
|
161
|
-
zstd: HeadersInit
|
|
162
|
-
}
|
|
163
|
-
const assetHeaderCache = new Map<string, AssetHeaderBundle>()
|
|
164
|
-
function headersForAsset(pathname: string): AssetHeaderBundle {
|
|
165
|
-
const cached = assetHeaderCache.get(pathname)
|
|
166
|
-
if (cached) {
|
|
167
|
-
return cached
|
|
168
|
-
}
|
|
169
|
-
const base: HeadersInit = {
|
|
170
|
-
'Content-Type': mimeForExtension(pathname),
|
|
171
|
-
Vary: 'Accept-Encoding',
|
|
172
|
-
'Cache-Control': cacheControlForAsset(pathname),
|
|
173
|
-
}
|
|
174
|
-
const zstd: HeadersInit = { ...base, 'Content-Encoding': 'zstd' }
|
|
175
|
-
const bundle = { base, zstd }
|
|
176
|
-
assetHeaderCache.set(pathname, bundle)
|
|
177
|
-
return bundle
|
|
178
|
-
}
|
|
168
|
+
// Per-pathname asset header bundles, hashed-chunk-aware Cache-Control.
|
|
169
|
+
const headersForAsset = createAssetHeaderCache(cacheControlForAsset)
|
|
179
170
|
|
|
180
171
|
async function serveStaticAsset(req: Request, url: URL): Promise<Response> {
|
|
181
172
|
/*
|
|
@@ -206,7 +197,7 @@ export async function createServer({
|
|
|
206
197
|
if (wantsZstd) {
|
|
207
198
|
return new Response(compressed, { headers: zstdHeaders })
|
|
208
199
|
}
|
|
209
|
-
return new Response(Bun.
|
|
200
|
+
return new Response(await Bun.zstdDecompress(compressed), { headers: baseHeaders })
|
|
210
201
|
}
|
|
211
202
|
const diskPath = distDir + url.pathname
|
|
212
203
|
if (wantsZstd && diskZstdPaths.has(url.pathname)) {
|
|
@@ -374,9 +365,7 @@ export async function createServer({
|
|
|
374
365
|
const store: RequestStore = {
|
|
375
366
|
url,
|
|
376
367
|
req,
|
|
377
|
-
signal: req.signal,
|
|
378
368
|
cache: createCacheStore(),
|
|
379
|
-
server,
|
|
380
369
|
}
|
|
381
370
|
return requestContext.run(store, async () => {
|
|
382
371
|
const start = logRequests ? Bun.nanoseconds() : 0
|
|
@@ -388,16 +377,7 @@ export async function createServer({
|
|
|
388
377
|
response = await app.handleError(error, req)
|
|
389
378
|
} else {
|
|
390
379
|
log.error(error)
|
|
391
|
-
response =
|
|
392
|
-
`<pre>${String((error as Error)?.stack ?? error)}</pre>`,
|
|
393
|
-
{
|
|
394
|
-
status: 500,
|
|
395
|
-
headers: {
|
|
396
|
-
'Content-Type': 'text/html; charset=utf-8',
|
|
397
|
-
'Cache-Control': NO_STORE,
|
|
398
|
-
},
|
|
399
|
-
},
|
|
400
|
-
)
|
|
380
|
+
response = internalErrorResponse(error)
|
|
401
381
|
}
|
|
402
382
|
}
|
|
403
383
|
if (logRequests) {
|
|
@@ -417,7 +397,8 @@ export async function createServer({
|
|
|
417
397
|
a busy socket doesn't iterate JS per subscriber per message.
|
|
418
398
|
*/
|
|
419
399
|
const socketDispatcher = createSocketDispatcher(sockets)
|
|
420
|
-
|
|
400
|
+
// Server<unknown> pins Bun's WebSocketData generic so upgrade({ data: {} }) typechecks.
|
|
401
|
+
const server: Server<unknown> = Bun.serve({
|
|
421
402
|
port,
|
|
422
403
|
|
|
423
404
|
websocket: {
|
|
@@ -436,6 +417,23 @@ export async function createServer({
|
|
|
436
417
|
|
|
437
418
|
async fetch(req, bunServer) {
|
|
438
419
|
const url = new URL(req.url)
|
|
420
|
+
/*
|
|
421
|
+
Identity probe — answered directly, ahead of any app.handle middleware,
|
|
422
|
+
so the bundle's connect screen can confirm a URL really is a belte
|
|
423
|
+
server (and which app) before pointing the desktop window at it. It
|
|
424
|
+
must stay reachable even when the app guards everything behind auth,
|
|
425
|
+
hence the early return that bypasses dispatchRequest.
|
|
426
|
+
*/
|
|
427
|
+
if (url.pathname === IDENTITY_PATH) {
|
|
428
|
+
return Response.json(
|
|
429
|
+
{
|
|
430
|
+
belte: true,
|
|
431
|
+
name: appInfo?.name ?? cliName,
|
|
432
|
+
version: appInfo?.version ?? '0.0.0',
|
|
433
|
+
},
|
|
434
|
+
{ headers: { 'Cache-Control': NO_STORE } },
|
|
435
|
+
)
|
|
436
|
+
}
|
|
439
437
|
if (url.pathname === SOCKETS_PATH) {
|
|
440
438
|
if (bunServer.upgrade(req, { data: {} })) {
|
|
441
439
|
return undefined as unknown as Response
|
|
@@ -515,13 +513,7 @@ export async function createServer({
|
|
|
515
513
|
|
|
516
514
|
error(err) {
|
|
517
515
|
log.error(err)
|
|
518
|
-
return
|
|
519
|
-
status: 500,
|
|
520
|
-
headers: {
|
|
521
|
-
'Content-Type': 'text/html; charset=utf-8',
|
|
522
|
-
'Cache-Control': NO_STORE,
|
|
523
|
-
},
|
|
524
|
-
})
|
|
516
|
+
return internalErrorResponse(err)
|
|
525
517
|
},
|
|
526
518
|
})
|
|
527
519
|
|
|
@@ -20,29 +20,47 @@ type RegistryManifests = {
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
let manifests: RegistryManifests | undefined
|
|
23
|
-
let
|
|
23
|
+
let loading: Promise<void> | undefined
|
|
24
24
|
|
|
25
25
|
export function setRegistryManifests(value: RegistryManifests): void {
|
|
26
26
|
manifests = value
|
|
27
|
-
|
|
27
|
+
loading = undefined
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
/*
|
|
31
31
|
On first call, eagerly imports every rpc + socket + prompt module so
|
|
32
32
|
defineVerb / defineSocket / definePrompt fire and populate the
|
|
33
|
-
registries. Idempotent — repeat calls
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
registries. Idempotent — repeat calls reuse the same in-flight promise,
|
|
34
|
+
so concurrent first requests (e.g. /openapi.json + an MCP tools/list)
|
|
35
|
+
trigger exactly one load instead of racing to fire the full import set
|
|
36
|
+
each. Eager loading is acceptable here because enumeration (MCP
|
|
37
|
+
tool/resource/prompt lists, the OpenAPI document) fundamentally requires
|
|
38
|
+
the full surface; the alternative of per-call lazy loading produces flaky
|
|
39
|
+
first-call latency.
|
|
37
40
|
*/
|
|
38
|
-
export
|
|
39
|
-
if (
|
|
40
|
-
return
|
|
41
|
+
export function ensureRegistriesLoaded(): Promise<void> {
|
|
42
|
+
if (!manifests) {
|
|
43
|
+
return Promise.resolve()
|
|
41
44
|
}
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
if (!loading) {
|
|
46
|
+
const { rpc, sockets, prompts } = manifests
|
|
47
|
+
loading = Promise.all([
|
|
48
|
+
...Object.values(rpc).map((loader) => loader()),
|
|
49
|
+
...Object.values(sockets).map((loader) => loader()),
|
|
50
|
+
...Object.values(prompts).map((loader) => loader()),
|
|
51
|
+
])
|
|
52
|
+
.then(() => undefined)
|
|
53
|
+
/*
|
|
54
|
+
Clear the memo on failure so a transient import error (a
|
|
55
|
+
module that throws at load, fixed by the next HMR pass)
|
|
56
|
+
doesn't poison every later enumeration request for the
|
|
57
|
+
process lifetime. The rejection still propagates to this
|
|
58
|
+
caller; the reset only affects subsequent calls.
|
|
59
|
+
*/
|
|
60
|
+
.catch((error) => {
|
|
61
|
+
loading = undefined
|
|
62
|
+
throw error
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
return loading
|
|
48
66
|
}
|
|
@@ -1,15 +1,14 @@
|
|
|
1
|
-
import type { Server } from 'bun'
|
|
2
1
|
import type { CacheStore } from '../../../shared/types/CacheStore.ts'
|
|
3
2
|
|
|
4
3
|
/*
|
|
5
4
|
Per-request state propagated through AsyncLocalStorage. Every field is
|
|
6
5
|
populated once at the server's fetch boundary; helpers and verb-defined
|
|
7
6
|
remote functions read from it without threading arguments through user code.
|
|
7
|
+
The inbound request's AbortSignal is reached via `req.signal` rather than a
|
|
8
|
+
separate field.
|
|
8
9
|
*/
|
|
9
10
|
export type RequestStore = {
|
|
10
11
|
url: URL
|
|
11
12
|
req: Request
|
|
12
|
-
signal: AbortSignal
|
|
13
13
|
cache: CacheStore
|
|
14
|
-
server: Server<unknown>
|
|
15
14
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Merges a caller's `ResponseInit` over a respond helper's default headers.
|
|
3
|
+
The helper's defaults seed the header set; the caller's headers overlay them
|
|
4
|
+
per-key (so an explicit `cache-control` wins over the helper's `no-store`),
|
|
5
|
+
and the rest of `init` (status, statusText) passes straight through. Shared
|
|
6
|
+
by `json` / `jsonl` / `sse` / `error` / `redirect` so every helper accepts a
|
|
7
|
+
final `ResponseInit` with identical override semantics.
|
|
8
|
+
|
|
9
|
+
A helper that owns the status itself (`error`, `redirect`) passes it as the
|
|
10
|
+
final `status` argument; it is applied last so it always wins over any
|
|
11
|
+
`init.status`, keeping that precedence inside the helper rather than relying
|
|
12
|
+
on each call site spreading in the right order.
|
|
13
|
+
*/
|
|
14
|
+
export function withResponseDefaults(
|
|
15
|
+
init: ResponseInit | undefined,
|
|
16
|
+
defaultHeaders: Record<string, string>,
|
|
17
|
+
status?: number,
|
|
18
|
+
): ResponseInit {
|
|
19
|
+
const headers = new Headers(defaultHeaders)
|
|
20
|
+
new Headers(init?.headers).forEach((value, key) => {
|
|
21
|
+
headers.set(key, value)
|
|
22
|
+
})
|
|
23
|
+
return { ...init, headers, ...(status !== undefined && { status }) }
|
|
24
|
+
}
|
|
@@ -3,6 +3,10 @@ import { log } from '../../shared/log.ts'
|
|
|
3
3
|
import { lookupSocket } from './lookupSocket.ts'
|
|
4
4
|
import type { SocketClientFrame } from './types/SocketClientFrame.ts'
|
|
5
5
|
import type { SocketRoutes } from './types/SocketRoutes.ts'
|
|
6
|
+
import type { SocketServerFrame } from './types/SocketServerFrame.ts'
|
|
7
|
+
|
|
8
|
+
// Reused across every inbound binary frame rather than allocated per message.
|
|
9
|
+
const textDecoder = new TextDecoder()
|
|
6
10
|
|
|
7
11
|
type SocketDispatcher = {
|
|
8
12
|
open(ws: ServerWebSocket<unknown>): void
|
|
@@ -60,8 +64,8 @@ export function createSocketDispatcher(sockets: SocketRoutes): SocketDispatcher
|
|
|
60
64
|
return promise
|
|
61
65
|
}
|
|
62
66
|
|
|
63
|
-
function send(ws: ServerWebSocket<unknown>, frame:
|
|
64
|
-
if (ws.readyState !==
|
|
67
|
+
function send(ws: ServerWebSocket<unknown>, frame: SocketServerFrame): void {
|
|
68
|
+
if (ws.readyState !== WebSocket.OPEN) {
|
|
65
69
|
return
|
|
66
70
|
}
|
|
67
71
|
ws.send(JSON.stringify(frame))
|
|
@@ -154,10 +158,9 @@ export function createSocketDispatcher(sockets: SocketRoutes): SocketDispatcher
|
|
|
154
158
|
const replayCount =
|
|
155
159
|
frame.replay === undefined ? history.length : Math.min(frame.replay, history.length)
|
|
156
160
|
if (replayCount > 0) {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
}
|
|
161
|
+
history.slice(history.length - replayCount).forEach((message) => {
|
|
162
|
+
send(ws, { type: 'msg', socket: frame.socket, message })
|
|
163
|
+
})
|
|
161
164
|
}
|
|
162
165
|
}
|
|
163
166
|
|
|
@@ -232,7 +235,7 @@ export function createSocketDispatcher(sockets: SocketRoutes): SocketDispatcher
|
|
|
232
235
|
if (!state) {
|
|
233
236
|
return
|
|
234
237
|
}
|
|
235
|
-
const text = typeof data === 'string' ? data :
|
|
238
|
+
const text = typeof data === 'string' ? data : textDecoder.decode(data)
|
|
236
239
|
let frame: SocketClientFrame
|
|
237
240
|
try {
|
|
238
241
|
frame = JSON.parse(text) as SocketClientFrame
|
package/src/lib/server/sse.ts
CHANGED
|
@@ -26,22 +26,27 @@ maps it to the entry's `error` field.
|
|
|
26
26
|
import { NO_STORE } from '../shared/cacheControlValues.ts'
|
|
27
27
|
import type { TypedResponse } from './rpc/types/TypedResponse.ts'
|
|
28
28
|
import { streamFromIterator } from './runtime/streamFromIterator.ts'
|
|
29
|
+
import { withResponseDefaults } from './runtime/withResponseDefaults.ts'
|
|
29
30
|
|
|
30
31
|
const KEEPALIVE_INTERVAL_MS = 15000
|
|
31
32
|
|
|
32
|
-
export function sse<Frame>(
|
|
33
|
+
export function sse<Frame>(
|
|
34
|
+
iterable: AsyncIterable<Frame>,
|
|
35
|
+
init?: ResponseInit,
|
|
36
|
+
): TypedResponse<Frame> {
|
|
33
37
|
const body = streamFromIterator(iterable, {
|
|
34
38
|
encodeFrame: (value) => `data: ${JSON.stringify(value)}\n\n`,
|
|
35
39
|
encodeError: (message) => `event: error\ndata: ${JSON.stringify({ message })}\n\n`,
|
|
36
40
|
keepaliveMs: KEEPALIVE_INTERVAL_MS,
|
|
37
41
|
keepalivePayload: ': keepalive\n\n',
|
|
38
42
|
})
|
|
39
|
-
return new Response(
|
|
40
|
-
|
|
43
|
+
return new Response(
|
|
44
|
+
body,
|
|
45
|
+
withResponseDefaults(init, {
|
|
41
46
|
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
42
47
|
'Cache-Control': NO_STORE,
|
|
43
48
|
'X-Content-Type-Options': 'nosniff',
|
|
44
49
|
Connection: 'keep-alive',
|
|
45
|
-
},
|
|
46
|
-
|
|
50
|
+
}),
|
|
51
|
+
) as TypedResponse<Frame>
|
|
47
52
|
}
|
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
/*
|
|
2
2
|
Cache-Control values used by belte's framework responses. Centralised so
|
|
3
3
|
the framework's policy (no-store on errors and rpc dispatch helpers,
|
|
4
|
-
private/no-cache on SSR HTML) lives in one place
|
|
5
|
-
the server core and the respond
|
|
4
|
+
private/no-cache on SSR HTML, the static-asset policies) lives in one place
|
|
5
|
+
and can't drift between the server core, the asset servers, and the respond
|
|
6
|
+
helpers.
|
|
6
7
|
*/
|
|
7
8
|
export const NO_STORE = 'no-store'
|
|
8
9
|
export const SSR_CACHE_CONTROL = 'private, no-cache'
|
|
10
|
+
|
|
11
|
+
// Content-addressed `/_app/` chunks (name carries the hash) — cache forever.
|
|
12
|
+
export const IMMUTABLE_ASSET_CACHE_CONTROL = 'public, max-age=31536000, immutable'
|
|
13
|
+
// Unhashed `/_app/` entries (entry bundle, shell) — must revalidate each time.
|
|
14
|
+
export const REVALIDATE_ASSET_CACHE_CONTROL = 'public, max-age=0, must-revalidate'
|
|
15
|
+
// Files served from public/ at the site root — short shared cache.
|
|
16
|
+
export const PUBLIC_ASSET_CACHE_CONTROL = 'public, max-age=3600'
|
|
@@ -16,9 +16,5 @@ function sortedKeysReplacer(_key: string, value: unknown): unknown {
|
|
|
16
16
|
}
|
|
17
17
|
const record = value as Record<string, unknown>
|
|
18
18
|
const sortedKeys = Object.keys(record).sort()
|
|
19
|
-
|
|
20
|
-
for (const key of sortedKeys) {
|
|
21
|
-
sorted[key] = record[key]
|
|
22
|
-
}
|
|
23
|
-
return sorted
|
|
19
|
+
return Object.fromEntries(sortedKeys.map((key) => [key, record[key]]))
|
|
24
20
|
}
|
|
@@ -7,14 +7,16 @@ Returns a fresh cache store. On the server, every request gets its own
|
|
|
7
7
|
store via the AsyncLocalStorage RequestStore. On the client, a single
|
|
8
8
|
module-level store is created at startup and shared across the tab.
|
|
9
9
|
|
|
10
|
-
Each key gets a lazily-created Svelte subscriber
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
10
|
+
Each key gets a lazily-created Svelte subscriber. Reading a key from a
|
|
11
|
+
tracking scope ($derived / $effect) subscribes that scope; invalidating
|
|
12
|
+
the key dispatches an 'invalidate' event whose detail is a Set of affected
|
|
13
|
+
keys so each listener's lookup is O(1). The subscriber outlives entry
|
|
14
|
+
eviction — invalidating/refetching a key reuses the same subscriber, so
|
|
15
|
+
there's no listener churn or duplicate registration as cache values come
|
|
16
|
+
and go. It's evicted only when its last reactive reader tears down (the
|
|
17
|
+
client store is module-level/tab-scoped, so retaining a thunk per distinct
|
|
18
|
+
key would otherwise grow unbounded across a session), identity-guarded so
|
|
19
|
+
a concurrent re-subscribe isn't clobbered — mirroring subscribe.ts.
|
|
18
20
|
*/
|
|
19
21
|
export function createCacheStore(): CacheStore {
|
|
20
22
|
const entries = new Map<string, CacheEntry>()
|
|
@@ -22,19 +24,26 @@ export function createCacheStore(): CacheStore {
|
|
|
22
24
|
const subscribers = new Map<string, () => void>()
|
|
23
25
|
|
|
24
26
|
function subscribe(key: string): void {
|
|
25
|
-
|
|
26
|
-
if (
|
|
27
|
-
|
|
28
|
-
|
|
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)
|
|
27
|
+
const existing = subscribers.get(key)
|
|
28
|
+
if (existing) {
|
|
29
|
+
existing()
|
|
30
|
+
return
|
|
37
31
|
}
|
|
32
|
+
const registered = createSubscriber((update) => {
|
|
33
|
+
const onInvalidate = (event: Event) => {
|
|
34
|
+
if ((event as CustomEvent<Set<string>>).detail.has(key)) {
|
|
35
|
+
update()
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
events.addEventListener('invalidate', onInvalidate)
|
|
39
|
+
return () => {
|
|
40
|
+
events.removeEventListener('invalidate', onInvalidate)
|
|
41
|
+
if (subscribers.get(key) === registered) {
|
|
42
|
+
subscribers.delete(key)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
subscribers.set(key, registered)
|
|
38
47
|
registered()
|
|
39
48
|
}
|
|
40
49
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { BuildOutput } from 'bun'
|
|
2
|
+
import { log } from './log.ts'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
On a failed Bun.build(), logs each diagnostic and exits non-zero. Every belte
|
|
6
|
+
build entrypoint (build / compile / buildCli / bundleApp) funnels its result
|
|
7
|
+
through here so failure reporting can't drift between them.
|
|
8
|
+
*/
|
|
9
|
+
export function exitOnBuildFailure(result: BuildOutput): void {
|
|
10
|
+
if (result.success) {
|
|
11
|
+
return
|
|
12
|
+
}
|
|
13
|
+
result.logs.forEach((entry) => {
|
|
14
|
+
log.error(entry)
|
|
15
|
+
})
|
|
16
|
+
process.exit(1)
|
|
17
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/*
|
|
2
|
+
The bare filename of a path, with directory and trailing extension stripped —
|
|
3
|
+
e.g. `users/list.ts` → `list`, `/_virtual/mcp-resources.ts` → `mcp-resources`.
|
|
4
|
+
Used to derive a virtual-module name from its path and to check an $rpc /
|
|
5
|
+
$sockets module's single export name against its file stem.
|
|
6
|
+
*/
|
|
7
|
+
export function fileStem(path: string): string {
|
|
8
|
+
return (path.split('/').pop() ?? '').replace(/\.[^.]+$/, '')
|
|
9
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { PromptArgument } from './types/PromptArgument.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Turns a markdown prompt's frontmatter `arguments` list into the JSON
|
|
5
|
+
Schema the MCP dispatcher advertises in `prompts/list` (top-level string
|
|
6
|
+
properties + a `required` array). Prompt arguments are always strings —
|
|
7
|
+
MCP fills them from model output — so every property is `{ type: 'string' }`.
|
|
8
|
+
Returns undefined for an argument-less prompt so the generated module
|
|
9
|
+
omits the field entirely.
|
|
10
|
+
*/
|
|
11
|
+
export function jsonSchemaForPromptArguments(
|
|
12
|
+
args: PromptArgument[],
|
|
13
|
+
): Record<string, unknown> | undefined {
|
|
14
|
+
if (args.length === 0) {
|
|
15
|
+
return undefined
|
|
16
|
+
}
|
|
17
|
+
const properties = Object.fromEntries(
|
|
18
|
+
args.map((arg) => [
|
|
19
|
+
arg.name,
|
|
20
|
+
{ type: 'string', ...(arg.description ? { description: arg.description } : {}) },
|
|
21
|
+
]),
|
|
22
|
+
)
|
|
23
|
+
const required = args.filter((arg) => arg.required).map((arg) => arg.name)
|
|
24
|
+
return {
|
|
25
|
+
type: 'object',
|
|
26
|
+
properties,
|
|
27
|
+
...(required.length > 0 ? { required } : {}),
|
|
28
|
+
}
|
|
29
|
+
}
|