Repository navigation
Expand file tree
/
Copy pathserver.js
More file actions
229 lines (211 loc) · 12.1 KB
/
Copy pathserver.js
File metadata and controls
229 lines (211 loc) · 12.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
// COD2 LAN listen-server: serves the game client over http and relays game
// traffic over WebSocket. It does NOT simulate — one browser is the host
// (authoritative), this just switchboards: client->host, and host->all clients.
// ponytail: dumb relay, ~1 file. A dedicated headless server is a later upgrade.
'use strict';
const http = require('http');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { WebSocketServer } = require('ws');
const MODERN_ROOT = path.join(__dirname, 'dist');
const ROOT = process.env.COD2_CLIENT !== 'legacy' && fs.existsSync(path.join(MODERN_ROOT, 'index.html')) ? MODERN_ROOT : __dirname;
const MAX_PLAYERS = 16; // total, host included. MIRROR: js/12-net.js MAX_PLAYERS must match.
// ---- pure routing decisions (unit-tested in server.test.js) ----
// Who should receive a message, given who sent it.
function relayTargets(senderId, m, hostId, allIds) {
if (senderId === hostId) {
if (m && m.to != null) return [m.to]; // host -> one client (you/cfg/spawn/dmg/...)
return allIds.filter(id => id !== hostId); // host -> every client (snapshots/global events)
}
return hostId != null ? [hostId] : []; // client -> host only
}
// What to do with a registration request.
function regDecision(r, hostExists, registeredCount, max) {
if (r === 'h') return hostExists ? { ok: false, why: 'host-exists' } : { ok: true, role: 'host' };
if (!hostExists) return { ok: false, why: 'nohost' };
if (registeredCount >= max) return { ok: false, why: 'match-full' };
return { ok: true, role: 'client' };
}
// Non-internal IPv4 addresses, for the startup banner. Pure (inject ifaces in tests).
function lanIPv4s(ifaces) {
ifaces = ifaces || os.networkInterfaces();
const out = [];
for (const name in ifaces) for (const a of ifaces[name])
if (a.family === 'IPv4' && !a.internal && !a.address.startsWith('169.254.')) out.push(a.address); // skip APIPA link-local
return out;
}
function parsePort(value) {
const text = value == null || value === '' ? '8666' : String(value).trim();
const port = Number(text);
if (!/^\d+$/.test(text) || !Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error('PORT must be a whole number from 1 to 65535 (the default is 8666).');
}
return port;
}
function browserOpenCommand(port, platform, env) {
const url = `http://localhost:${parsePort(port)}/`;
if (platform === 'win32') {
const configured = String(env.ComSpec || env.COMSPEC || '').trim().replace(/^"(.*)"$/, '$1');
const systemRoot = env.SystemRoot || env.SYSTEMROOT || env.WINDIR || 'C:\\Windows';
const fallbackRoot = path.win32.isAbsolute(systemRoot) ? systemRoot : 'C:\\Windows';
const command = path.win32.normalize(path.win32.isAbsolute(configured) ? configured :
path.win32.join(fallbackRoot, 'System32', 'cmd.exe'));
// cmd.exe is explicit: the user's PATH need not contain Windows\System32.
// All shell arguments are constants except the strictly validated numeric port.
return { command, args: ['/d', '/s', '/c', 'start', '""', url], windowsVerbatimArguments: true, url };
}
return { command: platform === 'darwin' ? 'open' : 'xdg-open', args: [url], windowsVerbatimArguments: false, url };
}
function openGameBrowser(port, options = {}) {
const command = browserOpenCommand(port, options.platform || process.platform, options.env || process.env);
const spawn = options.spawn || require('child_process').spawn;
const warn = options.warn || console.warn;
let reported = false;
const failed = error => {
if (reported) return;
reported = true;
warn(`Could not open the browser automatically (${error.message || error}). Open ${command.url} manually; the game server is still running.`);
};
try {
const child = spawn(command.command, command.args, {
windowsHide: true, windowsVerbatimArguments: command.windowsVerbatimArguments, stdio: 'ignore'
});
child.once('error', failed);
child.once('exit', (code, signal) => {
if (code !== 0) failed(new Error(signal ? `opener stopped by ${signal}` : `opener exited with code ${code}`));
});
child.unref();
return child;
} catch (error) { failed(error); return null; }
}
function serverFailureMessage(error, port) {
if (error && error.code === 'EADDRINUSE') {
return `Port ${port} is already in use. If COD2 is already running, open http://localhost:${port}/. Otherwise close the other server yourself or choose a different PORT, then launch again. No existing process was stopped.`;
}
return `The COD2 server could not start or continue on port ${port}: ${(error && error.message) || error}`;
}
function attachServerErrorHandlers(httpServer, webSocketServer, fail) {
let handled = false;
const onError = error => {
if (handled) return;
handled = true;
fail(error);
};
// ws forwards HTTP-server errors to its own emitter, so both need listeners.
webSocketServer.on('error', onError);
httpServer.on('error', onError);
return onError;
}
if (require.main !== module) {
module.exports = { relayTargets, regDecision, MAX_PLAYERS, lanIPv4s,
parsePort, browserOpenCommand, openGameBrowser, serverFailureMessage, attachServerErrorHandlers };
return;
}
let PORT;
try { PORT = parsePort(process.env.PORT); }
catch (error) { console.error('COD2 could not start: ' + error.message); process.exitCode = 1; return; }
// ---- static file serving (index.html + js/) ----
const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.md': 'text/plain', '.txt':'text/plain', '.glb': 'model/gltf-binary', '.png': 'image/png', '.wasm': 'application/wasm', '.map': 'application/json', '.ttf':'font/ttf' };
const httpServer = http.createServer((req, res) => {
let urlPath;
try { urlPath = decodeURIComponent(req.url.split('?')[0]); }
catch { res.writeHead(400); res.end('bad path'); return; }
if (urlPath.includes('\0')) { res.writeHead(400); res.end('bad path'); return; }
if (urlPath === '/') urlPath = '/index.html';
const filePath = path.resolve(ROOT, '.' + urlPath);
const relativePath = path.relative(ROOT, filePath);
if (relativePath.startsWith('..' + path.sep) || relativePath === '..' || path.isAbsolute(relativePath)) { res.writeHead(403); res.end('forbidden'); return; }
fs.readFile(filePath, (err, data) => {
if (err) { res.writeHead(404); res.end('not found'); return; }
res.writeHead(200, { 'Content-Type': MIME[path.extname(filePath).toLowerCase()] || 'application/octet-stream' });
res.end(data);
});
});
// ---- relay ----
const wss = new WebSocketServer({ server: httpServer, path: '/ws' });
const clients = new Map(); // id -> ws
let hostId = null;
let nextId = 1;
const send = (ws, obj) => { if (ws && ws.readyState === 1) ws.send(JSON.stringify(obj)); };
const hostWs = () => (hostId != null ? clients.get(hostId) : null);
const registeredCount = () => { let n = 0; for (const c of clients.values()) if (c.registered) n++; return n; };
// ---- logging: connection lifecycle + previously-swallowed errors are ALWAYS logged; set
// LOGGAME=1 to also trace loadout/ammo traffic (sel picks, spawn loadouts, any mag=0 push). ----
const now = () => new Date().toISOString().slice(11, 23);
// file sink: every log() line also lands in logs/ (console unchanged). Daily file, named once at
// startup (no midnight re-roll — LAN sessions are short). LOGFILE=path overrides (used by tests).
const LOGFILE = process.env.LOGFILE ||
path.join(__dirname, 'logs', 'server-' + new Date().toISOString().slice(0, 10).replace(/-/g, '') + '.log');
let logStream = null;
try {
fs.mkdirSync(path.dirname(LOGFILE), { recursive: true });
logStream = fs.createWriteStream(LOGFILE, { flags: 'a' });
logStream.on('error', () => { logStream = null; }); // disk full/locked: drop the sink, keep the console
} catch (e) { /* best-effort; console still works */ }
const log = (...a) => { const line = `[${now()}] ` + a.join(' '); console.log(line); if (logStream) logStream.write(line + '\n'); };
const TRACE = !!process.env.LOGGAME;
function traceGame(cid, m) {
if (m.t === 'sel') log(` sel client#${cid} primary=${m.primary} faction=${m.faction} team=${m.team}`);
else if (m.t === 'ev' && m.k === 'spawn') log(` spawn host->#${m.to} pri=${m.pri} sec=${m.sec}`);
else if (m.t === 'you' && m.mag === 0) log(` AMMO=0 host->#${m.to} hp=${m.hp} mag=0 res=${m.res} <-- empty magazine pushed to client`);
}
const onServerError = attachServerErrorHandlers(httpServer, wss, error => {
log('ERROR ' + serverFailureMessage(error, PORT));
process.exitCode = 1;
// Only this attempted server's own resources are closed. Never kill a listener
// found on the requested port, since it could be another app or a live match.
for (const client of clients.values()) client.terminate();
wss.close();
httpServer.close();
if (logStream) logStream.end();
});
wss.on('connection', (ws) => {
ws.cid = nextId++;
ws.registered = false;
clients.set(ws.cid, ws);
log(`conn #${ws.cid} opened (${clients.size} live)`);
ws.on('message', (buf) => {
let m; try { m = JSON.parse(buf); } catch { log(`WARN #${ws.cid} sent unparseable data (${buf.length} bytes) — dropped`); return; }
if (!ws.registered) {
if (m.t !== 'reg') { log(`WARN #${ws.cid} sent '${m.t}' before registering — dropped`); return; }
const d = regDecision(m.r, hostId != null && clients.has(hostId), registeredCount(), MAX_PLAYERS);
if (!d.ok) { log(`reg REJECT #${ws.cid} (wanted ${m.r === 'h' ? 'host' : 'client'}) -> ${d.why}`); send(ws, { t: d.why === 'nohost' ? 'nohost' : 'full', why: d.why }); return; }
ws.registered = true; ws.role = d.role;
ws.name = String(m.name || '').slice(0, 24); // kept for clog attribution
if (d.role === 'host') hostId = ws.cid;
log(`reg OK #${ws.cid} as ${d.role} name=${JSON.stringify(String(m.name || ''))} (${registeredCount()}/${MAX_PLAYERS})`);
send(ws, { t: 'welcome', id: ws.cid, role: d.role });
if (d.role === 'client') send(hostWs(), { t: 'join', id: ws.cid, name: m.name, color: m.color });
return;
}
if (m.t === 'clog') { // browser log line -> server log file only, never relayed
ws.clogN = (ws.clogN || 0) + 1; // per-connection cap (defense vs a hot error loop / hostile client)
if (ws.clogN > 500) { if (ws.clogN === 501) log(`CLOG #${ws.cid} muted (500-line cap)`); return; }
log(`CLOG #${ws.cid}(${ws.role}${ws.name ? ' ' + ws.name : ''}) ${m.lvl === 'err' ? 'ERR' : m.lvl === 'warn' ? 'WARN' : 'INFO'} ` +
`${String(m.msg || '').slice(0, 500)}${m.n > 1 ? ` (x${m.n})` : ''}${m.x ? ' | ' + String(m.x).slice(0, 500) : ''}`);
return;
}
if (TRACE) traceGame(ws.cid, m);
const ids = [...clients.keys()];
for (const rid of relayTargets(ws.cid, m, hostId, ids)) {
if (rid === ws.cid) continue;
send(clients.get(rid), ws.role === 'host' ? m : Object.assign(m, { from: ws.cid }));
}
});
ws.on('close', () => {
clients.delete(ws.cid);
if (ws.cid === hostId) { hostId = null; log(`HOST #${ws.cid} disconnected — telling ${clients.size} client(s) 'hostgone'`); for (const c of clients.values()) send(c, { t: 'hostgone' }); }
else if (ws.registered) { log(`client #${ws.cid} disconnected (${clients.size} live)`); send(hostWs(), { t: 'leave', id: ws.cid }); }
else log(`conn #${ws.cid} closed before registering`);
});
ws.on('error', (e) => { log(`WS error on #${ws.cid}: ${(e && e.message) || e}`); });
});
try { httpServer.listen(PORT, () => {
log(`server up :${PORT}${TRACE ? ' LOGGAME=1' : ''} log=${LOGFILE}`); // session marker (file + console)
console.log(`COD2 listen-server: http://localhost:${PORT} (max ${MAX_PLAYERS} players)`);
console.log(` Client: ${ROOT === MODERN_ROOT ? 'enhanced build (classic fallback at /legacy/index.html)' : 'classic source'}`);
for (const ip of lanIPv4s()) console.log(` LAN friends: http://${ip}:${PORT}`);
console.log(` Over the internet: forward TCP ${PORT} on your router to this PC, then share http://<your-public-ip>:${PORT}`);
if (!process.env.NO_OPEN) openGameBrowser(PORT);
}); } catch (error) { onServerError(error); }