@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.
@@ -4,7 +4,7 @@
4
4
  * The panels were built independently and each grew its own copy of the same
5
5
  * four things: an element-text setter, a `results-empty` block, a
6
6
  * fetch/`.json()` pair, and a prev/next pager. They are declared once here
7
- * because this file is compiled into the shipped bundle — a duplicated string
7
+ * because this file is compiled into the shipped bundle: a duplicated string
8
8
  * literal is duplicated bytes, the minifier does not merge them.
9
9
  *
10
10
  * Shrank when the database editor was retired: `executeAction`, `getJson`,
@@ -26,12 +26,12 @@ export function setText(id: string, value: string) {
26
26
  * caller happened to pass a literal or escape first, which is exactly the state
27
27
  * a codebase is in right before it stops being true. Every XSS found in this
28
28
  * repo came from hand-built DOM strings in these panel files, including a
29
- * stored-XSS to arbitrary-SQL chain through the database grid — which is gone
29
+ * stored-XSS to arbitrary-SQL chain through the database grid, which is gone
30
30
  * now, but the escaping default it argued for is not, because the sessions and
31
31
  * stats panels build markup the same way from data they did not write.
32
32
  */
33
33
  export function emptyBox(message: string, isError = false): string {
34
- const style = isError ? ' style="color: var(--accent-red);"' : ''
34
+ const style = isError ? ' style="color: var(--danger);"' : ''
35
35
  return `<div class="results-empty"${style}><span>${escapeHTML(message)}</span></div>`
36
36
  }
37
37
 
@@ -45,8 +45,8 @@ export function setEmpty(
45
45
 
46
46
  /**
47
47
  * Inline SVGs, built from the path data alone. Every hand-written copy in the
48
- * panels carried `width="1em" height="1em"` twice — a duplicate attribute the
49
- * parser silently drops — and the same ~380-byte markup was pasted once per
48
+ * panels carried `width="1em" height="1em"` twice (a duplicate attribute the
49
+ * parser silently drops), and the same ~380-byte markup was pasted once per
50
50
  * use site.
51
51
  */
52
52
  export function icon(d: string, size: string): string {
@@ -57,8 +57,9 @@ export function icon(d: string, size: string): string {
57
57
  )
58
58
  }
59
59
 
60
- export const ICON_EDIT =
61
- 'M5 19h1.425L16.2 9.225L14.775 7.8L5 17.575zm-2 2v-4.25L16.2 3.575q.3-.275.663-.425t.762-.15t.775.15t.65.45L20.425 5q.3.275.438.65T21 6.4q0 .4-.137.763t-.438.662L7.25 21zM19 6.4L17.6 5zm-3.525 2.125l-.7-.725L16.2 9.225z'
60
+ // `ICON_EDIT` was here. It drew the pencil on the session key editor's Edit
61
+ // button, and that button went with the editor it could not open. See the
62
+ // note in `sessions.ts`. The path data has no other caller.
62
63
  export const ICON_DELETE =
63
64
  'M7 21q-.825 0-1.412-.587T5 19V6H4V4h5V3h6v1h5v2h-1v13q0 .825-.587 1.413T17 21zM17 6H7v13h10zM9 17h2V8H9zm4 0h2V8h-2zM7 6v13z'
64
65
  /** POST a JSON body to a dashboard endpoint and unwrap the envelope. */
@@ -84,67 +85,20 @@ export function setPager(
84
85
  if (nextBtn) nextBtn.disabled = page >= totalPages
85
86
  }
86
87
 
87
- export class SegmentedProgress {
88
- private container: HTMLElement
89
- private percent: number
90
- private barWidth: number
91
- private barGap: number
92
- private resizeObserver: ResizeObserver | null = null
93
-
94
- constructor(
95
- container: HTMLElement,
96
- percent: number,
97
- barWidth = 4,
98
- barGap = 6,
99
- ) {
100
- this.container = container
101
- this.percent = percent
102
- this.barWidth = barWidth
103
- this.barGap = barGap
104
- this.init()
105
- }
106
-
107
- private init() {
108
- this.container.classList.add('segmented-progress-container')
109
- if (this.barGap !== 6) {
110
- this.container.style.gap = `${this.barGap}px`
111
- }
112
-
113
- if (typeof ResizeObserver !== 'undefined') {
114
- this.resizeObserver = new ResizeObserver(() => this.draw())
115
- this.resizeObserver.observe(this.container)
116
- }
117
-
118
- this.draw()
119
- }
120
-
121
- public destroy() {
122
- if (this.resizeObserver) {
123
- this.resizeObserver.disconnect()
124
- }
125
- }
126
-
127
- public draw() {
128
- const containerWidth = this.container.clientWidth
129
- if (containerWidth === 0) return
130
-
131
- const count = Math.floor(
132
- (containerWidth + this.barGap) / (this.barWidth + this.barGap),
133
- )
134
- const activeCount = Math.round((this.percent / 100) * count)
135
-
136
- let html = ''
137
- for (let i = 0; i < count; i++) {
138
- const className = i < activeCount ? 'active' : 'inactive'
139
- let styleAttr = ''
140
- if (this.barWidth !== 4) {
141
- styleAttr = ` style="width: ${this.barWidth}px;"`
142
- }
143
- html += `<div class="segmented-bar-segment ${className}"${styleAttr}></div>`
144
- }
145
- this.container.innerHTML = html
146
- }
147
- }
88
+ /*
89
+ * `SegmentedProgress` was here, and nothing it drew was ever visible.
90
+ *
91
+ * It filled a `.segmented-progress-container` with one `div` per 10px of
92
+ * width and watched the container with a `ResizeObserver`. Neither that
93
+ * class nor `.segmented-bar-segment` has a rule in the sheet - grep it - so
94
+ * the container is a bare block with no height and the segments are bare
95
+ * blocks with no width. Measured against a running console on the Traffic
96
+ * panel: 10 containers, 98 children each, every one of them 0px tall.
97
+ *
98
+ * So it cost 980 nodes and 10 `ResizeObserver`s per render of that panel,
99
+ * redrawn on every resize, to show nothing. The list it sat under - path and
100
+ * hit count - works and stays.
101
+ */
148
102
 
149
103
  export function getWebSocketUrl(path: string) {
150
104
  const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
@@ -9,7 +9,7 @@
9
9
  * shipping a second, weaker copy.
10
10
  *
11
11
  * Rendered **only when nothing serves `/_db`**. With the explorer mounted the
12
- * nav entry links straight to it and this panel never appears — a tab whose
12
+ * nav entry links straight to it and this panel never appears: a tab whose
13
13
  * whole content is "go there" is a click of ceremony in front of going there.
14
14
  * Without it, this is what explains where the editor went.
15
15
  *
@@ -24,7 +24,6 @@ export function renderDatabaseBrowser() {
24
24
  <div class="browser-header">
25
25
  <div class="table-info">
26
26
  <h2>
27
- <iconify-icon icon="lucide:database"></iconify-icon>
28
27
  <span>Database</span>
29
28
  </h2>
30
29
  </div>
@@ -43,7 +42,6 @@ export function renderDatabaseBrowser() {
43
42
  </p>
44
43
  <div class="modal-actions">
45
44
  <a class="btn btn-primary" href="/_db">
46
- <iconify-icon icon="lucide:external-link"></iconify-icon>
47
45
  <span>Open Database Explorer</span>
48
46
  </a>
49
47
  </div>
@@ -10,14 +10,12 @@ export function renderLogsPanel() {
10
10
  class="btn btn-secondary"
11
11
  onclick="toggleLogsPlay()"
12
12
  id="btn-logs-play">
13
- <iconify-icon icon="lucide:pause"></iconify-icon>
14
13
  <span>Pause</span>
15
14
  </button>
16
15
  <button
17
16
  type="button"
18
17
  class="btn btn-secondary btn-danger"
19
18
  onclick="clearLogs()">
20
- <iconify-icon icon="lucide:trash-2"></iconify-icon>
21
19
  <span>Clear Logs</span>
22
20
  </button>
23
21
  <label>
@@ -13,7 +13,6 @@ export function renderSessionsPanel() {
13
13
  type="button"
14
14
  class="btn btn-secondary"
15
15
  onclick="loadSessions()">
16
- <iconify-icon icon="lucide:refresh-cw"></iconify-icon>
17
16
  <span>Refresh Sessions</span>
18
17
  </button>
19
18
  </div>
@@ -62,7 +61,6 @@ export function renderSessionsPanel() {
62
61
  id="session-page-prev"
63
62
  class="btn btn-secondary"
64
63
  onclick="prevSessionPage()">
65
- <iconify-icon icon="lucide:chevron-left"></iconify-icon>
66
64
  <span>Prev</span>
67
65
  </button>
68
66
  <span id="session-page-info">Page 1 of 1</span>
@@ -72,7 +70,6 @@ export function renderSessionsPanel() {
72
70
  class="btn btn-secondary"
73
71
  onclick="nextSessionPage()">
74
72
  <span>Next</span>
75
- <iconify-icon icon="lucide:chevron-right"></iconify-icon>
76
73
  </button>
77
74
  </div>
78
75
  <div class="rows-meta">
@@ -54,7 +54,6 @@ export function renderStatsPanel() {
54
54
 
55
55
  <div class="section-header">
56
56
  <h2>
57
- <iconify-icon icon="lucide:area-chart"></iconify-icon>
58
57
  <span>Performance History</span>
59
58
  </h2>
60
59
  <div class="timescale-selector">
@@ -223,24 +222,6 @@ export function renderStatsPanel() {
223
222
  </span>
224
223
  </div>
225
224
  </div>
226
- <div class="chart-card glass-effect" id="chart-db-hits">
227
- <span class="card-title">DB Hits History</span>
228
- <span class="card-sub">
229
- Database query executions (last 1 min, 1s resolution)
230
- </span>
231
- <canvas id="canvas-db-hits" class="big-chart"></canvas>
232
- <div class="chart-stats">
233
- <span>
234
- MIN: <strong id="dbHits-min">-</strong>
235
- </span>
236
- <span>
237
- MAX: <strong id="dbHits-max">-</strong>
238
- </span>
239
- <span>
240
- AVG: <strong id="dbHits-avg">-</strong>
241
- </span>
242
- </div>
243
- </div>
244
225
  <div class="chart-card glass-effect" id="chart-error-page-hits">
245
226
  <span class="card-title">Error Page Hits History</span>
246
227
  <span class="card-sub">
@@ -3,7 +3,6 @@ export function renderTopPagesPanel() {
3
3
  <div id="panel-top-pages" class="panel">
4
4
  <div class="section-header">
5
5
  <h2>
6
- <iconify-icon icon="lucide:file-text"></iconify-icon>
7
6
  <span>Top Visited Pages</span>
8
7
  </h2>
9
8
  <div class="timescale-selector">
@@ -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
+ }
@@ -46,7 +46,7 @@ export async function handleUpdateSession(
46
46
  return response.json.error(400, 'Invalid payload')
47
47
 
48
48
  // Without this, editing a session could set the dashboard's own auth marker
49
- // on any user's session — a permanent backdoor.
49
+ // on any user's session: a permanent backdoor.
50
50
  if (isReservedSessionKey(key)) {
51
51
  return response.json.error(403, 'Reserved session key')
52
52
  }
package/src/index.ts CHANGED
@@ -3,7 +3,7 @@ import type { AuthorizeFn } from '@bakery-framework/core/utils/http'
3
3
 
4
4
  /**
5
5
  * Re-exported so the plugin's public surface is unchanged by the guard moving
6
- * into core. It is the same type either way — an app that imported it from here
6
+ * into core. It is the same type either way: an app that imported it from here
7
7
  * keeps working, and one that reaches for `@bakery-framework/core/utils/http`
8
8
  * directly gets the identical declaration rather than a structural twin.
9
9
  */
@@ -12,7 +12,7 @@ export type { AuthorizeFn } from '@bakery-framework/core/utils/http'
12
12
  export interface DashboardPluginOptions {
13
13
  /**
14
14
  * Register the dashboard. Defaults to true. Set to false to keep it out of a
15
- * build entirely — the documented way to disable it in production.
15
+ * build entirely: the documented way to disable it in production.
16
16
  */
17
17
  enabled?: boolean
18
18
 
@@ -32,7 +32,7 @@ export interface DashboardPluginOptions {
32
32
  * production, so an unconfigured console is never exposed.
33
33
  *
34
34
  * Handed straight to `@bakery-framework/plugin-analytics`, which owns the
35
- * door for both surfaces — so this predicate also admits to
35
+ * door for both surfaces, so this predicate also admits to
36
36
  * `/api/_analytics/stats` and the `/_analytics_ws` socket the console reads.
37
37
  */
38
38
  authorize?: AuthorizeFn
@@ -46,13 +46,13 @@ export interface DashboardPluginOptions {
46
46
  *
47
47
  * Presented as `Authorization: Bearer`, an `x-analytics-key` header, or an
48
48
  * `?analytics-key=` query. Checked in constant time. Unset or empty means
49
- * this path is off — it never means open. Composes with `authorize`: either
49
+ * this path is off: it never means open. Composes with `authorize`: either
50
50
  * admits.
51
51
  *
52
52
  * It is the *analytics* key, not a second one: the console delegates its
53
53
  * authorization to `@bakery-framework/plugin-analytics`, so configuring it
54
54
  * here and configuring it on `analyticsPlugin` are the same act. Set it on
55
- * either plugin — a bare call never clears what the other one set.
55
+ * either plugin: a bare call never clears what the other one set.
56
56
  */
57
57
  credential?: string
58
58
  }
@@ -64,7 +64,7 @@ export interface DashboardPluginOptions {
64
64
  * companion: the console renders analytics, its client calls
65
65
  * `/api/_analytics/reset` and opens `/_analytics_ws`, and registering the
66
66
  * dashboard brings analytics' handlers up so those endpoints exist. It follows
67
- * that they share one door rather than two — see `authorize` and `credential`
67
+ * that they share one door rather than two. See `authorize` and `credential`
68
68
  * above.
69
69
  */
70
70
  export default function dashboardPlugin(options: DashboardPluginOptions = {}) {
package/src/paths.ts CHANGED
@@ -3,7 +3,7 @@ import { fs } from '@bakery-framework/core/utils'
3
3
  /**
4
4
  * Where this plugin's own files live.
5
5
  *
6
- * Not `frameworkPath()` — that anchors to `@bakery-framework/core`, so once the dashboard
6
+ * Not `frameworkPath()`: that anchors to `@bakery-framework/core`, so once the dashboard
7
7
  * became its own package every asset lookup pointed into core and 404'd. Each
8
8
  * package that ships files needs its own anchor, derived from its own location.
9
9
  */
package/src/setup.ts CHANGED
@@ -2,7 +2,7 @@ import { bundleModule } from '@bakery-framework/core/compiler'
2
2
  import { Bakery } from '@bakery-framework/core/core/bakery'
3
3
  import { isDevWorker } from '@bakery-framework/core/core/init'
4
4
  import { Handler, mountRoutes } from '@bakery-framework/core/handlers'
5
- // LiveReloadHandler owns this registry — it adds and removes sockets from it.
5
+ // LiveReloadHandler owns this registry: it adds and removes sockets from it.
6
6
  import {
7
7
  connectedLoggers,
8
8
  errorMsg,
@@ -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
 
@@ -42,7 +43,7 @@ let cachedDashboardJsPath: string | null = null
42
43
  * `/_dashboard` and `/api/_dashboard` are namespace *roots*, not prefixes.
43
44
  *
44
45
  * A bare `startsWith` also matches `/api/_dashboard-anything`, and this handler
45
- * outranks every content handler at priority 120 — so the prefix form silently
46
+ * outranks every content handler at priority 120, so the prefix form silently
46
47
  * claimed, and then 404'd, any application route whose path merely began with
47
48
  * those characters. Match the root itself or a segment below it, nothing else.
48
49
  */
@@ -57,9 +58,11 @@ function isDashboardPath(path: string): boolean {
57
58
  }
58
59
 
59
60
  export class DashboardHandler extends Handler {
61
+ static override namespace = '/_dashboard'
62
+
60
63
  static canHandle(path: string) {
61
64
  // Deliberately narrow. This handler outranks every content handler, so
62
- // anything it claims can never reach the route mount below — assets must
65
+ // anything it claims can never reach the route mount below: assets must
63
66
  // fall through.
64
67
  if (inNamespace(path, '/api/_dashboard')) return true
65
68
  return path === '/_dashboard' || path === '/_dashboard/dashboard.js'
@@ -82,7 +85,7 @@ export class DashboardHandler extends Handler {
82
85
  }
83
86
 
84
87
  /**
85
- * The console holds no authorization state of its own — there is no
88
+ * The console holds no authorization state of its own: there is no
86
89
  * `__setTestAuthorize` seam here any more, because there is nothing local to
87
90
  * seam. Both options land in analytics, and a test drives the door with
88
91
  * `setAnalyticsAuthorize` / `setAnalyticsCredential` from
@@ -94,7 +97,7 @@ export class DashboardHandler extends Handler {
94
97
  * the console keeps its documented default of loopback-in-development,
95
98
  * because `bun create bakery` scaffolds a bare `dashboardPlugin()` and a
96
99
  * console that 404s on the first `bun run dev` is the wrong first impression.
97
- * Forwarding `defaultAuthorize` can only ever narrow — it denies in
100
+ * Forwarding `defaultAuthorize` can only ever narrow: it denies in
98
101
  * production and admits nothing but this machine in development.
99
102
  */
100
103
  export function setupDashboard(
@@ -124,9 +127,15 @@ export function setupDashboard(
124
127
  })
125
128
 
126
129
  // The stylesheet is served by the normal static pipeline from this
127
- // plugin's own directory — no bespoke asset route, no hand-rolled caching.
130
+ // plugin's own directory: no bespoke asset route, no hand-rolled caching.
128
131
  mountRoutes('/_dashboard', pluginPath('../public'))
129
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
+
130
139
  Bakery.handlers.fetch.set(DashboardHandler, 120)
131
140
  }
132
141
 
@@ -137,10 +146,74 @@ async function handleDashboardView() {
137
146
  return injected ? injected : response.html(htmlContent)
138
147
  }
139
148
 
140
- async function handleJsAsset() {
149
+ /**
150
+ * Why the bundle cannot run in a `<script>`, or `null`.
151
+ *
152
+ * `bundleModule` marks every *installed* package external, so a bare specifier
153
+ * survives into the output and resolves through the page's import map. That is
154
+ * correct for the `/_nm/` bundles, which are loaded as modules. It is fatal
155
+ * here: this file is served as a classic `<script>`, an import map does not
156
+ * apply to one, and a top-level `import` is a syntax error, so the *entire*
157
+ * bundle fails to execute and the console does nothing at all.
158
+ *
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.
176
+ *
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.
180
+ */
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
+ }
188
+ }
189
+
190
+ /**
191
+ * Test seams (convention 9). The cached branch is **production-only** -
192
+ * `isDevWorker` skips it, and in development the bundle is returned as a
193
+ * string, which `response.type` does give an ETag. That is why the defect was
194
+ * invisible to a dev server and had to be reproduced under `bun run serve`,
195
+ * and why a test has to reach the branch directly rather than through a mode.
196
+ */
197
+ export function __setCachedDashboardJs(path: string | null): void {
198
+ cachedDashboardJsPath = path
199
+ }
200
+
201
+ export async function handleJsAsset() {
141
202
  if (cachedDashboardJsPath && !isDevWorker) {
142
203
  const cachedFile = Bun.file(cachedDashboardJsPath)
143
- if (cachedFile.size > 0) return response.type(cachedFile, 'text/javascript')
204
+ // **The file, not a `Response` wrapping it.** `processResponse` sends a
205
+ // `Blob` through `ETag.sendFile`, which computes an ETag from the file,
206
+ // negotiates a precompressed variant and answers a conditional request
207
+ // with a 304. A `Response` skips all of that: `ETag.sendResponse` returns
208
+ // early when there is no ETag header already set, so it gains neither an
209
+ // ETag nor a `Cache-Control`.
210
+ //
211
+ // The first request took this path correctly - it returns the written
212
+ // file below - and every request after it did not, so the console
213
+ // re-downloaded 30.6 KB on every load. The content type comes from the
214
+ // `.js` extension on the cached path, which is what the first request
215
+ // already relied on.
216
+ if (cachedFile.size > 0) return cachedFile
144
217
  }
145
218
 
146
219
  const bundleResult = await bundleModule(pluginPath('client/dashboard.ts'))
@@ -155,6 +228,19 @@ async function handleJsAsset() {
155
228
  )
156
229
  }
157
230
 
231
+ // See `whyUnrunnable`. A build that "succeeded" and cannot run is worse than
232
+ // one that failed, because it is silent.
233
+ const unrunnable = whyUnrunnable(bundleResult.content as string)
234
+ if (unrunnable) {
235
+ pluginLog.DASHBOARD_BUNDLE_ERR({
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.`,
237
+ })
238
+ return response.error(
239
+ `dashboard.js cannot run as a classic script: ${unrunnable}`,
240
+ 500,
241
+ )
242
+ }
243
+
158
244
  if (!isDevWorker) {
159
245
  const tmpPath = `${Bakery.cacheDir}/_dashboard.js`
160
246
  await Bun.write(tmpPath, bundleResult.content)
@@ -171,7 +257,7 @@ async function handleJsAsset() {
171
257
  /**
172
258
  * One door, and it is analytics'. `isAnalyticsAuthorized` reads the same
173
259
  * credential and the same predicate that gate `/api/_analytics/stats` and the
174
- * `/_analytics_ws` socket — which the console's own client calls, so a console
260
+ * `/_analytics_ws` socket, which the console's own client calls, so a console
175
261
  * admitted by a different key than the data it renders would be half-open by
176
262
  * construction.
177
263
  *
@@ -184,7 +270,7 @@ async function handleJsAsset() {
184
270
  */
185
271
  async function checkAuthMiddleware(req: Request, path: string) {
186
272
  // Styling and script for the console are not secrets, and letting them
187
- // through keeps an unauthorised response from rendering unstyled.
273
+ // through keeps an unauthorized response from rendering unstyled.
188
274
  if (/\.(css|js)$/.test(path)) return null
189
275
 
190
276
  if (await isAnalyticsAuthorized(req)) return null
@@ -195,7 +281,7 @@ async function checkAuthMiddleware(req: Request, path: string) {
195
281
  }
196
282
 
197
283
  /**
198
- * `checkCsrf` has exactly one other call site — inside `ApiHandler`, at
284
+ * `checkCsrf` has exactly one other call site: inside `ApiHandler`, at
199
285
  * priority 70. This handler sits at 120 and claims the whole `/api/_dashboard`
200
286
  * namespace, so `ApiHandler.handle` never ran for these paths and the check
201
287
  * never happened: a cross-origin `<form method=post>` arrived with the
@@ -208,7 +294,7 @@ async function checkAuthMiddleware(req: Request, path: string) {
208
294
  * that reason; neither half closes the hole alone.
209
295
  *
210
296
  * The example used to be `execute-action?action=truncate`, which was the worst
211
- * of them — a link that emptied a table. That endpoint is gone with the grid
297
+ * of them: a link that emptied a table. That endpoint is gone with the grid
212
298
  * editor, and the two session routes are what is left to protect. The hazard
213
299
  * is unchanged; only the blast radius shrank.
214
300
  */
@@ -222,11 +308,11 @@ function checkCsrfMiddleware(req: Request, url: URL): DashboardResponse | null {
222
308
  }
223
309
 
224
310
  /**
225
- * Authenticated routes only — the logout/login cases below run before the auth
311
+ * Authenticated routes only: the logout/login cases below run before the auth
226
312
  * check and are deliberately kept out of this table.
227
313
  *
228
314
  * Every mutating endpoint is method-qualified. A bare key matches **any**
229
- * method, which made each of these reachable by GET — and a GET is a safe
315
+ * method, which made each of these reachable by GET, and a GET is a safe
230
316
  * method as far as `checkCsrf` is concerned, so the CSRF guard above cannot
231
317
  * see it. `POST /api/_analytics/reset` is the same shape.
232
318
  *
@@ -248,8 +334,8 @@ const dispatchDashboardRoute = routeTable(dashboardRoutes)
248
334
  * Three genuinely different shapes, so a union rather than a single type: the
249
335
  * shell is a `Response`, `dashboard.js` is served straight off disk as a
250
336
  * `Bun.BunFile` once it has been bundled and cached, and every `/api/` endpoint
251
- * answers with the JSON envelope. `processResponse` in `router.ts` serialises
252
- * all three — `Response` passes through, a `Blob` goes to `ETag.sendFile`, and
337
+ * answers with the JSON envelope. `processResponse` in `router.ts` serializes
338
+ * all three: `Response` passes through, a `Blob` goes to `ETag.sendFile`, and
253
339
  * a `JsonResponseData` is stamped with the elapsed time and JSON-encoded.
254
340
  */
255
341
  export type DashboardResponse =
@@ -278,7 +364,7 @@ export async function handleDashboardRequest(
278
364
  if (result) return result
279
365
 
280
366
  // `dispatch` answers `null` for a path no key matched, and a handler
281
- // returning `null` means "not mine" — which core turns into **204 No
367
+ // returning `null` means "not mine", which core turns into **204 No
282
368
  // Content**. Every unmatched request under this namespace therefore answered
283
369
  // 204: a status that reads as success and carries nothing.
284
370
  //
@@ -290,7 +376,7 @@ export async function handleDashboardRequest(
290
376
  // **Only under `/api/`, and that boundary is load-bearing.** The stylesheet
291
377
  // is served by `mountRoutes`, not by a key in the table above, so `null` is
292
378
  // precisely how `/_dashboard/style.css` *falls through* to the static
293
- // pipeline. A blanket 404 here claims it and the console loads unstyled —
379
+ // pipeline. A blanket 404 here claims it and the console loads unstyled,
294
380
  // which is what happened when this was first written without the condition.
295
381
  if (path.startsWith('/api/')) return response.error('Not Found', 404)
296
382
  return null