bloby-bot 0.69.5 → 0.69.6
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/channels/whatsapp.ts +25 -22
- package/supervisor/index.ts +224 -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 +15 -1
- package/supervisor/widget.js +124 -24
- package/supervisor/workspace-guard.js +33 -0
- package/vite.config.ts +22 -0
- 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/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/package.json
CHANGED
|
@@ -481,6 +481,7 @@ export class WhatsAppChannel implements ChannelProvider {
|
|
|
481
481
|
if (connection === 'open') {
|
|
482
482
|
this.connected = true;
|
|
483
483
|
this.reconnectAttempts = 0;
|
|
484
|
+
this.pairingRetries = 0;
|
|
484
485
|
this.qrData = null;
|
|
485
486
|
this.qrSvg = null;
|
|
486
487
|
this.buildLidMap();
|
|
@@ -516,38 +517,40 @@ export class WhatsAppChannel implements ChannelProvider {
|
|
|
516
517
|
return;
|
|
517
518
|
}
|
|
518
519
|
|
|
519
|
-
//
|
|
520
|
-
//
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
520
|
+
// restartRequired (515) is the server's reconnect-now handoff. In the QR flow it
|
|
521
|
+
// arrives right after pair-success, while creds.registered is STILL false (that
|
|
522
|
+
// flag is only set in the pairing-code flow, messages-recv link_code_pairing_ref)
|
|
523
|
+
// — so it MUST be handled before any unpaired/phantom logic, or a successful
|
|
524
|
+
// scan gets thrown away. Backoff guards a pathological repeated-515 loop.
|
|
525
|
+
if (statusCode === DisconnectReason.restartRequired) {
|
|
526
|
+
const delay = Math.min(1000 * 2 ** this.reconnectAttempts++, 60_000);
|
|
527
|
+
log.info(`[whatsapp] Restart required (normal after pairing) — reconnecting in ${delay}ms...`);
|
|
528
|
+
this.emitStatus();
|
|
529
|
+
this.scheduleReconnect(delay);
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// Pure QR wait expired without anyone claiming an identity — regenerate a fresh
|
|
534
|
+
// QR a couple of times (the link page is likely still open), then stop instead
|
|
535
|
+
// of cycling QR codes in the background forever.
|
|
536
|
+
if (!state.creds.registered && !state.creds.me) {
|
|
537
|
+
if (this.pairingRetries < 2) {
|
|
532
538
|
this.pairingRetries++;
|
|
533
539
|
log.info(`[whatsapp] QR expired — generating a fresh one (retry ${this.pairingRetries}/2)`);
|
|
534
540
|
this.emitStatus();
|
|
535
541
|
this.scheduleReconnect(2000);
|
|
536
|
-
return;
|
|
537
542
|
} else {
|
|
538
543
|
log.info('[whatsapp] Pairing window closed without a scan — connect again to retry');
|
|
544
|
+
this.emitStatus();
|
|
539
545
|
}
|
|
540
|
-
this.emitStatus();
|
|
541
546
|
return;
|
|
542
547
|
}
|
|
543
548
|
|
|
544
|
-
//
|
|
545
|
-
//
|
|
546
|
-
//
|
|
547
|
-
//
|
|
548
|
-
const
|
|
549
|
-
const delay = fastRestart ? 1000 : Math.min(5000 * 2 ** this.reconnectAttempts, 60_000);
|
|
550
|
-
this.reconnectAttempts++;
|
|
549
|
+
// Anything else reconnects with backoff (5s → 60s). This includes the
|
|
550
|
+
// me-set-but-unregistered states: a real post-pairing identity completes its
|
|
551
|
+
// login on reconnect, and a stale pairing-code placeholder gets a 401 from the
|
|
552
|
+
// server — which lands in the loggedOut branch above and wipes it cleanly.
|
|
553
|
+
const delay = Math.min(5000 * 2 ** this.reconnectAttempts++, 60_000);
|
|
551
554
|
log.info(`[whatsapp] Reconnecting in ${Math.round(delay / 1000)}s...`);
|
|
552
555
|
this.emitStatus();
|
|
553
556
|
this.scheduleReconnect(delay);
|
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,150 @@ 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
|
+
// Request timing sample — attached before any routing so every branch is covered.
|
|
728
|
+
{
|
|
729
|
+
const t0 = Date.now();
|
|
730
|
+
const sock = req.socket as any;
|
|
731
|
+
const reused = !!sock.__blobySeen;
|
|
732
|
+
sock.__blobySeen = true;
|
|
733
|
+
const w0 = sock.bytesWritten || 0;
|
|
734
|
+
let sampled = false;
|
|
735
|
+
res.on('close', () => {
|
|
736
|
+
if (sampled) return;
|
|
737
|
+
sampled = true;
|
|
738
|
+
perfRequests.push({
|
|
739
|
+
ts: t0,
|
|
740
|
+
m: req.method,
|
|
741
|
+
p: (req.url || '').split('?')[0].slice(0, 120),
|
|
742
|
+
s: res.statusCode,
|
|
743
|
+
ms: Date.now() - t0,
|
|
744
|
+
out: Math.max(0, (req.socket.bytesWritten || 0) - w0),
|
|
745
|
+
enc: String(res.getHeader('content-encoding') || ''),
|
|
746
|
+
reused,
|
|
747
|
+
});
|
|
748
|
+
if (perfRequests.length > 400) perfRequests.splice(0, perfRequests.length - 400);
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// Client perf beacon — one compact JSON per page load, posted by the shell.
|
|
753
|
+
if (req.url === '/__bloby/perf' && req.method === 'POST') {
|
|
754
|
+
const chunks: Buffer[] = [];
|
|
755
|
+
let size = 0;
|
|
756
|
+
req.on('data', (c: Buffer) => { size += c.length; if (size <= 32_768) chunks.push(c); });
|
|
757
|
+
req.on('end', () => {
|
|
758
|
+
try {
|
|
759
|
+
if (size <= 32_768) {
|
|
760
|
+
const beacon = JSON.parse(Buffer.concat(chunks).toString('utf-8'));
|
|
761
|
+
perfBeacons.push({ ts: Date.now(), ...beacon });
|
|
762
|
+
if (perfBeacons.length > 20) perfBeacons.splice(0, perfBeacons.length - 20);
|
|
763
|
+
}
|
|
764
|
+
} catch {}
|
|
765
|
+
res.writeHead(204);
|
|
766
|
+
res.end();
|
|
767
|
+
});
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
if (req.url === '/__bloby/perf' && req.method === 'GET') {
|
|
771
|
+
if (!isLoopback(req)) { res.writeHead(403); res.end('localhost only'); return; }
|
|
772
|
+
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
773
|
+
res.end(JSON.stringify({ beacons: perfBeacons, requests: perfRequests }));
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
|
|
610
777
|
// Bloby widget — served directly (not part of Vite build)
|
|
611
778
|
if (req.url === '/bloby/widget.js') {
|
|
612
|
-
|
|
613
|
-
res.end(fs.readFileSync(paths.widgetJs));
|
|
779
|
+
serveCachedText(req, res, 'widget', { file: paths.widgetJs }, 'application/javascript', 'no-cache');
|
|
614
780
|
return;
|
|
615
781
|
}
|
|
616
782
|
|
|
617
783
|
// App WS client — served directly (proxies /app/api calls through WebSocket)
|
|
618
784
|
if (req.url === '/bloby/app-ws.js') {
|
|
619
|
-
res.
|
|
620
|
-
res.end(fs.readFileSync(path.join(PKG_DIR, 'supervisor', 'app-ws.js')));
|
|
785
|
+
serveCachedText(req, res, 'app-ws', { file: path.join(PKG_DIR, 'supervisor', 'app-ws.js') }, 'application/javascript', 'no-cache');
|
|
621
786
|
return;
|
|
622
787
|
}
|
|
623
788
|
|
|
@@ -625,15 +790,13 @@ export async function startSupervisor() {
|
|
|
625
790
|
// below). Auto-reloads into the "backend down" interstitial when the backend gives up, and
|
|
626
791
|
// replaces Vite's raw error overlay with a friendly one. Served here so it's always current.
|
|
627
792
|
if (req.url === '/bloby/workspace-guard.js') {
|
|
628
|
-
res.
|
|
629
|
-
res.end(fs.readFileSync(path.join(PKG_DIR, 'supervisor', 'workspace-guard.js')));
|
|
793
|
+
serveCachedText(req, res, 'guard', { file: path.join(PKG_DIR, 'supervisor', 'workspace-guard.js') }, 'application/javascript', 'no-cache');
|
|
630
794
|
return;
|
|
631
795
|
}
|
|
632
796
|
|
|
633
797
|
// Service worker — served from embedded constant (supervisor/ is always updated)
|
|
634
798
|
if (req.url === '/sw.js' || req.url === '/bloby/sw.js') {
|
|
635
|
-
|
|
636
|
-
res.end(SW_JS);
|
|
799
|
+
serveCachedText(req, res, 'sw', { content: SW_JS }, 'application/javascript', 'no-cache');
|
|
637
800
|
return;
|
|
638
801
|
}
|
|
639
802
|
|
|
@@ -650,10 +813,8 @@ export async function startSupervisor() {
|
|
|
650
813
|
// every reconnect. A mismatch means the immortal shell survived a self-update running
|
|
651
814
|
// pre-update code; the chat then triggers one full shell reload to pick up the new bundle.
|
|
652
815
|
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
816
|
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
656
|
-
res.end(JSON.stringify({ version:
|
|
817
|
+
res.end(JSON.stringify({ version: pkgVersion }));
|
|
657
818
|
return;
|
|
658
819
|
}
|
|
659
820
|
|
|
@@ -822,7 +983,7 @@ export async function startSupervisor() {
|
|
|
822
983
|
}
|
|
823
984
|
|
|
824
985
|
const proxy = http.request(
|
|
825
|
-
{ host: '127.0.0.1', port: backendPort, path: backendPath, method: req.method, headers: req.headers },
|
|
986
|
+
{ host: '127.0.0.1', port: backendPort, path: backendPath, method: req.method, headers: stripHopByHop(req.headers), agent: loopbackAgent },
|
|
826
987
|
(proxyRes) => {
|
|
827
988
|
const ct = String(proxyRes.headers['content-type'] || '');
|
|
828
989
|
const isSse = ct.includes('text/event-stream');
|
|
@@ -2698,6 +2859,13 @@ ${alreadyLinked ? '' : `
|
|
|
2698
2859
|
// HTML files: no-cache so rebuilds are picked up immediately
|
|
2699
2860
|
// Hashed assets (.js, .css): immutable caching
|
|
2700
2861
|
const cacheControl = ext === '.html' ? 'no-cache' : 'public, max-age=31536000, immutable';
|
|
2862
|
+
// Text assets go through the in-memory cache: gzip (the 750 KB chat entry → ~230 KB
|
|
2863
|
+
// over the tunnel) + ETag/304. The mtime check inside handles rebuilds. Binary types
|
|
2864
|
+
// (images, fonts, webm) stream as before — gzip wouldn't help them.
|
|
2865
|
+
if (['.js', '.css', '.html', '.svg', '.json'].includes(ext) && stat.size < 5_000_000) {
|
|
2866
|
+
serveCachedText(req, res, 'bloby:' + filePath, { file: fullPath }, mime, cacheControl);
|
|
2867
|
+
return;
|
|
2868
|
+
}
|
|
2701
2869
|
res.writeHead(200, { 'Content-Type': mime, 'Cache-Control': cacheControl });
|
|
2702
2870
|
// A file removed/replaced mid-stream (common during a workspace rebuild) emits an
|
|
2703
2871
|
// async 'error' on the stream — without this listener it crashes the supervisor (G1).
|
|
@@ -2725,6 +2893,14 @@ ${alreadyLinked ? '' : `
|
|
|
2725
2893
|
if (stat.isFile()) {
|
|
2726
2894
|
const ext = path.extname(assetPath);
|
|
2727
2895
|
const mime = MIME_TYPES[ext] || 'application/octet-stream';
|
|
2896
|
+
// ETag + 304 via the memory cache (these had no validator, so every >24h-stale
|
|
2897
|
+
// client re-downloaded the sprite sheets in full). Binary types won't gzip below
|
|
2898
|
+
// the keep threshold — the helper drops unhelpful gzip automatically. Very large
|
|
2899
|
+
// files (videos) keep streaming.
|
|
2900
|
+
if (stat.size < 5_000_000) {
|
|
2901
|
+
serveCachedText(req, res, 'pub:' + cleanUrl, { file: assetPath }, mime, 'public, max-age=86400');
|
|
2902
|
+
return;
|
|
2903
|
+
}
|
|
2728
2904
|
res.writeHead(200, { 'Content-Type': mime, 'Cache-Control': 'public, max-age=86400' });
|
|
2729
2905
|
const rs = fs.createReadStream(assetPath);
|
|
2730
2906
|
rs.on('error', () => { if (!res.headersSent) { res.writeHead(404); res.end('Not found'); } else res.destroy(); });
|
|
@@ -2770,8 +2946,7 @@ ${alreadyLinked ? '' : `
|
|
|
2770
2946
|
(cleanUrl === '/' || fetchDest === 'document' ||
|
|
2771
2947
|
(!fetchDest && String(req.headers['accept'] || '').includes('text/html')))
|
|
2772
2948
|
) {
|
|
2773
|
-
|
|
2774
|
-
res.end(SHELL_HTML);
|
|
2949
|
+
serveCachedText(req, res, 'shell', { content: SHELL_HTML }, 'text/html', 'no-cache', { 'X-Bloby-Origin': 'supervisor' });
|
|
2775
2950
|
return;
|
|
2776
2951
|
}
|
|
2777
2952
|
|
|
@@ -2800,8 +2975,14 @@ ${alreadyLinked ? '' : `
|
|
|
2800
2975
|
|
|
2801
2976
|
// Everything else → proxy to dashboard Vite dev server
|
|
2802
2977
|
const GUARD_TAG = '<script defer src="/bloby/workspace-guard.js"></script>';
|
|
2978
|
+
// Vite dev serves everything identity-encoded and nothing else on the origin side
|
|
2979
|
+
// compresses, so the residential UPLINK — the slowest hop — carried every module raw.
|
|
2980
|
+
// Gzip text responses on the fly (level 4: ~4x smaller for ~nothing on a Pi, per response).
|
|
2981
|
+
// Streams (SSE), pre-encoded bodies, non-text types, and 304s pass through untouched.
|
|
2982
|
+
const clientAcceptsGzip = String(req.headers['accept-encoding'] || '').includes('gzip');
|
|
2983
|
+
const GZIPPABLE_RE = /^(text\/(?!event-stream)|application\/(javascript|json|manifest\+json)|image\/svg)/;
|
|
2803
2984
|
const proxy = http.request(
|
|
2804
|
-
{ host: '127.0.0.1', port: vitePorts.dashboard, path: req.url, method: req.method, headers: req.headers },
|
|
2985
|
+
{ host: '127.0.0.1', port: vitePorts.dashboard, path: req.url, method: req.method, headers: stripHopByHop(req.headers), agent: loopbackAgent },
|
|
2805
2986
|
(proxyRes) => {
|
|
2806
2987
|
const ct = String(proxyRes.headers['content-type'] || '');
|
|
2807
2988
|
const enc = String(proxyRes.headers['content-encoding'] || '');
|
|
@@ -2810,8 +2991,21 @@ ${alreadyLinked ? '' : `
|
|
|
2810
2991
|
// untouched. The guard auto-reloads into the "backend down" interstitial and replaces
|
|
2811
2992
|
// Vite's raw error overlay — supervisor-side so it reaches every workspace, no edits needed.
|
|
2812
2993
|
if (!ct.includes('text/html') || enc) {
|
|
2813
|
-
|
|
2814
|
-
|
|
2994
|
+
const len = Number(proxyRes.headers['content-length'] || NaN);
|
|
2995
|
+
const gzip =
|
|
2996
|
+
clientAcceptsGzip && !enc && req.method === 'GET' && proxyRes.statusCode === 200 &&
|
|
2997
|
+
GZIPPABLE_RE.test(ct) && !proxyRes.headers['content-range'] && !(len < 1024);
|
|
2998
|
+
if (!gzip) {
|
|
2999
|
+
res.writeHead(proxyRes.statusCode!, proxyRes.headers);
|
|
3000
|
+
proxyRes.pipe(res);
|
|
3001
|
+
return;
|
|
3002
|
+
}
|
|
3003
|
+
const headers = { ...proxyRes.headers, 'content-encoding': 'gzip', vary: 'Accept-Encoding' };
|
|
3004
|
+
delete headers['content-length']; // length changes; let Node chunk it
|
|
3005
|
+
res.writeHead(proxyRes.statusCode!, headers);
|
|
3006
|
+
const gz = zlib.createGzip({ level: 4 });
|
|
3007
|
+
gz.on('error', () => { try { res.destroy(); } catch {} });
|
|
3008
|
+
proxyRes.pipe(gz).pipe(res);
|
|
2815
3009
|
return;
|
|
2816
3010
|
}
|
|
2817
3011
|
const chunks: Buffer[] = [];
|
|
@@ -2823,9 +3017,19 @@ ${alreadyLinked ? '' : `
|
|
|
2823
3017
|
}
|
|
2824
3018
|
const headers = { ...proxyRes.headers };
|
|
2825
3019
|
// Body length changed — drop both framing headers and let Node set content-length
|
|
2826
|
-
// from the single res.end() write.
|
|
3020
|
+
// from the single res.end() write. Vite's validators describe the UNMODIFIED body:
|
|
3021
|
+
// forwarding them would let a future conditional request 304 against bytes we changed.
|
|
2827
3022
|
delete headers['content-length'];
|
|
2828
3023
|
delete headers['transfer-encoding'];
|
|
3024
|
+
delete headers['etag'];
|
|
3025
|
+
delete headers['last-modified'];
|
|
3026
|
+
if (clientAcceptsGzip && proxyRes.statusCode === 200) {
|
|
3027
|
+
headers['content-encoding'] = 'gzip';
|
|
3028
|
+
headers['vary'] = 'Accept-Encoding';
|
|
3029
|
+
res.writeHead(proxyRes.statusCode!, headers);
|
|
3030
|
+
res.end(zlib.gzipSync(Buffer.from(html, 'utf-8'), { level: 4 }));
|
|
3031
|
+
return;
|
|
3032
|
+
}
|
|
2829
3033
|
res.writeHead(proxyRes.statusCode!, headers);
|
|
2830
3034
|
res.end(html);
|
|
2831
3035
|
});
|
|
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({
|