@bakery-framework/core 2.1.2 → 2.2.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/package.json +1 -1
- package/src/cache/tiered.ts +9 -0
- package/src/compiler/compiler.ts +10 -0
- package/src/core/config.ts +16 -0
- package/src/core/context.ts +8 -0
- package/src/core/index.ts +5 -0
- package/src/core/plugins.ts +24 -0
- package/src/global.d.ts +25 -14
- package/src/handlers/assets/nm.ts +5 -0
- package/src/handlers/assets/public.ts +4 -0
- package/src/handlers/assets/static.ts +5 -0
- package/src/handlers/assets/ts.ts +5 -0
- package/src/handlers/assets/virtual-asset.ts +5 -0
- package/src/handlers/core/$base.ts +28 -0
- package/src/logger/serve-log.ts +16 -0
- package/src/plugins/types.ts +8 -0
- package/src/router.ts +18 -5
- package/src/session.ts +516 -30
- package/src/startup.ts +18 -0
- package/src/utils/http/index.ts +1 -0
- package/src/utils/http/rate-limit.ts +56 -0
- package/src/utils/shared-pool.ts +120 -56
package/package.json
CHANGED
package/src/cache/tiered.ts
CHANGED
|
@@ -202,6 +202,15 @@ export class TieredCache<K extends string | number, V> {
|
|
|
202
202
|
return fromRAM || info.changes > 0
|
|
203
203
|
}
|
|
204
204
|
|
|
205
|
+
/**
|
|
206
|
+
* The value if it is in the memory tier. Records no access and reads
|
|
207
|
+
* nothing from disk: for a caller that needs the live object, if there is
|
|
208
|
+
* one, without promoting a row that nothing is using.
|
|
209
|
+
*/
|
|
210
|
+
peek(key: K): V | undefined {
|
|
211
|
+
return this.memoryStore.get(key)?.value
|
|
212
|
+
}
|
|
213
|
+
|
|
205
214
|
getAccessedAt(key: K): number | undefined {
|
|
206
215
|
if (this.memoryStore.has(key)) return this.memoryStore.get(key)!.accessedAt
|
|
207
216
|
return (
|
package/src/compiler/compiler.ts
CHANGED
|
@@ -56,6 +56,16 @@ function getDefines(): Promise<MapOf<string>> {
|
|
|
56
56
|
return definesPromise
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/**
|
|
60
|
+
* The `import.meta.env.*` values browser code is compiled with: the table the
|
|
61
|
+
* transpiler applies to a served `.ts` module. Public for a plugin bundling
|
|
62
|
+
* the same modules (plugin-vue's `bakery --build`), which has to apply the
|
|
63
|
+
* same values or a bundled page would read different ones than a served page.
|
|
64
|
+
*/
|
|
65
|
+
export function clientDefines(): Promise<MapOf<string>> {
|
|
66
|
+
return getDefines()
|
|
67
|
+
}
|
|
68
|
+
|
|
59
69
|
let transpilerPromise: Promise<Bun.Transpiler> | null = null
|
|
60
70
|
function getTranspiler(): Promise<Bun.Transpiler> {
|
|
61
71
|
transpilerPromise ??= getDefines().then(
|
package/src/core/config.ts
CHANGED
|
@@ -54,6 +54,13 @@ const defaultConfig: Required<AppConfig> = {
|
|
|
54
54
|
// Empty means auto-detect, matching `head`/`body` above. There is no default
|
|
55
55
|
// *path* to give here: the default behavior is the probe itself.
|
|
56
56
|
schema: '',
|
|
57
|
+
// Empty means migrations mode is off, and the schema declarations are the
|
|
58
|
+
// truth that db:sync enforces.
|
|
59
|
+
migrations: '',
|
|
60
|
+
// No store means the built-in one: memory, with bakery/sessions.db behind
|
|
61
|
+
// it. No account key means `Session.endForAccount` has nothing to look up,
|
|
62
|
+
// and says so rather than ending nothing.
|
|
63
|
+
sessions: {},
|
|
57
64
|
root: 'src',
|
|
58
65
|
hosts: {},
|
|
59
66
|
websocket: {
|
|
@@ -179,6 +186,15 @@ export function __resetTestConfig(): void {
|
|
|
179
186
|
testOverrides = null
|
|
180
187
|
}
|
|
181
188
|
|
|
189
|
+
/**
|
|
190
|
+
* The config once `initConfig()` has run, and null before it: for code that
|
|
191
|
+
* also runs outside a server, where the defaults are the answer. Sessions are
|
|
192
|
+
* the case: a unit test or a script builds one without booting anything.
|
|
193
|
+
*/
|
|
194
|
+
export function peekConfig(): Readonly<ProcessedAppConfig> | null {
|
|
195
|
+
return cachedConfig ? getConfig() : null
|
|
196
|
+
}
|
|
197
|
+
|
|
182
198
|
export function getConfig(): Readonly<ProcessedAppConfig> {
|
|
183
199
|
if (!cachedConfig) {
|
|
184
200
|
throw new Error('Config has not been initialized. Call initConfig() first.')
|
package/src/core/context.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
2
|
+
import type { Handler } from '../handlers/core/$base'
|
|
2
3
|
import { matchBlocked } from '../utils/constants'
|
|
3
4
|
import { fs } from '../utils/fs'
|
|
4
5
|
|
|
@@ -35,6 +36,13 @@ export type HostContext = {
|
|
|
35
36
|
* globs, which are frozen for the life of the store.
|
|
36
37
|
*/
|
|
37
38
|
blockedPaths?: Map<string, boolean>
|
|
39
|
+
/**
|
|
40
|
+
* The handler routing chose for this request, set by `handleRequest` just
|
|
41
|
+
* before it is called. The rate limiter reads it once the response is
|
|
42
|
+
* ready, to ask `isAsset`. Absent when no handler answered: a CORS
|
|
43
|
+
* preflight, a plugin's `onRequest`, a forbidden path, a WebSocket upgrade.
|
|
44
|
+
*/
|
|
45
|
+
handler?: typeof Handler
|
|
38
46
|
}
|
|
39
47
|
|
|
40
48
|
export const hostStore = new AsyncLocalStorage<HostContext>()
|
package/src/core/index.ts
CHANGED
|
@@ -51,6 +51,11 @@ export type {
|
|
|
51
51
|
} from '../types'
|
|
52
52
|
/** Configured in `server.config.ts`; the type is exported so an app can build one. */
|
|
53
53
|
export type { CorsOptions } from '../utils/http/cors'
|
|
54
|
+
export type {
|
|
55
|
+
RateLimitKey,
|
|
56
|
+
RateLimitOptions,
|
|
57
|
+
RateLimitRule,
|
|
58
|
+
} from '../utils/http/rate-limit'
|
|
54
59
|
export type { SSEMessage, SSEOptions, SSEStream } from '../utils/http/sse'
|
|
55
60
|
// The validation surface, so an app can type its own schemas and validators.
|
|
56
61
|
export type {
|
package/src/core/plugins.ts
CHANGED
|
@@ -111,6 +111,30 @@ export namespace PluginHooks {
|
|
|
111
111
|
}
|
|
112
112
|
}
|
|
113
113
|
|
|
114
|
+
/**
|
|
115
|
+
* Every plugin's `build`, in order. A failure is logged and the rest still
|
|
116
|
+
* run, so one command reports every plugin that cannot build rather than
|
|
117
|
+
* the first. Returns the names of the plugins that built and that failed.
|
|
118
|
+
*/
|
|
119
|
+
export async function build(): Promise<{
|
|
120
|
+
built: string[]
|
|
121
|
+
failed: string[]
|
|
122
|
+
}> {
|
|
123
|
+
const built: string[] = []
|
|
124
|
+
const failed: string[] = []
|
|
125
|
+
for (const plugin of getPlugins()) {
|
|
126
|
+
if (!plugin.build) continue
|
|
127
|
+
const [err] = await Try.catch(() => plugin.build!(Bakery.config))
|
|
128
|
+
if (err) {
|
|
129
|
+
pluginLog.UNHANDLED_ERR({ error: `${plugin.name}: ${errorMsg(err)}` })
|
|
130
|
+
failed.push(plugin.name)
|
|
131
|
+
} else {
|
|
132
|
+
built.push(plugin.name)
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return { built, failed }
|
|
136
|
+
}
|
|
137
|
+
|
|
114
138
|
export async function onCompile(
|
|
115
139
|
content: string,
|
|
116
140
|
path: string,
|
package/src/global.d.ts
CHANGED
|
@@ -72,13 +72,7 @@ declare global {
|
|
|
72
72
|
body?: string
|
|
73
73
|
proxy?: Record<string, string>
|
|
74
74
|
blocked?: string[]
|
|
75
|
-
rateLimit?:
|
|
76
|
-
| {
|
|
77
|
-
max: number
|
|
78
|
-
refill: number
|
|
79
|
-
keyBy?: (req: Request) => string
|
|
80
|
-
}
|
|
81
|
-
| false
|
|
75
|
+
rateLimit?: import('./utils/http/rate-limit').RateLimitOptions | false
|
|
82
76
|
}
|
|
83
77
|
|
|
84
78
|
type AppConfig = {
|
|
@@ -132,13 +126,7 @@ declare global {
|
|
|
132
126
|
|
|
133
127
|
blocked?: string[]
|
|
134
128
|
|
|
135
|
-
rateLimit?:
|
|
136
|
-
| {
|
|
137
|
-
max: number
|
|
138
|
-
refill: number
|
|
139
|
-
keyBy?: (req: Request) => string
|
|
140
|
-
}
|
|
141
|
-
| false
|
|
129
|
+
rateLimit?: import('./utils/http/rate-limit').RateLimitOptions | false
|
|
142
130
|
|
|
143
131
|
trustProxy?: boolean
|
|
144
132
|
|
|
@@ -156,6 +144,29 @@ declare global {
|
|
|
156
144
|
*/
|
|
157
145
|
schema?: string
|
|
158
146
|
|
|
147
|
+
/**
|
|
148
|
+
* The folder of SQL migration files, relative to the app's cwd, which
|
|
149
|
+
* turns on migrations mode: the files are the schema's source of truth,
|
|
150
|
+
* `db:migrate` applies them in order, and `db:sync` only checks the
|
|
151
|
+
* declared tables against the database, never altering or dropping
|
|
152
|
+
* anything. Empty (the default) leaves the declarations as the truth, the
|
|
153
|
+
* way `db:sync` has always worked.
|
|
154
|
+
*
|
|
155
|
+
* A plain string for the reason `schema` is one.
|
|
156
|
+
*/
|
|
157
|
+
migrations?: string
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Where sessions live, and which session key holds the account id.
|
|
161
|
+
*
|
|
162
|
+
* `store` replaces the built-in store (memory, with `bakery/sessions.db`
|
|
163
|
+
* behind it) with one every worker shares and nothing caches, such as
|
|
164
|
+
* `databaseSessions()` from `@bakery-framework/orm/sessions`. `account`
|
|
165
|
+
* names the key a store indexes, which is what lets
|
|
166
|
+
* `Session.endForAccount()` find every session of one account.
|
|
167
|
+
*/
|
|
168
|
+
sessions?: import('./session').SessionOptions
|
|
169
|
+
|
|
159
170
|
hosts?: Record<string, HostEntry>
|
|
160
171
|
}
|
|
161
172
|
|
|
@@ -13,6 +13,11 @@ export class NMHandler extends Handler {
|
|
|
13
13
|
return path.startsWith('/_nm/')
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/** A bundled package. The bundling is bounded by what is installed, and cached. */
|
|
17
|
+
static isAsset(): boolean {
|
|
18
|
+
return true
|
|
19
|
+
}
|
|
20
|
+
|
|
16
21
|
/**
|
|
17
22
|
* The file a `/_nm/` path names.
|
|
18
23
|
*
|
|
@@ -8,6 +8,10 @@ export class PublicHandler extends Handler {
|
|
|
8
8
|
return path.startsWith('/uploads/')
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
+
static isAsset(): boolean {
|
|
12
|
+
return true
|
|
13
|
+
}
|
|
14
|
+
|
|
11
15
|
static async handle(path: string) {
|
|
12
16
|
const resolved = await getStatic(path, Bakery.publicRoot)
|
|
13
17
|
if (!resolved) return response.error('Not Found')
|
|
@@ -12,6 +12,11 @@ export class StaticHandler extends Handler {
|
|
|
12
12
|
return true
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/** A file from the serve root. A missing one is a 404, which never counts as an asset. */
|
|
16
|
+
static isAsset(): boolean {
|
|
17
|
+
return true
|
|
18
|
+
}
|
|
19
|
+
|
|
15
20
|
static get cacheDir() {
|
|
16
21
|
return fs.resolve(Bakery.cacheDir, 'static')
|
|
17
22
|
}
|
|
@@ -20,6 +20,11 @@ export class TSHandler extends DynamicHandler {
|
|
|
20
20
|
return fs.resolve(Bakery.cacheDir, 'ts_cache')
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/** A browser module, compiled once per file and cached on disk by the file. */
|
|
24
|
+
static isAsset(): boolean {
|
|
25
|
+
return true
|
|
26
|
+
}
|
|
27
|
+
|
|
23
28
|
static async canHandle(path: string, req: Request) {
|
|
24
29
|
if (path.endsWith('.ts')) return true
|
|
25
30
|
return await super.canHandle(normalizePath(path), req)
|
|
@@ -17,6 +17,11 @@ export class VirtualAssetHandler extends Handler {
|
|
|
17
17
|
return fs.resolve(Bakery.cacheDir, 'virtual')
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/** The framework's own client files. */
|
|
21
|
+
static isAsset(): boolean {
|
|
22
|
+
return true
|
|
23
|
+
}
|
|
24
|
+
|
|
20
25
|
static get clientAssets(): MapOf<string> {
|
|
21
26
|
// frameworkPath, not Bakery.root: these ship with the framework, so they
|
|
22
27
|
// must resolve relative to it rather than to the application's cwd.
|
|
@@ -269,6 +269,34 @@ export class Handler {
|
|
|
269
269
|
*/
|
|
270
270
|
static namespace: string | null = null
|
|
271
271
|
|
|
272
|
+
/**
|
|
273
|
+
* Whether this handler's successful answer to `req` was an asset, which the
|
|
274
|
+
* rate limiter then stops counting: a file from the serve root, a compiled
|
|
275
|
+
* module, a stylesheet. Asked after the response, with the status already
|
|
276
|
+
* known to be under 400, so it describes what was served rather than
|
|
277
|
+
* guessing from the request.
|
|
278
|
+
*
|
|
279
|
+
* An asset's URL is remembered, and a later request for the same URL
|
|
280
|
+
* (host, path, query and `Sec-Fetch-Dest`) skips the bucket entirely, which
|
|
281
|
+
* is what keeps a page's forty modules from counting forty times, and
|
|
282
|
+
* keeps a school behind one address from sharing one budget for them. A
|
|
283
|
+
* first request still borrows a token for the duration and gets it back
|
|
284
|
+
* once this answers true.
|
|
285
|
+
*
|
|
286
|
+
* So answer true only when a successful answer costs about what reading a
|
|
287
|
+
* file costs, *and* the work behind a first answer (a compile, a bundle) is
|
|
288
|
+
* bounded by the app's own files: a client must not be able to mint new
|
|
289
|
+
* work by varying the URL. That rules out `ImageHandler`, where each new
|
|
290
|
+
* size is a new resize, and `GoogleFontHandler`, where each new family is
|
|
291
|
+
* an upstream fetch. A handler that runs app code per request (pages, API
|
|
292
|
+
* routes, middleware, Vue server blocks) is not serving an asset either.
|
|
293
|
+
*
|
|
294
|
+
* False by default, so a plugin's handler counts unless it says otherwise.
|
|
295
|
+
*/
|
|
296
|
+
static isAsset(_path: string, _req: Request): boolean {
|
|
297
|
+
return false
|
|
298
|
+
}
|
|
299
|
+
|
|
272
300
|
protected constructor() {}
|
|
273
301
|
|
|
274
302
|
static get cache(): HandlerCache<string, Route.Info> {
|
package/src/logger/serve-log.ts
CHANGED
|
@@ -18,8 +18,19 @@ const serveMsgs = {
|
|
|
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.',
|
|
20
20
|
UNHANDLED_ERR: 'E Unhandled Server Error: %r{error}%*',
|
|
21
|
+
// A configured session store that could not be read: the request is
|
|
22
|
+
// answered as an error rather than as a visitor with no session, which
|
|
23
|
+
// would sign everybody out for as long as the store is down.
|
|
24
|
+
SESSION_LOAD_ERR: 'E Could not read the session store: %r{error}%*',
|
|
25
|
+
SESSION_PRUNE_ERR: 'E Could not prune expired sessions: %r{error}%*',
|
|
26
|
+
BUILD_FAILED: 'E %rbakery --build failed%* in: %y{plugins}%*',
|
|
27
|
+
BUILD_NOTHING:
|
|
28
|
+
'I Nothing to build: no plugin in %yserver.config.ts%* has a build step.',
|
|
21
29
|
SHUTTING_DOWN: 'W %yShutting down server...%*',
|
|
22
30
|
BACKEND_CHANGE: 'I Backend change detected: %y{file}%*',
|
|
31
|
+
// The banner's first line: the framework serving the app, which is the
|
|
32
|
+
// version a bug report needs and a log otherwise never said.
|
|
33
|
+
BAKERY_VERSION: 'I Bakery v{version}',
|
|
23
34
|
SERVER_STARTED: 'I %gServer running at:%*',
|
|
24
35
|
SERVER_URL: 'I ➜ %w{type}%*: %bhttp://{host}:{port}%*',
|
|
25
36
|
WATCHER_ERR: 'E Watcher error: %r{error}%*',
|
|
@@ -67,6 +78,11 @@ const serveMsgs = {
|
|
|
67
78
|
// lines back; the "30s" must stay in step with RATE_LIMIT_LOG_WINDOW_MS.
|
|
68
79
|
RATE_LIMITED_SUPPRESSED:
|
|
69
80
|
'W Rate limited: %y{ip}%* (%y{count}%* rejections suppressed in the last 30s)',
|
|
81
|
+
// The same two for a request charged to a `rateLimit.routes` rule, named by
|
|
82
|
+
// its prefix, so a sign-in limit firing reads differently from a flood.
|
|
83
|
+
RATE_LIMITED_ROUTE: 'W Rate limited: %y{ip}%* on %y{prefix}%*',
|
|
84
|
+
RATE_LIMITED_ROUTE_SUPPRESSED:
|
|
85
|
+
'W Rate limited: %y{ip}%* on %y{prefix}%* (%y{count}%* rejections suppressed in the last 30s)',
|
|
70
86
|
// Printed only when the *default* limit is in effect: an unconfigured rate
|
|
71
87
|
// limit silently 429s load tests and shared-NAT offices, so its existence
|
|
72
88
|
// gets one announcement. An app-configured value prints nothing.
|
package/src/plugins/types.ts
CHANGED
|
@@ -89,6 +89,14 @@ export interface ServerPlugin {
|
|
|
89
89
|
onError?(error: Handler.Error.Data, req?: Request): ValidResponses
|
|
90
90
|
onShutdown?(): MixedPromise<void>
|
|
91
91
|
onCompile?(content: string, path: string): MixedPromise<string>
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* `bakery --build`: work done once, ahead of a production start, such as
|
|
95
|
+
* plugin-vue bundling every page. Runs after `setup()`, with no server and
|
|
96
|
+
* no database connection. A throw fails the command, which is what lets a
|
|
97
|
+
* deploy stop on it rather than serve what did not build.
|
|
98
|
+
*/
|
|
99
|
+
build?(config: ProcessedAppConfig): MixedPromise<void>
|
|
92
100
|
}
|
|
93
101
|
|
|
94
102
|
export function definePlugin<T extends ServerPlugin>(plugin: T): T {
|
package/src/router.ts
CHANGED
|
@@ -143,6 +143,11 @@ export async function handleRequest(req: Request) {
|
|
|
143
143
|
return new Response('Forbidden', { status: 403 })
|
|
144
144
|
}
|
|
145
145
|
|
|
146
|
+
// Recorded for the rate limiter, which asks the handler afterwards whether
|
|
147
|
+
// what it served was an asset. See `Handler.isAsset`.
|
|
148
|
+
const store = hostStore.getStore()
|
|
149
|
+
if (store) store.handler = handler
|
|
150
|
+
|
|
146
151
|
return handler.handle(path, req)
|
|
147
152
|
}
|
|
148
153
|
|
|
@@ -315,8 +320,18 @@ export async function processResponse(
|
|
|
315
320
|
let status = 200
|
|
316
321
|
let type = 'text/plain; charset=utf-8'
|
|
317
322
|
if (data === WebSocketHandler.WS_UPGRADE) return
|
|
318
|
-
|
|
319
|
-
|
|
323
|
+
|
|
324
|
+
// Every other response commits the session, the empty one below included.
|
|
325
|
+
// A handler answering 204 used to skip this, so a session it wrote was
|
|
326
|
+
// never stored when it was new; with a configured store, whose writes wait
|
|
327
|
+
// for the commit, a logout answered 204 would not have ended anything.
|
|
328
|
+
const cookie = await Session.commit(req)
|
|
329
|
+
|
|
330
|
+
if (data === null || data === undefined) {
|
|
331
|
+
const empty = new Response(null, { status: 204 })
|
|
332
|
+
if (cookie) empty.headers.append('Set-Cookie', cookie)
|
|
333
|
+
return empty
|
|
334
|
+
}
|
|
320
335
|
|
|
321
336
|
const resp = await (async function getResponse() {
|
|
322
337
|
if (data instanceof Response) {
|
|
@@ -347,11 +362,9 @@ export async function processResponse(
|
|
|
347
362
|
return ETag.sendText(String(data), req, type, status)
|
|
348
363
|
})()
|
|
349
364
|
|
|
350
|
-
const sess = Session.getCookie(req)
|
|
351
|
-
|
|
352
365
|
// append, not set: a handler may already have issued its own Set-Cookie
|
|
353
366
|
// (e.g. an auth cookie from a login route) that must not be overwritten.
|
|
354
|
-
|
|
367
|
+
if (cookie) resp.headers.append('Set-Cookie', cookie)
|
|
355
368
|
|
|
356
369
|
// Every response funnels through here (pages, API JSON, static files, error
|
|
357
370
|
// pages), so this is the one place that cannot miss one. Applied before ETag
|