Compare commits

...

24 Commits

Author SHA1 Message Date
d2c1947a80 3.0.12
All checks were successful
continuous-integration/drone/push Build is passing
2020-03-14 09:17:17 +01:00
9ceb59e5fe Fix .drone.yml
All checks were successful
continuous-integration/drone/push Build is passing
2020-03-14 09:15:42 +01:00
e28eb73962 Fix redis entry for Gitlab CI 2020-03-14 09:13:52 +01:00
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
509485e173 3.0.8
All checks were successful
continuous-integration/drone/push Build is passing
2020-03-11 15:19:26 +01:00
e250443ca3 Add webrtc-adapter for compatibility
https://github.com/webrtcHacks/adapter

From MDN: https://developer.mozilla.org/en-US/docs/Web/API/Screen_Capture_API/Using_Screen_Capture

Note: It may be useful to note that recent versions of the WebRTC
adapter.js shim include implementations of getDisplayMedia() to enable
screen sharing on browsers that support it but do not implement the
current standard API. This works with at least Chrome, Edge, and
Firefox.
2020-03-11 15:18:28 +01:00
6b9c03eb84 3.0.7 2020-03-11 15:08:35 +01:00
9f1320a907 Add touchStart/touchEnd event handlers for video 2020-03-11 15:08:32 +01:00
47 changed files with 4019 additions and 6893 deletions

View File

@ -4,13 +4,18 @@ type: docker
name: default
steps:
- name: build
image: node:12
commands:
- npm install
- npm run ci
- name: build
image: node:12
commands:
- npm install
- npm run ci
environment:
TEST_REDIS_HOST: redis
services:
- name: redis
image: redis:5-alpine
---
kind: signature
hmac: a49a1e7c428472d0237bb2ba73511965607384f45114941b869c6a9eff7aef70
hmac: 6cf23314158a6b508ef5240c110304b4b8c8f155d837c393ad1beb7a75a7d990
...

View File

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

View File

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

View File

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

View File

@ -134,6 +134,19 @@ unix domain socket.
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
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] Add ability to share files (Fixed in 3877893)
- [ ] 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,
deny, and remember allowed/denied connections to specific peers.
- [ ] 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

9742
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
import socket from '../socket'
import { ThunkResult } from '../store'
import { callId } from '../window'
import { callId, userId } from '../window'
import * as NotifyActions from './NotifyActions'
import * as SocketActions from './SocketActions'
@ -25,6 +25,7 @@ async (dispatch, getState) => {
dispatch(SocketActions.handshake({
socket,
roomName: callId,
userId,
}))
dispatch(initialize())
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 { Dispatch } from 'redux'
import { ClientSocket } from '../socket'
import { PEERCALLS, PEER_EVENT_DATA, ME } from '../constants'
describe('PeerActions', () => {
function createSocket () {
const socket = new EventEmitter() as unknown as ClientSocket
socket.id = 'user1'
socket.id = 'socket-id-user-1'
return socket
}
@ -29,7 +30,7 @@ describe('PeerActions', () => {
dispatch = store.dispatch
getState = store.getState
user = { id: 'user2' }
user = { id: 'user1' }
socket = createSocket()
instances = (Peer as any).instances = [];
(Peer as unknown as jest.Mock).mockClear()
@ -39,7 +40,7 @@ describe('PeerActions', () => {
describe('create', () => {
it('creates a new peer', () => {
PeerActions.createPeer({ socket, user, initiator: 'user2', stream })(
PeerActions.createPeer({ socket, user, initiator: 'other-user', stream })(
dispatch, getState)
expect(instances.length).toBe(1)
@ -51,7 +52,7 @@ describe('PeerActions', () => {
it('sets initiator correctly', () => {
PeerActions
.createPeer({
socket, user, initiator: 'user1', stream,
socket, user, initiator: user.id, stream,
})(dispatch, getState)
expect(instances.length).toBe(1)
@ -74,20 +75,30 @@ describe('PeerActions', () => {
})
describe('events', () => {
let peer: Peer.Instance
beforeEach(() => {
function createPeer() {
PeerActions.createPeer({ socket, user, initiator: 'user1', stream })(
dispatch, getState)
peer = instances[0]
})
const peer = instances[instances.length - 1]
return peer
}
describe('connect', () => {
beforeEach(() => peer.emit('connect'))
it('dispatches peer connection established message', () => {
createPeer().emit('connect')
// 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', () => {
@ -103,12 +114,17 @@ describe('PeerActions', () => {
})
it('decodes a message', () => {
const payload = 'test'
const object = JSON.stringify({ payload })
const peer = createPeer()
const message = {
type: 'text',
payload: 'test',
}
const object = JSON.stringify(message)
peer.emit('data', Buffer.from(object, 'utf-8'))
const { list } = store.getState().messages
expect(list.length).toBeGreaterThan(0)
expect(list[list.length - 1]).toEqual({
userId: 'user2',
userId: user.id,
timestamp: jasmine.any(String),
image: undefined,
message: 'test',
@ -162,7 +178,7 @@ describe('PeerActions', () => {
})(dispatch, getState)
})
it('sends a message to all peers', () => {
it('sends a text message to all peers', () => {
PeerActions.sendMessage({ payload: 'test', type: 'text' })(
dispatch, getState)
const { peers } = store.getState()
@ -172,5 +188,76 @@ describe('PeerActions', () => {
.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 NotifyActions from '../actions/NotifyActions'
import * as StreamActions from '../actions/StreamActions'
import * as ChatActions from './ChatActions'
import * as NicknameActions from './NicknameActions'
import * as NotifyActions from './NotifyActions'
import * as StreamActions from './StreamActions'
import * as constants from '../constants'
import Peer, { SignalData } from 'simple-peer'
import forEach from 'lodash/forEach'
@ -8,6 +9,7 @@ import _debug from 'debug'
import { iceServers } from '../window'
import { Dispatch, GetState } from '../store'
import { ClientSocket } from '../socket'
import { getNickname } from '../nickname'
const debug = _debug('peercalls')
@ -65,6 +67,13 @@ class PeerHandler {
peer.addTrack(track, s.stream)
})
})
const nickname = state.nicknames[constants.ME]
if (nickname) {
sendData(peer, {
payload: {nickname},
type: 'nickname',
})
}
}
handleTrack = (track: MediaStreamTrack, stream: MediaStream) => {
const { user, dispatch } = this
@ -86,9 +95,10 @@ class PeerHandler {
}))
}
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))
debug('peer: %s, message: %o', user.id, buffer)
debug('peer: %s, message: %o', user.id, message)
switch (message.type) {
case 'file':
dispatch(ChatActions.addMessage({
@ -98,6 +108,19 @@ class PeerHandler {
image: message.payload.data,
}))
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:
dispatch(ChatActions.addMessage({
userId: user.id,
@ -150,7 +173,7 @@ export function createPeer (options: CreatePeerOptions) {
}
const peer = new Peer({
initiator: socket.id === initiator,
initiator: userId === initiator,
config: { iceServers },
// Allow the peer to receive video, even if it's not sending stream:
// https://github.com/feross/simple-peer/issues/95
@ -234,33 +257,58 @@ export interface FileMessage {
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) =>
(dispatch: Dispatch, getState: GetState) => {
const { peers } = getState()
debug('Sending message type: %s to %s peers.',
message.type, Object.keys(peers).length)
switch (message.type) {
case 'file':
dispatch(ChatActions.addMessage({
userId: constants.ME,
message: 'Send file: "' +
message.payload.name + '" to all peers',
timestamp: new Date().toLocaleString(),
image: message.payload.data,
}))
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:
dispatch(ChatActions.addMessage({
userId: constants.ME,
message: message.payload,
timestamp: new Date().toLocaleString(),
image: undefined,
}))
}
forEach(peers, (peer, userId) => {
switch (message.type) {
case 'file':
dispatch(ChatActions.addMessage({
userId: 'You',
message: 'Send file: "' +
message.payload.name + '" to peer: ' + userId,
timestamp: new Date().toLocaleString(),
image: message.payload.data,
}))
break
default:
dispatch(ChatActions.addMessage({
userId: 'You',
message: message.payload,
timestamp: new Date().toLocaleString(),
image: undefined,
}))
}
peer.send(JSON.stringify(message))
sendData(peer, message)
})
}

View File

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

View File

@ -1,11 +1,10 @@
import * as NotifyActions from '../actions/NotifyActions'
import * as PeerActions from '../actions/PeerActions'
import * as constants from '../constants'
import keyBy from 'lodash/keyBy'
import _debug from 'debug'
import { SignalData } from 'simple-peer'
import { Dispatch, GetState } from '../store'
import { ClientSocket } from '../socket'
import { SocketEvent } from '../../shared'
const debug = _debug('peercalls')
@ -15,24 +14,16 @@ export interface SocketHandlerOptions {
stream?: MediaStream
dispatch: Dispatch
getState: GetState
}
export interface SignalOptions {
signal: SignalData
userId: string
}
export interface UsersOptions {
initiator: string
users: Array<{ id: string }>
}
class SocketHandler {
socket: ClientSocket
roomName: string
stream?: MediaStream
dispatch: Dispatch
getState: GetState
userId: string
constructor (options: SocketHandlerOptions) {
this.socket = options.socket
@ -40,44 +31,47 @@ class SocketHandler {
this.stream = options.stream
this.dispatch = options.dispatch
this.getState = options.getState
this.userId = options.userId
}
handleSignal = ({ userId, signal }: SignalOptions) => {
handleSignal = ({ userId, signal }: SocketEvent['signal']) => {
const { getState } = this
const peer = getState().peers[userId]
// debug('socket signal, userId: %s, signal: %o', userId, signal);
if (!peer) return debug('user: %s, no peer found', userId)
peer.signal(signal)
}
handleUsers = ({ initiator, users }: UsersOptions) => {
handleUsers = ({ initiator, users }: SocketEvent['users']) => {
const { socket, stream, dispatch, getState } = this
debug('socket users: %o', users)
this.dispatch(NotifyActions.info('Connected users: {0}', users.length))
const { peers } = this.getState()
debug('active peers: %o', Object.keys(peers))
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({
socket,
user,
user: {
// users without id should be filtered out
id: user.userId!,
},
initiator,
stream,
})(dispatch, getState))
const newUsersMap = keyBy(users, 'id')
Object.keys(peers)
.filter(id => !newUsersMap[id])
.forEach(id => peers[id].destroy())
}
}
export interface HandshakeOptions {
socket: ClientSocket
roomName: string
userId: string
stream?: MediaStream
}
export function handshake (options: HandshakeOptions) {
const { socket, roomName, stream } = options
const { socket, roomName, stream, userId } = options
return (dispatch: Dispatch, getState: GetState) => {
const handler = new SocketHandler({
@ -86,6 +80,7 @@ export function handshake (options: HandshakeOptions) {
stream,
dispatch,
getState,
userId,
})
// 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_USERS, handler.handleUsers)
debug('socket.id: %s', socket.id)
debug('userId: %s', userId)
debug('emit ready for room: %s', roomName)
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 { Message } from '../actions/ChatActions'
import { dismissNotification, Notification } from '../actions/NotifyActions'
import { TextMessage } from '../actions/PeerActions'
import { Message as MessageType } from '../actions/PeerActions'
import { removeStream } from '../actions/StreamActions'
import * as constants from '../constants'
import Chat from './Chat'
@ -15,17 +15,19 @@ import Toolbar from './Toolbar'
import Video from './Video'
import { getDesktopStream } from '../actions/MediaActions'
import { StreamsState } from '../reducers/streams'
import { Nicknames } from '../reducers/nicknames'
export interface AppProps {
active: string | null
dismissNotification: typeof dismissNotification
init: () => void
nicknames: Nicknames
notifications: Record<string, Notification>
messages: Message[]
messagesCount: number
peers: Record<string, Peer.Instance>
play: () => void
sendMessage: (message: TextMessage) => void
sendMessage: (message: MessageType) => void
streams: StreamsState
getDesktopStream: typeof getDesktopStream
removeStream: typeof removeStream
@ -34,13 +36,11 @@ export interface AppProps {
}
export interface AppState {
videos: Record<string, unknown>
chatVisible: boolean
}
export default class App extends React.PureComponent<AppProps, AppState> {
state: AppState = {
videos: {},
chatVisible: false,
}
handleShowChat = () => {
@ -79,6 +79,7 @@ export default class App extends React.PureComponent<AppProps, AppState> {
active,
dismissNotification,
notifications,
nicknames,
messages,
messagesCount,
onSendFile,
@ -89,8 +90,6 @@ export default class App extends React.PureComponent<AppProps, AppState> {
streams,
} = this.props
const { videos } = this.state
const chatVisibleClassName = classnames({
'chat-visible': this.state.chatVisible,
})
@ -127,6 +126,7 @@ export default class App extends React.PureComponent<AppProps, AppState> {
</Side>
<Chat
messages={messages}
nicknames={nicknames}
onClose={this.handleHideChat}
sendMessage={sendMessage}
visible={this.state.chatVisible}
@ -136,7 +136,6 @@ export default class App extends React.PureComponent<AppProps, AppState> {
const key = localStreams.userId + '_' + i
return (
<Video
videos={videos}
key={key}
active={active === key}
onClick={toggleActive}
@ -164,7 +163,6 @@ export default class App extends React.PureComponent<AppProps, AppState> {
play={play}
stream={s}
userId={key}
videos={videos}
/>
)
})

View File

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

View File

@ -2,12 +2,12 @@ import Input from './Input'
import React from 'react'
import ReactDOM from 'react-dom'
import TestUtils from 'react-dom/test-utils'
import { TextMessage } from '../actions/PeerActions'
import { Message } from '../actions/PeerActions'
describe('components/Input', () => {
let node: Element
let sendMessage: jest.Mock<(message: TextMessage) => void>
let sendMessage: jest.MockedFunction<(message: Message) => void>
async function render () {
sendMessage = jest.fn()
const div = document.createElement('div')
@ -32,23 +32,61 @@ describe('components/Input', () => {
beforeEach(() => {
sendMessage.mockClear()
input = node.querySelector('textarea')!
TestUtils.Simulate.change(input, {
target: { value: message } as any,
})
expect(input.value).toBe(message)
})
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', () => {
TestUtils.Simulate.change(input, {
target: { value: message } as any,
})
TestUtils.Simulate.submit(node)
expect(input.value).toBe('')
expect(sendMessage.mock.calls)
.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', () => {
it('sends a message', () => {
TestUtils.Simulate.change(input, {
target: { value: message } as any,
})
TestUtils.Simulate.keyPress(input, {
key: 'Enter',
})
@ -67,6 +105,9 @@ describe('components/Input', () => {
describe('handleSmileClick', () => {
it('adds smile to message', () => {
TestUtils.Simulate.change(input, {
target: { value: message } as any,
})
const div = node.querySelector('.chat-controls-buttons-smile')!
TestUtils.Simulate.click(div)
expect(input.value).toBe('test message😑')

View File

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

View File

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

View File

@ -4,7 +4,7 @@ import socket from '../socket'
import { StreamWithURL } from '../reducers/streams'
export interface VideoProps {
videos: Record<string, unknown>
// videos: Record<string, unknown>
onClick: (userId: string) => void
active: boolean
stream?: StreamWithURL
@ -32,6 +32,7 @@ export default class Video extends React.PureComponent<VideoProps> {
this.timeout = undefined
}
handleMouseDown: ReactEventHandler<HTMLVideoElement> = e => {
clearTimeout(this.timeout)
this.timeout = window.setTimeout(this.toggleCover, 300)
}
handleMouseUp: ReactEventHandler<HTMLVideoElement> = e => {
@ -48,7 +49,7 @@ export default class Video extends React.PureComponent<VideoProps> {
this.componentDidUpdate()
}
componentDidUpdate () {
const { videos, stream } = this.props
const { stream } = this.props
const video = this.videoRef.current!
const mediaStream = stream && stream.stream || null
const url = stream && stream.url
@ -59,7 +60,6 @@ export default class Video extends React.PureComponent<VideoProps> {
} else if (video.src !== url) {
video.src = url || ''
}
videos[socket.id] = video
}
render () {
const { active, mirrored, muted } = this.props
@ -71,7 +71,9 @@ export default class Video extends React.PureComponent<VideoProps> {
autoPlay
onClick={this.handleClick}
onMouseDown={this.handleMouseDown}
onTouchStart={this.handleMouseDown}
onMouseUp={this.handleMouseUp}
onTouchEnd={this.handleMouseUp}
onLoadedMetadata={() => this.props.play()}
playsInline
ref={this.videoRef}

View File

@ -8,6 +8,7 @@ export const ALERT_CLEAR = 'ALERT_CLEAR'
export const INIT = 'INIT'
export const ME = '_me_'
export const PEERCALLS = '[PeerCalls]'
export const NOTIFY = 'NOTIFY'
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_PLAY = 'MEDIA_PLAY'
export const NICKNAME_SET = 'NICKNAME_SET'
export const PEER_ADD = 'PEER_ADD'
export const PEER_REMOVE = 'PEER_REMOVE'
export const PEERS_DESTROY = 'PEERS_DESTROY'

View File

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

View File

@ -1,4 +1,5 @@
import '@babel/polyfill'
import 'webrtc-adapter'
import App from './containers/App'
import React from 'react'
import ReactDOM from 'react-dom'

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 media from './media'
import streams from './streams'
import nicknames from './nicknames'
import { combineReducers } from 'redux'
export default combineReducers({
@ -11,6 +12,7 @@ export default combineReducers({
notifications,
messages,
media,
nicknames,
peers,
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 callId = valueOf('callId')
export const userId = valueOf('userId')
export const iceServers = JSON.parse(valueOf('iceServers')!)
export const MediaStream = window.MediaStream

View File

@ -10,6 +10,7 @@ import { config } from './config'
import handleSocket from './socket'
import SocketIO from 'socket.io'
import request from 'supertest'
import { MemoryStore } from './store'
const io = SocketIO()
@ -61,7 +62,14 @@ describe('server/app', () => {
it('calls handleSocket with socket', () => {
const socket = { hi: 'me 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 _debug from 'debug'
import ejs from 'ejs'
import express from 'express'
import handleSocket from './socket'
import path from 'path'
import { createServer } from './server'
import SocketIO from 'socket.io'
import { config } from './config'
import { configureStores } from './configureStores'
import call from './routes/call'
import index from './routes/index'
import ejs from 'ejs'
import { createServer } from './server'
import handleSocket from './socket'
const debug = _debug('peercalls')
const logRequest = _debug('peercalls:requests')
@ -48,6 +49,7 @@ router.use('/call', call)
router.use('/', index)
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

View File

@ -21,12 +21,27 @@ export interface Config {
cert: 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()
export const config: Config = {
baseUrl: cfg.get('baseUrl', ''),
iceServers: cfg.get('iceServers'),
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)
res.render('call', {
callId: encodeURIComponent(req.params.callId),
userId: v4(),
iceServers,
})
})

View File

@ -2,58 +2,63 @@ import { EventEmitter } from 'events'
import { Socket } from 'socket.io'
import { TypedIO } from '../shared'
import handleSocket from './socket'
import { MemoryStore, Store } from './store'
describe('server/socket', () => {
type SocketMock = Socket & {
type NamespaceMock = Socket & {
id: string
room?: string
join: jest.Mock
leave: jest.Mock
emit: jest.Mock
clients: (callback: (
err: Error | undefined, clients: string[]
) => void) => void
}
let socket: SocketMock
let socket: NamespaceMock
let io: TypedIO & {
in: jest.Mock<(room: string) => SocketMock>
to: jest.Mock<(room: string) => SocketMock>
in: jest.Mock<(room: string) => NamespaceMock>
to: jest.Mock<(room: string) => NamespaceMock>
}
let rooms: Record<string, {emit: any}>
const socket0 = {
id: 'socket0',
}
const socket1 = {
id: 'socket1',
}
const socket2 = {
id: 'socket2',
}
let emitPromise: Promise<void>
beforeEach(() => {
socket = new EventEmitter() as SocketMock
socket = new EventEmitter() as NamespaceMock
socket.id = 'socket0'
socket.join = jest.fn()
socket.leave = jest.fn()
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.in = io.to = jest.fn().mockImplementation(room => {
return (rooms[room] = rooms[room] || {
emit: jest.fn(),
})
})
io.sockets = {
adapter: {
rooms: {
room1: {
socket0: true,
} as any,
room2: {
socket0: true,
} as any,
room3: {
sockets: {
'socket0': true,
'socket1': true,
'socket2': true,
},
} as any,
emit: jest.fn().mockImplementation(() => emitResolve()),
clients: callback => {
callback(undefined, socketsByRoom[room] || [])
},
} as any,
} as any
socket.leave = jest.fn()
socket.join = jest.fn()
} as NamespaceMock)
})
})
it('should be a function', () => {
@ -61,18 +66,35 @@ describe('server/socket', () => {
})
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', () => {
it('should broadcast signal to specific user', () => {
it('should broadcast signal to specific user', async () => {
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('a').emit as jest.Mock).mock.calls).toEqual([[
expect(io.to.mock.calls).toEqual([[ socket1.id ]])
expect((io.to(socket1.id).emit as jest.Mock).mock.calls).toEqual([[
'signal', {
userId: 'socket0',
userId: 'a',
signal,
},
]])
@ -80,33 +102,48 @@ describe('server/socket', () => {
})
describe('ready', () => {
it('should call socket.leave if socket.room', () => {
it('never calls socket.leave', async () => {
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' ]])
})
it('should call socket.join to room', () => {
socket.emit('ready', 'room3')
it('should call socket.join to room', async () => {
socket.emit('ready', {
userId: 'b',
room: 'room3',
})
await emitPromise
expect(socket.join.mock.calls).toEqual([[ 'room3' ]])
})
it('should emit users', () => {
socket.emit('ready', 'room3')
it('should emit users', async () => {
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([
[
'users', {
initiator: 'socket0',
initiator: 'a',
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'
import _debug from 'debug'
import map from 'lodash/map'
import { ServerSocket, TypedIO } from '../shared'
import { Store } from './store'
const debug = _debug('peercalls:socket')
export default function handleSocket(socket: ServerSocket, io: TypedIO) {
socket.on('signal', payload => {
// debug('signal: %s, payload: %o', socket.id, payload)
io.to(payload.userId).emit('signal', {
userId: socket.id,
signal: payload.signal,
})
export interface Stores {
userIdBySocketId: Store
socketIdByUserId: Store
}
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('ready', roomName => {
debug('ready: %s, room: %s', socket.id, roomName)
if (socket.room) socket.leave(socket.room)
socket.room = roomName
socket.join(roomName)
socket.room = roomName
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,
})
}
})
const users = getUsers(roomName)
socket.on('ready', async payload => {
const { userId, room } = payload
debug('ready: %s, room: %s', userId, room)
// no need to leave rooms because there will be only one room for the
// duration of the socket connection
await Promise.all([
stores.socketIdByUserId.set(userId, socket.id),
stores.userIdBySocketId.set(socket.id, userId),
])
socket.join(room)
debug('ready: %s, room: %s, users: %o', socket.id, roomName, users)
const users = await getUsers(room)
io.to(roomName).emit('users', {
initiator: socket.id,
debug('ready: %s, room: %s, users: %o', userId, room, users)
io.to(room).emit('users', {
initiator: userId,
users,
})
})
function getUsers (roomName: string) {
return map(io.sockets.adapter.rooms[roomName].sockets, (_, id) => {
return { id }
})
async function getUsers (room: string) {
const socketIds = await getClientsInRoom(room)
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'
export interface User {
id: string
socketId: string
userId?: string
}
export interface Ready {
room: string
userId: string
}
export interface SocketEvent {
@ -17,13 +23,12 @@ export interface SocketEvent {
}
connect: undefined
disconnect: undefined
ready: string
ready: Ready
}
export type ServerSocket =
Omit<SocketIO.Socket, TypedEmitterKeys> &
TypedEmitter<SocketEvent> &
{ room?: string }
TypedEmitter<SocketEvent>
export type TypedIO = SocketIO.Server & {
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="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="icon" sizes="256x256" 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="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="callId" value="<%= callId %>">
<input type="hidden" id="iceServers" value='<%- JSON.stringify(iceServers) %>'>
<input type="hidden" id="userId" value="<%= userId %>">
<div id="container"></div>
<script src="<%= baseUrl + '/static/index.js' %>"></script>