@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
|
@@ -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,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'
|