146 lines
5.0 KiB
JavaScript
146 lines
5.0 KiB
JavaScript
import { createServer as createHttpServer } from "node:http";
|
|
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
import { existsSync } from "node:fs";
|
|
import { dirname, join } from "node:path";
|
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
|
|
const projectDir = dirname(fileURLToPath(import.meta.url));
|
|
|
|
function sendJson(response, statusCode, value) {
|
|
response.writeHead(statusCode, {
|
|
"content-type": "application/json; charset=utf-8",
|
|
"cache-control": "no-store"
|
|
});
|
|
response.end(JSON.stringify(value));
|
|
}
|
|
|
|
function sendText(response, statusCode, text, contentType = "text/plain; charset=utf-8") {
|
|
response.writeHead(statusCode, {
|
|
"content-type": contentType,
|
|
"cache-control": "no-store"
|
|
});
|
|
response.end(text);
|
|
}
|
|
|
|
async function readRequestBody(request, limitBytes = 5 * 1024 * 1024) {
|
|
const chunks = [];
|
|
let size = 0;
|
|
|
|
for await (const chunk of request) {
|
|
size += chunk.length;
|
|
if (size > limitBytes) {
|
|
throw new Error("Payload zu gross");
|
|
}
|
|
chunks.push(chunk);
|
|
}
|
|
|
|
return Buffer.concat(chunks).toString("utf8");
|
|
}
|
|
|
|
function validateMapData(value) {
|
|
if (!value || typeof value !== "object") {
|
|
throw new Error("Karte muss ein Objekt sein");
|
|
}
|
|
if (!Array.isArray(value.systems) || !Array.isArray(value.connections)) {
|
|
throw new Error("systems und connections muessen Arrays sein");
|
|
}
|
|
|
|
const ids = new Set();
|
|
for (const system of value.systems) {
|
|
if (!system || typeof system !== "object") {
|
|
throw new Error("Ungueltiges System");
|
|
}
|
|
if (typeof system.id !== "string" || typeof system.name !== "string") {
|
|
throw new Error("System braucht id und name");
|
|
}
|
|
if (!Number.isFinite(system.x) || !Number.isFinite(system.y)) {
|
|
throw new Error("System braucht x/y Koordinaten");
|
|
}
|
|
ids.add(system.id);
|
|
}
|
|
|
|
for (const connection of value.connections) {
|
|
if (!connection || typeof connection !== "object") {
|
|
throw new Error("Ungueltige Verbindung");
|
|
}
|
|
if (typeof connection.id !== "string" || typeof connection.from !== "string" || typeof connection.to !== "string") {
|
|
throw new Error("Verbindung braucht id/from/to");
|
|
}
|
|
if (!ids.has(connection.from) || !ids.has(connection.to)) {
|
|
throw new Error("Verbindung verweist auf unbekanntes System");
|
|
}
|
|
}
|
|
|
|
return {
|
|
source: typeof value.source === "string" ? value.source : undefined,
|
|
background: typeof value.background === "string" ? value.background : "#05080f",
|
|
grid: value.grid && typeof value.grid === "object" ? value.grid : undefined,
|
|
viewBox: value.viewBox && typeof value.viewBox === "object" ? value.viewBox : undefined,
|
|
systems: value.systems,
|
|
connections: value.connections
|
|
};
|
|
}
|
|
|
|
async function readInitialMap(htmlPath) {
|
|
const html = await readFile(htmlPath, "utf8");
|
|
const match = html.match(/<script type="application\/json" id="initial-map-data">([\s\S]*?)<\/script>/);
|
|
if (!match) {
|
|
throw new Error("initial-map-data nicht gefunden");
|
|
}
|
|
return validateMapData(JSON.parse(match[1]));
|
|
}
|
|
|
|
export function createMapServer(options = {}) {
|
|
const htmlPath = options.htmlPath || join(projectDir, "star-map.html");
|
|
const dataDir = options.dataDir || process.env.DATA_DIR || join(projectDir, "data");
|
|
const dataPath = join(dataDir, "map.json");
|
|
|
|
return createHttpServer(async (request, response) => {
|
|
try {
|
|
const url = new URL(request.url, "http://localhost");
|
|
|
|
if (request.method === "GET" && (url.pathname === "/" || url.pathname === "/star-map.html")) {
|
|
const html = await readFile(htmlPath, "utf8");
|
|
sendText(response, 200, html, "text/html; charset=utf-8");
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/api/map" && request.method === "GET") {
|
|
if (existsSync(dataPath)) {
|
|
sendJson(response, 200, validateMapData(JSON.parse(await readFile(dataPath, "utf8"))));
|
|
return;
|
|
}
|
|
sendJson(response, 200, await readInitialMap(htmlPath));
|
|
return;
|
|
}
|
|
|
|
if (url.pathname === "/api/map" && request.method === "PUT") {
|
|
const body = await readRequestBody(request);
|
|
const mapData = validateMapData(JSON.parse(body));
|
|
await mkdir(dataDir, { recursive: true });
|
|
const tempPath = `${dataPath}.tmp`;
|
|
await writeFile(tempPath, `${JSON.stringify(mapData, null, 2)}\n`, "utf8");
|
|
await rename(tempPath, dataPath);
|
|
sendJson(response, 200, { ok: true });
|
|
return;
|
|
}
|
|
|
|
sendJson(response, 404, { error: "Nicht gefunden" });
|
|
} catch (error) {
|
|
const status = error instanceof SyntaxError || /ungueltig|braucht|muss|verweist|Payload|systems/i.test(error.message)
|
|
? 400
|
|
: 500;
|
|
sendJson(response, status, { error: error.message });
|
|
}
|
|
});
|
|
}
|
|
|
|
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
const port = Number(process.env.PORT || 3000);
|
|
const host = process.env.HOST || "127.0.0.1";
|
|
const server = createMapServer();
|
|
server.listen(port, host, () => {
|
|
console.log(`Star Map Editor: http://${host}:${port}`);
|
|
});
|
|
}
|