14 KiB
/**
- REFERENCE TOOL: Svelte + SvelteKit Best Practices & Patterns
- Every Svelte file follows these patterns. No exceptions. */
// ============================================================================ // PATTERN 1: Svelte Store Pattern // ============================================================================
/**
- STORE: authStore
- PURPOSE: Centralized auth state (user, tokens, login status)
- UPDATES: Called only through explicit update functions
- SUBSCRIBERS: Any component needing auth state
- Files reference this store: components that check auth, routes that require auth */
// File: src/stores/auth.ts import { writable, derived } from 'svelte/store';
/**
- USER STORE
- Holds current user info from Microsoft */ export const user = writable<User | null>(null);
/**
- ACCESS TOKEN STORE
- Holds current access token (short-lived, 1 hour)
- RULE: Cleared when token expires
- RULE: Never persisted to localStorage */ export const accessToken = writable<string | null>(null);
/**
- TOKEN EXPIRY STORE
- When does current token expire (timestamp in ms) */ export const tokenExpiry = writable<number | null>(null);
/**
- DERIVED: Is authenticated?
- Returns true if user exists and token is not expired */ export const isAuthenticated = derived( [user, tokenExpiry], ([$user, $expiry]) => { return !!$user && $expiry && Date.now() < $expiry; } );
/**
- UPDATE FUNCTION: Set user after login
- CALLED BY: OAuth callback handler
- UPDATES: user, accessToken, tokenExpiry
- SIDE EFFECTS: Stores token in sessionStorage, sets up refresh timer */ export async function setAuthUser( userInfo: User, token: string, expiresIn: number ): Promise { // Update stores user.set(userInfo); accessToken.set(token); tokenExpiry.set(Date.now() + expiresIn * 1000);
// Store in sessionStorage (safe, lost on browser close) sessionStorage.setItem('ms_access_token', token); sessionStorage.setItem('ms_token_expiry', (Date.now() + expiresIn * 1000).toString());
// Set up automatic refresh timer (5 minutes before expiry) const refreshIn = (expiresIn * 1000) - (5 * 60 * 1000); setTimeout(() => { refreshAccessToken(); }, refreshIn);
console.log('Auth user set:', userInfo.displayName); }
/**
-
UPDATE FUNCTION: Refresh token via backend
-
CALLED BY: Automatic timer (5 min before expiry), before API calls
-
SIDE EFFECTS: Updates accessToken, tokenExpiry, sessionStorage */ export async function refreshAccessToken(): Promise { try { const response = await fetch('/api/auth/refresh', { method: 'POST', headers: { 'Authorization':
Bearer ${get(user)?.id}}, });if (!response.ok) { // Token refresh failed clearAuth(); // Force re-login throw new Error('Token refresh failed'); }
const { accessToken: newToken, expiresIn } = await response.json();
// Update stores accessToken.set(newToken); tokenExpiry.set(Date.now() + expiresIn * 1000); sessionStorage.setItem('ms_access_token', newToken); sessionStorage.setItem('ms_token_expiry', (Date.now() + expiresIn * 1000).toString());
// Reset refresh timer setTimeout(() => { refreshAccessToken(); }, (expiresIn * 1000) - (5 * 60 * 1000));
console.log('Access token refreshed'); } catch (error) { console.error('Token refresh error:', error); clearAuth(); } }
/**
- UPDATE FUNCTION: Logout
- CALLED BY: Logout button, auth failures
- SIDE EFFECTS: Clears all auth stores, sessionStorage, redirects */ export async function clearAuth(): Promise { user.set(null); accessToken.set(null); tokenExpiry.set(null); sessionStorage.removeItem('ms_access_token'); sessionStorage.removeItem('ms_token_expiry');
// Call backend logout (optional, clears refresh token from PocketBase) try { await fetch('/api/auth/logout', { method: 'POST' }); } catch (e) { console.error('Logout API error:', e); }
console.log('Auth cleared'); }
// ============================================================================ // PATTERN 2: Svelte Component with Auth Requirement // ============================================================================
/**
- COMPONENT: JobList.svelte
- PURPOSE: Display list of jobs from Microsoft Graph
- REQUIRES: User to be authenticated
- Reactive: $isAuthenticated, $jobs, searchQuery
- Events: None (pure presentation)
- API calls: Loads jobs via GraphService */
// File: src/components/JobList.svelte
<script lang="ts"> // Imports - organize by type import { onMount } from 'svelte'; import { isAuthenticated, accessToken } from '../stores/auth'; import { jobs, jobsLoading, searchJobs } from '../stores/jobs'; import JobCard from './JobCard.svelte'; import SearchBar from './SearchBar.svelte'; // Props (none in this case) export let jobCategory: string = 'all'; // Reactive state let searchQuery = ''; let filteredJobs: Job[] = []; // Lifecycle: Load jobs on component mount onMount(async () => { if (!$isAuthenticated) { return; // Don't load if not authenticated } jobsLoading.set(true); try { await searchJobs(jobCategory); } catch (error) { console.error('Failed to load jobs:', error); } finally { jobsLoading.set(false); } }); // Reactive: Filter jobs based on search query $: filteredJobs = $jobs.filter(job => job.title.toLowerCase().includes(searchQuery.toLowerCase()) || job.company.toLowerCase().includes(searchQuery.toLowerCase()) ); // Event handler: Search input function handleSearch(e: CustomEvent) { searchQuery = e.detail; } </script>{#if !$isAuthenticated}
// ============================================================================ // PATTERN 3: API Service Layer // ============================================================================
/**
- SERVICE: graphService
- PURPOSE: Handle all Microsoft Graph API calls with proper headers
- DEPENDENCIES: accessToken store, error handling
- Exports: Functions for each Graph API operation
- Error handling: Throws errors that UI catches */
// File: src/services/graphService.ts import { get } from 'svelte/store'; import { accessToken, refreshAccessToken, clearAuth } from '../stores/auth';
/**
- Helper: Ensure token is valid before API call
- Checks if token expired, refreshes if needed
- Throws if no token available */ async function ensureValidToken(): Promise { const token = get(accessToken); if (!token) { throw new Error('No access token available - not authenticated'); }
// Check if token might be expired, refresh proactively const expiry = Number(sessionStorage.getItem('ms_token_expiry') || 0); if (expiry - Date.now() < 5 * 60 * 1000) { // Token expires in less than 5 minutes, refresh now await refreshAccessToken(); }
return get(accessToken) || token; }
/**
- API CALL: Get list of files in OneDrive
- Endpoint: GET https://graph.microsoft.com/v1.0/me/drive/root/children
- Scopes required: Files.Read.All
- Caching: Results cached in IndexedDB via jobsStore
- Errors:
-
- 401: Token invalid, user redirected to login
-
- 403: Insufficient permissions
-
- 404: Folder not found
-
- 429: Rate limited, should retry with backoff */ export async function listFiles(folderId: string = 'root'): Promise<File[]> { const token = await ensureValidToken();
const endpoint = folderId === 'root'
? 'https://graph.microsoft.com/v1.0/me/drive/root/children'
: https://graph.microsoft.com/v1.0/me/drive/items/${folderId}/children;
try {
const response = await fetch(endpoint, {
headers: {
Authorization: Bearer ${token},
'Content-Type': 'application/json',
},
});
if (response.status === 401) {
// Token invalid, clear auth and redirect
clearAuth();
throw new Error('Session expired - please log in again');
}
if (!response.ok) {
const error = await response.json().catch(() => ({ message: response.statusText }));
throw new Error(`Graph API error ${response.status}: ${error.message}`);
}
const data = await response.json();
return data.value || [];
} catch (error) { console.error('Graph API error:', error); throw error; } }
// ============================================================================ // PATTERN 4: Data Store (Derived from API) // ============================================================================
/**
- STORE: jobs
- PURPOSE: Cached list of jobs loaded from device storage (IndexedDB)
- UPDATES: Called by services after fetching from Graph API
- PERSISTENCE: IndexedDB database
- STRUCTURE:
-
- jobs: Job[] - Current list of jobs
-
- jobsLoading: boolean - Loading state
-
- jobsError: string | null - Last error if any */
// File: src/stores/jobs.ts import { writable } from 'svelte/store'; import { loadJobsFromDB, saveJobsToDBt } from '../services/storageService';
export const jobs = writable<Job[]>([]); export const jobsLoading = writable(false); export const jobsError = writable<string | null>(null);
/**
- Load jobs from device storage
- Called on app init
- Populates jobs store from IndexedDB */ export async function loadJobs(): Promise { jobsLoading.set(true); try { const cachedJobs = await loadJobsFromDB(); jobs.set(cachedJobs); jobsError.set(null); } catch (error) { console.error('Failed to load jobs:', error); jobsError.set((error as Error).message); } finally { jobsLoading.set(false); } }
/**
- Search jobs locally (no API call needed)
- All job data is already in device storage
- This filters locally for instant results */ export async function searchJobs(query: string): Promise<Job[]> { // Get current jobs const allJobs = get(jobs);
// Filter based on query const results = allJobs.filter(job => job.title.toLowerCase().includes(query.toLowerCase()) || job.company.toLowerCase().includes(query.toLowerCase()) || job.description.toLowerCase().includes(query.toLowerCase()) );
return results; }
// ============================================================================ // PATTERN 5: Layout Component (Root) // ============================================================================
/**
- COMPONENT: +layout.svelte
- PURPOSE: Root layout for all pages
- FEATURES: Navigation, auth check, error handling
- Lifecycle: Checks auth on mount, refreshes token */
// File: src/routes/+layout.svelte
<script lang="ts"> import { onMount } from 'svelte'; import { isAuthenticated, user, clearAuth } from '../stores/auth'; import { loadJobs } from '../stores/jobs'; import Navigation from '../components/Navigation.svelte'; import ErrorBoundary from '../components/ErrorBoundary.svelte'; let error: Error | null = null; onMount(async () => { // Load cached jobs on app startup try { await loadJobs(); } catch (e) { console.error('Failed to load initial jobs:', e); error = e as Error; } }); </script>{#if error} <ErrorBoundary {error} /> {/if}
// ============================================================================ // PATTERN 6: Route Page with Auth Guard // ============================================================================
/**
- PAGE: +page.svelte (Home)
- PURPOSE: Job listing homepage
- AUTH: Redirects to login if not authenticated */
// File: src/routes/+page.svelte
<script lang="ts"> import { goto } from '$app/navigation'; import { onMount } from 'svelte'; import { isAuthenticated } from '../stores/auth'; import JobList from '../components/JobList.svelte'; onMount(() => { if (!$isAuthenticated) { goto('/signin'); } }); </script>{#if $isAuthenticated}
Job Listings
{/if}// File: src/routes/signin/+page.svelte (OAuth login page)
<script lang="ts"> import { goto } from '$app/navigation'; import { isAuthenticated } from '../../stores/auth'; onMount(() => { if ($isAuthenticated) { goto('/'); // Already logged in } }); function handleLogin() { // Initiate OAuth flow initiateOAuthFlow(); } </script>// ============================================================================ // SUMMARY // ============================================================================
/**
- SVELTE PATTERNS - GOLDEN RULES:
-
- STORES: Centralize state, update via explicit functions
-
- COMPONENTS: Keep reactive, let stores handle state
-
- SERVICES: Handle API calls with error handling, never expose tokens
-
- LIFECYCLE: Use onMount for async operations
-
- DERIVED: Use derived stores for computed state (isAuthenticated)
-
- REACTIVITY: Use $store syntax to subscribe in templates
-
- PROPS: Always document and type check
-
- ERROR HANDLING: Catch all async operations, display to user
-
- PERSISTENCE: Use IndexedDB for device storage, sessionStorage for tokens
-
- TESTING: Every component independently testable */