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:
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.`
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user