import express from 'express'; import compression from 'compression'; import { execSync } from 'child_process'; import { existsSync, mkdirSync, cpSync, rmSync, readdirSync, readFileSync } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const PORT = process.env.PORT || 3000; const GIT_REPO_URL = process.env.GIT_REPO_URL; const GIT_BRANCH = process.env.GIT_BRANCH || 'main'; const VITE_CONVEX_URL = process.env.VITE_CONVEX_URL; const VITE_LIVEKIT_URL = process.env.VITE_LIVEKIT_URL; const RELEASES_DIR = '/app/releases'; const LOADING_HTML = readFileSync(join(__dirname, 'loading.html'), 'utf-8'); let currentDistPath = null; let currentCommitHash = null; let building = false; let lastBuildStatus = { status: 'idle', timestamp: null, error: null }; // --- Helpers --- function log(msg) { console.log(`[${new Date().toISOString()}] ${msg}`); } function findLatestRelease() { if (!existsSync(RELEASES_DIR)) return null; const entries = readdirSync(RELEASES_DIR).sort(); if (entries.length === 0) return null; const latest = join(RELEASES_DIR, entries[entries.length - 1]); if (existsSync(join(latest, 'index.html'))) return latest; return null; } function run(cmd, opts = {}) { log(`> ${cmd}`); execSync(cmd, { stdio: 'inherit', timeout: 600_000, ...opts }); } async function triggerBuild(webhookCommit) { if (building) return; // Fast path: skip clone entirely if webhook provides the commit and it matches if (webhookCommit && webhookCommit === currentCommitHash) { log(`Commit ${webhookCommit} is already deployed, skipping build (webhook fast path).`); return; } building = true; lastBuildStatus = { status: 'building', timestamp: Date.now(), error: null }; const timestamp = Date.now().toString(); const buildDir = `/tmp/build-${timestamp}`; const releaseDir = join(RELEASES_DIR, timestamp); try { log('Starting build...'); // Clone run(`git clone --depth 1 --branch ${GIT_BRANCH} ${GIT_REPO_URL} ${buildDir}`); // Read the cloned commit hash const clonedHash = execSync('git rev-parse HEAD', { cwd: buildDir, encoding: 'utf-8' }).trim(); log(`Cloned commit: ${clonedHash}`); // Skip if this commit is already deployed if (clonedHash === currentCommitHash) { log(`Commit ${clonedHash} is already deployed, skipping build.`); lastBuildStatus = { status: 'skipped', timestamp: Date.now(), error: null }; return; } // Install deps (web workspaces only) run( 'npm ci --workspace=apps/web --workspace=packages/shared --workspace=packages/platform-web --include-workspace-root', { cwd: buildDir } ); // Build const env = { ...process.env, VITE_CONVEX_URL, VITE_LIVEKIT_URL }; run('npm run build:web', { cwd: buildDir, env }); // Copy dist to release dir const distDir = join(buildDir, 'apps', 'web', 'dist'); if (!existsSync(distDir)) throw new Error('Build did not produce apps/web/dist/'); mkdirSync(releaseDir, { recursive: true }); cpSync(distDir, releaseDir, { recursive: true }); // Swap — instant, zero downtime const oldDist = currentDistPath; currentDistPath = releaseDir; currentCommitHash = clonedHash; log(`Swapped to new release: ${releaseDir} (commit ${clonedHash})`); // Clean up old release if (oldDist && existsSync(oldDist) && oldDist !== releaseDir) { rmSync(oldDist, { recursive: true, force: true }); log(`Deleted old release: ${oldDist}`); } lastBuildStatus = { status: 'success', timestamp: Date.now(), error: null }; log('Build complete.'); } catch (err) { lastBuildStatus = { status: 'failed', timestamp: Date.now(), error: err.message }; log(`Build failed: ${err.message}`); // Clean up failed release dir if it was created if (existsSync(releaseDir)) rmSync(releaseDir, { recursive: true, force: true }); } finally { // Always clean up temp build dir if (existsSync(buildDir)) rmSync(buildDir, { recursive: true, force: true }); building = false; } } // --- Express App --- const app = express(); app.use(compression()); // Webhook endpoint app.post('/api/webhook', express.json(), (req, res) => { if (building) return res.status(409).json({ error: 'Build already in progress' }); // Extract commit SHA from Gitea/GitHub push webhook payload const webhookCommit = req.body?.after || null; triggerBuild(webhookCommit); res.json({ message: 'Build triggered' }); }); // Status endpoint app.get('/api/status', (_req, res) => { res.json({ building, commitHash: currentCommitHash, ...lastBuildStatus }); }); // Static file serving + SPA fallback app.use((req, res, next) => { // While no release exists, serve loading page if (!currentDistPath) return res.status(503).type('html').send(LOADING_HTML); // Serve static files from the current release express.static(currentDistPath, { maxAge: req.path.startsWith('/assets/') ? '1y' : 0, immutable: req.path.startsWith('/assets/'), index: false, // We handle index.html ourselves for SPA fallback })(req, res, () => { // SPA fallback: if no static file matched, serve index.html res.sendFile(join(currentDistPath, 'index.html')); }); }); // --- Start --- mkdirSync(RELEASES_DIR, { recursive: true }); // Check for existing release currentDistPath = findLatestRelease(); if (currentDistPath) { log(`Found existing release: ${currentDistPath}`); } else { log('No existing release found, will build on startup.'); } app.listen(PORT, () => { log(`Server listening on port ${PORT}`); // Auto-build on first start if no release exists if (!currentDistPath && GIT_REPO_URL) { triggerBuild(); } });