diff --git a/public/js/main.js b/public/js/main.js index ed9306d3..b9a60aaf 100644 --- a/public/js/main.js +++ b/public/js/main.js @@ -8,3 +8,4 @@ import './fade-scroll.js' import './flash-message.js' import './map-thumbnail.js' import './notification.js' +import './navigation.js' diff --git a/public/js/navigation.js b/public/js/navigation.js new file mode 100644 index 00000000..612fb7d4 --- /dev/null +++ b/public/js/navigation.js @@ -0,0 +1,33 @@ +import htmx from './htmx.js' + +/** + * @typedef WsSend + * @type {function} + * @param {string} message + */ + +/** + * @typedef SocketWrapper + * @type {object} + * @property {WsSend} send + */ + +/** @type {SocketWrapper} */ +let socket + +export function reportNavigation(/** @type {string} */ path) { + const msg = JSON.stringify({ navigated: path }) + socket?.send(msg) +} + +/** + * @param {{detail: {socketWrapper: SocketWrapper}}} event + */ +htmx.on('htmx:wsOpen', event => { + socket = event.detail.socketWrapper + reportNavigation(window.location.pathname) +}) + +htmx.on('htmx:pushedIntoHistory', ({ detail }) => { + reportNavigation(detail.path) +}) diff --git a/src/players/plugins/redirect-player-to-new-game.ts b/src/games/plugins/redirect-player-to-new-game.ts similarity index 78% rename from src/players/plugins/redirect-player-to-new-game.ts rename to src/games/plugins/redirect-player-to-new-game.ts index 8d8017f5..39b4c80f 100644 --- a/src/players/plugins/redirect-player-to-new-game.ts +++ b/src/games/plugins/redirect-player-to-new-game.ts @@ -7,8 +7,8 @@ export default fp( events.on('player:updated', ({ before, after }) => { if (before.activeGame === undefined && after.activeGame !== undefined) { app.gateway - .toPlayers(after.steamId) - .broadcast(async () => await GoToGame(after.activeGame!)) + .to({ player: after.steamId }) + .send(async () => await GoToGame(after.activeGame!)) } }) }, diff --git a/src/games/plugins/sync-clients.ts b/src/games/plugins/sync-clients.ts index b4e5774c..44553293 100644 --- a/src/games/plugins/sync-clients.ts +++ b/src/games/plugins/sync-clients.ts @@ -18,38 +18,54 @@ import { GameScore } from '../views/html/game-score' export default fp(async app => { events.on('game:updated', async ({ before, after }) => { if (before.state !== after.state) { - app.gateway.broadcast(async () => await GameStateIndicator({ game: after })) - app.gateway.broadcast(async actor => await ConnectInfo({ game: after, actor })) + app.gateway + .to({ url: `/games/${after.number}` }) + .send(async () => await GameStateIndicator({ game: after })) + app.gateway + .to({ url: `/games/${after.number}` }) + .send(async actor => await ConnectInfo({ game: after, actor })) if ([GameState.launching, GameState.ended, GameState.interrupted].includes(after.state)) { - app.gateway.broadcast(async actor => await GameSlotList({ game: after, actor })) + app.gateway + .to({ url: `/games/${after.number}` }) + .send(async actor => await GameSlotList({ game: after, actor })) } } if (before.score?.blu !== after.score?.blu || before.score?.red !== after.score?.red) { - app.gateway.broadcast(async () => await GameScore({ game: after })) + app.gateway + .to({ url: `/games/${after.number}` }) + .send(async () => await GameScore({ game: after })) } if ( before.connectString !== after.connectString || before.stvConnectString !== after.stvConnectString ) { - app.gateway.broadcast(async actor => await ConnectInfo({ game: after, actor })) + app.gateway + .to({ url: `/games/${after.number}` }) + .send(async actor => await ConnectInfo({ game: after, actor })) } if (before.logsUrl !== after.logsUrl) { - app.gateway.broadcast(async () => await LogsLink({ game: after })) + app.gateway + .to({ url: `/games/${after.number}` }) + .send(async () => await LogsLink({ game: after })) } if (before.demoUrl !== after.demoUrl) { - app.gateway.broadcast(async () => await DemoLink({ game: after })) + app.gateway + .to({ url: `/games/${after.number}` }) + .send(async () => await DemoLink({ game: after })) } if (before.events.length < after.events.length) { const n = before.events.length - after.events.length const newEvents = after.events.slice(n) for (const event of newEvents) { - app.gateway.broadcast(async () => await GameEventList.append({ game: after, event })) + app.gateway + .to({ url: `/games/${after.number}` }) + .send(async () => await GameEventList.append({ game: after, event })) } } @@ -62,8 +78,9 @@ export default fp(async app => { if (beforeSlot.shouldJoinBy !== slot.shouldJoinBy) { app.gateway - .toPlayers(slot.player) - .broadcast(async actor => await ConnectInfo({ game: after, actor })) + .to({ url: `/games/${after.number}` }) + .to({ player: slot.player }) + .send(async actor => await ConnectInfo({ game: after, actor })) } }), ) @@ -76,9 +93,9 @@ export default fp(async app => { events.on( 'game:updated', - whenGameEnds(async () => { + whenGameEnds(async ({ after }) => { const cmp = await GamesLink() - app.gateway.broadcast(() => cmp) + app.gateway.to({ url: `/games/${after.number}` }).send(() => cmp) }), ) @@ -92,7 +109,10 @@ export default fp(async app => { connectionStatus: playerConnectionStatus, }), ) - app.gateway.toPlayers(player).broadcast(async actor => await ConnectInfo({ game, actor })) + app.gateway + .to({ url: `/games/${game.number}` }) + .to({ player }) + .send(async actor => await ConnectInfo({ game, actor })) }), ) @@ -102,11 +122,15 @@ export default fp(async app => { throw new Error(`no such game slot: ${game.number} ${replacee}`) } - app.gateway.broadcast(async actor => await GameSlot({ game, slot, actor })) + app.gateway + .to({ url: `/games/${game.number}` }) + .send(async actor => await GameSlot({ game, slot, actor })) }) events.on('game:playerReplaced', ({ game }) => { // fixme refresh only one slot - app.gateway.broadcast(async actor => await GameSlotList({ game, actor })) + app.gateway + .to({ url: `/games/${game.number}` }) + .send(async actor => await GameSlotList({ game, actor })) }) }) diff --git a/src/players/views/html/go-to-game.tsx b/src/games/views/html/go-to-game.tsx similarity index 78% rename from src/players/views/html/go-to-game.tsx rename to src/games/views/html/go-to-game.tsx index f7fb8ad8..8203c2b9 100644 --- a/src/players/views/html/go-to-game.tsx +++ b/src/games/views/html/go-to-game.tsx @@ -1,6 +1,5 @@ import { nanoid } from 'nanoid' import type { GameNumber } from '../../../database/models/game.model' -import { environment } from '../../../environment' export function GoToGame(number: GameNumber) { const id = nanoid() @@ -8,9 +7,11 @@ export function GoToGame(number: GameNumber) {
diff --git a/src/players/index.ts b/src/players/index.ts index 14283164..8969c28a 100644 --- a/src/players/index.ts +++ b/src/players/index.ts @@ -2,7 +2,6 @@ import fp from 'fastify-plugin' import { bySteamId } from './by-steam-id' import { getPlayerGameCountOnClasses } from './get-player-game-count-on-classes' import { update } from './update' -import { resolve } from 'node:path' export const players = { bySteamId, @@ -12,9 +11,6 @@ export const players = { export default fp( async app => { - await app.register((await import('@fastify/autoload')).default, { - dir: resolve(import.meta.dirname, 'plugins'), - }) await app.register((await import('./routes')).default) }, { name: 'players' }, diff --git a/src/queue/plugins/gateway-listeners.ts b/src/queue/plugins/gateway-listeners.ts index 925c09fc..d9975a10 100644 --- a/src/queue/plugins/gateway-listeners.ts +++ b/src/queue/plugins/gateway-listeners.ts @@ -21,7 +21,7 @@ export default fp( async function refreshTakenSlots(actor: SteamId64) { const slots = await collections.queueSlots.find({ player: { $ne: null } }).toArray() const cmps = await Promise.all(slots.map(async slot => await QueueSlot({ slot, actor }))) - app.gateway.toPlayers(actor).broadcast(() => cmps) + app.gateway.to({ player: actor }).send(() => cmps) } app.gateway.on('queue:join', async (socket, slotId) => { @@ -31,10 +31,10 @@ export default fp( try { const slots = await join(slotId, socket.player.steamId) - app.gateway.toPlayers(socket.player.steamId).broadcast(async () => await MapVote.enable()) + app.gateway.to({ player: socket.player.steamId }).send(async () => await MapVote.enable()) app.gateway - .toPlayers(socket.player.steamId) - .broadcast(async () => await PreReadyUpButton.enable()) + .to({ player: socket.player.steamId }) + .send(async () => await PreReadyUpButton.enable()) if (slots.find(s => s.canMakeFriendsWith?.length)) { await refreshTakenSlots(socket.player.steamId) @@ -51,14 +51,10 @@ export default fp( try { const slot = await leave(socket.player.steamId) - app.gateway.toPlayers(socket.player.steamId).broadcast(async () => await MapVote.disable()) + app.gateway.to({ player: socket.player.steamId }).send(async () => await MapVote.disable()) app.gateway - .toPlayers(socket.player.steamId) - .broadcast(async () => await PreReadyUpButton.disable()) - - app.gateway - .toPlayers(socket.player.steamId) - .broadcast(async actor => await MapVote({ actor })) + .to({ player: socket.player.steamId }) + .send(async () => await PreReadyUpButton.disable()) if (slot.canMakeFriendsWith?.length) { await refreshTakenSlots(socket.player.steamId) @@ -67,7 +63,7 @@ export default fp( const queueState = await getState() if (queueState === QueueState.ready) { const close = await ReadyUpDialog.close() - app.gateway.toPlayers(socket.player.steamId).broadcast(() => close) + app.gateway.to({ player: socket.player.steamId }).send(() => close) } } catch (error) { logger.error(error) @@ -81,7 +77,7 @@ export default fp( try { const [, close] = await Promise.all([readyUp(socket.player.steamId), ReadyUpDialog.close()]) - app.gateway.toPlayers(socket.player.steamId).broadcast(() => close) + app.gateway.to({ player: socket.player.steamId }).send(() => close) } catch (error) { logger.error(error) } @@ -95,8 +91,8 @@ export default fp( try { await voteMap(socket.player.steamId, map) app.gateway - .toPlayers(socket.player.steamId) - .broadcast(async actor => await MapVote({ actor })) + .to({ player: socket.player.steamId }) + .send(async actor => await MapVote({ actor })) } catch (error) { logger.error(error) } diff --git a/src/queue/plugins/sync-clients.ts b/src/queue/plugins/sync-clients.ts index f9c6456f..84697671 100644 --- a/src/queue/plugins/sync-clients.ts +++ b/src/queue/plugins/sync-clients.ts @@ -23,11 +23,15 @@ export default fp( async function syncAllSlots(...players: SteamId64[]) { const slots = await collections.queueSlots.find().toArray() slots.forEach(async slot => { - app.gateway.toPlayers(...players).broadcast(async actor => await QueueSlot({ slot, actor })) + app.gateway.to({ players }).send(async actor => await QueueSlot({ slot, actor })) }) } - app.gateway.on('connected', async socket => { + app.gateway.on('ready', async socket => { + if (socket.currentUrl !== '/') { + return + } + const slots = await collections.queueSlots.find().toArray() slots.forEach(async slot => { socket.send(await QueueSlot({ slot, actor: socket.player?.steamId })) @@ -62,14 +66,14 @@ export default fp( safe(async ({ before, after }) => { if (before.activeGame !== after.activeGame) { const cmp = await RunningGameSnackbar({ gameNumber: after.activeGame }) - app.gateway.toPlayers(after.steamId).broadcast(() => cmp) + app.gateway.to({ players: [after.steamId] }).send(() => cmp) await syncAllSlots(after.steamId) } if (before.preReadyUntil !== after.preReadyUntil) { app.gateway - .toPlayers(after.steamId) - .broadcast(async actor => await PreReadyUpButton({ actor })) + .to({ players: [after.steamId] }) + .send(async actor => await PreReadyUpButton({ actor })) } }), ) @@ -107,7 +111,7 @@ export default fp( .filter(Boolean) as SteamId64[] const show = await ReadyUpDialog.show() - app.gateway.toPlayers(...players).broadcast(() => show) + app.gateway.to({ players }).send(() => show) } }), ) @@ -133,29 +137,29 @@ export default fp( if (!slot) { return } - const actors = await collections.queueSlots - .find({ 'canMakeFriendsWith.0': { $exists: true }, player: { $ne: null } }) - .toArray() - app.gateway - .toPlayers(...actors.map(a => a.player!)) - .broadcast(async actor => await QueueSlot({ slot, actor })) + const players = ( + await collections.queueSlots + .find({ 'canMakeFriendsWith.0': { $exists: true }, player: { $ne: null } }) + .toArray() + ).map(({ player }) => player!) + app.gateway.to({ players }).send(async actor => await QueueSlot({ slot, actor })) }), ) events.on( 'queue/friendship:updated', safe(async ({ target }) => { - const actors = await collections.queueSlots - .find({ 'canMakeFriendsWith.0': { $exists: true }, player: { $ne: null } }) - .toArray() + const players = ( + await collections.queueSlots + .find({ 'canMakeFriendsWith.0': { $exists: true }, player: { $ne: null } }) + .toArray() + ).map(({ player }) => player!) const slots = await collections.queueSlots .find({ player: { $in: [target.before, target.after] } }) .toArray() slots.forEach(slot => { - app.gateway - .toPlayers(...actors.map(a => a.player!)) - .broadcast(async actor => await QueueSlot({ slot, actor })) + app.gateway.to({ players }).send(async actor => await QueueSlot({ slot, actor })) }) }), ) @@ -167,12 +171,12 @@ export default fp( if (!slot) { return } - const actors = await collections.queueSlots - .find({ 'canMakeFriendsWith.0': { $exists: true }, player: { $ne: null } }) - .toArray() - app.gateway - .toPlayers(...actors.map(a => a.player!)) - .broadcast(async actor => await QueueSlot({ slot, actor })) + const players = ( + await collections.queueSlots + .find({ 'canMakeFriendsWith.0': { $exists: true }, player: { $ne: null } }) + .toArray() + ).map(({ player }) => player!) + app.gateway.to({ players }).send(async actor => await QueueSlot({ slot, actor })) }), ) @@ -193,7 +197,7 @@ export default fp( const refreshBanAlerts = async (player: SteamId64) => { const cmp = await BanAlerts({ actor: player }) - app.gateway.toPlayers(player).broadcast(() => cmp) + app.gateway.to({ players: [player] }).send(() => cmp) setImmediate(async () => { await syncAllSlots(player) diff --git a/src/websocket/gateway.ts b/src/websocket/gateway.ts index f5735caa..6bf38df3 100644 --- a/src/websocket/gateway.ts +++ b/src/websocket/gateway.ts @@ -8,6 +8,8 @@ import { logger } from '../logger' export interface ClientToServerEvents { connected: (ipAddress: string, userAgent?: string) => void + ready: () => void + navigated: (url: string) => void 'queue:join': (slotId: number) => void 'queue:leave': () => void 'queue:votemap': (mapName: string) => void @@ -57,6 +59,11 @@ const preReadyToggle = z.object({ HEADERS: htmxHeaders, }) +const navigated = z.object({ + navigated: z.string(), + HEADERS: htmxHeaders.optional(), +}) + const clientMessage = z.union([ joinQueue, leaveQueue, @@ -64,6 +71,7 @@ const clientMessage = z.union([ voteMap, markAsFriend, preReadyToggle, + navigated, ]) type MessageFn = ( @@ -73,6 +81,102 @@ interface Broadcaster { broadcast: (messageFn: MessageFn) => void } +async function sendSafe(client: WebSocket, msg: string) { + return new Promise((resolve, reject) => { + if (client.readyState !== WebSocket.OPEN) { + resolve() + return + } + + client.send(msg, err => { + if (err) { + if ('code' in err && err.code === 'EPIPE') { + client.terminate() + resolve() + return + } + + reject(err) + } else { + resolve() + } + }) + }) +} + +async function send(client: WebSocket, message: MessageFn) { + try { + const m = await message(client.player?.steamId) + if (Array.isArray(m)) { + for (const msg of m) { + await sendSafe(client, msg) + } + } else { + await sendSafe(client, m) + } + } catch (error) { + assertIsError(error) + logger.error(error) + } +} + +type Filters = { players?: Set; urls?: Set } +type UserFilters = + | { player: SteamId64 } + | { players: SteamId64[] } + | { url: string } + | { urls: string[] } + +function mergeFilters(base: Filters, additional: UserFilters) { + if ('players' in additional || 'player' in additional) { + base.players ??= new Set() + } + + if ('url' in additional || 'urls' in additional) { + base.urls ??= new Set() + } + + if ('player' in additional) { + base.players!.add(additional.player) + } else if ('players' in additional) { + additional.players.forEach(player => base.players!.add(player)) + } else if ('url' in additional) { + base.urls!.add(additional.url) + } else if ('urls' in additional) { + additional.urls.forEach(url => base.urls!.add(url)) + } + return base +} + +class BroadcastOperator { + constructor( + public readonly app: FastifyInstance, + private readonly filters: Filters, + ) {} + + to(filter: UserFilters) { + return new BroadcastOperator(this.app, mergeFilters(this.filters, filter)) + } + + send(message: MessageFn) { + this.app.websocketServer.clients.forEach(async client => { + if (this.filters.players) { + if (!client.player || !this.filters.players.has(client.player.steamId)) { + return + } + } + + if (this.filters.urls) { + if (!this.filters.urls.has(client.currentUrl)) { + return + } + } + + await send(client, message) + }) + } +} + export class Gateway extends EventEmitter implements Broadcaster { constructor(public readonly app: FastifyInstance) { super() @@ -86,95 +190,33 @@ export class Gateway extends EventEmitter implements Broadcaster { return this } - broadcast(messageFn: MessageFn) { - this.app.websocketServer.clients.forEach(async client => { - const send = async (msg: string) => - new Promise((resolve, reject) => { - if (client.readyState !== WebSocket.OPEN) { - resolve() - return - } - - client.send(msg, err => { - if (err) { - if ('code' in err && err.code === 'EPIPE') { - client.terminate() - resolve() - return - } - - reject(err) - } else { - resolve() - } - }) - }) - - try { - const message = await messageFn(client.player?.steamId) - if (Array.isArray(message)) { - for (const msg of message) { - await send(msg) - } - } else { - await send(message) - } - } catch (error) { - assertIsError(error) - logger.error(error) - } - }) + broadcast(message: MessageFn) { + this.app.websocketServer.clients.forEach(async client => await send(client, message)) } - toPlayers(...players: SteamId64[]): Broadcaster { - return { - broadcast: (messageFn: MessageFn) => { - this.app.websocketServer.clients.forEach(async client => { - if (client.player && players.includes(client.player.steamId)) { - const send = async (msg: string) => - new Promise((resolve, reject) => { - if (client.readyState !== WebSocket.OPEN) { - resolve() - return - } - - client.send(msg, err => { - if (err) { - if ('code' in err && err.code === 'EPIPE') { - client.terminate() - resolve() - return - } - - reject(err) - } else { - resolve() - } - }) - }) - - try { - const message = await messageFn(client.player.steamId) - if (Array.isArray(message)) { - for (const msg of message) { - await send(msg) - } - } else { - await send(message) - } - } catch (error) { - assertIsError(error) - logger.error(error) - } - } - }) - }, - } + to(filter: UserFilters): BroadcastOperator { + return new BroadcastOperator(this.app, mergeFilters({}, filter)) } parse(socket: WebSocket, message: string) { try { const parsed = clientMessage.parse(JSON.parse(message)) + if ('navigated' in parsed) { + if (!socket.currentUrl) { + socket.currentUrl = parsed.navigated + this.emit('ready', socket) + } else { + socket.currentUrl = parsed.navigated + this.emit('navigated', socket, parsed.navigated) + } + return + } + + if (!socket.player) { + return + } + + // all the other calls are for authenticated clients only if ('join' in parsed) { this.emit('queue:join', socket, parsed.join) } else if ('leave' in parsed) { diff --git a/src/websocket/index.ts b/src/websocket/index.ts index bd99254b..39744a2c 100644 --- a/src/websocket/index.ts +++ b/src/websocket/index.ts @@ -5,6 +5,7 @@ import { extractClientIp } from './extract-client-ip' import websocket from '@fastify/websocket' import { secondsToMilliseconds } from 'date-fns' import type { SteamId64 } from '../shared/types/steam-id-64' +import { nanoid } from 'nanoid' declare module 'fastify' { interface FastifyInstance { @@ -14,7 +15,9 @@ declare module 'fastify' { declare module 'ws' { export default interface WebSocket { + id: string isAlive: boolean + currentUrl: string player?: { steamId: SteamId64 } @@ -55,25 +58,26 @@ export default fp( app.decorate('gateway', gateway) app.get('/ws', { websocket: true }, (socket, req) => { + socket.id = nanoid() + if (req.user) { socket.player = { steamId: req.user.player.steamId, } - - socket.on('message', message => { - let messageString: string - if (Array.isArray(message)) { - messageString = Buffer.concat(message).toString() - } else if (message instanceof ArrayBuffer) { - messageString = Buffer.from(message).toString() - } else { - messageString = message.toString() - } - logger.trace(`${req.user!.player.name}: ${messageString}`) - gateway.parse(socket, messageString) - }) } + socket.on('message', message => { + let messageString: string + if (Array.isArray(message)) { + messageString = Buffer.concat(message).toString() + } else if (message instanceof ArrayBuffer) { + messageString = Buffer.from(message).toString() + } else { + messageString = message.toString() + } + gateway.parse(socket, messageString) + }) + const ipAddress = extractClientIp(req.headers) ?? req.socket.remoteAddress const userAgent = req.headers['user-agent'] gateway.emit('connected', socket, ipAddress, userAgent)