beehaven 0.8.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/LICENSE +21 -0
- package/README.md +201 -0
- package/dist/__tests__/_cli-switch.js +105 -0
- package/dist/__tests__/agent-wrap-probe.js +136 -0
- package/dist/agent-attribution.js +192 -0
- package/dist/agent-identity-daemon.js +135 -0
- package/dist/agent-onboard.js +156 -0
- package/dist/agent-scope.js +79 -0
- package/dist/agent-sessions.js +370 -0
- package/dist/agent-state.js +482 -0
- package/dist/app-manifest.js +221 -0
- package/dist/app-signer.js +54 -0
- package/dist/app-signing.js +127 -0
- package/dist/audio-play.js +98 -0
- package/dist/avatar-protocol.js +25 -0
- package/dist/avatar-route.js +79 -0
- package/dist/bee/cloud-proxy.js +130 -0
- package/dist/blocked-actions.js +120 -0
- package/dist/build-channel.js +101 -0
- package/dist/buildings/daemon-workspace.js +475 -0
- package/dist/claude-bridge.js +310 -0
- package/dist/claude-credential-store.js +633 -0
- package/dist/clearly-sync.js +518 -0
- package/dist/clearly.js +616 -0
- package/dist/cli-state.js +334 -0
- package/dist/cli.js +6973 -0
- package/dist/commands/agent-skills.js +302 -0
- package/dist/commands/agent.js +1310 -0
- package/dist/commands/app.js +1123 -0
- package/dist/commands/auth.js +171 -0
- package/dist/commands/building-shell.js +392 -0
- package/dist/commands/canvas.js +932 -0
- package/dist/commands/claude.js +218 -0
- package/dist/commands/compliance.js +387 -0
- package/dist/commands/compose.js +507 -0
- package/dist/commands/composition.js +95 -0
- package/dist/commands/connect-session.js +409 -0
- package/dist/commands/conversations.js +160 -0
- package/dist/commands/costs.js +443 -0
- package/dist/commands/d1.js +253 -0
- package/dist/commands/daemon-cmd.js +162 -0
- package/dist/commands/deploy.js +164 -0
- package/dist/commands/design.js +617 -0
- package/dist/commands/dev-generate.js +125 -0
- package/dist/commands/dev.js +109 -0
- package/dist/commands/devices.js +135 -0
- package/dist/commands/explain-router.js +24 -0
- package/dist/commands/explain.js +882 -0
- package/dist/commands/explore.js +264 -0
- package/dist/commands/help.js +2418 -0
- package/dist/commands/init.js +408 -0
- package/dist/commands/install.js +129 -0
- package/dist/commands/library.js +229 -0
- package/dist/commands/marketplace.js +138 -0
- package/dist/commands/mcp-install.js +476 -0
- package/dist/commands/mention.js +257 -0
- package/dist/commands/omni.js +529 -0
- package/dist/commands/secrets.js +323 -0
- package/dist/commands/self-update.js +206 -0
- package/dist/commands/sheet.js +264 -0
- package/dist/commands/shell-output.js +309 -0
- package/dist/commands/shell.js +1048 -0
- package/dist/commands/shop.js +368 -0
- package/dist/commands/skills.js +311 -0
- package/dist/commands/slides.js +418 -0
- package/dist/commands/swarm.js +341 -0
- package/dist/commands/ticket.js +1301 -0
- package/dist/commands/tool.js +312 -0
- package/dist/commands/trash.js +106 -0
- package/dist/commands/treehouse.js +120 -0
- package/dist/commands/user.js +240 -0
- package/dist/commands/voice-tts.js +319 -0
- package/dist/commands/voice.js +166 -0
- package/dist/commands/watch-git.js +401 -0
- package/dist/commands/webhook.js +159 -0
- package/dist/commands/workspace.js +3354 -0
- package/dist/composition-repos.js +91 -0
- package/dist/daemon-avatar.js +130 -0
- package/dist/daemon-claude-code-auto-install.js +52 -0
- package/dist/daemon-client.js +363 -0
- package/dist/daemon-codex.js +349 -0
- package/dist/daemon-entry.js +101 -0
- package/dist/daemon-fetch-allowlist.js +194 -0
- package/dist/daemon-gemini.js +224 -0
- package/dist/daemon-mcp-clearly.js +353 -0
- package/dist/daemon-mcp-sandbox.js +127 -0
- package/dist/daemon-omni-company.js +338 -0
- package/dist/daemon-omni-hooks.js +375 -0
- package/dist/daemon-omni-identity.js +229 -0
- package/dist/daemon-omni-pipeline.js +251 -0
- package/dist/daemon-omni-policies.js +346 -0
- package/dist/daemon-omni-principal.js +580 -0
- package/dist/daemon-omni-tool-gate.js +116 -0
- package/dist/daemon-omni.js +1442 -0
- package/dist/daemon-path-transform.js +120 -0
- package/dist/daemon-write-sandbox.js +191 -0
- package/dist/daemon.js +4471 -0
- package/dist/dashboard.js +846 -0
- package/dist/dep-installer.js +286 -0
- package/dist/dev-mode.js +67 -0
- package/dist/doc-indexer.js +169 -0
- package/dist/e2e-crypto.js +94 -0
- package/dist/feature-flags.js +154 -0
- package/dist/file-browser.js +88 -0
- package/dist/first-party-hives.js +211 -0
- package/dist/folder-sync.js +226 -0
- package/dist/health.js +56 -0
- package/dist/ipc-auth.js +299 -0
- package/dist/ipc-client.js +495 -0
- package/dist/ipc-server.js +684 -0
- package/dist/keychain.js +224 -0
- package/dist/lib/admin-client.js +73 -0
- package/dist/lib/mobile-build-parser.js +156 -0
- package/dist/local-app-manager.js +660 -0
- package/dist/log-redaction.js +89 -0
- package/dist/logger.js +108 -0
- package/dist/manifest.js +143 -0
- package/dist/marketplace.js +793 -0
- package/dist/min-version-copy.js +51 -0
- package/dist/min-version.js +214 -0
- package/dist/public-surface.js +23 -0
- package/dist/relay-presets.js +89 -0
- package/dist/relay.js +2723 -0
- package/dist/repl.js +619 -0
- package/dist/setup-relay.js +363 -0
- package/dist/setup-service.js +536 -0
- package/dist/shared/bee-memory/index.js +6 -0
- package/dist/shared/bee-memory/types.js +682 -0
- package/dist/shared/claude-code/daemon-types.js +112 -0
- package/dist/shared/claude-code/index.js +2 -0
- package/dist/shared/claude-code/types.js +1 -0
- package/dist/shared/status/schema.js +133 -0
- package/dist/shell-session.js +198 -0
- package/dist/speak-lock.js +97 -0
- package/dist/spinner.js +57 -0
- package/dist/telemetry.js +166 -0
- package/dist/terminal/block-parser.js +207 -0
- package/dist/terminal/shell-integration.js +108 -0
- package/dist/transcript-tailer.js +215 -0
- package/dist/tts-qwen3.js +547 -0
- package/dist/types.js +4 -0
- package/dist/utm.js +38 -0
- package/dist/verb-surface.js +331 -0
- package/dist/version-utils.js +85 -0
- package/dist/watcher.js +207 -0
- package/package.json +88 -0
- package/python/qwen3_tts.py +230 -0
- package/scripts/stamp-build-channel.mjs +115 -0
- package/scripts/sync-installed.mjs +159 -0
- package/scripts/sync-skills.mjs +112 -0
- package/skills/README.md +24 -0
- package/skills/agent-doodle-space/SKILL.md +118 -0
- package/skills/agent-home/SKILL.md +98 -0
- package/skills/beehaven-cli/SKILL.md +144 -0
- package/skills/claude-shopper/README.md +90 -0
- package/skills/claude-shopper/SKILL.md +151 -0
- package/skills/clearly-agent/SKILL.md +183 -0
- package/skills/clearly-canvas/SKILL.md +222 -0
- package/skills/clearly-canvas/authoring.md +160 -0
- package/skills/clearly-canvas/communications.md +32 -0
- package/skills/clearly-canvas/composition-patterns.md +84 -0
- package/skills/clearly-canvas/example.mjs +78 -0
- package/skills/clearly-canvas/project-plans.md +156 -0
- package/skills/clearly-canvas/review.mjs +68 -0
- package/skills/clearly-workspace/SKILL.md +79 -0
- package/skills/clearly-workspace/survey.mjs +101 -0
- package/skills/codebase-map/SKILL.md +142 -0
- package/skills/design-craft/SKILL.md +300 -0
- package/skills/index.json +79 -0
- package/skills/layout-systems/SKILL.md +184 -0
- package/skills/pair-on-canvas/SKILL.md +92 -0
|
@@ -0,0 +1,846 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dashboard — Local web dashboard for Beehaven observability.
|
|
3
|
+
*
|
|
4
|
+
* Serves a single-page HTML dashboard on localhost.
|
|
5
|
+
* Accepts WebSocket connections for real-time metric streaming.
|
|
6
|
+
* Accepts console log forwarding from browser clients.
|
|
7
|
+
*
|
|
8
|
+
* const dashboard = new Dashboard({ port: 9800 });
|
|
9
|
+
* dashboard.start();
|
|
10
|
+
* dashboard.pushRadar(radarSnapshot);
|
|
11
|
+
* dashboard.pushThreats(threats);
|
|
12
|
+
* dashboard.pushEvent(event);
|
|
13
|
+
* dashboard.pushConsoleLog(log);
|
|
14
|
+
*/
|
|
15
|
+
import { createServer } from 'node:http';
|
|
16
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
17
|
+
import { join, dirname } from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { WebSocketServer } from 'ws';
|
|
20
|
+
// ── Fallback HTML ───────────────────────────────────────────────────────────
|
|
21
|
+
const FALLBACK_HTML = `<!DOCTYPE html>
|
|
22
|
+
<html lang="en">
|
|
23
|
+
<head>
|
|
24
|
+
<meta charset="UTF-8">
|
|
25
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
26
|
+
<title>Beehaven Dashboard</title>
|
|
27
|
+
<style>
|
|
28
|
+
body { font-family: 'Inter', system-ui, sans-serif; background: #0A0A12; color: #e0e0e0; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; }
|
|
29
|
+
.container { text-align: center; max-width: 480px; padding: 2rem; }
|
|
30
|
+
h1 { color: #F5A623; font-family: 'Space Grotesk', system-ui, sans-serif; }
|
|
31
|
+
p { color: #888; line-height: 1.6; }
|
|
32
|
+
code { background: #1a1a2e; padding: 0.2em 0.5em; border-radius: 4px; font-family: 'JetBrains Mono', monospace; color: #7C3AED; }
|
|
33
|
+
</style>
|
|
34
|
+
</head>
|
|
35
|
+
<body>
|
|
36
|
+
<div class="container">
|
|
37
|
+
<h1>Beehaven Dashboard</h1>
|
|
38
|
+
<p>Dashboard HTML not found. Place <code>dashboard.html</code> next to the compiled JS to enable the full UI.</p>
|
|
39
|
+
<p>The API endpoints are still available:</p>
|
|
40
|
+
<ul style="text-align:left;color:#888;">
|
|
41
|
+
<li><code>GET /api/state</code> — full state snapshot</li>
|
|
42
|
+
<li><code>GET /api/events</code> — recent events</li>
|
|
43
|
+
<li><code>GET /api/console</code> — console logs</li>
|
|
44
|
+
<li><code>GET /api/radar</code> — latest radar</li>
|
|
45
|
+
<li><code>GET /api/threats</code> — latest threats</li>
|
|
46
|
+
<li><code>WS /ws</code> — real-time stream</li>
|
|
47
|
+
</ul>
|
|
48
|
+
</div>
|
|
49
|
+
</body>
|
|
50
|
+
</html>`;
|
|
51
|
+
// ── Implementation ──────────────────────────────────────────────────────────
|
|
52
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
53
|
+
const __dirname = dirname(__filename);
|
|
54
|
+
export class Dashboard {
|
|
55
|
+
_config;
|
|
56
|
+
_server = null;
|
|
57
|
+
_wss = null;
|
|
58
|
+
_clients = new Set();
|
|
59
|
+
// Rolling buffers
|
|
60
|
+
_events = [];
|
|
61
|
+
_consoleLogs = [];
|
|
62
|
+
_latestRadar = null;
|
|
63
|
+
_latestThreats = null;
|
|
64
|
+
_latestMetrics = null;
|
|
65
|
+
// Cached HTML
|
|
66
|
+
_htmlContent = null;
|
|
67
|
+
_explorerHtmlContent = null;
|
|
68
|
+
// Stats
|
|
69
|
+
_startedAt = 0;
|
|
70
|
+
_totalEvents = 0;
|
|
71
|
+
_totalConsoleLogs = 0;
|
|
72
|
+
// Browser-runtime buildings — set externally via setBrowserBuildings.
|
|
73
|
+
// Keyed by app name → map of building key → absolute path to compiled JS.
|
|
74
|
+
// The dashboard serves these as text/javascript modules at
|
|
75
|
+
// http://127.0.0.1:9800/apps/<name>/buildings/<key>.js so browser-side
|
|
76
|
+
// hive runtime code can dynamic-import them.
|
|
77
|
+
_browserBuildings = new Map();
|
|
78
|
+
// Installed apps — set externally via setAppFrontends. Keyed by app name.
|
|
79
|
+
// `appRoot` is the .app extract root; `frontendEntry` is the relative
|
|
80
|
+
// path within it to serve when no sub-path is requested.
|
|
81
|
+
_appFrontends = new Map();
|
|
82
|
+
// Registered marketplace feed — optional static catalog of available apps
|
|
83
|
+
// to surface alongside installed ones.
|
|
84
|
+
_marketplaceCatalogProvider = null;
|
|
85
|
+
// Bridge to the IPC bus — set externally via setAppBridge. When present,
|
|
86
|
+
// /apps/ws WebSocket connections are forwarded into the bus as virtual
|
|
87
|
+
// IPC clients so the same pipeline/app-rpc handlers serve browsers too.
|
|
88
|
+
_appBridge = null;
|
|
89
|
+
_appWss = null;
|
|
90
|
+
constructor(config) {
|
|
91
|
+
this._config = {
|
|
92
|
+
port: config?.port ?? 9800,
|
|
93
|
+
host: config?.host ?? '127.0.0.1',
|
|
94
|
+
maxEvents: config?.maxEvents ?? 500,
|
|
95
|
+
maxConsoleLogs: config?.maxConsoleLogs ?? 1000,
|
|
96
|
+
explorerTarget: config?.explorerTarget ?? 'http://127.0.0.1:8787',
|
|
97
|
+
explorerSecret: config?.explorerSecret || this._resolveExplorerSecret(config?.projectDir) || '',
|
|
98
|
+
projectDir: config?.projectDir ?? '',
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Resolve the Explorer admin secret from .dev.vars or process.env.
|
|
103
|
+
* Wrangler reads .dev.vars automatically during `wrangler dev` — we mirror
|
|
104
|
+
* that behavior so the dashboard proxy can authenticate to the same Worker.
|
|
105
|
+
*/
|
|
106
|
+
_resolveExplorerSecret(projectDir) {
|
|
107
|
+
// 1. Try .dev.vars in the project directory (same file wrangler reads)
|
|
108
|
+
const dirs = [projectDir, process.cwd()].filter(Boolean);
|
|
109
|
+
for (const dir of dirs) {
|
|
110
|
+
const devVarsPath = join(dir, '.dev.vars');
|
|
111
|
+
if (existsSync(devVarsPath)) {
|
|
112
|
+
try {
|
|
113
|
+
const content = readFileSync(devVarsPath, 'utf8');
|
|
114
|
+
for (const line of content.split('\n')) {
|
|
115
|
+
const trimmed = line.trim();
|
|
116
|
+
if (trimmed.startsWith('#') || !trimmed.includes('='))
|
|
117
|
+
continue;
|
|
118
|
+
const eqIdx = trimmed.indexOf('=');
|
|
119
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
120
|
+
if (key === 'ADMIN_SECRET') {
|
|
121
|
+
const val = trimmed
|
|
122
|
+
.slice(eqIdx + 1)
|
|
123
|
+
.trim()
|
|
124
|
+
.replace(/^["']|["']$/g, '');
|
|
125
|
+
if (val)
|
|
126
|
+
return val;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
/* ignore read errors */
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
// 2. Fall back to process.env
|
|
136
|
+
if (process.env.ADMIN_SECRET)
|
|
137
|
+
return process.env.ADMIN_SECRET;
|
|
138
|
+
return '';
|
|
139
|
+
}
|
|
140
|
+
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
|
141
|
+
async start() {
|
|
142
|
+
if (this._server)
|
|
143
|
+
return;
|
|
144
|
+
this._startedAt = Date.now();
|
|
145
|
+
this._loadHTML();
|
|
146
|
+
this._server = createServer((req, res) => this._handleHTTP(req, res));
|
|
147
|
+
// One WebSocketServer for the whole HTTP server. We don't use the
|
|
148
|
+
// { path } filter because attaching a SECOND WSS with a different
|
|
149
|
+
// path causes the ws library to 400 mismatched upgrades from the
|
|
150
|
+
// first WSS's listener — both listeners fire on every upgrade event.
|
|
151
|
+
// Instead we accept all upgrades and route by req.url in the
|
|
152
|
+
// connection handler.
|
|
153
|
+
this._wss = new WebSocketServer({ server: this._server });
|
|
154
|
+
this._wss.on('connection', (ws, req) => {
|
|
155
|
+
const url = req.url ?? '';
|
|
156
|
+
if (url.startsWith('/apps/ws')) {
|
|
157
|
+
this._handleAppWsConnection(ws);
|
|
158
|
+
}
|
|
159
|
+
else if (url.startsWith('/ws')) {
|
|
160
|
+
this._handleWSConnection(ws, req);
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
try {
|
|
164
|
+
ws.close(1008, `unknown path: ${url}`);
|
|
165
|
+
}
|
|
166
|
+
catch { }
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
this._wss.on('error', (err) => {
|
|
170
|
+
// Suppress ECONNRESET and similar transient errors
|
|
171
|
+
if (err.code !== 'ECONNRESET') {
|
|
172
|
+
console.error('[dashboard] WebSocket server error:', err.message);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
// `_appWss` is no longer a separate server — routing is unified above.
|
|
176
|
+
this._appWss = this._wss;
|
|
177
|
+
return new Promise((resolve, reject) => {
|
|
178
|
+
const server = this._server;
|
|
179
|
+
const onError = (err) => {
|
|
180
|
+
server.removeListener('listening', onListening);
|
|
181
|
+
this._server = null;
|
|
182
|
+
this._wss = null;
|
|
183
|
+
reject(err);
|
|
184
|
+
};
|
|
185
|
+
const onListening = () => {
|
|
186
|
+
server.removeListener('error', onError);
|
|
187
|
+
resolve();
|
|
188
|
+
};
|
|
189
|
+
server.once('error', onError);
|
|
190
|
+
server.once('listening', onListening);
|
|
191
|
+
server.listen(this._config.port, this._config.host);
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
async stop() {
|
|
195
|
+
if (!this._server)
|
|
196
|
+
return;
|
|
197
|
+
// Close all WebSocket clients
|
|
198
|
+
for (const client of this._clients) {
|
|
199
|
+
try {
|
|
200
|
+
client.close(1001, 'Dashboard shutting down');
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
// Ignore errors during shutdown
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
this._clients.clear();
|
|
207
|
+
// Close WebSocket server
|
|
208
|
+
if (this._wss) {
|
|
209
|
+
await new Promise((resolve) => {
|
|
210
|
+
this._wss.close(() => resolve());
|
|
211
|
+
});
|
|
212
|
+
this._wss = null;
|
|
213
|
+
}
|
|
214
|
+
// Close HTTP server
|
|
215
|
+
await new Promise((resolve, reject) => {
|
|
216
|
+
this._server.close((err) => {
|
|
217
|
+
if (err)
|
|
218
|
+
reject(err);
|
|
219
|
+
else
|
|
220
|
+
resolve();
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
this._server = null;
|
|
224
|
+
}
|
|
225
|
+
// ── Push methods ──────────────────────────────────────────────────────────
|
|
226
|
+
pushRadar(snapshot) {
|
|
227
|
+
this._latestRadar = snapshot;
|
|
228
|
+
this._broadcast({ type: 'radar', data: snapshot });
|
|
229
|
+
}
|
|
230
|
+
pushThreats(data) {
|
|
231
|
+
this._latestThreats = data;
|
|
232
|
+
this._broadcast({ type: 'threats', data });
|
|
233
|
+
}
|
|
234
|
+
pushMetrics(data) {
|
|
235
|
+
this._latestMetrics = data;
|
|
236
|
+
this._broadcast({ type: 'metrics', data });
|
|
237
|
+
}
|
|
238
|
+
pushEvent(event) {
|
|
239
|
+
this._events.push(event);
|
|
240
|
+
if (this._events.length > this._config.maxEvents) {
|
|
241
|
+
this._events.shift();
|
|
242
|
+
}
|
|
243
|
+
this._totalEvents++;
|
|
244
|
+
this._broadcast({ type: 'event', data: event });
|
|
245
|
+
}
|
|
246
|
+
pushConsoleLog(entry) {
|
|
247
|
+
this._consoleLogs.push(entry);
|
|
248
|
+
if (this._consoleLogs.length > this._config.maxConsoleLogs) {
|
|
249
|
+
this._consoleLogs.shift();
|
|
250
|
+
}
|
|
251
|
+
this._totalConsoleLogs++;
|
|
252
|
+
this._broadcast({ type: 'console-log', data: entry });
|
|
253
|
+
}
|
|
254
|
+
// ── Getters ───────────────────────────────────────────────────────────────
|
|
255
|
+
get url() {
|
|
256
|
+
return `http://${this._config.host}:${this._config.port}`;
|
|
257
|
+
}
|
|
258
|
+
get port() {
|
|
259
|
+
return this._config.port;
|
|
260
|
+
}
|
|
261
|
+
get running() {
|
|
262
|
+
return this._server !== null;
|
|
263
|
+
}
|
|
264
|
+
get clientCount() {
|
|
265
|
+
return this._clients.size;
|
|
266
|
+
}
|
|
267
|
+
// ── HTTP handler ──────────────────────────────────────────────────────────
|
|
268
|
+
_handleHTTP(req, res) {
|
|
269
|
+
const url = req.url ?? '/';
|
|
270
|
+
const method = req.method ?? 'GET';
|
|
271
|
+
// Handle CORS preflight
|
|
272
|
+
if (method === 'OPTIONS') {
|
|
273
|
+
res.writeHead(204, {
|
|
274
|
+
'Access-Control-Allow-Origin': '*',
|
|
275
|
+
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
276
|
+
'Access-Control-Allow-Headers': 'Content-Type',
|
|
277
|
+
'Access-Control-Max-Age': '86400',
|
|
278
|
+
});
|
|
279
|
+
res.end();
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
try {
|
|
283
|
+
// Explorer API proxy — forward /api/explorer/* to the Worker
|
|
284
|
+
if (url.startsWith('/api/explorer')) {
|
|
285
|
+
this._proxyExplorer(req, res, url);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
// Browser-runtime building JS modules: /apps/<name>/buildings/<key>.js
|
|
289
|
+
// These are runtime: 'browser' buildings declared in a hive's
|
|
290
|
+
// app.json — the daemon serves their compiled entry as a static
|
|
291
|
+
// module so the user's browser can dynamic-import + dispatch via
|
|
292
|
+
// the hive runtime (`hive.call(<hive>, <action>, ...)`).
|
|
293
|
+
// Matched BEFORE the general /apps/<name>/... static route so a
|
|
294
|
+
// hive's frontend can't accidentally shadow a building's URL.
|
|
295
|
+
if (method === 'GET') {
|
|
296
|
+
const buildingMatch = url.match(/^\/apps\/([^\/]+)\/buildings\/([^\/]+)\.js$/);
|
|
297
|
+
if (buildingMatch) {
|
|
298
|
+
this._serveBrowserBuilding(res, buildingMatch[1], buildingMatch[2]);
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
// App frontend static files: /apps/<name>/... → <app.frontendDir>/...
|
|
303
|
+
if (method === 'GET' && url.startsWith('/apps/')) {
|
|
304
|
+
if (url === '/hives' || url === '/apps/') {
|
|
305
|
+
this._serveAppIndex(res);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
this._serveAppStatic(res, url);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
if (method === 'GET') {
|
|
312
|
+
switch (url) {
|
|
313
|
+
case '/':
|
|
314
|
+
case '/index.html':
|
|
315
|
+
this._serveHTML(res);
|
|
316
|
+
return;
|
|
317
|
+
case '/explorer':
|
|
318
|
+
case '/explorer/':
|
|
319
|
+
this._serveExplorerHTML(res);
|
|
320
|
+
return;
|
|
321
|
+
case '/api/state':
|
|
322
|
+
this._serveJSON(res, this._getState());
|
|
323
|
+
return;
|
|
324
|
+
case '/api/events':
|
|
325
|
+
this._serveJSON(res, this._events);
|
|
326
|
+
return;
|
|
327
|
+
case '/api/console':
|
|
328
|
+
this._serveJSON(res, this._consoleLogs);
|
|
329
|
+
return;
|
|
330
|
+
case '/api/radar':
|
|
331
|
+
this._serveJSON(res, this._latestRadar);
|
|
332
|
+
return;
|
|
333
|
+
case '/api/threats':
|
|
334
|
+
this._serveJSON(res, this._latestThreats);
|
|
335
|
+
return;
|
|
336
|
+
case '/api/metrics':
|
|
337
|
+
this._serveJSON(res, this._latestMetrics);
|
|
338
|
+
return;
|
|
339
|
+
case '/api/marketplace':
|
|
340
|
+
case '/api/marketplace/':
|
|
341
|
+
this._serveMarketplace(res);
|
|
342
|
+
return;
|
|
343
|
+
default:
|
|
344
|
+
res.writeHead(404, {
|
|
345
|
+
'Content-Type': 'text/plain',
|
|
346
|
+
'Access-Control-Allow-Origin': '*',
|
|
347
|
+
});
|
|
348
|
+
res.end('Not Found');
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
if (method === 'POST' && url === '/api/console') {
|
|
353
|
+
this._handleConsolePost(req, res);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
res.writeHead(405, { 'Content-Type': 'text/plain', 'Access-Control-Allow-Origin': '*' });
|
|
357
|
+
res.end('Method Not Allowed');
|
|
358
|
+
}
|
|
359
|
+
catch (err) {
|
|
360
|
+
console.error('[dashboard] HTTP error:', err);
|
|
361
|
+
res.writeHead(500, { 'Content-Type': 'text/plain', 'Access-Control-Allow-Origin': '*' });
|
|
362
|
+
res.end('Internal Server Error');
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
_handleConsolePost(req, res) {
|
|
366
|
+
const chunks = [];
|
|
367
|
+
req.on('data', (chunk) => {
|
|
368
|
+
chunks.push(chunk);
|
|
369
|
+
// Guard against oversized payloads (1MB limit)
|
|
370
|
+
const totalSize = chunks.reduce((sum, c) => sum + c.length, 0);
|
|
371
|
+
if (totalSize > 1_048_576) {
|
|
372
|
+
res.writeHead(413, { 'Content-Type': 'text/plain', 'Access-Control-Allow-Origin': '*' });
|
|
373
|
+
res.end('Payload Too Large');
|
|
374
|
+
req.destroy();
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
req.on('end', () => {
|
|
378
|
+
try {
|
|
379
|
+
const body = Buffer.concat(chunks).toString('utf8');
|
|
380
|
+
const parsed = JSON.parse(body);
|
|
381
|
+
// Accept single entry or array
|
|
382
|
+
const entries = Array.isArray(parsed) ? parsed : [parsed];
|
|
383
|
+
for (const entry of entries) {
|
|
384
|
+
if (entry && typeof entry.level === 'string' && Array.isArray(entry.args)) {
|
|
385
|
+
const normalized = {
|
|
386
|
+
level: entry.level,
|
|
387
|
+
args: entry.args,
|
|
388
|
+
timestamp: entry.timestamp ?? Date.now(),
|
|
389
|
+
source: entry.source,
|
|
390
|
+
url: entry.url,
|
|
391
|
+
sessionId: entry.sessionId,
|
|
392
|
+
userId: entry.userId,
|
|
393
|
+
};
|
|
394
|
+
this.pushConsoleLog(normalized);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
res.writeHead(200, {
|
|
398
|
+
'Content-Type': 'application/json',
|
|
399
|
+
'Access-Control-Allow-Origin': '*',
|
|
400
|
+
});
|
|
401
|
+
res.end(JSON.stringify({ ok: true, accepted: entries.length }));
|
|
402
|
+
}
|
|
403
|
+
catch {
|
|
404
|
+
res.writeHead(400, { 'Content-Type': 'text/plain', 'Access-Control-Allow-Origin': '*' });
|
|
405
|
+
res.end('Bad Request: invalid JSON');
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
req.on('error', () => {
|
|
409
|
+
res.writeHead(400, { 'Content-Type': 'text/plain', 'Access-Control-Allow-Origin': '*' });
|
|
410
|
+
res.end('Bad Request');
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
// ── WebSocket handler ─────────────────────────────────────────────────────
|
|
414
|
+
_handleWSConnection(ws, _req) {
|
|
415
|
+
this._clients.add(ws);
|
|
416
|
+
// Send initial state
|
|
417
|
+
try {
|
|
418
|
+
ws.send(JSON.stringify({ type: 'init', data: this._getState() }));
|
|
419
|
+
}
|
|
420
|
+
catch {
|
|
421
|
+
// Client may have disconnected immediately
|
|
422
|
+
this._clients.delete(ws);
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
ws.on('message', (raw) => {
|
|
426
|
+
try {
|
|
427
|
+
const msg = JSON.parse(typeof raw === 'string' ? raw : raw.toString('utf8'));
|
|
428
|
+
switch (msg.type) {
|
|
429
|
+
case 'console-log': {
|
|
430
|
+
if (msg.data && typeof msg.data.level === 'string' && Array.isArray(msg.data.args)) {
|
|
431
|
+
const entry = {
|
|
432
|
+
level: msg.data.level,
|
|
433
|
+
args: msg.data.args,
|
|
434
|
+
timestamp: msg.data.timestamp ?? Date.now(),
|
|
435
|
+
source: msg.data.source,
|
|
436
|
+
url: msg.data.url,
|
|
437
|
+
sessionId: msg.data.sessionId,
|
|
438
|
+
userId: msg.data.userId,
|
|
439
|
+
};
|
|
440
|
+
this.pushConsoleLog(entry);
|
|
441
|
+
}
|
|
442
|
+
break;
|
|
443
|
+
}
|
|
444
|
+
case 'ping': {
|
|
445
|
+
ws.send(JSON.stringify({ type: 'pong' }));
|
|
446
|
+
break;
|
|
447
|
+
}
|
|
448
|
+
default:
|
|
449
|
+
// Unknown message type — ignore silently
|
|
450
|
+
break;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
catch {
|
|
454
|
+
// Malformed message — ignore
|
|
455
|
+
}
|
|
456
|
+
});
|
|
457
|
+
ws.on('close', () => {
|
|
458
|
+
this._clients.delete(ws);
|
|
459
|
+
});
|
|
460
|
+
ws.on('error', () => {
|
|
461
|
+
this._clients.delete(ws);
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
// ── Private helpers ───────────────────────────────────────────────────────
|
|
465
|
+
_broadcast(message) {
|
|
466
|
+
if (this._clients.size === 0)
|
|
467
|
+
return;
|
|
468
|
+
const json = JSON.stringify(message);
|
|
469
|
+
for (const client of this._clients) {
|
|
470
|
+
if (client.readyState === 1) {
|
|
471
|
+
// WebSocket.OPEN
|
|
472
|
+
try {
|
|
473
|
+
client.send(json);
|
|
474
|
+
}
|
|
475
|
+
catch {
|
|
476
|
+
// Remove broken clients
|
|
477
|
+
this._clients.delete(client);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
_getState() {
|
|
483
|
+
return {
|
|
484
|
+
radar: this._latestRadar,
|
|
485
|
+
threats: this._latestThreats,
|
|
486
|
+
metrics: this._latestMetrics,
|
|
487
|
+
events: this._events.slice(-100),
|
|
488
|
+
consoleLogs: this._consoleLogs.slice(-100),
|
|
489
|
+
stats: {
|
|
490
|
+
startedAt: this._startedAt,
|
|
491
|
+
uptime: Date.now() - this._startedAt,
|
|
492
|
+
totalEvents: this._totalEvents,
|
|
493
|
+
totalConsoleLogs: this._totalConsoleLogs,
|
|
494
|
+
dashboardClients: this._clients.size,
|
|
495
|
+
},
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
_loadHTML() {
|
|
499
|
+
try {
|
|
500
|
+
const htmlPath = join(__dirname, 'dashboard.html');
|
|
501
|
+
this._htmlContent = readFileSync(htmlPath, 'utf8');
|
|
502
|
+
}
|
|
503
|
+
catch {
|
|
504
|
+
this._htmlContent = null;
|
|
505
|
+
}
|
|
506
|
+
try {
|
|
507
|
+
const explorerPath = join(__dirname, 'explorer.html');
|
|
508
|
+
this._explorerHtmlContent = readFileSync(explorerPath, 'utf8');
|
|
509
|
+
}
|
|
510
|
+
catch {
|
|
511
|
+
this._explorerHtmlContent = null;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
_serveHTML(res) {
|
|
515
|
+
const html = this._htmlContent ?? FALLBACK_HTML;
|
|
516
|
+
res.writeHead(200, {
|
|
517
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
518
|
+
'Cache-Control': 'no-cache',
|
|
519
|
+
'Access-Control-Allow-Origin': '*',
|
|
520
|
+
});
|
|
521
|
+
res.end(html);
|
|
522
|
+
}
|
|
523
|
+
_serveExplorerHTML(res) {
|
|
524
|
+
if (this._explorerHtmlContent) {
|
|
525
|
+
res.writeHead(200, {
|
|
526
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
527
|
+
'Cache-Control': 'no-cache',
|
|
528
|
+
'Access-Control-Allow-Origin': '*',
|
|
529
|
+
});
|
|
530
|
+
res.end(this._explorerHtmlContent);
|
|
531
|
+
}
|
|
532
|
+
else {
|
|
533
|
+
res.writeHead(200, {
|
|
534
|
+
'Content-Type': 'text/html; charset=utf-8',
|
|
535
|
+
'Cache-Control': 'no-cache',
|
|
536
|
+
'Access-Control-Allow-Origin': '*',
|
|
537
|
+
});
|
|
538
|
+
res.end(`<!DOCTYPE html><html><head><title>Beehaven Explorer</title>
|
|
539
|
+
<style>body{font-family:Inter,system-ui;background:#0A0A12;color:#e0e0e0;display:flex;align-items:center;justify-content:center;min-height:100vh;margin:0}
|
|
540
|
+
.c{text-align:center;max-width:480px;padding:2rem}h1{color:#F5A623}code{background:#1a1a2e;padding:.2em .5em;border-radius:4px;color:#7C3AED}</style>
|
|
541
|
+
</head><body><div class="c"><h1>Beehaven Explorer</h1>
|
|
542
|
+
<p style="color:#888">Explorer HTML not found. Place <code>explorer.html</code> next to the compiled JS.</p>
|
|
543
|
+
</div></body></html>`);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
async _proxyExplorer(req, res, url) {
|
|
547
|
+
// Strip /api/explorer prefix → forward to Worker's /explorer/* endpoints
|
|
548
|
+
const path = url.replace('/api/explorer', '/explorer');
|
|
549
|
+
const target = this._config.explorerTarget + path;
|
|
550
|
+
const method = req.method ?? 'GET';
|
|
551
|
+
// Collect request body for POST/PUT
|
|
552
|
+
let body;
|
|
553
|
+
if (method === 'POST' || method === 'PUT') {
|
|
554
|
+
const chunks = [];
|
|
555
|
+
for await (const chunk of req) {
|
|
556
|
+
chunks.push(chunk);
|
|
557
|
+
}
|
|
558
|
+
body = Buffer.concat(chunks).toString('utf8');
|
|
559
|
+
}
|
|
560
|
+
try {
|
|
561
|
+
const headers = {
|
|
562
|
+
'Content-Type': 'application/json',
|
|
563
|
+
};
|
|
564
|
+
// Inject admin secret for auth
|
|
565
|
+
if (this._config.explorerSecret) {
|
|
566
|
+
headers['Authorization'] = `Bearer ${this._config.explorerSecret}`;
|
|
567
|
+
}
|
|
568
|
+
const upstream = await fetch(target, {
|
|
569
|
+
method,
|
|
570
|
+
headers,
|
|
571
|
+
body: method !== 'GET' && method !== 'HEAD' ? body : undefined,
|
|
572
|
+
});
|
|
573
|
+
const responseBody = await upstream.text();
|
|
574
|
+
res.writeHead(upstream.status, {
|
|
575
|
+
'Content-Type': upstream.headers.get('Content-Type') || 'application/json',
|
|
576
|
+
'Access-Control-Allow-Origin': '*',
|
|
577
|
+
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
|
578
|
+
'Access-Control-Allow-Headers': 'Content-Type',
|
|
579
|
+
});
|
|
580
|
+
res.end(responseBody);
|
|
581
|
+
}
|
|
582
|
+
catch (err) {
|
|
583
|
+
res.writeHead(502, {
|
|
584
|
+
'Content-Type': 'application/json',
|
|
585
|
+
'Access-Control-Allow-Origin': '*',
|
|
586
|
+
});
|
|
587
|
+
res.end(JSON.stringify({
|
|
588
|
+
error: `Cannot reach Explorer API at ${this._config.explorerTarget}: ${err.message}`,
|
|
589
|
+
}));
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
_serveJSON(res, data) {
|
|
593
|
+
res.writeHead(200, {
|
|
594
|
+
'Content-Type': 'application/json',
|
|
595
|
+
'Access-Control-Allow-Origin': '*',
|
|
596
|
+
});
|
|
597
|
+
res.end(JSON.stringify(data));
|
|
598
|
+
}
|
|
599
|
+
// ── App frontend hosting ──────────────────────────────────────────────
|
|
600
|
+
//
|
|
601
|
+
// The daemon calls setAppFrontends after loadInstalledApps resolves,
|
|
602
|
+
// giving the dashboard the map of app name → absolute frontend dir.
|
|
603
|
+
// Requests to /apps/<name>/<path> are served as static files from that
|
|
604
|
+
// directory (mime-typed, path-escaped, scoped so "../" can't escape).
|
|
605
|
+
/**
|
|
606
|
+
* Register installed apps with the dashboard so their frontends + assets
|
|
607
|
+
* are served under /apps/<name>/. We serve from the app ROOT (not just
|
|
608
|
+
* the frontend dir) so a UI can fetch its own recipe JSONs and assets
|
|
609
|
+
* via stable /apps/<name>/... paths. The default response for a bare
|
|
610
|
+
* /apps/<name>/ hits the declared frontend.entry.
|
|
611
|
+
*/
|
|
612
|
+
setAppFrontends(frontends) {
|
|
613
|
+
this._appFrontends.clear();
|
|
614
|
+
for (const f of frontends) {
|
|
615
|
+
if (!f.manifest?.frontend)
|
|
616
|
+
continue;
|
|
617
|
+
const frontendDir = f.manifest.frontend.dir ?? '';
|
|
618
|
+
const entry = f.manifest.frontend.entry ?? 'index.html';
|
|
619
|
+
// Resolve the frontend entry to a path relative to the app root.
|
|
620
|
+
const frontendEntry = frontendDir ? `${frontendDir.replace(/\/$/, '')}/${entry}` : entry;
|
|
621
|
+
this._appFrontends.set(f.name, { appRoot: f.appRoot, frontendEntry, manifest: f.manifest });
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
/**
|
|
625
|
+
* Register browser-runtime buildings with the dashboard. The daemon's
|
|
626
|
+
* LocalAppManager calls this after loadInstalledApps finishes, passing
|
|
627
|
+
* absolute file paths for each `runtime: "browser"` building. The
|
|
628
|
+
* dashboard then serves each as a JS module at
|
|
629
|
+
* /apps/<name>/buildings/<key>.js for the user's browser to fetch.
|
|
630
|
+
*/
|
|
631
|
+
setBrowserBuildings(items) {
|
|
632
|
+
this._browserBuildings.clear();
|
|
633
|
+
for (const item of items) {
|
|
634
|
+
let appMap = this._browserBuildings.get(item.appName);
|
|
635
|
+
if (!appMap) {
|
|
636
|
+
appMap = new Map();
|
|
637
|
+
this._browserBuildings.set(item.appName, appMap);
|
|
638
|
+
}
|
|
639
|
+
appMap.set(item.buildingKey, item.absolutePath);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Serve a browser-runtime building's JS module. Path validated against
|
|
644
|
+
* registered absolute paths so there's no traversal risk — the URL only
|
|
645
|
+
* looks up an entry that was explicitly registered by the daemon at
|
|
646
|
+
* boot, no arbitrary filesystem read possible.
|
|
647
|
+
*/
|
|
648
|
+
_serveBrowserBuilding(res, appName, buildingKey) {
|
|
649
|
+
const appMap = this._browserBuildings.get(appName);
|
|
650
|
+
if (!appMap) {
|
|
651
|
+
res.writeHead(404, { 'Content-Type': 'text/plain', 'Access-Control-Allow-Origin': '*' });
|
|
652
|
+
res.end(`App "${appName}" has no browser buildings registered`);
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
const filePath = appMap.get(buildingKey);
|
|
656
|
+
if (!filePath) {
|
|
657
|
+
res.writeHead(404, { 'Content-Type': 'text/plain', 'Access-Control-Allow-Origin': '*' });
|
|
658
|
+
res.end(`Browser building "${buildingKey}" not found in app "${appName}"`);
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
let data;
|
|
662
|
+
try {
|
|
663
|
+
data = readFileSync(filePath);
|
|
664
|
+
}
|
|
665
|
+
catch {
|
|
666
|
+
res.writeHead(500, { 'Content-Type': 'text/plain', 'Access-Control-Allow-Origin': '*' });
|
|
667
|
+
res.end(`Failed to read browser building file`);
|
|
668
|
+
return;
|
|
669
|
+
}
|
|
670
|
+
res.writeHead(200, {
|
|
671
|
+
'Content-Type': 'text/javascript; charset=utf-8',
|
|
672
|
+
'Access-Control-Allow-Origin': '*',
|
|
673
|
+
// Short cache so dev rebuilds picked up quickly.
|
|
674
|
+
'Cache-Control': 'no-cache',
|
|
675
|
+
});
|
|
676
|
+
res.end(data);
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Give the dashboard a bridge to the IPC bus. Once set, /apps/ws
|
|
680
|
+
* WebSocket connections become virtual IPC clients — the browser can
|
|
681
|
+
* send `app-rpc`, `pipeline`, `subscribe`, `ping` messages just like
|
|
682
|
+
* a local Unix-socket client and receive broadcasts the same way.
|
|
683
|
+
*
|
|
684
|
+
* The /apps/ws WebSocketServer itself is started inside start() so
|
|
685
|
+
* connections work even if they arrive before the bridge is set.
|
|
686
|
+
*/
|
|
687
|
+
setAppBridge(bridge) {
|
|
688
|
+
this._appBridge = bridge;
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Register a provider that returns the full marketplace catalog
|
|
692
|
+
* (installed + available for install). Used by /api/marketplace.
|
|
693
|
+
* Daemon calls this during startup with a closure over the app
|
|
694
|
+
* manager + any cloud catalog it can reach.
|
|
695
|
+
*/
|
|
696
|
+
setMarketplaceCatalogProvider(provider) {
|
|
697
|
+
this._marketplaceCatalogProvider = provider;
|
|
698
|
+
}
|
|
699
|
+
async _serveMarketplace(res) {
|
|
700
|
+
const payload = this._marketplaceCatalogProvider
|
|
701
|
+
? await this._marketplaceCatalogProvider()
|
|
702
|
+
: { installed: [], available: [] };
|
|
703
|
+
this._serveJSON(res, payload);
|
|
704
|
+
}
|
|
705
|
+
_handleAppWsConnection(ws) {
|
|
706
|
+
if (!this._appBridge) {
|
|
707
|
+
ws.close(1011, 'app bridge not configured');
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
const bridge = this._appBridge;
|
|
711
|
+
// Bind a virtual client whose outgoing messages forward to this ws.
|
|
712
|
+
const send = (msg) => {
|
|
713
|
+
if (ws.readyState !== 1)
|
|
714
|
+
return;
|
|
715
|
+
try {
|
|
716
|
+
ws.send(JSON.stringify(msg));
|
|
717
|
+
}
|
|
718
|
+
catch { }
|
|
719
|
+
};
|
|
720
|
+
// Subscribe broadly by default — browser UIs usually want to see
|
|
721
|
+
// everything (pipeline:*, broadcast:*, etc.). Clients can tighten this
|
|
722
|
+
// by sending their own { type: 'subscribe', patterns: [...] } message.
|
|
723
|
+
const virtId = bridge.createVirtualClient(send, ['*']);
|
|
724
|
+
ws.on('message', (raw) => {
|
|
725
|
+
let msg;
|
|
726
|
+
try {
|
|
727
|
+
msg = JSON.parse(typeof raw === 'string' ? raw : raw.toString('utf8'));
|
|
728
|
+
}
|
|
729
|
+
catch {
|
|
730
|
+
send({ type: 'error', error: 'invalid JSON' });
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
try {
|
|
734
|
+
bridge.injectMessage(virtId, msg);
|
|
735
|
+
}
|
|
736
|
+
catch (err) {
|
|
737
|
+
send({ type: 'error', error: err?.message ?? String(err) });
|
|
738
|
+
}
|
|
739
|
+
});
|
|
740
|
+
const close = () => {
|
|
741
|
+
try {
|
|
742
|
+
bridge.releaseVirtualClient(virtId);
|
|
743
|
+
}
|
|
744
|
+
catch { }
|
|
745
|
+
};
|
|
746
|
+
ws.on('close', close);
|
|
747
|
+
ws.on('error', close);
|
|
748
|
+
}
|
|
749
|
+
_serveAppIndex(res) {
|
|
750
|
+
const list = Array.from(this._appFrontends.entries()).map(([name, h]) => ({
|
|
751
|
+
name,
|
|
752
|
+
version: h.manifest.version,
|
|
753
|
+
title: h.manifest.frontend?.title ?? h.manifest.name,
|
|
754
|
+
icon: h.manifest.frontend?.icon ?? '',
|
|
755
|
+
description: h.manifest.description ?? '',
|
|
756
|
+
url: `/apps/${name}/`,
|
|
757
|
+
}));
|
|
758
|
+
const body = `<!doctype html><html><head><meta charset="utf-8"><title>Hives</title>
|
|
759
|
+
<style>body{font-family:-apple-system,system-ui,sans-serif;background:#0a0a0a;color:#eee;padding:40px;max-width:720px;margin:auto}
|
|
760
|
+
a{color:#6af;text-decoration:none}h1{font-weight:300;letter-spacing:0.5px}
|
|
761
|
+
ul{list-style:none;padding:0}li{padding:16px;border:1px solid #222;border-radius:8px;margin:12px 0}
|
|
762
|
+
li .title{font-size:18px;font-weight:500}li .meta{color:#888;font-size:13px}</style>
|
|
763
|
+
</head><body><h1>Installed Hives</h1>${list.length === 0
|
|
764
|
+
? '<p style="color:#888">No apps installed. Run <code>beehaven app install <path></code>.</p>'
|
|
765
|
+
: `<ul>${list.map((l) => `<li><a href="${l.url}"><div class="title">${l.icon ? l.icon + ' ' : ''}${l.title}</div><div class="meta">${l.name}@${l.version}</div><div>${l.description}</div></a></li>`).join('')}</ul>`}</body></html>`;
|
|
766
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
767
|
+
res.end(body);
|
|
768
|
+
}
|
|
769
|
+
_serveAppStatic(res, url) {
|
|
770
|
+
// /apps/<name>/<...path> — extract name + relative path
|
|
771
|
+
const match = url.match(/^\/hives\/([^\/]+)(\/.*)?$/);
|
|
772
|
+
if (!match) {
|
|
773
|
+
res.writeHead(404);
|
|
774
|
+
res.end('Not Found');
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
const [, name, rawPath] = match;
|
|
778
|
+
const appEntry = this._appFrontends.get(name);
|
|
779
|
+
if (!appEntry) {
|
|
780
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
781
|
+
res.end(`App "${name}" not installed or has no frontend`);
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
784
|
+
// Default to the app's frontend entry file when no sub-path is requested.
|
|
785
|
+
let relPath = rawPath && rawPath !== '/' ? rawPath : `/${appEntry.frontendEntry}`;
|
|
786
|
+
relPath = relPath.replace(/[?#].*$/, '');
|
|
787
|
+
const normalized = relPath
|
|
788
|
+
.split('/')
|
|
789
|
+
.filter((p) => p && p !== '.')
|
|
790
|
+
.join('/');
|
|
791
|
+
if (normalized.split('/').includes('..')) {
|
|
792
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
793
|
+
res.end('Bad Request');
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
const filePath = join(appEntry.appRoot, normalized);
|
|
797
|
+
// Defense against path escape — readFileSync doesn't enforce this for us.
|
|
798
|
+
if (!filePath.startsWith(appEntry.appRoot)) {
|
|
799
|
+
res.writeHead(400, { 'Content-Type': 'text/plain' });
|
|
800
|
+
res.end('Bad Request');
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
let data;
|
|
804
|
+
try {
|
|
805
|
+
data = readFileSync(filePath);
|
|
806
|
+
}
|
|
807
|
+
catch {
|
|
808
|
+
// SPA fallback — unknown path returns the frontend entry so client-side
|
|
809
|
+
// routers work, but this also means 404s for missing assets look like
|
|
810
|
+
// HTML. Acceptable trade-off for dev.
|
|
811
|
+
try {
|
|
812
|
+
data = readFileSync(join(appEntry.appRoot, appEntry.frontendEntry));
|
|
813
|
+
}
|
|
814
|
+
catch {
|
|
815
|
+
res.writeHead(404, { 'Content-Type': 'text/plain' });
|
|
816
|
+
res.end('Not Found');
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
const ext = (normalized.split('.').pop() ?? '').toLowerCase();
|
|
821
|
+
const type = {
|
|
822
|
+
html: 'text/html; charset=utf-8',
|
|
823
|
+
htm: 'text/html; charset=utf-8',
|
|
824
|
+
js: 'text/javascript; charset=utf-8',
|
|
825
|
+
mjs: 'text/javascript; charset=utf-8',
|
|
826
|
+
css: 'text/css; charset=utf-8',
|
|
827
|
+
json: 'application/json; charset=utf-8',
|
|
828
|
+
svg: 'image/svg+xml',
|
|
829
|
+
png: 'image/png',
|
|
830
|
+
jpg: 'image/jpeg',
|
|
831
|
+
jpeg: 'image/jpeg',
|
|
832
|
+
gif: 'image/gif',
|
|
833
|
+
ico: 'image/x-icon',
|
|
834
|
+
webp: 'image/webp',
|
|
835
|
+
woff: 'font/woff',
|
|
836
|
+
woff2: 'font/woff2',
|
|
837
|
+
}[ext] ?? 'application/octet-stream';
|
|
838
|
+
res.writeHead(200, {
|
|
839
|
+
'Content-Type': type,
|
|
840
|
+
'Access-Control-Allow-Origin': '*',
|
|
841
|
+
// Short cache so hot reloads pick up edits.
|
|
842
|
+
'Cache-Control': 'no-cache',
|
|
843
|
+
});
|
|
844
|
+
res.end(data);
|
|
845
|
+
}
|
|
846
|
+
}
|