import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4'; import { edgeLogger } from '../_shared/logger.ts'; import { formatEdgeError } from '../_shared/errorFormatter.ts'; const BASE_URL = 'https://dev.thrillwiki.com'; // ============================================================================ // TYPE DEFINITIONS // ============================================================================ interface SitemapUrl { loc: string; lastmod: string; changefreq: 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never'; priority: number; } interface SitemapStats { total_urls: number; parks: number; rides: number; manufacturers: number; models: number; designers: number; operators: number; owners: number; static_pages: number; generation_time_ms: number; } // ============================================================================ // IN-MEMORY CACHE // ============================================================================ let cachedSitemap: string | null = null; let cacheTimestamp: number = 0; const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour const cacheHeaders = { 'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400', 'CDN-Cache-Control': 'public, s-maxage=3600', 'Vercel-CDN-Cache-Control': 'public, s-maxage=3600', }; function isCacheValid(): boolean { if (!cachedSitemap) return false; const age = Date.now() - cacheTimestamp; return age < CACHE_TTL_MS; } // ============================================================================ // XML GENERATION UTILITIES // ============================================================================ function escapeXml(unsafe: string): string { return unsafe .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } function calculatePriority(entityType: string, metadata?: { ride_count?: number }): number { switch (entityType) { case 'home': return 1.0; case 'park': const rideCount = metadata?.ride_count || 0; return rideCount > 10 ? 0.9 : 0.8; case 'ride': return 0.9; case 'manufacturer': return 0.7; case 'model': return 0.6; case 'designer': return 0.6; case 'operator': return 0.6; case 'owner': return 0.6; case 'list': return 0.5; case 'static': return 0.3; default: return 0.5; } } function getChangeFreq(entityType: string): SitemapUrl['changefreq'] { switch (entityType) { case 'home': return 'daily'; case 'park': case 'ride': case 'manufacturer': return 'weekly'; case 'model': case 'designer': case 'operator': case 'owner': return 'monthly'; case 'list': return 'daily'; case 'static': return 'yearly'; default: return 'monthly'; } } function createUrl( path: string, entityType: string, lastmod: string, metadata?: { ride_count?: number } ): SitemapUrl { return { loc: `${BASE_URL}${path}`, lastmod: new Date(lastmod).toISOString().split('T')[0], changefreq: getChangeFreq(entityType), priority: calculatePriority(entityType, metadata), }; } function generateSitemapXml(urls: SitemapUrl[]): string { const header = '\n' + '\n'; const entries = urls.map(url => ` \n` + ` ${escapeXml(url.loc)}\n` + ` ${url.lastmod}\n` + ` ${url.changefreq}\n` + ` ${url.priority.toFixed(1)}\n` + ` ` ).join('\n'); const footer = '\n'; return header + entries + footer; } // ============================================================================ // SITEMAP GENERATION // ============================================================================ async function generateSitemap(requestId: string): Promise<{ xml: string; stats: SitemapStats; }> { const startTime = Date.now(); const urls: SitemapUrl[] = []; const stats: SitemapStats = { total_urls: 0, parks: 0, rides: 0, manufacturers: 0, models: 0, designers: 0, operators: 0, owners: 0, static_pages: 0, generation_time_ms: 0, }; const supabase = createClient( Deno.env.get('SUPABASE_URL') ?? '', Deno.env.get('SUPABASE_ANON_KEY') ?? '' ); // Static pages const now = new Date().toISOString(); urls.push( createUrl('/', 'home', now), createUrl('/parks', 'list', now), createUrl('/rides', 'list', now), createUrl('/manufacturers', 'list', now), createUrl('/designers', 'list', now), createUrl('/operators', 'list', now), createUrl('/owners', 'list', now), createUrl('/blog', 'list', now), createUrl('/terms', 'static', now), createUrl('/privacy', 'static', now), createUrl('/submission-guidelines', 'static', now), createUrl('/contact', 'static', now), ); stats.static_pages = 12; // Parallel database queries const [parksResult, ridesResult, companiesResult, modelsResult] = await Promise.all([ supabase .from('parks') .select('slug, updated_at') .not('slug', 'is', null) .order('updated_at', { ascending: false }), supabase .from('rides') .select('slug, updated_at, park:parks!inner(slug)') .not('slug', 'is', null) .order('updated_at', { ascending: false }), supabase .from('companies') .select('slug, updated_at, company_type') .not('slug', 'is', null) .order('updated_at', { ascending: false }), supabase .from('ride_models') .select('slug, updated_at, manufacturer:companies!ride_models_manufacturer_id_fkey(slug)') .not('slug', 'is', null) .order('updated_at', { ascending: false }), ]); // Process parks if (parksResult.data) { for (const park of parksResult.data) { urls.push(createUrl(`/parks/${park.slug}`, 'park', park.updated_at)); urls.push(createUrl(`/parks/${park.slug}/rides`, 'list', park.updated_at)); stats.parks++; } } // Process rides if (ridesResult.data) { for (const ride of ridesResult.data) { if (ride.park?.slug) { urls.push(createUrl(`/parks/${ride.park.slug}/rides/${ride.slug}`, 'ride', ride.updated_at)); stats.rides++; } } } // Process companies if (companiesResult.data) { for (const company of companiesResult.data) { const type = company.company_type; if (type === 'manufacturer') { urls.push(createUrl(`/manufacturers/${company.slug}`, 'manufacturer', company.updated_at)); urls.push(createUrl(`/manufacturers/${company.slug}/rides`, 'list', company.updated_at)); urls.push(createUrl(`/manufacturers/${company.slug}/models`, 'list', company.updated_at)); stats.manufacturers++; } else if (type === 'designer') { urls.push(createUrl(`/designers/${company.slug}`, 'designer', company.updated_at)); urls.push(createUrl(`/designers/${company.slug}/rides`, 'list', company.updated_at)); stats.designers++; } else if (type === 'operator') { urls.push(createUrl(`/operators/${company.slug}`, 'operator', company.updated_at)); urls.push(createUrl(`/operators/${company.slug}/parks`, 'list', company.updated_at)); stats.operators++; } else if (type === 'property_owner') { urls.push(createUrl(`/owners/${company.slug}`, 'owner', company.updated_at)); urls.push(createUrl(`/owners/${company.slug}/parks`, 'list', company.updated_at)); stats.owners++; } } } // Process ride models if (modelsResult.data) { for (const model of modelsResult.data) { if (model.manufacturer?.slug) { urls.push( createUrl(`/manufacturers/${model.manufacturer.slug}/models/${model.slug}`, 'model', model.updated_at) ); urls.push( createUrl(`/manufacturers/${model.manufacturer.slug}/models/${model.slug}/rides`, 'list', model.updated_at) ); stats.models++; } } } stats.total_urls = urls.length; stats.generation_time_ms = Date.now() - startTime; const xml = generateSitemapXml(urls); edgeLogger.info('Sitemap generated', { requestId, stats, sizeKB: (xml.length / 1024).toFixed(2), }); return { xml, stats }; } function generateFallbackSitemap(): string { const now = new Date().toISOString(); const urls: SitemapUrl[] = [ createUrl('/', 'home', now), createUrl('/parks', 'list', now), createUrl('/rides', 'list', now), createUrl('/manufacturers', 'list', now), createUrl('/designers', 'list', now), createUrl('/operators', 'list', now), createUrl('/owners', 'list', now), ]; return generateSitemapXml(urls); } // ============================================================================ // MAIN HANDLER // ============================================================================ Deno.serve(async (req) => { const requestId = crypto.randomUUID(); const startTime = Date.now(); try { // Return cached version if valid if (isCacheValid()) { const duration = Date.now() - startTime; edgeLogger.info('Sitemap cache hit', { requestId, cacheAge: Date.now() - cacheTimestamp, duration, }); return new Response(cachedSitemap, { headers: { 'Content-Type': 'application/xml; charset=utf-8', 'X-Request-ID': requestId, 'X-Cache': 'HIT', 'X-Generation-Time': `${duration}ms`, ...cacheHeaders, }, }); } // Generate fresh sitemap const sitemap = await generateSitemap(requestId); // Update cache cachedSitemap = sitemap.xml; cacheTimestamp = Date.now(); const duration = Date.now() - startTime; edgeLogger.info('Sitemap cache miss - generated', { requestId, duration, stats: sitemap.stats, }); return new Response(sitemap.xml, { headers: { 'Content-Type': 'application/xml; charset=utf-8', 'X-Request-ID': requestId, 'X-Cache': 'MISS', 'X-Generation-Time': `${duration}ms`, ...cacheHeaders, }, }); } catch (error) { const duration = Date.now() - startTime; edgeLogger.error('Sitemap generation failed', { requestId, error: formatEdgeError(error), duration, }); // Return minimal valid sitemap on error (graceful degradation) const fallbackSitemap = generateFallbackSitemap(); return new Response(fallbackSitemap, { status: 200, // Still return 200 for SEO headers: { 'Content-Type': 'application/xml; charset=utf-8', 'X-Request-ID': requestId, 'X-Error': 'true', 'Cache-Control': 'public, max-age=300', // Cache errors for 5min only }, }); } });