feat: expire offline work with sessions (#399)
CI / lint (pull_request) Successful in 45s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-09 12:03:55 +00:00
1 parent 33427d2407
commit 2e2a7821ae
11 files changed
+243 -32

No files matched your search

+42 -3
View File
@@ -1,6 +1,7 @@
const BASE = new URL('./', self.location.href).pathname;
importScripts(BASE + 'static/background-issue-sync.js');
const CACHE = 'stackchain-dashboard-shell-v61';
const CACHE = 'stackchain-dashboard-shell-v62';
const OFFLINE_LEASE_URL = new URL(BASE + '__offline-session-lease', self.location.origin).href;
const OUTAGE_STATUSES = new Set([500, 502, 503, 504]);
const NAVIGATION_TIMEOUT_MS = self.__STACKCHAIN_NAVIGATION_TIMEOUT_MS || 4000;
const SHELL = [
@@ -104,6 +105,41 @@ async function purgeRevokedSessionData() {
clients.forEach(client => client.postMessage?.({ type: 'stackchain-session-revoked' }));
}
async function storeOfflineLease(expiresAt) {
if (!Number.isInteger(expiresAt) || expiresAt <= 0) return;
const cache = await caches.open(CACHE);
await cache.put(OFFLINE_LEASE_URL, new Response(
JSON.stringify({ expires_at: expiresAt }),
{ headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' } },
));
}
async function validOfflineLease(cache) {
const response = await cache.match(OFFLINE_LEASE_URL);
const payload = await response?.json?.().catch(() => ({})) || {};
return Number.isInteger(payload.expires_at)
&& payload.expires_at > Math.floor(Date.now() / 1000);
}
async function expiredOfflineResponse() {
await issueSync.purge();
const keys = await caches.keys();
await Promise.all(
keys.filter(key => key.startsWith('stackchain-dashboard-')).map(key => caches.delete(key))
);
const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true });
clients.forEach(client => client.postMessage?.({ type: 'stackchain-session-expired' }));
return new Response(
'Your Stackchain session expired. Reconnect and sign in.',
{ status: 401, headers: { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' } },
);
}
async function cachedShellWithValidLease(cache) {
if (!await validOfflineLease(cache)) return expiredOfflineResponse();
return cache.match(BASE);
}
async function fetchJson(url, options = {}) {
const requestOptions = { ...options };
const method = String(options.method || 'GET').toUpperCase();
@@ -177,6 +213,9 @@ self.addEventListener('sync', event => {
self.addEventListener('message', event => {
if (event.data?.type === 'stackchain-resume-outbox') event.waitUntil(flushAndNotify());
if (event.data?.type === 'stackchain-session-lease') {
event.waitUntil(storeOfflineLease(event.data.expiresAt));
}
if (event.data?.type === 'stackchain-purge-outbox') event.waitUntil((async () => {
try {
await issueSync.purge();
@@ -215,13 +254,13 @@ self.addEventListener('fetch', event => {
if (response.ok && !response.redirected && isDashboardShell) {
await cache.put(BASE, response.clone());
} else if (OUTAGE_STATUSES.has(response.status)) {
const cached = await cache.match(BASE);
const cached = await cachedShellWithValidLease(cache);
if (cached) return cached;
}
return response;
}).catch(async () => {
const cache = await caches.open(CACHE);
const cached = await cache.match(BASE);
const cached = await cachedShellWithValidLease(cache);
return cached || new Response(
'Stackchain is offline and the dashboard is not cached yet. Reconnect and try again.',
{ status: 504, headers: { 'Content-Type': 'text/plain; charset=utf-8' } },
+55 -3
View File
@@ -81,7 +81,9 @@
devicesSheet.hidden = true;
devicesButton?.focus();
});
boundary.resumeQueuedWork();
boundary.refreshOfflineLease().then(valid => {
if (valid) boundary.resumeQueuedWork();
});
};
root.navigator?.serviceWorker?.addEventListener?.('message', event => boundary.handleServiceWorkerMessage(event));
if (root.document.readyState === 'loading') root.document.addEventListener('DOMContentLoaded', attach);
@@ -94,6 +96,8 @@
onExpired = () => {},
onClearError = () => {},
requestTimeoutMs = 15000,
now = () => Date.now(),
setTimer = (callback, delay) => setTimeout(callback, delay),
}) {
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
let expirationStarted = false;
@@ -105,10 +109,38 @@
location.replace(base + 'login?reason=session-revoked');
}
async function handleSessionExpiry() {
if (expirationStarted) return;
expirationStarted = true;
await clearPrivateDeviceData();
onExpired();
location.replace(base + 'login?reason=session-expired');
}
async function handleServiceWorkerMessage(event) {
if (event?.data?.type === 'stackchain-session-revoked') {
await handleRemoteRevocation();
}
if (event?.data?.type === 'stackchain-session-expired') {
await handleSessionExpiry();
}
}
function storedOfflineLease() {
const value = Number(localStorage?.getItem?.('stackchain.session-expires-at'));
return Number.isInteger(value) && value > 0 ? value : 0;
}
function scheduleOfflineExpiry(expiresAt) {
const delay = Math.max(0, expiresAt * 1000 - now());
setTimer(() => handleSessionExpiry(), delay);
}
async function publishOfflineLease(expiresAt) {
localStorage?.setItem?.('stackchain.session-expires-at', String(expiresAt));
scheduleOfflineExpiry(expiresAt);
const registration = await serviceWorker?.ready;
registration?.active?.postMessage({ type: 'stackchain-session-lease', expiresAt });
}
function csrfToken() {
@@ -222,13 +254,32 @@
expirationStarted = false;
await handleRemoteRevocation();
} else {
onExpired();
location.replace(base + 'login?reason=session-expired');
expirationStarted = false;
await handleSessionExpiry();
}
}
return response;
}
async function refreshOfflineLease() {
try {
const response = await sessionFetch(base + 'api/v1/session');
if (!response.ok) return false;
const payload = await response.json().catch(() => ({}));
if (!payload.authenticated || !Number.isInteger(payload.expires_at)) return false;
await publishOfflineLease(payload.expires_at);
return true;
} catch (_error) {
const expiresAt = storedOfflineLease();
if (expiresAt * 1000 > now()) {
scheduleOfflineExpiry(expiresAt);
return true;
}
if (expiresAt) await handleSessionExpiry();
return false;
}
}
function removeDashboardStorage(storage) {
if (!storage) return;
const keys = [];
@@ -338,6 +389,7 @@
revokeActiveDevice,
clearPrivateDeviceData,
handleServiceWorkerMessage,
refreshOfflineLease,
resumeQueuedWork,
};
});