@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/README.md +6 -6
- package/package.json +7 -5
- package/src/client/dashboard.ts +3 -10
- package/src/client/parts/logs.ts +19 -13
- package/src/client/parts/metrics.ts +251 -0
- package/src/client/parts/sessions.ts +26 -124
- package/src/client/parts/sparkline-tooltip.ts +158 -0
- package/src/client/parts/sparkline.ts +383 -0
- package/src/client/parts/stats.ts +33 -758
- package/src/client/parts/utils.ts +22 -68
- package/src/components/DBBrowser.tsx +1 -3
- package/src/components/LogsPanel.tsx +0 -2
- package/src/components/SessionsPanel.tsx +0 -3
- package/src/components/StatsPanel.tsx +0 -19
- package/src/components/TopPagesPanel.tsx +0 -1
- package/src/endpoints/logs-socket.ts +72 -0
- package/src/endpoints/sessions.ts +1 -1
- package/src/index.ts +6 -6
- package/src/paths.ts +1 -1
- package/src/setup.ts +104 -18
- package/src/shell.tsx +51 -30
- package/src/test-fixtures.ts +1 -1
- package/src/client/parts/effects.ts +0 -327
|
@@ -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
|
|
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
|
|
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(--
|
|
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
|
|
49
|
-
* parser silently drops
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
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">
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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()
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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`
|
|
252
|
-
* all three
|
|
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"
|
|
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
|