Layonara Forge — NWN Development IDE
Electron desktop application for Neverwinter Nights module development. Clone, edit, build, and run a complete Layonara NWNX server with only Docker required. - React 19 + Vite frontend with Monaco editor and NWScript LSP - Node.js + Express backend managing Docker sibling containers - Electron shell with Docker availability check and auto-setup - Builder image auto-builds on first use from bundled Dockerfile - Cross-platform: Windows (.exe), macOS (.dmg), Linux (.AppImage) - Gitea Actions CI for automated release builds
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import express from "express";
|
||||
import cors from "cors";
|
||||
import { createServer } from "http";
|
||||
import type { Server } from "http";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import { WebSocketServer } from "ws";
|
||||
@@ -21,72 +22,82 @@ import { loadTlkIndex } from "./nwscript/tlk-index.js";
|
||||
import { getRepoPath } from "./services/workspace.service.js";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const app = express();
|
||||
const server = createServer(app);
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
export function startServer(port: number): Promise<Server> {
|
||||
const app = express();
|
||||
const server = createServer(app);
|
||||
|
||||
initWebSocket(server);
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
app.get("/api/health", (_req, res) => {
|
||||
res.json({
|
||||
status: "ok",
|
||||
wsClients: getClientCount(),
|
||||
uptime: process.uptime(),
|
||||
initWebSocket(server);
|
||||
|
||||
app.get("/api/health", (_req, res) => {
|
||||
res.json({
|
||||
status: "ok",
|
||||
wsClients: getClientCount(),
|
||||
uptime: process.uptime(),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
app.use("/api/workspace", workspaceRouter);
|
||||
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);
|
||||
app.use("/api/toolset", toolsetRouter);
|
||||
app.use("/api/github", githubRouter);
|
||||
app.use("/api/repos", reposRouter);
|
||||
app.use("/api/workspace", workspaceRouter);
|
||||
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);
|
||||
app.use("/api/toolset", toolsetRouter);
|
||||
app.use("/api/github", githubRouter);
|
||||
app.use("/api/repos", reposRouter);
|
||||
|
||||
const frontendDist = path.resolve(__dirname, "../../frontend/dist");
|
||||
app.use(express.static(frontendDist));
|
||||
app.get("*path", (_req, res) => {
|
||||
res.sendFile(path.join(frontendDist, "index.html"));
|
||||
});
|
||||
const frontendDist = path.resolve(__dirname, "../../frontend/dist");
|
||||
app.use(express.static(frontendDist));
|
||||
app.get("*path", (_req, res) => {
|
||||
res.sendFile(path.join(frontendDist, "index.html"));
|
||||
});
|
||||
|
||||
server.on("upgrade", (request, socket, head) => {
|
||||
const url = new URL(request.url || "", `http://${request.headers.host}`);
|
||||
server.on("upgrade", (request, socket, head) => {
|
||||
const url = new URL(request.url || "", `http://${request.headers.host}`);
|
||||
|
||||
if (url.pathname === "/ws/lsp") {
|
||||
const lspWss = new WebSocketServer({ noServer: true });
|
||||
lspWss.handleUpgrade(request, socket, head, (ws) => {
|
||||
attachLspWebSocket(ws);
|
||||
if (url.pathname === "/ws/lsp") {
|
||||
const lspWss = new WebSocketServer({ noServer: true });
|
||||
lspWss.handleUpgrade(request, socket, head, (ws) => {
|
||||
attachLspWebSocket(ws);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const termMatch = url.pathname.match(/^\/ws\/terminal\/(.+)$/);
|
||||
if (termMatch) {
|
||||
const sessionId = termMatch[1];
|
||||
const termWss = new WebSocketServer({ noServer: true });
|
||||
termWss.handleUpgrade(request, socket, head, (ws) => {
|
||||
if (!attachWebSocket(sessionId, ws)) {
|
||||
createTerminalSession(sessionId);
|
||||
attachWebSocket(sessionId, ws);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/ws") {
|
||||
handleEventUpgrade(request, socket, head);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
server.listen(port, "0.0.0.0", () => {
|
||||
console.log(`Layonara Forge listening on http://0.0.0.0:${port}`);
|
||||
startUpstreamPolling();
|
||||
const tlkPath = getRepoPath("nwn-haks", "layonara.tlk.json");
|
||||
loadTlkIndex(tlkPath).then(() => console.log(`TLK index loaded`)).catch(() => {});
|
||||
resolve(server);
|
||||
});
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const termMatch = url.pathname.match(/^\/ws\/terminal\/(.+)$/);
|
||||
if (termMatch) {
|
||||
const sessionId = termMatch[1];
|
||||
const termWss = new WebSocketServer({ noServer: true });
|
||||
termWss.handleUpgrade(request, socket, head, (ws) => {
|
||||
if (!attachWebSocket(sessionId, ws)) {
|
||||
createTerminalSession(sessionId);
|
||||
attachWebSocket(sessionId, ws);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (url.pathname === "/ws") {
|
||||
handleEventUpgrade(request, socket, head);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
const PORT = parseInt(process.env.PORT || "3000", 10);
|
||||
server.listen(PORT, "0.0.0.0", () => {
|
||||
console.log(`Layonara Forge listening on http://0.0.0.0:${PORT}`);
|
||||
startUpstreamPolling();
|
||||
const tlkPath = getRepoPath("nwn-haks", "layonara.tlk.json");
|
||||
loadTlkIndex(tlkPath).then(() => console.log(`TLK index loaded`)).catch(() => {});
|
||||
});
|
||||
if (!process.env.ELECTRON) {
|
||||
const PORT = parseInt(process.env.PORT || "3000", 10);
|
||||
startServer(PORT);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,55 @@
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { runEphemeralContainer } from "./docker.service.js";
|
||||
import { runEphemeralContainer, getDockerClient } from "./docker.service.js";
|
||||
import {
|
||||
getWorkspacePath,
|
||||
getServerPath,
|
||||
} from "./workspace.service.js";
|
||||
import { broadcast } from "./ws.service.js";
|
||||
|
||||
const BUILDER_IMAGE = "layonara-builder";
|
||||
|
||||
async function ensureBuilderImage(): Promise<void> {
|
||||
const docker = getDockerClient();
|
||||
try {
|
||||
await docker.getImage(BUILDER_IMAGE).inspect();
|
||||
return;
|
||||
} catch {
|
||||
// image doesn't exist — build it
|
||||
}
|
||||
|
||||
broadcast("build", "info", { message: "Building the builder image (first-time setup, ~45s)..." });
|
||||
|
||||
const builderDir = process.env.ELECTRON
|
||||
? path.join((process as any).resourcesPath ?? __dirname, "builder")
|
||||
: path.resolve(__dirname, "../../../builder");
|
||||
|
||||
const stream = await docker.buildImage(
|
||||
{ context: builderDir, src: ["."] },
|
||||
{ t: BUILDER_IMAGE },
|
||||
);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
docker.modem.followProgress(
|
||||
stream,
|
||||
(err: Error | null) => {
|
||||
if (err) {
|
||||
broadcast("build", "error", { message: `Builder image build failed: ${err.message}` });
|
||||
reject(err);
|
||||
} else {
|
||||
broadcast("build", "info", { message: "Builder image ready." });
|
||||
resolve();
|
||||
}
|
||||
},
|
||||
(event: { stream?: string }) => {
|
||||
if (event.stream) {
|
||||
broadcast("build", "output", { text: event.stream });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function buildModule(
|
||||
target: string = "bare",
|
||||
mode: "compile" | "pack" = "compile",
|
||||
@@ -18,9 +61,10 @@ export async function buildModule(
|
||||
: ["nasher", "pack", target, "--yes"];
|
||||
|
||||
broadcast("build", "start", { type: "module", target, mode });
|
||||
await ensureBuilderImage();
|
||||
|
||||
const result = await runEphemeralContainer({
|
||||
image: "layonara-builder",
|
||||
image: BUILDER_IMAGE,
|
||||
cmd,
|
||||
binds: [
|
||||
`${workspacePath}/repos/nwn-module:/build/nwn-module`,
|
||||
@@ -101,9 +145,10 @@ export async function buildHaks(): Promise<{
|
||||
const workspacePath = getWorkspacePath();
|
||||
|
||||
broadcast("build", "start", { type: "haks" });
|
||||
await ensureBuilderImage();
|
||||
|
||||
const result = await runEphemeralContainer({
|
||||
image: "layonara-builder",
|
||||
image: BUILDER_IMAGE,
|
||||
cmd: ["layonara_nwn", "hak", "--yes"],
|
||||
binds: [
|
||||
`${workspacePath}/repos/nwn-haks:/build/nwn-haks`,
|
||||
@@ -127,6 +172,7 @@ export async function buildNWNX(
|
||||
const workspacePath = getWorkspacePath();
|
||||
|
||||
broadcast("build", "start", { type: "nwnx", target });
|
||||
await ensureBuilderImage();
|
||||
|
||||
const cmd = target
|
||||
? [
|
||||
@@ -141,7 +187,7 @@ export async function buildNWNX(
|
||||
];
|
||||
|
||||
const result = await runEphemeralContainer({
|
||||
image: "layonara-builder",
|
||||
image: BUILDER_IMAGE,
|
||||
cmd,
|
||||
binds: [`${workspacePath}/repos/unified:/build/unified`],
|
||||
workingDir: "/build/unified",
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import Docker from "dockerode";
|
||||
import { platform } from "os";
|
||||
import { broadcast } from "./ws.service.js";
|
||||
|
||||
const docker = new Docker({ socketPath: "/var/run/docker.sock" });
|
||||
const socketPath = platform() === "win32"
|
||||
? "//./pipe/docker_engine"
|
||||
: "/var/run/docker.sock";
|
||||
const docker = new Docker({ socketPath });
|
||||
|
||||
export interface ContainerInfo {
|
||||
id: string;
|
||||
|
||||
@@ -189,22 +189,33 @@ export async function seedDatabase(cdKey: string, playerName: string): Promise<v
|
||||
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`,
|
||||
],
|
||||
const schemaPath = path.resolve(__dirname, "../../../db/schema.sql");
|
||||
let schemaSql: string;
|
||||
try {
|
||||
schemaSql = await fs.readFile(schemaPath, "utf-8");
|
||||
} catch {
|
||||
const altPath = path.resolve(__dirname, "../../db/schema.sql");
|
||||
schemaSql = await fs.readFile(altPath, "utf-8");
|
||||
}
|
||||
|
||||
const schemaExec = await container.exec({
|
||||
Cmd: ["bash", "-c", "mysql -u root -p$MYSQL_ROOT_PASSWORD nwn 2>&1"],
|
||||
AttachStdin: true,
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
});
|
||||
await exec.start({});
|
||||
const schemaStream = await schemaExec.start({ hijack: true, stdin: true });
|
||||
schemaStream.write(schemaSql);
|
||||
schemaStream.end();
|
||||
await new Promise<void>((resolve) => schemaStream.on("end", resolve));
|
||||
|
||||
const safeKey = cdKey.replace(/'/g, "''");
|
||||
const safeName = playerName.replace(/'/g, "''");
|
||||
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`,
|
||||
`mysql -u root -p$MYSQL_ROOT_PASSWORD nwn -e "INSERT IGNORE INTO dms (cdkey, playername, role) VALUES ('${safeKey}', '${safeName}', 1);" 2>&1`,
|
||||
],
|
||||
AttachStdout: true,
|
||||
AttachStderr: true,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import { homedir } from "os";
|
||||
|
||||
const WORKSPACE_PATH = process.env.WORKSPACE_PATH || "/workspace";
|
||||
const WORKSPACE_PATH = process.env.WORKSPACE_PATH || path.join(homedir(), "Layonara Forge");
|
||||
|
||||
interface ForgeConfig {
|
||||
githubPat?: string;
|
||||
|
||||
Reference in New Issue
Block a user