From 2c09d32b5bb42c466043a9bdaf1117c2776aceff Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Sun, 18 Jan 2026 16:36:37 +0200 Subject: [PATCH] feat(docker): add robust healthcheck script (#5028) * feat(docker): add robust healthcheck script - Added `docker/healthcheck.cjs`: A standalone, dependency-free Node.js script for verifying server status. - Updated `Dockerfile`: Added HEALTHCHECK instruction and script copy step. - Features: Auto-detects port from env/config, handles IPv4/IPv6 fallback, auto-retries HTTPS on socket hangup, and sets custom User-Agent. * feat(docker): new healthcheck with /api/health endpoint - Added `GET /api/health` endpoint to `server.js` (unauthenticated) for lightweight status checks. - Update `docker/healthcheck.cjs`: Rewrite - Updated Error handle for `HEALTHCHECK` * feat(docker): switch to heartbeat file healthcheck mechanism - Replaced network-based check with a file-based heartbeat approach. - Updated `src/command-line.js`: Added `heartbeatInterval` argument with explicit ENV override (`SILLYTAVERN_HEARTBEAT_INTERVAL`). - Updated `src/server-main.js`: Added logic to write `heartbeat.json` to data directory at set intervals. - Rewrote `docker/healthcheck.cjs`: Script now monitors the heartbeat file timestamp (zero dependencies, no config parsing required). - Updated `Dockerfile`: Sets default heartbeat interval to 30s and ensures script availability. - Updated `config.yaml`: Added `heartbeatInterval` defaulting to 0 (disabled) for non-Docker users. * Fix variable names * Convert to ESM, use serverDirectory variable * Move file to /src * fix: update heartbeat path to use global DATA_ROOT variable * Pretty colors * Move healthcheck to docker-compose.yml * Comment fixed * Even cleaner diff! --------- Co-authored-by: Pavdig <101715456+Pavdig@users.noreply.github.com> --- default/config.yaml | 3 +++ docker/docker-compose.yml | 7 +++++++ src/command-line.js | 8 ++++++++ src/healthcheck.js | 40 +++++++++++++++++++++++++++++++++++++++ src/server-main.js | 23 ++++++++++++++++++++++ 5 files changed, 81 insertions(+) create mode 100644 src/healthcheck.js diff --git a/default/config.yaml b/default/config.yaml index 14bccb3ba..d99ca0e98 100644 --- a/default/config.yaml +++ b/default/config.yaml @@ -38,6 +38,9 @@ browserLaunch: avoidLocalhost: false # Server port port: 8000 +# Interval in seconds to write a heartbeat file. Set to 0 to disable. +# This is used primarily for Docker healthchecks. +heartbeatInterval: 0 # -- SSL options -- ssl: # Enable SSL/TLS encryption diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 74b749c48..1cafe6ceb 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -7,6 +7,7 @@ services: environment: - NODE_ENV=production - FORCE_COLOR=1 + - SILLYTAVERN_HEARTBEATINTERVAL=30 ports: - "8000:8000" volumes: @@ -14,4 +15,10 @@ services: - "./data:/home/node/app/data" - "./plugins:/home/node/app/plugins" - "./extensions:/home/node/app/public/scripts/extensions/third-party" + healthcheck: + test: ["CMD", "node", "src/healthcheck.js"] + interval: 30s + timeout: 10s + start_period: 20s + retries: 3 restart: unless-stopped diff --git a/src/command-line.js b/src/command-line.js index 1ee73e840..6540397ff 100644 --- a/src/command-line.js +++ b/src/command-line.js @@ -18,6 +18,7 @@ import { initConfig } from './config-init.js'; * @property {boolean|string} enableIPv4 If enable IPv4 protocol ("auto" is also allowed) * @property {boolean|string} enableIPv6 If enable IPv6 protocol ("auto" is also allowed) * @property {boolean} dnsPreferIPv6 If prefer IPv6 for DNS + * @property {number} heartbeatInterval Interval in seconds to write a heartbeat file. 0 to disable. * @property {boolean} browserLaunchEnabled If automatically launch SillyTavern in the browser * @property {string} browserLaunchHostname Browser launch hostname * @property {number} browserLaunchPort Browser launch port override (-1 is use server port) @@ -62,6 +63,7 @@ export class CommandLineParser { enableIPv4: true, enableIPv6: false, dnsPreferIPv6: false, + heartbeatInterval: 0, browserLaunchEnabled: false, browserLaunchHostname: 'auto', browserLaunchPort: -1, @@ -229,6 +231,11 @@ export class CommandLineParser { type: 'array', describe: 'Request proxy bypass list (space separated list of hosts)', }) + .option('heartbeatInterval', { + type: 'number', + default: null, + describe: 'Interval in seconds to write a heartbeat file. 0 to disable.', + }) /* DEPRECATED options */ .option('autorun', { type: 'boolean', @@ -289,6 +296,7 @@ export class CommandLineParser { enableIPv4: stringToBool(cliArguments.enableIPv4) ?? stringToBool(getConfigValue('protocol.ipv4', defaultConfig.enableIPv4)) ?? defaultConfig.enableIPv4, enableIPv6: stringToBool(cliArguments.enableIPv6) ?? stringToBool(getConfigValue('protocol.ipv6', defaultConfig.enableIPv6)) ?? defaultConfig.enableIPv6, dnsPreferIPv6: cliArguments.dnsPreferIPv6 ?? getConfigValue('dnsPreferIPv6', defaultConfig.dnsPreferIPv6, 'boolean'), + heartbeatInterval: cliArguments.heartbeatInterval ?? getConfigValue('heartbeatInterval', defaultConfig.heartbeatInterval, 'number'), browserLaunchEnabled: cliArguments.browserLaunchEnabled ?? cliArguments.autorun ?? getConfigValue('browserLaunch.enabled', defaultConfig.browserLaunchEnabled, 'boolean'), browserLaunchHostname: cliArguments.browserLaunchHostname ?? cliArguments.autorunHostname ?? getConfigValue('browserLaunch.hostname', defaultConfig.browserLaunchHostname), browserLaunchPort: cliArguments.browserLaunchPort ?? cliArguments.autorunPortOverride ?? getConfigValue('browserLaunch.port', defaultConfig.browserLaunchPort, 'number'), diff --git a/src/healthcheck.js b/src/healthcheck.js new file mode 100644 index 000000000..d2fe1ecb6 --- /dev/null +++ b/src/healthcheck.js @@ -0,0 +1,40 @@ +import fs from 'fs'; +import path from 'path'; +import { serverDirectory } from './server-directory.js'; + +// Default to 0 seconds (disabled) if not set +const intervalSeconds = parseInt(process.env.SILLYTAVERN_HEARTBEATINTERVAL || '0'); +const intervalMs = intervalSeconds * 1000; + +// Heartbeat disabled +if (Number.isNaN(intervalSeconds) || intervalSeconds <= 0) { + process.exit(0); +} + +// Allow a grace period (2 missed beats) +const threshold = intervalMs * 2; + +const dataRoot = process.env.SILLYTAVERN_DATAROOT || path.join(serverDirectory, 'data'); +const heartbeatFile = path.join(dataRoot, 'heartbeat.json'); + +try { + if (!fs.existsSync(heartbeatFile)) { + console.error(`Heartbeat file not found at: ${heartbeatFile}`); + process.exit(1); + } + + const stats = fs.statSync(heartbeatFile); + const lastModified = stats.mtimeMs; + const now = Date.now(); + const diff = now - lastModified; + + if (diff > threshold) { + console.error(`Server is unresponsive. Last heartbeat was ${Math.round(diff / 1000)} seconds ago.`); + process.exit(1); + } + + process.exit(0); +} catch (err) { + console.error('Healthcheck error:', err.message); + process.exit(1); +} diff --git a/src/server-main.js b/src/server-main.js index 2d5e14d5f..23c78292d 100644 --- a/src/server-main.js +++ b/src/server-main.js @@ -1,4 +1,5 @@ // native node modules +import fs from 'node:fs'; import path from 'node:path'; import util from 'node:util'; import net from 'node:net'; @@ -349,6 +350,28 @@ async function postSetupTasks(result) { } } + if (cliArgs.heartbeatInterval > 0) { + // Convert seconds to milliseconds for the timer + const intervalMs = cliArgs.heartbeatInterval * 1000; + const heartbeatPath = path.join(globalThis.DATA_ROOT, 'heartbeat.json'); + + console.log(`Heartbeat enabled. Updating ${color.green(heartbeatPath)} every ${cliArgs.heartbeatInterval} seconds`); + + const writeHeartbeat = () => { + try { + fs.writeFileSync(heartbeatPath, JSON.stringify({ timestamp: Date.now() })); + } catch (err) { + console.error(`Failed to write heartbeat file at ${color.green(heartbeatPath)}:`, err.message); + } + }; + + // Write immediately + writeHeartbeat(); + + // Loop using the converted milliseconds + setInterval(writeHeartbeat, intervalMs).unref(); + } + setWindowTitle('SillyTavern WebServer'); let logListen = 'SillyTavern is listening on';