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