Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d2c1947a80 | |||
| 9ceb59e5fe | |||
| e28eb73962 | |||
| dc72a6389a | |||
| 5a03779139 | |||
| 5173b15c82 | |||
| 4173ca0169 | |||
| f26b72a996 | |||
| 80eb39b5b8 | |||
| d6104bae14 | |||
| 170c52eefa | |||
| 41705177c5 | |||
| 27d2459e1d | |||
| 6459aa6228 | |||
| aa7a6927f8 | |||
| cd4979c3be | |||
| ba92214296 | |||
| 54659863b5 | |||
| becafd5042 | |||
| c26b0bc5f8 | |||
| 509485e173 | |||
| e250443ca3 | |||
| 6b9c03eb84 | |||
| 9f1320a907 |
17
.drone.yml
17
.drone.yml
@ -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
|
||||
|
||||
...
|
||||
|
||||
@ -51,6 +51,7 @@ rules:
|
||||
'@typescript-eslint/triple-slash-reference':
|
||||
- warn
|
||||
- path: always
|
||||
'@typescript-eslint/no-empty-function': off
|
||||
overrides:
|
||||
- files:
|
||||
- '*.test.ts'
|
||||
|
||||
@ -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
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
language: node_js
|
||||
services:
|
||||
- redis-server
|
||||
node_js:
|
||||
- "8"
|
||||
- "12"
|
||||
|
||||
15
README.md
15
README.md
@ -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
8
docker-compose.yml
Normal 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
9742
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
79
package.json
79
package.json
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/* eslint-disable */
|
||||
import EventEmitter from 'events'
|
||||
import { EventEmitter } from 'events'
|
||||
|
||||
const Peer = jest.fn().mockImplementation(() => {
|
||||
const peer = new EventEmitter();
|
||||
|
||||
@ -1,2 +1,2 @@
|
||||
import EventEmitter from 'events'
|
||||
import { EventEmitter } from 'events'
|
||||
export default new EventEmitter()
|
||||
|
||||
@ -42,4 +42,6 @@ export const valueOf = jest.fn()
|
||||
|
||||
export const callId = 'call1234'
|
||||
|
||||
export const userId = 'user1234'
|
||||
|
||||
export const iceServers = []
|
||||
|
||||
@ -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,
|
||||
}]])
|
||||
})
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
20
src/client/actions/NicknameActions.ts
Normal file
20
src/client/actions/NicknameActions.ts
Normal 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
|
||||
@ -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),
|
||||
}])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -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({})
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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😑')
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
13
src/client/nickname.ts
Normal 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
|
||||
}
|
||||
@ -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,
|
||||
})
|
||||
|
||||
27
src/client/reducers/nicknames.ts
Normal file
27
src/client/reducers/nicknames.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
},
|
||||
]])
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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'}),
|
||||
}
|
||||
|
||||
42
src/server/configureStores.test.ts
Normal file
42
src/server/configureStores.test.ts
Normal 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))
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
55
src/server/configureStores.ts
Normal file
55
src/server/configureStores.ts
Normal 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,
|
||||
})
|
||||
}
|
||||
@ -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,
|
||||
})
|
||||
})
|
||||
|
||||
@ -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',
|
||||
}],
|
||||
},
|
||||
],
|
||||
|
||||
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
3
src/server/store/index.ts
Normal file
3
src/server/store/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './store'
|
||||
export * from './memory'
|
||||
export * from './redis'
|
||||
25
src/server/store/memory.ts
Normal file
25
src/server/store/memory.ts
Normal 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
54
src/server/store/redis.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
47
src/server/store/store.test.ts
Normal file
47
src/server/store/store.test.ts
Normal 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])
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
6
src/server/store/store.ts
Normal file
6
src/server/store/store.ts
Normal 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>
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user