From 015a7d15813505bd7acd259ebb1a8187fed7a1e8 Mon Sep 17 00:00:00 2001 From: Hatter Jiang Date: Sun, 1 Feb 2026 01:04:35 +0800 Subject: [PATCH] add server-control.ts --- libraries/deno-commons-mod.ts | 28 ++- single-scripts/server-control.ts | 302 +++++++++++++++++++++++++++++++ 2 files changed, 326 insertions(+), 4 deletions(-) create mode 100755 single-scripts/server-control.ts diff --git a/libraries/deno-commons-mod.ts b/libraries/deno-commons-mod.ts index 243bdde..3c6161d 100644 --- a/libraries/deno-commons-mod.ts +++ b/libraries/deno-commons-mod.ts @@ -1,10 +1,10 @@ // Reference: // - https://docs.deno.com/runtime/fundamentals/testing/ -import { assert } from "jsr:@std/assert/assert"; -import { assertEquals } from "jsr:@std/assert"; -import { decodeBase64, encodeBase64 } from "jsr:@std/encoding/base64"; -import { dirname } from "https://deno.land/std@0.208.0/path/mod.ts"; +import {assert} from "jsr:@std/assert/assert"; +import {assertEquals} from "jsr:@std/assert"; +import {decodeBase64, encodeBase64} from "jsr:@std/encoding/base64"; +import {dirname} from "https://deno.land/std@0.208.0/path/mod.ts"; // reference: https://docs.deno.com/examples/hex_base64_encoding/ // import { decodeBase64, encodeBase64 } from "jsr:@std/encoding/base64"; @@ -569,6 +569,26 @@ export class ProcessBar { } } +export async function fetchWithTimeout( + input: URL | Request | string, + timeout?: number, + initCallback?: (init: RequestInit) => RequestInit, +): Promise { + const fetchTimeout = timeout || 10000; + const abortController = new AbortController(); + const timeoutHandler = setTimeout(() => { + abortController.abort(`Timeout ${fetchTimeout} ms`); + }, fetchTimeout); + let init: RequestInit = {}; + init.signal = abortController.signal; + if (initCallback) { + init = initCallback(init); + } + const response = await fetch(input, init); + clearTimeout(timeoutHandler); + return response; +} + Deno.test("isOn", () => { assertEquals(false, isOn(undefined)); assertEquals(false, isOn("")); diff --git a/single-scripts/server-control.ts b/single-scripts/server-control.ts new file mode 100755 index 0000000..7f141c6 --- /dev/null +++ b/single-scripts/server-control.ts @@ -0,0 +1,302 @@ +#!/usr/bin/env deno -- --allow-all + +// !IMPORTANT! not ready + +import { + execCommand, + fetchWithTimeout, + log, + ProcessBar, + sleep, +} from "https://global.hatter.ink/script/get/@24/deno-commons-mod.ts"; +import {assertEquals} from "jsr:@std/assert"; +import {assert} from "jsr:@std/assert/assert"; +import {fromFileUrl} from "https://deno.land/std/path/mod.ts"; +import {execCommandShell, readFileToString,} from "../libraries/deno-commons-mod.ts"; + +interface HealthCheck { + url?: string; + count?: number; // default 30 + timeoutMillis?: number; // default 1000 + intervalMillis?: number; // default 1000 +} + +interface ServerControlConfig { + appId?: string; + startup?: string; + healthCheck?: HealthCheck; +} + +class Process { + user: string; + pid: number; + cpu: number; + mem: number; + vsz: number; + rss: number; + tty: string; + stat: string; + start: string; + time: string; + command: string; + constructor( + user: string, + pid: number, + cpu: number, + mem: number, + vsz: number, + rss: number, + tty: string, + stat: string, + start: string, + time: string, + command: string, + ) { + this.user = user; + this.pid = pid; + this.cpu = cpu; + this.mem = mem; + this.vsz = vsz; + this.rss = rss; + this.tty = tty; + this.stat = stat; + this.start = start; + this.time = time; + this.command = command; + } +} + +async function listJavaProcesses( + serverControlConfig: ServerControlConfig, +): Promise { + const appId = serverControlConfig.appId; + const processOutput = await execCommand("ps", ["aux"]); + processOutput.assertSuccess(); + const stdout = processOutput.getStdoutAsStringThenTrim(); + const lines = stdout.split("\n"); + return lines.filter((line) => { + return line.includes("java") && line.includes("hatterserver") && + line.includes(`-DAPPID=${appId}`); + }).map((line) => { + return parseProcessLine(line); + }).filter((line) => line !== null); +} + +function parseProcessLine(line: string): Process | null { + const processMatchRegex = + /^\s*([\w+\-_]+)\s+(\d+)\s+([\d.]+)\s+([\d.]+)\s+(\d+)\s+(\d+)\s+([\w\/\\?]+)\s+([\w+<>]+)\s+([\w:]+)\s+([\d:]+)\s+(.*)$/; + // "app 3622 0.2 24.0 2932504 453004 ? Sl Jan25 23:04 /usr/lib/jvm/jdk-25/bin/java -Dfastjson.parser.safeMode=true......"; + // USER PID CPU MEM VSZ RSS TTY STAT START TIME COMMAND + const matcher = line.match(processMatchRegex); + if (!matcher) { + return null; + } + const user = matcher[1]; + const pid = parseInt(matcher[2]); + const cpu = Number(matcher[3]); + const mem = Number(matcher[4]); + const vsz = parseInt(matcher[5]); + const rss = parseInt(matcher[6]); + const tty = matcher[7]; + const stat = matcher[8]; + const start = matcher[9]; + const time = matcher[10]; + const command = matcher[11]; + return new Process( + user, + pid, + cpu, + mem, + vsz, + rss, + tty, + stat, + start, + time, + command, + ); +} + +async function checkServerStarted( + serverControlConfig: ServerControlConfig, +): Promise { + const healthCheck = serverControlConfig.healthCheck; + const healthCheckUrl = healthCheck?.url; + if (!healthCheck || !healthCheckUrl) { + log.error("Health check URL is not configured!"); + return false; + } + const count = healthCheck.count || 30; + const intervalMillis = healthCheck.intervalMillis || 1000; + const timeoutMillis = healthCheck.timeoutMillis || 1000; + await new ProcessBar("Starting server").call(async () => { + for (let i = 0; i < count; i++) { + try { + const response = await fetchWithTimeout( + healthCheckUrl, + timeoutMillis, + ); + if (response.status === 200) { + log.success("Server started!"); + return true; + } + } catch (e) { + // IGNORE + } + await sleep(intervalMillis); + } + }); + log.warn("Server failed!"); + return false; +} + +async function loadServerControlConfig( + serverControlConfigFile?: string, +): Promise { + const fullServerControlConfigFile = serverControlConfigFile || + (fromFileUrl(import.meta.url).replace(".ts", ".json")); + const serverControlConfigJson = await readFileToString( + fullServerControlConfigFile, + ); + if (serverControlConfigJson === null) { + throw new Error(`Read file ${fullServerControlConfigFile} failed.`); + } + return JSON.parse( + serverControlConfigJson, + ) as ServerControlConfig; +} + +async function handleStatus( + serverControlConfig: ServerControlConfig, +): Promise { + const processes = await listJavaProcesses(serverControlConfig); + if (processes.length === 0) { + log.info("No process are running!"); + return; + } + log.info( + `Find ${processes.length} process(es), pid(s): ${ + processes.map((p) => p.pid).join(", ") + }`, + ); +} + +async function handleStop(serverControlConfig: ServerControlConfig) { + const processes = await listJavaProcesses(serverControlConfig); + if (processes.length === 0) { + log.info("No process are running!"); + return; + } + if (processes.length > 1) { + log.warn( + `Too many processes are running, pid(s): ${ + processes.map((p) => p.pid).join(", ") + }`, + ); + return; + } + const pid = processes[0].pid; + log.warn(`Kill pid: ${pid}`); + await execCommandShell("kill", [`${pid}`]); + await sleep(500); +} + +async function handleRestart(serverControlConfig: ServerControlConfig) { + let processes = await listJavaProcesses(serverControlConfig); + if (processes.length > 1) { + log.warn( + `Too many processes are running, pid(s): ${ + processes.map((p) => p.pid).join(", ") + }`, + ); + return; + } + if (!serverControlConfig.startup) { + log.error("Startup command is not configured!"); + return; + } + while (processes.length > 0) { + await execCommandShell("kill", [`${processes[0].pid}`]); + await sleep(500); + processes = await listJavaProcesses(serverControlConfig); + } + log.info(`Run command: ${serverControlConfig.startup} &`); + await execCommandShell("sh", ["-c", `${serverControlConfig.startup} &`]); + log.success("Start server, checking ..."); + await checkServerStarted(serverControlConfig); +} + +async function main() { + const args = Deno.args; + if (args.length === 0) { + log.error("No args."); + console.log(""); + console.log("server-control.js status"); + console.log("server-control.js kill|stop"); + console.log("server-control.js re|restart"); + return; + } + const serverControlConfig = await loadServerControlConfig(); + if (!serverControlConfig.appId) { + log.error("Config appId not found!"); + return; + } + + switch (args[0]) { + case "status": + await handleStatus(serverControlConfig); + return; + case "kill": + case "stop": + await handleStop(serverControlConfig); + return; + case "re": + case "restart": + await handleRestart(serverControlConfig); + return; + default: + log.warn("Argument error!"); + return; + } +} + +main().catch((e) => log.error(e)); + +Deno.test("parseProcessLine", () => { + const p1 = parseProcessLine( + "app 3622 0.2 24.0 2932504 453004 ? Sl Jan25 23:04 /usr/lib/jvm/jdk-25/bin/java -Dfastjson.parser.safeMode=true......", + ); + if (p1 !== null) { + assertEquals("app", p1.user); + assertEquals(3622, p1.pid); + } else { + assert(false); + } + const p2 = parseProcessLine( + "root 10880 0.0 0.0 151104 1820 pts/2 R+ 23:17 0:00 ps aux", + ); + if (p2 !== null) { + assertEquals("root", p2.user); + assertEquals(10880, p2.pid); + } else { + assert(false); + } + const p3 = parseProcessLine( + "root 18 0.0 0.0 0 0 ? S< 2020 0:00 [kblockd]", + ); + if (p3 !== null) { + assertEquals("root", p3.user); + assertEquals(18, p3.pid); + } else { + assert(false); + } + const p4 = parseProcessLine( + "filebro+ 10377 0.0 0.6 1901492 12432 ? Sl 2024 25:15 /home/filebrowser/filebrowser", + ); + if (p4 !== null) { + assertEquals("filebro+", p4.user); + assertEquals(10377, p4.pid); + } else { + assert(false); + } +});