@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,660 @@
1
+ import { watch } from 'node:fs/promises'
2
+ import { relative, resolve } from 'node:path'
3
+ import { initRoutes } from '../cache'
4
+ import { Bakery } from '../core/bakery'
5
+ import { isDevWorker } from '../core/init'
6
+ // Dependency-free by construction (see the note above about not dragging the
7
+ // handler or plugin runtime into this graph): `core/port` imports nothing.
8
+ import { resolvePort } from '../core/port'
9
+ // Type-only: the compiler must not pull the handler or plugin runtime into the
10
+ // dev worker's module graph, and nothing here needs a value from either.
11
+ import type { Handler } from '../handlers/core/$base'
12
+ import { compLog, serveLog } from '../logger'
13
+ import type { ServerPlugin } from '../plugins/types'
14
+ import { Try } from '../utils'
15
+ import { fs, Glob } from '../utils/fs'
16
+ import { PromptTracker } from './prompt-tracker'
17
+
18
+ export function notifySockets(server: any, filename: string) {
19
+ const serveRoot = Bakery.serveRoot || '.'
20
+ const relativePath = relative(resolve(serveRoot), resolve(filename)).replace(
21
+ /\\/g,
22
+ '/',
23
+ )
24
+ server?.publish('livereload', relativePath)
25
+ }
26
+
27
+ /**
28
+ * Push an error to every connected browser as a JSON frame:
29
+ * `{type: 'error', title, body}`. client/livereload.ts renders it as an
30
+ * overlay and removes it on the next successful reload frame.
31
+ *
32
+ * Legacy reload frames stay plain filename strings — the client distinguishes
33
+ * the two by a leading `{`, so this must always be JSON and filenames must
34
+ * never be. Paths that cannot reach a live socket at all (worker dead, boot
35
+ * failure before `Bun.serve`) are covered client-side by the disconnect
36
+ * overlay instead.
37
+ *
38
+ * Two producers reach here. The watcher's `catch` below sends internal watcher
39
+ * faults directly; every *request-time* failure arrives through `emitDevError`
40
+ * and the sink `startCompileService` installs. That second producer is the
41
+ * point of the whole apparatus — for a long time only the first existed, so the
42
+ * overlay was reachable solely on an IO fault inside the watcher loop and never
43
+ * appeared for the failures developers actually hit.
44
+ */
45
+ export function notifyError(server: any, title: string, body: string) {
46
+ server?.publish('livereload', JSON.stringify({ type: 'error', title, body }))
47
+ }
48
+
49
+ /**
50
+ * Longest error body that may cross the livereload socket.
51
+ *
52
+ * `errorBody` is a stack for anything `extractErrorData` builds from a thrown
53
+ * `Error`, and a framework-deep throw produces tens of kilobytes of it. The
54
+ * overlay renders it into a single `<pre>`, so past a screenful the extra bytes
55
+ * are unreadable *and* re-broadcast to every connected tab on every failing
56
+ * request.
57
+ */
58
+ export const MAX_DEV_ERROR_BODY = 8000
59
+
60
+ /** Name the dev overlay plugin registers under; also its dedupe key. */
61
+ export const DEV_ERROR_PLUGIN = 'bakery:dev-error-overlay'
62
+
63
+ /**
64
+ * Whether a request-time failure earns the full-screen dev overlay.
65
+ *
66
+ * 4xx is deliberately excluded. A 404 for a mistyped URL or an absent
67
+ * `favicon.ico` is the router working as designed, and covering the page for it
68
+ * would train developers to dismiss the overlay reflexively — which is how the
69
+ * feature dies a second time. 5xx is the band that means "your code, or the
70
+ * framework, broke", and that is the band this overlay exists for.
71
+ *
72
+ * A record with no usable `errorCode` is treated as 500: `extractErrorData`
73
+ * fills that field on every path it owns, so the only way to arrive without one
74
+ * is a malformed record, and surfacing that beats swallowing it.
75
+ */
76
+ export function classifyDevError(
77
+ error: Partial<Handler.Error.Data> | null | undefined,
78
+ ): 'overlay' | 'ignore' {
79
+ const code = typeof error?.errorCode === 'number' ? error.errorCode : 500
80
+ return code >= 500 ? 'overlay' : 'ignore'
81
+ }
82
+
83
+ /**
84
+ * The request path an error belongs to, or `''` when it cannot be determined.
85
+ *
86
+ * `handleRequestError` substitutes `http://localhost/__internal__` for a
87
+ * missing request, which names nothing the developer can act on, so it is
88
+ * reported as unknown rather than as a route.
89
+ */
90
+ function devErrorRequestPath(req?: { url?: string } | null): string {
91
+ const url = req?.url
92
+ if (!url) return ''
93
+ const parsed = Try(() => new URL(url))
94
+ if (!parsed) return ''
95
+ return parsed.pathname === '/__internal__' ? '' : parsed.pathname
96
+ }
97
+
98
+ /**
99
+ * An error record rendered into the `{title, body}` the overlay draws.
100
+ *
101
+ * Pure, and separate from the publish so the shape is pinned by tests without
102
+ * a server. The path goes in the *title* on purpose: the body is a stack whose
103
+ * first frames are usually framework internals, and "which request did this"
104
+ * is the one thing the developer needs before reading any of it.
105
+ */
106
+ export function formatDevErrorFrame(
107
+ error: Partial<Handler.Error.Data> | null | undefined,
108
+ req?: { url?: string } | null,
109
+ ): { title: string; body: string } {
110
+ const code = typeof error?.errorCode === 'number' ? error.errorCode : 500
111
+ const text = error?.errorText || 'Internal Server Error'
112
+ const path = devErrorRequestPath(req)
113
+ const body = String(error?.errorBody ?? '')
114
+
115
+ return {
116
+ title: path ? `${code} ${text} — ${path}` : `${code} ${text}`,
117
+ body:
118
+ body.length > MAX_DEV_ERROR_BODY
119
+ ? `${body.slice(0, MAX_DEV_ERROR_BODY)}\n… truncated`
120
+ : body,
121
+ }
122
+ }
123
+
124
+ /**
125
+ * Where a dev error goes, once something has said where that is.
126
+ *
127
+ * The producer of request-time errors (the plugin below, running inside a
128
+ * request) and the thing that owns the `Bun.Server` (`startCompileService`)
129
+ * are on opposite sides of the process, and neither may import the other's
130
+ * concerns: the router and the error handlers must not learn about the
131
+ * compiler. So the compiler pushes a publisher in here, and everything else
132
+ * emits through a hook that is a no-op until it does.
133
+ *
134
+ * Null in PROD, in a cluster worker, and in every test that does not install
135
+ * one — `emitDevError` is then a single optional call and nothing else runs.
136
+ */
137
+ let devErrorSink: ((title: string, body: string) => void) | null = null
138
+
139
+ /** Install (or, with `null`, remove) the dev error publisher. */
140
+ export function setDevErrorSink(
141
+ sink: ((title: string, body: string) => void) | null,
142
+ ): void {
143
+ devErrorSink = sink
144
+ }
145
+
146
+ /** Publish a dev error, or do nothing at all if no sink is installed. */
147
+ export function emitDevError(title: string, body: string): void {
148
+ devErrorSink?.(title, body)
149
+ }
150
+
151
+ /**
152
+ * The dev-only plugin that carries request-time failures to the overlay.
153
+ *
154
+ * `ServerPlugin.onError` is the framework's own observation point for request
155
+ * errors: `router.ts handleRequestError` calls it first, before the plugin
156
+ * response check, before `config.onError`, and before the error-handler
157
+ * registry — so it sees every failure the error path sees (thrown handler,
158
+ * 5xx `Response`, and Bun's `error()` callback), with `errorBody` still holding
159
+ * the full stack. Using it is what lets the overlay be wired without router.ts
160
+ * or the error handlers ever hearing about the compiler.
161
+ *
162
+ * It returns `undefined` unconditionally. `PluginHooks.onError` treats that as
163
+ * "no opinion" and carries on down the list, so registering this cannot change
164
+ * which page a failing request answers with — it only observes.
165
+ */
166
+ export function createDevErrorPlugin(): ServerPlugin {
167
+ return {
168
+ name: DEV_ERROR_PLUGIN,
169
+ onError(error, req) {
170
+ if (classifyDevError(error) === 'overlay') {
171
+ const frame = formatDevErrorFrame(error, req)
172
+ emitDevError(frame.title, frame.body)
173
+ }
174
+ // Observer, never an answer. See the note above.
175
+ return undefined
176
+ },
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Put the overlay plugin at the front of the plugin list.
182
+ *
183
+ * *Front*, not back: `PluginHooks.onError` stops at the first plugin that
184
+ * returns a response, so an app plugin that answers errors itself would
185
+ * otherwise hide every failure from the overlay. This one never answers, so
186
+ * running it first costs the others nothing.
187
+ *
188
+ * The array is mutated in place because `initConfig` hands back a frozen
189
+ * config — `config.plugins = [...]` would throw, and the array itself is the
190
+ * only writable seam. Host configs are built with `{...base}` and never
191
+ * override `plugins`, so every host shares this exact array and one
192
+ * registration covers all of them. Idempotent by name, because a re-registration
193
+ * would double every frame.
194
+ */
195
+ export function registerDevErrorOverlay(
196
+ plugins: unknown = Bakery.config.plugins,
197
+ ): 'registered' | 'duplicate' | 'unavailable' {
198
+ if (!Array.isArray(plugins)) return 'unavailable'
199
+ if (plugins.some(p => p?.name === DEV_ERROR_PLUGIN)) return 'duplicate'
200
+
201
+ // A config may legitimately hand over a frozen array; that is a reason to
202
+ // skip the overlay, not to take the dev server down.
203
+ const added = Try(() => plugins.unshift(createDevErrorPlugin()))
204
+ return added === null ? 'unavailable' : 'registered'
205
+ }
206
+
207
+ function setupPingInterval(
208
+ url: string,
209
+ signal: AbortSignal,
210
+ onServerUp: () => void,
211
+ ): any {
212
+ const interval = setInterval(async () => {
213
+ if (signal.aborted) return clearInterval(interval)
214
+
215
+ try {
216
+ await fetch(url, {
217
+ method: 'HEAD',
218
+ headers: { 'User-Agent': 'dev-watcher-ping' },
219
+ })
220
+ onServerUp()
221
+ clearInterval(interval)
222
+ } catch {
223
+ // A refused connection is the expected state until the server binds.
224
+ // The interval simply pings again; there is nothing to report.
225
+ }
226
+ }, 200)
227
+ return interval
228
+ }
229
+
230
+ function setupPromptCheckInterval(
231
+ workerPid: number,
232
+ signal: AbortSignal,
233
+ isRawModeActive: () => boolean,
234
+ isServerUp: () => boolean,
235
+ disableRaw: () => void,
236
+ enableRaw: () => void,
237
+ ): any {
238
+ const interval = setInterval(async () => {
239
+ if (signal.aborted) return clearInterval(interval)
240
+
241
+ const promptActive = await PromptTracker.isActive(workerPid)
242
+
243
+ if (promptActive && isRawModeActive()) return disableRaw()
244
+ if (!promptActive && isServerUp() && !isRawModeActive()) return enableRaw()
245
+ }, 100)
246
+
247
+ return interval
248
+ }
249
+
250
+ function createTTYManager(getWorker: () => Bun.Subprocess | null) {
251
+ let rawModeActive = false
252
+
253
+ const stdinHandler = (key: string) => {
254
+ switch (key.toLowerCase()) {
255
+ case '\u0003':
256
+ return getWorker()?.kill('SIGINT')
257
+ case 's':
258
+ return process.emit('SIGINT')
259
+ }
260
+ }
261
+
262
+ return {
263
+ get isRawModeActive() {
264
+ return rawModeActive
265
+ },
266
+ disableRawMode: () => {
267
+ rawModeActive = false
268
+ if (!process.stdin.isTTY) return
269
+
270
+ Try(() => process.stdin.setRawMode(false))
271
+ process.stdin.off('data', stdinHandler)
272
+ Try(() => process.stdin.pause())
273
+ },
274
+ enableRawMode: () => {
275
+ rawModeActive = true
276
+ if (!process.stdin.isTTY) return
277
+
278
+ Try(() => {
279
+ process.stdin.setRawMode(true)
280
+ process.stdin.resume()
281
+ process.stdin.setEncoding('utf8')
282
+ process.stdin.off('data', stdinHandler)
283
+ process.stdin.on('data', stdinHandler)
284
+ })
285
+ },
286
+ }
287
+ }
288
+
289
+ export async function handleDevMaster(): Promise<never> {
290
+ const { initConfig } = await import('../core/config')
291
+ const config = await initConfig()
292
+
293
+ // Shared with `worker.ts` (which binds) and `startup.ts` (which prints the
294
+ // banner), so the URL this master advertises cannot drift from the port its
295
+ // worker listens on. A malformed `PORT` throws here rather than in the
296
+ // spawned worker: the dev master is the process the developer is watching,
297
+ // and `cli/index.ts`'s bootstrap catch turns it into one clear fatal line.
298
+ const port = resolvePort(config.port)
299
+ const host =
300
+ config.host === '0.0.0.0' ? '127.0.0.1' : config.host || '127.0.0.1'
301
+ const url = `http://${host}:${port}/`
302
+
303
+ let workerProc: Bun.Subprocess<'inherit', 'inherit', 'inherit'> | null = null
304
+ let abortController: AbortController | null = null
305
+
306
+ const tty = createTTYManager(() => workerProc)
307
+
308
+ const cleanupAndExit = () => {
309
+ workerProc?.kill('SIGINT')
310
+ tty.disableRawMode()
311
+ if (workerProc?.pid) {
312
+ PromptTracker.deactivate(workerProc.pid)
313
+ }
314
+ process.exit(0)
315
+ }
316
+
317
+ process.on('SIGINT', cleanupAndExit)
318
+ process.on('SIGTERM', cleanupAndExit)
319
+
320
+ async function startWatcher(): Promise<never> {
321
+ tty.disableRawMode()
322
+ abortController?.abort()
323
+ abortController = new AbortController()
324
+ const signal = abortController.signal
325
+
326
+ if (workerProc?.pid) {
327
+ PromptTracker.deactivate(workerProc.pid)
328
+ }
329
+
330
+ const isDetached = process.env.DETACHED === '1'
331
+ const inspectArgs = [...process.execArgv, ...process.argv].filter(arg =>
332
+ arg.startsWith('--inspect'),
333
+ )
334
+
335
+ workerProc = Bun.spawn(
336
+ [
337
+ process.execPath,
338
+ '--smol',
339
+ ...inspectArgs,
340
+ // Respawn whatever entry started this process. The entry now lives in
341
+ // @bakery-framework/cli, and core must not reach into a package that depends on
342
+ // it — argv[1] is both correct and dependency-free.
343
+ process.argv[1],
344
+ '--dev',
345
+ '--dev-worker',
346
+ // `--sync` means "force the schema sync"; without forwarding it the
347
+ // dev worker's hash-skip (cli/dev.ts) would judge the schema unchanged
348
+ // and override the developer's explicit ask on every respawn.
349
+ ...(process.argv.includes('--sync') || process.argv.includes('-s')
350
+ ? ['--sync']
351
+ : []),
352
+ ],
353
+ {
354
+ stdio: [isDetached ? 'ignore' : 'inherit', 'inherit', 'inherit'],
355
+ windowsHide: isDetached,
356
+ env: {
357
+ ...process.env,
358
+ DEV_WATCHER_ACTIVE: '1',
359
+ },
360
+ },
361
+ )
362
+
363
+ let serverUp = false
364
+ const pingInterval = setupPingInterval(url, signal, () => {
365
+ serverUp = true
366
+ })
367
+ const checkInterval = setupPromptCheckInterval(
368
+ workerProc.pid,
369
+ signal,
370
+ () => tty.isRawModeActive,
371
+ () => serverUp,
372
+ tty.disableRawMode,
373
+ tty.enableRawMode,
374
+ )
375
+
376
+ const code = (await workerProc.exited) ?? 0
377
+
378
+ clearInterval(pingInterval)
379
+ clearInterval(checkInterval)
380
+ tty.disableRawMode()
381
+ if (workerProc?.pid) {
382
+ PromptTracker.deactivate(workerProc.pid)
383
+ }
384
+
385
+ if (code === 42) {
386
+ serveLog.RESTART_REQ()
387
+ // Clear only when the exiting worker had actually come up: then the
388
+ // scrollback is routine request logs and clearing keeps the console
389
+ // readable. A worker that exited before binding — the sync engine also
390
+ // exits 42 mid-boot (orm/sync/engine.ts) — printed the only record of
391
+ // why, and wiping it left the developer staring at a blank screen where
392
+ // the error had just been. Keeping scrollback is chosen over
393
+ // capture-and-reprint because worker stdio is inherited, not piped.
394
+ if (serverUp) console.clear()
395
+ return startWatcher()
396
+ }
397
+
398
+ if (code === 130) {
399
+ serveLog.SHUTTING_DOWN()
400
+ return process.exit(0)
401
+ }
402
+
403
+ return process.exit(code)
404
+ }
405
+
406
+ await startWatcher()
407
+ process.exit(0)
408
+ }
409
+
410
+ const pkgFilesGlob = Glob.strings('package.json', 'bun.lock', 'bun.lockb')
411
+ const fileTypeGlob = Glob.fromExt([
412
+ 'css',
413
+ 'html',
414
+ 'ts',
415
+ 'js',
416
+ 'tsx',
417
+ 'jsx',
418
+ 'vue',
419
+ ])
420
+ // `.tsx`/`.jsx` are here — the cheap path — and not in `prioFilesGlob`:
421
+ // TSXHandler re-imports the page module with `?v=<mtime>` (handlers/assets/
422
+ // tsx.ts, same mechanism as ApiHandler), so an edited page never serves stale
423
+ // and the route-cache flush + browser reload below is all a page edit needs.
424
+ const tsScriptGlob = Glob.fromExt(['ts', 'js', 'html', 'vue', 'tsx', 'jsx'])
425
+ const watchIgnores = Glob.strings(
426
+ 'node_modules/**/*',
427
+ '**/.git/**/*',
428
+ '**/.vscode/**/*',
429
+ '**/.backups/**/*',
430
+ // The framework's own cache directory (`Bakery.cacheDir`).
431
+ '**/.cache/**/*',
432
+ // `bakery/` (`Bakery.dataDir`) holds the database plus
433
+ // `backups/schema.<timestamp>.ts` files the DB backup writes at runtime —
434
+ // without this, taking a backup while the dev server ran flushed the route
435
+ // cache and reloaded the browser. It is un-dotted on purpose (see
436
+ // `core/bakery.ts`), which is exactly why it has to be named here: a
437
+ // "skip the dotfiles" heuristic would no longer cover it.
438
+ '**/bakery/**/*',
439
+ // `**/` so nested layouts (e.g. `orm/schema.ts`) are covered too. schema.ts
440
+ // anywhere in the tree is the ORM schema convention; route files are never
441
+ // named schema.ts, so the breadth is safe.
442
+ '**/schema.ts',
443
+ )
444
+
445
+ // `'api/**/*'` used to be a third entry here, left over from when API routes
446
+ // lived at the repo root. They live under `<root>/api` now — `src/api` by
447
+ // default — so the pattern matched nothing and API routes never triggered the
448
+ // restart below. The route *file* still hot-reloaded through the `?v=<mtime>`
449
+ // cache-buster, which is why this looked fine: only its imports went stale.
450
+ // The replacement is `isBackendPriorityFile`, which asks the config where the
451
+ // api directory actually is instead of hard-coding it.
452
+ //
453
+ // `'**/*.tsx'` was the second entry here, and it made every page edit — the
454
+ // most common edit there is — cost a full process restart (config, plugins,
455
+ // import map, tsconfig sync, schema-sync check). The only reason it needed a
456
+ // restart was Bun's module registry caching the imported page module, and
457
+ // TSXHandler now busts that per request in dev exactly as ApiHandler always
458
+ // has. Pages take the cheap path via `tsScriptGlob` above. The one thing the
459
+ // restart still bought — flushing *components* a page imports — is a
460
+ // documented limitation (docs/getting-started/first-app.md): touch
461
+ // server.config.ts or restart when editing a shared Layout.tsx.
462
+ const prioFilesGlob = Glob.strings('server.config.ts')
463
+
464
+ /**
465
+ * Whether a change to `filePath` requires a full backend restart rather than a
466
+ * livereload notification.
467
+ *
468
+ * `filePath` is watcher-relative — i.e. relative to the app's cwd, with forward
469
+ * slashes — so the configured api root is converted to the same shape.
470
+ *
471
+ * Host-specific roots are not consulted: a `hosts` entry can override `root`,
472
+ * but the watcher runs outside any request and so has no host context. The
473
+ * process-level root is the one that covers the common case.
474
+ */
475
+ export function isBackendPriorityFile(filePath: string): boolean {
476
+ if (prioFilesGlob.match(filePath)) return true
477
+
478
+ const apiRoot = relative(fs.cwd, Bakery.apiRoot).replace(/\\/g, '/')
479
+ // Empty means the api dir *is* the cwd; '..' means it sits outside the tree
480
+ // this watcher covers. Neither can match a watcher-relative path.
481
+ if (!apiRoot || apiRoot.startsWith('..')) return false
482
+
483
+ return filePath === apiRoot || filePath.startsWith(`${apiRoot}/`)
484
+ }
485
+
486
+ /**
487
+ * Whether a dev-worker boot needs to run the ORM schema sync.
488
+ *
489
+ * Every boot used to run the full sync unconditionally, and it dominated
490
+ * restart time. cli/dev.ts now hashes the schema sources, records the hash
491
+ * under `.cache/` after a *successful* sync only, and consults this
492
+ * before the next one. Pure so the fail-closed branches are testable: any
493
+ * indeterminate state (`currentHash: null` — sources unreadable; `storedHash:
494
+ * null` — no successful sync on record) must sync, never skip.
495
+ */
496
+ export function classifySchemaSync(opts: {
497
+ /** `--sync` / `-s` was passed: the developer asked, so sync regardless. */
498
+ force: boolean
499
+ /** Hash of the current schema sources, or null if it could not be computed. */
500
+ currentHash: string | null
501
+ /** Hash recorded after the last successful sync, or null if none. */
502
+ storedHash: string | null
503
+ /** The local database file is gone (e.g. `bakery/` deleted to reset). */
504
+ dbMissing: boolean
505
+ }): 'sync' | 'skip' {
506
+ if (opts.force) return 'sync'
507
+ if (opts.dbMissing) return 'sync'
508
+ if (opts.currentHash === null || opts.storedHash === null) return 'sync'
509
+ return opts.currentHash === opts.storedHash ? 'skip' : 'sync'
510
+ }
511
+
512
+ /** What `startCompileService` should do with a change to `filePath`. */
513
+ export type WatchEventKind = 'ignored' | 'package' | 'file'
514
+
515
+ /**
516
+ * The package branch has to be tested *before* the extension filter, which is
517
+ * the whole bug: `package.json` and `bun.lock` do not end in one of the watched
518
+ * source extensions, so the filter dropped them and the branch below was
519
+ * unreachable. Editing `package.json` in dev produced no log line at all.
520
+ */
521
+ export function classifyWatchEvent(filePath: string): WatchEventKind {
522
+ if (watchIgnores.match(filePath)) return 'ignored'
523
+ if (pkgFilesGlob.match(filePath)) return 'package'
524
+ if (!fileTypeGlob.match(filePath)) return 'ignored'
525
+ return 'file'
526
+ }
527
+
528
+ /** Route modules reached through `import()`, whose resolution Bun caches. */
529
+ const MODULE_ROUTE_EXT = /\.(tsx|jsx)$/
530
+
531
+ /**
532
+ * Whether this event is the *creation* of a route module, which needs a
533
+ * restart where an edit does not.
534
+ *
535
+ * Editing a `.tsx` page is instant: the page module carries a `?v=<mtime>`
536
+ * specifier, so Bun re-imports it and the change is served on the next
537
+ * request. Creating one is not — Bun caches the *directory listing* it
538
+ * resolved against, so a file that did not exist at boot fails to import at
539
+ * any specifier, including a freshly-stamped one. The page 500s with
540
+ * `Cannot find module '<path>?v=<mtime>'` until the process restarts. That is
541
+ * a regression from taking `.tsx` off the restart path for fast edits: API
542
+ * routes never showed it because a new `.ts` under `apiRoot` is a backend
543
+ * change and restarts already.
544
+ *
545
+ * `rename` is the creation signal. Measured on Windows: an in-place write
546
+ * (`Bun.write`, `fs.writeFile`) emits only `change` and keeps the fast path —
547
+ * 14-15ms edit-to-served — while creating a file emits `rename` then
548
+ * `change`. Writers that replace rather than overwrite report `rename` for an
549
+ * *edit* too and pay one restart (~440ms): shell redirection (`> file`) does
550
+ * this, and an editor that saves atomically will as well. That is the safe
551
+ * direction to be wrong in — a slower save beats a page that does not serve
552
+ * at all — but it is why this is scoped as narrowly as possible.
553
+ * Deletes are `rename` with no file, and need no restart.
554
+ *
555
+ * Scoped to imported modules: a new `.html`, `.css` or client `.ts` is read or
556
+ * transpiled from disk rather than imported, and already works untouched.
557
+ */
558
+ export function isCreatedRouteModule(
559
+ filePath: string,
560
+ eventType: string,
561
+ exists: boolean,
562
+ ): boolean {
563
+ if (eventType !== 'rename' || !exists) return false
564
+ return MODULE_ROUTE_EXT.test(filePath)
565
+ }
566
+
567
+ async function processFileEvent(
568
+ filePath: string,
569
+ server: any,
570
+ isDevWorker: boolean,
571
+ eventType = 'change',
572
+ ) {
573
+ if (isDevWorker) {
574
+ if (isBackendPriorityFile(filePath)) {
575
+ serveLog.BACKEND_CHANGE({ file: filePath })
576
+ return process.exit(42)
577
+ }
578
+
579
+ // Checked before the cheap path below: a *new* page cannot be imported by
580
+ // this process at all — see `isCreatedRouteModule`.
581
+ if (
582
+ isCreatedRouteModule(
583
+ filePath,
584
+ eventType,
585
+ await Bun.file(filePath).exists(),
586
+ )
587
+ ) {
588
+ serveLog.BACKEND_CHANGE({ file: filePath })
589
+ return process.exit(42)
590
+ }
591
+
592
+ if (tsScriptGlob.match(filePath)) {
593
+ initRoutes()
594
+ return notifySockets(server, filePath)
595
+ }
596
+
597
+ if (fileTypeGlob.match(filePath)) {
598
+ return notifySockets(server, filePath)
599
+ }
600
+ }
601
+
602
+ if (await Bun.file(filePath).exists()) {
603
+ return compLog.FILE_STATUS({ status: 'changed', file: filePath })
604
+ }
605
+
606
+ compLog.FILE_DEL({ file: filePath })
607
+ if (isDevWorker) notifySockets(server, filePath)
608
+ }
609
+
610
+ export async function startCompileService(server: any): Promise<void> {
611
+ if (!import.meta.env.DEV) return
612
+ const watcher = watch('./', { recursive: true })
613
+
614
+ // Only the dev worker serves `/_livereload` (LiveReloadHandler.canHandle
615
+ // gates on DEV && DEV_WORKER), so only the dev worker has anywhere to publish
616
+ // to. Everywhere else the sink stays null and the plugin is never registered,
617
+ // which is what keeps this off the production path entirely.
618
+ if (isDevWorker) {
619
+ setDevErrorSink((title, body) => notifyError(server, title, body))
620
+ if (registerDevErrorOverlay() === 'unavailable') {
621
+ // Not fatal: hot reload and the watcher-fault overlay still work, the
622
+ // request-time overlay just will not appear. Say so rather than leaving
623
+ // the developer to wonder why it never shows.
624
+ serveLog.WATCHER_ERR({
625
+ error:
626
+ 'dev error overlay not registered: config.plugins is not writable',
627
+ })
628
+ }
629
+ }
630
+
631
+ for await (const { filename, eventType } of watcher) {
632
+ if (!filename) continue
633
+ const filePath = filename.replace(/\\/g, '/')
634
+
635
+ const kind = classifyWatchEvent(filePath)
636
+ if (kind === 'ignored') continue
637
+
638
+ if (kind === 'package') {
639
+ // Log only, no restart — deliberately. `bun install` rewrites the
640
+ // lockfile several times, and restarting on each would put the dev server
641
+ // in a loop for the duration of an install. The line tells you a restart
642
+ // is warranted; you decide when.
643
+ compLog.FILE_STATUS({ status: 'changed', file: filePath })
644
+ continue
645
+ }
646
+
647
+ const [error] = await Try.catch(
648
+ processFileEvent(filePath, server, isDevWorker, eventType),
649
+ )
650
+ if (error) {
651
+ // A single bad event must not kill the watcher: before this, a throw
652
+ // here unwound the for-await and file watching silently stopped until
653
+ // the next manual restart. The server is alive at this point, so the
654
+ // browser is told too — this is the in-worker sender for the error
655
+ // overlay protocol.
656
+ serveLog.WATCHER_ERR({ error: String(error) })
657
+ notifyError(server, 'Dev watcher error', String(error))
658
+ }
659
+ }
660
+ }
@@ -0,0 +1,2 @@
1
+ export * from './compiler'
2
+ export * from './dev-service'
@@ -0,0 +1,36 @@
1
+ import { unlinkSync } from 'node:fs'
2
+ import { Bakery } from '../core/bakery'
3
+ import { Try } from '../utils/common/try'
4
+
5
+ export const PromptTracker = {
6
+ getFilePath(pid: number): string {
7
+ // Derived, not written out: this lands in the cache directory, which the
8
+ // framework wipes wholesale, and a stale literal here would leave marker
9
+ // files behind in a directory nothing sweeps.
10
+ return `${Bakery.cacheDir}/.prompt-active-${pid}`
11
+ },
12
+
13
+ async isActive(pid: number): Promise<boolean> {
14
+ return (
15
+ (await Promise.try(() => Bun.file(this.getFilePath(pid)).exists()).catch(
16
+ () => false,
17
+ )) ?? false
18
+ )
19
+ },
20
+
21
+ activate(pid: number): void {
22
+ Try(() => Bun.write(this.getFilePath(pid), '1'))
23
+ },
24
+
25
+ deactivate(pid: number): void {
26
+ Try(() => {
27
+ try {
28
+ unlinkSync(this.getFilePath(pid))
29
+ } catch {
30
+ Bun.file(this.getFilePath(pid))
31
+ .delete()
32
+ .catch(() => {})
33
+ }
34
+ })
35
+ },
36
+ }