@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.
Files changed (103) 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 +236 -202
  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/belteImportName.test.ts +58 -0
  82. package/src/lib/shared/belteImportName.ts +45 -0
  83. package/src/lib/shared/beltePackageName.ts +7 -0
  84. package/src/lib/shared/cacheControlValues.ts +10 -2
  85. package/src/lib/shared/canonicalJson.ts +1 -5
  86. package/src/lib/shared/createCacheStore.ts +29 -20
  87. package/src/lib/shared/exitOnBuildFailure.ts +17 -0
  88. package/src/lib/shared/fileStem.ts +9 -0
  89. package/src/lib/shared/jsonSchemaForPromptArguments.ts +29 -0
  90. package/src/lib/shared/keyForRemoteCall.ts +7 -5
  91. package/src/lib/shared/parsePromptMarkdown.ts +34 -0
  92. package/src/lib/shared/prepareRpcModule.ts +14 -4
  93. package/src/lib/shared/prepareSocketModule.ts +16 -2
  94. package/src/lib/shared/promptNameForFile.ts +5 -5
  95. package/src/lib/shared/subscribableFromResponse.ts +104 -215
  96. package/src/lib/shared/types/PromptArgument.ts +12 -0
  97. package/src/lib/shared/writeRoutesDts.ts +5 -7
  98. package/src/serverBuildPlugins.ts +25 -0
  99. package/src/serverEntry.ts +4 -0
  100. package/template/package.json +3 -2
  101. package/src/lib/server/prompt.ts +0 -30
  102. package/src/lib/server/prompts/types/PromptMessage.ts +0 -10
  103. 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 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
+ }
@@ -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. Sorted key/value pairs are walked once
10
- and concatenated directly so the hot GET-cache path doesn't allocate per
11
- intermediate (entries / filtered / URLSearchParams).
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(source: string): PreparedRpcModule | undefined {
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 stripped = VERB_IMPORT_PATHS.reduce((current, path) => stripImport(current, path), source)
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(source: string): PreparedSocketModule | undefined {
21
- const stripped = stripImport(source, 'belte/server/socket')
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/server/prompts/` into the
3
- prompt's MCP name. Strips `.ts` and joins nested folder segments with `-`
4
- (e.g. `code/review.ts` → `code-review`) so two prompts with the same stem
5
- in different folders don't collide and the name stays a single valid MCP
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(/\.ts$/, '').replaceAll('/', '-')
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
- function errorIterable<T>(error: Error): AsyncIterable<T> {
40
- return {
41
- [Symbol.asyncIterator](): AsyncIterator<T, void, undefined> {
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): AsyncIterable<T> {
68
- return {
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
- SSE parser: reads the response body as text frames separated by blank
91
- lines, splits each frame into `event:` / `data:` lines, and yields the
92
- JSON-parsed data payload. The `sse()` respond helper emits an
93
- `event: error\ndata: {"message":...}` frame when the source generator
94
- throws, which we surface as a thrown Error so consumer loops can
95
- surface mid-stream failure rather than silently stopping.
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 parseSse<T>(response: Response): AsyncIterable<T> {
98
- return {
99
- [Symbol.asyncIterator](): AsyncIterator<T, void, undefined> {
100
- const body = response.body
101
- if (!body) {
102
- return emptyIterator<T>()
103
- }
104
- const reader = body.pipeThrough(new TextDecoderStream()).getReader()
105
- let buffer = ''
106
- let bufferStart = 0
107
- const pending: Array<{ event: string; data: string }> = []
108
- let done = false
109
-
110
- async function pullFrames(): Promise<void> {
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
- return {
150
- async next() {
151
- while (true) {
152
- if (pending.length > 0) {
153
- const next = pending.shift() as { event: string; data: string }
154
- if (next.event === 'error') {
155
- try {
156
- const decoded = JSON.parse(next.data) as { message?: string }
157
- throw new Error(decoded?.message ?? 'sse stream error')
158
- } catch (err) {
159
- if (err instanceof SyntaxError) {
160
- throw new Error(next.data || 'sse stream error')
161
- }
162
- throw err
163
- }
164
- }
165
- const value = JSON.parse(next.data) as T
166
- return { value, done: false }
167
- }
168
- if (done) {
169
- return { value: undefined, done: true }
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: reads the response body as text, splits on `\n`,
209
- parses each non-empty line as JSON, and yields the value. The `jsonl()`
210
- respond helper emits a trailing `{"$error":"<message>"}` line when the
211
- source generator throws — that's surfaced here as a thrown Error so
212
- consumer loops can react to mid-stream failure.
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): AsyncIterable<T> {
215
- return {
216
- [Symbol.asyncIterator](): AsyncIterator<T, void, undefined> {
217
- const body = response.body
218
- if (!body) {
219
- return emptyIterator<T>()
220
- }
221
- const reader = body.pipeThrough(new TextDecoderStream()).getReader()
222
- let buffer = ''
223
- let bufferStart = 0
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
+ }