@bakery-framework/plugin-dashboard 2.0.0-alpha.9 → 2.0.0-rc.2

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/src/shell.tsx CHANGED
@@ -1,6 +1,5 @@
1
1
  import { getFrameworkVersion } from '@bakery-framework/core'
2
2
  import { Bakery } from '@bakery-framework/core/core/bakery'
3
- import { Try } from '@bakery-framework/core/utils/common'
4
3
  import { renderDatabaseBrowser } from './components/DBBrowser'
5
4
  import { renderLogsPanel } from './components/LogsPanel'
6
5
  import { renderSessionsPanel } from './components/SessionsPanel'
@@ -13,37 +12,26 @@ import { renderTopPagesPanel } from './components/TopPagesPanel'
13
12
  * Keeping that contract lets the chrome be replaced without touching the ~3k
14
13
  * lines of panel client code.
15
14
  */
16
- /**
17
- * A path nothing should claim, used as the control below.
18
- */
19
- const NOT_A_ROUTE = '/__bakery_probe_no_handler_serves_this__'
20
-
21
15
  /**
22
16
  * Is anything serving `/_db`?
23
17
  *
24
- * Asked **behaviourally**, never by importing db-explorer: a plugin-to-plugin
25
- * import is a package-graph edge, and `tests/conventions.test.ts` allows
26
- * exactly one of those (this package → analytics). It is also the better
27
- * question — the console wants to know whether that path leads somewhere, not
28
- * which package put it there, so an application serving its own explorer at
29
- * `/_db` gets the link too.
30
- *
31
- * **The control probe is what makes this work.** `StaticHandler.canHandle()`
32
- * returns `true` unconditionally — it is the priority-0 fallback and claims
33
- * every path — so "does some handler claim `/_db`" is always yes. A handler
34
- * that claims `/_db` *and declines a path nobody serves* is claiming a
35
- * namespace rather than catching everything.
18
+ * Asked of the registry's *declarations*, never by importing db-explorer: a
19
+ * plugin-to-plugin import is a package-graph edge, and
20
+ * `tests/conventions.test.ts` allows exactly one of those (this package →
21
+ * analytics). `Handler.namespace` is the declaration: any handler that owns
22
+ * `/_db` as a surface gets the link, including an application serving its own
23
+ * explorer there.
36
24
  *
37
- * `canHandle` signatures vary across handlers and some read the request, so a
38
- * throw here means "not the one we are looking for" rather than an error.
25
+ * This replaces a behavioral probe that called every handler's
26
+ * `canHandle('/_db')` with a control path to exclude the priority-0 catch-all.
27
+ * The probe worked and its `as any` was the tell: the registry could not say
28
+ * what a handler serves, so the question had to be asked by experiment. Now it
29
+ * can: `list()` is typed `typeof Handler[]`, so this reads with no cast, and
30
+ * a second plugin wanting a console entry declares a namespace rather than
31
+ * copying a probe.
39
32
  */
40
33
  function explorerIsMounted(): boolean {
41
- for (const handler of Bakery.handlers.fetch.list()) {
42
- const claims = (path: string) =>
43
- Try.return(() => (handler as any).canHandle?.(path) === true, false)
44
- if (claims('/_db') && !claims(NOT_A_ROUTE)) return true
45
- }
46
- return false
34
+ return Bakery.handlers.fetch.list().some(h => h.namespace === '/_db')
47
35
  }
48
36
 
49
37
  interface NavEntry {
@@ -70,7 +58,7 @@ function navSections(explorerMounted: boolean): {
70
58
  group: 'Data',
71
59
  items: [
72
60
  // The console does not browse the database any more, so Database is a
73
- // way *out* of it when the explorer is mounted — a link, not a tab.
61
+ // way *out* of it when the explorer is mounted: a link, not a tab.
74
62
  // Without it the entry stays a tab showing the panel that explains
75
63
  // where the editor went and how to get it back; an entry that silently
76
64
  // navigates to a 404 would be worse than either.
@@ -107,11 +95,37 @@ function NavItem({ id, label, href }: NavEntry) {
107
95
  )
108
96
  }
109
97
 
98
+ /**
99
+ * The rendered shell, and the one fact it depends on.
100
+ *
101
+ * Everything in this document is fixed for the life of the process except
102
+ * whether an explorer is mounted at `/_db`, which decides one nav entry, and
103
+ * the framework version, which cannot change at all. Rendering it again per
104
+ * request produced an identical 11.8 KB string every time.
105
+ *
106
+ * Measured at **0.50 ms per render** with a CPU-bound control flat at 27-30 ms
107
+ * - the backlog recorded 12.6 ms, which is 25 times the figure this actually
108
+ * measures. Small, then, and still pure waste on every console page load.
109
+ *
110
+ * Keyed on the mounted flag rather than held unconditionally, so a registry
111
+ * that changes after the first render is followed rather than remembered
112
+ * wrongly. Two entries at most, which is why a plain pair is enough and
113
+ * convention 6 has nothing to say about it.
114
+ */
115
+ let shellCache: { mounted: boolean; html: string } | null = null
116
+
117
+ /** Test seam (convention 9): one test's shell must not answer for another's. */
118
+ export function __resetShellCache(): void {
119
+ shellCache = null
120
+ }
121
+
110
122
  export default function Dashboard() {
111
123
  const explorerMounted = explorerIsMounted()
124
+ if (shellCache?.mounted === explorerMounted) return shellCache.html
125
+
112
126
  const NAV = navSections(explorerMounted)
113
127
 
114
- return (
128
+ const html = (
115
129
  <html lang="en">
116
130
  <head>
117
131
  <meta charSet="UTF-8" />
@@ -140,7 +154,7 @@ export default function Dashboard() {
140
154
  <div class="rail-foot">
141
155
  <span>Bakery</span>
142
156
  {/* Was the literal `v3`, which was never any version of anything
143
- — nothing filled the id, and the framework was on 1.x when it
157
+ : nothing filled the id, and the framework was on 1.x when it
144
158
  was written. `getFrameworkVersion()` reads core's own
145
159
  manifest, which is the number this label claims to be; the
146
160
  app's version is a different question and `BAKERY_VERSION`
@@ -209,5 +223,12 @@ export default function Dashboard() {
209
223
  <script src="/_dashboard/dashboard.js"></script>
210
224
  </body>
211
225
  </html>
212
- )
226
+ ) as unknown as string
227
+ // The same cast `raw()` makes in `core/jsx.ts`: `createElement` is declared
228
+ // to return `string` and actually returns a `SafeHtml`, a String subclass
229
+ // that behaves like one everywhere. Cached as it came rather than through
230
+ // `String()`, so nothing about the value changes by being remembered.
231
+
232
+ shellCache = { mounted: explorerMounted, html }
233
+ return html
213
234
  }
@@ -9,7 +9,7 @@
9
9
  * A database stand-in that records what it was asked to do and does none of it.
10
10
  *
11
11
  * The assertion that matters in both suites is that the recorded list stays
12
- * *empty* — a rejection that arrives after the truncate is not a rejection — so
12
+ * *empty* (a rejection that arrives after the truncate is not a rejection), so
13
13
  * the stub must reach no real database and must log every method the endpoints
14
14
  * can call. `setup.test.ts` (routing and CSRF) and `endpoints/database.test.ts`
15
15
  * (the write gate) each kept their own copy, and the copies had already
@@ -1,327 +0,0 @@
1
- export let refreshShimmerCache = () => {}
2
-
3
- /**
4
- * Both effects below run on import. Without this guard, importing anything that
5
- * transitively reaches this file outside a browser throws on the first `window`
6
- * access — which made every module in `client/parts/` untestable, including the
7
- * hand-built HTML in the since-retired `database.ts` that produced every XSS
8
- * found in this repo. A DOM effect with no DOM is a no-op, not an error.
9
- */
10
- const HAS_DOM = typeof window !== 'undefined' && typeof document !== 'undefined'
11
-
12
- ;(function initDashboardShimmer() {
13
- if (!HAS_DOM) return
14
- if (!window.matchMedia('(hover: hover) and (pointer: fine)').matches) return
15
-
16
- let cachedCards: {
17
- el: HTMLElement
18
- width: number
19
- height: number
20
- pageLeft: number
21
- pageTop: number
22
- }[] = []
23
- const mouse = { x: -9999, y: -9999 }
24
- let rafId = 0
25
-
26
- refreshShimmerCache = function updateRects() {
27
- const cards = Array.from(
28
- document.querySelectorAll('.glass-effect'),
29
- ) as HTMLElement[]
30
-
31
- cachedCards = cards.map(el => {
32
- const rect = el.getBoundingClientRect()
33
- return {
34
- el,
35
- width: rect.width,
36
- height: rect.height,
37
- pageLeft: rect.left + window.scrollX,
38
- pageTop: rect.top + window.scrollY,
39
- }
40
- })
41
- }
42
-
43
- /**
44
- * The observer below fires for every subtree mutation on the page, and
45
- * `refreshShimmerCache` reads `getBoundingClientRect` once per glass card —
46
- * a forced synchronous layout. The log console appends one node per
47
- * websocket frame, each in its own task, so a busy server bought one full
48
- * layout pass per log line. Coalescing into a frame makes that cost
49
- * proportional to frames, not to mutations.
50
- */
51
- let refreshRafId = 0
52
- function scheduleRefresh() {
53
- if (refreshRafId) return
54
- refreshRafId = requestAnimationFrame(() => {
55
- refreshRafId = 0
56
- refreshShimmerCache()
57
- })
58
- }
59
-
60
- window.addEventListener('resize', scheduleRefresh, { passive: true })
61
-
62
- const observer = new MutationObserver(scheduleRefresh)
63
- observer.observe(document.body, {
64
- childList: true,
65
- subtree: true,
66
- attributes: true,
67
- attributeFilter: ['class'],
68
- })
69
-
70
- refreshShimmerCache()
71
-
72
- function updateStyles() {
73
- const scrollX = window.scrollX
74
- const scrollY = window.scrollY
75
-
76
- for (let i = 0; i < cachedCards.length; i++) {
77
- const c = cachedCards[i]
78
- const left = c.pageLeft - scrollX
79
- const top = c.pageTop - scrollY
80
-
81
- const rx = mouse.x - left
82
- const ry = mouse.y - top
83
-
84
- const closestX = Math.max(left, Math.min(mouse.x, left + c.width))
85
- const closestY = Math.max(top, Math.min(mouse.y, top + c.height))
86
- const dx = mouse.x - closestX
87
- const dy = mouse.y - closestY
88
- const dist = Math.sqrt(dx * dx + dy * dy)
89
-
90
- if (dist < 200) {
91
- c.el.style.setProperty('--mouse-x', `${rx}px`)
92
- c.el.style.setProperty('--mouse-y', `${ry}px`)
93
- } else {
94
- c.el.style.removeProperty('--mouse-x')
95
- c.el.style.removeProperty('--mouse-y')
96
- }
97
- }
98
- rafId = 0
99
- }
100
-
101
- window.addEventListener(
102
- 'pointermove',
103
- e => {
104
- mouse.x = e.clientX
105
- mouse.y = e.clientY
106
- if (!rafId) rafId = requestAnimationFrame(updateStyles)
107
- },
108
- { passive: true },
109
- )
110
-
111
- window.addEventListener(
112
- 'scroll',
113
- () => {
114
- if (mouse.x === -9999) return
115
- if (!rafId) rafId = requestAnimationFrame(updateStyles)
116
- },
117
- { passive: true },
118
- )
119
-
120
- window.addEventListener('mouseleave', () => {
121
- mouse.x = -9999
122
- mouse.y = -9999
123
- if (!rafId) rafId = requestAnimationFrame(updateStyles)
124
- })
125
- })()
126
-
127
- ;(function initDotPattern() {
128
- if (!HAS_DOM) return
129
-
130
- const canvas = document.createElement('canvas')
131
- canvas.id = 'dot-pattern-canvas'
132
- canvas.style.cssText = [
133
- 'position: fixed',
134
- 'inset: 0',
135
- 'width: 100%',
136
- 'height: 100%',
137
- 'z-index: -1',
138
- 'pointer-events: none',
139
- ].join(';')
140
- document.documentElement.prepend(canvas)
141
-
142
- const ctx = canvas.getContext('2d')!
143
- const DOT_SPACING = 28
144
- const DOT_RADIUS = 1.1
145
- const GLOW_RADIUS = 130
146
-
147
- interface Dot {
148
- ox: number
149
- oy: number
150
- x: number
151
- y: number
152
- }
153
-
154
- let dots: Dot[] = []
155
- const mouse = { x: -9999, y: -9999 }
156
- let isAnimating = false
157
- let isPressed = false
158
- /**
159
- * The loop below keeps requesting frames while the pointer is anywhere on
160
- * the page, which on a hidden tab is pure waste — and this console is the
161
- * kind of page that lives in a background tab for hours. Ported from the
162
- * example app's copy of this effect, which had the throttle where this one
163
- * did not; the two files are separate copies on purpose (a plugin's client
164
- * bundle and an app's script cannot share a module), so a fix to one is
165
- * worth checking against the other.
166
- */
167
- let isTabVisible = !document.hidden
168
-
169
- function initDots() {
170
- dots = []
171
- const cols = Math.ceil(canvas.width / DOT_SPACING) + 1
172
- const rows = Math.ceil(canvas.height / DOT_SPACING) + 1
173
-
174
- for (let r = 0; r < rows; r++) {
175
- for (let c = 0; c < cols; c++) {
176
- const ox = c * DOT_SPACING
177
- const oy = r * DOT_SPACING
178
- dots.push({ ox, oy, x: ox, y: oy })
179
- }
180
- }
181
- }
182
-
183
- function resize() {
184
- canvas.width = window.innerWidth
185
- canvas.height = window.innerHeight
186
- initDots()
187
- if (!window.matchMedia('(hover: hover) and (pointer: fine)').matches) {
188
- draw()
189
- } else {
190
- if (!isAnimating) {
191
- isAnimating = true
192
- requestAnimationFrame(draw)
193
- }
194
- }
195
- }
196
-
197
- interface DotTarget {
198
- x: number
199
- y: number
200
- alpha: number
201
- radius: number
202
- }
203
-
204
- function computeDotTarget(dot: Dot): DotTarget {
205
- const dx = mouse.x - dot.ox
206
- const dy = mouse.y - dot.oy
207
- const dist = Math.sqrt(dx * dx + dy * dy)
208
-
209
- let targetX = dot.ox
210
- let targetY = dot.oy
211
- let alpha = 0.07
212
- let radius = DOT_RADIUS
213
-
214
- if (dist < GLOW_RADIUS) {
215
- const t = 1 - dist / GLOW_RADIUS
216
- alpha = 0.07 + t * 0.35
217
- radius = DOT_RADIUS + t * 0.9
218
-
219
- if (dist > 0.01) {
220
- const force = t * (isPressed ? 32 : 18)
221
- if (isPressed) {
222
- targetX = dot.ox + (dx / dist) * force
223
- targetY = dot.oy + (dy / dist) * force
224
- } else {
225
- targetX = dot.ox - (dx / dist) * force
226
- targetY = dot.oy - (dy / dist) * force
227
- }
228
- }
229
- }
230
-
231
- return { x: targetX, y: targetY, alpha, radius }
232
- }
233
-
234
- function moveDot(dot: Dot, target: DotTarget): boolean {
235
- const dx = target.x - dot.x
236
- const dy = target.y - dot.y
237
- if (Math.abs(dx) > 0.01 || Math.abs(dy) > 0.01) {
238
- dot.x += dx * 0.15
239
- dot.y += dy * 0.15
240
- return true
241
- }
242
- dot.x = target.x
243
- dot.y = target.y
244
- return false
245
- }
246
-
247
- function draw() {
248
- ctx.clearRect(0, 0, canvas.width, canvas.height)
249
-
250
- let needsMoreFrames = false
251
-
252
- for (let i = 0; i < dots.length; i++) {
253
- const dot = dots[i]
254
- const target = computeDotTarget(dot)
255
- if (moveDot(dot, target)) needsMoreFrames = true
256
-
257
- ctx.beginPath()
258
- ctx.arc(dot.x, dot.y, target.radius, 0, Math.PI * 2)
259
- ctx.fillStyle = `rgba(255, 255, 255, ${target.alpha})`
260
- ctx.fill()
261
- }
262
-
263
- if (
264
- window.matchMedia('(hover: hover) and (pointer: fine)').matches &&
265
- isTabVisible
266
- ) {
267
- if (mouse.x !== -9999 || needsMoreFrames) {
268
- requestAnimationFrame(draw)
269
- } else {
270
- isAnimating = false
271
- }
272
- } else {
273
- // Hidden tab: stop the loop and let visibilitychange restart it, or the
274
- // flag would stay true and the resume below would never fire.
275
- isAnimating = false
276
- }
277
- }
278
-
279
- if (window.matchMedia('(hover: hover) and (pointer: fine)').matches) {
280
- window.addEventListener('pointermove', e => {
281
- mouse.x = e.clientX
282
- mouse.y = e.clientY
283
- if (!isAnimating) {
284
- isAnimating = true
285
- requestAnimationFrame(draw)
286
- }
287
- })
288
-
289
- window.addEventListener('mouseleave', () => {
290
- mouse.x = -9999
291
- mouse.y = -9999
292
- isPressed = false
293
- if (!isAnimating) {
294
- isAnimating = true
295
- requestAnimationFrame(draw)
296
- }
297
- })
298
-
299
- window.addEventListener('pointerdown', () => {
300
- isPressed = true
301
- if (!isAnimating) {
302
- isAnimating = true
303
- requestAnimationFrame(draw)
304
- }
305
- })
306
-
307
- window.addEventListener('pointerup', () => {
308
- isPressed = false
309
- if (!isAnimating) {
310
- isAnimating = true
311
- requestAnimationFrame(draw)
312
- }
313
- })
314
- }
315
-
316
- document.addEventListener('visibilitychange', () => {
317
- isTabVisible = !document.hidden
318
- if (isTabVisible && !isAnimating) {
319
- isAnimating = true
320
- requestAnimationFrame(draw)
321
- }
322
- })
323
-
324
- window.addEventListener('resize', resize)
325
- resize()
326
- draw()
327
- })()