feat: add PWA offline support and model caching

Add service worker that enables full offline launch and persistent
caching of large Moonshine voice model assets (136MB .data file).

- sw.js: stale-while-revalidate for app shell (HTML, JS, CSS, fonts);
  cache-first for sherpa model files and ghostty WASM; old shell caches
  deleted on SW activate to reclaim storage after updates
- server.go: /sw.js route injects current staticAssetCacheBust version
  into SW content so browser detects new SW on each deploy; new SW
  pre-caches versioned terminal.js during install so update is ready
  before next launch; SW registration added to dashboard HTML
- terminal.ts: register /sw.js on load; detect navigator.onLine at
  startup; listen for online/offline events; pause reconnect loop when
  offline instead of exhausting attempts; resume with reset attempts
  when network returns; show "Offline. Will reconnect..." instead of
  misleading WebSocket error

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-15 11:59:13 -04:00
parent 35fa7b5111
commit 6c273feda7
4 changed files with 161 additions and 1108 deletions
+20
View File
@@ -2019,6 +2019,7 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
setInterval(refreshSparklines, 30000);
}
</script>
<script>if("serviceWorker"in navigator){navigator.serviceWorker.register("/sw.js").catch(function(){})}</script>
</body>
</html>`, string(tilesJSON), composeModeJS, dockerWatchJS, screenshotEndpoint, screenshotDownloadEndpoint, screenshotDownloadQuery, screenshotDownloadExt, Version)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
@@ -2082,6 +2083,24 @@ func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, page)
}
func (s *LocalServer) handleServiceWorker(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/javascript; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
var data []byte
var err error
if s.staticPath != "" {
data, err = os.ReadFile(filepath.Join(s.staticPath, "sw.js"))
} else {
data, err = embeddedStaticAssets.ReadFile("static/sw.js")
}
if err != nil {
http.NotFound(w, r)
return
}
content := strings.ReplaceAll(string(data), "{{SHELL_VERSION}}", s.staticAssetCacheBust)
_, _ = io.WriteString(w, content)
}
func htmlEscape(value string) string {
return strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;").Replace(value)
}
@@ -2176,6 +2195,7 @@ func (s *LocalServer) Handler() http.Handler {
mux.HandleFunc("/health", s.handleHealth)
mux.HandleFunc("/api/client-log", s.handleFrontendLog)
mux.HandleFunc("/tiles", s.handleTiles)
mux.HandleFunc("/sw.js", s.handleServiceWorker)
mux.HandleFunc("/", s.handleRoot)
if strings.TrimSpace(s.staticPath) != "" {
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(s.staticPath))))
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+94
View File
@@ -0,0 +1,94 @@
const SHELL_CACHE = 'webterm-shell-{{SHELL_VERSION}}';
const MODEL_CACHE = 'webterm-models-v1';
const SHELL_ASSETS = [
'/static/js/terminal.js?v={{SHELL_VERSION}}',
'/static/monospace.css',
'/static/fonts/FiraCodeNerdFont-Regular.ttf',
'/static/icons/webterm-192.png',
'/static/manifest.json',
];
const MODEL_PREFIXES = [
'/static/js/sherpa-moonshine',
'/static/js/ghostty-vt.wasm',
];
// Pre-cache shell assets; ignore network failures during install
self.addEventListener('install', (event) => {
self.skipWaiting();
event.waitUntil(
caches.open(SHELL_CACHE).then((cache) =>
Promise.allSettled(SHELL_ASSETS.map((url) => cache.add(url)))
)
);
});
// Delete old shell caches, then claim all clients immediately
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys()
.then((keys) =>
Promise.all(
keys
.filter((k) => k.startsWith('webterm-shell-') && k !== SHELL_CACHE)
.map((k) => caches.delete(k))
)
)
.then(() => self.clients.claim())
);
});
function isModelAsset(pathname) {
return MODEL_PREFIXES.some((p) => pathname.startsWith(p));
}
self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') return;
const url = new URL(event.request.url);
// Model files: large and stable, cache-first forever
if (isModelAsset(url.pathname)) {
event.respondWith(
caches.open(MODEL_CACHE).then(async (cache) => {
const cached = await cache.match(event.request);
if (cached) return cached;
const response = await fetch(event.request);
if (response.ok) cache.put(event.request, response.clone());
return response;
})
);
return;
}
// Static assets and navigation: stale-while-revalidate
// Serve from cache immediately; update cache in background
if (url.pathname.startsWith('/static/') || event.request.mode === 'navigate') {
event.respondWith(
caches.open(SHELL_CACHE).then(async (cache) => {
const cached = await cache.match(event.request);
const networkFetch = fetch(event.request)
.then((response) => {
if (response.ok) cache.put(event.request, response.clone());
return response;
})
.catch(() => null);
if (cached) {
// Serve cache now; network update happens in background
networkFetch.catch(() => {});
return cached;
}
// Nothing cached yet — must wait for network
const response = await networkFetch;
if (response) return response;
// Offline and nothing cached: try root as fallback for navigation
if (event.request.mode === 'navigate') {
const root = await cache.match('/');
if (root) return root;
}
return Response.error();
})
);
}
});