@bakery-framework/plugin-dashboard 2.0.0-alpha.14 → 2.0.0-alpha.16

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bakery-framework/plugin-dashboard",
3
- "version": "2.0.0-alpha.14",
3
+ "version": "2.0.0-alpha.16",
4
4
  "description": "Bakery dashboard plugin.",
5
5
  "keywords": [
6
6
  "bakery",
@@ -33,11 +33,11 @@
33
33
  "!src/tests"
34
34
  ],
35
35
  "dependencies": {
36
- "@bakery-framework/core": "^2.0.0-alpha.14",
37
- "@bakery-framework/plugin-analytics": "^2.0.0-alpha.14"
36
+ "@bakery-framework/core": "^2.0.0-alpha.16",
37
+ "@bakery-framework/plugin-analytics": "^2.0.0-alpha.16"
38
38
  },
39
39
  "devDependencies": {
40
- "@bakery-framework/orm": "^2.0.0-alpha.14"
40
+ "@bakery-framework/orm": "^2.0.0-alpha.16"
41
41
  },
42
42
  "engines": {
43
43
  "bun": ">=1.4.0"
@@ -77,12 +77,19 @@ export function initLogsWebSocket() {
77
77
  '<div style="color: var(--text-muted);">Connecting to server log stream...</div>'
78
78
 
79
79
  try {
80
- logsWs = new WebSocket(getWebSocketUrl('/_livereload'))
80
+ // `/_dashboard/logs`, not `/_livereload`. The live-reload socket is
81
+ // registered only under `DEV`, so this panel answered 400 on every
82
+ // production server and sat on "Connecting..." for ever. The console has
83
+ // its own socket now, behind the console's own door.
84
+ logsWs = new WebSocket(getWebSocketUrl('/_dashboard/logs'))
81
85
 
82
86
  logsWs.onopen = () => {
83
87
  consoleEl.innerHTML =
84
88
  '<div style="color: var(--ok); display: flex; align-items: center; gap: 0.25rem;"><span>Connected to logs pipeline. Listening for events...</span></div>'
85
- logsWs?.send(JSON.stringify({ type: 'subscribe_logger' }))
89
+ // No `subscribe_logger` frame: membership is the handler's `open`, so
90
+ // there is nothing to ask for. That message was `LiveReloadHandler`'s
91
+ // protocol, and it is what an app page still sends to forward its own
92
+ // console in development.
86
93
  }
87
94
 
88
95
  logsWs.onmessage = event => {
@@ -0,0 +1,72 @@
1
+ import { WebSocketHandler } from '@bakery-framework/core/handlers'
2
+ import { connectedLoggers } from '@bakery-framework/core/logger'
3
+ import { isAnalyticsAuthorized } from '@bakery-framework/plugin-analytics/stats'
4
+ import type { ServerWebSocket } from 'bun'
5
+
6
+ /**
7
+ * The console's log stream.
8
+ *
9
+ * **The Logs panel was dead in production**, and only in production. Its client
10
+ * connected to `/_livereload`, which `LiveReloadHandler` serves — and that
11
+ * handler is registered only under `DEV` and refuses in `canHandle` besides.
12
+ * So a production server answered 400 on the upgrade, nothing ever joined
13
+ * `connectedLoggers`, and the panel sat on "Connecting to server log
14
+ * stream..." for the life of the process. Measured against `bun run serve`
15
+ * before this existed: `/_livereload` → 400, console → 200.
16
+ *
17
+ * Same shape as the bundle ETag bug and the timescale import before it: a dev
18
+ * server takes a different branch, so the thing works on the machine where it
19
+ * is written and nowhere else.
20
+ *
21
+ * **Not fixed by registering `LiveReloadHandler` in production.** That socket
22
+ * is the *watcher's*: it publishes `force_reload` to every subscriber and
23
+ * echoes `client_log` frames between browsers. Shipping it to production would
24
+ * be adding a surface, not repairing one. The console owns its own panel, so
25
+ * it owns its own socket, which is the same split analytics already has with
26
+ * `/_analytics_ws`.
27
+ *
28
+ * Membership is the only thing this does. Core owns the registry
29
+ * (`logger/clients.ts`) and the dashboard's `setLogCallback` does the
30
+ * broadcasting; in development `LiveReloadHandler` adds app pages to the same
31
+ * Set when they forward their console, which is why those lines show up here
32
+ * too.
33
+ */
34
+ export class DashboardLogsHandler extends WebSocketHandler {
35
+ static override readonly namespace = '/_dashboard'
36
+
37
+ /**
38
+ * **The door is checked here because there is nowhere later.**
39
+ *
40
+ * `router.ts` attempts the upgrade before `onRoute`, before `onRequest` and
41
+ * before any fetch handler resolves, so a plugin hook cannot gate a socket.
42
+ * `AnalyticsWSHandler` carries the same note for the same reason.
43
+ *
44
+ * It is the console's own door: `isAnalyticsAuthorized` is what guards
45
+ * `/_dashboard` itself, so a stream of every server log line is admitted by
46
+ * exactly what admits the page that renders it. Anything weaker would hand
47
+ * out log lines to someone who cannot open the console.
48
+ *
49
+ * Async because `authorize` may be a predicate the application supplies; the
50
+ * registry awaits a promise-returning `canHandle`.
51
+ *
52
+ * The path sits inside `/_dashboard`, which `DashboardHandler` claims for
53
+ * *fetch* at priority 120. That is not a collision: the upgrade is dispatched
54
+ * from a separate registry and runs first, so the fetch handler never sees
55
+ * this request.
56
+ */
57
+ static override async canHandle(
58
+ path: string,
59
+ req?: Request,
60
+ ): Promise<boolean> {
61
+ if (path !== '/_dashboard/logs') return false
62
+ return req ? await isAnalyticsAuthorized(req) : false
63
+ }
64
+
65
+ static override open(ws: ServerWebSocket<any>) {
66
+ connectedLoggers.add(ws)
67
+ }
68
+
69
+ static override close(ws: ServerWebSocket<any>) {
70
+ connectedLoggers.delete(ws)
71
+ }
72
+ }
package/src/setup.ts CHANGED
@@ -31,6 +31,7 @@ import {
31
31
  handleGetSessions,
32
32
  handleUpdateSession,
33
33
  } from './endpoints/sessions'
34
+ import { DashboardLogsHandler } from './endpoints/logs-socket'
34
35
  import { pluginPath } from './paths'
35
36
  import renderDashboardShell from './shell'
36
37
 
@@ -129,6 +130,12 @@ export function setupDashboard(
129
130
  // plugin's own directory — no bespoke asset route, no hand-rolled caching.
130
131
  mountRoutes('/_dashboard', pluginPath('../public'))
131
132
 
133
+ // The console's log stream, and it is registered in every mode. The Logs
134
+ // panel used to ride on `/_livereload`, which exists only under `DEV` — so
135
+ // the panel worked on the machine it was written on and answered 400
136
+ // everywhere else. See `endpoints/logs-socket.ts`.
137
+ Bakery.handlers.websocket.set(DashboardLogsHandler)
138
+
132
139
  Bakery.handlers.fetch.set(DashboardHandler, 120)
133
140
  }
134
141
 
@@ -140,7 +147,7 @@ async function handleDashboardView() {
140
147
  }
141
148
 
142
149
  /**
143
- * A bare `import` left in the bundle, or `null`.
150
+ * Why the bundle cannot run in a `<script>`, or `null`.
144
151
  *
145
152
  * `bundleModule` marks every *installed* package external, so a bare specifier
146
153
  * survives into the output and resolves through the page's import map. That is
@@ -149,19 +156,35 @@ async function handleDashboardView() {
149
156
  * apply to one, and a top-level `import` is a syntax error — so the *entire*
150
157
  * bundle fails to execute and the console does nothing at all.
151
158
  *
152
- * It is worth a check rather than a convention because of how it failed.
153
- * `Bun.build` reported success, the file was served with a 200 and the right
154
- * length, typecheck passed, and 2,376 tests passed because nothing
155
- * *requested the page*. That is the same gap CLAUDE.md already records for
156
- * `apps/starter`: a process that boots is not a page that works.
159
+ * **This asks the engine instead of matching a pattern, and the first version
160
+ * of it matched a pattern and was wrong.** It anchored on `^import`. In
161
+ * development the bundle is not minified and the import starts a line, so it
162
+ * looked right. Production minifies, the import lands mid-line, and the check
163
+ * that existed to catch exactly this missed it measured by reintroducing the
164
+ * bug and watching a production server answer 200 with the import present.
165
+ *
166
+ * Against seven shapes, the anchored regex was wrong on three: a minified bare
167
+ * import, a side-effect-only `import"./x.css"`, and a top-level `await`. A
168
+ * widened regex got the first two and still missed the third. `new Function`
169
+ * is wrong on none of them, because it is not approximating the question — a
170
+ * function body rejects a top-level `import` and a top-level `await` for the
171
+ * same reasons a classic script does. It also costs less than the widened
172
+ * regex: 13.0 us against 26.4 on a 24 KB bundle, and 12.0 for the broken one.
173
+ *
174
+ * It parses without executing, and only on the build path — the cached branch
175
+ * returns the file and never reaches here.
157
176
  *
158
- * Matched on a line starting with `import`, which is what an ESM bundle emits
159
- * for an external. A dynamic `import(...)` inside a function body is fine and
160
- * does not start a line.
177
+ * One known difference, and it is in the safe direction: a function body
178
+ * permits a top-level `return`, which a script does not. `Bun.build` does not
179
+ * emit one, and a false pass is a missed report rather than a broken page.
161
180
  */
162
- export function bareImportIn(bundle: string): string | null {
163
- const match = bundle.match(/^import\s[^\n]*?from\s*['"]([^'"]+)['"]/m)
164
- return match ? match[1]! : null
181
+ export function whyUnrunnable(bundle: string): string | null {
182
+ try {
183
+ new Function(bundle)
184
+ return null
185
+ } catch (error) {
186
+ return (error as Error).message
187
+ }
165
188
  }
166
189
 
167
190
  /**
@@ -205,15 +228,15 @@ export async function handleJsAsset() {
205
228
  )
206
229
  }
207
230
 
208
- // See `bareImportIn`. A build that "succeeded" and cannot run is worse than
231
+ // See `whyUnrunnable`. A build that "succeeded" and cannot run is worse than
209
232
  // one that failed, because it is silent.
210
- const bare = bareImportIn(bundleResult.content as string)
211
- if (bare) {
233
+ const unrunnable = whyUnrunnable(bundleResult.content as string)
234
+ if (unrunnable) {
212
235
  pluginLog.DASHBOARD_BUNDLE_ERR({
213
- error: `dashboard.js kept a bare import of "${bare}". It is served as a classic script, so an import map cannot resolve it and the whole bundle fails to parse. The console client must not import across a package boundary.`,
236
+ error: `dashboard.js cannot run as a classic script: ${unrunnable}. The usual cause is an import across a package boundary, which the bundler leaves as a bare specifier for the import map and an import map does not apply to a classic script.`,
214
237
  })
215
238
  return response.error(
216
- `dashboard.js kept a bare import of "${bare}"`,
239
+ `dashboard.js cannot run as a classic script: ${unrunnable}`,
217
240
  500,
218
241
  )
219
242
  }