Files
Battle-for-Tismo/server/websocket.js
T
adrien f3e94a3d53 Added the devlog, fixed the map labels and reworked joining
- Political map country names were invisible because the label SVG had a zero-sized viewport, which some browsers never paint; size it to the world and guard it with a test.
- Added a Devlog button showing the ten latest commits, generated into client/js/devlog_data.js by scripts/generate-devlog.js and refreshed by the pre-commit hook.
- Worked through FIXME.md: the city button and a unit double-click now open the city, and the stat tooltip escapes the HUD so the unit panel cannot cover it.
- The server creates its game at startup and clients only join it; the Create game screen and the configure_game path are gone, and testing is a --testing server flag.
- A page on a loopback host signs in as adrien, claims France and gets the free, instant testing buttons, while the server grants free orders only to a loopback socket whose Origin is also loopback.
2026-09-21 23:08:05 +02:00

208 lines
6.0 KiB
JavaScript

// Minimal RFC 6455 WebSocket server built only on Node's built-in modules.
// The project rule is "no external JS libraries other than jQuery", so the
// transport is implemented here rather than pulled from npm. It supports the
// subset the game needs: text and binary messages, fragmentation, ping/pong,
// close, and large frames (state snapshots are hundreds of KiB).
import { createHash } from "node:crypto";
import { EventEmitter } from "node:events";
const GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
const OP_CONTINUATION = 0x0;
const OP_TEXT = 0x1;
const OP_BINARY = 0x2;
const OP_CLOSE = 0x8;
const OP_PING = 0x9;
const OP_PONG = 0xa;
export class WebSocketServer extends EventEmitter {
constructor(httpServer, options = {}) {
super();
this.path = options.path || "/ws";
this._nextId = 1;
this._connections = new Map();
httpServer.on("upgrade", (request, socket, head) => {
const url = request.url || "";
const path = url.split("?")[0];
if (path !== this.path) {
socket.destroy();
return;
}
this._accept(request, socket, head);
});
}
connectionCount() {
return this._connections.size;
}
_accept(request, socket, head) {
const wsKey = request.headers["sec-websocket-key"];
if (!wsKey || (request.headers.upgrade || "").toLowerCase() !== "websocket") {
socket.destroy();
return;
}
const accept = createHash("sha1").update(wsKey + GUID).digest("base64");
socket.write(
"HTTP/1.1 101 Switching Protocols\r\n" +
"Upgrade: websocket\r\n" +
"Connection: Upgrade\r\n" +
`Sec-WebSocket-Accept: ${accept}\r\n` +
"\r\n"
);
socket.setNoDelay(true);
const id = this._nextId++;
const connection = new Connection(id, socket, head);
// The peer's address and the page's Origin, so the server can tell a local
// connection (which may use the testing tools) from a remote one.
connection.remoteAddress = socket.remoteAddress || "";
connection.origin = request.headers.origin || "";
this._connections.set(id, connection);
this.emit("connection", connection);
connection.on("close", () => this._connections.delete(id));
}
}
class Connection extends EventEmitter {
constructor(id, socket, head) {
super();
this.id = id;
this.socket = socket;
this._buffer = Buffer.alloc(0);
this._fragments = [];
this._fragmentOpcode = null;
this._closed = false;
if (head && head.length > 0) this._buffer = Buffer.concat([this._buffer, head]);
socket.on("data", (chunk) => {
this._buffer = Buffer.concat([this._buffer, chunk]);
this._parse();
});
socket.on("error", () => this._close());
socket.on("close", () => this._close());
if (this._buffer.length > 0) this._parse();
}
send(data) {
if (this._closed) return;
const payload = Buffer.isBuffer(data) ? data : Buffer.from(String(data), "utf8");
const opcode = Buffer.isBuffer(data) ? OP_BINARY : OP_TEXT;
try {
this.socket.write(encodeFrame(opcode, payload));
} catch {
this._close();
}
}
close(code = 1000, reason = "") {
if (this._closed) return;
const reasonBuffer = Buffer.from(reason, "utf8");
const payload = Buffer.alloc(2 + reasonBuffer.length);
payload.writeUInt16BE(code, 0);
reasonBuffer.copy(payload, 2);
try {
// Flush the close frame before shutting the socket down; destroying
// immediately can discard it.
this.socket.write(encodeFrame(OP_CLOSE, payload));
this.socket.end();
} catch {
this._close();
}
}
_close() {
if (this._closed) return;
this._closed = true;
try {
this.socket.destroy();
} catch {
// ignore
}
this.emit("close");
}
_parse() {
while (true) {
const buffer = this._buffer;
if (buffer.length < 2) return;
const b0 = buffer[0];
const b1 = buffer[1];
const fin = (b0 & 0x80) !== 0;
const opcode = b0 & 0x0f;
const masked = (b1 & 0x80) !== 0;
let length = b1 & 0x7f;
let offset = 2;
if (length === 126) {
if (buffer.length < 4) return;
length = buffer.readUInt16BE(2);
offset = 4;
} else if (length === 127) {
if (buffer.length < 10) return;
length = Number(buffer.readBigUInt64BE(2));
offset = 10;
}
let maskKey = null;
if (masked) {
if (buffer.length < offset + 4) return;
maskKey = buffer.subarray(offset, offset + 4);
offset += 4;
}
if (buffer.length < offset + length) return;
let payload = buffer.subarray(offset, offset + length);
if (masked) {
const out = Buffer.allocUnsafe(length);
for (let i = 0; i < length; i++) out[i] = payload[i] ^ maskKey[i & 3];
payload = out;
}
this._buffer = buffer.subarray(offset + length);
this._handleFrame(fin, opcode, payload);
}
}
_handleFrame(fin, opcode, payload) {
if (opcode === OP_CLOSE) {
this.close(1000);
return;
}
if (opcode === OP_PING) {
this.socket.write(encodeFrame(OP_PONG, payload));
return;
}
if (opcode === OP_PONG) return;
if (opcode === OP_CONTINUATION) {
this._fragments.push(payload);
} else {
this._fragmentOpcode = opcode;
this._fragments = [payload];
}
if (!fin) return;
const full = Buffer.concat(this._fragments);
const isBinary = this._fragmentOpcode === OP_BINARY;
this._fragments = [];
this._fragmentOpcode = null;
this.emit("message", isBinary ? full : full.toString("utf8"));
}
}
function encodeFrame(opcode, payload) {
const length = payload.length;
let header;
if (length < 126) {
header = Buffer.alloc(2);
header[1] = length;
} else if (length < 65536) {
header = Buffer.alloc(4);
header[1] = 126;
header.writeUInt16BE(length, 2);
} else {
header = Buffer.alloc(10);
header[1] = 127;
header.writeBigUInt64BE(BigInt(length), 2);
}
header[0] = 0x80 | opcode;
return Buffer.concat([header, payload]);
}