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:
@@ -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("&", "&", "<", "<", ">", ">").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
+47
-943
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user