@briancray/belte 0.7.0 → 0.8.1

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.1",
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'
32
- import { findOpenPort } from './findOpenPort.ts'
28
+ import { createRouteDispatcher } from './createRouteDispatcher.ts'
29
+ import { disableIdleTimeoutForStream } from './disableIdleTimeoutForStream.ts'
33
30
  import { globToPathSet } from './globToPathSet.ts'
31
+ import { internalErrorResponse } from './internalErrorResponse.ts'
32
+ import { listenOnOpenPort } from './listenOnOpenPort.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
@@ -109,9 +92,16 @@ export async function createServer({
109
92
  distDir = `${process.cwd()}/dist`,
110
93
  publicDir = `${process.cwd()}/src/browser/public`,
111
94
  resourcesDir = `${process.cwd()}/src/mcp/resources`,
112
- // No PORT set scan for the first open port at/above 3000 rather than
113
- // hardcoding 3000, so a second app boots cleanly instead of colliding.
114
- port = parsePort(process.env.PORT) ?? findOpenPort(3000),
95
+ // A configured PORT is honored as-is; left undefined, the real listener
96
+ // scans upward from 3000 at bind time (see buildServer / listenOnOpenPort).
97
+ port = parsePort(process.env.PORT),
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
 
@@ -409,136 +310,155 @@ export async function createServer({
409
310
  a busy socket doesn't iterate JS per subscriber per message.
410
311
  */
411
312
  const socketDispatcher = createSocketDispatcher(sockets)
412
- // Server<unknown> pins Bun's WebSocketData generic so upgrade({ data: {} }) typechecks.
413
- const server: Server<unknown> = Bun.serve({
414
- port,
415
313
 
416
- websocket: {
417
- open(ws) {
418
- socketDispatcher.open(ws)
419
- },
420
- message(ws, data) {
421
- socketDispatcher.message(ws, data)
422
- },
423
- close(ws) {
424
- socketDispatcher.close(ws)
314
+ /*
315
+ Bind the real server on `boundPort`. Only the port varies between scan
316
+ attempts, so the rest of the config lives inline and just the port is spread
317
+ in — passing the literal straight to Bun.serve keeps contextual typing of the
318
+ websocket handlers (and Server<unknown> pins Bun's WebSocketData generic so
319
+ upgrade({ data: {} }) typechecks).
320
+ */
321
+ const bindAt = (boundPort: number): Server<unknown> =>
322
+ Bun.serve({
323
+ port: boundPort,
324
+ idleTimeout,
325
+
326
+ websocket: {
327
+ open(ws) {
328
+ socketDispatcher.open(ws)
329
+ },
330
+ message(ws, data) {
331
+ socketDispatcher.message(ws, data)
332
+ },
333
+ close(ws) {
334
+ socketDispatcher.close(ws)
335
+ },
425
336
  },
426
- },
427
337
 
428
- routes,
338
+ routes,
429
339
 
430
- async fetch(req, bunServer) {
431
- const url = new URL(req.url)
432
- /*
433
- Identity probe — answered directly, ahead of any app.handle middleware,
434
- so the bundle's connect screen can confirm a URL really is a belte
435
- server (and which app) before pointing the desktop window at it. It
436
- must stay reachable even when the app guards everything behind auth,
437
- hence the early return that bypasses dispatchRequest.
438
- */
439
- if (url.pathname === IDENTITY_PATH) {
440
- return Response.json(
441
- {
442
- belte: true,
443
- name: appInfo?.name ?? cliName,
444
- version: appInfo?.version ?? '0.0.0',
445
- },
446
- { headers: { 'Cache-Control': NO_STORE } },
447
- )
448
- }
449
- if (url.pathname === SOCKETS_PATH) {
450
- if (bunServer.upgrade(req, { data: {} })) {
451
- return undefined as unknown as Response
340
+ async fetch(req, bunServer) {
341
+ const url = new URL(req.url)
342
+ /*
343
+ Identity probe — answered directly, ahead of any app.handle middleware,
344
+ so the bundle's connect screen can confirm a URL really is a belte
345
+ server (and which app) before pointing the desktop window at it. It
346
+ must stay reachable even when the app guards everything behind auth,
347
+ hence the early return that bypasses dispatchRequest.
348
+ */
349
+ if (url.pathname === IDENTITY_PATH) {
350
+ return Response.json(
351
+ {
352
+ belte: true,
353
+ name: appInfo?.name ?? cliName,
354
+ version: appInfo?.version ?? '0.0.0',
355
+ },
356
+ { headers: { 'Cache-Control': NO_STORE } },
357
+ )
452
358
  }
453
- return new Response('Upgrade failed', { status: 400 })
454
- }
455
- /*
456
- HTTP face of a socket (`/__belte/sockets/<name>`) — tail over
457
- SSE / JSON and publish for the CLI and MCP. Runs through
458
- dispatchRequest so app.handle auth applies, like the rpc paths.
459
- The socket name may contain `/` (nested files), so it's the
460
- whole remaining pathname, percent-decoded.
461
- */
462
- if (url.pathname.startsWith(SOCKETS_REST_PREFIX)) {
463
- const name = decodeURIComponent(url.pathname.slice(SOCKETS_REST_PREFIX.length))
464
- return dispatchRequest(req, {}, async () => socketDispatcher.rest(req, name))
465
- }
466
- if (url.pathname === MCP_PATH && mcp) {
467
- return dispatchRequest(req, {}, async () => mcp.handle(req))
468
- }
469
- if (url.pathname === CLI_PATH) {
470
- return dispatchRequest(req, {}, async () => handleCliInstall(req, cliName))
471
- }
472
- if (url.pathname.startsWith(CLI_DOWNLOAD_PREFIX)) {
473
- const platform = url.pathname.slice(CLI_DOWNLOAD_PREFIX.length)
474
- return dispatchRequest(req, {}, async () =>
475
- handleCliDownload(req, platform, cliName, cliCwd),
476
- )
477
- }
478
- if (url.pathname === OPENAPI_PATH) {
479
- return dispatchRequest(req, {}, async () => {
480
- await ensureRegistriesLoaded()
481
- const spec = buildOpenApiSpec({
482
- title: appInfo?.name ?? cliName,
483
- version: appInfo?.version ?? '0.0.0',
484
- })
485
- return Response.json(spec, { headers: { 'Cache-Control': NO_STORE } })
486
- })
487
- }
488
- /*
489
- Static assets sidestep ALS + the per-request CacheStore + the
490
- app.handle middleware: they have no need for cache() and the
491
- allocation overhead matters on a cold page load that pulls
492
- dozens of chunks. The global server.error() handler still
493
- catches anything that goes wrong inside serveStaticAsset.
494
- */
495
- if (url.pathname.startsWith('/_app/')) {
496
- if (!logRequests) {
497
- return serveStaticAsset(req, url)
359
+ if (url.pathname === SOCKETS_PATH) {
360
+ if (bunServer.upgrade(req, { data: {} })) {
361
+ return undefined as unknown as Response
362
+ }
363
+ return new Response('Upgrade failed', { status: 400 })
498
364
  }
499
- const start = Bun.nanoseconds()
500
- const response = await serveStaticAsset(req, url)
501
- const ms = (Bun.nanoseconds() - start) / 1e6
502
- log.request(req.method, `${url.pathname}${url.search}`, response.status, ms)
503
- return response
504
- }
505
- /*
506
- Files under public/ are served at the site root, sidestepping
507
- ALS + middleware like the /_app/ assets do. A miss returns
508
- undefined so the request falls through to the 404 / middleware
509
- path below.
510
- */
511
- const publicResponse = await servePublicAsset(req, url)
512
- if (publicResponse) {
513
- if (logRequests) {
514
- log.request(
515
- req.method,
516
- `${url.pathname}${url.search}`,
517
- publicResponse.status,
518
- 0,
365
+ /*
366
+ HTTP face of a socket (`/__belte/sockets/<name>`) — tail over
367
+ SSE / JSON and publish for the CLI and MCP. Runs through
368
+ dispatchRequest so app.handle auth applies, like the rpc paths.
369
+ The socket name may contain `/` (nested files), so it's the
370
+ whole remaining pathname, percent-decoded.
371
+ */
372
+ if (url.pathname.startsWith(SOCKETS_REST_PREFIX)) {
373
+ const name = decodeURIComponent(url.pathname.slice(SOCKETS_REST_PREFIX.length))
374
+ return dispatchRequest(req, {}, async () => socketDispatcher.rest(req, name))
375
+ }
376
+ if (url.pathname === MCP_PATH && mcp) {
377
+ return dispatchRequest(req, {}, async () => mcp.handle(req))
378
+ }
379
+ if (url.pathname === CLI_PATH) {
380
+ return dispatchRequest(req, {}, async () => handleCliInstall(req, cliName))
381
+ }
382
+ if (url.pathname.startsWith(CLI_DOWNLOAD_PREFIX)) {
383
+ const platform = url.pathname.slice(CLI_DOWNLOAD_PREFIX.length)
384
+ return dispatchRequest(req, {}, async () =>
385
+ handleCliDownload(req, platform, cliName, cliCwd),
519
386
  )
520
387
  }
521
- return publicResponse
522
- }
523
- /*
524
- Unknown routes still run through dispatchRequest so user-defined
525
- app.handle middleware can rewrite the request, serve a custom
526
- 404, or branch on the URL. The inner handler returns the
527
- framework's default 404 when nothing intervenes.
528
- */
529
- return dispatchRequest(req, {}, async () => {
530
- return new Response('Not Found', {
531
- status: 404,
532
- headers: { 'Cache-Control': NO_STORE },
388
+ if (url.pathname === OPENAPI_PATH) {
389
+ return dispatchRequest(req, {}, async () => {
390
+ await ensureRegistriesLoaded()
391
+ const spec = buildOpenApiSpec({
392
+ title: appInfo?.name ?? cliName,
393
+ version: appInfo?.version ?? '0.0.0',
394
+ })
395
+ return Response.json(spec, { headers: { 'Cache-Control': NO_STORE } })
396
+ })
397
+ }
398
+ /*
399
+ Static assets sidestep ALS + the per-request CacheStore + the
400
+ app.handle middleware: they have no need for cache() and the
401
+ allocation overhead matters on a cold page load that pulls
402
+ dozens of chunks. The global server.error() handler still
403
+ catches anything that goes wrong inside serveStaticAsset.
404
+ */
405
+ if (url.pathname.startsWith('/_app/')) {
406
+ if (!logRequests) {
407
+ return serveStaticAsset(req, url)
408
+ }
409
+ const start = Bun.nanoseconds()
410
+ const response = await serveStaticAsset(req, url)
411
+ const ms = (Bun.nanoseconds() - start) / 1e6
412
+ log.request(req.method, `${url.pathname}${url.search}`, response.status, ms)
413
+ return response
414
+ }
415
+ /*
416
+ Files under public/ are served at the site root, sidestepping
417
+ ALS + middleware like the /_app/ assets do. A miss returns
418
+ undefined so the request falls through to the 404 / middleware
419
+ path below.
420
+ */
421
+ const publicResponse = await servePublicAsset(req, url)
422
+ if (publicResponse) {
423
+ if (logRequests) {
424
+ log.request(
425
+ req.method,
426
+ `${url.pathname}${url.search}`,
427
+ publicResponse.status,
428
+ 0,
429
+ )
430
+ }
431
+ return publicResponse
432
+ }
433
+ /*
434
+ Unknown routes still run through dispatchRequest so user-defined
435
+ app.handle middleware can rewrite the request, serve a custom
436
+ 404, or branch on the URL. The inner handler returns the
437
+ framework's default 404 when nothing intervenes.
438
+ */
439
+ return dispatchRequest(req, {}, async () => {
440
+ return new Response('Not Found', {
441
+ status: 404,
442
+ headers: { 'Cache-Control': NO_STORE },
443
+ })
533
444
  })
534
- })
535
- },
445
+ },
446
+
447
+ error(err) {
448
+ log.error(err)
449
+ return internalErrorResponse(err)
450
+ },
451
+ })
536
452
 
537
- error(err) {
538
- log.error(err)
539
- return internalErrorResponse(err)
540
- },
541
- })
453
+ /*
454
+ A configured PORT binds that exact port — a collision surfaces loudly rather
455
+ than silently moving, since something connecting to the app needs a known
456
+ address. With none set, scan upward from 3000 binding the real listener, so
457
+ whichever server wins the port keeps it (no probe-release gap to lose it in,
458
+ which used to crash boot on EADDRINUSE instead of stepping to the next port).
459
+ */
460
+ const server: Server<unknown> =
461
+ port === undefined ? listenOnOpenPort(bindAt, 3000) : bindAt(port)
542
462
 
543
463
  /*
544
464
  Publishes the live server through `belte/server` before invoking the
@@ -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,36 @@
1
+ import type { Server } from 'bun'
2
+
3
+ // Ports tried upward from `start` before giving up and letting the kernel assign one.
4
+ const SCAN_RANGE = 100
5
+
6
+ /*
7
+ Binds the real server, scanning upward from `start` for the first free port.
8
+ The listener that wins a port is the one that keeps it: unlike probing a
9
+ throwaway server and releasing it before the real bind, this leaves no window
10
+ for the chosen port to be stolen in between — the gap that crashed boot on
11
+ EADDRINUSE instead of stepping to the next port. `bindAt` does the actual
12
+ Bun.serve; only an in-use port is retried, any other failure propagates. After
13
+ SCAN_RANGE occupied ports it binds port 0 so the kernel assigns any free port.
14
+ */
15
+ export function listenOnOpenPort(
16
+ bindAt: (port: number) => Server<unknown>,
17
+ start: number,
18
+ ): Server<unknown> {
19
+ for (let port = start; port < start + SCAN_RANGE; port++) {
20
+ try {
21
+ return bindAt(port)
22
+ } catch (error) {
23
+ if (!isAddressInUse(error)) {
24
+ throw error
25
+ }
26
+ // port in use — try the next one up
27
+ }
28
+ }
29
+ // every candidate was taken; bind to 0 so the kernel picks a free port
30
+ return bindAt(0)
31
+ }
32
+
33
+ // Bun reports a taken port as an Error carrying code 'EADDRINUSE'.
34
+ function isAddressInUse(error: unknown): boolean {
35
+ return error instanceof Error && (error as { code?: string }).code === 'EADDRINUSE'
36
+ }
@@ -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
+ }