@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.
- package/LICENSE +21 -0
- package/README.md +49 -0
- package/bin/belte.ts +136 -0
- package/package.json +80 -0
- package/src/App.svelte +31 -0
- package/src/assets/app.html +14 -0
- package/src/belteResolverPlugin.ts +832 -0
- package/src/build.ts +144 -0
- package/src/buildCli.ts +160 -0
- package/src/cliEntry.ts +31 -0
- package/src/clientEntry.ts +7 -0
- package/src/compile.ts +64 -0
- package/src/devEntry.ts +33 -0
- package/src/discoveryEntry.ts +33 -0
- package/src/lib/browser/cache.ts +191 -0
- package/src/lib/browser/page.svelte.ts +215 -0
- package/src/lib/browser/remoteProxy.ts +44 -0
- package/src/lib/browser/socketChannel.ts +182 -0
- package/src/lib/browser/socketProxy.ts +64 -0
- package/src/lib/browser/startClient.ts +132 -0
- package/src/lib/browser/subscribe.ts +131 -0
- package/src/lib/browser/types/Layouts.ts +7 -0
- package/src/lib/browser/types/Pages.ts +7 -0
- package/src/lib/cli/createClient.ts +126 -0
- package/src/lib/cli/loadEnvFromBinaryDir.ts +44 -0
- package/src/lib/cli/parseArgvForRpc.ts +97 -0
- package/src/lib/cli/printHelp.ts +70 -0
- package/src/lib/cli/runCli.ts +88 -0
- package/src/lib/cli/types/CliManifest.ts +9 -0
- package/src/lib/cli/types/CliManifestEntry.ts +12 -0
- package/src/lib/mcp/createMcpResourceServer.ts +101 -0
- package/src/lib/mcp/createMcpServer.ts +40 -0
- package/src/lib/mcp/dispatchMcpRequest.ts +294 -0
- package/src/lib/mcp/mcpResourceServerSlot.ts +18 -0
- package/src/lib/mcp/types/JsonRpcRequest.ts +12 -0
- package/src/lib/mcp/types/JsonRpcResponse.ts +20 -0
- package/src/lib/mcp/types/McpResourceContents.ts +10 -0
- package/src/lib/mcp/types/McpResourceDescriptor.ts +6 -0
- package/src/lib/mcp/types/McpResourceServer.ts +12 -0
- package/src/lib/mcp/types/McpServer.ts +9 -0
- package/src/lib/mcp/types/McpServerOptions.ts +16 -0
- package/src/lib/server/AppModule.ts +25 -0
- package/src/lib/server/DELETE.ts +9 -0
- package/src/lib/server/GET.ts +9 -0
- package/src/lib/server/HEAD.ts +9 -0
- package/src/lib/server/HttpError.ts +19 -0
- package/src/lib/server/PATCH.ts +9 -0
- package/src/lib/server/POST.ts +9 -0
- package/src/lib/server/PUT.ts +9 -0
- package/src/lib/server/cli/buildEnvContent.ts +18 -0
- package/src/lib/server/cli/createTarGz.ts +76 -0
- package/src/lib/server/cli/handleCliDownload.ts +124 -0
- package/src/lib/server/cli/handleCliInstall.ts +20 -0
- package/src/lib/server/cli/installScript.ts +29 -0
- package/src/lib/server/cli/maxSourceMtime.ts +27 -0
- package/src/lib/server/error.ts +56 -0
- package/src/lib/server/json.ts +28 -0
- package/src/lib/server/jsonl.ts +40 -0
- package/src/lib/server/prompt.ts +30 -0
- package/src/lib/server/prompts/definePrompt.ts +20 -0
- package/src/lib/server/prompts/promptRegistry.ts +9 -0
- package/src/lib/server/prompts/registerPrompt.ts +6 -0
- package/src/lib/server/prompts/types/Prompt.ts +14 -0
- package/src/lib/server/prompts/types/PromptMessage.ts +10 -0
- package/src/lib/server/prompts/types/PromptOptions.ts +17 -0
- package/src/lib/server/prompts/types/PromptRegistryEntry.ts +15 -0
- package/src/lib/server/prompts/types/PromptRoutes.ts +10 -0
- package/src/lib/server/redirect.ts +37 -0
- package/src/lib/server/request.ts +18 -0
- package/src/lib/server/rpc/defineVerb.ts +103 -0
- package/src/lib/server/rpc/parseArgs.ts +60 -0
- package/src/lib/server/rpc/registerVerb.ts +6 -0
- package/src/lib/server/rpc/types/HttpVerb.ts +1 -0
- package/src/lib/server/rpc/types/RawRemoteFunction.ts +13 -0
- package/src/lib/server/rpc/types/RemoteFunction.ts +35 -0
- package/src/lib/server/rpc/types/RemoteHandler.ts +22 -0
- package/src/lib/server/rpc/types/RemoteRoutes.ts +13 -0
- package/src/lib/server/rpc/types/StandardSchemaV1.ts +57 -0
- package/src/lib/server/rpc/types/TypedResponse.ts +18 -0
- package/src/lib/server/rpc/types/VerbHelper.ts +39 -0
- package/src/lib/server/rpc/types/VerbRegistryEntry.ts +17 -0
- package/src/lib/server/rpc/unprocessed.ts +14 -0
- package/src/lib/server/rpc/verbRegistry.ts +11 -0
- package/src/lib/server/runtime/buildOpenApiSpec.ts +66 -0
- package/src/lib/server/runtime/cacheControlForAsset.ts +17 -0
- package/src/lib/server/runtime/containsTraversal.ts +37 -0
- package/src/lib/server/runtime/createPublicAssetServer.ts +66 -0
- package/src/lib/server/runtime/createServer.ts +555 -0
- package/src/lib/server/runtime/getActiveServer.ts +6 -0
- package/src/lib/server/runtime/mimeForExtension.ts +20 -0
- package/src/lib/server/runtime/registryManifests.ts +48 -0
- package/src/lib/server/runtime/requestContext.ts +5 -0
- package/src/lib/server/runtime/safeJsonForScript.ts +17 -0
- package/src/lib/server/runtime/serializeCacheSnapshot.ts +84 -0
- package/src/lib/server/runtime/serverSlot.ts +13 -0
- package/src/lib/server/runtime/setActiveServer.ts +6 -0
- package/src/lib/server/runtime/streamFromIterator.ts +76 -0
- package/src/lib/server/runtime/types/Assets.ts +1 -0
- package/src/lib/server/runtime/types/CompileTarget.ts +6 -0
- package/src/lib/server/runtime/types/RequestStore.ts +15 -0
- package/src/lib/server/runtime/types/SvelteConfig.ts +5 -0
- package/src/lib/server/server.ts +19 -0
- package/src/lib/server/socket.ts +31 -0
- package/src/lib/server/sockets/createSocketDispatcher.ts +267 -0
- package/src/lib/server/sockets/defineSocket.ts +160 -0
- package/src/lib/server/sockets/lookupSocket.ts +6 -0
- package/src/lib/server/sockets/registerSocket.ts +6 -0
- package/src/lib/server/sockets/socketRegistry.ts +9 -0
- package/src/lib/server/sockets/types/Socket.ts +21 -0
- package/src/lib/server/sockets/types/SocketClientFrame.ts +18 -0
- package/src/lib/server/sockets/types/SocketOptions.ts +22 -0
- package/src/lib/server/sockets/types/SocketRegistryEntry.ts +18 -0
- package/src/lib/server/sockets/types/SocketRoutes.ts +10 -0
- package/src/lib/server/sockets/types/SocketServerFrame.ts +15 -0
- package/src/lib/server/sse.ts +47 -0
- package/src/lib/shared/activeCacheStore.ts +20 -0
- package/src/lib/shared/buildRpcRequest.ts +61 -0
- package/src/lib/shared/cacheControlValues.ts +8 -0
- package/src/lib/shared/cacheStoreSlot.ts +16 -0
- package/src/lib/shared/canonicalJson.ts +24 -0
- package/src/lib/shared/commandNameForUrl.ts +17 -0
- package/src/lib/shared/createCacheStore.ts +42 -0
- package/src/lib/shared/createPushIterator.ts +77 -0
- package/src/lib/shared/createRemoteFunction.ts +89 -0
- package/src/lib/shared/decodeResponse.ts +47 -0
- package/src/lib/shared/detectTarget.ts +27 -0
- package/src/lib/shared/findExportCallSite.ts +479 -0
- package/src/lib/shared/forwardHeaders.ts +28 -0
- package/src/lib/shared/getRemoteMeta.ts +5 -0
- package/src/lib/shared/isDebugEnabled.ts +23 -0
- package/src/lib/shared/jsonSchemaForSchema.ts +38 -0
- package/src/lib/shared/keyForRemoteCall.ts +38 -0
- package/src/lib/shared/loadSvelteConfig.ts +18 -0
- package/src/lib/shared/log.ts +104 -0
- package/src/lib/shared/nearestLayoutPrefix.ts +36 -0
- package/src/lib/shared/normalizeTarget.ts +10 -0
- package/src/lib/shared/pageUrlForFile.ts +14 -0
- package/src/lib/shared/parseRouteSegments.ts +22 -0
- package/src/lib/shared/preparePromptModule.ts +36 -0
- package/src/lib/shared/prepareRpcModule.ts +51 -0
- package/src/lib/shared/prepareSocketModule.ts +37 -0
- package/src/lib/shared/programNameForPackage.ts +14 -0
- package/src/lib/shared/promptNameForFile.ts +10 -0
- package/src/lib/shared/recordRemoteMeta.ts +5 -0
- package/src/lib/shared/remoteMetaStore.ts +16 -0
- package/src/lib/shared/resolveClientFlags.ts +18 -0
- package/src/lib/shared/rpcUrlForFile.ts +19 -0
- package/src/lib/shared/setCacheStoreResolver.ts +6 -0
- package/src/lib/shared/socketNameForFile.ts +11 -0
- package/src/lib/shared/streamingContentTypes.ts +11 -0
- package/src/lib/shared/stripImport.ts +27 -0
- package/src/lib/shared/subscribableFromResponse.ts +333 -0
- package/src/lib/shared/toBunRoutePattern.ts +28 -0
- package/src/lib/shared/types/CacheEntry.ts +16 -0
- package/src/lib/shared/types/CacheOptions.ts +10 -0
- package/src/lib/shared/types/CacheSnapshotEntry.ts +15 -0
- package/src/lib/shared/types/CacheStore.ts +15 -0
- package/src/lib/shared/types/ClientFlags.ts +11 -0
- package/src/lib/shared/types/Subscribable.ts +15 -0
- package/src/lib/shared/writeRoutesDts.ts +64 -0
- package/src/preload.ts +20 -0
- package/src/scaffold.ts +92 -0
- package/src/serverEntry.ts +47 -0
- package/src/sveltePlugin.ts +58 -0
- package/src/tailwindStylePreprocessor.ts +62 -0
- package/template/package.json +16 -0
- package/template/src/app.ts +23 -0
- package/template/src/browser/app.css +21 -0
- package/template/src/browser/app.html +24 -0
- package/template/src/browser/pages/about/page.svelte +5 -0
- package/template/src/browser/pages/layout.svelte +26 -0
- package/template/src/browser/pages/page.svelte +20 -0
- package/template/src/cli/banner.txt +3 -0
- package/template/src/cli/footer.txt +1 -0
- package/template/src/server/rpc/getHello.ts +33 -0
- package/template/svelte.config.js +12 -0
- package/template/tsconfig.json +18 -0
- package/tsconfig.app.json +16 -0
|
@@ -0,0 +1,555 @@
|
|
|
1
|
+
import type { BunRequest, Server } from 'bun'
|
|
2
|
+
import { Glob } from 'bun'
|
|
3
|
+
import type { Component } from 'svelte'
|
|
4
|
+
import { render } from 'svelte/server'
|
|
5
|
+
import App from '../../../App.svelte'
|
|
6
|
+
import type { Layouts } from '../../browser/types/Layouts.ts'
|
|
7
|
+
import type { Pages } from '../../browser/types/Pages.ts'
|
|
8
|
+
import { createMcpResourceServer } from '../../mcp/createMcpResourceServer.ts'
|
|
9
|
+
import { setMcpResourceServer } from '../../mcp/mcpResourceServerSlot.ts'
|
|
10
|
+
import type { McpServer } from '../../mcp/types/McpServer.ts'
|
|
11
|
+
import { NO_STORE, SSR_CACHE_CONTROL } from '../../shared/cacheControlValues.ts'
|
|
12
|
+
import { createCacheStore } from '../../shared/createCacheStore.ts'
|
|
13
|
+
import { isDebugEnabled } from '../../shared/isDebugEnabled.ts'
|
|
14
|
+
import { log } from '../../shared/log.ts'
|
|
15
|
+
import { nearestLayoutPrefix, normalizeLayoutPrefixes } from '../../shared/nearestLayoutPrefix.ts'
|
|
16
|
+
import { toBunRoutePattern } from '../../shared/toBunRoutePattern.ts'
|
|
17
|
+
import type { AppModule } from '../AppModule.ts'
|
|
18
|
+
import { handleCliDownload } from '../cli/handleCliDownload.ts'
|
|
19
|
+
import { handleCliInstall } from '../cli/handleCliInstall.ts'
|
|
20
|
+
import type { PromptRoutes } from '../prompts/types/PromptRoutes.ts'
|
|
21
|
+
import type { HttpVerb } from '../rpc/types/HttpVerb.ts'
|
|
22
|
+
import type { RemoteFunction } from '../rpc/types/RemoteFunction.ts'
|
|
23
|
+
import type { RemoteRoutes } from '../rpc/types/RemoteRoutes.ts'
|
|
24
|
+
import { createSocketDispatcher } from '../sockets/createSocketDispatcher.ts'
|
|
25
|
+
import type { SocketRoutes } from '../sockets/types/SocketRoutes.ts'
|
|
26
|
+
import { buildOpenApiSpec } from './buildOpenApiSpec.ts'
|
|
27
|
+
import { cacheControlForAsset } from './cacheControlForAsset.ts'
|
|
28
|
+
import { containsTraversal } from './containsTraversal.ts'
|
|
29
|
+
import { createPublicAssetServer } from './createPublicAssetServer.ts'
|
|
30
|
+
import { mimeForExtension } from './mimeForExtension.ts'
|
|
31
|
+
import { ensureRegistriesLoaded, setRegistryManifests } from './registryManifests.ts'
|
|
32
|
+
import { requestContext } from './requestContext.ts'
|
|
33
|
+
import { safeJsonForScript } from './safeJsonForScript.ts'
|
|
34
|
+
import { serializeCacheSnapshot } from './serializeCacheSnapshot.ts'
|
|
35
|
+
import { setActiveServer } from './setActiveServer.ts'
|
|
36
|
+
import type { Assets } from './types/Assets.ts'
|
|
37
|
+
import type { RequestStore } from './types/RequestStore.ts'
|
|
38
|
+
|
|
39
|
+
function acceptsZstd(req: Request): boolean {
|
|
40
|
+
return (req.headers.get('accept-encoding') ?? '').toLowerCase().includes('zstd')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function wantsJson(req: Request): boolean {
|
|
44
|
+
return (req.headers.get('accept') ?? '').includes('application/json')
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const SOCKETS_PATH = '/__belte/sockets'
|
|
48
|
+
const MCP_PATH = '/__belte/mcp'
|
|
49
|
+
const CLI_PATH = '/__belte/cli'
|
|
50
|
+
const CLI_DOWNLOAD_PREFIX = '/__belte/cli/'
|
|
51
|
+
const OPENAPI_PATH = '/openapi.json'
|
|
52
|
+
|
|
53
|
+
type AnyRemoteFunction = RemoteFunction<unknown, unknown>
|
|
54
|
+
|
|
55
|
+
/*
|
|
56
|
+
Starts a Bun HTTP server that ties together the framework conventions:
|
|
57
|
+
page.svelte + layout.svelte under src/browser/pages/ for views, one named export
|
|
58
|
+
per file under src/server/rpc/ for verb-bound remote functions, one named export
|
|
59
|
+
per file under src/server/sockets/ for broadcast sockets, and an optional
|
|
60
|
+
app.ts for boot-time setup, request middleware, and error fallback. Page
|
|
61
|
+
URLs and rpc URLs live in disjoint spaces — pages mount at the folder
|
|
62
|
+
path, rpc files mount at `/rpc/<file path>` — so each registered URL
|
|
63
|
+
resolves to exactly one thing. Per request, an AsyncLocalStorage
|
|
64
|
+
RequestStore carries the cache store and request metadata.
|
|
65
|
+
*/
|
|
66
|
+
export async function createServer({
|
|
67
|
+
pages,
|
|
68
|
+
rpc,
|
|
69
|
+
sockets,
|
|
70
|
+
prompts,
|
|
71
|
+
layouts,
|
|
72
|
+
shell,
|
|
73
|
+
app,
|
|
74
|
+
assets,
|
|
75
|
+
publicAssets,
|
|
76
|
+
mcpResources,
|
|
77
|
+
mcp,
|
|
78
|
+
cliProgramName,
|
|
79
|
+
appInfo,
|
|
80
|
+
distDir = `${process.cwd()}/dist`,
|
|
81
|
+
publicDir = `${process.cwd()}/src/browser/public`,
|
|
82
|
+
resourcesDir = `${process.cwd()}/src/mcp/resources`,
|
|
83
|
+
port = Number(process.env.PORT ?? 3000),
|
|
84
|
+
}: {
|
|
85
|
+
pages: Pages
|
|
86
|
+
rpc: RemoteRoutes
|
|
87
|
+
sockets: SocketRoutes
|
|
88
|
+
prompts: PromptRoutes
|
|
89
|
+
layouts?: Layouts
|
|
90
|
+
shell: string
|
|
91
|
+
app?: AppModule
|
|
92
|
+
assets?: Assets
|
|
93
|
+
publicAssets?: Assets
|
|
94
|
+
mcpResources?: Assets
|
|
95
|
+
mcp?: McpServer
|
|
96
|
+
cliProgramName?: string
|
|
97
|
+
appInfo?: { name: string; version: string }
|
|
98
|
+
distDir?: string
|
|
99
|
+
publicDir?: string
|
|
100
|
+
resourcesDir?: string
|
|
101
|
+
port?: number
|
|
102
|
+
}): Promise<Server<unknown>> {
|
|
103
|
+
setRegistryManifests({ rpc, sockets, prompts })
|
|
104
|
+
setMcpResourceServer(createMcpResourceServer({ resourcesDir, mcpResources }))
|
|
105
|
+
const cliName = cliProgramName ?? 'app'
|
|
106
|
+
const cliCwd = process.cwd()
|
|
107
|
+
const servePublicAsset = createPublicAssetServer({ publicDir, publicAssets })
|
|
108
|
+
/*
|
|
109
|
+
Forward-declared so the per-request closures below can reference it. The
|
|
110
|
+
value is assigned by Bun.serve() further down; closures only fire after
|
|
111
|
+
that, so the read-before-write is safe at runtime.
|
|
112
|
+
*/
|
|
113
|
+
let server!: Server<unknown>
|
|
114
|
+
const layoutPrefixes = layouts ? normalizeLayoutPrefixes(Object.keys(layouts)) : []
|
|
115
|
+
|
|
116
|
+
const diskZstdPaths = new Set<string>(
|
|
117
|
+
!assets && (await Bun.file(`${distDir}/_app`).exists())
|
|
118
|
+
? (await Array.fromAsync(new Glob('**/*.zst').scan({ cwd: `${distDir}/_app` }))).map(
|
|
119
|
+
(file) => `/_app/${file.replace(/\.zst$/, '')}`,
|
|
120
|
+
)
|
|
121
|
+
: [],
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
const rpcModuleCache = new Map<string, Promise<AnyRemoteFunction | undefined>>()
|
|
125
|
+
function loadRpc(url: string): Promise<AnyRemoteFunction | undefined> | undefined {
|
|
126
|
+
const existing = rpcModuleCache.get(url)
|
|
127
|
+
if (existing) {
|
|
128
|
+
return existing
|
|
129
|
+
}
|
|
130
|
+
const loader = rpc[url]
|
|
131
|
+
if (!loader) {
|
|
132
|
+
return undefined
|
|
133
|
+
}
|
|
134
|
+
/*
|
|
135
|
+
Each $rpc module has exactly one named export, validated at build
|
|
136
|
+
time. Pick the first export that looks like a RemoteFunction so the
|
|
137
|
+
framework stays tolerant of incidental re-exports.
|
|
138
|
+
*/
|
|
139
|
+
const promise = loader().then((mod) => {
|
|
140
|
+
for (const value of Object.values(mod)) {
|
|
141
|
+
if (typeof value === 'function' && 'method' in value && 'url' in value) {
|
|
142
|
+
return value as AnyRemoteFunction
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return undefined
|
|
146
|
+
})
|
|
147
|
+
rpcModuleCache.set(url, promise)
|
|
148
|
+
return promise
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const logRequests = isDebugEnabled('belte')
|
|
152
|
+
|
|
153
|
+
/*
|
|
154
|
+
Header objects for a pathname depend only on the pathname's extension
|
|
155
|
+
and the immutable HASHED test. Cache them so repeat hits on the same
|
|
156
|
+
chunk reuse a single frozen header bag instead of allocating per
|
|
157
|
+
request.
|
|
158
|
+
*/
|
|
159
|
+
type AssetHeaderBundle = {
|
|
160
|
+
base: HeadersInit
|
|
161
|
+
zstd: HeadersInit
|
|
162
|
+
}
|
|
163
|
+
const assetHeaderCache = new Map<string, AssetHeaderBundle>()
|
|
164
|
+
function headersForAsset(pathname: string): AssetHeaderBundle {
|
|
165
|
+
const cached = assetHeaderCache.get(pathname)
|
|
166
|
+
if (cached) {
|
|
167
|
+
return cached
|
|
168
|
+
}
|
|
169
|
+
const base: HeadersInit = {
|
|
170
|
+
'Content-Type': mimeForExtension(pathname),
|
|
171
|
+
Vary: 'Accept-Encoding',
|
|
172
|
+
'Cache-Control': cacheControlForAsset(pathname),
|
|
173
|
+
}
|
|
174
|
+
const zstd: HeadersInit = { ...base, 'Content-Encoding': 'zstd' }
|
|
175
|
+
const bundle = { base, zstd }
|
|
176
|
+
assetHeaderCache.set(pathname, bundle)
|
|
177
|
+
return bundle
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
async function serveStaticAsset(req: Request, url: URL): Promise<Response> {
|
|
181
|
+
/*
|
|
182
|
+
Defence-in-depth path-traversal check against the raw request URL.
|
|
183
|
+
The WHATWG URL parser decodes `%2E%2E` to `..` and then normalises
|
|
184
|
+
dot-segments away before `url.pathname` is even visible, so an
|
|
185
|
+
attacker's traversal sequence would be invisible if we only looked
|
|
186
|
+
at the parsed pathname. Inspecting `req.url` instead catches the
|
|
187
|
+
encoded forms before normalization eats them; `%2F` (encoded slash)
|
|
188
|
+
is preserved in the pathname but still flagged here for clarity.
|
|
189
|
+
*/
|
|
190
|
+
if (containsTraversal(req.url)) {
|
|
191
|
+
return new Response('Not Found', {
|
|
192
|
+
status: 404,
|
|
193
|
+
headers: { 'Cache-Control': NO_STORE },
|
|
194
|
+
})
|
|
195
|
+
}
|
|
196
|
+
const wantsZstd = acceptsZstd(req)
|
|
197
|
+
const { base: baseHeaders, zstd: zstdHeaders } = headersForAsset(url.pathname)
|
|
198
|
+
if (assets) {
|
|
199
|
+
const compressed = assets[url.pathname]
|
|
200
|
+
if (!compressed) {
|
|
201
|
+
return new Response('Not Found', {
|
|
202
|
+
status: 404,
|
|
203
|
+
headers: { 'Cache-Control': NO_STORE },
|
|
204
|
+
})
|
|
205
|
+
}
|
|
206
|
+
if (wantsZstd) {
|
|
207
|
+
return new Response(compressed, { headers: zstdHeaders })
|
|
208
|
+
}
|
|
209
|
+
return new Response(Bun.zstdDecompressSync(compressed), { headers: baseHeaders })
|
|
210
|
+
}
|
|
211
|
+
const diskPath = distDir + url.pathname
|
|
212
|
+
if (wantsZstd && diskZstdPaths.has(url.pathname)) {
|
|
213
|
+
return new Response(Bun.file(`${diskPath}.zst`), { headers: zstdHeaders })
|
|
214
|
+
}
|
|
215
|
+
return new Response(Bun.file(diskPath), { headers: baseHeaders })
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function renderPage(
|
|
219
|
+
routeUrl: string,
|
|
220
|
+
params: Record<string, string>,
|
|
221
|
+
store: RequestStore,
|
|
222
|
+
): Promise<Response> {
|
|
223
|
+
const json = wantsJson(store.req)
|
|
224
|
+
if (json) {
|
|
225
|
+
return Response.json(
|
|
226
|
+
{ route: routeUrl, params },
|
|
227
|
+
{
|
|
228
|
+
headers: {
|
|
229
|
+
Vary: 'Accept',
|
|
230
|
+
'Cache-Control': SSR_CACHE_CONTROL,
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
)
|
|
234
|
+
}
|
|
235
|
+
const layoutPrefix = nearestLayoutPrefix(routeUrl, layoutPrefixes)
|
|
236
|
+
const [pageMod, layoutMod] = await Promise.all([
|
|
237
|
+
pages[routeUrl](),
|
|
238
|
+
layoutPrefix && layouts ? layouts[layoutPrefix]() : Promise.resolve(undefined),
|
|
239
|
+
])
|
|
240
|
+
const Page = pageMod.default as Component
|
|
241
|
+
const Layout = layoutMod?.default as Component | undefined
|
|
242
|
+
const rendered = await render(App, {
|
|
243
|
+
props: {
|
|
244
|
+
state: {
|
|
245
|
+
page: {
|
|
246
|
+
route: routeUrl,
|
|
247
|
+
params,
|
|
248
|
+
url: store.url,
|
|
249
|
+
},
|
|
250
|
+
render: { Layout, Page },
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
})
|
|
254
|
+
const cacheSnapshot = await serializeCacheSnapshot(store.cache)
|
|
255
|
+
const stateTag = `<script>window.__SSR__ = ${safeJsonForScript({
|
|
256
|
+
route: routeUrl,
|
|
257
|
+
params,
|
|
258
|
+
cache: cacheSnapshot,
|
|
259
|
+
})};</script>`
|
|
260
|
+
const html = shell
|
|
261
|
+
.replace('<!--ssr:head-->', rendered.head)
|
|
262
|
+
.replace('<!--ssr:body-->', rendered.body)
|
|
263
|
+
.replace('<!--ssr:state-->', stateTag)
|
|
264
|
+
return new Response(html, {
|
|
265
|
+
headers: {
|
|
266
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
267
|
+
Vary: 'Accept',
|
|
268
|
+
'Cache-Control': SSR_CACHE_CONTROL,
|
|
269
|
+
},
|
|
270
|
+
})
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/*
|
|
274
|
+
Per-route handler bound by buildRoutes(). Receives a BunRequest with
|
|
275
|
+
`params` filled from the route pattern (only pages use path params;
|
|
276
|
+
$rpc URLs are flat). Page URLs (under src/browser/pages/) serve GET/HEAD by
|
|
277
|
+
rendering; rpc URLs (under src/server/rpc/, prefixed with `/rpc/`) dispatch
|
|
278
|
+
to the single declared verb-bound handler. URLs are disjoint by
|
|
279
|
+
construction so each path goes to exactly one branch.
|
|
280
|
+
*/
|
|
281
|
+
function buildRouteHandler(routeUrl: string) {
|
|
282
|
+
const hasPage = pages[routeUrl] !== undefined
|
|
283
|
+
const hasRpc = rpc[routeUrl] !== undefined
|
|
284
|
+
return async function routeHandler(
|
|
285
|
+
req: Request,
|
|
286
|
+
pathParams: Record<string, string>,
|
|
287
|
+
store: RequestStore,
|
|
288
|
+
): Promise<Response> {
|
|
289
|
+
const method = req.method as HttpVerb
|
|
290
|
+
if (hasRpc) {
|
|
291
|
+
const fn = await loadRpc(routeUrl)
|
|
292
|
+
if (fn && fn.method === method) {
|
|
293
|
+
return fn.fetch(req)
|
|
294
|
+
}
|
|
295
|
+
const allow = fn ? fn.method : ''
|
|
296
|
+
return new Response('Method Not Allowed', {
|
|
297
|
+
status: 405,
|
|
298
|
+
headers: {
|
|
299
|
+
Allow: allow,
|
|
300
|
+
'Cache-Control': NO_STORE,
|
|
301
|
+
},
|
|
302
|
+
})
|
|
303
|
+
}
|
|
304
|
+
if (hasPage) {
|
|
305
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
306
|
+
return new Response('Method Not Allowed', {
|
|
307
|
+
status: 405,
|
|
308
|
+
headers: { Allow: 'GET, HEAD', 'Cache-Control': NO_STORE },
|
|
309
|
+
})
|
|
310
|
+
}
|
|
311
|
+
return renderPage(routeUrl, pathParams, store)
|
|
312
|
+
}
|
|
313
|
+
return new Response('Not Found', {
|
|
314
|
+
status: 404,
|
|
315
|
+
headers: { 'Cache-Control': NO_STORE },
|
|
316
|
+
})
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/*
|
|
321
|
+
Page URLs (folder paths, e.g. `/media/[id]`) get translated to Bun's
|
|
322
|
+
pattern syntax (`/media/:id`) at registration. Bun's `*` wildcard
|
|
323
|
+
matches but does not capture into req.params, so for `[...rest]`
|
|
324
|
+
routes the catch-all value is reconstructed from the request URL by
|
|
325
|
+
slicing the pathname segments after the catch-all's pattern index.
|
|
326
|
+
The reconstructed value is set under the original name (e.g. `rest`)
|
|
327
|
+
so the page component's $props destructure stays consistent with the
|
|
328
|
+
file path. Page URLs and rpc URLs (always `/rpc/...`, flat) are
|
|
329
|
+
disjoint by construction, so a plain object needs no deduplication.
|
|
330
|
+
*/
|
|
331
|
+
const routes: Record<string, (req: BunRequest) => Promise<Response>> = {}
|
|
332
|
+
for (const routeUrl of Object.keys(pages)) {
|
|
333
|
+
const handler = buildRouteHandler(routeUrl)
|
|
334
|
+
const { pattern, catchAllName } = toBunRoutePattern(routeUrl)
|
|
335
|
+
const catchAllIndex = catchAllName
|
|
336
|
+
? routeUrl.split('/').findIndex((segment) => segment.startsWith('[...'))
|
|
337
|
+
: -1
|
|
338
|
+
routes[pattern] = (req) => {
|
|
339
|
+
const pathParams = { ...((req.params as Record<string, string> | undefined) ?? {}) }
|
|
340
|
+
if (catchAllName && catchAllIndex !== -1) {
|
|
341
|
+
const pathSegments = new URL(req.url).pathname.split('/')
|
|
342
|
+
pathParams[catchAllName] = pathSegments.slice(catchAllIndex).join('/')
|
|
343
|
+
}
|
|
344
|
+
return dispatchRequest(req, pathParams, handler)
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
for (const routeUrl of Object.keys(rpc)) {
|
|
348
|
+
const handler = buildRouteHandler(routeUrl)
|
|
349
|
+
routes[routeUrl] = (req) => dispatchRequest(req, {}, handler)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function dispatchRequest(
|
|
353
|
+
req: Request,
|
|
354
|
+
pathParams: Record<string, string>,
|
|
355
|
+
handler: (
|
|
356
|
+
req: Request,
|
|
357
|
+
pathParams: Record<string, string>,
|
|
358
|
+
store: RequestStore,
|
|
359
|
+
) => Promise<Response>,
|
|
360
|
+
): Promise<Response> {
|
|
361
|
+
return runWithStore(req, async (store) => {
|
|
362
|
+
if (!app?.handle) {
|
|
363
|
+
return handler(req, pathParams, store)
|
|
364
|
+
}
|
|
365
|
+
return app.handle(req, (next) => handler(next, pathParams, store))
|
|
366
|
+
})
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function runWithStore(
|
|
370
|
+
req: Request,
|
|
371
|
+
body: (store: RequestStore) => Promise<Response>,
|
|
372
|
+
): Promise<Response> {
|
|
373
|
+
const url = new URL(req.url)
|
|
374
|
+
const store: RequestStore = {
|
|
375
|
+
url,
|
|
376
|
+
req,
|
|
377
|
+
signal: req.signal,
|
|
378
|
+
cache: createCacheStore(),
|
|
379
|
+
server,
|
|
380
|
+
}
|
|
381
|
+
return requestContext.run(store, async () => {
|
|
382
|
+
const start = logRequests ? Bun.nanoseconds() : 0
|
|
383
|
+
let response: Response
|
|
384
|
+
try {
|
|
385
|
+
response = await body(store)
|
|
386
|
+
} catch (error) {
|
|
387
|
+
if (app?.handleError) {
|
|
388
|
+
response = await app.handleError(error, req)
|
|
389
|
+
} else {
|
|
390
|
+
log.error(error)
|
|
391
|
+
response = new Response(
|
|
392
|
+
`<pre>${String((error as Error)?.stack ?? error)}</pre>`,
|
|
393
|
+
{
|
|
394
|
+
status: 500,
|
|
395
|
+
headers: {
|
|
396
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
397
|
+
'Cache-Control': NO_STORE,
|
|
398
|
+
},
|
|
399
|
+
},
|
|
400
|
+
)
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
if (logRequests) {
|
|
404
|
+
const ms = (Bun.nanoseconds() - start) / 1e6
|
|
405
|
+
log.request(req.method, `${url.pathname}${url.search}`, response.status, ms)
|
|
406
|
+
}
|
|
407
|
+
return response
|
|
408
|
+
})
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/*
|
|
412
|
+
Belte's only native WebSocket surface is the sockets hub: every Socket
|
|
413
|
+
declared under src/server/sockets/ multiplexes onto one framework-owned
|
|
414
|
+
connection per client at /__belte/sockets. The dispatcher owns the
|
|
415
|
+
open/message/close handlers below; user code never sees the raw ws
|
|
416
|
+
lifecycle. Steady-state fan-out rides Bun's native server.publish so
|
|
417
|
+
a busy socket doesn't iterate JS per subscriber per message.
|
|
418
|
+
*/
|
|
419
|
+
const socketDispatcher = createSocketDispatcher(sockets)
|
|
420
|
+
server = Bun.serve({
|
|
421
|
+
port,
|
|
422
|
+
|
|
423
|
+
websocket: {
|
|
424
|
+
open(ws) {
|
|
425
|
+
socketDispatcher.open(ws)
|
|
426
|
+
},
|
|
427
|
+
message(ws, data) {
|
|
428
|
+
socketDispatcher.message(ws, data)
|
|
429
|
+
},
|
|
430
|
+
close(ws) {
|
|
431
|
+
socketDispatcher.close(ws)
|
|
432
|
+
},
|
|
433
|
+
},
|
|
434
|
+
|
|
435
|
+
routes,
|
|
436
|
+
|
|
437
|
+
async fetch(req, bunServer) {
|
|
438
|
+
const url = new URL(req.url)
|
|
439
|
+
if (url.pathname === SOCKETS_PATH) {
|
|
440
|
+
if (bunServer.upgrade(req, { data: {} })) {
|
|
441
|
+
return undefined as unknown as Response
|
|
442
|
+
}
|
|
443
|
+
return new Response('Upgrade failed', { status: 400 })
|
|
444
|
+
}
|
|
445
|
+
if (url.pathname === MCP_PATH && mcp) {
|
|
446
|
+
return dispatchRequest(req, {}, async () => mcp.handle(req))
|
|
447
|
+
}
|
|
448
|
+
if (url.pathname === CLI_PATH) {
|
|
449
|
+
return dispatchRequest(req, {}, async () => handleCliInstall(req, cliName))
|
|
450
|
+
}
|
|
451
|
+
if (url.pathname.startsWith(CLI_DOWNLOAD_PREFIX)) {
|
|
452
|
+
const platform = url.pathname.slice(CLI_DOWNLOAD_PREFIX.length)
|
|
453
|
+
return dispatchRequest(req, {}, async () =>
|
|
454
|
+
handleCliDownload(req, platform, cliName, cliCwd),
|
|
455
|
+
)
|
|
456
|
+
}
|
|
457
|
+
if (url.pathname === OPENAPI_PATH) {
|
|
458
|
+
return dispatchRequest(req, {}, async () => {
|
|
459
|
+
await ensureRegistriesLoaded()
|
|
460
|
+
const spec = buildOpenApiSpec({
|
|
461
|
+
title: appInfo?.name ?? cliName,
|
|
462
|
+
version: appInfo?.version ?? '0.0.0',
|
|
463
|
+
})
|
|
464
|
+
return Response.json(spec, { headers: { 'Cache-Control': NO_STORE } })
|
|
465
|
+
})
|
|
466
|
+
}
|
|
467
|
+
/*
|
|
468
|
+
Static assets sidestep ALS + the per-request CacheStore + the
|
|
469
|
+
app.handle middleware: they have no need for cache() and the
|
|
470
|
+
allocation overhead matters on a cold page load that pulls
|
|
471
|
+
dozens of chunks. The global server.error() handler still
|
|
472
|
+
catches anything that goes wrong inside serveStaticAsset.
|
|
473
|
+
*/
|
|
474
|
+
if (url.pathname.startsWith('/_app/')) {
|
|
475
|
+
if (!logRequests) {
|
|
476
|
+
return serveStaticAsset(req, url)
|
|
477
|
+
}
|
|
478
|
+
const start = Bun.nanoseconds()
|
|
479
|
+
const response = await serveStaticAsset(req, url)
|
|
480
|
+
const ms = (Bun.nanoseconds() - start) / 1e6
|
|
481
|
+
log.request(req.method, `${url.pathname}${url.search}`, response.status, ms)
|
|
482
|
+
return response
|
|
483
|
+
}
|
|
484
|
+
/*
|
|
485
|
+
Files under public/ are served at the site root, sidestepping
|
|
486
|
+
ALS + middleware like the /_app/ assets do. A miss returns
|
|
487
|
+
undefined so the request falls through to the 404 / middleware
|
|
488
|
+
path below.
|
|
489
|
+
*/
|
|
490
|
+
const publicResponse = await servePublicAsset(req, url)
|
|
491
|
+
if (publicResponse) {
|
|
492
|
+
if (logRequests) {
|
|
493
|
+
log.request(
|
|
494
|
+
req.method,
|
|
495
|
+
`${url.pathname}${url.search}`,
|
|
496
|
+
publicResponse.status,
|
|
497
|
+
0,
|
|
498
|
+
)
|
|
499
|
+
}
|
|
500
|
+
return publicResponse
|
|
501
|
+
}
|
|
502
|
+
/*
|
|
503
|
+
Unknown routes still run through dispatchRequest so user-defined
|
|
504
|
+
app.handle middleware can rewrite the request, serve a custom
|
|
505
|
+
404, or branch on the URL. The inner handler returns the
|
|
506
|
+
framework's default 404 when nothing intervenes.
|
|
507
|
+
*/
|
|
508
|
+
return dispatchRequest(req, {}, async () => {
|
|
509
|
+
return new Response('Not Found', {
|
|
510
|
+
status: 404,
|
|
511
|
+
headers: { 'Cache-Control': NO_STORE },
|
|
512
|
+
})
|
|
513
|
+
})
|
|
514
|
+
},
|
|
515
|
+
|
|
516
|
+
error(err) {
|
|
517
|
+
log.error(err)
|
|
518
|
+
return new Response(`<pre>${String(err.stack ?? err)}</pre>`, {
|
|
519
|
+
status: 500,
|
|
520
|
+
headers: {
|
|
521
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
522
|
+
'Cache-Control': NO_STORE,
|
|
523
|
+
},
|
|
524
|
+
})
|
|
525
|
+
},
|
|
526
|
+
})
|
|
527
|
+
|
|
528
|
+
/*
|
|
529
|
+
Publishes the live server through `belte/server` before invoking the
|
|
530
|
+
user's init() hook. The exported `server()` function reads from this
|
|
531
|
+
slot and throws on access before the slot is set, so init() callers
|
|
532
|
+
can hold the import at module scope and still see the real instance
|
|
533
|
+
once boot completes.
|
|
534
|
+
*/
|
|
535
|
+
setActiveServer(server)
|
|
536
|
+
|
|
537
|
+
if (app?.init) {
|
|
538
|
+
const cleanup = await app.init({ server })
|
|
539
|
+
if (typeof cleanup === 'function') {
|
|
540
|
+
const shutdown = async () => {
|
|
541
|
+
try {
|
|
542
|
+
await cleanup()
|
|
543
|
+
} catch (err) {
|
|
544
|
+
log.error(err)
|
|
545
|
+
}
|
|
546
|
+
process.exit(0)
|
|
547
|
+
}
|
|
548
|
+
process.once('SIGINT', shutdown)
|
|
549
|
+
process.once('SIGTERM', shutdown)
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
log.success(`ready at http://localhost:${server.port}`)
|
|
554
|
+
return server
|
|
555
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Derives the MIME type from a URL pathname using Bun.file().type, which
|
|
3
|
+
operates on the file extension synchronously without touching the disk. The
|
|
4
|
+
Bun.file ref here is never read from — it exists only to reuse Bun's
|
|
5
|
+
extension-to-MIME table. Cache by extension so repeat hits for the same
|
|
6
|
+
chunk type (.js / .css / .map / .svg / …) skip the BunFile allocation.
|
|
7
|
+
*/
|
|
8
|
+
const mimeByExtension = new Map<string, string>()
|
|
9
|
+
|
|
10
|
+
export function mimeForExtension(pathname: string): string {
|
|
11
|
+
const dot = pathname.lastIndexOf('.')
|
|
12
|
+
const extension = dot === -1 ? '' : pathname.slice(dot)
|
|
13
|
+
const cached = mimeByExtension.get(extension)
|
|
14
|
+
if (cached !== undefined) {
|
|
15
|
+
return cached
|
|
16
|
+
}
|
|
17
|
+
const type = Bun.file(pathname).type
|
|
18
|
+
mimeByExtension.set(extension, type)
|
|
19
|
+
return type
|
|
20
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { PromptRoutes } from '../prompts/types/PromptRoutes.ts'
|
|
2
|
+
import type { RemoteRoutes } from '../rpc/types/RemoteRoutes.ts'
|
|
3
|
+
import type { SocketRoutes } from '../sockets/types/SocketRoutes.ts'
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
Process-wide slot for the rpc + sockets + prompts manifests. createServer
|
|
7
|
+
assigns once at boot (right after the route table is built); the MCP
|
|
8
|
+
server, the OpenAPI emitter, and prompt enumeration read it on first
|
|
9
|
+
request so they can lazy-import every module and walk the
|
|
10
|
+
verb/socket/prompt registries.
|
|
11
|
+
|
|
12
|
+
The slot pattern (mirrors getActiveServer) lets the framework-generated
|
|
13
|
+
McpServer bind to the manifests at module scope while the loaders stay
|
|
14
|
+
lazy until the first enumeration request.
|
|
15
|
+
*/
|
|
16
|
+
type RegistryManifests = {
|
|
17
|
+
rpc: RemoteRoutes
|
|
18
|
+
sockets: SocketRoutes
|
|
19
|
+
prompts: PromptRoutes
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let manifests: RegistryManifests | undefined
|
|
23
|
+
let loadedAll = false
|
|
24
|
+
|
|
25
|
+
export function setRegistryManifests(value: RegistryManifests): void {
|
|
26
|
+
manifests = value
|
|
27
|
+
loadedAll = false
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/*
|
|
31
|
+
On first call, eagerly imports every rpc + socket + prompt module so
|
|
32
|
+
defineVerb / defineSocket / definePrompt fire and populate the
|
|
33
|
+
registries. Idempotent — repeat calls are no-ops. Eager loading is
|
|
34
|
+
acceptable here because enumeration (MCP tool/resource/prompt lists,
|
|
35
|
+
the OpenAPI document) fundamentally requires the full surface; the
|
|
36
|
+
alternative of per-call lazy loading produces flaky first-call latency.
|
|
37
|
+
*/
|
|
38
|
+
export async function ensureRegistriesLoaded(): Promise<void> {
|
|
39
|
+
if (loadedAll || !manifests) {
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
await Promise.all([
|
|
43
|
+
...Object.values(manifests.rpc).map((loader) => loader()),
|
|
44
|
+
...Object.values(manifests.sockets).map((loader) => loader()),
|
|
45
|
+
...Object.values(manifests.prompts).map((loader) => loader()),
|
|
46
|
+
])
|
|
47
|
+
loadedAll = true
|
|
48
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Escapes characters that could prematurely terminate the surrounding <script>
|
|
3
|
+
tag or be interpreted as HTML comment delimiters when a JSON literal is
|
|
4
|
+
inlined into an HTML document. U+2028 (LS) and U+2029 (PS) are valid in JSON
|
|
5
|
+
but break a `<script>` tag's inline content because the JavaScript lexer
|
|
6
|
+
treats them as line terminators; encode them as Unicode escapes.
|
|
7
|
+
*/
|
|
8
|
+
const LINE_SEPARATOR = String.fromCharCode(0x2028)
|
|
9
|
+
const PARAGRAPH_SEPARATOR = String.fromCharCode(0x2029)
|
|
10
|
+
|
|
11
|
+
export function safeJsonForScript(value: unknown): string {
|
|
12
|
+
return JSON.stringify(value)
|
|
13
|
+
.replace(/</g, '\\u003c')
|
|
14
|
+
.replace(/-->/g, '--\\u003e')
|
|
15
|
+
.replaceAll(LINE_SEPARATOR, '\\u2028')
|
|
16
|
+
.replaceAll(PARAGRAPH_SEPARATOR, '\\u2029')
|
|
17
|
+
}
|