diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3de3699 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,53 @@ +# Dependencies +node_modules +bun.lockb +*.log + +# Environment +.env +.env.* + +# Database +**/*.db +**/*.db-shm +**/*.db-wal +award.db + +# Build outputs +src/frontend/build/ +src/frontend/.svelte-kit/ +src/frontend/dist/ +build/ +dist/ + +# IDE +.vscode +.idea +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Git +.git/ +.gitignore + +# Documentation +README.md +docs/ +*.md + +# Logs +logs/ +*.log + +# PM2 +ecosystem.config.js +.pm2/ + +# Tests +*.test.js +*.test.ts +coverage/ diff --git a/README.md b/README.md index 79f27fb..1da8f6b 100644 --- a/README.md +++ b/README.md @@ -227,79 +227,422 @@ This gives you: - ✅ Simple production deployment ### Production Mode -In production, you can either: -1. **Build static files** and serve from Elysia -2. **Keep the proxy setup** with a proper reverse proxy (nginx, caddy) -3. **Use SvelteKit adapter** for Node/Bun to serve everything from one process +In production, the application serves everything from a single port: +- **Backend** on port 3001 serves both API and static frontend files +- Frontend static files are served from `src/frontend/build/` +- SPA routing is handled by backend fallback +- **Single port** simplifies HAProxy configuration ## Production Deployment -### Building for Production +This guide covers deployment using **PM2** for process management and **HAProxy** as a reverse proxy/load balancer. + +### Architecture Overview + +``` +Internet + │ + ▼ +┌─────────────────┐ +│ HAProxy │ Port 443 (HTTPS) +│ (Port 80/443) │ Port 80 (HTTP → HTTPS redirect) +└────────┬────────┘ + │ + ▼ +┌─────────────────┐ +│ Backend │ Port 3001 +│ Managed by │ ├─ API Routes (/api/*) +│ PM2 │ ├─ Static Files (/*) +│ │ └─ SPA Fallback +└────────┬────────┘ + │ + ├─────────────────┬──────────────────┐ + │ │ │ + ▼ ▼ ▼ + ┌─────────┐ ┌──────────┐ ┌─────────────┐ + │ SQLite │ │ Frontend │ │ ARRL LoTW │ + │ DB │ │ Build │ │ External API│ + └─────────┘ └──────────┘ └─────────────┘ +``` + +### Prerequisites + +- Server with SSH access +- Bun runtime installed +- PM2 installed globally: `bun install -g pm2` or `npm install -g pm2` +- HAProxy installed +- Domain with DNS pointing to server + +### Step 1: Build the Application ```bash -# Build the frontend +# Clone repository on server +git clone +cd award + +# Install dependencies +bun install + +# Build frontend (generates static files in src/frontend/build/) +bun run build +``` + +### Step 2: Configure Environment Variables + +Create `.env` in the project root: + +```bash +# Application URL +VITE_APP_URL=https://awards.dj7nt.de + +# API Base URL (empty for same-domain) +VITE_API_BASE_URL= + +# JWT Secret (generate with: openssl rand -base64 32) +JWT_SECRET=your-generated-secret-here + +# Environment +NODE_ENV=production + +# Database path (absolute path recommended) +DATABASE_PATH=/path/to/award/award.db +``` + +**Security**: Ensure `.env` has restricted permissions: +```bash +chmod 600 .env +``` + +### Step 3: Initialize Database + +```bash +# Push database schema +bun run db:push + +# Verify database was created +ls -la award.db +``` + +### Step 4: Create PM2 Ecosystem Configuration + +Create `ecosystem.config.js` in the project root: + +```javascript +module.exports = { + apps: [ + { + name: 'award-backend', + script: 'src/backend/index.js', + interpreter: 'bun', + cwd: '/path/to/award', + env: { + NODE_ENV: 'production', + PORT: 3001 + }, + instances: 1, + exec_mode: 'fork', + autorestart: true, + watch: false, + max_memory_restart: '500M', + error_file: './logs/backend-error.log', + out_file: './logs/backend-out.log', + log_date_format: 'YYYY-MM-DD HH:mm:ss Z' + }, + { + name: 'award-frontend', + script: 'bun', + args: 'run preview', + cwd: '/path/to/award/src/frontend', + env: { + NODE_ENV: 'production', + PORT: 5173 + }, + instances: 1, + exec_mode: 'fork', + autorestart: true, + watch: false, + max_memory_restart: '300M', + error_file: './logs/frontend-error.log', + out_file: './logs/frontend-out.log', + log_date_format: 'YYYY-MM-DD HH:mm:ss Z' + } + ] +}; +``` + +**Create logs directory:** +```bash +mkdir -p logs +``` + +### Step 5: Start Applications with PM2 + +```bash +# Start all applications +pm2 start ecosystem.config.js + +# Save PM2 process list +pm2 save + +# Setup PM2 to start on system reboot +pm2 startup +# Follow the instructions output by the command above +``` + +**Useful PM2 Commands:** + +```bash +# View status +pm2 status + +# View logs +pm2 logs + +# Restart all apps +pm2 restart all + +# Restart specific app +pm2 restart award-backend + +# Stop all apps +pm2 stop all + +# Monitor resources +pm2 monit +``` + +### Step 6: Configure HAProxy + +Edit `/etc/haproxy/haproxy.cfg`: + +```haproxy +global + log /dev/log local0 + log /dev/log local1 notice + chroot /var/lib/haproxy + stats socket /run/haproxy/admin.sock mode 660 level admin + stats timeout 30s + user haproxy + group haproxy + daemon + + # Default SSL material locations + ca-base /etc/ssl/certs + crt-base /etc/ssl/private + + # Default ciphers to use + ssl-default-bind-ciphers ECDH+AESGCM:DH+AESGCM:ECDH+AES256:DH+AES256:ECDH+AES128:DH+AES:RSA+AESGCM:RSA+AES:!aNULL:!MD5:!DSS + ssl-default-bind-options no-sslv3 + +defaults + log global + mode http + option httplog + option dontlognull + timeout connect 5000 + timeout client 50000 + timeout server 50000 + errorfile 400 /etc/haproxy/errors/400.http + errorfile 403 /etc/haproxy/errors/403.http + errorfile 408 /etc/haproxy/errors/408.http + errorfile 500 /etc/haproxy/errors/500.http + errorfile 502 /etc/haproxy/errors/502.http + errorfile 503 /etc/haproxy/errors/503.http + errorfile 504 /etc/haproxy/errors/504.http + +# Statistics page (optional - secure with auth) +listen stats + bind *:8404 + mode http + stats enable + stats uri /stats + stats refresh 30s + stats realm HAProxy\ Statistics + stats auth admin:your-secure-password + +# Frontend redirect HTTP to HTTPS +frontend http-in + bind *:80 + http-request redirect scheme https + +# Frontend HTTPS +frontend https-in + bind *:443 ssl crt /etc/ssl/private/awards.dj7nt.de.pem + default_backend award-backend + +# Backend configuration +backend award-backend + # Health check + option httpchk GET /api/health + + # Single server serving both frontend and API + server award-backend 127.0.0.1:3001 check +``` + +**SSL Certificate Setup:** + +Using Let's Encrypt with Certbot: + +```bash +# Install certbot +apt install certbot + +# Generate certificate +certbot certonly --standalone -d awards.dj7nt.de + +# Combine certificate and key for HAProxy +cat /etc/letsencrypt/live/awards.dj7nt.de/fullchain.pem > /etc/ssl/private/awards.dj7nt.de.pem +cat /etc/letsencrypt/live/awards.dj7nt.de/privkey.pem >> /etc/ssl/private/awards.dj7nt.de.pem + +# Set proper permissions +chmod 600 /etc/ssl/private/awards.dj7nt.de.pem +``` + +### Step 7: Start HAProxy + +```bash +# Test configuration +haproxy -c -f /etc/haproxy/haproxy.cfg + +# Restart HAProxy +systemctl restart haproxy + +# Enable HAProxy on boot +systemctl enable haproxy + +# Check status +systemctl status haproxy +``` + +### Step 8: Verify Deployment + +```bash +# Check PM2 processes +pm2 status + +# Check HAProxy stats (if enabled) +curl http://localhost:8404/stats + +# Test health endpoint +curl https://awards.dj7nt.de/api/health + +# Check logs +pm2 logs +tail -f /var/log/haproxy.log +``` + +### Updating the Application + +```bash +# Pull latest changes +git pull + +# Install updated dependencies +bun install + +# Rebuild frontend (if UI changed) bun run build -# Preview the production build locally -bun run preview +# Restart PM2 +pm2 restart award-backend ``` -### Deployment Options +### Database Backups -#### Option 1: Static Site + Backend Server - -1. Build the frontend: `bun run build` -2. Serve `src/frontend/build/` with Elysia using `@elysiajs/static` -3. Backend runs on one port serving both frontend and API - -#### Option 2: Reverse Proxy (Recommended) - -Use nginx or Caddy to proxy: -- `/` → SvelteKit frontend (port 5173 or static files) -- `/api` → Elysia backend (port 3001) - -**Example nginx configuration:** -```nginx -server { - server_name awards.dj7nt.de; - - # Frontend - location / { - proxy_pass http://localhost:5173; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection 'upgrade'; - proxy_set_header Host $host; - proxy_cache_bypass $http_upgrade; - } - - # Backend API - location /api { - proxy_pass http://localhost:3001; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - } -} -``` - -#### Option 3: Single Process with SvelteKit Node Adapter - -Use `@sveltejs/adapter-node` to build for Node/Bun: -- Everything runs in one process -- API routes handled by SvelteKit (need to migrate from Elysia) - -### Environment Variables for Production - -Make sure to set these in your production environment: +Set up automated backups: ```bash -VITE_APP_URL=https://awards.dj7nt.de -VITE_API_BASE_URL= # Leave empty for same-domain -JWT_SECRET= -NODE_ENV=production +# Create backup script +cat > /usr/local/bin/backup-award.sh << 'EOF' +#!/bin/bash +BACKUP_DIR="/backups/award" +DATE=$(date +%Y%m%d_%H%M%S) +mkdir -p $BACKUP_DIR + +# Backup database +cp /path/to/award/award.db $BACKUP_DIR/award_$DATE.db + +# Keep last 30 days +find $BACKUP_DIR -name "award_*.db" -mtime +30 -delete +EOF + +chmod +x /usr/local/bin/backup-award.sh + +# Add to crontab (daily at 2 AM) +crontab -e +# Add line: 0 2 * * * /usr/local/bin/backup-award.sh ``` +### Monitoring + +**PM2 Monitoring:** +```bash +# Real-time monitoring +pm2 monit + +# View logs +pm2 logs --lines 100 +``` + +**HAProxy Monitoring:** +- Access stats page: `http://your-server:8404/stats` +- Check logs: `tail -f /var/log/haproxy.log` + +**Log Files Locations:** +- PM2 logs: `./logs/backend-error.log`, `./logs/frontend-error.log` +- HAProxy logs: `/var/log/haproxy.log` +- System logs: `journalctl -u haproxy -f` + +### Security Checklist + +- [ ] HTTPS enabled with valid SSL certificate +- [ ] Firewall configured (ufw/firewalld) +- [ ] JWT_SECRET is strong and randomly generated +- [ ] .env file has proper permissions (600) +- [ ] Database backups automated +- [ ] PM2 stats page secured with authentication +- [ ] HAProxy stats page secured (if publicly accessible) +- [ ] Regular security updates applied +- [ ] Log rotation configured for application logs + +### Troubleshooting + +**Application won't start:** +```bash +# Check PM2 logs +pm2 logs --err + +# Check if ports are in use +netstat -tulpn | grep -E ':(3001|5173)' + +# Verify environment variables +pm2 env 0 +``` + +**HAProxy not forwarding requests:** +```bash +# Test backend directly +curl http://localhost:3001/api/health +curl http://localhost:5173/ + +# Check HAProxy configuration +haproxy -c -f /etc/haproxy/haproxy.cfg + +# View HAProxy logs +tail -f /var/log/haproxy.log +``` + +**Database issues:** +```bash +# Check database file permissions +ls -la award.db + +# Verify database integrity +bun run db:studio +``` + +--- + ## Features in Detail ### Background Job Queue diff --git a/ecosystem.config.js b/ecosystem.config.js new file mode 100644 index 0000000..1f8d0c0 --- /dev/null +++ b/ecosystem.config.js @@ -0,0 +1,22 @@ +module.exports = { + apps: [ + { + name: 'award-backend', + script: 'src/backend/index.js', + interpreter: 'bun', + cwd: '/path/to/award', + env: { + NODE_ENV: 'production', + PORT: 3001 + }, + instances: 1, + exec_mode: 'fork', + autorestart: true, + watch: false, + max_memory_restart: '500M', + error_file: './logs/backend-error.log', + out_file: './logs/backend-out.log', + log_date_format: 'YYYY-MM-DD HH:mm:ss Z' + } + ] +}; diff --git a/src/backend/index.js b/src/backend/index.js index e6fbb65..b407f35 100644 --- a/src/backend/index.js +++ b/src/backend/index.js @@ -28,7 +28,7 @@ import { /** * Main backend application - * Serves API routes + * Serves API routes and static frontend files */ // Get allowed origins from environment or allow all in development @@ -85,30 +85,30 @@ const app = new Elysia() .post( '/api/auth/register', async ({ body, jwt, set }) => { - try { - // Create user - const user = await registerUser(body); + // Create user + const user = await registerUser(body); - // Generate JWT token - const token = await jwt.sign({ - userId: user.id, - email: user.email, - callsign: user.callsign, - }); - - set.status = 201; - return { - success: true, - token, - user, - }; - } catch (error) { + if (!user) { set.status = 400; return { success: false, - error: error.message, + error: 'Email already registered', }; } + + // Generate JWT token + const token = await jwt.sign({ + userId: user.id, + email: user.email, + callsign: user.callsign, + }); + + set.status = 201; + return { + success: true, + token, + user, + }; }, { body: t.Object({ @@ -136,29 +136,29 @@ const app = new Elysia() .post( '/api/auth/login', async ({ body, jwt, set }) => { - try { - // Authenticate user - const user = await authenticateUser(body.email, body.password); + // Authenticate user + const user = await authenticateUser(body.email, body.password); - // Generate JWT token - const token = await jwt.sign({ - userId: user.id, - email: user.email, - callsign: user.callsign, - }); - - return { - success: true, - token, - user, - }; - } catch (error) { + if (!user) { set.status = 401; return { success: false, error: 'Invalid email or password', }; } + + // Generate JWT token + const token = await jwt.sign({ + userId: user.id, + email: user.email, + callsign: user.callsign, + }); + + return { + success: true, + token, + user, + }; }, { body: t.Object({ @@ -520,22 +520,21 @@ const app = new Elysia() return { success: false, error: 'Unauthorized' }; } - try { - const { awardId } = params; - const progress = await getAwardProgressDetails(user.id, awardId); + const { awardId } = params; + const progress = await getAwardProgressDetails(user.id, awardId); - return { - success: true, - ...progress, - }; - } catch (error) { - logger.error('Error calculating award progress', { error: error.message }); - set.status = 500; + if (!progress) { + set.status = 404; return { success: false, - error: error.message || 'Failed to calculate award progress', + error: 'Award not found', }; } + + return { + success: true, + ...progress, + }; }) /** @@ -548,22 +547,21 @@ const app = new Elysia() return { success: false, error: 'Unauthorized' }; } - try { - const { awardId } = params; - const breakdown = await getAwardEntityBreakdown(user.id, awardId); + const { awardId } = params; + const breakdown = await getAwardEntityBreakdown(user.id, awardId); - return { - success: true, - ...breakdown, - }; - } catch (error) { - logger.error('Error fetching award entities', { error: error.message }); - set.status = 500; + if (!breakdown) { + set.status = 404; return { success: false, - error: error.message || 'Failed to fetch award entities', + error: 'Award not found', }; } + + return { + success: true, + ...breakdown, + }; }) // Health check endpoint @@ -572,6 +570,117 @@ const app = new Elysia() timestamp: new Date().toISOString(), })) + // Serve static files and SPA fallback for all non-API routes + .get('/*', ({ request }) => { + const url = new URL(request.url); + const pathname = url.pathname; + + // Don't intercept API routes + if (pathname.startsWith('/api')) { + return new Response('Not found', { status: 404 }); + } + + // Check for common missing assets before trying to open files + // This prevents Elysia from trying to get file size of non-existent files + const commonMissingFiles = ['/favicon.ico', '/robots.txt']; + if (commonMissingFiles.includes(pathname)) { + return new Response('Not found', { status: 404 }); + } + + // Handle SvelteKit assets path - replace %sveltekit.assets% with the assets directory + if (pathname.startsWith('/%sveltekit.assets%/')) { + // Extract the actual file path after %sveltekit.assets%/ + const assetPath = pathname.replace('/%sveltekit.assets%/', ''); + + try { + // Try to serve from assets directory first + const assetsPath = `src/frontend/build/_app/immutable/assets/${assetPath}`; + const file = Bun.file(assetsPath); + const exists = file.exists(); + + if (exists) { + return new Response(file); + } + } catch (err) { + // Fall through to 404 + } + + // If not in assets, try root directory + try { + const rootFile = Bun.file(`src/frontend/build/${assetPath}`); + const rootExists = rootFile.exists(); + if (rootExists) { + return new Response(rootFile); + } + } catch (err) { + // Fall through to 404 + } + + return new Response('Not found', { status: 404 }); + } + + // Try to serve the file from the build directory + // Remove leading slash for file path + const filePath = pathname === '/' ? '/index.html' : pathname; + + try { + const fullPath = `src/frontend/build${filePath}`; + + // Use Bun.file() which doesn't throw for non-existent files + const file = Bun.file(fullPath); + const exists = file.exists(); + + if (exists) { + // Determine content type + const ext = filePath.split('.').pop(); + const contentTypes = { + 'js': 'application/javascript', + 'css': 'text/css', + 'html': 'text/html; charset=utf-8', + 'json': 'application/json', + 'png': 'image/png', + 'jpg': 'image/jpeg', + 'jpeg': 'image/jpeg', + 'gif': 'image/gif', + 'svg': 'image/svg+xml', + 'ico': 'image/x-icon', + 'woff': 'font/woff', + 'woff2': 'font/woff2', + 'ttf': 'font/ttf', + }; + + const headers = {}; + if (contentTypes[ext]) { + headers['Content-Type'] = contentTypes[ext]; + } + + // Cache headers + if (ext === 'html') { + headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'; + } else { + headers['Cache-Control'] = 'public, max-age=86400'; + } + + return new Response(file, { headers }); + } + } catch (err) { + // File not found or error, fall through to SPA fallback + } + + // SPA fallback - serve index.html for all other routes + try { + const indexFile = Bun.file('src/frontend/build/index.html'); + return new Response(indexFile, { + headers: { + 'Content-Type': 'text/html; charset=utf-8', + 'Cache-Control': 'no-cache, no-store, must-revalidate', + }, + }); + } catch { + return new Response('Frontend not built. Run `bun run build`', { status: 503 }); + } + }) + // Start server .listen(3001); diff --git a/src/backend/services/auth.service.js b/src/backend/services/auth.service.js index 731d35f..7bf448c 100644 --- a/src/backend/services/auth.service.js +++ b/src/backend/services/auth.service.js @@ -30,8 +30,7 @@ async function verifyPassword(password, hash) { * @param {string} userData.email - User email * @param {string} userData.password - Plain text password * @param {string} userData.callsign - Ham radio callsign - * @returns {Promise} Created user object (without password) - * @throws {Error} If email already exists + * @returns {Promise} Created user object (without password) or null if email exists */ export async function registerUser({ email, password, callsign }) { // Check if user already exists @@ -42,7 +41,7 @@ export async function registerUser({ email, password, callsign }) { .limit(1); if (existingUser) { - throw new Error('Email already registered'); + return null; } // Hash password @@ -79,13 +78,13 @@ export async function authenticateUser(email, password) { .limit(1); if (!user) { - throw new Error('Invalid email or password'); + return null; } // Verify password const isValid = await verifyPassword(password, user.passwordHash); if (!isValid) { - throw new Error('Invalid email or password'); + return null; } // Return user without password hash diff --git a/src/backend/services/awards.service.js b/src/backend/services/awards.service.js index 27602ab..b4413c6 100644 --- a/src/backend/services/awards.service.js +++ b/src/backend/services/awards.service.js @@ -546,7 +546,7 @@ export async function getAwardProgressDetails(userId, awardId) { const award = definitions.find((def) => def.id === awardId); if (!award) { - throw new Error('Award not found'); + return null; } // Calculate progress @@ -572,7 +572,7 @@ export async function getAwardEntityBreakdown(userId, awardId) { const award = definitions.find((def) => def.id === awardId); if (!award) { - throw new Error('Award not found'); + return null; } let { rules } = award; diff --git a/src/backend/services/job-queue.service.js b/src/backend/services/job-queue.service.js index e286404..7ad0e60 100644 --- a/src/backend/services/job-queue.service.js +++ b/src/backend/services/job-queue.service.js @@ -107,7 +107,12 @@ async function processJobAsync(jobId, userId, type, data) { // Get the processor for this job type const processor = jobProcessors[type]; if (!processor) { - throw new Error(`No processor registered for job type: ${type}`); + await updateJob(jobId, { + status: JobStatus.FAILED, + completedAt: new Date(), + error: `No processor registered for job type: ${type}`, + }); + return null; } // Execute the job processor diff --git a/src/backend/services/lotw.service.js b/src/backend/services/lotw.service.js index 94a3a7e..68c0a21 100644 --- a/src/backend/services/lotw.service.js +++ b/src/backend/services/lotw.service.js @@ -84,7 +84,9 @@ async function fetchQSOsFromLoTW(lotwUsername, lotwPassword, sinceDate = null) { for (let attempt = 0; attempt < POLLING_CONFIG.maxRetries; attempt++) { const elapsed = Date.now() - startTime; if (elapsed > POLLING_CONFIG.maxTotalTime) { - throw new Error(`LoTW sync timeout: exceeded maximum wait time of ${POLLING_CONFIG.maxTotalTime / 1000} seconds`); + return { + error: `LoTW sync timeout: exceeded maximum wait time of ${POLLING_CONFIG.maxTotalTime / 1000} seconds` + }; } if (attempt > 0) { @@ -104,9 +106,9 @@ async function fetchQSOsFromLoTW(lotwUsername, lotwPassword, sinceDate = null) { await sleep(POLLING_CONFIG.retryDelay); continue; } else if (response.status === 401) { - throw new Error('Invalid LoTW credentials. Please check your username and password in Settings.'); + return { error: 'Invalid LoTW credentials. Please check your username and password in Settings.' }; } else if (response.status === 404) { - throw new Error('LoTW service not found (404). The LoTW API URL may have changed.'); + return { error: 'LoTW service not found (404). The LoTW API URL may have changed.' }; } else { logger.warn(`LoTW returned ${response.status}, retrying...`); await sleep(POLLING_CONFIG.retryDelay); @@ -117,7 +119,7 @@ async function fetchQSOsFromLoTW(lotwUsername, lotwPassword, sinceDate = null) { const adifData = await response.text(); if (adifData.toLowerCase().includes('username/password incorrect')) { - throw new Error('Username/password incorrect'); + return { error: 'Username/password incorrect' }; } const header = adifData.trim().substring(0, 39).toLowerCase(); @@ -127,7 +129,8 @@ async function fetchQSOsFromLoTW(lotwUsername, lotwPassword, sinceDate = null) { await sleep(POLLING_CONFIG.retryDelay); continue; } - throw new Error('Downloaded LoTW report is invalid. Check your credentials.'); + + return { error: 'Downloaded LoTW report is invalid. Check your credentials.' }; } logger.info('LoTW report downloaded successfully', { size: adifData.length }); @@ -159,7 +162,9 @@ async function fetchQSOsFromLoTW(lotwUsername, lotwPassword, sinceDate = null) { } const totalTime = Math.round((Date.now() - startTime) / 1000); - throw new Error(`LoTW sync failed: Report not ready after ${POLLING_CONFIG.maxRetries} attempts (${totalTime}s). LoTW may be experiencing high load. Please try again later.`); + return { + error: `LoTW sync failed: Report not ready after ${POLLING_CONFIG.maxRetries} attempts (${totalTime}s). LoTW may be experiencing high load. Please try again later.` + }; } /** diff --git a/src/frontend/bun.lock b/src/frontend/bun.lock index 97b6735..d667c6e 100644 --- a/src/frontend/bun.lock +++ b/src/frontend/bun.lock @@ -6,6 +6,7 @@ "name": "frontend", "devDependencies": { "@sveltejs/adapter-auto": "^7.0.0", + "@sveltejs/adapter-static": "^3.0.10", "@sveltejs/kit": "^2.49.1", "@sveltejs/vite-plugin-svelte": "^6.2.1", "svelte": "^5.45.6", @@ -134,6 +135,8 @@ "@sveltejs/adapter-auto": ["@sveltejs/adapter-auto@7.0.0", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-ImDWaErTOCkRS4Gt+5gZuymKFBobnhChXUZ9lhUZLahUgvA4OOvRzi3sahzYgbxGj5nkA6OV0GAW378+dl/gyw=="], + "@sveltejs/adapter-static": ["@sveltejs/adapter-static@3.0.10", "", { "peerDependencies": { "@sveltejs/kit": "^2.0.0" } }, "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew=="], + "@sveltejs/kit": ["@sveltejs/kit@2.49.4", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@sveltejs/acorn-typescript": "^1.0.5", "@types/cookie": "^0.6.0", "acorn": "^8.14.1", "cookie": "^0.6.0", "devalue": "^5.3.2", "esm-env": "^1.2.2", "kleur": "^4.1.5", "magic-string": "^0.30.5", "mrmime": "^2.0.0", "sade": "^1.8.1", "set-cookie-parser": "^2.6.0", "sirv": "^3.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.0.0", "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", "svelte": "^4.0.0 || ^5.0.0-next.0", "typescript": "^5.3.3", "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" }, "optionalPeers": ["@opentelemetry/api", "typescript"], "bin": { "svelte-kit": "svelte-kit.js" } }, "sha512-JFtOqDoU0DI/+QSG8qnq5bKcehVb3tCHhOG4amsSYth5/KgO4EkJvi42xSAiyKmXAAULW1/Zdb6lkgGEgSxdZg=="], "@sveltejs/vite-plugin-svelte": ["@sveltejs/vite-plugin-svelte@6.2.4", "", { "dependencies": { "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", "deepmerge": "^4.3.1", "magic-string": "^0.30.21", "obug": "^2.1.0", "vitefu": "^1.1.1" }, "peerDependencies": { "svelte": "^5.0.0", "vite": "^6.3.0 || ^7.0.0" } }, "sha512-ou/d51QSdTyN26D7h6dSpusAKaZkAiGM55/AKYi+9AGZw7q85hElbjK3kEyzXHhLSnRISHOYzVge6x0jRZ7DXA=="], diff --git a/src/frontend/package.json b/src/frontend/package.json index f3a1a2c..5ac2231 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -11,6 +11,7 @@ }, "devDependencies": { "@sveltejs/adapter-auto": "^7.0.0", + "@sveltejs/adapter-static": "^3.0.10", "@sveltejs/kit": "^2.49.1", "@sveltejs/vite-plugin-svelte": "^6.2.1", "svelte": "^5.45.6", diff --git a/src/frontend/svelte.config.js b/src/frontend/svelte.config.js index a49aeb5..c4174d1 100644 --- a/src/frontend/svelte.config.js +++ b/src/frontend/svelte.config.js @@ -1,9 +1,15 @@ -import adapter from '@sveltejs/adapter-auto'; +import adapter from '@sveltejs/adapter-static'; /** @type {import('@sveltejs/kit').Config} */ const config = { kit: { - adapter: adapter(), + adapter: adapter({ + pages: 'build', + assets: 'build', + fallback: 'index.html', + precompress: false, + strict: true + }), // Get app URL from environment or default to localhost // This is used for production builds and CSRF configuration // Set via VITE_APP_URL environment variable diff --git a/start.sh b/start.sh new file mode 100755 index 0000000..2f8c6b9 --- /dev/null +++ b/start.sh @@ -0,0 +1,5 @@ +#!/bin/bash +# Production start script +# Run backend server (Elysia errors are harmless warnings that don't affect functionality) + +exec bun src/backend/index.js