@bakery-framework/core 1.0.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.
Files changed (91) hide show
  1. package/LICENSE +19 -0
  2. package/README.md +89 -0
  3. package/package.json +69 -0
  4. package/src/cache/index.ts +8 -0
  5. package/src/cache/lru.ts +41 -0
  6. package/src/cache/shared-db.ts +51 -0
  7. package/src/cache/string.ts +150 -0
  8. package/src/cache/tiered.ts +493 -0
  9. package/src/client/globals.d.ts +74 -0
  10. package/src/client/livereload.ts +437 -0
  11. package/src/client/utils.ts +315 -0
  12. package/src/compiler/compiler.ts +263 -0
  13. package/src/compiler/dev-service.ts +660 -0
  14. package/src/compiler/index.ts +2 -0
  15. package/src/compiler/prompt-tracker.ts +36 -0
  16. package/src/compiler/tsconfig-sync.ts +71 -0
  17. package/src/core/bakery.ts +96 -0
  18. package/src/core/cache-version.ts +119 -0
  19. package/src/core/config.ts +296 -0
  20. package/src/core/context.ts +121 -0
  21. package/src/core/index.ts +61 -0
  22. package/src/core/init.ts +90 -0
  23. package/src/core/jsx.ts +152 -0
  24. package/src/core/paths.ts +24 -0
  25. package/src/core/plugins.ts +120 -0
  26. package/src/core/port.ts +73 -0
  27. package/src/global.d.ts +374 -0
  28. package/src/handlers/assets/google-font.ts +225 -0
  29. package/src/handlers/assets/image.ts +136 -0
  30. package/src/handlers/assets/nm.ts +73 -0
  31. package/src/handlers/assets/public.ts +17 -0
  32. package/src/handlers/assets/static.ts +86 -0
  33. package/src/handlers/assets/ts.ts +61 -0
  34. package/src/handlers/assets/tsx.ts +106 -0
  35. package/src/handlers/assets/virtual-asset.ts +104 -0
  36. package/src/handlers/core/$base.ts +256 -0
  37. package/src/handlers/core/$dynamic.ts +285 -0
  38. package/src/handlers/core/$error.ts +301 -0
  39. package/src/handlers/core/$middleware.ts +71 -0
  40. package/src/handlers/core/$mounts.ts +84 -0
  41. package/src/handlers/core/$registry.ts +153 -0
  42. package/src/handlers/core/$routing.ts +205 -0
  43. package/src/handlers/core/$static.ts +100 -0
  44. package/src/handlers/core/$websocket.ts +52 -0
  45. package/src/handlers/index.ts +21 -0
  46. package/src/handlers/routes/api.ts +95 -0
  47. package/src/handlers/routes/html.ts +95 -0
  48. package/src/handlers/routes/livereload.ts +54 -0
  49. package/src/handlers/routes/proxy.ts +74 -0
  50. package/src/logger/clients.ts +12 -0
  51. package/src/logger/index.ts +3 -0
  52. package/src/logger/logger.ts +375 -0
  53. package/src/logger/serve-log.ts +206 -0
  54. package/src/plugins/index.ts +15 -0
  55. package/src/plugins/routes.ts +110 -0
  56. package/src/plugins/types.ts +19 -0
  57. package/src/router.ts +351 -0
  58. package/src/session.ts +556 -0
  59. package/src/shared.d.ts +63 -0
  60. package/src/startup.ts +154 -0
  61. package/src/types.d.ts +111 -0
  62. package/src/utils/common/case.ts +11 -0
  63. package/src/utils/common/index.ts +5 -0
  64. package/src/utils/common/json.ts +35 -0
  65. package/src/utils/common/match.ts +6 -0
  66. package/src/utils/common/misc.ts +53 -0
  67. package/src/utils/common/try.ts +6 -0
  68. package/src/utils/constants.ts +153 -0
  69. package/src/utils/fs.ts +621 -0
  70. package/src/utils/http/body.ts +65 -0
  71. package/src/utils/http/csrf.ts +111 -0
  72. package/src/utils/http/dom.ts +238 -0
  73. package/src/utils/http/escape.ts +8 -0
  74. package/src/utils/http/etag.ts +318 -0
  75. package/src/utils/http/html.ts +525 -0
  76. package/src/utils/http/index.ts +8 -0
  77. package/src/utils/http/ip.ts +32 -0
  78. package/src/utils/http/response.ts +129 -0
  79. package/src/utils/index.ts +4 -0
  80. package/src/utils/isomorphic/case.ts +52 -0
  81. package/src/utils/isomorphic/escape.ts +43 -0
  82. package/src/utils/isomorphic/index.ts +15 -0
  83. package/src/utils/isomorphic/is.ts +36 -0
  84. package/src/utils/isomorphic/match.ts +50 -0
  85. package/src/utils/isomorphic/math.ts +11 -0
  86. package/src/utils/isomorphic/misc.ts +22 -0
  87. package/src/utils/isomorphic/stringify.ts +42 -0
  88. package/src/utils/isomorphic/try.ts +94 -0
  89. package/src/utils/jsonc.ts +10 -0
  90. package/src/utils/shared-pool.ts +193 -0
  91. package/tsconfig.app.json +34 -0
@@ -0,0 +1,301 @@
1
+ import { errorDetail } from '../../logger/serve-log'
2
+ import type { MapOf, MixedPromise } from '../../types'
3
+ import { is } from '../../utils/common'
4
+ import { fs } from '../../utils/fs'
5
+ import { response } from '../../utils/http'
6
+ import { Handler } from './$base'
7
+ import { DynamicHandler } from './$dynamic'
8
+
9
+ const DEFAULT_ERROR: Handler.Error.Data = {
10
+ errorCode: 500,
11
+ errorText: 'Internal Server Error',
12
+ errorBody: 'An unexpected error occurred.',
13
+ }
14
+
15
+ /**
16
+ * A fresh copy of the process-wide default error data.
17
+ *
18
+ * The copy is the point, not the convenience. `extractErrorData` assigns the
19
+ * getter's result to a local and then *mutates* it, which is safe only while
20
+ * every `this` it runs under hands back a new object — a plain
21
+ * `= DEFAULT_ERROR` would let any caller write the process-wide default's
22
+ * fields. Both `ErrorHandler` and `DynamicErrorHandler` need the getter and
23
+ * they sit in different class hierarchies, so neither can inherit it from the
24
+ * other; they call this instead of each keeping a copy of the copy.
25
+ */
26
+ function defaultErrorData(): Handler.Error.Data {
27
+ return Object.assign({}, DEFAULT_ERROR)
28
+ }
29
+
30
+ /**
31
+ * The three lines every page handler opens with: the error-data default, the
32
+ * route lookup, and the 404 for a path that resolves to nothing.
33
+ *
34
+ * `HTMLHandler`, `TSXHandler` and the Vue plugin's handler render three
35
+ * different file formats and their bodies genuinely differ — but all three
36
+ * reach them this way. `errors` is `undefined` for an ordinary page, and
37
+ * `DEFAULT_ERROR` exists only on the error subclasses, so the fallback stays
38
+ * `undefined` for the ordinary handlers. `DynamicErrorHandler.resolveRoute`
39
+ * uses the second argument to prefer `error-<code>` over `error`;
40
+ * `DynamicHandler.resolveRoute` ignores it.
41
+ *
42
+ * Hands back the 404 `Response` itself rather than a `null` the caller has to
43
+ * remember to turn into one.
44
+ */
45
+ export async function beginPageRoute(
46
+ handler: typeof DynamicHandler | typeof DynamicErrorHandler,
47
+ path: string,
48
+ errors?: Handler.Error.Data,
49
+ ): Promise<
50
+ | { errorData: Handler.Error.Data | undefined; info: Handler.Route.Info }
51
+ | Response
52
+ > {
53
+ const errorData = errors || (handler as any).DEFAULT_ERROR
54
+ const info = await handler.resolveRoute(path, errorData)
55
+ if (!info) return response.error('Not Found')
56
+ return { errorData, info }
57
+ }
58
+
59
+ /**
60
+ * Stamp the DEV-only `__file` marker onto a page's params.
61
+ *
62
+ * One spelling of one rule: the marker goes on the params object **before**
63
+ * whatever error data gets merged in, and it names the route-relative path.
64
+ * `HTMLHandler` used to set it after the merge and `TSXHandler` before, which
65
+ * comes out the same today only because `publicErrorData` never emits a
66
+ * `__file` key — two orderings for one rule is what makes it look like the
67
+ * order might matter. It does not; this is the order, and `params` is the
68
+ * object because TSX also feeds that same object to `injectIfHtml`.
69
+ */
70
+ export function markDevFile(params: MapOf<any>, routePath: string): void {
71
+ if (import.meta.env.DEV) params.__file = routePath
72
+ }
73
+
74
+ export class HandlerError extends Error {
75
+ data: Handler.Error.Data
76
+ request?: Request = undefined
77
+
78
+ static getDefaultData() {
79
+ return DEFAULT_ERROR
80
+ }
81
+
82
+ constructor(
83
+ message?: string,
84
+ req?: Request,
85
+ data?: Partial<Handler.Error.Data>,
86
+ ) {
87
+ const finalData = {
88
+ ...HandlerError.getDefaultData(),
89
+ ...data,
90
+ }
91
+
92
+ super(message || finalData.errorText || 'Handler Error')
93
+ this.data = finalData as Handler.Error.Data
94
+ this.request = req
95
+ }
96
+ }
97
+
98
+ export class ErrorHandler extends Handler {
99
+ /** A fresh copy every read — see `defaultErrorData`. */
100
+ static get DEFAULT_ERROR() {
101
+ return defaultErrorData()
102
+ }
103
+
104
+ static isError(error: any): boolean {
105
+ if (error instanceof Response) return error.status >= 400
106
+ if (error instanceof HandlerError) return true
107
+ if (is.object(error)) {
108
+ if ('errorCode' in error && is.number(error.errorCode)) {
109
+ return error.errorCode >= 400
110
+ }
111
+ }
112
+
113
+ return false
114
+ }
115
+
116
+ static canHandle(
117
+ path: string,
118
+ req: Request,
119
+ errors?: Handler.Error.Data,
120
+ ): MixedPromise<boolean>
121
+ static canHandle() {
122
+ return true
123
+ }
124
+
125
+ static handle(
126
+ path: string,
127
+ req: Request,
128
+ errors?: Handler.Error.Data,
129
+ ): Handler.Response
130
+ static handle() {}
131
+
132
+ /**
133
+ * The part of `error` that may cross the wire.
134
+ *
135
+ * `extractErrorData` puts `error.stack` into `errorBody` deliberately — that
136
+ * is what reaches the server log, and losing it would be a regression. But
137
+ * the same field was being handed straight to the client: a `SQLiteError`
138
+ * from a failed query answered a 500 with the failing statement, the table
139
+ * and column names, and absolute paths into the source tree.
140
+ *
141
+ * So the redaction belongs at the boundary, not at extraction. 5xx is the
142
+ * only band `extractErrorData` ever fills from a thrown `Error`, and
143
+ * therefore the only one whose body can be a stack; a 4xx body is authored
144
+ * by whoever constructed the `HandlerError` or the `Response` and is theirs
145
+ * to disclose.
146
+ */
147
+ static publicBody(error: Handler.Error.Data): string {
148
+ if (import.meta.env.DEV) return error.errorBody
149
+ return error.errorCode >= 500 ? DEFAULT_ERROR.errorBody : error.errorBody
150
+ }
151
+
152
+ /**
153
+ * Whether `error` is one of Bun's compile-time diagnostics.
154
+ *
155
+ * `BuildMessage` (a syntax error) and `ResolveMessage` (an import that
156
+ * resolves to nothing) are what the transpiler and the module resolver throw,
157
+ * and neither is `instanceof Error`. They used to reach the `is.object`
158
+ * branch below, which reads only `errorCode`/`errorText`/`errorBody` — none
159
+ * of which a diagnostic has — and so returned the untouched default. A typo
160
+ * in a route file, the single most common server-side failure there is,
161
+ * answered `An unexpected error occurred.` in development as well as in
162
+ * production.
163
+ *
164
+ * Recognised by shape rather than by constructor: the classes are not
165
+ * exported, and both report zero own enumerable keys, so a string `message`
166
+ * on a non-`Error` object is the only tell. The error-data keys are checked
167
+ * first by the caller, so a record that carries those still takes its own
168
+ * branch.
169
+ */
170
+ static isDiagnostic(error: any): boolean {
171
+ if (!is.object(error)) return false
172
+ if (error instanceof Error || error instanceof Response) return false
173
+ return is.string(error.message) && error.message.length > 0
174
+ }
175
+
176
+ static extractErrorData(error: any): Handler.Error.Data {
177
+ if (error instanceof HandlerError) return error.data
178
+
179
+ if (error instanceof Error) {
180
+ return {
181
+ ...this.DEFAULT_ERROR,
182
+ errorText: error.message,
183
+ // `errorDetail` is `error.stack` whenever there is one — so the common
184
+ // case is untouched — and falls back to the aggregated sub-diagnostics
185
+ // for the stackless `AggregateError` that `import()`ing a broken `.tsx`
186
+ // throws, where `String(error)` was a summary count and nothing else.
187
+ errorBody: errorDetail(error) || String(error),
188
+ }
189
+ }
190
+
191
+ if (error instanceof Response) {
192
+ return {
193
+ ...this.DEFAULT_ERROR,
194
+ errorCode: error.status,
195
+ errorText: error.statusText,
196
+ errorBody: `${error.status}: "${error.statusText}"`,
197
+ }
198
+ }
199
+
200
+ if (is.object(error)) {
201
+ const errorObj = error as Partial<Handler.Error.Data>
202
+ const authored =
203
+ errorObj.errorCode !== undefined ||
204
+ errorObj.errorText !== undefined ||
205
+ errorObj.errorBody !== undefined
206
+
207
+ // Authored error data wins, `message` or no `message` — the branch order
208
+ // and its semantics are unchanged for everything that ever reached it.
209
+ if (!authored && this.isDiagnostic(error)) {
210
+ return {
211
+ ...this.DEFAULT_ERROR,
212
+ errorText: error.message,
213
+ errorBody: errorDetail(error),
214
+ }
215
+ }
216
+
217
+ const errorData = this.DEFAULT_ERROR
218
+
219
+ errorData.errorCode = errorObj.errorCode ?? errorData.errorCode
220
+ errorData.errorText = errorObj.errorText ?? errorData.errorText
221
+ errorData.errorBody = errorObj.errorBody ?? errorData.errorBody
222
+ return errorData
223
+ }
224
+
225
+ if (is.string(error)) {
226
+ return { ...this.DEFAULT_ERROR, errorText: error }
227
+ }
228
+
229
+ return this.DEFAULT_ERROR
230
+ }
231
+ }
232
+
233
+ export class DynamicErrorHandler extends DynamicHandler {
234
+ /**
235
+ * The same fresh copy `ErrorHandler.DEFAULT_ERROR` hands back — see
236
+ * `defaultErrorData`. Declared again rather than inherited because this
237
+ * class descends from `DynamicHandler`, not from `ErrorHandler`.
238
+ */
239
+ static get DEFAULT_ERROR() {
240
+ return defaultErrorData()
241
+ }
242
+
243
+ static canHandle(
244
+ path: string,
245
+ req: Request,
246
+ errors?: Handler.Error.Data,
247
+ ): MixedPromise<boolean>
248
+ static async canHandle(path: string, _: any, errors?: Handler.Error.Data) {
249
+ return Boolean(await this.resolveRoute(path, errors))
250
+ }
251
+
252
+ static handle(
253
+ path: string,
254
+ req: Request,
255
+ errors?: Handler.Error.Data,
256
+ ): Handler.Response
257
+
258
+ static handle(path: string, req: Request, errors?: Handler.Error.Data) {
259
+ return (super.handle as any)(path, req, errors)
260
+ }
261
+
262
+ static async resolveRoute(path: string, errors?: Handler.Error.Data) {
263
+ errors ||= this.DEFAULT_ERROR
264
+
265
+ const parsed = fs.parse(path)
266
+ const pathArray = parsed.dir.split('/').filter(Boolean)
267
+
268
+ for (let i = pathArray.length; i >= 0; i--) {
269
+ const prefix = i ? `/${pathArray.slice(0, i).join('/')}` : ''
270
+
271
+ const defsPage = `${prefix}/error`
272
+ const codePage = `${prefix}/error-${errors.errorCode}`
273
+ const routeInfo =
274
+ (await super.resolveRoute(codePage)) ||
275
+ (await super.resolveRoute(defsPage))
276
+ if (routeInfo) return routeInfo
277
+ }
278
+
279
+ return null
280
+ }
281
+ }
282
+
283
+ /**
284
+ * Error data with `errorBody` reduced to what a client may see.
285
+ *
286
+ * `ErrorHandler.publicBody` is the rule; this is it applied to a whole record,
287
+ * for the handlers that hand error data to a *template* rather than rendering
288
+ * a string themselves. `HTMLErrorHandler` and `TSXErrorHandler` merge the
289
+ * record into their page params, and those params reach the document twice —
290
+ * through `{{...}}` substitution and through the `window.__PAGE_PARAMS__`
291
+ * script `DOMTools.params` injects into every page. The second path is why
292
+ * redacting in the template was never enough: an `error.html` that never
293
+ * mentions `errorBody` still published the stack, absolute source paths and
294
+ * all, to any anonymous request in production.
295
+ *
296
+ * `errorText` is deliberately untouched — `DefaultErrorHandler` shows it in
297
+ * its heading in every mode, and one rule in one place beats two that drift.
298
+ */
299
+ export function publicErrorData(error: Handler.Error.Data): Handler.Error.Data {
300
+ return { ...error, errorBody: ErrorHandler.publicBody(error) }
301
+ }
@@ -0,0 +1,71 @@
1
+ import { Bakery } from '../../core/bakery'
2
+ import { errorMsg, handlerLog } from '../../logger/serve-log'
3
+ import { injectIfHtml, response } from '../../utils/http'
4
+ import { Handler } from './$base'
5
+
6
+ /**
7
+ * Per-request slot for the response produced during `canHandle`, so `handle`
8
+ * can return it without re-running the chain. This was previously a static
9
+ * field, which meant two concurrent requests could swap responses — including
10
+ * each other's `Set-Cookie` headers — at any `await` boundary.
11
+ */
12
+ const pending = new WeakMap<Request, Response>()
13
+
14
+ export class MiddlewareHandler extends Handler {
15
+ /** Answers from app code, not from disk. See `Handler.servesFiles`. */
16
+ static servesFiles = false
17
+
18
+ /**
19
+ * `canHandle` here means "was this request denied", which is a fact about
20
+ * the request and not about the path. Without this the route cache would
21
+ * serve the page handler that the first *allowed* request resolved to,
22
+ * and every later request to that path would skip middleware entirely.
23
+ */
24
+ static override alwaysResolve = true
25
+
26
+ static async canHandle(path: string, req: Request) {
27
+ const result = await this.handle(path, req)
28
+ if (result) pending.set(req, result)
29
+ return Boolean(result)
30
+ }
31
+
32
+ static async handle(_path: string, req: Request) {
33
+ const cached = pending.get(req)
34
+ if (cached) {
35
+ pending.delete(req)
36
+ return cached
37
+ }
38
+
39
+ // One config read for both `onRequest` and the middleware chain — same
40
+ // request, same host store, so the snapshot cannot go stale mid-call.
41
+ const config = Bakery.config
42
+ const intercepted = await config.onRequest(req!)
43
+ if (intercepted) {
44
+ // `|| undefined` used to sit here, and `injectIfHtml` returns null for
45
+ // anything that is not HTML — so a plain-text 403 from `onRequest`
46
+ // vanished and the request carried on. Inject when it is HTML, keep the
47
+ // original otherwise, exactly as the middleware chain below does.
48
+ return (await injectIfHtml(intercepted)) || intercepted
49
+ }
50
+
51
+ let data: any
52
+
53
+ for (const middleware of config.middleware) {
54
+ try {
55
+ const result = await middleware(req, Bakery.server!)
56
+ if (result instanceof Response) {
57
+ data = result
58
+ break
59
+ }
60
+ } catch (error) {
61
+ // Fail closed: a middleware that throws is often an auth check, and
62
+ // treating it as "no response" would let the request through.
63
+ handlerLog.MIDDLEWARE_ERR({ error: errorMsg(error) })
64
+ return response.error('Internal Server Error', 500)
65
+ }
66
+ }
67
+
68
+ const injectedRes = await injectIfHtml(data)
69
+ return injectedRes || data
70
+ }
71
+ }
@@ -0,0 +1,84 @@
1
+ import { fs } from '../../utils'
2
+
3
+ /**
4
+ * Route mounts: map a URL prefix onto a directory outside the app's serve root.
5
+ *
6
+ * Handlers resolve files under `Bakery.serveRoot`, so a plugin wanting to serve
7
+ * its own pages or client assets had no choice but to hand-roll it — reading
8
+ * files itself, bundling on the fly, and reimplementing the caching and
9
+ * containment the handler pipeline already does. `DashboardHandler` is the
10
+ * worked example of that cost.
11
+ *
12
+ * A mount lets a plugin say "paths under `/_dashboard` come from *my*
13
+ * directory", after which the normal handlers (TSX, TS, HTML, static, the
14
+ * compiler, the route cache) treat those files exactly like app files.
15
+ *
16
+ * Containment still applies: `getRoute` receives the mount directory as both
17
+ * the search dir *and* the root, so traversal cannot escape it.
18
+ */
19
+ export interface RouteMount {
20
+ /** URL prefix, leading slash, no trailing slash — e.g. `/_dashboard`. */
21
+ prefix: string
22
+ /** Absolute directory the prefix resolves against. */
23
+ dir: fs.AbsolutePath
24
+ }
25
+
26
+ /** Registered longest-prefix-first, so nested mounts resolve predictably. */
27
+ const mounts: RouteMount[] = []
28
+
29
+ function normalizePrefix(prefix: string): string {
30
+ const withSlash = prefix.startsWith('/') ? prefix : `/${prefix}`
31
+ return withSlash.length > 1 && withSlash.endsWith('/')
32
+ ? withSlash.slice(0, -1)
33
+ : withSlash
34
+ }
35
+
36
+ /**
37
+ * Serve `prefix/*` from `dir`. Call from a plugin's `setup()`.
38
+ *
39
+ * Re-registering the same prefix replaces it, so a reloading dev worker does
40
+ * not accumulate duplicates.
41
+ */
42
+ export function mountRoutes(prefix: string, dir: string): RouteMount {
43
+ const mount: RouteMount = {
44
+ prefix: normalizePrefix(prefix),
45
+ dir: fs.resolve(dir) as fs.AbsolutePath,
46
+ }
47
+
48
+ const existing = mounts.findIndex(m => m.prefix === mount.prefix)
49
+ if (existing !== -1) mounts.splice(existing, 1)
50
+
51
+ mounts.push(mount)
52
+ mounts.sort((a, b) => b.prefix.length - a.prefix.length)
53
+ return mount
54
+ }
55
+
56
+ /**
57
+ * The mount owning `path`, plus the path relative to it, or null.
58
+ *
59
+ * A prefix matches only on a segment boundary: `/_dash` must not capture
60
+ * `/_dashboard`.
61
+ */
62
+ export function resolveMount(
63
+ path: string,
64
+ ): { mount: RouteMount; rest: string } | null {
65
+ const normalized = path.startsWith('/') ? path : `/${path}`
66
+
67
+ for (const mount of mounts) {
68
+ if (normalized === mount.prefix) return { mount, rest: '' }
69
+ if (normalized.startsWith(`${mount.prefix}/`)) {
70
+ return { mount, rest: normalized.slice(mount.prefix.length + 1) }
71
+ }
72
+ }
73
+ return null
74
+ }
75
+
76
+ /** Every registered mount, longest prefix first. */
77
+ export function getMounts(): readonly RouteMount[] {
78
+ return mounts
79
+ }
80
+
81
+ /** Drop all mounts. Intended for tests. */
82
+ export function clearMounts(): void {
83
+ mounts.length = 0
84
+ }
@@ -0,0 +1,153 @@
1
+ import { LRUCache } from '../../cache/lru'
2
+ import type { Handler } from './$base'
3
+
4
+ /**
5
+ * Registry ids for `routeCache` keys. A module counter gives the same
6
+ * per-process uniqueness a UUID did (the cache never outlives the process)
7
+ * with a key prefix of a few characters instead of thirty-six.
8
+ */
9
+ let nextMapId = 0
10
+
11
+ export class HandlerMap<T extends typeof Handler = typeof Handler> extends Map<
12
+ any,
13
+ number
14
+ > {
15
+ public static routeCache = new LRUCache<string, typeof Handler>(
16
+ import.meta.env.THREAD_WORKER ? 500 : 5000,
17
+ )
18
+
19
+ private cachedList: T[] | null = null
20
+ private cachedGates: T[] | null = null
21
+ private cachedOrder: Map<any, number> | null = null
22
+ private id = nextMapId++
23
+
24
+ constructor(entries?: readonly (readonly [any, number])[] | null) {
25
+ super()
26
+ if (!entries) return
27
+
28
+ for (const [handlerClass, priority] of entries) {
29
+ this.set(handlerClass, priority)
30
+ }
31
+ }
32
+
33
+ set(handlerClass: any, priority: number = 10): this {
34
+ super.set(handlerClass, priority)
35
+ this.cachedList = null
36
+ this.cachedGates = null
37
+ this.cachedOrder = null
38
+ return this
39
+ }
40
+
41
+ add(handlerClass: any, priority?: number): this {
42
+ return this.set(handlerClass, priority)
43
+ }
44
+
45
+ list(): T[] {
46
+ if (this.cachedList) {
47
+ return this.cachedList
48
+ }
49
+ this.cachedList = Array.from(this.entries())
50
+ .sort((a, b) => b[1] - a[1])
51
+ .map(entry => entry[0])
52
+
53
+ return this.cachedList
54
+ }
55
+
56
+ /** Handlers that opt out of the cache bypass, in priority order. */
57
+ private gatekeepers(): T[] {
58
+ this.cachedGates ??= this.list().filter(h => (h as any).alwaysResolve)
59
+ return this.cachedGates
60
+ }
61
+
62
+ /** Position of each handler in the priority-sorted list. */
63
+ private order(): Map<any, number> {
64
+ this.cachedOrder ??= new Map(this.list().map((h, i) => [h, i]))
65
+ return this.cachedOrder
66
+ }
67
+
68
+ /**
69
+ * `canHandle` without the microtask hop when the answer is synchronous.
70
+ *
71
+ * Most canHandles are plain predicates, and `await`ing their boolean cost a
72
+ * microtask hop (~426ns) per probe, 2–4 probes per request. Returns a
73
+ * boolean for a sync answer and the promise itself for an async one, so
74
+ * callers only `await` a value that is actually a promise:
75
+ *
76
+ * const r = HandlerMap.probe(...)
77
+ * if (r === true || (r !== false && (await r))) ...
78
+ */
79
+ private static probe(
80
+ handler: any,
81
+ path: string,
82
+ req: Request | undefined,
83
+ rest: any[],
84
+ ): boolean | Promise<any> {
85
+ const r = handler.canHandle(path, req, ...rest)
86
+ return r && typeof (r as any).then === 'function' ? r : Boolean(r)
87
+ }
88
+
89
+ /**
90
+ * The first extra argument is the request in every registry's call shape
91
+ * (fetch: `(path, req)`, websocket: `(path, req)`, error:
92
+ * `(path, req, error)`), so it is named rather than fished out of a variadic
93
+ * list with an `instanceof` scan per request. `...rest` carries the error
94
+ * registry's error through to `canHandle`/`handle` untouched.
95
+ */
96
+ // The `probed` bookkeeping exists because middleware has side effects and
97
+ // must not run twice. That constraint is what the branching encodes.
98
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: cache-path + side-effect-sensitive probe loop
99
+ async resolve(path: string, req?: Request, ...rest: any[]) {
100
+ const host = req?.__hostname || ''
101
+ const pathId = `${this.id}:${host}:${path}`
102
+ const cached: any = HandlerMap.routeCache.get(pathId)
103
+
104
+ // Only tracked once there is a cache hit to skip past. Middleware has
105
+ // side effects (sessions, rate-limit counters), so a handler probed on the
106
+ // cache path must not be probed a second time by the loop below.
107
+ let probed: Set<any> | null = null
108
+
109
+ if (cached) {
110
+ probed = new Set()
111
+ // A cache hit skips every handler above the cached one. Ask the
112
+ // gatekeepers that outrank it first — the same handlers, in the same
113
+ // order, a cold resolve would have reached before the cached one.
114
+ const rank = this.order().get(cached) ?? Number.POSITIVE_INFINITY
115
+ for (const gate of this.gatekeepers()) {
116
+ if ((this.order().get(gate) ?? 0) >= rank) break
117
+ probed.add(gate)
118
+ const r = HandlerMap.probe(gate, path, req, rest)
119
+ if (r === true || (r !== false && (await r))) return gate
120
+ }
121
+
122
+ probed.add(cached)
123
+ const r = HandlerMap.probe(cached, path, req, rest)
124
+ if (r === true || (r !== false && (await r))) return cached
125
+ HandlerMap.routeCache.delete(pathId)
126
+ }
127
+
128
+ for (const handler of this.list() as any) {
129
+ if (probed?.has(handler)) continue
130
+ const r = HandlerMap.probe(handler, path, req, rest)
131
+ if (r === true || (r !== false && (await r))) {
132
+ // A gatekeeper's `true` describes this request, not this path. Caching
133
+ // it would both evict the path's real handler and park a per-request
134
+ // decision in a cache that has no TTL.
135
+ if (!handler.alwaysResolve) HandlerMap.routeCache.set(pathId, handler)
136
+ return handler as T
137
+ }
138
+ }
139
+
140
+ return null
141
+ }
142
+
143
+ initRoutes() {
144
+ return Promise.all(this.list().map(handler => handler.initRoutes()))
145
+ }
146
+
147
+ handle(path: string, req?: Request, ...rest: any[]): Handler.Response
148
+ async handle(path: string, req?: Request, ...rest: any[]) {
149
+ const handler: any = await this.resolve(path, req, ...rest)
150
+
151
+ return handler ? handler.handle(path, req, ...rest) : null
152
+ }
153
+ }