feat: add NWN server stack management service

This commit is contained in:
plenarius
2026-04-20 19:55:31 -04:00
parent 973310113c
commit dc45098cd1
3 changed files with 284 additions and 0 deletions
+2
View File
@@ -10,6 +10,7 @@ import dockerRouter from "./routes/docker.js";
import editorRouter from "./routes/editor.js";
import terminalRouter from "./routes/terminal.js";
import buildRouter from "./routes/build.js";
import serverRouter from "./routes/server.js";
import { attachWebSocket, createTerminalSession } from "./services/terminal.service.js";
import { attachLspWebSocket } from "./services/lsp.service.js";
@@ -35,6 +36,7 @@ app.use("/api/docker", dockerRouter);
app.use("/api/editor", editorRouter);
app.use("/api/terminal", terminalRouter);
app.use("/api/build", buildRouter);
app.use("/api/server", serverRouter);
const frontendDist = path.resolve(__dirname, "../../frontend/dist");
app.use(express.static(frontendDist));
+70
View File
@@ -0,0 +1,70 @@
import { Router } from "express";
import {
startServer,
stopServer,
restartServer,
getServerStatus,
generateServerConfig,
seedDatabase,
} from "../services/server.service.js";
const router = Router();
router.get("/status", async (_req, res) => {
try {
const status = await getServerStatus();
res.json(status);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
router.post("/start", async (_req, res) => {
try {
await startServer();
res.json({ ok: true });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
router.post("/stop", async (_req, res) => {
try {
await stopServer();
res.json({ ok: true });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
router.post("/restart", async (_req, res) => {
try {
await restartServer();
res.json({ ok: true });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
router.post("/generate-config", async (_req, res) => {
try {
await generateServerConfig();
res.json({ ok: true });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
router.post("/seed-db", async (req, res) => {
const { cdKey, playerName } = req.body;
if (!cdKey || !playerName)
return res.status(400).json({ error: "cdKey and playerName required" });
try {
await seedDatabase(cdKey, playerName);
res.json({ ok: true });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
export default router;
@@ -0,0 +1,212 @@
import fs from "fs/promises";
import path from "path";
import { getWorkspacePath } from "./workspace.service.js";
import { getDockerClient } from "./docker.service.js";
import { broadcast } from "./ws.service.js";
import { generateServerEnv } from "../config/env-template.js";
const NWSERVER_NAME = "layonara-nwserver";
const MARIADB_NAME = "layonara-mariadb";
const NETWORK_NAME = "layonara-forge";
export async function generateServerConfig(): Promise<void> {
const configDir = path.join(getWorkspacePath(), "config");
const envContent = generateServerEnv();
await fs.writeFile(path.join(configDir, ".env"), envContent);
}
export async function getServerStatus(): Promise<{ nwserver: string; mariadb: string }> {
const docker = getDockerClient();
const containers = await docker.listContainers({ all: true });
const nwserver = containers.find((c) =>
c.Names.some((n) => n.includes(NWSERVER_NAME)),
);
const mariadb = containers.find((c) =>
c.Names.some((n) => n.includes(MARIADB_NAME)),
);
return {
nwserver: nwserver?.State || "not created",
mariadb: mariadb?.State || "not created",
};
}
export async function startServer(): Promise<void> {
broadcast("docker", "server:starting", {});
const docker = getDockerClient();
try {
await docker.getNetwork(NETWORK_NAME).inspect();
} catch {
await docker.createNetwork({ Name: NETWORK_NAME });
}
const envPath = path.join(getWorkspacePath(), "config", ".env");
try {
await fs.access(envPath);
} catch {
await generateServerConfig();
}
const envContent = await fs.readFile(envPath, "utf-8");
const env = envContent
.split("\n")
.filter((l) => l.trim() && !l.startsWith("#"))
.map((l) => l.trim());
await startMariaDB(docker, env);
await waitForContainer(docker, MARIADB_NAME, 30000);
await startNWServer(docker, env);
broadcast("docker", "server:started", {});
}
async function startMariaDB(docker: ReturnType<typeof getDockerClient>, env: string[]): Promise<void> {
try {
const existing = docker.getContainer(MARIADB_NAME);
const info = await existing.inspect();
if (info.State.Running) return;
await existing.start();
return;
} catch {
/* container doesn't exist */
}
const mysqlPassword =
env.find((e) => e.startsWith("MYSQL_PASSWORD="))?.split("=")[1] || "forge";
const mysqlRootPassword =
env.find((e) => e.startsWith("MYSQL_ROOT_PASSWORD="))?.split("=")[1] || "forge";
const container = await docker.createContainer({
Image: "mariadb:10.11",
name: MARIADB_NAME,
Env: [
`MYSQL_ROOT_PASSWORD=${mysqlRootPassword}`,
`MYSQL_DATABASE=nwn`,
`MYSQL_USER=nwn`,
`MYSQL_PASSWORD=${mysqlPassword}`,
],
HostConfig: {
NetworkMode: NETWORK_NAME,
RestartPolicy: { Name: "unless-stopped" },
},
Healthcheck: {
Test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"],
Interval: 10000000000,
Timeout: 5000000000,
Retries: 5,
},
});
await container.start();
}
async function startNWServer(docker: ReturnType<typeof getDockerClient>, env: string[]): Promise<void> {
try {
const existing = docker.getContainer(NWSERVER_NAME);
const info = await existing.inspect();
if (info.State.Running) return;
await existing.start();
return;
} catch {
/* container doesn't exist */
}
const workspacePath = getWorkspacePath();
const container = await docker.createContainer({
Image: "ghcr.io/plenarius/unified:latest",
name: NWSERVER_NAME,
Env: env,
ExposedPorts: { "5121/udp": {}, "5121/tcp": {} },
HostConfig: {
NetworkMode: NETWORK_NAME,
PortBindings: {
"5121/udp": [{ HostPort: "5121" }],
"5121/tcp": [{ HostPort: "5121" }],
},
Binds: [
`${workspacePath}/server/hak:/nwn/home/hak:ro`,
`${workspacePath}/server/tlk:/nwn/home/tlk:ro`,
`${workspacePath}/server/override:/nwn/home/override:ro`,
`${workspacePath}/server/portraits:/nwn/home/portraits:ro`,
`${workspacePath}/server/modules:/nwn/home/modules`,
`${workspacePath}/server/servervault:/nwn/home/servervault`,
`${workspacePath}/server/database:/nwn/home/database`,
`${workspacePath}/server/development:/nwn/home/development`,
`${workspacePath}/logs:/nwn/run/logs.0`,
],
RestartPolicy: { Name: "unless-stopped" },
},
Tty: true,
OpenStdin: true,
});
await container.start();
}
async function waitForContainer(
docker: ReturnType<typeof getDockerClient>,
name: string,
timeoutMs: number,
): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try {
const container = docker.getContainer(name);
const info = await container.inspect();
if (info.State.Running) return;
} catch {
/* not ready yet */
}
await new Promise((r) => setTimeout(r, 2000));
}
}
export async function stopServer(): Promise<void> {
broadcast("docker", "server:stopping", {});
const docker = getDockerClient();
try {
await docker.getContainer(NWSERVER_NAME).stop();
} catch {
/* already stopped or doesn't exist */
}
try {
await docker.getContainer(MARIADB_NAME).stop();
} catch {
/* already stopped or doesn't exist */
}
broadcast("docker", "server:stopped", {});
}
export async function restartServer(): Promise<void> {
await stopServer();
await startServer();
}
export async function seedDatabase(cdKey: string, playerName: string): Promise<void> {
const docker = getDockerClient();
const container = docker.getContainer(MARIADB_NAME);
const exec = await container.exec({
Cmd: [
"bash",
"-c",
`mysql -u root -p$MYSQL_ROOT_PASSWORD nwn < /app/db/schema.sql 2>&1 || true`,
],
AttachStdout: true,
AttachStderr: true,
});
await exec.start({});
const dmExec = await container.exec({
Cmd: [
"bash",
"-c",
`mysql -u root -p$MYSQL_ROOT_PASSWORD nwn -e "INSERT IGNORE INTO dms (cdkey, playername, role) VALUES ('${cdKey}', '${playerName}', 1);" 2>&1`,
],
AttachStdout: true,
AttachStderr: true,
});
await dmExec.start({});
}