feat: podpora high-availability a multi-replica nasazení
- Socket.io Redis adapter pro sdílený stav přes repliky - graceful shutdown serveru - WATCH/MULTI v updateData pro race-condition-safe aktualizace - lease mechanismus pro push reminder (zabrání duplicitnímu odesílání) - k8s/ manifesty pro testovací kind cluster - Dockerfile: opraven EXPOSE port na 3001 - .gitignore: ignorovány Claude pracovní soubory
This commit is contained in:
+79
-34
@@ -9,9 +9,11 @@ import { getQr } from "./qr";
|
||||
import { generateToken, getLogin, verify } from "./auth";
|
||||
import { getIsWeekend, InsufficientPermissions, PizzaDayConflictError, parseToken } from "./utils";
|
||||
import { getPendingQrs } from "./pizza";
|
||||
import { initWebsocket, getWebsocket } from "./websocket";
|
||||
import { startReminderScheduler, verifyQuickChoiceToken } from "./pushReminder";
|
||||
import { initWebsocket, initRedisAdapter, shutdownWebsocketClients, getWebsocket } from "./websocket";
|
||||
import { startReminderScheduler, stopReminderScheduler, releaseReminderLease, verifyQuickChoiceToken } from "./pushReminder";
|
||||
import { storageReady } from "./storage";
|
||||
import getStorage from "./storage";
|
||||
import { shutdownRedisStorage } from "./storage/redis";
|
||||
import pizzaDayRoutes from "./routes/pizzaDayRoutes";
|
||||
import foodRoutes, { refreshMetoda } from "./routes/foodRoutes";
|
||||
import suggestionRoutes from "./routes/suggestionRoutes";
|
||||
@@ -27,23 +29,24 @@ import storeRoutes from "./routes/storeRoutes";
|
||||
const ENVIRONMENT = process.env.NODE_ENV ?? 'production';
|
||||
dotenv.config({ path: path.resolve(__dirname, `../.env.${ENVIRONMENT}`) });
|
||||
|
||||
// Validace nastavení JWT tokenu - nemá bez něj smysl vůbec povolit server spustit
|
||||
if (!process.env.JWT_SECRET) {
|
||||
throw new Error("Není vyplněna proměnná prostředí JWT_SECRET");
|
||||
}
|
||||
|
||||
const app = express();
|
||||
const server = require("http").createServer(app);
|
||||
|
||||
// Tune keep-alive timeouts to outlive Traefik's 60s idle timeout.
|
||||
// headersTimeout must be strictly greater than keepAliveTimeout.
|
||||
server.keepAliveTimeout = 65_000;
|
||||
server.headersTimeout = 66_000;
|
||||
server.requestTimeout = 30_000;
|
||||
|
||||
initWebsocket(server);
|
||||
|
||||
// Body-parser middleware for parsing JSON
|
||||
app.use(bodyParser.json());
|
||||
app.use(cors({ origin: '*' }));
|
||||
|
||||
app.use(cors({
|
||||
origin: '*'
|
||||
}));
|
||||
|
||||
// Zapínatelný login přes hlavičky - pokud je zapnutý nepovolí "basicauth"
|
||||
const HTTP_REMOTE_USER_ENABLED = process.env.HTTP_REMOTE_USER_ENABLED === 'true' || false;
|
||||
const HTTP_REMOTE_USER_HEADER_NAME = process.env.HTTP_REMOTE_USER_HEADER_NAME ?? 'remote-user';
|
||||
if (HTTP_REMOTE_USER_ENABLED) {
|
||||
@@ -51,19 +54,69 @@ if (HTTP_REMOTE_USER_ENABLED) {
|
||||
throw new Error('Je zapnutý login z hlaviček, ale není nastaven rozsah adres ze kterých hlavička může přijít.');
|
||||
}
|
||||
const HTTP_REMOTE_TRUSTED_IPS = process.env.HTTP_REMOTE_TRUSTED_IPS.split(',').map(ip => ip.trim());
|
||||
//TODO: nevim jak udelat console.log pouze pro "debug"
|
||||
//console.log("Budu věřit hlavičkám z: " + HTTP_REMOTE_TRUSTED_IPS);
|
||||
app.set('trust proxy', HTTP_REMOTE_TRUSTED_IPS);
|
||||
console.log('Zapnutý login přes hlavičky z proxy.');
|
||||
}
|
||||
|
||||
// ─── Shutdown state ──────────────────────────────────────────────────────────
|
||||
|
||||
// ----------- Metody nevyžadující token --------------
|
||||
let shuttingDown = false;
|
||||
|
||||
async function shutdown(signal: string) {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
console.log(`${signal} received — initiating graceful shutdown`);
|
||||
|
||||
// Hard-exit failsafe: fires before terminationGracePeriodSeconds (30s)
|
||||
setTimeout(() => {
|
||||
console.error('Graceful shutdown timed out, forcing exit');
|
||||
process.exit(1);
|
||||
}, 25_000).unref();
|
||||
|
||||
// Disconnect WebSocket clients so they reconnect to another pod
|
||||
const io = getWebsocket();
|
||||
io?.disconnectSockets(true);
|
||||
|
||||
// Stop accepting new HTTP connections and drain in-flight requests
|
||||
(server as any).closeIdleConnections?.();
|
||||
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
|
||||
// Stop reminder scheduler and release leader lease
|
||||
stopReminderScheduler();
|
||||
await releaseReminderLease();
|
||||
|
||||
// Shut down Redis pub/sub clients (Socket.io adapter)
|
||||
await shutdownWebsocketClients();
|
||||
|
||||
// Shut down main Redis storage client
|
||||
if (process.env.STORAGE?.toLowerCase() === 'redis') {
|
||||
await shutdownRedisStorage();
|
||||
}
|
||||
|
||||
console.log('Graceful shutdown complete');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
|
||||
// ─── Routes — no auth required ───────────────────────────────────────────────
|
||||
|
||||
/** Liveness probe — cheap, no external deps. */
|
||||
app.get("/api/health", (_req, res) => {
|
||||
res.status(200).json({ ok: true });
|
||||
});
|
||||
|
||||
/** Readiness probe — verifies Redis connectivity and rejects traffic during shutdown. */
|
||||
app.get("/api/health/ready", async (_req, res) => {
|
||||
if (shuttingDown) {
|
||||
return res.status(503).json({ ok: false, reason: 'shutting down' });
|
||||
}
|
||||
const healthy = await getStorage().healthCheck?.() ?? true;
|
||||
if (!healthy) return res.status(503).json({ ok: false, reason: 'storage unavailable' });
|
||||
res.status(200).json({ ok: true });
|
||||
});
|
||||
|
||||
app.get("/api/whoami", (req, res) => {
|
||||
if (!HTTP_REMOTE_USER_ENABLED) {
|
||||
res.status(403).json({ error: 'Není zapnuté přihlášení z hlaviček' });
|
||||
@@ -76,21 +129,17 @@ app.get("/api/whoami", (req, res) => {
|
||||
})
|
||||
|
||||
app.post("/api/login", (req, res) => {
|
||||
if (HTTP_REMOTE_USER_ENABLED) { // je rovno app.enabled('trust proxy')
|
||||
// Autentizace pomocí trusted headers
|
||||
if (HTTP_REMOTE_USER_ENABLED) {
|
||||
const remoteUser = req.header(HTTP_REMOTE_USER_HEADER_NAME);
|
||||
//const remoteName = req.header('remote-name');
|
||||
if (remoteUser && remoteUser.length > 0) {
|
||||
res.status(200).json(generateToken(Buffer.from(remoteUser, 'latin1').toString(), true));
|
||||
} else {
|
||||
throw new Error("Je zapnuto přihlášení přes hlavičky, ale nepřišla hlavička nebo ??");
|
||||
}
|
||||
} else {
|
||||
// Klasická autentizace loginem
|
||||
if (!req.body?.login || req.body.login.trim().length === 0) {
|
||||
throw new Error("Nebyl předán login");
|
||||
}
|
||||
// TODO zavést podmínky pro délku loginu (min i max)
|
||||
res.status(200).json(generateToken(req.body.login, false));
|
||||
}
|
||||
});
|
||||
@@ -111,12 +160,10 @@ app.get("/api/qr", async (req, res) => {
|
||||
res.end(img);
|
||||
});
|
||||
|
||||
// ----------------------------------------------------
|
||||
// ─── Semi-public routes ───────────────────────────────────────────────────────
|
||||
|
||||
// Přeskočení auth pro refresh dat xd
|
||||
app.use("/api/food/refresh", refreshMetoda);
|
||||
|
||||
// Rychlá akce z push notifikace — autentizace pomocí HMAC tokenu z push payloadu (SW nemá přístup k JWT)
|
||||
app.post("/api/notifications/push/quickChoice", async (req, res, next) => {
|
||||
try {
|
||||
const { login, token } = req.body ?? {};
|
||||
@@ -132,10 +179,10 @@ app.post("/api/notifications/push/quickChoice", async (req, res, next) => {
|
||||
} catch (e: any) { next(e); }
|
||||
});
|
||||
|
||||
/** Middleware ověřující JWT token */
|
||||
// ─── Auth middleware ──────────────────────────────────────────────────────────
|
||||
|
||||
app.use("/api/", (req, res, next) => {
|
||||
if (HTTP_REMOTE_USER_ENABLED) {
|
||||
// Autentizace pomocí trusted headers
|
||||
const remoteUser = req.header(HTTP_REMOTE_USER_HEADER_NAME);
|
||||
if (process.env.ENABLE_HEADERS_LOGGING === 'yes') {
|
||||
delete req.headers["cookie"]
|
||||
@@ -158,7 +205,8 @@ app.use("/api/", (req, res, next) => {
|
||||
next();
|
||||
});
|
||||
|
||||
/** Vrátí data pro aktuální den. */
|
||||
// ─── Authenticated routes ─────────────────────────────────────────────────────
|
||||
|
||||
app.get("/api/data", async (req, res) => {
|
||||
let date = undefined;
|
||||
if (req.query.date != null && typeof req.query.date === 'string') {
|
||||
@@ -175,7 +223,6 @@ app.get("/api/data", async (req, res) => {
|
||||
date = getDateForWeekIndex(parseInt(req.query.dayIndex));
|
||||
}
|
||||
} else if (getIsWeekend(getToday())) {
|
||||
// Na víkendu zobrazíme pátek místo hlášky "Užívejte víkend"
|
||||
date = getDateForWeekIndex(4);
|
||||
}
|
||||
const slotParam = typeof req.query.slot === 'string' ? req.query.slot as MealSlot : undefined;
|
||||
@@ -183,7 +230,6 @@ app.get("/api/data", async (req, res) => {
|
||||
return res.status(400).json({ error: 'Neplatný slot' });
|
||||
}
|
||||
const data = await getData(date, slotParam);
|
||||
// Připojíme nevyřízené QR kódy pro přihlášeného uživatele
|
||||
try {
|
||||
const login = getLogin(parseToken(req));
|
||||
const pendingQrs = await getPendingQrs(login);
|
||||
@@ -196,7 +242,6 @@ app.get("/api/data", async (req, res) => {
|
||||
res.status(200).json(data);
|
||||
});
|
||||
|
||||
// Ostatní routes
|
||||
app.use("/api/pizzaDay", pizzaDayRoutes);
|
||||
app.use("/api/food", foodRoutes);
|
||||
app.use("/api/suggestions", suggestionRoutes);
|
||||
@@ -214,7 +259,7 @@ app.get('*splat', (_req, res) => {
|
||||
res.sendFile(path.join(process.cwd(), 'public', 'index.html'));
|
||||
});
|
||||
|
||||
// Middleware pro zpracování chyb
|
||||
// Error handling middleware
|
||||
app.use((err: any, req: any, res: any, next: any) => {
|
||||
if (err instanceof InsufficientPermissions) {
|
||||
res.status(403).send({ error: err.message })
|
||||
@@ -226,18 +271,18 @@ app.use((err: any, req: any, res: any, next: any) => {
|
||||
next();
|
||||
});
|
||||
|
||||
// ─── Bootstrap ────────────────────────────────────────────────────────────────
|
||||
|
||||
const PORT = process.env.PORT ?? 3001;
|
||||
const HOST = process.env.HOST ?? '0.0.0.0';
|
||||
|
||||
storageReady.then(() => {
|
||||
storageReady.then(async () => {
|
||||
// Init Redis adapter after storage is connected (only in Redis mode)
|
||||
if (process.env.STORAGE?.toLowerCase() === 'redis') {
|
||||
await initRedisAdapter();
|
||||
}
|
||||
server.listen(PORT, () => {
|
||||
console.log(`Server listening on ${HOST}, port ${PORT}`);
|
||||
startReminderScheduler();
|
||||
});
|
||||
});
|
||||
|
||||
// Umožníme vypnutí serveru přes SIGINT, jinak Docker čeká než ho sestřelí
|
||||
process.on('SIGINT', function () {
|
||||
console.log("\nSIGINT (Ctrl-C), vypínám server");
|
||||
process.exit(0);
|
||||
});
|
||||
Reference in New Issue
Block a user