@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
|
@@ -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
|
+
}
|
|
@@ -3,16 +3,18 @@ import { canonicalJson } from './canonicalJson.ts'
|
|
|
3
3
|
|
|
4
4
|
/*
|
|
5
5
|
Derives a cache key from a verb-defined remote function and its args. The
|
|
6
|
-
prefix is `${method} ${url}` where `url` is the route template. GET/DELETE
|
|
6
|
+
prefix is `${method} ${url}` where `url` is the route template. GET/DELETE/HEAD
|
|
7
7
|
serialise args onto the URL as `?key=value` with keys sorted so the order
|
|
8
8
|
the caller assembled the object doesn't change the key; POST/PUT/PATCH join
|
|
9
|
-
args after a space as canonical JSON.
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
args after a space as canonical JSON. The verb split mirrors buildRpcRequest
|
|
10
|
+
exactly so the key and the synthesized Request can't disagree. Sorted
|
|
11
|
+
key/value pairs are walked once and concatenated directly so the hot
|
|
12
|
+
GET-cache path doesn't allocate per intermediate (entries / filtered /
|
|
13
|
+
URLSearchParams).
|
|
12
14
|
*/
|
|
13
15
|
export function keyForRemoteCall(method: HttpVerb, url: string, args: unknown): string {
|
|
14
16
|
const prefix = `${method} ${url}`
|
|
15
|
-
if (method === 'GET' || method === 'DELETE') {
|
|
17
|
+
if (method === 'GET' || method === 'DELETE' || method === 'HEAD') {
|
|
16
18
|
if (args && typeof args === 'object' && !Array.isArray(args)) {
|
|
17
19
|
const record = args as Record<string, unknown>
|
|
18
20
|
const keys = Object.keys(record).sort()
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { PromptArgument } from './types/PromptArgument.ts'
|
|
2
|
+
|
|
3
|
+
export type ParsedPromptMarkdown = {
|
|
4
|
+
description: string | undefined
|
|
5
|
+
arguments: PromptArgument[]
|
|
6
|
+
body: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// Leading YAML frontmatter block fenced by `---` lines (CRLF tolerant).
|
|
10
|
+
const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/
|
|
11
|
+
|
|
12
|
+
/*
|
|
13
|
+
Splits a `src/mcp/prompts/**.md` file into its frontmatter metadata and
|
|
14
|
+
template body. The frontmatter (optional) carries `description` and an
|
|
15
|
+
`arguments` list; everything after the closing `---` is the prompt body,
|
|
16
|
+
interpolated at render time via `{{name}}` placeholders. A file with no
|
|
17
|
+
frontmatter is all body. Parsed with Bun.YAML — the resolver plugin runs
|
|
18
|
+
under Bun, so the native parser is always available at build time.
|
|
19
|
+
*/
|
|
20
|
+
export function parsePromptMarkdown(source: string): ParsedPromptMarkdown {
|
|
21
|
+
const match = FRONTMATTER.exec(source)
|
|
22
|
+
if (!match) {
|
|
23
|
+
return { description: undefined, arguments: [], body: source.trim() }
|
|
24
|
+
}
|
|
25
|
+
const frontmatter = (Bun.YAML.parse(match[1]) ?? {}) as {
|
|
26
|
+
description?: string
|
|
27
|
+
arguments?: PromptArgument[]
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
description: frontmatter.description,
|
|
31
|
+
arguments: Array.isArray(frontmatter.arguments) ? frontmatter.arguments : [],
|
|
32
|
+
body: source.slice(match[0].length).trim(),
|
|
33
|
+
}
|
|
34
|
+
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import type { HttpVerb } from '../server/rpc/types/HttpVerb.ts'
|
|
2
|
+
import { beltePackageName } from './beltePackageName.ts'
|
|
2
3
|
import { findExportCallSite } from './findExportCallSite.ts'
|
|
3
4
|
import { stripImport } from './stripImport.ts'
|
|
4
5
|
|
|
5
6
|
const VERB_NAMES = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'] as const
|
|
6
7
|
const VERB_SET = new Set<string>(VERB_NAMES)
|
|
7
|
-
const VERB_IMPORT_PATHS = VERB_NAMES.map((verb) => `belte/server/${verb}`)
|
|
8
8
|
|
|
9
9
|
const SINGLE_EXPORT_ERROR =
|
|
10
10
|
'[belte] $rpc module contains more than one `<VERB>(...)` export — each file must declare exactly one remote function'
|
|
@@ -27,14 +27,24 @@ A regex pass would be tidier but it can't tell a `GET` mention inside a
|
|
|
27
27
|
docstring or template literal from the real call, and it can't follow
|
|
28
28
|
nested generics like `GET<Map<K, V>>(`.
|
|
29
29
|
*/
|
|
30
|
-
export function prepareRpcModule(
|
|
30
|
+
export function prepareRpcModule(
|
|
31
|
+
source: string,
|
|
32
|
+
importName: string,
|
|
33
|
+
): PreparedRpcModule | undefined {
|
|
31
34
|
/*
|
|
32
35
|
The "no barrels" surface places each verb at its own path
|
|
33
36
|
(`belte/server/GET`, `belte/server/POST`, …) — strip every one so
|
|
34
37
|
the user's verb import doesn't linger and side-effect-load the
|
|
35
|
-
stub module into the server bundle.
|
|
38
|
+
stub module into the server bundle. The user may import under the
|
|
39
|
+
project's chosen name or the canonical package name, so strip both.
|
|
36
40
|
*/
|
|
37
|
-
const
|
|
41
|
+
const importNames =
|
|
42
|
+
importName === beltePackageName ? [beltePackageName] : [importName, beltePackageName]
|
|
43
|
+
const stripped = importNames.reduce(
|
|
44
|
+
(current, name) =>
|
|
45
|
+
VERB_NAMES.reduce((acc, verb) => stripImport(acc, `${name}/server/${verb}`), current),
|
|
46
|
+
source,
|
|
47
|
+
)
|
|
38
48
|
const site = findExportCallSite(stripped, (ident) => VERB_SET.has(ident), SINGLE_EXPORT_ERROR)
|
|
39
49
|
if (!site) {
|
|
40
50
|
return undefined
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { beltePackageName } from './beltePackageName.ts'
|
|
1
2
|
import { findExportCallSite } from './findExportCallSite.ts'
|
|
2
3
|
import { stripImport } from './stripImport.ts'
|
|
3
4
|
|
|
@@ -17,8 +18,21 @@ original source). The single scan replaces the prior separate
|
|
|
17
18
|
extract + rewrite passes, so the resolver plugin only walks each source
|
|
18
19
|
character-by-character once.
|
|
19
20
|
*/
|
|
20
|
-
export function prepareSocketModule(
|
|
21
|
-
|
|
21
|
+
export function prepareSocketModule(
|
|
22
|
+
source: string,
|
|
23
|
+
importName: string,
|
|
24
|
+
): PreparedSocketModule | undefined {
|
|
25
|
+
/*
|
|
26
|
+
Strip the user's `socket` import under the project's chosen name and the
|
|
27
|
+
canonical package name so the dead import can't side-effect-load the
|
|
28
|
+
socket helper into the server bundle.
|
|
29
|
+
*/
|
|
30
|
+
const importNames =
|
|
31
|
+
importName === beltePackageName ? [beltePackageName] : [importName, beltePackageName]
|
|
32
|
+
const stripped = importNames.reduce(
|
|
33
|
+
(current, name) => stripImport(current, `${name}/server/socket`),
|
|
34
|
+
source,
|
|
35
|
+
)
|
|
22
36
|
const site = findExportCallSite(stripped, (ident) => ident === 'socket', SINGLE_EXPORT_ERROR)
|
|
23
37
|
if (!site) {
|
|
24
38
|
return undefined
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/*
|
|
2
|
-
Translates a prompt file path under `src/
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
2
|
+
Translates a prompt file path under `src/mcp/prompts/` into the prompt's
|
|
3
|
+
MCP name. Strips `.md` and joins nested folder segments with `-` (e.g.
|
|
4
|
+
`code/review.md` → `code-review`) so two prompts with the same stem in
|
|
5
|
+
different folders don't collide and the name stays a single valid MCP
|
|
6
6
|
prompt identifier.
|
|
7
7
|
*/
|
|
8
8
|
export function promptNameForFile(relativePath: string): string {
|
|
9
|
-
return relativePath.replace(/\.
|
|
9
|
+
return relativePath.replace(/\.md$/, '').replaceAll('/', '-')
|
|
10
10
|
}
|
|
@@ -24,7 +24,7 @@ mirroring the plain `fn(args)` decode path.
|
|
|
24
24
|
*/
|
|
25
25
|
function streamResponse<T>(response: Response): AsyncIterable<T> {
|
|
26
26
|
if (!response.ok) {
|
|
27
|
-
return errorIterable(new HttpError(response))
|
|
27
|
+
return errorIterable<T>(new HttpError(response))
|
|
28
28
|
}
|
|
29
29
|
const contentType = (response.headers.get('content-type') ?? '').toLowerCase()
|
|
30
30
|
if (contentType.startsWith('text/event-stream')) {
|
|
@@ -36,25 +36,9 @@ function streamResponse<T>(response: Response): AsyncIterable<T> {
|
|
|
36
36
|
return oneShot<T>(response)
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
let done = false
|
|
43
|
-
return {
|
|
44
|
-
async next() {
|
|
45
|
-
if (done) {
|
|
46
|
-
return { value: undefined, done: true }
|
|
47
|
-
}
|
|
48
|
-
done = true
|
|
49
|
-
throw error
|
|
50
|
-
},
|
|
51
|
-
async return() {
|
|
52
|
-
done = true
|
|
53
|
-
return { value: undefined, done: true }
|
|
54
|
-
},
|
|
55
|
-
}
|
|
56
|
-
},
|
|
57
|
-
}
|
|
39
|
+
/* Surfaces a non-2xx response (or any pre-stream failure) as a thrown error on the first pull. */
|
|
40
|
+
async function* errorIterable<T>(error: Error): AsyncGenerator<T> {
|
|
41
|
+
throw error
|
|
58
42
|
}
|
|
59
43
|
|
|
60
44
|
/*
|
|
@@ -64,120 +48,84 @@ completes. Makes `fn.stream(args)` symmetrical across streaming and
|
|
|
64
48
|
non-streaming handlers — callers can pick the iteration shape without
|
|
65
49
|
worrying about which body the handler returned.
|
|
66
50
|
*/
|
|
67
|
-
function oneShot<T>(response: Response):
|
|
68
|
-
|
|
69
|
-
[Symbol.asyncIterator](): AsyncIterator<T, void, undefined> {
|
|
70
|
-
let yielded = false
|
|
71
|
-
return {
|
|
72
|
-
async next() {
|
|
73
|
-
if (yielded) {
|
|
74
|
-
return { value: undefined, done: true }
|
|
75
|
-
}
|
|
76
|
-
yielded = true
|
|
77
|
-
const value = (await decodeResponse(response)) as T
|
|
78
|
-
return { value, done: false }
|
|
79
|
-
},
|
|
80
|
-
async return() {
|
|
81
|
-
yielded = true
|
|
82
|
-
return { value: undefined, done: true }
|
|
83
|
-
},
|
|
84
|
-
}
|
|
85
|
-
},
|
|
86
|
-
}
|
|
51
|
+
async function* oneShot<T>(response: Response): AsyncGenerator<T> {
|
|
52
|
+
yield (await decodeResponse(response)) as T
|
|
87
53
|
}
|
|
88
54
|
|
|
89
55
|
/*
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
56
|
+
Reads a streaming text Response and yields raw frame strings split on
|
|
57
|
+
`delimiter` (`\n\n` for SSE events, `\n` for JSON lines). Owns the whole
|
|
58
|
+
buffering lifecycle: incremental decode, amortised-O(n) compaction, a
|
|
59
|
+
final flush of the trailing partial frame, and reader cancellation when
|
|
60
|
+
the consumer stops iterating (the generator's `finally` runs on
|
|
61
|
+
`return()`). The SSE and jsonl parsers layer their per-frame parsing on
|
|
62
|
+
top of this single machine so the two can't drift.
|
|
96
63
|
*/
|
|
97
|
-
function
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
while (pending.length === 0 && !done) {
|
|
112
|
-
const { value, done: streamDone } = await reader.read()
|
|
113
|
-
if (streamDone) {
|
|
114
|
-
done = true
|
|
115
|
-
if (bufferStart < buffer.length) {
|
|
116
|
-
const frame = parseFrame(buffer.slice(bufferStart))
|
|
117
|
-
if (frame) {
|
|
118
|
-
pending.push(frame)
|
|
119
|
-
}
|
|
120
|
-
buffer = ''
|
|
121
|
-
bufferStart = 0
|
|
122
|
-
}
|
|
123
|
-
return
|
|
124
|
-
}
|
|
125
|
-
/*
|
|
126
|
-
Compact only when the unread region is small relative to
|
|
127
|
-
the consumed prefix — keeps amortised work O(n) instead
|
|
128
|
-
of quadratic slicing per frame boundary.
|
|
129
|
-
*/
|
|
130
|
-
if (bufferStart > buffer.length / 2) {
|
|
131
|
-
buffer = buffer.slice(bufferStart) + value
|
|
132
|
-
bufferStart = 0
|
|
133
|
-
} else {
|
|
134
|
-
buffer += value
|
|
135
|
-
}
|
|
136
|
-
let boundary = buffer.indexOf('\n\n', bufferStart)
|
|
137
|
-
while (boundary !== -1) {
|
|
138
|
-
const raw = buffer.slice(bufferStart, boundary)
|
|
139
|
-
bufferStart = boundary + 2
|
|
140
|
-
const frame = parseFrame(raw)
|
|
141
|
-
if (frame) {
|
|
142
|
-
pending.push(frame)
|
|
143
|
-
}
|
|
144
|
-
boundary = buffer.indexOf('\n\n', bufferStart)
|
|
145
|
-
}
|
|
64
|
+
async function* frameReader(response: Response, delimiter: string): AsyncGenerator<string> {
|
|
65
|
+
const body = response.body
|
|
66
|
+
if (!body) {
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
const reader = body.pipeThrough(new TextDecoderStream()).getReader()
|
|
70
|
+
let buffer = ''
|
|
71
|
+
let bufferStart = 0
|
|
72
|
+
try {
|
|
73
|
+
while (true) {
|
|
74
|
+
const { value, done } = await reader.read()
|
|
75
|
+
if (done) {
|
|
76
|
+
if (bufferStart < buffer.length) {
|
|
77
|
+
yield buffer.slice(bufferStart)
|
|
146
78
|
}
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
/*
|
|
82
|
+
Compact only when the unread region is small relative to the
|
|
83
|
+
consumed prefix — keeps amortised work O(n) instead of
|
|
84
|
+
quadratic slicing per frame boundary.
|
|
85
|
+
*/
|
|
86
|
+
if (bufferStart > buffer.length / 2) {
|
|
87
|
+
buffer = buffer.slice(bufferStart) + value
|
|
88
|
+
bufferStart = 0
|
|
89
|
+
} else {
|
|
90
|
+
buffer += value
|
|
147
91
|
}
|
|
92
|
+
let boundary = buffer.indexOf(delimiter, bufferStart)
|
|
93
|
+
while (boundary !== -1) {
|
|
94
|
+
yield buffer.slice(bufferStart, boundary)
|
|
95
|
+
bufferStart = boundary + delimiter.length
|
|
96
|
+
boundary = buffer.indexOf(delimiter, bufferStart)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
} finally {
|
|
100
|
+
await reader.cancel().catch(() => undefined)
|
|
101
|
+
}
|
|
102
|
+
}
|
|
148
103
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
await pullFrames()
|
|
172
|
-
}
|
|
173
|
-
},
|
|
174
|
-
async return() {
|
|
175
|
-
done = true
|
|
176
|
-
await reader.cancel().catch(() => undefined)
|
|
177
|
-
return { value: undefined, done: true }
|
|
178
|
-
},
|
|
104
|
+
/*
|
|
105
|
+
SSE parser: yields the JSON-parsed `data` payload of each `event:`/`data:`
|
|
106
|
+
frame. The `sse()` respond helper emits an `event: error\ndata:
|
|
107
|
+
{"message":...}` frame when the source generator throws, which we surface
|
|
108
|
+
as a thrown Error so consumer loops can react to mid-stream failure
|
|
109
|
+
rather than silently stopping.
|
|
110
|
+
*/
|
|
111
|
+
async function* parseSse<T>(response: Response): AsyncGenerator<T> {
|
|
112
|
+
for await (const raw of frameReader(response, '\n\n')) {
|
|
113
|
+
const frame = parseFrame(raw)
|
|
114
|
+
if (!frame) {
|
|
115
|
+
continue
|
|
116
|
+
}
|
|
117
|
+
if (frame.event === 'error') {
|
|
118
|
+
try {
|
|
119
|
+
const decoded = JSON.parse(frame.data) as { message?: string }
|
|
120
|
+
throw new Error(decoded?.message ?? 'sse stream error')
|
|
121
|
+
} catch (err) {
|
|
122
|
+
if (err instanceof SyntaxError) {
|
|
123
|
+
throw new Error(frame.data || 'sse stream error')
|
|
124
|
+
}
|
|
125
|
+
throw err
|
|
179
126
|
}
|
|
180
|
-
}
|
|
127
|
+
}
|
|
128
|
+
yield JSON.parse(frame.data) as T
|
|
181
129
|
}
|
|
182
130
|
}
|
|
183
131
|
|
|
@@ -205,96 +153,22 @@ function parseFrame(raw: string): { event: string; data: string } | undefined {
|
|
|
205
153
|
}
|
|
206
154
|
|
|
207
155
|
/*
|
|
208
|
-
JSONL/NDJSON parser:
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
156
|
+
JSONL/NDJSON parser: parses each non-empty line as JSON and yields the
|
|
157
|
+
value. The `jsonl()` respond helper emits a trailing
|
|
158
|
+
`{"$error":"<message>"}` line when the source generator throws — that's
|
|
159
|
+
surfaced here as a thrown Error so consumer loops can react to mid-stream
|
|
160
|
+
failure.
|
|
213
161
|
*/
|
|
214
|
-
function parseJsonLines<T>(response: Response):
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
const pending: string[] = []
|
|
225
|
-
let done = false
|
|
226
|
-
|
|
227
|
-
async function pullLines(): Promise<void> {
|
|
228
|
-
while (pending.length === 0 && !done) {
|
|
229
|
-
const { value, done: streamDone } = await reader.read()
|
|
230
|
-
if (streamDone) {
|
|
231
|
-
done = true
|
|
232
|
-
if (bufferStart < buffer.length) {
|
|
233
|
-
pending.push(buffer.slice(bufferStart))
|
|
234
|
-
buffer = ''
|
|
235
|
-
bufferStart = 0
|
|
236
|
-
}
|
|
237
|
-
return
|
|
238
|
-
}
|
|
239
|
-
if (bufferStart > buffer.length / 2) {
|
|
240
|
-
buffer = buffer.slice(bufferStart) + value
|
|
241
|
-
bufferStart = 0
|
|
242
|
-
} else {
|
|
243
|
-
buffer += value
|
|
244
|
-
}
|
|
245
|
-
let newline = buffer.indexOf('\n', bufferStart)
|
|
246
|
-
while (newline !== -1) {
|
|
247
|
-
const line = buffer.slice(bufferStart, newline)
|
|
248
|
-
bufferStart = newline + 1
|
|
249
|
-
if (line.length > 0) {
|
|
250
|
-
pending.push(line)
|
|
251
|
-
}
|
|
252
|
-
newline = buffer.indexOf('\n', bufferStart)
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
return {
|
|
258
|
-
async next() {
|
|
259
|
-
while (true) {
|
|
260
|
-
if (pending.length > 0) {
|
|
261
|
-
const line = pending.shift() as string
|
|
262
|
-
const parsed = JSON.parse(line) as Record<string, unknown> & {
|
|
263
|
-
$error?: string
|
|
264
|
-
}
|
|
265
|
-
if (
|
|
266
|
-
parsed &&
|
|
267
|
-
typeof parsed === 'object' &&
|
|
268
|
-
typeof parsed.$error === 'string'
|
|
269
|
-
) {
|
|
270
|
-
throw new Error(parsed.$error)
|
|
271
|
-
}
|
|
272
|
-
return { value: parsed as T, done: false }
|
|
273
|
-
}
|
|
274
|
-
if (done) {
|
|
275
|
-
return { value: undefined, done: true }
|
|
276
|
-
}
|
|
277
|
-
await pullLines()
|
|
278
|
-
}
|
|
279
|
-
},
|
|
280
|
-
async return() {
|
|
281
|
-
done = true
|
|
282
|
-
await reader.cancel().catch(() => undefined)
|
|
283
|
-
return { value: undefined, done: true }
|
|
284
|
-
},
|
|
285
|
-
}
|
|
286
|
-
},
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
function emptyIterator<T>(): AsyncIterator<T, void, undefined> {
|
|
291
|
-
return {
|
|
292
|
-
async next() {
|
|
293
|
-
return { value: undefined, done: true }
|
|
294
|
-
},
|
|
295
|
-
async return() {
|
|
296
|
-
return { value: undefined, done: true }
|
|
297
|
-
},
|
|
162
|
+
async function* parseJsonLines<T>(response: Response): AsyncGenerator<T> {
|
|
163
|
+
for await (const raw of frameReader(response, '\n')) {
|
|
164
|
+
if (raw.length === 0) {
|
|
165
|
+
continue
|
|
166
|
+
}
|
|
167
|
+
const parsed = JSON.parse(raw) as Record<string, unknown> & { $error?: string }
|
|
168
|
+
if (parsed && typeof parsed === 'object' && typeof parsed.$error === 'string') {
|
|
169
|
+
throw new Error(parsed.$error)
|
|
170
|
+
}
|
|
171
|
+
yield parsed as T
|
|
298
172
|
}
|
|
299
173
|
}
|
|
300
174
|
|
|
@@ -315,15 +189,30 @@ export function subscribableFromResponse<T>(
|
|
|
315
189
|
name,
|
|
316
190
|
[Symbol.asyncIterator]() {
|
|
317
191
|
let inner: AsyncIterator<T, void, undefined> | undefined
|
|
192
|
+
let cancelled = false
|
|
318
193
|
return {
|
|
319
194
|
async next() {
|
|
195
|
+
if (cancelled) {
|
|
196
|
+
return { value: undefined, done: true }
|
|
197
|
+
}
|
|
320
198
|
if (!inner) {
|
|
321
199
|
const response = await fetchResponse()
|
|
322
200
|
inner = streamResponse<T>(response)[Symbol.asyncIterator]()
|
|
201
|
+
/*
|
|
202
|
+
If return() landed while we were awaiting the
|
|
203
|
+
fetch, `inner` was still undefined then so its
|
|
204
|
+
reader was never cancelled — release the body now
|
|
205
|
+
rather than leaving the HTTP stream open.
|
|
206
|
+
*/
|
|
207
|
+
if (cancelled) {
|
|
208
|
+
await inner.return?.(undefined)
|
|
209
|
+
return { value: undefined, done: true }
|
|
210
|
+
}
|
|
323
211
|
}
|
|
324
212
|
return inner.next()
|
|
325
213
|
},
|
|
326
214
|
async return() {
|
|
215
|
+
cancelled = true
|
|
327
216
|
await inner?.return?.(undefined)
|
|
328
217
|
return { value: undefined, done: true }
|
|
329
218
|
},
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/*
|
|
2
|
+
A single declared argument of a markdown prompt, parsed from the file's
|
|
3
|
+
YAML frontmatter `arguments:` list. `name` is the placeholder the body
|
|
4
|
+
interpolates via `{{name}}`; `description` + `required` feed the argument
|
|
5
|
+
list MCP advertises in `prompts/list`. Build-time only — markdown prompts
|
|
6
|
+
carry no runtime schema object, so this drives the generated JSON Schema.
|
|
7
|
+
*/
|
|
8
|
+
export type PromptArgument = {
|
|
9
|
+
name: string
|
|
10
|
+
description?: string
|
|
11
|
+
required?: boolean
|
|
12
|
+
}
|