bloby-bot 0.63.0 → 0.65.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/supervisor/app-ws.js +0 -10
- package/supervisor/chat/src/hooks/useBlobyChat.ts +0 -11
- package/supervisor/chat/src/hooks/useChat.ts +0 -5
- package/supervisor/index.ts +59 -69
- package/supervisor/shell.ts +120 -0
- package/supervisor/vite-dev.ts +0 -7
- package/supervisor/widget.js +26 -13
- package/supervisor/workspace-guard.js +216 -18
- package/vite.config.ts +33 -2
- package/worker/prompts/prompt-conditions.ts +9 -0
- package/worker/prompts/prompt-fragments.json +8 -0
- package/workspace/client/index.html +1 -0
- package/workspace/client/public/sw.js +4 -9
- package/workspace/client/src/App.tsx +13 -103
- package/workspace/client/src/components/Layout/DashboardLayout.tsx +0 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bloby-bot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.65.0",
|
|
4
4
|
"releaseNotes": [
|
|
5
5
|
"1. Fix: image (and audio) attachments now render in chat again — /api/files is fetched with the auth token instead of a raw <img> src that 401'd after the endpoint hardening",
|
|
6
6
|
"2. Affects chat thumbnails, the image lightbox, voice-note playback, and agent image cards",
|
package/supervisor/app-ws.js
CHANGED
|
@@ -32,7 +32,6 @@
|
|
|
32
32
|
function sendChatSubscribe() {
|
|
33
33
|
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
34
34
|
ws.send(JSON.stringify({ type: 'chat:subscribe', data: { clientId: chatClientId } }));
|
|
35
|
-
console.log('[app-ws] chat:subscribe sent clientId=' + chatClientId);
|
|
36
35
|
}
|
|
37
36
|
|
|
38
37
|
function buildWsUrl() {
|
|
@@ -59,7 +58,6 @@
|
|
|
59
58
|
for (var i = 0; i < ids.length; i++) {
|
|
60
59
|
var p = pendingRequests[ids[i]];
|
|
61
60
|
clearTimeout(p.timer);
|
|
62
|
-
console.log('[app-ws] WS dropped, falling back to fetch: ' + p.method + ' ' + p.path);
|
|
63
61
|
p.resolve(originalFetch(p.input, p.init));
|
|
64
62
|
}
|
|
65
63
|
pendingRequests = {};
|
|
@@ -74,7 +72,6 @@
|
|
|
74
72
|
connected = true;
|
|
75
73
|
reconnectDelay = RECONNECT_BASE;
|
|
76
74
|
startHeartbeat();
|
|
77
|
-
console.log('[app-ws] Connected to /app/ws');
|
|
78
75
|
if (chatSubscribed) sendChatSubscribe();
|
|
79
76
|
};
|
|
80
77
|
|
|
@@ -94,7 +91,6 @@
|
|
|
94
91
|
}
|
|
95
92
|
|
|
96
93
|
if (msg.type === 'chat:subscribed') {
|
|
97
|
-
console.log('[app-ws] chat:subscribed ack clientId=' + (msg.data && msg.data.clientId));
|
|
98
94
|
return;
|
|
99
95
|
}
|
|
100
96
|
|
|
@@ -104,7 +100,6 @@
|
|
|
104
100
|
clearTimeout(pending.timer);
|
|
105
101
|
delete pendingRequests[msg.data.id];
|
|
106
102
|
|
|
107
|
-
console.log('[app-ws] Response via WS: ' + msg.data.status + ' ' + pending.method + ' ' + pending.path);
|
|
108
103
|
|
|
109
104
|
var responseBody = msg.data.body;
|
|
110
105
|
if (responseBody === null || responseBody === undefined) responseBody = '';
|
|
@@ -125,7 +120,6 @@
|
|
|
125
120
|
failoverPending();
|
|
126
121
|
|
|
127
122
|
if (!intentionalClose) {
|
|
128
|
-
console.log('[app-ws] Disconnected, reconnecting in ' + reconnectDelay + 'ms');
|
|
129
123
|
reconnectTimer = setTimeout(function () {
|
|
130
124
|
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX);
|
|
131
125
|
connect();
|
|
@@ -150,14 +144,12 @@
|
|
|
150
144
|
|
|
151
145
|
// Fallback: WS not connected
|
|
152
146
|
if (!connected || !ws || ws.readyState !== WebSocket.OPEN) {
|
|
153
|
-
console.log('[app-ws] WS not connected, falling back to fetch: ' + method + ' ' + url);
|
|
154
147
|
return originalFetch.apply(this, arguments);
|
|
155
148
|
}
|
|
156
149
|
|
|
157
150
|
// Fallback: non-serializable body (FormData, Blob, ArrayBuffer)
|
|
158
151
|
var body = init && init.body;
|
|
159
152
|
if (body && (body instanceof FormData || body instanceof Blob || body instanceof ArrayBuffer)) {
|
|
160
|
-
console.log('[app-ws] Non-serializable body, falling back to fetch: ' + method + ' ' + url);
|
|
161
153
|
return originalFetch.apply(this, arguments);
|
|
162
154
|
}
|
|
163
155
|
|
|
@@ -189,7 +181,6 @@
|
|
|
189
181
|
bodyStr = typeof body === 'string' ? body : JSON.stringify(body);
|
|
190
182
|
}
|
|
191
183
|
|
|
192
|
-
console.log('[app-ws] Proxying via WS: ' + method + ' ' + reqPath);
|
|
193
184
|
|
|
194
185
|
ws.send(
|
|
195
186
|
JSON.stringify({
|
|
@@ -204,7 +195,6 @@
|
|
|
204
195
|
return new Promise(function (resolve, reject) {
|
|
205
196
|
var timer = setTimeout(function () {
|
|
206
197
|
delete pendingRequests[id];
|
|
207
|
-
console.log('[app-ws] Request timed out, falling back to fetch: ' + method + ' ' + reqPath);
|
|
208
198
|
resolve(originalFetch(savedInput, savedInit));
|
|
209
199
|
}, REQUEST_TIMEOUT);
|
|
210
200
|
|
|
@@ -41,7 +41,6 @@ export function useBlobyChat(ws: WsClient | null, triggerReload?: number, enable
|
|
|
41
41
|
function handleExtMessage(event: MessageEvent) {
|
|
42
42
|
if (event.data?.type === 'bloby:page-context') {
|
|
43
43
|
extensionPageContext.current = event.data.context;
|
|
44
|
-
console.log('[blobyChat] Extension page context:', event.data.context?.url);
|
|
45
44
|
}
|
|
46
45
|
}
|
|
47
46
|
|
|
@@ -153,11 +152,9 @@ export function useBlobyChat(ws: WsClient | null, triggerReload?: number, enable
|
|
|
153
152
|
useEffect(() => {
|
|
154
153
|
if (!ws) return;
|
|
155
154
|
|
|
156
|
-
console.log('[blobyChat] ──── WS HANDLERS REGISTERED ────');
|
|
157
155
|
|
|
158
156
|
const unsubs = [
|
|
159
157
|
ws.on('bot:typing', () => {
|
|
160
|
-
console.log('[blobyChat] bot:typing → streaming=true');
|
|
161
158
|
setStreaming(true);
|
|
162
159
|
setTools([]);
|
|
163
160
|
}),
|
|
@@ -175,7 +172,6 @@ export function useBlobyChat(ws: WsClient | null, triggerReload?: number, enable
|
|
|
175
172
|
// This creates separate message bubbles and shows dots during work
|
|
176
173
|
const content = streamBufferRef.current;
|
|
177
174
|
if (content) {
|
|
178
|
-
console.log(`[blobyChat] bot:tool (${data.name}) — committing ${content.length} chars as bubble`);
|
|
179
175
|
committedTextLength.current += content.length;
|
|
180
176
|
setMessages((msgs) => [
|
|
181
177
|
...msgs,
|
|
@@ -205,9 +201,6 @@ export function useBlobyChat(ws: WsClient | null, triggerReload?: number, enable
|
|
|
205
201
|
if (committedTextLength.current > 0) {
|
|
206
202
|
content = content.slice(committedTextLength.current).replace(/^\n+/, '');
|
|
207
203
|
committedTextLength.current = 0;
|
|
208
|
-
console.log('[blobyChat] bot:response — stripped partial, remaining:', content.slice(0, 60));
|
|
209
|
-
} else {
|
|
210
|
-
console.log('[blobyChat] bot:response — new bubble');
|
|
211
204
|
}
|
|
212
205
|
|
|
213
206
|
// Only add a bubble if there's new content
|
|
@@ -232,7 +225,6 @@ export function useBlobyChat(ws: WsClient | null, triggerReload?: number, enable
|
|
|
232
225
|
ws.on('bot:task-created', () => {
|
|
233
226
|
const content = streamBufferRef.current;
|
|
234
227
|
if (content) {
|
|
235
|
-
console.log('[blobyChat] bot:task-created — committing stream, showing dots');
|
|
236
228
|
committedTextLength.current += content.length;
|
|
237
229
|
setMessages((msgs) => [
|
|
238
230
|
...msgs,
|
|
@@ -250,11 +242,9 @@ export function useBlobyChat(ws: WsClient | null, triggerReload?: number, enable
|
|
|
250
242
|
}),
|
|
251
243
|
ws.on('bot:idle', () => {
|
|
252
244
|
// Server confirmed agent is idle — safe to stop streaming
|
|
253
|
-
console.log('[blobyChat] bot:idle → streaming=false');
|
|
254
245
|
setStreaming(false);
|
|
255
246
|
}),
|
|
256
247
|
ws.on('bot:error', (data: { error: string }) => {
|
|
257
|
-
console.log('[blobyChat] bot:error');
|
|
258
248
|
setStreamBuffer('');
|
|
259
249
|
streamBufferRef.current = '';
|
|
260
250
|
setStreaming(false);
|
|
@@ -334,7 +324,6 @@ export function useBlobyChat(ws: WsClient | null, triggerReload?: number, enable
|
|
|
334
324
|
// If bot is streaming, commit partial response first
|
|
335
325
|
const partialContent = streamBufferRef.current;
|
|
336
326
|
if (partialContent) {
|
|
337
|
-
console.log('[blobyChat] Committing partial —', partialContent.length, 'chars');
|
|
338
327
|
committedTextLength.current += partialContent.length;
|
|
339
328
|
setMessages((msgs) => [
|
|
340
329
|
...msgs,
|
|
@@ -128,7 +128,6 @@ export function useChat(ws: WsClient | null) {
|
|
|
128
128
|
|
|
129
129
|
const unsubs = [
|
|
130
130
|
ws.on('bot:typing', () => {
|
|
131
|
-
console.log('[useChat] bot:typing → streaming=true');
|
|
132
131
|
setStreaming(true);
|
|
133
132
|
setTools([]);
|
|
134
133
|
}),
|
|
@@ -149,7 +148,6 @@ export function useChat(ws: WsClient | null) {
|
|
|
149
148
|
});
|
|
150
149
|
}),
|
|
151
150
|
ws.on('bot:response', (data: { conversationId: string; messageId?: string; content: string }) => {
|
|
152
|
-
console.log('[useChat] bot:response — adding message bubble');
|
|
153
151
|
setConversationId(data.conversationId);
|
|
154
152
|
|
|
155
153
|
// Always add as a new bubble
|
|
@@ -170,11 +168,9 @@ export function useChat(ws: WsClient | null) {
|
|
|
170
168
|
}),
|
|
171
169
|
ws.on('bot:idle', () => {
|
|
172
170
|
// Server confirmed agent is idle — safe to stop streaming
|
|
173
|
-
console.log('[useChat] bot:idle → streaming=false');
|
|
174
171
|
setStreaming(false);
|
|
175
172
|
}),
|
|
176
173
|
ws.on('bot:error', (data: { error: string }) => {
|
|
177
|
-
console.log('[useChat] bot:error');
|
|
178
174
|
setStreamBuffer('');
|
|
179
175
|
streamBufferRef.current = '';
|
|
180
176
|
setStreaming(false);
|
|
@@ -218,7 +214,6 @@ export function useChat(ws: WsClient | null) {
|
|
|
218
214
|
// so the user's new message appears BELOW the bot's in-progress text (chronological order)
|
|
219
215
|
const partialContent = streamBufferRef.current;
|
|
220
216
|
if (partialContent) {
|
|
221
|
-
console.log('[useChat] Committing partial stream buffer as message before user send');
|
|
222
217
|
setMessages((msgs) => [
|
|
223
218
|
...msgs,
|
|
224
219
|
{
|
package/supervisor/index.ts
CHANGED
|
@@ -27,6 +27,7 @@ import { startScheduler, stopScheduler, readPulseConfig, readCronsConfig, nextRu
|
|
|
27
27
|
import { execSync, spawn as cpSpawn } from 'child_process';
|
|
28
28
|
import crypto from 'crypto';
|
|
29
29
|
import { ChannelManager } from './channels/manager.js';
|
|
30
|
+
import { SHELL_HTML } from './shell.js';
|
|
30
31
|
|
|
31
32
|
// Last-resort process-level safety nets. The supervisor is the SINGLE process that keeps
|
|
32
33
|
// chat alive (G1) and heals the backend (G3); a stray throw inside an emitter callback
|
|
@@ -120,32 +121,29 @@ const SW_JS = `// Service worker — app-shell caching + push notifications
|
|
|
120
121
|
// cached shell that masks a broken (or just-fixed) frontend and produces the confusing
|
|
121
122
|
// "normal refresh is broken but hard refresh works" split. Cache is a pure offline fallback.
|
|
122
123
|
|
|
123
|
-
var CACHE = 'bloby-
|
|
124
|
+
var CACHE = 'bloby-v24';
|
|
124
125
|
var HASHED_RE = new RegExp('/assets/.+-[a-zA-Z0-9]{6,}[.](js|css)$');
|
|
125
126
|
|
|
126
127
|
// Precache the HTML shell on install so the cache is never empty.
|
|
127
128
|
// Without this, the first navigation isn't intercepted (SW wasn't
|
|
128
129
|
// controlling yet), so refresh would find an empty cache → white screen.
|
|
129
130
|
self.addEventListener('install', function(e) {
|
|
130
|
-
console.log('[SW] installing, cache:', CACHE);
|
|
131
131
|
e.waitUntil(
|
|
132
132
|
caches.open(CACHE)
|
|
133
133
|
.then(function(c) { return c.add('/'); })
|
|
134
|
-
.then(function() {
|
|
134
|
+
.then(function() { return self.skipWaiting(); })
|
|
135
135
|
.catch(function(err) { console.error('[SW] install failed:', err); throw err; })
|
|
136
136
|
);
|
|
137
137
|
});
|
|
138
138
|
|
|
139
139
|
self.addEventListener('activate', function(e) {
|
|
140
|
-
console.log('[SW] activating, cache:', CACHE);
|
|
141
140
|
e.waitUntil(
|
|
142
141
|
caches.keys()
|
|
143
142
|
.then(function(keys) {
|
|
144
143
|
var old = keys.filter(function(k) { return k !== CACHE; });
|
|
145
|
-
if (old.length) console.log('[SW] deleting old caches:', old);
|
|
146
144
|
return Promise.all(old.map(function(k) { return caches.delete(k); }));
|
|
147
145
|
})
|
|
148
|
-
.then(function() {
|
|
146
|
+
.then(function() { return self.clients.claim(); })
|
|
149
147
|
);
|
|
150
148
|
});
|
|
151
149
|
|
|
@@ -183,18 +181,20 @@ self.addEventListener('fetch', function(event) {
|
|
|
183
181
|
// /bloby/* is a separate app — let it go to network to avoid
|
|
184
182
|
// caching the wrong HTML under the / key.
|
|
185
183
|
if (request.mode === 'navigate') {
|
|
186
|
-
|
|
184
|
+
// Workspace iframe documents (?__bloby_frame=1) share pathname '/' with the shell.
|
|
185
|
+
// Network-only: never c.put() them (would overwrite the precached shell under the '/'
|
|
186
|
+
// key) and never fall back to c.match('/') (would serve the shell INTO the iframe).
|
|
187
|
+
if (url.searchParams.has('__bloby_frame')) return;
|
|
187
188
|
if (url.pathname === '/' || url.pathname === '/index.html') {
|
|
188
189
|
// Network-first: always fetch the live dashboard; only fall back to cache when offline.
|
|
189
190
|
event.respondWith(caches.open(CACHE).then(function(c) {
|
|
190
191
|
return fetch(request)
|
|
191
192
|
.then(function(r) { if (r.ok) c.put('/', r.clone()); return r; })
|
|
192
|
-
.catch(function(
|
|
193
|
+
.catch(function() { return c.match('/'); });
|
|
193
194
|
}));
|
|
194
195
|
return;
|
|
195
196
|
}
|
|
196
197
|
// Other navigations (/bloby/*, etc.) — network only
|
|
197
|
-
console.log('[SW] navigate (network-only) →', url.pathname);
|
|
198
198
|
return;
|
|
199
199
|
}
|
|
200
200
|
|
|
@@ -421,7 +421,6 @@ export async function startSupervisor() {
|
|
|
421
421
|
log.error(`Vite dev server failed to start — dashboard degraded, chat still available: ${err instanceof Error ? err.message : err}`);
|
|
422
422
|
vitePorts = { dashboard: -1 }; // sentinel → dashboard proxy serves RECOVERING_HTML
|
|
423
423
|
}
|
|
424
|
-
console.log(`[supervisor] Upgrade listeners on server: ${server.listenerCount('upgrade')}`);
|
|
425
424
|
|
|
426
425
|
// Ensure file storage dirs exist
|
|
427
426
|
ensureFileDirs();
|
|
@@ -584,7 +583,6 @@ export async function startSupervisor() {
|
|
|
584
583
|
server.on('request', async (req, res) => {
|
|
585
584
|
// Bloby widget — served directly (not part of Vite build)
|
|
586
585
|
if (req.url === '/bloby/widget.js') {
|
|
587
|
-
console.log('[supervisor] Serving /bloby/widget.js directly');
|
|
588
586
|
res.writeHead(200, { 'Content-Type': 'application/javascript', 'Cache-Control': 'no-cache' });
|
|
589
587
|
res.end(fs.readFileSync(paths.widgetJs));
|
|
590
588
|
return;
|
|
@@ -592,7 +590,6 @@ export async function startSupervisor() {
|
|
|
592
590
|
|
|
593
591
|
// App WS client — served directly (proxies /app/api calls through WebSocket)
|
|
594
592
|
if (req.url === '/bloby/app-ws.js') {
|
|
595
|
-
console.log('[supervisor] Serving /bloby/app-ws.js directly');
|
|
596
593
|
res.writeHead(200, { 'Content-Type': 'application/javascript', 'Cache-Control': 'no-cache' });
|
|
597
594
|
res.end(fs.readFileSync(path.join(PKG_DIR, 'supervisor', 'app-ws.js')));
|
|
598
595
|
return;
|
|
@@ -626,9 +623,7 @@ export async function startSupervisor() {
|
|
|
626
623
|
// App API routes → proxy to user's backend server
|
|
627
624
|
if (req.url?.startsWith('/app/api')) {
|
|
628
625
|
const backendPath = req.url.replace(/^\/app/, '');
|
|
629
|
-
console.log(`[supervisor] → backend :${backendPort} | ${req.method} ${backendPath}`);
|
|
630
626
|
if (!isBackendAlive()) {
|
|
631
|
-
console.log('[supervisor] Backend down — returning 503');
|
|
632
627
|
res.writeHead(503, { 'Content-Type': 'application/json' });
|
|
633
628
|
res.end(JSON.stringify({ error: 'Backend is starting...' }));
|
|
634
629
|
return;
|
|
@@ -639,18 +634,10 @@ export async function startSupervisor() {
|
|
|
639
634
|
(proxyRes) => {
|
|
640
635
|
const ct = String(proxyRes.headers['content-type'] || '');
|
|
641
636
|
const isSse = ct.includes('text/event-stream');
|
|
642
|
-
if (isSse) {
|
|
643
|
-
console.log(`[app-proxy] SSE upstream status=${proxyRes.statusCode} ct="${ct}" url=${backendPath}`);
|
|
644
|
-
}
|
|
645
637
|
res.writeHead(proxyRes.statusCode!, proxyRes.headers);
|
|
638
|
+
// SSE needs Nagle off so chat tokens flush immediately instead of in batched frames.
|
|
646
639
|
if (isSse) {
|
|
647
640
|
try { res.socket?.setNoDelay(true); } catch {}
|
|
648
|
-
proxyRes.on('data', (chunk: Buffer) => {
|
|
649
|
-
console.log(`[app-proxy] SSE chunk bytes=${chunk.length} preview=${JSON.stringify(chunk.toString('utf-8').slice(0, 80))}`);
|
|
650
|
-
});
|
|
651
|
-
proxyRes.on('end', () => console.log(`[app-proxy] SSE upstream END`));
|
|
652
|
-
proxyRes.on('error', (e: any) => console.log(`[app-proxy] SSE upstream ERROR ${e.message}`));
|
|
653
|
-
res.on('close', () => console.log(`[app-proxy] SSE res CLOSE`));
|
|
654
641
|
}
|
|
655
642
|
proxyRes.pipe(res);
|
|
656
643
|
},
|
|
@@ -2134,7 +2121,6 @@ ${alreadyLinked ? '' : `
|
|
|
2134
2121
|
if (req.method === 'GET' && agentPath === '/api/agent/chat/stream') {
|
|
2135
2122
|
const clientId = urlObj.searchParams.get('clientId') || undefined;
|
|
2136
2123
|
const subId = crypto.randomBytes(8).toString('hex');
|
|
2137
|
-
console.log(`[sse-handler] OPEN sub=${subId} clientId=${clientId} remote=${req.socket.remoteAddress}`);
|
|
2138
2124
|
|
|
2139
2125
|
res.setHeader('Content-Type', 'text/event-stream');
|
|
2140
2126
|
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate');
|
|
@@ -2142,19 +2128,14 @@ ${alreadyLinked ? '' : `
|
|
|
2142
2128
|
res.setHeader('X-Accel-Buffering', 'no');
|
|
2143
2129
|
res.writeHead(200);
|
|
2144
2130
|
try { res.socket?.setNoDelay(true); } catch {}
|
|
2145
|
-
|
|
2146
|
-
console.log(`[sse-handler] wrote initial comment sub=${subId} writeOk=${wrote}`);
|
|
2131
|
+
res.write(': connected\n\n');
|
|
2147
2132
|
|
|
2148
2133
|
const sub: ChatSubscriber = {
|
|
2149
2134
|
id: subId,
|
|
2150
2135
|
clientId,
|
|
2151
2136
|
send: (type, data) => {
|
|
2152
|
-
if (res.writableEnded)
|
|
2153
|
-
|
|
2154
|
-
return;
|
|
2155
|
-
}
|
|
2156
|
-
const ok = res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
2157
|
-
console.log(`[sse-handler] write sub=${subId} type=${type} ok=${ok} bytes=${(type.length + JSON.stringify(data).length + 10)}`);
|
|
2137
|
+
if (res.writableEnded) return;
|
|
2138
|
+
res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`);
|
|
2158
2139
|
},
|
|
2159
2140
|
close: () => { try { res.end(); } catch {} },
|
|
2160
2141
|
};
|
|
@@ -2171,20 +2152,14 @@ ${alreadyLinked ? '' : `
|
|
|
2171
2152
|
const keepAlive = setInterval(() => {
|
|
2172
2153
|
if (res.writableEnded) return;
|
|
2173
2154
|
res.write(': ping\n\n');
|
|
2174
|
-
console.log(`[sse-handler] ping sub=${subId}`);
|
|
2175
2155
|
}, 25_000);
|
|
2176
2156
|
|
|
2177
2157
|
req.on('close', () => {
|
|
2178
|
-
console.log(`[sse-handler] CLOSE sub=${subId} reason=req-close`);
|
|
2179
2158
|
clearInterval(keepAlive);
|
|
2180
2159
|
chatSubscribers.delete(sub);
|
|
2181
2160
|
});
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
});
|
|
2185
|
-
res.on('error', (err: any) => {
|
|
2186
|
-
console.log(`[sse-handler] ERROR sub=${subId} ${err?.message}`);
|
|
2187
|
-
});
|
|
2161
|
+
// Keep an error listener so a client reset can't surface as an unhandled 'error'.
|
|
2162
|
+
res.on('error', () => {});
|
|
2188
2163
|
return;
|
|
2189
2164
|
}
|
|
2190
2165
|
|
|
@@ -2560,6 +2535,47 @@ ${alreadyLinked ? '' : `
|
|
|
2560
2535
|
} catch { /* fall through to Vite */ }
|
|
2561
2536
|
}
|
|
2562
2537
|
|
|
2538
|
+
// Document-ish request? Used by the backend-down interstitial (NOT the shell branch,
|
|
2539
|
+
// which needs top-level-only gating). sec-fetch-dest 'document' covers top-level
|
|
2540
|
+
// navigations; sec-fetch-mode 'navigate' also matches iframe documents (dest 'iframe',
|
|
2541
|
+
// wanted here — the interstitial must show inside the workspace frame too); the accept
|
|
2542
|
+
// fallback covers old clients that send no sec-fetch headers at all.
|
|
2543
|
+
const wantsHtml = req.method === 'GET' && (
|
|
2544
|
+
req.headers['sec-fetch-dest'] === 'document' ||
|
|
2545
|
+
req.headers['sec-fetch-mode'] === 'navigate' ||
|
|
2546
|
+
(!req.headers['sec-fetch-dest'] && String(req.headers['accept'] || '').includes('text/html'))
|
|
2547
|
+
);
|
|
2548
|
+
|
|
2549
|
+
// SHELL INVERSION: TOP-LEVEL document navigations get the immortal shell (bubble +
|
|
2550
|
+
// chat + a same-origin iframe that hosts the actual workspace app). The iframe
|
|
2551
|
+
// re-requests the same URL with ?__bloby_frame=1, which skips this branch and falls
|
|
2552
|
+
// through to the interstitials / Vite proxy below — so Vite reloads, rebuilds, and
|
|
2553
|
+
// crash pages all happen INSIDE the iframe while the chat chrome survives. (The
|
|
2554
|
+
// Lovable/Bolt pattern.) The __bloby_frame param is lost on real in-frame navigations
|
|
2555
|
+
// (<a href> links, location.href redirects, 302s), so the frame is also excluded by
|
|
2556
|
+
// sec-fetch-dest — those requests carry dest 'iframe' and must fall through to Vite,
|
|
2557
|
+
// never get a nested shell. wantsHtml is intentionally NOT reused here: its
|
|
2558
|
+
// mode === 'navigate' arm matches iframe documents (fine for the interstitial below,
|
|
2559
|
+
// wrong for the shell). pathname === '/' is special-cased because the service worker's
|
|
2560
|
+
// install-time precache fetch of '/' may carry no sec-fetch/accept headers yet must
|
|
2561
|
+
// still cache the shell.
|
|
2562
|
+
// Kill switch: BLOBY_NO_SHELL=1 restores legacy behavior (workspace served top-level;
|
|
2563
|
+
// widget.js injects the bubble into the workspace document as before).
|
|
2564
|
+
const fetchDest = req.headers['sec-fetch-dest'];
|
|
2565
|
+
const isSubframe = fetchDest === 'iframe' || fetchDest === 'frame' || fetchDest === 'embed' || fetchDest === 'object';
|
|
2566
|
+
if (
|
|
2567
|
+
req.method === 'GET' &&
|
|
2568
|
+
process.env.BLOBY_NO_SHELL !== '1' &&
|
|
2569
|
+
!(req.url || '').includes('__bloby_frame=1') &&
|
|
2570
|
+
!isSubframe &&
|
|
2571
|
+
(cleanUrl === '/' || fetchDest === 'document' ||
|
|
2572
|
+
(!fetchDest && String(req.headers['accept'] || '').includes('text/html')))
|
|
2573
|
+
) {
|
|
2574
|
+
res.writeHead(200, { 'Content-Type': 'text/html', 'Cache-Control': 'no-cache', 'X-Bloby-Origin': 'supervisor' });
|
|
2575
|
+
res.end(SHELL_HTML);
|
|
2576
|
+
return;
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2563
2579
|
// Workspace backend has crash-looped and given up → serve the "backend down" interstitial
|
|
2564
2580
|
// for dashboard DOCUMENT navigations, instead of proxying to Vite (which serves the user's
|
|
2565
2581
|
// SPA that then 503s on every /app/api call and, for the common workspace-lock template,
|
|
@@ -2567,11 +2583,6 @@ ${alreadyLinked ? '' : `
|
|
|
2567
2583
|
// top-level navigations only (not assets/HMR/XHR) and only when the backend has truly given
|
|
2568
2584
|
// up — never during a normal 1–2s restart. The chat PWA (/bloby/*) is served earlier and is
|
|
2569
2585
|
// unaffected.
|
|
2570
|
-
const wantsHtml = req.method === 'GET' && (
|
|
2571
|
-
req.headers['sec-fetch-dest'] === 'document' ||
|
|
2572
|
-
req.headers['sec-fetch-mode'] === 'navigate' ||
|
|
2573
|
-
(!req.headers['sec-fetch-dest'] && String(req.headers['accept'] || '').includes('text/html'))
|
|
2574
|
-
);
|
|
2575
2586
|
if (wantsHtml && isBackendDead()) {
|
|
2576
2587
|
// X-Bloby-Origin marks this as the agent's OWN branded page so the relay passes it through
|
|
2577
2588
|
// (and never mistakes it for a Cloudflare tunnel error to be replaced).
|
|
@@ -2589,7 +2600,6 @@ ${alreadyLinked ? '' : `
|
|
|
2589
2600
|
}
|
|
2590
2601
|
|
|
2591
2602
|
// Everything else → proxy to dashboard Vite dev server
|
|
2592
|
-
console.log(`[supervisor] → dashboard Vite :${vitePorts.dashboard} | ${req.method} ${(req.url || '').split('?')[0]}`);
|
|
2593
2603
|
const GUARD_TAG = '<script defer src="/bloby/workspace-guard.js"></script>';
|
|
2594
2604
|
const proxy = http.request(
|
|
2595
2605
|
{ host: '127.0.0.1', port: vitePorts.dashboard, path: req.url, method: req.method, headers: req.headers },
|
|
@@ -2638,10 +2648,9 @@ ${alreadyLinked ? '' : `
|
|
|
2638
2648
|
const appWss = new WebSocketServer({ noServer: true });
|
|
2639
2649
|
|
|
2640
2650
|
appWss.on('connection', (ws) => {
|
|
2641
|
-
console.log('[supervisor] App API WS client connected');
|
|
2642
2651
|
// An 'error' event with no listener is rethrown by Node as an uncaught exception,
|
|
2643
2652
|
// which would crash the whole supervisor. ws still tears down + fires 'close'.
|
|
2644
|
-
ws.on('error', (
|
|
2653
|
+
ws.on('error', () => {});
|
|
2645
2654
|
// Liveness: a half-open socket (mobile/Wi-Fi drop behind the tunnel) never fires 'close', so
|
|
2646
2655
|
// its chat subscription + maps would leak and broadcastBloby would keep writing to it. The
|
|
2647
2656
|
// heartbeat below pings; a peer that misses a pong is terminated (which fires 'close' → cleanup).
|
|
@@ -2683,7 +2692,6 @@ ${alreadyLinked ? '' : `
|
|
|
2683
2692
|
close: () => {},
|
|
2684
2693
|
};
|
|
2685
2694
|
chatSubscribers.add(chatSub);
|
|
2686
|
-
console.log(`[app-ws-chat] subscribe sub=${subId} clientId=${clientId} total=${chatSubscribers.size}`);
|
|
2687
2695
|
|
|
2688
2696
|
if (agentQueryActive && currentStreamConvId) {
|
|
2689
2697
|
chatSub.send('chat:state', {
|
|
@@ -2701,7 +2709,6 @@ ${alreadyLinked ? '' : `
|
|
|
2701
2709
|
if (msg.type === 'chat:unsubscribe') {
|
|
2702
2710
|
if (chatSub) {
|
|
2703
2711
|
chatSubscribers.delete(chatSub);
|
|
2704
|
-
console.log(`[app-ws-chat] unsubscribe sub=${chatSub.id} total=${chatSubscribers.size}`);
|
|
2705
2712
|
chatSub = null;
|
|
2706
2713
|
}
|
|
2707
2714
|
return;
|
|
@@ -2712,10 +2719,8 @@ ${alreadyLinked ? '' : `
|
|
|
2712
2719
|
const { id, method, path: reqPath, headers: reqHeaders, body } = msg.data;
|
|
2713
2720
|
const backendPath = (reqPath || '').replace(/^\/app/, '');
|
|
2714
2721
|
|
|
2715
|
-
console.log(`[supervisor] App WS → backend :${backendPort} | ${method} ${backendPath} (${id})`);
|
|
2716
2722
|
|
|
2717
2723
|
if (!isBackendAlive()) {
|
|
2718
|
-
console.log('[supervisor] App WS: Backend down — returning 503');
|
|
2719
2724
|
if (ws.readyState === WebSocket.OPEN) {
|
|
2720
2725
|
ws.send(JSON.stringify({
|
|
2721
2726
|
type: 'app:api:response',
|
|
@@ -2743,7 +2748,6 @@ ${alreadyLinked ? '' : `
|
|
|
2743
2748
|
else if (Array.isArray(v)) resHeaders[k] = v.join(', ');
|
|
2744
2749
|
}
|
|
2745
2750
|
|
|
2746
|
-
console.log(`[supervisor] App WS ← backend: ${proxyRes.statusCode} (${id})`);
|
|
2747
2751
|
|
|
2748
2752
|
if (ws.readyState === WebSocket.OPEN) {
|
|
2749
2753
|
ws.send(JSON.stringify({
|
|
@@ -2772,10 +2776,8 @@ ${alreadyLinked ? '' : `
|
|
|
2772
2776
|
ws.on('close', () => {
|
|
2773
2777
|
if (chatSub) {
|
|
2774
2778
|
chatSubscribers.delete(chatSub);
|
|
2775
|
-
console.log(`[app-ws-chat] auto-unsubscribe on close sub=${chatSub.id} total=${chatSubscribers.size}`);
|
|
2776
2779
|
chatSub = null;
|
|
2777
2780
|
}
|
|
2778
|
-
console.log('[supervisor] App API WS client disconnected');
|
|
2779
2781
|
});
|
|
2780
2782
|
});
|
|
2781
2783
|
|
|
@@ -2783,18 +2785,11 @@ ${alreadyLinked ? '' : `
|
|
|
2783
2785
|
* subscribers. The workspace mirror sees the exact same event stream the widget does. */
|
|
2784
2786
|
function broadcastBloby(type: string, data: any = {}) {
|
|
2785
2787
|
const msg = JSON.stringify({ type, data });
|
|
2786
|
-
let wsCount = 0;
|
|
2787
2788
|
for (const client of blobyWss.clients) {
|
|
2788
|
-
if (client.readyState === WebSocket.OPEN)
|
|
2789
|
+
if (client.readyState === WebSocket.OPEN) client.send(msg);
|
|
2789
2790
|
}
|
|
2790
|
-
let sseCount = 0;
|
|
2791
2791
|
for (const sub of chatSubscribers) {
|
|
2792
|
-
try { sub.send(type, data);
|
|
2793
|
-
console.log(`[sse-broadcast] send failed sub=${sub.id}: ${e.message}`);
|
|
2794
|
-
}
|
|
2795
|
-
}
|
|
2796
|
-
if (type.startsWith('bot:') || type.startsWith('chat:')) {
|
|
2797
|
-
console.log(`[sse-broadcast] type=${type} ws=${wsCount} sse=${sseCount} subs=${chatSubscribers.size}`);
|
|
2792
|
+
try { sub.send(type, data); } catch {}
|
|
2798
2793
|
}
|
|
2799
2794
|
}
|
|
2800
2795
|
|
|
@@ -3365,17 +3360,14 @@ ${alreadyLinked ? '' : `
|
|
|
3365
3360
|
// Bloby chat WebSocket — Vite HMR is handled automatically (hmr.server = this server)
|
|
3366
3361
|
server.on('upgrade', async (req, socket: net.Socket, head) => {
|
|
3367
3362
|
// Strip the query string: /bloby/ws?token=<7-day session token> must not be logged.
|
|
3368
|
-
console.log(`[supervisor] WebSocket upgrade: ${(req.url || '').split('?')[0]} | protocol=${req.headers['sec-websocket-protocol'] || 'none'}`);
|
|
3369
3363
|
|
|
3370
3364
|
// App API WebSocket — no auth (backend handles its own auth)
|
|
3371
3365
|
if (req.url?.startsWith('/app/ws')) {
|
|
3372
|
-
console.log('[supervisor] → App API WebSocket');
|
|
3373
3366
|
appWss.handleUpgrade(req, socket, head, (ws) => appWss.emit('connection', ws, req));
|
|
3374
3367
|
return;
|
|
3375
3368
|
}
|
|
3376
3369
|
|
|
3377
3370
|
if (!req.url?.startsWith('/bloby/ws')) {
|
|
3378
|
-
console.log('[supervisor] → Letting Vite handle this upgrade');
|
|
3379
3371
|
return;
|
|
3380
3372
|
}
|
|
3381
3373
|
|
|
@@ -3392,7 +3384,6 @@ ${alreadyLinked ? '' : `
|
|
|
3392
3384
|
}
|
|
3393
3385
|
}
|
|
3394
3386
|
|
|
3395
|
-
console.log('[supervisor] → Bloby chat WebSocket');
|
|
3396
3387
|
blobyWss.handleUpgrade(req, socket, head, (ws) => blobyWss.emit('connection', ws, req));
|
|
3397
3388
|
});
|
|
3398
3389
|
|
|
@@ -3818,7 +3809,6 @@ ${alreadyLinked ? '' : `
|
|
|
3818
3809
|
closeDb();
|
|
3819
3810
|
await stopBackend();
|
|
3820
3811
|
stopTunnel();
|
|
3821
|
-
console.log('[supervisor] Stopping Vite dev servers...');
|
|
3822
3812
|
await stopViteDevServers();
|
|
3823
3813
|
server.close();
|
|
3824
3814
|
process.exit(0);
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// The immortal shell — served by the supervisor at top-level '/' (and document navigations).
|
|
2
|
+
// It contains ONLY the bubble + chat chrome (widget.js) and a same-origin iframe hosting the
|
|
3
|
+
// user's workspace app. There is NO Vite client here, NO HMR, no service-worker auto-reload —
|
|
4
|
+
// nothing that can reload this page. All workspace reloads, rebuilds, and interstitials
|
|
5
|
+
// (RECOVERING_HTML / backend-down) happen INSIDE the iframe, so the chat — the user's lifeline
|
|
6
|
+
// to the agent — never dies. The iframe src carries ?__bloby_frame=1 so the supervisor routes
|
|
7
|
+
// it past the shell branch to the real workspace (Vite proxy / interstitials).
|
|
8
|
+
// Kill switch: BLOBY_NO_SHELL=1 makes the supervisor skip this entirely (legacy top-level
|
|
9
|
+
// workspace). Static string by design — no server-side templating.
|
|
10
|
+
|
|
11
|
+
export const SHELL_HTML = `<!DOCTYPE html>
|
|
12
|
+
<html lang="en" style="background-color:#0A0A0A;margin:0;overflow:hidden">
|
|
13
|
+
<head>
|
|
14
|
+
<meta charset="UTF-8" />
|
|
15
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, interactive-widget=resizes-content" />
|
|
16
|
+
<meta name="theme-color" content="#212121" />
|
|
17
|
+
<meta name="mobile-web-app-capable" content="yes" />
|
|
18
|
+
<meta name="apple-mobile-web-app-capable" content="yes" />
|
|
19
|
+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
|
20
|
+
<meta name="apple-mobile-web-app-title" content="Bloby" />
|
|
21
|
+
<link rel="icon" type="image/png" href="/morphy-favicon.png" />
|
|
22
|
+
<link rel="apple-touch-icon" href="/morphy-icon-192.png" />
|
|
23
|
+
<link rel="manifest" href="/manifest.json" />
|
|
24
|
+
<title>Bloby</title>
|
|
25
|
+
</head>
|
|
26
|
+
<body style="background-color:#0A0A0A;margin:0;overflow:hidden">
|
|
27
|
+
<!-- Dark background fallback — visible instantly while widget.js loads.
|
|
28
|
+
The canvas animation (in widget.js) takes over as the actual splash. -->
|
|
29
|
+
<div id="splash" style="background:#0A0A0A;position:fixed;inset:0;z-index:9998;transition:opacity .25s ease-out"></div>
|
|
30
|
+
|
|
31
|
+
<iframe
|
|
32
|
+
id="bloby-workspace"
|
|
33
|
+
style="position:fixed;inset:0;width:100vw;height:100dvh;border:none;background:#0A0A0A"
|
|
34
|
+
allow="camera; microphone; geolocation; clipboard-read; clipboard-write; fullscreen; autoplay; display-capture"
|
|
35
|
+
></iframe>
|
|
36
|
+
|
|
37
|
+
<script>
|
|
38
|
+
(function () {
|
|
39
|
+
var frame = document.getElementById('bloby-workspace');
|
|
40
|
+
|
|
41
|
+
// Preserve deep links: the iframe loads the SAME url the shell was opened with, plus
|
|
42
|
+
// __bloby_frame=1 so the supervisor serves the workspace instead of the shell again.
|
|
43
|
+
// Collapse leading slashes first — a pathname like '//evil.com' would otherwise be a
|
|
44
|
+
// protocol-relative URL and point the iframe off-origin under our trusted domain.
|
|
45
|
+
var path = location.pathname.replace(/^\\/+/, '/');
|
|
46
|
+
var target = new URL(path + location.search + location.hash, location.origin);
|
|
47
|
+
if (target.origin !== location.origin) target = new URL('/', location.origin);
|
|
48
|
+
target.searchParams.set('__bloby_frame', '1');
|
|
49
|
+
|
|
50
|
+
// Framed shell self-check: browsers without Fetch Metadata (older Safari/WebViews) can
|
|
51
|
+
// get a shell served INTO the workspace iframe when an in-frame navigation loses the
|
|
52
|
+
// __bloby_frame param (the supervisor's sec-fetch-dest exclusion can't see those).
|
|
53
|
+
// A nested shell replaces itself with the workspace document it should have been.
|
|
54
|
+
try {
|
|
55
|
+
if (window.top !== window) {
|
|
56
|
+
location.replace(target.pathname + target.search + target.hash);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
} catch (e) { /* cross-origin top — not our nesting case; carry on */ }
|
|
60
|
+
|
|
61
|
+
frame.src = target.pathname + target.search + target.hash;
|
|
62
|
+
|
|
63
|
+
// widget.js (loaded below, in THIS document) waits for window.__blobyAppReady / the
|
|
64
|
+
// 'bloby:app-ready' window event before transitioning splash → bubble. The workspace
|
|
65
|
+
// posts 'bloby:app-ready' up to us; we re-dispatch it locally. Idempotent.
|
|
66
|
+
var appReadyFired = false;
|
|
67
|
+
function fireAppReady() {
|
|
68
|
+
if (appReadyFired) return;
|
|
69
|
+
appReadyFired = true;
|
|
70
|
+
window.__blobyAppReady = true;
|
|
71
|
+
window.dispatchEvent(new Event('bloby:app-ready'));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
window.addEventListener('message', function (e) {
|
|
75
|
+
// Same-origin iframe only — drop anything else (extensions, third-party embeds).
|
|
76
|
+
if (e.origin !== location.origin) return;
|
|
77
|
+
var d = e.data;
|
|
78
|
+
if (!d || typeof d.type !== 'string') return;
|
|
79
|
+
if (d.type === 'bloby:app-ready') {
|
|
80
|
+
fireAppReady();
|
|
81
|
+
} else if (d.type === 'bloby:title') {
|
|
82
|
+
document.title = String(d.title || '').slice(0, 200) || 'Bloby';
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Interstitials and broken workspaces never post 'bloby:app-ready' — the bubble must
|
|
87
|
+
// still appear (it IS the recovery path). Fire on iframe load, with a timer backstop
|
|
88
|
+
// for the case where the iframe never finishes loading at all.
|
|
89
|
+
frame.addEventListener('load', fireAppReady);
|
|
90
|
+
setTimeout(fireAppReady, 8000);
|
|
91
|
+
|
|
92
|
+
// Last-resort splash backstop: only widget.js ever hides #splash (it does so as soon
|
|
93
|
+
// as its sprite loads, ~1s). If widget.js fails to load, throws, or its sprite fetch
|
|
94
|
+
// fails, the shell — the user's lifeline — must not sit as a permanent black screen
|
|
95
|
+
// over the workspace iframe.
|
|
96
|
+
setTimeout(function () {
|
|
97
|
+
var s = document.getElementById('splash');
|
|
98
|
+
if (s && s.style.display !== 'none') s.style.display = 'none';
|
|
99
|
+
}, 10000);
|
|
100
|
+
})();
|
|
101
|
+
</script>
|
|
102
|
+
|
|
103
|
+
<script>
|
|
104
|
+
// Register the SW but DO NOT auto-reload on controllerchange.
|
|
105
|
+
// The old pattern (skipWaiting + claim + controllerchange → location.reload)
|
|
106
|
+
// caused surprise reloads on mobile PWAs: iOS aggressively re-checks sw.js
|
|
107
|
+
// and any byte-level variation in the response (esp. via Vite dev) trips a
|
|
108
|
+
// new SW activation → controllerchange → unwanted reload. The new SW
|
|
109
|
+
// silently takes over for future fetches; the user gets the new build on
|
|
110
|
+
// their next natural refresh.
|
|
111
|
+
if ('serviceWorker' in navigator) {
|
|
112
|
+
navigator.serviceWorker.register('/sw.js').catch(function (err) {
|
|
113
|
+
console.error('[sw-reg] registration failed:', err);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
</script>
|
|
117
|
+
<script src="/bloby/widget.js"></script>
|
|
118
|
+
</body>
|
|
119
|
+
</html>
|
|
120
|
+
`;
|