Deploy to Production / Build & Verify (push) Failing after 5m56s
Ping Search Engines / Notify Search Engines (push) Successful in 2s
Deploy to Production / Pre-Deploy Tests (push) Has been skipped
Deploy to Production / Deploy to Railway (push) Has been skipped
Deploy to Production / Deploy to Render (push) Has been skipped
Deploy to Production / Deploy to VPS (PM2) (push) Has been skipped
Deploy to Production / Deploy to Fly.io (push) Has been skipped
Deploy to Production / Post-Deploy Verification (push) Has been skipped
Deploy to Production / Notify on Failure (push) Successful in 2s
E2E Test Suite / Critical User Journeys (push) Has been skipped
E2E Test Suite / API Integration Tests (push) Has been skipped
E2E Test Suite / Smoke Tests (P0) (push) Failing after 11m26s
E2E Test Suite / Form Interaction Tests (push) Failing after 11m42s
E2E Test Suite / Destructive & Chaos Tests (push) Failing after 12m2s
E2E Test Suite / Cross-Browser Regression (chromium) (push) Failing after 16m14s
E2E Test Suite / Cross-Browser Regression (webkit) (push) Failing after 17m45s
E2E Test Suite / Cross-Browser Regression (firefox) (push) Failing after 25m23s
E2E Test Suite / Security Header Tests (push) Failing after 7m55s
E2E Test Suite / Test Report Summary (push) Failing after 20s
E2E Test Suite / Mobile Device Tests (push) Failing after 2h49m9s
Uptime Monitor / Health & Response Time (push) Failing after 2s
Uptime Monitor / SSL Certificate (push) Successful in 2s
Uptime Monitor / Send Alerts (push) Failing after 3s
Uptime Monitor / Record Uptime Success (push) Has been skipped
248 lines
7.7 KiB
JavaScript
248 lines
7.7 KiB
JavaScript
/**
|
|
* WorkRoot IT Solutions - Service Worker
|
|
* Implements:
|
|
* - Cache-first strategy for static assets
|
|
* - Stale-while-revalidate for HTML pages
|
|
* - Offline fallback page
|
|
* - Background sync for form submissions
|
|
*/
|
|
|
|
const CACHE_VERSION = 'v1';
|
|
const STATIC_CACHE = `workroot-static-${CACHE_VERSION}`;
|
|
const PAGE_CACHE = `workroot-pages-${CACHE_VERSION}`;
|
|
const SYNC_QUEUE = 'workroot-sync-queue';
|
|
|
|
// Static assets to pre-cache on install
|
|
const PRECACHE_ASSETS = [
|
|
'/',
|
|
'/offline',
|
|
'/manifest.json',
|
|
'/favicon.svg',
|
|
'/apple-touch-icon.png',
|
|
];
|
|
|
|
// Page routes to cache with stale-while-revalidate
|
|
const PAGE_ROUTES = [
|
|
'/',
|
|
'/about',
|
|
'/services',
|
|
'/portfolio',
|
|
'/contact',
|
|
'/blog',
|
|
'/privacy',
|
|
'/terms',
|
|
];
|
|
|
|
// ─── Install Event ────────────────────────────────────────────────────────────
|
|
|
|
self.addEventListener('install', (event) => {
|
|
event.waitUntil(
|
|
caches.open(STATIC_CACHE).then((cache) => {
|
|
return cache.addAll(PRECACHE_ASSETS);
|
|
}).then(() => self.skipWaiting())
|
|
);
|
|
});
|
|
|
|
// ─── Activate Event ───────────────────────────────────────────────────────────
|
|
|
|
self.addEventListener('activate', (event) => {
|
|
event.waitUntil(
|
|
Promise.all([
|
|
// Delete old caches
|
|
caches.keys().then((cacheNames) => {
|
|
return Promise.all(
|
|
cacheNames
|
|
.filter((name) => name.startsWith('workroot-') && name !== STATIC_CACHE && name !== PAGE_CACHE)
|
|
.map((name) => caches.delete(name))
|
|
);
|
|
}),
|
|
// Take control immediately
|
|
self.clients.claim(),
|
|
])
|
|
);
|
|
});
|
|
|
|
// ─── Fetch Event ──────────────────────────────────────────────────────────────
|
|
|
|
self.addEventListener('fetch', (event) => {
|
|
const { request } = event;
|
|
const url = new URL(request.url);
|
|
|
|
// Only handle same-origin requests
|
|
if (url.origin !== self.location.origin) return;
|
|
|
|
// Skip non-GET requests (POST etc. handled via background sync)
|
|
if (request.method !== 'GET') return;
|
|
|
|
// Skip API routes — always go to network
|
|
if (url.pathname.startsWith('/api/')) return;
|
|
|
|
// Static assets: cache-first
|
|
if (isStaticAsset(url.pathname)) {
|
|
event.respondWith(cacheFirst(request, STATIC_CACHE));
|
|
return;
|
|
}
|
|
|
|
// HTML pages: stale-while-revalidate with offline fallback
|
|
if (request.headers.get('Accept')?.includes('text/html')) {
|
|
event.respondWith(staleWhileRevalidate(request));
|
|
return;
|
|
}
|
|
});
|
|
|
|
// ─── Background Sync Event ───────────────────────────────────────────────────
|
|
|
|
self.addEventListener('sync', (event) => {
|
|
if (event.tag === 'form-sync') {
|
|
event.waitUntil(processSyncQueue());
|
|
}
|
|
});
|
|
|
|
// ─── Push Event (for future notifications) ──────────────────────────────────
|
|
|
|
self.addEventListener('push', (event) => {
|
|
if (!event.data) return;
|
|
|
|
const data = event.data.json();
|
|
event.waitUntil(
|
|
self.registration.showNotification(data.title || 'WorkRoot', {
|
|
body: data.body || '',
|
|
icon: '/apple-touch-icon.png',
|
|
badge: '/favicon.svg',
|
|
data: data.url ? { url: data.url } : undefined,
|
|
})
|
|
);
|
|
});
|
|
|
|
self.addEventListener('notificationclick', (event) => {
|
|
event.notification.close();
|
|
if (event.notification.data?.url) {
|
|
event.waitUntil(clients.openWindow(event.notification.data.url));
|
|
}
|
|
});
|
|
|
|
// ─── Strategy Helpers ─────────────────────────────────────────────────────────
|
|
|
|
function isStaticAsset(pathname) {
|
|
return (
|
|
pathname.startsWith('/_assets/') ||
|
|
/\.(js|css|woff2?|ttf|otf|eot|png|jpg|jpeg|gif|webp|avif|svg|ico)$/i.test(pathname)
|
|
);
|
|
}
|
|
|
|
async function cacheFirst(request, cacheName) {
|
|
const cache = await caches.open(cacheName);
|
|
const cached = await cache.match(request);
|
|
if (cached) return cached;
|
|
|
|
try {
|
|
const response = await fetch(request);
|
|
if (response.ok) {
|
|
cache.put(request, response.clone());
|
|
}
|
|
return response;
|
|
} catch {
|
|
return new Response('Asset unavailable offline', { status: 503 });
|
|
}
|
|
}
|
|
|
|
async function staleWhileRevalidate(request) {
|
|
const cache = await caches.open(PAGE_CACHE);
|
|
const cached = await cache.match(request);
|
|
|
|
const fetchPromise = fetch(request)
|
|
.then((response) => {
|
|
if (response.ok) {
|
|
cache.put(request, response.clone());
|
|
}
|
|
return response;
|
|
})
|
|
.catch(() => null);
|
|
|
|
if (cached) {
|
|
// Serve cached immediately, update in background
|
|
fetchPromise.catch(() => {});
|
|
return cached;
|
|
}
|
|
|
|
// No cache — await network
|
|
const response = await fetchPromise;
|
|
if (response) return response;
|
|
|
|
// Network failed and no cache — serve offline fallback
|
|
const offlineFallback = await cache.match('/offline') || await caches.match('/offline');
|
|
if (offlineFallback) return offlineFallback;
|
|
|
|
return new Response(
|
|
'<html><body><h1>You are offline</h1><p>Please check your connection.</p></body></html>',
|
|
{ status: 503, headers: { 'Content-Type': 'text/html' } }
|
|
);
|
|
}
|
|
|
|
// ─── Background Sync Queue ────────────────────────────────────────────────────
|
|
|
|
async function processSyncQueue() {
|
|
const db = await openDB();
|
|
const queue = await getAllFromDB(db, SYNC_QUEUE);
|
|
|
|
for (const item of queue) {
|
|
try {
|
|
const response = await fetch(item.url, {
|
|
method: item.method,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(item.body),
|
|
});
|
|
|
|
if (response.ok) {
|
|
await deleteFromDB(db, SYNC_QUEUE, item.id);
|
|
// Notify any open clients of success
|
|
const clients = await self.clients.matchAll();
|
|
clients.forEach((client) => {
|
|
client.postMessage({ type: 'SYNC_SUCCESS', id: item.id, formType: item.formType });
|
|
});
|
|
}
|
|
} catch {
|
|
// Leave in queue for next sync attempt
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── IndexedDB Helpers ────────────────────────────────────────────────────────
|
|
|
|
function openDB() {
|
|
return new Promise((resolve, reject) => {
|
|
const request = indexedDB.open('workroot-pwa', 1);
|
|
|
|
request.onupgradeneeded = (event) => {
|
|
const db = event.target.result;
|
|
if (!db.objectStoreNames.contains(SYNC_QUEUE)) {
|
|
const store = db.createObjectStore(SYNC_QUEUE, { keyPath: 'id', autoIncrement: true });
|
|
store.createIndex('timestamp', 'timestamp', { unique: false });
|
|
}
|
|
};
|
|
|
|
request.onsuccess = (event) => resolve(event.target.result);
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
}
|
|
|
|
function getAllFromDB(db, storeName) {
|
|
return new Promise((resolve, reject) => {
|
|
const tx = db.transaction(storeName, 'readonly');
|
|
const store = tx.objectStore(storeName);
|
|
const request = store.getAll();
|
|
request.onsuccess = () => resolve(request.result);
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
}
|
|
|
|
function deleteFromDB(db, storeName, id) {
|
|
return new Promise((resolve, reject) => {
|
|
const tx = db.transaction(storeName, 'readwrite');
|
|
const store = tx.objectStore(storeName);
|
|
const request = store.delete(id);
|
|
request.onsuccess = () => resolve();
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
}
|