├── .nvmrc ├── .vscode └── settings.json ├── src ├── lib │ ├── constants │ │ ├── InternalActions.ts │ │ ├── RedisKeys.ts │ │ ├── RoomConfig.ts │ │ ├── PubSubListeners.ts │ │ ├── ServerActions.ts │ │ └── ClientActions.ts │ ├── server │ │ ├── Emitter.ts │ │ ├── RoomMaker.ts │ │ ├── Connection │ │ │ ├── JoinRoom.ts │ │ │ ├── CreateNewRoom.ts │ │ │ └── ConnectionHandler.ts │ │ ├── CustomGameValues.ts │ │ ├── RoomFetcher.ts │ │ └── Server.ts │ ├── utils │ │ ├── Logger.ts │ │ ├── SendMessageToClient.ts │ │ └── Callback.ts │ ├── pubsub │ │ ├── PubSub.ts │ │ ├── EventEmitter.ts │ │ ├── Redis.ts │ │ ├── RabbitMQ.ts │ │ └── EventEmitterCluster.ts │ ├── api │ │ ├── SetGameValues.ts │ │ ├── GetGameValues.ts │ │ ├── GetRooms.ts │ │ ├── GetRoom.ts │ │ └── SendExternalMessage.ts │ ├── storage │ │ ├── Storage.ts │ │ ├── Redis.ts │ │ ├── Memory.ts │ │ └── ClusterMemory.ts │ └── room │ │ ├── Client.ts │ │ └── Room.ts ├── types │ ├── AvailableRoomType.ts │ ├── SimpleClient.ts │ ├── RoomMessage.ts │ ├── RoomSnapshot.ts │ └── example.d.ts ├── example │ ├── State.ts │ ├── index.ts │ └── ChatRoom.ts └── index.ts ├── .prettierignore ├── .gitignore ├── .npmignore ├── tsconfig.module.json ├── .github ├── CONTRIBUTING.md ├── PULL_REQUEST_TEMPLATE.md └── ISSUE_TEMPLATE.md ├── tslint.json ├── LICENSE ├── tsconfig.json ├── README.md ├── package.json └── CHANGELOG.md /.nvmrc: -------------------------------------------------------------------------------- 1 | 10.14.2 -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "tslint.autoFixOnSave": true 3 | } -------------------------------------------------------------------------------- /src/lib/constants/InternalActions.ts: -------------------------------------------------------------------------------- 1 | const actions = {} 2 | 3 | export default actions 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # package.json is formatted by package managers, so we ignore it here 2 | package.json -------------------------------------------------------------------------------- /src/lib/constants/RedisKeys.ts: -------------------------------------------------------------------------------- 1 | export const ROOM_PREFIX = 'room:' 2 | export const GAME_VALUES = 'game-values' -------------------------------------------------------------------------------- /src/lib/constants/RoomConfig.ts: -------------------------------------------------------------------------------- 1 | export const ROOM_STATE_PATCH_RATE = 50 // state updates every x milliseconds 2 | -------------------------------------------------------------------------------- /src/types/AvailableRoomType.ts: -------------------------------------------------------------------------------- 1 | interface AvaiableRoomType {name: string, handler: any, options?: any} 2 | export default AvaiableRoomType -------------------------------------------------------------------------------- /src/types/SimpleClient.ts: -------------------------------------------------------------------------------- 1 | export default interface SimpleClient { 2 | id: string 3 | sessionId: string 4 | origin: string 5 | ip: string 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build 3 | test 4 | src/**.js 5 | .idea/* 6 | 7 | .DS_Store 8 | 9 | coverage 10 | .nyc_output 11 | *.log 12 | 13 | package-lock.json -------------------------------------------------------------------------------- /src/lib/server/Emitter.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from 'events' 2 | 3 | const Emitter = new EventEmitter() 4 | Emitter.setMaxListeners(5000) 5 | 6 | export default Emitter 7 | -------------------------------------------------------------------------------- /src/types/RoomMessage.ts: -------------------------------------------------------------------------------- 1 | import SimpleClient from "./SimpleClient"; 2 | 3 | interface RoomMessage { 4 | action: string 5 | client: SimpleClient 6 | payload: any 7 | } 8 | 9 | export default RoomMessage -------------------------------------------------------------------------------- /src/lib/constants/PubSubListeners.ts: -------------------------------------------------------------------------------- 1 | export const PLAYER_LEFT = 'blueboat-player-left' 2 | export const REQUEST_INFO = 'blueboat-request-room-info' 3 | export const EXTERNAL_MESSAGE = 'blueboat-external-message' -------------------------------------------------------------------------------- /src/types/RoomSnapshot.ts: -------------------------------------------------------------------------------- 1 | import SimpleClient from "./SimpleClient"; 2 | 3 | export interface RoomSnapshot { 4 | id: string 5 | type: string 6 | owner: SimpleClient 7 | metadata: any 8 | createdAt: number 9 | } -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | test 3 | tsconfig.json 4 | tsconfig.module.json 5 | tslint.json 6 | .travis.yml 7 | .github 8 | .prettierignore 9 | .vscode 10 | build/docs 11 | **/*.spec.* 12 | coverage 13 | .nyc_output 14 | *.log 15 | -------------------------------------------------------------------------------- /tsconfig.module.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig", 3 | "compilerOptions": { 4 | "target": "esnext", 5 | "outDir": "build/module", 6 | "module": "esnext" 7 | }, 8 | "exclude": [ 9 | "node_modules/**" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Example Contributing Guidelines 2 | 3 | This is an example of GitHub's contributing guidelines file. Check out GitHub's [CONTRIBUTING.md help center article](https://help.github.com/articles/setting-guidelines-for-repository-contributors/) for more information. 4 | -------------------------------------------------------------------------------- /src/lib/utils/Logger.ts: -------------------------------------------------------------------------------- 1 | export const LoggerTypes = { 2 | server: 'SERVER', 3 | room: 'ROOM', 4 | io: 'IO' 5 | } 6 | 7 | const Logger = (message: string, type: string) => { 8 | if (process.env.BLUEBOATLOG) { 9 | console.log(`[${type}] - ${message}`) 10 | } 11 | } 12 | 13 | export default Logger 14 | -------------------------------------------------------------------------------- /src/example/State.ts: -------------------------------------------------------------------------------- 1 | interface Message { 2 | message: string 3 | senderId: string 4 | } 5 | 6 | class State { 7 | public messages: Message[] 8 | 9 | constructor(initialMessage: string) { 10 | this.messages = [{ message: initialMessage, senderId: 'Botsy' }] 11 | } 12 | } 13 | 14 | export default State 15 | -------------------------------------------------------------------------------- /src/lib/utils/SendMessageToClient.ts: -------------------------------------------------------------------------------- 1 | import { Server } from 'socket.io' 2 | 3 | const SendMessageToClient = ( 4 | io: Server, 5 | roomId: string, 6 | to: string, 7 | key: string, 8 | data?: any 9 | ) => { 10 | io.to(to).emit(`message-${roomId}`, { key, data }) 11 | } 12 | export default SendMessageToClient 13 | -------------------------------------------------------------------------------- /src/lib/constants/ServerActions.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * List of actions sent from the server 3 | */ 4 | 5 | const SeverActions = { 6 | clientIdSet: 'CLIENT_ID_SET', 7 | joinedRoom: 'blueboat_JOINED_ROOM', 8 | statePatch: 'STATE_PATCH', 9 | removedFromRoom: 'blueboat_REMOVED_FROM_ROOM', 10 | } 11 | 12 | export default SeverActions 13 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | * **What kind of change does this PR introduce?** (Bug fix, feature, docs update, ...) 2 | 3 | 4 | 5 | * **What is the current behavior?** (You can also link to an open issue here) 6 | 7 | 8 | 9 | * **What is the new behavior (if this is a feature change)?** 10 | 11 | 12 | 13 | * **Other information**: 14 | -------------------------------------------------------------------------------- /src/lib/server/RoomMaker.ts: -------------------------------------------------------------------------------- 1 | // import RedisClient from "./RedisClient"; 2 | 3 | // export interface RoomSnapshot { 4 | // id: string 5 | // clients: string[] 6 | 7 | // } 8 | 9 | // interface RoomMakerOptions { 10 | // redis: RedisClient 11 | // } 12 | 13 | // class RoomMaker { 14 | // constructor(options: RoomMakerOptions) { 15 | 16 | // } 17 | // } -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | * **I'm submitting a ...** 2 | [ ] bug report 3 | [ ] feature request 4 | [ ] question about the decisions made in the repository 5 | [ ] question about how to use this project 6 | 7 | * **Summary** 8 | 9 | 10 | 11 | * **Other information** (e.g. detailed explanation, stacktraces, related issues, suggestions how to fix, links for us to have context, eg. StackOverflow, personal fork, etc.) 12 | -------------------------------------------------------------------------------- /src/lib/constants/ClientActions.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * List of actions sent from the client 3 | */ 4 | 5 | const ClientActions = { 6 | createNewRoom: 'blueboat_CREATE_NEW_ROOM', 7 | joinRoom: 'blueboat_JOIN_ROOM', 8 | sendMessage: 'blueboat_SEND_MESSAGE', 9 | listen: 'blueboat_LISTEN_STATE', 10 | requestAvailableRooms: 'blueboat_AVAILABLE_ROOMS', 11 | ping: 'blueboat-ping' 12 | } 13 | 14 | export default ClientActions 15 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["tslint:latest", "tslint-config-prettier", "tslint-immutable"], 3 | "rules": { 4 | "no-console": [false], 5 | "interface-name": [true, "never-prefix"], 6 | // TODO: allow devDependencies only in **/*.spec.ts files: 7 | // waiting on https://github.com/palantir/tslint/pull/3708 8 | "no-implicit-dependencies": [true, "dev"], 9 | "object-literal-sort-keys": [false] 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/lib/pubsub/PubSub.ts: -------------------------------------------------------------------------------- 1 | export type OnFunction = ( 2 | key: string, 3 | callback: (data: any) => any 4 | ) => { unsubscribe: () => any } 5 | export type Publish = (key: string, data: any) => any 6 | 7 | class PubSub { 8 | public on: OnFunction 9 | public publish: Publish 10 | 11 | constructor(on: OnFunction, publish: Publish) { 12 | this.on = on 13 | this.publish = publish 14 | } 15 | } 16 | 17 | export default PubSub 18 | -------------------------------------------------------------------------------- /src/lib/api/SetGameValues.ts: -------------------------------------------------------------------------------- 1 | import { serializeError } from 'serialize-error' 2 | import Server from '../server/Server' 3 | 4 | const SetGameValues = async (req: any, res: any) => { 5 | try { 6 | const gameServer = req.gameServer as Server 7 | await gameServer.gameValues.setGlobalGameValuesObject(req.body) 8 | res.send('OK') 9 | } catch (e) { 10 | res.status(500).send(serializeError(e)) 11 | } 12 | } 13 | 14 | export default SetGameValues 15 | -------------------------------------------------------------------------------- /src/lib/api/GetGameValues.ts: -------------------------------------------------------------------------------- 1 | import { serializeError } from 'serialize-error' 2 | import Server from '../server/Server' 3 | 4 | const GetGameValues = async (req: any, res: any) => { 5 | try { 6 | const gameServer = req.gameServer as Server 7 | const gameValues = await gameServer.gameValues.getGameValues() 8 | res.send(gameValues) 9 | } catch (e) { 10 | res.status(500).send(serializeError(e)) 11 | } 12 | } 13 | 14 | export default GetGameValues 15 | -------------------------------------------------------------------------------- /src/lib/api/GetRooms.ts: -------------------------------------------------------------------------------- 1 | import { serializeError } from 'serialize-error' 2 | import Server from '../server/Server' 3 | 4 | const GetRooms = async (req: any, res: any) => { 5 | try { 6 | const gameServer = req.gameServer as Server 7 | // @ts-ignore 8 | const rooms = await gameServer.roomFetcher.getListOfRoomsWithData() 9 | res.send(rooms) 10 | } catch (e) { 11 | res.status(500).send(serializeError(e)) 12 | } 13 | } 14 | 15 | export default GetRooms 16 | -------------------------------------------------------------------------------- /src/lib/server/Connection/JoinRoom.ts: -------------------------------------------------------------------------------- 1 | import SimpleClient from '../../../types/SimpleClient' 2 | import ClientActions from '../../constants/ClientActions' 3 | import PubSub from '../../pubsub/PubSub' 4 | import RoomFetcher from '../RoomFetcher' 5 | 6 | const JoinRoom = async ( 7 | roomId: string, 8 | client: SimpleClient, 9 | roomFetcher: RoomFetcher, 10 | pubsub: PubSub, 11 | options?: any 12 | ) => { 13 | try { 14 | await roomFetcher.findRoomById(roomId) 15 | pubsub.publish(roomId, { 16 | action: ClientActions.joinRoom, 17 | client, 18 | data: { options } 19 | }) 20 | return 21 | } catch (e) { 22 | throw e 23 | } 24 | } 25 | 26 | export default JoinRoom 27 | -------------------------------------------------------------------------------- /src/lib/storage/Storage.ts: -------------------------------------------------------------------------------- 1 | type GetFunction = (key: string, resolveIfNoData?: boolean) => Promise 2 | type SetFunction = (key: string, value: string, options?: any) => Promise 3 | type RemoveFunction = (key: string) => Promise 4 | type FetchKeysFunction = (keyPrefix: string) => Promise 5 | 6 | class Storage { 7 | public get: GetFunction 8 | public set: SetFunction 9 | public remove: RemoveFunction 10 | public fetchKeys: FetchKeysFunction 11 | 12 | constructor( 13 | get: GetFunction, 14 | set: SetFunction, 15 | remove: RemoveFunction, 16 | fetch: FetchKeysFunction 17 | ) { 18 | this.get = get 19 | this.set = set 20 | this.remove = remove 21 | this.fetchKeys = fetch 22 | } 23 | } 24 | 25 | export default Storage 26 | -------------------------------------------------------------------------------- /src/example/index.ts: -------------------------------------------------------------------------------- 1 | import cors from 'cors' 2 | import Express from 'express' 3 | import { EventEmitterPubSub, MemoryStorage, Server } from '../index' 4 | import ChatRoom from './ChatRoom' 5 | 6 | const redisOptions = { 7 | host: 'localhost', 8 | port: 6379 9 | } 10 | 11 | const start = async () => { 12 | try { 13 | const app = Express() 14 | app.use(cors()) 15 | app.options('*', cors()) 16 | const server = new Server({ 17 | app, 18 | storage: MemoryStorage(), 19 | transports: ['polling', 'websocket'], 20 | pubsub: EventEmitterPubSub(), 21 | redis: redisOptions, 22 | admins: { blueboat: 'pass' } 23 | }) 24 | server.registerRoom('Chat', ChatRoom) 25 | server.listen(4000, () => { 26 | console.log('Server listening on port 4000') 27 | }) 28 | } catch (e) { 29 | console.log(e) 30 | } 31 | } 32 | 33 | start() 34 | -------------------------------------------------------------------------------- /src/lib/api/GetRoom.ts: -------------------------------------------------------------------------------- 1 | import { REQUEST_INFO } from '../constants/PubSubListeners' 2 | import Server from '../server/Server' 3 | 4 | const GetRoom = (req: any, res: any) => { 5 | let hasSent = false 6 | try { 7 | const gameServer = req.gameServer as Server 8 | // @ts-ignore 9 | const listener = gameServer.pubsub.on(REQUEST_INFO, info => { 10 | res.send(JSON.parse(JSON.stringify(info))) 11 | hasSent = true 12 | listener.unsubscribe() 13 | }) 14 | // @ts-ignore 15 | gameServer.pubsub.publish(req.params.room, { action: REQUEST_INFO }) 16 | setTimeout(() => { 17 | if (!hasSent) { 18 | listener.unsubscribe() 19 | res.status(404).send('No room found') 20 | } 21 | }, 5000) 22 | } catch (e) { 23 | if (!hasSent) { 24 | res.status(404).send('Error sending room') 25 | } 26 | } 27 | } 28 | 29 | export default GetRoom 30 | -------------------------------------------------------------------------------- /src/lib/room/Client.ts: -------------------------------------------------------------------------------- 1 | import { Server } from 'socket.io' 2 | import SendMessageToClient from '../utils/SendMessageToClient' 3 | 4 | class Client { 5 | public id: string 6 | public sessionId: string 7 | public origin: string 8 | public ip: string 9 | public send: (key: string, data?: any) => void 10 | public removeFromRoom: () => void 11 | 12 | constructor( 13 | roomId: string, 14 | id: string, 15 | sessionId: string, 16 | origin: string, 17 | ip: string, 18 | io: Server, 19 | remove: (clientSessionId: string, intentional: boolean) => void 20 | ) { 21 | this.id = id 22 | this.sessionId = sessionId 23 | this.origin = origin 24 | this.ip = ip 25 | this.send = (key: string, data?: any) => { 26 | SendMessageToClient(io, roomId, sessionId, key, data) 27 | } 28 | this.removeFromRoom = () => remove(this.sessionId, true) 29 | } 30 | } 31 | 32 | export default Client 33 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import EventEmitterPubSub from './lib/pubsub/EventEmitter' 2 | import * as EventEmitterClusterPubsub from './lib/pubsub/EventEmitterCluster' 3 | import PubSub from './lib/pubsub/PubSub' 4 | import RabbitMQPubSub from './lib/pubsub/RabbitMQ' 5 | import RedisPubSub from './lib/pubsub/Redis' 6 | import Client from './lib/room/Client' 7 | import Room from './lib/room/Room' 8 | import Server from './lib/server/Server' 9 | import ClusterMemoryStorage from './lib/storage/ClusterMemory' 10 | import MemoryStorage from './lib/storage/Memory' 11 | import RedisStorage from './lib/storage/Redis' 12 | import Storage from './lib/storage/Storage' 13 | import SimpleClient from './types/SimpleClient' 14 | 15 | export interface EntityMap { 16 | [entityId: string]: T 17 | } 18 | 19 | export { 20 | Server, 21 | Room, 22 | Client, 23 | SimpleClient, 24 | PubSub, 25 | RedisPubSub, 26 | EventEmitterPubSub, 27 | RabbitMQPubSub, 28 | Storage, 29 | RedisStorage, 30 | MemoryStorage, 31 | EventEmitterClusterPubsub, 32 | ClusterMemoryStorage 33 | } 34 | -------------------------------------------------------------------------------- /src/lib/api/SendExternalMessage.ts: -------------------------------------------------------------------------------- 1 | import { EXTERNAL_MESSAGE } from '../constants/PubSubListeners' 2 | 3 | const SendExternalMessage = (req: any, res: any) => { 4 | const body: { room: string; key: string; data?: any } = req.body 5 | 6 | if (!body) { 7 | res.status(500).send('Body required for external messages') 8 | return 9 | } 10 | if (!body.key) { 11 | res.status(500).send('Key property required for external messages') 12 | return 13 | } 14 | if (typeof body.key !== 'string') { 15 | res.status(500).send('Key property must be a string') 16 | return 17 | } 18 | if (!body.room) { 19 | res.status(500).send('Room property required for external messages') 20 | return 21 | } 22 | if (typeof body.room !== 'string') { 23 | res.status(500).send('Room property must be a string') 24 | return 25 | } 26 | 27 | req.gameServer.pubsub.publish(body.room, { 28 | action: EXTERNAL_MESSAGE, 29 | data: { 30 | key: body.key, 31 | data: body.data 32 | } 33 | }) 34 | res.send('OK') 35 | } 36 | 37 | export default SendExternalMessage 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Josh Feinsilber 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/lib/utils/Callback.ts: -------------------------------------------------------------------------------- 1 | interface Call { 2 | callback: (e?: any, e2?: any) => void 3 | timesCalled: number 4 | canCallMultipleTimes: boolean 5 | id: string 6 | } 7 | 8 | class Callback { 9 | public callbacks: Call[] = [] 10 | 11 | public add(callback: (e?: any, e2?: any) => void, onlyCallOnce?: boolean) { 12 | const id = Math.random().toString() 13 | this.callbacks.push({ 14 | callback, 15 | timesCalled: 0, 16 | canCallMultipleTimes: !onlyCallOnce, 17 | id 18 | }) 19 | return { clear: () => this.removeCallback(id) } 20 | } 21 | 22 | public clear() { 23 | this.callbacks.splice(0, this.callbacks.length) 24 | } 25 | 26 | public call(argument?: any, argument2?: any) { 27 | this.callbacks = this.callbacks.map(callback => { 28 | if (callback.timesCalled > 0) { 29 | if (!callback.canCallMultipleTimes) { 30 | return callback 31 | } 32 | } 33 | callback.callback(argument, argument2) 34 | return { 35 | ...callback, 36 | timesCalled: callback.timesCalled + 1 37 | } 38 | }) 39 | } 40 | 41 | private removeCallback = (id: string) => { 42 | this.callbacks = this.callbacks.filter(callback => callback.id !== id) 43 | } 44 | } 45 | export default Callback 46 | -------------------------------------------------------------------------------- /src/example/ChatRoom.ts: -------------------------------------------------------------------------------- 1 | import { Client, Room } from '../index' 2 | import State from './State' 3 | 4 | class ChatRoom extends Room { 5 | public async onCreate() { 6 | try { 7 | this.setState( 8 | new State( 9 | this.initialGameValues.initialBotMessage || 'Welcome to the chat!' 10 | ) 11 | ) 12 | } catch (e) { 13 | throw e 14 | } 15 | } 16 | 17 | public onJoin(client: Client) { 18 | this.catchUp(client) 19 | } 20 | 21 | public onMessage(client: Client, action: string, data?: any) { 22 | if (!action) { 23 | return 24 | } 25 | if (action === 'LATENCY') { 26 | client.send('LATENCY', data) 27 | } 28 | if (action === 'CHAT') { 29 | this.addMessage(data, client.id) 30 | } 31 | } 32 | 33 | public async beforeDispose() { 34 | this.broadcast('DISPOSED') 35 | } 36 | 37 | public onLeave = async (client: Client) => { 38 | const reconnected = await this.allowReconnection(client, 30) 39 | if (reconnected) { 40 | return 41 | } 42 | this.addMessage(`${client.id} has left the room`, 'Botsy') 43 | } 44 | 45 | private addMessage = (text: string, senderId: string) => { 46 | const message = { message: text, senderId } 47 | this.state.messages.push(message) 48 | this.broadcast('MESSAGE', message) 49 | } 50 | 51 | private catchUp = (client: Client) => 52 | client.send('MESSAGES', this.state.messages) 53 | } 54 | 55 | export default ChatRoom 56 | -------------------------------------------------------------------------------- /src/types/example.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * If you import a dependency which does not include its own type definitions, 3 | * TypeScript will try to find a definition for it by following the `typeRoots` 4 | * compiler option in tsconfig.json. For this project, we've configured it to 5 | * fall back to this folder if nothing is found in node_modules/@types. 6 | * 7 | * Often, you can install the DefinitelyTyped 8 | * (https://github.com/DefinitelyTyped/DefinitelyTyped) type definition for the 9 | * dependency in question. However, if no one has yet contributed definitions 10 | * for the package, you may want to declare your own. (If you're using the 11 | * `noImplicitAny` compiler options, you'll be required to declare it.) 12 | * 13 | * This is an example type definition for the `sha.js` package, used in hash.ts. 14 | * 15 | * (This definition was primarily extracted from: 16 | * https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/node/v8/index.d.ts 17 | */ 18 | declare module 'sha.js' { 19 | export default function shaJs(algorithm: string): Hash; 20 | 21 | type Utf8AsciiLatin1Encoding = 'utf8' | 'ascii' | 'latin1'; 22 | type HexBase64Latin1Encoding = 'latin1' | 'hex' | 'base64'; 23 | 24 | export interface Hash extends NodeJS.ReadWriteStream { 25 | // tslint:disable:no-method-signature 26 | update( 27 | data: string | Buffer | DataView, 28 | inputEncoding?: Utf8AsciiLatin1Encoding 29 | ): Hash; 30 | digest(): Buffer; 31 | digest(encoding: HexBase64Latin1Encoding): string; 32 | // tslint:enable:no-method-signature 33 | } 34 | } 35 | 36 | declare module "*.txt" { 37 | const content: string; 38 | export default content; 39 | } 40 | -------------------------------------------------------------------------------- /src/lib/pubsub/EventEmitter.ts: -------------------------------------------------------------------------------- 1 | import events from 'events' 2 | import nanoid from 'nanoid' 3 | import PubSub from './PubSub' 4 | 5 | interface Callback { 6 | id: any 7 | callback: any 8 | } 9 | 10 | const EventEmitter = () => { 11 | const emitter = new events.EventEmitter() 12 | const listeners = new Map() 13 | emitter.setMaxListeners(10000) 14 | 15 | const on = (key: string, callback: (data: string) => any) => { 16 | const alreadyListeningForKey = listeners.has(key) 17 | const id = nanoid() 18 | if (!alreadyListeningForKey) { 19 | emitter.addListener(key, callback) 20 | listeners.set(key, [{ id, callback }]) 21 | } else { 22 | const currentListeners = listeners.get(key) 23 | if (currentListeners && currentListeners.push) { 24 | // @ts-ignore 25 | const newListeners: Callback[] = currentListeners.push({ id, callback }) 26 | listeners.set(key, newListeners) 27 | } 28 | } 29 | return { unsubscribe: () => unsubscribe(key, id) } 30 | } 31 | 32 | const publish = (key: string, data: string) => { 33 | emitter.emit(key, data) 34 | } 35 | 36 | const unsubscribe = (key: string, id: string) => { 37 | const listenersForKey = listeners.get(key) 38 | if (!listenersForKey || !listenersForKey.length) { 39 | return 40 | } 41 | if (listenersForKey.length === 1) { 42 | emitter.removeListener(key, listenersForKey[0].callback) 43 | listeners.delete(key) 44 | } else { 45 | const newListeners = listenersForKey.filter(l => l.id !== id) 46 | listeners.set(key, newListeners) 47 | } 48 | } 49 | 50 | return new PubSub(on, publish) 51 | } 52 | 53 | export default EventEmitter 54 | -------------------------------------------------------------------------------- /src/lib/server/CustomGameValues.ts: -------------------------------------------------------------------------------- 1 | import { GAME_VALUES } from '../constants/RedisKeys' 2 | import Storage from '../storage/Storage' 3 | 4 | interface CustomGameValueOptions { 5 | storage: Storage 6 | } 7 | 8 | /** 9 | * Used for global game values in which you can change in the admin panel 10 | */ 11 | class CustomGameValues { 12 | public storage: Storage = null 13 | 14 | constructor(options: CustomGameValueOptions) { 15 | this.storage = options.storage 16 | } 17 | 18 | public setGlobalGameValuesObject = async (newGameValues: any) => { 19 | try { 20 | await this.storage.set(GAME_VALUES, JSON.stringify(newGameValues), { 21 | noExpiration: true 22 | }) 23 | } catch (e) { 24 | throw e 25 | } 26 | } 27 | 28 | public getGameValues = async (): Promise => { 29 | try { 30 | const gameValues = await this.storage.get(GAME_VALUES, true) 31 | if (gameValues) { 32 | return JSON.parse(gameValues) 33 | } 34 | return {} 35 | } catch (e) { 36 | return {} 37 | } 38 | } 39 | 40 | public getGameValue = async ( 41 | key: string, 42 | defaultValue?: T 43 | ): Promise => { 44 | try { 45 | const gameValues = await this.getGameValues() 46 | if (gameValues[key]) { 47 | return gameValues[key] 48 | } 49 | return defaultValue 50 | } catch (e) { 51 | throw defaultValue 52 | } 53 | } 54 | 55 | public setGameValue = async (key: string, value?: any) => { 56 | try { 57 | const gameValues = await this.getGameValues() 58 | const newGameValues = { ...gameValues, [key]: value } 59 | await this.storage.set(GAME_VALUES, JSON.stringify(newGameValues), true) 60 | } catch (e) { 61 | throw e 62 | } 63 | } 64 | 65 | public resetGameValues = async () => { 66 | try { 67 | await this.storage.set(GAME_VALUES, JSON.stringify({}), true) 68 | } catch (e) { 69 | throw e 70 | } 71 | } 72 | } 73 | 74 | export default CustomGameValues 75 | -------------------------------------------------------------------------------- /src/lib/storage/Redis.ts: -------------------------------------------------------------------------------- 1 | import Redis from 'ioredis' 2 | import Storage from './Storage' 3 | const threeHours = 60 * 60 * 3 4 | 5 | interface RedisClientOptions { 6 | clientOptions?: Redis.RedisOptions 7 | customPrefix?: string 8 | } 9 | 10 | const RedisStorage = (options: RedisClientOptions) => { 11 | const client = new Redis(options.clientOptions) 12 | const basePrefix = options.customPrefix || 'blueboat:' 13 | client.on('error', (e: any) => { 14 | throw new Error(e) 15 | }) 16 | 17 | const getKey = (key: string) => basePrefix + key 18 | 19 | const fetchKeys = async (prefix: string) => { 20 | try { 21 | const fullPrefix = getKey(prefix) 22 | const keys = await client.keys(getKey(prefix + '*')) 23 | return keys.map(key => key.replace(fullPrefix, '')) 24 | } catch (e) { 25 | throw e 26 | } 27 | } 28 | 29 | const set = async ( 30 | key: string, 31 | value: string, 32 | setOptions?: { noExpiration: boolean } 33 | ) => { 34 | try { 35 | const noExpiration = setOptions && setOptions.noExpiration 36 | if (noExpiration) { 37 | await client.set(getKey(key), value) 38 | } else { 39 | await client.set(getKey(key), value, 'EX', threeHours) 40 | } 41 | } catch (e) { 42 | throw new Error( 43 | getKey(key) + ' - failed to set value - ' + e && e.message 44 | ? e.message 45 | : 'No error message' 46 | ) 47 | } 48 | } 49 | 50 | const get = async (key: string, resolveIfNoData?: boolean) => { 51 | try { 52 | const value = await client.get(getKey(key)) 53 | if (!value) { 54 | if (resolveIfNoData) { 55 | return null 56 | } else { 57 | throw new Error(getKey(key) + ' - No data received') 58 | } 59 | } 60 | return value 61 | } catch (e) { 62 | throw e 63 | } 64 | } 65 | 66 | const remove = async (key: string) => { 67 | try { 68 | await client.del(getKey(key)) 69 | } catch (e) { 70 | throw e 71 | } 72 | } 73 | 74 | return new Storage(get, set, remove, fetchKeys) 75 | } 76 | 77 | export default RedisStorage 78 | -------------------------------------------------------------------------------- /src/lib/storage/Memory.ts: -------------------------------------------------------------------------------- 1 | import NodeCache from 'node-cache' 2 | import Storage from './Storage' 3 | const threeHours = 60 * 60 * 3 4 | 5 | const Memory = () => { 6 | const cache = new NodeCache() 7 | 8 | const fetchKeys = (prefix: string): Promise => { 9 | return new Promise((resolve, reject) => { 10 | cache.keys((err, keys) => { 11 | if (err) { 12 | return reject(err) 13 | } 14 | return resolve( 15 | keys 16 | .filter(key => key.startsWith(prefix)) 17 | .map(key => key.replace(prefix, '')) 18 | ) 19 | }) 20 | }) 21 | } 22 | 23 | const set = ( 24 | key: string, 25 | value: string, 26 | setOptions?: { noExpiration: boolean } 27 | ): Promise => { 28 | return new Promise((resolve, reject) => { 29 | if (setOptions && setOptions.noExpiration) { 30 | cache.set(key, value, (err) => { 31 | if (err) { 32 | reject(err) 33 | } else { 34 | resolve() 35 | } 36 | }) 37 | } else { 38 | cache.set(key, value, threeHours, (err) => { 39 | if (err) { 40 | reject(err) 41 | } else { 42 | resolve() 43 | } 44 | }) 45 | } 46 | }) 47 | } 48 | 49 | const get = (key: string, resolveIfNoData?: boolean): Promise => { 50 | return new Promise((resolve, reject) => { 51 | cache.get(key, (err, data) => { 52 | if (err) { 53 | reject(err) 54 | } else { 55 | if (!data && !resolveIfNoData) { 56 | reject(`No data found for ${key}`) 57 | } else { 58 | // @ts-ignore 59 | resolve(data) 60 | } 61 | } 62 | }) 63 | }) 64 | } 65 | 66 | const remove = (key: string): Promise => { 67 | return new Promise((resolve, reject) => { 68 | cache.del(key, err => { 69 | if (err) { 70 | reject(err) 71 | } else { 72 | resolve() 73 | } 74 | }) 75 | }) 76 | } 77 | 78 | return new Storage(get, set, remove, fetchKeys) 79 | } 80 | 81 | export default Memory 82 | -------------------------------------------------------------------------------- /src/lib/pubsub/Redis.ts: -------------------------------------------------------------------------------- 1 | import Redis, { RedisOptions } from 'ioredis' 2 | import msgpack from 'msgpack-lite' 3 | import nanoid from 'nanoid' 4 | import PubSub from './PubSub' 5 | 6 | interface Callback { 7 | id: any 8 | callback: any 9 | } 10 | 11 | const RedisPubsub = (options: RedisOptions) => { 12 | const redis = new Redis(options) 13 | const pub = new Redis(options) 14 | 15 | const listeners = new Map() 16 | 17 | redis.on('messageBuffer', (k: Buffer, d: Buffer) => { 18 | const key = k.toString('utf8') 19 | const data = msgpack.decode(d).data 20 | const callbacks = listeners.get(key) 21 | if (callbacks && callbacks.length) { 22 | callbacks.forEach(callback => { 23 | callback.callback(data) 24 | }) 25 | } 26 | }) 27 | 28 | const on = (key: string, callback: (data: string) => any) => { 29 | const alreadyListeningForKey = listeners.has(key) 30 | const id = nanoid() 31 | if (!alreadyListeningForKey) { 32 | listeners.set(key, [{ id, callback }]) 33 | redis 34 | .subscribe(key) 35 | .then() 36 | .catch() 37 | } else { 38 | const currentListeners = listeners.get(key) 39 | // @ts-ignore 40 | const newListeners: Callback[] = currentListeners.push({ id, callback }) 41 | listeners.set(key, newListeners) 42 | } 43 | return { unsubscribe: () => unsubscribe(key, id) } 44 | } 45 | 46 | const publish = (key: string, data: any) => { 47 | pub 48 | // @ts-ignore 49 | .publish(key, msgpack.encode({ data })) 50 | .then() 51 | .catch() 52 | return 53 | } 54 | 55 | const unsubscribe = (key: string, id: string) => { 56 | const listenersForKey = listeners.get(key) 57 | if (!listenersForKey || !listenersForKey.length) { 58 | return 59 | } 60 | if (listenersForKey.length === 1) { 61 | redis 62 | .unsubscribe(key) 63 | .then() 64 | .catch() 65 | listeners.delete(key) 66 | } else { 67 | const newListeners = listenersForKey.filter(l => l.id !== id) 68 | listeners.set(key, newListeners) 69 | } 70 | } 71 | 72 | return new PubSub(on, publish) 73 | } 74 | 75 | export default RedisPubsub 76 | -------------------------------------------------------------------------------- /src/lib/server/RoomFetcher.ts: -------------------------------------------------------------------------------- 1 | import { RoomSnapshot } from '../../types/RoomSnapshot' 2 | import { ROOM_PREFIX } from '../constants/RedisKeys' 3 | import Storage from '../storage/Storage' 4 | 5 | interface RoomFetcherOptions { 6 | storage: Storage 7 | } 8 | 9 | /** 10 | * Can help find a list of currently available Rooms and their snapshots 11 | */ 12 | class RoomFetcher { 13 | public storage: Storage = null 14 | 15 | constructor(options: RoomFetcherOptions) { 16 | this.storage = options.storage 17 | } 18 | 19 | public getListOfRooms = async () => { 20 | try { 21 | const rooms = await this.storage.fetchKeys(ROOM_PREFIX) 22 | return rooms 23 | } catch (e) { 24 | throw e 25 | } 26 | } 27 | 28 | public getListOfRoomsWithData = async () => { 29 | try { 30 | const roomList = await this.getListOfRooms() 31 | const rooms = await Promise.all( 32 | roomList.map(async r => { 33 | try { 34 | const room = await this.storage.get(ROOM_PREFIX + r, true) 35 | if (room) { 36 | return JSON.parse(room) as RoomSnapshot 37 | } 38 | return null 39 | } catch (e) { 40 | throw e 41 | } 42 | }) 43 | ) 44 | return rooms.filter(room => room !== null) 45 | } catch (e) { 46 | throw e 47 | } 48 | } 49 | 50 | public findRoomById = async (roomId: string) => { 51 | try { 52 | const room = await this.storage.get(ROOM_PREFIX + roomId) 53 | return JSON.parse(room) 54 | } catch (e) { 55 | throw e 56 | } 57 | } 58 | 59 | public setRoomMetadata = async (roomId: string, newMetadata: any) => { 60 | try { 61 | const room = await this.findRoomById(roomId) 62 | await this.storage.set( 63 | ROOM_PREFIX + room.id, 64 | JSON.stringify({ ...room, metadata: newMetadata }) 65 | ) 66 | } catch (e) { 67 | return e 68 | } 69 | } 70 | 71 | public addRoom = async (room: RoomSnapshot) => { 72 | try { 73 | await this.storage.set(ROOM_PREFIX + room.id, JSON.stringify(room)) 74 | } catch (e) { 75 | throw e 76 | } 77 | } 78 | 79 | public removeRoom = async (roomId: string) => { 80 | try { 81 | await this.storage.remove(ROOM_PREFIX + roomId) 82 | } catch (e) { 83 | throw e 84 | } 85 | } 86 | } 87 | 88 | export default RoomFetcher 89 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "outDir": "build/main", 5 | "rootDir": "src", 6 | "moduleResolution": "node", 7 | "module": "commonjs", 8 | "declaration": true, 9 | "inlineSourceMap": true, 10 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 11 | 12 | "strict": false /* Enable all strict type-checking options. */, 13 | 14 | /* Strict Type-Checking Options */ 15 | // "noImplicitAny": true /* Raise error on expressions and declarations with an implied 'any' type. */, 16 | // "strictNullChecks": true /* Enable strict null checks. */, 17 | // "strictFunctionTypes": true /* Enable strict checking of function types. */, 18 | // "strictPropertyInitialization": true /* Enable strict checking of property initialization in classes. */, 19 | // "noImplicitThis": true /* Raise error on 'this' expressions with an implied 'any' type. */, 20 | // "alwaysStrict": true /* Parse in strict mode and emit "use strict" for each source file. */, 21 | 22 | /* Additional Checks */ 23 | "noUnusedLocals": true /* Report errors on unused locals. */, 24 | "noUnusedParameters": true /* Report errors on unused parameters. */, 25 | "noImplicitReturns": true /* Report error when not all code paths in function return a value. */, 26 | "noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */, 27 | 28 | /* Debugging Options */ 29 | "traceResolution": false /* Report module resolution log messages. */, 30 | "listEmittedFiles": false /* Print names of generated files part of the compilation. */, 31 | "listFiles": false /* Print names of files part of the compilation. */, 32 | "pretty": true /* Stylize errors and messages using color and context. */, 33 | 34 | /* Experimental Options */ 35 | // "experimentalDecorators": true /* Enables experimental support for ES7 decorators. */, 36 | // "emitDecoratorMetadata": true /* Enables experimental support for emitting type metadata for decorators. */, 37 | 38 | "lib": ["es2017"], 39 | "types": ["node"], 40 | "typeRoots": ["node_modules/@types", "src/types"], 41 | "experimentalDecorators": true 42 | }, 43 | "include": ["src/**/*.ts"], 44 | "exclude": ["node_modules/**"], 45 | "compileOnSave": false 46 | } 47 | -------------------------------------------------------------------------------- /src/lib/pubsub/RabbitMQ.ts: -------------------------------------------------------------------------------- 1 | import ampq from 'amqplib' 2 | import msgpack from 'msgpack-lite' 3 | // @ts-ignore 4 | import nanoid = require('nanoid') 5 | import { PubSub } from '../..' 6 | 7 | interface Callback { 8 | id: any 9 | callback: any 10 | } 11 | 12 | const RabbitMQ = (connectString: string) => { 13 | const listeners = new Map() 14 | 15 | let rabbitPublish: any = null 16 | let rabbitListen: any = null 17 | let rabbitUnsubscribe: any = null 18 | 19 | const on = (key: string, callback: any) => { 20 | const id = nanoid() 21 | rabbitListen(key) 22 | const alreadyListening = listeners.has(key) 23 | if (alreadyListening) { 24 | const currentListeners = listeners.get(key) 25 | const newListeners = currentListeners.push({ id, callback }) 26 | // @ts-ignore 27 | listeners.set(key, newListeners) 28 | } else { 29 | listeners.set(key, [{ id, callback }]) 30 | } 31 | return { unsubscribe: () => unsubscribe(key, id) } 32 | } 33 | 34 | const publish = (key: string, data: any) => { 35 | rabbitPublish(key, data) 36 | } 37 | 38 | const unsubscribe = (key: string, id: string) => { 39 | const listenersForKey = listeners.get(key) 40 | if (!listeners || !listenersForKey.length) { 41 | return 42 | } 43 | if (listenersForKey.length === 1) { 44 | rabbitUnsubscribe(key) 45 | listeners.delete(key) 46 | } else { 47 | const newListeners = listenersForKey.filter(l => l.id !== id) 48 | listeners.set(key, newListeners) 49 | } 50 | } 51 | 52 | return new Promise((resolve, reject) => { 53 | ampq 54 | .connect(connectString) 55 | .then(connection => { 56 | connection.createChannel().then(channel => { 57 | rabbitPublish = (key: string, message: any) => { 58 | channel.assertQueue(key) 59 | channel.sendToQueue(key, msgpack.encode({ data: message })) 60 | } 61 | rabbitListen = (key: string) => { 62 | const alreadyListening = listeners.has(key) 63 | if (alreadyListening) { 64 | return 65 | } 66 | channel.assertQueue(key) 67 | channel.consume(key, d => { 68 | const data = msgpack.decode(d.content).data 69 | const listenersToCall = listeners.get(key) 70 | if (listenersToCall && listenersToCall.length) { 71 | listenersToCall.forEach(listener => listener.callback(data)) 72 | } 73 | }) 74 | } 75 | rabbitUnsubscribe = (key: string) => { 76 | channel.cancel(key) 77 | } 78 | resolve(new PubSub(on, publish)) 79 | }) 80 | }) 81 | .catch(e => { 82 | reject(e) 83 | }) 84 | }) 85 | } 86 | 87 | export default RabbitMQ 88 | -------------------------------------------------------------------------------- /src/lib/pubsub/EventEmitterCluster.ts: -------------------------------------------------------------------------------- 1 | import cluster from 'cluster' 2 | import nanoid from 'nanoid' 3 | 4 | // @ts-ignore 5 | // tslint:disable-next-line 6 | const ClusterMaster = require('socket.io-adapter-cluster/master') 7 | 8 | // @ts-ignore 9 | // tslint:disable-next-line 10 | export const ClusterAdapter = require('socket.io-adapter-cluster') as () => SocketIO.Adapter 11 | 12 | import PubSubCreator from './PubSub' 13 | 14 | interface Callback { 15 | id: any 16 | callback: any 17 | } 18 | 19 | export const ProcessStarter = ( 20 | startFunction: any, 21 | numberOfWorkers: number, 22 | options?: any 23 | ) => { 24 | if (cluster.isWorker) { 25 | const processOptions = JSON.parse(process.env.blueboatGameValues || "{}") 26 | startFunction(processOptions) 27 | } 28 | if (cluster.isMaster) { 29 | const workers = [] as cluster.Worker[] 30 | const envVariables = { 31 | blueboatGameValues: options ? JSON.stringify(options) : "{}" 32 | } 33 | for (let i = 0; i < numberOfWorkers; i++) { 34 | const worker = cluster.fork(envVariables) 35 | workers.push(worker) 36 | worker.on('message', message => { 37 | if (message.key) { 38 | workers.forEach(w => { 39 | if (w.isConnected) { 40 | w.send(message) 41 | } 42 | }) 43 | } 44 | }) 45 | } 46 | ClusterMaster() 47 | } 48 | } 49 | 50 | export const PubSub = () => { 51 | const listeners = new Map() 52 | 53 | process.on('message', (data: any) => { 54 | const callbacks = listeners.get(data.key) 55 | if (callbacks && callbacks.length) { 56 | callbacks.forEach(callback => { 57 | callback.callback(data.data) 58 | }) 59 | } 60 | }) 61 | 62 | const on = (key: string, callback: (data: string) => any) => { 63 | const alreadyListeningForKey = listeners.has(key) 64 | const id = nanoid() 65 | if (!alreadyListeningForKey) { 66 | listeners.set(key, [{ id, callback }]) 67 | } else { 68 | const currentListeners = listeners.get(key) 69 | // @ts-ignore 70 | const newListeners: Callback[] = currentListeners.push({ id, callback }) 71 | listeners.set(key, newListeners) 72 | } 73 | return { unsubscribe: () => unsubscribe(key, id) } 74 | } 75 | 76 | const publish = (key: string, data: any) => { 77 | process.send({ key, data }) 78 | return 79 | } 80 | 81 | const unsubscribe = (key: string, id: string) => { 82 | const listenersForKey = listeners.get(key) 83 | if (!listenersForKey || !listenersForKey.length) { 84 | return 85 | } 86 | if (listenersForKey.length === 1) { 87 | listeners.delete(key) 88 | } else { 89 | const newListeners = listenersForKey.filter(l => l.id !== id) 90 | listeners.set(key, newListeners) 91 | } 92 | } 93 | 94 | return new PubSubCreator(on, publish) 95 | } 96 | -------------------------------------------------------------------------------- /src/lib/server/Connection/CreateNewRoom.ts: -------------------------------------------------------------------------------- 1 | import nanoid from 'nanoid' 2 | import { Server } from 'socket.io' 3 | import AvaiableRoomType from '../../../types/AvailableRoomType' 4 | import { RoomSnapshot } from '../../../types/RoomSnapshot' 5 | import SimpleClient from '../../../types/SimpleClient' 6 | import PubSub from '../../pubsub/PubSub' 7 | import Room from '../../room/Room' 8 | import Storage from '../../storage/Storage' 9 | import CustomGameValues from '../CustomGameValues' 10 | import RoomFetcher from '../RoomFetcher' 11 | 12 | const CreateNewRoom = ( 13 | client: SimpleClient, 14 | io: Server, 15 | roomFetcher: RoomFetcher, 16 | gameValues: CustomGameValues, 17 | pubsub: PubSub, 18 | storage: Storage, 19 | availableRooms: AvaiableRoomType[], 20 | onRoomDisposed: (roomId: string) => void, 21 | roomName: string, 22 | existingRoomIds: string[], 23 | creatorOptions: any, 24 | customRoomIdGenerator?: ( 25 | roomName: string, 26 | roomOptions?: any, 27 | creatorOptions?: any 28 | ) => string 29 | ): Promise => { 30 | return new Promise(async (resolve, reject) => { 31 | try { 32 | const roomToCreate = availableRooms.filter(r => r.name === roomName)[0] 33 | if (!roomToCreate) { 34 | throw new Error(`${roomName} does not have a room handler`) 35 | } 36 | let roomId: string 37 | for (let i = 0; i < 3; i++) { 38 | if (roomId) { 39 | break 40 | } 41 | const possibleRoomId = customRoomIdGenerator 42 | ? customRoomIdGenerator( 43 | roomToCreate.name, 44 | roomToCreate.options, 45 | creatorOptions 46 | ) 47 | : nanoid() 48 | if (!existingRoomIds.includes(possibleRoomId)) { 49 | roomId = possibleRoomId 50 | } 51 | } 52 | if (!roomId) { 53 | throw new Error('Failed to create room with unique ID') 54 | } 55 | const initialGameValues = await gameValues.getGameValues() 56 | const room = new roomToCreate.handler({ 57 | io, 58 | pubsub, 59 | owner: client, 60 | roomId, 61 | storage, 62 | creatorOptions, 63 | options: roomToCreate.options, 64 | roomFetcher, 65 | gameValues, 66 | initialGameValues, 67 | onRoomDisposed, 68 | roomType: roomToCreate.name, 69 | onRoomCreated: (error?: any) => { 70 | if (!error) { 71 | const snapshot: RoomSnapshot = { 72 | id: roomId, 73 | type: roomName, 74 | owner: client, 75 | metadata: {}, 76 | createdAt: Date.now() 77 | } 78 | roomFetcher 79 | .addRoom(snapshot) 80 | .then(() => resolve(room as Room)) 81 | .catch(e => reject(e)) 82 | } else { 83 | reject(error) 84 | } 85 | } 86 | }) 87 | } catch (e) { 88 | reject(e) 89 | } 90 | }) 91 | } 92 | 93 | export default CreateNewRoom 94 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 |
4 | 5 | # blueboat 6 | 7 | Blueboat is a simple game server backend for NodeJS focused on scalability. Blueboat's job is to focus on networking, timing, and state management so that you can focus on what makes your game special. 8 | 9 | Blueboat was created by and for [Gimkit](https://www.gimkit.com), and because of that, it has some opinionated decisions and requirements. Blueboat's main goal is to be scalable so that you don't have to change anything as your game grows. 10 | 11 | **Blueboat takes care of** 12 | * Real-time communication between server and clients 13 | * State management on the server 14 | * Room joining/leaving 15 | * Timing events 16 | * Scaling 17 | 18 | **Blueboat does not take care of** 19 | * State synchronization between server and client 20 | * Matchmaking 21 | * Game or Physics Engine 22 | * External Database (for saving player stats) 23 | 24 | ### Install 25 | ```bash 26 | 27 | yarn add blueboat 28 | ``` 29 | 30 | or 31 | 32 | ``` 33 | npm install blueboat --save 34 | ``` 35 | 36 | Blueboat comes with Typescript definitions already so need to install those! 37 | 38 | 39 | ### Documentation 40 | * [Documentation Home (Server)](https://github.com/gimkit/blueboat/wiki) 41 | * [Server API](https://github.com/gimkit/blueboat/wiki/Server-API) 42 | * [Room](https://github.com/gimkit/blueboat/wiki/Room) 43 | * [Client](https://github.com/gimkit/blueboat/wiki/Client) 44 | * [PubSub API](https://github.com/gimkit/blueboat/wiki/Pubsub-API) 45 | * [Storage API](https://github.com/gimkit/blueboat/wiki/Storage-API) 46 | * [Documentation Home (Client)](https://github.com/gimkit/blueboat-client/wiki) 47 | * [Client API](https://github.com/gimkit/blueboat-client/wiki/Client-API) 48 | * [Room API](https://github.com/gimkit/blueboat-client/wiki/Room-API) 49 | 50 | 51 | 52 | ### Admin Tool 53 | When running your server, you can visit an admin panel, but going to `/blueboat-panel` 54 | 55 | **View List Of Rooms** 56 | 57 | By default, you can see the list of rooms currently active in your server. 58 | 59 | 60 | 61 | **View Room** 62 | 63 | Click into a room to see the list of clients and the room's state 64 | 65 | 66 | **Change Game Values** 67 | 68 | Use Blueboat's game values tab to quickly change game values. Super useful to easily make balance adjustments or enable/disable game features. Here's an example of a simple chat application that gets a message from a bot when you create a room: 69 | 70 | 71 | 72 | 73 | ### Inspirations 74 | Blueboat is inspired by [Colyseus](https://github.com/colyseus/colyseus/). The creator of Colyseus, [Endel Dreyer](https://github.com/endel) helped significantly throughout the building of Blueboat. Thanks so much, Endel! 75 | 76 |
Icon made by fjstudio from www.flaticon.com is licensed by CC 3.0 BY
77 | -------------------------------------------------------------------------------- /src/lib/storage/ClusterMemory.ts: -------------------------------------------------------------------------------- 1 | import NodeCache from 'node-cache' 2 | import * as EventEmitterCluster from '../pubsub/EventEmitterCluster' 3 | import Storage from './Storage' 4 | 5 | const threeHours = 60 * 60 * 3 6 | 7 | const MEMORY_COMMAND = 'INTERNAL_MEMORY_CLUSTER' 8 | const SET = 'SET' 9 | const REMOVE = 'REMOVE' 10 | 11 | const Memory = () => { 12 | const cache = new NodeCache() 13 | const pubsub = EventEmitterCluster.PubSub() 14 | 15 | pubsub.on(MEMORY_COMMAND, (data: any) => { 16 | if (data && data.type) { 17 | if (data.type === SET) { 18 | set(data.data.key, data.data.value, data.data.setOptions).catch() 19 | } 20 | if (data.type === REMOVE) { 21 | remove(data.data.key).catch() 22 | } 23 | } 24 | }) 25 | 26 | const fetchKeys = (prefix: string): Promise => { 27 | return new Promise((resolve, reject) => { 28 | cache.keys((err, keys) => { 29 | if (err) { 30 | return reject(err) 31 | } 32 | return resolve( 33 | keys 34 | .filter(key => key.startsWith(prefix)) 35 | .map(key => key.replace(prefix, '')) 36 | ) 37 | }) 38 | }) 39 | } 40 | 41 | const set = ( 42 | key: string, 43 | value: string, 44 | setOptions?: { noExpiration: boolean } 45 | ): Promise => { 46 | return new Promise((resolve, reject) => { 47 | if (setOptions && setOptions.noExpiration) { 48 | cache.set(key, value, err => { 49 | if (err) { 50 | reject(err) 51 | } else { 52 | resolve() 53 | } 54 | }) 55 | } else { 56 | cache.set(key, value, threeHours, err => { 57 | if (err) { 58 | reject(err) 59 | } else { 60 | resolve() 61 | } 62 | }) 63 | } 64 | }) 65 | } 66 | 67 | const get = (key: string, resolveIfNoData?: boolean): Promise => { 68 | return new Promise((resolve, reject) => { 69 | cache.get(key, (err, data) => { 70 | if (err) { 71 | reject(err) 72 | } else { 73 | if (!data && !resolveIfNoData) { 74 | reject(`No data found for ${key}`) 75 | } else { 76 | // @ts-ignore 77 | resolve(data) 78 | } 79 | } 80 | }) 81 | }) 82 | } 83 | 84 | const remove = (key: string): Promise => { 85 | return new Promise((resolve, reject) => { 86 | cache.del(key, err => { 87 | if (err) { 88 | reject(err) 89 | } else { 90 | resolve() 91 | } 92 | }) 93 | }) 94 | } 95 | 96 | const setContainer = async ( 97 | key: string, 98 | value: string, 99 | setOptions?: { noExpiration: boolean } 100 | ) => { 101 | pubsub.publish(MEMORY_COMMAND, { 102 | type: SET, 103 | data: { 104 | key, 105 | value, 106 | setOptions 107 | } 108 | }) 109 | } 110 | 111 | const removeContainer = async (key: string) => { 112 | pubsub.publish(MEMORY_COMMAND, { 113 | type: REMOVE, 114 | data: { 115 | key 116 | } 117 | }) 118 | } 119 | 120 | return new Storage(get, setContainer, removeContainer, fetchKeys) 121 | } 122 | 123 | export default Memory 124 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "blueboat", 3 | "version": "0.3.42", 4 | "description": "Game server backend for NodeJS", 5 | "main": "build/main/index.js", 6 | "typings": "build/main/index.d.ts", 7 | "module": "build/module/index.js", 8 | "repository": "https://github.com/joshfeinsilber/blueboat", 9 | "license": "MIT", 10 | "keywords": [], 11 | "scripts": { 12 | "describe": "npm-scripts-info", 13 | "build": "run-s clean && run-p build:*", 14 | "build:main": "tsc -p tsconfig.json", 15 | "build:module": "tsc -p tsconfig.module.json", 16 | "example": "node build/main/example", 17 | "fix": "run-s fix:*", 18 | "fix:prettier": "prettier \"src/**/*.ts\" --write", 19 | "fix:tslint": "tslint --fix --project .", 20 | "test": "run-s build test:*", 21 | "test:lint": "tslint --project . && prettier \"src/**/*.ts\" --list-different", 22 | "test:unit": "nyc --silent ava", 23 | "watch": "run-s clean build:main && run-p \"build:main -- -w\" \"test:unit -- --watch\"", 24 | "cov": "run-s build test:unit cov:html && opn coverage/index.html", 25 | "cov:html": "nyc report --reporter=html", 26 | "cov:send": "nyc report --reporter=lcov > coverage.lcov && codecov", 27 | "cov:check": "nyc report && nyc check-coverage --lines 100 --functions 100 --branches 100", 28 | "doc": "run-s doc:html && opn build/docs/index.html", 29 | "doc:html": "typedoc src/ --exclude **/*.spec.ts --target ES6 --mode file --out build/docs", 30 | "doc:json": "typedoc src/ --exclude **/*.spec.ts --target ES6 --mode file --json build/docs/typedoc.json", 31 | "doc:publish": "gh-pages -m \"[ci skip] Updates\" -d build/docs", 32 | "version": "standard-version", 33 | "reset": "git clean -dfx && git reset --hard && npm i", 34 | "clean": "trash build test", 35 | "all": "run-s reset test cov:check doc:html", 36 | "prepare-release": "run-s all version doc:publish" 37 | }, 38 | "scripts-info": { 39 | "info": "Display information about the package scripts", 40 | "build": "Clean and rebuild the project", 41 | "fix": "Try to automatically fix any linting problems", 42 | "test": "Lint and unit test the project", 43 | "watch": "Watch and rebuild the project on save, then rerun relevant tests", 44 | "cov": "Rebuild, run tests, then create and open the coverage report", 45 | "doc": "Generate HTML API documentation and open it in a browser", 46 | "doc:json": "Generate API documentation in typedoc JSON format", 47 | "version": "Bump package.json version, update CHANGELOG.md, tag release", 48 | "reset": "Delete all untracked files and reset the repo to the last commit", 49 | "prepare-release": "One-step: clean, build, test, publish docs, and prep a release" 50 | }, 51 | "engines": { 52 | "node": ">=8.9" 53 | }, 54 | "dependencies": { 55 | "@gamestdio/state-listener": "^3.1.0", 56 | "@gamestdio/timer": "^1.3.0", 57 | "@types/socket.io": "^2.1.2", 58 | "amqplib": "^0.5.3", 59 | "body-parser": "^1.18.3", 60 | "express": "^4.16.4", 61 | "express-basic-auth": "^1.2.0", 62 | "ioredis": "^4.6.2", 63 | "lodash": "^4.17.15", 64 | "msgpack-lite": "^0.1.26", 65 | "nanoid": "^2.0.1", 66 | "node-cache": "^4.2.0", 67 | "serialize-error": "^6.0.0", 68 | "sha.js": "^2.4.11", 69 | "socket.io": "^2.3.0", 70 | "socket.io-adapter-cluster": "^1.0.1", 71 | "socket.io-msgpack-parser": "^2.2.0" 72 | }, 73 | "devDependencies": { 74 | "@types/amqplib": "^0.5.11", 75 | "@types/express": "^4.16.1", 76 | "@types/faker": "^4.1.5", 77 | "@types/ioredis": "^4.0.10", 78 | "@types/msgpack-lite": "^0.1.6", 79 | "@types/nanoid": "^1.2.0", 80 | "@types/node-cache": "^4.1.3", 81 | "@types/sticky-cluster": "^0.3.0", 82 | "ava": "1.0.0-beta.7", 83 | "codecov": "^3.1.0", 84 | "cors": "^2.8.5", 85 | "cz-conventional-changelog": "^2.1.0", 86 | "gh-pages": "^2.0.1", 87 | "npm-run-all": "^4.1.5", 88 | "nyc": "^13.1.0", 89 | "opn-cli": "^4.0.0", 90 | "prettier": "^1.15.2", 91 | "standard-version": "^4.4.0", 92 | "trash-cli": "^1.4.0", 93 | "tslint": "^5.11.0", 94 | "tslint-config-prettier": "^1.17.0", 95 | "tslint-immutable": "^5.0.0", 96 | "typedoc": "^0.13.0", 97 | "typescript": "^3.1.6" 98 | }, 99 | "ava": { 100 | "failFast": true, 101 | "files": [ 102 | "build/main/**/*.spec.js" 103 | ], 104 | "sources": [ 105 | "build/main/**/*.js" 106 | ] 107 | }, 108 | "config": { 109 | "commitizen": { 110 | "path": "cz-conventional-changelog" 111 | } 112 | }, 113 | "prettier": { 114 | "singleQuote": true, 115 | "semi": false 116 | }, 117 | "nyc": { 118 | "exclude": [ 119 | "**/*.spec.js" 120 | ] 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/lib/server/Connection/ConnectionHandler.ts: -------------------------------------------------------------------------------- 1 | import nanoid from 'nanoid' 2 | import { serializeError } from 'serialize-error' 3 | import Socket from 'socket.io' 4 | import AvaiableRoomType from '../../../types/AvailableRoomType' 5 | import SimpleClient from '../../../types/SimpleClient' 6 | import ClientActions from '../../constants/ClientActions' 7 | import { PLAYER_LEFT } from '../../constants/PubSubListeners' 8 | import ServerActions from '../../constants/ServerActions' 9 | import PubSub from '../../pubsub/PubSub' 10 | import Room from '../../room/Room' 11 | import Storage from '../../storage/Storage' 12 | import Logger, { LoggerTypes } from '../../utils/Logger' 13 | import CustomGameValues from '../CustomGameValues' 14 | import RoomFetcher from '../RoomFetcher' 15 | import CreateNewRoom from './CreateNewRoom' 16 | import JoinRoom from './JoinRoom' 17 | 18 | interface ConnectionHandlerOptions { 19 | io: Socket.Server 20 | socket: Socket.Socket 21 | pubsub: PubSub 22 | storage: Storage 23 | availableRoomTypes: AvaiableRoomType[] 24 | roomFetcher: RoomFetcher 25 | gameValues: CustomGameValues 26 | onRoomMade: (room: Room) => void 27 | onRoomDisposed: (roomId: string) => void 28 | customRoomIdGenerator?: ( 29 | roomName: string, 30 | roomOptions?: any, 31 | creatorOptions?: any 32 | ) => string 33 | returnIp?: (socket: Socket.Socket) => string 34 | } 35 | 36 | const ConnectionHandler = (options: ConnectionHandlerOptions) => { 37 | const { 38 | io, 39 | socket, 40 | storage, 41 | pubsub, 42 | availableRoomTypes, 43 | roomFetcher, 44 | gameValues, 45 | onRoomMade, 46 | onRoomDisposed, 47 | customRoomIdGenerator 48 | } = options 49 | 50 | const userId = socket.handshake.query.id || nanoid() 51 | const client: SimpleClient = { 52 | id: userId, 53 | sessionId: socket.id, 54 | origin: 55 | socket && 56 | socket.request && 57 | socket.request.headers && 58 | socket.request.headers.origin 59 | ? socket.request.headers.origin 60 | : '', 61 | ip: socket && options.returnIp ? options.returnIp(socket) : '' 62 | } 63 | socket.emit(ServerActions.clientIdSet, userId) 64 | 65 | socket.on( 66 | ClientActions.createNewRoom, 67 | async (request: { 68 | type: string 69 | options: any 70 | uniqueRequestId: string 71 | }) => { 72 | try { 73 | if (!request || !request.type || !request.uniqueRequestId) { 74 | throw new Error('Room type needed') 75 | } 76 | Logger( 77 | `${socket.id} trying to create a new ${request.type} room`, 78 | LoggerTypes.io 79 | ) 80 | const room = await CreateNewRoom( 81 | client, 82 | io, 83 | roomFetcher, 84 | gameValues, 85 | pubsub, 86 | storage, 87 | availableRoomTypes, 88 | onRoomDisposed, 89 | request.type, 90 | await roomFetcher.getListOfRooms(), 91 | request.options, 92 | customRoomIdGenerator 93 | ) 94 | Logger(`${room.roomId} made`, LoggerTypes.room) 95 | onRoomMade(room) 96 | 97 | socket.emit(`${request.uniqueRequestId}-create`, room.roomId) 98 | } catch (e) { 99 | const error = serializeError(e) 100 | Logger( 101 | `${socket.id} error creating room - ${JSON.stringify(error)}`, 102 | LoggerTypes.room 103 | ) 104 | socket.emit(`${request.uniqueRequestId}-error`, error) 105 | } 106 | } 107 | ) 108 | 109 | socket.on( 110 | ClientActions.joinRoom, 111 | async (payload: { roomId: string; options?: any }) => { 112 | try { 113 | const { roomId } = payload 114 | if (!roomId) { 115 | throw new Error('Room ID not provided') 116 | } 117 | Logger(`${socket.id} trying to join ${roomId} room`, LoggerTypes.io) 118 | await JoinRoom(roomId, client, roomFetcher, pubsub, payload.options) 119 | Logger(`${socket.id} joined ${roomId} room`, LoggerTypes.io) 120 | } catch (e) { 121 | if (payload && payload.roomId) { 122 | const error = serializeError(e) 123 | Logger( 124 | `${socket.id} error joining room ${ 125 | payload.roomId 126 | } - ${JSON.stringify(error)}`, 127 | LoggerTypes.room 128 | ) 129 | socket.emit(`${payload.roomId}-error`, error) 130 | } 131 | } 132 | } 133 | ) 134 | 135 | socket.on( 136 | ClientActions.sendMessage, 137 | (message: { room: string; key: string; data?: any }) => { 138 | if (message.key === undefined || !message.room) { 139 | return 140 | } 141 | Logger( 142 | `${socket.id} - message - ${JSON.stringify(message)}`, 143 | LoggerTypes.io 144 | ) 145 | pubsub.publish(message.room, { 146 | client, 147 | action: ClientActions.sendMessage, 148 | data: { key: message.key, data: message.data } 149 | }) 150 | } 151 | ) 152 | 153 | socket.on('disconnect', () => { 154 | Logger(`${socket.id} - disconnected`, LoggerTypes.io) 155 | pubsub.publish(PLAYER_LEFT, socket.id) 156 | }) 157 | } 158 | 159 | export default ConnectionHandler 160 | -------------------------------------------------------------------------------- /src/lib/server/Server.ts: -------------------------------------------------------------------------------- 1 | import bodyParser from 'body-parser' 2 | import Express from 'express' 3 | import basicAuth from 'express-basic-auth' 4 | import { Server as HTTPServer } from 'http' 5 | import { RedisOptions } from 'ioredis' 6 | import * as socket from 'socket.io' 7 | import MessagePackParser from 'socket.io-msgpack-parser' 8 | import { RedisStorage } from '../..' 9 | import AvaiableRoomType from '../../types/AvailableRoomType' 10 | import GetGameValues from '../api/GetGameValues' 11 | import GetRoom from '../api/GetRoom' 12 | import GetRooms from '../api/GetRooms' 13 | import SendExternalMessage from '../api/SendExternalMessage' 14 | import SetGameValues from '../api/SetGameValues' 15 | import { PLAYER_LEFT } from '../constants/PubSubListeners' 16 | import BUNDLED_PANEL_JS from '../panel/bundle' 17 | import PubSub from '../pubsub/PubSub' 18 | import Room from '../room/Room' 19 | import Storage from '../storage/Storage' 20 | import Logger, { LoggerTypes } from '../utils/Logger' 21 | import ConnectionHandler from './Connection/ConnectionHandler' 22 | import CustomGameValues from './CustomGameValues' 23 | import Emitter from './Emitter' 24 | import RoomFetcher from './RoomFetcher' 25 | 26 | const PANEL_PREFIX = '/blueboat-panel' 27 | const PANEL_HTML = ` 28 | 29 | 30 | Blueboat Panel 31 | 32 |
33 | 36 | 37 | ` 38 | 39 | interface ServerArguments { 40 | app: Express.Application 41 | storage: Storage 42 | pubsub: PubSub 43 | redis: RedisOptions 44 | admins: any 45 | adapters?: SocketIO.Adapter[] 46 | customRoomIdGenerator?: ( 47 | roomName: string, 48 | roomOptions?: any, 49 | creatorOptions?: any 50 | ) => string 51 | onDispose?: () => Promise 52 | onError?: (code: string, reason?: any) => void 53 | pingTimeout?: number 54 | pingInterval?: number 55 | transports?: string[] 56 | returnIp?: (socket: socket.Socket) => string 57 | } 58 | 59 | interface ServerState { 60 | availableRoomTypes: AvaiableRoomType[] 61 | managingRooms: Map 62 | } 63 | 64 | const signals = ['SIGINT', 'SIGTERM', 'SIGUSR2', 'uncaughtException'] 65 | 66 | class Server { 67 | public server: HTTPServer = null 68 | public storage: Storage = null 69 | public gameValues: CustomGameValues 70 | 71 | public state: ServerState = { 72 | availableRoomTypes: [], 73 | managingRooms: new Map() 74 | } 75 | public listen: (port: number, callback?: () => void) => void = null 76 | 77 | private initialOptions: ServerArguments = null 78 | private app: Express.Application = null 79 | private io: SocketIO.Server = null 80 | private pubsub: PubSub 81 | private roomFetcher: RoomFetcher = null 82 | private customRoomIdGenerator = null 83 | private onError = null as (code: string, reason?: any) => void 84 | 85 | constructor(options: ServerArguments) { 86 | this.initialOptions = options 87 | this.app = options.app 88 | this.storage = options.storage 89 | // @ts-ignore 90 | this.pubsub = options.pubsub 91 | this.roomFetcher = new RoomFetcher({ storage: this.storage }) 92 | this.gameValues = new CustomGameValues({ 93 | storage: RedisStorage({ clientOptions: options.redis }) 94 | }) 95 | this.customRoomIdGenerator = options.customRoomIdGenerator 96 | if (options.onError) { 97 | this.onError = options.onError 98 | } 99 | this.spawnServer(options) 100 | } 101 | 102 | public registerRoom = (roomName: string, handler: any, options?: any) => { 103 | const { availableRoomTypes } = this.state 104 | if (availableRoomTypes.map(room => room.name).includes(roomName)) { 105 | // Can't have two handlers for the same room 106 | return 107 | } 108 | this.state.availableRoomTypes.push({ name: roomName, handler, options }) 109 | return 110 | } 111 | 112 | public getRoomCount = async () => { 113 | try { 114 | const rooms = await this.roomFetcher.getListOfRooms() 115 | return rooms.length 116 | } catch (e) { 117 | throw e 118 | } 119 | } 120 | 121 | public getRooms = async () => { 122 | try { 123 | const rooms = await this.roomFetcher.getListOfRoomsWithData() 124 | return rooms 125 | } catch (e) { 126 | throw e 127 | } 128 | } 129 | 130 | public getNumberOfConnectedClients: () => Promise = () => { 131 | return new Promise(resolve => { 132 | this.io.of('/').clients((error, clients) => { 133 | if (!error) { 134 | resolve(clients.length) 135 | } else { 136 | resolve(0) 137 | } 138 | }) 139 | }) 140 | } 141 | 142 | public gracefullyShutdown = () => 143 | this.shutdown() 144 | .then() 145 | .catch() 146 | 147 | private onRoomMade = (room: Room) => { 148 | this.state.managingRooms.set(room.roomId, room) 149 | } 150 | private onRoomDisposed = (roomId: string) => { 151 | Logger(`${roomId} disposed`, LoggerTypes.room) 152 | this.state.managingRooms.delete(roomId) 153 | } 154 | 155 | private spawnServer = (options: ServerArguments) => { 156 | Logger('Spawning server...', LoggerTypes.server) 157 | this.server = new HTTPServer(this.app) 158 | this.makeRoutes(options.admins) 159 | this.listen = (port: number, callback?: () => void) => { 160 | this.server.listen(port, callback) 161 | Logger('Server listening on port ' + port, LoggerTypes.server) 162 | } 163 | 164 | const socketOptions: socket.ServerOptions = { 165 | // @ts-ignore 166 | parser: MessagePackParser, 167 | path: '/blueboat', 168 | transports: options.transports || ['websocket'], 169 | pingTimeout: options.pingTimeout || 5000, 170 | pingInterval: options.pingInterval || 25000 171 | } 172 | 173 | this.io = socket.default(socketOptions) 174 | if (options.adapters && options.adapters.length) { 175 | options.adapters.forEach(adapter => this.io.adapter(adapter)) 176 | } 177 | this.io.attach(this.server, socketOptions) 178 | this.io.on('connection', s => { 179 | Logger(s.id + ' connected', LoggerTypes.io) 180 | ConnectionHandler({ 181 | availableRoomTypes: this.state.availableRoomTypes, 182 | io: this.io, 183 | pubsub: this.pubsub, 184 | storage: this.storage, 185 | roomFetcher: this.roomFetcher, 186 | gameValues: this.gameValues, 187 | socket: s, 188 | onRoomMade: this.onRoomMade, 189 | onRoomDisposed: this.onRoomDisposed, 190 | customRoomIdGenerator: this.customRoomIdGenerator, 191 | returnIp: options.returnIp 192 | }) 193 | }) 194 | 195 | this.spawnPubSub() 196 | 197 | signals.forEach(signal => 198 | process.once(signal as any, (reason?: any) => 199 | this.shutdown(signal, reason) 200 | ) 201 | ) 202 | } 203 | 204 | private spawnPubSub = () => { 205 | this.pubsub.on(PLAYER_LEFT, (playerId: string) => { 206 | Emitter.emit(PLAYER_LEFT, playerId) 207 | }) 208 | } 209 | 210 | private makeRoutes = (adminUsers: any) => { 211 | const router = Express.Router() 212 | router.use(bodyParser.json()) 213 | // @ts-ignore 214 | router.use((req, res, next) => { 215 | // @ts-ignore 216 | req.gameServer = this 217 | next() 218 | }) 219 | router.use(basicAuth({ users: adminUsers, challenge: true })) 220 | 221 | // @ts-ignore 222 | router.get('/', (req, res) => { 223 | res.send(PANEL_HTML) 224 | }) 225 | 226 | router.get('/rooms', GetRooms) 227 | router.get('/rooms/:room', GetRoom) 228 | router.get('/gameValues', GetGameValues) 229 | router.post('/gameValues', SetGameValues) 230 | router.post('/external-message', SendExternalMessage) 231 | 232 | this.app.use(PANEL_PREFIX, router) 233 | } 234 | 235 | private shutdown = async (signal?: string, reason?: any) => { 236 | if (this.onError) { 237 | this.onError(signal, reason) 238 | } 239 | if (signal === 'uncaughtException' && reason) { 240 | console.log(reason) 241 | } 242 | 243 | try { 244 | if (this.state.managingRooms.size) { 245 | await Promise.all( 246 | Array.from(this.state.managingRooms.values()).map(room => 247 | room 248 | .dispose() 249 | .then() 250 | .catch() 251 | ) 252 | ) 253 | } 254 | if (this.initialOptions && this.initialOptions.onDispose) { 255 | await this.initialOptions.onDispose() 256 | } 257 | Logger('Server closing...', LoggerTypes.server) 258 | this.io.close() 259 | this.server.close() 260 | } catch (e) { 261 | Logger('Server closing...', LoggerTypes.server) 262 | this.io.close() 263 | this.server.close() 264 | return 265 | } 266 | } 267 | } 268 | 269 | export default Server 270 | -------------------------------------------------------------------------------- /src/lib/room/Room.ts: -------------------------------------------------------------------------------- 1 | import Clock from '@gamestdio/timer' 2 | import { serializeError } from 'serialize-error' 3 | import { Server } from 'socket.io' 4 | import SimpleClient from '../../types/SimpleClient' 5 | import ClientActions from '../constants/ClientActions' 6 | import { 7 | EXTERNAL_MESSAGE, 8 | PLAYER_LEFT, 9 | REQUEST_INFO 10 | } from '../constants/PubSubListeners' 11 | import ServerActions from '../constants/ServerActions' 12 | import PubSub, { OnFunction } from '../pubsub/PubSub' 13 | import CustomGameValues from '../server/CustomGameValues' 14 | import Emitter from '../server/Emitter' 15 | import RoomFetcher from '../server/RoomFetcher' 16 | import Storage from '../storage/Storage' 17 | import Callback from '../utils/Callback' 18 | import Client from './Client' 19 | 20 | interface RoomOptions { 21 | io: Server 22 | roomId: string 23 | storage: Storage 24 | pubsub: PubSub 25 | owner: SimpleClient 26 | creatorOptions: any 27 | options: {} 28 | onRoomDisposed: (roomId: string) => void 29 | roomFetcher: RoomFetcher 30 | gameValues: CustomGameValues 31 | initialGameValues: any 32 | roomType: string 33 | onRoomCreated: (error?: any) => void 34 | } 35 | 36 | class Room { 37 | // Public values 38 | 39 | // @ts-ignore 40 | public state: State = {} 41 | public initialGameValues: any = {} 42 | public roomId: string 43 | public clock = new Clock(true) 44 | public clients: Client[] = [] 45 | public options = {} as any 46 | public creatorOptions = {} as any 47 | public metadata: any 48 | public gameValues?: CustomGameValues 49 | public roomType: string 50 | public owner: SimpleClient 51 | 52 | public listeners = { 53 | onJoin: new Callback(), 54 | onLeave: new Callback() 55 | } 56 | 57 | // Private room Helpers and Objects 58 | private io: Server 59 | 60 | private pubsub: PubSub 61 | // @ts-ignore 62 | private storage: Storage 63 | private onRoomDisposed: (roomId: string) => void 64 | private roomFetcher: RoomFetcher 65 | private disposing: boolean = false 66 | /* tslint:disable */ 67 | private _gameMessagePubsub: ReturnType 68 | private _playerPubsub: any 69 | // @ts-ignore 70 | private _lastState: State = {} 71 | /* tslint:enable */ 72 | 73 | constructor(options: RoomOptions) { 74 | this.roomId = options.roomId 75 | this.io = options.io 76 | this.pubsub = options.pubsub 77 | this.storage = options.storage 78 | this.owner = options.owner 79 | this.onRoomDisposed = options.onRoomDisposed 80 | this.roomFetcher = options.roomFetcher 81 | this.gameValues = options.gameValues 82 | this.roomType = options.roomType 83 | this.initialGameValues = options.initialGameValues 84 | if (options.options) { 85 | this.options = options.options 86 | } 87 | if (options.creatorOptions) { 88 | this.creatorOptions = options.creatorOptions 89 | } 90 | 91 | const roomCreated = (error?: any) => { 92 | options.onRoomCreated(error) 93 | this.onRoomCreated() 94 | if (error) { 95 | // @ts-ignore 96 | this.dispose().catch((e: any) => false) 97 | } 98 | } 99 | 100 | if (this.onCreate) { 101 | this.onCreate(options.options) 102 | .then(() => roomCreated()) 103 | .catch(e => roomCreated(e)) 104 | } else { 105 | roomCreated() 106 | } 107 | // Dispose room automatically in 2.5 hours 108 | this.clock.setTimeout( 109 | () => 110 | this.dispose() 111 | .then() 112 | .catch(), 113 | 1000 * 60 * 60 * 2.5 114 | ) 115 | this.pubSubListener() 116 | } 117 | 118 | // API functions 119 | public async onCreate?(options?: any): Promise 120 | public async canClientJoin?( 121 | client: SimpleClient, 122 | options?: any 123 | ): Promise 124 | public onJoin?(client: Client, options?: any): void 125 | public onMessage?(client: Client, key: string, data?: any): void 126 | public async onLeave?(client: Client, intentional: boolean): Promise 127 | public beforePatch?(lastState: State): void 128 | public afterPatch?(lastState: State): void 129 | public beforeDispose?(): Promise 130 | public onDispose?(): Promise 131 | public onExternalMessage?(key: string, data?: any) 132 | 133 | public setState = (newState: State) => { 134 | this.state = newState 135 | } 136 | 137 | public broadcast = (key: string, data?: any) => { 138 | this.clients.forEach(client => client.send(key, data)) 139 | } 140 | 141 | public setMetadata = (newMetadata: any) => { 142 | this.roomFetcher 143 | .setRoomMetadata(this.roomId, newMetadata) 144 | .then(() => (this.metadata = newMetadata)) 145 | .catch() 146 | } 147 | 148 | public dispose = async () => { 149 | if (this.disposing) { 150 | return 151 | } 152 | this.disposing = true 153 | try { 154 | await Promise.all( 155 | this.clients.map(client => this.removeClient(client.sessionId, true)) 156 | ) 157 | if (this.beforeDispose) { 158 | await this.beforeDispose() 159 | } 160 | this.clock.stop() 161 | await this.roomFetcher.removeRoom(this.roomId) 162 | if (this._gameMessagePubsub && this._gameMessagePubsub.unsubscribe) { 163 | this._gameMessagePubsub.unsubscribe() 164 | } 165 | Emitter.removeListener(PLAYER_LEFT, this._playerPubsub) 166 | if (this.onDispose) { 167 | await this.onDispose() 168 | } 169 | this.onRoomDisposed(this.roomId) 170 | } catch (e) { 171 | throw e 172 | } 173 | } 174 | public allowReconnection = (client: Client, seconds: number) => { 175 | return new Promise(resolve => { 176 | if (this.disposing) { 177 | resolve(false) 178 | return 179 | } 180 | 181 | const timeOut = this.clock.setTimeout(() => { 182 | const reconnected = this.clients.filter(c => c.id === client.id).length 183 | ? true 184 | : false 185 | if (listener) { 186 | listener.clear() 187 | } 188 | 189 | resolve(reconnected) 190 | }, seconds * 1000) 191 | 192 | const listener = this.listeners.onJoin.add((joinedClient: Client) => { 193 | const reconnected = this.clients.filter(c => c.id === joinedClient.id) 194 | .length 195 | ? true 196 | : false 197 | 198 | if (reconnected) { 199 | if (timeOut) { 200 | timeOut.clear() 201 | } 202 | resolve(true) 203 | 204 | listener.clear() 205 | } 206 | }) 207 | }) 208 | } 209 | 210 | private onRoomCreated = () => { 211 | // 212 | } 213 | 214 | private findFullClientFromSimpleClient = (simpleClient: SimpleClient) => { 215 | return this.clients.filter( 216 | client => 217 | client.sessionId === simpleClient.sessionId && client.id === client.id 218 | )[0] 219 | } 220 | 221 | private addClient = (prejoinedClient: SimpleClient, options?: any) => { 222 | this.clients.push( 223 | new Client( 224 | this.roomId, 225 | prejoinedClient.id, 226 | prejoinedClient.sessionId, 227 | prejoinedClient.origin, 228 | prejoinedClient.ip, 229 | this.io, 230 | this.removeClient 231 | ) 232 | ) 233 | this.clientHasJoined( 234 | this.findFullClientFromSimpleClient(prejoinedClient), 235 | options 236 | ) 237 | } 238 | 239 | private clientHasJoined = (client: Client, options?: any) => { 240 | if (this.owner && this.owner.id && client.id === this.owner.id) { 241 | this.owner.sessionId = client.sessionId 242 | } 243 | client.send(ServerActions.joinedRoom) 244 | if (this.onJoin) { 245 | this.listeners.onJoin.call(client, options) 246 | this.onJoin(client, options) 247 | } 248 | } 249 | 250 | private clientRequestsToJoin = async (client: SimpleClient, options: any) => { 251 | try { 252 | if (this.canClientJoin) { 253 | await this.canClientJoin(client, options) 254 | } 255 | return true 256 | } catch (e) { 257 | throw e 258 | } 259 | } 260 | 261 | private removeClient = async ( 262 | clientSessionId: string, 263 | intentional: boolean 264 | ) => { 265 | const client = this.clients.filter(c => c.sessionId === clientSessionId)[0] 266 | if (!client) { 267 | return 268 | } 269 | client.send(ServerActions.removedFromRoom) 270 | this.clients = this.clients.filter(c => c !== client) 271 | this.listeners.onLeave.call(client, intentional) 272 | if (this.onLeave) { 273 | await this.onLeave(client, intentional) 274 | } 275 | if (!this.clients || !this.clients.length) { 276 | this.dispose() 277 | .then() 278 | .catch() 279 | } 280 | } 281 | 282 | private pubSubListener = () => { 283 | this._gameMessagePubsub = this.pubsub.on(this.roomId, (d: any) => { 284 | const payload = d as { 285 | action: string 286 | client: SimpleClient 287 | data?: any 288 | } 289 | if (!payload || !payload.action) { 290 | return 291 | } 292 | if (payload.action === REQUEST_INFO) { 293 | this.pubsub.publish(REQUEST_INFO, { 294 | clients: this.clients, 295 | state: this.state 296 | }) 297 | return 298 | } 299 | 300 | if (payload.action === EXTERNAL_MESSAGE) { 301 | if (this.onExternalMessage) { 302 | if (!payload.data) { 303 | return 304 | } 305 | if (!payload.data.key) { 306 | return 307 | } 308 | this.onExternalMessage(payload.data.key, payload.data.data) 309 | } 310 | return 311 | } 312 | 313 | if (!payload.client) { 314 | return 315 | } 316 | const { action, data, client } = payload 317 | if (action === ClientActions.joinRoom) { 318 | this.clientRequestsToJoin(client, data.options) 319 | .then(() => { 320 | this.addClient(client, data.options) 321 | }) 322 | .catch(e => 323 | this.io 324 | .to(client.sessionId) 325 | .emit(`${this.roomId}-error`, serializeError(e)) 326 | ) 327 | } 328 | if (action === ClientActions.sendMessage) { 329 | const roomClient = this.clients.find( 330 | c => c.sessionId === payload.client.sessionId 331 | ) 332 | if (!roomClient) { 333 | return 334 | } 335 | if (this.onMessage) { 336 | this.onMessage(roomClient, payload.data.key, payload.data.data) 337 | } 338 | } 339 | }) 340 | 341 | this._playerPubsub = (playerSessionId: string) => { 342 | const client = this.clients.find(c => c.sessionId === playerSessionId) 343 | if (!client) { 344 | return 345 | } 346 | this.removeClient(client.sessionId, false) 347 | .then() 348 | .catch() 349 | } 350 | Emitter.addListener(PLAYER_LEFT, this._playerPubsub) 351 | } 352 | } 353 | 354 | export default Room 355 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | 6 | ## [0.3.42](https://github.com/joshfeinsilber/blueboat/compare/v0.3.40...v0.3.42) (2021-11-06) 7 | 8 | 9 | 10 | 11 | ## [0.3.40](https://github.com/joshfeinsilber/blueboat/compare/v0.3.38...v0.3.40) (2021-11-06) 12 | 13 | 14 | 15 | 16 | ## [0.3.38](https://github.com/joshfeinsilber/blueboat/compare/v0.3.36...v0.3.38) (2020-05-17) 17 | 18 | 19 | 20 | 21 | ## [0.3.36](https://github.com/joshfeinsilber/blueboat/compare/v0.3.34...v0.3.36) (2020-03-24) 22 | 23 | 24 | 25 | 26 | ## [0.3.34](https://github.com/joshfeinsilber/blueboat/compare/v0.2.32...v0.3.34) (2020-03-23) 27 | 28 | 29 | 30 | 31 | ## [0.2.32](https://github.com/joshfeinsilber/blueboat/compare/v0.2.30...v0.2.32) (2020-03-20) 32 | 33 | 34 | 35 | 36 | ## [0.2.30](https://github.com/joshfeinsilber/blueboat/compare/v0.2.28...v0.2.30) (2020-02-04) 37 | 38 | 39 | 40 | 41 | ## [0.2.28](https://github.com/joshfeinsilber/blueboat/compare/v0.2.26...v0.2.28) (2020-01-30) 42 | 43 | 44 | 45 | 46 | ## [0.2.26](https://github.com/joshfeinsilber/blueboat/compare/v0.2.24...v0.2.26) (2020-01-22) 47 | 48 | 49 | 50 | 51 | ## [0.2.24](https://github.com/joshfeinsilber/blueboat/compare/v0.2.22...v0.2.24) (2020-01-17) 52 | 53 | 54 | 55 | 56 | ## [0.2.22](https://github.com/joshfeinsilber/blueboat/compare/v0.2.2...v0.2.22) (2020-01-17) 57 | 58 | 59 | 60 | 61 | ## [0.2.2](https://github.com/joshfeinsilber/blueboat/compare/v0.1.45...v0.2.2) (2020-01-17) 62 | 63 | 64 | 65 | 66 | ## [0.1.54](https://github.com/joshfeinsilber/blueboat/compare/v0.1.45...v0.1.54) (2020-01-17) 67 | 68 | 69 | 70 | 71 | ## [0.1.48](https://github.com/joshfeinsilber/blueboat/compare/v0.1.45...v0.1.48) (2020-01-17) 72 | 73 | 74 | 75 | 76 | ## [0.1.45](https://github.com/joshfeinsilber/blueboat/compare/v0.1.43...v0.1.45) (2020-01-17) 77 | 78 | 79 | 80 | 81 | ## [0.1.43](https://github.com/joshfeinsilber/blueboat/compare/v0.1.41...v0.1.43) (2020-01-17) 82 | 83 | 84 | 85 | 86 | ## [0.1.41](https://github.com/joshfeinsilber/blueboat/compare/v0.1.39...v0.1.41) (2020-01-16) 87 | 88 | 89 | 90 | 91 | ## [0.1.39](https://github.com/joshfeinsilber/blueboat/compare/v0.1.37...v0.1.39) (2020-01-15) 92 | 93 | 94 | 95 | 96 | ## [0.1.37](https://github.com/joshfeinsilber/blueboat/compare/v0.1.30...v0.1.37) (2020-01-15) 97 | 98 | 99 | 100 | 101 | ## [0.1.34](https://github.com/joshfeinsilber/blueboat/compare/v0.1.30...v0.1.34) (2020-01-15) 102 | 103 | 104 | 105 | 106 | ## [0.1.32](https://github.com/joshfeinsilber/blueboat/compare/v0.1.30...v0.1.32) (2020-01-15) 107 | 108 | 109 | 110 | 111 | ## [0.1.30](https://github.com/joshfeinsilber/blueboat/compare/v0.1.28...v0.1.30) (2020-01-15) 112 | 113 | 114 | 115 | 116 | ## [0.1.28](https://github.com/joshfeinsilber/blueboat/compare/v0.1.26...v0.1.28) (2020-01-14) 117 | 118 | 119 | 120 | 121 | ## [0.1.26](https://github.com/joshfeinsilber/blueboat/compare/v0.1.24...v0.1.26) (2020-01-14) 122 | 123 | 124 | 125 | 126 | ## [0.1.24](https://github.com/joshfeinsilber/blueboat/compare/v0.1.22...v0.1.24) (2020-01-14) 127 | 128 | 129 | 130 | 131 | ## [0.1.22](https://github.com/joshfeinsilber/blueboat/compare/v0.1.20...v0.1.22) (2020-01-14) 132 | 133 | 134 | 135 | 136 | ## [0.1.20](https://github.com/joshfeinsilber/blueboat/compare/v0.1.18...v0.1.20) (2020-01-13) 137 | 138 | 139 | 140 | 141 | ## [0.1.18](https://github.com/joshfeinsilber/blueboat/compare/v0.1.16...v0.1.18) (2020-01-13) 142 | 143 | 144 | 145 | 146 | ## [0.1.16](https://github.com/joshfeinsilber/blueboat/compare/v0.1.14...v0.1.16) (2019-12-20) 147 | 148 | 149 | 150 | 151 | ## [0.1.14](https://github.com/joshfeinsilber/blueboat/compare/v0.1.126...v0.1.14) (2019-12-10) 152 | 153 | 154 | 155 | 156 | ## [0.1.126](https://github.com/joshfeinsilber/blueboat/compare/v0.1.124...v0.1.126) (2019-12-02) 157 | 158 | 159 | 160 | 161 | ## [0.1.124](https://github.com/joshfeinsilber/blueboat/compare/v0.1.122...v0.1.124) (2019-11-06) 162 | 163 | 164 | 165 | 166 | ## [0.1.122](https://github.com/joshfeinsilber/blueboat/compare/v0.1.120...v0.1.122) (2019-11-06) 167 | 168 | 169 | 170 | 171 | ## [0.1.120](https://github.com/joshfeinsilber/blueboat/compare/v0.1.118...v0.1.120) (2019-11-06) 172 | 173 | 174 | 175 | 176 | ## [0.1.118](https://github.com/joshfeinsilber/blueboat/compare/v0.1.116...v0.1.118) (2019-11-05) 177 | 178 | 179 | 180 | 181 | ## [0.1.116](https://github.com/joshfeinsilber/blueboat/compare/v0.1.114...v0.1.116) (2019-08-27) 182 | 183 | 184 | 185 | 186 | ## [0.1.114](https://github.com/joshfeinsilber/blueboat/compare/v0.1.112...v0.1.114) (2019-08-27) 187 | 188 | 189 | 190 | 191 | ## [0.1.112](https://github.com/joshfeinsilber/blueboat/compare/v0.1.110...v0.1.112) (2019-07-12) 192 | 193 | 194 | 195 | 196 | ## [0.1.110](https://github.com/joshfeinsilber/blueboat/compare/v0.1.108...v0.1.110) (2019-07-12) 197 | 198 | 199 | 200 | 201 | ## [0.1.108](https://github.com/joshfeinsilber/blueboat/compare/v0.1.106...v0.1.108) (2019-05-01) 202 | 203 | 204 | 205 | 206 | ## [0.1.106](https://github.com/joshfeinsilber/blueboat/compare/v0.1.104...v0.1.106) (2019-04-17) 207 | 208 | 209 | 210 | 211 | ## [0.1.104](https://github.com/joshfeinsilber/blueboat/compare/v0.1.102...v0.1.104) (2019-04-16) 212 | 213 | 214 | 215 | 216 | ## [0.1.102](https://github.com/joshfeinsilber/blueboat/compare/v0.1.100...v0.1.102) (2019-04-04) 217 | 218 | 219 | 220 | 221 | ## [0.1.100](https://github.com/joshfeinsilber/blueboat/compare/v0.1.98...v0.1.100) (2019-04-04) 222 | 223 | 224 | 225 | 226 | ## [0.1.98](https://github.com/joshfeinsilber/blueboat/compare/v0.1.96...v0.1.98) (2019-04-04) 227 | 228 | 229 | 230 | 231 | ## [0.1.96](https://github.com/joshfeinsilber/blueboat/compare/v0.1.94...v0.1.96) (2019-04-03) 232 | 233 | 234 | 235 | 236 | ## [0.1.94](https://github.com/joshfeinsilber/blueboat/compare/v0.1.92...v0.1.94) (2019-04-03) 237 | 238 | 239 | 240 | 241 | ## [0.1.92](https://github.com/joshfeinsilber/blueboat/compare/v0.1.90...v0.1.92) (2019-03-31) 242 | 243 | 244 | 245 | 246 | ## [0.1.90](https://github.com/joshfeinsilber/blueboat/compare/v0.1.88...v0.1.90) (2019-03-27) 247 | 248 | 249 | 250 | 251 | ## [0.1.88](https://github.com/joshfeinsilber/blueboat/compare/v0.1.86...v0.1.88) (2019-03-27) 252 | 253 | 254 | 255 | 256 | ## [0.1.86](https://github.com/joshfeinsilber/blueboat/compare/v0.1.84...v0.1.86) (2019-03-26) 257 | 258 | 259 | 260 | 261 | ## [0.1.84](https://github.com/joshfeinsilber/blueboat/compare/v0.1.82...v0.1.84) (2019-03-26) 262 | 263 | 264 | 265 | 266 | ## [0.1.82](https://github.com/joshfeinsilber/blueboat/compare/v0.1.80...v0.1.82) (2019-03-26) 267 | 268 | 269 | 270 | 271 | ## [0.1.80](https://github.com/joshfeinsilber/blueboat/compare/v0.1.78...v0.1.80) (2019-03-25) 272 | 273 | 274 | 275 | 276 | ## [0.1.78](https://github.com/joshfeinsilber/blueboat/compare/v0.1.76...v0.1.78) (2019-03-25) 277 | 278 | 279 | 280 | 281 | ## [0.1.76](https://github.com/joshfeinsilber/blueboat/compare/v0.1.74...v0.1.76) (2019-03-24) 282 | 283 | 284 | 285 | 286 | ## [0.1.74](https://github.com/joshfeinsilber/blueboat/compare/v0.1.72...v0.1.74) (2019-03-24) 287 | 288 | 289 | 290 | 291 | ## [0.1.72](https://github.com/joshfeinsilber/blueboat/compare/v0.1.70...v0.1.72) (2019-03-24) 292 | 293 | 294 | 295 | 296 | ## [0.1.70](https://github.com/joshfeinsilber/blueboat/compare/v0.1.68...v0.1.70) (2019-03-22) 297 | 298 | 299 | 300 | 301 | ## [0.1.68](https://github.com/joshfeinsilber/blueboat/compare/v0.1.66...v0.1.68) (2019-03-22) 302 | 303 | 304 | 305 | 306 | ## [0.1.66](https://github.com/joshfeinsilber/blueboat/compare/v0.1.64...v0.1.66) (2019-03-22) 307 | 308 | 309 | 310 | 311 | ## [0.1.64](https://github.com/joshfeinsilber/blueboat/compare/v0.1.62...v0.1.64) (2019-03-21) 312 | 313 | 314 | 315 | 316 | ## [0.1.62](https://github.com/joshfeinsilber/blueboat/compare/v0.1.60...v0.1.62) (2019-03-20) 317 | 318 | 319 | 320 | 321 | ## [0.1.60](https://github.com/joshfeinsilber/blueboat/compare/v0.1.58...v0.1.60) (2019-03-19) 322 | 323 | 324 | 325 | 326 | ## [0.1.58](https://github.com/joshfeinsilber/blueboat/compare/v0.1.56...v0.1.58) (2019-03-19) 327 | 328 | 329 | 330 | 331 | ## [0.1.56](https://github.com/joshfeinsilber/blueboat/compare/v0.1.54...v0.1.56) (2019-03-11) 332 | 333 | 334 | 335 | 336 | ## [0.1.54](https://github.com/joshfeinsilber/blueboat/compare/v0.1.52...v0.1.54) (2019-03-11) 337 | 338 | 339 | 340 | 341 | ## [0.1.52](https://github.com/joshfeinsilber/blueboat/compare/v0.1.50...v0.1.52) (2019-03-11) 342 | 343 | 344 | 345 | 346 | ## [0.1.50](https://github.com/joshfeinsilber/blueboat/compare/v0.1.48...v0.1.50) (2019-03-11) 347 | 348 | 349 | 350 | 351 | ## [0.1.48](https://github.com/joshfeinsilber/blueboat/compare/v0.1.46...v0.1.48) (2019-03-11) 352 | 353 | 354 | 355 | 356 | ## [0.1.46](https://github.com/joshfeinsilber/blueboat/compare/v0.1.44...v0.1.46) (2019-03-09) 357 | 358 | 359 | 360 | 361 | ## [0.1.44](https://github.com/joshfeinsilber/blueboat/compare/v0.1.42...v0.1.44) (2019-03-06) 362 | 363 | 364 | 365 | 366 | ## [0.1.42](https://github.com/joshfeinsilber/blueboat/compare/v0.1.38...v0.1.42) (2019-03-06) 367 | 368 | 369 | 370 | 371 | ## [0.1.38](https://github.com/joshfeinsilber/blueboat/compare/v0.1.36...v0.1.38) (2019-03-05) 372 | 373 | 374 | 375 | 376 | ## [0.1.36](https://github.com/joshfeinsilber/blueboat/compare/v0.1.34...v0.1.36) (2019-02-27) 377 | 378 | 379 | 380 | 381 | ## [0.1.34](https://github.com/joshfeinsilber/blueboat/compare/v0.1.32...v0.1.34) (2019-02-26) 382 | 383 | 384 | 385 | 386 | ## [0.1.32](https://github.com/joshfeinsilber/blueboat/compare/v0.1.3...v0.1.32) (2019-02-23) 387 | 388 | 389 | 390 | 391 | ## [0.1.3](https://github.com/joshfeinsilber/blueboat/compare/v0.1.1...v0.1.3) (2019-02-22) 392 | 393 | 394 | ### Bug Fixes 395 | 396 | * **state-sync:** patch rate default set to 50ms ([7411254](https://github.com/joshfeinsilber/blueboat/commit/7411254)) 397 | 398 | 399 | 400 | 401 | ## 0.1.1 (2019-02-21) 402 | --------------------------------------------------------------------------------