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.
Files changed (41) hide show
  1. package/README.md +4 -4
  2. package/package.json +1 -1
  3. package/supervisor/channels/whatsapp.ts +25 -22
  4. package/supervisor/chat/bloby-main.tsx +1 -1
  5. package/supervisor/harnesses/claude.ts +24 -0
  6. package/supervisor/harnesses/codex.ts +2 -27
  7. package/supervisor/harnesses/pi/index.ts +6 -0
  8. package/supervisor/harnesses/skills.ts +133 -0
  9. package/supervisor/index.ts +230 -20
  10. package/supervisor/public/morphy/headphones-idle.webp +0 -0
  11. package/supervisor/public/morphy/headphones.json +4 -4
  12. package/supervisor/public/morphy/headphones.webp +0 -0
  13. package/supervisor/public/morphy/teleporting.json +2 -2
  14. package/supervisor/public/morphy/teleporting.webp +0 -0
  15. package/supervisor/shell.ts +53 -0
  16. package/supervisor/vite-dev.ts +28 -13
  17. package/supervisor/widget.js +124 -24
  18. package/supervisor/workspace-guard.js +33 -0
  19. package/vite.config.ts +26 -1
  20. package/workspace/client/index.html +6 -1
  21. package/workspace/client/public/morphy/headphones-idle.webp +0 -0
  22. package/workspace/client/public/morphy/headphones.json +4 -4
  23. package/workspace/client/public/morphy/headphones.webp +0 -0
  24. package/workspace/client/public/morphy/teleporting.json +2 -2
  25. package/workspace/client/public/morphy/teleporting.webp +0 -0
  26. package/workspace/client/public/sw.js +25 -2
  27. package/workspace/skills/create-skill/SKILL.md +188 -0
  28. package/workspace/skills/create-skill/references/patterns.md +126 -0
  29. package/workspace/skills/mac/skill.json +15 -2
  30. package/workspace/client/public/arrow.png +0 -0
  31. package/workspace/client/public/bloby_happy.mov +0 -0
  32. package/workspace/client/public/bloby_happy.webm +0 -0
  33. package/workspace/client/public/bloby_happy_reappearing.mov +0 -0
  34. package/workspace/client/public/bloby_happy_reappearing.webm +0 -0
  35. package/workspace/client/public/bloby_say_hi.mov +0 -0
  36. package/workspace/client/public/bloby_say_hi.webm +0 -0
  37. package/workspace/client/public/bloby_tilts.webm +0 -0
  38. package/workspace/client/public/headphones_spritesheet.webp +0 -0
  39. package/workspace/client/public/spritesheet.webp +0 -0
  40. package/workspace/skills/telegram/.claude-plugin/plugin.json +0 -6
  41. package/workspace/skills/whatsapp/.claude-plugin/plugin.json +0 -6
@@ -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-v24';
145
- var HASHED_RE = new RegExp('/assets/.+-[a-zA-Z0-9]{6,}[.](js|css)$');
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
- res.writeHead(200, { 'Content-Type': 'application/javascript', 'Cache-Control': 'no-cache' });
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.writeHead(200, { 'Content-Type': 'application/javascript', 'Cache-Control': 'no-cache' });
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.writeHead(200, { 'Content-Type': 'application/javascript', 'Cache-Control': 'no-cache' });
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
- res.writeHead(200, { 'Content-Type': 'application/javascript', 'Cache-Control': 'no-cache' });
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: v }));
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
- res.writeHead(200, { 'Content-Type': 'text/html', 'Cache-Control': 'no-cache', 'X-Bloby-Origin': 'supervisor' });
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
- res.writeHead(proxyRes.statusCode!, proxyRes.headers);
2814
- proxyRes.pipe(res);
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
  });
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "headphones",
3
- "spritesheet": "headphones.png",
3
+ "spritesheet": "headphones.webp",
4
4
  "frame": {
5
- "w": 174,
6
- "h": 180
5
+ "w": 145,
6
+ "h": 150
7
7
  },
8
8
  "grid": {
9
9
  "cols": 16,
@@ -37,4 +37,4 @@
37
37
  "next": "idle"
38
38
  }
39
39
  }
40
- }
40
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teleporting",
3
- "spritesheet": "teleporting.png",
3
+ "spritesheet": "teleporting.webp",
4
4
  "frame": {
5
5
  "w": 218,
6
6
  "h": 180
@@ -35,4 +35,4 @@
35
35
  "next": "idle"
36
36
  }
37
37
  }
38
- }
38
+ }
@@ -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
 
@@ -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 dashboard entry to pre-transform modules
67
- fetch(`http://127.0.0.1:${ports.dashboard}/`).then(r => r.text()).then(async (html) => {
68
- const scriptRe = /src="([^"]+\.tsx)"/g;
69
- let m;
70
- while ((m = scriptRe.exec(html)) !== null) {
71
- const url = `http://127.0.0.1:${ports.dashboard}${m[1].startsWith('/') ? '' : '/'}${m[1]}`;
72
- await fetch(url).then(r => r.text()).catch(() => {});
73
- }
74
- console.log('__VITE_WARM__');
75
- }).catch(() => {
76
- console.log('__VITE_WARM__');
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
  }