Files
2026FIFAWorldCup/platform/web/public/sw.js
QuantBot aa7e3bba76
Some checks failed
2026 World Cup Quant Platform - Production Deployment / Code Quality & Testing (push) Failing after 1m49s
2026 World Cup Quant Platform - Production Deployment / Deploy to Production VM via Rsync (push) Has been skipped
chore: migrate deployment to Gitea Actions with zero-trust rsync
2026-06-16 19:06:50 +08:00

260 lines
7.0 KiB
JavaScript

const CACHE_NAME = 'wc2026-quant-20260615-network-realtime-v1';
const OFFLINE_URL = '/offline';
const ASSETS = ['/offline', '/manifest.json', '/manifest-icon-192.svg', '/manifest-icon-512.svg'];
const ODDS_KEYS = 'wc2026:lastOddsSnapshot';
const MATCHES_API_PREFIX = '/api/analytics/matches';
self.addEventListener('install', (event) => {
event.waitUntil(caches.open(CACHE_NAME).then((cache) => cache.addAll(ASSETS)));
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
(async () => {
const keys = await caches.keys();
await Promise.all(keys.filter((key) => key !== CACHE_NAME).map((key) => caches.delete(key)));
await self.clients.claim();
})(),
);
});
function cacheSafeCopy(request, response) {
const cacheable = request.method === 'GET' &&
(request.url.endsWith('.png') || request.url.endsWith('.svg'));
if (!cacheable) {
return;
}
caches.open(CACHE_NAME).then((cache) => {
cache.put(request, response.clone());
});
}
function extractOddsSnapshot(payload) {
if (!payload || !Array.isArray(payload)) {
return [];
}
const now = new Date().toISOString();
return payload
.filter((item) => item?.market_type && item?.selection && typeof item?.decimal_odds === 'number')
.slice(0, 20)
.map((item) => ({
match_id: item.match_id,
home_team: item.home_team,
away_team: item.away_team,
odds: Number(item.decimal_odds),
market_type: item.market_type,
selection: item.selection,
captured_at: now,
}));
}
async function writeOddsSnapshotFromDetail(data) {
if (!data || typeof data !== 'object') {
return;
}
const rows = [];
if (Array.isArray(data.odds_series)) {
for (const row of data.odds_series) {
if (row && row.bookmaker) {
rows.push({
match_id: data.match_id,
home_team: data.home_team,
away_team: data.away_team,
decimal_odds: Number(row.decimal_odds),
market_type: row.market_type,
selection: row.selection,
});
}
}
}
const snapshot = extractOddsSnapshot(rows);
if (snapshot.length === 0) {
return;
}
const cache = await caches.open(CACHE_NAME);
await cache.put(new Request(`/api/${ODDS_KEYS}`),
new Response(JSON.stringify(snapshot), {
headers: { 'Content-Type': 'application/json' },
}));
const clientsList = await self.clients.matchAll({ type: 'window' });
clientsList.forEach((client) => {
client.postMessage({ type: 'odds-cache-updated', data: snapshot });
});
}
self.addEventListener('fetch', (event) => {
const request = event.request;
if (request.method !== 'GET' || request.url.includes('/api/analytics/proof-of-yield')) {
return;
}
const url = new URL(request.url);
if (url.pathname === '/sw.js') {
return;
}
if (url.pathname.startsWith(MATCHES_API_PREFIX)) {
event.respondWith(
(async () => {
try {
const response = await fetch(request, { cache: 'no-store' });
if (response.ok) {
const payload = await response.clone().json().catch(() => null);
if (payload && !Array.isArray(payload)) {
await writeOddsSnapshotFromDetail(payload).catch(() => {});
}
}
return response;
} catch {
return new Response(JSON.stringify({
message: '賽事資料暫時無法即時更新,請稍後重新整理。',
offline: true,
source: 'service-worker-network-only',
}), {
status: 503,
statusText: 'Service Unavailable',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
});
}
})(),
);
return;
}
if (request.mode === 'navigate') {
event.respondWith(
fetch(request).catch(() => caches.match(OFFLINE_URL)),
);
return;
}
event.respondWith(
(async () => {
const cached = await caches.match(request);
try {
const response = await fetch(request);
if (!response.ok) {
return cached || Promise.reject(new TypeError('response-not-ok'));
}
cacheSafeCopy(request, response);
return response;
} catch {
if (cached) {
return cached;
}
if (request.mode === 'navigate') {
return caches.match(OFFLINE_URL);
}
if (url.pathname.startsWith('/api/')) {
return new Response(JSON.stringify({
message: '資料服務暫時無法連線,請稍後重新整理。',
offline: true,
source: 'service-worker',
}), {
status: 503,
statusText: 'Service Unavailable',
headers: { 'Content-Type': 'application/json; charset=utf-8' },
});
}
return new Response('Service unavailable', {
status: 503,
statusText: 'Service Unavailable',
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
});
}
})(),
);
});
self.addEventListener('message', (event) => {
if (event.data?.type === 'clear-cache') {
caches.keys().then((keys) => {
keys
.filter((key) => key.startsWith('wc2026-') || key.startsWith('fifa2026'))
.forEach((key) => caches.delete(key));
});
}
if (event.data?.type === 'skip-waiting') {
self.skipWaiting();
}
if (event.data?.type === 'persist-odds' && event.data?.payload) {
const payload = event.data.payload;
caches.open(CACHE_NAME).then((cache) => {
cache.put(
new Request(`/api/${ODDS_KEYS}`),
new Response(JSON.stringify(payload), {
headers: { 'Content-Type': 'application/json' },
}),
);
});
}
});
self.addEventListener('push', (event) => {
let notify = {
title: '🚨 鎖利警報',
body: '鎖利機會已觸發,請檢查您的串關進度並考慮提前對沖。',
url: '/portfolio',
};
if (event.data) {
try {
const payload = event.data.json();
notify = {
title: payload.title || notify.title,
body: payload.body || notify.body,
url: payload.url || notify.url,
};
} catch {
notify.body = event.data.text() || notify.body;
}
}
event.waitUntil(
self.registration.showNotification(notify.title, {
body: notify.body,
icon: '/manifest-icon-192.svg',
badge: '/manifest-icon-192.svg',
data: { url: notify.url },
tag: 'wc2026-alert',
requireInteraction: true,
vibrate: [80, 80, 80],
}),
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const target = event.notification.data?.url || '/';
event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
for (const client of clients) {
if (client.url === `${self.location.origin}${target}` && 'focus' in client) {
return client.focus();
}
}
if (self.clients.openWindow) {
return self.clients.openWindow(target);
}
return undefined;
}),
);
});