fluxy-bot 0.9.6 → 0.9.8

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 CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "fluxy-bot",
3
- "version": "0.9.6",
3
+ "version": "0.9.8",
4
4
  "releaseNotes": [
5
- "Another test for self update",
5
+ "Moving away from POST to use websockets instead",
6
6
  "2. ",
7
7
  "3. ",
8
8
  "4. "
@@ -0,0 +1,200 @@
1
+ (function () {
2
+ if (window.__appWs) return;
3
+
4
+ var RECONNECT_BASE = 1000;
5
+ var RECONNECT_MAX = 8000;
6
+ var HEARTBEAT_MS = 25000;
7
+ var REQUEST_TIMEOUT = 30000;
8
+
9
+ var ws = null;
10
+ var connected = false;
11
+ var reconnectDelay = RECONNECT_BASE;
12
+ var reconnectTimer = null;
13
+ var heartbeatTimer = null;
14
+ var pendingRequests = {};
15
+ var requestCounter = 0;
16
+ var intentionalClose = false;
17
+ var originalFetch = window.fetch;
18
+
19
+ function buildWsUrl() {
20
+ var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
21
+ return proto + '//' + location.host + '/app/ws';
22
+ }
23
+
24
+ function startHeartbeat() {
25
+ stopHeartbeat();
26
+ heartbeatTimer = setInterval(function () {
27
+ if (ws && ws.readyState === WebSocket.OPEN) ws.send('ping');
28
+ }, HEARTBEAT_MS);
29
+ }
30
+
31
+ function stopHeartbeat() {
32
+ if (heartbeatTimer) {
33
+ clearInterval(heartbeatTimer);
34
+ heartbeatTimer = null;
35
+ }
36
+ }
37
+
38
+ function failoverPending() {
39
+ var ids = Object.keys(pendingRequests);
40
+ for (var i = 0; i < ids.length; i++) {
41
+ var p = pendingRequests[ids[i]];
42
+ clearTimeout(p.timer);
43
+ console.log('[app-ws] WS dropped, falling back to fetch: ' + p.method + ' ' + p.path);
44
+ p.resolve(originalFetch(p.input, p.init));
45
+ }
46
+ pendingRequests = {};
47
+ }
48
+
49
+ function connect() {
50
+ intentionalClose = false;
51
+ var url = buildWsUrl();
52
+ ws = new WebSocket(url);
53
+
54
+ ws.onopen = function () {
55
+ connected = true;
56
+ reconnectDelay = RECONNECT_BASE;
57
+ startHeartbeat();
58
+ console.log('[app-ws] Connected to /app/ws');
59
+ };
60
+
61
+ ws.onmessage = function (e) {
62
+ if (e.data === 'pong') return;
63
+
64
+ var msg;
65
+ try {
66
+ msg = JSON.parse(e.data);
67
+ } catch (err) {
68
+ return;
69
+ }
70
+
71
+ if (msg.type === 'app:api:response' && msg.data && msg.data.id) {
72
+ var pending = pendingRequests[msg.data.id];
73
+ if (pending) {
74
+ clearTimeout(pending.timer);
75
+ delete pendingRequests[msg.data.id];
76
+
77
+ console.log('[app-ws] Response via WS: ' + msg.data.status + ' ' + pending.method + ' ' + pending.path);
78
+
79
+ var responseBody = msg.data.body;
80
+ if (responseBody === null || responseBody === undefined) responseBody = '';
81
+
82
+ var responseInit = {
83
+ status: msg.data.status,
84
+ headers: msg.data.headers || {},
85
+ };
86
+
87
+ pending.resolve(new Response(responseBody, responseInit));
88
+ }
89
+ }
90
+ };
91
+
92
+ ws.onclose = function () {
93
+ connected = false;
94
+ stopHeartbeat();
95
+ failoverPending();
96
+
97
+ if (!intentionalClose) {
98
+ console.log('[app-ws] Disconnected, reconnecting in ' + reconnectDelay + 'ms');
99
+ reconnectTimer = setTimeout(function () {
100
+ reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX);
101
+ connect();
102
+ }, reconnectDelay);
103
+ }
104
+ };
105
+
106
+ ws.onerror = function () {
107
+ if (ws) ws.close();
108
+ };
109
+ }
110
+
111
+ // Override window.fetch to intercept /app/api/* calls
112
+ window.fetch = function (input, init) {
113
+ var url = input instanceof Request ? input.url : String(input);
114
+ var method = (init && init.method) || (input instanceof Request ? input.method : 'GET');
115
+
116
+ // Only intercept /app/api/* calls
117
+ if (url.indexOf('/app/api') === -1) {
118
+ return originalFetch.apply(this, arguments);
119
+ }
120
+
121
+ // Fallback: WS not connected
122
+ if (!connected || !ws || ws.readyState !== WebSocket.OPEN) {
123
+ console.log('[app-ws] WS not connected, falling back to fetch: ' + method + ' ' + url);
124
+ return originalFetch.apply(this, arguments);
125
+ }
126
+
127
+ // Fallback: non-serializable body (FormData, Blob, ArrayBuffer)
128
+ var body = init && init.body;
129
+ if (body && (body instanceof FormData || body instanceof Blob || body instanceof ArrayBuffer)) {
130
+ console.log('[app-ws] Non-serializable body, falling back to fetch: ' + method + ' ' + url);
131
+ return originalFetch.apply(this, arguments);
132
+ }
133
+
134
+ // Build request
135
+ var id = 'r' + ++requestCounter + '-' + Date.now().toString(36);
136
+
137
+ var reqPath = url;
138
+ try {
139
+ var parsed = new URL(url, location.origin);
140
+ reqPath = parsed.pathname + parsed.search;
141
+ } catch (e) {}
142
+
143
+ var headers = {};
144
+ if (init && init.headers) {
145
+ if (init.headers instanceof Headers) {
146
+ init.headers.forEach(function (v, k) {
147
+ headers[k] = v;
148
+ });
149
+ } else if (typeof init.headers === 'object') {
150
+ var h = init.headers;
151
+ for (var k in h) {
152
+ if (Object.prototype.hasOwnProperty.call(h, k)) headers[k] = h[k];
153
+ }
154
+ }
155
+ }
156
+
157
+ var bodyStr = null;
158
+ if (body !== undefined && body !== null) {
159
+ bodyStr = typeof body === 'string' ? body : JSON.stringify(body);
160
+ }
161
+
162
+ console.log('[app-ws] Proxying via WS: ' + method + ' ' + reqPath);
163
+
164
+ ws.send(
165
+ JSON.stringify({
166
+ type: 'app:api',
167
+ data: { id: id, method: method.toUpperCase(), path: reqPath, headers: headers, body: bodyStr },
168
+ })
169
+ );
170
+
171
+ var savedInput = input;
172
+ var savedInit = init;
173
+
174
+ return new Promise(function (resolve, reject) {
175
+ var timer = setTimeout(function () {
176
+ delete pendingRequests[id];
177
+ console.log('[app-ws] Request timed out, falling back to fetch: ' + method + ' ' + reqPath);
178
+ resolve(originalFetch(savedInput, savedInit));
179
+ }, REQUEST_TIMEOUT);
180
+
181
+ pendingRequests[id] = {
182
+ resolve: resolve,
183
+ reject: reject,
184
+ timer: timer,
185
+ input: savedInput,
186
+ init: savedInit,
187
+ method: method,
188
+ path: reqPath,
189
+ };
190
+ });
191
+ };
192
+
193
+ window.__appWs = {
194
+ connected: function () {
195
+ return connected;
196
+ },
197
+ };
198
+
199
+ connect();
200
+ })();
@@ -154,11 +154,12 @@ self.addEventListener('notificationclick', function(event) {
154
154
  });
155
155
  `;
156
156
 
157
- const RECOVERING_HTML = `<!DOCTYPE html><html><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Recovering</title>
158
- <style>body{background:#0a0a0f;color:#94a3b8;font-family:system-ui;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
159
- div{text-align:center}h1{font-size:18px;margin-bottom:8px;color:#e2e8f0}p{font-size:14px}a{color:#60a5fa}</style></head>
160
- <body><div><h1>Dashboard is restarting...</h1><p>Refreshing automatically.</p></div>
161
- <script>console.error('[refresh-diag] RECOVERING_HTML loaded — will auto-reload in 3s');setTimeout(()=>{console.error('[refresh-diag] RECOVERING_HTML 3s timer fired — reloading');location.reload()},3000)</script>
157
+ const RECOVERING_HTML = `<!DOCTYPE html><html style="background:#222122"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Fluxy</title>
158
+ <style>@keyframes _fs{to{transform:rotate(360deg)}}body{background:#222122;margin:0}</style></head>
159
+ <body><div style="background:#222122;color:#fff;display:flex;flex-direction:column;align-items:center;justify-content:center;height:100dvh;width:100vw;font-family:system-ui,-apple-system,sans-serif">
160
+ <img src="/fluxy-icon-192.png" width="56" height="56" style="border-radius:14px;margin-bottom:20px" alt="" />
161
+ <div style="width:18px;height:18px;border:2px solid rgba(255,255,255,0.12);border-top-color:rgba(255,255,255,0.7);border-radius:50%;animation:_fs .6s linear infinite"></div>
162
+ </div><script>setTimeout(function(){location.reload()},3000)</script>
162
163
  <script src="/fluxy/widget.js"></script></body></html>`;
163
164
 
164
165
  export async function startSupervisor() {
@@ -294,6 +295,14 @@ export async function startSupervisor() {
294
295
  return;
295
296
  }
296
297
 
298
+ // App WS client — served directly (proxies /app/api calls through WebSocket)
299
+ if (req.url === '/fluxy/app-ws.js') {
300
+ console.log('[supervisor] Serving /fluxy/app-ws.js directly');
301
+ res.writeHead(200, { 'Content-Type': 'application/javascript', 'Cache-Control': 'no-cache' });
302
+ res.end(fs.readFileSync(path.join(PKG_DIR, 'supervisor', 'app-ws.js')));
303
+ return;
304
+ }
305
+
297
306
  // Service worker — served from embedded constant (supervisor/ is always updated)
298
307
  if (req.url === '/sw.js' || req.url === '/fluxy/sw.js') {
299
308
  res.writeHead(200, { 'Content-Type': 'application/javascript', 'Cache-Control': 'no-cache' });
@@ -426,6 +435,94 @@ export async function startSupervisor() {
426
435
  // WebSocket: Fluxy chat + proxy worker WS
427
436
  const fluxyWss = new WebSocketServer({ noServer: true });
428
437
 
438
+ // WebSocket: App API proxy (routes /app/api calls through WS to avoid tunnel POST issues)
439
+ const appWss = new WebSocketServer({ noServer: true });
440
+
441
+ appWss.on('connection', (ws) => {
442
+ console.log('[supervisor] App API WS client connected');
443
+
444
+ ws.on('message', (raw) => {
445
+ const rawStr = raw.toString();
446
+
447
+ if (rawStr === 'ping') {
448
+ if (ws.readyState === WebSocket.OPEN) ws.send('pong');
449
+ return;
450
+ }
451
+
452
+ let msg: any;
453
+ try {
454
+ msg = JSON.parse(rawStr);
455
+ } catch {
456
+ return;
457
+ }
458
+
459
+ if (msg.type !== 'app:api' || !msg.data) return;
460
+
461
+ const { id, method, path: reqPath, headers: reqHeaders, body } = msg.data;
462
+ const backendPath = (reqPath || '').replace(/^\/app\/api/, '') || '/';
463
+
464
+ console.log(`[supervisor] App WS → backend :${backendPort} | ${method} ${backendPath} (${id})`);
465
+
466
+ if (!isBackendAlive()) {
467
+ console.log('[supervisor] App WS: Backend down — returning 503');
468
+ if (ws.readyState === WebSocket.OPEN) {
469
+ ws.send(JSON.stringify({
470
+ type: 'app:api:response',
471
+ data: { id, status: 503, headers: { 'content-type': 'application/json' }, body: JSON.stringify({ error: 'Backend is starting...' }) },
472
+ }));
473
+ }
474
+ return;
475
+ }
476
+
477
+ const proxyHeaders: Record<string, string> = { ...(reqHeaders || {}) };
478
+ if (body && !proxyHeaders['content-type']) {
479
+ proxyHeaders['content-type'] = 'application/json';
480
+ }
481
+
482
+ const proxyReq = http.request(
483
+ { host: '127.0.0.1', port: backendPort, path: backendPath, method, headers: proxyHeaders },
484
+ (proxyRes) => {
485
+ const chunks: Buffer[] = [];
486
+ proxyRes.on('data', (chunk: Buffer) => chunks.push(chunk));
487
+ proxyRes.on('end', () => {
488
+ const responseBody = Buffer.concat(chunks).toString('utf-8');
489
+ const resHeaders: Record<string, string> = {};
490
+ for (const [k, v] of Object.entries(proxyRes.headers)) {
491
+ if (typeof v === 'string') resHeaders[k] = v;
492
+ else if (Array.isArray(v)) resHeaders[k] = v.join(', ');
493
+ }
494
+
495
+ console.log(`[supervisor] App WS ← backend: ${proxyRes.statusCode} (${id})`);
496
+
497
+ if (ws.readyState === WebSocket.OPEN) {
498
+ ws.send(JSON.stringify({
499
+ type: 'app:api:response',
500
+ data: { id, status: proxyRes.statusCode, headers: resHeaders, body: responseBody },
501
+ }));
502
+ }
503
+ });
504
+ },
505
+ );
506
+
507
+ proxyReq.on('error', (e) => {
508
+ console.error(`[supervisor] App WS backend error: ${backendPath}`, e.message);
509
+ if (ws.readyState === WebSocket.OPEN) {
510
+ ws.send(JSON.stringify({
511
+ type: 'app:api:response',
512
+ data: { id, status: 503, headers: { 'content-type': 'application/json' }, body: JSON.stringify({ error: 'Backend unavailable' }) },
513
+ }));
514
+ }
515
+ });
516
+
517
+ if (body) proxyReq.write(body);
518
+ proxyReq.end();
519
+ });
520
+
521
+ ws.on('close', () => {
522
+ console.log('[supervisor] App API WS client disconnected');
523
+ });
524
+ });
525
+
429
526
  /** Send a message to all connected fluxy WS clients */
430
527
  function broadcastFluxy(type: string, data: any = {}) {
431
528
  const msg = JSON.stringify({ type, data });
@@ -805,6 +902,13 @@ export async function startSupervisor() {
805
902
  server.on('upgrade', async (req, socket: net.Socket, head) => {
806
903
  console.log(`[supervisor] WebSocket upgrade: ${req.url} | protocol=${req.headers['sec-websocket-protocol'] || 'none'}`);
807
904
 
905
+ // App API WebSocket — no auth (backend handles its own auth)
906
+ if (req.url?.startsWith('/app/ws')) {
907
+ console.log('[supervisor] → App API WebSocket');
908
+ appWss.handleUpgrade(req, socket, head, (ws) => appWss.emit('connection', ws, req));
909
+ return;
910
+ }
911
+
808
912
  if (!req.url?.startsWith('/fluxy/ws')) {
809
913
  console.log('[supervisor] → Letting Vite handle this upgrade');
810
914
  return;
@@ -139,4 +139,10 @@
139
139
  })
140
140
  .catch(function () {});
141
141
  } catch (e) {}
142
+
143
+ // Inject app-ws.js — proxies /app/api/* fetch calls through WebSocket
144
+ // (works around POST request failures through Cloudflare tunnel)
145
+ var awsScript = document.createElement('script');
146
+ awsScript.src = '/fluxy/app-ws.js';
147
+ document.head.appendChild(awsScript);
142
148
  })();
@@ -265,9 +265,16 @@ Browser: GET /app/api/tasks → Supervisor strips prefix → Backend receives: G
265
265
  - **Backend** Express routes: register as `/tasks`, `/health` — NO `/app/api` prefix
266
266
  - No exceptions
267
267
 
268
+ **Tunnel reliability:** All `/app/api/*` fetch calls from the dashboard are automatically proxied through WebSocket when available. This is transparent — just use `fetch('/app/api/...')` normally. The WebSocket proxy activates automatically and falls back to regular HTTP if unavailable. You don't need to handle this in your code.
269
+
268
270
  ## Build Rules
269
271
  NEVER run `npm run build`, `vite build`, or any build commands. Vite HMR handles frontend changes automatically. The backend auto-restarts when you edit files. Never look in `dist/` or `dist-fluxy/`.
270
272
 
273
+ ## Installing Packages
274
+ You can install npm packages when a feature requires one that isn't already available. Run `npm install <package>` from the **project root** (`$PKG_DIR` / the parent of `workspace/`). Do NOT create a `package.json` inside `workspace/` — all dependencies live at the root level.
275
+
276
+ Before installing, check if a suitable package is already in `node_modules/`. Prefer well-known, maintained packages. After installing, the backend picks up new imports on the next auto-restart and Vite resolves new frontend imports via HMR — no build step needed.
277
+
271
278
  ## Backend Lifecycle (Critical)
272
279
 
273
280
  The supervisor manages the backend process. You don't need to manage it yourself.
@@ -318,6 +325,53 @@ When an MCP server is configured, its tools appear alongside your built-in tools
318
325
  - `shared/` — shared utilities
319
326
  - `bin/` — CLI entry point
320
327
 
328
+ ## Workspace Security — CRITICAL: Two Password Systems
329
+
330
+ There are TWO completely separate password systems in Fluxy. Understanding the difference is essential — confusing them WILL cause problems.
331
+
332
+ ### 1. Chat Password (Portal Password) — DO NOT TOUCH
333
+
334
+ - **Set during onboarding** — it is MANDATORY. Every Fluxy has one.
335
+ - **Protects the chat interface** (fluxy.bot/your_name) where your human talks to you.
336
+ - Stored as `portal_pass` in the **worker** database (scrypt-hashed). You cannot and should not access or modify this.
337
+ - **This is the MOST CRITICAL credential** — if compromised, an attacker gets chat access, and through you, they get terminal access, file access, and potentially root access to the entire machine.
338
+ - Optional 2FA (TOTP) can be layered on top for extra protection.
339
+ - **YOU DO NOT SET OR CHANGE THIS.** It was configured during onboarding. If your human mentions their "chat password" or "portal password", it refers to this. Never try to look it up, reset it, or modify it. If they need to change it, they re-run onboarding.
340
+
341
+ ### 2. Workspace Password (Dashboard Password) — YOU CAN SET THIS
342
+
343
+ - **OPTIONAL — not set by default.**
344
+ - Protects the **dashboard/workspace** (the `/app/` path) where your human's mini-apps, modules, data, and tools live.
345
+ - **Without this password, ANYONE who knows the URL can view the entire workspace** — all pages, all data displayed in the UI.
346
+ - Your human sets this **through you** — when they say "add a password", "protect the workspace", "lock the dashboard", or just "set a password", they mean THIS one. They already have a chat password.
347
+
348
+ **How to set the workspace password:**
349
+ ```
350
+ POST /app/api/workspace/set-password
351
+ Body: { "password": "the_password" }
352
+ ```
353
+
354
+ **How to remove it:**
355
+ ```
356
+ POST /app/api/workspace/remove-password
357
+ ```
358
+
359
+ **How it works under the hood:**
360
+ - Password is hashed (scrypt with random salt) and stored in the workspace `app.db` database (`workspace_settings` table).
361
+ - When someone visits the dashboard, a lock screen appears asking for the password.
362
+ - On correct entry, a 7-day session token is created and stored in the browser's localStorage.
363
+ - The session persists across page reloads until it expires or the password is changed.
364
+ - Changing the password invalidates all existing sessions.
365
+
366
+ ### Default State — BE AWARE
367
+
368
+ The workspace is **NOT secured by default**. If your human's Fluxy is accessible via the internet (relay, Cloudflare tunnel, etc.) and they haven't set a workspace password, their workspace data is visible to anyone who knows or guesses the URL. Be aware of this and **proactively suggest setting a workspace password** when appropriate — especially if sensitive data is in the workspace.
369
+
370
+ ### The Cardinal Rule
371
+
372
+ **When your human says "add a password" or "set a password" → they mean the WORKSPACE password.**
373
+ They already have a chat password from onboarding. Don't confuse the two. Don't go looking in the worker database for `portal_pass`. Don't tell them "you already have a password set." Set the workspace password using the route above.
374
+
321
375
  ## Modular Philosophy
322
376
  When your human asks for something new, don't rebuild the app — add a module. A sidebar icon, a dashboard card, a new page. Yesterday it was a CRM, today a finance tracker, tomorrow a diet log. They all coexist. Keep it organized.
323
377
 
@@ -1,5 +1,5 @@
1
1
  <!DOCTYPE html>
2
- <html lang="en">
2
+ <html lang="en" style="background:#222122">
3
3
  <head>
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, interactive-widget=resizes-content" />
@@ -10,9 +10,21 @@
10
10
  <link rel="apple-touch-icon" href="/fluxy-icon-192.png" />
11
11
  <link rel="manifest" href="/manifest.json" />
12
12
  <title>Fluxy</title>
13
+ <style>
14
+ @keyframes _fs{to{transform:rotate(360deg)}}
15
+ </style>
13
16
  </head>
14
- <body class="bg-background text-foreground">
17
+ <body class="bg-background text-foreground" style="background:#222122">
18
+ <!-- App shell splash — visible instantly, no JS needed.
19
+ Covers the screen during reload/restore so there's never a white flash.
20
+ React hides it on mount; shown again before any reload. -->
21
+ <div id="splash" style="background:#222122;color:#fff;display:flex;flex-direction:column;align-items:center;justify-content:center;height:100dvh;width:100vw;position:fixed;inset:0;z-index:9999;font-family:system-ui,-apple-system,sans-serif;transition:opacity .25s ease-out">
22
+ <img src="/fluxy-icon-192.png" width="56" height="56" style="border-radius:14px;margin-bottom:20px" alt="" />
23
+ <div style="width:18px;height:18px;border:2px solid rgba(255,255,255,0.12);border-top-color:rgba(255,255,255,0.7);border-radius:50%;animation:_fs .6s linear infinite"></div>
24
+ </div>
25
+
15
26
  <div id="root"></div>
27
+
16
28
  <script>
17
29
  // Global error handler — catches errors outside React's Error Boundary
18
30
  // (e.g., Vite compilation errors, module loading failures)
@@ -33,21 +45,12 @@
33
45
  <script>
34
46
  if('serviceWorker' in navigator){
35
47
  navigator.serviceWorker.register('/sw.js').then(function(r){
36
- console.warn('[refresh-diag] SW registered, state:', r.active?.state, 'waiting:', !!r.waiting);
37
48
  r.update();
38
- if(r.waiting){
39
- console.warn('[refresh-diag] SW: found waiting worker — sending SKIP_WAITING');
40
- r.waiting.postMessage({type:'SKIP_WAITING'});
41
- }
49
+ if(r.waiting){r.waiting.postMessage({type:'SKIP_WAITING'})}
42
50
  r.addEventListener('updatefound',function(){
43
- console.warn('[refresh-diag] SW: updatefound — new version installing');
44
51
  var w=r.installing;
45
52
  if(w)w.addEventListener('statechange',function(){
46
- console.warn('[refresh-diag] SW installing statechange:', w.state);
47
- if(w.state==='installed'&&navigator.serviceWorker.controller){
48
- console.warn('[refresh-diag] SW: new version installed while controller exists — sending SKIP_WAITING');
49
- w.postMessage({type:'SKIP_WAITING'});
50
- }
53
+ if(w.state==='installed'&&navigator.serviceWorker.controller)w.postMessage({type:'SKIP_WAITING'});
51
54
  });
52
55
  });
53
56
  });
@@ -27,76 +27,69 @@ export default function App() {
27
27
  const [rebuildState, setRebuildState] = useState<'idle' | 'rebuilding' | 'error'>('idle');
28
28
  const [buildError, setBuildError] = useState('');
29
29
 
30
- // ── Refresh diagnostics ──────────────────────────────────────────
31
- // Logs every possible reload trigger so we can trace phantom refreshes.
32
- // Safe to remove once the mystery is solved.
30
+ // ── Seamless reload: splash screen + freeze-thaw ──────────────────
31
+ // Prevents the "white flash" and "delayed reload" jank that plagues PWAs.
32
+ //
33
+ // How it works:
34
+ // 1. Any location.reload() shows the HTML splash BEFORE reloading
35
+ // so the user sees: app → splash → splash → app (no white gap).
36
+ // 2. When returning from background (> 30s hidden), the splash shows
37
+ // IMMEDIATELY — before Vite's delayed reconnect-reload can fire.
38
+ // If no reload comes within 3s, the splash fades away.
39
+ // 3. Vite full-reloads trigger the splash too (same mechanism).
33
40
  useEffect(() => {
34
- const t0 = Date.now();
35
- const tag = `[refresh-diag ${new Date().toISOString()}]`;
41
+ const splash = document.getElementById('splash');
42
+ let hiddenAt = 0;
36
43
 
37
- // 1. Log page load reason
38
- const navEntries = performance.getEntriesByType('navigation') as PerformanceNavigationTiming[];
39
- const navType = navEntries[0]?.type ?? 'unknown'; // 'navigate' | 'reload' | 'back_forward' | 'prerender'
40
- console.warn(`${tag} Page loaded — navType="${navType}", wasDiscarded=${document.wasDiscarded ?? 'N/A'}, visState="${document.visibilityState}"`);
44
+ // Show the splash screen (used before reloads and on resume)
45
+ function showSplash() {
46
+ if (!splash) return;
47
+ splash.style.display = 'flex';
48
+ splash.style.opacity = '1';
49
+ }
50
+
51
+ // Hide the splash screen with a fade
52
+ function hideSplash() {
53
+ if (!splash || splash.style.display === 'none') return;
54
+ splash.style.opacity = '0';
55
+ splash.addEventListener('transitionend', () => { splash.style.display = 'none'; }, { once: true });
56
+ }
41
57
 
42
- // 2. Intercept location.reload to capture stack trace
58
+ // Wrap location.reload: show splash, wait for paint, then reload.
59
+ // This ensures the dark splash is visible during the brief unload→load gap.
43
60
  const origReload = location.reload.bind(location);
44
61
  location.reload = () => {
45
- console.error(`${tag} ⚠ location.reload() called! Stack trace:`);
46
- console.trace();
47
- origReload();
62
+ showSplash();
63
+ requestAnimationFrame(() => requestAnimationFrame(() => origReload()));
48
64
  };
49
65
 
50
- // 3. Service Worker controller changes
51
- if (navigator.serviceWorker) {
52
- navigator.serviceWorker.addEventListener('controllerchange', () => {
53
- console.warn(`${tag} SW controllerchange — new SW took control (can cause reload in some browsers)`);
54
- });
55
- }
56
-
57
- // 4. Vite HMR events (Vite injects import.meta.hot on the client)
66
+ // Vite HMR: show splash before a full-reload
58
67
  if (import.meta.hot) {
59
- // Vite fires 'vite:beforeFullReload' before a full page reload
60
- import.meta.hot.on('vite:beforeFullReload', (payload: unknown) => {
61
- console.error(`${tag} ⚠ Vite HMR: full-reload triggered!`, payload);
62
- console.trace();
63
- });
64
- import.meta.hot.on('vite:beforeUpdate', (payload: unknown) => {
65
- console.warn(`${tag} Vite HMR: beforeUpdate`, payload);
66
- });
67
- import.meta.hot.on('vite:error', (payload: unknown) => {
68
- console.error(`${tag} Vite HMR: error`, payload);
69
- });
70
- import.meta.hot.on('vite:ws:disconnect', () => {
71
- console.warn(`${tag} Vite HMR: WebSocket disconnected`);
72
- });
73
- import.meta.hot.on('vite:ws:connect', () => {
74
- console.warn(`${tag} Vite HMR: WebSocket reconnected (may trigger full-reload if stale)`);
75
- });
68
+ import.meta.hot.on('vite:beforeFullReload', () => showSplash());
76
69
  }
77
70
 
78
- // 5. Visibility + focus changes (iOS kills PWA processes in background)
79
- const onVisChange = () => console.warn(`${tag} visibilitychange "${document.visibilityState}" (uptime: ${((Date.now() - t0) / 1000).toFixed(1)}s)`);
80
- const onPageShow = (e: PageTransitionEvent) => console.warn(`${tag} pageshow persisted=${e.persisted} (bfcache restore)`);
81
- const onFreeze = () => console.warn(`${tag} freeze page is being frozen by OS`);
82
- const onResume = () => console.warn(`${tag} resume — page resumed from frozen state`);
83
- document.addEventListener('visibilitychange', onVisChange);
84
- window.addEventListener('pageshow', onPageShow);
85
- document.addEventListener('freeze', onFreeze);
86
- document.addEventListener('resume', onResume);
71
+ // Freeze-thaw: when returning from background after > 30s,
72
+ // show splash proactively so the user never sees the "working app
73
+ // suddenly yank away" pattern. If Vite decides to full-reload,
74
+ // the splash is already visible. If not, we fade it away after 3s.
75
+ let thawTimer: ReturnType<typeof setTimeout>;
76
+ const BACKGROUND_THRESHOLD = 30_000; // 30 seconds
87
77
 
88
- // 6. beforeunload last chance to log before the page goes away
89
- const onBeforeUnload = () => {
90
- console.warn(`${tag} beforeunload — page is about to unload (uptime: ${((Date.now() - t0) / 1000).toFixed(1)}s)`);
78
+ const onVisChange = () => {
79
+ if (document.visibilityState === 'hidden') {
80
+ hiddenAt = Date.now();
81
+ } else if (hiddenAt && Date.now() - hiddenAt > BACKGROUND_THRESHOLD) {
82
+ showSplash();
83
+ // Give Vite 3s to trigger a reload; if it doesn't, hide splash
84
+ clearTimeout(thawTimer);
85
+ thawTimer = setTimeout(hideSplash, 3_000);
86
+ }
91
87
  };
92
- window.addEventListener('beforeunload', onBeforeUnload);
88
+ document.addEventListener('visibilitychange', onVisChange);
93
89
 
94
90
  return () => {
95
91
  document.removeEventListener('visibilitychange', onVisChange);
96
- window.removeEventListener('pageshow', onPageShow);
97
- document.removeEventListener('freeze', onFreeze);
98
- document.removeEventListener('resume', onResume);
99
- window.removeEventListener('beforeunload', onBeforeUnload);
92
+ clearTimeout(thawTimer);
100
93
  };
101
94
  }, []);
102
95
 
@@ -114,22 +107,16 @@ export default function App() {
114
107
  let safetyTimer: ReturnType<typeof setTimeout>;
115
108
  const handler = (e: MessageEvent) => {
116
109
  if (e.data?.type === 'fluxy:rebuilding') {
117
- console.warn('[refresh-diag] fluxy:rebuilding received — starting 60s safety timer');
118
110
  setRebuildState('rebuilding');
119
111
  setBuildError('');
120
112
  // Safety: auto-reload after 60s in case app:rebuilt message is lost
121
113
  clearTimeout(safetyTimer);
122
- safetyTimer = setTimeout(() => {
123
- console.error('[refresh-diag] ⚠ 60s safety timer fired — forcing reload');
124
- location.reload();
125
- }, 60_000);
114
+ safetyTimer = setTimeout(() => location.reload(), 60_000);
126
115
  } else if (e.data?.type === 'fluxy:rebuilt') {
127
- console.warn('[refresh-diag] fluxy:rebuilt received — reloading now');
128
116
  clearTimeout(safetyTimer);
129
117
  setRebuildState('idle');
130
118
  location.reload();
131
119
  } else if (e.data?.type === 'fluxy:build-error') {
132
- console.warn('[refresh-diag] fluxy:build-error received:', e.data.error);
133
120
  clearTimeout(safetyTimer);
134
121
  setRebuildState('error');
135
122
  setBuildError(e.data.error || 'Build failed');
@@ -8,3 +8,10 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
8
8
  <App />
9
9
  </React.StrictMode>,
10
10
  );
11
+
12
+ // Fade out the HTML splash screen now that React has mounted
13
+ const splash = document.getElementById('splash');
14
+ if (splash) {
15
+ splash.style.opacity = '0';
16
+ splash.addEventListener('transitionend', () => { splash.style.display = 'none'; }, { once: true });
17
+ }