This commit is contained in:
2026-02-03 00:16:35 +08:00
parent 66bb18e150
commit 0ee5254338
3 changed files with 595 additions and 563 deletions

View File

@@ -0,0 +1,382 @@
// Compatible with Deno 1.6.0 (works on CentOS 7)
// https://play.hatter.me/doc/showDocDetail.jssp?id=8879
// https://github.com/denoland/deno/releases/tag/v1.6.0
// https://play.hatter.me/doc/showDocDetail.jssp?id=8881
// https://play.hatter.me/doc/showDocDetail.jssp?id=8882
class Term {
constructor() {
}
blink(message: string): string {
return `\x1b[5m${message}\x1b[0m`;
}
bold(message: string): string {
return `\x1b[1m${message}\x1b[0m`;
}
under(message: string): string {
return `\x1b[4m${message}\x1b[0m`;
}
red(message: string): string {
return `\x1b[31m${message}\x1b[0m`;
}
green(message: string): string {
return `\x1b[32m${message}\x1b[0m`;
}
yellow(message: string): string {
return `\x1b[33m${message}\x1b[0m`;
}
blue(message: string): string {
return `\x1b[34m${message}\x1b[0m`;
}
pink(message: string): string {
return `\x1b[35m${message}\x1b[0m`;
}
cyan(message: string): string {
return `\x1b[36m${message}\x1b[0m`;
}
}
export const term = new Term();
function pad(message: string, length: number): string {
if (message.length >= length) {
return message;
}
return message + " ".repeat(length - message.length);
}
const LOGGER_PREFIX_LEN: number = 8;
class Logger {
constructor() {
}
// deno-lint-ignore no-explicit-any
success(...data: any[]) {
this.log(
term.bold(term.green(`[${pad("SUCCESS", LOGGER_PREFIX_LEN)}]`)),
data,
);
}
// deno-lint-ignore no-explicit-any
error(...data: any[]) {
this.log(
term.bold(term.red(`[${pad("ERROR", LOGGER_PREFIX_LEN)}]`)),
data,
);
}
// deno-lint-ignore no-explicit-any
warn(...data: any[]) {
this.log(
term.bold(term.yellow(`[${pad("WARN", LOGGER_PREFIX_LEN)}]`)),
data,
);
}
// deno-lint-ignore no-explicit-any
warning(...data: any[]) {
this.log(
term.blink(
term.bold(term.yellow(`[${pad("WARN", LOGGER_PREFIX_LEN)}]`)),
),
data,
);
}
// deno-lint-ignore no-explicit-any
info(...data: any[]) {
this.log(term.bold(`[${pad("INFO", LOGGER_PREFIX_LEN)}]`), data);
}
// deno-lint-ignore no-explicit-any
debug(...data: any[]) {
this.log(`[${pad("DEBUG", LOGGER_PREFIX_LEN)}]`, data);
}
// deno-lint-ignore no-explicit-any
log(prefix: string, data: any[]) {
const args = [prefix];
for (let i = 0; i < data.length; i++) {
args.push(data[i]);
}
console.log.apply(console, args);
}
}
export const log = new Logger();
export function getHomeDir(): string | null {
if (Deno.build.os === "windows") {
const userProfile = Deno.env.get("USERPROFILE");
if (userProfile) {
return userProfile;
}
const homeDrive = Deno.env.get("HOMEDRIVE");
const homePath = Deno.env.get("HOMEPATH");
if (homeDrive && homePath) {
return homeDrive + homePath;
}
return null;
}
return Deno.env.get("HOME") || null;
}
export function resolveFilename(filename: string): string {
if (filename.startsWith("~/")) {
return getHomeDir() + filename.substring(1);
}
return filename;
}
function toCmdArray(command: string, args?: string[]): string[] {
const cmdArray = [command];
if (args) {
for (const arg of args) {
cmdArray.push(arg);
}
}
log.debug("Command: ", cmdArray);
return cmdArray;
}
export async function execCommandShell(
command: string,
args?: string[],
): Promise<number> {
const process = Deno.run({
cmd: toCmdArray(command, args),
stdout: "inherit",
stderr: "inherit",
});
const status = await process.status();
process.close();
return status.code;
}
export class ProcessOutput {
code: number;
stdout: string;
stderr: string;
constructor(code: number, stdout: string, stderr: string) {
this.code = code;
this.stdout = stdout;
this.stderr = stderr;
}
assertSuccess(): ProcessOutput {
if (this.code !== 0) {
throw new Error(
`Failed to execute command, exit code: ${this.code}\n- stdout: ${this.stdout}\n- stderr: ${this.stderr}\n`,
);
}
return this;
}
getStdoutAsStringThenTrim(): string {
return this.stdout.trim();
}
getStdoutAsJson(): any {
return JSON.parse(this.stdout);
}
getStderrAsJson(): any {
return JSON.parse(this.stderr);
}
}
export async function execCommand(
command: string,
args?: string[],
): Promise<ProcessOutput> {
const process = Deno.run({
cmd: toCmdArray(command, args),
stdout: "piped",
stderr: "piped",
});
const { code } = await process.status();
const stdout = await process.output();
const stderr = await process.stderrOutput();
return new ProcessOutput(
code,
new TextDecoder().decode(stdout),
new TextDecoder().decode(stderr),
);
}
export async function fetchWithTimeout(
input: URL | Request | string,
timeout?: number,
initCallback?: (init: RequestInit) => RequestInit,
): Promise<Response> {
const fetchTimeout = timeout || 10000;
const abortController = new AbortController();
const timeoutHandler = setTimeout(() => {
abortController.abort();
}, fetchTimeout);
let init: RequestInit = {};
init.signal = abortController.signal;
if (initCallback) {
init = initCallback(init);
}
const response = await fetch(input, init);
clearTimeout(timeoutHandler);
return response;
}
export async function sleep(timeoutMillis: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, timeoutMillis));
}
export async function readFileToString(
filename: string,
): Promise<string | null> {
try {
return await Deno.readTextFile(resolveFilename(filename));
} catch (e) {
if (e instanceof Error && e.name == "NotFound") {
return null;
}
throw e;
}
}
export class ProcessBar {
interval?: number;
message: string;
constructor(message?: string) {
this.message = message || "Processing";
}
async call<T>(cb: () => Promise<T>, clearLine?: boolean): Promise<T> {
this.start();
try {
return await cb();
} finally {
this.stop(clearLine);
}
}
write(message: string): void {
Deno.stdout.writeSync(new TextEncoder().encode(message));
}
start(): void {
const startMs = new Date().getTime();
let count = 0;
this.interval = setInterval(() => {
const dots = ".".repeat(((count++) % 10) + 1);
const costMs = new Date().getTime() - startMs;
const time = `${Math.floor(costMs / 1000)}s`;
this.write(`\r${this.message} ${time} ${dots}\x1b[K`);
}, 500);
}
stop(clearLine?: boolean): void {
if (this.interval) {
clearInterval(this.interval);
this.write(clearLine ? "\r\x1b[K" : "\n");
}
}
}
export 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;
}
}
export 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,
);
}
function assertArg(url: URL | string) {
url = url instanceof URL ? url : new URL(url);
if (url.protocol !== "file:") {
throw new TypeError("Must be a file URL.");
}
return url;
}
function posixFromFileUrl(url: URL | string): string {
url = assertArg(url);
return decodeURIComponent(
url.pathname.replace(/%(?![0-9A-Fa-f]{2})/g, "%25"),
);
}
export function fromFileUrl(url: URL | string): string {
return posixFromFileUrl(url);
}

View File

@@ -0,0 +1,210 @@
// Compatible with Deno 1.6.0 (works on CentOS 7)
// https://play.hatter.me/doc/showDocDetail.jssp?id=8879
// https://github.com/denoland/deno/releases/tag/v1.6.0
// https://play.hatter.me/doc/showDocDetail.jssp?id=8881
// https://play.hatter.me/doc/showDocDetail.jssp?id=8882
import {
execCommand,
execCommandShell,
fetchWithTimeout,
fromFileUrl,
log,
parseProcessLine,
Process,
ProcessBar,
readFileToString,
sleep,
} from "https://global.hatter.ink/script/get/@1/deno-commons-1.6.0-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;
}
async function listJavaProcesses(
serverControlConfig: ServerControlConfig,
): Promise<Process[]> {
const appId = serverControlConfig.appId;
const processOutput = await execCommand("ps", ["aux"]);
processOutput.assertSuccess();
const stdout = processOutput.getStdoutAsStringThenTrim();
const lines = stdout.split("\n");
const processes = lines.filter((line) => {
return line.includes("java") && line.includes("hatterserver") &&
line.includes(`-DAPPID=${appId}`);
}).map((line) => {
return parseProcessLine(line);
});
const filterProcesses: Process[] = [];
for (const process of processes) {
if (process !== null) {
filterProcesses.push(process);
}
}
return filterProcesses;
}
async function checkServerStarted(
serverControlConfig: ServerControlConfig,
): Promise<boolean> {
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;
let startServerSuccess = false;
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) {
startServerSuccess = true;
return true;
}
} catch (e) {
// IGNORE
}
await sleep(intervalMillis);
}
});
if (startServerSuccess) {
log.success("Server started!");
} else {
log.warn("Server failed!");
}
return false;
}
async function loadServerControlConfig(
metaUrl: string,
serverControlConfigFile?: string,
): Promise<ServerControlConfig> {
const fullServerControlConfigFile = serverControlConfigFile ||
(fromFileUrl(metaUrl).replace(".ts", ".json"));
log.debug(
`Read server control config file: ${fullServerControlConfigFile}`,
);
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<void> {
const processes = await listJavaProcesses(serverControlConfig);
if (processes.length === 0) {
log.warn("No process are running!");
return;
}
log.success(
`Find ${processes.length} process(es), pid: \n-> ${
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);
}
export async function serverControlMain(metaUrl: string) {
const args = Deno.args;
if (args.length === 0) {
log.error(`No args.
server-control.ts status
server-control.ts kill|stop
server-control.ts re|restart`);
return;
}
const serverControlConfigFile = Deno.env.get("SERVER_CONTROL_CONFIG_FILE");
const serverControlConfig = await loadServerControlConfig(
metaUrl,
serverControlConfigFile,
);
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;
}
}