Files
MAD-Treasure-Hunt/server/server.js
T
bobbert 1e405bb87f Add FOV cone visualization to map screens; refactor geoMath utility for heading and cone calculations
Have FOV config as it's own thing so it's easier to change the size of the FOV triangle without having to change multiple parts of the maths for it.

Fov triangle needed because for some reason maps doesn't let us have our own fov ??
2026-04-07 17:19:25 +01:00

714 lines
26 KiB
JavaScript

const http = require('http');
const fs = require('fs');
const path = require('path');
// --- Configuration ---
const PORT = process.env.PORT || 3000;
const DATA_DIR = path.join(__dirname, 'data');
// --- Data Helpers ---
const readJson = (fileName) => JSON.parse(fs.readFileSync(path.join(DATA_DIR, fileName), 'utf-8'));
const writeJson = (fileName, data) => fs.writeFileSync(path.join(DATA_DIR, fileName), JSON.stringify(data, null, 2));
// --- Utility Helpers ---
const hashPassword = (value) => {
let hash = 0;
for (let i = 0; i < value.length; i += 1) {
hash = (hash << 5) - hash + value.charCodeAt(i);
hash |= 0;
}
return `H_${Math.abs(hash)}`;
};
const nextId = (rows, key) => rows.reduce((max, row) => {
const val = Number(row[key] || 0);
return val > max ? val : max;
}, 0) + 1;
const generateCode = (len = 5) => {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let code = '';
for (let i = 0; i < len; i++) code += chars.charAt(Math.floor(Math.random() * chars.length));
return code;
};
// --- Request / Response Helpers ---
const parseBody = (req) => new Promise((resolve, reject) => {
let body = '';
req.on('data', (chunk) => { body += chunk.toString(); });
req.on('end', () => {
try { resolve(body ? JSON.parse(body) : {}); }
catch { reject(new Error('Invalid JSON body')); }
});
req.on('error', reject);
});
const sendJson = (res, statusCode, payload) => {
const headers = {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET,POST,PUT,DELETE,OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type',
};
if (statusCode === 204) {
res.writeHead(204, headers);
res.end();
return;
}
headers['Content-Type'] = 'application/json';
res.writeHead(statusCode, headers);
res.end(JSON.stringify(payload));
};
// --- Simple Router ---
const routes = [];
const addRoute = (method, pattern, handler) => {
const keys = [];
const regexStr = pattern.replace(/:([a-zA-Z]+)/g, (_, key) => {
keys.push(key);
return '([^/]+)';
});
routes.push({ method, regex: new RegExp(`^${regexStr}$`), keys, handler });
};
const matchRoute = (method, pathname) => {
for (const r of routes) {
if (r.method !== method) continue;
const match = pathname.match(r.regex);
if (match) {
const params = {};
r.keys.forEach((key, i) => { params[key] = match[i + 1]; });
return { handler: r.handler, params };
}
}
return null;
};
// ======================================================================
// AUTH ROUTES
// ======================================================================
addRoute('POST', '/auth/login', async (req, res) => {
const payload = await parseBody(req);
const users = readJson('users.json');
const user = users.find((u) => u.email.toLowerCase() === String(payload.email || '').toLowerCase());
if (!user) return sendJson(res, 400, { message: 'Email not found' });
if (user.passwordHash !== hashPassword(String(payload.password || '')))
return sendJson(res, 400, { message: 'Invalid password' });
sendJson(res, 200, user);
});
addRoute('POST', '/auth/register', async (req, res) => {
const payload = await parseBody(req);
const username = String(payload.username || '').trim();
const email = String(payload.email || '').trim().toLowerCase();
const password = String(payload.password || '');
const confirmPassword = String(payload.confirmPassword || '');
const accountType = String(payload.accountType || 'Individual');
if (!username || !email || !password || !confirmPassword)
return sendJson(res, 400, { message: 'Missing required registration fields' });
if (password !== confirmPassword)
return sendJson(res, 400, { message: 'Passwords do not match' });
const users = readJson('users.json');
if (users.some((u) => u.email.toLowerCase() === email))
return sendJson(res, 400, { message: 'Email already in use' });
const user = {
Uid: nextId(users, 'Uid'),
username,
email,
passwordHash: hashPassword(password),
isBusiness: accountType === 'Business/School',
IsAcceptedAdmin: false,
Gid: null,
SGid: null,
TGid: null,
};
users.push(user);
writeJson('users.json', users);
sendJson(res, 201, user);
});
// ======================================================================
// USERS CRUDL
// ======================================================================
addRoute('GET', '/users', (req, res) => {
sendJson(res, 200, readJson('users.json'));
});
addRoute('GET', '/users/:id', (req, res, params) => {
const users = readJson('users.json');
const user = users.find((u) => String(u.Uid) === params.id);
if (!user) return sendJson(res, 404, { message: 'User not found' });
sendJson(res, 200, user);
});
addRoute('PUT', '/users/:id', async (req, res, params) => {
const payload = await parseBody(req);
const users = readJson('users.json');
const idx = users.findIndex((u) => String(u.Uid) === params.id);
if (idx === -1) return sendJson(res, 404, { message: 'User not found' });
users[idx] = { ...users[idx], ...payload, Uid: users[idx].Uid };
writeJson('users.json', users);
sendJson(res, 200, users[idx]);
});
addRoute('DELETE', '/users/:id', (req, res, params) => {
const users = readJson('users.json');
const idx = users.findIndex((u) => String(u.Uid) === params.id);
if (idx === -1) return sendJson(res, 404, { message: 'User not found' });
users.splice(idx, 1);
writeJson('users.json', users);
sendJson(res, 204, null);
});
// ======================================================================
// GAME TYPES (read-only list)
// ======================================================================
addRoute('GET', '/game-types', (req, res) => {
sendJson(res, 200, readJson('game-types.json'));
});
// ======================================================================
// GROUPS CRUDL
// ======================================================================
addRoute('GET', '/groups', (req, res, params, query) => {
let groups = readJson('groups.json');
if (query.CreatedByUid) groups = groups.filter((g) => String(g.CreatedByUid) === query.CreatedByUid);
sendJson(res, 200, groups);
});
addRoute('GET', '/groups/:gid', (req, res, params) => {
const groups = readJson('groups.json');
const group = groups.find((g) => String(g.Gid) === params.gid);
if (!group) return sendJson(res, 404, { message: 'Group not found' });
sendJson(res, 200, group);
});
addRoute('POST', '/groups', async (req, res) => {
const payload = await parseBody(req);
const groups = readJson('groups.json');
const group = {
Gid: nextId(groups, 'Gid'),
GroupName: payload.GroupName || 'Unnamed Group',
BusinessOrSchoolName: payload.BusinessOrSchoolName || null,
GameType: payload.GameType || 'private',
AdminJoinCode: generateCode(),
CreatedByUid: payload.CreatedByUid,
MaxMemberSubgroups: payload.MaxMemberSubgroups || 1,
TeamsEnabled: payload.TeamsEnabled !== undefined ? payload.TeamsEnabled : true,
ApprovedAdmins: [payload.CreatedByUid],
CreatedAt: new Date().toISOString(),
};
groups.push(group);
writeJson('groups.json', groups);
// Create default admin and member subgroups
const subgroups = readJson('subgroups.json');
const adminSg = {
SGid: nextId(subgroups, 'SGid'),
SubGroupName: 'Admins',
Gid: group.Gid,
JoinCode: null,
IsAdminGroup: true,
CacheTriggerMeters: 20,
};
subgroups.push(adminSg);
const memberSg = {
SGid: nextId(subgroups, 'SGid'),
SubGroupName: 'Members',
Gid: group.Gid,
JoinCode: generateCode(),
IsAdminGroup: false,
CacheTriggerMeters: 20,
};
subgroups.push(memberSg);
writeJson('subgroups.json', subgroups);
// Add creator as admin membership
const memberships = readJson('subgroup-memberships.json');
memberships.push({
id: nextId(memberships, 'id'),
Uid: payload.CreatedByUid,
Gid: group.Gid,
SGid: adminSg.SGid,
IsAcceptedAdmin: true,
});
writeJson('subgroup-memberships.json', memberships);
// Initialise empty game-data entry
const gameData = readJson('game-data.json');
gameData.push({ Gid: group.Gid, GeoCaches: [] });
writeJson('game-data.json', gameData);
// Update the creating user
const users = readJson('users.json');
const uIdx = users.findIndex((u) => u.Uid === payload.CreatedByUid);
if (uIdx !== -1) {
users[uIdx].Gid = group.Gid;
users[uIdx].SGid = adminSg.SGid;
users[uIdx].IsAcceptedAdmin = true;
writeJson('users.json', users);
}
sendJson(res, 201, group);
});
addRoute('PUT', '/groups/:gid', async (req, res, params) => {
const payload = await parseBody(req);
const groups = readJson('groups.json');
const idx = groups.findIndex((g) => String(g.Gid) === params.gid);
if (idx === -1) return sendJson(res, 404, { message: 'Group not found' });
groups[idx] = { ...groups[idx], ...payload, Gid: groups[idx].Gid };
writeJson('groups.json', groups);
sendJson(res, 200, groups[idx]);
});
addRoute('DELETE', '/groups/:gid', (req, res, params) => {
const groups = readJson('groups.json');
const idx = groups.findIndex((g) => String(g.Gid) === params.gid);
if (idx === -1) return sendJson(res, 404, { message: 'Group not found' });
groups.splice(idx, 1);
writeJson('groups.json', groups);
sendJson(res, 204, null);
});
// ======================================================================
// SUBGROUPS CRUDL
// ======================================================================
addRoute('GET', '/subgroups', (req, res, params, query) => {
let rows = readJson('subgroups.json');
if (query.Gid) rows = rows.filter((r) => String(r.Gid) === query.Gid);
sendJson(res, 200, rows);
});
addRoute('GET', '/subgroups/:id', (req, res, params) => {
const rows = readJson('subgroups.json');
const row = rows.find((r) => String(r.SGid) === params.id);
if (!row) return sendJson(res, 404, { message: 'Subgroup not found' });
sendJson(res, 200, row);
});
addRoute('POST', '/subgroups', async (req, res) => {
const payload = await parseBody(req);
const rows = readJson('subgroups.json');
payload.SGid = nextId(rows, 'SGid');
if (!payload.JoinCode) payload.JoinCode = generateCode();
rows.push(payload);
writeJson('subgroups.json', rows);
sendJson(res, 201, payload);
});
addRoute('PUT', '/subgroups/:id', async (req, res, params) => {
const payload = await parseBody(req);
const rows = readJson('subgroups.json');
const idx = rows.findIndex((r) => String(r.SGid) === params.id);
if (idx === -1) return sendJson(res, 404, { message: 'Subgroup not found' });
rows[idx] = { ...rows[idx], ...payload, SGid: rows[idx].SGid };
writeJson('subgroups.json', rows);
sendJson(res, 200, rows[idx]);
});
addRoute('DELETE', '/subgroups/:id', (req, res, params) => {
const rows = readJson('subgroups.json');
const idx = rows.findIndex((r) => String(r.SGid) === params.id);
if (idx === -1) return sendJson(res, 404, { message: 'Subgroup not found' });
rows.splice(idx, 1);
writeJson('subgroups.json', rows);
sendJson(res, 204, null);
});
// ======================================================================
// SUBGROUP MEMBERSHIPS CRUDL
// ======================================================================
addRoute('GET', '/subgroup-memberships', (req, res, params, query) => {
let rows = readJson('subgroup-memberships.json');
if (query.Gid) rows = rows.filter((r) => String(r.Gid) === query.Gid);
if (query.Uid) rows = rows.filter((r) => String(r.Uid) === query.Uid);
if (query.SGid) rows = rows.filter((r) => String(r.SGid) === query.SGid);
sendJson(res, 200, rows);
});
addRoute('GET', '/subgroup-memberships/:id', (req, res, params) => {
const rows = readJson('subgroup-memberships.json');
const row = rows.find((r) => String(r.id) === params.id);
if (!row) return sendJson(res, 404, { message: 'Membership not found' });
sendJson(res, 200, row);
});
addRoute('POST', '/subgroup-memberships', async (req, res) => {
const payload = await parseBody(req);
const rows = readJson('subgroup-memberships.json');
// If a JoinCode was supplied, look up the matching subgroup
if (payload.JoinCode) {
const subgroups = readJson('subgroups.json');
const sg = subgroups.find((s) => s.JoinCode === payload.JoinCode);
if (!sg) return sendJson(res, 400, { message: 'Invalid join code' });
payload.Gid = sg.Gid;
payload.SGid = sg.SGid;
}
payload.id = nextId(rows, 'id');
payload.IsAcceptedAdmin = payload.IsAcceptedAdmin || false;
rows.push(payload);
writeJson('subgroup-memberships.json', rows);
// Update user record with group / subgroup
const users = readJson('users.json');
const uIdx = users.findIndex((u) => u.Uid === payload.Uid);
if (uIdx !== -1) {
users[uIdx].Gid = payload.Gid;
users[uIdx].SGid = payload.SGid;
writeJson('users.json', users);
}
sendJson(res, 201, payload);
});
addRoute('PUT', '/subgroup-memberships/:id', async (req, res, params) => {
const payload = await parseBody(req);
const rows = readJson('subgroup-memberships.json');
const idx = rows.findIndex((r) => String(r.id) === params.id);
if (idx === -1) return sendJson(res, 404, { message: 'Membership not found' });
rows[idx] = { ...rows[idx], ...payload, id: rows[idx].id };
writeJson('subgroup-memberships.json', rows);
sendJson(res, 200, rows[idx]);
});
addRoute('DELETE', '/subgroup-memberships/:id', (req, res, params) => {
const rows = readJson('subgroup-memberships.json');
const idx = rows.findIndex((r) => String(r.id) === params.id);
if (idx === -1) return sendJson(res, 404, { message: 'Membership not found' });
rows.splice(idx, 1);
writeJson('subgroup-memberships.json', rows);
sendJson(res, 204, null);
});
// ======================================================================
// TEAMS CRUDL
// ======================================================================
addRoute('GET', '/teams', (req, res, params, query) => {
let rows = readJson('teams.json');
if (query.Gid) rows = rows.filter((r) => String(r.Gid) === query.Gid);
sendJson(res, 200, rows);
});
addRoute('GET', '/teams/:id', (req, res, params) => {
const rows = readJson('teams.json');
const row = rows.find((r) => String(r.Tid) === params.id);
if (!row) return sendJson(res, 404, { message: 'Team not found' });
sendJson(res, 200, row);
});
addRoute('POST', '/teams', async (req, res) => {
const payload = await parseBody(req);
const rows = readJson('teams.json');
payload.Tid = nextId(rows, 'Tid');
if (!payload.JoinCode) payload.JoinCode = generateCode();
rows.push(payload);
writeJson('teams.json', rows);
sendJson(res, 201, payload);
});
addRoute('PUT', '/teams/:id', async (req, res, params) => {
const payload = await parseBody(req);
const rows = readJson('teams.json');
const idx = rows.findIndex((r) => String(r.Tid) === params.id);
if (idx === -1) return sendJson(res, 404, { message: 'Team not found' });
rows[idx] = { ...rows[idx], ...payload, Tid: rows[idx].Tid };
writeJson('teams.json', rows);
sendJson(res, 200, rows[idx]);
});
addRoute('DELETE', '/teams/:id', (req, res, params) => {
const rows = readJson('teams.json');
const idx = rows.findIndex((r) => String(r.Tid) === params.id);
if (idx === -1) return sendJson(res, 404, { message: 'Team not found' });
rows.splice(idx, 1);
writeJson('teams.json', rows);
sendJson(res, 204, null);
});
// ======================================================================
// TEAM MEMBERS CRUDL
// ======================================================================
addRoute('GET', '/team-members', (req, res, params, query) => {
let rows = readJson('team-members.json');
if (query.Tid) rows = rows.filter((r) => String(r.Tid) === query.Tid);
if (query.Uid) rows = rows.filter((r) => String(r.Uid) === query.Uid);
sendJson(res, 200, rows);
});
addRoute('GET', '/team-members/:id', (req, res, params) => {
const rows = readJson('team-members.json');
const row = rows.find((r) => String(r.id) === params.id);
if (!row) return sendJson(res, 404, { message: 'Team member not found' });
sendJson(res, 200, row);
});
addRoute('POST', '/team-members', async (req, res) => {
const payload = await parseBody(req);
const rows = readJson('team-members.json');
// If a JoinCode was supplied, look up the matching team
if (payload.JoinCode) {
const teams = readJson('teams.json');
const team = teams.find((t) => t.JoinCode === payload.JoinCode);
if (!team) return sendJson(res, 400, { message: 'Invalid team join code' });
payload.Tid = team.Tid;
}
payload.id = nextId(rows, 'id');
rows.push(payload);
writeJson('team-members.json', rows);
// Update user TGid
const users = readJson('users.json');
const uIdx = users.findIndex((u) => u.Uid === payload.Uid);
if (uIdx !== -1) {
users[uIdx].TGid = payload.Tid;
writeJson('users.json', users);
}
sendJson(res, 201, payload);
});
addRoute('PUT', '/team-members/:id', async (req, res, params) => {
const payload = await parseBody(req);
const rows = readJson('team-members.json');
const idx = rows.findIndex((r) => String(r.id) === params.id);
if (idx === -1) return sendJson(res, 404, { message: 'Team member not found' });
rows[idx] = { ...rows[idx], ...payload, id: rows[idx].id };
writeJson('team-members.json', rows);
sendJson(res, 200, rows[idx]);
});
addRoute('DELETE', '/team-members/:id', (req, res, params) => {
const rows = readJson('team-members.json');
const idx = rows.findIndex((r) => String(r.id) === params.id);
if (idx === -1) return sendJson(res, 404, { message: 'Team member not found' });
rows.splice(idx, 1);
writeJson('team-members.json', rows);
sendJson(res, 204, null);
});
// ======================================================================
// ADMIN WAITLIST CRUDL
// ======================================================================
addRoute('GET', '/admin-waitlist', (req, res, params, query) => {
let rows = readJson('admin-waitlist.json');
if (query.Gid) rows = rows.filter((r) => String(r.Gid) === query.Gid);
sendJson(res, 200, rows);
});
addRoute('POST', '/admin-waitlist', async (req, res) => {
const payload = await parseBody(req);
const rows = readJson('admin-waitlist.json');
payload.id = nextId(rows, 'id');
rows.push(payload);
writeJson('admin-waitlist.json', rows);
sendJson(res, 201, payload);
});
addRoute('DELETE', '/admin-waitlist/:id', (req, res, params) => {
const rows = readJson('admin-waitlist.json');
const idx = rows.findIndex((r) => String(r.id) === params.id);
if (idx === -1) return sendJson(res, 404, { message: 'Waitlist entry not found' });
rows.splice(idx, 1);
writeJson('admin-waitlist.json', rows);
sendJson(res, 204, null);
});
// ======================================================================
// GAME DATA CRUDL
// ======================================================================
addRoute('GET', '/game-data', (req, res) => {
sendJson(res, 200, readJson('game-data.json'));
});
addRoute('GET', '/game-data/:gid', (req, res, params) => {
const rows = readJson('game-data.json');
const row = rows.find((r) => String(r.Gid) === params.gid);
if (!row) return sendJson(res, 404, { message: 'Game data not found' });
sendJson(res, 200, row);
});
// ======================================================================
// CACHES (nested under game-data) CRUDL
// ======================================================================
// Helper: convert storage cache to API response format
const toCacheApi = (cache, gid) => ({
id: cache.CacheId,
coordinates: { latitude: cache.Latitude, longitude: cache.Longitude },
radius: cache.TriggerMeters,
clue: cache.Title,
groupId: Number(gid),
subgroupId: cache.SGid,
ClaimedByUid: cache.ClaimedByUid || null,
ClaimedByTid: cache.ClaimedByTid || null,
});
// Helper: convert API payload to storage cache format
const toCacheStorage = (data, cacheId) => ({
CacheId: cacheId,
Title: data.clue || data.Title || '',
Latitude: data.latitude != null ? data.latitude : (data.Latitude || 0),
Longitude: data.longitude != null ? data.longitude : (data.Longitude || 0),
TriggerMeters: data.radius || data.TriggerMeters || 20,
SGid: data.subgroupId != null ? data.subgroupId : (data.SGid || null),
ClaimedByUid: data.ClaimedByUid || null,
ClaimedByTid: data.ClaimedByTid || null,
});
// GET /game-data/:gid/caches — list caches for a group
addRoute('GET', '/game-data/:gid/caches', (req, res, params, query) => {
const rows = readJson('game-data.json');
const entry = rows.find((r) => String(r.Gid) === params.gid);
if (!entry) return sendJson(res, 404, { message: 'Game data not found' });
let caches = (entry.GeoCaches || []).map((c) => toCacheApi(c, params.gid));
if (query.SGid) caches = caches.filter((c) => String(c.subgroupId) === query.SGid);
sendJson(res, 200, caches);
});
// GET /game-data/:gid/caches/:cacheId — read single cache
addRoute('GET', '/game-data/:gid/caches/:cacheId', (req, res, params) => {
const rows = readJson('game-data.json');
const entry = rows.find((r) => String(r.Gid) === params.gid);
if (!entry) return sendJson(res, 404, { message: 'Game data not found' });
const cache = (entry.GeoCaches || []).find((c) => c.CacheId === params.cacheId);
if (!cache) return sendJson(res, 404, { message: 'Cache not found' });
sendJson(res, 200, toCacheApi(cache, params.gid));
});
// POST /game-data/:gid/caches — create a new cache
addRoute('POST', '/game-data/:gid/caches', async (req, res, params) => {
const payload = await parseBody(req);
const rows = readJson('game-data.json');
const entry = rows.find((r) => String(r.Gid) === params.gid);
if (!entry) return sendJson(res, 404, { message: 'Game data not found' });
const existingIds = (entry.GeoCaches || []).map((c) => {
const num = parseInt(String(c.CacheId).replace(/\D/g, ''), 10);
return isNaN(num) ? 0 : num;
});
const nextNum = existingIds.length > 0 ? Math.max(...existingIds) + 1 : 1;
const cacheId = `C${nextNum}`;
const stored = toCacheStorage(payload, cacheId);
if (!entry.GeoCaches) entry.GeoCaches = [];
entry.GeoCaches.push(stored);
writeJson('game-data.json', rows);
sendJson(res, 201, toCacheApi(stored, params.gid));
});
// PUT /game-data/:gid/caches/:cacheId — update a cache
addRoute('PUT', '/game-data/:gid/caches/:cacheId', async (req, res, params) => {
const payload = await parseBody(req);
const rows = readJson('game-data.json');
const entry = rows.find((r) => String(r.Gid) === params.gid);
if (!entry) return sendJson(res, 404, { message: 'Game data not found' });
const idx = (entry.GeoCaches || []).findIndex((c) => c.CacheId === params.cacheId);
if (idx === -1) return sendJson(res, 404, { message: 'Cache not found' });
const existing = entry.GeoCaches[idx];
const updated = { ...existing, ...toCacheStorage(payload, params.cacheId) };
entry.GeoCaches[idx] = updated;
writeJson('game-data.json', rows);
sendJson(res, 200, toCacheApi(updated, params.gid));
});
// DELETE /game-data/:gid/caches/:cacheId — delete a cache
addRoute('DELETE', '/game-data/:gid/caches/:cacheId', (req, res, params) => {
const rows = readJson('game-data.json');
const entry = rows.find((r) => String(r.Gid) === params.gid);
if (!entry) return sendJson(res, 404, { message: 'Game data not found' });
const idx = (entry.GeoCaches || []).findIndex((c) => c.CacheId === params.cacheId);
if (idx === -1) return sendJson(res, 404, { message: 'Cache not found' });
entry.GeoCaches.splice(idx, 1);
writeJson('game-data.json', rows);
sendJson(res, 204, null);
});
// ======================================================================
// HEALTH CHECK
// ======================================================================
addRoute('GET', '/health', (req, res) => {
sendJson(res, 200, { ok: true });
});
// ======================================================================
// SERVER
// ======================================================================
const server = http.createServer(async (req, res) => {
// CORS preflight
if (req.method === 'OPTIONS') return sendJson(res, 200, { ok: true });
const url = new URL(req.url, 'http://localhost');
const pathname = url.pathname.replace(/\/+$/, '') || '/';
const query = Object.fromEntries(url.searchParams);
const matched = matchRoute(req.method, pathname);
if (matched) {
try {
await matched.handler(req, res, matched.params, query);
} catch (err) {
sendJson(res, 500, { message: err.message });
}
return;
}
sendJson(res, 404, { message: 'Not found' });
});
server.listen(PORT, '0.0.0.0', () => {
process.stdout.write(`Server running on http://0.0.0.0:${PORT}\n`);
process.stdout.write(` -> Local: http://localhost:${PORT}\n`);
// Log the LAN IP so users know what to point to from other devices
const nets = require('os').networkInterfaces();
for (const name of Object.keys(nets)) {
for (const net of nets[name]) {
if (net.family === 'IPv4' && !net.internal) {
process.stdout.write(` -> Network: http://${net.address}:${PORT}\n`);
}
}
}
});
server.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
process.stderr.write(`ERROR: Port ${PORT} is already in use.\n`);
} else {
process.stderr.write(`ERROR: ${err.message}\n`);
}
process.exit(1);
});