@briancray/belte 0.1.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 (178) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +49 -0
  3. package/bin/belte.ts +136 -0
  4. package/package.json +80 -0
  5. package/src/App.svelte +31 -0
  6. package/src/assets/app.html +14 -0
  7. package/src/belteResolverPlugin.ts +832 -0
  8. package/src/build.ts +144 -0
  9. package/src/buildCli.ts +160 -0
  10. package/src/cliEntry.ts +31 -0
  11. package/src/clientEntry.ts +7 -0
  12. package/src/compile.ts +64 -0
  13. package/src/devEntry.ts +33 -0
  14. package/src/discoveryEntry.ts +33 -0
  15. package/src/lib/browser/cache.ts +191 -0
  16. package/src/lib/browser/page.svelte.ts +215 -0
  17. package/src/lib/browser/remoteProxy.ts +44 -0
  18. package/src/lib/browser/socketChannel.ts +182 -0
  19. package/src/lib/browser/socketProxy.ts +64 -0
  20. package/src/lib/browser/startClient.ts +132 -0
  21. package/src/lib/browser/subscribe.ts +131 -0
  22. package/src/lib/browser/types/Layouts.ts +7 -0
  23. package/src/lib/browser/types/Pages.ts +7 -0
  24. package/src/lib/cli/createClient.ts +126 -0
  25. package/src/lib/cli/loadEnvFromBinaryDir.ts +44 -0
  26. package/src/lib/cli/parseArgvForRpc.ts +97 -0
  27. package/src/lib/cli/printHelp.ts +70 -0
  28. package/src/lib/cli/runCli.ts +88 -0
  29. package/src/lib/cli/types/CliManifest.ts +9 -0
  30. package/src/lib/cli/types/CliManifestEntry.ts +12 -0
  31. package/src/lib/mcp/createMcpResourceServer.ts +101 -0
  32. package/src/lib/mcp/createMcpServer.ts +40 -0
  33. package/src/lib/mcp/dispatchMcpRequest.ts +294 -0
  34. package/src/lib/mcp/mcpResourceServerSlot.ts +18 -0
  35. package/src/lib/mcp/types/JsonRpcRequest.ts +12 -0
  36. package/src/lib/mcp/types/JsonRpcResponse.ts +20 -0
  37. package/src/lib/mcp/types/McpResourceContents.ts +10 -0
  38. package/src/lib/mcp/types/McpResourceDescriptor.ts +6 -0
  39. package/src/lib/mcp/types/McpResourceServer.ts +12 -0
  40. package/src/lib/mcp/types/McpServer.ts +9 -0
  41. package/src/lib/mcp/types/McpServerOptions.ts +16 -0
  42. package/src/lib/server/AppModule.ts +25 -0
  43. package/src/lib/server/DELETE.ts +9 -0
  44. package/src/lib/server/GET.ts +9 -0
  45. package/src/lib/server/HEAD.ts +9 -0
  46. package/src/lib/server/HttpError.ts +19 -0
  47. package/src/lib/server/PATCH.ts +9 -0
  48. package/src/lib/server/POST.ts +9 -0
  49. package/src/lib/server/PUT.ts +9 -0
  50. package/src/lib/server/cli/buildEnvContent.ts +18 -0
  51. package/src/lib/server/cli/createTarGz.ts +76 -0
  52. package/src/lib/server/cli/handleCliDownload.ts +124 -0
  53. package/src/lib/server/cli/handleCliInstall.ts +20 -0
  54. package/src/lib/server/cli/installScript.ts +29 -0
  55. package/src/lib/server/cli/maxSourceMtime.ts +27 -0
  56. package/src/lib/server/error.ts +56 -0
  57. package/src/lib/server/json.ts +28 -0
  58. package/src/lib/server/jsonl.ts +40 -0
  59. package/src/lib/server/prompt.ts +30 -0
  60. package/src/lib/server/prompts/definePrompt.ts +20 -0
  61. package/src/lib/server/prompts/promptRegistry.ts +9 -0
  62. package/src/lib/server/prompts/registerPrompt.ts +6 -0
  63. package/src/lib/server/prompts/types/Prompt.ts +14 -0
  64. package/src/lib/server/prompts/types/PromptMessage.ts +10 -0
  65. package/src/lib/server/prompts/types/PromptOptions.ts +17 -0
  66. package/src/lib/server/prompts/types/PromptRegistryEntry.ts +15 -0
  67. package/src/lib/server/prompts/types/PromptRoutes.ts +10 -0
  68. package/src/lib/server/redirect.ts +37 -0
  69. package/src/lib/server/request.ts +18 -0
  70. package/src/lib/server/rpc/defineVerb.ts +103 -0
  71. package/src/lib/server/rpc/parseArgs.ts +60 -0
  72. package/src/lib/server/rpc/registerVerb.ts +6 -0
  73. package/src/lib/server/rpc/types/HttpVerb.ts +1 -0
  74. package/src/lib/server/rpc/types/RawRemoteFunction.ts +13 -0
  75. package/src/lib/server/rpc/types/RemoteFunction.ts +35 -0
  76. package/src/lib/server/rpc/types/RemoteHandler.ts +22 -0
  77. package/src/lib/server/rpc/types/RemoteRoutes.ts +13 -0
  78. package/src/lib/server/rpc/types/StandardSchemaV1.ts +57 -0
  79. package/src/lib/server/rpc/types/TypedResponse.ts +18 -0
  80. package/src/lib/server/rpc/types/VerbHelper.ts +39 -0
  81. package/src/lib/server/rpc/types/VerbRegistryEntry.ts +17 -0
  82. package/src/lib/server/rpc/unprocessed.ts +14 -0
  83. package/src/lib/server/rpc/verbRegistry.ts +11 -0
  84. package/src/lib/server/runtime/buildOpenApiSpec.ts +66 -0
  85. package/src/lib/server/runtime/cacheControlForAsset.ts +17 -0
  86. package/src/lib/server/runtime/containsTraversal.ts +37 -0
  87. package/src/lib/server/runtime/createPublicAssetServer.ts +66 -0
  88. package/src/lib/server/runtime/createServer.ts +555 -0
  89. package/src/lib/server/runtime/getActiveServer.ts +6 -0
  90. package/src/lib/server/runtime/mimeForExtension.ts +20 -0
  91. package/src/lib/server/runtime/registryManifests.ts +48 -0
  92. package/src/lib/server/runtime/requestContext.ts +5 -0
  93. package/src/lib/server/runtime/safeJsonForScript.ts +17 -0
  94. package/src/lib/server/runtime/serializeCacheSnapshot.ts +84 -0
  95. package/src/lib/server/runtime/serverSlot.ts +13 -0
  96. package/src/lib/server/runtime/setActiveServer.ts +6 -0
  97. package/src/lib/server/runtime/streamFromIterator.ts +76 -0
  98. package/src/lib/server/runtime/types/Assets.ts +1 -0
  99. package/src/lib/server/runtime/types/CompileTarget.ts +6 -0
  100. package/src/lib/server/runtime/types/RequestStore.ts +15 -0
  101. package/src/lib/server/runtime/types/SvelteConfig.ts +5 -0
  102. package/src/lib/server/server.ts +19 -0
  103. package/src/lib/server/socket.ts +31 -0
  104. package/src/lib/server/sockets/createSocketDispatcher.ts +267 -0
  105. package/src/lib/server/sockets/defineSocket.ts +160 -0
  106. package/src/lib/server/sockets/lookupSocket.ts +6 -0
  107. package/src/lib/server/sockets/registerSocket.ts +6 -0
  108. package/src/lib/server/sockets/socketRegistry.ts +9 -0
  109. package/src/lib/server/sockets/types/Socket.ts +21 -0
  110. package/src/lib/server/sockets/types/SocketClientFrame.ts +18 -0
  111. package/src/lib/server/sockets/types/SocketOptions.ts +22 -0
  112. package/src/lib/server/sockets/types/SocketRegistryEntry.ts +18 -0
  113. package/src/lib/server/sockets/types/SocketRoutes.ts +10 -0
  114. package/src/lib/server/sockets/types/SocketServerFrame.ts +15 -0
  115. package/src/lib/server/sse.ts +47 -0
  116. package/src/lib/shared/activeCacheStore.ts +20 -0
  117. package/src/lib/shared/buildRpcRequest.ts +61 -0
  118. package/src/lib/shared/cacheControlValues.ts +8 -0
  119. package/src/lib/shared/cacheStoreSlot.ts +16 -0
  120. package/src/lib/shared/canonicalJson.ts +24 -0
  121. package/src/lib/shared/commandNameForUrl.ts +17 -0
  122. package/src/lib/shared/createCacheStore.ts +42 -0
  123. package/src/lib/shared/createPushIterator.ts +77 -0
  124. package/src/lib/shared/createRemoteFunction.ts +89 -0
  125. package/src/lib/shared/decodeResponse.ts +47 -0
  126. package/src/lib/shared/detectTarget.ts +27 -0
  127. package/src/lib/shared/findExportCallSite.ts +479 -0
  128. package/src/lib/shared/forwardHeaders.ts +28 -0
  129. package/src/lib/shared/getRemoteMeta.ts +5 -0
  130. package/src/lib/shared/isDebugEnabled.ts +23 -0
  131. package/src/lib/shared/jsonSchemaForSchema.ts +38 -0
  132. package/src/lib/shared/keyForRemoteCall.ts +38 -0
  133. package/src/lib/shared/loadSvelteConfig.ts +18 -0
  134. package/src/lib/shared/log.ts +104 -0
  135. package/src/lib/shared/nearestLayoutPrefix.ts +36 -0
  136. package/src/lib/shared/normalizeTarget.ts +10 -0
  137. package/src/lib/shared/pageUrlForFile.ts +14 -0
  138. package/src/lib/shared/parseRouteSegments.ts +22 -0
  139. package/src/lib/shared/preparePromptModule.ts +36 -0
  140. package/src/lib/shared/prepareRpcModule.ts +51 -0
  141. package/src/lib/shared/prepareSocketModule.ts +37 -0
  142. package/src/lib/shared/programNameForPackage.ts +14 -0
  143. package/src/lib/shared/promptNameForFile.ts +10 -0
  144. package/src/lib/shared/recordRemoteMeta.ts +5 -0
  145. package/src/lib/shared/remoteMetaStore.ts +16 -0
  146. package/src/lib/shared/resolveClientFlags.ts +18 -0
  147. package/src/lib/shared/rpcUrlForFile.ts +19 -0
  148. package/src/lib/shared/setCacheStoreResolver.ts +6 -0
  149. package/src/lib/shared/socketNameForFile.ts +11 -0
  150. package/src/lib/shared/streamingContentTypes.ts +11 -0
  151. package/src/lib/shared/stripImport.ts +27 -0
  152. package/src/lib/shared/subscribableFromResponse.ts +333 -0
  153. package/src/lib/shared/toBunRoutePattern.ts +28 -0
  154. package/src/lib/shared/types/CacheEntry.ts +16 -0
  155. package/src/lib/shared/types/CacheOptions.ts +10 -0
  156. package/src/lib/shared/types/CacheSnapshotEntry.ts +15 -0
  157. package/src/lib/shared/types/CacheStore.ts +15 -0
  158. package/src/lib/shared/types/ClientFlags.ts +11 -0
  159. package/src/lib/shared/types/Subscribable.ts +15 -0
  160. package/src/lib/shared/writeRoutesDts.ts +64 -0
  161. package/src/preload.ts +20 -0
  162. package/src/scaffold.ts +92 -0
  163. package/src/serverEntry.ts +47 -0
  164. package/src/sveltePlugin.ts +58 -0
  165. package/src/tailwindStylePreprocessor.ts +62 -0
  166. package/template/package.json +16 -0
  167. package/template/src/app.ts +23 -0
  168. package/template/src/browser/app.css +21 -0
  169. package/template/src/browser/app.html +24 -0
  170. package/template/src/browser/pages/about/page.svelte +5 -0
  171. package/template/src/browser/pages/layout.svelte +26 -0
  172. package/template/src/browser/pages/page.svelte +20 -0
  173. package/template/src/cli/banner.txt +3 -0
  174. package/template/src/cli/footer.txt +1 -0
  175. package/template/src/server/rpc/getHello.ts +33 -0
  176. package/template/svelte.config.js +12 -0
  177. package/template/tsconfig.json +18 -0
  178. package/tsconfig.app.json +16 -0
@@ -0,0 +1,18 @@
1
+ /*
2
+ Generates the `.env` file content shipped alongside the CLI binary in
3
+ the download tarball. APP_URL is always present (derived from the
4
+ inbound request's origin); APP_TOKEN is included only when the inbound
5
+ request carried an Authorization: Bearer header, so an authenticated
6
+ download bakes the caller's credential into the binary's env.
7
+
8
+ Tokens forward verbatim — the framework doesn't issue or refresh; the
9
+ user's auth code at the actual RPC endpoints validates whatever value
10
+ arrives back in subsequent calls.
11
+ */
12
+ export function buildEnvContent(appUrl: string, bearerToken: string | undefined): string {
13
+ const lines = [`APP_URL=${appUrl}`]
14
+ if (bearerToken) {
15
+ lines.push(`APP_TOKEN=${bearerToken}`)
16
+ }
17
+ return `${lines.join('\n')}\n`
18
+ }
@@ -0,0 +1,76 @@
1
+ /*
2
+ Minimal ustar tarball writer. Each entry is a 512-byte header followed
3
+ by the content padded to a 512-byte boundary; the archive ends with two
4
+ 512-byte zero blocks. After assembly the buffer is gzipped via
5
+ Bun.gzipSync — no external `tar` invocation, no extra deps.
6
+
7
+ Format constraints:
8
+ - File names ≤ 100 bytes (we never write longer paths).
9
+ - Numeric fields are zero-padded octal ASCII strings (POSIX rule).
10
+ - Checksum is the sum of all header bytes treating the checksum
11
+ field as spaces; encoded as 6 octal digits + NUL + space.
12
+ */
13
+
14
+ type TarEntry = {
15
+ name: string
16
+ content: Uint8Array
17
+ mode?: number
18
+ }
19
+
20
+ const BLOCK = 512
21
+ const ENC = new TextEncoder()
22
+
23
+ function writeString(buf: Uint8Array, offset: number, length: number, value: string): void {
24
+ const bytes = ENC.encode(value)
25
+ buf.set(bytes.subarray(0, Math.min(bytes.length, length)), offset)
26
+ }
27
+
28
+ function writeOctal(buf: Uint8Array, offset: number, length: number, value: number): void {
29
+ // length-1 octal digits + trailing NUL
30
+ const oct = value.toString(8).padStart(length - 1, '0')
31
+ writeString(buf, offset, length - 1, oct)
32
+ buf[offset + length - 1] = 0
33
+ }
34
+
35
+ function buildHeader(entry: TarEntry): Uint8Array {
36
+ const header = new Uint8Array(BLOCK)
37
+ writeString(header, 0, 100, entry.name)
38
+ writeOctal(header, 100, 8, entry.mode ?? 0o644)
39
+ writeOctal(header, 108, 8, 0)
40
+ writeOctal(header, 116, 8, 0)
41
+ writeOctal(header, 124, 12, entry.content.length)
42
+ writeOctal(header, 136, 12, Math.floor(Date.now() / 1000))
43
+ header.fill(0x20, 148, 156)
44
+ header[156] = 0x30 // '0' = regular file
45
+ writeString(header, 257, 6, 'ustar\0')
46
+ writeString(header, 263, 2, '00')
47
+ // Checksum: sum of all bytes with checksum field treated as spaces.
48
+ let sum = 0
49
+ for (let index = 0; index < BLOCK; index++) {
50
+ sum += header[index] ?? 0
51
+ }
52
+ writeOctal(header, 148, 7, sum)
53
+ header[155] = 0x20 // trailing space after checksum digits
54
+ return header
55
+ }
56
+
57
+ /*
58
+ Builds a gzipped tarball from the given entries and returns the bytes.
59
+ Sized eagerly (sum of headers + padded contents + 2 trailing blocks).
60
+ */
61
+ export function createTarGz(entries: TarEntry[]): Uint8Array {
62
+ let totalSize = BLOCK * 2 // trailing zero blocks
63
+ for (const entry of entries) {
64
+ totalSize += BLOCK
65
+ totalSize += Math.ceil(entry.content.length / BLOCK) * BLOCK
66
+ }
67
+ const tar = new Uint8Array(totalSize)
68
+ let offset = 0
69
+ for (const entry of entries) {
70
+ tar.set(buildHeader(entry), offset)
71
+ offset += BLOCK
72
+ tar.set(entry.content, offset)
73
+ offset += Math.ceil(entry.content.length / BLOCK) * BLOCK
74
+ }
75
+ return Bun.gzipSync(tar)
76
+ }
@@ -0,0 +1,124 @@
1
+ import { existsSync, statSync } from 'node:fs'
2
+ import { NO_STORE } from '../../shared/cacheControlValues.ts'
3
+ import { log } from '../../shared/log.ts'
4
+ import { normalizeTarget } from '../../shared/normalizeTarget.ts'
5
+ import { buildEnvContent } from './buildEnvContent.ts'
6
+ import { createTarGz } from './createTarGz.ts'
7
+ import { maxSourceMtime } from './maxSourceMtime.ts'
8
+
9
+ /*
10
+ Process-wide per-platform build coalescing. Two concurrent curls for
11
+ the same /__belte/cli/<platform> share one promise; the later requests
12
+ await the same one the first installed. The promise both runs the
13
+ freshness check AND the rebuild, so the map insertion is synchronous
14
+ relative to the first request's entry into the function — no window
15
+ between an `await` and `pendingBuilds.set` for a second concurrent
16
+ request to slip through and fire its own buildCli against the same
17
+ output paths.
18
+ */
19
+ const pendingBuilds = new Map<string, Promise<string | undefined>>()
20
+
21
+ async function ensurePlatformBinary(
22
+ platform: string,
23
+ programName: string,
24
+ cwd: string,
25
+ ): Promise<string | undefined> {
26
+ const existing = pendingBuilds.get(platform)
27
+ if (existing) {
28
+ return existing
29
+ }
30
+ const promise = computeBinary(platform, programName, cwd)
31
+ pendingBuilds.set(platform, promise)
32
+ /*
33
+ Drop the entry after settlement so a later request rebuilds if the
34
+ source has changed again. Identity-guard so a still-pending entry
35
+ installed by a follow-up request isn't evicted by ours.
36
+ */
37
+ promise.finally(() => {
38
+ if (pendingBuilds.get(platform) === promise) {
39
+ pendingBuilds.delete(platform)
40
+ }
41
+ })
42
+ return promise
43
+ }
44
+
45
+ async function computeBinary(
46
+ platform: string,
47
+ programName: string,
48
+ cwd: string,
49
+ ): Promise<string | undefined> {
50
+ const binaryPath = `${cwd}/dist/cli-thin/${platform}/${programName}`
51
+ /*
52
+ On-disk binary is fresh when it exists AND its mtime beats the
53
+ newest rpc/socket source mtime. The mtime check catches the
54
+ common dev iteration where the user edits an rpc handler but
55
+ didn't run `belte cli` again. Other source paths (project lib,
56
+ transitive imports) fall back to manual rebuild.
57
+ */
58
+ if (existsSync(binaryPath)) {
59
+ const binaryMtime = statSync(binaryPath).mtimeMs
60
+ const sourceMtime = await maxSourceMtime(cwd)
61
+ if (binaryMtime >= sourceMtime) {
62
+ return binaryPath
63
+ }
64
+ log.info(`thin cli for ${platform} is stale — rebuilding`)
65
+ }
66
+ try {
67
+ log.info(`lazy-building thin cli for ${platform}…`)
68
+ // Lazy-import buildCli so the build pipeline isn't pulled into
69
+ // production processes that never serve a download.
70
+ const { buildCli } = await import('../../../buildCli.ts')
71
+ await buildCli({
72
+ cwd,
73
+ platforms: [normalizeTarget(platform)],
74
+ thin: true,
75
+ })
76
+ return existsSync(binaryPath) ? binaryPath : undefined
77
+ } catch (error) {
78
+ log.error(error)
79
+ return undefined
80
+ }
81
+ }
82
+
83
+ /*
84
+ Handles GET /__belte/cli/<platform> — streams a gzipped tarball
85
+ containing the platform-specific thin binary + a `.env` carrying
86
+ APP_URL (and APP_TOKEN if the inbound request was authenticated).
87
+
88
+ Thin binaries live at `dist/cli-thin/<platform>/<programName>`
89
+ (produced by `belte cli` with APP_URL set). Missing platforms produce
90
+ 404 — the install script reports it, doesn't try to fall back.
91
+ */
92
+ export async function handleCliDownload(
93
+ request: Request,
94
+ platform: string,
95
+ programName: string,
96
+ cwd: string,
97
+ ): Promise<Response> {
98
+ const binaryPath = await ensurePlatformBinary(platform, programName, cwd)
99
+ if (!binaryPath) {
100
+ return new Response(`unknown platform: ${platform}`, {
101
+ status: 404,
102
+ headers: { 'Cache-Control': NO_STORE },
103
+ })
104
+ }
105
+ const url = new URL(request.url)
106
+ const appUrl = `${url.protocol}//${url.host}`
107
+ const auth = request.headers.get('authorization')
108
+ const bearer =
109
+ auth && auth.toLowerCase().startsWith('bearer ') ? auth.slice('bearer '.length) : undefined
110
+ const envContent = buildEnvContent(appUrl, bearer)
111
+
112
+ const binaryBytes = await Bun.file(binaryPath).bytes()
113
+ const archive = createTarGz([
114
+ { name: programName, content: binaryBytes, mode: 0o755 },
115
+ { name: '.env', content: new TextEncoder().encode(envContent), mode: 0o644 },
116
+ ])
117
+ return new Response(archive, {
118
+ headers: {
119
+ 'Content-Type': 'application/gzip',
120
+ 'Content-Disposition': `attachment; filename="${programName}-${platform}.tar.gz"`,
121
+ 'Cache-Control': NO_STORE,
122
+ },
123
+ })
124
+ }
@@ -0,0 +1,20 @@
1
+ import { NO_STORE } from '../../shared/cacheControlValues.ts'
2
+ import { installScript } from './installScript.ts'
3
+
4
+ /*
5
+ Handles GET /__belte/cli — returns the platform-detecting shell script.
6
+ Authoritative URL for the tarball is derived from the inbound request
7
+ (so the script's curl line points at whatever host the user reached us
8
+ on). Program name is the bundler-emitted `belte:cli-name` value.
9
+ */
10
+ export function handleCliInstall(request: Request, programName: string): Response {
11
+ const url = new URL(request.url)
12
+ const appUrl = `${url.protocol}//${url.host}`
13
+ const script = installScript(appUrl, programName)
14
+ return new Response(script, {
15
+ headers: {
16
+ 'Content-Type': 'text/x-shellscript; charset=utf-8',
17
+ 'Cache-Control': NO_STORE,
18
+ },
19
+ })
20
+ }
@@ -0,0 +1,29 @@
1
+ /*
2
+ The shell script returned by `GET /__belte/cli` (no platform). Detects
3
+ uname OS + arch, normalises common arch aliases, then curls the right
4
+ platform-specific tarball and extracts it into `$BELTE_INSTALL_DIR`
5
+ (default `~/.local/bin`). The tarball already contains the `.env` next
6
+ to the binary — no separate config write step in the script.
7
+
8
+ The script is rendered server-side so `<APP_URL>` is the request's own
9
+ origin and the embedded curl URL needs no escaping or quoting beyond
10
+ basic shell hygiene.
11
+ */
12
+ export function installScript(appUrl: string, programName: string): string {
13
+ return `#!/usr/bin/env sh
14
+ set -e
15
+ OS=$(uname -s | tr '[:upper:]' '[:lower:]')
16
+ case "$(uname -m)" in
17
+ x86_64|amd64) ARCH=x64 ;;
18
+ aarch64|arm64) ARCH=arm64 ;;
19
+ *) echo "unsupported architecture: $(uname -m)" >&2 ; exit 1 ;;
20
+ esac
21
+ INSTALL_DIR="\${BELTE_INSTALL_DIR:-$HOME/.local/bin}"
22
+ mkdir -p "$INSTALL_DIR"
23
+ URL="${appUrl.replace(/\/$/, '')}/__belte/cli/\${OS}-\${ARCH}"
24
+ echo "installing ${programName} from $URL into $INSTALL_DIR"
25
+ curl -fsSL "$URL" | tar -xz -C "$INSTALL_DIR"
26
+ echo "installed: $INSTALL_DIR/${programName}"
27
+ echo "ensure $INSTALL_DIR is in your PATH"
28
+ `
29
+ }
@@ -0,0 +1,27 @@
1
+ import { existsSync, statSync } from 'node:fs'
2
+ import { Glob } from 'bun'
3
+
4
+ /*
5
+ Returns the most-recent mtime across every rpc + socket source file in
6
+ the project, or 0 when both directories are absent. The lazy CLI
7
+ download path compares this to the binary's mtime to decide whether to
8
+ rebuild — covers the common dev iteration of "user edited an rpc
9
+ handler" without needing to scan transitively-imported modules.
10
+ */
11
+ export async function maxSourceMtime(cwd: string): Promise<number> {
12
+ const roots = [`${cwd}/src/server/rpc`, `${cwd}/src/server/sockets`]
13
+ let newest = 0
14
+ for (const root of roots) {
15
+ if (!existsSync(root)) {
16
+ continue
17
+ }
18
+ const files = new Glob('**/*.ts').scan({ cwd: root, onlyFiles: true })
19
+ for await (const file of files) {
20
+ const stat = statSync(`${root}/${file}`)
21
+ if (stat.mtimeMs > newest) {
22
+ newest = stat.mtimeMs
23
+ }
24
+ }
25
+ }
26
+ return newest
27
+ }
@@ -0,0 +1,56 @@
1
+ import { NO_STORE } from '../shared/cacheControlValues.ts'
2
+ import type { TypedResponse } from './rpc/types/TypedResponse.ts'
3
+
4
+ /*
5
+ Plain-text error Response — clearer than constructing a Response by
6
+ hand with a status and a text body, and shaped so the client's
7
+ HttpError carries the message verbatim (`HttpError.response.text()`
8
+ returns the message, no parsing).
9
+
10
+ if (!order) return error(404, 'order not found')
11
+
12
+ `message` defaults to the status's standard reason phrase when
13
+ omitted (e.g. `error(404)` body = 'Not Found'). The body is
14
+ text/plain so intermediaries don't try to render or sniff it.
15
+
16
+ To short-circuit a handler instead of returning, `throw new Error(...)`
17
+ or `throw new HttpError(error(...))` — the framework's `app.handleError`
18
+ hook catches thrown errors. This helper deliberately returns a Response
19
+ rather than throwing one so a single `return error(...)` is the
20
+ expected pattern, with the same control flow as `return json(...)`.
21
+ */
22
+ const STATUS_TEXT: Record<number, string> = {
23
+ 400: 'Bad Request',
24
+ 401: 'Unauthorized',
25
+ 403: 'Forbidden',
26
+ 404: 'Not Found',
27
+ 405: 'Method Not Allowed',
28
+ 409: 'Conflict',
29
+ 410: 'Gone',
30
+ 422: 'Unprocessable Content',
31
+ 429: 'Too Many Requests',
32
+ 500: 'Internal Server Error',
33
+ 501: 'Not Implemented',
34
+ 502: 'Bad Gateway',
35
+ 503: 'Service Unavailable',
36
+ 504: 'Gateway Timeout',
37
+ }
38
+
39
+ /*
40
+ Body type is `never` because `error()` only travels the non-2xx path on
41
+ the wire — the caller's `await fn(args)` throws `HttpError` and never
42
+ resolves to this response's body. Returning a TypedResponse<never> lets
43
+ the union of branches in a handler narrow to whatever the success
44
+ branch carries (`TypedResponse<{user}> | TypedResponse<never>` → Return
45
+ = {user}).
46
+ */
47
+ export function error(status: number, message?: string): TypedResponse<never> {
48
+ const body = message ?? STATUS_TEXT[status] ?? `HTTP ${status}`
49
+ return new Response(body, {
50
+ status,
51
+ headers: {
52
+ 'Content-Type': 'text/plain; charset=utf-8',
53
+ 'Cache-Control': NO_STORE,
54
+ },
55
+ }) as TypedResponse<never>
56
+ }
@@ -0,0 +1,28 @@
1
+ import { NO_STORE } from '../shared/cacheControlValues.ts'
2
+ import type { TypedResponse } from './rpc/types/TypedResponse.ts'
3
+
4
+ /*
5
+ JSON Response with rpc-friendly defaults — same shape as
6
+ `Response.json(data, init)`, except `Cache-Control: no-store` is set
7
+ unless the caller overrides it. Intermediary caches (browsers, CDNs,
8
+ shared proxies) shouldn't cache rpc replies by default; the framework's
9
+ own per-request cache handles in-process dedupe.
10
+
11
+ export const getOrder = GET<{ id: string }>(async ({ id }) =>
12
+ json(await db.getOrder(id)),
13
+ )
14
+
15
+ The return type carries `T` as a phantom brand so the verb helper can
16
+ infer the caller-facing `Return` from the handler body — no need to
17
+ annotate `GET<Args, Return>` just to type the response shape.
18
+
19
+ For non-default cache policy pass `init.headers`; explicit
20
+ `cache-control` wins over the default.
21
+ */
22
+ export function json<T>(data: T, init?: ResponseInit): TypedResponse<T> {
23
+ const headers = new Headers(init?.headers)
24
+ if (!headers.has('cache-control')) {
25
+ headers.set('cache-control', NO_STORE)
26
+ }
27
+ return Response.json(data, { ...init, headers }) as TypedResponse<T>
28
+ }
@@ -0,0 +1,40 @@
1
+ /*
2
+ Wraps an AsyncIterable<Frame> in a Response whose body is JSON Lines
3
+ (application/jsonl) — one JSON value per line, terminated by `\n`. Used
4
+ inside an rpc handler to turn a generator into a streaming HTTP response
5
+ that `subscribe(fn.stream)(args)` consumes frame-by-frame on the client.
6
+
7
+ export const orderFeed = GET<Args>((args) =>
8
+ jsonl(async function* () {
9
+ for await (const order of db.watchOrders(args)) yield order
10
+ }())
11
+ )
12
+
13
+ Cancellation flows from the consumer through ReadableStream's `cancel`
14
+ into `iter.return()` so the handler's `for await` exits via its normal
15
+ control path (DB cursors, file handles, etc. get to release in finally).
16
+
17
+ Errors thrown by the generator are emitted as a final
18
+ `{"$error":"<message>"}` line before the stream closes. The convention
19
+ keeps the format JSON-safe and lets the consumer distinguish "stream
20
+ ended cleanly" from "handler threw" without a side-channel. The full
21
+ error is logged server-side via the framework's error handler — only the
22
+ message crosses the wire.
23
+ */
24
+ import { NO_STORE } from '../shared/cacheControlValues.ts'
25
+ import type { TypedResponse } from './rpc/types/TypedResponse.ts'
26
+ import { streamFromIterator } from './runtime/streamFromIterator.ts'
27
+
28
+ export function jsonl<Frame>(iterable: AsyncIterable<Frame>): TypedResponse<Frame> {
29
+ const body = streamFromIterator(iterable, {
30
+ encodeFrame: (value) => `${JSON.stringify(value)}\n`,
31
+ encodeError: (message) => `${JSON.stringify({ $error: message })}\n`,
32
+ })
33
+ return new Response(body, {
34
+ headers: {
35
+ 'Content-Type': 'application/jsonl; charset=utf-8',
36
+ 'Cache-Control': NO_STORE,
37
+ 'X-Content-Type-Options': 'nosniff',
38
+ },
39
+ }) as TypedResponse<Frame>
40
+ }
@@ -0,0 +1,30 @@
1
+ import type { Prompt } from './prompts/types/Prompt.ts'
2
+ import type { PromptOptions } from './prompts/types/PromptOptions.ts'
3
+ import type { StandardSchemaV1 } from './rpc/types/StandardSchemaV1.ts'
4
+
5
+ /*
6
+ Declares an MCP prompt inside a file under `src/server/prompts/`. Each
7
+ file contains exactly one export, named after the file (e.g.
8
+ `summarize.ts` → `export const summarize = prompt(...)`). The bundler
9
+ reads the export name from the filename and the prompt name from the file
10
+ path under `src/server/prompts/`, then rewrites this call to bind the name
11
+ into definePrompt.
12
+
13
+ `render(args)` returns the messages MCP hands back for `prompts/get`:
14
+ either a bare string (one user message) or an explicit message array.
15
+ When `schema` is set, `Args` infers from `InferOutput<Schema>`, incoming
16
+ arguments validate against it, and MCP advertises the argument list in
17
+ `prompts/list`.
18
+
19
+ This function exists only for the type signature; calling it directly
20
+ means the bundler plugin didn't process the file, which throws.
21
+ */
22
+ export function prompt<Schema extends StandardSchemaV1>(
23
+ opts: PromptOptions<StandardSchemaV1.InferOutput<Schema>> & { schema: Schema },
24
+ ): Prompt<StandardSchemaV1.InferOutput<Schema>>
25
+ export function prompt<Args = Record<string, string>>(opts: PromptOptions<Args>): Prompt<Args>
26
+ export function prompt(_opts: PromptOptions): Prompt {
27
+ throw new Error(
28
+ '[belte] `prompt(...)` was called outside a prompts module — the prompt helper is only valid as the value of `export const <filename> = ...` inside a file under src/server/prompts/',
29
+ )
30
+ }
@@ -0,0 +1,20 @@
1
+ import { registerPrompt } from './registerPrompt.ts'
2
+ import type { Prompt } from './types/Prompt.ts'
3
+ import type { PromptOptions } from './types/PromptOptions.ts'
4
+
5
+ /*
6
+ Builds a Prompt from a name + options. The bundler rewrites every
7
+ `export const NAME = prompt(opts)` inside `src/server/prompts/<file>.ts`
8
+ into `__belteDefinePrompt__("<name>", opts)` so the file path becomes the
9
+ prompt's identity. Registers itself so the MCP dispatcher can enumerate
10
+ and render it.
11
+ */
12
+ export function definePrompt(name: string, opts: PromptOptions): Prompt {
13
+ const self: Prompt = {
14
+ name,
15
+ description: opts.description,
16
+ render: opts.render,
17
+ }
18
+ registerPrompt({ prompt: self, schema: opts.schema, jsonSchema: opts.jsonSchema })
19
+ return self
20
+ }
@@ -0,0 +1,9 @@
1
+ import type { PromptRegistryEntry } from './types/PromptRegistryEntry.ts'
2
+
3
+ /*
4
+ Process-wide registry of every prompt declared in the app. definePrompt
5
+ inserts on first construction (eagerly when the registry loader walks the
6
+ prompts manifest at MCP boot). The MCP server reads this to build its
7
+ `prompts/list` + `prompts/get` responses.
8
+ */
9
+ export const promptRegistry = new Map<string, PromptRegistryEntry>()
@@ -0,0 +1,6 @@
1
+ import { promptRegistry } from './promptRegistry.ts'
2
+ import type { PromptRegistryEntry } from './types/PromptRegistryEntry.ts'
3
+
4
+ export function registerPrompt(entry: PromptRegistryEntry): void {
5
+ promptRegistry.set(entry.prompt.name, entry)
6
+ }
@@ -0,0 +1,14 @@
1
+ import type { PromptMessage } from './PromptMessage.ts'
2
+
3
+ /*
4
+ An MCP prompt declared once with `prompt(opts)` inside a file under
5
+ `src/server/prompts/`. The bundler stamps in the `name` from the file
6
+ path; `render(args)` produces the messages returned by `prompts/get`.
7
+ Prompts are MCP-only — there is no client-side counterpart, so the
8
+ shape carries no ClientFlags.
9
+ */
10
+ export type Prompt<Args = Record<string, string>> = {
11
+ readonly name: string
12
+ readonly description: string | undefined
13
+ render(args: Args): PromptMessage[] | string | Promise<PromptMessage[] | string>
14
+ }
@@ -0,0 +1,10 @@
1
+ /*
2
+ A single message in an MCP prompt's rendered output. `prompt({ render })`
3
+ returns either a bare string (sugar for one `user` message) or an array
4
+ of these. The dispatcher maps each into the MCP `prompts/get` wire shape
5
+ ({ role, content: { type: 'text', text } }).
6
+ */
7
+ export type PromptMessage = {
8
+ role: 'user' | 'assistant'
9
+ text: string
10
+ }
@@ -0,0 +1,17 @@
1
+ import type { StandardSchemaV1 } from '../../rpc/types/StandardSchemaV1.ts'
2
+ import type { PromptMessage } from './PromptMessage.ts'
3
+
4
+ /*
5
+ Server-side options passed when declaring a prompt via `prompt(opts)`.
6
+ MCP prompts are read-only templates: `render(args)` turns the caller's
7
+ arguments into one or more chat messages. The optional Standard Schema
8
+ both validates incoming arguments and supplies the argument list MCP
9
+ advertises in `prompts/list` (top-level properties + required array).
10
+ All of this is server-only — prompts are never imported by client code.
11
+ */
12
+ export type PromptOptions<Args = Record<string, string>> = {
13
+ description?: string
14
+ schema?: StandardSchemaV1
15
+ jsonSchema?: Record<string, unknown>
16
+ render: (args: Args) => PromptMessage[] | string | Promise<PromptMessage[] | string>
17
+ }
@@ -0,0 +1,15 @@
1
+ import type { StandardSchemaV1 } from '../../rpc/types/StandardSchemaV1.ts'
2
+ import type { Prompt } from './Prompt.ts'
3
+
4
+ /*
5
+ Per-prompt registry record. The MCP dispatcher enumerates this to build
6
+ `prompts/list` (description + arguments from the schema) and to dispatch
7
+ `prompts/get` (validate args against the schema, then render). Schema +
8
+ jsonSchema stay off the public Prompt shape so the render closure isn't
9
+ burdened with metadata it never reads.
10
+ */
11
+ export type PromptRegistryEntry = {
12
+ prompt: Prompt
13
+ schema: StandardSchemaV1 | undefined
14
+ jsonSchema: Record<string, unknown> | undefined
15
+ }
@@ -0,0 +1,10 @@
1
+ import type { Prompt } from './Prompt.ts'
2
+
3
+ /*
4
+ Manifest of prompt-name → module loader. Produced by the resolver plugin
5
+ from each `.ts` under src/server/prompts/. Each module has exactly one
6
+ named export, a Prompt whose `.name` was stamped in by the bundler
7
+ rewrite. The registry loader imports every module once so the MCP
8
+ dispatcher can enumerate the full prompt surface.
9
+ */
10
+ export type PromptRoutes = Record<string, () => Promise<Record<string, Prompt>>>
@@ -0,0 +1,37 @@
1
+ /*
2
+ Redirect Response with belte-friendly ergonomics — accepts relative
3
+ URLs (the platform's `Response.redirect` throws on them), defaults to
4
+ 302, and matches the helper-style call site of `json`/`error` for
5
+ visual consistency inside a handler.
6
+
7
+ return redirect('/login') // 302 to /login
8
+ return redirect('/articles/1', 301) // permanent
9
+ return redirect(externalUrl, 307) // preserve method (POST stays POST)
10
+
11
+ Status guidance:
12
+ - 301 — moved permanently (cacheable; browsers may swap method to GET)
13
+ - 302 — found / temporary (default; browsers may swap method to GET)
14
+ - 303 — "after a POST, GET this" (forces GET on the follow-up)
15
+ - 307 — temporary, preserve method
16
+ - 308 — permanent, preserve method
17
+ */
18
+ import { NO_STORE } from '../shared/cacheControlValues.ts'
19
+ import type { TypedResponse } from './rpc/types/TypedResponse.ts'
20
+
21
+ type RedirectStatus = 301 | 302 | 303 | 307 | 308
22
+
23
+ /*
24
+ Return type is `TypedResponse<never>` for the same reason `error()` is —
25
+ the wire response is a 3xx with no body the caller resolves to, so it
26
+ must not pollute the inferred `Return` of a route that conditionally
27
+ redirects vs returns json.
28
+ */
29
+ export function redirect(url: string, status: RedirectStatus = 302): TypedResponse<never> {
30
+ return new Response(null, {
31
+ status,
32
+ headers: {
33
+ Location: url,
34
+ 'Cache-Control': NO_STORE,
35
+ },
36
+ }) as TypedResponse<never>
37
+ }
@@ -0,0 +1,18 @@
1
+ import { requestContext } from './runtime/requestContext.ts'
2
+
3
+ /*
4
+ Returns the inbound Request for the current SSR/RPC pass. Implemented as an
5
+ AsyncLocalStorage lookup over the per-request store the server installs at
6
+ the fetch boundary. Throws if called outside a request scope (e.g. from
7
+ top-level module code or from app.ts init) — silent undefined would mask
8
+ the misuse.
9
+ */
10
+ export function request(): Request {
11
+ const store = requestContext.getStore()
12
+ if (!store) {
13
+ throw new Error(
14
+ '[belte] request() called outside a request scope — it only resolves while an SSR render or rpc handler is in flight',
15
+ )
16
+ }
17
+ return store.req
18
+ }