@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.
Files changed (98) hide show
  1. package/bin/belte.ts +25 -12
  2. package/package.json +2 -1
  3. package/src/appEntry.ts +124 -0
  4. package/src/belteResolverPlugin.ts +217 -194
  5. package/src/build.ts +6 -67
  6. package/src/buildCli.ts +36 -63
  7. package/src/buildDisconnected.ts +127 -0
  8. package/src/bundleApp.ts +123 -0
  9. package/src/bundleDisconnectedEntry.ts +17 -0
  10. package/src/cliEntry.ts +3 -9
  11. package/src/compile.ts +4 -15
  12. package/src/controlServerWorker.ts +261 -0
  13. package/src/dedupeSveltePlugin.ts +66 -0
  14. package/src/discoveryEntry.ts +12 -11
  15. package/src/lib/browser/cache.ts +3 -6
  16. package/src/lib/browser/page.svelte.ts +19 -21
  17. package/src/lib/browser/socketChannel.ts +11 -1
  18. package/src/lib/browser/types/Pages.ts +1 -1
  19. package/src/lib/bundle/BundleMenu.ts +11 -0
  20. package/src/lib/bundle/BundleMenuItem.ts +24 -0
  21. package/src/lib/bundle/BundleWindow.ts +20 -0
  22. package/src/lib/bundle/bindConnectedFlag.ts +29 -0
  23. package/src/lib/bundle/bindRequestNavigate.ts +31 -0
  24. package/src/lib/bundle/buildWebviewLib.ts +111 -0
  25. package/src/lib/bundle/disconnected.css +9 -0
  26. package/src/lib/bundle/disconnected.svelte +192 -0
  27. package/src/lib/bundle/ensureWebviewLib.ts +20 -0
  28. package/src/lib/bundle/exitWithParent.ts +28 -0
  29. package/src/lib/bundle/findFreePort.ts +14 -0
  30. package/src/lib/bundle/infoPlist.ts +46 -0
  31. package/src/lib/bundle/installMacMenu.ts +39 -0
  32. package/src/lib/bundle/listenLocalControlServer.ts +19 -0
  33. package/src/lib/bundle/native/belteMenu.mm +298 -0
  34. package/src/lib/bundle/native/webview.h +4557 -0
  35. package/src/lib/bundle/onMenu.ts +26 -0
  36. package/src/lib/bundle/openWebview.ts +81 -0
  37. package/src/lib/bundle/pngToIcns.ts +47 -0
  38. package/src/lib/bundle/probeBelteServer.ts +34 -0
  39. package/src/lib/bundle/resolveServerBinary.ts +12 -0
  40. package/src/lib/bundle/resolveWebviewLib.ts +51 -0
  41. package/src/lib/bundle/serverBinaryFilename.ts +8 -0
  42. package/src/lib/bundle/stableLocalPort.ts +19 -0
  43. package/src/lib/bundle/waitForServer.ts +23 -0
  44. package/src/lib/bundle/webviewBuildRevision.ts +9 -0
  45. package/src/lib/bundle/webviewCachePath.ts +23 -0
  46. package/src/lib/bundle/webviewLibName.ts +11 -0
  47. package/src/lib/bundle/webviewVersion.ts +7 -0
  48. package/src/lib/cli/createClient.ts +34 -36
  49. package/src/lib/cli/printHelp.ts +45 -2
  50. package/src/lib/cli/runCli.ts +12 -3
  51. package/src/lib/mcp/createMcpResourceServer.ts +1 -1
  52. package/src/lib/mcp/dispatchMcpRequest.ts +53 -73
  53. package/src/lib/server/AppModule.ts +2 -2
  54. package/src/lib/server/cli/handleCliDownload.ts +4 -5
  55. package/src/lib/server/cli/handleCliInstall.ts +17 -0
  56. package/src/lib/server/error.ts +23 -9
  57. package/src/lib/server/json.ts +5 -5
  58. package/src/lib/server/jsonl.ts +10 -5
  59. package/src/lib/server/prompts/definePrompt.ts +6 -6
  60. package/src/lib/server/prompts/renderPromptTemplate.ts +16 -0
  61. package/src/lib/server/prompts/types/Prompt.ts +8 -9
  62. package/src/lib/server/prompts/types/PromptOptions.ts +7 -12
  63. package/src/lib/server/prompts/types/PromptRegistryEntry.ts +3 -5
  64. package/src/lib/server/prompts/types/PromptRoutes.ts +4 -4
  65. package/src/lib/server/redirect.ts +13 -8
  66. package/src/lib/server/rpc/defineVerb.ts +4 -3
  67. package/src/lib/server/rpc/findVerbByCommandName.ts +18 -0
  68. package/src/lib/server/rpc/types/RemoteFunction.ts +1 -1
  69. package/src/lib/server/rpc/types/RemoteHandler.ts +4 -0
  70. package/src/lib/server/runtime/acceptsZstd.ts +8 -0
  71. package/src/lib/server/runtime/buildOpenApiSpec.ts +2 -0
  72. package/src/lib/server/runtime/cacheControlForAsset.ts +7 -2
  73. package/src/lib/server/runtime/createAssetHeaderCache.ts +35 -0
  74. package/src/lib/server/runtime/createPublicAssetServer.ts +6 -20
  75. package/src/lib/server/runtime/createServer.ts +50 -58
  76. package/src/lib/server/runtime/registryManifests.ts +33 -15
  77. package/src/lib/server/runtime/types/RequestStore.ts +2 -3
  78. package/src/lib/server/runtime/withResponseDefaults.ts +24 -0
  79. package/src/lib/server/sockets/createSocketDispatcher.ts +10 -7
  80. package/src/lib/server/sse.ts +10 -5
  81. package/src/lib/shared/cacheControlValues.ts +10 -2
  82. package/src/lib/shared/canonicalJson.ts +1 -5
  83. package/src/lib/shared/createCacheStore.ts +29 -20
  84. package/src/lib/shared/exitOnBuildFailure.ts +17 -0
  85. package/src/lib/shared/fileStem.ts +9 -0
  86. package/src/lib/shared/jsonSchemaForPromptArguments.ts +29 -0
  87. package/src/lib/shared/keyForRemoteCall.ts +7 -5
  88. package/src/lib/shared/parsePromptMarkdown.ts +34 -0
  89. package/src/lib/shared/promptNameForFile.ts +5 -5
  90. package/src/lib/shared/subscribableFromResponse.ts +104 -215
  91. package/src/lib/shared/types/PromptArgument.ts +12 -0
  92. package/src/lib/shared/writeRoutesDts.ts +5 -7
  93. package/src/serverBuildPlugins.ts +25 -0
  94. package/src/serverEntry.ts +4 -0
  95. package/template/package.json +2 -1
  96. package/src/lib/server/prompt.ts +0 -30
  97. package/src/lib/server/prompts/types/PromptMessage.ts +0 -10
  98. 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 'public, max-age=31536000, immutable'
19
+ return IMMUTABLE_ASSET_CACHE_CONTROL
15
20
  }
16
- return 'public, max-age=0, must-revalidate'
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 { mimeForExtension } from './mimeForExtension.ts'
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 headerCache = new Map<string, { base: HeadersInit; zstd: HeadersInit }>()
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.headers.get('accept-encoding') ?? '').toLowerCase().includes('zstd')
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.zstdDecompressSync(compressed), { headers: base })
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
- Header objects for a pathname depend only on the pathname's extension
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.zstdDecompressSync(compressed), { headers: baseHeaders })
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 = new 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
- server = Bun.serve({
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 new Response(`<pre>${String(err.stack ?? err)}</pre>`, {
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 loadedAll = false
23
+ let loading: Promise<void> | undefined
24
24
 
25
25
  export function setRegistryManifests(value: RegistryManifests): void {
26
26
  manifests = value
27
- loadedAll = false
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 are no-ops. Eager loading is
34
- acceptable here because enumeration (MCP tool/resource/prompt lists,
35
- the OpenAPI document) fundamentally requires the full surface; the
36
- alternative of per-call lazy loading produces flaky first-call latency.
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 async function ensureRegistriesLoaded(): Promise<void> {
39
- if (loadedAll || !manifests) {
40
- return
41
+ export function ensureRegistriesLoaded(): Promise<void> {
42
+ if (!manifests) {
43
+ return Promise.resolve()
41
44
  }
42
- await Promise.all([
43
- ...Object.values(manifests.rpc).map((loader) => loader()),
44
- ...Object.values(manifests.sockets).map((loader) => loader()),
45
- ...Object.values(manifests.prompts).map((loader) => loader()),
46
- ])
47
- loadedAll = true
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: unknown): void {
64
- if (ws.readyState !== 1) {
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
- const start = history.length - replayCount
158
- for (let index = start; index < history.length; index++) {
159
- send(ws, { type: 'msg', socket: frame.socket, message: history[index] })
160
- }
161
+ 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 : data.toString('utf8')
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
@@ -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>(iterable: AsyncIterable<Frame>): TypedResponse<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(body, {
40
- headers: {
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
- }) as TypedResponse<Frame>
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 and can't drift between
5
- the server core and the respond helpers.
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
- const sorted: Record<string, unknown> = {}
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 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.
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
- 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)
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
+ }