All checks were successful
Deploy production / deploy (push) Successful in 1m15s
- server.js wraps Bun.serve to add /ws/<channel> upgrades (same server and port) and serve the map tiles from the host mount; Dockerfile now runs bun ./server.js - ws hub (channel registry, origin check) + pubg-map channel: rooms by unguessable code, validated + rate-limited messages, per-room write queue, presence - games.pubg_rooms / pubg_matches / pubg_markers; per-match sessions with history - /trackers/games/pubg/map hub (owner: rooms, landings over time, history) and /trackers/games/pubg/map/<code> fullscreen Leaflet map (all 11 maps) - deploy-remote.sh bind-mounts ~/pubg-maps; scripts/build-pubg-tiles.py rebuilds it
270 lines
7.8 KiB
TypeScript
270 lines
7.8 KiB
TypeScript
import { registerChannel, type Socket } from '#lib/server/ws/hub.js';
|
|
import {
|
|
MAP_SLUGS,
|
|
MAX_MARKERS,
|
|
ROOM_CODE_RE,
|
|
parseClientMsg,
|
|
type ClientMsg,
|
|
type Match,
|
|
type Player,
|
|
type ServerMsg
|
|
} from '#lib/pubg-map/shared.js';
|
|
import * as store from './store';
|
|
|
|
// Realtime side of the PUBG drop map: the `pubg-map` channel of the site WebSocket hub
|
|
// (wss://<host>/ws/pubg-map?room=<code>). Every socket belongs to one room; every change is
|
|
// written to Postgres first and then sent to everyone in the room, the sender included.
|
|
// Access is the room code itself (unguessable, shared by link); Stan's login is not needed.
|
|
|
|
type Data = {
|
|
channel: string;
|
|
ip: string;
|
|
roomId: number;
|
|
code: string;
|
|
name: string;
|
|
sid: string;
|
|
player: Player | null;
|
|
tokens: number;
|
|
last: number;
|
|
strikes: number;
|
|
lastFlash: number;
|
|
};
|
|
type WS = Socket<Data>;
|
|
|
|
type Room = {
|
|
id: number;
|
|
code: string;
|
|
name: string;
|
|
sockets: Set<WS>;
|
|
match: Match | null;
|
|
/** Serialises writes per room, so e.g. two "New match" clicks make one new match. */
|
|
queue: Promise<unknown>;
|
|
};
|
|
|
|
const MAX_PER_ROOM = 32;
|
|
const MAX_PER_IP = 12;
|
|
// Token bucket per socket: bursts of 30 messages, 10 a second sustained.
|
|
const BURST = 30;
|
|
const RATE = 10;
|
|
|
|
const rooms = new Map<number, Room>();
|
|
const perIp = new Map<string, number>();
|
|
|
|
const send = (ws: WS, msg: ServerMsg) => ws.send(JSON.stringify(msg));
|
|
function broadcast(room: Room, msg: ServerMsg) {
|
|
const text = JSON.stringify(msg);
|
|
for (const ws of room.sockets) ws.send(text);
|
|
}
|
|
const playersOf = (room: Room) =>
|
|
[...room.sockets].flatMap((ws) => (ws.data.player ? [ws.data.player] : []));
|
|
|
|
function sid() {
|
|
return crypto.randomUUID().replace(/-/g, '').slice(0, 12);
|
|
}
|
|
|
|
function enqueue(room: Room, job: () => Promise<void>) {
|
|
room.queue = room.queue.then(job).catch((e) => console.error('[ws pubg-map]', e));
|
|
return room.queue;
|
|
}
|
|
|
|
async function ensureMatch(room: Room) {
|
|
room.match ??= await store.currentMatch(room.id);
|
|
return room.match;
|
|
}
|
|
|
|
function allow(ws: WS) {
|
|
const now = Date.now();
|
|
const d = ws.data;
|
|
d.tokens = Math.min(BURST, d.tokens + ((now - d.last) / 1000) * RATE);
|
|
d.last = now;
|
|
if (d.tokens >= 1) {
|
|
d.tokens -= 1;
|
|
return true;
|
|
}
|
|
if (++d.strikes > 50) ws.close(1008, 'rate limit');
|
|
else send(ws, { t: 'err', msg: 'Slow down a little.' });
|
|
return false;
|
|
}
|
|
|
|
async function handle(room: Room, ws: WS, msg: ClientMsg) {
|
|
const player = ws.data.player;
|
|
if (msg.t === 'hello') {
|
|
ws.data.player = { sid: ws.data.sid, name: msg.name, color: msg.color };
|
|
broadcast(room, { t: 'players', players: playersOf(room) });
|
|
return;
|
|
}
|
|
if (!player) return send(ws, { t: 'err', msg: 'Pick a nickname first.' });
|
|
const by = player.sid;
|
|
const match = await ensureMatch(room);
|
|
switch (msg.t) {
|
|
case 'add': {
|
|
const existing = await store.markersOf(match.id);
|
|
if (existing.length >= MAX_MARKERS)
|
|
return send(ws, { t: 'err', msg: 'This match is full; clear something first.' });
|
|
// One landing per player and one plane path per match: placing a new one replaces it.
|
|
const replace = existing
|
|
.filter((m) =>
|
|
msg.m.kind === 'landing'
|
|
? m.kind === 'landing' && m.player === player.name
|
|
: msg.m.kind === 'plane' && m.kind === 'plane'
|
|
)
|
|
.map((m) => m.id);
|
|
const gone = await store.deleteMarkers(match.id, replace);
|
|
if (gone.length) broadcast(room, { t: 'del', ids: gone, by });
|
|
const m = await store.insertMarker(match.id, {
|
|
...msg.m,
|
|
player: player.name,
|
|
color: player.color
|
|
});
|
|
if (m) broadcast(room, { t: 'add', m, by });
|
|
return;
|
|
}
|
|
case 'move':
|
|
if (
|
|
await store.updateMarker(match.id, msg.id, {
|
|
x: msg.x,
|
|
y: msg.y,
|
|
...(msg.x2 != null ? { x2: msg.x2, y2: msg.y2 } : {})
|
|
})
|
|
)
|
|
broadcast(room, { t: 'move', id: msg.id, x: msg.x, y: msg.y, x2: msg.x2, y2: msg.y2, by });
|
|
return;
|
|
case 'label':
|
|
if (await store.updateMarker(match.id, msg.id, { label: msg.label }))
|
|
broadcast(room, { t: 'label', id: msg.id, label: msg.label, by });
|
|
return;
|
|
case 'del': {
|
|
const ids = await store.deleteMarkers(match.id, [msg.id]);
|
|
if (ids.length) broadcast(room, { t: 'del', ids, by });
|
|
return;
|
|
}
|
|
case 'clear':
|
|
await store.clearMatch(match.id);
|
|
broadcast(room, { t: 'match', match, markers: [], by });
|
|
return;
|
|
case 'set-map':
|
|
await store.setMatchMap(match.id, msg.map);
|
|
room.match = { ...match, map: msg.map };
|
|
broadcast(room, {
|
|
t: 'match',
|
|
match: room.match,
|
|
markers: await store.markersOf(match.id),
|
|
by
|
|
});
|
|
return;
|
|
case 'new-match': {
|
|
// An untouched match is not worth keeping in the history: just switch its map.
|
|
const markers = await store.markersOf(match.id);
|
|
if (!markers.length) {
|
|
await store.setMatchMap(match.id, msg.map);
|
|
room.match = { ...match, map: msg.map, startedAt: new Date().toISOString() };
|
|
} else room.match = await store.startMatch(room.id, msg.map, match.id);
|
|
broadcast(room, { t: 'match', match: room.match, markers: [], by });
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Called by the owner's HTTP actions so open maps notice right away. */
|
|
export function roomDeleted(roomId: number) {
|
|
const room = rooms.get(roomId);
|
|
if (!room) return;
|
|
for (const ws of room.sockets) ws.close(4404, 'room deleted');
|
|
rooms.delete(roomId);
|
|
}
|
|
|
|
let registered = false;
|
|
export function registerPubgMapChannel() {
|
|
if (registered) return;
|
|
registered = true;
|
|
registerChannel('pubg-map', {
|
|
async authorize(_request, url, ip) {
|
|
const code = url.searchParams.get('room') ?? '';
|
|
if (!ROOM_CODE_RE.test(code)) return new Response('Not Found', { status: 404 });
|
|
if ((perIp.get(ip) ?? 0) >= MAX_PER_IP)
|
|
return new Response('Too Many Requests', { status: 429 });
|
|
const row = await store.roomByCode(code);
|
|
if (!row) return new Response('Not Found', { status: 404 });
|
|
if ((rooms.get(row.id)?.sockets.size ?? 0) >= MAX_PER_ROOM)
|
|
return new Response('Room full', { status: 429 });
|
|
return {
|
|
roomId: row.id,
|
|
code: row.code,
|
|
name: row.name,
|
|
sid: sid(),
|
|
player: null,
|
|
tokens: BURST,
|
|
last: Date.now(),
|
|
strikes: 0,
|
|
lastFlash: 0
|
|
};
|
|
},
|
|
open(raw) {
|
|
const ws = raw as unknown as WS;
|
|
const d = ws.data;
|
|
perIp.set(d.ip, (perIp.get(d.ip) ?? 0) + 1);
|
|
let room = rooms.get(d.roomId);
|
|
if (!room) {
|
|
room = {
|
|
id: d.roomId,
|
|
code: d.code,
|
|
name: d.name,
|
|
sockets: new Set(),
|
|
match: null,
|
|
queue: Promise.resolve()
|
|
};
|
|
rooms.set(d.roomId, room);
|
|
}
|
|
room.sockets.add(ws);
|
|
const r = room;
|
|
enqueue(r, async () => {
|
|
const match = await ensureMatch(r);
|
|
send(ws, {
|
|
t: 'init',
|
|
you: d.sid,
|
|
room: { code: r.code, name: r.name },
|
|
match,
|
|
markers: await store.markersOf(match.id),
|
|
players: playersOf(r)
|
|
});
|
|
});
|
|
},
|
|
message(raw, text) {
|
|
const ws = raw as unknown as WS;
|
|
const room = rooms.get(ws.data.roomId);
|
|
if (!room || !allow(ws)) return;
|
|
const msg = parseClientMsg(text);
|
|
if (!msg) return send(ws, { t: 'err', msg: 'Bad message.' });
|
|
if (msg.t === 'flash') {
|
|
// Pings are not stored; one a second per player is plenty.
|
|
const now = Date.now();
|
|
if (!ws.data.player || now - ws.data.lastFlash < 1000) return;
|
|
ws.data.lastFlash = now;
|
|
return broadcast(room, {
|
|
t: 'flash',
|
|
x: msg.x,
|
|
y: msg.y,
|
|
color: ws.data.player.color,
|
|
name: ws.data.player.name
|
|
});
|
|
}
|
|
if ((msg.t === 'new-match' || msg.t === 'set-map') && !MAP_SLUGS.has(msg.map)) return;
|
|
enqueue(room, async () => {
|
|
await handle(room, ws, msg);
|
|
});
|
|
},
|
|
close(raw) {
|
|
const ws = raw as unknown as WS;
|
|
const d = ws.data;
|
|
const n = (perIp.get(d.ip) ?? 1) - 1;
|
|
if (n > 0) perIp.set(d.ip, n);
|
|
else perIp.delete(d.ip);
|
|
const room = rooms.get(d.roomId);
|
|
if (!room) return;
|
|
room.sockets.delete(ws);
|
|
if (!room.sockets.size) rooms.delete(d.roomId);
|
|
else if (d.player) broadcast(room, { t: 'players', players: playersOf(room) });
|
|
}
|
|
});
|
|
}
|