index.html raw
1 <!DOCTYPE html>
2 <html lang="en">
3 <head>
4 <meta charset="utf-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1">
6 <base href="/">
7 <title>smesh</title>
8 <link rel="icon" type="image/svg+xml" href="./favicon.svg">
9 <style>
10 @font-face {
11 font-family: 'Fira Code';
12 src: url('./fonts/FiraCode-Regular.woff2') format('woff2');
13 font-weight: 400;
14 font-style: normal;
15 font-display: swap;
16 }
17 @font-face {
18 font-family: 'Fira Code';
19 src: url('./fonts/FiraCode-Bold.woff2') format('woff2');
20 font-weight: 700;
21 font-style: normal;
22 font-display: swap;
23 }
24 html { margin: 0; padding: 0; }
25 body {
26 --bg: #fff;
27 --fg: #111;
28 --accent: #f59e0b;
29 --muted: #888;
30 --border: #ddd;
31 --bg2: #f5f5f5;
32 --c1: #f59e0b;
33 --c2: #cc33ff;
34 --c3: #00aabb;
35 margin: 0;
36 padding: 0;
37 min-height: 100vh;
38 background: var(--bg);
39 color: var(--fg);
40 font-family: 'Fira Code', emoji, system-ui, monospace;
41 }
42 body.dark {
43 --bg: #000;
44 --fg: #e0e0e0;
45 --accent: #f59e0b;
46 --muted: #666;
47 --border: #333;
48 --bg2: #000;
49 --c1: #f59e0b;
50 --c2: #cc33ff;
51 --c3: #00aabb;
52 }
53 [stroke="#e07030"] { stroke: var(--c1); }
54 [stroke="#8833bb"] { stroke: var(--c2); }
55 [stroke="#00aabb"] { stroke: var(--c3); }
56 #update-snackbar {
57 position: fixed;
58 bottom: 37px;
59 left: 0;
60 right: 0;
61 background: var(--accent);
62 color: #000;
63 text-align: center;
64 padding: 12px;
65 font-family: 'Fira Code', emoji, system-ui, monospace;
66 font-size: 14px;
67 cursor: pointer;
68 transform: translateY(calc(100% + 37px));
69 transition: transform 0.3s ease;
70 z-index: 9999;
71 }
72 #update-snackbar.visible {
73 transform: translateY(0);
74 }
75 #update-snackbar:hover {
76 filter: brightness(1.1);
77 }
78 </style>
79 </head>
80 <body>
81 <script>{let t=localStorage.getItem('smesh-theme');if(t==='dark'||(t===null&&matchMedia('(prefers-color-scheme:dark)').matches))document.body.classList.add('dark')}</script>
82 <div id="app-root"></div>
83 <div id="update-snackbar">new version available — click to update</div>
84 <script>
85 if ('serviceWorker' in navigator) {
86 // Queue for relay→shell messages that arrive before controller is set.
87 // After hard refresh or deploy, controller is null until claimClients() completes.
88 // Without this queue, READY from the relay SW is silently dropped and the shell
89 // SW never learns the relay is alive → all messages queue forever.
90 var _pendingBus = [];
91
92 function busToShell(d) {
93 if (navigator.serviceWorker.controller) {
94 navigator.serviceWorker.controller.postMessage(d);
95 } else {
96 _pendingBus.push(d);
97 }
98 }
99
100 navigator.serviceWorker.addEventListener('controllerchange', function() {
101 if (_pendingBus.length && navigator.serviceWorker.controller) {
102 for (var i = 0; i < _pendingBus.length; i++)
103 navigator.serviceWorker.controller.postMessage(_pendingBus[i]);
104 _pendingBus = [];
105 }
106 });
107
108 navigator.serviceWorker.register('./$sw/$entry.mjs', { type: 'module', scope: '/', updateViaCache: 'none' }).then(reg => {
109 if (!navigator.serviceWorker.controller && reg.active) {
110 reg.active.postMessage('CLAIM');
111 }
112 // Trigger update check on every page load — catches stale SW modules.
113 reg.update().catch(function(){});
114 }).catch(err => {
115 console.error('smesh: sw registration failed:', err);
116 });
117
118
119 navigator.serviceWorker.addEventListener('message', (event) => {
120 const d = event.data;
121 // Bus relay: shell SW → page → satellite SWs via _busPorts.
122 if (typeof d === 'string' && d.length > 0 && d[0] === '{') {
123 var ports = window._busPorts;
124 if (ports) for (var name in ports) ports[name].postMessage(d);
125 return;
126 }
127 if (typeof d === 'string' && d.startsWith('sw-error:')) {
128 console.error('SW RUNTIME ERROR:', d.slice(9));
129 return;
130 }
131 if (typeof d === 'string' && d === 'sw-started') return;
132 if (d === 'update-available') {
133 document.getElementById('update-snackbar').classList.add('visible');
134 }
135 if (d === 'update-activated') {
136 window.location.reload();
137 }
138 // Version epoch: shell SW detected satellite version mismatch — force re-register.
139 if (Array.isArray(d) && d[0] === 'FORCE_UPDATE_SW') {
140 var dir = d[1];
141 // Loop protection: max 3 retries per dir per page load.
142 if (!window._fuswRetries) window._fuswRetries = {};
143 var count = window._fuswRetries[dir] || 0;
144 if (count >= 3) {
145 console.error('FORCE_UPDATE_SW: giving up on ' + dir + ' after 3 retries — rebuild required');
146 return;
147 }
148 window._fuswRetries[dir] = count + 1;
149 console.warn('FORCE_UPDATE_SW: re-registering ' + dir + ' (attempt ' + (count+1) + '/3)');
150 var isLocalDev = location.hostname === 'localhost' || /^127\.0\.0\.\d+$/.test(location.hostname);
151 if (isLocalDev) {
152 // Dev mode: reload the iframe to pick up new SW.
153 var iframe = document.getElementById('sw-iframe-' + dir);
154 if (iframe) iframe.contentWindow.location.reload();
155 } else {
156 navigator.serviceWorker.getRegistrations().then(function(regs) {
157 for (var i = 0; i < regs.length; i++) {
158 if (regs[i].scope.indexOf('/' + dir + '/') !== -1) {
159 regs[i].unregister().then(function() { regSW(dir); }).catch(function() { regSW(dir); });
160 }
161 }
162 });
163 }
164 }
165 });
166
167 document.getElementById('update-snackbar').addEventListener('click', () => {
168 if (navigator.serviceWorker.controller) {
169 navigator.serviceWorker.controller.postMessage('activate-update');
170 }
171 });
172 }
173 </script>
174 <script>
175 // Satellite SWs — two modes:
176 // Production (HTTPS): same-origin path-based registration via MessagePort.
177 // Dev (localhost): cross-origin iframe at 127.0.0.3 using loader.html.
178 {
179 window._busPorts = {};
180 var isLocalDev = location.hostname === 'localhost' || /^127\.0\.0\.\d+$/.test(location.hostname);
181
182 // Same-origin path-based registration (production).
183 async function regSW(dir) {
184 try {
185 var reg = await navigator.serviceWorker.register('/'+dir+'/$entry.mjs',{type:'module',scope:'/'+dir+'/',updateViaCache:'none'});
186 reg.update().catch(function(){});
187 var _pongOk = false;
188 function sendPort(w) {
189 var ch = new MessageChannel();
190 w.postMessage({type:'bus-port'},[ch.port2]);
191 window._busPorts[dir] = ch.port1;
192 ch.port1.onmessage = function(ev) {
193 var d = ev.data;
194 if(d === 'PONG') { _pongOk = true; return; }
195 if(typeof d==='string'&&d.length>0&&d[0]==='{') busToShell(d);
196 };
197 }
198 var sw = reg.active;
199 if(sw) sendPort(sw);
200 reg.addEventListener('updatefound',function(){
201 var nw = reg.installing;
202 if(nw) nw.addEventListener('statechange',function(){
203 if(nw.state==='activated') sendPort(nw);
204 });
205 });
206 if(!sw){
207 sw = reg.installing||reg.waiting;
208 if(sw) sw.addEventListener('statechange',function(){if(sw.state==='activated')sendPort(sw);});
209 }
210 // --- Layer 1: Keepalive (10s) ---
211 // postMessage creates ExtendableMessageEvent → resets termination timer.
212 // SW calls waitUntil(25s promise) on each → overlapping holds.
213 // Detects SW restart (new Worker instance) → re-establishes MessagePort.
214 var _lastPortTarget = sw || null;
215 setInterval(function(){
216 var w = reg.active;
217 if(!w) return;
218 if(w !== _lastPortTarget) {
219 _lastPortTarget = w;
220 sendPort(w);
221 } else {
222 w.postMessage('keepalive');
223 }
224 }, 10000);
225 // --- Layer 2: Health check (30s) ---
226 // PING via MessagePort, expect PONG within 3s.
227 // On failure: re-send bus-port to recover from terminated/restarted SW.
228 var _failCount = 0;
229 setInterval(function(){
230 var port = window._busPorts[dir];
231 if(!port) return;
232 _pongOk = false;
233 try { port.postMessage('PING'); } catch(e) { _failCount++; return; }
234 setTimeout(function(){
235 if(_pongOk) { _failCount = 0; return; }
236 _failCount++;
237 console.warn(dir+' health check failed ('+_failCount+')');
238 var w = reg.active;
239 if(w) { _lastPortTarget = w; sendPort(w); }
240 }, 3000);
241 }, 30000);
242 } catch(e) { console.error(dir+' SW failed:',e); }
243 }
244
245 // Cross-origin iframe registration (dev mode).
246 // Relay SW runs on 127.0.0.3 for thread isolation.
247 function regSWIframe(dir, ip) {
248 var origin = 'http://'+ip+':'+location.port;
249 var iframe = document.createElement('iframe');
250 iframe.src = origin+'/'+dir+'/loader.html';
251 iframe.style.display = 'none';
252 iframe.id = 'sw-iframe-'+dir;
253 document.body.appendChild(iframe);
254 // Relay bus messages between iframe (satellite SW) and shell SW.
255 window.addEventListener('message', function(ev) {
256 if(ev.origin !== origin) return;
257 var d = ev.data;
258 if(typeof d==='string'&&d.length>0&&d[0]==='{') busToShell(d);
259 });
260 // Forward shell→satellite messages via iframe.
261 window._busPorts[dir] = {postMessage: function(msg) { iframe.contentWindow.postMessage(msg, origin); }};
262 }
263
264 if (isLocalDev) {
265 regSWIframe('$sw-relay', '127.0.0.3');
266 } else {
267 regSW('$sw-relay');
268 }
269 }
270 </script>
271 <script type="module" src="./mls-bridge.mjs"></script>
272 <script type="module" src="./$entry.mjs"></script>
273 </body>
274 </html>
275