@bakery-framework/core 2.1.2 → 2.2.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakery-framework/core",
3
- "version": "2.1.2",
3
+ "version": "2.2.0",
4
4
  "description": "Bakery framework core: handlers, router, config, session, caches, logger, compiler.",
5
5
  "keywords": [
6
6
  "bakery",
@@ -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 (
@@ -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(
@@ -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.')
@@ -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 {
@@ -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> {
@@ -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.
@@ -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
- if (data === null || data === undefined)
319
- return new Response(null, { status: 204 })
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
- sess && resp.headers.append('Set-Cookie', sess)
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