Compare commits

...

17 Commits

Author SHA1 Message Date
dc72a6389a Update CI entries for redis
Some checks failed
continuous-integration/drone/push Build is failing
2020-03-14 09:11:21 +01:00
5a03779139 3.0.11 2020-03-14 08:59:31 +01:00
5173b15c82 Add Redis info to README.md 2020-03-14 08:59:22 +01:00
4173ca0169 Merge branch 'redis' 2020-03-14 08:59:01 +01:00
f26b72a996 Upgrade all packages to latest versions 2020-03-14 08:49:22 +01:00
80eb39b5b8 Add test for memory and redis store 2020-03-14 08:36:21 +01:00
d6104bae14 Make redis configurable 2020-03-13 22:56:11 +01:00
170c52eefa Add store factory 2020-03-13 22:03:46 +01:00
41705177c5 Add redis store
Tested locally with docker-compose and two instances of peer-calls
running on different ports.
2020-03-13 21:41:03 +01:00
27d2459e1d Make socket.ts asynchronous
Also do not monkey-patch socket objects with user ids.
2020-03-13 20:28:46 +01:00
6459aa6228 3.0.10 2020-03-13 14:10:24 +01:00
aa7a6927f8 Keep active peer connections after server restart 2020-03-13 14:01:45 +01:00
cd4979c3be Generate userIDs on server-side
We don't want to depend on:

1) socket.io generated IDs because they change on server reconnect
2) simple-peer generated IDs because they change for every peer
connection

We generate a single ID when the call web page is refreshed and use that
throughout the session (until page refresh).

We keep relations of user-id to socket-id on the server side in memory
and use that to get to the right socket. In the future this might be
replaced with Redis to allow multiple nodes.

If the server is restarted, but people have active calls, we want them
to keep using the active peer connections and only connect to new peers.

Ideally, we do not want to disturb the active peer connections, but peer
connections might be restarted because the in-memory store will not have
the information on for any peers in the room upon restart.
2020-03-13 13:33:54 +01:00
ba92214296 Add ability to set nickname using /nick command in chat 2020-03-13 11:19:47 +01:00
54659863b5 Closes #74 2020-03-13 08:50:13 +01:00
becafd5042 3.0.9 2020-03-12 21:16:45 +01:00
c26b0bc5f8 Add baseUrl for favicon 2020-03-12 21:13:53 +01:00
46 changed files with 3986 additions and 6890 deletions

View File

@ -9,8 +9,11 @@ steps:
commands: commands:
- npm install - npm install
- npm run ci - npm run ci
services:
- name: redis
image: redis:5-alpine
--- ---
kind: signature kind: signature
hmac: a49a1e7c428472d0237bb2ba73511965607384f45114941b869c6a9eff7aef70 hmac: a88ee9f582a6f84910dc99e1deac94587796a88b0fda212c78a4c80422bbf33b
... ...

View File

@ -51,6 +51,7 @@ rules:
'@typescript-eslint/triple-slash-reference': '@typescript-eslint/triple-slash-reference':
- warn - warn
- path: always - path: always
'@typescript-eslint/no-empty-function': off
overrides: overrides:
- files: - files:
- '*.test.ts' - '*.test.ts'

View File

@ -1,9 +1,12 @@
stages: stages:
- test - test
test: test:
image: node:12 image: node:12
stage: test stage: test
variables:
TEST_REDIS_HOST: redis
script: script:
- npm install - npm install
- npm run ci - npm run ci
services:
redis:5-alpine

View File

@ -1,4 +1,6 @@
language: node_js language: node_js
services:
- redis-server
node_js: node_js:
- "8" - "8"
- "12" - "12"

View File

@ -134,6 +134,19 @@ unix domain socket.
To access the server, go to http://localhost:3000 (or another port). To access the server, go to http://localhost:3000 (or another port).
# Multiple Instances and Redis
Redis can be used to allow users connected to different instances to connect.
The following needs to be added to `config.yaml` to enable Redis:
```yaml
store:
type: redis
host: 127.0.0.1 # redis host
port: 6379 # redis port
prefix: peercalls # all instances must use the same prefix
```
# Logging # Logging
By default, Peer Calls server will log only basic information. Client-side By default, Peer Calls server will log only basic information. Client-side
@ -187,7 +200,7 @@ For more details, see here:
- [x] Reduce production build size by removing Pug. (Fixed in 2d14e5f c743f19) - [x] Reduce production build size by removing Pug. (Fixed in 2d14e5f c743f19)
- [x] Add ability to share files (Fixed in 3877893) - [x] Add ability to share files (Fixed in 3877893)
- [ ] Enable node cluster support (to scale vertically). - [ ] Enable node cluster support (to scale vertically).
- [ ] Add Socket.IO support for Redis (to scale horizontally). - [x] Add Socket.IO support for Redis (to scale horizontally).
- [ ] Generate public keys for each client, and allow each client to accept, - [ ] Generate public keys for each client, and allow each client to accept,
deny, and remember allowed/denied connections to specific peers. deny, and remember allowed/denied connections to specific peers.
- [ ] Add support for browser push notifications - [ ] Add support for browser push notifications

8
docker-compose.yml Normal file
View File

@ -0,0 +1,8 @@
version: '3.1'
services:
redis:
image: redis:5-alpine
restart: always
ports:
- 127.0.0.1:6379:6379

10153
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "peer-calls", "name": "peer-calls",
"version": "3.0.8", "version": "3.0.11",
"description": "Group peer to peer video calls for anybody.", "description": "Group peer to peer video calls for anybody.",
"repository": "https://github.com/jeremija/peer-calls", "repository": "https://github.com/jeremija/peer-calls",
"main": "lib/index.js", "main": "lib/index.js",
@ -50,68 +50,72 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"debug": "^4.1.1", "debug": "^4.1.1",
"ejs": "^2.7.4", "ejs": "^3.0.1",
"express": "^4.17.1", "express": "^4.17.1",
"ioredis": "^4.16.0",
"js-yaml": "^3.13.1", "js-yaml": "^3.13.1",
"lodash": "^4.17.15", "lodash": "^4.17.15",
"socket.io": "^2.3.0", "socket.io": "^2.3.0",
"uuid": "^3.3.3" "socket.io-redis": "^5.2.0",
"uuid": "^7.0.2"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.7.2", "@babel/core": "^7.8.7",
"@babel/polyfill": "^7.7.0", "@babel/polyfill": "^7.8.7",
"@babel/preset-env": "^7.7.1", "@babel/preset-env": "^7.8.7",
"@types/body-parser": "^1.19.0", "@types/body-parser": "^1.19.0",
"@types/classnames": "^2.2.9", "@types/classnames": "^2.2.10",
"@types/debug": "^4.1.5", "@types/debug": "^4.1.5",
"@types/ejs": "^2.6.3", "@types/ejs": "^3.0.1",
"@types/express": "^4.17.2", "@types/express": "^4.17.3",
"@types/jest": "^25.1.0", "@types/ioredis": "^4.14.9",
"@types/js-yaml": "^3.12.1", "@types/jest": "^25.1.4",
"@types/lodash": "^4.14.148", "@types/js-yaml": "^3.12.2",
"@types/node": "^12.12.8", "@types/lodash": "^4.14.149",
"@types/react": "^16.9.11", "@types/node": "^13.9.1",
"@types/react-dom": "^16.9.4", "@types/react": "^16.9.23",
"@types/react-redux": "^7.1.5", "@types/react-dom": "^16.9.5",
"@types/react-transition-group": "^4.2.3", "@types/react-redux": "^7.1.7",
"@types/react-transition-group": "^4.2.4",
"@types/redux-logger": "^3.0.7", "@types/redux-logger": "^3.0.7",
"@types/simple-peer": "^9.6.0", "@types/simple-peer": "^9.6.0",
"@types/socket.io": "^2.1.4", "@types/socket.io": "^2.1.4",
"@types/socket.io-client": "^1.4.32", "@types/socket.io-client": "^1.4.32",
"@types/socket.io-redis": "^1.0.25",
"@types/supertest": "^2.0.8", "@types/supertest": "^2.0.8",
"@types/uuid": "^3.4.6", "@types/uuid": "^7.0.0",
"@typescript-eslint/eslint-plugin": "^2.7.0", "@typescript-eslint/eslint-plugin": "^2.23.0",
"@typescript-eslint/parser": "^2.7.0", "@typescript-eslint/parser": "^2.23.0",
"babel-core": "^7.0.0-bridge.0", "babel-core": "^7.0.0-bridge.0",
"babel-minify": "^0.5.1", "babel-minify": "^0.5.1",
"babelify": "^10.0.0", "babelify": "^10.0.0",
"body-parser": "^1.19.0", "body-parser": "^1.19.0",
"chastifol": "^4.1.0", "chastifol": "^4.1.0",
"classnames": "^2.2.6", "classnames": "^2.2.6",
"core-js": "^3.4.1", "core-js": "^3.6.4",
"eslint": "^6.6.0", "eslint": "^6.8.0",
"eslint-plugin-import": "^2.18.2", "eslint-plugin-import": "^2.20.1",
"eslint-plugin-react": "^7.16.0", "eslint-plugin-react": "^7.19.0",
"jest": "^25.1.0", "jest": "^25.1.0",
"loose-envify": "^1.4.0", "loose-envify": "^1.4.0",
"node-sass": "4.13.1", "node-sass": "^4.13.1",
"nodemon": "^1.19.4", "nodemon": "^2.0.2",
"react": "^16.12.0", "react": "^16.13.0",
"react-dom": "^16.12.0", "react-dom": "^16.13.0",
"react-redux": "^7.1.3", "react-redux": "^7.2.0",
"react-transition-group": "^4.3.0", "react-transition-group": "^4.3.0",
"redux": "^4.0.4", "redux": "^4.0.5",
"redux-logger": "^3.0.6", "redux-logger": "^3.0.6",
"redux-thunk": "^2.2.0", "redux-thunk": "^2.3.0",
"rimraf": "^3.0.0", "rimraf": "^3.0.2",
"screenfull": "^5.0.0", "screenfull": "^5.0.2",
"simple-peer": "^9.6.2", "simple-peer": "^9.6.2",
"socket.io-client": "^2.3.0", "socket.io-client": "^2.3.0",
"supertest": "^4.0.2", "supertest": "^4.0.2",
"ts-jest": "^25.1.0", "ts-jest": "^25.2.1",
"ts-node": "^8.5.2", "ts-node": "^8.6.2",
"tsify": "^4.0.1", "tsify": "^4.0.1",
"typescript": "^3.7.2", "typescript": "^3.8.3",
"watchify": "^3.11.1", "watchify": "^3.11.1",
"webrtc-adapter": "^7.5.0" "webrtc-adapter": "^7.5.0"
} }

View File

@ -1,5 +1,5 @@
/* eslint-disable */ /* eslint-disable */
import EventEmitter from 'events' import { EventEmitter } from 'events'
const Peer = jest.fn().mockImplementation(() => { const Peer = jest.fn().mockImplementation(() => {
const peer = new EventEmitter(); const peer = new EventEmitter();

View File

@ -1,2 +1,2 @@
import EventEmitter from 'events' import { EventEmitter } from 'events'
export default new EventEmitter() export default new EventEmitter()

View File

@ -42,4 +42,6 @@ export const valueOf = jest.fn()
export const callId = 'call1234' export const callId = 'call1234'
export const userId = 'user1234'
export const iceServers = [] export const iceServers = []

View File

@ -6,7 +6,7 @@ import * as CallActions from './CallActions'
import * as SocketActions from './SocketActions' import * as SocketActions from './SocketActions'
import * as constants from '../constants' import * as constants from '../constants'
import socket from '../socket' import socket from '../socket'
import { callId } from '../window' import { callId, userId } from '../window'
import { bindActionCreators, createStore, AnyAction, combineReducers, applyMiddleware } from 'redux' import { bindActionCreators, createStore, AnyAction, combineReducers, applyMiddleware } from 'redux'
import reducers from '../reducers' import reducers from '../reducers'
import { middlewares } from '../middlewares' import { middlewares } from '../middlewares'
@ -60,6 +60,7 @@ describe('CallActions', () => {
expect((SocketActions.handshake as jest.Mock).mock.calls).toEqual([[{ expect((SocketActions.handshake as jest.Mock).mock.calls).toEqual([[{
socket, socket,
roomName: callId, roomName: callId,
userId: userId,
}]]) }]])
}) })

View File

@ -1,6 +1,6 @@
import socket from '../socket' import socket from '../socket'
import { ThunkResult } from '../store' import { ThunkResult } from '../store'
import { callId } from '../window' import { callId, userId } from '../window'
import * as NotifyActions from './NotifyActions' import * as NotifyActions from './NotifyActions'
import * as SocketActions from './SocketActions' import * as SocketActions from './SocketActions'
@ -25,6 +25,7 @@ async (dispatch, getState) => {
dispatch(SocketActions.handshake({ dispatch(SocketActions.handshake({
socket, socket,
roomName: callId, roomName: callId,
userId,
})) }))
dispatch(initialize()) dispatch(initialize())
resolve() resolve()

View File

@ -0,0 +1,20 @@
import { NICKNAME_SET } from '../constants'
export interface NicknameSetPayload {
nickname: string
userId: string
}
export interface NicknameSetAction {
type: 'NICKNAME_SET'
payload: NicknameSetPayload
}
export function setNickname(payload: NicknameSetPayload): NicknameSetAction {
return {
type: NICKNAME_SET,
payload,
}
}
export type NicknameActions = NicknameSetAction

View File

@ -7,11 +7,12 @@ import { EventEmitter } from 'events'
import { createStore, Store, GetState } from '../store' import { createStore, Store, GetState } from '../store'
import { Dispatch } from 'redux' import { Dispatch } from 'redux'
import { ClientSocket } from '../socket' import { ClientSocket } from '../socket'
import { PEERCALLS, PEER_EVENT_DATA, ME } from '../constants'
describe('PeerActions', () => { describe('PeerActions', () => {
function createSocket () { function createSocket () {
const socket = new EventEmitter() as unknown as ClientSocket const socket = new EventEmitter() as unknown as ClientSocket
socket.id = 'user1' socket.id = 'socket-id-user-1'
return socket return socket
} }
@ -29,7 +30,7 @@ describe('PeerActions', () => {
dispatch = store.dispatch dispatch = store.dispatch
getState = store.getState getState = store.getState
user = { id: 'user2' } user = { id: 'user1' }
socket = createSocket() socket = createSocket()
instances = (Peer as any).instances = []; instances = (Peer as any).instances = [];
(Peer as unknown as jest.Mock).mockClear() (Peer as unknown as jest.Mock).mockClear()
@ -39,7 +40,7 @@ describe('PeerActions', () => {
describe('create', () => { describe('create', () => {
it('creates a new peer', () => { it('creates a new peer', () => {
PeerActions.createPeer({ socket, user, initiator: 'user2', stream })( PeerActions.createPeer({ socket, user, initiator: 'other-user', stream })(
dispatch, getState) dispatch, getState)
expect(instances.length).toBe(1) expect(instances.length).toBe(1)
@ -51,7 +52,7 @@ describe('PeerActions', () => {
it('sets initiator correctly', () => { it('sets initiator correctly', () => {
PeerActions PeerActions
.createPeer({ .createPeer({
socket, user, initiator: 'user1', stream, socket, user, initiator: user.id, stream,
})(dispatch, getState) })(dispatch, getState)
expect(instances.length).toBe(1) expect(instances.length).toBe(1)
@ -74,20 +75,30 @@ describe('PeerActions', () => {
}) })
describe('events', () => { describe('events', () => {
let peer: Peer.Instance function createPeer() {
beforeEach(() => {
PeerActions.createPeer({ socket, user, initiator: 'user1', stream })( PeerActions.createPeer({ socket, user, initiator: 'user1', stream })(
dispatch, getState) dispatch, getState)
peer = instances[0] const peer = instances[instances.length - 1]
}) return peer
}
describe('connect', () => { describe('connect', () => {
beforeEach(() => peer.emit('connect'))
it('dispatches peer connection established message', () => { it('dispatches peer connection established message', () => {
createPeer().emit('connect')
// TODO // TODO
}) })
it('sends existing local streams to new peer', () => {
PeerActions.sendMessage({
payload: {nickname: 'john'},
type: 'nickname',
})(dispatch, getState)
const peer = createPeer()
peer.emit('connect')
})
it('sends current nickname to new peer', () => {
})
}) })
describe('data', () => { describe('data', () => {
@ -103,12 +114,17 @@ describe('PeerActions', () => {
}) })
it('decodes a message', () => { it('decodes a message', () => {
const payload = 'test' const peer = createPeer()
const object = JSON.stringify({ payload }) const message = {
type: 'text',
payload: 'test',
}
const object = JSON.stringify(message)
peer.emit('data', Buffer.from(object, 'utf-8')) peer.emit('data', Buffer.from(object, 'utf-8'))
const { list } = store.getState().messages const { list } = store.getState().messages
expect(list.length).toBeGreaterThan(0)
expect(list[list.length - 1]).toEqual({ expect(list[list.length - 1]).toEqual({
userId: 'user2', userId: user.id,
timestamp: jasmine.any(String), timestamp: jasmine.any(String),
image: undefined, image: undefined,
message: 'test', message: 'test',
@ -162,7 +178,7 @@ describe('PeerActions', () => {
})(dispatch, getState) })(dispatch, getState)
}) })
it('sends a message to all peers', () => { it('sends a text message to all peers', () => {
PeerActions.sendMessage({ payload: 'test', type: 'text' })( PeerActions.sendMessage({ payload: 'test', type: 'text' })(
dispatch, getState) dispatch, getState)
const { peers } = store.getState() const { peers } = store.getState()
@ -172,5 +188,76 @@ describe('PeerActions', () => {
.toEqual([[ '{"payload":"test","type":"text"}' ]]) .toEqual([[ '{"payload":"test","type":"text"}' ]])
}) })
it('sends a nickname change to all peers', () => {
PeerActions.sendMessage({
payload: {nickname: 'john'},
type: 'nickname',
})(dispatch, getState)
const { nicknames, peers } = store.getState()
expect((peers['user2'].send as jest.Mock).mock.calls)
.toEqual([[ '{"payload":{"nickname":"john"},"type":"nickname"}' ]])
expect((peers['user3'].send as jest.Mock).mock.calls)
.toEqual([[ '{"payload":{"nickname":"john"},"type":"nickname"}' ]])
expect(nicknames[ME]).toBe('john')
})
})
describe('receive message (handleData)', () => {
let peer: Peer.Instance
function emitData(message: PeerActions.Message) {
peer.emit(PEER_EVENT_DATA, JSON.stringify(message))
}
beforeEach(() => {
PeerActions.createPeer({
socket, user: { id: 'user2' }, initiator: 'user2', stream,
})(dispatch, getState)
peer = store.getState().peers['user2']
})
it('handles a message', () => {
emitData({
payload: 'hello',
type: 'text',
})
expect(store.getState().messages.list)
.toEqual([{
message: 'Connecting to peer...',
userId: PEERCALLS,
timestamp: jasmine.any(String),
}, {
message: 'hello',
userId: 'user2',
image: undefined,
timestamp: jasmine.any(String),
}])
})
it('handles nickname changes', () => {
emitData({
payload: {nickname: 'john'},
type: 'nickname',
})
emitData({
payload: {nickname: 'john2'},
type: 'nickname',
})
expect(store.getState().messages.list)
.toEqual([{
message: 'Connecting to peer...',
userId: PEERCALLS,
timestamp: jasmine.any(String),
}, {
message: 'User user2 is now known as john',
userId: PEERCALLS,
image: undefined,
timestamp: jasmine.any(String),
}, {
message: 'User john is now known as john2',
userId: PEERCALLS,
image: undefined,
timestamp: jasmine.any(String),
}])
})
}) })
}) })

View File

@ -1,6 +1,7 @@
import * as ChatActions from '../actions/ChatActions' import * as ChatActions from './ChatActions'
import * as NotifyActions from '../actions/NotifyActions' import * as NicknameActions from './NicknameActions'
import * as StreamActions from '../actions/StreamActions' import * as NotifyActions from './NotifyActions'
import * as StreamActions from './StreamActions'
import * as constants from '../constants' import * as constants from '../constants'
import Peer, { SignalData } from 'simple-peer' import Peer, { SignalData } from 'simple-peer'
import forEach from 'lodash/forEach' import forEach from 'lodash/forEach'
@ -8,6 +9,7 @@ import _debug from 'debug'
import { iceServers } from '../window' import { iceServers } from '../window'
import { Dispatch, GetState } from '../store' import { Dispatch, GetState } from '../store'
import { ClientSocket } from '../socket' import { ClientSocket } from '../socket'
import { getNickname } from '../nickname'
const debug = _debug('peercalls') const debug = _debug('peercalls')
@ -65,6 +67,13 @@ class PeerHandler {
peer.addTrack(track, s.stream) peer.addTrack(track, s.stream)
}) })
}) })
const nickname = state.nicknames[constants.ME]
if (nickname) {
sendData(peer, {
payload: {nickname},
type: 'nickname',
})
}
} }
handleTrack = (track: MediaStreamTrack, stream: MediaStream) => { handleTrack = (track: MediaStreamTrack, stream: MediaStream) => {
const { user, dispatch } = this const { user, dispatch } = this
@ -86,9 +95,10 @@ class PeerHandler {
})) }))
} }
handleData = (buffer: ArrayBuffer) => { handleData = (buffer: ArrayBuffer) => {
const { dispatch, user } = this const { dispatch, getState, user } = this
const state = getState()
const message = JSON.parse(new window.TextDecoder('utf-8').decode(buffer)) const message = JSON.parse(new window.TextDecoder('utf-8').decode(buffer))
debug('peer: %s, message: %o', user.id, buffer) debug('peer: %s, message: %o', user.id, message)
switch (message.type) { switch (message.type) {
case 'file': case 'file':
dispatch(ChatActions.addMessage({ dispatch(ChatActions.addMessage({
@ -98,6 +108,19 @@ class PeerHandler {
image: message.payload.data, image: message.payload.data,
})) }))
break break
case 'nickname':
dispatch(ChatActions.addMessage({
userId: constants.PEERCALLS,
message: 'User ' + getNickname(state.nicknames, user.id) +
' is now known as ' + message.payload.nickname,
timestamp: new Date().toLocaleString(),
image: undefined,
}))
dispatch(NicknameActions.setNickname({
userId: user.id,
nickname: message.payload.nickname,
}))
break
default: default:
dispatch(ChatActions.addMessage({ dispatch(ChatActions.addMessage({
userId: user.id, userId: user.id,
@ -150,7 +173,7 @@ export function createPeer (options: CreatePeerOptions) {
} }
const peer = new Peer({ const peer = new Peer({
initiator: socket.id === initiator, initiator: userId === initiator,
config: { iceServers }, config: { iceServers },
// Allow the peer to receive video, even if it's not sending stream: // Allow the peer to receive video, even if it's not sending stream:
// https://github.com/feross/simple-peer/issues/95 // https://github.com/feross/simple-peer/issues/95
@ -234,33 +257,58 @@ export interface FileMessage {
payload: Base64File payload: Base64File
} }
export type Message = TextMessage | FileMessage export interface NicknameMessage {
type: 'nickname'
payload: {
nickname: string
}
}
export type Message = TextMessage | FileMessage | NicknameMessage
function sendData(peer: Peer.Instance, message: Message) {
peer.send(JSON.stringify(message))
}
export const sendMessage = (message: Message) => export const sendMessage = (message: Message) =>
(dispatch: Dispatch, getState: GetState) => { (dispatch: Dispatch, getState: GetState) => {
const { peers } = getState() const { peers } = getState()
debug('Sending message type: %s to %s peers.', debug('Sending message type: %s to %s peers.',
message.type, Object.keys(peers).length) message.type, Object.keys(peers).length)
forEach(peers, (peer, userId) => {
switch (message.type) { switch (message.type) {
case 'file': case 'file':
dispatch(ChatActions.addMessage({ dispatch(ChatActions.addMessage({
userId: 'You', userId: constants.ME,
message: 'Send file: "' + message: 'Send file: "' +
message.payload.name + '" to peer: ' + userId, message.payload.name + '" to all peers',
timestamp: new Date().toLocaleString(), timestamp: new Date().toLocaleString(),
image: message.payload.data, image: message.payload.data,
})) }))
break break
case 'nickname':
dispatch(ChatActions.addMessage({
userId: constants.PEERCALLS,
message: 'You are now known as: ' + message.payload.nickname,
timestamp: new Date().toLocaleString(),
image: undefined,
}))
dispatch(NicknameActions.setNickname({
userId: constants.ME,
nickname: message.payload.nickname,
}))
window.localStorage &&
(window.localStorage.nickname = message.payload.nickname)
break
default: default:
dispatch(ChatActions.addMessage({ dispatch(ChatActions.addMessage({
userId: 'You', userId: constants.ME,
message: message.payload, message: message.payload,
timestamp: new Date().toLocaleString(), timestamp: new Date().toLocaleString(),
image: undefined, image: undefined,
})) }))
} }
peer.send(JSON.stringify(message)) forEach(peers, (peer, userId) => {
sendData(peer, message)
}) })
} }

View File

@ -9,6 +9,7 @@ import { createStore, Store, GetState } from '../store'
import { ClientSocket } from '../socket' import { ClientSocket } from '../socket'
import { Dispatch } from 'redux' import { Dispatch } from 'redux'
import { MediaStream } from '../window' import { MediaStream } from '../window'
import { SocketEvent } from '../../shared'
describe('SocketActions', () => { describe('SocketActions', () => {
const roomName = 'bla' const roomName = 'bla'
@ -29,28 +30,45 @@ describe('SocketActions', () => {
instances = (Peer as any).instances = [] instances = (Peer as any).instances = []
}) })
const userA = {
socketId: 'socket-a',
userId: 'user-a',
}
const userId = userA.userId
const userB = {
socketId: 'socket-b',
userId: 'user-b',
}
const userC = {
socketId: 'socket-c',
userId: 'user-c',
}
describe('handshake', () => { describe('handshake', () => {
describe('users', () => { describe('users', () => {
beforeEach(() => { beforeEach(() => {
SocketActions.handshake({ socket, roomName })(dispatch, getState) SocketActions
.handshake({ socket, roomName, userId })(dispatch, getState)
const payload = { const payload = {
users: [{ id: 'a' }, { id: 'b' }], users: [userA, userB],
initiator: 'a', initiator: userA.userId,
} }
socket.emit('users', payload) socket.emit('users', payload)
expect(instances.length).toBe(1) expect(instances.length).toBe(1)
}) })
it('adds a peer for each new user and destroys peers for missing', () => { it('adds a peer for each new user and keeps active connections', () => {
const payload = { const payload = {
users: [{ id: 'a' }, { id: 'c' }], users: [userA, userC],
initiator: 'c', initiator: userC.userId,
} }
socket.emit(constants.SOCKET_EVENT_USERS, payload) socket.emit(constants.SOCKET_EVENT_USERS, payload)
// then // then
expect(instances.length).toBe(2) expect(instances.length).toBe(2)
expect((instances[0].destroy as jest.Mock).mock.calls.length).toBe(1) expect((instances[0].destroy as jest.Mock).mock.calls.length).toBe(0)
expect((instances[1].destroy as jest.Mock).mock.calls.length).toBe(0) expect((instances[1].destroy as jest.Mock).mock.calls.length).toBe(0)
}) })
}) })
@ -59,16 +77,17 @@ describe('SocketActions', () => {
let data: Peer.SignalData let data: Peer.SignalData
beforeEach(() => { beforeEach(() => {
data = {} as any data = {} as any
SocketActions.handshake({ socket, roomName })(dispatch, getState) SocketActions
.handshake({ socket, roomName, userId })(dispatch, getState)
socket.emit('users', { socket.emit('users', {
initiator: 'a', initiator: userA.userId,
users: [{ id: 'a' }, { id: 'b' }], users: [userA, userB],
}) })
}) })
it('should forward signal to peer', () => { it('should forward signal to peer', () => {
socket.emit('signal', { socket.emit('signal', {
userId: 'b', userId: userB.userId,
signal: data, signal: data,
}) })
@ -94,11 +113,12 @@ describe('SocketActions', () => {
let ready = false let ready = false
socket.once('ready', () => { ready = true }) socket.once('ready', () => { ready = true })
SocketActions.handshake({ socket, roomName })(dispatch, getState) SocketActions
.handshake({ socket, roomName, userId })(dispatch, getState)
socket.emit('users', { socket.emit('users', {
initiator: 'a', initiator: userA.userId,
users: [{ id: 'a' }, { id: 'b' }], users: [userA, userB],
}) })
expect(instances.length).toBe(1) expect(instances.length).toBe(1)
peer = instances[0] peer = instances[0]
@ -117,8 +137,8 @@ describe('SocketActions', () => {
it('emits socket signal with user id', done => { it('emits socket signal with user id', done => {
const signal = { bla: 'bla' } const signal = { bla: 'bla' }
socket.once('signal', (payload: SocketActions.SignalOptions) => { socket.once('signal', (payload: SocketEvent['signal']) => {
expect(payload.userId).toEqual('b') expect(payload.userId).toEqual(userB.userId)
expect(payload.signal).toBe(signal) expect(payload.signal).toBe(signal)
done() done()
}) })
@ -139,8 +159,8 @@ describe('SocketActions', () => {
peer.emit(constants.PEER_EVENT_TRACK, stream.getTracks()[0], stream) peer.emit(constants.PEER_EVENT_TRACK, stream.getTracks()[0], stream)
expect(store.getState().streams).toEqual({ expect(store.getState().streams).toEqual({
b: { [userB.userId]: {
userId: 'b', userId: userB.userId,
streams: [{ streams: [{
stream, stream,
type: undefined, type: undefined,
@ -159,8 +179,8 @@ describe('SocketActions', () => {
// test stream with two tracks // test stream with two tracks
peer.emit(constants.PEER_EVENT_TRACK, track, stream) peer.emit(constants.PEER_EVENT_TRACK, track, stream)
expect(store.getState().streams).toEqual({ expect(store.getState().streams).toEqual({
b: { [userB.userId]: {
userId: 'b', userId: userB.userId,
streams: [{ streams: [{
stream, stream,
type: undefined, type: undefined,
@ -171,7 +191,7 @@ describe('SocketActions', () => {
}) })
it('removes stream & peer from store', () => { it('removes stream & peer from store', () => {
expect(store.getState().peers).toEqual({ b: peer }) expect(store.getState().peers).toEqual({ [userB.userId]: peer })
peer.emit('close') peer.emit('close')
expect(store.getState().streams).toEqual({}) expect(store.getState().streams).toEqual({})
expect(store.getState().peers).toEqual({}) expect(store.getState().peers).toEqual({})

View File

@ -1,11 +1,10 @@
import * as NotifyActions from '../actions/NotifyActions' import * as NotifyActions from '../actions/NotifyActions'
import * as PeerActions from '../actions/PeerActions' import * as PeerActions from '../actions/PeerActions'
import * as constants from '../constants' import * as constants from '../constants'
import keyBy from 'lodash/keyBy'
import _debug from 'debug' import _debug from 'debug'
import { SignalData } from 'simple-peer'
import { Dispatch, GetState } from '../store' import { Dispatch, GetState } from '../store'
import { ClientSocket } from '../socket' import { ClientSocket } from '../socket'
import { SocketEvent } from '../../shared'
const debug = _debug('peercalls') const debug = _debug('peercalls')
@ -15,24 +14,16 @@ export interface SocketHandlerOptions {
stream?: MediaStream stream?: MediaStream
dispatch: Dispatch dispatch: Dispatch
getState: GetState getState: GetState
}
export interface SignalOptions {
signal: SignalData
userId: string userId: string
} }
export interface UsersOptions {
initiator: string
users: Array<{ id: string }>
}
class SocketHandler { class SocketHandler {
socket: ClientSocket socket: ClientSocket
roomName: string roomName: string
stream?: MediaStream stream?: MediaStream
dispatch: Dispatch dispatch: Dispatch
getState: GetState getState: GetState
userId: string
constructor (options: SocketHandlerOptions) { constructor (options: SocketHandlerOptions) {
this.socket = options.socket this.socket = options.socket
@ -40,44 +31,47 @@ class SocketHandler {
this.stream = options.stream this.stream = options.stream
this.dispatch = options.dispatch this.dispatch = options.dispatch
this.getState = options.getState this.getState = options.getState
this.userId = options.userId
} }
handleSignal = ({ userId, signal }: SignalOptions) => { handleSignal = ({ userId, signal }: SocketEvent['signal']) => {
const { getState } = this const { getState } = this
const peer = getState().peers[userId] const peer = getState().peers[userId]
// debug('socket signal, userId: %s, signal: %o', userId, signal); // debug('socket signal, userId: %s, signal: %o', userId, signal);
if (!peer) return debug('user: %s, no peer found', userId) if (!peer) return debug('user: %s, no peer found', userId)
peer.signal(signal) peer.signal(signal)
} }
handleUsers = ({ initiator, users }: UsersOptions) => { handleUsers = ({ initiator, users }: SocketEvent['users']) => {
const { socket, stream, dispatch, getState } = this const { socket, stream, dispatch, getState } = this
debug('socket users: %o', users) debug('socket users: %o', users)
this.dispatch(NotifyActions.info('Connected users: {0}', users.length)) this.dispatch(NotifyActions.info('Connected users: {0}', users.length))
const { peers } = this.getState() const { peers } = this.getState()
debug('active peers: %o', Object.keys(peers))
users users
.filter(user => !peers[user.id] && user.id !== socket.id) .filter(
user =>
user.userId && !peers[user.userId] && user.userId !== this.userId)
.forEach(user => PeerActions.createPeer({ .forEach(user => PeerActions.createPeer({
socket, socket,
user, user: {
// users without id should be filtered out
id: user.userId!,
},
initiator, initiator,
stream, stream,
})(dispatch, getState)) })(dispatch, getState))
const newUsersMap = keyBy(users, 'id')
Object.keys(peers)
.filter(id => !newUsersMap[id])
.forEach(id => peers[id].destroy())
} }
} }
export interface HandshakeOptions { export interface HandshakeOptions {
socket: ClientSocket socket: ClientSocket
roomName: string roomName: string
userId: string
stream?: MediaStream stream?: MediaStream
} }
export function handshake (options: HandshakeOptions) { export function handshake (options: HandshakeOptions) {
const { socket, roomName, stream } = options const { socket, roomName, stream, userId } = options
return (dispatch: Dispatch, getState: GetState) => { return (dispatch: Dispatch, getState: GetState) => {
const handler = new SocketHandler({ const handler = new SocketHandler({
@ -86,6 +80,7 @@ export function handshake (options: HandshakeOptions) {
stream, stream,
dispatch, dispatch,
getState, getState,
userId,
}) })
// remove listeneres to make seocket reusable // remove listeneres to make seocket reusable
@ -95,9 +90,12 @@ export function handshake (options: HandshakeOptions) {
socket.on(constants.SOCKET_EVENT_SIGNAL, handler.handleSignal) socket.on(constants.SOCKET_EVENT_SIGNAL, handler.handleSignal)
socket.on(constants.SOCKET_EVENT_USERS, handler.handleUsers) socket.on(constants.SOCKET_EVENT_USERS, handler.handleUsers)
debug('socket.id: %s', socket.id) debug('userId: %s', userId)
debug('emit ready for room: %s', roomName) debug('emit ready for room: %s', roomName)
dispatch(NotifyActions.info('Ready for connections')) dispatch(NotifyActions.info('Ready for connections'))
socket.emit('ready', roomName) socket.emit('ready', {
room: roomName,
userId,
})
} }
} }

View File

@ -4,7 +4,7 @@ import React from 'react'
import Peer from 'simple-peer' import Peer from 'simple-peer'
import { Message } from '../actions/ChatActions' import { Message } from '../actions/ChatActions'
import { dismissNotification, Notification } from '../actions/NotifyActions' import { dismissNotification, Notification } from '../actions/NotifyActions'
import { TextMessage } from '../actions/PeerActions' import { Message as MessageType } from '../actions/PeerActions'
import { removeStream } from '../actions/StreamActions' import { removeStream } from '../actions/StreamActions'
import * as constants from '../constants' import * as constants from '../constants'
import Chat from './Chat' import Chat from './Chat'
@ -15,17 +15,19 @@ import Toolbar from './Toolbar'
import Video from './Video' import Video from './Video'
import { getDesktopStream } from '../actions/MediaActions' import { getDesktopStream } from '../actions/MediaActions'
import { StreamsState } from '../reducers/streams' import { StreamsState } from '../reducers/streams'
import { Nicknames } from '../reducers/nicknames'
export interface AppProps { export interface AppProps {
active: string | null active: string | null
dismissNotification: typeof dismissNotification dismissNotification: typeof dismissNotification
init: () => void init: () => void
nicknames: Nicknames
notifications: Record<string, Notification> notifications: Record<string, Notification>
messages: Message[] messages: Message[]
messagesCount: number messagesCount: number
peers: Record<string, Peer.Instance> peers: Record<string, Peer.Instance>
play: () => void play: () => void
sendMessage: (message: TextMessage) => void sendMessage: (message: MessageType) => void
streams: StreamsState streams: StreamsState
getDesktopStream: typeof getDesktopStream getDesktopStream: typeof getDesktopStream
removeStream: typeof removeStream removeStream: typeof removeStream
@ -34,13 +36,11 @@ export interface AppProps {
} }
export interface AppState { export interface AppState {
videos: Record<string, unknown>
chatVisible: boolean chatVisible: boolean
} }
export default class App extends React.PureComponent<AppProps, AppState> { export default class App extends React.PureComponent<AppProps, AppState> {
state: AppState = { state: AppState = {
videos: {},
chatVisible: false, chatVisible: false,
} }
handleShowChat = () => { handleShowChat = () => {
@ -79,6 +79,7 @@ export default class App extends React.PureComponent<AppProps, AppState> {
active, active,
dismissNotification, dismissNotification,
notifications, notifications,
nicknames,
messages, messages,
messagesCount, messagesCount,
onSendFile, onSendFile,
@ -89,8 +90,6 @@ export default class App extends React.PureComponent<AppProps, AppState> {
streams, streams,
} = this.props } = this.props
const { videos } = this.state
const chatVisibleClassName = classnames({ const chatVisibleClassName = classnames({
'chat-visible': this.state.chatVisible, 'chat-visible': this.state.chatVisible,
}) })
@ -127,6 +126,7 @@ export default class App extends React.PureComponent<AppProps, AppState> {
</Side> </Side>
<Chat <Chat
messages={messages} messages={messages}
nicknames={nicknames}
onClose={this.handleHideChat} onClose={this.handleHideChat}
sendMessage={sendMessage} sendMessage={sendMessage}
visible={this.state.chatVisible} visible={this.state.chatVisible}
@ -136,7 +136,6 @@ export default class App extends React.PureComponent<AppProps, AppState> {
const key = localStreams.userId + '_' + i const key = localStreams.userId + '_' + i
return ( return (
<Video <Video
videos={videos}
key={key} key={key}
active={active === key} active={active === key}
onClick={toggleActive} onClick={toggleActive}
@ -164,7 +163,6 @@ export default class App extends React.PureComponent<AppProps, AppState> {
play={play} play={play}
stream={s} stream={s}
userId={key} userId={key}
videos={videos}
/> />
) )
}) })

View File

@ -1,14 +1,17 @@
import classnames from 'classnames' import classnames from 'classnames'
import React from 'react' import React from 'react'
import { Message as MessageType } from '../actions/ChatActions' import { Message as ChatMessage } from '../actions/ChatActions'
import { TextMessage } from '../actions/PeerActions' import { Message } from '../actions/PeerActions'
import { Nicknames } from '../reducers/nicknames'
import Input from './Input' import Input from './Input'
import { ME } from '../constants'
import { getNickname } from '../nickname'
export interface MessageProps { export interface MessageProps {
message: MessageType message: ChatMessage
} }
function Message (props: MessageProps) { function MessageEntry (props: MessageProps) {
const { message } = props const { message } = props
return ( return (
<p className="message-text"> <p className="message-text">
@ -22,9 +25,10 @@ function Message (props: MessageProps) {
export interface ChatProps { export interface ChatProps {
visible: boolean visible: boolean
messages: MessageType[] messages: ChatMessage[]
nicknames: Nicknames
onClose: () => void onClose: () => void
sendMessage: (message: TextMessage) => void sendMessage: (message: Message) => void
} }
export default class Chat extends React.PureComponent<ChatProps> { export default class Chat extends React.PureComponent<ChatProps> {
@ -67,15 +71,15 @@ export default class Chat extends React.PureComponent<ChatProps> {
{messages.length ? ( {messages.length ? (
messages.map((message, i) => ( messages.map((message, i) => (
<div key={i}> <div key={i}>
{message.userId === 'You' ? ( {message.userId === ME ? (
<div className="chat-item chat-item-me"> <div className="chat-item chat-item-me">
<div className="message"> <div className="message">
<span className="message-user-name"> <span className="message-user-name">
{message.userId} {getNickname(this.props.nicknames, message.userId)}
</span> </span>
<span className="icon icon-schedule" /> <span className="icon icon-schedule" />
<time className="message-time">{message.timestamp}</time> <time className="message-time">{message.timestamp}</time>
<Message message={message} /> <MessageEntry message={message} />
</div> </div>
{message.image ? ( {message.image ? (
<img className="chat-item-img" src={message.image} /> <img className="chat-item-img" src={message.image} />
@ -92,11 +96,11 @@ export default class Chat extends React.PureComponent<ChatProps> {
)} )}
<div className="message"> <div className="message">
<span className="message-user-name"> <span className="message-user-name">
{message.userId} {getNickname(this.props.nicknames, message.userId)}
</span> </span>
<span className="icon icon-schedule" /> <span className="icon icon-schedule" />
<time className="message-time">{message.timestamp}</time> <time className="message-time">{message.timestamp}</time>
<Message message={message} /> <MessageEntry message={message} />
</div> </div>
</div> </div>
)} )}

View File

@ -2,12 +2,12 @@ import Input from './Input'
import React from 'react' import React from 'react'
import ReactDOM from 'react-dom' import ReactDOM from 'react-dom'
import TestUtils from 'react-dom/test-utils' import TestUtils from 'react-dom/test-utils'
import { TextMessage } from '../actions/PeerActions' import { Message } from '../actions/PeerActions'
describe('components/Input', () => { describe('components/Input', () => {
let node: Element let node: Element
let sendMessage: jest.Mock<(message: TextMessage) => void> let sendMessage: jest.MockedFunction<(message: Message) => void>
async function render () { async function render () {
sendMessage = jest.fn() sendMessage = jest.fn()
const div = document.createElement('div') const div = document.createElement('div')
@ -32,23 +32,61 @@ describe('components/Input', () => {
beforeEach(() => { beforeEach(() => {
sendMessage.mockClear() sendMessage.mockClear()
input = node.querySelector('textarea')! input = node.querySelector('textarea')!
TestUtils.Simulate.change(input, {
target: { value: message } as any,
})
expect(input.value).toBe(message)
}) })
describe('handleSubmit', () => { describe('handleSubmit', () => {
it('does nothing when no message', () => {
TestUtils.Simulate.change(input, {
target: { value: '' } as any,
})
TestUtils.Simulate.submit(node)
expect(sendMessage.mock.calls)
.toEqual([])
})
it('sends a message', () => { it('sends a message', () => {
TestUtils.Simulate.change(input, {
target: { value: message } as any,
})
TestUtils.Simulate.submit(node) TestUtils.Simulate.submit(node)
expect(input.value).toBe('') expect(input.value).toBe('')
expect(sendMessage.mock.calls) expect(sendMessage.mock.calls)
.toEqual([[ { payload: message, type: 'text' } ]]) .toEqual([[ { payload: message, type: 'text' } ]])
}) })
it('sends a nickname command', () => {
TestUtils.Simulate.change(input, {
target: { value: '/nick john' } as any,
})
TestUtils.Simulate.submit(node)
expect(sendMessage.mock.calls)
.toEqual([[ { payload: {nickname: 'john'}, type: 'nickname' } ]])
})
it('does not fail when command is empty', () => {
TestUtils.Simulate.change(input, {
target: { value: '/nick ' } as any,
})
TestUtils.Simulate.submit(node)
expect(sendMessage.mock.calls)
.toEqual([[ { payload: {nickname: ''}, type: 'nickname' } ]])
})
it('sends message when command is invalid', () => {
TestUtils.Simulate.change(input, {
target: { value: '/nick' } as any,
})
TestUtils.Simulate.submit(node)
expect(sendMessage.mock.calls)
.toEqual([[ { payload: '/nick', type: 'text' } ]])
})
}) })
describe('handleKeyPress', () => { describe('handleKeyPress', () => {
it('sends a message', () => { it('sends a message', () => {
TestUtils.Simulate.change(input, {
target: { value: message } as any,
})
TestUtils.Simulate.keyPress(input, { TestUtils.Simulate.keyPress(input, {
key: 'Enter', key: 'Enter',
}) })
@ -67,6 +105,9 @@ describe('components/Input', () => {
describe('handleSmileClick', () => { describe('handleSmileClick', () => {
it('adds smile to message', () => { it('adds smile to message', () => {
TestUtils.Simulate.change(input, {
target: { value: message } as any,
})
const div = node.querySelector('.chat-controls-buttons-smile')! const div = node.querySelector('.chat-controls-buttons-smile')!
TestUtils.Simulate.click(div) TestUtils.Simulate.click(div)
expect(input.value).toBe('test message😑') expect(input.value).toBe('test message😑')

View File

@ -1,14 +1,16 @@
import React, { ReactEventHandler, ChangeEventHandler, KeyboardEventHandler, MouseEventHandler } from 'react' import React, { ReactEventHandler, ChangeEventHandler, KeyboardEventHandler, MouseEventHandler } from 'react'
import { TextMessage } from '../actions/PeerActions' import { Message } from '../actions/PeerActions'
export interface InputProps { export interface InputProps {
sendMessage: (message: TextMessage) => void sendMessage: (message: Message) => void
} }
export interface InputState { export interface InputState {
message: string message: string
} }
const regexp = /^\/([a-z0-9]+) (.*)$/
export default class Input extends React.PureComponent<InputProps, InputState> { export default class Input extends React.PureComponent<InputProps, InputState> {
textArea = React.createRef<HTMLTextAreaElement>() textArea = React.createRef<HTMLTextAreaElement>()
state = { state = {
@ -38,10 +40,22 @@ export default class Input extends React.PureComponent<InputProps, InputState> {
const { sendMessage } = this.props const { sendMessage } = this.props
const { message } = this.state const { message } = this.state
if (message) { if (message) {
const matches = regexp.exec(message)
const command = matches && matches[1]
const restOfMessage = matches && matches[2] || ''
switch (command) {
case 'nick':
sendMessage({
type: 'nickname',
payload: {nickname: restOfMessage},
})
break
default:
sendMessage({ sendMessage({
payload: message, payload: message,
type: 'text', type: 'text',
}) })
}
// let image = null // let image = null
// // take snapshoot // // take snapshoot

View File

@ -25,7 +25,6 @@ describe('components/Video', () => {
render () { render () {
return <Video return <Video
ref={this.ref} ref={this.ref}
videos={this.props.videos}
active={this.props.active} active={this.props.active}
stream={this.state.stream || this.props.stream} stream={this.state.stream || this.props.stream}
onClick={this.props.onClick} onClick={this.props.onClick}
@ -38,7 +37,6 @@ describe('components/Video', () => {
} }
let component: VideoWrapper let component: VideoWrapper
let videos: Record<string, unknown> = {}
let video: Video let video: Video
let onClick: (userId: string) => void let onClick: (userId: string) => void
let mediaStream: MediaStream let mediaStream: MediaStream
@ -57,7 +55,6 @@ describe('components/Video', () => {
} }
async function render (args?: Partial<Flags>) { async function render (args?: Partial<Flags>) {
const flags: Flags = Object.assign({}, defaultFlags, args) const flags: Flags = Object.assign({}, defaultFlags, args)
videos = {}
onClick = jest.fn() onClick = jest.fn()
mediaStream = new MediaStream() mediaStream = new MediaStream()
const div = document.createElement('div') const div = document.createElement('div')
@ -70,7 +67,6 @@ describe('components/Video', () => {
ReactDOM.render( ReactDOM.render(
<VideoWrapper <VideoWrapper
ref={instance => resolve(instance!)} ref={instance => resolve(instance!)}
videos={videos}
active={flags.active} active={flags.active}
stream={stream} stream={stream}
onClick={onClick} onClick={onClick}

View File

@ -4,7 +4,7 @@ import socket from '../socket'
import { StreamWithURL } from '../reducers/streams' import { StreamWithURL } from '../reducers/streams'
export interface VideoProps { export interface VideoProps {
videos: Record<string, unknown> // videos: Record<string, unknown>
onClick: (userId: string) => void onClick: (userId: string) => void
active: boolean active: boolean
stream?: StreamWithURL stream?: StreamWithURL
@ -49,7 +49,7 @@ export default class Video extends React.PureComponent<VideoProps> {
this.componentDidUpdate() this.componentDidUpdate()
} }
componentDidUpdate () { componentDidUpdate () {
const { videos, stream } = this.props const { stream } = this.props
const video = this.videoRef.current! const video = this.videoRef.current!
const mediaStream = stream && stream.stream || null const mediaStream = stream && stream.stream || null
const url = stream && stream.url const url = stream && stream.url
@ -60,7 +60,6 @@ export default class Video extends React.PureComponent<VideoProps> {
} else if (video.src !== url) { } else if (video.src !== url) {
video.src = url || '' video.src = url || ''
} }
videos[socket.id] = video
} }
render () { render () {
const { active, mirrored, muted } = this.props const { active, mirrored, muted } = this.props

View File

@ -8,6 +8,7 @@ export const ALERT_CLEAR = 'ALERT_CLEAR'
export const INIT = 'INIT' export const INIT = 'INIT'
export const ME = '_me_' export const ME = '_me_'
export const PEERCALLS = '[PeerCalls]'
export const NOTIFY = 'NOTIFY' export const NOTIFY = 'NOTIFY'
export const NOTIFY_DISMISS = 'NOTIFY_DISMISS' export const NOTIFY_DISMISS = 'NOTIFY_DISMISS'
@ -21,6 +22,8 @@ export const MEDIA_VIDEO_CONSTRAINT_SET = 'MEDIA_VIDEO_CONSTRAINT_SET'
export const MEDIA_AUDIO_CONSTRAINT_SET = 'MEDIA_AUDIO_CONSTRAINT_SET' export const MEDIA_AUDIO_CONSTRAINT_SET = 'MEDIA_AUDIO_CONSTRAINT_SET'
export const MEDIA_PLAY = 'MEDIA_PLAY' export const MEDIA_PLAY = 'MEDIA_PLAY'
export const NICKNAME_SET = 'NICKNAME_SET'
export const PEER_ADD = 'PEER_ADD' export const PEER_ADD = 'PEER_ADD'
export const PEER_REMOVE = 'PEER_REMOVE' export const PEER_REMOVE = 'PEER_REMOVE'
export const PEERS_DESTROY = 'PEERS_DESTROY' export const PEERS_DESTROY = 'PEERS_DESTROY'

View File

@ -12,6 +12,7 @@ function mapStateToProps (state: State) {
streams: state.streams, streams: state.streams,
peers: state.peers, peers: state.peers,
notifications: state.notifications, notifications: state.notifications,
nicknames: state.nicknames,
messages: state.messages.list, messages: state.messages.list,
messagesCount: state.messages.count, messagesCount: state.messages.count,
active: state.active, active: state.active,

13
src/client/nickname.ts Normal file
View File

@ -0,0 +1,13 @@
import { Nicknames } from './reducers/nicknames'
import { ME } from './constants'
export function getNickname(nicknames: Nicknames, userId: string): string {
const nickname = nicknames[userId]
if (nickname) {
return nickname
}
if (userId === ME) {
return 'You'
}
return userId
}

View File

@ -4,6 +4,7 @@ import messages from './messages'
import peers from './peers' import peers from './peers'
import media from './media' import media from './media'
import streams from './streams' import streams from './streams'
import nicknames from './nicknames'
import { combineReducers } from 'redux' import { combineReducers } from 'redux'
export default combineReducers({ export default combineReducers({
@ -11,6 +12,7 @@ export default combineReducers({
notifications, notifications,
messages, messages,
media, media,
nicknames,
peers, peers,
streams, streams,
}) })

View File

@ -0,0 +1,27 @@
import { NICKNAME_SET, PEER_REMOVE, ME } from '../constants'
import { NicknameActions } from '../actions/NicknameActions'
import { RemovePeerAction } from '../actions/PeerActions'
import omit = require('lodash/omit')
export type Nicknames = Record<string, string | undefined>
const defaultState: Nicknames = {
[ME]: localStorage && localStorage.nickname,
}
export default function nicknames(
state = defaultState,
action: NicknameActions | RemovePeerAction,
) {
switch (action.type) {
case PEER_REMOVE:
return omit(state, [action.payload.userId])
case NICKNAME_SET:
return {
...state,
[action.payload.userId]: action.payload.nickname,
}
default:
return state
}
}

View File

@ -9,6 +9,7 @@ export const valueOf = (id: string) => {
export const baseUrl = valueOf('baseUrl') export const baseUrl = valueOf('baseUrl')
export const callId = valueOf('callId') export const callId = valueOf('callId')
export const userId = valueOf('userId')
export const iceServers = JSON.parse(valueOf('iceServers')!) export const iceServers = JSON.parse(valueOf('iceServers')!)
export const MediaStream = window.MediaStream export const MediaStream = window.MediaStream

View File

@ -10,6 +10,7 @@ import { config } from './config'
import handleSocket from './socket' import handleSocket from './socket'
import SocketIO from 'socket.io' import SocketIO from 'socket.io'
import request from 'supertest' import request from 'supertest'
import { MemoryStore } from './store'
const io = SocketIO() const io = SocketIO()
@ -61,7 +62,14 @@ describe('server/app', () => {
it('calls handleSocket with socket', () => { it('calls handleSocket with socket', () => {
const socket = { hi: 'me socket' } const socket = { hi: 'me socket' }
io.emit('connection', socket) io.emit('connection', socket)
expect((handleSocket as jest.Mock).mock.calls).toEqual([[ socket, io ]]) expect((handleSocket as jest.Mock).mock.calls).toEqual([[
socket,
io,
{
socketIdByUserId: jasmine.any(MemoryStore),
userIdBySocketId: jasmine.any(MemoryStore),
},
]])
}) })
}) })

View File

@ -1,14 +1,15 @@
import { config } from './config'
import _debug from 'debug'
import bodyParser from 'body-parser' import bodyParser from 'body-parser'
import _debug from 'debug'
import ejs from 'ejs'
import express from 'express' import express from 'express'
import handleSocket from './socket'
import path from 'path' import path from 'path'
import { createServer } from './server'
import SocketIO from 'socket.io' import SocketIO from 'socket.io'
import { config } from './config'
import { configureStores } from './configureStores'
import call from './routes/call' import call from './routes/call'
import index from './routes/index' import index from './routes/index'
import ejs from 'ejs' import { createServer } from './server'
import handleSocket from './socket'
const debug = _debug('peercalls') const debug = _debug('peercalls')
const logRequest = _debug('peercalls:requests') const logRequest = _debug('peercalls:requests')
@ -48,6 +49,7 @@ router.use('/call', call)
router.use('/', index) router.use('/', index)
app.use(BASE_URL, router) app.use(BASE_URL, router)
io.on('connection', socket => handleSocket(socket, io)) const stores = configureStores(io, config.store)
io.on('connection', socket => handleSocket(socket, io, stores))
export default server export default server

View File

@ -21,12 +21,27 @@ export interface Config {
cert: string cert: string
key: string key: string
} }
store?: StoreConfig
} }
export interface StoreRedisConfig {
host: string
port: number
prefix: string
type: 'redis'
}
export interface StoreMemoryConfig {
type: 'memory'
}
export type StoreConfig = StoreRedisConfig | StoreMemoryConfig
const cfg = readConfig() const cfg = readConfig()
export const config: Config = { export const config: Config = {
baseUrl: cfg.get('baseUrl', ''), baseUrl: cfg.get('baseUrl', ''),
iceServers: cfg.get('iceServers'), iceServers: cfg.get('iceServers'),
ssl: cfg.get('ssl', undefined), ssl: cfg.get('ssl', undefined),
store: cfg.get('store', {type: 'memory'}),
} }

View File

@ -0,0 +1,42 @@
jest.mock('ioredis')
import Redis from 'ioredis'
import SocketIO from 'socket.io'
import { configureStores } from './configureStores'
import { MemoryStore, RedisStore } from './store'
describe('configureStores', () => {
describe('memory', () => {
it('should be in memory when no params specified', () => {
const io = SocketIO()
const stores = configureStores(io)
expect(stores.socketIdByUserId).toEqual(jasmine.any(MemoryStore))
expect(stores.userIdBySocketId).toEqual(jasmine.any(MemoryStore))
})
it('should be in memory when type="memory"', () => {
const io = SocketIO()
const stores = configureStores(io)
expect(stores.socketIdByUserId).toEqual(jasmine.any(MemoryStore))
expect(stores.userIdBySocketId).toEqual(jasmine.any(MemoryStore))
})
})
describe('redis', () => {
it('should be redis when type="redis"', () => {
const io = SocketIO()
const stores = configureStores(io, {
type: 'redis',
host: 'localhost',
port: 6379,
prefix: 'peercalls',
})
expect(io.adapter().pubClient).toEqual(jasmine.any(Redis))
expect(io.adapter().subClient).toEqual(jasmine.any(Redis))
expect(stores.socketIdByUserId).toEqual(jasmine.any(RedisStore))
expect(stores.userIdBySocketId).toEqual(jasmine.any(RedisStore))
})
})
})

View File

@ -0,0 +1,55 @@
import _debug from 'debug'
import Redis from 'ioredis'
import redisAdapter from 'socket.io-redis'
import { StoreConfig, StoreRedisConfig } from './config'
import { Stores } from './socket'
import { MemoryStore, RedisStore } from './store'
const debug = _debug('peercalls')
export function configureStores(
io: SocketIO.Server,
config: StoreConfig = { type: 'memory'},
): Stores {
switch (config.type) {
case 'redis':
debug('Using redis store: %s:%s', config.host, config.port)
configureRedis(io, config)
return {
socketIdByUserId: new RedisStore(
createRedisClient(config),
[config.prefix, 'socketIdByUserId'].join(':'),
),
userIdBySocketId: new RedisStore(
createRedisClient(config),
[config.prefix, 'socketIdByUserId'].join(':'),
),
}
default:
debug('Using in-memory store')
return {
socketIdByUserId: new MemoryStore(),
userIdBySocketId: new MemoryStore(),
}
}
}
function configureRedis(
io: SocketIO.Server,
config: StoreRedisConfig,
) {
const pubClient = createRedisClient(config)
const subClient = createRedisClient(config)
io.adapter(redisAdapter({
key: 'peercalls',
pubClient,
subClient,
}))
}
function createRedisClient(config: StoreRedisConfig) {
return new Redis({
host: config.host,
port: config.port,
})
}

View File

@ -17,6 +17,7 @@ router.get('/:callId', (req, res) => {
const iceServers = turn.processServers(cfgIceServers) const iceServers = turn.processServers(cfgIceServers)
res.render('call', { res.render('call', {
callId: encodeURIComponent(req.params.callId), callId: encodeURIComponent(req.params.callId),
userId: v4(),
iceServers, iceServers,
}) })
}) })

View File

@ -2,58 +2,63 @@ import { EventEmitter } from 'events'
import { Socket } from 'socket.io' import { Socket } from 'socket.io'
import { TypedIO } from '../shared' import { TypedIO } from '../shared'
import handleSocket from './socket' import handleSocket from './socket'
import { MemoryStore, Store } from './store'
describe('server/socket', () => { describe('server/socket', () => {
type SocketMock = Socket & { type NamespaceMock = Socket & {
id: string id: string
room?: string room?: string
join: jest.Mock join: jest.Mock
leave: jest.Mock leave: jest.Mock
emit: jest.Mock emit: jest.Mock
clients: (callback: (
err: Error | undefined, clients: string[]
) => void) => void
} }
let socket: SocketMock let socket: NamespaceMock
let io: TypedIO & { let io: TypedIO & {
in: jest.Mock<(room: string) => SocketMock> in: jest.Mock<(room: string) => NamespaceMock>
to: jest.Mock<(room: string) => SocketMock> to: jest.Mock<(room: string) => NamespaceMock>
} }
let rooms: Record<string, {emit: any}> let rooms: Record<string, {emit: any}>
const socket0 = {
id: 'socket0',
}
const socket1 = {
id: 'socket1',
}
const socket2 = {
id: 'socket2',
}
let emitPromise: Promise<void>
beforeEach(() => { beforeEach(() => {
socket = new EventEmitter() as SocketMock socket = new EventEmitter() as NamespaceMock
socket.id = 'socket0' socket.id = 'socket0'
socket.join = jest.fn() socket.join = jest.fn()
socket.leave = jest.fn() socket.leave = jest.fn()
rooms = {} rooms = {}
let emitResolve: () => void
emitPromise = new Promise(resolve => {
emitResolve = resolve
})
const socketsByRoom: Record<string, string[]> = {
room1: [socket0.id],
room2: [socket0.id],
room3: [socket0.id, socket1.id, socket2.id],
}
io = {} as any io = {} as any
io.in = io.to = jest.fn().mockImplementation(room => { io.in = io.to = jest.fn().mockImplementation(room => {
return (rooms[room] = rooms[room] || { return (rooms[room] = rooms[room] || {
emit: jest.fn(), emit: jest.fn().mockImplementation(() => emitResolve()),
}) clients: callback => {
}) callback(undefined, socketsByRoom[room] || [])
io.sockets = {
adapter: {
rooms: {
room1: {
socket0: true,
} as any,
room2: {
socket0: true,
} as any,
room3: {
sockets: {
'socket0': true,
'socket1': true,
'socket2': true,
}, },
} as any, } as NamespaceMock)
}, })
} as any,
} as any
socket.leave = jest.fn()
socket.join = jest.fn()
}) })
it('should be a function', () => { it('should be a function', () => {
@ -61,18 +66,35 @@ describe('server/socket', () => {
}) })
describe('socket events', () => { describe('socket events', () => {
beforeEach(() => handleSocket(socket, io)) let stores: {
userIdBySocketId: Store
socketIdByUserId: Store
}
beforeEach(() => {
stores = {
userIdBySocketId: new MemoryStore(),
socketIdByUserId: new MemoryStore(),
}
stores.socketIdByUserId.set('a', socket0.id)
stores.userIdBySocketId.set(socket0.id, 'a')
stores.socketIdByUserId.set('b', socket1.id)
stores.userIdBySocketId.set(socket1.id, 'b')
stores.socketIdByUserId.set('c', socket2.id)
stores.userIdBySocketId.set(socket2.id, 'c')
handleSocket(socket, io, stores)
})
describe('signal', () => { describe('signal', () => {
it('should broadcast signal to specific user', () => { it('should broadcast signal to specific user', async () => {
const signal = { type: 'signal' } const signal = { type: 'signal' }
socket.emit('signal', { userId: 'a', signal }) socket.emit('signal', { userId: 'b', signal })
await emitPromise
expect(io.to.mock.calls).toEqual([[ 'a' ]]) expect(io.to.mock.calls).toEqual([[ socket1.id ]])
expect((io.to('a').emit as jest.Mock).mock.calls).toEqual([[ expect((io.to(socket1.id).emit as jest.Mock).mock.calls).toEqual([[
'signal', { 'signal', {
userId: 'socket0', userId: 'a',
signal, signal,
}, },
]]) ]])
@ -80,33 +102,48 @@ describe('server/socket', () => {
}) })
describe('ready', () => { describe('ready', () => {
it('should call socket.leave if socket.room', () => { it('never calls socket.leave', async () => {
socket.room = 'room1' socket.room = 'room1'
socket.emit('ready', 'room2') socket.emit('ready', {
userId: 'a',
room: 'room2',
})
await emitPromise
expect(socket.leave.mock.calls).toEqual([[ 'room1' ]]) expect(socket.leave.mock.calls).toEqual([])
expect(socket.join.mock.calls).toEqual([[ 'room2' ]]) expect(socket.join.mock.calls).toEqual([[ 'room2' ]])
}) })
it('should call socket.join to room', () => { it('should call socket.join to room', async () => {
socket.emit('ready', 'room3') socket.emit('ready', {
userId: 'b',
room: 'room3',
})
await emitPromise
expect(socket.join.mock.calls).toEqual([[ 'room3' ]]) expect(socket.join.mock.calls).toEqual([[ 'room3' ]])
}) })
it('should emit users', () => { it('should emit users', async () => {
socket.emit('ready', 'room3') socket.emit('ready', {
userId: 'a',
room: 'room3',
})
await emitPromise
expect(io.to.mock.calls).toEqual([[ 'room3' ]]) // expect(io.to.mock.calls).toEqual([[ 'room3' ]])
expect((io.to('room3').emit as jest.Mock).mock.calls).toEqual([ expect((io.to('room3').emit as jest.Mock).mock.calls).toEqual([
[ [
'users', { 'users', {
initiator: 'socket0', initiator: 'a',
users: [{ users: [{
id: 'socket0', socketId: socket0.id,
userId: 'a',
}, { }, {
id: 'socket1', socketId: socket1.id,
userId: 'b',
}, { }, {
id: 'socket2', socketId: socket2.id,
userId: 'c',
}], }],
}, },
], ],

View File

@ -1,40 +1,82 @@
'use strict' 'use strict'
import _debug from 'debug' import _debug from 'debug'
import map from 'lodash/map'
import { ServerSocket, TypedIO } from '../shared' import { ServerSocket, TypedIO } from '../shared'
import { Store } from './store'
const debug = _debug('peercalls:socket') const debug = _debug('peercalls:socket')
export default function handleSocket(socket: ServerSocket, io: TypedIO) { export interface Stores {
socket.on('signal', payload => { userIdBySocketId: Store
// debug('signal: %s, payload: %o', socket.id, payload) socketIdByUserId: Store
io.to(payload.userId).emit('signal', { }
userId: socket.id,
export default function handleSocket(
socket: ServerSocket,
io: TypedIO,
stores: Stores,
) {
socket.once('disconnect', async () => {
const userId = await stores.userIdBySocketId.get(socket.id)
if (userId) {
await Promise.all([
stores.userIdBySocketId.remove(socket.id),
stores.socketIdByUserId.remove(userId),
])
}
})
socket.on('signal', async payload => {
// debug('signal: %s, payload: %o', socket.userId, payload)
const socketId = await stores.socketIdByUserId.get(payload.userId)
const userId = await stores.userIdBySocketId.get(socket.id)
if (socketId) {
io.to(socketId).emit('signal', {
userId,
signal: payload.signal, signal: payload.signal,
}) })
}
}) })
socket.on('ready', roomName => { socket.on('ready', async payload => {
debug('ready: %s, room: %s', socket.id, roomName) const { userId, room } = payload
if (socket.room) socket.leave(socket.room) debug('ready: %s, room: %s', userId, room)
socket.room = roomName // no need to leave rooms because there will be only one room for the
socket.join(roomName) // duration of the socket connection
socket.room = roomName await Promise.all([
stores.socketIdByUserId.set(userId, socket.id),
stores.userIdBySocketId.set(socket.id, userId),
])
socket.join(room)
const users = getUsers(roomName) const users = await getUsers(room)
debug('ready: %s, room: %s, users: %o', socket.id, roomName, users) debug('ready: %s, room: %s, users: %o', userId, room, users)
io.to(roomName).emit('users', { io.to(room).emit('users', {
initiator: socket.id, initiator: userId,
users, users,
}) })
}) })
function getUsers (roomName: string) { async function getUsers (room: string) {
return map(io.sockets.adapter.rooms[roomName].sockets, (_, id) => { const socketIds = await getClientsInRoom(room)
return { id } const userIds = await stores.userIdBySocketId.getMany(socketIds)
}) return socketIds.map((socketId, i) => ({
socketId,
userId: userIds[i],
}))
} }
async function getClientsInRoom(room: string): Promise<string[]> {
return new Promise((resolve, reject) => {
io.in(room).clients((err: Error, clients: string[]) => {
if (err) {
reject(err)
} else {
resolve(clients)
} }
})
})
}
}

View File

@ -0,0 +1,3 @@
export * from './store'
export * from './memory'
export * from './redis'

View File

@ -0,0 +1,25 @@
import { Store } from './store'
export class MemoryStore implements Store {
store: Record<string, string> = {}
async getMany(keys: string[]): Promise<Array<string | undefined>> {
return keys.map(key => this.syncGet(key))
}
private syncGet(key: string): string | undefined {
return this.store[key]
}
async get(key: string): Promise<string | undefined> {
return this.syncGet(key)
}
async set(key: string, value: string) {
this.store[key] = value
}
async remove(key: string) {
delete this.store[key]
}
}

54
src/server/store/redis.ts Normal file
View File

@ -0,0 +1,54 @@
import Redis from 'ioredis'
import { Store } from './store'
import _debug from 'debug'
const debug = _debug('peercalls:redis')
interface RedisClient {
get: Redis.Redis['get']
set: Redis.Redis['set']
del: Redis.Redis['del']
}
export class RedisStore implements Store {
constructor(
protected readonly redis: RedisClient,
protected readonly prefix: string,
) {
}
private getKey(key: string): string {
return [this.prefix, key].filter(k => !!k).join(':')
}
private nullToUndefined(value: string | null): string | undefined {
if (value === null) {
return undefined
}
return value
}
async getMany(keys: string[]): Promise<Array<string | undefined>> {
const result = await Promise.all(
keys.map(key => this.redis.get(this.getKey(key))))
return result.map(this.nullToUndefined)
}
async get(key: string): Promise<string | undefined> {
key = this.getKey(key)
debug('get %s', key)
const result = await this.redis.get(key)
return this.nullToUndefined(result)
}
async set(key: string, value: string) {
key = this.getKey(key)
debug('set %s %s', key, value)
await this.redis.set(key, value)
}
async remove(key: string) {
key = this.getKey(key)
debug('del %s', key)
await this.redis.del(key)
}
}

View File

@ -0,0 +1,47 @@
import { MemoryStore, RedisStore } from './'
import Redis from 'ioredis'
import { Store } from './store'
describe('store', () => {
const redis = new Redis({
host: process.env.TEST_REDIS_HOST || 'localhost',
port: parseInt(process.env.TEST_REDIS_PORT!) || 6379,
enableOfflineQueue: true,
})
const testCases: Array<{name: string, store: Store}> = [{
name: MemoryStore.name,
store: new MemoryStore(),
}, {
name: RedisStore.name,
store: new RedisStore(redis, 'peercallstest'),
}]
afterAll(() => {
redis.disconnect()
})
testCases.forEach(({name, store}) => {
describe(name, () => {
afterEach(async () => {
await Promise.all([
store.remove('a'),
store.remove('b'),
])
})
describe('set, get, getMany', () => {
it('sets and retreives value(s)', async () => {
await store.set('a', 'A')
await store.set('b', 'B')
expect(await store.get('a')).toBe('A')
expect(await store.get('b')).toBe('B')
expect(await store.remove('b'))
expect(await store.get('c')).toBe(undefined)
expect(await store.getMany(['a', 'b', 'c']))
.toEqual(['A', undefined, undefined])
})
})
})
})
})

View File

@ -0,0 +1,6 @@
export interface Store {
set(key: string, value: string): Promise<void>
get(key: string): Promise<string | undefined>
getMany(keys: string[]): Promise<Array<string | undefined>>
remove(key: string): Promise<void>
}

View File

@ -2,7 +2,13 @@ import { TypedEmitter, TypedEmitterKeys } from './TypedEmitter'
import { SignalData } from 'simple-peer' import { SignalData } from 'simple-peer'
export interface User { export interface User {
id: string socketId: string
userId?: string
}
export interface Ready {
room: string
userId: string
} }
export interface SocketEvent { export interface SocketEvent {
@ -17,13 +23,12 @@ export interface SocketEvent {
} }
connect: undefined connect: undefined
disconnect: undefined disconnect: undefined
ready: string ready: Ready
} }
export type ServerSocket = export type ServerSocket =
Omit<SocketIO.Socket, TypedEmitterKeys> & Omit<SocketIO.Socket, TypedEmitterKeys> &
TypedEmitter<SocketEvent> & TypedEmitter<SocketEvent>
{ room?: string }
export type TypedIO = SocketIO.Server & { export type TypedIO = SocketIO.Server & {
to(roomName: string): TypedEmitter<SocketEvent> to(roomName: string): TypedEmitter<SocketEvent>

View File

@ -3,6 +3,6 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"><meta> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"><meta>
<meta name="mobile-web-app-capable" content="yes"><meta> <meta name="mobile-web-app-capable" content="yes"><meta>
<meta name="apple-mobile-web-app-capable" content="yes"><meta> <meta name="apple-mobile-web-app-capable" content="yes"><meta>
<link rel="apple-touch-icon" href="<%= baseUrl + 'res/icon.png' %>"><link> <link rel="apple-touch-icon" href="<%= baseUrl + '/res/icon.png' %>"><link>
<link rel="icon" sizes="256x256" href="<%= baseUrl + 'res/icon.png' %>"><link> <link rel="icon" sizes="256x256" href="<%= baseUrl + '/res/icon.png' %>"><link>
<link rel="stylesheet" type="text/css" href="<%= baseUrl + '/static/style.css' %>"><link> <link rel="stylesheet" type="text/css" href="<%= baseUrl + '/static/style.css' %>"><link>

View File

@ -8,6 +8,7 @@
<input type="hidden" id="baseUrl" value="<%= baseUrl %>"> <input type="hidden" id="baseUrl" value="<%= baseUrl %>">
<input type="hidden" id="callId" value="<%= callId %>"> <input type="hidden" id="callId" value="<%= callId %>">
<input type="hidden" id="iceServers" value='<%- JSON.stringify(iceServers) %>'> <input type="hidden" id="iceServers" value='<%- JSON.stringify(iceServers) %>'>
<input type="hidden" id="userId" value="<%= userId %>">
<div id="container"></div> <div id="container"></div>
<script src="<%= baseUrl + '/static/index.js' %>"></script> <script src="<%= baseUrl + '/static/index.js' %>"></script>