#!/usr/bin/env node
"use strict";
/*
Shadow of the Fell Crown LAN relay
No npm packages required. Works with modern Node.js.
Start it on the host machine:
node fell-crown-lan-server.js
Players join the host's LAN address on port 8787, for example:
192.168.1.42:8787
*/
const http = require("http");
const crypto = require("crypto");
const PORT = Number(process.env.PORT || 8787);
const HOST = process.env.HOST || "0.0.0.0";
const clients = new Map();
let hostClient = null;
let nextId = 1;
function frameText(text) {
const payload = Buffer.from(text);
let header;
if (payload.length < 126) {
header = Buffer.from([0x81, payload.length]);
} else if (payload.length < 65536) {
header = Buffer.alloc(4);
header[0] = 0x81;
header[1] = 126;
header.writeUInt16BE(payload.length, 2);
} else {
header = Buffer.alloc(10);
header[0] = 0x81;
header[1] = 127;
header.writeBigUInt64BE(BigInt(payload.length), 2);
}
return Buffer.concat([header, payload]);
}
function send(client, object) {
if (!client.socket.destroyed) {
client.socket.write(frameText(JSON.stringify(object)));
}
}
function broadcast(object, except = null) {
for (const client of clients.values()) {
if (client !== except) send(client, object);
}
}
function parseFrames(client, chunk) {
client.buffer = Buffer.concat([client.buffer, chunk]);
while (client.buffer.length >= 2) {
const first = client.buffer[0];
const second = client.buffer[1];
const opcode = first & 0x0f;
const masked = Boolean(second & 0x80);
let length = second & 0x7f;
let offset = 2;
if (length === 126) {
if (client.buffer.length < 4) return;
length = client.buffer.readUInt16BE(2);
offset = 4;
} else if (length === 127) {
if (client.buffer.length < 10) return;
const big = client.buffer.readBigUInt64BE(2);
if (big > BigInt(Number.MAX_SAFE_INTEGER)) {
client.socket.destroy();
return;
}
length = Number(big);
offset = 10;
}
const maskBytes = masked ? 4 : 0;
if (client.buffer.length < offset + maskBytes + length) return;
let payload;
if (masked) {
const mask = client.buffer.subarray(offset, offset + 4);
offset += 4;
payload = Buffer.alloc(length);
for (let i = 0; i < length; i++) {
payload[i] = client.buffer[offset + i] ^ mask[i % 4];
}
} else {
payload = client.buffer.subarray(offset, offset + length);
}
client.buffer = client.buffer.subarray(offset + length);
if (opcode === 0x8) {
client.socket.end();
return;
}
if (opcode === 0x9) {
client.socket.write(Buffer.concat([Buffer.from([0x8a, payload.length]), payload]));
continue;
}
if (opcode !== 0x1) continue;
try {
handleMessage(client, JSON.parse(payload.toString("utf8")));
} catch {
send(client, { type: "error", message: "Invalid network packet." });
}
}
}
function handleMessage(client, message) {
if (!message || typeof message.type !== "string") return;
if (message.type === "claim-host") {
if (hostClient && hostClient !== client) {
send(client, { type: "error", message: "A host is already active on this LAN server." });
return;
}
hostClient = client;
client.role = "host";
send(client, { type: "host-accepted", id: client.id });
return;
}
if (message.type === "join") {
if (!hostClient) {
send(client, { type: "error", message: "No game is currently being hosted." });
return;
}
client.role = "client";
send(client, { type: "join-accepted", id: client.id });
send(hostClient, { type: "peer-joined", id: client.id });
return;
}
if (!client.role) {
send(client, { type: "error", message: "Choose Host or Join first." });
return;
}
const relayed = { ...message, id: client.id };
if (message.type === "snapshot") {
if (client !== hostClient) return;
for (const other of clients.values()) {
if (other.role === "client") send(other, relayed);
}
return;
}
if (message.type === "pose" || message.type === "cast") {
broadcast(relayed, client);
}
}
const server = http.createServer((req, res) => {
res.writeHead(200, { "content-type": "text/plain; charset=utf-8" });
res.end("Shadow of the Fell Crown LAN relay is running.\n");
});
server.on("upgrade", (req, socket) => {
const key = req.headers["sec-websocket-key"];
if (!key) {
socket.destroy();
return;
}
const accept = crypto
.createHash("sha1")
.update(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")
.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`
);
const client = {
id: `player-${nextId++}`,
socket,
buffer: Buffer.alloc(0),
role: null
};
clients.set(socket, client);
send(client, { type: "welcome", id: client.id });
socket.on("data", chunk => parseFrames(client, chunk));
socket.on("error", () => socket.destroy());
socket.on("close", () => {
clients.delete(socket);
broadcast({ type: "peer-left", id: client.id }, client);
if (hostClient === client) {
hostClient = null;
broadcast({ type: "host-left" });
for (const other of clients.values()) other.role = null;
}
});
});
server.listen(PORT, HOST, () => {
console.log(`Shadow of the Fell Crown LAN relay listening on ws://${HOST}:${PORT}`);
console.log("Keep this terminal open while playing.");
});