@belte/belte 0.19.0 → 0.19.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@belte/belte",
3
- "version": "0.19.0",
3
+ "version": "0.19.2",
4
4
  "type": "module",
5
5
  "description": "Isomorphic multimodal HTTP framework built for humans and machines in a single Bun runtime",
6
6
  "license": "MIT",
@@ -1,9 +1,11 @@
1
1
  import type { Component } from 'svelte'
2
+ import { activePage } from '../shared/activePage.ts'
2
3
  import {
3
4
  type NormalizedLayoutPrefix,
4
5
  nearestLayoutPrefix,
5
6
  normalizeLayoutPrefixes,
6
7
  } from '../shared/nearestLayoutPrefix.ts'
8
+ import type { PageSnapshot } from '../shared/types/PageSnapshot.ts'
7
9
  import { abortPageStream } from './pageStreamController.ts'
8
10
  import type { Layouts } from './types/Layouts.ts'
9
11
  import type { Pages } from './types/Pages.ts'
@@ -42,13 +44,37 @@ export type Page = keyof Routes extends never
42
44
  ? PageStateFor<string>
43
45
  : { [R in keyof Routes]: PageStateFor<R> }[keyof Routes]
44
46
 
45
- // biome-ignore lint/suspicious/noExplicitAny: discriminated-union init needs a single arm
46
- export const page: Page = $state<any>({
47
+ /*
48
+ Client-side singleton the resolver returns. navigate()/applyState mutate it;
49
+ because it's $state, reads taken through the `page` proxy inside a $derived
50
+ re-run on every nav. Never populated server-side — there the resolver reads
51
+ the per-request ALS store instead, so concurrent renders don't share it.
52
+ */
53
+ export const clientPageState: PageSnapshot = $state({
47
54
  route: '',
48
55
  params: {},
49
56
  url: new URL('http://localhost/'),
50
57
  })
51
58
 
59
+ /*
60
+ Public page state. A getter proxy over the side's registered resolver
61
+ (activePage): client → clientPageState singleton, server → the per-request
62
+ ALS snapshot. Property reads on the client resolve to the $state singleton, so
63
+ reactivity flows; on the server each render reads its own request scope. Users
64
+ only ever see route/params/url.
65
+ */
66
+ export const page = {
67
+ get route() {
68
+ return activePage().route
69
+ },
70
+ get params() {
71
+ return activePage().params
72
+ },
73
+ get url() {
74
+ return activePage().url
75
+ },
76
+ } as unknown as Page
77
+
52
78
  /*
53
79
  Internal renderer state — the Layout/Page components App.svelte mounts.
54
80
  Kept on a separate $state object so it doesn't leak into the public `page`
@@ -116,15 +142,13 @@ function applyState(
116
142
  ): void {
117
143
  renderState.Layout = Layout
118
144
  renderState.Page = Page
119
- const mutable = page as PageStateFor<string>
120
- mutable.route = route
121
- mutable.params = params
145
+ clientPageState.route = route
146
+ clientPageState.params = params
122
147
  syncUrl()
123
148
  }
124
149
 
125
150
  function syncUrl(): void {
126
- const mutable = page as PageStateFor<string>
127
- mutable.url = new URL(window.location.href)
151
+ clientPageState.url = new URL(window.location.href)
128
152
  }
129
153
 
130
154
  /*
@@ -207,7 +231,7 @@ export async function navigate(href: string, options: NavigateOptions = {}): Pro
207
231
  return
208
232
  }
209
233
  const fullTarget = `${target.pathname}${target.search}${target.hash}`
210
- if (target.pathname === page.url.pathname) {
234
+ if (target.pathname === clientPageState.url.pathname) {
211
235
  writeHistory(replace, fullTarget)
212
236
  syncUrl()
213
237
  return
@@ -234,7 +258,7 @@ only refreshes `page.url`; a pathname change resolves and swaps the page, or
234
258
  hard-navigates when the restored URL isn't an SPA route.
235
259
  */
236
260
  async function applyTarget(pathname: string, fullTarget: string): Promise<void> {
237
- if (pathname === page.url.pathname) {
261
+ if (pathname === clientPageState.url.pathname) {
238
262
  syncUrl()
239
263
  return
240
264
  }
@@ -3,13 +3,21 @@ import App from '../../App.svelte'
3
3
  import { createCacheStore } from '../shared/createCacheStore.ts'
4
4
  import { setCacheStoreResolver } from '../shared/setCacheStoreResolver.ts'
5
5
  import { setGlobalCacheStoreResolver } from '../shared/setGlobalCacheStoreResolver.ts'
6
+ import { setPageResolver } from '../shared/setPageResolver.ts'
6
7
  import type { CacheSnapshotEntry } from '../shared/types/CacheSnapshotEntry.ts'
7
8
  import type { CacheStore } from '../shared/types/CacheStore.ts'
8
9
  import type { StreamingPlaceholder } from '../shared/types/StreamingPlaceholder.ts'
9
10
  import { cacheEntryFromSnapshot } from './cacheEntryFromSnapshot.ts'
10
11
  import { installStreamingPlaceholders } from './installStreamingPlaceholders.ts'
11
12
  import { openResolveStream } from './openResolveStream.ts'
12
- import { bindPage, handlePopstate, navigate, page, renderState } from './page.svelte.ts'
13
+ import {
14
+ bindPage,
15
+ clientPageState,
16
+ handlePopstate,
17
+ navigate,
18
+ page,
19
+ renderState,
20
+ } from './page.svelte.ts'
13
21
  import type { Layouts } from './types/Layouts.ts'
14
22
  import type { Pages } from './types/Pages.ts'
15
23
 
@@ -107,6 +115,8 @@ export async function startClient({
107
115
  setCacheStoreResolver(() => cacheStore)
108
116
  /* One tab store: cache(fn, { global: true }) shares it, so global is a no-op here. */
109
117
  setGlobalCacheStoreResolver(() => cacheStore)
118
+ /* One document: the `page` proxy resolves to this $state singleton, mutated by navigate(). */
119
+ setPageResolver(() => clientPageState)
110
120
  if (window.__SSR__.cache) {
111
121
  hydrateCacheFromSnapshot(cacheStore, window.__SSR__.cache)
112
122
  }
@@ -1,8 +1,7 @@
1
- import { promptRegistry } from '../server/prompts/promptRegistry.ts'
2
1
  import { ensureRegistriesLoaded } from '../server/runtime/registryManifests.ts'
3
2
  import { NO_STORE } from '../shared/CACHE_CONTROL_VALUES.ts'
4
3
  import { getMcpResourceServer } from './mcpResourceServerSlot.ts'
5
- import { buildPrompts, buildTools, callTool } from './mcpSurface.ts'
4
+ import { buildPrompts, buildTools, callTool, renderPrompt } from './mcpSurface.ts'
6
5
  import type { JsonRpcRequest } from './types/JsonRpcRequest.ts'
7
6
  import type { JsonRpcResponse } from './types/JsonRpcResponse.ts'
8
7
  import type { McpServerOptions } from './types/McpServerOptions.ts'
@@ -33,14 +32,13 @@ function getPrompt(
33
32
  name: string,
34
33
  args: Record<string, unknown> | undefined,
35
34
  ): Record<string, unknown> {
36
- const entry = promptRegistry.get(name)
37
- if (!entry) {
38
- throw new Error(`unknown prompt: ${name}`)
39
- }
40
- const rendered = entry.prompt.render((args ?? {}) as Record<string, string>)
35
+ const { description, messages } = renderPrompt(name, args)
41
36
  return {
42
- ...(entry.prompt.description ? { description: entry.prompt.description } : {}),
43
- messages: [{ role: 'user', content: { type: 'text', text: rendered } }],
37
+ ...(description ? { description } : {}),
38
+ messages: messages.map((message) => ({
39
+ role: message.role,
40
+ content: { type: 'text', text: message.text },
41
+ })),
44
42
  }
45
43
  }
46
44
 
@@ -230,16 +230,31 @@ export async function callTool(
230
230
  }
231
231
 
232
232
  /*
233
- Renders a prompt to the message(s) that seed a conversation. A markdown
234
- prompt is a single user turn whose text is the interpolated template.
235
- Throws on an unknown prompt name.
233
+ Renders a prompt: looks it up, interpolates the caller's args, and returns its
234
+ optional description plus the message(s) that seed a conversation. A markdown
235
+ prompt is a single user turn whose text is the interpolated template. Throws on
236
+ an unknown prompt name. The one place prompt rendering lives — dispatchMcpRequest
237
+ wraps this in the prompts/get wire shape, the agent loop reads the messages plain.
236
238
  */
237
- export function getPromptMessages(name: string, args?: Record<string, unknown>): PromptMessage[] {
239
+ export function renderPrompt(
240
+ name: string,
241
+ args?: Record<string, unknown>,
242
+ ): { description?: string; messages: PromptMessage[] } {
238
243
  const entry = promptRegistry.get(name)
239
244
  if (!entry) {
240
245
  throw new Error(`unknown prompt: ${name}`)
241
246
  }
242
- return [{ role: 'user', text: entry.prompt.render((args ?? {}) as Record<string, string>) }]
247
+ return {
248
+ ...(entry.prompt.description ? { description: entry.prompt.description } : {}),
249
+ messages: [
250
+ { role: 'user', text: entry.prompt.render((args ?? {}) as Record<string, string>) },
251
+ ],
252
+ }
253
+ }
254
+
255
+ // The conversation-seeding messages for a prompt, without the wire-shape wrapping.
256
+ export function getPromptMessages(name: string, args?: Record<string, unknown>): PromptMessage[] {
257
+ return renderPrompt(name, args).messages
243
258
  }
244
259
 
245
260
  /*
@@ -248,10 +263,20 @@ Projects the app's MCP surface for an in-process consumer bound to `request`
248
263
  the model acts with the caller's identity. Used by `agent()`.
249
264
  */
250
265
  export function mcpSurface(request: Request): McpSurface {
266
+ // Built on first read and memoized: an engine that advertises tools but never
267
+ // reads prompts (or reaches tools over HTTP, reading neither) skips the unused build.
268
+ let tools: ToolDescriptor[] | undefined
269
+ let prompts: PromptDescriptor[] | undefined
251
270
  return {
252
- tools: buildTools(),
271
+ get tools() {
272
+ tools ??= buildTools()
273
+ return tools
274
+ },
275
+ get prompts() {
276
+ prompts ??= buildPrompts()
277
+ return prompts
278
+ },
253
279
  call: (name, args) => callTool(name, args, request),
254
- prompts: buildPrompts(),
255
280
  getPrompt: getPromptMessages,
256
281
  async listResources() {
257
282
  const server = getMcpResourceServer()
@@ -41,7 +41,16 @@ export type AgentFrame =
41
41
  | { type: 'text'; delta: string }
42
42
  | { type: 'tool_use'; id: string; name: string; input: unknown }
43
43
  | { type: 'tool_result'; id: string; name: string; ok: boolean }
44
- | { type: 'done'; stop: 'end' | 'tool_use' | 'max_tokens' | 'refusal' }
44
+ /*
45
+ `stop` is the reason the engine's loop ended. `error` covers an abnormal
46
+ stop the model didn't choose — a provider error/limit (e.g. Claude Code's
47
+ max-turns) or the engine's own tool-loop cap — so a client can tell a cut-off
48
+ answer from a clean `end`.
49
+ */
50
+ | {
51
+ type: 'done'
52
+ stop: 'end' | 'tool_use' | 'max_tokens' | 'refusal' | 'error'
53
+ }
45
54
 
46
55
  // The app's tool/prompt/resource surface handed to an engine (already gated).
47
56
  export type AgentSurface = McpSurface
@@ -262,17 +262,17 @@ export async function createServer({
262
262
  ])
263
263
  const ErrorView = errorMod.default as Component
264
264
  const Layout = layoutMod?.default as Component | undefined
265
+ // status is a number (and stack optional); the page-params shape is
266
+ // string-keyed generically, so the error props ride through as-is.
267
+ const errorParams = { status, message, stack } as unknown as Record<string, string>
268
+ /* Publish to the store too, so the `page` proxy resolves these during the error render
269
+ (renderError bypasses renderPage, which is where normal renders set them). */
270
+ store.route = pathname
271
+ store.params = errorParams
265
272
  const rendered = await render(App, {
266
273
  props: {
267
274
  state: {
268
- page: {
269
- route: pathname,
270
- // status is a number (and stack optional); the page-params
271
- // shape is string-keyed generically, so the error props
272
- // ride through to the component as-is.
273
- params: { status, message, stack } as unknown as Record<string, string>,
274
- url: store.url,
275
- },
275
+ page: { route: pathname, params: errorParams, url: store.url },
276
276
  render: { Layout, Page: ErrorView },
277
277
  },
278
278
  },
@@ -293,6 +293,9 @@ export async function createServer({
293
293
  params: Record<string, string>,
294
294
  store: RequestStore,
295
295
  ): Promise<Response> {
296
+ /* Publish the match so the `page` proxy resolves route/params during SSR render. */
297
+ store.route = routeUrl
298
+ store.params = params
296
299
  const json = wantsJson(store.req)
297
300
  if (json) {
298
301
  return Response.json(
@@ -12,6 +12,13 @@ export type RequestStore = {
12
12
  req: Request
13
13
  cache: CacheStore
14
14
  /*
15
+ The matched page route and its decoded params, set just before the page
16
+ renders so the `page` proxy resolves them inside layout-scoped components
17
+ during SSR. Undefined on rpc/socket requests and until a page match lands.
18
+ */
19
+ route?: string
20
+ params?: Record<string, string>
21
+ /*
15
22
  The request's cookie jar, materialized lazily by the first cookies() call
16
23
  and flushed to Set-Cookie headers when the scope returns. Undefined while a
17
24
  request never touches cookies, so the common path parses and emits nothing.
@@ -0,0 +1,20 @@
1
+ import { pageSlot } from './pageSlot.ts'
2
+ import type { PageSnapshot } from './types/PageSnapshot.ts'
3
+
4
+ /*
5
+ Resolves the active page snapshot. The runtime is registered via
6
+ `setPageResolver` from the server entry (request-scoped via ALS) or the
7
+ client entry (module-level singleton). If no resolver is registered, a single
8
+ fallback snapshot is created lazily so isolated tests still work. Mirrors
9
+ activeCacheStore.
10
+ */
11
+ export function activePage(): PageSnapshot {
12
+ const fromResolver = pageSlot.resolver?.()
13
+ if (fromResolver) {
14
+ return fromResolver
15
+ }
16
+ if (!pageSlot.fallback) {
17
+ pageSlot.fallback = { route: '', params: {}, url: new URL('http://localhost/') }
18
+ }
19
+ return pageSlot.fallback
20
+ }
@@ -0,0 +1,17 @@
1
+ import type { PageSnapshot } from './types/PageSnapshot.ts'
2
+
3
+ /*
4
+ Internal slot the runtime entries register their page resolver into. The
5
+ server entry installs an ALS-backed resolver (request-scoped, so concurrent
6
+ and streaming renders never share state); the client entry installs a
7
+ module-singleton resolver. `fallback` is a single lazy snapshot used only
8
+ when no resolver is registered — keeps isolated tests working without forcing
9
+ them to spin up the runtime. Mirrors cacheStoreSlot.
10
+ */
11
+ export const pageSlot: {
12
+ resolver: (() => PageSnapshot | undefined) | undefined
13
+ fallback: PageSnapshot | undefined
14
+ } = {
15
+ resolver: undefined,
16
+ fallback: undefined,
17
+ }
@@ -0,0 +1,7 @@
1
+ import { pageSlot } from './pageSlot.ts'
2
+ import type { PageSnapshot } from './types/PageSnapshot.ts'
3
+
4
+ // Registers the runtime's page resolver. Called once per side at boot.
5
+ export function setPageResolver(fn: () => PageSnapshot | undefined): void {
6
+ pageSlot.resolver = fn
7
+ }
@@ -0,0 +1,11 @@
1
+ /*
2
+ Side-agnostic shape of the active page state: the matched route, its decoded
3
+ params, and the live request/location URL. The browser `page` proxy reads its
4
+ public fields through this; the server resolver reads them off the per-request
5
+ store, the client resolver off the module singleton.
6
+ */
7
+ export type PageSnapshot = {
8
+ route: string
9
+ params: Record<string, string>
10
+ url: URL
11
+ }
@@ -35,6 +35,7 @@ import { loadEnvFromDataDir } from './lib/shared/loadEnvFromDataDir.ts'
35
35
  import { runningAsStandaloneBinary } from './lib/shared/runningAsStandaloneBinary.ts'
36
36
  import { setCacheStoreResolver } from './lib/shared/setCacheStoreResolver.ts'
37
37
  import { setGlobalCacheStoreResolver } from './lib/shared/setGlobalCacheStoreResolver.ts'
38
+ import { setPageResolver } from './lib/shared/setPageResolver.ts'
38
39
 
39
40
  /*
40
41
  Resolve config into process.env before anything reads it (createServer reads
@@ -67,6 +68,21 @@ exitWithParent()
67
68
 
68
69
  setCacheStoreResolver(() => requestContext.getStore()?.cache)
69
70
 
71
+ /*
72
+ Request-scoped page resolver: the `page` proxy reads route/params/url off the
73
+ ALS store, so layout-scoped components see the live match during SSR without a
74
+ module singleton leaking across concurrent or streaming renders. route/params
75
+ land just before render; url is set at the request boundary, so 404/error
76
+ renders still get a correct page.url.
77
+ */
78
+ setPageResolver(() => {
79
+ const store = requestContext.getStore()
80
+ if (!store) {
81
+ return undefined
82
+ }
83
+ return { route: store.route ?? '', params: store.params ?? {}, url: store.url }
84
+ })
85
+
70
86
  /*
71
87
  Process-level store for cache(fn, { global: true }) — one per server process,
72
88
  outlives every request so memoised external calls are shared across them.