boardgame.io
Version:
library for turn-based games
278 lines (249 loc) • 8.38 kB
text/typescript
/*
* Copyright 2018 The boardgame.io Authors
*
* Use of this source code is governed by a MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/
import type { CorsOptions } from 'cors';
import IO from 'koa-socket-2';
import type IOTypes from 'socket.io';
import type { ServerOptions as HttpsOptions } from 'https';
import PQueue from 'p-queue';
import { Master } from '../../master/master';
import type {
TransportAPI as MasterTransport,
TransportData,
} from '../../master/master';
import { getFilterPlayerView } from '../../master/filter-player-view';
import type { Game, Server } from '../../types';
import type { GenericPubSub } from './pubsub/generic-pub-sub';
import type { IntermediateTransportData } from '../../master/master';
import { InMemoryPubSub } from './pubsub/in-memory-pub-sub';
const PING_TIMEOUT = 20 * 1e3;
const PING_INTERVAL = 10 * 1e3;
const emit = (socket: IOTypes.Socket, { type, args }: TransportData) => {
socket.emit(type, ...args);
};
function getPubSubChannelId(matchID: string): string {
return `MATCH-${matchID}`;
}
/**
* API that's exposed by SocketIO for the Master to send
* information to the clients.
*/
export const TransportAPI = (
matchID: string,
socket: IOTypes.Socket,
filterPlayerView: any,
pubSub: GenericPubSub<IntermediateTransportData>
): MasterTransport => {
const send: MasterTransport['send'] = ({ playerID, ...data }) => {
emit(socket, filterPlayerView(playerID, data));
};
/**
* Send a message to all clients.
*/
const sendAll: MasterTransport['sendAll'] = (payload) => {
pubSub.publish(getPubSubChannelId(matchID), payload);
};
return { send, sendAll };
};
export interface SocketOpts {
https?: HttpsOptions;
socketOpts?: IOTypes.ServerOptions;
socketAdapter?: any;
pubSub?: GenericPubSub<IntermediateTransportData>;
}
interface Client {
matchID: string;
playerID: string;
socket: IOTypes.Socket;
credentials: string | undefined;
}
/**
* Transport interface that uses socket.io
*/
export class SocketIO {
protected clientInfo: Map<string, Client>;
protected roomInfo: Map<string, Set<string>>;
protected perMatchQueue: Map<string, PQueue>;
private readonly https: HttpsOptions;
private readonly socketAdapter: any;
private readonly socketOpts: IOTypes.ServerOptions;
protected pubSub: GenericPubSub<IntermediateTransportData>;
constructor({ https, socketAdapter, socketOpts, pubSub }: SocketOpts = {}) {
this.clientInfo = new Map();
this.roomInfo = new Map();
this.perMatchQueue = new Map();
this.https = https;
this.socketAdapter = socketAdapter;
this.socketOpts = socketOpts;
this.pubSub = pubSub || new InMemoryPubSub();
}
/**
* Unregister client data for a socket.
*/
private removeClient(socketID: string): void {
// Get client data for this socket ID.
const client = this.clientInfo.get(socketID);
if (!client) return;
// Remove client from list of connected sockets for this match.
const { matchID } = client;
const matchClients = this.roomInfo.get(matchID);
matchClients.delete(socketID);
// If the match is now empty, delete its promise queue & client ID list.
if (matchClients.size === 0) {
this.unsubscribePubSubChannel(matchID);
this.roomInfo.delete(matchID);
this.deleteMatchQueue(matchID);
}
// Remove client data from the client map.
this.clientInfo.delete(socketID);
}
/**
* Register client data for a socket.
*/
private addClient(client: Client, game: Game): void {
const { matchID, socket } = client;
// Add client to list of connected sockets for this match.
let matchClients = this.roomInfo.get(matchID);
if (matchClients === undefined) {
this.subscribePubSubChannel(matchID, game);
matchClients = new Set<string>();
this.roomInfo.set(matchID, matchClients);
}
matchClients.add(socket.id);
// Register data for this socket in the client map.
this.clientInfo.set(socket.id, client);
}
private subscribePubSubChannel(matchID: string, game: Game) {
const filterPlayerView = getFilterPlayerView(game);
const broadcast = (payload: IntermediateTransportData) => {
this.roomInfo.get(matchID).forEach((clientID) => {
const client = this.clientInfo.get(clientID);
const data = filterPlayerView(client.playerID, payload);
emit(client.socket, data);
});
};
this.pubSub.subscribe(getPubSubChannelId(matchID), broadcast);
}
private unsubscribePubSubChannel(matchID: string) {
this.pubSub.unsubscribeAll(getPubSubChannelId(matchID));
}
init(
app: Server.App & { _io?: IOTypes.Server },
games: Game[],
origins: CorsOptions['origin'] = []
) {
const io = new IO({
ioOptions: {
pingTimeout: PING_TIMEOUT,
pingInterval: PING_INTERVAL,
cors: {
origins,
},
...this.socketOpts,
},
});
app.context.io = io;
io.attach(app, !!this.https, this.https);
if (this.socketAdapter) {
io.adapter(this.socketAdapter);
}
for (const game of games) {
const nsp = app._io.of(game.name);
const filterPlayerView = getFilterPlayerView(game);
nsp.on('connection', (socket: IOTypes.Socket) => {
socket.on('update', async (...args: Parameters<Master['onUpdate']>) => {
const [action, stateID, matchID, playerID] = args;
const master = new Master(
game,
app.context.db,
TransportAPI(matchID, socket, filterPlayerView, this.pubSub),
app.context.auth
);
const matchQueue = this.getMatchQueue(matchID);
await matchQueue.add(() =>
master.onUpdate(action, stateID, matchID, playerID)
);
});
socket.on('sync', async (...args: Parameters<Master['onSync']>) => {
const [matchID, playerID, credentials] = args;
socket.join(matchID);
this.removeClient(socket.id);
const requestingClient = { socket, matchID, playerID, credentials };
const transport = TransportAPI(
matchID,
socket,
filterPlayerView,
this.pubSub
);
const master = new Master(
game,
app.context.db,
transport,
app.context.auth
);
const syncResponse = await master.onSync(...args);
if (syncResponse && syncResponse.error === 'unauthorized') {
return;
}
this.addClient(requestingClient, game);
await master.onConnectionChange(matchID, playerID, credentials, true);
});
socket.on('disconnect', async () => {
const client = this.clientInfo.get(socket.id);
this.removeClient(socket.id);
if (client) {
const { matchID, playerID, credentials } = client;
const master = new Master(
game,
app.context.db,
TransportAPI(matchID, socket, filterPlayerView, this.pubSub),
app.context.auth
);
await master.onConnectionChange(
matchID,
playerID,
credentials,
false
);
}
});
socket.on(
'chat',
async (...args: Parameters<Master['onChatMessage']>) => {
const [matchID] = args;
const master = new Master(
game,
app.context.db,
TransportAPI(matchID, socket, filterPlayerView, this.pubSub),
app.context.auth
);
master.onChatMessage(...args);
}
);
});
}
}
/**
* Create a PQueue for a given matchID if none exists and return it.
* @param matchID
* @returns
*/
getMatchQueue(matchID: string): PQueue {
if (!this.perMatchQueue.has(matchID)) {
// PQueue should process only one action at a time.
this.perMatchQueue.set(matchID, new PQueue({ concurrency: 1 }));
}
return this.perMatchQueue.get(matchID);
}
/**
* Delete a PQueue for a given matchID.
* @param matchID
*/
deleteMatchQueue(matchID: string): void {
this.perMatchQueue.delete(matchID);
}
}