120 lines
4.3 KiB
JavaScript
120 lines
4.3 KiB
JavaScript
import { createHash } from "node:crypto";
|
|
|
|
const WEBSOCKET_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";
|
|
|
|
function frame(opcode, payload = Buffer.alloc(0)) {
|
|
const body = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
|
|
let header;
|
|
if (body.length < 126) {
|
|
header = Buffer.from([0x80 | opcode, body.length]);
|
|
} else if (body.length <= 0xffff) {
|
|
header = Buffer.allocUnsafe(4);
|
|
header[0] = 0x80 | opcode;
|
|
header[1] = 126;
|
|
header.writeUInt16BE(body.length, 2);
|
|
} else {
|
|
header = Buffer.allocUnsafe(10);
|
|
header[0] = 0x80 | opcode;
|
|
header[1] = 127;
|
|
header.writeBigUInt64BE(BigInt(body.length), 2);
|
|
}
|
|
return Buffer.concat([header, body]);
|
|
}
|
|
|
|
export function upgradeArenaWebSocket(request, socket, head, arenaService) {
|
|
const url = new URL(request.url ?? "/", "http://localhost");
|
|
if (url.pathname !== "/ws/arena") return false;
|
|
const key = String(request.headers["sec-websocket-key"] ?? "");
|
|
const version = String(request.headers["sec-websocket-version"] ?? "");
|
|
const accountId = arenaService.consumeSocketTicket(url.searchParams.get("ticket") ?? "");
|
|
if (!accountId || !key || version !== "13") {
|
|
socket.end("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\nContent-Length: 0\r\n\r\n");
|
|
return true;
|
|
}
|
|
const accept = createHash("sha1").update(`${key}${WEBSOCKET_GUID}`).digest("base64");
|
|
socket.write([
|
|
"HTTP/1.1 101 Switching Protocols",
|
|
"Upgrade: websocket",
|
|
"Connection: Upgrade",
|
|
`Sec-WebSocket-Accept: ${accept}`,
|
|
"\r\n",
|
|
].join("\r\n"));
|
|
|
|
let buffered = Buffer.from(head ?? Buffer.alloc(0));
|
|
let closed = false;
|
|
const send = (message) => {
|
|
if (!closed && socket.writable) socket.write(frame(0x1, JSON.stringify(message)));
|
|
};
|
|
const unsubscribe = arenaService.subscribe(accountId, send);
|
|
|
|
function close(code = 1000, reason = "") {
|
|
if (closed) return;
|
|
closed = true;
|
|
const reasonBytes = Buffer.from(String(reason).slice(0, 100));
|
|
const body = Buffer.allocUnsafe(2 + reasonBytes.length);
|
|
body.writeUInt16BE(code, 0);
|
|
reasonBytes.copy(body, 2);
|
|
if (socket.writable) socket.end(frame(0x8, body));
|
|
unsubscribe();
|
|
}
|
|
|
|
function parse() {
|
|
while (buffered.length >= 2) {
|
|
const first = buffered[0];
|
|
const second = buffered[1];
|
|
const final = Boolean(first & 0x80);
|
|
const opcode = first & 0x0f;
|
|
const masked = Boolean(second & 0x80);
|
|
let length = second & 0x7f;
|
|
let offset = 2;
|
|
if (!final || !masked) return close(1002, "Invalid client frame");
|
|
if (length === 126) {
|
|
if (buffered.length < 4) return;
|
|
length = buffered.readUInt16BE(2);
|
|
offset = 4;
|
|
} else if (length === 127) {
|
|
if (buffered.length < 10) return;
|
|
const largeLength = buffered.readBigUInt64BE(2);
|
|
if (largeLength > 65536n) return close(1009, "Frame too large");
|
|
length = Number(largeLength);
|
|
offset = 10;
|
|
}
|
|
if (length > 65536) return close(1009, "Frame too large");
|
|
if (buffered.length < offset + 4 + length) return;
|
|
const mask = buffered.subarray(offset, offset + 4);
|
|
const payload = Buffer.from(buffered.subarray(offset + 4, offset + 4 + length));
|
|
buffered = buffered.subarray(offset + 4 + length);
|
|
for (let index = 0; index < payload.length; index += 1) payload[index] ^= mask[index % 4];
|
|
if (opcode === 0x8) return close();
|
|
if (opcode === 0x9) {
|
|
if (socket.writable) socket.write(frame(0xa, payload));
|
|
continue;
|
|
}
|
|
if (opcode !== 0x1) return close(1003, "Text messages only");
|
|
try {
|
|
const message = JSON.parse(payload.toString("utf8"));
|
|
const result = arenaService.receive(accountId, message);
|
|
if (!result.accepted) send({ type: "intent-rejected", sequence: message?.sequence, reason: result.reason });
|
|
} catch {
|
|
send({ type: "protocol-error", message: "Arena messages must be valid JSON." });
|
|
}
|
|
}
|
|
}
|
|
|
|
socket.on("data", (chunk) => {
|
|
if (closed) return;
|
|
buffered = Buffer.concat([buffered, chunk]);
|
|
parse();
|
|
});
|
|
socket.on("error", () => close(1011, "Socket error"));
|
|
socket.on("close", () => {
|
|
if (!closed) {
|
|
closed = true;
|
|
unsubscribe();
|
|
}
|
|
});
|
|
if (buffered.length) parse();
|
|
send({ type: "connected", queue: arenaService.queueState(accountId) });
|
|
return true;
|
|
}
|