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 -0Ignore whitespace
default/config.yaml+3 -0
@@ -38,6 +38,9 @@ browserLaunch:
38 avoidLocalhost: false38 avoidLocalhost: false
39# Server port39# Server port
40port: 800040port: 8000
41# Interval in seconds to write a heartbeat file. Set to 0 to disable.
42# This is used primarily for Docker healthchecks.
43heartbeatInterval: 0
41# -- SSL options --44# -- SSL options --
42ssl:45ssl:
43 # Enable SSL/TLS encryption46 # Enable SSL/TLS encryption
docker/docker-compose.yml+7 -0
@@ -7,6 +7,7 @@ services:
7 environment:7 environment:
8 - NODE_ENV=production8 - NODE_ENV=production
9 - FORCE_COLOR=19 - FORCE_COLOR=1
10 - SILLYTAVERN_HEARTBEATINTERVAL=30
10 ports:11 ports:
11 - "8000:8000"12 - "8000:8000"
12 volumes:13 volumes:
@@ -14,4 +15,10 @@ services:
14 - "./data:/home/node/app/data"15 - "./data:/home/node/app/data"
15 - "./plugins:/home/node/app/plugins"16 - "./plugins:/home/node/app/plugins"
16 - "./extensions:/home/node/app/public/scripts/extensions/third-party"17 - "./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
17 restart: unless-stopped24 restart: unless-stopped
src/command-line.js+8 -0
@@ -18,6 +18,7 @@ import { initConfig } from './config-init.js';
18 * @property {boolean|string} enableIPv4 If enable IPv4 protocol ("auto" is also allowed)18 * @property {boolean|string} enableIPv4 If enable IPv4 protocol ("auto" is also allowed)
19 * @property {boolean|string} enableIPv6 If enable IPv6 protocol ("auto" is also allowed)19 * @property {boolean|string} enableIPv6 If enable IPv6 protocol ("auto" is also allowed)
20 * @property {boolean} dnsPreferIPv6 If prefer IPv6 for DNS20 * @property {boolean} dnsPreferIPv6 If prefer IPv6 for DNS
21 * @property {number} heartbeatInterval Interval in seconds to write a heartbeat file. 0 to disable.
21 * @property {boolean} browserLaunchEnabled If automatically launch SillyTavern in the browser22 * @property {boolean} browserLaunchEnabled If automatically launch SillyTavern in the browser
22 * @property {string} browserLaunchHostname Browser launch hostname23 * @property {string} browserLaunchHostname Browser launch hostname
23 * @property {number} browserLaunchPort Browser launch port override (-1 is use server port)24 * @property {number} browserLaunchPort Browser launch port override (-1 is use server port)
@@ -62,6 +63,7 @@ export class CommandLineParser {
62 enableIPv4: true,63 enableIPv4: true,
63 enableIPv6: false,64 enableIPv6: false,
64 dnsPreferIPv6: false,65 dnsPreferIPv6: false,
66 heartbeatInterval: 0,
65 browserLaunchEnabled: false,67 browserLaunchEnabled: false,
66 browserLaunchHostname: 'auto',68 browserLaunchHostname: 'auto',
67 browserLaunchPort: -1,69 browserLaunchPort: -1,
@@ -229,6 +231,11 @@ export class CommandLineParser {
229 type: 'array',231 type: 'array',
230 describe: 'Request proxy bypass list (space separated list of hosts)',232 describe: 'Request proxy bypass list (space separated list of hosts)',
231 })233 })
234 .option('heartbeatInterval', {
235 type: 'number',
236 default: null,
237 describe: 'Interval in seconds to write a heartbeat file. 0 to disable.',
238 })
232 /* DEPRECATED options */239 /* DEPRECATED options */
233 .option('autorun', {240 .option('autorun', {
234 type: 'boolean',241 type: 'boolean',
@@ -289,6 +296,7 @@ export class CommandLineParser {
289 enableIPv4: stringToBool(cliArguments.enableIPv4) ?? stringToBool(getConfigValue('protocol.ipv4', defaultConfig.enableIPv4)) ?? defaultConfig.enableIPv4,296 enableIPv4: stringToBool(cliArguments.enableIPv4) ?? stringToBool(getConfigValue('protocol.ipv4', defaultConfig.enableIPv4)) ?? defaultConfig.enableIPv4,
290 enableIPv6: stringToBool(cliArguments.enableIPv6) ?? stringToBool(getConfigValue('protocol.ipv6', defaultConfig.enableIPv6)) ?? defaultConfig.enableIPv6,297 enableIPv6: stringToBool(cliArguments.enableIPv6) ?? stringToBool(getConfigValue('protocol.ipv6', defaultConfig.enableIPv6)) ?? defaultConfig.enableIPv6,
291 dnsPreferIPv6: cliArguments.dnsPreferIPv6 ?? getConfigValue('dnsPreferIPv6', defaultConfig.dnsPreferIPv6, 'boolean'),298 dnsPreferIPv6: cliArguments.dnsPreferIPv6 ?? getConfigValue('dnsPreferIPv6', defaultConfig.dnsPreferIPv6, 'boolean'),
299 heartbeatInterval: cliArguments.heartbeatInterval ?? getConfigValue('heartbeatInterval', defaultConfig.heartbeatInterval, 'number'),
292 browserLaunchEnabled: cliArguments.browserLaunchEnabled ?? cliArguments.autorun ?? getConfigValue('browserLaunch.enabled', defaultConfig.browserLaunchEnabled, 'boolean'),300 browserLaunchEnabled: cliArguments.browserLaunchEnabled ?? cliArguments.autorun ?? getConfigValue('browserLaunch.enabled', defaultConfig.browserLaunchEnabled, 'boolean'),
293 browserLaunchHostname: cliArguments.browserLaunchHostname ?? cliArguments.autorunHostname ?? getConfigValue('browserLaunch.hostname', defaultConfig.browserLaunchHostname),301 browserLaunchHostname: cliArguments.browserLaunchHostname ?? cliArguments.autorunHostname ?? getConfigValue('browserLaunch.hostname', defaultConfig.browserLaunchHostname),
294 browserLaunchPort: cliArguments.browserLaunchPort ?? cliArguments.autorunPortOverride ?? getConfigValue('browserLaunch.port', defaultConfig.browserLaunchPort, 'number'),302 browserLaunchPort: cliArguments.browserLaunchPort ?? cliArguments.autorunPortOverride ?? getConfigValue('browserLaunch.port', defaultConfig.browserLaunchPort, 'number'),
src/healthcheck.js+40 -0
@@ -0,0 +1,40 @@
1import fs from 'fs';
2import path from 'path';
3import { serverDirectory } from './server-directory.js';
4
5// Default to 0 seconds (disabled) if not set
6const intervalSeconds = parseInt(process.env.SILLYTAVERN_HEARTBEATINTERVAL || '0');
7const intervalMs = intervalSeconds * 1000;
8
9// Heartbeat disabled
10if (Number.isNaN(intervalSeconds) || intervalSeconds <= 0) {
11 process.exit(0);
12}
13
14// Allow a grace period (2 missed beats)
15const threshold = intervalMs * 2;
16
17const dataRoot = process.env.SILLYTAVERN_DATAROOT || path.join(serverDirectory, 'data');
18const heartbeatFile = path.join(dataRoot, 'heartbeat.json');
19
20try {
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 @@
1// native node modules1// native node modules
2import fs from 'node:fs';
2import path from 'node:path';3import path from 'node:path';
3import util from 'node:util';4import util from 'node:util';
4import net from 'node:net';5import net from 'node:net';
@@ -349,6 +350,28 @@ async function postSetupTasks(result) {
349 }350 }
350 }351 }
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
352 setWindowTitle('SillyTavern WebServer');375 setWindowTitle('SillyTavern WebServer');
353376
354 let logListen = 'SillyTavern is listening on';377 let logListen = 'SillyTavern is listening on';