8 Commits

Author SHA1 Message Date
Roman Krček
e616165a77 Fix service worker mess 2025-09-03 10:38:30 +02:00
Roman Krček
238d2eebc5 Fix worker reloads 2025-09-03 10:22:59 +02:00
Roman Krček
aedf260551 Fixed problem where auth is bypassed 2025-09-03 10:17:20 +02:00
Roman Krček
f1179ddc09 Fix when people order multiple times 2025-09-03 08:34:22 +02:00
Roman Krček
7b4a179428 Fix diff counting logic 2025-09-02 19:35:49 +02:00
Roman Krček
5ef9735ea5 Fixed hardcoded range 2025-09-02 19:30:33 +02:00
Roman Krček
03eeef5c69 Updated google button 2025-09-02 19:16:52 +02:00
Roman Krček
fa685e6ba9 Added loading indicator 2025-09-02 18:21:18 +02:00
10 changed files with 253 additions and 329 deletions

View File

@@ -1,7 +1,7 @@
{ {
"name": "scan-wave", "name": "scan-wave",
"private": true, "private": true,
"version": "0.0.1", "version": "0.0.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",

View File

@@ -6,12 +6,14 @@
let { let {
onSuccess, onSuccess,
onError, onError,
onDisconnect,
disabled = false, disabled = false,
size = 'default', size = 'default',
variant = 'primary' variant = 'primary'
} = $props<{ } = $props<{
onSuccess?: (token: string) => void; onSuccess?: (token: string) => void;
onError?: (error: string) => void; onError?: (error: string) => void;
onDisconnect?: () => void;
disabled?: boolean; disabled?: boolean;
size?: 'small' | 'default' | 'large'; size?: 'small' | 'default' | 'large';
variant?: 'primary' | 'secondary'; variant?: 'primary' | 'secondary';
@@ -21,8 +23,8 @@
let authState = $state(createGoogleAuthState()); let authState = $state(createGoogleAuthState());
let authManager = new GoogleAuthManager(authState); let authManager = new GoogleAuthManager(authState);
onMount(() => { onMount(async () => {
authManager.checkConnection(); await authManager.checkConnection();
}); });
async function handleConnect() { async function handleConnect() {
@@ -41,6 +43,7 @@
async function handleDisconnect() { async function handleDisconnect() {
await authManager.disconnectGoogle(); await authManager.disconnectGoogle();
onDisconnect?.();
} }
// Size classes // Size classes
@@ -57,7 +60,14 @@
}; };
</script> </script>
{#if authState.isConnected} {#if authState.checking}
<div class="flex items-center gap-3">
<div class="flex items-center gap-2 rounded-full bg-gray-100 px-3 py-1 border border-gray-300 whitespace-nowrap">
<div class="w-4 h-4 animate-spin rounded-full border-2 border-current border-t-transparent text-gray-600"></div>
<span class="text-sm font-medium text-gray-800">Checking connection...</span>
</div>
</div>
{:else if authState.isConnected}
<div class="flex flex-wrap items-center gap-3"> <div class="flex flex-wrap items-center gap-3">
<div class="flex items-center gap-2 rounded-full bg-green-100 px-3 py-1 border border-green-300 whitespace-nowrap"> <div class="flex items-center gap-2 rounded-full bg-green-100 px-3 py-1 border border-green-300 whitespace-nowrap">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-green-600" viewBox="0 0 20 20" fill="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-green-600" viewBox="0 0 20 20" fill="currentColor">

View File

@@ -30,7 +30,7 @@ export class GoogleAuthManager {
this.state = state; this.state = state;
} }
checkConnection(): void { async checkConnection(): Promise<void> {
this.state.checking = true; this.state.checking = true;
this.state.error = null; this.state.error = null;
@@ -38,12 +38,39 @@ export class GoogleAuthManager {
const token = localStorage.getItem('google_refresh_token'); const token = localStorage.getItem('google_refresh_token');
const email = localStorage.getItem('google_user_email'); const email = localStorage.getItem('google_user_email');
this.state.isConnected = !!token; if (!token) {
this.state.token = token; this.state.isConnected = false;
this.state.userEmail = email; this.state.token = null;
this.state.userEmail = null;
return;
}
// Verify the token by calling our backend endpoint
const response = await fetch('/private/api/google/auth/check', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ refreshToken: token })
});
if (response.ok) {
this.state.isConnected = true;
this.state.token = token;
this.state.userEmail = email;
} else {
// Token is invalid or expired
await this.disconnectGoogle();
if (response.status === 401) {
this.state.error = 'Google session expired. Please reconnect.';
} else {
this.state.error = 'Failed to verify connection.';
}
}
} catch (error) { } catch (error) {
console.error('Error checking connection:', error); console.error('Error checking connection:', error);
this.state.error = 'Failed to check connection status'; this.state.error = 'Failed to verify connection status';
this.state.isConnected = false;
} finally { } finally {
this.state.checking = false; this.state.checking = false;
} }

View File

@@ -51,25 +51,48 @@ export async function getRecentSpreadsheets(
* Get data from a Google Sheet * Get data from a Google Sheet
* @param refreshToken - Google refresh token * @param refreshToken - Google refresh token
* @param spreadsheetId - ID of the spreadsheet * @param spreadsheetId - ID of the spreadsheet
* @param range - Cell range to retrieve (default: A1:Z10) * @param range - Optional cell range. If not provided, it will fetch the entire first sheet.
* @returns Sheet data as a 2D array * @returns Sheet data as a 2D array
*/ */
export async function getSpreadsheetData( export async function getSpreadsheetData(
refreshToken: string, refreshToken: string,
spreadsheetId: string, spreadsheetId: string,
range: string = 'A1:Z10' range?: string
): Promise<SheetData> { ): Promise<SheetData> {
const oauth = getAuthenticatedClient(refreshToken); const oauth = getAuthenticatedClient(refreshToken);
const sheets = google.sheets({ version: 'v4', auth: oauth }); const sheets = google.sheets({ version: 'v4', auth: oauth });
const response = await sheets.spreadsheets.values.get({ let effectiveRange = range;
spreadsheetId,
range
});
return { // If no range is provided, get the name of the first sheet and use that as the range
values: response.data.values || [] // to fetch all its content.
}; if (!effectiveRange) {
try {
const info = await getSpreadsheetInfo(refreshToken, spreadsheetId);
const firstSheetName = info.sheets?.[0]?.properties?.title;
if (firstSheetName) {
// To use a sheet name as a range, it must be quoted if it contains spaces or special characters.
effectiveRange = `'${firstSheetName}'`;
} else {
// Fallback if sheet name can't be determined.
effectiveRange = 'A1:Z1000'; // A sensible default for a large preview
}
} catch (error) {
console.error(`Failed to get sheet info for spreadsheet ${spreadsheetId}`, error);
// Fallback if the info call fails
effectiveRange = 'A1:Z1000';
}
}
const response = await sheets.spreadsheets.values.get({
spreadsheetId,
range: effectiveRange
});
return {
values: response.data.values || []
};
} }
/** /**

View File

@@ -0,0 +1,32 @@
import { json } from '@sveltejs/kit';
import { getAuthenticatedClient } from '$lib/google/auth/server';
/**
* @description Verify the validity of a Google refresh token
* @method POST
* @param {Request} request
* @returns {Response}
*/
export async function POST({ request }: { request: Request }): Promise<Response> {
try {
const { refreshToken } = await request.json();
if (!refreshToken) {
return json({ error: 'Refresh token is required' }, { status: 400 });
}
// Get an authenticated client. This will attempt to get a new access token,
// which effectively validates the refresh token.
const oauth2Client = getAuthenticatedClient(refreshToken);
// Attempt to get a new access token
await oauth2Client.getAccessToken();
// If no error is thrown, the token is valid
return json({ success: true });
} catch (error) {
console.error('Failed to verify Google refresh token:', error);
// The token is likely invalid or revoked
return json({ error: 'Invalid or expired refresh token' }, { status: 401 });
}
}

View File

@@ -2,17 +2,18 @@ import { json } from '@sveltejs/kit';
import type { RequestHandler } from './$types'; import type { RequestHandler } from './$types';
import { googleSheetsServer } from '$lib/google/sheets/server.js'; import { googleSheetsServer } from '$lib/google/sheets/server.js';
export const GET: RequestHandler = async ({ params, request }) => { export const GET: RequestHandler = async ({ params, request, url }) => {
try { try {
const { sheetId } = params; const { sheetId } = params;
const authHeader = request.headers.get('authorization'); const authHeader = request.headers.get('authorization');
const range = url.searchParams.get('range') || undefined;
if (!authHeader?.startsWith('Bearer ')) { if (!authHeader?.startsWith('Bearer ')) {
return json({ error: 'Missing or invalid authorization header' }, { status: 401 }); return json({ error: 'Missing or invalid authorization header' }, { status: 401 });
} }
const refreshToken = authHeader.slice(7); const refreshToken = authHeader.slice(7);
const sheetData = await googleSheetsServer.getSpreadsheetData(refreshToken, sheetId, 'A1:Z10'); const sheetData = await googleSheetsServer.getSpreadsheetData(refreshToken, sheetId, range);
return json(sheetData); return json(sheetData);
} catch (error) { } catch (error) {

View File

@@ -1,6 +1,5 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { isTokenValid, getUserInfo, revokeToken } from '$lib/google/auth/client.js';
import type { GoogleSheet } from '$lib/google/sheets/types.ts'; import type { GoogleSheet } from '$lib/google/sheets/types.ts';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { toast } from '$lib/stores/toast.js'; import { toast } from '$lib/stores/toast.js';
@@ -16,19 +15,11 @@
let { data } = $props(); let { data } = $props();
// Step management // Step management
let currentStep = $state(0); // Start at step 0 for Google auth check let currentStep = $state(0);
const totalSteps = 4; // Increased to include auth step const totalSteps = 4;
// Step 0: Google Auth // Step 0: Google Auth
let authData = $state({ let isGoogleConnected = $state(false);
isConnected: false,
checking: true,
connecting: false,
showCancelOption: false,
token: null as string | null,
error: null as string | null,
userEmail: null as string | null
});
// Step 1: Event Details // Step 1: Event Details
let eventData = $state({ let eventData = $state({
@@ -42,13 +33,13 @@
selectedSheet: null as GoogleSheet | null, selectedSheet: null as GoogleSheet | null,
sheetData: [] as string[][], sheetData: [] as string[][],
columnMapping: { columnMapping: {
name: 0, // Initialize to 0 (no column selected) name: 0,
surname: 0, surname: 0,
email: 0, email: 0,
confirmation: 0 confirmation: 0
}, },
loading: false, loading: false,
expandedSheetList: true // Add this flag to control sheet list expansion expandedSheetList: true
}); });
// Step 3: Email // Step 3: Email
@@ -62,189 +53,11 @@
let errors = $state<Record<string, string>>({}); let errors = $state<Record<string, string>>({});
onMount(async () => { onMount(async () => {
// Check Google auth status on mount
await checkGoogleAuth();
if (currentStep === 2) { if (currentStep === 2) {
await loadRecentSheets(); await loadRecentSheets();
} }
}); });
// Google Auth functions
async function checkGoogleAuth() {
authData.checking = true;
try {
const accessToken = localStorage.getItem('google_access_token');
const refreshToken = localStorage.getItem('google_refresh_token');
if (accessToken && refreshToken) {
// Check if token is still valid
const isValid = await isTokenValid(accessToken);
authData.isConnected = isValid;
authData.token = accessToken;
if (isValid) {
// Fetch user info
await fetchUserInfo(accessToken);
}
} else {
authData.isConnected = false;
authData.userEmail = null;
}
} catch (error) {
console.error('Error checking Google auth:', error);
authData.isConnected = false;
authData.error = 'Error checking Google connection';
authData.userEmail = null;
} finally {
authData.checking = false;
}
}
async function connectToGoogle() {
authData.error = '';
authData.connecting = true;
try {
// Open popup window for OAuth
const popup = window.open(
'/auth/google',
'google-auth',
'width=500,height=600,scrollbars=yes,resizable=yes,left=' +
Math.round(window.screen.width / 2 - 250) +
',top=' +
Math.round(window.screen.height / 2 - 300)
);
if (!popup) {
authData.error = 'Failed to open popup window. Please allow popups for this site.';
authData.connecting = false;
return;
}
let authCompleted = false;
let popupTimer: number | null = null;
let cancelTimeout: number | null = null;
// Store current timestamp to detect changes in localStorage
const startTimestamp = localStorage.getItem('google_auth_timestamp') || '0';
// Poll localStorage for auth completion
const pollInterval = setInterval(() => {
try {
const currentTimestamp = localStorage.getItem('google_auth_timestamp');
// If timestamp has changed, auth is complete
if (currentTimestamp && currentTimestamp !== startTimestamp) {
handleAuthSuccess();
}
} catch (e) {
console.error('Error checking auth timestamp:', e);
}
}, 500); // Poll every 500ms
// Common handler for authentication success
function handleAuthSuccess() {
if (authCompleted) return; // Prevent duplicate handling
authCompleted = true;
authData.connecting = false;
authData.showCancelOption = false;
// Clean up timers
clearInterval(pollInterval);
if (popupTimer) clearTimeout(popupTimer);
if (cancelTimeout) clearTimeout(cancelTimeout);
// Update auth state
setTimeout(checkGoogleAuth, 100);
}
// Clean up function to handle all cleanup in one place
const cleanUp = () => {
clearInterval(pollInterval);
if (popupTimer) clearTimeout(popupTimer);
if (cancelTimeout) clearTimeout(cancelTimeout);
authData.connecting = false;
};
// Set a timeout for initial auth check
popupTimer = setTimeout(() => {
// Only check if auth isn't already completed
if (!authCompleted) {
cleanUp();
// Check if tokens were stored by the popup before it was closed
setTimeout(checkGoogleAuth, 100);
}
}, 30 * 1000) as unknown as number; // Reduced from 60s to 30s
// Show cancel option sooner
cancelTimeout = setTimeout(() => {
if (!authCompleted) {
authData.showCancelOption = true;
}
}, 10 * 1000) as unknown as number; // Reduced from 20s to 10s
// Final cleanup timeout
setTimeout(() => {
if (!authCompleted) {
cleanUp();
}
}, 60 * 1000); // Reduced from 3min to 1min
} catch (error) {
console.error('Error connecting to Google:', error);
authData.error = 'Failed to connect to Google';
authData.connecting = false;
}
}
function cancelGoogleAuth() {
authData.connecting = false;
authData.showCancelOption = false;
}
async function fetchUserInfo(accessToken: string) {
try {
// Use the new getUserInfo function from our lib
const userData = await getUserInfo(accessToken);
if (userData) {
authData.userEmail = userData.email;
} else {
authData.userEmail = null;
}
} catch (error) {
console.error('Error fetching user info:', error);
authData.userEmail = null;
}
}
async function disconnectGoogle() {
try {
// First revoke the token at Google using our API
const accessToken = localStorage.getItem('google_access_token');
if (accessToken) {
await revokeToken(accessToken);
}
// Remove tokens from local storage
localStorage.removeItem('google_access_token');
localStorage.removeItem('google_refresh_token');
// Update auth state
authData.isConnected = false;
authData.token = null;
authData.userEmail = null;
// Clear any selected sheets data
sheetsData.availableSheets = [];
sheetsData.selectedSheet = null;
sheetsData.sheetData = [];
} catch (error) {
console.error('Error disconnecting from Google:', error);
authData.error = 'Failed to disconnect from Google';
}
}
// Step navigation // Step navigation
function nextStep() { function nextStep() {
if (validateCurrentStep()) { if (validateCurrentStep()) {
@@ -265,7 +78,7 @@
let isValid = true; let isValid = true;
if (currentStep === 0) { if (currentStep === 0) {
if (!authData.isConnected) { if (!isGoogleConnected) {
toast.error('Please connect your Google account to continue'); toast.error('Please connect your Google account to continue');
errors.auth = 'Please connect your Google account to continue'; errors.auth = 'Please connect your Google account to continue';
return false; return false;
@@ -359,8 +172,8 @@
} }
try { try {
// Use the new unified API endpoint // Use the new unified API endpoint, requesting only a preview range
const response = await fetch(`/private/api/google/sheets/${sheet.id}/data`, { const response = await fetch(`/private/api/google/sheets/${sheet.id}/data?range=A1:Z10`, {
method: 'GET', method: 'GET',
headers: { headers: {
Authorization: `Bearer ${localStorage.getItem('google_refresh_token')}` Authorization: `Bearer ${localStorage.getItem('google_refresh_token')}`
@@ -437,13 +250,13 @@
// Computed values // Computed values
let canProceed = $derived(() => { let canProceed = $derived(() => {
if (currentStep === 0) return authData.isConnected; if (currentStep === 0) return isGoogleConnected;
if (currentStep === 1) return eventData.name && eventData.date; if (currentStep === 1) return !!(eventData.name && eventData.date);
if (currentStep === 2) { if (currentStep === 2) {
const { name, surname, email, confirmation } = sheetsData.columnMapping; const { name, surname, email, confirmation } = sheetsData.columnMapping;
return sheetsData.selectedSheet && name && surname && email && confirmation; return !!(sheetsData.selectedSheet && name && surname && email && confirmation);
} }
if (currentStep === 3) return emailData.subject && emailData.body; if (currentStep === 3) return !!(emailData.subject && emailData.body);
return false; return false;
}); });
</script> </script>
@@ -455,16 +268,9 @@
<div class="mb-4 rounded border border-gray-300 bg-white p-6"> <div class="mb-4 rounded border border-gray-300 bg-white p-6">
{#if currentStep === 0} {#if currentStep === 0}
<GoogleAuthStep <GoogleAuthStep
onSuccess={(token) => { onSuccess={() => (isGoogleConnected = true)}
authData.error = null; onDisconnect={() => (isGoogleConnected = false)}
authData.token = token; onError={(err) => toast.error(err)}
authData.isConnected = true;
setTimeout(checkGoogleAuth, 100);
}}
onError={(error) => {
authData.error = error;
authData.isConnected = false;
}}
/> />
{:else if currentStep === 1} {:else if currentStep === 1}
<EventDetailsStep bind:eventData /> <EventDetailsStep bind:eventData />
@@ -485,7 +291,7 @@
<StepNavigation <StepNavigation
{currentStep} {currentStep}
{totalSteps} {totalSteps}
{canProceed} canProceed={canProceed()}
{loading} {loading}
{prevStep} {prevStep}
{nextStep} {nextStep}

View File

@@ -2,9 +2,10 @@
import GoogleAuthButton from '$lib/components/GoogleAuthButton.svelte'; import GoogleAuthButton from '$lib/components/GoogleAuthButton.svelte';
// Props // Props
let { onSuccess, onError } = $props<{ let { onSuccess, onError, onDisconnect } = $props<{
onSuccess?: (token: string) => void; onSuccess?: (token: string) => void;
onError?: (error: string) => void; onError?: (error: string) => void;
onDisconnect?: () => void;
}>(); }>();
</script> </script>
@@ -15,11 +16,12 @@
To create events and import participants from Google Sheets, you need to connect your Google account. To create events and import participants from Google Sheets, you need to connect your Google account.
</p> </p>
<GoogleAuthButton <GoogleAuthButton
size="large" size="large"
variant="primary" variant="primary"
onSuccess={onSuccess} {onSuccess}
onError={onError} {onError}
/> {onDisconnect}
/>
</div> </div>
</div> </div>

View File

@@ -115,6 +115,8 @@
} }
syncingParticipants = true; syncingParticipants = true;
const previousCount = participants.length; // Capture count before sync
try { try {
// Fetch sheet data // Fetch sheet data
const response = await fetch(`/private/api/google/sheets/${event.sheet_id}/data`, { const response = await fetch(`/private/api/google/sheets/${event.sheet_id}/data`, {
@@ -136,35 +138,64 @@
if (rows.length === 0) throw new Error('No data found in sheet'); if (rows.length === 0) throw new Error('No data found in sheet');
// Extract participant data based on column mapping // --- Start of new logic to handle duplicates ---
const names: string[] = [];
const surnames: string[] = [];
const emails: string[] = [];
// Skip header row (start from index 1) // First, extract all potential participants from the sheet
const potentialParticipants = [];
for (let i = 1; i < rows.length; i++) { for (let i = 1; i < rows.length; i++) {
const row = rows[i]; const row = rows[i];
if (row.length > 0) { if (row.length > 0) {
const name = row[event.name_column - 1] || ''; const name = row[event.name_column - 1] || '';
const surname = row[event.surname_column - 1] || ''; const surname = row[event.surname_column - 1] || '';
const email = row[event.email_column - 1] || ''; const email = (row[event.email_column - 1] || '').trim();
const confirmation = row[event.confirmation_column - 1] || ''; const confirmation = row[event.confirmation_column - 1] || '';
// Only add if the row has meaningful data (not all empty) AND confirmation is TRUE
const isConfirmed = const isConfirmed =
confirmation.toString().toLowerCase() === 'true' || confirmation.toString().toLowerCase() === 'true' ||
confirmation.toString().toLowerCase() === 'yes' || confirmation.toString().toLowerCase() === 'yes' ||
confirmation === '1' || confirmation === '1' ||
confirmation === 'x'; confirmation === 'x';
if ((name.trim() || surname.trim() || email.trim()) && isConfirmed) { if ((name.trim() || surname.trim() || email) && isConfirmed) {
names.push(name.trim()); potentialParticipants.push({ name: name.trim(), surname: surname.trim(), email });
surnames.push(surname.trim());
emails.push(email.trim());
} }
} }
} }
// Create a map to count occurrences of each unique participant combination
const participantCounts = new Map<string, number>();
for (const p of potentialParticipants) {
const key = `${p.name}|${p.surname}|${p.email}`.toLowerCase(); // Create a unique key
participantCounts.set(key, (participantCounts.get(key) || 0) + 1);
}
// Create final arrays, modifying duplicate surnames to be unique
const names: string[] = [];
const surnames: string[] = [];
const emails: string[] = [];
const processedParticipants = new Map<string, number>();
for (const p of potentialParticipants) {
const key = `${p.name}|${p.surname}|${p.email}`.toLowerCase();
let finalSurname = p.surname;
// If this participant is a duplicate
if (participantCounts.get(key)! > 1) {
const count = (processedParticipants.get(key) || 0) + 1;
processedParticipants.set(key, count);
// If it's not the first occurrence, append a counter to the surname
if (count > 1) {
finalSurname = `${p.surname} (${count})`;
}
}
names.push(p.name);
surnames.push(finalSurname);
emails.push(p.email); // Keep the original email
}
// --- End of new logic ---
// Call database function to add participants // Call database function to add participants
const { error: syncError } = await data.supabase.rpc('participants_add_bulk', { const { error: syncError } = await data.supabase.rpc('participants_add_bulk', {
p_event: eventId, p_event: eventId,
@@ -177,16 +208,23 @@
// Reload participants // Reload participants
await loadParticipants(); await loadParticipants();
// Show success message with count of synced participants // Show success message with accurate count of changes
const previousCount = participants.length; const newCount = participants.length;
const newCount = names.length; const diff = newCount - previousCount;
const addedCount = Math.max(0, participants.length - previousCount); const processedCount = names.length;
toast.success( let message = `Sync complete. ${processedCount} confirmed entries processed from the sheet.`;
`Successfully synced participants. ${newCount} entries processed, ${addedCount} new participants added.`,
5000 if (diff > 0) {
); message += ` ${diff} new participants added.`;
} else if (diff < 0) {
message += ` ${-diff} participants removed.`;
} else {
message += ` No changes to the participant list.`;
}
toast.success(message, 6000);
} catch (err) { } catch (err) {
console.error('Error syncing participants:', err); console.error('Error syncing participants:', err);
toast.error(`Failed to sync participants: ${err instanceof Error ? err.message : 'Unknown error'}`); toast.error(`Failed to sync participants: ${err instanceof Error ? err.message : 'Unknown error'}`);

View File

@@ -1,86 +1,71 @@
/// <reference lib="webworker" />
/// <reference types="@sveltejs/kit" /> /// <reference types="@sveltejs/kit" />
import { build, files, version } from '$service-worker'; import { build, files, version } from '$service-worker';
// Create a unique cache name for this deployment declare const self: ServiceWorkerGlobalScope;
const CACHE = `cache-${version}`;
const CACHE = `cache-${version}`;
const ASSETS = [ const ASSETS = [
...build, // the app itself ...build,
...files // everything in `static` ...files
]; ];
self.addEventListener('install', (event) => { self.addEventListener('install', (event: ExtendableEvent) => {
// Create a new cache and add all files to it const addFilesToCache = async () => {
async function addFilesToCache() { const cache = await caches.open(CACHE);
const cache = await caches.open(CACHE); await cache.addAll(ASSETS);
await cache.addAll(ASSETS); };
}
event.waitUntil(addFilesToCache()); console.log("[SW] Installing new service worker");
event.waitUntil(addFilesToCache());
self.skipWaiting();
}); });
self.addEventListener('activate', (event) => { self.addEventListener('activate', (event: ExtendableEvent) => {
// Remove previous cached data from disk const deleteOldCaches = async () => {
async function deleteOldCaches() { for (const key of await caches.keys()) {
for (const key of await caches.keys()) { if (key !== CACHE) await caches.delete(key);
if (key !== CACHE) await caches.delete(key); console.log("[SW] Removing old service worker")
} }
} };
event.waitUntil(deleteOldCaches()); event.waitUntil(deleteOldCaches());
self.clients.claim();
}); });
self.addEventListener('fetch', (event) => { self.addEventListener('fetch', (event: FetchEvent) => {
// ignore POST requests etc if (event.request.method !== 'GET') return;
if (event.request.method !== 'GET') return;
async function respond() { const url = new URL(event.request.url);
const url = new URL(event.request.url);
// Skip caching for auth routes
if (url.pathname.startsWith('/auth/')) {
return fetch(event.request);
}
const cache = await caches.open(CACHE); // Never cache private routes
if (url.pathname.startsWith('/private')) {
event.respondWith(fetch(event.request));
return;
}
// `build`/`files` can always be served from the cache const respond = async () => {
if (ASSETS.includes(url.pathname)) { const cache = await caches.open(CACHE);
const response = await cache.match(url.pathname);
if (response) { if (ASSETS.includes(url.pathname)) {
return response; const cached = await cache.match(url.pathname);
} if (cached) return cached;
} }
// for everything else, try the network first, but try {
// fall back to the cache if we're offline const response = await fetch(event.request);
try { if (response.status === 200 && build.length > 0 && url.pathname.startsWith(`/${build[0]}/`)) {
const response = await fetch(event.request); cache.put(event.request, response.clone());
}
return response;
} catch {
const cached = await cache.match(event.request);
if (cached) return cached;
}
// if we're offline, fetch can return a value that is not a Response return new Response('Not found', { status: 404 });
// instead of throwing - and we can't pass this non-Response to respondWith };
if (!(response instanceof Response)) {
throw new Error('invalid response from fetch');
}
if (response.status === 200) { event.respondWith(respond());
cache.put(event.request, response.clone()); });
}
return response;
} catch (err) {
const response = await cache.match(event.request);
if (response) {
return response;
}
// if there's no cache, then just error out
// as there is nothing we can do to respond to this request
throw err;
}
}
event.respondWith(respond());
});