chore: initial commit of foundryvtt-admin

Spun out of the hbm-books working folder into its own repo. A small
backend+frontend+COBOL admin tool for managing Foundry VTT worlds
(socket inspection, join/shutdown debugging).
This commit is contained in:
2026-08-17 21:46:42 +02:00
commit 460d136d21
28 changed files with 2494 additions and 0 deletions
+940
View File
@@ -0,0 +1,940 @@
import { io } from "socket.io-client";
import * as fs from "fs";
import * as path from "path";
const PORT = process.env.PORT || 3000;
const FOUNDRY_URL = process.env.FOUNDRY_URL || "http://localhost:30000";
function getFoundryUrl(subpath: string): string {
const base = FOUNDRY_URL.endsWith("/") ? FOUNDRY_URL.slice(0, -1) : FOUNDRY_URL;
const normalizedPath = subpath.startsWith("/") ? subpath : `/${subpath}`;
return `${base}${normalizedPath}`;
}
const SHARED_DIR = "/app/shared";
const ACTORS_FILE = path.join(SHARED_DIR, "ACTORS.DAT");
const UPDATES_FILE = path.join(SHARED_DIR, "UPDATES.DAT");
const WORLDS_CACHE_FILE = path.join(SHARED_DIR, "WORLDS_CACHE.json");
const FOUNDRY_ADMIN_KEY = process.env.FOUNDRY_ADMIN_KEY || "";
let currentWorld = process.env.FOUNDRY_WORLD || "";
let currentWorldTitle = "";
let currentSystem = "";
let currentUser = process.env.FOUNDRY_USER || "Admin";
let currentPassword = process.env.FOUNDRY_PASSWORD || "";
let socket: any = null;
let actorsList: any[] = [];
interface SystemMapping {
getHP: (actor: any) => { value: number; max: number };
setHP: (hp: { value: number; max: number }, actorSystem: any) => void;
getLevel: (actor: any) => number;
setLevel: (level: number, actorSystem: any) => void;
}
const SYSTEM_MAPPINGS: Record<string, SystemMapping> = {
"hbm-rpg-v3": {
getHP: (actor) => {
const h = actor.system?.attributes?.health || actor.system?.attributes?.hp || {};
return { value: h.value ?? 0, max: h.max ?? 0 };
},
setHP: (hp, actorSystem) => {
actorSystem.attributes = actorSystem.attributes || {};
actorSystem.attributes.health = { value: hp.value, max: hp.max };
actorSystem.attributes.hp = { value: hp.value, max: hp.max };
},
getLevel: (actor) => {
return actor.system?.details?.year ?? actor.system?.details?.level ?? 0;
},
setLevel: (level, actorSystem) => {
actorSystem.details = actorSystem.details || {};
actorSystem.details.year = level;
actorSystem.details.level = level;
}
},
"dnd5e": {
getHP: (actor) => {
const hp = actor.system?.attributes?.hp || {};
return { value: hp.value ?? 0, max: hp.max ?? 0 };
},
setHP: (hp, actorSystem) => {
actorSystem.attributes = actorSystem.attributes || {};
actorSystem.attributes.hp = { value: hp.value, max: hp.max };
},
getLevel: (actor) => {
return actor.system?.details?.level ?? 0;
},
setLevel: (level, actorSystem) => {
actorSystem.details = actorSystem.details || {};
actorSystem.details.level = level;
}
},
"pf2e": {
getHP: (actor) => {
const hp = actor.system?.attributes?.hp || {};
return { value: hp.value ?? 0, max: hp.max ?? 0 };
},
setHP: (hp, actorSystem) => {
actorSystem.attributes = actorSystem.attributes || {};
actorSystem.attributes.hp = { value: hp.value, max: hp.max };
},
getLevel: (actor) => {
return actor.system?.details?.level?.value ?? actor.system?.details?.level ?? 0;
},
setLevel: (level, actorSystem) => {
actorSystem.details = actorSystem.details || {};
actorSystem.details.level = actorSystem.details.level || {};
if (typeof actorSystem.details.level === 'object') {
actorSystem.details.level.value = level;
} else {
actorSystem.details.level = level;
}
}
}
};
function getActiveMapping(): SystemMapping {
return SYSTEM_MAPPINGS[currentSystem] || SYSTEM_MAPPINGS["dnd5e"];
}
if (!fs.existsSync(SHARED_DIR)) {
fs.mkdirSync(SHARED_DIR, { recursive: true });
}
function disconnectFromFoundry() {
if (socket) {
console.log("Disconnecting existing WebSocket from Foundry VTT...");
socket.disconnect();
socket = null;
}
actorsList = [];
writeActorsToDat();
}
async function fetchUserId(cookie: string): Promise<string> {
return new Promise((resolve, reject) => {
const tempSocket = io(getFoundryUrl("/"), {
forceNew: true,
multiplex: false,
transports: ["websocket", "polling"],
extraHeaders: {
Cookie: cookie
},
timeout: 5000
});
tempSocket.on("connect", () => {
tempSocket.emit("getJoinData", (data: any) => {
tempSocket.disconnect();
console.log("getJoinData response:", JSON.stringify(data));
if (data && data.users) {
if (data.world) {
currentWorldTitle = data.world.title || "";
currentSystem = data.world.system || "";
}
const user = data.users.find((u: any) => u.name.toLowerCase() === currentUser.toLowerCase());
if (user) {
resolve(user._id || user.id);
} else {
reject(new Error(`User "${currentUser}" not found in world users list. Available: ${data.users.map((u: any) => u.name).join(", ")}`));
}
} else {
reject(new Error(`Failed to retrieve users list. Data: ${data ? JSON.stringify(data) : "null"}`));
}
});
});
tempSocket.on("connect_error", (err) => {
tempSocket.disconnect();
reject(err);
});
});
}
async function connectToFoundry() {
disconnectFromFoundry();
if (!currentWorld) {
console.log("No active world configured. Waiting for world selection... ");
return;
}
console.log(`Connecting to Foundry VTT world "${currentWorld}" as "${currentUser}" at ${FOUNDRY_URL}...`);
try {
const joinPageRes = await fetch(getFoundryUrl("/join"));
if (joinPageRes.url.includes("/setup")) {
console.log(`World "${currentWorld}" is not running. Please launch it first.`);
return;
}
const cookies = joinPageRes.headers.getSetCookie();
console.log("Response cookies from join page:", cookies);
let guestCookie = "";
for (const c of cookies) {
if (c.startsWith("session=")) {
guestCookie = c.split(";")[0];
break;
}
}
console.log("Extracted guest cookie:", guestCookie);
if (!guestCookie) {
throw new Error("Failed to obtain guest session cookie from join page.");
}
console.log("Fetching User ID from join socket...");
const userId = await fetchUserId(guestCookie);
console.log(`Found User ID for "${currentUser}": ${userId}`);
const postData = {
userid: userId,
password: currentPassword,
action: "join"
};
console.log(`Sending POST /join with JSON payload...`);
const loginRes = await fetch(getFoundryUrl("/join"), {
method: "POST",
headers: {
"Content-Type": "application/json",
Cookie: guestCookie
},
body: JSON.stringify(postData),
redirect: "manual"
});
console.log(`Login response status: ${loginRes.status}`);
const setCookieHeaders = loginRes.headers.getSetCookie();
console.log("Login response set-cookie headers:", setCookieHeaders);
let sessionCookie = "";
for (const cookie of setCookieHeaders) {
if (cookie.startsWith("session=")) {
sessionCookie = cookie.split(";")[0];
break;
}
}
if (!sessionCookie) {
if (loginRes.status === 200) {
console.log("No new session cookie returned, using guest cookie as session cookie.");
sessionCookie = guestCookie;
} else {
const htmlText = await loginRes.text();
console.log(`Login failed. Response body length: ${htmlText.length}`);
// Log some body context if we can find error messages
if (htmlText.includes("Invalid password") || htmlText.includes("error")) {
console.log("Found error indicator in body. HTML preview:", htmlText.substring(0, 1000));
} else {
console.log("HTML preview:", htmlText.substring(0, 500));
}
throw new Error(`Failed to get session cookie from login (Status: ${loginRes.status}).`);
}
}
console.log("Authenticated successfully! Connecting WebSocket...");
socket = io(getFoundryUrl("/"), {
forceNew: true,
multiplex: false,
transports: ["websocket", "polling"],
extraHeaders: {
Cookie: sessionCookie
}
});
socket.on("connect", () => {
console.log(`WebSocket connected to Foundry VTT world "${currentWorld}"!`);
fetchActors().catch(err => console.error("Error fetching actors:", err));
});
socket.on("disconnect", () => {
console.log("WebSocket disconnected.");
});
socket.on("createActor", (actor: any) => {
console.log(`Actor created in Foundry: ${actor._id || actor.id}`);
fetchActors().catch(err => console.error("Error fetching actors on create:", err));
});
socket.on("updateActor", (actor: any) => {
console.log(`Actor updated in Foundry: ${actor._id || actor.id}`);
fetchActors().catch(err => console.error("Error fetching actors on update:", err));
});
socket.on("deleteActor", (actor: any) => {
console.log(`Actor deleted in Foundry: ${actor._id || actor.id}`);
fetchActors().catch(err => console.error("Error fetching actors on delete:", err));
});
} catch (err: any) {
console.error("Error connecting to Foundry:", err.message);
console.log("Retrying in 10s...");
setTimeout(connectToFoundry, 10000);
}
}
function fetchActors(): Promise<any[]> {
return new Promise((resolve, reject) => {
if (!socket) {
reject(new Error("WebSocket offline"));
return;
}
const req = {
type: "Actor",
action: "get",
operation: { query: {} }
};
socket.emit("modifyDocument", req, (response: any) => {
if (response && response.result) {
const mapping = getActiveMapping();
actorsList = response.result.map((actor: any) => {
if (actor.system) {
const hp = mapping.getHP(actor);
const level = mapping.getLevel(actor);
actor.system.attributes = actor.system.attributes || {};
actor.system.attributes.hp = hp;
actor.system.details = actor.system.details || {};
actor.system.details.level = level;
}
return actor;
});
console.log(`Fetched ${actorsList.length} actors.`);
writeActorsToDat();
resolve(actorsList);
} else {
console.log("Failed to fetch actors. Response:", JSON.stringify(response));
reject(new Error("Failed to fetch actors"));
}
});
});
}
function writeActorsToDat() {
try {
const mapping = getActiveMapping();
const lines = actorsList.map(actor => {
const id = (actor._id || "").padEnd(16).substring(0, 16);
const name = (actor.name || "").padEnd(20).substring(0, 20);
const type = (actor.type || "").padEnd(10).substring(0, 10);
const hp = mapping.getHP(actor);
const hpCur = String(hp.value).padStart(3, "0").substring(0, 3);
const hpMax = String(hp.max).padStart(3, "0").substring(0, 3);
const lvlVal = mapping.getLevel(actor);
const level = String(lvlVal).padStart(2, "0").substring(0, 2);
const padding = "".padEnd(25);
return `${id}${name}${type}${hpCur}${hpMax}${level}${padding}`;
});
fs.writeFileSync(ACTORS_FILE, lines.join("\n") + "\n", "utf8");
console.log(`Synced actors to ${ACTORS_FILE}`);
} catch (err) {
console.error("Error writing ACTORS.DAT:", err);
}
}
function startWatchingCobolUpdates() {
console.log(`Watching ${UPDATES_FILE}...`);
if (!fs.existsSync(UPDATES_FILE)) {
fs.writeFileSync(UPDATES_FILE, "", "utf8");
}
let fsWait = false;
fs.watch(UPDATES_FILE, (eventType) => {
if (eventType === "change") {
if (fsWait) return;
fsWait = true;
setTimeout(() => {
fsWait = false;
processCobolUpdates();
}, 200);
}
});
}
function processCobolUpdates() {
try {
if (!fs.existsSync(UPDATES_FILE)) return;
const content = fs.readFileSync(UPDATES_FILE, "utf8");
if (!content.trim()) return;
const lines = content.split("\n");
const updatesToSend: any[] = [];
for (const line of lines) {
if (line.length < 54) continue;
const id = line.substring(0, 16).trim();
const name = line.substring(16, 36).trim();
const type = line.substring(36, 46).trim();
const hpCur = parseInt(line.substring(46, 49), 10);
const hpMax = parseInt(line.substring(49, 52), 10);
const level = parseInt(line.substring(52, 54), 10);
if (!id) continue;
const mapping = getActiveMapping();
const actorSystem: any = {};
mapping.setHP({ value: hpCur, max: hpMax }, actorSystem);
mapping.setLevel(level, actorSystem);
const update: any = {
_id: id,
name: name,
system: actorSystem
};
updatesToSend.push(update);
}
if (updatesToSend.length > 0 && socket) {
console.log(`Sending updates from COBOL updates to Foundry...`);
const req = {
type: "Actor",
action: "update",
operation: {
updates: updatesToSend
}
};
socket.emit("modifyDocument", req, async (res: any) => {
console.log("Foundry update response received.");
try {
await fetchActors();
} catch (err) {
console.error("Error refetching actors after COBOL update:", err);
}
fs.writeFileSync(UPDATES_FILE, "", "utf8");
});
}
} catch (err) {
console.error("Error processing COBOL updates:", err);
}
}
async function getAdminSessionCookie(): Promise<string> {
if (!FOUNDRY_ADMIN_KEY) {
throw new Error("FOUNDRY_ADMIN_KEY is not configured in backend environment.");
}
const res = await fetch(getFoundryUrl("/setup"), {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
action: "adminPassword",
adminPassword: FOUNDRY_ADMIN_KEY
}),
redirect: "manual",
});
if (res.status === 403) {
throw new Error("Invalid Administrator Access Key (check FOUNDRY_ADMIN_KEY in .env.backend).");
}
const cookies = res.headers.getSetCookie();
let sessionCookie = "";
for (const cookie of cookies) {
if (cookie.startsWith("session=")) {
sessionCookie = cookie.split(";")[0];
break;
}
}
if (!sessionCookie) {
throw new Error("Failed to get session cookie from setup login.");
}
return sessionCookie;
}
function readWorldsCache(): any[] {
try {
if (fs.existsSync(WORLDS_CACHE_FILE)) {
const content = fs.readFileSync(WORLDS_CACHE_FILE, "utf8");
return JSON.parse(content);
}
} catch (err) {
console.error("Error reading worlds cache:", err);
}
return [];
}
function writeWorldsCache(worlds: any[]) {
try {
fs.writeFileSync(WORLDS_CACHE_FILE, JSON.stringify(worlds, null, 2), "utf8");
console.log("Worlds list cached successfully.");
} catch (err) {
console.error("Error writing worlds cache:", err);
}
}
async function getActiveWorldFromJoin(): Promise<{ id: string; title: string } | null> {
try {
const joinPageRes = await fetch(getFoundryUrl("/join"));
if (joinPageRes.url.includes("/setup")) {
return null;
}
const cookies = joinPageRes.headers.getSetCookie();
let guestCookie = "";
for (const c of cookies) {
if (c.startsWith("session=")) {
guestCookie = c.split(";")[0];
break;
}
}
if (!guestCookie) return null;
return new Promise((resolve) => {
const tempSocket = io(getFoundryUrl("/"), {
forceNew: true,
multiplex: false,
transports: ["websocket", "polling"],
extraHeaders: {
Cookie: guestCookie
},
timeout: 4000
});
tempSocket.on("connect", () => {
tempSocket.emit("getJoinData", (data: any) => {
tempSocket.disconnect();
if (data && data.world) {
resolve({
id: data.world.id,
title: data.world.title,
system: data.world.system
});
} else {
resolve(null);
}
});
});
tempSocket.on("connect_error", () => {
tempSocket.disconnect();
resolve(null);
});
});
} catch (err) {
console.error("Error in getActiveWorldFromJoin:", err);
return null;
}
}
async function handleWorldsListFallback(originalError: any): Promise<any[]> {
const activeWorld = await getActiveWorldFromJoin();
if (activeWorld) {
console.log(`handleWorldsListFallback: Detected active world "${activeWorld.id}" from join page.`);
currentWorldTitle = activeWorld.title;
currentSystem = activeWorld.system || "";
const cached = readWorldsCache();
if (cached && cached.length > 0) {
if (!cached.find((w: any) => w.id === activeWorld.id)) {
cached.push({ id: activeWorld.id, title: activeWorld.title });
writeWorldsCache(cached);
}
return cached.map((w: any) => ({
id: w.id,
title: w.title,
active: w.id === activeWorld.id
}));
} else {
const singleWorldList = [{ id: activeWorld.id, title: activeWorld.title, active: true }];
writeWorldsCache([{ id: activeWorld.id, title: activeWorld.title }]);
return singleWorldList;
}
} else {
const cached = readWorldsCache();
if (cached && cached.length > 0) {
console.log("handleWorldsListFallback: No active world detected, returning cached worlds list with active: false.");
return cached.map((w: any) => ({
id: w.id,
title: w.title,
active: false
}));
}
throw originalError || new Error("No worlds data returned from setup socket");
}
}
async function fetchWorldsList(): Promise<any[]> {
console.log("fetchWorldsList: Authenticating and getting setup cookie...");
let cookie: string;
try {
cookie = await getAdminSessionCookie();
} catch (err: any) {
console.error("fetchWorldsList: Failed to get admin session cookie:", err.message);
return await handleWorldsListFallback(err);
}
console.log("fetchWorldsList: Cookie retrieved successfully. Connecting setup socket...");
try {
const worlds = await new Promise<any[]>((resolve, reject) => {
const socketSetup = io(getFoundryUrl("/"), {
forceNew: true,
multiplex: false,
transports: ["websocket", "polling"],
extraHeaders: {
Cookie: cookie
},
timeout: 5000
});
socketSetup.on("connect", () => {
console.log("fetchWorldsList: Setup socket connected. Emitting getSetupData...");
socketSetup.emit("getSetupData", (data: any) => {
socketSetup.disconnect();
if (data && data.worlds) {
const activeWorldId = data.options?.world || null;
console.log(`fetchWorldsList: Found ${data.worlds.length} worlds. Active world ID: ${activeWorldId}`);
const worldsList = data.worlds.map((w: any) => ({
id: w.id,
title: w.title,
active: w.id === activeWorldId
}));
const activeWorld = worldsList.find(w => w.active);
if (activeWorld) {
currentWorldTitle = activeWorld.title;
}
writeWorldsCache(worldsList.map((w: any) => ({ id: w.id, title: w.title })));
resolve(worldsList);
} else {
console.log("fetchWorldsList: No worlds data found in response. Full data keys:", data ? Object.keys(data) : "null");
reject(new Error("No worlds data returned from setup socket"));
}
});
});
socketSetup.on("connect_error", (err) => {
console.error("fetchWorldsList: Setup socket connection error:", err.message);
socketSetup.disconnect();
reject(err);
});
});
return worlds;
} catch (err) {
console.log("fetchWorldsList: Setup socket failed or returned empty. Attempting fallback...");
return await handleWorldsListFallback(err);
}
}
async function getActiveGMCookie(): Promise<string> {
const joinPageRes = await fetch(getFoundryUrl("/join"));
if (joinPageRes.url.includes("/setup")) {
return "";
}
const cookies = joinPageRes.headers.getSetCookie();
let guestCookie = "";
for (const c of cookies) {
if (c.startsWith("session=")) {
guestCookie = c.split(";")[0];
break;
}
}
if (!guestCookie) {
throw new Error("Failed to get guest session cookie for GM login.");
}
const userId = await fetchUserId(guestCookie);
const postData = {
userid: userId,
password: currentPassword,
action: "join"
};
const loginRes = await fetch(getFoundryUrl("/join"), {
method: "POST",
headers: {
"Content-Type": "application/json",
Cookie: guestCookie
},
body: JSON.stringify(postData),
redirect: "manual"
});
const setCookieHeaders = loginRes.headers.getSetCookie();
let sessionCookie = "";
for (const cookie of setCookieHeaders) {
if (cookie.startsWith("session=")) {
sessionCookie = cookie.split(";")[0];
break;
}
}
if (!sessionCookie) {
if (loginRes.status === 200) {
console.log("No new session cookie returned for GM, using guest cookie as session cookie.");
sessionCookie = guestCookie;
} else {
console.error(`GM login failed with status ${loginRes.status}`);
}
}
return sessionCookie;
}
async function launchWorld(worldId: string) {
const cookie = await getAdminSessionCookie();
const res = await fetch(getFoundryUrl("/setup"), {
method: "POST",
headers: {
"Content-Type": "application/json",
Cookie: cookie,
},
body: JSON.stringify({
action: "launchWorld",
world: worldId
}),
});
if (!res.ok) {
throw new Error(`Failed to launch world ${worldId}`);
}
}
async function shutdownWorld() {
console.log("Shutting down active world...");
let cookie = "";
try {
cookie = await getActiveGMCookie();
} catch (err: any) {
console.log("Could not get GM cookie for shutdown (setup might be active):", err.message);
}
if (!cookie) {
console.log("Attempting shutdown using Admin access...");
cookie = await getAdminSessionCookie();
}
const res = await fetch(getFoundryUrl("/setup"), {
method: "POST",
headers: {
"Content-Type": "application/json",
Cookie: cookie,
},
body: JSON.stringify({
shutdown: true
}),
});
if (!res.ok) {
throw new Error(`Failed to shut down active world. Status: ${res.status}`);
}
console.log("World shutdown successfully completed.");
}
Bun.serve({
port: PORT,
async fetch(req) {
const url = new URL(req.url);
const headers = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type",
"Content-Type": "application/json"
};
if (req.method === "OPTIONS") {
return new Response(null, { headers });
}
if (url.pathname === "/api/actors" && req.method === "GET") {
return new Response(JSON.stringify(actorsList), { headers });
}
if (url.pathname === "/api/actors/refetch" && req.method === "POST") {
try {
if (socket) {
const list = await fetchActors();
return new Response(JSON.stringify(list), { headers });
} else {
return new Response(JSON.stringify({ error: "WebSocket offline" }), { status: 503, headers });
}
} catch (err: any) {
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
}
}
if (url.pathname === "/api/actors/update" && req.method === "POST") {
try {
const body: any = await req.json();
const { id, updates } = body;
if (!id || !updates) {
return new Response(JSON.stringify({ error: "Missing parameters" }), { status: 400, headers });
}
if (socket) {
if (updates.system) {
const mapping = getActiveMapping();
const hpVal = updates.system.attributes?.hp?.value;
const hpMax = updates.system.attributes?.hp?.max;
const level = updates.system.details?.level;
const actorSystem: any = {};
if (hpVal !== undefined && hpMax !== undefined) {
mapping.setHP({ value: hpVal, max: hpMax }, actorSystem);
}
if (level !== undefined) {
mapping.setLevel(level, actorSystem);
}
updates.system = actorSystem;
}
const reqObj = {
type: "Actor",
action: "update",
operation: {
updates: [{ _id: id, ...updates }]
}
};
await new Promise<void>((resolve, reject) => {
socket.emit("modifyDocument", reqObj, async (res: any) => {
console.log(`Updated Actor ${id} via REST API. Response:`, JSON.stringify(res));
try {
await fetchActors();
resolve();
} catch (err) {
reject(err);
}
});
});
return new Response(JSON.stringify({ success: true }), { headers });
} else {
return new Response(JSON.stringify({ error: "WebSocket offline" }), { status: 503, headers });
}
} catch (err: any) {
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
}
}
if (url.pathname === "/api/worlds" && req.method === "GET") {
try {
const worlds = await fetchWorldsList();
return new Response(JSON.stringify(worlds), { headers });
} catch (err: any) {
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
}
}
if (url.pathname === "/api/worlds/launch" && req.method === "POST") {
try {
const { worldId } = await req.json();
if (!worldId) {
return new Response(JSON.stringify({ error: "Missing worldId" }), { status: 400, headers });
}
// Find if there's currently an active world
const worlds = await fetchWorldsList();
const activeWorld = worlds.find(w => w.active);
if (activeWorld) {
console.log(`Shutting down active world ${activeWorld.id} first...`);
await shutdownWorld();
await new Promise(r => setTimeout(r, 2000));
}
console.log(`Launching world ${worldId}...`);
await launchWorld(worldId);
await new Promise(r => setTimeout(r, 3000));
currentWorld = worldId;
connectToFoundry();
return new Response(JSON.stringify({ success: true }), { headers });
} catch (err: any) {
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
}
}
if (url.pathname === "/api/worlds/shutdown" && req.method === "POST") {
try {
console.log("Shutting down active world...");
await shutdownWorld();
disconnectFromFoundry();
currentWorld = "";
return new Response(JSON.stringify({ success: true }), { headers });
} catch (err: any) {
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
}
}
if (url.pathname === "/api/worlds/select" && req.method === "POST") {
try {
const body: any = await req.json();
const { worldId, user, password } = body;
if (!worldId) {
return new Response(JSON.stringify({ error: "Missing worldId" }), { status: 400, headers });
}
console.log(`Selecting world targeting: ${worldId}`);
currentWorld = worldId;
if (user) currentUser = user;
if (password !== undefined) currentPassword = password;
connectToFoundry();
return new Response(JSON.stringify({ success: true, world: currentWorld, user: currentUser }), { headers });
} catch (err: any) {
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
}
}
if (url.pathname === "/api/packages/update" && req.method === "POST") {
try {
const body: any = await req.json();
const { manifest, type } = body;
if (!manifest) {
return new Response(JSON.stringify({ error: "Missing manifest URL" }), { status: 400, headers });
}
const activeWorld = await getActiveWorldFromJoin();
if (activeWorld) {
return new Response(JSON.stringify({ error: `A world (${activeWorld.title}) is currently active. Please shut down the world before updating packages.` }), { status: 400, headers });
}
console.log(`Installing/updating package of type ${type || "system"} from manifest: ${manifest}`);
const cookie = await getAdminSessionCookie();
const res = await fetch(getFoundryUrl("/setup"), {
method: "POST",
headers: {
"Content-Type": "application/json",
Cookie: cookie,
},
body: JSON.stringify({
action: "installPackage",
type: type || "system",
manifest: manifest
}),
});
if (!res.ok) {
const errMsg = await res.text();
throw new Error(`Foundry Setup returned status ${res.status}: ${errMsg}`);
}
const resData = await res.json();
console.log("Package update response:", resData);
return new Response(JSON.stringify({ success: true, details: resData }), { headers });
} catch (err: any) {
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers });
}
}
if (url.pathname === "/api/worlds/active" && req.method === "GET") {
return new Response(JSON.stringify({
world: currentWorld,
worldTitle: currentWorldTitle || currentWorld,
system: currentSystem,
user: currentUser,
connected: socket?.connected || false
}), { headers });
}
return new Response(JSON.stringify({ error: "Not Found" }), { status: 404, headers });
}
});
console.log(`Proxy Backend listening on port ${PORT}`);
connectToFoundry();
startWatchingCobolUpdates();