@bakery-framework/core 2.0.0-alpha.8 → 2.0.0-rc.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/README.md +8 -8
- package/package.json +2 -5
- package/src/cache/lru.ts +2 -2
- package/src/cache/shared-db.ts +81 -11
- package/src/cache/string.ts +4 -0
- package/src/cache/tiered.ts +11 -5
- package/src/client/globals.d.ts +7 -7
- package/src/client/livereload.ts +2 -2
- package/src/client/utils.ts +11 -6
- package/src/compiler/compiler.ts +48 -44
- package/src/compiler/dev-service.ts +29 -30
- package/src/compiler/prompt-tracker.ts +2 -2
- package/src/compiler/tsconfig-sync.ts +130 -59
- package/src/core/bakery.ts +6 -3
- package/src/core/cache-version.ts +68 -18
- package/src/core/config.ts +16 -16
- package/src/core/context.ts +8 -8
- package/src/core/define-route.ts +5 -6
- package/src/core/index.ts +19 -7
- package/src/core/init.ts +38 -35
- package/src/core/jsx.ts +19 -4
- package/src/core/paths.ts +3 -1
- package/src/core/plugins.ts +15 -4
- package/src/core/port.ts +6 -6
- package/src/global.d.ts +36 -18
- package/src/handlers/assets/google-font.ts +6 -6
- package/src/handlers/assets/image.ts +36 -3
- package/src/handlers/assets/nm.ts +7 -7
- package/src/handlers/assets/static.ts +55 -12
- package/src/handlers/assets/ts.ts +3 -3
- package/src/handlers/assets/tsx.ts +1 -1
- package/src/handlers/assets/virtual-asset.ts +1 -1
- package/src/handlers/core/$base.ts +24 -8
- package/src/handlers/core/$dynamic.ts +66 -23
- package/src/handlers/core/$error.ts +29 -17
- package/src/handlers/core/$middleware.ts +6 -6
- package/src/handlers/core/$mounts.ts +2 -2
- package/src/handlers/core/$registry.ts +16 -6
- package/src/handlers/core/$routing.ts +18 -19
- package/src/handlers/core/$static.ts +11 -12
- package/src/handlers/routes/api.ts +5 -5
- package/src/handlers/routes/html.ts +2 -2
- package/src/handlers/routes/proxy.ts +1 -1
- package/src/logger/clients.ts +2 -2
- package/src/logger/logger.ts +4 -4
- package/src/logger/serve-log.ts +32 -18
- package/src/plugins/index.ts +1 -1
- package/src/plugins/routes.ts +7 -7
- package/src/plugins/types.ts +13 -11
- package/src/router.ts +12 -12
- package/src/session.ts +41 -23
- package/src/shared.d.ts +5 -5
- package/src/startup.ts +3 -3
- package/src/types.d.ts +10 -10
- package/src/utils/common/case.ts +1 -1
- package/src/utils/common/match.ts +1 -1
- package/src/utils/common/misc.ts +1 -1
- package/src/utils/constants.ts +15 -15
- package/src/utils/fs.ts +97 -39
- package/src/utils/http/authorize.ts +24 -17
- package/src/utils/http/cors.ts +6 -6
- package/src/utils/http/credential.ts +4 -4
- package/src/utils/http/csrf.ts +6 -7
- package/src/utils/http/dom.ts +9 -9
- package/src/utils/http/etag.ts +54 -13
- package/src/utils/http/html.ts +29 -20
- package/src/utils/http/ip.ts +1 -1
- package/src/utils/http/sse.ts +11 -11
- package/src/utils/http/url.ts +4 -4
- package/src/utils/http/validate.ts +3 -4
- package/src/utils/isomorphic/escape.ts +1 -1
- package/src/utils/isomorphic/is.ts +2 -2
- package/src/utils/isomorphic/misc.ts +1 -1
- package/src/utils/isomorphic/stringify.ts +2 -3
- package/src/utils/isomorphic/try.ts +1 -1
- package/src/utils/shared-pool.ts +23 -16
- package/tsconfig.app.json +3 -3
- package/tsconfig.vue.json +3 -3
|
@@ -4,7 +4,7 @@ import { fs } from '../../utils'
|
|
|
4
4
|
* Route mounts: map a URL prefix onto a directory outside the app's serve root.
|
|
5
5
|
*
|
|
6
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
|
|
7
|
+
* its own pages or client assets had no choice but to hand-roll it: reading
|
|
8
8
|
* files itself, bundling on the fly, and reimplementing the caching and
|
|
9
9
|
* containment the handler pipeline already does. `DashboardHandler` is the
|
|
10
10
|
* worked example of that cost.
|
|
@@ -17,7 +17,7 @@ import { fs } from '../../utils'
|
|
|
17
17
|
* the search dir *and* the root, so traversal cannot escape it.
|
|
18
18
|
*/
|
|
19
19
|
export interface RouteMount {
|
|
20
|
-
/** URL prefix, leading slash, no trailing slash
|
|
20
|
+
/** URL prefix, leading slash, no trailing slash, e.g. `/_dashboard`. */
|
|
21
21
|
prefix: string
|
|
22
22
|
/** Absolute directory the prefix resolves against. */
|
|
23
23
|
dir: fs.AbsolutePath
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { LRUCache } from '../../cache/lru'
|
|
2
|
+
import { hostKey } from '../../core/bakery'
|
|
2
3
|
import type { Handler } from './$base'
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -51,8 +52,8 @@ export class HandlerMap<T extends typeof Handler = typeof Handler> extends Map<
|
|
|
51
52
|
* `delete` and `clear` invalidate too, and neither used to.
|
|
52
53
|
*
|
|
53
54
|
* `list()` memoizes the sorted handler array and only `set` cleared it, so a
|
|
54
|
-
* removed handler stayed in the list
|
|
55
|
-
* pipeline*
|
|
55
|
+
* removed handler stayed in the list (and therefore stayed *in the request
|
|
56
|
+
* pipeline*) until something happened to add one. Registration is
|
|
56
57
|
* add-only in a served process, which is why this never bit: it is reachable
|
|
57
58
|
* only by code that unregisters, and the first thing to do that was a test.
|
|
58
59
|
*
|
|
@@ -98,7 +99,7 @@ export class HandlerMap<T extends typeof Handler = typeof Handler> extends Map<
|
|
|
98
99
|
* `canHandle` without the microtask hop when the answer is synchronous.
|
|
99
100
|
*
|
|
100
101
|
* Most canHandles are plain predicates, and `await`ing their boolean cost a
|
|
101
|
-
* microtask hop (~426ns) per probe, 2
|
|
102
|
+
* microtask hop (~426ns) per probe, 2 to 4 probes per request. Returns a
|
|
102
103
|
* boolean for a sync answer and the promise itself for an async one, so
|
|
103
104
|
* callers only `await` a value that is actually a promise:
|
|
104
105
|
*
|
|
@@ -126,8 +127,17 @@ export class HandlerMap<T extends typeof Handler = typeof Handler> extends Map<
|
|
|
126
127
|
// must not run twice. That constraint is what the branching encodes.
|
|
127
128
|
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: cache-path + side-effect-sensitive probe loop
|
|
128
129
|
async resolve(path: string, req?: Request, ...rest: any[]) {
|
|
129
|
-
|
|
130
|
-
|
|
130
|
+
// The **resolved** host, not the header. `__hostname` is whatever arrived
|
|
131
|
+
// in `Host`, and `routeCache` is one bounded LRU shared by every tenant, so
|
|
132
|
+
// a client varying that header minted an entry per spelling and walked the
|
|
133
|
+
// real hosts' entries out of the cache for as long as it kept asking. The
|
|
134
|
+
// file cache carried the same bug and was fixed; this one kept it.
|
|
135
|
+
//
|
|
136
|
+
// `hostKey` resolves through the multi-host config the way every other
|
|
137
|
+
// per-tenant key does, so an unknown or unconfigured host collapses to a
|
|
138
|
+
// single entry instead of one per spelling. The shape is unchanged when
|
|
139
|
+
// there is no host: `hostKey` returns the bare path.
|
|
140
|
+
const pathId = `${this.id}:${hostKey(path)}`
|
|
131
141
|
const cached: any = HandlerMap.routeCache.get(pathId)
|
|
132
142
|
|
|
133
143
|
// Only tracked once there is a cache hit to skip past. Middleware has
|
|
@@ -138,7 +148,7 @@ export class HandlerMap<T extends typeof Handler = typeof Handler> extends Map<
|
|
|
138
148
|
if (cached) {
|
|
139
149
|
probed = new Set()
|
|
140
150
|
// A cache hit skips every handler above the cached one. Ask the
|
|
141
|
-
// gatekeepers that outrank it first
|
|
151
|
+
// gatekeepers that outrank it first: the same handlers, in the same
|
|
142
152
|
// order, a cold resolve would have reached before the cached one.
|
|
143
153
|
const rank = this.order().get(cached) ?? Number.POSITIVE_INFINITY
|
|
144
154
|
for (const gate of this.gatekeepers()) {
|
|
@@ -15,14 +15,14 @@ export type RouteScanOptions = {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
// `[!.]` keeps catch-alls (`[...name]`) out of the single-param globs: they
|
|
18
|
-
// are matched separately, and last
|
|
18
|
+
// are matched separately, and last. A catch-all is the weakest route form,
|
|
19
19
|
// consulted only after specific files, single-param siblings and child-index
|
|
20
20
|
// descent have all missed.
|
|
21
21
|
const catchAllGlob = (ext: string) => new Bun.Glob(`[[]...*${ext || '.*'}`)
|
|
22
22
|
|
|
23
23
|
// The single-param route forms, in the order they are tried: `[name].ext`
|
|
24
24
|
// first, then the literal-asterisk `*.ext`. Built here rather than twice inside
|
|
25
|
-
// `routeGlobs
|
|
25
|
+
// `routeGlobs`: the `dynamicOnly` branch and the combined branch returned
|
|
26
26
|
// character-identical pairs, and the two must stay in step or a route form
|
|
27
27
|
// resolves under one caller and not the other. A function, not a hoisted
|
|
28
28
|
// constant: `ext` varies per handler, and `staticOnly` returns before it needs
|
|
@@ -33,22 +33,22 @@ const catchAllGlob = (ext: string) => new Bun.Glob(`[[]...*${ext || '.*'}`)
|
|
|
33
33
|
// a path separator: Bun read `\*.*` as a drive-absolute pattern, ignored the
|
|
34
34
|
// `cwd` in `GETFILE` entirely, and matched files at `C:\`. A route lookup under
|
|
35
35
|
// a serve root six levels down returned `C:\$WINRE_BACKUP_PARTITION.MARKER`,
|
|
36
|
-
// and `fs.isForbidden`
|
|
36
|
+
// and `fs.isForbidden` (whose walk is bounded by `startsWith(root)`) waved it
|
|
37
37
|
// through because an out-of-root path skipped the loop and answered "allowed".
|
|
38
38
|
// That clamp now fails closed, so this is belt and braces; both halves are
|
|
39
39
|
// pinned, and neither test can see the other's bug.
|
|
40
40
|
//
|
|
41
41
|
// Measured with a scan whose cwd was a temp directory holding one file: `[*]`
|
|
42
42
|
// yields nothing, `\*` yields four files from the drive root. The escape is
|
|
43
|
-
// also unreachable on Windows in the direction it was meant for
|
|
43
|
+
// also unreachable on Windows in the direction it was meant for: `*` is a
|
|
44
44
|
// reserved character in a Windows filename, so a route file literally named
|
|
45
45
|
// `*.ts` can only exist on POSIX, where both spellings match it identically
|
|
46
46
|
// (verified on Linux). The character class costs nothing and means the same
|
|
47
47
|
// thing on both platforms.
|
|
48
48
|
//
|
|
49
49
|
// Exported only as a test seam: with the clamp in place no test driving
|
|
50
|
-
// `getRoute` can tell the two spellings apart
|
|
51
|
-
// and watching all 17 pass
|
|
50
|
+
// `getRoute` can tell the two spellings apart (verified by reverting this line
|
|
51
|
+
// and watching all 17 pass), so the pattern has to be asserted directly.
|
|
52
52
|
export const dynamicGlobs = (ext: string) => [
|
|
53
53
|
new Bun.Glob(`[[][!.]*${ext || '.*'}`),
|
|
54
54
|
new Bun.Glob(`[*]${ext || '.*'}`),
|
|
@@ -88,8 +88,7 @@ const routeGlobs = (
|
|
|
88
88
|
* The catch-all fallback for one directory level: `dir/[...name].ext`,
|
|
89
89
|
* containment-checked like every other candidate. Skipped for `staticOnly`
|
|
90
90
|
* (the caller wants a literal file), and it *yields to any real file*: when
|
|
91
|
-
* the request's remaining segments name an existing file under `dir`
|
|
92
|
-
* whatever its extension — the catch-all declines, so a lower-priority
|
|
91
|
+
* the request's remaining segments name an existing file under `dir` ( * whatever its extension) the catch-all declines, so a lower-priority
|
|
93
92
|
* handler (TSHandler, StaticHandler) can serve the file itself. A directory
|
|
94
93
|
* is not a file and does not trigger the yield. `findDynamicRoute` applies
|
|
95
94
|
* the same rule on the cached path; the two must agree.
|
|
@@ -102,7 +101,7 @@ const routeGlobs = (
|
|
|
102
101
|
* `provides.ts` at `/teacher/provides` and `/teacher/provides.js`, so neither
|
|
103
102
|
* spelling named a file on disk and a Vue catch-all above it (priority 58 vs
|
|
104
103
|
* 50) served HTML to a browser that asked for a module. The probe now also
|
|
105
|
-
* tries the registered dynamic extensions against the extensionless base
|
|
104
|
+
* tries the registered dynamic extensions against the extensionless base:
|
|
106
105
|
* the same mapping the serving handlers apply, read from the live registry so
|
|
107
106
|
* a plugin's extension (`.vue`) counts without core naming it.
|
|
108
107
|
*
|
|
@@ -118,7 +117,7 @@ export function servedSourceExists(target: string): boolean {
|
|
|
118
117
|
try {
|
|
119
118
|
exts = (handler as { config?: { ext?: unknown } }).config?.ext
|
|
120
119
|
} catch {
|
|
121
|
-
// A config getter that needs state this process lacks
|
|
120
|
+
// A config getter that needs state this process lacks: a handler with
|
|
122
121
|
// no ext table cannot claim a source file either way.
|
|
123
122
|
continue
|
|
124
123
|
}
|
|
@@ -147,13 +146,13 @@ async function getCatchAllRoute(
|
|
|
147
146
|
const target = fs.resolve(dir, restSegments.join('/'))
|
|
148
147
|
// Stat only inside `dir`. A rest containing `..` resolves outside it, and
|
|
149
148
|
// statting there would make this yield a boolean existence probe for any
|
|
150
|
-
// path on the filesystem
|
|
151
|
-
// URL parsing
|
|
149
|
+
// path on the filesystem: the 404-vs-render difference is observable.
|
|
150
|
+
// URL parsing normalizes `..` and `%2e%2e` away, so no HTTP request
|
|
152
151
|
// reaches here with one; this closes the door for any caller that skips
|
|
153
|
-
// that
|
|
152
|
+
// that normalization. An escape skips the yield rather than refusing, so
|
|
154
153
|
// the catch-all answers exactly as it did before the yield rule existed.
|
|
155
154
|
// `dir` comes from `fs.resolve`, so the separator-suffixed prefix test is
|
|
156
|
-
// exact
|
|
155
|
+
// exact: plain `startsWith(dir)` would also accept a sibling directory
|
|
157
156
|
// whose name merely begins with it.
|
|
158
157
|
if (
|
|
159
158
|
(target === dir || target.startsWith(`${dir}/`)) &&
|
|
@@ -169,7 +168,7 @@ async function getCatchAllRoute(
|
|
|
169
168
|
|
|
170
169
|
// A bare-directory request (`/docs` with no index) reaches here with no
|
|
171
170
|
// rest segments, and only the `[...name!]` spelling opted into claiming
|
|
172
|
-
// it
|
|
171
|
+
// it: the plain form keeps requiring at least one segment.
|
|
173
172
|
if (!restSegments.length && !info.optionalCatchAll) return null
|
|
174
173
|
|
|
175
174
|
return info
|
|
@@ -183,7 +182,7 @@ export async function getRoute(
|
|
|
183
182
|
root?: fs.AbsolutePath,
|
|
184
183
|
options: RouteScanOptions = {},
|
|
185
184
|
): Promise<Handler.Route.Info | null> {
|
|
186
|
-
// Both used to default to `Bakery.serveRoot` independently
|
|
185
|
+
// Both used to default to `Bakery.serveRoot` independently. Two reads of
|
|
187
186
|
// the config getter (an AsyncLocalStorage getStore each) when a caller
|
|
188
187
|
// omits both. One read now covers whichever is missing; the values are
|
|
189
188
|
// unchanged.
|
|
@@ -234,7 +233,7 @@ export async function getRoute(
|
|
|
234
233
|
// statement is `isForbidden(dir, root)` with these exact arguments, and its
|
|
235
234
|
// `null` reaches the same `return null` this function ends on. Checking
|
|
236
235
|
// here as well made every level of a nested route pay two identical
|
|
237
|
-
// directory tree-walks
|
|
236
|
+
// directory tree-walks, and `isForbidden` walks from the file to the root
|
|
238
237
|
// doing an existsSync at each level, so that is the most expensive thing
|
|
239
238
|
// this function does. `src/tests/forbidden.test.ts` pins both branches.
|
|
240
239
|
if (first !== 'index') {
|
|
@@ -246,7 +245,7 @@ export async function getRoute(
|
|
|
246
245
|
// `first === 'index'` is the bare-directory request (`/docs` arrives here
|
|
247
246
|
// as an injected 'index' segment, after no index file matched). The plain
|
|
248
247
|
// `[...name]` pattern requires at least one rest segment and cannot claim
|
|
249
|
-
// it; `[...name!]` exists to
|
|
248
|
+
// it; `[...name!]` exists to: `getCatchAllRoute` tells them apart.
|
|
250
249
|
if (!options.staticOnly) {
|
|
251
250
|
return await getCatchAllRoute(
|
|
252
251
|
ext,
|
|
@@ -263,7 +262,7 @@ export async function getRoute(
|
|
|
263
262
|
if (route) return route
|
|
264
263
|
|
|
265
264
|
// The walk above descends one real segment at a time and dead-ends when
|
|
266
|
-
// the next directory does not exist
|
|
265
|
+
// the next directory does not exist, which for a catch-all request
|
|
267
266
|
// (`/docs/a/b/c` against `docs/[...slug].tsx`) is the common case. Each
|
|
268
267
|
// level unwinds through here, so the deepest existing directory gets the
|
|
269
268
|
// first chance to claim the rest.
|
|
@@ -4,14 +4,13 @@ import { fs } from '../../utils/fs'
|
|
|
4
4
|
import { resolveMount } from './$mounts'
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* Literal file resolution
|
|
7
|
+
* Literal file resolution: the static counterpart to `getRoute`.
|
|
8
8
|
*
|
|
9
9
|
* `getRoute` answers "which file backs this *route*", with extensionless URLs,
|
|
10
10
|
* dynamic `[id]` segments and glob scanning. This answers the simpler question
|
|
11
11
|
* "which file is at this path, and am I allowed to serve it".
|
|
12
12
|
*
|
|
13
|
-
* It exists because four handlers were each spelling that out themselves
|
|
14
|
-
* StaticHandler, PublicHandler, ImageHandler and NMHandler — in four slightly
|
|
13
|
+
* It exists because four handlers were each spelling that out themselves ( * StaticHandler, PublicHandler, ImageHandler and NMHandler) in four slightly
|
|
15
14
|
* different ways, and only some of them checked everything. Once route mounts
|
|
16
15
|
* arrived the drift got worse: a mount-aware StaticHandler sitting next to a
|
|
17
16
|
* mount-blind PublicHandler is the kind of inconsistency that turns into a bug
|
|
@@ -19,7 +18,7 @@ import { resolveMount } from './$mounts'
|
|
|
19
18
|
*
|
|
20
19
|
* **Three of those four go through this; NMHandler does not, on purpose.** Its
|
|
21
20
|
* entry point is resolved by `Bun.build`, not by the filesystem, so
|
|
22
|
-
* `/_nm/pkg/sub` legitimately means `pkg/sub/index.js
|
|
21
|
+
* `/_nm/pkg/sub` legitimately means `pkg/sub/index.js`: a path this function
|
|
23
22
|
* answers `null` for, because it is a directory. It therefore repeats the
|
|
24
23
|
* containment test and calls `fs.isForbidden` itself; the reasoning, and the
|
|
25
24
|
* test that pins the divergence, are in `handlers/assets/nm.ts`. It is the one
|
|
@@ -43,7 +42,7 @@ export interface StaticTarget {
|
|
|
43
42
|
* mounted plugin cannot be traversed out of.
|
|
44
43
|
*
|
|
45
44
|
* Roots are tried in order, and a root that fails containment is skipped
|
|
46
|
-
* rather than aborting
|
|
45
|
+
* rather than aborting: mirroring ImageHandler, which legitimately looks in
|
|
47
46
|
* both the serve root and the public root.
|
|
48
47
|
*/
|
|
49
48
|
export async function getStatic(
|
|
@@ -64,23 +63,23 @@ export async function getStatic(
|
|
|
64
63
|
const file = fs.resolve(root, candidate.relative)
|
|
65
64
|
|
|
66
65
|
// Two checks, not one: the prefix test catches a resolved path that
|
|
67
|
-
// escaped the root, and isForbidden additionally
|
|
68
|
-
// markers. It does *not* apply the blocked globs
|
|
66
|
+
// escaped the root, and isForbidden additionally honors `.forbidden`
|
|
67
|
+
// markers. It does *not* apply the blocked globs: `Bakery.config.blocked`
|
|
69
68
|
// is matched against the request path in `router.ts` and again in
|
|
70
|
-
// `handlers/assets/static.ts`, never against a resolved file path
|
|
71
|
-
// caller reaching for `getStatic` as a one-stop
|
|
69
|
+
// `handlers/assets/static.ts`, never against a resolved file path, so a
|
|
70
|
+
// caller reaching for `getStatic` as a one-stop authorization check is
|
|
72
71
|
// getting containment and `.forbidden` only.
|
|
73
72
|
//
|
|
74
|
-
// Containment stays first
|
|
73
|
+
// Containment stays first: nothing stats a path that escaped the root.
|
|
75
74
|
// After that the order is by cost: `isForbidden` walks every directory
|
|
76
75
|
// between the file and the root, so running it before the existence check
|
|
77
|
-
// paid for a whole tree-walk on every candidate that simply is not there
|
|
76
|
+
// paid for a whole tree-walk on every candidate that simply is not there,
|
|
78
77
|
// which is most of them, since a miss here is how each root in the list,
|
|
79
78
|
// and every extensionless probe, gets ruled out. All checks are pure and
|
|
80
79
|
// all `continue`, so the candidate chosen is unchanged.
|
|
81
80
|
//
|
|
82
81
|
// One stat answers both questions. This used to be `fs.exists` (a stat)
|
|
83
|
-
// then `fs.isDir` (which is `fs.exists` plus a second stat of its own)
|
|
82
|
+
// then `fs.isDir` (which is `fs.exists` plus a second stat of its own):
|
|
84
83
|
// up to three stats per candidate at ~28us each on Bun/Windows. A failed
|
|
85
84
|
// stat means "nothing servable here" and falls through to the next
|
|
86
85
|
// candidate, exactly as the exists check did.
|
|
@@ -46,19 +46,19 @@ export class ApiHandler extends DynamicHandler {
|
|
|
46
46
|
if (result !== null && result !== undefined) return result
|
|
47
47
|
|
|
48
48
|
// Two different faults used to share one bare 404, and neither channel
|
|
49
|
-
// named the file
|
|
49
|
+
// named the file, so "No response from handler" sent the developer
|
|
50
50
|
// hunting for a route that was sitting on disk the whole time.
|
|
51
51
|
//
|
|
52
52
|
// `null` and `undefined` are what tell them apart, and the split is exact
|
|
53
53
|
// rather than incidental: `DynamicHandler.executeModule` *throws* when the
|
|
54
54
|
// import fails (that is the 500 path), returns a literal `null` for
|
|
55
55
|
// "module loaded, no `default`", and otherwise returns whatever the
|
|
56
|
-
// handler produced
|
|
56
|
+
// handler produced, `undefined` when it produced nothing.
|
|
57
57
|
if (result === null) {
|
|
58
58
|
// A 500, not a 404: the route resolved, the file exists, and the server
|
|
59
59
|
// could not answer with it. That is a server fault, and the message says
|
|
60
|
-
// what to add. PROD still redacts it
|
|
61
|
-
// body
|
|
60
|
+
// what to add. PROD still redacts it (`publicBody` replaces any 5xx
|
|
61
|
+
// body), so naming the file here discloses nothing to a client.
|
|
62
62
|
handlerLog.API_NO_DEFAULT({ file: filePath })
|
|
63
63
|
return response.error(
|
|
64
64
|
`API route has no export default: ${info.path}`,
|
|
@@ -88,7 +88,7 @@ export class ApiErrorHandler extends ErrorHandler {
|
|
|
88
88
|
|
|
89
89
|
static handle(_p: string, _r: Request, error: Handler.Error.Data) {
|
|
90
90
|
// `publicBody`, not `errorBody`: in production the latter is the stack of
|
|
91
|
-
// whatever threw. The full trace still reaches the log
|
|
91
|
+
// whatever threw. The full trace still reaches the log: `handleRequestError`
|
|
92
92
|
// passes the unredacted data to `config.onError` before this runs.
|
|
93
93
|
return response.json.error(error.errorCode, ErrorHandler.publicBody(error))
|
|
94
94
|
}
|
|
@@ -78,8 +78,8 @@ async function sharedHandler(
|
|
|
78
78
|
// through `{{...}}` *and* through the `__PAGE_PARAMS__` script injected
|
|
79
79
|
// into every page, so the raw stack was published in PROD even by a
|
|
80
80
|
// template that never mentioned it. Guarded because `errorData` is
|
|
81
|
-
// `undefined` for an ordinary page
|
|
82
|
-
// error handler
|
|
81
|
+
// `undefined` for an ordinary page (`DEFAULT_ERROR` only exists on the
|
|
82
|
+
// error handler), and the spread below tolerates that where the helper,
|
|
83
83
|
// deliberately strict about the shape it redacts, does not.
|
|
84
84
|
// Annotated because `beginPageRoute` hands back real `Handler.Error.Data`
|
|
85
85
|
// where this used to read an untyped `(this as any).DEFAULT_ERROR`: the
|
|
@@ -63,7 +63,7 @@ export class ProxyHandler extends Handler {
|
|
|
63
63
|
|
|
64
64
|
const resHeaders = new Headers(proxyRes.headers)
|
|
65
65
|
// Bun already decompressed the body, so the upstream's content-encoding is
|
|
66
|
-
// wrong
|
|
66
|
+
// wrong, and so is its content-length, which described the compressed size.
|
|
67
67
|
resHeaders.delete('content-encoding')
|
|
68
68
|
resHeaders.delete('content-length')
|
|
69
69
|
return new Response(proxyRes.body, {
|
package/src/logger/clients.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* WebSocket clients subscribed to the live log stream.
|
|
3
3
|
*
|
|
4
|
-
* `LiveReloadHandler` owns membership
|
|
4
|
+
* `LiveReloadHandler` owns membership: it adds a socket on
|
|
5
5
|
* `subscribe_logger` and removes it on close. Consumers (the dashboard's
|
|
6
6
|
* log broadcast, the analytics `activeLoggers` gauge) only read.
|
|
7
7
|
*
|
|
8
8
|
* This lives in core rather than a plugin deliberately: it used to be
|
|
9
9
|
* defined in `plugins/analytics/core`, which made core import from a
|
|
10
|
-
* plugin
|
|
10
|
+
* plugin, a backwards edge that blocks packaging core on its own.
|
|
11
11
|
*/
|
|
12
12
|
export const connectedLoggers = new Set<any>()
|
package/src/logger/logger.ts
CHANGED
|
@@ -46,7 +46,7 @@ export function setLogCallback(cb: (entry: LoggerEntry) => void) {
|
|
|
46
46
|
*
|
|
47
47
|
* Three call sites used to spell this as `Promise.try(() =>
|
|
48
48
|
* onLogCallback?.(…)).catch(() => {})`, which allocated two promises and
|
|
49
|
-
* scheduled a microtask for *every* line
|
|
49
|
+
* scheduled a microtask for *every* line, including every line logged while
|
|
50
50
|
* no sink was registered at all. `Try` swallows a synchronous throw and a
|
|
51
51
|
* rejected promise alike (see `utils/isomorphic/try.ts`) without allocating
|
|
52
52
|
* anything on the synchronous path, which is the only path a sink typed
|
|
@@ -61,7 +61,7 @@ function emit(entry: LoggerEntry): void {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
// Hoisted: both were rebuilt on every call, and neither depends on the input.
|
|
64
|
-
// The
|
|
64
|
+
// The color table is a 23-property literal and the pattern a regex literal,
|
|
65
65
|
// so a process logging steadily paid for both on every line.
|
|
66
66
|
const COLORS: MapOf<string> = {
|
|
67
67
|
r: '\x1b[31m', // Red
|
|
@@ -299,7 +299,7 @@ type Messages<T extends MapOf<string>> = {
|
|
|
299
299
|
const RX_PARAM = /\{([^}]+)\}/g
|
|
300
300
|
|
|
301
301
|
type ParsedMessage = {
|
|
302
|
-
/** The string this was parsed from
|
|
302
|
+
/** The string this was parsed from: the cache's validity check. */
|
|
303
303
|
raw: string
|
|
304
304
|
level: LogLevels
|
|
305
305
|
template: string
|
|
@@ -332,7 +332,7 @@ export function messageLogger<T extends MapOf<string>>(
|
|
|
332
332
|
targetMsgs: T,
|
|
333
333
|
) {
|
|
334
334
|
/**
|
|
335
|
-
* One entry per *declared* key, so it is bounded by the message table
|
|
335
|
+
* One entry per *declared* key, so it is bounded by the message table: the
|
|
336
336
|
* tables in `serve-log.ts` are literals of a dozen-odd entries each.
|
|
337
337
|
* Undeclared props are deliberately left uncached: the trap fabricates a
|
|
338
338
|
* message for those, and a caller reading arbitrary property names off the
|
package/src/logger/serve-log.ts
CHANGED
|
@@ -10,10 +10,10 @@ const serveMsgs = {
|
|
|
10
10
|
// the one recorded after the last successful sync, so the boot-time schema
|
|
11
11
|
// sync is skipped. `--sync` bypasses the skip.
|
|
12
12
|
SCHEMA_SYNC_SKIP:
|
|
13
|
-
'I Schema unchanged since last sync
|
|
13
|
+
'I Schema unchanged since last sync: %yskipping schema sync%* (%c--sync%* forces it)',
|
|
14
14
|
// The skip above is precisely when drift goes unnoticed: schema.ts has not
|
|
15
15
|
// changed, so no sync runs, so nothing looks at the database. Something else
|
|
16
|
-
// altered it
|
|
16
|
+
// altered it: a hand-run ALTER, another environment pointed at the same URL,
|
|
17
17
|
// a restore from an older dump.
|
|
18
18
|
SCHEMA_DRIFT:
|
|
19
19
|
'W %yDatabase no longer matches the last schema Bakery applied%*: {reason}. Run %cdb:history%* to see what was applied, or %cdb:sync --dry-run%* to see what the difference means.',
|
|
@@ -27,26 +27,32 @@ const serveMsgs = {
|
|
|
27
27
|
'I Synced %ytsconfig.json%* paths with %yserver.config.ts%*!',
|
|
28
28
|
TSCONFIG_PROJECTS_WRITTEN:
|
|
29
29
|
'I Wrote %y{count}%* tsconfig project(s) to %y.cache/tsconfig/%*',
|
|
30
|
+
// One-time repair. Previous releases wired the generated projects into the
|
|
31
|
+
// app's tsconfig.json as `references`, which broke `tsc -p <app>`
|
|
32
|
+
// (TS6305/6306/6310). Named so the rewrite of a tracked file comes with a
|
|
33
|
+
// line saying why it happened.
|
|
34
|
+
TSCONFIG_REFERENCES_REMOVED:
|
|
35
|
+
'I Removed generated %yreferences%* from %ytsconfig.json%*, the %y.cache/tsconfig/%* projects are standalone, and referencing them broke %ytsc -p%*',
|
|
30
36
|
// A plugin asking for a project name that is taken. Named rather than
|
|
31
37
|
// silent: the symptom otherwise is one plugin's types quietly not applying,
|
|
32
38
|
// discovered much later and blamed on the wrong thing.
|
|
33
39
|
TSCONFIG_PROJECT_CLASH:
|
|
34
|
-
'W Plugin %y{plugin}%* wants tsconfig project %y{project}%*, which already exists
|
|
40
|
+
'W Plugin %y{plugin}%* wants tsconfig project %y{project}%*, which already exists: %rskipped%*',
|
|
35
41
|
// Degrades to "no types" rather than failing the boot: a missing declaration
|
|
36
42
|
// is a worse editor experience, not a broken server.
|
|
37
43
|
TSCONFIG_FILE_UNRESOLVED:
|
|
38
|
-
'W Could not resolve tsconfig files entry %y{entry}
|
|
44
|
+
'W Could not resolve tsconfig files entry %y{entry}%*: %rskipped%*',
|
|
39
45
|
MANUAL_RELOAD: 'I %yManual reload%* triggered from client logger!',
|
|
40
46
|
CONFIG_IMPORT_ERR: 'E Failed to import %yserver.config.ts%*: %r{error}%*',
|
|
41
47
|
// Multi-line on purpose: a present-but-broken config booting on defaults is
|
|
42
|
-
// the kind of failure a single scrolled-away line hides. DEV only
|
|
48
|
+
// the kind of failure a single scrolled-away line hides. DEV only: in PROD
|
|
43
49
|
// the same condition throws out of initConfig() instead of logging.
|
|
44
50
|
CONFIG_BROKEN:
|
|
45
|
-
'E %rserver.config.ts is present but failed to load
|
|
51
|
+
'E %rserver.config.ts is present but failed to load: booting on built-in defaults (no plugins, no hosts, default port)%*\n file: %y{file}%*\n{error}',
|
|
46
52
|
// The banner restatement of CONFIG_BROKEN, so the warning survives console
|
|
47
53
|
// scrollback and sits next to the URLs the developer actually reads.
|
|
48
54
|
CONFIG_BROKEN_BANNER:
|
|
49
|
-
'W %rserver.config.ts failed to load%* %y
|
|
55
|
+
'W %rserver.config.ts failed to load%* %y, running on built-in defaults. See the import error above.%*',
|
|
50
56
|
WEBSOCKET_ERR: 'E WebSocket error from %y{ip}%*: %r{error}%*',
|
|
51
57
|
RATE_LIMITED: 'W Rate limited: %y{ip}%*',
|
|
52
58
|
// Emitted instead of RATE_LIMITED when sampling (cli/rate-limit.ts) held
|
|
@@ -57,32 +63,39 @@ const serveMsgs = {
|
|
|
57
63
|
// limit silently 429s load tests and shared-NAT offices, so its existence
|
|
58
64
|
// gets one announcement. An app-configured value prints nothing.
|
|
59
65
|
RATE_LIMIT_DEFAULT:
|
|
60
|
-
'I Rate limit: %y{max}%* burst / %y{refill}%* req/s per IP (default)
|
|
66
|
+
'I Rate limit: %y{max}%* burst / %y{refill}%* req/s per IP (default). Set %crateLimit: false%* to disable',
|
|
61
67
|
// Multi-worker port sharing rides on kernel-level SO_REUSEPORT load
|
|
62
68
|
// balancing, which only Linux provides; elsewhere N sockets either fail to
|
|
63
69
|
// bind or never receive balanced traffic.
|
|
64
70
|
CLUSTER_CLAMPED:
|
|
65
71
|
'W [Cluster] %y{platform}%* has no kernel-level SO_REUSEPORT load balancing (Linux-only), so %y{requested}%* workers cannot share one port. Running a single worker.',
|
|
66
72
|
WORKER_RESPAWN:
|
|
67
|
-
'W [Cluster] Worker %g{id}%* exited unexpectedly
|
|
73
|
+
'W [Cluster] Worker %g{id}%* exited unexpectedly: restarting in %y{delay}%*ms (consecutive failure #%y{failures}%*).',
|
|
68
74
|
// Thread-worker only: the master should hand over the shared memory pool
|
|
69
75
|
// before the worker starts serving; past the deadline the worker serves on
|
|
70
76
|
// its local pool rather than deadlock.
|
|
71
77
|
SHARED_POOL_TIMEOUT:
|
|
72
|
-
'W [Cluster] No shared memory pool from the master within %y{timeout}%*ms
|
|
78
|
+
'W [Cluster] No shared memory pool from the master within %y{timeout}%*ms: serving on a worker-local pool; cross-worker counters and rate limits will not be shared until it arrives.',
|
|
73
79
|
// The version wipe could not empty the cache directory, so no "current"
|
|
74
80
|
// marker was written and the next boot will try again. Worth a line rather
|
|
75
81
|
// than silence: something is holding those files open, and a stale compiled
|
|
76
82
|
// page surviving an upgrade is the failure the wipe exists to prevent.
|
|
83
|
+
// SQLite would not take WAL for this file, so it is running the DELETE
|
|
84
|
+
// journal instead. Worth one line rather than silence: on a local disk WAL
|
|
85
|
+
// is 100x faster per write and 29x on a transaction, and sessions write on
|
|
86
|
+
// the request path, so this line is the difference between "this
|
|
87
|
+
// filesystem cannot do better" and a deployment quietly paying that.
|
|
88
|
+
JOURNAL_WAL_REFUSED:
|
|
89
|
+
'W SQLite refused WAL for %y{file}%* (answered %y{answer}%*): running the %y{mode}%* journal instead. Expected on a network path; on a local disk it costs every write.',
|
|
77
90
|
CACHE_WIPE_INCOMPLETE:
|
|
78
|
-
'W Cache directory could not be cleared for the version change
|
|
91
|
+
'W Cache directory could not be cleared for the version change: %y{dir}%* still contains %y{files}%*. Retrying on next start; close anything holding those files.',
|
|
79
92
|
} as const
|
|
80
93
|
|
|
81
94
|
export const serveLog = messageLogger(new Logger('serve'), serveMsgs)
|
|
82
95
|
|
|
83
96
|
const handlerMsgs = {
|
|
84
97
|
// Emitted by `DynamicHandler.executeModule`, which every route handler goes
|
|
85
|
-
// through
|
|
98
|
+
// through: an API route, a `.tsx` page and an `error-*.tsx` all land here.
|
|
86
99
|
// It used to say "API module" while printing a `.tsx` path, alongside a
|
|
87
100
|
// `TSX_IMPORT_ERR` that no code ever reached.
|
|
88
101
|
API_IMPORT_ERR: 'E Failed to import route module (%y{file}%*): %r{error}%*',
|
|
@@ -90,7 +103,7 @@ const handlerMsgs = {
|
|
|
90
103
|
// default. `ApiHandler` used to answer that with a bare 404 naming nothing,
|
|
91
104
|
// which reads as "your route file is missing" for a file that is right there.
|
|
92
105
|
API_NO_DEFAULT:
|
|
93
|
-
'E API route has no %cexport default%* (%y{file}%*)
|
|
106
|
+
'E API route has no %cexport default%* (%y{file}%*): the module loaded but exposes no handler',
|
|
94
107
|
// A handler ran and returned neither a value nor a Response. Still a 404 (see
|
|
95
108
|
// `ApiHandler.handle`), but the log names the file rather than leaving the
|
|
96
109
|
// developer to guess which route answered.
|
|
@@ -106,13 +119,13 @@ const handlerMsgs = {
|
|
|
106
119
|
BUNDLE_SIDE_EFFECTS_REPAIRED:
|
|
107
120
|
'I %y{file}%* tree-shook to an empty export list because its package declares %ysideEffects: false%*; re-bundled through a re-export shim, which keeps the code.',
|
|
108
121
|
BUNDLE_EMPTY_EXPORTS:
|
|
109
|
-
'E %y{file}%* bundled to an export list with no code behind it
|
|
122
|
+
'E %y{file}%* bundled to an export list with no code behind it: every name it exports is undefined, so it is rejected rather than served. The bundler reported success, and re-bundling through a re-export shim did not recover it. Import the specific module you need instead of the package root.',
|
|
110
123
|
BUNDLE_CJS_INTEROP:
|
|
111
124
|
'I Generated named exports for the CommonJS package %y{file}%*, so a named import of it works in the browser.',
|
|
112
125
|
BUNDLE_CJS_PROBED:
|
|
113
126
|
'I Could not read %y{file}%* export names statically, so they were probed by importing it in a short-lived child process.',
|
|
114
127
|
BUNDLE_CJS_DEFAULT_ONLY:
|
|
115
|
-
'W %y{file}%* assigns %ymodule.exports%* wholesale and its members could not be read, so its browser bundle exports only %ydefault
|
|
128
|
+
'W %y{file}%* assigns %ymodule.exports%* wholesale and its members could not be read, so its browser bundle exports only %ydefault%*: a named import from it fails in the browser with "does not provide an export named …", and nothing fails here. Import the default and read the property off it, or use an ESM build.',
|
|
116
129
|
} as const
|
|
117
130
|
|
|
118
131
|
export const handlerLog = messageLogger(new Logger('handlers'), handlerMsgs)
|
|
@@ -131,6 +144,7 @@ const pluginMsgs = {
|
|
|
131
144
|
UNHANDLED_ERR: 'E Unhandled Plugin Error: %r{error}%*',
|
|
132
145
|
ANALYTICS_STORE_ERR: 'E Analytics store init failed: %r{error}%*',
|
|
133
146
|
DASHBOARD_BUNDLE_ERR: 'E Failed to bundle %ydashboard.js%*: %r{error}%*',
|
|
147
|
+
EXPLORER_QUERY_ERR: 'E Explorer %y{op}%* failed: %r{error}%*',
|
|
134
148
|
} as const
|
|
135
149
|
|
|
136
150
|
export const pluginLog = messageLogger(new Logger('plugins'), pluginMsgs)
|
|
@@ -142,7 +156,7 @@ export const errorMsg = (err: any) => err?.stack || err?.message || String(err)
|
|
|
142
156
|
*
|
|
143
157
|
* `BuildMessage` and `ResolveMessage` are the two things Bun throws for a
|
|
144
158
|
* syntax error and an unresolvable import. Neither is `instanceof Error`,
|
|
145
|
-
* neither carries a `stack`, and both report *zero* own enumerable keys
|
|
159
|
+
* neither carries a `stack`, and both report *zero* own enumerable keys, so
|
|
146
160
|
* nothing that inspects a thrown value by spreading it or by `instanceof` sees
|
|
147
161
|
* anything at all. `message`, `name` and `position` are the accessors that
|
|
148
162
|
* actually answer.
|
|
@@ -188,7 +202,7 @@ function positionLines(position: Diagnostic['position']): string[] {
|
|
|
188
202
|
* `Error` and useless for the diagnostics above: they have no stack, so it
|
|
189
203
|
* degrades to a bare "Expected identifier but found end of file" with no file
|
|
190
204
|
* and no line. This walks `position` instead, and recurses through the
|
|
191
|
-
* `AggregateError.errors` array that `import()`ing a broken `.tsx` throws
|
|
205
|
+
* `AggregateError.errors` array that `import()`ing a broken `.tsx` throws:
|
|
192
206
|
* that one *is* an `Error`, but it has no stack either, so the summary line
|
|
193
207
|
* ("4 errors building …") was all anyone ever saw.
|
|
194
208
|
*
|
|
@@ -217,7 +231,7 @@ export function errorDetail(err: any): string {
|
|
|
217
231
|
*
|
|
218
232
|
* For the log lines whose template already names the file. Bun's own
|
|
219
233
|
* `position.file` is `input.ts` whenever `transform()` was handed a source
|
|
220
|
-
* string rather than a path
|
|
234
|
+
* string rather than a path (which is every compile this codebase does), so
|
|
221
235
|
* printing it beside the real path would contradict it.
|
|
222
236
|
*/
|
|
223
237
|
export function errorWithPosition(err: any): string {
|
package/src/plugins/index.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* The plugin-authoring surface, as one barrel.
|
|
3
3
|
*
|
|
4
4
|
* Every other public directory in core has an `index.ts` and `plugins/` did
|
|
5
|
-
* not, so `@bakery-framework/core/plugins` pointed straight at `routes.ts
|
|
5
|
+
* not, so `@bakery-framework/core/plugins` pointed straight at `routes.ts`, which meant
|
|
6
6
|
* `definePlugin` and `ServerPlugin` were only reachable through
|
|
7
7
|
* `@bakery-framework/core/plugins/types`, a second subpath for one concept. It also broke
|
|
8
8
|
* under TypeScript's `paths` resolution, which maps `@bakery-framework/core/plugins` to
|
package/src/plugins/routes.ts
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* Declarative endpoint tables for plugins.
|
|
3
3
|
*
|
|
4
4
|
* The dashboard already used a `Record<path, handler>` map; analytics used a
|
|
5
|
-
* hand-rolled `if (path === … && method === …)` chain. This
|
|
6
|
-
* dashboard's shape
|
|
5
|
+
* hand-rolled `if (path === … && method === …)` chain. This generalizes the
|
|
6
|
+
* dashboard's shape (the one that was already working), so a third plugin does
|
|
7
7
|
* not arrive with a fourth style of dispatch.
|
|
8
8
|
*
|
|
9
9
|
* Scope is deliberately narrow: exact-match paths only, no params or wildcards.
|
|
@@ -16,7 +16,7 @@ import { response } from '../utils/http/response'
|
|
|
16
16
|
import type { ValidResponses } from './types'
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
|
-
* Handlers may return anything the router knows how to serialize
|
|
19
|
+
* Handlers may return anything the router knows how to serialize: the
|
|
20
20
|
* dashboard's return `JsonResponseData` and `BunFile` as well as `Response`.
|
|
21
21
|
* `ValidResponses` (= `Handler.Response`) is the framework's name for that set.
|
|
22
22
|
*/
|
|
@@ -27,7 +27,7 @@ export type PluginRoute = (req: Request, url: URL) => ValidResponses
|
|
|
27
27
|
* method-qualified path (`'POST /api/_x/reset'`). A method-qualified entry wins
|
|
28
28
|
* over a bare one for the same path.
|
|
29
29
|
*
|
|
30
|
-
* The two forms are guarded differently
|
|
30
|
+
* The two forms are guarded differently. See `guardFor` below. A bare key is
|
|
31
31
|
* same-origin-only; a method-qualified one gets the ordinary CSRF check.
|
|
32
32
|
*/
|
|
33
33
|
export type PluginRouteTable = Record<string, PluginRoute>
|
|
@@ -35,7 +35,7 @@ export type PluginRouteTable = Record<string, PluginRoute>
|
|
|
35
35
|
/**
|
|
36
36
|
* What a dispatch built from `T` actually resolves to: the union of that
|
|
37
37
|
* table's own handler return types, awaited, with the `undefined`/`void` cases
|
|
38
|
-
* replaced by `null
|
|
38
|
+
* replaced by `null`. Exactly what `dispatch` does at runtime with `?? null`.
|
|
39
39
|
*
|
|
40
40
|
* Keyed on the table type rather than flattened to `ValidResponses` because
|
|
41
41
|
* `ValidResponses` contains `object`, which absorbs `Response`, `Bun.BunFile`
|
|
@@ -59,7 +59,7 @@ export type PluginRouteResult<T extends PluginRouteTable> =
|
|
|
59
59
|
* unsafe ones same-origin. `'GET /api/_x/public'` is therefore also the way to
|
|
60
60
|
* declare an endpoint that *is* meant to answer cross-origin.
|
|
61
61
|
*
|
|
62
|
-
* A **bare** key matches every method, which means the author did not say
|
|
62
|
+
* A **bare** key matches every method, which means the author did not say,
|
|
63
63
|
* and that is exactly how the dashboard's mutating endpoints ended up
|
|
64
64
|
* answering a `GET`. `checkCsrf` is no help there: it waves GET through by
|
|
65
65
|
* definition, so a cross-site `<img src="/api/_dashboard/execute-action">`
|
|
@@ -76,7 +76,7 @@ function guardFor(qualified: boolean) {
|
|
|
76
76
|
|
|
77
77
|
export function routeTable<T extends PluginRouteTable>(routes: T) {
|
|
78
78
|
/**
|
|
79
|
-
* Declared as an overload
|
|
79
|
+
* Declared as an overload: the same shape `handleRequest` uses in
|
|
80
80
|
* `router.ts`. The signature carries the precise per-table union; the
|
|
81
81
|
* implementation only ever sees the `PluginRoute` *constraint*, so on its own
|
|
82
82
|
* it could infer no better than the wide `ValidResponses`.
|