@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,437 @@
1
+ import { safeStringify } from '../utils/isomorphic/stringify'
2
+
3
+ let needsReload = false
4
+ let isDead = false
5
+ let consoleHooked = false
6
+ let reconnectAttempts = 0
7
+
8
+ /**
9
+ * Buffered log frames for a socket that isn't open yet. Bounded: a tab left
10
+ * open against a stopped dev server would otherwise accumulate every log line
11
+ * in memory and flood the server on reconnect.
12
+ */
13
+ const MAX_LOG_QUEUE = 500
14
+ const logQueue: string[] = []
15
+
16
+ function queueLog(msg: string) {
17
+ logQueue.push(msg)
18
+ if (logQueue.length > MAX_LOG_QUEUE) logQueue.shift()
19
+ }
20
+
21
+ const OVERLAY_ID = 'bakery-livereload-overlay'
22
+
23
+ function hideOverlay() {
24
+ document.getElementById(OVERLAY_ID)?.remove()
25
+ }
26
+
27
+ /**
28
+ * Full-viewport dev overlay for server-pushed errors and a dead dev server.
29
+ * Built strictly with createElement/textContent — the title and body arrive
30
+ * over the wire and may contain markup-shaped text (stack traces quoting
31
+ * generics, user file names); nothing here may pass through innerHTML.
32
+ */
33
+ function showOverlay(title: string, body: string) {
34
+ hideOverlay()
35
+
36
+ const overlay = document.createElement('div')
37
+ overlay.id = OVERLAY_ID
38
+ overlay.style.cssText =
39
+ 'position:fixed;inset:0;z-index:2147483647;background:rgba(12,12,14,0.88);' +
40
+ 'color:#f5f5f5;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;' +
41
+ 'padding:32px;overflow:auto;box-sizing:border-box;cursor:pointer'
42
+
43
+ const titleEl = document.createElement('div')
44
+ titleEl.textContent = title
45
+ titleEl.style.cssText =
46
+ 'color:#ff6b6b;font-size:16px;font-weight:700;margin-bottom:16px'
47
+
48
+ const bodyEl = document.createElement('pre')
49
+ bodyEl.textContent = body
50
+ bodyEl.style.cssText =
51
+ 'white-space:pre-wrap;font-size:13px;line-height:1.5;margin:0;font-family:inherit'
52
+
53
+ const hint = document.createElement('div')
54
+ hint.textContent = 'click anywhere or press Esc to dismiss'
55
+ hint.style.cssText = 'margin-top:24px;font-size:11px;opacity:0.6'
56
+
57
+ overlay.append(titleEl, bodyEl, hint)
58
+ overlay.addEventListener('click', hideOverlay)
59
+ document.body.appendChild(overlay)
60
+ }
61
+
62
+ document.addEventListener('keydown', e => {
63
+ if (e.key === 'Escape') hideOverlay()
64
+ })
65
+
66
+ function getHtmlDifference(htmlA: string, htmlB: string): number {
67
+ const getBigrams = (str: string) => {
68
+ const s = str.replace(/\s+/g, '')
69
+ const bigrams = new Set<string>()
70
+ for (let i = 0; i < s.length - 1; i++) {
71
+ bigrams.add(s.substring(i, i + 2))
72
+ }
73
+ return bigrams
74
+ }
75
+
76
+ const setA = getBigrams(htmlA)
77
+ const setB = getBigrams(htmlB)
78
+
79
+ const intersection = setA.intersection(setB).size
80
+
81
+ const similarity = (2.0 * intersection) / (setA.size + setB.size) || 0
82
+ return (1 - similarity) * 100
83
+ }
84
+
85
+ function replaceNode(current: Node, incoming: Node) {
86
+ if (current.parentNode) {
87
+ current.parentNode.replaceChild(
88
+ document.importNode(incoming, true),
89
+ current,
90
+ )
91
+ }
92
+ }
93
+
94
+ function updateTextOrCommentNode(current: Node, incoming: Node) {
95
+ if (current.nodeValue !== incoming.nodeValue) {
96
+ current.nodeValue = incoming.nodeValue
97
+ }
98
+ }
99
+
100
+ function patchAttributes(curEl: Element, incEl: Element) {
101
+ for (const attr of Array.from(incEl.attributes)) {
102
+ if (curEl.getAttribute(attr.name) !== attr.value) {
103
+ curEl.setAttribute(attr.name, attr.value)
104
+ }
105
+ }
106
+ for (const attr of Array.from(curEl.attributes)) {
107
+ if (!incEl.hasAttribute(attr.name)) {
108
+ curEl.removeAttribute(attr.name)
109
+ }
110
+ }
111
+ }
112
+
113
+ function patchInputFields(curEl: Element, incEl: Element) {
114
+ if (curEl instanceof HTMLInputElement && incEl instanceof HTMLInputElement) {
115
+ if (curEl.value !== incEl.value) {
116
+ curEl.value = incEl.value
117
+ }
118
+ if (curEl.checked !== incEl.checked) {
119
+ curEl.checked = incEl.checked
120
+ }
121
+ } else if (
122
+ curEl instanceof HTMLTextAreaElement &&
123
+ incEl instanceof HTMLTextAreaElement
124
+ ) {
125
+ if (curEl.value !== incEl.value) {
126
+ curEl.value = incEl.value
127
+ }
128
+ } else if (
129
+ curEl instanceof HTMLSelectElement &&
130
+ incEl instanceof HTMLSelectElement
131
+ ) {
132
+ if (curEl.value !== incEl.value) {
133
+ curEl.value = incEl.value
134
+ }
135
+ }
136
+ }
137
+
138
+ function patchChildNodes(curEl: Element, incEl: Element) {
139
+ const curChildren = Array.from(curEl.childNodes)
140
+ const incChildren = Array.from(incEl.childNodes)
141
+ const minLen = Math.min(curChildren.length, incChildren.length)
142
+
143
+ for (let i = 0; i < minLen; i++) {
144
+ const curChild = curChildren[i]
145
+ const incChild = incChildren[i]
146
+
147
+ if (
148
+ curChild.nodeType === incChild.nodeType &&
149
+ (curChild.nodeType !== Node.ELEMENT_NODE ||
150
+ (curChild as Element).tagName === (incChild as Element).tagName)
151
+ ) {
152
+ patchDOM(curChild, incChild)
153
+ } else {
154
+ if (curChild.parentNode === curEl) {
155
+ curEl.replaceChild(document.importNode(incChild, true), curChild)
156
+ }
157
+ }
158
+ }
159
+
160
+ for (let i = minLen; i < curChildren.length; i++) {
161
+ const child = curChildren[i]
162
+ if (child.parentNode === curEl) {
163
+ curEl.removeChild(child)
164
+ }
165
+ }
166
+
167
+ for (let i = minLen; i < incChildren.length; i++) {
168
+ curEl.appendChild(document.importNode(incChildren[i], true))
169
+ }
170
+ }
171
+
172
+ function patchElementNode(curEl: Element, incEl: Element) {
173
+ if (curEl.tagName !== incEl.tagName) {
174
+ replaceNode(curEl, incEl)
175
+ return
176
+ }
177
+
178
+ patchAttributes(curEl, incEl)
179
+ patchInputFields(curEl, incEl)
180
+ patchChildNodes(curEl, incEl)
181
+ }
182
+
183
+ function patchDOM(current: Node, incoming: Node) {
184
+ if (current.nodeType !== incoming.nodeType) {
185
+ replaceNode(current, incoming)
186
+ return
187
+ }
188
+
189
+ if (
190
+ current.nodeType === Node.TEXT_NODE ||
191
+ current.nodeType === Node.COMMENT_NODE
192
+ ) {
193
+ updateTextOrCommentNode(current, incoming)
194
+ return
195
+ }
196
+
197
+ if (current.nodeType === Node.ELEMENT_NODE) {
198
+ patchElementNode(current as Element, incoming as Element)
199
+ }
200
+ }
201
+
202
+ function connect() {
203
+ const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
204
+ const ws = new WebSocket(`${protocol}//${location.host}/_livereload`)
205
+
206
+ const sendLog = (level: string, args: any[]) => {
207
+ const payload = Array.from(args)
208
+ .map(a => safeStringify(a))
209
+ .join(' ')
210
+
211
+ const msg = JSON.stringify({
212
+ type: 'client_log',
213
+ level,
214
+ payload,
215
+ ip: '',
216
+ })
217
+
218
+ ws.readyState === WebSocket.OPEN ? ws.send(msg) : queueLog(msg)
219
+ }
220
+
221
+ // connect() runs again on every reconnect, so this must only happen once.
222
+ // Re-wrapping meant console.log nested one level deeper per reconnect and
223
+ // emitted a duplicate frame each time, plus a new listener per cycle.
224
+ if (!consoleHooked) {
225
+ consoleHooked = true
226
+
227
+ const ogLog = console.log,
228
+ ogWarn = console.warn,
229
+ ogErr = console.error
230
+ console.log = (...args) => {
231
+ ogLog(...args)
232
+ sendLog('info', args)
233
+ }
234
+ console.warn = (...args) => {
235
+ ogWarn(...args)
236
+ sendLog('warn', args)
237
+ }
238
+ console.error = (...args) => {
239
+ ogErr(...args)
240
+ sendLog('error', args)
241
+ }
242
+
243
+ window.onerror = (m, s, l, c) =>
244
+ sendLog('error', [`${m} at ${s}:${l}:${c}`])
245
+ window.addEventListener('unhandledrejection', e =>
246
+ sendLog('error', [`Unhandled Promise: ${e.reason}`]),
247
+ )
248
+ }
249
+
250
+ const isSameFile = (fileA: string, fileB: string): boolean => {
251
+ const norm = (f: string) =>
252
+ f
253
+ .replace(/\\/g, '/')
254
+ .replace(/\/+/g, '/')
255
+ .replace(/^\.\//, '')
256
+ .replace(/^\//, '')
257
+ return norm(fileA) === norm(fileB)
258
+ }
259
+
260
+ const checkHTMLFallback = (filename: string): boolean => {
261
+ if (!filename.endsWith('.html')) return false
262
+ const normFile = filename.startsWith('.')
263
+ ? filename.substring(1)
264
+ : filename.startsWith('/')
265
+ ? filename
266
+ : `/${filename}`
267
+ const p = location.pathname
268
+
269
+ return (
270
+ p === normFile ||
271
+ `${p}.html` === normFile ||
272
+ (p.endsWith('/') ? `${p}index.html` : `${p}/index.html`) === normFile
273
+ )
274
+ }
275
+
276
+ const checkSelfPage = (filename: string): boolean => {
277
+ const currentRouteFile = (window as any).Bakery?.params()?.__file
278
+ if (currentRouteFile) {
279
+ return isSameFile(filename, currentRouteFile)
280
+ }
281
+ return checkHTMLFallback(filename)
282
+ }
283
+
284
+ const handleCSSUpdate = (filename: string) => {
285
+ const normCssFile = filename.startsWith('.')
286
+ ? filename.substring(1)
287
+ : filename.startsWith('/')
288
+ ? filename
289
+ : `/${filename}`
290
+ console.log(`[LiveReload] CSS change detected: ${filename}`)
291
+ const links = document.querySelectorAll(
292
+ 'link[rel="stylesheet"]:not([data-removing])',
293
+ ) as NodeListOf<HTMLLinkElement>
294
+ for (const link of links) {
295
+ const url = new URL(link.href, location.href)
296
+ if (url.origin === location.origin && url.pathname === normCssFile) {
297
+ link.setAttribute('data-removing', 'true')
298
+ url.searchParams.set('v', String(Date.now()))
299
+ const newHref = url.pathname + url.search
300
+ void fetch(newHref, { mode: 'no-cors' }).then(() => {
301
+ const newLink = document.createElement('link')
302
+ newLink.rel = 'stylesheet'
303
+ newLink.href = newHref
304
+ document.head.appendChild(newLink)
305
+ setTimeout(() => link.remove(), 50)
306
+ })
307
+ }
308
+ }
309
+ }
310
+
311
+ const handleHtmlOrTsxUpdate = (filename: string) => {
312
+ if (document.visibilityState !== 'visible') {
313
+ needsReload = true
314
+ return
315
+ }
316
+
317
+ const isHtmlOrTsx = filename.endsWith('.html') || filename.endsWith('.tsx')
318
+ if (isHtmlOrTsx) {
319
+ fetch(location.href)
320
+ .then(res => res.text())
321
+ .then(newHtml => {
322
+ const diffPercent = getHtmlDifference(
323
+ document.documentElement.outerHTML,
324
+ newHtml,
325
+ )
326
+ if (diffPercent < 15) {
327
+ const parser = new DOMParser()
328
+ const newDoc = parser.parseFromString(newHtml, 'text/html')
329
+ patchDOM(document.body, newDoc.body)
330
+ console.log(
331
+ `[LiveReload] Hot-swapped DOM body (${diffPercent.toFixed(1)}% change)`,
332
+ )
333
+ } else {
334
+ console.log(
335
+ `[LiveReload] Large change detected (${diffPercent.toFixed(1)}%), reloading...`,
336
+ )
337
+ location.reload()
338
+ }
339
+ })
340
+ .catch(() => {
341
+ location.reload()
342
+ })
343
+ } else {
344
+ location.reload()
345
+ }
346
+ }
347
+
348
+ const handleUpdate = (filename: string) => {
349
+ const isCSS = filename.endsWith('.css')
350
+ const isSelfPage = checkSelfPage(filename)
351
+ const isOtherHTML = filename.endsWith('.html') && !isSelfPage
352
+
353
+ if (isOtherHTML) return
354
+
355
+ if (isCSS) {
356
+ handleCSSUpdate(filename)
357
+ } else {
358
+ handleHtmlOrTsxUpdate(filename)
359
+ }
360
+ }
361
+
362
+ ws.onmessage = e => {
363
+ const data = typeof e.data === 'string' ? e.data : String(e.data)
364
+
365
+ // The server sends two frame shapes: legacy plain strings (a
366
+ // watcher-relative filename, or the literal 'force_reload') and JSON
367
+ // objects, currently `{type: 'error', title, body}` from
368
+ // compiler/dev-service.ts's notifyError. A relative path can never begin
369
+ // with '{', so the brace is a sound discriminator.
370
+ if (data.startsWith('{')) {
371
+ let frame: { type?: string; title?: unknown; body?: unknown } | null =
372
+ null
373
+ try {
374
+ frame = JSON.parse(data)
375
+ } catch {
376
+ // A '{'-prefixed frame that is not JSON is not a filename either;
377
+ // frame stays null and the string falls through as a legacy frame.
378
+ }
379
+ if (frame) {
380
+ if (frame.type === 'error') {
381
+ showOverlay(
382
+ String(frame.title ?? 'Dev server error'),
383
+ String(frame.body ?? ''),
384
+ )
385
+ }
386
+ // Unknown JSON frame types are ignored: older clients surviving a
387
+ // framework upgrade must not treat new frames as filenames.
388
+ return
389
+ }
390
+ }
391
+
392
+ // Any successful reload frame supersedes whatever error was on screen.
393
+ hideOverlay()
394
+ handleUpdate(data)
395
+ }
396
+
397
+ ws.onopen = () => {
398
+ reconnectAttempts = 0
399
+ hideOverlay()
400
+
401
+ while (logQueue.length > 0) {
402
+ ws.send(logQueue.shift()!)
403
+ }
404
+
405
+ if (isDead) {
406
+ console.log('[LiveReload] Server is back! Refreshing...')
407
+ location.reload()
408
+ } else {
409
+ console.log('[LiveReload] Connected')
410
+ }
411
+ }
412
+
413
+ ws.onclose = () => {
414
+ isDead = true
415
+ // Back off with jitter. A flat 1s retry meant every open tab hammered a
416
+ // stopped dev server once a second, indefinitely and in lockstep.
417
+ const delay = Math.min(1000 * 2 ** reconnectAttempts, 30_000)
418
+ reconnectAttempts += 1
419
+ // A dead dev server used to mean silent reconnect attempts — the page just
420
+ // quietly stopped reloading. After a few failures (~7s of downtime with
421
+ // the backoff above) say so; onopen dismisses it and reloads on reconnect.
422
+ if (reconnectAttempts > 3) {
423
+ showOverlay('dev server disconnected', 'waiting to reconnect…')
424
+ }
425
+ setTimeout(connect, delay + Math.random() * 500)
426
+ }
427
+
428
+ ws.onerror = () => ws.close()
429
+ }
430
+
431
+ connect()
432
+
433
+ document.addEventListener('visibilitychange', () => {
434
+ if (document.visibilityState === 'visible' && needsReload) {
435
+ location.reload()
436
+ }
437
+ })