Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 45 additions & 7 deletions public/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,15 @@
*/


const SHELL_CACHE = 'stellar-shell-v3';
const API_CACHE = 'stellar-api-v3';
const SHELL_CACHE = 'stellar-shell-v4';
const API_CACHE = 'stellar-api-v4';
const OLD_CACHES = [
'stellar-shell-v1',
'stellar-shell-v2',
'stellar-shell-v3',
'stellar-api-v1',
'stellar-api-v2',
'stellar-api-v3',
];

// ─── App shell assets ──────────────────────────────────────────────────────────
Expand Down Expand Up @@ -86,20 +88,52 @@
}

/**
* Wrap a Response with a custom expiry timestamp header so we can
* validate TTL when reading from the SW cache.
* True when a URL targets a Horizon/Soroban account endpoint.
* Account data is the primary surface for offline read-only mode.
*/
function isAccountUrl(url) {
try {
return /\/accounts\/[A-Za-z0-9]+/.test(new URL(url).pathname);
} catch { return false; }
}

/**
* Wrap a Response with stamped headers:
* x-sw-expires — numeric epoch expiry (existing)
* x-sw-data-source — 'network' | 'cache' | 'cache-stale'
* x-sw-cached-at — epoch ms when the entry was first stored
* x-sw-is-account — '1' for account endpoints
*/
function stampedResponse(response, ttlMs = API_CACHE_TTL_MS) {
const expires = Date.now() + ttlMs;
const headers = new Headers(response.headers);
headers.set('x-sw-expires', String(expires));
headers.set('x-sw-data-source', 'network');
headers.set('x-sw-cached-at', String(Date.now()));
if (isAccountUrl(response.url)) {
headers.set('x-sw-is-account', '1');
}
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}

/**
* Copy a cached Response and re-stamp its data-source header so callers can
* tell this payload came from cache rather than the live network.
*/
function restampCachedResponse(cached, fresh = false) {
const headers = new Headers(cached.headers);
headers.set('x-sw-data-source', fresh ? 'cache' : 'cache-stale');
return new Response(cached.body, {
status: cached.status,
statusText: cached.statusText,
headers,
});
}

/**
* Return true if a cached Response has not yet passed its sw-expires header.
*/
Expand Down Expand Up @@ -175,9 +209,10 @@
caches.open(API_CACHE).then(async (apiCache) => {
// Try L3 cache first
const cached = await apiCache.match(request);
if (cached && isFreshResponse(cached)) {
const freshHit = cached && isFreshResponse(cached);
if (freshHit) {
stats.apiHits++;
return cached;
return restampCachedResponse(cached, true);
}

// Network fetch with TTL stamp
Expand All @@ -193,7 +228,7 @@
} catch {
stats.networkErrors++;
// Return stale data if available, even if expired
if (cached) return cached;
if (cached) return restampCachedResponse(cached, false);
throw new Error('Network unavailable and no cached response');
}
}),
Expand Down Expand Up @@ -261,13 +296,13 @@
event.notification.close();
const urlToOpen = event.notification.data?.url || '/';
event.waitUntil(
clients

Check failure on line 299 in public/sw.js

View workflow job for this annotation

GitHub Actions / Lint & Format Check

'clients' is not defined
.matchAll({ type: 'window', includeUncontrolled: true })
.then((windowClients) => {
for (const client of windowClients) {
if (client.url === urlToOpen && 'focus' in client) return client.focus();
}
if (clients.openWindow) return clients.openWindow(urlToOpen);

Check failure on line 305 in public/sw.js

View workflow job for this annotation

GitHub Actions / Lint & Format Check

'clients' is not defined

Check failure on line 305 in public/sw.js

View workflow job for this annotation

GitHub Actions / Lint & Format Check

'clients' is not defined
}),
);
});
Expand All @@ -290,6 +325,9 @@
headers: {
'Content-Type': 'application/json',
'x-sw-expires': String(Date.now() + (msg.ttl || API_CACHE_TTL_MS)),
'x-sw-data-source': 'cache',
'x-sw-cached-at': String(Date.now()),
...(isAccountUrl(msg.url) ? { 'x-sw-is-account': '1' } : {}),
},
});
cache.put(msg.url, response);
Expand Down
Loading
Loading