@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 +1 -1
- package/src/lib/server/runtime/createRouteDispatcher.ts +100 -0
- package/src/lib/server/runtime/createServer.ts +166 -246
- package/src/lib/server/runtime/disableIdleTimeoutForStream.ts +23 -0
- package/src/lib/server/runtime/internalErrorResponse.ts +17 -0
- package/src/lib/server/runtime/listenOnOpenPort.ts +36 -0
- package/src/lib/server/runtime/parseIdleTimeout.ts +10 -0
- package/src/lib/server/runtime/parsePort.ts +7 -12
- package/src/lib/server/runtime/runWithRequestScope.ts +47 -0
- package/src/lib/shared/parseBoundedEnvInt.ts +20 -0
package/package.json
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
-
//
|
|
113
|
-
//
|
|
114
|
-
port = parsePort(process.env.PORT)
|
|
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
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
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
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
|
|
338
|
+
routes,
|
|
429
339
|
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
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
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
if (
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
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
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
-
|
|
538
|
-
|
|
539
|
-
|
|
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
|
|
3
|
-
missing, empty, or out-of-range/non-integer input so the caller can fall
|
|
4
|
-
to a default. A bare Number()
|
|
5
|
-
and 'abc' into NaN, both silently wrong
|
|
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
|
-
|
|
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
|
+
}
|