diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 1ec148a5..be0e45e1 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -93,7 +93,8 @@ "Bash(ss:*)", "Bash(git config:*)", "Bash(git fetch:*)", - "Bash(timeout 30 bun run build)" + "Bash(timeout 30 bun run build)", + "Bash(rg:*)" ], "deny": [] } diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..b54d5fa2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,131 @@ +# Dependencies +node_modules +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# Build outputs +dist +build +.next +out + +# Development files +.git +.gitignore +README.md +*.md +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# IDE files +.vscode +.idea +*.swp +*.swo +*~ + +# OS files +.DS_Store +Thumbs.db + +# Logs +logs +*.log + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Dependency directories +jspm_packages/ + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next + +# Nuxt.js build / generate output +.nuxt + +# Storybook build outputs +.out +.storybook-out + +# Temporary folders +tmp/ +temp/ + +# Editor directories and files +.vscode/ +.idea/ +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Test files +test +tests +__tests__ +*.test.js +*.test.ts +*.spec.js +*.spec.ts + +# Documentation +docs/ +*.md + +# CI/CD +.github/ +.gitlab-ci.yml +.travis.yml +.circleci/ + +# Docker +Dockerfile* +docker-compose* +.dockerignore + +# Kubernetes +k8s-*.yaml +*.yaml +!package.json +!bun.lock diff --git a/API-SERVER-README.md b/API-SERVER-README.md index cdf509a1..0bfbb997 100644 --- a/API-SERVER-README.md +++ b/API-SERVER-README.md @@ -16,7 +16,8 @@ This enhanced API server transforms the original development-only server into a - **Rate Limiting**: Configurable request limits per IP (default: 1000/min) - **Health Checks**: Built-in `/health` endpoint for monitoring - **Security Headers**: HSTS, Content Security Policy (CSP), Referrer-Policy, Permissions-Policy, Cross-Origin-Opener-Policy (COOP), Cross-Origin-Resource-Policy (CORP), and X-Content-Type-Options=nosniff -- **Request Timeout**: Configurable timeout protection (default: 30s)- **Graceful Shutdown**: Clean shutdown with analytics reporting +- **Request Timeout**: Configurable timeout protection (default: 30s) +- **Graceful Shutdown**: Clean shutdown with analytics reporting ### πŸ›‘οΈ **Enhanced Security** - **CORS Configuration**: Configurable origins (supports wildcards) diff --git a/DEPLOYMENT-DOCKER-K8S.md b/DEPLOYMENT-DOCKER-K8S.md new file mode 100644 index 00000000..e0033f57 --- /dev/null +++ b/DEPLOYMENT-DOCKER-K8S.md @@ -0,0 +1,362 @@ +# πŸš€ Docker & Kubernetes Deployment Guide for zapdev API Server + +This guide covers deploying the zapdev API server using Docker and Kubernetes with production-ready configurations. + +## πŸ“¦ Docker Deployment + +### Prerequisites +- Docker and Docker Compose installed +- Environment variables configured + +### Quick Start with Docker Compose + +1. **Set up environment variables:** +```bash +# Create .env file +cp env-template.txt .env + +# Edit .env with your values +POSTHOG_API_KEY=your_posthog_key_here +POSTHOG_HOST=https://app.posthog.com +CORS_ORIGINS=https://zapdev.link,https://www.zapdev.link +``` + +2. **Start production server:** +```bash +docker-compose up zapdev-api -d +``` + +3. **Start development server:** +```bash +docker-compose up zapdev-api-dev -d +``` + +4. **View logs:** +```bash +docker-compose logs -f zapdev-api +``` + +### Manual Docker Commands + +**Build and run production:** +```bash +# Build image +docker build -t zapdev/api:latest . + +# Run container +docker run -d \ + --name zapdev-api \ + -p 3000:3000 \ + -e NODE_ENV=production \ + -e POSTHOG_API_KEY=your_key \ + -e CORS_ORIGINS=https://zapdev.link \ + zapdev/api:latest +``` + +**Build and run development:** +```bash +# Build dev image +docker build -f Dockerfile.dev -t zapdev/api:dev . + +# Run dev container +docker run -d \ + --name zapdev-api-dev \ + -p 3001:3000 \ + -v $(pwd):/app \ + -e NODE_ENV=development \ + zapdev/api:dev +``` + +## ☸️ Kubernetes Deployment + +### Prerequisites +- Kubernetes cluster (minikube, GKE, EKS, AKS, etc.) +- kubectl configured +- Helm (optional, for advanced deployments) + +### Quick Deployment + +1. **Create namespace and apply configuration:** +```bash +kubectl apply -f k8s-deployment.yaml +``` + +2. **Check deployment status:** +```bash +kubectl get all -n zapdev +kubectl get pods -n zapdev +``` + +3. **View logs:** +```bash +kubectl logs -f deployment/zapdev-api -n zapdev +``` + +### Step-by-Step Deployment + +1. **Create namespace:** +```bash +kubectl create namespace zapdev +``` + +2. **Create ConfigMap:** +```bash +kubectl apply -f - <) - }); - - if (!authResult.success) { - return res.status(401).json({ error: 'Invalid authentication' }); - } - - // Verify the authenticated user matches the provided userId - if (authResult.userId !== userId) { - return res.status(403).json({ error: 'User ID mismatch' }); - } + const authResult = await verifyAuth({ + headers: new Headers({ authorization }) + }); + if (!authResult.success || !authResult.userId) { + return res.status(401).json({ error: 'Unauthorized' }); } // Get subscription status from Polar.sh API const baseUrl = process.env.PUBLIC_ORIGIN || 'http://localhost:3000'; const response = await fetch(`${baseUrl}/api/get-subscription`, { - headers: authHeader ? { Authorization: authHeader } : {}, + headers: { Authorization: authorization }, }); if (!response.ok) { @@ -80,7 +67,7 @@ export default async function handler(req: VercelRequest, res: VercelResponse) { logSanitizedError('Success endpoint error', error instanceof Error ? error : new Error(String(error)), { method: req.method, url: req.url, - hasUserId: !!(typeof req.body === 'object' && req.body?.userId) + hasAuth: !!authorization }); return res.status(500).json({ diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..8a587496 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,58 @@ +version: '3.8' + +services: + zapdev-api: + build: + context: . + dockerfile: Dockerfile + container_name: zapdev-api + ports: + - "3000:3000" + environment: + - NODE_ENV=production + - PORT=3000 + - ENABLE_CLUSTERING=false + - ENABLE_ANALYTICS=true + - POSTHOG_API_KEY=${POSTHOG_API_KEY} + - POSTHOG_HOST=${POSTHOG_HOST:-https://app.posthog.com} + - CORS_ORIGINS=${CORS_ORIGINS:-https://zapdev.link,https://www.zapdev.link} + - RATE_LIMIT=${RATE_LIMIT:-1000} + - REQUEST_TIMEOUT=${REQUEST_TIMEOUT:-30000} + volumes: + - ./logs:/app/logs + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + networks: + - zapdev-network + + zapdev-api-dev: + build: + context: . + dockerfile: Dockerfile.dev + container_name: zapdev-api-dev + ports: + - "3001:3000" + environment: + - NODE_ENV=development + - PORT=3000 + - ENABLE_CLUSTERING=false + - ENABLE_ANALYTICS=false + - CORS_ORIGINS=http://localhost:5173,http://localhost:3000 + volumes: + - .:/app + - /app/node_modules + restart: unless-stopped + networks: + - zapdev-network + +networks: + zapdev-network: + driver: bridge + +volumes: + logs: diff --git a/k8s-deployment.yaml b/k8s-deployment.yaml new file mode 100644 index 00000000..6713b0c9 --- /dev/null +++ b/k8s-deployment.yaml @@ -0,0 +1,215 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: zapdev + labels: + name: zapdev +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: zapdev-api-config + namespace: zapdev +data: + NODE_ENV: "production" + PORT: "3000" + ENABLE_CLUSTERING: "false" + ENABLE_ANALYTICS: "true" + POSTHOG_HOST: "https://app.posthog.com" + CORS_ORIGINS: "https://zapdev.link,https://www.zapdev.link" + RATE_LIMIT: "1000" + REQUEST_TIMEOUT: "30000" +--- +apiVersion: v1 +kind: Secret +metadata: + name: zapdev-api-secrets + namespace: zapdev +type: Opaque +data: + POSTHOG_API_KEY: "" # Base64 encoded value +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: zapdev-api + namespace: zapdev + labels: + app: zapdev-api + version: v1 +spec: + replicas: 3 + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + selector: + matchLabels: + app: zapdev-api + template: + metadata: + labels: + app: zapdev-api + version: v1 + annotations: + prometheus.io/scrape: "true" + prometheus.io/port: "3000" + prometheus.io/path: "/metrics" + spec: + containers: + - name: api + image: zapdev/api:latest + imagePullPolicy: Always + ports: + - name: http + containerPort: 3000 + protocol: TCP + envFrom: + - configMapRef: + name: zapdev-api-config + - secretRef: + name: zapdev-api-secrets + resources: + requests: + memory: "256Mi" + cpu: "250m" + limits: + memory: "512Mi" + cpu: "500m" + livenessProbe: + httpGet: + path: /health + port: 3000 + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /health + port: 3000 + initialDelaySeconds: 5 + periodSeconds: 5 + timeoutSeconds: 3 + failureThreshold: 3 + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + runAsNonRoot: true + runAsUser: 1000 + capabilities: + drop: + - ALL + volumeMounts: + - name: logs + mountPath: /app/logs + - name: tmp + mountPath: /tmp + volumes: + - name: logs + emptyDir: {} + - name: tmp + emptyDir: {} + securityContext: + fsGroup: 1000 + runAsNonRoot: true + runAsUser: 1000 +--- +apiVersion: v1 +kind: Service +metadata: + name: zapdev-api-service + namespace: zapdev + labels: + app: zapdev-api +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: 3000 + protocol: TCP + name: http + selector: + app: zapdev-api +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: zapdev-api-ingress + namespace: zapdev + annotations: + kubernetes.io/ingress.class: "nginx" + nginx.ingress.kubernetes.io/ssl-redirect: "true" + nginx.ingress.kubernetes.io/force-ssl-redirect: "true" + nginx.ingress.kubernetes.io/proxy-body-size: "10m" + nginx.ingress.kubernetes.io/proxy-read-timeout: "30" + nginx.ingress.kubernetes.io/proxy-send-timeout: "30" + nginx.ingress.kubernetes.io/rate-limit: "1000" + nginx.ingress.kubernetes.io/rate-limit-window: "1m" +spec: + tls: + - hosts: + - api.zapdev.link + secretName: zapdev-api-tls + rules: + - host: api.zapdev.link + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: zapdev-api-service + port: + number: 80 +--- +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: zapdev-api-hpa + namespace: zapdev +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: zapdev-api + minReplicas: 3 + maxReplicas: 10 + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: 70 + - type: Resource + resource: + name: memory + target: + type: Utilization + averageUtilization: 80 + behavior: + scaleUp: + stabilizationWindowSeconds: 60 + policies: + - type: Percent + value: 100 + periodSeconds: 15 + scaleDown: + stabilizationWindowSeconds: 300 + policies: + - type: Percent + value: 10 + periodSeconds: 60 +--- +apiVersion: policy/v1 +kind: PodDisruptionBudget +metadata: + name: zapdev-api-pdb + namespace: zapdev +spec: + minAvailable: 2 + selector: + matchLabels: + app: zapdev-api diff --git a/lib/deployment/manager.ts b/lib/deployment/manager.ts index dcafa4e1..74b0034c 100644 --- a/lib/deployment/manager.ts +++ b/lib/deployment/manager.ts @@ -11,6 +11,7 @@ import { DeploymentResult, CustomDomainConfig, ZapdevDeploymentConfig, + ZapdevDeploymentSecrets, DeploymentAnalyticsEvent, DeploymentError, DomainConfigurationError @@ -21,6 +22,7 @@ import { VercelDeploymentService } from './vercel.js'; interface DeploymentManagerOptions { config: ZapdevDeploymentConfig; + secrets: ZapdevDeploymentSecrets; analytics?: { track: (event: DeploymentAnalyticsEvent) => Promise; }; @@ -34,11 +36,13 @@ interface DeploymentManagerOptions { export class ZapdevDeploymentManager { private services: Map = new Map(); private config: ZapdevDeploymentConfig; + private secrets: ZapdevDeploymentSecrets; private analytics?: DeploymentManagerOptions['analytics']; private logger?: DeploymentManagerOptions['logger']; constructor(options: DeploymentManagerOptions) { this.config = options.config; + this.secrets = options.secrets; this.analytics = options.analytics; this.logger = options.logger; @@ -48,20 +52,20 @@ export class ZapdevDeploymentManager { private initializeServices(): void { // Initialize Netlify service - if (this.config.netlify.accessToken) { + if (this.secrets.netlify.accessToken) { const netlifyService = new NetlifyDeploymentService( - this.config.netlify.accessToken, - this.config.netlify.teamId + this.secrets.netlify.accessToken, + this.config.netlify?.teamId || this.secrets.netlify.teamId ); this.services.set('netlify', netlifyService); this.logger?.info('Netlify deployment service initialized'); } // Initialize Vercel service - if (this.config.vercel.accessToken) { + if (this.secrets.vercel.accessToken) { const vercelService = new VercelDeploymentService( - this.config.vercel.accessToken, - this.config.vercel.teamId + this.secrets.vercel.accessToken, + this.config.vercel?.teamId || this.secrets.vercel.teamId ); this.services.set('vercel', vercelService); this.logger?.info('Vercel deployment service initialized'); diff --git a/lib/deployment/types.ts b/lib/deployment/types.ts index ff1a65a1..9f303a37 100644 --- a/lib/deployment/types.ts +++ b/lib/deployment/types.ts @@ -164,7 +164,7 @@ export interface DeploymentAnalyticsEvent { }; } -// Configuration for zapdev deployment service (non-sensitive settings) +// Configuration for zapdev deployment service (non-sensitive settings) export interface ZapdevDeploymentConfig { // Include sensitive deployment configuration netlify: { @@ -182,6 +182,17 @@ export interface ZapdevDeploymentConfig { // Secrets must be retrieved from ZapdevDeploymentSecrets at runtime secretsRef?: string; // Optional reference to secret storage key + // Non-sensitive platform configuration + netlify?: { + teamId?: string; // Team ID is generally not secret + // Note: accessToken must be retrieved from ZapdevDeploymentSecrets + }; + + vercel?: { + teamId?: string; // Team ID is generally not secret + // Note: accessToken must be retrieved from ZapdevDeploymentSecrets + }; + // Default deployment settings defaults: { platform: DeploymentPlatform; diff --git a/lib/deployment/vercel.ts b/lib/deployment/vercel.ts index b92554d7..5f811dc6 100644 --- a/lib/deployment/vercel.ts +++ b/lib/deployment/vercel.ts @@ -594,9 +594,30 @@ export class VercelDeploymentService implements IDeploymentService { private extractRepoPath(url: string): string { // Extract owner/repo from git URL const sanitized = url.trim(); - const match = sanitized.match(/[:/]([^/]+\/[^/]+?)(?:\.git)?(?:[?#]|$)/); - const repo = match?.[1] || sanitized; - // Basic allowlist to avoid path traversal or invalid characters - return repo.replace(/[^A-Za-z0-9._/-]/g, ''); + // Try robust URL parsing first (supports https and ssh://) + try { + const normalized = sanitized.startsWith('git@') + // Convert scp-like git@host:owner/repo.git to ssh://git@host/owner/repo.git for URL parsing + ? sanitized.replace(/^git@/, 'ssh://git@').replace(':', '/') + : sanitized; + const u = new URL(normalized); + const parts = u.pathname.replace(/^\/+/, '').split('/'); + const owner = parts[0]; + const repo = parts[1]?.replace(/\.git$/, ''); + if (owner && repo) { + return `${owner}/${repo}`.replace(/[^A-Za-z0-9._/-]/g, ''); + } + } catch { + // fall through to regex fallback + } + // Fallback: accept trailing slash or extra segments after owner/repo + const match = sanitized.match(/[:/]([^/]+\/[^/]+?)(?:\.git)?(?:\/|[?#]|$)/); + if (match?.[1]) { + return match[1].replace(/[^A-Za-z0-9._/-]/g, ''); + } + + // If no match found, return empty string as fallback instead of sanitized input + // to prevent potential injection of malformed repo paths + return ''; } } \ No newline at end of file diff --git a/public/sw-custom.js b/public/sw-custom.js new file mode 100644 index 00000000..43e01a02 --- /dev/null +++ b/public/sw-custom.js @@ -0,0 +1,188 @@ +// Custom service worker optimizations for ZapDev +const CACHE_NAME = 'zapdev-v1.0.0'; +const CRITICAL_CACHE = 'zapdev-critical-v1.0.0'; +const STATIC_CACHE = 'zapdev-static-v1.0.0'; +const API_CACHE = 'zapdev-api-v1.0.0'; + +// Resources to cache immediately +const CRITICAL_RESOURCES = [ + '/', +const CRITICAL_RESOURCES = [ + '/', // SPA entry + '/index.html', // optional, if served +]; + +// Resources to cache on first request +const STATIC_RESOURCES = [ + '/favicon.ico', + '/favicon.svg', + '/manifest.json', + '/og-image.svg', +]; + +// Install event - cache critical resources +self.addEventListener('install', event => { + event.waitUntil( + caches.open(CRITICAL_CACHE) + .then(cache => { + return cache.addAll(CRITICAL_RESOURCES.map(url => + new Request(url, { cache: 'reload' }) + )); + }) + .then(() => self.skipWaiting()) + ); +}); + +// Activate event - clean up old caches +self.addEventListener('activate', event => { + event.waitUntil( + caches.keys().then(cacheNames => { + return Promise.all( + cacheNames.map(cacheName => { + if (cacheName !== CACHE_NAME && + cacheName !== CRITICAL_CACHE && + cacheName !== STATIC_CACHE && + cacheName !== API_CACHE) { + return caches.delete(cacheName); + } + }) + ); + }).then(() => self.clients.claim()) + ); +}); + +// Fetch event - implement caching strategies +self.addEventListener('fetch', event => { + const url = new URL(event.request.url); + + // Handle API requests + if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/hono/')) { + event.respondWith( + caches.open(API_CACHE).then(cache => { + return fetch(event.request) + .then(response => { + // Cache successful GET requests for 5 minutes + if (event.request.method === 'GET' && response.ok) { + const responseClone = response.clone(); + const expirationTime = Date.now() + (5 * 60 * 1000); // 5 minutes + const extendedResponse = new Response(responseClone.body, { + status: responseClone.status, + statusText: responseClone.statusText, + headers: { + ...responseClone.headers, + 'sw-cache-expires': expirationTime, + } + }); + cache.put(event.request, extendedResponse); + } + return response; + }) + .catch(() => { + // Return cached version if network fails + return cache.match(event.request).then(cachedResponse => { + if (cachedResponse) { + const expirationTime = cachedResponse.headers.get('sw-cache-expires'); + if (!expirationTime || Date.now() < parseInt(expirationTime)) { + return cachedResponse; + } + } + throw new Error('Network failed and no valid cache available'); + }); + }); + }) + ); + return; + } + + // Handle static assets + if (url.pathname.startsWith('/assets/')) { + event.respondWith( + caches.open(STATIC_CACHE).then(cache => { + return cache.match(event.request).then(cachedResponse => { + if (cachedResponse) { + return cachedResponse; + } + + return fetch(event.request).then(response => { + if (response.ok) { + cache.put(event.request, response.clone()); + } + return response; + }); + }); + }) + ); + return; + } + + // Handle navigation requests + if (event.request.mode === 'navigate') { + event.respondWith( + caches.open(CRITICAL_CACHE).then(cache => { + return fetch(event.request) + .then(response => { + if (response.ok) { + cache.put(event.request, response.clone()); + } + return response; + }) + .catch(() => { + // Fallback to cached index.html for SPA routing + return cache.match('/') || + cache.match('/index.html') || + new Response('Offline - Please check your connection', { + status: 200, + headers: { 'Content-Type': 'text/html' } + }); + }); + }) + ); + return; + } + + // Default: network first for everything else + event.respondWith( + fetch(event.request) + .catch(() => { + return caches.match(event.request); + }) + ); +}); + +// Background sync for offline actions +self.addEventListener('sync', event => { + if (event.tag === 'background-sync') { + event.waitUntil(doBackgroundSync()); + } +}); + +async function doBackgroundSync() { + // Handle any offline actions when connection is restored + console.log('Background sync triggered'); +} + +// Push notifications (if needed later) +self.addEventListener('push', event => { + if (event.data) { + const options = { + body: event.data.text(), + icon: '/pwa-192x192.png', + badge: '/pwa-64x64.png', + vibrate: [200, 100, 200], + data: { url: '/' } + }; + + event.waitUntil( + self.registration.showNotification('ZapDev', options) + ); + } +}); + +// Notification click handler +self.addEventListener('notificationclick', event => { + event.notification.close(); + + event.waitUntil( + clients.openWindow(event.notification.data?.url || '/') + ); +}); \ No newline at end of file diff --git a/src/components/LazyComponents.tsx b/src/components/LazyComponents.tsx new file mode 100644 index 00000000..0abdcd04 --- /dev/null +++ b/src/components/LazyComponents.tsx @@ -0,0 +1,68 @@ +import React, { lazy, Suspense } from 'react'; +import { cn } from '@/lib/utils'; +import type { WebsiteAnalysis } from '@/lib/firecrawl'; +import type { LivePreviewProps } from './LivePreview'; + +// Lazy load heavy components to improve initial bundle size +export const LazyEnhancedChatInterface = lazy(() => + import('./EnhancedChatInterface').then(module => ({ default: module.EnhancedChatInterface })) +); + +export const LazyWebsiteCloneDialog = lazy(() => + import('./WebsiteCloneDialog').then(module => ({ default: module.WebsiteCloneDialog })) +); + +export const LazySmartPrompts = lazy(() => + import('./SmartPrompts').then(module => ({ default: module.SmartPrompts })) +); + +export const LazyLivePreview = lazy(() => + import('./LivePreview').then(module => ({ default: module.LivePreview })) +); + +// High-performance fallback component +const OptimizedFallback = ({ className, children }: { className?: string; children?: React.ReactNode }) => ( +
+
+
+
+ {children} +
+); + +// Wrapper components with optimized suspense boundaries +interface WebsiteCloneDialogProps { + isOpen: boolean; + onOpenChange: (open: boolean) => void; + onCloneRequest: (analysis: WebsiteAnalysis, clonePrompt: string) => void; +} + +interface SmartPromptsProps { + onPromptSelect: (prompt: string) => void; + isVisible: boolean; +} + + +export const SuspendedWebsiteCloneDialog: React.FC = (props) => ( + }> + + +); + +export const SuspendedSmartPrompts: React.FC = (props) => ( + }> + + +); + +export const SuspendedLivePreview: React.FC = (props) => ( + }> + + +); + +export default { + SuspendedWebsiteCloneDialog, + SuspendedSmartPrompts, + SuspendedLivePreview, +}; \ No newline at end of file diff --git a/src/components/PerformanceOptimizer.tsx b/src/components/PerformanceOptimizer.tsx index 414269a4..7b97d538 100644 --- a/src/components/PerformanceOptimizer.tsx +++ b/src/components/PerformanceOptimizer.tsx @@ -226,7 +226,6 @@ export const PerformanceOptimizer: React.FC = ({ const optimizeBundleLoading = useCallback(() => { // Add modulepreload for critical scripts const criticalScripts = [ - '/src/main.tsx', 'https://cdn.gpteng.co/gptengineer.js' ]; diff --git a/src/components/ResourcePreloader.tsx b/src/components/ResourcePreloader.tsx new file mode 100644 index 00000000..8ecf63a3 --- /dev/null +++ b/src/components/ResourcePreloader.tsx @@ -0,0 +1,78 @@ +import { useEffect } from 'react'; + +interface Resource { + href: string; + as: string; + type?: string; + crossorigin?: string; +} + +const CRITICAL_RESOURCES: Resource[] = [ + // Font stylesheet (safe cross-origin hint; actual font files are fetched by the stylesheet) + { href: 'https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap', as: 'style' }, +]; + +const DNS_PREFETCH_DOMAINS = [ + 'https://fonts.googleapis.com', + 'https://fonts.gstatic.com', + 'https://api.groq.com', + 'https://api.clerk.dev', +]; + +export const ResourcePreloader: React.FC = () => { + useEffect(() => { + // DNS prefetch for external domains + DNS_PREFETCH_DOMAINS.forEach(domain => { + const link = document.createElement('link'); + link.rel = 'dns-prefetch'; + link.href = domain; + document.head.appendChild(link); + }); + + // Preload critical resources + CRITICAL_RESOURCES.forEach(resource => { + const link = document.createElement('link'); + link.rel = 'preload'; + link.href = resource.href; + link.as = resource.as; + if (resource.type) link.type = resource.type; + if (resource.crossorigin) link.crossOrigin = resource.crossorigin; + document.head.appendChild(link); + }); + + // Preconnect to known third-party origins + const preconnectDomains = [ + 'https://fonts.gstatic.com', + 'https://api.groq.com', + 'https://api.clerk.dev', + ]; + + preconnectDomains.forEach(domain => { + const link = document.createElement('link'); + link.rel = 'preconnect'; + link.href = domain; + link.crossOrigin = 'anonymous'; + document.head.appendChild(link); + }); + + // Optimize resource hints + const modulePreload = document.createElement('link'); + modulePreload.rel = 'modulepreload'; + modulePreload.href = '/src/main.tsx'; + document.head.appendChild(modulePreload); + + // Service Worker registration with performance optimizations + if ('serviceWorker' in navigator) { + navigator.serviceWorker.register('/sw.js', { + scope: '/', + updateViaCache: 'none' + }).catch(() => { + // Silently fail - SW is optional for performance + }); + } + }, []); + + return null; +}; + +export default ResourcePreloader; \ No newline at end of file diff --git a/src/components/auth/EnhancedSignUp.tsx b/src/components/auth/EnhancedSignUp.tsx index 634c6c64..b5b6511c 100644 --- a/src/components/auth/EnhancedSignUp.tsx +++ b/src/components/auth/EnhancedSignUp.tsx @@ -13,10 +13,9 @@ import { Shield, Sparkles, Code, Zap } from 'lucide-react'; interface EnhancedSignUpProps { redirectUrl?: string; - onComplete?: () => void; } -export function EnhancedSignUp({ redirectUrl = '/chat', onComplete }: EnhancedSignUpProps) { +export function EnhancedSignUp({ redirectUrl = '/chat' }: EnhancedSignUpProps) { const [step, setStep] = useState<'privacy' | 'signup' | 'welcome'>('privacy'); const { showConsent, hasConsent, PrivacyConsentStep, closeConsent } = useSignupPrivacyConsent(); const { openSignUp } = useClerk(); diff --git a/src/components/auth/PrivacyAwareSignInButton.tsx b/src/components/auth/PrivacyAwareSignInButton.tsx index f0764af1..275d390b 100644 --- a/src/components/auth/PrivacyAwareSignInButton.tsx +++ b/src/components/auth/PrivacyAwareSignInButton.tsx @@ -77,6 +77,7 @@ export function PrivacyAwareSignInButton({ }); }; + const handlePrivacyConsentClose = () => { setShowPrivacyConsent(false); }; @@ -110,9 +111,7 @@ export function PrivacyAwareSignInButton({ {showPrivacyConsent && ( )} diff --git a/src/components/ui/CriticalCSS.tsx b/src/components/ui/CriticalCSS.tsx new file mode 100644 index 00000000..0ad38a29 --- /dev/null +++ b/src/components/ui/CriticalCSS.tsx @@ -0,0 +1,114 @@ +import { useEffect } from 'react'; + +// Critical CSS for above-the-fold content to reduce render delay +const criticalCSS = ` + .hero-section { + font-display: swap; + } + + /* Preload critical fonts */ + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('/fonts/inter-v12-latin-regular.woff2') format('woff2'); + } + + @font-face { + font-family: 'Inter'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url('/fonts/inter-v12-latin-600.woff2') format('woff2'); + } + + /* Critical layout styles */ + .critical-layout { + min-height: 100vh; + display: flex; + flex-direction: column; + } + + .critical-nav { + position: sticky; + top: 0; + z-index: 50; + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + background-color: rgba(255, 255, 255, 0.8); + } + + /* Optimize animations for better performance */ + .optimized-transition { + transition: transform 0.2s cubic-bezier(0.4, 0, 0.2, 1); + will-change: transform; + } + + .optimized-transition:hover { + transform: translateY(-2px); + } + + /* Reduce layout shift */ + .skeleton-loader { + background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); + background-size: 200% 100%; + animation: loading 1.5s infinite; + } + + @keyframes loading { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } + } + + /* Improve text rendering */ + body { + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + + /* Reduce paint complexity */ + .gpu-accelerated { + transform: translateZ(0); + backface-visibility: hidden; + perspective: 1000px; + } + + /* Optimize images */ + img { + content-visibility: auto; + contain-intrinsic-size: 200px 200px; + } +`; + +export const CriticalCSS: React.FC = () => { + useEffect(() => { + // Inject critical CSS + const style = document.createElement('style'); + style.textContent = criticalCSS; + style.id = 'critical-css'; + document.head.insertBefore(style, document.head.firstChild); + + // Preload critical resources + const preloadLink = document.createElement('link'); + preloadLink.rel = 'preload'; + preloadLink.href = '/fonts/inter-v12-latin-regular.woff2'; + preloadLink.as = 'font'; + preloadLink.type = 'font/woff2'; + preloadLink.crossOrigin = 'anonymous'; + document.head.appendChild(preloadLink); + + // Cleanup on unmount + return () => { + const existingStyle = document.getElementById('critical-css'); + if (existingStyle) { + existingStyle.remove(); + } + }; + }, []); + + return null; +}; + +export default CriticalCSS; \ No newline at end of file diff --git a/src/components/ui/HeroImagePreloader.tsx b/src/components/ui/HeroImagePreloader.tsx new file mode 100644 index 00000000..9cb7934e --- /dev/null +++ b/src/components/ui/HeroImagePreloader.tsx @@ -0,0 +1,40 @@ +import { useEffect } from 'react'; + +// Preload critical above-the-fold images to improve LCP +const CRITICAL_IMAGES = [ + '/placeholder.svg', + '/og-image.svg', + '/favicon.svg', + // Add your hero/landing page images here +]; + +export const HeroImagePreloader: React.FC = () => { + useEffect(() => { + const preloadImages = () => { + CRITICAL_IMAGES.forEach(src => { + const img = new Image(); + img.src = src; + + // Add to link preload for better browser prioritization + const link = document.createElement('link'); + link.rel = 'preload'; + link.as = 'image'; + link.href = src; + link.fetchPriority = 'high'; + document.head.appendChild(link); + }); + }; + + // Use requestIdleCallback for better performance + if ('requestIdleCallback' in window) { + requestIdleCallback(preloadImages); + } else { + // Fallback for browsers without requestIdleCallback + setTimeout(preloadImages, 1); + } + }, []); + + return null; +}; + +export default HeroImagePreloader; \ No newline at end of file diff --git a/src/components/ui/LazyLoader.tsx b/src/components/ui/LazyLoader.tsx new file mode 100644 index 00000000..3395959d --- /dev/null +++ b/src/components/ui/LazyLoader.tsx @@ -0,0 +1,71 @@ +import React, { useState, useEffect, useRef, ReactNode } from 'react'; +import { cn } from '@/lib/utils'; + +interface LazyLoaderProps { + children: ReactNode; + fallback?: ReactNode; + rootMargin?: string; + threshold?: number; + className?: string; + minHeight?: number; +} + +export const LazyLoader: React.FC = ({ + children, + fallback, + rootMargin = '50px', + threshold = 0.1, + className, + minHeight = 200, +}) => { + const [isIntersecting, setIsIntersecting] = useState(false); + const [hasLoaded, setHasLoaded] = useState(false); + const ref = useRef(null); + + useEffect(() => { + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting && !hasLoaded) { + setIsIntersecting(true); + setHasLoaded(true); + observer.unobserve(entry.target); + } + }, + { + rootMargin, + threshold, + } + ); + + const currentRef = ref.current; + if (currentRef) { + observer.observe(currentRef); + } + + return () => { + if (currentRef) { + observer.unobserve(currentRef); + } + }; + }, [rootMargin, threshold, hasLoaded]); + + const defaultFallback = ( +
+
Loading...
+
+ ); + + return ( +
+ {isIntersecting || hasLoaded ? children : (fallback || defaultFallback)} +
+ ); +}; + +export default LazyLoader; \ No newline at end of file diff --git a/src/components/ui/OptimizedImage.tsx b/src/components/ui/OptimizedImage.tsx new file mode 100644 index 00000000..2fb846eb --- /dev/null +++ b/src/components/ui/OptimizedImage.tsx @@ -0,0 +1,124 @@ +import React, { useState, useCallback, useRef } from 'react'; +import { cn } from '@/lib/utils'; + +interface OptimizedImageProps { + src: string; + alt: string; + className?: string; + width?: number; + height?: number; + quality?: number; + loading?: 'lazy' | 'eager'; + placeholder?: string; + sizes?: string; + priority?: boolean; +} + +export const OptimizedImage: React.FC = ({ + src, + alt, + className, + width, + height, + quality = 85, + loading = 'lazy', + placeholder = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjIwMCIgdmlld0JveD0iMCAwIDIwMCAyMDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSIyMDAiIGhlaWdodD0iMjAwIiBmaWxsPSIjRjNGNEY2Ii8+Cjx0ZXh0IHg9IjEwMCIgeT0iMTEwIiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBmb250LWZhbWlseT0ic2Fucy1zZXJpZiIgZm9udC1zaXplPSIxNCIgZmlsbD0iIzlDQTNBRiI+TG9hZGluZy4uLjwvdGV4dD4KPHN2Zz4=', + sizes = '(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw', + priority = false, +}) => { + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(false); + const imgRef = useRef(null); + + // Generate optimized image formats + const generateSrcSet = useCallback((originalSrc: string) => { + const baseSrc = originalSrc.split('?')[0]; + const webpSrc = `${baseSrc}?format=webp&quality=${quality}`; + const avifSrc = `${baseSrc}?format=avif&quality=${quality}`; + + // Generate different sizes + const sizes = [480, 768, 1024, 1200]; + const srcSet = sizes.map(size => { + return `${webpSrc}&w=${size} ${size}w`; + }).join(', '); + + return { webpSrc, avifSrc, srcSet }; + }, [quality]); + + const handleLoad = useCallback(() => { + setIsLoading(false); + }, []); + + const handleError = useCallback(() => { + setError(true); + setIsLoading(false); + }, []); + + const { webpSrcSet, avifSrcSet } = generateSrcSet(src); + + if (error) { + return ( +
+ Failed to load image +
+ ); + } + + return ( +
+ {isLoading && ( +
+
Loading...
+
+ )} + + + {/* AVIF format for modern browsers */} + + + {/* WebP format */} + + + {/* Fallback to original format */} + {alt} + +
+ ); +}; + +export default OptimizedImage; \ No newline at end of file diff --git a/src/hooks/usePerformanceMonitoring.ts b/src/hooks/usePerformanceMonitoring.ts new file mode 100644 index 00000000..428c3289 --- /dev/null +++ b/src/hooks/usePerformanceMonitoring.ts @@ -0,0 +1,167 @@ +import { useEffect, useCallback } from 'react'; + +interface PerformanceMetrics { + navigationTiming?: PerformanceNavigationTiming; + paintTiming?: PerformancePaintTiming[]; + resourceTiming?: PerformanceResourceTiming[]; + memoryInfo?: Performance & { memory?: { usedJSHeapSize: number } }; +} + +export const usePerformanceMonitoring = () => { + const measureWebVitals = useCallback(async () => { + if (typeof window === 'undefined') return; + + try { + // Core Web Vitals measurement + const { getCLS, getFID, getFCP, getLCP, getTTFB } = await import('web-vitals'); + + const sendMetrics = (name: string, value: number, rating: string) => { + // Only send in production or when explicitly enabled + if (process.env.NODE_ENV === 'production') { + // Send to your analytics service (PostHog, Google Analytics, etc.) + if (window.gtag) { + window.gtag('event', name, { + event_category: 'Web Vitals', + event_label: rating, + value: Math.round(name === 'CLS' ? value * 1000 : value), + non_interaction: true, + }); + } + + // Send to PostHog if available + if (window.posthog) { + window.posthog.capture('web_vital', { + metric: name, + value: value, + rating: rating, + }); + } + } + }; + + // Measure each Core Web Vital + getCLS(({ name, value, rating }) => sendMetrics(name, value, rating)); + getFID(({ name, value, rating }) => sendMetrics(name, value, rating)); + getFCP(({ name, value, rating }) => sendMetrics(name, value, rating)); + getLCP(({ name, value, rating }) => sendMetrics(name, value, rating)); + getTTFB(({ name, value, rating }) => sendMetrics(name, value, rating)); + } catch (error) { + // web-vitals library not available, use Performance API + measureWithPerformanceAPI(); + } + }, []); + + const measureWithPerformanceAPI = useCallback(() => { + if (!('performance' in window)) return; + + const perfData: PerformanceMetrics = {}; + + // Navigation timing + const navigationTiming = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming; + if (navigationTiming) { + perfData.navigationTiming = navigationTiming; + + // Calculate key metrics + const metrics = { + dns: navigationTiming.domainLookupEnd - navigationTiming.domainLookupStart, + tcp: navigationTiming.connectEnd - navigationTiming.connectStart, + request: navigationTiming.responseStart - navigationTiming.requestStart, + response: navigationTiming.responseEnd - navigationTiming.responseStart, + dom: navigationTiming.domContentLoadedEventStart - navigationTiming.domLoading, + load: navigationTiming.loadEventStart - navigationTiming.domContentLoadedEventStart, + total: navigationTiming.loadEventEnd - navigationTiming.navigationStart, + }; + + // Log performance insights in development + if (process.env.NODE_ENV === 'development') { + console.group('πŸš€ Performance Metrics'); + console.log('DNS Lookup:', `${metrics.dns}ms`); + console.log('TCP Connection:', `${metrics.tcp}ms`); + console.log('Request Time:', `${metrics.request}ms`); + console.log('Response Time:', `${metrics.response}ms`); + console.log('DOM Processing:', `${metrics.dom}ms`); + console.log('Load Events:', `${metrics.load}ms`); + console.log('Total Time:', `${metrics.total}ms`); + console.groupEnd(); + } + } + + // Paint timing + const paintTiming = performance.getEntriesByType('paint') as PerformancePaintTiming[]; + if (paintTiming.length > 0) { + perfData.paintTiming = paintTiming; + + if (process.env.NODE_ENV === 'development') { + console.group('🎨 Paint Timing'); + paintTiming.forEach(({ name, startTime }) => { + console.log(`${name}:`, `${Math.round(startTime)}ms`); + }); + console.groupEnd(); + } + } + + // Memory usage (Chrome only) + if ('memory' in performance) { + const memory = (performance as any).memory; + perfData.memoryInfo = memory; + + if (process.env.NODE_ENV === 'development') { + console.log('🧠 Memory Usage:', `${Math.round(memory.usedJSHeapSize / 1024 / 1024)}MB`); + } + } + + return perfData; + }, []); + + const logResourceTiming = useCallback(() => { + if (!('performance' in window)) return; + + const resources = performance.getEntriesByType('resource') as PerformanceResourceTiming[]; + const largeResources = resources + .filter(resource => resource.transferSize > 100000) // > 100KB + .sort((a, b) => b.transferSize - a.transferSize) + .slice(0, 10); // Top 10 + + if (largeResources.length > 0 && process.env.NODE_ENV === 'development') { + console.group('πŸ“¦ Large Resources (>100KB)'); + largeResources.forEach(resource => { + console.log( + `${resource.name.split('/').pop()}:`, + `${Math.round(resource.transferSize / 1024)}KB`, + `(${Math.round(resource.duration)}ms)` + ); + }); + console.groupEnd(); + } + }, []); + + const measureComponentPerformance = useCallback((componentName: string, startTime: number) => { + const endTime = performance.now(); + const duration = endTime - startTime; + + if (duration > 16 && process.env.NODE_ENV === 'development') { + console.warn(`⚠️ Slow component render: ${componentName} took ${Math.round(duration)}ms`); + } + + return duration; + }, []); + + useEffect(() => { + // Delay measurements to avoid affecting initial performance + const timer = setTimeout(() => { + measureWebVitals(); + logResourceTiming(); + }, 2000); + + return () => clearTimeout(timer); + }, [measureWebVitals, logResourceTiming]); + + return { + measureWebVitals, + measureWithPerformanceAPI, + logResourceTiming, + measureComponentPerformance, + }; +}; + +export default usePerformanceMonitoring; \ No newline at end of file diff --git a/src/index.css b/src/index.css index 39a40fbd..ed4f60e6 100644 --- a/src/index.css +++ b/src/index.css @@ -1,6 +1,6 @@ -@import url('https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap'); -@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap'); @import "tailwindcss"; @theme { diff --git a/src/main.tsx b/src/main.tsx index bab4e55a..2cee5225 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -335,6 +335,8 @@ VITE_CONVEX_URL=https://your-app.convex.cloud root.render( + + {PUBLISHABLE_KEY ? ( ]*>[\s\S]*?<\/script[^<]*>/is, + /]*>[\s\S]*?<\/script>/is, /\bjavascript:/i, // quoted or unquoted inline handlers /\bon\w+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/i, diff --git a/src/utils/text-sanitizer.ts b/src/utils/text-sanitizer.ts index 3b304f9c..72d2f4a9 100644 --- a/src/utils/text-sanitizer.ts +++ b/src/utils/text-sanitizer.ts @@ -5,6 +5,7 @@ * to prevent XSS attacks when rendering in React components. */ +import { z } from 'zod'; /** * HTML escape characters mapping */ @@ -19,6 +20,32 @@ const HTML_ESCAPE_MAP: Record = { '=': '=' }; +// Input security limits (tune per product requirements) +export const MAX_MESSAGE_LENGTH = 4000; +export const MAX_TITLE_LENGTH = 120; +export const MAX_CODE_BLOCK_LENGTH = 20000; + +// Basic malicious pattern detection (defense-in-depth; escaping already neutralizes tags) +const MALICIOUS_PATTERNS: ReadonlyArray = [ + /<\s*script\b/i, + /\bon\w+\s*=/i, // onload=, onclick=, etc. + /javascript:\s*/i, +]; + +export interface ValidationResult { isValid: boolean; error?: string } + +export const safeTextSchema = z + .string() + .max(MAX_MESSAGE_LENGTH, `Text must be <= ${MAX_MESSAGE_LENGTH} characters`) + .refine((s) => !MALICIOUS_PATTERNS.some((rx) => rx.test(s)), 'Potentially unsafe content detected'); + +export function validateInput(text: unknown): ValidationResult { + if (typeof text !== 'string') return { isValid: false, error: 'Text must be a string' }; + const parsed = safeTextSchema.safeParse(text); + return parsed.success ? { isValid: true } : { isValid: false, error: parsed.error.issues[0]?.message }; +} + + /** * Escapes HTML characters in a string to prevent XSS attacks * @param text - The text to escape @@ -83,3 +110,9 @@ export function sanitizeOutput(output: string | null | undefined, fallback = 'No export function createSanitizedHtml(text: string | null | undefined, fallback = ''): { __html: string } { return { __html: sanitizeText(text, fallback) }; } + +export function sanitizeAndValidateText(text: unknown, fallback = ''): { value: string; isValid: boolean; error?: string } { + const result = validateInput(text); + if (!result.isValid) return { value: fallback, isValid: false, error: result.error }; + return { value: sanitizeText(text, fallback), isValid: true }; +} diff --git a/vite.config.ts b/vite.config.ts index f004a271..ad190cf1 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,6 +2,7 @@ import { defineConfig, loadEnv } from "vite"; import react from "@vitejs/plugin-react-swc"; import path from "path"; import { VitePWA } from "vite-plugin-pwa"; +import type { PreRenderedAsset } from 'rollup'; let componentTagger: (() => unknown) | null = null; try { @@ -42,19 +43,80 @@ export default defineConfig(({ mode }) => { }, }, plugins: [ - react(), + react({ + // Enable Fast Refresh optimizations + devTarget: 'es2020', + }), mode === 'development' && componentTagger ? componentTagger() : null, VitePWA({ registerType: 'autoUpdate', injectRegister: 'auto', + strategies: mode === 'production' ? 'generateSW' : 'injectManifest', + srcDir: 'public', + filename: mode === 'production' ? 'sw.js' : 'sw-custom.js', workbox: { - maximumFileSizeToCacheInBytes: 5 * 1024 * 1024, + maximumFileSizeToCacheInBytes: 3 * 1024 * 1024, // Reduced to 3MB navigateFallbackDenylist: [/^\/api\//, /^\/hono\//, /^\/convex\//, /^\/_/], - runtimeCaching: [{ - urlPattern: /^https:\/\/api\./, - handler: 'NetworkFirst', - options: { cacheName: 'api-cache' } - }] + cleanupOutdatedCaches: true, + skipWaiting: true, + clientsClaim: true, + runtimeCaching: [ + { + // Only cache specific public API endpoints, not all api.* domains + urlPattern: ({ url }) => { + const publicEndpoints = [ + '/api/health', + '/hono/health', + ]; + return publicEndpoints.some(endpoint => url.pathname === endpoint); + }, + method: 'GET', + handler: 'NetworkFirst', + options: { + cacheName: 'api-cache', + expiration: { + maxEntries: 50, + maxAgeSeconds: 5 * 60, // 5 minutes + }, + cacheableResponse: { + statuses: [0, 200], + headers: { + 'Cache-Control': /^(?!.*no-store).*/, + }, + }, + plugins: [ + { + cacheWillUpdate: async ({ request, response }) => { + // Never cache authenticated requests or responses with sensitive headers + if (request.headers.get('Authorization') || + request.headers.get('Cookie') || + response.headers.get('Cache-Control')?.includes('no-store') || + response.headers.get('Set-Cookie')) { + return null; + } + return response; + }, + }, + ], + } + }, + { + urlPattern: /^https:\/\/fonts\.googleapis\.com\//, + handler: 'StaleWhileRevalidate', + options: { + cacheName: 'google-fonts-stylesheets', + expiration: { maxAgeSeconds: 60 * 60 * 24 * 365 } // 1 year + } + }, + { + urlPattern: /^https:\/\/fonts\.gstatic\.com\//, + handler: 'CacheFirst', + options: { + cacheName: 'google-fonts-webfonts', + expiration: { maxAgeSeconds: 60 * 60 * 24 * 365 } // 1 year + } + } + ] }, devOptions: { enabled: mode === 'development', @@ -113,41 +175,49 @@ export default defineConfig(({ mode }) => { ), }, build: { + target: 'es2020', + minify: 'esbuild', + cssMinify: true, rollupOptions: { output: { - manualChunks(id) { - if (!id || !id.includes('node_modules')) return; - const normalized = id.replace(/\\/g, '/'); - const lastIndex = normalized.lastIndexOf('node_modules'); - if (lastIndex === -1) return; - - let start = lastIndex + 'node_modules'.length; - if (normalized.charAt(start) === '/') start += 1; - - const after = normalized.slice(start); - if (!after) return; - - const segments = after.split('/'); - if (segments.length === 0 || !segments[0]) return; - - const isScoped = segments[0].startsWith('@'); - let pkg = ''; - if (isScoped && segments.length > 1) { - pkg = `${segments[0]}/${segments[1]}`; - } else if (!isScoped) { - pkg = segments[0]; + manualChunks: { + // Core vendor chunks + 'react-vendor': ['react', 'react-dom', 'react-router-dom'], + 'ui-vendor': ['@headlessui/react', 'lucide-react', 'framer-motion'], + 'form-vendor': ['react-hook-form', '@hookform/resolvers', 'zod'], + 'query-vendor': ['@tanstack/react-query', 'convex'], + 'ai-vendor': ['@ai-sdk/groq', 'groq-sdk', '@e2b/code-interpreter'], + // Keep heavy libraries separate + 'chart-vendor': ['recharts'], + 'auth-vendor': ['@clerk/clerk-react', '@clerk/backend'], + }, + // Optimize asset handling + assetFileNames: (assetInfo: PreRenderedAsset) => { + const safeName = assetInfo.name ?? 'unknown'; + const info = safeName.split('.'); + const ext = info[info.length - 1]; + if (/\.(png|jpe?g|svg|gif|tiff|bmp|ico)$/i.test(safeName)) { + return `assets/images/[name]-[hash][extname]`; } - - if (!pkg) return; - - const safe = pkg.replace(/@/g, '').replace(/\//g, '-'); - if (!safe) return; - - return `vendor-${safe}`; + if (/\.(woff2?|eot|ttf|otf)$/i.test(safeName)) { + return `assets/fonts/[name]-[hash][extname]`; + } + return `assets/${ext}/[name]-[hash][extname]`; }, + chunkFileNames: 'assets/js/[name]-[hash].js', + entryFileNames: 'assets/js/[name]-[hash].js', + }, + treeshake: { + moduleSideEffects: false, + propertyReadSideEffects: false, + tryCatchDeoptimization: false, }, }, - chunkSizeWarningLimit: 1600, + chunkSizeWarningLimit: 1000, + // Enable source maps only in development + sourcemap: mode === 'development', + // Optimize CSS + cssCodeSplit: true, }, }; });