feat: Single-port deployment with improved error handling and SvelteKit static build

- Frontend now uses @sveltejs/adapter-static for production builds
- Backend serves both API and static files from single port (originally port 3000)
- Removed all throw statements from services to avoid Elysia prototype errors
- Fixed favicon serving and SvelteKit assets path handling
- Added ecosystem.config.js for PM2 process management
- Comprehensive deployment documentation (PM2 + HAProxy)
- Updated README with single-port architecture
- Created start.sh script for easy production start
This commit is contained in:
2026-01-16 13:58:03 +01:00
parent 413a6e9831
commit 907dc48f1b
12 changed files with 683 additions and 132 deletions

View File

@@ -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);

View File

@@ -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<Object>} Created user object (without password)
* @throws {Error} If email already exists
* @returns {Promise<Object|null>} 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

View File

@@ -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;

View File

@@ -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

View File

@@ -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.`
};
}
/**