@briancray/belte 0.7.0 → 0.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@briancray/belte",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "description": "Isomorphic multimodal HTTP framework built for humans and machines in a single Bun runtime",
6
6
  "license": "MIT",
@@ -0,0 +1,100 @@
1
+ import type { Pages } from '../../browser/types/Pages.ts'
2
+ import { NO_STORE } from '../../shared/cacheControlValues.ts'
3
+ import { memoizeByKey } from '../../shared/memoizeByKey.ts'
4
+ import type { HttpVerb } from '../rpc/types/HttpVerb.ts'
5
+ import type { RemoteFunction } from '../rpc/types/RemoteFunction.ts'
6
+ import type { RemoteRoutes } from '../rpc/types/RemoteRoutes.ts'
7
+ import type { RequestStore } from './types/RequestStore.ts'
8
+
9
+ type AnyRemoteFunction = RemoteFunction<unknown, unknown>
10
+
11
+ /* Resolves a matched route to a Response, given the request and its scope. */
12
+ type RouteHandler = (
13
+ req: Request,
14
+ pathParams: Record<string, string>,
15
+ store: RequestStore,
16
+ ) => Promise<Response>
17
+
18
+ /* Renders the page at `routeUrl` — injected so dispatch is testable without SSR. */
19
+ type RenderPage = (
20
+ routeUrl: string,
21
+ params: Record<string, string>,
22
+ store: RequestStore,
23
+ ) => Promise<Response>
24
+
25
+ /* The framework's 405 — `Allow` names the permitted verb(s), body and NO_STORE shared so the rpc and page branches can't drift. */
26
+ function methodNotAllowed(allow: string): Response {
27
+ return new Response('Method Not Allowed', {
28
+ status: 405,
29
+ headers: { Allow: allow, 'Cache-Control': NO_STORE },
30
+ })
31
+ }
32
+
33
+ /*
34
+ Owns route dispatch: deciding, per registered URL, whether a request hits an
35
+ rpc verb, a page render, or nothing — and the method-matching that picks the
36
+ status. Page URLs (under src/browser/pages/) serve GET/HEAD by rendering; rpc
37
+ URLs (under src/server/rpc/, `/rpc/...`) dispatch to the single declared verb,
38
+ 405 on method mismatch; an unregistered URL is 404. Page and rpc URLs are
39
+ disjoint by construction, so each route lands in exactly one branch.
40
+
41
+ `renderPage` is injected rather than built here: dispatch decisions (the
42
+ 405/404 branches and the rpc method match) are the behaviour worth testing,
43
+ and keeping the Svelte render behind the seam lets a test exercise them with a
44
+ stub instead of booting a server. The rpc-module loader is memoised internally
45
+ so each module loads once.
46
+ */
47
+ export function createRouteDispatcher({
48
+ pages,
49
+ rpc,
50
+ renderPage,
51
+ }: {
52
+ pages: Pages
53
+ rpc: RemoteRoutes
54
+ renderPage: RenderPage
55
+ }): (routeUrl: string) => RouteHandler {
56
+ const loadRpc = memoizeByKey((url): Promise<AnyRemoteFunction | undefined> | undefined => {
57
+ const loader = rpc[url]
58
+ if (!loader) {
59
+ return undefined
60
+ }
61
+ /*
62
+ Each $rpc module has exactly one named export, validated at build
63
+ time. Pick the first export that looks like a RemoteFunction so the
64
+ framework stays tolerant of incidental re-exports.
65
+ */
66
+ return loader().then((mod) => {
67
+ for (const value of Object.values(mod)) {
68
+ if (typeof value === 'function' && 'method' in value && 'url' in value) {
69
+ return value as AnyRemoteFunction
70
+ }
71
+ }
72
+ return undefined
73
+ })
74
+ })
75
+
76
+ return function buildRouteHandler(routeUrl: string): RouteHandler {
77
+ const hasPage = pages[routeUrl] !== undefined
78
+ const hasRpc = rpc[routeUrl] !== undefined
79
+ return async function routeHandler(req, pathParams, store) {
80
+ const method = req.method as HttpVerb
81
+ if (hasRpc) {
82
+ const fn = await loadRpc(routeUrl)
83
+ if (fn && fn.method === method) {
84
+ return fn.fetch(req)
85
+ }
86
+ return methodNotAllowed(fn ? fn.method : '')
87
+ }
88
+ if (hasPage) {
89
+ if (method !== 'GET' && method !== 'HEAD') {
90
+ return methodNotAllowed('GET, HEAD')
91
+ }
92
+ return renderPage(routeUrl, pathParams, store)
93
+ }
94
+ return new Response('Not Found', {
95
+ status: 404,
96
+ headers: { 'Cache-Control': NO_STORE },
97
+ })
98
+ }
99
+ }
100
+ }
@@ -8,18 +8,14 @@ import { createMcpResourceServer } from '../../mcp/createMcpResourceServer.ts'
8
8
  import { setMcpResourceServer } from '../../mcp/mcpResourceServerSlot.ts'
9
9
  import type { McpServer } from '../../mcp/types/McpServer.ts'
10
10
  import { NO_STORE, SSR_CACHE_CONTROL } from '../../shared/cacheControlValues.ts'
11
- import { createCacheStore } from '../../shared/createCacheStore.ts'
12
11
  import { isDebugEnabled } from '../../shared/isDebugEnabled.ts'
13
12
  import { log } from '../../shared/log.ts'
14
- import { memoizeByKey } from '../../shared/memoizeByKey.ts'
15
13
  import { nearestLayoutPrefix, normalizeLayoutPrefixes } from '../../shared/nearestLayoutPrefix.ts'
16
14
  import { toBunRoutePattern } from '../../shared/toBunRoutePattern.ts'
17
15
  import type { AppModule } from '../AppModule.ts'
18
16
  import { handleCliDownload } from '../cli/handleCliDownload.ts'
19
17
  import { handleCliInstall } from '../cli/handleCliInstall.ts'
20
18
  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
19
  import type { RemoteRoutes } from '../rpc/types/RemoteRoutes.ts'
24
20
  import { createSocketDispatcher } from '../sockets/createSocketDispatcher.ts'
25
21
  import type { SocketRoutes } from '../sockets/types/SocketRoutes.ts'
@@ -29,12 +25,16 @@ import { cacheControlForAsset } from './cacheControlForAsset.ts'
29
25
  import { containsTraversal } from './containsTraversal.ts'
30
26
  import { createAssetHeaderCache } from './createAssetHeaderCache.ts'
31
27
  import { createPublicAssetServer } from './createPublicAssetServer.ts'
28
+ import { createRouteDispatcher } from './createRouteDispatcher.ts'
29
+ import { disableIdleTimeoutForStream } from './disableIdleTimeoutForStream.ts'
32
30
  import { findOpenPort } from './findOpenPort.ts'
33
31
  import { globToPathSet } from './globToPathSet.ts'
32
+ import { internalErrorResponse } from './internalErrorResponse.ts'
34
33
  import { logBrowserOnlyRoutes } from './logBrowserOnlyRoutes.ts'
34
+ import { parseIdleTimeout } from './parseIdleTimeout.ts'
35
35
  import { parsePort } from './parsePort.ts'
36
36
  import { ensureRegistriesLoaded, setRegistryManifests } from './registryManifests.ts'
37
- import { requestContext } from './requestContext.ts'
37
+ import { runWithRequestScope } from './runWithRequestScope.ts'
38
38
  import { safeJsonForScript } from './safeJsonForScript.ts'
39
39
  import { serializeCacheSnapshot } from './serializeCacheSnapshot.ts'
40
40
  import { setActiveServer } from './setActiveServer.ts'
@@ -48,21 +48,6 @@ function wantsJson(req: Request): boolean {
48
48
  // SSR placeholders the shell carries; filled in a single pass per render.
49
49
  const SSR_MARKER = /<!--ssr:(head|body|state)-->/g
50
50
 
51
- /*
52
- The framework's default 500 response — a `<pre>` stack dump. Shared by the
53
- per-request catch and Bun.serve's global error() fallback so the two can't
54
- drift. Only reached when the app supplies no `handleError` hook.
55
- */
56
- function internalErrorResponse(err: unknown): Response {
57
- return new Response(`<pre>${String((err as Error)?.stack ?? err)}</pre>`, {
58
- status: 500,
59
- headers: {
60
- 'Content-Type': 'text/html; charset=utf-8',
61
- 'Cache-Control': NO_STORE,
62
- },
63
- })
64
- }
65
-
66
51
  const IDENTITY_PATH = '/__belte/identity'
67
52
  const SOCKETS_PATH = '/__belte/sockets'
68
53
  const SOCKETS_REST_PREFIX = '/__belte/sockets/'
@@ -79,8 +64,6 @@ conventional root path where external tooling and scanners expect to find it
79
64
  */
80
65
  const OPENAPI_PATH = '/openapi.json'
81
66
 
82
- type AnyRemoteFunction = RemoteFunction<unknown, unknown>
83
-
84
67
  /*
85
68
  Starts a Bun HTTP server that ties together the framework conventions:
86
69
  page.svelte + layout.svelte under src/browser/pages/ for views, one named export
@@ -112,6 +95,13 @@ export async function createServer({
112
95
  // No PORT set → scan for the first open port at/above 3000 rather than
113
96
  // hardcoding 3000, so a second app boots cleanly instead of colliding.
114
97
  port = parsePort(process.env.PORT) ?? findOpenPort(3000),
98
+ /*
99
+ Bun's per-connection idle timeout in seconds (its own default is 10).
100
+ Surfaced for apps whose unary handlers legitimately compute longer than
101
+ that; streaming responses opt out per-request via disableIdleTimeoutForStream
102
+ regardless of this floor.
103
+ */
104
+ idleTimeout = parseIdleTimeout(process.env.BELTE_IDLE_TIMEOUT) ?? 10,
115
105
  }: {
116
106
  pages: Pages
117
107
  rpc: RemoteRoutes
@@ -130,6 +120,7 @@ export async function createServer({
130
120
  publicDir?: string
131
121
  resourcesDir?: string
132
122
  port?: number
123
+ idleTimeout?: number
133
124
  }): Promise<Server<unknown>> {
134
125
  setRegistryManifests({ rpc, sockets, prompts })
135
126
  setMcpResourceServer(createMcpResourceServer({ resourcesDir, mcpResources }))
@@ -153,26 +144,6 @@ export async function createServer({
153
144
  (file) => `/_app/${file.replace(/\.zst$/, '')}`,
154
145
  )
155
146
 
156
- const loadRpc = memoizeByKey((url): Promise<AnyRemoteFunction | undefined> | undefined => {
157
- const loader = rpc[url]
158
- if (!loader) {
159
- return undefined
160
- }
161
- /*
162
- Each $rpc module has exactly one named export, validated at build
163
- time. Pick the first export that looks like a RemoteFunction so the
164
- framework stays tolerant of incidental re-exports.
165
- */
166
- return loader().then((mod) => {
167
- for (const value of Object.values(mod)) {
168
- if (typeof value === 'function' && 'method' in value && 'url' in value) {
169
- return value as AnyRemoteFunction
170
- }
171
- }
172
- return undefined
173
- })
174
- })
175
-
176
147
  const logRequests = isDebugEnabled('belte')
177
148
 
178
149
  // Per-pathname asset header bundles, hashed-chunk-aware Cache-Control.
@@ -274,51 +245,11 @@ export async function createServer({
274
245
  }
275
246
 
276
247
  /*
277
- Per-route handler bound by buildRoutes(). Receives a BunRequest with
278
- `params` filled from the route pattern (only pages use path params;
279
- $rpc URLs are flat). Page URLs (under src/browser/pages/) serve GET/HEAD by
280
- rendering; rpc URLs (under src/server/rpc/, prefixed with `/rpc/`) dispatch
281
- to the single declared verb-bound handler. URLs are disjoint by
282
- construction so each path goes to exactly one branch.
248
+ Route dispatch rpc-vs-page-vs-404 resolution and method matching — lives
249
+ behind createRouteDispatcher; renderPage is injected so those decisions stay
250
+ testable without SSR. buildRoutes() below binds the returned handler per URL.
283
251
  */
284
- function buildRouteHandler(routeUrl: string) {
285
- const hasPage = pages[routeUrl] !== undefined
286
- const hasRpc = rpc[routeUrl] !== undefined
287
- return async function routeHandler(
288
- req: Request,
289
- pathParams: Record<string, string>,
290
- store: RequestStore,
291
- ): Promise<Response> {
292
- const method = req.method as HttpVerb
293
- if (hasRpc) {
294
- const fn = await loadRpc(routeUrl)
295
- if (fn && fn.method === method) {
296
- return fn.fetch(req)
297
- }
298
- const allow = fn ? fn.method : ''
299
- return new Response('Method Not Allowed', {
300
- status: 405,
301
- headers: {
302
- Allow: allow,
303
- 'Cache-Control': NO_STORE,
304
- },
305
- })
306
- }
307
- if (hasPage) {
308
- if (method !== 'GET' && method !== 'HEAD') {
309
- return new Response('Method Not Allowed', {
310
- status: 405,
311
- headers: { Allow: 'GET, HEAD', 'Cache-Control': NO_STORE },
312
- })
313
- }
314
- return renderPage(routeUrl, pathParams, store)
315
- }
316
- return new Response('Not Found', {
317
- status: 404,
318
- headers: { 'Cache-Control': NO_STORE },
319
- })
320
- }
321
- }
252
+ const buildRouteHandler = createRouteDispatcher({ pages, rpc, renderPage })
322
253
 
323
254
  /*
324
255
  Page URLs (folder paths, e.g. `/media/[id]`) get translated to Bun's
@@ -361,42 +292,12 @@ export async function createServer({
361
292
  store: RequestStore,
362
293
  ) => Promise<Response>,
363
294
  ): Promise<Response> {
364
- return runWithStore(req, async (store) => {
365
- if (!app?.handle) {
366
- return handler(req, pathParams, store)
367
- }
368
- return app.handle(req, (next) => handler(next, pathParams, store))
369
- })
370
- }
371
-
372
- function runWithStore(
373
- req: Request,
374
- body: (store: RequestStore) => Promise<Response>,
375
- ): Promise<Response> {
376
- const url = new URL(req.url)
377
- const store: RequestStore = {
378
- url,
379
- req,
380
- cache: createCacheStore(),
381
- }
382
- return requestContext.run(store, async () => {
383
- const start = logRequests ? Bun.nanoseconds() : 0
384
- let response: Response
385
- try {
386
- response = await body(store)
387
- } catch (error) {
388
- if (app?.handleError) {
389
- response = await app.handleError(error, req)
390
- } else {
391
- log.error(error)
392
- response = internalErrorResponse(error)
393
- }
394
- }
395
- if (logRequests) {
396
- const ms = (Bun.nanoseconds() - start) / 1e6
397
- log.request(req.method, `${url.pathname}${url.search}`, response.status, ms)
398
- }
399
- return response
295
+ return runWithRequestScope(req, { app, logRequests }, async (store) => {
296
+ const response = app?.handle
297
+ ? await app.handle(req, (next) => handler(next, pathParams, store))
298
+ : await handler(req, pathParams, store)
299
+ // Streaming bodies (sse/jsonl, socket tail) opt out of the idle timeout.
300
+ return disableIdleTimeoutForStream(server, req, response)
400
301
  })
401
302
  }
402
303
 
@@ -412,6 +313,7 @@ export async function createServer({
412
313
  // Server<unknown> pins Bun's WebSocketData generic so upgrade({ data: {} }) typechecks.
413
314
  const server: Server<unknown> = Bun.serve({
414
315
  port,
316
+ idleTimeout,
415
317
 
416
318
  websocket: {
417
319
  open(ws) {
@@ -0,0 +1,23 @@
1
+ import type { Server } from 'bun'
2
+ import { isStreamingResponse } from '../../shared/isStreamingResponse.ts'
3
+
4
+ /*
5
+ Opts a streaming response out of Bun's per-connection idle timeout. A stream
6
+ can stay quiet for longer than the 10s default between frames, which Bun would
7
+ otherwise read as an idle connection and close mid-stream. `server.timeout(req,
8
+ 0)` clears the timeout for just this in-flight request, leaving the global
9
+ default in place for ordinary request/response traffic. Streaming is detected
10
+ by Content-Type (the shared signal the CLI/MCP drain paths use) rather than
11
+ `body instanceof ReadableStream`, since every bodied Response exposes one.
12
+ Non-stream responses pass through untouched.
13
+ */
14
+ export function disableIdleTimeoutForStream(
15
+ server: Server<unknown>,
16
+ req: Request,
17
+ response: Response,
18
+ ): Response {
19
+ if (isStreamingResponse(response)) {
20
+ server.timeout(req, 0)
21
+ }
22
+ return response
23
+ }
@@ -0,0 +1,17 @@
1
+ import { NO_STORE } from '../../shared/cacheControlValues.ts'
2
+
3
+ /*
4
+ The framework's default 500 response — a `<pre>` stack dump. Shared by the
5
+ per-request scope's catch (runWithRequestScope) and Bun.serve's global
6
+ error() fallback so the two can't drift. Only reached when the app supplies
7
+ no `handleError` hook.
8
+ */
9
+ export function internalErrorResponse(error: unknown): Response {
10
+ return new Response(`<pre>${String((error as Error)?.stack ?? error)}</pre>`, {
11
+ status: 500,
12
+ headers: {
13
+ 'Content-Type': 'text/html; charset=utf-8',
14
+ 'Cache-Control': NO_STORE,
15
+ },
16
+ })
17
+ }
@@ -0,0 +1,10 @@
1
+ import { parseBoundedEnvInt } from '../../shared/parseBoundedEnvInt.ts'
2
+
3
+ /*
4
+ Parses BELTE_IDLE_TIMEOUT into Bun's per-connection idle timeout in seconds.
5
+ Bun accepts 0–255 (0 disables the timeout); returns undefined for missing,
6
+ empty, or out-of-range/non-integer input so the caller keeps its default.
7
+ */
8
+ export function parseIdleTimeout(value: string | undefined): number | undefined {
9
+ return parseBoundedEnvInt(value, 0, 255)
10
+ }
@@ -1,16 +1,11 @@
1
+ import { parseBoundedEnvInt } from '../../shared/parseBoundedEnvInt.ts'
2
+
1
3
  /*
2
- Parses a PORT env value into a usable TCP port, returning undefined for
3
- missing, empty, or out-of-range/non-integer input so the caller can fall back
4
- to a default. A bare Number() turns '' into 0 (a random kernel-assigned port)
5
- and 'abc' into NaN, both silently wrong; this rejects them instead.
4
+ Parses a PORT env value into a usable TCP port (0–65535), returning undefined
5
+ for missing, empty, or out-of-range/non-integer input so the caller can fall
6
+ back to a default. A bare Number() would turn '' into 0 (a random
7
+ kernel-assigned port) and 'abc' into NaN, both silently wrong.
6
8
  */
7
9
  export function parsePort(value: string | undefined): number | undefined {
8
- if (value === undefined || value.trim() === '') {
9
- return undefined
10
- }
11
- const port = Number(value)
12
- if (!Number.isInteger(port) || port < 0 || port > 65535) {
13
- return undefined
14
- }
15
- return port
10
+ return parseBoundedEnvInt(value, 0, 65535)
16
11
  }
@@ -0,0 +1,47 @@
1
+ import { createCacheStore } from '../../shared/createCacheStore.ts'
2
+ import { log } from '../../shared/log.ts'
3
+ import type { AppModule } from '../AppModule.ts'
4
+ import { internalErrorResponse } from './internalErrorResponse.ts'
5
+ import { requestContext } from './requestContext.ts'
6
+ import type { RequestStore } from './types/RequestStore.ts'
7
+
8
+ /*
9
+ Establishes the per-request scope and runs `body` inside it: a fresh
10
+ CacheStore plus request metadata published through the AsyncLocalStorage
11
+ RequestStore (so cache() and request()/server() resolve without threading
12
+ args), the app's handleError — or the framework's 500 fallback — on a thrown
13
+ body, and optional request logging. The single seam every dynamic route
14
+ crosses; extracted from createServer so the scope, error, and logging
15
+ behaviour is exercisable through this interface without booting a Bun server.
16
+ */
17
+ export function runWithRequestScope(
18
+ req: Request,
19
+ options: { app?: AppModule; logRequests: boolean },
20
+ body: (store: RequestStore) => Promise<Response>,
21
+ ): Promise<Response> {
22
+ const url = new URL(req.url)
23
+ const store: RequestStore = {
24
+ url,
25
+ req,
26
+ cache: createCacheStore(),
27
+ }
28
+ return requestContext.run(store, async () => {
29
+ const start = options.logRequests ? Bun.nanoseconds() : 0
30
+ let response: Response
31
+ try {
32
+ response = await body(store)
33
+ } catch (error) {
34
+ if (options.app?.handleError) {
35
+ response = await options.app.handleError(error, req)
36
+ } else {
37
+ log.error(error)
38
+ response = internalErrorResponse(error)
39
+ }
40
+ }
41
+ if (options.logRequests) {
42
+ const ms = (Bun.nanoseconds() - start) / 1e6
43
+ log.request(req.method, `${url.pathname}${url.search}`, response.status, ms)
44
+ }
45
+ return response
46
+ })
47
+ }
@@ -0,0 +1,20 @@
1
+ /*
2
+ Parses an env string into an integer within [min, max], returning undefined for
3
+ missing, empty, or out-of-range/non-integer input so the caller can fall back to
4
+ a default. A bare Number() turns '' into 0 and 'abc' into NaN, both silently
5
+ wrong; this rejects them instead.
6
+ */
7
+ export function parseBoundedEnvInt(
8
+ value: string | undefined,
9
+ min: number,
10
+ max: number,
11
+ ): number | undefined {
12
+ if (value === undefined || value.trim() === '') {
13
+ return undefined
14
+ }
15
+ const parsed = Number(value)
16
+ if (!Number.isInteger(parsed) || parsed < min || parsed > max) {
17
+ return undefined
18
+ }
19
+ return parsed
20
+ }