gitea-sheets-sync/src/server.js
Othmane Ataallah 2cc258daba release(v1.0)
2026-05-03 14:28:21 +01:00

187 lines
5.8 KiB
JavaScript

import dotenv from "dotenv";
dotenv.config();
import express from "express";
import morgan from "morgan";
import fs from "fs";
import path from "path";
import giteaWebhook from "./routes/giteaWebhook.js";
import issueProcessor, { isRunning as workerIsRunning } from "./workers/issueProcessor.js";
import errorHandler from "./middleware/errorHandler.js";
import logger from "./lib/logger.js";
import config from "./config/index.js";
import sheetsAdapter from "./adapters/sheetsAdapter.js";
const app = express();
// use morgan but route logs through structured logger
app.use(morgan("combined", { stream: { write: (s) => logger.info(s.trim()) } }));
// capture raw body for webhook signature verification on the gitea webhook route
app.use(
"/webhooks/gitea",
express.json({ verify: (req, res, buf) => (req.rawBody = buf) })
);
// regular JSON parser for other routes
app.use(express.json());
app.use("/webhooks/gitea", giteaWebhook);
app.get("/health", (req, res) => {
res.json({ status: "ok", uptime: process.uptime(), pid: process.pid, timestamp: new Date().toISOString() });
});
app.get("/ready", async (req, res) => {
const missing = [];
// config checks
if (!config.giteaWebhookSecret) missing.push("GITEA_WEBHOOK_SECRET");
if (!config.googleSheetId) missing.push("GOOGLE_SHEET_ID");
const hasGoogleCreds = !!(
process.env.GOOGLE_SERVICE_ACCOUNT_JSON ||
process.env.GOOGLE_APPLICATION_CREDENTIALS ||
(process.env.GOOGLE_PRIVATE_KEY && process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL) ||
fs.existsSync(path.resolve(process.cwd(), "service-account.json"))
);
if (!hasGoogleCreds) missing.push("Google service account credentials");
if (missing.length) {
logger.warn("Readiness check failed (missing config)", { missing });
return res.status(503).json({ ready: false, missing });
}
// active adapter readiness probe
try {
const ok = await sheetsAdapter.readinessProbe();
if (!ok) {
logger.warn("Readiness probe: sheets adapter not ready");
return res.status(503).json({ ready: false, missing: ["google_sheets_unreachable"] });
}
} catch (err) {
logger.warn("Readiness probe error", { err: err && err.message ? err.message : err });
return res.status(503).json({ ready: false, missing: ["google_sheets_unreachable"] });
}
// worker status
if (!workerIsRunning()) {
logger.warn("Readiness check failed: worker not running");
return res.status(503).json({ ready: false, missing: ["worker_not_running"] });
}
return res.json({ ready: true, uptime: process.uptime(), timestamp: new Date().toISOString() });
});
const PORT = config.port;
function validateConfig() {
const missing = [];
if (!config.giteaWebhookSecret) missing.push("GITEA_WEBHOOK_SECRET");
if (!config.googleSheetId) missing.push("GOOGLE_SHEET_ID");
const hasGoogleCreds = !!(
process.env.GOOGLE_SERVICE_ACCOUNT_JSON ||
process.env.GOOGLE_APPLICATION_CREDENTIALS ||
(process.env.GOOGLE_PRIVATE_KEY && process.env.GOOGLE_SERVICE_ACCOUNT_EMAIL) ||
fs.existsSync(path.resolve(process.cwd(), "service-account.json"))
);
if (!hasGoogleCreds) missing.push("Google service account credentials");
if (missing.length) {
logger.warn("Startup config warning: missing env vars:", { missing });
logger.warn("See SECRETS.md for secure configuration instructions.");
}
}
validateConfig();
// Start background workers (no business logic implemented yet)
issueProcessor.start();
// Central error handler
app.use(errorHandler);
const server = app.listen(PORT, "0.0.0.0", () => {
logger.info("Server running on port", { port: PORT });
});
// track active connections so we can force-close them on shutdown
const connections = new Set();
server.on("connection", (socket) => {
connections.add(socket);
socket.on("close", () => connections.delete(socket));
});
const SHUTDOWN_GRACE_MS = process.env.SHUTDOWN_GRACE_MS ? Number(process.env.SHUTDOWN_GRACE_MS) : 10000;
let shuttingDown = false;
async function shutdown(signal) {
if (shuttingDown) return;
shuttingDown = true;
logger.info("Shutdown initiated", { signal });
// stop accepting new connections
const closeServer = new Promise((resolve) => {
try {
server.close((err) => {
if (err) logger.warn("Error closing server", { err: err && err.message ? err.message : err });
else logger.info("HTTP server closed");
resolve();
});
} catch (e) {
logger.warn("Error during server.close", { err: e && e.message ? e.message : e });
resolve();
}
});
// schedule forced socket destruction after grace period
const forceTimeout = setTimeout(() => {
logger.warn("Forcing destruction of active connections after grace period");
for (const s of connections) {
try {
s.destroy();
} catch (e) {
// ignore
}
}
}, SHUTDOWN_GRACE_MS);
await closeServer;
// stop workers and drain adapter queues
try {
await issueProcessor.stop(SHUTDOWN_GRACE_MS);
} catch (e) {
logger.warn("Error stopping workers", { err: e && e.message ? e.message : e });
}
// final attempt to wait for adapter
try {
await sheetsAdapter.waitForIdle(SHUTDOWN_GRACE_MS);
} catch (e) {
logger.warn("Timeout waiting for adapter to become idle", { err: e && e.message ? e.message : e });
}
clearTimeout(forceTimeout);
logger.info("Shutdown complete, exiting");
// give a moment for logs to flush
setTimeout(() => process.exit(0), 100);
}
process.on("SIGINT", () => shutdown("SIGINT"));
process.on("SIGTERM", () => shutdown("SIGTERM"));
process.on("unhandledRejection", (reason) => {
logger.error("UnhandledRejection", { reason });
shutdown("unhandledRejection");
});
process.on("uncaughtException", (err) => {
logger.error("UncaughtException", { err: err && err.stack ? err.stack : err });
shutdown("uncaughtException");
});