mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-28 02:47:04 -05:00
Compare commits
3 Commits
68cddbbdd5
...
ffd71f51fb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffd71f51fb | ||
|
|
40ebc3c11b | ||
|
|
deabb72330 |
50
api/ssrOG.ts
50
api/ssrOG.ts
@@ -15,6 +15,7 @@ type VercelResponse = ServerResponse & {
|
||||
};
|
||||
|
||||
import { detectBot } from './botDetection/index.js';
|
||||
import { vercelLogger } from './utils/logger.js';
|
||||
|
||||
interface PageData {
|
||||
title: string;
|
||||
@@ -76,7 +77,10 @@ async function getPageData(pathname: string, fullUrl: string): Promise<PageData>
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[SSR-OG] Error fetching park data: ${error}`);
|
||||
vercelLogger.error('Error fetching park data', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
slug
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,7 +119,10 @@ async function getPageData(pathname: string, fullUrl: string): Promise<PageData>
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[SSR-OG] Error fetching ride data: ${error}`);
|
||||
vercelLogger.error('Error fetching ride data', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
slug: rideSlug
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,20 +211,29 @@ export default async function handler(req: VercelRequest, res: VercelResponse):
|
||||
|
||||
// Enhanced logging with detection details
|
||||
if (botDetection.isBot) {
|
||||
console.log(`[SSR-OG] ✅ Bot detected: ${botDetection.platform || 'unknown'} | Confidence: ${botDetection.confidence} (${botDetection.score}%) | Method: ${botDetection.detectionMethod}`);
|
||||
console.log(`[SSR-OG] Path: ${req.method} ${pathname}`);
|
||||
console.log(`[SSR-OG] UA: ${userAgent}`);
|
||||
if (botDetection.metadata.signals.length > 0) {
|
||||
console.log(`[SSR-OG] Signals: ${botDetection.metadata.signals.slice(0, 5).join(', ')}${botDetection.metadata.signals.length > 5 ? '...' : ''}`);
|
||||
}
|
||||
vercelLogger.info('Bot detected', {
|
||||
platform: botDetection.platform || 'unknown',
|
||||
confidence: botDetection.confidence,
|
||||
score: botDetection.score,
|
||||
method: botDetection.detectionMethod,
|
||||
path: `${req.method} ${pathname}`,
|
||||
userAgent,
|
||||
signals: botDetection.metadata.signals.slice(0, 5)
|
||||
});
|
||||
} else {
|
||||
// Log potential false negatives
|
||||
if (botDetection.score > 30) {
|
||||
console.warn(`[SSR-OG] ⚠️ Low confidence bot (${botDetection.score}%) - not serving SSR | ${req.method} ${pathname}`);
|
||||
console.warn(`[SSR-OG] UA: ${userAgent}`);
|
||||
console.warn(`[SSR-OG] Signals: ${botDetection.metadata.signals.join(', ')}`);
|
||||
vercelLogger.warn('Low confidence bot - not serving SSR', {
|
||||
score: botDetection.score,
|
||||
path: `${req.method} ${pathname}`,
|
||||
userAgent,
|
||||
signals: botDetection.metadata.signals
|
||||
});
|
||||
} else {
|
||||
console.log(`[SSR-OG] Regular user (score: ${botDetection.score}%) | ${req.method} ${pathname}`);
|
||||
vercelLogger.info('Regular user request', {
|
||||
score: botDetection.score,
|
||||
path: `${req.method} ${pathname}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,7 +244,10 @@ export default async function handler(req: VercelRequest, res: VercelResponse):
|
||||
if (botDetection.isBot) {
|
||||
// Fetch page-specific data
|
||||
const pageData = await getPageData(pathname, fullUrl);
|
||||
console.log(`[SSR-OG] Generated OG tags: ${pageData.title}`);
|
||||
vercelLogger.info('Generated OG tags', {
|
||||
title: pageData.title,
|
||||
pathname
|
||||
});
|
||||
|
||||
// Generate and inject OG tags
|
||||
const ogTags = generateOGTags(pageData);
|
||||
@@ -246,7 +265,10 @@ export default async function handler(req: VercelRequest, res: VercelResponse):
|
||||
res.status(200).send(html);
|
||||
|
||||
} catch (error) {
|
||||
console.error('[SSR-OG] Error:', error);
|
||||
vercelLogger.error('SSR processing failed', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
pathname
|
||||
});
|
||||
|
||||
// Fallback: serve original HTML
|
||||
try {
|
||||
|
||||
33
api/utils/logger.ts
Normal file
33
api/utils/logger.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* Vercel Serverless Function Logger
|
||||
* Provides structured JSON logging for Vercel API routes
|
||||
* Matches the edge function logging pattern for consistency
|
||||
*/
|
||||
|
||||
type LogLevel = 'info' | 'warn' | 'error';
|
||||
|
||||
interface LogContext {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
function formatLog(level: LogLevel, message: string, context?: LogContext): string {
|
||||
return JSON.stringify({
|
||||
timestamp: new Date().toISOString(),
|
||||
level,
|
||||
message,
|
||||
service: 'vercel-ssrog',
|
||||
...context
|
||||
});
|
||||
}
|
||||
|
||||
export const vercelLogger = {
|
||||
info: (message: string, context?: LogContext) => {
|
||||
console.info(formatLog('info', message, context));
|
||||
},
|
||||
warn: (message: string, context?: LogContext) => {
|
||||
console.warn(formatLog('warn', message, context));
|
||||
},
|
||||
error: (message: string, context?: LogContext) => {
|
||||
console.error(formatLog('error', message, context));
|
||||
}
|
||||
};
|
||||
@@ -12,3 +12,5 @@ Allow: /
|
||||
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://thrillwiki.com/sitemap.xml
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
project_id = "ydvtmnrszybqnbcqbdcy"
|
||||
|
||||
[functions.sitemap]
|
||||
verify_jwt = false
|
||||
|
||||
[functions.admin-delete-user]
|
||||
verify_jwt = true
|
||||
|
||||
|
||||
367
supabase/functions/sitemap/index.ts
Normal file
367
supabase/functions/sitemap/index.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2.57.4';
|
||||
import { edgeLogger } from '../_shared/logger.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, '"')
|
||||
.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 = '<?xml version="1.0" encoding="UTF-8"?>\n' +
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n';
|
||||
|
||||
const entries = urls.map(url =>
|
||||
` <url>\n` +
|
||||
` <loc>${escapeXml(url.loc)}</loc>\n` +
|
||||
` <lastmod>${url.lastmod}</lastmod>\n` +
|
||||
` <changefreq>${url.changefreq}</changefreq>\n` +
|
||||
` <priority>${url.priority.toFixed(1)}</priority>\n` +
|
||||
` </url>`
|
||||
).join('\n');
|
||||
|
||||
const footer = '\n</urlset>';
|
||||
|
||||
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: error instanceof Error ? error.message : String(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
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -2,6 +2,10 @@
|
||||
"installCommand": "bun install",
|
||||
"buildCommand": "bun run build",
|
||||
"rewrites": [
|
||||
{
|
||||
"source": "/sitemap.xml",
|
||||
"destination": "https://api.thrillwiki.com/functions/v1/sitemap"
|
||||
},
|
||||
{
|
||||
"source": "/",
|
||||
"destination": "/api/ssrOG"
|
||||
|
||||
Reference in New Issue
Block a user