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';