diff --git a/docs/api/API.md b/docs/api/API.md index 5ff0249a1..a29c1525a 100644 --- a/docs/api/API.md +++ b/docs/api/API.md @@ -1,7 +1,6 @@ # API -The [Server](/api/Server) object hosts a REST API that can be used to -create and join games. It is particularly useful when you want to +The [Server](/api/Server) hosts a REST API that can be used to create and join games. It is particularly useful when you want to authenticate clients to prove that they have the right to send actions on behalf of a player. @@ -11,10 +10,6 @@ A game that is authenticated will not accept moves from a client on behalf of a Use the `create` API call to create a game that requires credential tokens. When you call the `join` API, you can retrieve the credential token for a particular player. -The API is available at server `port + 1` by default (i.e. if your -server is at `localhost:8000`, then the API is hosted at -`localhost:8001`. - ### Creating a game #### POST `/games/{name}/create` diff --git a/docs/api/Server.md b/docs/api/Server.md index 2c9a46c22..25c988659 100644 --- a/docs/api/Server.md +++ b/docs/api/Server.md @@ -8,26 +8,38 @@ broadcasts updates to those clients so that all browsers that are connected to the same game are kept in sync in realtime. +The server also hosts a REST [API](/api/API) that is used for creating +and joining games. This is hosted on the same port, but can +be configured to run on a separate port. + ### Arguments -obj(_object_): A config object with the following options: +A config object with the following options: -1. `games`: a list of game implementations +1. `games` (_array_): a list of game implementations (each is the return value of [Game](/api/Game.md)). -2. `db`: the [database connector](/storage). +2. `db` (_object_): the [database connector](/storage). + If not provided, an in-memory implementation is used. + +3. `transport` (_object_): the transport implementation. + If not provided, socket.io is used. ### Returns An object that contains: 1. run (_function_): A function to run the server. - Signature: (port, callback) => {} -2. app (_object_): The Koa app. -3. db (_object_): The `db` implementation. + _(portOrConfig, callback) => ({ apiServer, appServer })_ +2. kill (_function_): A function to stop the server. + _({ apiServer, appServer }) => {}_ +3. app (_object_): The Koa app. +4. db (_object_): The `db` implementation. ### Usage +##### Basic + ```js const Server = require('boardgame.io/server').Server; @@ -39,3 +51,15 @@ const server = Server({ server.run(8000); ``` + +##### With callback + +``` +server.run(8000, () => console.log("server running...")); +``` + +##### Running the API server on a separate port + +```js +server.run({ port: 8000, apiPort: 8001 }); +``` diff --git a/examples/react-web/src/lobby/lobby.js b/examples/react-web/src/lobby/lobby.js index b25e69e24..b517bedb0 100644 --- a/examples/react-web/src/lobby/lobby.js +++ b/examples/react-web/src/lobby/lobby.js @@ -29,7 +29,7 @@ const LobbyView = () => ( diff --git a/examples/react-web/src/tic-tac-toe/authenticated.js b/examples/react-web/src/tic-tac-toe/authenticated.js index b578f4bb6..ddda66701 100644 --- a/examples/react-web/src/tic-tac-toe/authenticated.js +++ b/examples/react-web/src/tic-tac-toe/authenticated.js @@ -41,7 +41,7 @@ class AuthenticatedClient extends React.Component { const PORT = 8000; const newGame = await request - .post(`http://localhost:${PORT + 1}/games/${gameName}/create`) + .post(`http://localhost:${PORT}/games/${gameName}/create`) .send({ numPlayers: 2 }); const gameID = newGame.body.gameID; @@ -50,7 +50,7 @@ class AuthenticatedClient extends React.Component { for (let playerID of [0, 1]) { const player = await request - .post(`http://localhost:${PORT + 1}/games/${gameName}/${gameID}/join`) + .post(`http://localhost:${PORT}/games/${gameName}/${gameID}/join`) .send({ gameName, playerID, diff --git a/src/server/api.js b/src/server/api.js index 7568b2824..158eb47b8 100644 --- a/src/server/api.js +++ b/src/server/api.js @@ -95,6 +95,10 @@ export const CreateGame = async (db, game, numPlayers, setupData) => { export const createApiServer = ({ db, games }) => { const app = new Koa(); + return addApiToServer({ app, db, games }); +}; + +export const addApiToServer = ({ app, db, games }) => { const router = new Router(); router.get('/games', async ctx => { diff --git a/src/server/api.test.js b/src/server/api.test.js index 40b963feb..83c3f280f 100644 --- a/src/server/api.test.js +++ b/src/server/api.test.js @@ -8,7 +8,11 @@ import request from 'supertest'; -import { isActionFromAuthenticPlayer, createApiServer } from './api'; +import { + isActionFromAuthenticPlayer, + addApiToServer, + createApiServer, +} from './api'; import Game from '../core/game'; jest.setTimeout(2000000000); @@ -637,3 +641,34 @@ describe('.createApiServer', () => { }); }); }); + +describe('.addApiToServer', () => { + describe('when server app is provided', () => { + let setSpy; + let db; + let server; + let useChain; + let games; + + beforeEach(async () => { + setSpy = jest.fn(); + useChain = jest.fn(() => ({ use: useChain })); + server = { use: useChain }; + db = { + set: async (id, state) => setSpy(id, state), + }; + games = [ + Game({ + name: 'foo', + setup: () => {}, + }), + ]; + + addApiToServer({ app: server, db, games }); + }); + + test('call .use method several times', async () => { + expect(server.use.mock.calls.length).toBeGreaterThan(1); + }); + }); +}); diff --git a/src/server/index.js b/src/server/index.js index a4d0e5cb3..245e36af4 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -8,11 +8,31 @@ const Koa = require('koa'); +import { addApiToServer, createApiServer } from './api'; import { DBFromEnv } from './db'; -import { createApiServer } from './api'; import * as logger from '../core/logger'; import { SocketIO } from './transport/socketio'; +/** + * Build config object from server run arguments. + * + * @param {number} portOrConfig - Either port or server config object. Optional. + * @param {function} callback - Server run callback. Optional. + */ +export const createServerRunConfig = (portOrConfig, callback) => { + const config = {}; + if (portOrConfig && typeof portOrConfig === 'object') { + config.port = portOrConfig.port; + config.callback = portOrConfig.callback || callback; + config.apiPort = portOrConfig.apiPort; + config.apiCallback = portOrConfig.apiCallback; + } else { + config.port = portOrConfig; + config.callback = callback; + } + return config; +}; + /** * Instantiate a game server. * @@ -33,23 +53,44 @@ export function Server({ games, db, transport }) { } transport.init(app, games); - const api = createApiServer({ db, games }); - return { app, - api, db, - run: async (port, callback) => { + run: async (portOrConfig, callback) => { + const serverRunConfig = createServerRunConfig(portOrConfig, callback); + + // DB await db.connect(); - let apiServer = await api.listen(port + 1); - let appServer = await app.listen(port, callback); - logger.info('listening...'); + + // API + let apiServer; + if (!serverRunConfig.apiPort) { + addApiToServer({ app, db, games }); + } else { + // Run API in a separate Koa app. + const api = createApiServer({ db, games }); + apiServer = await api.listen( + Number(serverRunConfig.apiPort), + serverRunConfig.apiCallback + ); + logger.info(`API serving on ${apiServer.address().port}...`); + } + + // Run Game Server (+ API, if necessary). + const appServer = await app.listen( + Number(serverRunConfig.port), + serverRunConfig.callback + ); + logger.info(`App serving on ${appServer.address().port}...`); + return { apiServer, appServer }; }, kill: ({ apiServer, appServer }) => { - apiServer.close(); + if (apiServer) { + apiServer.close(); + } appServer.close(); }, }; diff --git a/src/server/index.test.js b/src/server/index.test.js index 8888db293..c4ece0e50 100644 --- a/src/server/index.test.js +++ b/src/server/index.test.js @@ -6,8 +6,9 @@ * https://opensource.org/licenses/MIT. */ -import { Server } from '.'; +import { Server, createServerRunConfig } from '.'; import Game from '../core/game'; +import * as api from './api'; const game = Game({ seed: 0 }); @@ -16,41 +17,184 @@ jest.mock('../core/logger', () => ({ error: () => {}, })); +const mockApiServerListen = jest.fn(async () => ({ + address: () => ({ port: 'mock-api-port' }), + close: () => {}, +})); jest.mock('./api', () => ({ - createApiServer: () => ({ - listen: async () => {}, - }), + createApiServer: jest.fn(() => ({ + listen: mockApiServerListen, + })), + addApiToServer: jest.fn(), })); +jest.mock('koa-socket-2', () => { + class MockSocket { + on() {} + } + + return class { + constructor() { + this.socket = new MockSocket(); + } + attach(app) { + app.io = app._io = this; + } + of() { + return this; + } + on(type, callback) { + callback(this.socket); + } + }; +}); + jest.mock('koa', () => { return class { constructor() { this.context = {}; + this.callback = () => {}; + this.listen = async () => ({ + address: () => ({ port: 'mock-api-port' }), + close: () => {}, + }); } - - callback() {} - async listen() {} }; }); -test('basic', async () => { - const server = Server({ games: [game] }); - await server.run(); - expect(server).not.toBe(undefined); - const close = () => {}; - server.kill({ apiServer: { close }, appServer: { close } }); +describe('new', () => { + test('custom db implementation', () => { + const game = Game({}); + const db = {}; + const server = Server({ games: [game], db }); + expect(server.db).toBe(db); + }); + + test('custom transport implementation', () => { + const game = Game({}); + const transport = { init: jest.fn() }; + Server({ games: [game], transport }); + expect(transport.init).toBeCalled(); + }); }); -test('custom db implementation', async () => { - const game = Game({}); - const db = {}; - const server = Server({ games: [game], db }); - expect(server.db).toBe(db); +describe('run', () => { + let server, runningServer; + + beforeEach(() => { + server = null; + runningServer = null; + api.createApiServer.mockClear(); + api.addApiToServer.mockClear(); + mockApiServerListen.mockClear(); + }); + + afterEach(() => { + if (server && runningServer) { + const { apiServer, appServer } = runningServer; + server.kill({ apiServer, appServer }); + } + }); + + test('single server running', async () => { + server = Server({ games: [game] }); + runningServer = await server.run(); + + expect(server).not.toBeUndefined(); + expect(api.addApiToServer).toBeCalled(); + expect(api.createApiServer).not.toBeCalled(); + expect(mockApiServerListen).not.toBeCalled(); + }); + + test('multiple servers running', async () => { + server = Server({ games: [game] }); + runningServer = await server.run({ port: 57890, apiPort: 57891 }); + + expect(server).not.toBeUndefined(); + expect(api.addApiToServer).not.toBeCalled(); + expect(api.createApiServer).toBeCalled(); + expect(mockApiServerListen).toBeCalled(); + }); +}); + +describe('kill', () => { + test('call close on both servers', async () => { + const apiServer = { + close: jest.fn(), + }; + const appServer = { + close: jest.fn(), + }; + const server = Server({ games: [game], singlePort: true }); + + server.kill({ appServer, apiServer }); + + expect(apiServer.close).toBeCalled(); + expect(appServer.close).toBeCalled(); + }); + + test('do not fail if api server is not defined', async () => { + const appServer = { + close: jest.fn(), + }; + const server = Server({ games: [game], singlePort: true }); + + expect(() => server.kill({ appServer })).not.toThrowError(); + expect(appServer.close).toBeCalled(); + }); }); -test('custom transport implementation', async () => { - const game = Game({}); - const transport = { init: jest.fn() }; - Server({ games: [game], transport }); - expect(transport.init).toBeCalled(); +describe('createServerRunConfig', () => { + // TODO use data-driven-test here after upgrading to Jest 23+. + test('should return valid config with different server run arguments', () => { + const mockCallback = () => {}; + const mockApiCallback = () => {}; + + expect(createServerRunConfig()).toEqual({ + port: undefined, + callback: undefined, + }); + expect(createServerRunConfig(8000)).toEqual({ + port: 8000, + callback: undefined, + }); + expect(createServerRunConfig(8000, mockCallback)).toEqual({ + port: 8000, + callback: mockCallback, + }); + + expect(createServerRunConfig({})).toEqual({ + port: undefined, + callback: undefined, + }); + expect(createServerRunConfig({ port: 1234 })).toEqual({ + port: 1234, + callback: undefined, + }); + expect( + createServerRunConfig({ port: 1234, callback: mockCallback }) + ).toEqual({ + port: 1234, + callback: mockCallback, + }); + + expect(createServerRunConfig({ port: 1234, apiPort: 5467 })).toEqual({ + port: 1234, + callback: undefined, + apiPort: 5467, + }); + expect( + createServerRunConfig({ + port: 1234, + callback: mockCallback, + apiPort: 5467, + apiCallback: mockApiCallback, + }) + ).toEqual({ + port: 1234, + callback: mockCallback, + apiPort: 5467, + apiCallback: mockApiCallback, + }); + }); });