let registration = null;
let updateAvailable = false;
const DEFAULT_SW_MESSAGE_TIMEOUT_MS = 3000;
const watchedRegistrations = new WeakSet();
let controllerChangeListenerInstalled = false;
function getCurrentScopeUrl() {
return new URL('./', window.location.href).href;
}
async function resolveRegistration() {
if (!('serviceWorker' in navigator)) {
registration = null;
return null;
}
try {
registration = await navigator.serviceWorker.getRegistration(getCurrentScopeUrl());
} catch (error) {
console.warn('[SW] Failed to resolve registration:', error);
registration = null;
}
return registration;
}
async function postMessageWithReply(message, { timeoutMs = DEFAULT_SW_MESSAGE_TIMEOUT_MS } = {}) {
const controller = navigator?.serviceWorker?.controller;
if (!controller) {
return null;
}
return new Promise((resolve) => {
const channel = new MessageChannel();
const timeoutId = setTimeout(() => {
console.warn('[SW] Timed out waiting for controller reply:', message.type);
resolve(null);
}, timeoutMs);
channel.port1.onmessage = (event) => {
clearTimeout(timeoutId);
resolve(event.data ?? null);
};
try {
controller.postMessage(message, [channel.port2]);
} catch (error) {
clearTimeout(timeoutId);
console.warn('[SW] Failed to post message to controller:', message.type, error);
resolve(null);
}
});
}
function waitForControllerChange({ timeoutMs = DEFAULT_SW_MESSAGE_TIMEOUT_MS } = {}) {
return new Promise((resolve) => {
let settled = false;
const finish = () => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeoutId);
navigator.serviceWorker.removeEventListener('controllerchange', handleControllerChange);
resolve();
};
const handleControllerChange = () => finish();
const timeoutId = setTimeout(() => {
console.warn('[SW] Timed out waiting for controller change');
finish();
}, timeoutMs);
navigator.serviceWorker.addEventListener('controllerchange', handleControllerChange);
});
}
export async function registerServiceWorker() {
if (!('serviceWorker' in navigator)) {
console.warn('[SW] Service Workers not supported');
return null;
}
try {
registration = await navigator.serviceWorker.register('./sw.js', {
scope: './',
});
console.log('[SW] Registered, scope:', registration.scope);
setupUpdateListener(registration);
await navigator.serviceWorker.ready;
await resolveRegistration();
console.log('[SW] Ready');
if (hasSharedArrayBuffer()) {
console.log('[SW] SharedArrayBuffer available');
} else {
console.warn('[SW] SharedArrayBuffer not available - reload may be needed');
}
return registration;
} catch (error) {
console.error('[SW] Registration failed:', error);
throw error;
}
}
export function hasSharedArrayBuffer() {
try {
new SharedArrayBuffer(1);
return true;
} catch {
return false;
}
}
function setupUpdateListener(reg) {
if (watchedRegistrations.has(reg)) {
return;
}
watchedRegistrations.add(reg);
reg.addEventListener('updatefound', () => {
const newWorker = reg.installing;
if (!newWorker) return;
newWorker.addEventListener('statechange', () => {
if (newWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
console.log('[SW] Update available');
updateAvailable = true;
showUpdateNotification();
} else {
console.log('[SW] First install complete');
}
}
});
});
if (!controllerChangeListenerInstalled) {
navigator.serviceWorker.addEventListener('controllerchange', () => {
console.log('[SW] Controller changed');
});
controllerChangeListenerInstalled = true;
}
}
function showUpdateNotification() {
if (document.querySelector('.sw-update-banner')) return;
const banner = document.createElement('div');
banner.className = 'sw-update-banner';
banner.innerHTML = `
<span>A new version is available.</span>
<button class="sw-update-btn">Refresh</button>
<button class="sw-dismiss-btn" aria-label="Dismiss">✕</button>
`;
Object.assign(banner.style, {
position: 'fixed',
top: '0',
left: '0',
right: '0',
padding: '12px 16px',
background: 'var(--color-primary, #3b82f6)',
color: 'white',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '16px',
zIndex: '10000',
fontFamily: 'var(--font-sans, sans-serif)',
fontSize: '14px',
});
const refreshBtn = banner.querySelector('.sw-update-btn');
Object.assign(refreshBtn.style, {
padding: '6px 16px',
background: 'white',
color: 'var(--color-primary, #3b82f6)',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontWeight: '500',
});
const dismissBtn = banner.querySelector('.sw-dismiss-btn');
Object.assign(dismissBtn.style, {
background: 'transparent',
border: 'none',
color: 'white',
cursor: 'pointer',
fontSize: '18px',
padding: '4px',
});
refreshBtn.addEventListener('click', () => {
void applyUpdate().catch((error) => {
console.error('[SW] Failed to apply update:', error);
});
});
dismissBtn.addEventListener('click', () => {
banner.remove();
});
document.body.prepend(banner);
}
export async function applyUpdate() {
const currentRegistration = registration ?? await resolveRegistration();
if (currentRegistration?.waiting) {
const waitForActivation = waitForControllerChange();
currentRegistration.waiting.postMessage({ type: 'SKIP_WAITING' });
await waitForActivation;
}
window.location.reload();
}
export function isUpdateAvailable() {
return updateAvailable;
}
export async function getRegistration() {
return registration ?? await resolveRegistration();
}
export async function unregisterServiceWorker() {
if (!('serviceWorker' in navigator)) {
registration = null;
return true;
}
const currentRegistration = registration ?? await resolveRegistration();
if (!currentRegistration) {
registration = null;
return true;
}
const unregistered = await currentRegistration.unregister();
if (unregistered) {
registration = null;
console.log('[SW] Unregistered');
return true;
}
console.warn('[SW] Service Worker refused unregister request');
return false;
}
export async function clearCache(options = {}) {
const reply = await postMessageWithReply({ type: 'CLEAR_CACHE' }, options);
if (reply?.type === 'CACHE_CLEARED') {
console.log('[SW] Cache cleared');
return true;
}
if (reply?.type === 'CACHE_CLEAR_FAILED') {
console.warn('[SW] Cache clear failed:', reply.error);
}
return false;
}
export async function getVersion(options = {}) {
const reply = await postMessageWithReply({ type: 'GET_VERSION' }, options);
return reply?.version ?? null;
}
export const swStatus = {
get isSupported() {
return 'serviceWorker' in navigator;
},
get isRegistered() {
return 'serviceWorker' in navigator
&& (registration !== null || navigator.serviceWorker.controller !== null);
},
get isActive() {
return 'serviceWorker' in navigator
&& navigator.serviceWorker.controller !== null;
},
get hasSharedArrayBuffer() {
return hasSharedArrayBuffer();
},
get updateAvailable() {
return updateAvailable;
},
};
export default {
registerServiceWorker,
hasSharedArrayBuffer,
applyUpdate,
isUpdateAvailable,
getRegistration,
unregisterServiceWorker,
clearCache,
getVersion,
swStatus,
};