@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,103 @@
|
|
|
1
|
+
import { buildRpcRequest } from '../../shared/buildRpcRequest.ts'
|
|
2
|
+
import { NO_STORE } from '../../shared/cacheControlValues.ts'
|
|
3
|
+
import { createRemoteFunction } from '../../shared/createRemoteFunction.ts'
|
|
4
|
+
import { forwardHeaders } from '../../shared/forwardHeaders.ts'
|
|
5
|
+
import { resolveClientFlags } from '../../shared/resolveClientFlags.ts'
|
|
6
|
+
import type { ClientFlags } from '../../shared/types/ClientFlags.ts'
|
|
7
|
+
import { requestContext } from '../runtime/requestContext.ts'
|
|
8
|
+
import { parseArgs } from './parseArgs.ts'
|
|
9
|
+
import { registerVerb } from './registerVerb.ts'
|
|
10
|
+
import type { HttpVerb } from './types/HttpVerb.ts'
|
|
11
|
+
import type { RemoteFunction } from './types/RemoteFunction.ts'
|
|
12
|
+
import type { RemoteHandler } from './types/RemoteHandler.ts'
|
|
13
|
+
import type { StandardSchemaV1 } from './types/StandardSchemaV1.ts'
|
|
14
|
+
|
|
15
|
+
/*
|
|
16
|
+
Builds a RemoteFunction from an HTTP verb + RPC URL + handler. The bundler
|
|
17
|
+
rewrites every `export const VERB = handler(fn)` inside an `$rpc/**` module
|
|
18
|
+
so the verb (from the export name) and the URL (from the file path under
|
|
19
|
+
`src/server/rpc/`, with `/rpc/` prefix) are threaded into defineVerb.
|
|
20
|
+
|
|
21
|
+
The plain call (`fn(args)`) resolves to the Content-Type-decoded body;
|
|
22
|
+
non-2xx responses throw HttpError. `.raw(args)` returns the underlying
|
|
23
|
+
Response for callers that need status/headers/body streaming.
|
|
24
|
+
`.fetch(req)` is the dispatch hook the framework's router uses to
|
|
25
|
+
invoke the handler from an incoming HTTP request (with args parsed off
|
|
26
|
+
the Request via parseArgs).
|
|
27
|
+
|
|
28
|
+
Every raw invocation records the synthesized Request against the returned
|
|
29
|
+
promise so cache() can stash it on the entry without re-building.
|
|
30
|
+
*/
|
|
31
|
+
export function defineVerb<Args, Return>(
|
|
32
|
+
method: HttpVerb,
|
|
33
|
+
url: string,
|
|
34
|
+
handler: RemoteHandler<Args, Return>,
|
|
35
|
+
opts?: {
|
|
36
|
+
schema?: StandardSchemaV1
|
|
37
|
+
jsonSchema?: Record<string, unknown>
|
|
38
|
+
clients?: Partial<ClientFlags>
|
|
39
|
+
},
|
|
40
|
+
): RemoteFunction<Args, Return> {
|
|
41
|
+
const schema = opts?.schema
|
|
42
|
+
const jsonSchema = opts?.jsonSchema
|
|
43
|
+
const clients = resolveClientFlags(opts?.clients, schema !== undefined)
|
|
44
|
+
|
|
45
|
+
function buildRequest(args: Args | undefined): Request {
|
|
46
|
+
const store = requestContext.getStore()
|
|
47
|
+
const baseUrl = store ? store.url.href : 'http://localhost/'
|
|
48
|
+
const headers = store ? forwardHeaders(store.req.headers) : new Headers()
|
|
49
|
+
return buildRpcRequest({ method, url, args, baseUrl, headers })
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/*
|
|
53
|
+
Handler bodies may throw synchronously (e.g. an `assert(...)` at the
|
|
54
|
+
top of the function). The `async function` wrapper coerces both sync
|
|
55
|
+
throws and returned non-promises into the Promise<Response> shape
|
|
56
|
+
callers expect, so an SSR caller's `await` always sees the rejection
|
|
57
|
+
through the cache layer's snapshot boundary instead of the error
|
|
58
|
+
escaping the request scope.
|
|
59
|
+
*/
|
|
60
|
+
async function runHandler(args: Args | undefined): Promise<Response> {
|
|
61
|
+
return handler(args as Args) as unknown as Response
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function validateThenHandle(args: Args | undefined): Promise<Response> {
|
|
65
|
+
const result = await schema!['~standard'].validate(args)
|
|
66
|
+
if (result.issues) {
|
|
67
|
+
return new Response(JSON.stringify({ issues: result.issues }), {
|
|
68
|
+
status: 422,
|
|
69
|
+
headers: {
|
|
70
|
+
'Content-Type': 'application/json',
|
|
71
|
+
'Cache-Control': NO_STORE,
|
|
72
|
+
},
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
return runHandler(result.value as Args)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/*
|
|
79
|
+
`getRequest` is unused on the server path — handlers receive parsed
|
|
80
|
+
`args` directly. createRemoteFunction passes a thunk so the client
|
|
81
|
+
side can lazily synthesize its Request without forcing the server
|
|
82
|
+
to allocate one per SSR call.
|
|
83
|
+
*/
|
|
84
|
+
function invoke(args: Args | undefined): Promise<Response> {
|
|
85
|
+
return schema ? validateThenHandle(args) : runHandler(args)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const remote = createRemoteFunction<Args, Return>({
|
|
89
|
+
method,
|
|
90
|
+
url,
|
|
91
|
+
clients,
|
|
92
|
+
buildRequest,
|
|
93
|
+
invoke,
|
|
94
|
+
parseArgsForFetch: (request) => parseArgs(method, request) as Promise<Args | undefined>,
|
|
95
|
+
})
|
|
96
|
+
registerVerb({
|
|
97
|
+
remote: remote as RemoteFunction<unknown, unknown>,
|
|
98
|
+
schema,
|
|
99
|
+
jsonSchema,
|
|
100
|
+
clients,
|
|
101
|
+
})
|
|
102
|
+
return remote
|
|
103
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { HttpVerb } from './types/HttpVerb.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Parses + merges every source of args available for a verb-defined handler:
|
|
5
|
+
- body (json or form-encoded, ignored for GET/DELETE/HEAD)
|
|
6
|
+
- url query string
|
|
7
|
+
|
|
8
|
+
When both are present and the body is a plain object, the merge folds the
|
|
9
|
+
query in on top so query keys win on collision. A non-object body (array,
|
|
10
|
+
primitive, null) skips the merge entirely and is returned as-is — there's
|
|
11
|
+
no key on the body to layer the query into, and the framework's args type
|
|
12
|
+
is a single bag rather than a `{body, query}` envelope. Returns undefined
|
|
13
|
+
when no source contributes any key.
|
|
14
|
+
*/
|
|
15
|
+
export async function parseArgs(method: HttpVerb, request: Request): Promise<unknown> {
|
|
16
|
+
/*
|
|
17
|
+
Skip the URL parse entirely when the raw request URL has no query —
|
|
18
|
+
typical POST/PUT/PATCH calls land here with a flat rpc URL and no
|
|
19
|
+
`?…`, so the `new URL(...)` constructor cost (which dwarfs the
|
|
20
|
+
indexOf check) is wasted work.
|
|
21
|
+
*/
|
|
22
|
+
const queryStart = request.url.indexOf('?')
|
|
23
|
+
const hasQuery = queryStart !== -1
|
|
24
|
+
const url = hasQuery ? new URL(request.url) : undefined
|
|
25
|
+
|
|
26
|
+
let body: unknown
|
|
27
|
+
if (method !== 'GET' && method !== 'DELETE' && method !== 'HEAD') {
|
|
28
|
+
const contentType = (request.headers.get('content-type') ?? '').toLowerCase()
|
|
29
|
+
if (contentType.includes('application/json')) {
|
|
30
|
+
const text = await request.text()
|
|
31
|
+
if (text !== '') {
|
|
32
|
+
body = JSON.parse(text)
|
|
33
|
+
}
|
|
34
|
+
} else if (
|
|
35
|
+
contentType.includes('application/x-www-form-urlencoded') ||
|
|
36
|
+
contentType.includes('multipart/form-data')
|
|
37
|
+
) {
|
|
38
|
+
const form = await request.formData()
|
|
39
|
+
body = Object.fromEntries(form)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (body !== undefined && (typeof body !== 'object' || body === null || Array.isArray(body))) {
|
|
44
|
+
return body
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (!url) {
|
|
48
|
+
if (body === undefined) {
|
|
49
|
+
return undefined
|
|
50
|
+
}
|
|
51
|
+
return body
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const bodyObject = (body ?? {}) as Record<string, unknown>
|
|
55
|
+
const merged = { ...bodyObject, ...Object.fromEntries(url.searchParams) }
|
|
56
|
+
if (Object.keys(merged).length === 0) {
|
|
57
|
+
return undefined
|
|
58
|
+
}
|
|
59
|
+
return merged
|
|
60
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export type HttpVerb = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD'
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { HttpVerb } from './HttpVerb.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Bare-response remote function — same call shape as RemoteFunction but
|
|
5
|
+
resolves to the underlying Response without Content-Type decode and
|
|
6
|
+
without throwing on non-2xx. Produced as `.raw` on every RemoteFunction
|
|
7
|
+
so callers that need status / headers / body streaming or want to
|
|
8
|
+
implement custom error handling can opt out of the decode.
|
|
9
|
+
*/
|
|
10
|
+
export type RawRemoteFunction<Args> = ((args: Args) => Promise<Response>) & {
|
|
11
|
+
readonly method: HttpVerb
|
|
12
|
+
readonly url: string
|
|
13
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { ClientFlags } from '../../../shared/types/ClientFlags.ts'
|
|
2
|
+
import type { Subscribable } from '../../../shared/types/Subscribable.ts'
|
|
3
|
+
import type { HttpVerb } from './HttpVerb.ts'
|
|
4
|
+
import type { RawRemoteFunction } from './RawRemoteFunction.ts'
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
Remote function reference produced by GET/POST/... inside an `$rpc/**`
|
|
8
|
+
module and consumed by rpc dispatch, cache(), SSR auto-hydration, and
|
|
9
|
+
direct calls. Same callable signature on server and client — the bundler
|
|
10
|
+
swaps the implementation for browser builds.
|
|
11
|
+
|
|
12
|
+
The plain call resolves to the decoded body shape (sniffed from
|
|
13
|
+
Content-Type) and throws HttpError on non-2xx. `.raw` is a sibling
|
|
14
|
+
RemoteFunction whose call resolves to the underlying Response — same
|
|
15
|
+
method, same url, same args, no decode. Pass `fn.raw` to cache() to
|
|
16
|
+
memoise raw Responses against the same cache key as `fn` (both share one
|
|
17
|
+
stored entry — the decode just happens on the way out for callers of
|
|
18
|
+
`fn`). `.stream(args)` returns an iterable view of the Response body:
|
|
19
|
+
SSE / JSONL handlers yield each frame; non-streaming handlers yield the
|
|
20
|
+
decoded body once then complete. The result is a Subscribable, so it
|
|
21
|
+
can be passed to subscribe() and shared across reactive consumers.
|
|
22
|
+
For sustained broadcast / pub-sub use the `belte/sockets` primitive —
|
|
23
|
+
HTTP rpc isn't the place for long-lived multi-publisher subscriptions.
|
|
24
|
+
`.fetch(req)` is the framework's request-dispatch entry point — used by
|
|
25
|
+
the router to invoke the handler from an incoming HTTP request, not
|
|
26
|
+
for user code.
|
|
27
|
+
*/
|
|
28
|
+
export type RemoteFunction<Args, Return> = ((args: Args) => Promise<Return>) & {
|
|
29
|
+
readonly method: HttpVerb
|
|
30
|
+
readonly url: string
|
|
31
|
+
readonly clients: ClientFlags
|
|
32
|
+
readonly raw: RawRemoteFunction<Args>
|
|
33
|
+
stream(args?: Args): Subscribable<Return>
|
|
34
|
+
fetch(request: Request): Promise<Response>
|
|
35
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { TypedResponse } from './TypedResponse.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Handler signature for verb-defined remote functions. Args is `undefined` for
|
|
5
|
+
GETs/DELETEs with no query, JSON-shaped objects for json bodies, and
|
|
6
|
+
form-shaped objects for form-encoded bodies. For binary or multipart bodies
|
|
7
|
+
Args is `undefined` — read the raw Request via `request()` from
|
|
8
|
+
`belte/server` instead.
|
|
9
|
+
|
|
10
|
+
Return is the value type the call site sees after Content-Type-driven
|
|
11
|
+
decoding (a parsed object for JSON, a string for text/*, a Blob otherwise,
|
|
12
|
+
`undefined` for 204). The handler must return a Response at runtime; the
|
|
13
|
+
`TypedResponse<Return>` brand on `json`/`error`/`redirect`/`jsonl`/`sse`
|
|
14
|
+
carries the body shape through the function's inferred return type so the
|
|
15
|
+
verb helper can infer `Return` automatically — no need to annotate
|
|
16
|
+
`GET<Args, Return>` when the handler returns one of the respond helpers.
|
|
17
|
+
A bare `new Response(...)` is still acceptable: the brand is optional, so
|
|
18
|
+
untagged Responses simply fall back to `Return = unknown`.
|
|
19
|
+
*/
|
|
20
|
+
export type RemoteHandler<Args, Return> = (
|
|
21
|
+
args: Args,
|
|
22
|
+
) => TypedResponse<Return> | Promise<TypedResponse<Return>>
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { RemoteFunction } from './RemoteFunction.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Manifest of RPC URL → module loader. Produced by the resolver plugin from
|
|
5
|
+
every `.ts` under src/server/rpc — each file maps to one URL (derived from its
|
|
6
|
+
path under `$rpc`, prefixed with `/rpc/`). Each module has exactly one
|
|
7
|
+
named export, a RemoteFunction whose `.method` and `.url` were stamped in
|
|
8
|
+
by the bundler rewrite.
|
|
9
|
+
*/
|
|
10
|
+
export type RemoteRoutes = Record<
|
|
11
|
+
string,
|
|
12
|
+
() => Promise<Record<string, RemoteFunction<unknown, unknown>>>
|
|
13
|
+
>
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Mirror of the Standard Schema v1 spec interface (standardschema.dev). Any
|
|
3
|
+
library that implements the spec — zod, valibot, arktype, etc. — produces
|
|
4
|
+
values whose `~standard` property structurally matches this shape, so users
|
|
5
|
+
can pass their existing schemas to verb helpers without an adapter.
|
|
6
|
+
|
|
7
|
+
Kept inline (no `@standard-schema/spec` dep) because the spec is type-only
|
|
8
|
+
and tiny; adding a package for ~30 lines of interface would be churn. The
|
|
9
|
+
namespace pattern below is the spec's own convention — `InferInput` /
|
|
10
|
+
`InferOutput` ride along the same export.
|
|
11
|
+
*/
|
|
12
|
+
export interface StandardSchemaV1<Input = unknown, Output = Input> {
|
|
13
|
+
readonly '~standard': StandardSchemaV1.Props<Input, Output>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// biome-ignore lint/style/useNamingConvention: matches the Standard Schema spec exactly
|
|
17
|
+
export namespace StandardSchemaV1 {
|
|
18
|
+
export interface Props<Input = unknown, Output = Input> {
|
|
19
|
+
readonly version: 1
|
|
20
|
+
readonly vendor: string
|
|
21
|
+
readonly validate: (value: unknown) => Result<Output> | Promise<Result<Output>>
|
|
22
|
+
readonly types?: Types<Input, Output> | undefined
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type Result<Output> = SuccessResult<Output> | FailureResult
|
|
26
|
+
|
|
27
|
+
export interface SuccessResult<Output> {
|
|
28
|
+
readonly value: Output
|
|
29
|
+
readonly issues?: undefined
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface FailureResult {
|
|
33
|
+
readonly issues: ReadonlyArray<Issue>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface Issue {
|
|
37
|
+
readonly message: string
|
|
38
|
+
readonly path?: ReadonlyArray<PropertyKey | PathSegment> | undefined
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface PathSegment {
|
|
42
|
+
readonly key: PropertyKey
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface Types<Input = unknown, Output = Input> {
|
|
46
|
+
readonly input: Input
|
|
47
|
+
readonly output: Output
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type InferInput<Schema extends StandardSchemaV1> = NonNullable<
|
|
51
|
+
Schema['~standard']['types']
|
|
52
|
+
>['input']
|
|
53
|
+
|
|
54
|
+
export type InferOutput<Schema extends StandardSchemaV1> = NonNullable<
|
|
55
|
+
Schema['~standard']['types']
|
|
56
|
+
>['output']
|
|
57
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/*
|
|
2
|
+
A `Response` tagged with the body type the framework will hand back to
|
|
3
|
+
callers after Content-Type-driven decoding. The tag is phantom — it
|
|
4
|
+
adds no runtime field, only a type-level slot so the verb helpers can
|
|
5
|
+
infer `Return` from the handler's return type instead of forcing every
|
|
6
|
+
route to annotate it via `GET<Args, Return>`.
|
|
7
|
+
|
|
8
|
+
The respond helpers (`json<T>`, `error`, `redirect`, `jsonl<F>`,
|
|
9
|
+
`sse<F>`) all return a `TypedResponse<T>`, so a handler ending in
|
|
10
|
+
`return json({ user })` exposes `{ user: ... }` as its body type; the
|
|
11
|
+
verb overload picks it up via `RemoteHandler<Args, Return>`.
|
|
12
|
+
|
|
13
|
+
`T` is optional on the brand so a plain `new Response(...)` (untagged)
|
|
14
|
+
remains assignable to `TypedResponse<unknown>`; in that case `Return`
|
|
15
|
+
just falls back to its `unknown` default, matching pre-existing
|
|
16
|
+
behaviour for handlers that build Responses by hand.
|
|
17
|
+
*/
|
|
18
|
+
export type TypedResponse<T> = Response & { readonly __body?: T }
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ClientFlags } from '../../../shared/types/ClientFlags.ts'
|
|
2
|
+
import type { RemoteFunction } from './RemoteFunction.ts'
|
|
3
|
+
import type { RemoteHandler } from './RemoteHandler.ts'
|
|
4
|
+
import type { StandardSchemaV1 } from './StandardSchemaV1.ts'
|
|
5
|
+
|
|
6
|
+
/*
|
|
7
|
+
Shared signature for every verb helper (GET / POST / …). Three overloads:
|
|
8
|
+
|
|
9
|
+
- `Verb(fn, { schema, clients? })` — `Args` infers from
|
|
10
|
+
`InferInput<Schema>`, the handler receives `InferOutput<Schema>`.
|
|
11
|
+
Generic order is `<Return, Schema>` so users can override `Return`
|
|
12
|
+
while letting `Schema` infer from `opts.schema`. `clients` controls
|
|
13
|
+
which surfaces (browser / mcp / cli) expose this verb.
|
|
14
|
+
- `Verb(fn, { clients })` — schemaless but with explicit client
|
|
15
|
+
targeting (e.g. server-internal RPC with `clients: { browser: false }`).
|
|
16
|
+
- `Verb(fn)` — bare handler. `Args` and `Return` come from the handler
|
|
17
|
+
type; `Return` is usually inferred via the `TypedResponse<T>` brand on
|
|
18
|
+
`json`/`error`/`redirect`/`jsonl`/`sse`.
|
|
19
|
+
*/
|
|
20
|
+
export type VerbHelper = {
|
|
21
|
+
<Return = unknown, Schema extends StandardSchemaV1 = StandardSchemaV1>(
|
|
22
|
+
fn: RemoteHandler<StandardSchemaV1.InferOutput<Schema>, Return>,
|
|
23
|
+
opts: {
|
|
24
|
+
schema: Schema
|
|
25
|
+
jsonSchema?: Record<string, unknown>
|
|
26
|
+
clients?: Partial<ClientFlags>
|
|
27
|
+
},
|
|
28
|
+
): RemoteFunction<StandardSchemaV1.InferInput<Schema>, Return>
|
|
29
|
+
<Args = undefined, Return = unknown>(
|
|
30
|
+
fn: RemoteHandler<Args, Return>,
|
|
31
|
+
opts: {
|
|
32
|
+
jsonSchema?: Record<string, unknown>
|
|
33
|
+
clients: Partial<ClientFlags>
|
|
34
|
+
},
|
|
35
|
+
): RemoteFunction<Args, Return>
|
|
36
|
+
<Args = undefined, Return = unknown>(
|
|
37
|
+
fn: RemoteHandler<Args, Return>,
|
|
38
|
+
): RemoteFunction<Args, Return>
|
|
39
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ClientFlags } from '../../../shared/types/ClientFlags.ts'
|
|
2
|
+
import type { RemoteFunction } from './RemoteFunction.ts'
|
|
3
|
+
import type { StandardSchemaV1 } from './StandardSchemaV1.ts'
|
|
4
|
+
|
|
5
|
+
/*
|
|
6
|
+
Per-verb registry record on the server side. MCP and CLI enumerate this
|
|
7
|
+
to discover which RPCs are advertised (clients flags) and what input
|
|
8
|
+
shape they expect (schema). The schema and resolved clients stay off the
|
|
9
|
+
public RemoteFunction shape so the browser-side proxy doesn't need to
|
|
10
|
+
carry server-only state.
|
|
11
|
+
*/
|
|
12
|
+
export type VerbRegistryEntry = {
|
|
13
|
+
remote: RemoteFunction<unknown, unknown>
|
|
14
|
+
schema: StandardSchemaV1 | undefined
|
|
15
|
+
jsonSchema: Record<string, unknown> | undefined
|
|
16
|
+
clients: ClientFlags
|
|
17
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { RemoteFunction } from './types/RemoteFunction.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Verb helpers (GET / POST / …) are placeholders — the bundler rewrites every
|
|
5
|
+
`export const x = GET(fn)` inside `src/server/rpc/<file>.ts` into a call to
|
|
6
|
+
defineVerb (server target) or remoteProxy (client target). If a call slips
|
|
7
|
+
through, the bundler plugin didn't process the file; throwing here surfaces
|
|
8
|
+
that cleanly instead of silently returning undefined.
|
|
9
|
+
*/
|
|
10
|
+
export function unprocessed<Args, Return>(verb: string): RemoteFunction<Args, Return> {
|
|
11
|
+
throw new Error(
|
|
12
|
+
`[belte] \`${verb}\` was called outside an $rpc module — verb helpers are only valid as the value of \`export const <filename> = ...\` inside a file under src/server/rpc/`,
|
|
13
|
+
)
|
|
14
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { VerbRegistryEntry } from './types/VerbRegistryEntry.ts'
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
Process-wide registry of every verb-bound RPC declared in the app.
|
|
5
|
+
defineVerb inserts on first construction (which happens at module-load
|
|
6
|
+
time inside the rpc dispatcher cache or eagerly when MCP / CLI walks the
|
|
7
|
+
rpc manifest). MCP server reads this to build its tools list; the CLI
|
|
8
|
+
binary reads it to generate subcommands. The browser path never touches
|
|
9
|
+
this — the client stub has no schema or clients metadata to register.
|
|
10
|
+
*/
|
|
11
|
+
export const verbRegistry = new Map<string, VerbRegistryEntry>()
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { commandNameForUrl } from '../../shared/commandNameForUrl.ts'
|
|
2
|
+
import { jsonSchemaForSchema } from '../../shared/jsonSchemaForSchema.ts'
|
|
3
|
+
import type { HttpVerb } from '../rpc/types/HttpVerb.ts'
|
|
4
|
+
import { verbRegistry } from '../rpc/verbRegistry.ts'
|
|
5
|
+
|
|
6
|
+
const BODY_METHODS = new Set<HttpVerb>(['POST', 'PUT', 'PATCH'])
|
|
7
|
+
|
|
8
|
+
/*
|
|
9
|
+
Turns a verb's resolved JSON Schema into OpenAPI query parameters — one
|
|
10
|
+
per top-level property, marked required when the schema lists it. Used
|
|
11
|
+
for GET/DELETE/HEAD operations, which carry their args on the query
|
|
12
|
+
string (mirroring buildRpcRequest).
|
|
13
|
+
*/
|
|
14
|
+
function queryParameters(jsonSchema: Record<string, unknown>): Array<Record<string, unknown>> {
|
|
15
|
+
const properties = jsonSchema.properties as Record<string, unknown> | undefined
|
|
16
|
+
if (!properties) {
|
|
17
|
+
return []
|
|
18
|
+
}
|
|
19
|
+
const required = new Set((jsonSchema.required as string[] | undefined) ?? [])
|
|
20
|
+
return Object.entries(properties).map(([name, schema]) => ({
|
|
21
|
+
name,
|
|
22
|
+
in: 'query',
|
|
23
|
+
required: required.has(name),
|
|
24
|
+
schema,
|
|
25
|
+
}))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/*
|
|
29
|
+
Builds an OpenAPI 3.1 document from the verb registry — the HTTP surface
|
|
30
|
+
every rpc exposes regardless of which non-browser clients it advertises.
|
|
31
|
+
GET/DELETE/HEAD args become query parameters; POST/PUT/PATCH args become
|
|
32
|
+
a JSON request body. operationId is the folder-prefixed command name so
|
|
33
|
+
it lines up with the MCP tool / CLI subcommand identifiers.
|
|
34
|
+
*/
|
|
35
|
+
export function buildOpenApiSpec(info: {
|
|
36
|
+
title: string
|
|
37
|
+
version: string
|
|
38
|
+
}): Record<string, unknown> {
|
|
39
|
+
const paths: Record<string, Record<string, unknown>> = {}
|
|
40
|
+
for (const entry of verbRegistry.values()) {
|
|
41
|
+
const url = entry.remote.url
|
|
42
|
+
const method = entry.remote.method
|
|
43
|
+
const jsonSchema = jsonSchemaForSchema(entry.schema, entry.jsonSchema)
|
|
44
|
+
const operation: Record<string, unknown> = {
|
|
45
|
+
operationId: commandNameForUrl(url),
|
|
46
|
+
responses: { '200': { description: 'OK' } },
|
|
47
|
+
}
|
|
48
|
+
if (BODY_METHODS.has(method)) {
|
|
49
|
+
operation.requestBody = {
|
|
50
|
+
content: { 'application/json': { schema: jsonSchema } },
|
|
51
|
+
}
|
|
52
|
+
} else {
|
|
53
|
+
const parameters = queryParameters(jsonSchema)
|
|
54
|
+
if (parameters.length > 0) {
|
|
55
|
+
operation.parameters = parameters
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
const path = (paths[url] ??= {})
|
|
59
|
+
path[method.toLowerCase()] = operation
|
|
60
|
+
}
|
|
61
|
+
return {
|
|
62
|
+
openapi: '3.1.0',
|
|
63
|
+
info: { title: info.title, version: info.version },
|
|
64
|
+
paths,
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Bun.build emits `[name]-[hash].[ext]` for chunks; hash is alnum and >=8 chars.
|
|
3
|
+
Source maps inherit the same name (e.g. foo-abc12345.js.map), so the suffix may be `.map`.
|
|
4
|
+
*/
|
|
5
|
+
const HASHED = /-[a-z0-9]{8,}\.[a-z0-9]+(\.map)?$/i
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
Returns the right `Cache-Control` for an asset path served under `/_app/`.
|
|
9
|
+
Hashed chunk filenames are content-addressed and immutable; everything else
|
|
10
|
+
(the entry bundle, the html shell, etc.) must revalidate every time.
|
|
11
|
+
*/
|
|
12
|
+
export function cacheControlForAsset(pathname: string): string {
|
|
13
|
+
if (HASHED.test(pathname)) {
|
|
14
|
+
return 'public, max-age=31536000, immutable'
|
|
15
|
+
}
|
|
16
|
+
return 'public, max-age=0, must-revalidate'
|
|
17
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/*
|
|
2
|
+
Inspects the raw request URL (not the parsed pathname) for path-traversal
|
|
3
|
+
patterns. The WHATWG URL parser decodes `%2E%2E` to `..` and then collapses
|
|
4
|
+
dot-segments out of the pathname during normalization, so by the time
|
|
5
|
+
`url.pathname` is observable any encoded traversal has been masked. The
|
|
6
|
+
remaining literal `..` check guards against any future URL-parser quirk
|
|
7
|
+
that lets a normalised path through.
|
|
8
|
+
|
|
9
|
+
Hot path early-out: if none of the suspect substrings appear in the raw
|
|
10
|
+
URL we never lowercase the whole string nor walk segments.
|
|
11
|
+
*/
|
|
12
|
+
export function containsTraversal(rawUrl: string): boolean {
|
|
13
|
+
if (rawUrl.includes('\\')) {
|
|
14
|
+
return true
|
|
15
|
+
}
|
|
16
|
+
if (rawUrl.includes('..') && segmentContainsDotDot(rawUrl)) {
|
|
17
|
+
return true
|
|
18
|
+
}
|
|
19
|
+
if (rawUrl.indexOf('%') === -1) {
|
|
20
|
+
return false
|
|
21
|
+
}
|
|
22
|
+
const lower = rawUrl.toLowerCase()
|
|
23
|
+
return lower.includes('%2e%2e') || lower.includes('%2f') || lower.includes('%5c')
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function segmentContainsDotDot(rawUrl: string): boolean {
|
|
27
|
+
const queryStart = rawUrl.indexOf('?')
|
|
28
|
+
const pathEnd = queryStart === -1 ? rawUrl.length : queryStart
|
|
29
|
+
const pathStart = rawUrl.indexOf('/', rawUrl.indexOf('://') + 3)
|
|
30
|
+
if (pathStart === -1 || pathStart >= pathEnd) {
|
|
31
|
+
return false
|
|
32
|
+
}
|
|
33
|
+
return rawUrl
|
|
34
|
+
.slice(pathStart, pathEnd)
|
|
35
|
+
.split('/')
|
|
36
|
+
.some((segment) => segment === '..')
|
|
37
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { containsTraversal } from './containsTraversal.ts'
|
|
2
|
+
import { mimeForExtension } from './mimeForExtension.ts'
|
|
3
|
+
import type { Assets } from './types/Assets.ts'
|
|
4
|
+
|
|
5
|
+
const PUBLIC_CACHE_CONTROL = 'public, max-age=3600'
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
Serves files from the project's `public/` folder at the site root. Two
|
|
9
|
+
sources, picked at construction:
|
|
10
|
+
|
|
11
|
+
- `publicAssets` (standalone compile): a map of root path → zstd bytes
|
|
12
|
+
embedded into the binary, mirroring the `_app` asset embed.
|
|
13
|
+
- `publicDir` on disk (dev + `belte start`): files read straight from
|
|
14
|
+
`${cwd}/src/browser/public`.
|
|
15
|
+
|
|
16
|
+
Returns a server fn that resolves to `undefined` when no public file
|
|
17
|
+
matches the request path, so the caller falls through to its own 404 /
|
|
18
|
+
middleware path. The path-traversal guard mirrors serveStaticAsset's
|
|
19
|
+
defence against encoded `..` segments in the raw URL.
|
|
20
|
+
*/
|
|
21
|
+
export function createPublicAssetServer({
|
|
22
|
+
publicDir,
|
|
23
|
+
publicAssets,
|
|
24
|
+
}: {
|
|
25
|
+
publicDir: string
|
|
26
|
+
publicAssets?: Assets
|
|
27
|
+
}): (req: Request, url: URL) => Promise<Response | undefined> {
|
|
28
|
+
const headerCache = new Map<string, { base: HeadersInit; zstd: HeadersInit }>()
|
|
29
|
+
function headersFor(pathname: string): { base: HeadersInit; zstd: HeadersInit } {
|
|
30
|
+
const cached = headerCache.get(pathname)
|
|
31
|
+
if (cached) {
|
|
32
|
+
return cached
|
|
33
|
+
}
|
|
34
|
+
const base: HeadersInit = {
|
|
35
|
+
'Content-Type': mimeForExtension(pathname),
|
|
36
|
+
Vary: 'Accept-Encoding',
|
|
37
|
+
'Cache-Control': PUBLIC_CACHE_CONTROL,
|
|
38
|
+
}
|
|
39
|
+
const bundle = { base, zstd: { ...base, 'Content-Encoding': 'zstd' } }
|
|
40
|
+
headerCache.set(pathname, bundle)
|
|
41
|
+
return bundle
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return async function servePublicAsset(req, url) {
|
|
45
|
+
if (containsTraversal(req.url)) {
|
|
46
|
+
return undefined
|
|
47
|
+
}
|
|
48
|
+
const wantsZstd = (req.headers.get('accept-encoding') ?? '').toLowerCase().includes('zstd')
|
|
49
|
+
const { base, zstd } = headersFor(url.pathname)
|
|
50
|
+
if (publicAssets) {
|
|
51
|
+
const compressed = publicAssets[url.pathname]
|
|
52
|
+
if (!compressed) {
|
|
53
|
+
return undefined
|
|
54
|
+
}
|
|
55
|
+
if (wantsZstd) {
|
|
56
|
+
return new Response(compressed, { headers: zstd })
|
|
57
|
+
}
|
|
58
|
+
return new Response(Bun.zstdDecompressSync(compressed), { headers: base })
|
|
59
|
+
}
|
|
60
|
+
const file = Bun.file(publicDir + url.pathname)
|
|
61
|
+
if (!(await file.exists())) {
|
|
62
|
+
return undefined
|
|
63
|
+
}
|
|
64
|
+
return new Response(file, { headers: base })
|
|
65
|
+
}
|
|
66
|
+
}
|