@briancray/belte 0.1.0 → 0.2.1
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 +236 -202
- 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/belteImportName.test.ts +58 -0
- package/src/lib/shared/belteImportName.ts +45 -0
- package/src/lib/shared/beltePackageName.ts +7 -0
- 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/prepareRpcModule.ts +14 -4
- package/src/lib/shared/prepareSocketModule.ts +16 -2
- 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 +3 -2
- 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
|
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { afterAll, expect, test } from 'bun:test'
|
|
2
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { belteImportName } from './belteImportName.ts'
|
|
5
|
+
|
|
6
|
+
const roots: string[] = []
|
|
7
|
+
afterAll(() => roots.forEach((root) => rmSync(root, { recursive: true, force: true })))
|
|
8
|
+
|
|
9
|
+
// Writes a package.json into a fresh temp dir and returns the dir.
|
|
10
|
+
async function projectWith(packageJson: unknown): Promise<string> {
|
|
11
|
+
const root = mkdtempSync(`${tmpdir()}/belte-import-name-`)
|
|
12
|
+
roots.push(root)
|
|
13
|
+
await Bun.write(`${root}/package.json`, JSON.stringify(packageJson))
|
|
14
|
+
return root
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
test('uses the canonical name for a direct dependency', async () => {
|
|
18
|
+
const cwd = await projectWith({ dependencies: { '@briancray/belte': '^0.2.0' } })
|
|
19
|
+
expect(await belteImportName(cwd)).toBe('@briancray/belte')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
test('uses the `belte` alias key for an npm alias', async () => {
|
|
23
|
+
const cwd = await projectWith({ dependencies: { belte: 'npm:@briancray/belte@^0.2.0' } })
|
|
24
|
+
expect(await belteImportName(cwd)).toBe('belte')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
test('uses the `belte` alias key for a workspace alias', async () => {
|
|
28
|
+
const cwd = await projectWith({ dependencies: { belte: 'workspace:@briancray/belte@*' } })
|
|
29
|
+
expect(await belteImportName(cwd)).toBe('belte')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
test('uses a non-`belte` alias key when that is how belte is declared', async () => {
|
|
33
|
+
const cwd = await projectWith({ dependencies: { framework: 'npm:@briancray/belte' } })
|
|
34
|
+
expect(await belteImportName(cwd)).toBe('framework')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
test('prefers the `belte` alias over a direct canonical dependency', async () => {
|
|
38
|
+
const cwd = await projectWith({
|
|
39
|
+
dependencies: { '@briancray/belte': '^0.2.0', belte: 'npm:@briancray/belte@^0.2.0' },
|
|
40
|
+
})
|
|
41
|
+
expect(await belteImportName(cwd)).toBe('belte')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('finds the alias in devDependencies', async () => {
|
|
45
|
+
const cwd = await projectWith({ devDependencies: { belte: 'npm:@briancray/belte@^0.2.0' } })
|
|
46
|
+
expect(await belteImportName(cwd)).toBe('belte')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test('falls back to the canonical name when belte is absent', async () => {
|
|
50
|
+
const cwd = await projectWith({ dependencies: { svelte: '^5.0.0' } })
|
|
51
|
+
expect(await belteImportName(cwd)).toBe('@briancray/belte')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
test('falls back to the canonical name when package.json is missing', async () => {
|
|
55
|
+
const root = mkdtempSync(`${tmpdir()}/belte-import-name-`)
|
|
56
|
+
roots.push(root)
|
|
57
|
+
expect(await belteImportName(root)).toBe('@briancray/belte')
|
|
58
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { beltePackageName } from './beltePackageName.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Resolves the bare specifier prefix a consuming project imports belte under —
|
|
5
|
+
the name belte is installed as in its package.json. A project may depend on
|
|
6
|
+
belte directly (`@briancray/belte`) or behind a package alias
|
|
7
|
+
(`"belte": "npm:@briancray/belte@..."`, or `workspace:@briancray/belte@*`
|
|
8
|
+
inside this repo). An alias-only install resolves only under the alias key and
|
|
9
|
+
a direct install only under the canonical name, so the generated rpc / socket
|
|
10
|
+
/ prompt modules must import under whichever name the project actually
|
|
11
|
+
declared.
|
|
12
|
+
|
|
13
|
+
Prefers a `belte` alias (the ergonomic surface the docs use) when present, then
|
|
14
|
+
a direct canonical dependency, then any other alias targeting belte. Falls back
|
|
15
|
+
to the canonical name when belte isn't found in package.json — the build can't
|
|
16
|
+
resolve belte at all in that case, and the canonical name yields the clearest
|
|
17
|
+
resolution error.
|
|
18
|
+
*/
|
|
19
|
+
export async function belteImportName(cwd: string): Promise<string> {
|
|
20
|
+
const packageJsonPath = `${cwd}/package.json`
|
|
21
|
+
if (!(await Bun.file(packageJsonPath).exists())) {
|
|
22
|
+
return beltePackageName
|
|
23
|
+
}
|
|
24
|
+
const packageJson = (await Bun.file(packageJsonPath).json()) as {
|
|
25
|
+
dependencies?: Record<string, string>
|
|
26
|
+
devDependencies?: Record<string, string>
|
|
27
|
+
}
|
|
28
|
+
const dependencies = { ...packageJson.devDependencies, ...packageJson.dependencies }
|
|
29
|
+
/*
|
|
30
|
+
Alias entries whose target is belte — `npm:` for a published install,
|
|
31
|
+
`workspace:` for the in-repo examples. The key is the name the project
|
|
32
|
+
imports under; the version suffix (`@^0.2.0`, `@*`) is optional.
|
|
33
|
+
*/
|
|
34
|
+
const aliasPattern = new RegExp(`^(npm|workspace):${beltePackageName}(@.*)?$`)
|
|
35
|
+
const aliasNames = Object.entries(dependencies)
|
|
36
|
+
.filter(([, specifier]) => aliasPattern.test(specifier))
|
|
37
|
+
.map(([name]) => name)
|
|
38
|
+
if (aliasNames.includes('belte')) {
|
|
39
|
+
return 'belte'
|
|
40
|
+
}
|
|
41
|
+
if (beltePackageName in dependencies) {
|
|
42
|
+
return beltePackageName
|
|
43
|
+
}
|
|
44
|
+
return aliasNames[0] ?? beltePackageName
|
|
45
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/*
|
|
2
|
+
The package's published npm name. The codegen and the import-name resolver
|
|
3
|
+
match a consuming project's dependency (direct or aliased) against this to
|
|
4
|
+
decide which specifier to emit, so keeping it in one place means a future
|
|
5
|
+
rename touches a single constant.
|
|
6
|
+
*/
|
|
7
|
+
export const beltePackageName = '@briancray/belte'
|
|
@@ -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
|
}
|