Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 5 additions & 8 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Stage 1: Build the application
FROM node:22.21.1-bookworm-slim AS builder
FROM node:22.21.1-alpine3.23 AS builder

WORKDIR /app

Expand All @@ -14,13 +14,9 @@ COPY entry.sh ./
RUN npm run build

# Stage 2: Create the production image
FROM node:22.21.1-bookworm-slim
FROM node:22.21.1-alpine3.23

RUN apt-get update && \
apt-get install -yqq --no-install-recommends wget && \
apt-get autoremove && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
RUN apk add --no-cache wget

WORKDIR /app

Expand All @@ -35,4 +31,5 @@ HEALTHCHECK --interval=30s \

EXPOSE 3000

CMD ["/entry.sh"]
CMD ["npm", "run", "start"]
ENTRYPOINT ["/entry.sh"]
91 changes: 89 additions & 2 deletions backend/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ const TaskScheduler = require("./classes/task-scheduler-singleton");
// const tasks = require("./tasks/tasks");

// websocket
const { setupWebSocketServer } = require("./ws");
const { setupWebSocketServer, shutdownWebSocketServer } = require("./ws");
const writeEnvVariables = require("./classes/env");

process.env.POSTGRES_USER = process.env.POSTGRES_USER ?? "postgres";
Expand Down Expand Up @@ -231,6 +231,7 @@ async function authenticate(req, res, next) {
}

// start server
let server;
try {
createdb.createDatabase().then((result) => {
if (result) {
Expand All @@ -240,7 +241,7 @@ try {
}

db.migrate.latest().then(() => {
const server = http.createServer(app);
server = http.createServer(app);

setupWebSocketServer(server, BASE_NAME);
server.listen(PORT, LISTEN_IP, async () => {
Expand All @@ -255,3 +256,89 @@ try {
} catch (error) {
console.log("[JELLYSTAT] An error has occured on startup: " + error);
}

function runStep(name, fn) {
return (async () => {
console.log(`[JELLYSTAT] Beginnging shutdown step ${name}`);
try {
await fn();
console.log(`[JELLYSTAT] Shutdown step ${name} complete.`);
} catch (err) {
console.error(`[JELLYSTAT] Shutdown step ${name} failed.`, err);
throw err;
}
})();
}

function shutdownKnex() {
if (db && typeof db.destroy === "function") {
console.info("[JELLYSTAT] Destroying knex connection.");
return db.destroy();
}

return Promise.resolve();
}

function shutdownHttpServer() {
return new Promise((resolve, reject) => {
// Stop new connections
server.close((err) => {
if (err) return reject(err);
resolve();
});

if (typeof server.closeIdleConnections === "function") {
console.info(`[JELLYSTAT] Closing idle HTTP connections`);
server.closeIdleConnections();
}
});
}

function withTimeout(promise, ms, label = "shutdown") {
let timeoutId;

const timeoutPromise = new Promise((_, reject) => {
timeoutId = setTimeout(() => {
reject(new Error(`[JELLYSTAT] ${label} timed out after ${ms}ms`));
}, ms);
});

return Promise.race([promise, timeoutPromise]).finally(() => {
clearTimeout(timeoutId);
});
}

// shutdown server
let shuttingDown = false;
async function shutdown(signal) {
if (shuttingDown) return;
shuttingDown = true;

console.log(`[JELLYSTAT] Recieved ${signal}, beginning shutdown process`);

const timeLimit = 9000;

const shutdownTasks = [
runStep("httpServer", async () => shutdownHttpServer(signal)),
runStep("webSocketServer", async () => shutdownWebSocketServer(signal)),
runStep("database", async () => shutdownKnex()),
];

try {
await withTimeout(
Promise.allSettled(shutdownTasks),
timeLimit,
"graceful shutdown"
);

console.log("[JELLYSTAT] Graceful shutdown complete");
process.exit(0);
} catch (err) {
console.error(`[JELLYSTAT] Error occurred while attempting to gracefully shut down.`, err);
process.exit(1);
}
}

// register signal handlers
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
4 changes: 4 additions & 0 deletions backend/socket-io-client.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ class SocketIoClient {
this.client = io(this.serverUrl, this.options); // Pass options to io()
}

disconnect() {
this.client?.disconnect();
}

waitForConnection() {
return new Promise((resolve) => {
if (this.client && this.client.connected) {
Expand Down
21 changes: 20 additions & 1 deletion backend/ws.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const jwt = require("jsonwebtoken");
const token = jwt.sign({ user: "internal" }, process.env.JWT_SECRET);
const socketClient = new SocketIoClient("http://127.0.0.1:3000", { auth: { token } });
let io; // Store the socket.io server instance
let shuttingDown = false;
const JWT_SECRET = process.env.JWT_SECRET;

const setupWebSocketServer = (server, namespacePath) => {
Expand Down Expand Up @@ -46,6 +47,24 @@ const setupWebSocketServer = (server, namespacePath) => {
webSocketServerSingleton.setInstance(io);
};

const shutdownWebSocketServer = async (signal) => {
if (!io) return;

try {
io.emit("server_shutdown", { reason: signal });

socketClient?.disconnect();

return new Promise((resolve) => {
io.close(() => {
resolve();
});
});
} catch (err) {
console.error("[JELLYSTAT] WebSocket shutdown error. ", err);
}
}

const sendToAllClients = (message) => {
const ioInstance = webSocketServerSingleton.getInstance();
if (ioInstance) {
Expand All @@ -71,4 +90,4 @@ const sendUpdate = async (tag, message) => {
}
};

module.exports = { setupWebSocketServer, sendToAllClients, sendUpdate };
module.exports = { setupWebSocketServer, sendToAllClients, sendUpdate, shutdownWebSocketServer };
2 changes: 1 addition & 1 deletion entry.sh
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,4 @@ load_secrets() {
# Load secrets
load_secrets
# Launch Jellystat
npm run start
exec "$@"