@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,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,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
|
+
}
|