@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
package/src/startup.ts ADDED
@@ -0,0 +1,154 @@
1
+ import { networkInterfaces } from 'node:os'
2
+ import { Bakery } from './core/bakery'
3
+ import { getConfigLoadError } from './core/config'
4
+ import { resolvePort } from './core/port'
5
+ import {
6
+ ApiErrorHandler,
7
+ ApiHandler,
8
+ DefaultErrorHandler,
9
+ GoogleFontHandler,
10
+ HTMLErrorHandler,
11
+ HTMLHandler,
12
+ ImageHandler,
13
+ LiveReloadHandler,
14
+ MiddlewareHandler,
15
+ NMHandler,
16
+ ProxyHandler,
17
+ PublicHandler,
18
+ StaticHandler,
19
+ TSHandler,
20
+ TSXErrorHandler,
21
+ TSXHandler,
22
+ VirtualAssetHandler,
23
+ } from './handlers'
24
+ import { log, serveLog } from './logger'
25
+ import { match } from './utils/common'
26
+ import { DEFAULT_RATE_LIMIT } from './utils/constants'
27
+
28
+ let pluginSetup: Promise<void> | null = null
29
+
30
+ /**
31
+ * `PluginHooks.setup()`, exactly once per process.
32
+ *
33
+ * Two call sites legitimately need plugins ready and neither can be dropped.
34
+ * The CLI entries (`cli/dev.ts`, `cli/prod.ts`) must run it before
35
+ * `initImportMap()`, because a plugin's `setup()` may add import-map entries.
36
+ * `setupServer()` must run it too, because a cluster worker is spawned straight
37
+ * into `cli/worker.ts` and never passes through either entry.
38
+ *
39
+ * Calling both meant every non-clustered worker set its plugins up twice.
40
+ * Handler and mount registration is idempotent so that went unnoticed, but the
41
+ * analytics plugin registers a shutdown hook and kicks off `loadAnalyticsData()`
42
+ * from `setup()` — so it got two of each. Memoising the promise (not a boolean)
43
+ * also makes a concurrent second caller await the first run rather than race it.
44
+ */
45
+ export function setupPlugins(): Promise<void> {
46
+ pluginSetup ??= (async () => {
47
+ const { PluginHooks } = await import('./core/plugins')
48
+ await PluginHooks.setup()
49
+ })()
50
+
51
+ return pluginSetup
52
+ }
53
+
54
+ /**
55
+ * Test seam, in the family of `__setTestConfig` / `__setTestDb`. The memo above
56
+ * is process-wide by design, which is exactly what a test asserting "once"
57
+ * needs to be able to clear between cases.
58
+ */
59
+ export function __resetPluginSetup(): void {
60
+ pluginSetup = null
61
+ }
62
+
63
+ export async function setupServer(): Promise<void> {
64
+ if (import.meta.env.DEV) {
65
+ await LiveReloadHandler.init()
66
+ Bakery.handlers.websocket.set(LiveReloadHandler)
67
+ }
68
+ Bakery.handlers.error.set(ApiErrorHandler, 30)
69
+ Bakery.handlers.error.set(TSXErrorHandler, 20)
70
+ Bakery.handlers.error.set(HTMLErrorHandler, 10)
71
+ Bakery.handlers.error.set(DefaultErrorHandler, 0)
72
+
73
+ Bakery.handlers.fetch.set(MiddlewareHandler, 100)
74
+ Bakery.handlers.fetch.set(ProxyHandler, 95)
75
+ Bakery.handlers.fetch.set(VirtualAssetHandler, 90)
76
+ Bakery.handlers.fetch.set(GoogleFontHandler, 87)
77
+ Bakery.handlers.fetch.set(ImageHandler, 85)
78
+ Bakery.handlers.fetch.set(PublicHandler, 84)
79
+ Bakery.handlers.fetch.set(NMHandler, 80)
80
+ Bakery.handlers.fetch.set(ApiHandler, 70)
81
+ Bakery.handlers.fetch.set(TSXHandler, 60)
82
+ Bakery.handlers.fetch.set(HTMLHandler, 55)
83
+ Bakery.handlers.fetch.set(TSHandler, 50)
84
+ Bakery.handlers.fetch.set(StaticHandler, 0)
85
+
86
+ await setupPlugins()
87
+
88
+ await Promise.all([
89
+ Bakery.handlers.fetch.initRoutes(),
90
+ Bakery.handlers.error.initRoutes(),
91
+ Bakery.handlers.websocket.initRoutes(),
92
+ ])
93
+ }
94
+
95
+ export async function runStartupBanner(): Promise<void> {
96
+ const host = Bakery.config.host
97
+
98
+ // Ground truth first. The banner's job is to print where the server is
99
+ // listening, and `Bakery.server.port` is what the OS actually gave us —
100
+ // which is the only correct answer under `PORT=0`, and the only *honest*
101
+ // one if the bound port and the requested one ever part company again.
102
+ // `resolvePort` covers the callers that print a banner without a server
103
+ // (a `--sync`-only run, an embedder), and it is the same function
104
+ // `worker.ts` binds with, so the two cannot drift apart.
105
+ const port = Bakery.server?.port || resolvePort(Bakery.config.port)
106
+
107
+ const isThreadWorker = import.meta.env.THREAD_WORKER
108
+ if (!isThreadWorker || import.meta.env.THREAD_ID === '0') {
109
+ if (isThreadWorker) {
110
+ serveLog.THREAD_STARTED({ id: import.meta.env.THREAD_ID })
111
+ } else {
112
+ serveLog.SERVER_STARTED()
113
+ }
114
+
115
+ const logAllNets = () => {
116
+ serveLog.SERVER_URL({ type: 'Local ', host: 'localhost', port })
117
+ const nets = networkInterfaces()
118
+ for (const name of Object.keys(nets)) {
119
+ for (const net of nets[name] || []) {
120
+ if (net.internal) continue
121
+ if (net.family !== 'IPv4') continue
122
+ serveLog.SERVER_URL({ type: 'Network', host: net.address, port })
123
+ }
124
+ }
125
+ }
126
+
127
+ match(host, {
128
+ '0.0.0.0': logAllNets,
129
+ '::': logAllNets,
130
+ [match]: () => serveLog.SERVER_URL({ type: 'Local ', host, port }),
131
+ })
132
+
133
+ // Identity, not deep equality: `defaultConfig.rateLimit` *is* this
134
+ // constant, and any user-supplied value replaces the reference. The
135
+ // default limiter 429s load tests and shared-NAT offices with nothing
136
+ // anywhere saying it exists, so this line is its one announcement. An
137
+ // app-configured value prints nothing — their choice, their knowledge.
138
+ if (Bakery.config.rateLimit === DEFAULT_RATE_LIMIT) {
139
+ serveLog.RATE_LIMIT_DEFAULT(DEFAULT_RATE_LIMIT)
140
+ }
141
+
142
+ // A broken server.config.ts already logged its import error in DEV, but
143
+ // that scrolls away; the banner is what the developer actually reads.
144
+ if (getConfigLoadError()) {
145
+ serveLog.CONFIG_BROKEN_BANNER()
146
+ }
147
+
148
+ log({ by: 'serve', msg: '' })
149
+ }
150
+
151
+ const { PluginHooks } = await import('./core/plugins')
152
+ await PluginHooks.onStart(Bakery.server!)
153
+ await Bakery.config.onStart()
154
+ }
package/src/types.d.ts ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Framework helper types, importable rather than ambient.
3
+ *
4
+ * These were declared in `declare global`, which is fine inside one repo and a
5
+ * hazard the moment the packages are published: the names are generic enough
6
+ * that a consuming app is likely to define its own `MapOf`, and a global type
7
+ * alias cannot be merged or opted out of — the app just gets a redeclaration
8
+ * error with nowhere to put the fix.
9
+ *
10
+ * Moving them cost 24 files an `import type` line and cost consumers nothing:
11
+ * neither app in this repo referenced any of the five, which is the evidence
12
+ * that they were always internal plumbing rather than DX.
13
+ *
14
+ * `verbatimModuleSyntax` makes the compiler reject a value-position import of
15
+ * these, so nothing can accidentally introduce a runtime module edge — which
16
+ * matters because `client/utils.ts` is compiled into the browser bundle.
17
+ */
18
+ export type MapOf<T> = { [key: string]: T }
19
+
20
+ /** A value, or a function producing it. */
21
+ export type Wrapped<T, Args extends any[] = []> = T | ((...args: Args) => T)
22
+
23
+ /** Awaited or not — the shape almost every framework hook returns. */
24
+ export type MixedPromise<T> = Promise<T> | T
25
+
26
+ type ResolveMatchReturn<V, R> = R extends any
27
+ ? R extends (v: V) => infer U
28
+ ? U
29
+ : R
30
+ : never
31
+
32
+ export type Match<D extends symbol> = {
33
+ default: D
34
+ [Symbol.toPrimitive](hint: string): symbol
35
+
36
+ <V, C extends readonly (readonly [any, any])[]>(
37
+ value: V,
38
+ cases: C,
39
+ ): [Extract<C[number][0], D>] extends [never]
40
+ ? ResolveMatchReturn<V, C[number][1]> | undefined
41
+ : ResolveMatchReturn<V, C[number][1]>
42
+
43
+ <V extends string, K>(
44
+ value: V,
45
+ cases: Record<string | symbol, K | ((v: V) => K)> & { length?: never },
46
+ ): K
47
+
48
+ <V extends string, K>(
49
+ value: V,
50
+ cases: Record<string, K | ((v: V) => K)> & { length?: never },
51
+ ): K | undefined
52
+ } & D
53
+
54
+ /**
55
+ * The `body` a route module's default export receives: declared params merge
56
+ * over a permissive base. `body.id` is `string` once you declare it, while
57
+ * `body.anythingElse` stays reachable as `any` — the parse rules in
58
+ * `utils/http/body.ts` mean the framework genuinely cannot know every key, so
59
+ * locking the object down would be a lie in the other direction.
60
+ *
61
+ * There is no filename-literal inference (`[id].ts` does not conjure
62
+ * `{ id: string }` on its own — that needs codegen); the contract is that the
63
+ * author declares the shape once, at the `defineRoute` / `html` call, instead
64
+ * of annotating the whole signature or settling for `any`.
65
+ */
66
+ export type RouteBody<P = {}> = P & MapOf<any>
67
+
68
+ /**
69
+ * What a route module may actually return — read off `processResponse`
70
+ * (`router.ts`) and `ApiHandler.handle`: a `Response`, a `BunFile` (streamed
71
+ * with an ETag), the JSON envelope, a plain data object or array (JSON-encoded
72
+ * as-is), a string (HTML-sniffed) or number, or nothing (204; a 404 under
73
+ * `/api/`).
74
+ *
75
+ * Deliberately not `object` (which `Handler.Response` uses): `object` absorbs
76
+ * `Response`, `BunFile` and `JsonResponse` into one member and the union stops
77
+ * saying anything — the same trap `routeTable()`'s dispatch avoids. `MapOf<any>`
78
+ * is the narrowest thing that covers "a plain data object"; a target index
79
+ * signature of `any` does still accept class instances, but that only matters
80
+ * for inference targets — this union is a constraint on what an author may
81
+ * return, and nothing consumes a route module's return type downstream, so the
82
+ * members stay legible instead of collapsing.
83
+ */
84
+ export type RouteResponse = MixedPromise<
85
+ | Response
86
+ | Bun.BunFile
87
+ | JsonResponse<any>
88
+ | MapOf<any>
89
+ | readonly unknown[]
90
+ | string
91
+ | number
92
+ | null
93
+ | undefined
94
+ | void
95
+ >
96
+
97
+ /**
98
+ * A typed route module default export. `$dynamic.executeModule` calls it as
99
+ * `(req, body, Bakery.server)` — the third argument is optional here because
100
+ * `Bakery.server` is itself optional before `Bun.serve` runs, and almost no
101
+ * handler wants it.
102
+ *
103
+ * Named `RouteHandler`, not `ApiHandler`: `handlers/routes/api.ts` already
104
+ * exports an `ApiHandler` class (and `$base.ts` a `Handler`), and TSX pages use
105
+ * the same calling convention, so the name should not claim `/api/` either.
106
+ */
107
+ export type RouteHandler<P = {}> = (
108
+ req: Request,
109
+ body: RouteBody<P>,
110
+ server?: Bun.Server<any>,
111
+ ) => RouteResponse
@@ -0,0 +1,11 @@
1
+ /**
2
+ * `Case` moved to `utils/isomorphic/case` — the client bundle carried an
3
+ * identical copy. `toHash` stays here: it calls `Bun.hash`, so it is server-only
4
+ * and must not be pulled into the isomorphic layer.
5
+ */
6
+ export * from '../isomorphic/case'
7
+
8
+ export function toHash(str = '') {
9
+ str ||= Math.random().toString(16).slice(2)
10
+ return Bun.hash(str).toString(36)
11
+ }
@@ -0,0 +1,5 @@
1
+ export * from './case'
2
+ export * from './json'
3
+ export * from './match'
4
+ export * from './misc'
5
+ export * from './try'
@@ -0,0 +1,35 @@
1
+ export class JsonResponseData<T = any> implements JsonResponse<T> {
2
+ time: number = 0
3
+
4
+ constructor(
5
+ public status: number,
6
+ public message: string,
7
+ public data?: T,
8
+ ) {}
9
+
10
+ toJson() {
11
+ return JSON.stringify({
12
+ time: this.time,
13
+ status: this.status,
14
+ message: this.message,
15
+ data: this.data,
16
+ })
17
+ }
18
+
19
+ toObject() {
20
+ return {
21
+ time: this.time,
22
+ status: this.status,
23
+ message: this.message,
24
+ data: this.data,
25
+ }
26
+ }
27
+ }
28
+
29
+ export function jsonResponse<T>(
30
+ status: number,
31
+ message: string,
32
+ data?: T,
33
+ ): JsonResponseData<T> {
34
+ return new JsonResponseData(status, message, data)
35
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Moved to `utils/isomorphic/match` so the browser bundle and the server share
3
+ * one implementation — the prototype-chain fix (`Object.hasOwn` rather than
4
+ * `in`) previously had to be applied to both copies separately.
5
+ */
6
+ export * from '../isomorphic/match'
@@ -0,0 +1,53 @@
1
+ /**
2
+ * `is`, `Math2` and `throws` moved to `utils/isomorphic/` — the client bundle
3
+ * carried its own copies of all three. They are re-exported here so existing
4
+ * `@server/utils` importers are unaffected.
5
+ *
6
+ * `deferredValue` / `hasDeferredValue` stay: they are server-only in practice
7
+ * and were never duplicated.
8
+ */
9
+ import type { MapOf } from '../../types'
10
+
11
+ export { is } from '../isomorphic/is'
12
+ export { Math2 } from '../isomorphic/math'
13
+ export { throws } from '../isomorphic/misc'
14
+
15
+ const DEFERRED = Symbol('deferred')
16
+ export function deferredValue<O extends object, T>(
17
+ object: O,
18
+ key: string,
19
+ value: (this: O, o: O) => T,
20
+ ) {
21
+ if (!(DEFERRED in object)) {
22
+ Object.defineProperty(object, DEFERRED, {
23
+ enumerable: false,
24
+ configurable: false,
25
+ writable: false,
26
+ value: {},
27
+ })
28
+ }
29
+
30
+ const map = (object as any)[DEFERRED] as MapOf<any>
31
+
32
+ Object.defineProperty(object, key, {
33
+ enumerable: true,
34
+ configurable: true,
35
+ get() {
36
+ if (key in map) return map[key]
37
+ map[key] = value.call(this, this)
38
+ return map[key]
39
+ },
40
+ set(val) {
41
+ map[key] = val
42
+ },
43
+ })
44
+ }
45
+
46
+ export function hasDeferredValue<
47
+ O extends object,
48
+ T extends keyof O | (string & {}),
49
+ >(object: object, key: T): boolean {
50
+ if (!(DEFERRED in object)) return false
51
+ const map = object[DEFERRED] as MapOf<any>
52
+ return key in map
53
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Moved to `utils/isomorphic/try` so the browser bundle and the server share one
3
+ * implementation. Re-exported here because `@server/utils` and a number of
4
+ * modules import `Try` from this path.
5
+ */
6
+ export * from '../isomorphic/try'
@@ -0,0 +1,153 @@
1
+ export const DEFAULT_PORT = 3000
2
+ export const DEFAULT_HOST = '0.0.0.0'
3
+
4
+ export const DEFAULT_DB_BACKUPS = 10
5
+
6
+ export const DEFAULT_SESSION_TTL = 1000 * 60 * 60 // 1 hour in ms
7
+ export const DEFAULT_SESSION_PERSIST = DEFAULT_SESSION_TTL * 24 * 30 // 30 days in ms
8
+
9
+ export const DEFAULT_RATE_LIMIT = { max: 100, refill: 10 }
10
+
11
+ /**
12
+ * Files that must never be served off disk.
13
+ *
14
+ * These are matched against the file a *file-serving* handler is about to
15
+ * return, not against every request path — see `servesFiles` in `router.ts`.
16
+ * An API route or a proxied path is a name, not a file, and never had any
17
+ * business being tested against this list.
18
+ *
19
+ * **Every pattern here must stay lower-case.** `matchBlocked` below folds the
20
+ * request path to lower case before the second of its two match attempts, and
21
+ * an upper-case character in a default pattern would be silently unreachable
22
+ * on that attempt.
23
+ *
24
+ * Note what is *not* here: an extension-wide ban on JSON. It caught every
25
+ * JSON document an app might legitimately publish — a web app manifest, a
26
+ * `.well-known` document — and since `blocked` in config can only ever append,
27
+ * an app had no way to opt back out. What the list is actually protecting is
28
+ * the handful of JSON files that describe the project, so those are named.
29
+ */
30
+ export const DEFAULT_BLOCKED_GLOBS = [
31
+ '**/.env',
32
+ '**/*.env',
33
+ '**/*.sql',
34
+ '**/*.db',
35
+ '**/package.json',
36
+ '**/package-lock.json',
37
+ '**/tsconfig.json',
38
+ '**/tsconfig.*.json',
39
+ '**/*.yaml',
40
+ '**/*.yml',
41
+ '**/*.lock',
42
+ // `bun.lock` is covered by `*.lock`; the binary lockfile is not.
43
+ '**/bun.lockb',
44
+ // `dir/**/*`, not `dir/**`. These patterns are joined into a single
45
+ // brace-expanded glob by `initConfig`, and inside a brace group Bun's
46
+ // trailing `**` collapses to a single segment: `{**/node_modules/**}` matches
47
+ // `/node_modules/a` and then stops, so `/node_modules/pkg/index.js` was not
48
+ // blocked by anything. Each pattern still matched on its own, which is why
49
+ // it survived — the defect only exists in the form the config actually uses.
50
+ // `.cache` is the framework's disposable working dir and `bakery/` holds the
51
+ // database and its backups — the visible one is the precious one, so that a
52
+ // dotfile sweep cannot reach the data. `bakery/` being un-dotted is why it
53
+ // needs naming here explicitly: `*.db` covers the database file itself, but
54
+ // not the `.ts` schema snapshots under `bakery/backups/`.
55
+ '**/.cache/**/*',
56
+ '**/bakery/**/*',
57
+ '**/_internal/**/*',
58
+ '**/.git/**/*',
59
+ '**/.vscode/**/*',
60
+ '**/node_modules/**/*',
61
+ '**/server.config.ts',
62
+ '**/schema.ts',
63
+ '**/.gitignore',
64
+ '**/*.exe',
65
+ ]
66
+
67
+ /**
68
+ * A request path folded to the form the *filesystem* would resolve it to.
69
+ *
70
+ * Two normalisations, both of them things the OS does and the glob does not:
71
+ *
72
+ * - **Case.** NTFS and APFS are case-insensitive, so `/PACKAGE.JSON` opens the
73
+ * same bytes as `/package.json` — but `Bun.Glob` is case-sensitive, so only
74
+ * the second one was blocked. Every file this list protects was reachable by
75
+ * holding down shift, and the static pipeline was verified to serve
76
+ * `/STYLES/GLOBAL.CSS` and `/styles/global.css` as the same 170 bytes.
77
+ * - **Trailing dots and spaces.** Win32 strips both from each path component,
78
+ * so `/package.json.` and `/package.json ` are the same file too.
79
+ *
80
+ * `.`, `..` and any all-dots component are left intact — trimming those would
81
+ * rewrite a relative segment into something else, and containment is
82
+ * `fs.isForbidden`'s job, not this function's.
83
+ */
84
+ export function normalizeBlockedPath(path: string): string {
85
+ return path
86
+ .toLowerCase()
87
+ .split('/')
88
+ .map(segment => segment.replace(/[. ]+$/, '') || segment)
89
+ .join('/')
90
+ }
91
+
92
+ /**
93
+ * Each path component truncated at its first `:`.
94
+ *
95
+ * Win32 resolves `x.ts::$DATA` to the bytes of `x.ts` and
96
+ * `dir::$INDEX_ALLOCATION` to `dir` — NTFS alternate-data-stream syntax — and
97
+ * `new URL()` keeps the suffix in `pathname`, so a glob anchored on the real
98
+ * name missed while the filesystem opened the file anyway. The directory form
99
+ * matters independently: it defeats the `dir/**` patterns that the file form
100
+ * cannot reach.
101
+ *
102
+ * Only ever used as an *extra* match candidate (see `matchBlocked`), never as
103
+ * a replacement — colons are legal in POSIX filenames, and a real file named
104
+ * `a:b.db` must stay blocked by `*.db` rather than being tested as `a`.
105
+ */
106
+ function stripStreamSuffix(path: string): string {
107
+ if (!path.includes(':')) return path
108
+ return path
109
+ .split('/')
110
+ .map(segment => {
111
+ const colon = segment.indexOf(':')
112
+ return colon === -1 ? segment : segment.slice(0, colon) || segment
113
+ })
114
+ .join('/')
115
+ }
116
+
117
+ /**
118
+ * Whether `path` is refused by the blocked globs.
119
+ *
120
+ * Matched twice: once as written, once normalised. Both are needed, and for
121
+ * different reasons. The normalised pass is what closes the case and
122
+ * trailing-dot variants above. The verbatim pass is what keeps an app's own
123
+ * `blocked: ['**\/Secrets.txt']` working — `initConfig` appends user patterns
124
+ * to this list untouched, so folding only the path would quietly stop matching
125
+ * the very patterns the app wrote.
126
+ *
127
+ * On a case-*sensitive* filesystem this over-blocks: a file genuinely named
128
+ * `/PACKAGE.JSON`, distinct from `/package.json`, is refused. That is the
129
+ * intended direction to be wrong in, and it is the reason this normalises the
130
+ * path rather than resolving the file. The router runs this check before it
131
+ * knows which file — often before there is a file at all — so "ask the
132
+ * filesystem for the real name" is not available at the point the answer is
133
+ * needed, and would cost a `realpath` per request where it was.
134
+ */
135
+ export function matchBlocked(
136
+ blocked: { match(path: string): boolean } | undefined,
137
+ path: string,
138
+ ): boolean {
139
+ if (!blocked) return false
140
+ if (blocked.match(path)) return true
141
+
142
+ // Candidates are only ever *added*, never substituted, so no form of this
143
+ // can unblock something a plainer form already caught.
144
+ const normalized = normalizeBlockedPath(path)
145
+ if (normalized !== path && blocked.match(normalized)) return true
146
+
147
+ const stripped = stripStreamSuffix(path)
148
+ if (stripped === path) return false
149
+ if (blocked.match(stripped)) return true
150
+
151
+ const strippedNormalized = normalizeBlockedPath(stripped)
152
+ return strippedNormalized !== stripped && blocked.match(strippedNormalized)
153
+ }