@briancray/belte 0.6.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 +1 -1
- package/src/lib/browser/cache.ts +29 -7
- package/src/lib/server/runtime/createRouteDispatcher.ts +100 -0
- package/src/lib/server/runtime/createServer.ts +24 -122
- package/src/lib/server/runtime/disableIdleTimeoutForStream.ts +23 -0
- package/src/lib/server/runtime/internalErrorResponse.ts +17 -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/src/lib/shared/types/CacheEntry.ts +4 -3
- package/src/lib/shared/types/CacheOptions.ts +4 -3
package/package.json
CHANGED
package/src/lib/browser/cache.ts
CHANGED
|
@@ -63,10 +63,12 @@ export function cache<Args, Return>(
|
|
|
63
63
|
/*
|
|
64
64
|
Tag an existing entry with this call's scope so a later
|
|
65
65
|
cache.invalidate({ scope }) reaches entries hydrated from the SSR
|
|
66
|
-
snapshot (which carry a value but no scope) without a refetch.
|
|
66
|
+
snapshot (which carry a value but no scope) without a refetch. Merge
|
|
67
|
+
rather than replace so a read tagging one group can't drop tags a
|
|
68
|
+
different read site already added.
|
|
67
69
|
*/
|
|
68
70
|
if (existing && options?.scope !== undefined) {
|
|
69
|
-
existing.scope = options.scope
|
|
71
|
+
existing.scope = mergeScopes(existing.scope, options.scope)
|
|
70
72
|
}
|
|
71
73
|
/*
|
|
72
74
|
Snapshot warm path: hydration pre-decoded the SSR body onto the
|
|
@@ -117,7 +119,7 @@ function invokeWithCache<Args>(
|
|
|
117
119
|
request,
|
|
118
120
|
ttl,
|
|
119
121
|
expiresAt: undefined as number | undefined,
|
|
120
|
-
scope: options?.scope,
|
|
122
|
+
scope: options?.scope === undefined ? undefined : toScopeSet(options.scope),
|
|
121
123
|
}
|
|
122
124
|
store.entries.set(key, entry)
|
|
123
125
|
function deleteIfCurrent() {
|
|
@@ -163,10 +165,11 @@ function shareable(promise: Promise<Response>): Promise<Response> {
|
|
|
163
165
|
Three call shapes:
|
|
164
166
|
invalidate() → drop everything
|
|
165
167
|
invalidate(fn) → drop one function's calls (method+url prefix)
|
|
166
|
-
invalidate({ key?, scope? }) → drop one entry by key and/or
|
|
168
|
+
invalidate({ key?, scope? }) → drop one entry by key and/or tagged groups
|
|
167
169
|
A selector with both fields drops the union; an empty or unmatched selector
|
|
168
170
|
is a no-op. `key` accepts the same string/array/object the cache() `key`
|
|
169
|
-
option does and is canonicalised the same way.
|
|
171
|
+
option does and is canonicalised the same way. `scope` accepts one tag or an
|
|
172
|
+
array; an entry is dropped when its tag set shares any tag with the request.
|
|
170
173
|
*/
|
|
171
174
|
function invalidate<Args, Return>(
|
|
172
175
|
arg?: AnyRemote<Args, Return> | Pick<CacheOptions, 'key' | 'scope'>,
|
|
@@ -195,11 +198,15 @@ function invalidate<Args, Return>(
|
|
|
195
198
|
}
|
|
196
199
|
const target = arg.key !== undefined ? canonicalKey(arg.key) : undefined
|
|
197
200
|
const byKey = target !== undefined && store.entries.has(target) ? [target] : []
|
|
201
|
+
const requestedScopes = arg.scope === undefined ? undefined : toScopeSet(arg.scope)
|
|
198
202
|
const byScope =
|
|
199
|
-
|
|
203
|
+
requestedScopes === undefined
|
|
200
204
|
? []
|
|
201
205
|
: Array.from(store.entries.values())
|
|
202
|
-
.filter(
|
|
206
|
+
.filter(
|
|
207
|
+
(entry) =>
|
|
208
|
+
entry.scope !== undefined && intersects(entry.scope, requestedScopes),
|
|
209
|
+
)
|
|
203
210
|
.map((entry) => entry.key)
|
|
204
211
|
/* emit() dedupes via a Set, so a key matching both criteria is harmless. */
|
|
205
212
|
const affected = [...byKey, ...byScope]
|
|
@@ -227,6 +234,21 @@ function canonicalKey(value: CacheOptions['key']): string {
|
|
|
227
234
|
return canonicalJson(value)
|
|
228
235
|
}
|
|
229
236
|
|
|
237
|
+
/* Normalizes a scope option (one tag or many) to a Set for O(1) membership. */
|
|
238
|
+
function toScopeSet(scope: string | string[]): Set<string> {
|
|
239
|
+
return new Set(typeof scope === 'string' ? [scope] : scope)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/* Folds new tags into an entry's existing set without duplicating them. */
|
|
243
|
+
function mergeScopes(existing: Set<string> | undefined, incoming: string | string[]): Set<string> {
|
|
244
|
+
return new Set([...(existing ?? []), ...toScopeSet(incoming)])
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/* True when an entry's tags and the requested tags overlap on any tag. */
|
|
248
|
+
function intersects(entryScopes: Set<string>, requestedScopes: Set<string>): boolean {
|
|
249
|
+
return Array.from(requestedScopes).some((scope) => entryScopes.has(scope))
|
|
250
|
+
}
|
|
251
|
+
|
|
230
252
|
/*
|
|
231
253
|
Detail is a Set so each subscriber's `has(key)` check is O(1) regardless of
|
|
232
254
|
how many keys a single invalidate touches.
|
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
|
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
|
+
}
|
|
@@ -12,8 +12,9 @@ snapshot body is pre-decoded synchronously so the first client render can
|
|
|
12
12
|
read it without a microtask hop and byte-match the SSR DOM. Live fetches
|
|
13
13
|
leave it undefined and take the async decode path.
|
|
14
14
|
|
|
15
|
-
`scope`
|
|
16
|
-
`cache.invalidate({ scope })` can drop every entry sharing
|
|
15
|
+
`scope` holds the cache() call's scope tags as a Set so
|
|
16
|
+
`cache.invalidate({ scope })` can drop every entry sharing any tag with O(1)
|
|
17
|
+
membership; a re-read merges new tags in rather than replacing them.
|
|
17
18
|
*/
|
|
18
19
|
export type CacheEntry = {
|
|
19
20
|
key: string
|
|
@@ -22,5 +23,5 @@ export type CacheEntry = {
|
|
|
22
23
|
ttl: number | undefined
|
|
23
24
|
expiresAt: number | undefined
|
|
24
25
|
value?: unknown
|
|
25
|
-
scope?: string
|
|
26
|
+
scope?: Set<string>
|
|
26
27
|
}
|
|
@@ -3,11 +3,12 @@ Options for cache(). `key` overrides the auto-derived WeakMap key — useful
|
|
|
3
3
|
when sharing entries across calls or stripping noisy args. `ttl` is the
|
|
4
4
|
milliseconds-past-resolve that the entry stays live: omitted = forever, 0 =
|
|
5
5
|
dedupe only (entry dropped once the promise settles), any other number = TTL.
|
|
6
|
-
`scope` is
|
|
7
|
-
`cache.invalidate({ scope })` drops them
|
|
6
|
+
`scope` is one or more free-form tags grouping unrelated calls so one
|
|
7
|
+
`cache.invalidate({ scope })` drops every entry sharing any of them — pass an
|
|
8
|
+
array when a call belongs to multiple invalidation groups.
|
|
8
9
|
*/
|
|
9
10
|
export type CacheOptions = {
|
|
10
11
|
key?: string | unknown[] | Record<string, unknown>
|
|
11
12
|
ttl?: number
|
|
12
|
-
scope?: string
|
|
13
|
+
scope?: string | string[]
|
|
13
14
|
}
|