261 lines · 7.9 KB · javascript
Raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261
const WebSocket = require('ws');
const http = require('http');
const crypto = require('crypto');
const PORT = 3000;
const INACTIVITY_CHECK = 30 * 1000; // check every 30s
const DEFAULT_INACTIVITY = 5 * 60 * 1000; // 5 min default
const MAX_INACTIVITY = 24 * 60 * 60 * 1000; // 24 hour max
const MAX_PAYLOAD = 25 * 1024 * 1024; // 25MB max message size
// roomId -> {
// clients: Map<ws, {id, name, lastActive}>,
// creatorId,
// inactivityLimit, // ms
// nukeOnLeave // bool
// }
const rooms = new Map();
const ADJECTIVES = [
'null','void','dark','silent','ghost','lost','blind','cold',
'dead','zero','blank','grey','static','empty','hollow','faded',
'neon','rogue','stray','anon','hidden','masked','veiled','mute'
];
const NOUNS = [
'node','signal','proxy','cipher','relay','packet','vector','daemon',
'shell','byte','socket','kernel','loop','thread','stack','hash',
'gate','root','shard','flux','drift','echo','pulse','trace'
];
function randomName() {
const adj = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)];
const num = Math.floor(Math.random() * 99) + 1;
return `${adj}_${noun}_${num}`;
}
function generateClientId() {
return crypto.randomBytes(8).toString('hex');
}
function broadcast(room, message, excludeWs = null) {
const data = JSON.stringify(message);
for (const [ws] of room.clients) {
if (ws !== excludeWs && ws.readyState === WebSocket.OPEN) {
ws.send(data);
}
}
}
function sendTo(ws, message) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(message));
}
}
function nukeRoom(roomId) {
const room = rooms.get(roomId);
if (!room) return;
for (const [ws] of room.clients) {
sendTo(ws, { type: 'nuked' });
ws.close();
}
rooms.delete(roomId);
console.log(`[nuke] room ${roomId} destroyed`);
}
function leaveRoom(roomId, ws) {
const room = rooms.get(roomId);
if (!room) return;
const client = room.clients.get(ws);
if (!client) return;
const wasCreator = client.id === room.creatorId;
room.clients.delete(ws);
console.log(`[leave] ${client.name} left room ${roomId} (creator: ${wasCreator})`);
// Nuke on host leave
if (wasCreator && room.nukeOnLeave && room.clients.size > 0) {
console.log(`[nuke-on-leave] creator left, nuking room ${roomId}`);
nukeRoom(roomId);
return;
}
broadcast(room, { type: 'system', text: `${client.name} disconnected.` });
broadcast(room, { type: 'userlist', users: [...room.clients.values()].map(c => c.name) });
if (room.clients.size === 0) {
rooms.delete(roomId);
console.log(`[cleanup] room ${roomId} empty, deleted`);
}
}
// Inactivity checker
setInterval(() => {
const now = Date.now();
for (const [roomId, room] of rooms) {
for (const [ws, client] of room.clients) {
if (now - client.lastActive > room.inactivityLimit) {
console.log(`[inactivity] kicking ${client.name} from ${roomId}`);
sendTo(ws, { type: 'kicked', reason: 'inactivity' });
leaveRoom(roomId, ws);
ws.close();
}
}
}
}, INACTIVITY_CHECK);
const server = http.createServer((req, res) => {
res.writeHead(200, { 'Content-Type': 'text/plain' });
res.end('NulledRoom');
});
const wss = new WebSocket.Server({ server, maxPayload: MAX_PAYLOAD });
wss.on('connection', (ws) => {
let currentRoomId = null;
ws.on('message', (raw) => {
let msg;
try { msg = JSON.parse(raw); } catch { return; }
// Update activity
if (currentRoomId) {
const room = rooms.get(currentRoomId);
if (room && room.clients.has(ws)) {
room.clients.get(ws).lastActive = Date.now();
}
}
switch (msg.type) {
case 'create': {
const roomId = msg.roomId;
if (!roomId || rooms.has(roomId)) {
sendTo(ws, { type: 'error', text: 'Room already exists or invalid ID.' });
return;
}
// Inactivity limit — clamp between 1 min and 24 hours
let inactivityLimit = DEFAULT_INACTIVITY;
if (msg.inactivityMinutes) {
const mins = parseInt(msg.inactivityMinutes);
if (!isNaN(mins)) {
inactivityLimit = Math.min(Math.max(mins * 60 * 1000, 60000), MAX_INACTIVITY);
}
}
const nukeOnLeave = msg.nukeOnLeave === true;
const clientId = generateClientId();
const name = randomName();
rooms.set(roomId, {
clients: new Map([[ws, { id: clientId, name, lastActive: Date.now() }]]),
creatorId: clientId,
inactivityLimit,
nukeOnLeave
});
currentRoomId = roomId;
sendTo(ws, { type: 'joined', roomId, name, clientId, isCreator: true, inactivityLimit, nukeOnLeave });
console.log(`[create] room ${roomId} by ${name} | inactivity: ${inactivityLimit/60000}min | nukeOnLeave: ${nukeOnLeave}`);
break;
}
case 'join': {
const roomId = msg.roomId;
const room = rooms.get(roomId);
if (!room) {
sendTo(ws, { type: 'error', text: 'Room not found.' });
return;
}
const clientId = generateClientId();
const name = randomName();
room.clients.set(ws, { id: clientId, name, lastActive: Date.now() });
currentRoomId = roomId;
sendTo(ws, { type: 'joined', roomId, name, clientId, isCreator: false });
broadcast(room, { type: 'system', text: `${name} connected.` }, ws);
const userlist = [...room.clients.values()].map(c => c.name);
broadcast(room, { type: 'userlist', users: userlist });
sendTo(ws, { type: 'userlist', users: userlist });
console.log(`[join] ${name} joined room ${roomId}`);
break;
}
case 'message': {
if (!currentRoomId) return;
const room = rooms.get(currentRoomId);
if (!room) return;
const client = room.clients.get(ws);
if (!client) return;
// payload = encrypted blob (text or image), server never sees plaintext
broadcast(room, {
type: 'message',
from: client.name,
payload: msg.payload,
isImage: msg.isImage === true // flag so receiver knows to render as image
}, ws);
break;
}
case 'nuke': {
if (!currentRoomId) return;
const room = rooms.get(currentRoomId);
if (!room) return;
const client = room.clients.get(ws);
if (!client || client.id !== room.creatorId) {
sendTo(ws, { type: 'error', text: 'Only the room creator can nuke.' });
return;
}
console.log(`[nuke] ${client.name} nuked room ${currentRoomId}`);
nukeRoom(currentRoomId);
break;
}
case 'rejoin': {
// Guest silently reconnecting — slide back in with same name, no announcement
const roomId = msg.roomId;
const room = rooms.get(roomId);
if (!room) {
sendTo(ws, { type: 'error', text: 'Room not found.' });
return;
}
const clientId = generateClientId();
const name = msg.name || randomName(); // keep same name if provided
room.clients.set(ws, { id: clientId, name, lastActive: Date.now() });
currentRoomId = roomId;
sendTo(ws, { type: 'rejoined', roomId, name, clientId });
// Update user list for everyone (including rejoiner)
const userlist = [...room.clients.values()].map(c => c.name);
broadcast(room, { type: 'userlist', users: userlist });
sendTo(ws, { type: 'userlist', users: userlist });
console.log(`[rejoin] ${name} silently rejoined room ${roomId}`);
break;
}
case 'ping': {
// keepalive, lastActive already updated above
break;
}
}
});
ws.on('close', () => {
if (currentRoomId) leaveRoom(currentRoomId, ws);
});
ws.on('error', (err) => {
console.error('[ws error]', err.message);
});
});
server.listen(PORT, () => {
console.log(`NulledRoom server running on port ${PORT}`);
});