@briancray/belte 0.3.1 → 0.4.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 (46) hide show
  1. package/package.json +1 -1
  2. package/src/bundleApp.ts +12 -2
  3. package/src/discoveryEntry.ts +58 -11
  4. package/src/lib/browser/cache.ts +29 -6
  5. package/src/lib/browser/startClient.ts +24 -1
  6. package/src/lib/bundle/onMenu.ts +20 -5
  7. package/src/lib/bundle/openWebview.ts +9 -2
  8. package/src/lib/bundle/signMacApp.ts +35 -0
  9. package/src/lib/cli/createClient.ts +65 -27
  10. package/src/lib/cli/runCli.ts +37 -15
  11. package/src/lib/cli/types/CliManifestEntry.ts +7 -2
  12. package/src/lib/mcp/annotationsForMethod.ts +29 -0
  13. package/src/lib/mcp/createMcpServer.ts +10 -8
  14. package/src/lib/mcp/dispatchMcpRequest.ts +115 -33
  15. package/src/lib/mcp/toolResultFromResponse.ts +66 -0
  16. package/src/lib/server/jsonl.ts +2 -1
  17. package/src/lib/server/rpc/defineVerb.ts +30 -17
  18. package/src/lib/server/rpc/parseArgs.ts +2 -1
  19. package/src/lib/server/rpc/types/VerbHelper.ts +19 -11
  20. package/src/lib/server/rpc/types/VerbRegistryEntry.ts +14 -6
  21. package/src/lib/server/runtime/buildOpenApiSpec.ts +17 -6
  22. package/src/lib/server/runtime/createPublicAssetServer.ts +17 -6
  23. package/src/lib/server/runtime/createServer.ts +37 -9
  24. package/src/lib/server/runtime/globToPathSet.ts +29 -0
  25. package/src/lib/server/runtime/logBrowserOnlyRoutes.ts +44 -0
  26. package/src/lib/server/sockets/createSocketDispatcher.ts +71 -8
  27. package/src/lib/server/sockets/defineSocket.ts +7 -1
  28. package/src/lib/server/sockets/recentHistory.ts +11 -0
  29. package/src/lib/server/sockets/socketOperations.ts +35 -0
  30. package/src/lib/server/sockets/types/SocketOperation.ts +22 -0
  31. package/src/lib/server/sse.ts +2 -1
  32. package/src/lib/shared/buildRpcRequest.ts +2 -1
  33. package/src/lib/shared/carriesBodyArgs.ts +13 -0
  34. package/src/lib/shared/isReadOnlyMethod.ts +14 -0
  35. package/src/lib/shared/isStreamingResponse.ts +11 -0
  36. package/src/lib/shared/jsonlErrorFrame.ts +24 -0
  37. package/src/lib/shared/keyForRemoteCall.ts +2 -1
  38. package/src/lib/shared/resolveClientFlags.ts +8 -6
  39. package/src/lib/shared/responseErrorText.ts +9 -0
  40. package/src/lib/shared/sseErrorFrame.ts +29 -0
  41. package/src/lib/shared/streamResponse.ts +168 -0
  42. package/src/lib/shared/subscribableFromResponse.ts +1 -172
  43. package/src/lib/shared/types/CacheEntry.ts +6 -0
  44. package/template/src/bundle/icon.png +0 -0
  45. package/template/src/server/rpc/getHello.ts +5 -3
  46. package/src/lib/shared/belteImportName.test.ts +0 -58
@@ -1,177 +1,6 @@
1
- import { HttpError } from '../server/HttpError.ts'
2
- import { decodeResponse } from './decodeResponse.ts'
3
- import { STREAMING_CONTENT_TYPES } from './streamingContentTypes.ts'
1
+ import { streamResponse } from './streamResponse.ts'
4
2
  import type { Subscribable } from './types/Subscribable.ts'
5
3
 
6
- /*
7
- Turns a Response into an AsyncIterable of frames. Used by
8
- `fn.stream(args)` to give callers a uniform iterator regardless of the
9
- handler's chosen body format. Three shapes are handled:
10
-
11
- - text/event-stream (SSE): emits the JSON-parsed `data:` payload of
12
- each event. The `event: error\ndata: {message}` frame the `sse()`
13
- helper emits on generator throws is mapped back to a thrown Error so
14
- consumers see the failure mid-iteration.
15
- - application/jsonl + application/x-ndjson: emits one JSON value per
16
- line. The trailing `{"$error":"..."}` line the `jsonl()` helper
17
- emits on generator throws is likewise re-thrown.
18
- - everything else: one-shot — yields the Content-Type-decoded body
19
- once, then completes. Lets `fn.stream(args)` work uniformly on every
20
- rpc handler, not just the streaming ones.
21
-
22
- Non-2xx responses surface as a thrown HttpError on the first pull,
23
- mirroring the plain `fn(args)` decode path.
24
- */
25
- function streamResponse<T>(response: Response): AsyncIterable<T> {
26
- if (!response.ok) {
27
- return errorIterable<T>(new HttpError(response))
28
- }
29
- const contentType = (response.headers.get('content-type') ?? '').toLowerCase()
30
- if (contentType.startsWith('text/event-stream')) {
31
- return parseSse<T>(response)
32
- }
33
- if (STREAMING_CONTENT_TYPES.some((type) => contentType.startsWith(type))) {
34
- return parseJsonLines<T>(response)
35
- }
36
- return oneShot<T>(response)
37
- }
38
-
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
42
- }
43
-
44
- /*
45
- One-shot iterator over a non-streaming Response: decodes the body once
46
- via the same Content-Type sniffing the plain call uses, yields it, then
47
- completes. Makes `fn.stream(args)` symmetrical across streaming and
48
- non-streaming handlers — callers can pick the iteration shape without
49
- worrying about which body the handler returned.
50
- */
51
- async function* oneShot<T>(response: Response): AsyncGenerator<T> {
52
- yield (await decodeResponse(response)) as T
53
- }
54
-
55
- /*
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.
63
- */
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)
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
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
- }
103
-
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
126
- }
127
- }
128
- yield JSON.parse(frame.data) as T
129
- }
130
- }
131
-
132
- function parseFrame(raw: string): { event: string; data: string } | undefined {
133
- const lines = raw.split('\n').filter((line) => line.length > 0 && !line.startsWith(':'))
134
- if (lines.length === 0) {
135
- return undefined
136
- }
137
- let event = 'message'
138
- const dataLines: string[] = []
139
- for (const line of lines) {
140
- const colon = line.indexOf(':')
141
- const field = colon === -1 ? line : line.slice(0, colon)
142
- const value = colon === -1 ? '' : line.slice(colon + 1).replace(/^ /, '')
143
- if (field === 'event') {
144
- event = value
145
- } else if (field === 'data') {
146
- dataLines.push(value)
147
- }
148
- }
149
- if (dataLines.length === 0) {
150
- return undefined
151
- }
152
- return { event, data: dataLines.join('\n') }
153
- }
154
-
155
- /*
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.
161
- */
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
172
- }
173
- }
174
-
175
4
  /*
176
5
  Builds the Subscribable returned by `fn.stream(args)`. The carried
177
6
  `name` is the cache-style key for (method, url, args) so subscribe()
@@ -6,6 +6,11 @@ the function. `ttl`/`expiresAt` drive eviction: expiresAt = undefined means
6
6
  soon as the promise settles). The stored promise resolves to the raw
7
7
  Response so the snapshot can read its status/headers/body; the cache
8
8
  layer hands callers a decoded view derived from this same promise.
9
+
10
+ `value` is set only for entries hydrated from the SSR snapshot: the
11
+ snapshot body is pre-decoded synchronously so the first client render can
12
+ read it without a microtask hop and byte-match the SSR DOM. Live fetches
13
+ leave it undefined and take the async decode path.
9
14
  */
10
15
  export type CacheEntry = {
11
16
  key: string
@@ -13,4 +18,5 @@ export type CacheEntry = {
13
18
  request: Request
14
19
  ttl: number | undefined
15
20
  expiresAt: number | undefined
21
+ value?: unknown
16
22
  }
Binary file
@@ -12,9 +12,11 @@ decoding) is inferred from the handler's return type via the
12
12
  `TypedResponse<T>` brand on `json`/`error`/`redirect`/`jsonl`/`sse`, so
13
13
  plain `GET(() => json({...}))` already types end-to-end.
14
14
 
15
- For inbound validation pass a Standard Schema-compatible schema as the
16
- second argument: `GET(fn, { schema })`. Args then infers from the schema's
17
- output type and the server replies with 422 on validation failure.
15
+ For inbound validation pass a Standard Schema as `inputSchema` in the
16
+ second argument: `GET(fn, { inputSchema })`. Args then infers from the
17
+ schema's output type and the server replies with 422 on validation
18
+ failure. An optional `outputSchema` describes the success body for the
19
+ OpenAPI 200 response and the MCP tool output.
18
20
 
19
21
  `json(...)` from `belte/server/json` is a thin wrapper over `Response.json`
20
22
  that defaults `Cache-Control: no-store`, since intermediary caches shouldn't
@@ -1,58 +0,0 @@
1
- import { afterAll, expect, test } from 'bun:test'
2
- import { mkdtempSync, rmSync } from 'node:fs'
3
- import { tmpdir } from 'node:os'
4
- import { belteImportName } from './belteImportName.ts'
5
-
6
- const roots: string[] = []
7
- afterAll(() => roots.forEach((root) => rmSync(root, { recursive: true, force: true })))
8
-
9
- // Writes a package.json into a fresh temp dir and returns the dir.
10
- async function projectWith(packageJson: unknown): Promise<string> {
11
- const root = mkdtempSync(`${tmpdir()}/belte-import-name-`)
12
- roots.push(root)
13
- await Bun.write(`${root}/package.json`, JSON.stringify(packageJson))
14
- return root
15
- }
16
-
17
- test('uses the canonical name for a direct dependency', async () => {
18
- const cwd = await projectWith({ dependencies: { '@briancray/belte': '^0.2.0' } })
19
- expect(await belteImportName(cwd)).toBe('@briancray/belte')
20
- })
21
-
22
- test('uses the `belte` alias key for an npm alias', async () => {
23
- const cwd = await projectWith({ dependencies: { belte: 'npm:@briancray/belte@^0.2.0' } })
24
- expect(await belteImportName(cwd)).toBe('belte')
25
- })
26
-
27
- test('uses the `belte` alias key for a workspace alias', async () => {
28
- const cwd = await projectWith({ dependencies: { belte: 'workspace:@briancray/belte@*' } })
29
- expect(await belteImportName(cwd)).toBe('belte')
30
- })
31
-
32
- test('uses a non-`belte` alias key when that is how belte is declared', async () => {
33
- const cwd = await projectWith({ dependencies: { framework: 'npm:@briancray/belte' } })
34
- expect(await belteImportName(cwd)).toBe('framework')
35
- })
36
-
37
- test('prefers the `belte` alias over a direct canonical dependency', async () => {
38
- const cwd = await projectWith({
39
- dependencies: { '@briancray/belte': '^0.2.0', belte: 'npm:@briancray/belte@^0.2.0' },
40
- })
41
- expect(await belteImportName(cwd)).toBe('belte')
42
- })
43
-
44
- test('finds the alias in devDependencies', async () => {
45
- const cwd = await projectWith({ devDependencies: { belte: 'npm:@briancray/belte@^0.2.0' } })
46
- expect(await belteImportName(cwd)).toBe('belte')
47
- })
48
-
49
- test('falls back to the canonical name when belte is absent', async () => {
50
- const cwd = await projectWith({ dependencies: { svelte: '^5.0.0' } })
51
- expect(await belteImportName(cwd)).toBe('@briancray/belte')
52
- })
53
-
54
- test('falls back to the canonical name when package.json is missing', async () => {
55
- const root = mkdtempSync(`${tmpdir()}/belte-import-name-`)
56
- roots.push(root)
57
- expect(await belteImportName(root)).toBe('@briancray/belte')
58
- })