@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.
- package/LICENSE +19 -0
- package/README.md +89 -0
- package/package.json +69 -0
- package/src/cache/index.ts +8 -0
- package/src/cache/lru.ts +41 -0
- package/src/cache/shared-db.ts +51 -0
- package/src/cache/string.ts +150 -0
- package/src/cache/tiered.ts +493 -0
- package/src/client/globals.d.ts +74 -0
- package/src/client/livereload.ts +437 -0
- package/src/client/utils.ts +315 -0
- package/src/compiler/compiler.ts +263 -0
- package/src/compiler/dev-service.ts +660 -0
- package/src/compiler/index.ts +2 -0
- package/src/compiler/prompt-tracker.ts +36 -0
- package/src/compiler/tsconfig-sync.ts +71 -0
- package/src/core/bakery.ts +96 -0
- package/src/core/cache-version.ts +119 -0
- package/src/core/config.ts +296 -0
- package/src/core/context.ts +121 -0
- package/src/core/index.ts +61 -0
- package/src/core/init.ts +90 -0
- package/src/core/jsx.ts +152 -0
- package/src/core/paths.ts +24 -0
- package/src/core/plugins.ts +120 -0
- package/src/core/port.ts +73 -0
- package/src/global.d.ts +374 -0
- package/src/handlers/assets/google-font.ts +225 -0
- package/src/handlers/assets/image.ts +136 -0
- package/src/handlers/assets/nm.ts +73 -0
- package/src/handlers/assets/public.ts +17 -0
- package/src/handlers/assets/static.ts +86 -0
- package/src/handlers/assets/ts.ts +61 -0
- package/src/handlers/assets/tsx.ts +106 -0
- package/src/handlers/assets/virtual-asset.ts +104 -0
- package/src/handlers/core/$base.ts +256 -0
- package/src/handlers/core/$dynamic.ts +285 -0
- package/src/handlers/core/$error.ts +301 -0
- package/src/handlers/core/$middleware.ts +71 -0
- package/src/handlers/core/$mounts.ts +84 -0
- package/src/handlers/core/$registry.ts +153 -0
- package/src/handlers/core/$routing.ts +205 -0
- package/src/handlers/core/$static.ts +100 -0
- package/src/handlers/core/$websocket.ts +52 -0
- package/src/handlers/index.ts +21 -0
- package/src/handlers/routes/api.ts +95 -0
- package/src/handlers/routes/html.ts +95 -0
- package/src/handlers/routes/livereload.ts +54 -0
- package/src/handlers/routes/proxy.ts +74 -0
- package/src/logger/clients.ts +12 -0
- package/src/logger/index.ts +3 -0
- package/src/logger/logger.ts +375 -0
- package/src/logger/serve-log.ts +206 -0
- package/src/plugins/index.ts +15 -0
- package/src/plugins/routes.ts +110 -0
- package/src/plugins/types.ts +19 -0
- package/src/router.ts +351 -0
- package/src/session.ts +556 -0
- package/src/shared.d.ts +63 -0
- package/src/startup.ts +154 -0
- package/src/types.d.ts +111 -0
- package/src/utils/common/case.ts +11 -0
- package/src/utils/common/index.ts +5 -0
- package/src/utils/common/json.ts +35 -0
- package/src/utils/common/match.ts +6 -0
- package/src/utils/common/misc.ts +53 -0
- package/src/utils/common/try.ts +6 -0
- package/src/utils/constants.ts +153 -0
- package/src/utils/fs.ts +621 -0
- package/src/utils/http/body.ts +65 -0
- package/src/utils/http/csrf.ts +111 -0
- package/src/utils/http/dom.ts +238 -0
- package/src/utils/http/escape.ts +8 -0
- package/src/utils/http/etag.ts +318 -0
- package/src/utils/http/html.ts +525 -0
- package/src/utils/http/index.ts +8 -0
- package/src/utils/http/ip.ts +32 -0
- package/src/utils/http/response.ts +129 -0
- package/src/utils/index.ts +4 -0
- package/src/utils/isomorphic/case.ts +52 -0
- package/src/utils/isomorphic/escape.ts +43 -0
- package/src/utils/isomorphic/index.ts +15 -0
- package/src/utils/isomorphic/is.ts +36 -0
- package/src/utils/isomorphic/match.ts +50 -0
- package/src/utils/isomorphic/math.ts +11 -0
- package/src/utils/isomorphic/misc.ts +22 -0
- package/src/utils/isomorphic/stringify.ts +42 -0
- package/src/utils/isomorphic/try.ts +94 -0
- package/src/utils/jsonc.ts +10 -0
- package/src/utils/shared-pool.ts +193 -0
- 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,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,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
|
+
}
|