182 lines
5.9 KiB
JavaScript
182 lines
5.9 KiB
JavaScript
import express from 'express';
|
|
import compression from 'compression';
|
|
import { exec } from 'child_process';
|
|
import { promisify } from 'util';
|
|
import { existsSync, mkdirSync, cpSync, rmSync, readdirSync, readFileSync } from 'fs';
|
|
import { join, dirname } from 'path';
|
|
import { fileURLToPath } from 'url';
|
|
|
|
const execAsync = promisify(exec);
|
|
|
|
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;
|
|
}
|
|
|
|
async function run(cmd, opts = {}) {
|
|
log(`> ${cmd}`);
|
|
const { stdout, stderr } = await execAsync(cmd, { timeout: 600_000, maxBuffer: 50 * 1024 * 1024, ...opts });
|
|
if (stdout) process.stdout.write(stdout);
|
|
if (stderr) process.stderr.write(stderr);
|
|
}
|
|
|
|
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
|
|
await run(`git clone --depth 1 --branch ${GIT_BRANCH} ${GIT_REPO_URL} ${buildDir}`);
|
|
|
|
// Read the cloned commit hash
|
|
const { stdout: hashOut } = await execAsync('git rev-parse HEAD', { cwd: buildDir });
|
|
const clonedHash = hashOut.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)
|
|
await 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 };
|
|
await 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();
|
|
}
|
|
});
|