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>

2c09d32b5bb42c466043a9bdaf1117c2776aceff

Cohee <18619528+Cohee1207@users.noreply.github.com>

Signed
5 files changed, +81 -0Showing whitespace changes
default/config.yaml+3 -0
@@ -38,6 +38,9 @@ browserLaunch:
3838 avoidLocalhost: false
3939# Server port
4040port: 8000
41+# Interval in seconds to write a heartbeat file. Set to 0 to disable.
42+# This is used primarily for Docker healthchecks.
43+heartbeatInterval: 0
4144# -- SSL options --
4245ssl:
4346 # Enable SSL/TLS encryption
docker/docker-compose.yml+7 -0
@@ -7,6 +7,7 @@ services:
77 environment:
88 - NODE_ENV=production
99 - FORCE_COLOR=1
10+ - SILLYTAVERN_HEARTBEATINTERVAL=30
1011 ports:
1112 - "8000:8000"
1213 volumes:
@@ -14,4 +15,10 @@ services:
1415 - "./data:/home/node/app/data"
1516 - "./plugins:/home/node/app/plugins"
1617 - "./extensions:/home/node/app/public/scripts/extensions/third-party"
18+ healthcheck:
19+ test: ["CMD", "node", "src/healthcheck.js"]
20+ interval: 30s
21+ timeout: 10s
22+ start_period: 20s
23+ retries: 3
1724 restart: unless-stopped
src/command-line.js+8 -0
@@ -18,6 +18,7 @@ import { initConfig } from './config-init.js';
1818 * @property {boolean|string} enableIPv4 If enable IPv4 protocol ("auto" is also allowed)
1919 * @property {boolean|string} enableIPv6 If enable IPv6 protocol ("auto" is also allowed)
2020 * @property {boolean} dnsPreferIPv6 If prefer IPv6 for DNS
21+ * @property {number} heartbeatInterval Interval in seconds to write a heartbeat file. 0 to disable.
2122 * @property {boolean} browserLaunchEnabled If automatically launch SillyTavern in the browser
2223 * @property {string} browserLaunchHostname Browser launch hostname
2324 * @property {number} browserLaunchPort Browser launch port override (-1 is use server port)
@@ -62,6 +63,7 @@ export class CommandLineParser {
6263 enableIPv4: true,
6364 enableIPv6: false,
6465 dnsPreferIPv6: false,
66+ heartbeatInterval: 0,
6567 browserLaunchEnabled: false,
6668 browserLaunchHostname: 'auto',
6769 browserLaunchPort: -1,
@@ -229,6 +231,11 @@ export class CommandLineParser {
229231 type: 'array',
230232 describe: 'Request proxy bypass list (space separated list of hosts)',
231233 })
234+ .option('heartbeatInterval', {
235+ type: 'number',
236+ default: null,
237+ describe: 'Interval in seconds to write a heartbeat file. 0 to disable.',
238+ })
232239 /* DEPRECATED options */
233240 .option('autorun', {
234241 type: 'boolean',
@@ -289,6 +296,7 @@ export class CommandLineParser {
289296 enableIPv4: stringToBool(cliArguments.enableIPv4) ?? stringToBool(getConfigValue('protocol.ipv4', defaultConfig.enableIPv4)) ?? defaultConfig.enableIPv4,
290297 enableIPv6: stringToBool(cliArguments.enableIPv6) ?? stringToBool(getConfigValue('protocol.ipv6', defaultConfig.enableIPv6)) ?? defaultConfig.enableIPv6,
291298 dnsPreferIPv6: cliArguments.dnsPreferIPv6 ?? getConfigValue('dnsPreferIPv6', defaultConfig.dnsPreferIPv6, 'boolean'),
299+ heartbeatInterval: cliArguments.heartbeatInterval ?? getConfigValue('heartbeatInterval', defaultConfig.heartbeatInterval, 'number'),
292300 browserLaunchEnabled: cliArguments.browserLaunchEnabled ?? cliArguments.autorun ?? getConfigValue('browserLaunch.enabled', defaultConfig.browserLaunchEnabled, 'boolean'),
293301 browserLaunchHostname: cliArguments.browserLaunchHostname ?? cliArguments.autorunHostname ?? getConfigValue('browserLaunch.hostname', defaultConfig.browserLaunchHostname),
294302 browserLaunchPort: cliArguments.browserLaunchPort ?? cliArguments.autorunPortOverride ?? getConfigValue('browserLaunch.port', defaultConfig.browserLaunchPort, 'number'),
src/healthcheck.js+40 -0
@@ -0,0 +1,40 @@
1+import fs from 'fs';
2+import path from 'path';
3+import { serverDirectory } from './server-directory.js';
4+
5+// Default to 0 seconds (disabled) if not set
6+const intervalSeconds = parseInt(process.env.SILLYTAVERN_HEARTBEATINTERVAL || '0');
7+const intervalMs = intervalSeconds * 1000;
8+
9+// Heartbeat disabled
10+if (Number.isNaN(intervalSeconds) || intervalSeconds <= 0) {
11+ process.exit(0);
12+}
13+
14+// Allow a grace period (2 missed beats)
15+const threshold = intervalMs * 2;
16+
17+const dataRoot = process.env.SILLYTAVERN_DATAROOT || path.join(serverDirectory, 'data');
18+const heartbeatFile = path.join(dataRoot, 'heartbeat.json');
19+
20+try {
21+ if (!fs.existsSync(heartbeatFile)) {
22+ console.error(`Heartbeat file not found at: ${heartbeatFile}`);
23+ process.exit(1);
24+ }
25+
26+ const stats = fs.statSync(heartbeatFile);
27+ const lastModified = stats.mtimeMs;
28+ const now = Date.now();
29+ const diff = now - lastModified;
30+
31+ if (diff > threshold) {
32+ console.error(`Server is unresponsive. Last heartbeat was ${Math.round(diff / 1000)} seconds ago.`);
33+ process.exit(1);
34+ }
35+
36+ process.exit(0);
37+} catch (err) {
38+ console.error('Healthcheck error:', err.message);
39+ process.exit(1);
40+}
src/server-main.js+23 -0
@@ -1,4 +1,5 @@
11// native node modules
2+import fs from 'node:fs';
23import path from 'node:path';
34import util from 'node:util';
45import net from 'node:net';
@@ -349,6 +350,28 @@ async function postSetupTasks(result) {
349350 }
350351 }
351352
353+ if (cliArgs.heartbeatInterval > 0) {
354+ // Convert seconds to milliseconds for the timer
355+ const intervalMs = cliArgs.heartbeatInterval * 1000;
356+ const heartbeatPath = path.join(globalThis.DATA_ROOT, 'heartbeat.json');
357+
358+ console.log(`Heartbeat enabled. Updating ${color.green(heartbeatPath)} every ${cliArgs.heartbeatInterval} seconds`);
359+
360+ const writeHeartbeat = () => {
361+ try {
362+ fs.writeFileSync(heartbeatPath, JSON.stringify({ timestamp: Date.now() }));
363+ } catch (err) {
364+ console.error(`Failed to write heartbeat file at ${color.green(heartbeatPath)}:`, err.message);
365+ }
366+ };
367+
368+ // Write immediately
369+ writeHeartbeat();
370+
371+ // Loop using the converted milliseconds
372+ setInterval(writeHeartbeat, intervalMs).unref();
373+ }
374+
352375 setWindowTitle('SillyTavern WebServer');
353376
354377 let logListen = 'SillyTavern is listening on';