bloby-bot 0.69.5 → 0.70.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/README.md +4 -4
- package/package.json +1 -1
- package/supervisor/channels/whatsapp.ts +25 -22
- package/supervisor/chat/bloby-main.tsx +1 -1
- package/supervisor/harnesses/claude.ts +24 -0
- package/supervisor/harnesses/codex.ts +2 -27
- package/supervisor/harnesses/pi/index.ts +6 -0
- package/supervisor/harnesses/skills.ts +133 -0
- package/supervisor/index.ts +230 -20
- package/supervisor/public/morphy/headphones-idle.webp +0 -0
- package/supervisor/public/morphy/headphones.json +4 -4
- package/supervisor/public/morphy/headphones.webp +0 -0
- package/supervisor/public/morphy/teleporting.json +2 -2
- package/supervisor/public/morphy/teleporting.webp +0 -0
- package/supervisor/shell.ts +53 -0
- package/supervisor/vite-dev.ts +28 -13
- package/supervisor/widget.js +124 -24
- package/supervisor/workspace-guard.js +33 -0
- package/vite.config.ts +26 -1
- package/workspace/client/index.html +6 -1
- package/workspace/client/public/morphy/headphones-idle.webp +0 -0
- package/workspace/client/public/morphy/headphones.json +4 -4
- package/workspace/client/public/morphy/headphones.webp +0 -0
- package/workspace/client/public/morphy/teleporting.json +2 -2
- package/workspace/client/public/morphy/teleporting.webp +0 -0
- package/workspace/client/public/sw.js +25 -2
- package/workspace/skills/create-skill/SKILL.md +188 -0
- package/workspace/skills/create-skill/references/patterns.md +126 -0
- package/workspace/skills/mac/skill.json +15 -2
- package/workspace/client/public/arrow.png +0 -0
- package/workspace/client/public/bloby_happy.mov +0 -0
- package/workspace/client/public/bloby_happy.webm +0 -0
- package/workspace/client/public/bloby_happy_reappearing.mov +0 -0
- package/workspace/client/public/bloby_happy_reappearing.webm +0 -0
- package/workspace/client/public/bloby_say_hi.mov +0 -0
- package/workspace/client/public/bloby_say_hi.webm +0 -0
- package/workspace/client/public/bloby_tilts.webm +0 -0
- package/workspace/client/public/headphones_spritesheet.webp +0 -0
- package/workspace/client/public/spritesheet.webp +0 -0
- package/workspace/skills/telegram/.claude-plugin/plugin.json +0 -6
- package/workspace/skills/whatsapp/.claude-plugin/plugin.json +0 -6
package/supervisor/index.ts
CHANGED
|
@@ -28,6 +28,7 @@ import { startViteDevServers, stopViteDevServers } from './vite-dev.js';
|
|
|
28
28
|
import { startScheduler, stopScheduler, readPulseConfig, readCronsConfig, nextRunISO, describeCron } from './scheduler.js';
|
|
29
29
|
import { execSync, spawn as cpSpawn } from 'child_process';
|
|
30
30
|
import crypto from 'crypto';
|
|
31
|
+
import zlib from 'zlib';
|
|
31
32
|
import { ChannelManager } from './channels/manager.js';
|
|
32
33
|
import { SHELL_HTML } from './shell.js';
|
|
33
34
|
|
|
@@ -141,8 +142,10 @@ const SW_JS = `// Service worker — app-shell caching + push notifications
|
|
|
141
142
|
// cached shell that masks a broken (or just-fixed) frontend and produces the confusing
|
|
142
143
|
// "normal refresh is broken but hard refresh works" split. Cache is a pure offline fallback.
|
|
143
144
|
|
|
144
|
-
var CACHE = 'bloby-
|
|
145
|
-
|
|
145
|
+
var CACHE = 'bloby-v25';
|
|
146
|
+
// Hash class includes -/_ — Vite's content hashes are base64url (e.g. bloby-Dfx1hOe-.js);
|
|
147
|
+
// without them ~3 MB of hashed chat assets silently fell out of cache-first into network-first.
|
|
148
|
+
var HASHED_RE = new RegExp('/assets/.+-[a-zA-Z0-9_-]{6,}[.](js|css)$');
|
|
146
149
|
|
|
147
150
|
// Precache the HTML shell on install so the cache is never empty.
|
|
148
151
|
// Without this, the first navigation isn't intercepted (SW wasn't
|
|
@@ -197,6 +200,31 @@ self.addEventListener('fetch', function(event) {
|
|
|
197
200
|
return;
|
|
198
201
|
}
|
|
199
202
|
|
|
203
|
+
// Vite's optimized deps (/node_modules/.vite/deps/*?v=<hash>) are content-addressed and
|
|
204
|
+
// served max-age=31536000,immutable — but fell into network-first below, re-crossing the
|
|
205
|
+
// tunnel whenever the HTTP cache evicted them (common on mobile). Cache-first, pruning
|
|
206
|
+
// older ?v= versions of the same module. Source modules (?t=, /@*) stay network-only.
|
|
207
|
+
if (url.pathname.indexOf('/node_modules/.vite/deps/') === 0 && url.searchParams.has('v')) {
|
|
208
|
+
event.respondWith(caches.open(CACHE).then(function(c) {
|
|
209
|
+
return c.match(request).then(function(hit) {
|
|
210
|
+
if (hit) return hit;
|
|
211
|
+
return fetch(request).then(function(r) {
|
|
212
|
+
if (r.ok) {
|
|
213
|
+
c.put(request, r.clone());
|
|
214
|
+
c.keys().then(function(keys) {
|
|
215
|
+
for (var i = 0; i < keys.length; i++) {
|
|
216
|
+
var u = new URL(keys[i].url);
|
|
217
|
+
if (u.pathname === url.pathname && u.search !== url.search) c.delete(keys[i]);
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
return r;
|
|
222
|
+
});
|
|
223
|
+
});
|
|
224
|
+
}));
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
200
228
|
// Navigation: only stale-while-revalidate the dashboard root (/).
|
|
201
229
|
// /bloby/* is a separate app — let it go to network to avoid
|
|
202
230
|
// caching the wrong HTML under the / key.
|
|
@@ -431,6 +459,12 @@ export async function startSupervisor() {
|
|
|
431
459
|
// Create HTTP server first (Vite needs it for HMR WebSocket)
|
|
432
460
|
// The request handler is set up later via server.on('request')
|
|
433
461
|
const server = http.createServer();
|
|
462
|
+
// cloudflared keeps a pool of ~100 origin connections with a 90s idle timeout; Node's
|
|
463
|
+
// default keepAliveTimeout is 5s, so after any pause every request burst re-paid TCP setup —
|
|
464
|
+
// and the 5s-vs-90s mismatch is the classic reused-socket race behind sporadic tunnel 502s
|
|
465
|
+
// ("error opening a stream to the origin"). Outlive cloudflared's pool instead.
|
|
466
|
+
server.keepAliveTimeout = 120_000;
|
|
467
|
+
server.headersTimeout = 125_000; // must exceed keepAliveTimeout (Node guards header floods with it)
|
|
434
468
|
|
|
435
469
|
// Start Vite dev server — pass supervisor server so Vite attaches HMR WebSocket directly.
|
|
436
470
|
// A Vite boot failure must NOT take down the supervisor (G1): chat is served independently and
|
|
@@ -605,19 +639,156 @@ export async function startSupervisor() {
|
|
|
605
639
|
});
|
|
606
640
|
}
|
|
607
641
|
|
|
642
|
+
// ── Cached static serving: in-memory buffers + content ETags + 304s + gzip ──────────────
|
|
643
|
+
// Everything the supervisor serves on the refresh path used to be fs.readFileSync per request
|
|
644
|
+
// with no validator — nothing could ever 304, so ~90 KB of identical bytes crossed the tunnel
|
|
645
|
+
// on every refresh (plus sync SD-card reads on Pi-class hardware). Entries are stat-validated
|
|
646
|
+
// by mtime, so editing a file (dev) or `bloby update` + restart (prod) is always picked up;
|
|
647
|
+
// `no-cache` semantics (revalidate every load) are preserved — clients just get 304s now.
|
|
648
|
+
type CachedAsset = { buf: Buffer; gz: Buffer | null; etag: string; mtimeMs: number };
|
|
649
|
+
const staticCache = new Map<string, CachedAsset>();
|
|
650
|
+
|
|
651
|
+
function buildCachedAsset(buf: Buffer, mtimeMs: number): CachedAsset {
|
|
652
|
+
const etag = '"' + crypto.createHash('sha1').update(buf).digest('base64url').slice(0, 16) + '"';
|
|
653
|
+
const gz = buf.length > 1024 ? zlib.gzipSync(buf, { level: 6 }) : null;
|
|
654
|
+
// Keep gzip only when it actually pays for the Content-Encoding overhead.
|
|
655
|
+
return { buf, gz: gz && gz.length < buf.length * 0.92 ? gz : null, etag, mtimeMs };
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function serveCachedText(
|
|
659
|
+
req: http.IncomingMessage,
|
|
660
|
+
res: http.ServerResponse,
|
|
661
|
+
key: string,
|
|
662
|
+
src: { file?: string; content?: string },
|
|
663
|
+
contentType: string,
|
|
664
|
+
cacheControl: string,
|
|
665
|
+
extra: Record<string, string> = {},
|
|
666
|
+
): void {
|
|
667
|
+
let entry = staticCache.get(key);
|
|
668
|
+
if (src.file !== undefined) {
|
|
669
|
+
let mtimeMs = 0;
|
|
670
|
+
try { mtimeMs = fs.statSync(src.file).mtimeMs; } catch { res.writeHead(404); res.end('Not found'); return; }
|
|
671
|
+
if (!entry || entry.mtimeMs !== mtimeMs) {
|
|
672
|
+
if (staticCache.size > 200) staticCache.clear(); // runaway guard; rebuilt on demand
|
|
673
|
+
try { entry = buildCachedAsset(fs.readFileSync(src.file), mtimeMs); staticCache.set(key, entry); }
|
|
674
|
+
catch { res.writeHead(404); res.end('Not found'); return; }
|
|
675
|
+
}
|
|
676
|
+
} else if (!entry) {
|
|
677
|
+
entry = buildCachedAsset(Buffer.from(src.content || '', 'utf-8'), -1);
|
|
678
|
+
staticCache.set(key, entry);
|
|
679
|
+
}
|
|
680
|
+
if (req.headers['if-none-match'] === entry.etag) {
|
|
681
|
+
res.writeHead(304, { ETag: entry.etag, 'Cache-Control': cacheControl, ...extra });
|
|
682
|
+
res.end();
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
const gzOk = entry.gz !== null && String(req.headers['accept-encoding'] || '').includes('gzip');
|
|
686
|
+
res.writeHead(200, {
|
|
687
|
+
'Content-Type': contentType,
|
|
688
|
+
'Cache-Control': cacheControl,
|
|
689
|
+
ETag: entry.etag,
|
|
690
|
+
Vary: 'Accept-Encoding',
|
|
691
|
+
...(gzOk ? { 'Content-Encoding': 'gzip' } : {}),
|
|
692
|
+
...extra,
|
|
693
|
+
});
|
|
694
|
+
res.end(gzOk ? entry.gz : entry.buf);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// Read once per process — the version can only change via an update, which restarts us.
|
|
698
|
+
let pkgVersion = 'unknown';
|
|
699
|
+
try { pkgVersion = JSON.parse(fs.readFileSync(path.join(PKG_DIR, 'package.json'), 'utf-8')).version || 'unknown'; } catch {}
|
|
700
|
+
|
|
701
|
+
// Keep-alive agent for the loopback proxies (Vite dev server + workspace backend). Without it
|
|
702
|
+
// (on Node 18, the install floor) every proxied module request opens a fresh TCP connection —
|
|
703
|
+
// dozens of handshakes per refresh that a Pi actually feels.
|
|
704
|
+
const loopbackAgent = new http.Agent({ keepAlive: true, maxSockets: 64 });
|
|
705
|
+
const HOP_BY_HOP = ['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade'];
|
|
706
|
+
function stripHopByHop(h: http.IncomingHttpHeaders): http.IncomingHttpHeaders {
|
|
707
|
+
const out: http.IncomingHttpHeaders = {};
|
|
708
|
+
for (const k in h) if (!HOP_BY_HOP.includes(k)) out[k] = h[k];
|
|
709
|
+
return out; // forwarding 'connection: close' would defeat the keep-alive pool
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// ── Refresh-performance instrumentation ─────────────────────────────────────────────────
|
|
713
|
+
// Server side: a ring of recent request timings (duration, bytes out, encoding, whether the
|
|
714
|
+
// socket was reused). Client side: the shell assembles marks from itself + widget + the
|
|
715
|
+
// workspace iframe into one beacon per page load and POSTs it here. Read both via
|
|
716
|
+
// GET /__bloby/perf — LOCALHOST ONLY (request paths/timings stay off the tunnel); remote
|
|
717
|
+
// debugging uses the console print behind localStorage.bloby_perf='1' in the shell.
|
|
718
|
+
const perfRequests: object[] = [];
|
|
719
|
+
const perfBeacons: object[] = [];
|
|
720
|
+
function isLoopback(req: http.IncomingMessage): boolean {
|
|
721
|
+
const a = req.socket.remoteAddress || '';
|
|
722
|
+
return a === '127.0.0.1' || a === '::1' || a === '::ffff:127.0.0.1';
|
|
723
|
+
}
|
|
724
|
+
|
|
608
725
|
// HTTP request handler — proxies to Vite dev servers + worker API
|
|
609
726
|
server.on('request', async (req, res) => {
|
|
727
|
+
// Every response carries the agent-origin stamp. The relay treats it as authoritative
|
|
728
|
+
// proof the bytes came from the agent (never substitutes a branded page) AND skips its
|
|
729
|
+
// 4 KB/1.5 s error-body sniff buffer on 4xx/5xx — without this, every legit 404/500
|
|
730
|
+
// through the tunnel paid the sniff. Proxied responses keep it unless upstream overrides.
|
|
731
|
+
res.setHeader('X-Bloby-Origin', 'supervisor');
|
|
732
|
+
|
|
733
|
+
// Request timing sample — attached before any routing so every branch is covered.
|
|
734
|
+
{
|
|
735
|
+
const t0 = Date.now();
|
|
736
|
+
const sock = req.socket as any;
|
|
737
|
+
const reused = !!sock.__blobySeen;
|
|
738
|
+
sock.__blobySeen = true;
|
|
739
|
+
const w0 = sock.bytesWritten || 0;
|
|
740
|
+
let sampled = false;
|
|
741
|
+
res.on('close', () => {
|
|
742
|
+
if (sampled) return;
|
|
743
|
+
sampled = true;
|
|
744
|
+
perfRequests.push({
|
|
745
|
+
ts: t0,
|
|
746
|
+
m: req.method,
|
|
747
|
+
p: (req.url || '').split('?')[0].slice(0, 120),
|
|
748
|
+
s: res.statusCode,
|
|
749
|
+
ms: Date.now() - t0,
|
|
750
|
+
out: Math.max(0, (req.socket.bytesWritten || 0) - w0),
|
|
751
|
+
enc: String(res.getHeader('content-encoding') || ''),
|
|
752
|
+
reused,
|
|
753
|
+
});
|
|
754
|
+
if (perfRequests.length > 400) perfRequests.splice(0, perfRequests.length - 400);
|
|
755
|
+
});
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// Client perf beacon — one compact JSON per page load, posted by the shell.
|
|
759
|
+
if (req.url === '/__bloby/perf' && req.method === 'POST') {
|
|
760
|
+
const chunks: Buffer[] = [];
|
|
761
|
+
let size = 0;
|
|
762
|
+
req.on('data', (c: Buffer) => { size += c.length; if (size <= 32_768) chunks.push(c); });
|
|
763
|
+
req.on('end', () => {
|
|
764
|
+
try {
|
|
765
|
+
if (size <= 32_768) {
|
|
766
|
+
const beacon = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
|
|
767
|
+
perfBeacons.push({ ts: Date.now(), ...beacon });
|
|
768
|
+
if (perfBeacons.length > 20) perfBeacons.splice(0, perfBeacons.length - 20);
|
|
769
|
+
}
|
|
770
|
+
} catch {}
|
|
771
|
+
res.writeHead(204);
|
|
772
|
+
res.end();
|
|
773
|
+
});
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
if (req.url === '/__bloby/perf' && req.method === 'GET') {
|
|
777
|
+
if (!isLoopback(req)) { res.writeHead(403); res.end('localhost only'); return; }
|
|
778
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
779
|
+
res.end(JSON.stringify({ beacons: perfBeacons, requests: perfRequests }));
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
|
|
610
783
|
// Bloby widget — served directly (not part of Vite build)
|
|
611
784
|
if (req.url === '/bloby/widget.js') {
|
|
612
|
-
|
|
613
|
-
res.end(fs.readFileSync(paths.widgetJs));
|
|
785
|
+
serveCachedText(req, res, 'widget', { file: paths.widgetJs }, 'application/javascript', 'no-cache');
|
|
614
786
|
return;
|
|
615
787
|
}
|
|
616
788
|
|
|
617
789
|
// App WS client — served directly (proxies /app/api calls through WebSocket)
|
|
618
790
|
if (req.url === '/bloby/app-ws.js') {
|
|
619
|
-
res.
|
|
620
|
-
res.end(fs.readFileSync(path.join(PKG_DIR, 'supervisor', 'app-ws.js')));
|
|
791
|
+
serveCachedText(req, res, 'app-ws', { file: path.join(PKG_DIR, 'supervisor', 'app-ws.js') }, 'application/javascript', 'no-cache');
|
|
621
792
|
return;
|
|
622
793
|
}
|
|
623
794
|
|
|
@@ -625,15 +796,13 @@ export async function startSupervisor() {
|
|
|
625
796
|
// below). Auto-reloads into the "backend down" interstitial when the backend gives up, and
|
|
626
797
|
// replaces Vite's raw error overlay with a friendly one. Served here so it's always current.
|
|
627
798
|
if (req.url === '/bloby/workspace-guard.js') {
|
|
628
|
-
res.
|
|
629
|
-
res.end(fs.readFileSync(path.join(PKG_DIR, 'supervisor', 'workspace-guard.js')));
|
|
799
|
+
serveCachedText(req, res, 'guard', { file: path.join(PKG_DIR, 'supervisor', 'workspace-guard.js') }, 'application/javascript', 'no-cache');
|
|
630
800
|
return;
|
|
631
801
|
}
|
|
632
802
|
|
|
633
803
|
// Service worker — served from embedded constant (supervisor/ is always updated)
|
|
634
804
|
if (req.url === '/sw.js' || req.url === '/bloby/sw.js') {
|
|
635
|
-
|
|
636
|
-
res.end(SW_JS);
|
|
805
|
+
serveCachedText(req, res, 'sw', { content: SW_JS }, 'application/javascript', 'no-cache');
|
|
637
806
|
return;
|
|
638
807
|
}
|
|
639
808
|
|
|
@@ -650,10 +819,8 @@ export async function startSupervisor() {
|
|
|
650
819
|
// every reconnect. A mismatch means the immortal shell survived a self-update running
|
|
651
820
|
// pre-update code; the chat then triggers one full shell reload to pick up the new bundle.
|
|
652
821
|
if (req.url === '/__bloby/version') {
|
|
653
|
-
let v = 'unknown';
|
|
654
|
-
try { v = JSON.parse(fs.readFileSync(path.join(PKG_DIR, 'package.json'), 'utf-8')).version || 'unknown'; } catch {}
|
|
655
822
|
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
656
|
-
res.end(JSON.stringify({ version:
|
|
823
|
+
res.end(JSON.stringify({ version: pkgVersion }));
|
|
657
824
|
return;
|
|
658
825
|
}
|
|
659
826
|
|
|
@@ -822,7 +989,7 @@ export async function startSupervisor() {
|
|
|
822
989
|
}
|
|
823
990
|
|
|
824
991
|
const proxy = http.request(
|
|
825
|
-
{ host: '127.0.0.1', port: backendPort, path: backendPath, method: req.method, headers: req.headers },
|
|
992
|
+
{ host: '127.0.0.1', port: backendPort, path: backendPath, method: req.method, headers: stripHopByHop(req.headers), agent: loopbackAgent },
|
|
826
993
|
(proxyRes) => {
|
|
827
994
|
const ct = String(proxyRes.headers['content-type'] || '');
|
|
828
995
|
const isSse = ct.includes('text/event-stream');
|
|
@@ -2698,6 +2865,13 @@ ${alreadyLinked ? '' : `
|
|
|
2698
2865
|
// HTML files: no-cache so rebuilds are picked up immediately
|
|
2699
2866
|
// Hashed assets (.js, .css): immutable caching
|
|
2700
2867
|
const cacheControl = ext === '.html' ? 'no-cache' : 'public, max-age=31536000, immutable';
|
|
2868
|
+
// Text assets go through the in-memory cache: gzip (the 750 KB chat entry → ~230 KB
|
|
2869
|
+
// over the tunnel) + ETag/304. The mtime check inside handles rebuilds. Binary types
|
|
2870
|
+
// (images, fonts, webm) stream as before — gzip wouldn't help them.
|
|
2871
|
+
if (['.js', '.css', '.html', '.svg', '.json'].includes(ext) && stat.size < 5_000_000) {
|
|
2872
|
+
serveCachedText(req, res, 'bloby:' + filePath, { file: fullPath }, mime, cacheControl);
|
|
2873
|
+
return;
|
|
2874
|
+
}
|
|
2701
2875
|
res.writeHead(200, { 'Content-Type': mime, 'Cache-Control': cacheControl });
|
|
2702
2876
|
// A file removed/replaced mid-stream (common during a workspace rebuild) emits an
|
|
2703
2877
|
// async 'error' on the stream — without this listener it crashes the supervisor (G1).
|
|
@@ -2725,6 +2899,14 @@ ${alreadyLinked ? '' : `
|
|
|
2725
2899
|
if (stat.isFile()) {
|
|
2726
2900
|
const ext = path.extname(assetPath);
|
|
2727
2901
|
const mime = MIME_TYPES[ext] || 'application/octet-stream';
|
|
2902
|
+
// ETag + 304 via the memory cache (these had no validator, so every >24h-stale
|
|
2903
|
+
// client re-downloaded the sprite sheets in full). Binary types won't gzip below
|
|
2904
|
+
// the keep threshold — the helper drops unhelpful gzip automatically. Very large
|
|
2905
|
+
// files (videos) keep streaming.
|
|
2906
|
+
if (stat.size < 5_000_000) {
|
|
2907
|
+
serveCachedText(req, res, 'pub:' + cleanUrl, { file: assetPath }, mime, 'public, max-age=86400');
|
|
2908
|
+
return;
|
|
2909
|
+
}
|
|
2728
2910
|
res.writeHead(200, { 'Content-Type': mime, 'Cache-Control': 'public, max-age=86400' });
|
|
2729
2911
|
const rs = fs.createReadStream(assetPath);
|
|
2730
2912
|
rs.on('error', () => { if (!res.headersSent) { res.writeHead(404); res.end('Not found'); } else res.destroy(); });
|
|
@@ -2770,8 +2952,7 @@ ${alreadyLinked ? '' : `
|
|
|
2770
2952
|
(cleanUrl === '/' || fetchDest === 'document' ||
|
|
2771
2953
|
(!fetchDest && String(req.headers['accept'] || '').includes('text/html')))
|
|
2772
2954
|
) {
|
|
2773
|
-
|
|
2774
|
-
res.end(SHELL_HTML);
|
|
2955
|
+
serveCachedText(req, res, 'shell', { content: SHELL_HTML }, 'text/html', 'no-cache', { 'X-Bloby-Origin': 'supervisor' });
|
|
2775
2956
|
return;
|
|
2776
2957
|
}
|
|
2777
2958
|
|
|
@@ -2800,8 +2981,14 @@ ${alreadyLinked ? '' : `
|
|
|
2800
2981
|
|
|
2801
2982
|
// Everything else → proxy to dashboard Vite dev server
|
|
2802
2983
|
const GUARD_TAG = '<script defer src="/bloby/workspace-guard.js"></script>';
|
|
2984
|
+
// Vite dev serves everything identity-encoded and nothing else on the origin side
|
|
2985
|
+
// compresses, so the residential UPLINK — the slowest hop — carried every module raw.
|
|
2986
|
+
// Gzip text responses on the fly (level 4: ~4x smaller for ~nothing on a Pi, per response).
|
|
2987
|
+
// Streams (SSE), pre-encoded bodies, non-text types, and 304s pass through untouched.
|
|
2988
|
+
const clientAcceptsGzip = String(req.headers['accept-encoding'] || '').includes('gzip');
|
|
2989
|
+
const GZIPPABLE_RE = /^(text\/(?!event-stream)|application\/(javascript|json|manifest\+json)|image\/svg)/;
|
|
2803
2990
|
const proxy = http.request(
|
|
2804
|
-
{ host: '127.0.0.1', port: vitePorts.dashboard, path: req.url, method: req.method, headers: req.headers },
|
|
2991
|
+
{ host: '127.0.0.1', port: vitePorts.dashboard, path: req.url, method: req.method, headers: stripHopByHop(req.headers), agent: loopbackAgent },
|
|
2805
2992
|
(proxyRes) => {
|
|
2806
2993
|
const ct = String(proxyRes.headers['content-type'] || '');
|
|
2807
2994
|
const enc = String(proxyRes.headers['content-encoding'] || '');
|
|
@@ -2810,8 +2997,21 @@ ${alreadyLinked ? '' : `
|
|
|
2810
2997
|
// untouched. The guard auto-reloads into the "backend down" interstitial and replaces
|
|
2811
2998
|
// Vite's raw error overlay — supervisor-side so it reaches every workspace, no edits needed.
|
|
2812
2999
|
if (!ct.includes('text/html') || enc) {
|
|
2813
|
-
|
|
2814
|
-
|
|
3000
|
+
const len = Number(proxyRes.headers['content-length'] || NaN);
|
|
3001
|
+
const gzip =
|
|
3002
|
+
clientAcceptsGzip && !enc && req.method === 'GET' && proxyRes.statusCode === 200 &&
|
|
3003
|
+
GZIPPABLE_RE.test(ct) && !proxyRes.headers['content-range'] && !(len < 1024);
|
|
3004
|
+
if (!gzip) {
|
|
3005
|
+
res.writeHead(proxyRes.statusCode!, proxyRes.headers);
|
|
3006
|
+
proxyRes.pipe(res);
|
|
3007
|
+
return;
|
|
3008
|
+
}
|
|
3009
|
+
const headers = { ...proxyRes.headers, 'content-encoding': 'gzip', vary: 'Accept-Encoding' };
|
|
3010
|
+
delete headers['content-length']; // length changes; let Node chunk it
|
|
3011
|
+
res.writeHead(proxyRes.statusCode!, headers);
|
|
3012
|
+
const gz = zlib.createGzip({ level: 4 });
|
|
3013
|
+
gz.on('error', () => { try { res.destroy(); } catch {} });
|
|
3014
|
+
proxyRes.pipe(gz).pipe(res);
|
|
2815
3015
|
return;
|
|
2816
3016
|
}
|
|
2817
3017
|
const chunks: Buffer[] = [];
|
|
@@ -2823,9 +3023,19 @@ ${alreadyLinked ? '' : `
|
|
|
2823
3023
|
}
|
|
2824
3024
|
const headers = { ...proxyRes.headers };
|
|
2825
3025
|
// Body length changed — drop both framing headers and let Node set content-length
|
|
2826
|
-
// from the single res.end() write.
|
|
3026
|
+
// from the single res.end() write. Vite's validators describe the UNMODIFIED body:
|
|
3027
|
+
// forwarding them would let a future conditional request 304 against bytes we changed.
|
|
2827
3028
|
delete headers['content-length'];
|
|
2828
3029
|
delete headers['transfer-encoding'];
|
|
3030
|
+
delete headers['etag'];
|
|
3031
|
+
delete headers['last-modified'];
|
|
3032
|
+
if (clientAcceptsGzip && proxyRes.statusCode === 200) {
|
|
3033
|
+
headers['content-encoding'] = 'gzip';
|
|
3034
|
+
headers['vary'] = 'Accept-Encoding';
|
|
3035
|
+
res.writeHead(proxyRes.statusCode!, headers);
|
|
3036
|
+
res.end(zlib.gzipSync(Buffer.from(html, 'utf-8'), { level: 4 }));
|
|
3037
|
+
return;
|
|
3038
|
+
}
|
|
2829
3039
|
res.writeHead(proxyRes.statusCode!, headers);
|
|
2830
3040
|
res.end(html);
|
|
2831
3041
|
});
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/supervisor/shell.ts
CHANGED
|
@@ -21,6 +21,8 @@ export const SHELL_HTML = `<!DOCTYPE html>
|
|
|
21
21
|
<link rel="icon" type="image/png" href="/morphy-favicon.png" />
|
|
22
22
|
<link rel="apple-touch-icon" href="/morphy-icon-192.png" />
|
|
23
23
|
<link rel="manifest" href="/manifest.json" />
|
|
24
|
+
<!-- widget.js sits at the end of body; fetch it while the iframe + inline scripts parse -->
|
|
25
|
+
<link rel="preload" href="/bloby/widget.js" as="script" />
|
|
24
26
|
<title>Bloby</title>
|
|
25
27
|
</head>
|
|
26
28
|
<body style="background-color:#0A0A0A;margin:0;overflow:hidden">
|
|
@@ -34,6 +36,53 @@ export const SHELL_HTML = `<!DOCTYPE html>
|
|
|
34
36
|
allow="camera; microphone; geolocation; clipboard-read; clipboard-write; fullscreen; autoplay; display-capture"
|
|
35
37
|
></iframe>
|
|
36
38
|
|
|
39
|
+
<script>
|
|
40
|
+
// ── Refresh-performance marks ──────────────────────────────────────────
|
|
41
|
+
// Collected from this document + widget.js (same window, via window.__blobyPerf.mark) +
|
|
42
|
+
// the workspace iframe (workspace-guard posts {type:'bloby:perf'}), assembled into ONE
|
|
43
|
+
// beacon per page load and POSTed to /__bloby/perf (kept server-side, localhost-readable).
|
|
44
|
+
// Live console print: localStorage.bloby_perf = '1'.
|
|
45
|
+
(function () {
|
|
46
|
+
var marks = {};
|
|
47
|
+
var workspace = null;
|
|
48
|
+
var sent = false;
|
|
49
|
+
window.__blobyPerf = {
|
|
50
|
+
mark: function (name) { if (marks[name] === undefined) marks[name] = Math.round(performance.now()); },
|
|
51
|
+
workspace: function (data) { workspace = data; },
|
|
52
|
+
};
|
|
53
|
+
function assemble() {
|
|
54
|
+
var nav = null;
|
|
55
|
+
try { nav = performance.getEntriesByType('navigation')[0] || null; } catch (e) {}
|
|
56
|
+
var b = {
|
|
57
|
+
host: location.host,
|
|
58
|
+
shell: nav ? {
|
|
59
|
+
ttfb: Math.round(nav.responseStart),
|
|
60
|
+
htmlDone: Math.round(nav.responseEnd),
|
|
61
|
+
domLoaded: Math.round(nav.domContentLoadedEventEnd),
|
|
62
|
+
} : null,
|
|
63
|
+
marks: marks,
|
|
64
|
+
workspace: workspace, // its marks arrive on the iframe's clock; offsetMs aligns them
|
|
65
|
+
};
|
|
66
|
+
if (workspace && typeof workspace.timeOrigin === 'number') {
|
|
67
|
+
b.workspace.offsetMs = Math.round(workspace.timeOrigin - performance.timeOrigin);
|
|
68
|
+
}
|
|
69
|
+
return b;
|
|
70
|
+
}
|
|
71
|
+
function send() {
|
|
72
|
+
if (sent) return;
|
|
73
|
+
sent = true;
|
|
74
|
+
var beacon = assemble();
|
|
75
|
+
try { if (localStorage.getItem('bloby_perf') === '1') console.log('[bloby-perf]', JSON.stringify(beacon, null, 2)); } catch (e) {}
|
|
76
|
+
try { fetch('/__bloby/perf', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(beacon), keepalive: true }).catch(function () {}); } catch (e) {}
|
|
77
|
+
}
|
|
78
|
+
// Send a few seconds after the workspace reports ready, so stragglers (sprite sheet,
|
|
79
|
+
// the deferred chat iframe, late modules) land in the same beacon. Backstop for broken
|
|
80
|
+
// workspaces. 7s covers the chat's idle-deferred load (~app-ready + 4s timeout + load).
|
|
81
|
+
window.addEventListener('bloby:app-ready', function () { setTimeout(send, 7000); });
|
|
82
|
+
setTimeout(send, 20000);
|
|
83
|
+
})();
|
|
84
|
+
</script>
|
|
85
|
+
|
|
37
86
|
<script>
|
|
38
87
|
(function () {
|
|
39
88
|
var frame = document.getElementById('bloby-workspace');
|
|
@@ -68,8 +117,10 @@ export const SHELL_HTML = `<!DOCTYPE html>
|
|
|
68
117
|
if (appReadyFired) return;
|
|
69
118
|
appReadyFired = true;
|
|
70
119
|
window.__blobyAppReady = true;
|
|
120
|
+
if (window.__blobyPerf) window.__blobyPerf.mark('shell:app-ready');
|
|
71
121
|
window.dispatchEvent(new Event('bloby:app-ready'));
|
|
72
122
|
}
|
|
123
|
+
if (window.__blobyPerf) window.__blobyPerf.mark('shell:frame-src-set');
|
|
73
124
|
|
|
74
125
|
window.addEventListener('message', function (e) {
|
|
75
126
|
// Same-origin iframe only — drop anything else (extensions, third-party embeds).
|
|
@@ -80,6 +131,8 @@ export const SHELL_HTML = `<!DOCTYPE html>
|
|
|
80
131
|
fireAppReady();
|
|
81
132
|
} else if (d.type === 'bloby:title') {
|
|
82
133
|
document.title = String(d.title || '').slice(0, 200) || 'Bloby';
|
|
134
|
+
} else if (d.type === 'bloby:perf' && d.data && window.__blobyPerf) {
|
|
135
|
+
window.__blobyPerf.workspace(d.data);
|
|
83
136
|
}
|
|
84
137
|
});
|
|
85
138
|
|
package/supervisor/vite-dev.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { createServer as createViteServer, createLogger, type ViteDevServer } from 'vite';
|
|
2
2
|
import type http from 'http';
|
|
3
|
+
import fs from 'fs';
|
|
3
4
|
import path from 'path';
|
|
4
|
-
import { PKG_DIR } from '../shared/paths.js';
|
|
5
|
+
import { PKG_DIR, WORKSPACE_DIR } from '../shared/paths.js';
|
|
5
6
|
import { log } from '../shared/logger.js';
|
|
6
7
|
import { appendFrontendLog } from './frontend-log.js';
|
|
7
8
|
|
|
@@ -37,6 +38,19 @@ export async function startViteDevServers(supervisorPort: number, hmrServer: htt
|
|
|
37
38
|
dashboard: supervisorPort + 2,
|
|
38
39
|
};
|
|
39
40
|
|
|
41
|
+
// vite.config.ts keys the dep-prebundle cache dir by package version (.vite-v<version>) so
|
|
42
|
+
// each release re-optimizes exactly once. Sweep the previous releases' dirs (best-effort,
|
|
43
|
+
// async — recharts-sized prebundles are ~10 MB each and would otherwise accumulate forever).
|
|
44
|
+
try {
|
|
45
|
+
let current = 'dev';
|
|
46
|
+
try { current = JSON.parse(fs.readFileSync(path.join(PKG_DIR, 'package.json'), 'utf-8')).version || 'dev'; } catch {}
|
|
47
|
+
const nmDir = path.join(WORKSPACE_DIR, 'client', 'node_modules');
|
|
48
|
+
for (const name of fs.readdirSync(nmDir)) {
|
|
49
|
+
if (name.startsWith('.vite-v') && name !== '.vite-v' + current) {
|
|
50
|
+
fs.rm(path.join(nmDir, name), { recursive: true, force: true }, () => {});
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
} catch { /* no node_modules yet / first boot — nothing to sweep */ }
|
|
40
54
|
|
|
41
55
|
try {
|
|
42
56
|
dashboardVite = await createViteServer({
|
|
@@ -63,18 +77,19 @@ export async function startViteDevServers(supervisorPort: number, hmrServer: htt
|
|
|
63
77
|
|
|
64
78
|
log.ok(`Vite HMR active — dashboard :${ports.dashboard}`);
|
|
65
79
|
|
|
66
|
-
// Warm up: fetch the
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
80
|
+
// Warm up: one fetch of the entry HTML (html transform isn't covered by server.warmup),
|
|
81
|
+
// then wait for the warmup graph (vite.config.ts now lists the whole src tree) to finish
|
|
82
|
+
// transforming. __VITE_WARM__ (consumed by the CLI spinner) used to print after only
|
|
83
|
+
// main.tsx — "warm" now actually means warm. Timeout guard so a huge graph or a wedged
|
|
84
|
+
// transform can never hang the boot signal.
|
|
85
|
+
const warm = dashboardVite;
|
|
86
|
+
Promise.resolve()
|
|
87
|
+
.then(() => fetch(`http://127.0.0.1:${ports.dashboard}/`).then((r) => r.text()).catch(() => {}))
|
|
88
|
+
.then(() => Promise.race([
|
|
89
|
+
warm ? warm.waitForRequestsIdle() : Promise.resolve(),
|
|
90
|
+
new Promise((resolve) => setTimeout(resolve, 20_000)),
|
|
91
|
+
]))
|
|
92
|
+
.finally(() => console.log('__VITE_WARM__'));
|
|
78
93
|
|
|
79
94
|
return ports;
|
|
80
95
|
}
|