@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
@@ -0,0 +1,74 @@
1
+ import { Bakery } from '../../core/bakery'
2
+ import { handlerLog } from '../../logger'
3
+ import { response } from '../../utils/http'
4
+ import { Handler } from '../core/$base'
5
+
6
+ export class ProxyHandler extends Handler {
7
+ /** Answers from an upstream, not from disk. See `Handler.servesFiles`. */
8
+ static servesFiles = false
9
+
10
+ static get proxies() {
11
+ return Bakery.config.proxy || {}
12
+ }
13
+
14
+ static canHandle(path: string) {
15
+ for (const prefix in this.proxies) {
16
+ if (path.startsWith(prefix)) return true
17
+ }
18
+ return false
19
+ }
20
+
21
+ static async handle(path: string, req: Request) {
22
+ let proxyUrl = ''
23
+
24
+ for (const [prefix, target] of Object.entries(this.proxies)) {
25
+ if (!path.startsWith(prefix)) continue
26
+
27
+ const trailingPath = path.substring(prefix.length)
28
+ const baseTarget = target.endsWith('/') ? target.slice(0, -1) : target
29
+ proxyUrl =
30
+ baseTarget +
31
+ (trailingPath.startsWith('/') ? '' : '/') +
32
+ trailingPath +
33
+ ((req as any).__parsedUrl || new URL(req.url)).search
34
+ break
35
+ }
36
+
37
+ if (!proxyUrl) return response.error('Not Found')
38
+
39
+ handlerLog.PROXY_REQ({ path, target: proxyUrl })
40
+
41
+ // Don't hand a third-party upstream the caller's credentials. `host` is
42
+ // dropped so fetch derives it from the target URL rather than ours.
43
+ const headers = new Headers(req.headers)
44
+ for (const h of ['cookie', 'authorization', 'host', 'sec-fetch-site']) {
45
+ headers.delete(h)
46
+ }
47
+
48
+ const proxyReq = new Request(proxyUrl, {
49
+ method: req.method,
50
+ headers,
51
+ body: ['GET', 'HEAD'].includes(req.method) ? undefined : req.body,
52
+ })
53
+
54
+ let proxyRes: Response
55
+ try {
56
+ // Manual redirects: following one automatically would re-attach these
57
+ // headers to whatever host the upstream names, including link-local IPs.
58
+ proxyRes = await fetch(proxyReq, { redirect: 'manual' })
59
+ } catch {
60
+ return response.error('Bad Gateway', 502)
61
+ }
62
+
63
+ const resHeaders = new Headers(proxyRes.headers)
64
+ // Bun already decompressed the body, so the upstream's content-encoding is
65
+ // wrong — and so is its content-length, which described the compressed size.
66
+ resHeaders.delete('content-encoding')
67
+ resHeaders.delete('content-length')
68
+ return new Response(proxyRes.body, {
69
+ status: proxyRes.status,
70
+ statusText: proxyRes.statusText,
71
+ headers: resHeaders,
72
+ })
73
+ }
74
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * WebSocket clients subscribed to the live log stream.
3
+ *
4
+ * `LiveReloadHandler` owns membership — it adds a socket on
5
+ * `subscribe_logger` and removes it on close. Consumers (the dashboard's
6
+ * log broadcast, the analytics `activeLoggers` gauge) only read.
7
+ *
8
+ * This lives in core rather than a plugin deliberately: it used to be
9
+ * defined in `plugins/analytics/core`, which made core import from a
10
+ * plugin — a backwards edge that blocks packaging core on its own.
11
+ */
12
+ export const connectedLoggers = new Set<any>()
@@ -0,0 +1,3 @@
1
+ export * from './clients'
2
+ export * from './logger'
3
+ export * from './serve-log'
@@ -0,0 +1,375 @@
1
+ import { PromptTracker } from '../compiler/prompt-tracker'
2
+ import type { MapOf } from '../types'
3
+ import { Case } from '../utils/common/case'
4
+ import { match } from '../utils/common/match'
5
+ import { Try } from '../utils/common/try'
6
+
7
+ const logLevels = ['info', 'warn', 'error', 'fatal', 'debug', 'trace'] as const
8
+ const byLength = 15
9
+
10
+ export type LogLevels = (typeof logLevels)[number]
11
+
12
+ export type LoggerEntry = {
13
+ level?: LogLevels
14
+ by?: string
15
+ msg: string
16
+ }
17
+
18
+ function getStackTrace(depth = 10, startAt = 0): string[] {
19
+ const stack = new Error().stack
20
+ const cwd = process.cwd()
21
+ if (!stack) return []
22
+ startAt += 2
23
+ return stack
24
+ .split('\n')
25
+ .map(line => line.replace(cwd, '.'))
26
+ .slice(startAt, startAt + depth)
27
+ }
28
+
29
+ const levelColors: Record<LogLevels | 'reset', string> = {
30
+ info: '%w', // White (Regular)
31
+ warn: '%y', // Yellow
32
+ error: '%r', // Red
33
+ fatal: '%r;31m', // Bold Red
34
+ debug: '%m', // Magenta
35
+ trace: '%d', // Gray
36
+ reset: '%0', // Reset
37
+ }
38
+
39
+ let onLogCallback: ((entry: LoggerEntry) => void) | null = null
40
+ export function setLogCallback(cb: (entry: LoggerEntry) => void) {
41
+ onLogCallback = cb
42
+ }
43
+
44
+ /**
45
+ * Hand an entry to the registered sink, if there is one.
46
+ *
47
+ * Three call sites used to spell this as `Promise.try(() =>
48
+ * onLogCallback?.(…)).catch(() => {})`, which allocated two promises and
49
+ * scheduled a microtask for *every* line — including every line logged while
50
+ * no sink was registered at all. `Try` swallows a synchronous throw and a
51
+ * rejected promise alike (see `utils/isomorphic/try.ts`) without allocating
52
+ * anything on the synchronous path, which is the only path a sink typed
53
+ * `=> void` is supposed to take.
54
+ *
55
+ * The call stays synchronous, as `Promise.try` also was: `logger.test.ts`
56
+ * asserts the callback has fired by the time `log()` returns.
57
+ */
58
+ function emit(entry: LoggerEntry): void {
59
+ if (!onLogCallback) return
60
+ Try(() => onLogCallback?.(entry))
61
+ }
62
+
63
+ // Hoisted: both were rebuilt on every call, and neither depends on the input.
64
+ // The colour table is a 23-property literal and the pattern a regex literal,
65
+ // so a process logging steadily paid for both on every line.
66
+ const COLORS: MapOf<string> = {
67
+ r: '\x1b[31m', // Red
68
+ g: '\x1b[32m', // Green
69
+ y: '\x1b[33m', // Yellow
70
+ b: '\x1b[34m', // Blue
71
+ m: '\x1b[35m', // Magenta
72
+ c: '\x1b[36m', // Cyan
73
+ w: '\x1b[37m', // White
74
+ d: '\x1b[90m', // Gray / Dark Gray
75
+ B: '\x1b[38;5;94m', // Brown
76
+ p: '\x1b[38;5;129m', // Purple / Indigo
77
+ o: '\x1b[38;5;208m', // Orange
78
+ '*': '\x1b[0m', // Reset
79
+ '0': '\x1b[0m', // Reset
80
+
81
+ red: '\x1b[31m',
82
+ green: '\x1b[32m',
83
+ yellow: '\x1b[33m',
84
+ blue: '\x1b[34m',
85
+ magenta: '\x1b[35m',
86
+ cyan: '\x1b[36m',
87
+ white: '\x1b[37m',
88
+ gray: '\x1b[90m',
89
+ brown: '\x1b[38;5;94m',
90
+ purple: '\x1b[38;5;129m',
91
+ orange: '\x1b[38;5;208m',
92
+ reset: '\x1b[0m',
93
+ }
94
+
95
+ // Safe to share despite the /g flag: `String.prototype.replace` resets
96
+ // `lastIndex` on a global regex before it starts.
97
+ const RX_COLORIZE = /%<([a-zA-Z0-9]+)>|%([a-zA-Z0-9*%])/g
98
+
99
+ function colorizeTerminal(msg: string): string {
100
+ return msg.replace(RX_COLORIZE, (match, longName, short) => {
101
+ if (longName) return COLORS[longName] || match
102
+ if (short === '%') return '%'
103
+ return COLORS[short] || match
104
+ })
105
+ }
106
+
107
+ function getFormattedLine(
108
+ line: string,
109
+ index: number,
110
+ totalLines: number,
111
+ level: LogLevels,
112
+ by: string,
113
+ newLine: boolean,
114
+ ): string | null {
115
+ if (index === totalLines - 1 && line === '' && index > 0) return null
116
+
117
+ const color = levelColors[level] || levelColors.info
118
+ const lvTag = `${color}[${Case.upper(level.at(0) || '?')}]`
119
+ const byPad =
120
+ by.length <= byLength
121
+ ? by.padEnd(byLength)
122
+ : `${by.substring(0, byLength - 3)}...`
123
+
124
+ let message = `${lvTag} ${byPad}%0 ${line}%0 `
125
+
126
+ if ((level === 'trace' || level === 'fatal') && index === totalLines - 1) {
127
+ const stack = getStackTrace(5, 1)
128
+ const prefix = `\n${lvTag} ${byPad}%d `
129
+ message += `${prefix + stack.join(prefix)}%0`
130
+ }
131
+
132
+ message += `%0${newLine || index < totalLines - 1 ? '\n' : ''}`
133
+ return message
134
+ }
135
+
136
+ export function log(
137
+ { level = 'info', by = 'global', msg }: LoggerEntry,
138
+ newLine = true,
139
+ ) {
140
+ if (level === 'debug' && import.meta.env.PROD) return
141
+
142
+ const lines = msg.split('\n')
143
+ for (let i = 0; i < lines.length; i++) {
144
+ const formatted = getFormattedLine(
145
+ lines[i],
146
+ i,
147
+ lines.length,
148
+ level,
149
+ by,
150
+ newLine,
151
+ )
152
+ if (formatted !== null) {
153
+ process.stdout.write(colorizeTerminal(formatted))
154
+ }
155
+ }
156
+
157
+ emit({ level, by, msg })
158
+ }
159
+
160
+ function withPromptTracker<T>(fn: () => T): T {
161
+ const isWatcherActive = process.env.DEV_WATCHER_ACTIVE === '1'
162
+ if (isWatcherActive) {
163
+ PromptTracker.activate(process.pid)
164
+ }
165
+
166
+ try {
167
+ return fn()
168
+ } finally {
169
+ if (isWatcherActive) {
170
+ PromptTracker.deactivate(process.pid)
171
+ }
172
+ }
173
+ }
174
+
175
+ export function confirm(msg: string, by = 'global'): boolean {
176
+ return withPromptTracker(() => {
177
+ const promptMsg = `%y${msg} (y/n): %r`
178
+
179
+ const formatted = getFormattedLine(promptMsg, 0, 1, 'warn', by, false)
180
+ const promptStr = formatted ? colorizeTerminal(formatted) : ''
181
+ emit({ level: 'warn', by, msg: promptMsg })
182
+
183
+ // No TTY: decline rather than treat an unanswerable prompt as consent.
184
+ if (!isInteractive()) return false
185
+
186
+ const response = prompt(promptStr)?.trim().toLowerCase()
187
+ return response === 'y' || response === 'yes'
188
+ })
189
+ }
190
+
191
+ const MAX_PROMPT_ATTEMPTS = 10
192
+
193
+ /** False in Docker/CI, where `prompt()` returns null instead of blocking. */
194
+ export function isInteractive(): boolean {
195
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY)
196
+ }
197
+
198
+ export function select(msg: string, options: string[], by = 'global'): string {
199
+ const index = selectIndex(msg, options, by)
200
+ return options[index] as string
201
+ }
202
+
203
+ function readValidIndex(_msg: string, max: number, by: string): number {
204
+ const promptMsg = `Select an option (1-${max}): `
205
+ const formatted = getFormattedLine(promptMsg, 0, 1, 'info', by, false)
206
+ const promptStr = formatted ? colorizeTerminal(formatted) : ''
207
+
208
+ // Without a TTY (Docker, CI) `prompt()` returns null immediately, so looping
209
+ // here would spin forever printing "Invalid option."
210
+ if (!isInteractive()) {
211
+ log({
212
+ level: 'error',
213
+ by,
214
+ msg: 'Cannot prompt for input: no interactive terminal. Re-run with an explicit choice (e.g. --choose=db) or a TTY.',
215
+ })
216
+ process.exit(1)
217
+ }
218
+
219
+ let attempts = 0
220
+ while (attempts++ < MAX_PROMPT_ATTEMPTS) {
221
+ emit({ level: 'info', by, msg: promptMsg })
222
+
223
+ const response = prompt(promptStr)?.trim()
224
+ const num = parseInt(response || '', 10)
225
+ if (!Number.isNaN(num) && num >= 1 && num <= max) {
226
+ return num - 1
227
+ }
228
+ log({ level: 'error', by, msg: 'Invalid option.' })
229
+ }
230
+
231
+ log({
232
+ level: 'error',
233
+ by,
234
+ msg: `No valid option after ${MAX_PROMPT_ATTEMPTS} attempts. Aborting.`,
235
+ })
236
+ process.exit(1)
237
+ }
238
+
239
+ export function selectIndex(msg: string, opt: string[], by = 'global'): number {
240
+ return withPromptTracker(() => {
241
+ log({ by, msg: '\n' })
242
+ log({ by, msg })
243
+
244
+ opt.forEach((opt, i) => void log({ by, msg: ` ${i + 1}. ${opt}` }))
245
+
246
+ const index = readValidIndex(msg, opt.length, by)
247
+ log({ by, msg: '\n' })
248
+ return index
249
+ })
250
+ }
251
+
252
+ export class Logger {
253
+ constructor(private by: string) {}
254
+
255
+ static log = log
256
+ static confirm = confirm
257
+ static select = select
258
+ static selectIndex = selectIndex
259
+
260
+ static messages<T extends MapOf<string>>(by: string, msgs: T) {
261
+ const logger = new Logger(by)
262
+ return messageLogger(logger, msgs)
263
+ }
264
+
265
+ log(msg: string, level?: LogLevels) {
266
+ log({ level, by: this.by, msg })
267
+ }
268
+
269
+ confirm(msg: string) {
270
+ return confirm(msg, this.by)
271
+ }
272
+
273
+ select(msg: string, options: string[]) {
274
+ return select(msg, options, this.by)
275
+ }
276
+
277
+ selectIndex(msg: string, options: string[]) {
278
+ return selectIndex(msg, options, this.by)
279
+ }
280
+ }
281
+
282
+ type Prettify<T> = { [K in keyof T]: T[K] } & {}
283
+
284
+ type ExtractArgs<S extends string> =
285
+ S extends `${infer _}{${infer Param}}${infer Rest}`
286
+ ? Prettify<{ [K in Param]: string | number | boolean } & ExtractArgs<Rest>>
287
+ : // No placeholders in this message, so it takes no arguments.
288
+ {}
289
+
290
+ type Messages<T extends MapOf<string>> = {
291
+ [K in keyof T]: T[K] extends string
292
+ ? keyof ExtractArgs<T[K]> extends never
293
+ ? () => void
294
+ : (payload: ExtractArgs<T[K]>) => void
295
+ : never
296
+ }
297
+
298
+ // Shared for the same reason as RX_COLORIZE above.
299
+ const RX_PARAM = /\{([^}]+)\}/g
300
+
301
+ type ParsedMessage = {
302
+ /** The string this was parsed from — the cache's validity check. */
303
+ raw: string
304
+ level: LogLevels
305
+ template: string
306
+ }
307
+
308
+ /**
309
+ * Split `'W Rate limited: %y{ip}%*'` into its level tag and its template.
310
+ *
311
+ * A pure function of the declared message string, so it is a per-key constant
312
+ * and belongs behind the cache in `messageLogger` rather than on the call path.
313
+ */
314
+ function parseMessage(raw: string): ParsedMessage {
315
+ const spaceIdx = raw.indexOf(' ')
316
+ const rawLevel = spaceIdx > -1 ? raw.substring(0, spaceIdx) : 'E'
317
+
318
+ return {
319
+ raw,
320
+ level: match(rawLevel, {
321
+ W: 'warn',
322
+ E: 'error',
323
+ D: 'debug',
324
+ [match]: 'info',
325
+ }),
326
+ template: spaceIdx > -1 ? raw.substring(spaceIdx + 1) : raw,
327
+ }
328
+ }
329
+
330
+ export function messageLogger<T extends MapOf<string>>(
331
+ loggerInstance: Logger,
332
+ targetMsgs: T,
333
+ ) {
334
+ /**
335
+ * One entry per *declared* key, so it is bounded by the message table — the
336
+ * tables in `serve-log.ts` are literals of a dozen-odd entries each.
337
+ * Undeclared props are deliberately left uncached: the trap fabricates a
338
+ * message for those, and a caller reading arbitrary property names off the
339
+ * proxy would otherwise grow this map without limit (convention 6).
340
+ */
341
+ const emitters = new Map<string, ParsedMessage & { fn: Emitter }>()
342
+
343
+ const build =
344
+ (parsed: ParsedMessage): Emitter =>
345
+ (payload?: MapOf<any>) => {
346
+ const formattedMessage = parsed.template.replace(RX_PARAM, (_, key) => {
347
+ return String(payload?.[key] ?? `{${key}}`)
348
+ })
349
+
350
+ loggerInstance.log(formattedMessage, parsed.level)
351
+ }
352
+
353
+ return new Proxy(targetMsgs, {
354
+ get(target, prop: string) {
355
+ const raw = target[prop]
356
+ if (!raw) return build(parseMessage(msgNotFound(prop)))
357
+
358
+ let cached = emitters.get(prop)
359
+ // Re-parse when the table was mutated under us. `raw` is the entire
360
+ // parse input, so comparing it is the whole validity check.
361
+ if (!cached || cached.raw !== raw) {
362
+ const parsed = parseMessage(raw)
363
+ cached = { ...parsed, fn: build(parsed) }
364
+ emitters.set(prop, cached)
365
+ }
366
+
367
+ return cached.fn
368
+ },
369
+ }) as any as Messages<T>
370
+ }
371
+
372
+ type Emitter = (payload?: MapOf<any>) => void
373
+
374
+ const msgNotFound = (prop: string) =>
375
+ `E Error message not found: ${String(prop)}`
@@ -0,0 +1,206 @@
1
+ import { Logger, messageLogger } from './logger'
2
+
3
+ const serveMsgs = {
4
+ STARTING: 'I Starting server in %c{mode}%* mode...',
5
+ STARTING_THREADS:
6
+ 'I Starting cluster supervisor with %g{count}%* workers (%creusePort%*)...',
7
+ THREAD_STARTED: 'I [%cWorker #{id}%*] Server running at:',
8
+ RESTART_REQ: 'I Dev server restart requested from %ysync engine%*!',
9
+ // Emitted by the dev worker (cli/dev.ts) when the schema-source hash matches
10
+ // the one recorded after the last successful sync, so the boot-time schema
11
+ // sync is skipped. `--sync` bypasses the skip.
12
+ SCHEMA_SYNC_SKIP:
13
+ 'I Schema unchanged since last sync — %yskipping schema sync%* (%c--sync%* forces it)',
14
+ // The skip above is precisely when drift goes unnoticed: schema.ts has not
15
+ // changed, so no sync runs, so nothing looks at the database. Something else
16
+ // altered it — a hand-run ALTER, another environment pointed at the same URL,
17
+ // a restore from an older dump.
18
+ SCHEMA_DRIFT:
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
+ UNHANDLED_ERR: 'E Unhandled Server Error: %r{error}%*',
21
+ SHUTTING_DOWN: 'W %yShutting down server...%*',
22
+ BACKEND_CHANGE: 'I Backend change detected: %y{file}%*',
23
+ SERVER_STARTED: 'I %gServer running at:%*',
24
+ SERVER_URL: 'I ➜ %w{type}%*: %bhttp://{host}:{port}%*',
25
+ WATCHER_ERR: 'E Watcher error: %r{error}%*',
26
+ TSCONFIG_SYNCED:
27
+ 'I Synced %ytsconfig.json%* paths with %yserver.config.ts%*!',
28
+ MANUAL_RELOAD: 'I %yManual reload%* triggered from client logger!',
29
+ CONFIG_IMPORT_ERR: 'E Failed to import %yserver.config.ts%*: %r{error}%*',
30
+ // Multi-line on purpose: a present-but-broken config booting on defaults is
31
+ // the kind of failure a single scrolled-away line hides. DEV only — in PROD
32
+ // the same condition throws out of initConfig() instead of logging.
33
+ CONFIG_BROKEN:
34
+ '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}',
35
+ // The banner restatement of CONFIG_BROKEN, so the warning survives console
36
+ // scrollback and sits next to the URLs the developer actually reads.
37
+ CONFIG_BROKEN_BANNER:
38
+ 'W %rserver.config.ts failed to load%* %y— running on built-in defaults. See the import error above.%*',
39
+ WEBSOCKET_ERR: 'E WebSocket error from %y{ip}%*: %r{error}%*',
40
+ RATE_LIMITED: 'W Rate limited: %y{ip}%*',
41
+ // Emitted instead of RATE_LIMITED when sampling (cli/rate-limit.ts) held
42
+ // lines back; the "30s" must stay in step with RATE_LIMIT_LOG_WINDOW_MS.
43
+ RATE_LIMITED_SUPPRESSED:
44
+ 'W Rate limited: %y{ip}%* (%y{count}%* rejections suppressed in the last 30s)',
45
+ // Printed only when the *default* limit is in effect: an unconfigured rate
46
+ // limit silently 429s load tests and shared-NAT offices, so its existence
47
+ // gets one announcement. An app-configured value prints nothing.
48
+ RATE_LIMIT_DEFAULT:
49
+ 'I Rate limit: %y{max}%* burst / %y{refill}%* req/s per IP (default) — set %crateLimit: false%* to disable',
50
+ // Multi-worker port sharing rides on kernel-level SO_REUSEPORT load
51
+ // balancing, which only Linux provides; elsewhere N sockets either fail to
52
+ // bind or never receive balanced traffic.
53
+ CLUSTER_CLAMPED:
54
+ '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.',
55
+ WORKER_RESPAWN:
56
+ 'W [Cluster] Worker %g{id}%* exited unexpectedly — restarting in %y{delay}%*ms (consecutive failure #%y{failures}%*).',
57
+ // Thread-worker only: the master should hand over the shared memory pool
58
+ // before the worker starts serving; past the deadline the worker serves on
59
+ // its local pool rather than deadlock.
60
+ SHARED_POOL_TIMEOUT:
61
+ '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.',
62
+ // The version wipe could not empty the cache directory, so no "current"
63
+ // marker was written and the next boot will try again. Worth a line rather
64
+ // than silence: something is holding those files open, and a stale compiled
65
+ // page surviving an upgrade is the failure the wipe exists to prevent.
66
+ CACHE_WIPE_INCOMPLETE:
67
+ '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.',
68
+ } as const
69
+
70
+ export const serveLog = messageLogger(new Logger('serve'), serveMsgs)
71
+
72
+ const handlerMsgs = {
73
+ // Emitted by `DynamicHandler.executeModule`, which every route handler goes
74
+ // through — an API route, a `.tsx` page and an `error-*.tsx` all land here.
75
+ // It used to say "API module" while printing a `.tsx` path, alongside a
76
+ // `TSX_IMPORT_ERR` that no code ever reached.
77
+ API_IMPORT_ERR: 'E Failed to import route module (%y{file}%*): %r{error}%*',
78
+ // The other half of the same failure: the module *loaded*, and exports no
79
+ // default. `ApiHandler` used to answer that with a bare 404 naming nothing,
80
+ // which reads as "your route file is missing" for a file that is right there.
81
+ API_NO_DEFAULT:
82
+ 'E API route has no %cexport default%* (%y{file}%*) — the module loaded but exposes no handler',
83
+ // A handler ran and returned neither a value nor a Response. Still a 404 (see
84
+ // `ApiHandler.handle`), but the log names the file rather than leaving the
85
+ // developer to guess which route answered.
86
+ API_NO_RESPONSE: 'W API route returned no response: %y{file}%*',
87
+ PROXY_REQ: 'I Proxying %y{path}%* -> %b{target}%*',
88
+ MIDDLEWARE_ERR: 'E Middleware error: %r{error}%*',
89
+ BUNDLE_ERR: 'E Failed to bundle module (%y{file}%*): %r{error}%*',
90
+ } as const
91
+
92
+ export const handlerLog = messageLogger(new Logger('handlers'), handlerMsgs)
93
+
94
+ const compileMsgs = {
95
+ FILE_STATUS: 'I File is %y{status}%*: %w{file}%*',
96
+ COMPILE_FAIL: 'E Failed to compile %y{file}%*: %r{error}%*',
97
+ /** Same failure, but for a source string with no originating file. */
98
+ COMPILE_SOURCE_FAIL: 'E Failed to compile inline source: %r{error}%*',
99
+ FILE_DEL: 'I File deleted: %w{file}%*',
100
+ } as const
101
+
102
+ export const compLog = messageLogger(new Logger('compile'), compileMsgs)
103
+
104
+ const pluginMsgs = {
105
+ UNHANDLED_ERR: 'E Unhandled Plugin Error: %r{error}%*',
106
+ ANALYTICS_STORE_ERR: 'E Analytics store init failed: %r{error}%*',
107
+ DASHBOARD_BUNDLE_ERR: 'E Failed to bundle %ydashboard.js%*: %r{error}%*',
108
+ } as const
109
+
110
+ export const pluginLog = messageLogger(new Logger('plugins'), pluginMsgs)
111
+
112
+ export const errorMsg = (err: any) => err?.stack || err?.message || String(err)
113
+
114
+ /**
115
+ * The position record Bun attaches to a transpiler or resolver diagnostic.
116
+ *
117
+ * `BuildMessage` and `ResolveMessage` are the two things Bun throws for a
118
+ * syntax error and an unresolvable import. Neither is `instanceof Error`,
119
+ * neither carries a `stack`, and both report *zero* own enumerable keys — so
120
+ * nothing that inspects a thrown value by spreading it or by `instanceof` sees
121
+ * anything at all. `message`, `name` and `position` are the accessors that
122
+ * actually answer.
123
+ */
124
+ type Diagnostic = {
125
+ name?: string
126
+ message?: string
127
+ position?: {
128
+ file?: string
129
+ line?: number
130
+ column?: number
131
+ lineText?: string
132
+ } | null
133
+ }
134
+
135
+ const headline = (err: Diagnostic) => {
136
+ const message = err.message || String(err)
137
+ return err.name && message
138
+ ? `${err.name}: ${message}`
139
+ : message || String(err)
140
+ }
141
+
142
+ function positionLines(position: Diagnostic['position']): string[] {
143
+ if (!position || typeof position !== 'object') return []
144
+
145
+ const lines: string[] = []
146
+ const { file, line, column, lineText } = position
147
+
148
+ if (file) lines.push(` at ${file}:${line ?? 0}:${column ?? 0}`)
149
+ else if (line != null) lines.push(` at line ${line}, column ${column ?? 0}`)
150
+
151
+ if (typeof lineText === 'string' && lineText.trim()) {
152
+ lines.push(` | ${lineText}`)
153
+ }
154
+
155
+ return lines
156
+ }
157
+
158
+ /**
159
+ * Everything a thrown value knows about where it came from.
160
+ *
161
+ * `errorMsg` answers `stack || message`, which is the right answer for an
162
+ * `Error` and useless for the diagnostics above: they have no stack, so it
163
+ * degrades to a bare "Expected identifier but found end of file" with no file
164
+ * and no line. This walks `position` instead, and recurses through the
165
+ * `AggregateError.errors` array that `import()`ing a broken `.tsx` throws —
166
+ * that one *is* an `Error`, but it has no stack either, so the summary line
167
+ * ("4 errors building …") was all anyone ever saw.
168
+ *
169
+ * A real `Error` with a real stack still returns exactly `err.stack`, so the
170
+ * existing contract for the common case is unchanged.
171
+ */
172
+ export function errorDetail(err: any): string {
173
+ if (!err || typeof err !== 'object') return String(err)
174
+ if (err.stack) return err.stack
175
+
176
+ const lines = [headline(err)]
177
+
178
+ if (Array.isArray(err.errors) && err.errors.length) {
179
+ for (const sub of err.errors) {
180
+ for (const line of errorDetail(sub).split('\n')) lines.push(` ${line}`)
181
+ }
182
+ return lines.join('\n')
183
+ }
184
+
185
+ lines.push(...positionLines(err.position))
186
+ return lines.join('\n')
187
+ }
188
+
189
+ /**
190
+ * `errorMsg` with the diagnostic's line and column, but not its file.
191
+ *
192
+ * For the log lines whose template already names the file. Bun's own
193
+ * `position.file` is `input.ts` whenever `transform()` was handed a source
194
+ * string rather than a path — which is every compile this codebase does — so
195
+ * printing it beside the real path would contradict it.
196
+ */
197
+ export function errorWithPosition(err: any): string {
198
+ const position = (err as Diagnostic)?.position
199
+ if (!position || position.line == null) return errorMsg(err)
200
+
201
+ const message = (err as Diagnostic).message || errorMsg(err)
202
+ return `${message} (line ${position.line}, column ${position.column ?? 0})`
203
+ }
204
+
205
+ const toMS = (ns: number) => parseFloat((ns / 1e6).toFixed(2))
206
+ export const getElapsed = (start: number) => toMS(Bun.nanoseconds() - start)
@@ -0,0 +1,15 @@
1
+ /**
2
+ * The plugin-authoring surface, as one barrel.
3
+ *
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` — which meant
6
+ * `definePlugin` and `ServerPlugin` were only reachable through
7
+ * `@bakery-framework/core/plugins/types`, a second subpath for one concept. It also broke
8
+ * under TypeScript's `paths` resolution, which maps `@bakery-framework/core/plugins` to
9
+ * this directory and looks for an index rather than consulting the export map.
10
+ *
11
+ * Two files, one entry point: `routeTable`/`dispatch` from `routes`, and the
12
+ * plugin shape from `types`.
13
+ */
14
+ export * from './routes'
15
+ export * from './types'