bloby-bot 0.63.0 → 0.64.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/index.ts +47 -6
- package/supervisor/shell.ts +120 -0
- package/supervisor/widget.js +26 -7
- package/supervisor/workspace-guard.js +213 -7
- 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/public/sw.js +1 -1
- package/workspace/client/src/App.tsx +13 -103
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bloby-bot",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.64.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/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,7 +121,7 @@ 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.
|
|
@@ -184,6 +185,10 @@ self.addEventListener('fetch', function(event) {
|
|
|
184
185
|
// caching the wrong HTML under the / key.
|
|
185
186
|
if (request.mode === 'navigate') {
|
|
186
187
|
console.log('[SW] navigate →', url.pathname);
|
|
188
|
+
// Workspace iframe documents (?__bloby_frame=1) share pathname '/' with the shell.
|
|
189
|
+
// Network-only: never c.put() them (would overwrite the precached shell under the '/'
|
|
190
|
+
// key) and never fall back to c.match('/') (would serve the shell INTO the iframe).
|
|
191
|
+
if (url.searchParams.has('__bloby_frame')) return;
|
|
187
192
|
if (url.pathname === '/' || url.pathname === '/index.html') {
|
|
188
193
|
// Network-first: always fetch the live dashboard; only fall back to cache when offline.
|
|
189
194
|
event.respondWith(caches.open(CACHE).then(function(c) {
|
|
@@ -2560,6 +2565,47 @@ ${alreadyLinked ? '' : `
|
|
|
2560
2565
|
} catch { /* fall through to Vite */ }
|
|
2561
2566
|
}
|
|
2562
2567
|
|
|
2568
|
+
// Document-ish request? Used by the backend-down interstitial (NOT the shell branch,
|
|
2569
|
+
// which needs top-level-only gating). sec-fetch-dest 'document' covers top-level
|
|
2570
|
+
// navigations; sec-fetch-mode 'navigate' also matches iframe documents (dest 'iframe',
|
|
2571
|
+
// wanted here — the interstitial must show inside the workspace frame too); the accept
|
|
2572
|
+
// fallback covers old clients that send no sec-fetch headers at all.
|
|
2573
|
+
const wantsHtml = req.method === 'GET' && (
|
|
2574
|
+
req.headers['sec-fetch-dest'] === 'document' ||
|
|
2575
|
+
req.headers['sec-fetch-mode'] === 'navigate' ||
|
|
2576
|
+
(!req.headers['sec-fetch-dest'] && String(req.headers['accept'] || '').includes('text/html'))
|
|
2577
|
+
);
|
|
2578
|
+
|
|
2579
|
+
// SHELL INVERSION: TOP-LEVEL document navigations get the immortal shell (bubble +
|
|
2580
|
+
// chat + a same-origin iframe that hosts the actual workspace app). The iframe
|
|
2581
|
+
// re-requests the same URL with ?__bloby_frame=1, which skips this branch and falls
|
|
2582
|
+
// through to the interstitials / Vite proxy below — so Vite reloads, rebuilds, and
|
|
2583
|
+
// crash pages all happen INSIDE the iframe while the chat chrome survives. (The
|
|
2584
|
+
// Lovable/Bolt pattern.) The __bloby_frame param is lost on real in-frame navigations
|
|
2585
|
+
// (<a href> links, location.href redirects, 302s), so the frame is also excluded by
|
|
2586
|
+
// sec-fetch-dest — those requests carry dest 'iframe' and must fall through to Vite,
|
|
2587
|
+
// never get a nested shell. wantsHtml is intentionally NOT reused here: its
|
|
2588
|
+
// mode === 'navigate' arm matches iframe documents (fine for the interstitial below,
|
|
2589
|
+
// wrong for the shell). pathname === '/' is special-cased because the service worker's
|
|
2590
|
+
// install-time precache fetch of '/' may carry no sec-fetch/accept headers yet must
|
|
2591
|
+
// still cache the shell.
|
|
2592
|
+
// Kill switch: BLOBY_NO_SHELL=1 restores legacy behavior (workspace served top-level;
|
|
2593
|
+
// widget.js injects the bubble into the workspace document as before).
|
|
2594
|
+
const fetchDest = req.headers['sec-fetch-dest'];
|
|
2595
|
+
const isSubframe = fetchDest === 'iframe' || fetchDest === 'frame' || fetchDest === 'embed' || fetchDest === 'object';
|
|
2596
|
+
if (
|
|
2597
|
+
req.method === 'GET' &&
|
|
2598
|
+
process.env.BLOBY_NO_SHELL !== '1' &&
|
|
2599
|
+
!(req.url || '').includes('__bloby_frame=1') &&
|
|
2600
|
+
!isSubframe &&
|
|
2601
|
+
(cleanUrl === '/' || fetchDest === 'document' ||
|
|
2602
|
+
(!fetchDest && String(req.headers['accept'] || '').includes('text/html')))
|
|
2603
|
+
) {
|
|
2604
|
+
res.writeHead(200, { 'Content-Type': 'text/html', 'Cache-Control': 'no-cache', 'X-Bloby-Origin': 'supervisor' });
|
|
2605
|
+
res.end(SHELL_HTML);
|
|
2606
|
+
return;
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2563
2609
|
// Workspace backend has crash-looped and given up → serve the "backend down" interstitial
|
|
2564
2610
|
// for dashboard DOCUMENT navigations, instead of proxying to Vite (which serves the user's
|
|
2565
2611
|
// SPA that then 503s on every /app/api call and, for the common workspace-lock template,
|
|
@@ -2567,11 +2613,6 @@ ${alreadyLinked ? '' : `
|
|
|
2567
2613
|
// top-level navigations only (not assets/HMR/XHR) and only when the backend has truly given
|
|
2568
2614
|
// up — never during a normal 1–2s restart. The chat PWA (/bloby/*) is served earlier and is
|
|
2569
2615
|
// 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
2616
|
if (wantsHtml && isBackendDead()) {
|
|
2576
2617
|
// X-Bloby-Origin marks this as the agent's OWN branded page so the relay passes it through
|
|
2577
2618
|
// (and never mistakes it for a Cloudflare tunnel error to be replaced).
|
|
@@ -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="apple-mobile-web-app-capable" content="yes" />
|
|
18
|
+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
|
19
|
+
<meta name="apple-mobile-web-app-title" content="Bloby" />
|
|
20
|
+
<link rel="icon" type="image/png" href="/morphy-favicon.png" />
|
|
21
|
+
<link rel="apple-touch-icon" href="/morphy-icon-192.png" />
|
|
22
|
+
<link rel="manifest" href="/manifest.json" />
|
|
23
|
+
<title>Bloby</title>
|
|
24
|
+
</head>
|
|
25
|
+
<body style="background-color:#0A0A0A;margin:0;overflow:hidden">
|
|
26
|
+
<!-- Dark background fallback — visible instantly while widget.js loads.
|
|
27
|
+
The canvas animation (in widget.js) takes over as the actual splash. -->
|
|
28
|
+
<div id="splash" style="background:#0A0A0A;position:fixed;inset:0;z-index:9998;transition:opacity .25s ease-out"></div>
|
|
29
|
+
|
|
30
|
+
<iframe
|
|
31
|
+
id="bloby-workspace"
|
|
32
|
+
style="position:fixed;inset:0;width:100vw;height:100dvh;border:none;background:#0A0A0A"
|
|
33
|
+
allow="camera; microphone; geolocation; clipboard-read; clipboard-write; fullscreen; autoplay; display-capture"
|
|
34
|
+
allowfullscreen
|
|
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
|
+
`;
|
package/supervisor/widget.js
CHANGED
|
@@ -1,4 +1,19 @@
|
|
|
1
1
|
(function () {
|
|
2
|
+
// app-ws.js (the /app/api → /app/ws fetch proxy) must load in EVERY document
|
|
3
|
+
// that includes widget.js — in shell mode the workspace runs inside the
|
|
4
|
+
// iframe, where the early return below would skip it, leaving the user's
|
|
5
|
+
// /app/api calls as raw tunnel POSTs. app-ws.js guards itself
|
|
6
|
+
// (window.__appWs), so duplicate loads are no-ops.
|
|
7
|
+
var awsScript = document.createElement('script');
|
|
8
|
+
awsScript.src = '/bloby/app-ws.js';
|
|
9
|
+
document.head.appendChild(awsScript);
|
|
10
|
+
|
|
11
|
+
// Shell mode: the immortal shell loads this script at top level, but legacy
|
|
12
|
+
// workspace index.html + the interstitial pages still reference it too, and
|
|
13
|
+
// those now render inside the shell's iframe — inside any iframe this must
|
|
14
|
+
// no-op so exactly one bubble exists. Cross-origin window.top access throws,
|
|
15
|
+
// which also means we're not the top window.
|
|
16
|
+
try { if (window.top !== window) return; } catch (e) { return; }
|
|
2
17
|
if (document.getElementById('bloby-widget')) return;
|
|
3
18
|
|
|
4
19
|
var PANEL_WIDTH = '480px';
|
|
@@ -571,7 +586,7 @@
|
|
|
571
586
|
var reader = new FileReader();
|
|
572
587
|
reader.onloadend = function() {
|
|
573
588
|
var base64 = reader.result.split(',')[1];
|
|
574
|
-
if (base64 && iframe.contentWindow) iframe.contentWindow.postMessage({ type: 'bloby:voice-record', audio: base64 },
|
|
589
|
+
if (base64 && iframe.contentWindow) iframe.contentWindow.postMessage({ type: 'bloby:voice-record', audio: base64 }, location.origin);
|
|
575
590
|
};
|
|
576
591
|
reader.readAsDataURL(blob);
|
|
577
592
|
};
|
|
@@ -583,7 +598,7 @@
|
|
|
583
598
|
if (sent) return; sent = true;
|
|
584
599
|
var text = hpSpeechTranscript.trim();
|
|
585
600
|
hpSpeechTranscript = '';
|
|
586
|
-
if (text && iframe.contentWindow) iframe.contentWindow.postMessage({ type: 'bloby:voice-record', transcript: text },
|
|
601
|
+
if (text && iframe.contentWindow) iframe.contentWindow.postMessage({ type: 'bloby:voice-record', transcript: text }, location.origin);
|
|
587
602
|
};
|
|
588
603
|
instance.onend = function() { hpSpeechInstance = null; sendTranscript(); };
|
|
589
604
|
try { instance.stop(); } catch(e) { hpSpeechInstance = null; sendTranscript(); }
|
|
@@ -760,21 +775,25 @@
|
|
|
760
775
|
|
|
761
776
|
// Handle messages from iframe
|
|
762
777
|
window.addEventListener('message', function (e) {
|
|
778
|
+
// Chat iframe + workspace iframe are same-origin; hardens against any third-party frame.
|
|
779
|
+
if (e.origin !== location.origin) return;
|
|
763
780
|
if (!e.data || !e.data.type) return;
|
|
764
781
|
if (e.data.type === 'bloby:close' && isOpen) toggle();
|
|
782
|
+
// Workspace iframe forwards Escape keypresses so ESC still closes the chat
|
|
783
|
+
// when focus is inside the user's app.
|
|
784
|
+
if (e.data.type === 'bloby:esc' && isOpen) toggle();
|
|
785
|
+
// Shell mode: the workspace app's same-document 'bloby:app-ready' window
|
|
786
|
+
// event fires inside the iframe, so the guard forwards it as a postMessage.
|
|
787
|
+
if (e.data.type === 'bloby:app-ready') { appReady = true; maybeTransition(); }
|
|
765
788
|
if (e.data.type === 'bloby:new-message' && !isOpen) { unreadCount++; updateBadge(); }
|
|
766
789
|
if (e.data.type === 'bloby:install-app') {
|
|
767
790
|
if (deferredInstallPrompt) {
|
|
768
791
|
deferredInstallPrompt.prompt();
|
|
769
792
|
deferredInstallPrompt.userChoice.then(function (r) { if (r.outcome === 'accepted') deferredInstallPrompt = null; });
|
|
770
|
-
} else { iframe.contentWindow.postMessage({ type: 'bloby:show-ios-install' },
|
|
793
|
+
} else { iframe.contentWindow.postMessage({ type: 'bloby:show-ios-install' }, location.origin); }
|
|
771
794
|
}
|
|
772
795
|
if (e.data.type === 'bloby:onboard-complete') { onboardActive = false; hideAfterTransition = false; canvas.style.display = 'block'; }
|
|
773
796
|
});
|
|
774
797
|
|
|
775
798
|
try { if (sessionStorage.getItem('bloby_widget_open') === '1') { sessionStorage.removeItem('bloby_widget_open'); if (canvasPhase === 'bubble') toggle(); } } catch (e) {}
|
|
776
|
-
|
|
777
|
-
var awsScript = document.createElement('script');
|
|
778
|
-
awsScript.src = '/bloby/app-ws.js';
|
|
779
|
-
document.head.appendChild(awsScript);
|
|
780
799
|
})();
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
(function () {
|
|
2
2
|
// Injected by the supervisor into the workspace dashboard HTML (and ONLY there — never the
|
|
3
|
-
// chat PWA or the supervisor's own interstitials).
|
|
3
|
+
// chat PWA or the supervisor's own interstitials). Five jobs, all aimed at non-technical users:
|
|
4
4
|
//
|
|
5
5
|
// 1. Backend-down auto-detect — when the workspace backend crash-loops and gives up, the
|
|
6
6
|
// supervisor serves a full "backend down" interstitial on the next navigation. Poll for
|
|
@@ -19,6 +19,22 @@
|
|
|
19
19
|
// console AND to a sessionStorage ring buffer that survives the reload, so the page that
|
|
20
20
|
// comes back can print exactly what killed its predecessor. Look for the
|
|
21
21
|
// "[bloby-forensics]" banner in the console after a mystery refresh.
|
|
22
|
+
//
|
|
23
|
+
// 4. Shell relay — under shell inversion the workspace renders inside the immortal shell's
|
|
24
|
+
// same-origin iframe and the chat bubble lives in the shell, so signals that used to be
|
|
25
|
+
// same-window must cross the frame boundary: app readiness ({type:'bloby:app-ready'}),
|
|
26
|
+
// document title ({type:'bloby:title'}), Escape keydowns ({type:'bloby:esc'}), and the
|
|
27
|
+
// nested onboard wizard's {type:'bloby:onboard-complete'} are posted up to the parent.
|
|
28
|
+
// No-ops when this document IS the top window (legacy mode / BLOBY_NO_SHELL=1).
|
|
29
|
+
//
|
|
30
|
+
// 5. Vite reconnect-reload suppression + staleness check — Vite's HMR client hard-reloads
|
|
31
|
+
// the page whenever its WebSocket drops. With an immortal dev server behind a fragile
|
|
32
|
+
// tunnel (and mobile backgrounding), that reload is almost always pointless. A
|
|
33
|
+
// synchronously-throwing 'vite:ws:disconnect' listener cancels it — but Vite 8 has NO
|
|
34
|
+
// reconnect loop (the suppressed reload IS its reconnection strategy), so the same
|
|
35
|
+
// handler starts a recovery poll against a server-side change counter
|
|
36
|
+
// (/__bloby/fe-stamp) and reloads ONLY when frontend files actually changed while the
|
|
37
|
+
// socket was dead.
|
|
22
38
|
if (window.__blobyWorkspaceGuard) return;
|
|
23
39
|
window.__blobyWorkspaceGuard = true;
|
|
24
40
|
|
|
@@ -98,14 +114,122 @@
|
|
|
98
114
|
}
|
|
99
115
|
} catch (e) {}
|
|
100
116
|
|
|
101
|
-
// Vite HMR client events —
|
|
102
|
-
//
|
|
117
|
+
// Vite HMR client events — forensics (job 3) + reconnect-reload suppression and staleness
|
|
118
|
+
// check (job 5), all on one hot context.
|
|
119
|
+
//
|
|
120
|
+
// The suppression throw below is dispatched from the transport's socket 'close' handler
|
|
121
|
+
// without an await/catch (client.mjs), so it surfaces as a window 'unhandledrejection'.
|
|
122
|
+
// The error-overlay handler further down recognizes this marker and ignores it.
|
|
123
|
+
var VITE_SUPPRESS_MARK = '[bloby] suppressing vite reload-on-reconnect';
|
|
103
124
|
try {
|
|
104
125
|
import('/@vite/client').then(function (m) {
|
|
105
126
|
var hot = m.createHotContext('/__bloby_forensics.js');
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
127
|
+
|
|
128
|
+
// Staleness stamp: a Vite-plugin middleware (reached through the supervisor's dashboard
|
|
129
|
+
// proxy) serves /__bloby/fe-stamp as {stamp:N}, bumped on every frontend hot update.
|
|
130
|
+
// Comparing the stamp across a disconnect tells us whether anything actually changed
|
|
131
|
+
// while we were gone. When Vite is down the supervisor answers 503 HTML — json() rejects
|
|
132
|
+
// and we treat it as "not back yet", never as "changed".
|
|
133
|
+
var lastStamp = null;
|
|
134
|
+
function fetchStamp() {
|
|
135
|
+
return fetch('/__bloby/fe-stamp', { cache: 'no-store' })
|
|
136
|
+
.then(function (r) { return r.json(); })
|
|
137
|
+
.then(function (j) { return (j && typeof j.stamp === 'number') ? j.stamp : null; });
|
|
138
|
+
}
|
|
139
|
+
function refreshStamp(reason) {
|
|
140
|
+
fetchStamp().then(function (s) {
|
|
141
|
+
if (s !== null) lastStamp = s;
|
|
142
|
+
}).catch(function (err) { flog('fe-stamp', reason + ' fetch failed: ' + err); });
|
|
143
|
+
}
|
|
144
|
+
// Baseline with retry: a transient blip right at page load must not leave us without a
|
|
145
|
+
// baseline — the recovery poll would then adopt a possibly-already-moved stamp as truth.
|
|
146
|
+
(function initStamp(attempt) {
|
|
147
|
+
fetchStamp().then(function (s) {
|
|
148
|
+
if (s !== null) { lastStamp = s; return; }
|
|
149
|
+
throw new Error('bad payload');
|
|
150
|
+
}).catch(function (err) {
|
|
151
|
+
if (attempt >= 5) { flog('fe-stamp', 'init gave up: ' + err); return; }
|
|
152
|
+
setTimeout(function () { initStamp(attempt + 1); }, 2000 * (attempt + 1));
|
|
153
|
+
});
|
|
154
|
+
})(0);
|
|
155
|
+
|
|
156
|
+
// Recovery poll — the ONLY change signal after a disconnect. Vite 8 never reconnects a
|
|
157
|
+
// dropped socket: transport.connect() runs once at module init, and the reload we
|
|
158
|
+
// suppress below is what would have re-run it. So once disconnected, HMR messages and
|
|
159
|
+
// 'vite:ws:connect' are gone for good; we poll the stamp instead. Stamp moved → reload
|
|
160
|
+
// (restores both freshness and HMR). Stamp unchanged → page content is current, keep
|
|
161
|
+
// polling (HMR stays dead until the next agent edit bumps the stamp). A failed fetch
|
|
162
|
+
// (supervisor's 503 HTML while Vite is down → json() rejects) means "not back yet".
|
|
163
|
+
// Single instance so rapid disconnects don't stack timers; paused while hidden
|
|
164
|
+
// (mirrors Vite's own visibility-aware ping) and kicked on visibility/online.
|
|
165
|
+
var recoveryTimer = null;
|
|
166
|
+
function recoveryTick() {
|
|
167
|
+
if (document.visibilityState === 'hidden') return;
|
|
168
|
+
fetchStamp().then(function (s) {
|
|
169
|
+
if (s === null || recoveryTimer === null) return;
|
|
170
|
+
if (lastStamp === null) { lastStamp = s; return; } // no baseline — adopt and watch
|
|
171
|
+
if (s !== lastStamp) {
|
|
172
|
+
flog('stale-after-disconnect', 'stamp ' + lastStamp + '→' + s);
|
|
173
|
+
stopRecoveryPoll();
|
|
174
|
+
location.reload(); // goes through the forensics wrapper: logged + splash
|
|
175
|
+
}
|
|
176
|
+
}).catch(function () {});
|
|
177
|
+
}
|
|
178
|
+
function startRecoveryPoll() {
|
|
179
|
+
if (recoveryTimer !== null) return;
|
|
180
|
+
flog('recovery-poll', 'started');
|
|
181
|
+
recoveryTimer = setInterval(recoveryTick, 3000);
|
|
182
|
+
recoveryTick();
|
|
183
|
+
}
|
|
184
|
+
function stopRecoveryPoll() {
|
|
185
|
+
if (recoveryTimer === null) return;
|
|
186
|
+
clearInterval(recoveryTimer);
|
|
187
|
+
recoveryTimer = null;
|
|
188
|
+
flog('recovery-poll', 'stopped');
|
|
189
|
+
}
|
|
190
|
+
document.addEventListener('visibilitychange', function () {
|
|
191
|
+
if (document.visibilityState === 'visible' && recoveryTimer !== null) recoveryTick();
|
|
192
|
+
});
|
|
193
|
+
window.addEventListener('online', function () {
|
|
194
|
+
if (recoveryTimer !== null) recoveryTick();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
hot.on('vite:ws:disconnect', function () {
|
|
198
|
+
flog('vite:ws:disconnect', 'visible=' + document.visibilityState);
|
|
199
|
+
// The poll must start BEFORE the suppressing throw aborts this handler.
|
|
200
|
+
startRecoveryPoll();
|
|
201
|
+
// Cancel Vite's reload-on-reconnect: client.mjs handleMessage awaits
|
|
202
|
+
// notifyListeners('vite:ws:disconnect') BEFORE waitForSuccessfulPing() +
|
|
203
|
+
// location.reload(). notifyListeners runs cbs.map((cb) => cb(data)) synchronously, so
|
|
204
|
+
// a SYNCHRONOUS throw here rejects that awaited promise and the reload never runs.
|
|
205
|
+
// (An async listener would NOT work — Promise.allSettled swallows rejections.) We
|
|
206
|
+
// suppress because the dev server is immortal while the tunnel/mobile connection is
|
|
207
|
+
// fragile: every blip would otherwise hard-reload a perfectly-current page. Net
|
|
208
|
+
// effect with the recovery poll above: connection blips no longer reload the page;
|
|
209
|
+
// we reload ONLY when edits actually happened while disconnected.
|
|
210
|
+
throw new Error(VITE_SUPPRESS_MARK + ' — staleness is handled by the recovery poll instead');
|
|
211
|
+
});
|
|
212
|
+
// Dead code on Vite 8 after the first disconnect (the event is emitted only inside
|
|
213
|
+
// transport.connect(), which never re-runs) — kept as a fast path for a future Vite
|
|
214
|
+
// that reconnects in place.
|
|
215
|
+
hot.on('vite:ws:connect', function () {
|
|
216
|
+
flog('vite:ws:connect');
|
|
217
|
+
stopRecoveryPoll();
|
|
218
|
+
if (lastStamp === null) { refreshStamp('connect'); return; }
|
|
219
|
+
fetchStamp().then(function (s) {
|
|
220
|
+
if (s === null) return;
|
|
221
|
+
if (s !== lastStamp) {
|
|
222
|
+
flog('stale-after-reconnect', 'stamp ' + lastStamp + '→' + s);
|
|
223
|
+
location.reload(); // goes through the forensics wrapper: logged + splash
|
|
224
|
+
}
|
|
225
|
+
}).catch(function (err) { flog('fe-stamp', 'connect fetch failed: ' + err); });
|
|
226
|
+
});
|
|
227
|
+
// We just received these updates over the live socket, so we're current — resync.
|
|
228
|
+
hot.on('vite:afterUpdate', function () { refreshStamp('after-update'); });
|
|
229
|
+
hot.on('vite:beforeFullReload', function (p) {
|
|
230
|
+
flog('vite:beforeFullReload', p && p.path ? 'path=' + p.path : '');
|
|
231
|
+
refreshStamp('before-full-reload');
|
|
232
|
+
});
|
|
109
233
|
hot.on('vite:invalidate', function (p) { flog('vite:invalidate', (p && p.path) || ''); });
|
|
110
234
|
hot.on('vite:error', function (p) { flog('vite:error', p && p.err && p.err.message || ''); });
|
|
111
235
|
flog('vite-hooks', 'installed');
|
|
@@ -114,6 +238,80 @@
|
|
|
114
238
|
flog('vite-hooks', 'unavailable: ' + e);
|
|
115
239
|
}
|
|
116
240
|
|
|
241
|
+
/* ── 4. Shell relay ───────────────────────────────────────────────────── */
|
|
242
|
+
// The shell (parent) hosts the bubble + chat; this document is its same-origin iframe.
|
|
243
|
+
// Everything here no-ops when we ARE the top window (legacy mode / BLOBY_NO_SHELL=1).
|
|
244
|
+
(function () {
|
|
245
|
+
var framed = false;
|
|
246
|
+
try { framed = !!window.parent && window.parent !== window; } catch (e) {}
|
|
247
|
+
if (!framed) return;
|
|
248
|
+
|
|
249
|
+
function post(msg) {
|
|
250
|
+
try { window.parent.postMessage(msg, location.origin); } catch (e) {}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// a. App readiness. The workspace's main.tsx dispatches the same-window DOM Event
|
|
254
|
+
// 'bloby:app-ready' and sets window.__blobyAppReady after first paint. The event may have
|
|
255
|
+
// fired before this script ran (or a custom workspace may never dispatch it), so also
|
|
256
|
+
// check the flag now and poll it; give up after 20s. Send at most once.
|
|
257
|
+
//
|
|
258
|
+
// Readiness also hides the workspace's own opaque #splash (index.html, z-index 9998):
|
|
259
|
+
// widget.js — the only thing that ever hid it — no-ops inside frames, so without this
|
|
260
|
+
// every shell-mode load (including onboarding, whose iframe sits at z-index 200) stays
|
|
261
|
+
// black forever. Timeout backstop (~8s, matching the shell's own) keeps broken apps
|
|
262
|
+
// visible too. Framed-only, so legacy mode still gets widget.js's splash animation.
|
|
263
|
+
var readySent = false;
|
|
264
|
+
function hideSplash() {
|
|
265
|
+
try {
|
|
266
|
+
var s = document.getElementById('splash');
|
|
267
|
+
if (s) s.style.display = 'none';
|
|
268
|
+
} catch (e) {}
|
|
269
|
+
}
|
|
270
|
+
setTimeout(hideSplash, 8000);
|
|
271
|
+
function sendReady() {
|
|
272
|
+
if (readySent) return;
|
|
273
|
+
readySent = true;
|
|
274
|
+
hideSplash();
|
|
275
|
+
post({ type: 'bloby:app-ready' });
|
|
276
|
+
}
|
|
277
|
+
window.addEventListener('bloby:app-ready', sendReady);
|
|
278
|
+
if (window.__blobyAppReady) sendReady();
|
|
279
|
+
var readyPolls = 0;
|
|
280
|
+
var readyTimer = setInterval(function () {
|
|
281
|
+
if (window.__blobyAppReady) sendReady();
|
|
282
|
+
if (readySent || ++readyPolls >= 80) clearInterval(readyTimer); // 80 × 250ms = 20s
|
|
283
|
+
}, 250);
|
|
284
|
+
|
|
285
|
+
// b. Title sync — the shell mirrors the iframe's title into the real tab title.
|
|
286
|
+
function postTitle() {
|
|
287
|
+
if (document.title) post({ type: 'bloby:title', title: document.title });
|
|
288
|
+
}
|
|
289
|
+
postTitle();
|
|
290
|
+
try {
|
|
291
|
+
var titleEl = document.querySelector('title');
|
|
292
|
+
if (titleEl) {
|
|
293
|
+
new MutationObserver(postTitle).observe(titleEl, { childList: true, characterData: true, subtree: true });
|
|
294
|
+
}
|
|
295
|
+
} catch (e) {}
|
|
296
|
+
|
|
297
|
+
// c. Escape — keydown inside the iframe never reaches the shell document, so the shell's
|
|
298
|
+
// widget would miss its close-the-chat-panel shortcut.
|
|
299
|
+
window.addEventListener('keydown', function (e) {
|
|
300
|
+
if (e.key === 'Escape') post({ type: 'bloby:esc' });
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// d. Onboard relay — the onboarding wizard iframe is nested INSIDE this workspace app and
|
|
304
|
+
// posts {type:'bloby:onboard-complete'} to ITS parent (= this window); the widget that
|
|
305
|
+
// must un-hide the bubble now lives in the shell. Re-post upward. Loop-safe by direction:
|
|
306
|
+
// only messages from below (never from the parent shell) are relayed.
|
|
307
|
+
window.addEventListener('message', function (e) {
|
|
308
|
+
if (e.source === window.parent) return;
|
|
309
|
+
if (e.origin === location.origin && e.data && e.data.type === 'bloby:onboard-complete') {
|
|
310
|
+
post(e.data);
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
})();
|
|
314
|
+
|
|
117
315
|
/* ── 1. Backend-down auto-detect ──────────────────────────────────────── */
|
|
118
316
|
var reloading = false;
|
|
119
317
|
function pollBackend() {
|
|
@@ -217,6 +415,14 @@
|
|
|
217
415
|
new MutationObserver(evaluate).observe(document.body, { childList: true });
|
|
218
416
|
setInterval(evaluate, 1500);
|
|
219
417
|
window.addEventListener('error', function () { sawError = true; evaluate(); });
|
|
220
|
-
window.addEventListener('unhandledrejection', function () {
|
|
418
|
+
window.addEventListener('unhandledrejection', function (e) {
|
|
419
|
+
// The reconnect-reload suppressor (job 5) throws a marker error inside Vite's HMR
|
|
420
|
+
// dispatch; it surfaces here on every tunnel blip. Not an app error — swallow it.
|
|
421
|
+
try {
|
|
422
|
+
var msg = e && e.reason && e.reason.message;
|
|
423
|
+
if (msg && String(msg).indexOf(VITE_SUPPRESS_MARK) !== -1) { e.preventDefault(); return; }
|
|
424
|
+
} catch (err) {}
|
|
425
|
+
sawError = true; evaluate();
|
|
426
|
+
});
|
|
221
427
|
evaluate();
|
|
222
428
|
})();
|
package/vite.config.ts
CHANGED
|
@@ -1,8 +1,39 @@
|
|
|
1
|
-
import { defineConfig } from 'vite';
|
|
1
|
+
import { defineConfig, type Plugin } from 'vite';
|
|
2
2
|
import react from '@vitejs/plugin-react';
|
|
3
3
|
import tailwindcss from '@tailwindcss/vite';
|
|
4
4
|
import path from 'path';
|
|
5
5
|
|
|
6
|
+
// Backs workspace-guard.js's reconnect staleness check: Vite's reload-on-reconnect
|
|
7
|
+
// is suppressed client-side, and on reconnect the client fetches this stamp and
|
|
8
|
+
// reloads ONLY if frontend code actually changed while it was disconnected.
|
|
9
|
+
// Seeded with Date.now() (not 0) so a Vite/supervisor restart — which resets this
|
|
10
|
+
// module — always reads as "changed" → clients reload after restarts, which is
|
|
11
|
+
// exactly right (in-memory HMR state is gone).
|
|
12
|
+
function blobyFeStamp(): Plugin {
|
|
13
|
+
let stamp = Date.now();
|
|
14
|
+
return {
|
|
15
|
+
name: 'bloby-fe-stamp',
|
|
16
|
+
// hotUpdate (not legacy handleHotUpdate): fires on create/update/delete and
|
|
17
|
+
// for .html edits that trigger full-reload. It runs once per environment, so
|
|
18
|
+
// only count the client one.
|
|
19
|
+
hotUpdate() {
|
|
20
|
+
if (this.environment.name !== 'client') return;
|
|
21
|
+
stamp++;
|
|
22
|
+
},
|
|
23
|
+
configureServer(server) {
|
|
24
|
+
// Reached through the supervisor's catch-all proxy to Vite. connect mounts
|
|
25
|
+
// by prefix, so reject subpaths to keep this an exact match.
|
|
26
|
+
server.middlewares.use('/__bloby/fe-stamp', (req, res, next) => {
|
|
27
|
+
const rest = (req.url || '').split('?')[0];
|
|
28
|
+
if (rest !== '' && rest !== '/') return next();
|
|
29
|
+
res.setHeader('Content-Type', 'application/json');
|
|
30
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
31
|
+
res.end(JSON.stringify({ stamp }));
|
|
32
|
+
});
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
6
37
|
export default defineConfig({
|
|
7
38
|
root: process.env.BLOBY_WORKSPACE
|
|
8
39
|
? path.join(process.env.BLOBY_WORKSPACE, 'client')
|
|
@@ -64,5 +95,5 @@ export default defineConfig({
|
|
|
64
95
|
'use-sync-external-store/shim',
|
|
65
96
|
],
|
|
66
97
|
},
|
|
67
|
-
plugins: [react(), tailwindcss()],
|
|
98
|
+
plugins: [react(), tailwindcss(), blobyFeStamp()],
|
|
68
99
|
});
|
|
@@ -19,6 +19,15 @@ export type ConditionResult = false | true | Record<string, string>;
|
|
|
19
19
|
|
|
20
20
|
export const conditions: Record<string, () => Promise<ConditionResult>> = {
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* Shell mode: the workspace renders inside a same-origin iframe under the
|
|
24
|
+
* supervisor's shell page. BLOBY_NO_SHELL=1 is the kill switch back to
|
|
25
|
+
* legacy top-level serving — the iframe note must disappear with it.
|
|
26
|
+
*/
|
|
27
|
+
'workspace-iframe-shell': async () => {
|
|
28
|
+
return process.env.BLOBY_NO_SHELL !== '1';
|
|
29
|
+
},
|
|
30
|
+
|
|
22
31
|
/**
|
|
23
32
|
* Official Workspace Lock is active.
|
|
24
33
|
* Calls GET http://localhost:{backendPort}/api/lock/status
|
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"id": "workspace-iframe-shell",
|
|
4
|
+
"description": "Shell mode active (BLOBY_NO_SHELL != 1): workspace renders inside a same-origin iframe under Bloby's shell. Priority 0 is load-bearing — must run before the workspace-lock replace fragments consume the workspace-security marker this append anchors on.",
|
|
5
|
+
"target": "workspace-security",
|
|
6
|
+
"action": "append",
|
|
7
|
+
"priority": 0,
|
|
8
|
+
"content": "## Iframe Shell\n\nThe dashboard your human sees renders inside a same-origin iframe under Bloby's shell page — the workspace app is never the top-level browsing context. This is transparent for normal code, with a few hard rules:\n\n- **Redirect-based auth (Google/GitHub OAuth, Stripe Checkout, etc.):** never navigate the current window to a third-party page — providers refuse to render inside iframes. Open a popup (`window.open`) or put `target=\"_top\"` on the redirect link. Prefer popup-based SDK flows when the provider offers one.\n- **`window.top` and `window.parent` belong to Bloby's shell.** Never navigate, reload, or postMessage them from workspace code.\n- **Browser permissions work normally** — camera, microphone, geolocation, clipboard, fullscreen, autoplay, and display-capture are all granted to the iframe by the shell.\n- **The browser tab title syncs automatically** from `document.title`. Nothing to do."
|
|
9
|
+
},
|
|
2
10
|
{
|
|
3
11
|
"id": "workspace-lock-official",
|
|
4
12
|
"description": "Official marketplace Workspace Lock is active (PIN or password)",
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
// build errors, never a stale cached shell. Cache is a pure offline fallback. (Mirror of the
|
|
9
9
|
// supervisor's SW_JS in supervisor/index.ts — keep in sync.)
|
|
10
10
|
|
|
11
|
-
const CACHE = 'bloby-
|
|
11
|
+
const CACHE = 'bloby-v24';
|
|
12
12
|
|
|
13
13
|
// Precache the HTML shell on install so the cache is never empty.
|
|
14
14
|
// Without this, the first navigation isn't intercepted (SW wasn't
|
|
@@ -28,80 +28,27 @@ export default function App() {
|
|
|
28
28
|
const [showOnboard, setShowOnboard] = useState(false);
|
|
29
29
|
const [userName, setUserName] = useState('');
|
|
30
30
|
const [botName, setBotName] = useState('Bloby');
|
|
31
|
-
const [rebuildState, setRebuildState] = useState<'idle' | 'rebuilding' | 'error'>('idle');
|
|
32
|
-
const [buildError, setBuildError] = useState('');
|
|
33
31
|
|
|
34
|
-
// ── Seamless reload: splash
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
// 2. When returning from background (> 30s hidden), the splash shows
|
|
41
|
-
// IMMEDIATELY — before Vite's delayed reconnect-reload can fire.
|
|
42
|
-
// If no reload comes within 3s, the splash fades away.
|
|
43
|
-
// 3. Vite full-reloads trigger the splash too (same mechanism).
|
|
32
|
+
// ── Seamless reload: splash before full-reloads ───────────────────
|
|
33
|
+
// Vite's reload-on-reconnect is suppressed by the supervisor-injected
|
|
34
|
+
// workspace-guard (which also owns location.reload wrapping), so the
|
|
35
|
+
// only reloads left are real ones: Vite full-reloads after frontend
|
|
36
|
+
// changes. Show the HTML splash before those so the user sees
|
|
37
|
+
// app → splash → app instead of a white flash.
|
|
44
38
|
useEffect(() => {
|
|
45
39
|
const splash = document.getElementById('splash');
|
|
46
|
-
let hiddenAt = 0;
|
|
47
40
|
|
|
48
|
-
// Show the dark background
|
|
41
|
+
// Show the dark background before the reload unloads the page
|
|
49
42
|
function showSplash() {
|
|
50
43
|
if (!splash) return;
|
|
51
44
|
splash.style.display = 'block';
|
|
52
45
|
splash.style.opacity = '1';
|
|
53
46
|
}
|
|
54
47
|
|
|
55
|
-
// Hide the splash screen with a fade
|
|
56
|
-
function hideSplash() {
|
|
57
|
-
if (!splash || splash.style.display === 'none') return;
|
|
58
|
-
splash.style.opacity = '0';
|
|
59
|
-
splash.addEventListener('transitionend', () => { splash.style.display = 'none'; }, { once: true });
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// Wrap location.reload: show splash, wait for paint, then reload.
|
|
63
|
-
// This ensures the dark splash is visible during the brief unload→load gap.
|
|
64
|
-
try {
|
|
65
|
-
const origReload = location.reload.bind(location);
|
|
66
|
-
Object.defineProperty(location, 'reload', {
|
|
67
|
-
configurable: true,
|
|
68
|
-
value: () => {
|
|
69
|
-
showSplash();
|
|
70
|
-
requestAnimationFrame(() => requestAnimationFrame(() => origReload()));
|
|
71
|
-
},
|
|
72
|
-
});
|
|
73
|
-
} catch {
|
|
74
|
-
// location.reload is non-configurable in some browsers — skip override
|
|
75
|
-
}
|
|
76
|
-
|
|
77
48
|
// Vite HMR: show splash before a full-reload
|
|
78
49
|
if (import.meta.hot) {
|
|
79
50
|
import.meta.hot.on('vite:beforeFullReload', () => showSplash());
|
|
80
51
|
}
|
|
81
|
-
|
|
82
|
-
// Freeze-thaw: when returning from background after > 30s,
|
|
83
|
-
// show splash proactively so the user never sees the "working app
|
|
84
|
-
// suddenly yank away" pattern. If Vite decides to full-reload,
|
|
85
|
-
// the splash is already visible. If not, we fade it away after 3s.
|
|
86
|
-
let thawTimer: ReturnType<typeof setTimeout>;
|
|
87
|
-
const BACKGROUND_THRESHOLD = 30_000; // 30 seconds
|
|
88
|
-
|
|
89
|
-
const onVisChange = () => {
|
|
90
|
-
if (document.visibilityState === 'hidden') {
|
|
91
|
-
hiddenAt = Date.now();
|
|
92
|
-
} else if (hiddenAt && Date.now() - hiddenAt > BACKGROUND_THRESHOLD) {
|
|
93
|
-
showSplash();
|
|
94
|
-
// Give Vite 3s to trigger a reload; if it doesn't, hide splash
|
|
95
|
-
clearTimeout(thawTimer);
|
|
96
|
-
thawTimer = setTimeout(hideSplash, 3_000);
|
|
97
|
-
}
|
|
98
|
-
};
|
|
99
|
-
document.addEventListener('visibilitychange', onVisChange);
|
|
100
|
-
|
|
101
|
-
return () => {
|
|
102
|
-
document.removeEventListener('visibilitychange', onVisChange);
|
|
103
|
-
clearTimeout(thawTimer);
|
|
104
|
-
};
|
|
105
52
|
}, []);
|
|
106
53
|
|
|
107
54
|
useEffect(() => {
|
|
@@ -119,39 +66,18 @@ export default function App() {
|
|
|
119
66
|
.catch(() => {});
|
|
120
67
|
}, []);
|
|
121
68
|
|
|
122
|
-
// Listen for
|
|
69
|
+
// Listen for events from Bloby iframes via postMessage.
|
|
70
|
+
// 'bloby:hmr-update' is intentionally ignored: Vite HMR handles hot
|
|
71
|
+
// updates natively, and a manual location.reload() here would kill the
|
|
72
|
+
// chat iframe's WebSocket connection for nothing.
|
|
123
73
|
useEffect(() => {
|
|
124
|
-
let safetyTimer: ReturnType<typeof setTimeout>;
|
|
125
74
|
const handler = (e: MessageEvent) => {
|
|
126
|
-
if (e.data?.type === 'bloby:
|
|
127
|
-
setRebuildState('rebuilding');
|
|
128
|
-
setBuildError('');
|
|
129
|
-
// Safety: auto-reload after 60s in case app:rebuilt message is lost
|
|
130
|
-
clearTimeout(safetyTimer);
|
|
131
|
-
safetyTimer = setTimeout(() => location.reload(), 60_000);
|
|
132
|
-
} else if (e.data?.type === 'bloby:rebuilt') {
|
|
133
|
-
clearTimeout(safetyTimer);
|
|
134
|
-
setRebuildState('idle');
|
|
135
|
-
location.reload();
|
|
136
|
-
} else if (e.data?.type === 'bloby:build-error') {
|
|
137
|
-
clearTimeout(safetyTimer);
|
|
138
|
-
setRebuildState('error');
|
|
139
|
-
setBuildError(e.data.error || 'Build failed');
|
|
140
|
-
setTimeout(() => setRebuildState('idle'), 5000);
|
|
141
|
-
} else if (e.data?.type === 'bloby:onboard-complete') {
|
|
75
|
+
if (e.data?.type === 'bloby:onboard-complete') {
|
|
142
76
|
setShowOnboard(false);
|
|
143
|
-
} else if (e.data?.type === 'bloby:hmr-update') {
|
|
144
|
-
// Vite HMR handles hot updates natively — no manual reload needed.
|
|
145
|
-
// Manual location.reload() here was causing unnecessary full-page refreshes
|
|
146
|
-
// that killed the chat iframe's WebSocket connection.
|
|
147
|
-
|
|
148
77
|
}
|
|
149
78
|
};
|
|
150
79
|
window.addEventListener('message', handler);
|
|
151
|
-
return () =>
|
|
152
|
-
window.removeEventListener('message', handler);
|
|
153
|
-
clearTimeout(safetyTimer);
|
|
154
|
-
};
|
|
80
|
+
return () => window.removeEventListener('message', handler);
|
|
155
81
|
}, []);
|
|
156
82
|
|
|
157
83
|
return (
|
|
@@ -172,22 +98,6 @@ export default function App() {
|
|
|
172
98
|
style={{ position: 'fixed', inset: 0, width: '100vw', height: '100dvh', border: 'none', zIndex: 200 }}
|
|
173
99
|
/>
|
|
174
100
|
)}
|
|
175
|
-
|
|
176
|
-
{rebuildState !== 'idle' && (
|
|
177
|
-
<div className="fixed inset-0 z-[49] flex flex-col items-center justify-center bg-background/90">
|
|
178
|
-
<video
|
|
179
|
-
src="/bloby_tilts.webm"
|
|
180
|
-
autoPlay
|
|
181
|
-
loop
|
|
182
|
-
muted
|
|
183
|
-
playsInline
|
|
184
|
-
className="h-24 w-24 rounded-full object-cover"
|
|
185
|
-
/>
|
|
186
|
-
<p className="mt-4 text-sm text-muted-foreground">
|
|
187
|
-
{rebuildState === 'rebuilding' ? 'Rebuilding app...' : buildError}
|
|
188
|
-
</p>
|
|
189
|
-
</div>
|
|
190
|
-
)}
|
|
191
101
|
</>
|
|
192
102
|
);
|
|
193
103
|
}
|