diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..779f99a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +root = true + +[*] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.md] +trim_trailing_whitespace = false diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a0cd991 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# Eclipse +.classpath +.project +.settings/ + +# Intellij +.idea/ +*.iml +*.iws + +# Mac +.DS_Store + +# Maven +log/ +target/ + +# Node.js +node_modules/ +npm-debug.log + +# Bower +bower_components/ diff --git a/package.json b/package.json new file mode 100644 index 0000000..54cfe2b --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "janus-client", + "version": "1.0.0", + "main": "src/janus.js", + "scripts": { + "test": "mocha -R nyan --delay" + }, + "author": "", + "license": "ISC", + "description": "", + "dependencies": { + "bluebird": "^3.4.1", + "debug-logger": "^0.4.1", + "lodash": "^4.14.1", + "uuid": "^2.0.2", + "ws": "^1.1.1" + }, + "devDependencies": { + "chai": "^3.5.0", + "mocha": "^3.0.1" + } +} diff --git a/src/client.js b/src/client.js new file mode 100644 index 0000000..b800f3c --- /dev/null +++ b/src/client.js @@ -0,0 +1,222 @@ +'use strict'; + +var _ = require('lodash'); +var WebSocket = require('ws'); +var EventEmitter = require('events').EventEmitter; +var Promise = require('bluebird'); +var Transaction = require('./transaction').Transaction; +var logger = require('debug-logger')('janus:client'); + +var ConnectionState = { + CONNECTED: 'CONNECTED', + DISCONNECTED: 'DISCONNECTED' +}; + +var ClientEvent = { + connected: 'connected', + disconnected: 'disconnected', + object: 'object', + error: 'error', + timeout: 'timeout' +}; + +var WebSocketEvent = { + open: 'open', + message: 'message', + error: 'error', + close: 'close' +}; + +/** + * @class + */ +class ClientResponse { + + constructor(req, res) { + this.request = req; + this.response = res; + } + + getRequest() { + return this.request; + } + + getResponse() { + return this.response; + } + + getType() { + return _.get(this.response, 'janus', null); + } + + isError() { + return this.getType() === 'error'; + } + + isAck() { + return this.getType() === 'ack'; + } + + isSuccess() { + return this.getType() === 'success'; + } +} + +/** + * @class + */ +class Client { + + constructor(options) { + options = options || {}; + this.url = options.url || 'ws://localhost:8188'; + this.logger = options.logger || logger || console; + this.requestTimeout = options.requestTimeout || 6000; + this.protocol = 'janus-protocol'; + this.webSocket = null; + this.connectionState = ConnectionState.DISCONNECTED; + this.emitter = new EventEmitter(); + this.transactions = {}; + this.timeoutTimer = null; + this.timeout = options.timeout || 60000; + } + + startTimeout() { + this.stopTimeout(); + this.timeoutTimer = setTimeout(()=>{ + this.emitter.emit(ClientEvent.timeout); + }, this.timeout); + } + + stopTimeout() { + if(this.timeoutTimer !== null) { + clearTimeout(this.timeoutTimer); + } + } + + connect() { + this.webSocket = new WebSocket(this.url, this.protocol); + this.webSocket.on(WebSocketEvent.open, ()=>{ + this.webSocketOpen(); + }); + this.webSocket.on(WebSocketEvent.close, ()=>{ + this.webSocketClose(); + }); + this.webSocket.on(WebSocketEvent.message, (message)=>{ + this.webSocketMessage(message); + }); + this.webSocket.on(WebSocketEvent.error, (err)=>{ + this.webSocketError(err); + }); + } + + webSocketOpen() { + this.setConnectionState(ConnectionState.CONNECTED); + } + + webSocketClose() { + this.setConnectionState(ConnectionState.DISCONNECTED); + } + + webSocketMessage(message) { + + this.startTimeout(); + + var obj; + try { + obj = JSON.parse(message); + this.logger.debug('Received message', obj); + this.dispatchObject(obj); + } catch(err) { + this.emitter.emit(ClientEvent.error, err); + } + } + + webSocketError(err) { + this.emitter.emit(ClientEvent.error, err); + } + + setConnectionState(state) { + var hasState = this.connectionState === state; + if(!hasState) { + switch(state) { + case ConnectionState.CONNECTED: + case ConnectionState.DISCONNECTED: + this.connectionState = state; + this.emitter.emit(state.toLowerCase()); + break; + default: + throw new Error('Invalid state ' + state); + } + } + } + + on(type, listener) { + this.emitter.on(type, listener); + } + + off(type, listener) { + this.emitter.removeListener(type, listener); + } + + dispatchObject(obj) { + if(_.isString(obj.transaction) && this.transactions[obj.transaction] instanceof Transaction) { + var transaction = this.transactions[obj.transaction]; + var response = new ClientResponse(transaction.getRequest(), obj); + transaction.response(response); + } else { + this.emitter.emit(ClientEvent.object, obj); + } + } + + sendObject(obj) { + return new Promise((resolve, reject)=>{ + this.webSocket.send(JSON.stringify(obj), (err)=>{ + if(_.isObject(err)) { + reject(err); + } else { + this.logger.debug('Sent message', obj); + resolve(); + } + }); + }); + } + + transact(req) { + var transaction = new Transaction(req, (finalReq)=>{ + this.sendObject(finalReq).then(()=>{ + + }).catch((err)=>{ + transaction.error(err); + }); + }); + this.transactions[transaction.getId()] = transaction; + transaction.onEnd(()=>{ + delete this.transactions[transaction.getId()]; + }); + return transaction; + } + + request(req, options) { + return new Promise((resolve, reject)=>{ + var options = options || {}; + var requestTimeout = options.requestTimeout || this.requestTimeout; + var transaction = this.transact(req).onAck((res)=>{ + transaction.end(); + resolve(res); + }).onResponse((res)=>{ + transaction.end(); + resolve(res); + }).onError(function(err){ + transaction.offError(this.constructor); + reject(err); + }).timeout(requestTimeout).start(); + }); + } +} + +exports.Client = Client; +exports.ClientEvent = ClientEvent; +exports.ConnectionState = ConnectionState; +exports.WebSocketEvent = WebSocketEvent; +exports.ClientResponse = ClientResponse; diff --git a/src/constants.js b/src/constants.js new file mode 100644 index 0000000..c519b4f --- /dev/null +++ b/src/constants.js @@ -0,0 +1,10 @@ + +exports.PluginNames = { + VideoRoom: 'janus.plugin.videoroom' +}; + +exports.JanusEvents = { + webrtcup: 'webrtcup', + media: 'media', + hangup: 'hangup' +}; diff --git a/src/errors.js b/src/errors.js new file mode 100644 index 0000000..caaa0f7 --- /dev/null +++ b/src/errors.js @@ -0,0 +1,69 @@ +'use strict'; + +var _ = require('lodash'); + +/** + * @class + */ +class ResponseError extends Error { + + constructor(req, res) { + super(); + this.name = this.constructor.name; + this.message = _.get(res, 'error.reason', null); + this.code = _.get(res, 'error.code', null); + this.request = req; + this.response = res; + } + + getCode() { + return this.code; + } + + getMessage() { + return this.message; + } + + getRequest() { + return this.request; + } + + getResponse() { + return this.response; + } +} + +/** + * @class + */ +class PluginError extends ResponseError { + + constructor(req, res, plugin) { + super(req, res); + this.message = _.get(res, 'plugindata.data.error', null); + this.code = _.get(res, 'plugindata.data.error_code', null); + this.plugin = plugin; + } + + getPlugin() { + return this.plugin; + } +} + +/** + * @class + */ +class RequestTimeoutError extends Error { + + constructor(req) { + super(); + this.name = this.constructor.name; + this.message = 'Request timeout'; + this.request = req; + } +} + +module.exports.ResponseError = ResponseError; +module.exports.RequestTimeoutError = RequestTimeoutError; +module.exports.PluginError = PluginError; + diff --git a/src/janus.js b/src/janus.js new file mode 100644 index 0000000..2fd27c7 --- /dev/null +++ b/src/janus.js @@ -0,0 +1,170 @@ +'use strict'; + +var _ = require('lodash'); +var Promise = require('bluebird'); +var client = require('./client'); +var Client = client.Client; +var ClientEvent = client.ClientEvent; +var EventEmitter = require('events').EventEmitter; +var Session = require('./session').Session; +var ResponseError = require('./errors').ResponseError; +var logger = require('debug-logger')('janus'); + +var State = { + connected: 'connected', + disconnected: 'disconnected' +}; + +/** + * @class + */ +class Janus { + + constructor(options) { + + options = options || {}; + this.url = options.url; + this.logger = options.logger || logger || console; + this.client = options.client || new Client({ + url: this.url + }); + this.client.on(ClientEvent.connected, ()=> { + this.clientConnected(); + }); + this.client.on(ClientEvent.disconnected, ()=> { + this.clientDisconnected(); + }); + this.client.on(ClientEvent.object, (obj)=> { + this.clientObject(obj); + }); + this.client.on(ClientEvent.error, (err)=> { + this.clientError(err); + }); + this.hasInfo = false; + this.info = { + version_string: null, + plugins: {} + }; + this.emitter = new EventEmitter(); + this.sessions = {}; + this.state = State.disconnected; + } + + clientConnected() { + this.state = State.connected; + this.getInfo().then((res)=>{ + this.info = res.response; + this.emitter.emit('connected'); + }).catch((err)=>{ + this.emitter.emit('error', err); + }); + } + + clientDisconnected() { + this.state = State.disconnected; + } + + clientObject(obj) { + if(obj.session_id && this.hasSession(obj.session_id)) { + this.sessions[obj.session_id].emitEvent(obj); + } else if(obj.janus === 'timeout' && this.hasSession(obj.session_id)) { + this.deleteSession(obj.session_id); + } else if(obj.janus === 'timeout') { + // Todo: Log dropped timeout + } else { + // Todo: emit janus event + } + } + + clientError(err) { + this.clientDisconnected(); + this.emitter.emit('error', err); + } + + connect() { + this.client.connect(); + } + + createSession() { + return new Promise((resolve, reject)=>{ + this.client.request({ janus: 'create' }).then((res)=>{ + if(res.isSuccess()) { + var session = new Session(res.getResponse().data.id, this); + this.sessions[session.getId()] = session; + this.logger.info('Created session=%s',session.getId()); + session.onKeepAlive((result)=>{ + if(result) { + this.logger.debug('KeepAlive session=%s', session.getId()); + } else { + this.logger.warn('KeepAlive failed session=%s', session.getId()); + } + }); + session.onTimeout(()=>{ + this.logger.info('Timeout session=%s',session.getId()); + this.deleteSession(session.getId()); + }); + resolve(session); + } else { + reject(new ResponseError(res.getRequest(), res)); + } + }).catch((err)=>{ + reject(err); + }); + }); + } + + hasSession(id) { + return this.sessions[id] instanceof Session; + } + + deleteSession(id) { + delete this.sessions[id]; + this.logger.info('Deleted session=%s', id); + this.logger.info('Sessions count=%s', Object.keys(this.sessions).length); + } + + getInfo() { + return new Promise((resolve, reject)=>{ + this.client.request({ janus: 'info' }).then((res)=>{ + if(res.getType() === 'server_info') { + this.hasInfo = true; + resolve(res); + } else { + reject(new ResponseError(res.getRequest(), res)); + } + }).catch((err)=>{ + reject(err); + }); + }); + } + + getVersion() { + return (this.hasInfo)? this.info.version_string : ''; + } + + transact() { + return this.client.transact.apply(this.client, arguments); + } + + request() { + return this.client.request.apply(this.client, arguments); + } + + isConnected() { + return this.state === State.connected; + } + + onConnected(listener) { + this.emitter.on('connected', listener); + } + + onDisconnected(listener) { + this.emitter.on('disconnected', listener); + } + + onError(listener) { + this.emitter.on('error', listener); + } +} + +exports.Janus = Janus; diff --git a/src/plugins/index.js b/src/plugins/index.js new file mode 100644 index 0000000..c38f469 --- /dev/null +++ b/src/plugins/index.js @@ -0,0 +1,3 @@ + + +exports.VideoRoom = require('./videoroom-handle').VideoRoomHandle; diff --git a/src/plugins/plugin-handle.js b/src/plugins/plugin-handle.js new file mode 100644 index 0000000..f739575 --- /dev/null +++ b/src/plugins/plugin-handle.js @@ -0,0 +1,98 @@ +'use strict'; + +var _ = require('lodash'); +var EventEmitter = require('events').EventEmitter; +var JanusEvents = require('../constants').JanusEvents; + +/** + * @class + */ +class PluginHandle { + + constructor(name, id, session) { + this.name = name; + this.id = id; + this.session = session; + this.emitter = new EventEmitter(); + } + + getName() { + return this.name; + } + + getId() { + return this.id; + } + + getSession() { + return this.session; + } + + emitEvent(event) { + + switch(event.janus) { + case JanusEvents.webrtcup: + this.emitter.emit(JanusEvents.webrtcup); + break; + case JanusEvents.media: + this.emitter.emit(JanusEvents.media); + break; + case JanusEvents.hangup: + this.emitter.emit(JanusEvents.hangup); + break; + default: + } + } + + onWebrtcUp(listener) { + this.emitter.addListener(JanusEvents.webrtcup, listener); + } + + onMedia(listener) { + this.emitter.addListener(JanusEvents.media, listener); + } + + onHangup(listener) { + this.emitter.addListener(JanusEvents.hangup, listener); + } + + transact(obj) { + obj.handle_id = this.getId(); + return this.session.transact(obj); + } + + request(obj, options) { + obj.handle_id = this.getId(); + return this.session.request(obj, options); + } + + transactJsepMessage(body, jsep) { + return this.transact({ + janus: 'message', + body: body, + jsep: jsep + }); + } + + transactMessage(body) { + return this.transact({ + janus: 'message', + body: body + }); + } + + requestMessage(body, options) { + return new Promise((resolve, reject)=>{ + this.request({ + janus: 'message', + body: body + }, options).then((res)=>{ + resolve(res); + }).catch((err)=>{ + reject(err); + }); + }); + } +} + +exports.PluginHandle = PluginHandle; diff --git a/src/plugins/videoroom-handle.js b/src/plugins/videoroom-handle.js new file mode 100644 index 0000000..5f3a7a9 --- /dev/null +++ b/src/plugins/videoroom-handle.js @@ -0,0 +1,388 @@ +'use strict'; + +var createId = require('uuid').v4; +var _ = require('lodash'); +var Promise = require('bluebird'); +var PluginHandle = require('./plugin-handle').PluginHandle; +var PluginNames = require('../constants').PluginNames; +var PluginError = require('../errors').PluginError; +var EventEmitter = require('events').EventEmitter; +var logger = require('debug-logger')('janus:videoroom'); + +/** + * @class + */ +class VideoRoomHandle extends PluginHandle { + + constructor(options) { + super(PluginNames.VideoRoom, options.id, options.session); + } + + create() { + return new Promise((resolve, reject)=>{ + this.requestMessage({ + request: 'create' + }).then((res)=>{ + resolve(new VideoRoom({ + room: _.get(res.getResponse(), 'plugindata.data.room', null) + }, this)); + }).catch((err)=>{ + reject(err); + }); + }); + } + + list() { + return new Promise((resolve, reject)=>{ + this.requestMessage({ + request: 'list' + }).then((res)=>{ + resolve(_.get(res.getResponse(), 'plugindata.data.list', [])); + }).catch((err)=>{ + reject(err); + }); + }); + } + + joinPublisher(room) { + return new Promise((resolve, reject)=>{ + var transaction = this.transactMessage({ + request: 'join', + ptype: 'publisher', + room: room + }).onResponse((res)=>{ + var videoRoom = _.get(res.getResponse(), 'plugindata.data.videoroom', null); + var errorCode = _.get(res.getResponse(), 'plugindata.data.error', null); + if(errorCode !== null) { + reject(new PluginError(transaction.getRequest(), res.getResponse(), this)); + } else if(videoRoom === 'joined') { + resolve(res.getResponse()); + } else { + reject(new Error('Unknown response')); + } + }).start(); + }); + } + + joinListener(room, feed) { + return new Promise((resolve, reject)=>{ + var transaction = this.transactMessage({ + request: 'join', + ptype: 'listener', + room: room, + feed: feed + }).onResponse((res)=>{ + var videoRoom = _.get(res.getResponse(), 'plugindata.data.videoroom', null); + var errorCode = _.get(res.getResponse(), 'plugindata.data.error', null); + if(errorCode !== null) { + reject(new PluginError(transaction.getRequest(), res.getResponse(), this)); + } else if(videoRoom === 'attached') { + resolve(res.getResponse()); + } else { + reject(new Error('Unknown response')); + } + }).start(); + }); + } + + configure(options) { + return new Promise((resolve, reject)=>{ + + var audio = _.get(options, 'audio', true); + var video = _.get(options, 'video', true); + + var jsep = _.get(options, 'jsep', null); + if(jsep === null) { + throw new Error('Missing argument jsep'); + } + + var transaction = this.transactJsepMessage({ + request: 'configure', + audio: audio, + video: video + }, options.jsep).onResponse((res)=>{ + var configured = _.get(res.getResponse(), 'plugindata.data.configured', null); + var errorCode = _.get(res.getResponse(), 'plugindata.data.error', null); + if(errorCode !== null) { + reject(new PluginError(transaction.getRequest(), res.getResponse(), this)); + } else if(configured === 'ok') { + resolve(res.getResponse()); + } else { + reject(new Error('Unknown response')); + } + }).start(); + }); + } + + start(options) { + return new Promise((resolve, reject)=>{ + + var jsep = _.get(options, 'jsep', null); + if(jsep === null) { + throw new Error('Missing argument jsep'); + } + + var transaction = this.transactJsepMessage({ + request: 'start', + room: options.room, + feed: options.feed + }, options.jsep).onResponse((res)=>{ + var started = _.get(res.getResponse(), 'plugindata.data.started', null); + var errorCode = _.get(res.getResponse(), 'plugindata.data.error', null); + if(errorCode !== null) { + reject(new PluginError(transaction.getRequest(), res.getResponse(), this)); + } else if(started === 'ok') { + resolve(res.getResponse()); + } else { + reject(new Error('Unknown response')); + } + }).start(); + }); + } + + publish(options) { + return new Promise((resolve, reject)=>{ + var joinResult = null; + var room = _.get(options, 'room', null); + if(room === null) { + throw new Error('Missing argument room'); + } + this.joinPublisher(options.room).then((res)=>{ + joinResult = res; + return this.configure({ + audio: options.audio, + video: options.video, + jsep: options.jsep + }); + }).then((res)=>{ + resolve({ + id: _.get(joinResult, 'plugindata.data.id', null), + publishers: _.get(joinResult, 'plugindata.data.publishers', []), + answer: _.get(res, 'jsep.sdp', null) + }); + }).catch((err)=>{ + reject(err); + }); + }); + } + + listen(options) { + return new Promise((resolve, reject)=>{ + this.joinListener(options.room, options.feed).then((joinResult)=>{ + resolve({ + id: _.get(joinResult, 'plugindata.data.id', null), + offer: _.get(joinResult, 'jsep.sdp', null) + }); + }).catch((err)=>{ + reject(err); + }); + }); + } + + trickle(candidate) { + return this.request({ + janus: 'trickle', + candidate: candidate + }); + } + + createPublisher(room) { + return new Promise((resolve, reject)=>{ + var publisher = new Publisher({ + session: this.session, + room: room + }); + publisher.init().then(()=>{ + resolve(publisher); + }).catch((err)=>{ + reject(err); + }); + }); + } + + createListener(room, feed) { + return new Promise((resolve, reject)=>{ + var listener = new Listener({ + session: this.session, + room: room, + feed: feed + }); + listener.init().then(()=>{ + resolve(listener); + }).catch((err)=>{ + reject(err); + }); + }); + } +} + +/** + * @class + */ +class VideoRoom { + + constructor(options, handle) { + this.room = options.room; + this.handle = handle; + } +} + +/** + * @class + */ +class VideoRoomParticipant { + + constructor(options) { + this.session = options.session; + this.offer = options.offer; + this.room = options.room; + this.handle = null; + this.answer = null; + } + + init() { + return new Promise((resolve, reject)=>{ + this.session.createVideoRoomHandle().then((handle)=>{ + this.handle = handle; + resolve(); + }).catch((err)=>{ + reject(err); + }); + }); + } + + getRoom() { + return this.room; + } + + setOffer(offer) { + this.offer = offer; + } + + getOffer() { + return this.offer; + } + + setAnswer(answer) { + this.answer = answer; + } + + getAnswer() { + return this.answer; + } + + trickle(candidate) { + return this.handle.trickle(candidate); + } +} + +/** + * @class + */ +class Publisher extends VideoRoomParticipant { + + constructor(options) { + super(options); + this.id = options.id; + this.emitter = new EventEmitter(); + this.listeners = {}; + } + + getId() { + return this.id; + } + + addListener(listener) { + this.listeners[listener.getFeed()] = listener; + } + + removeListener(id) { + delete this.listeners[id]; + } + + join(offer) { + return new Promise((resolve, reject)=>{ + this.setOffer(offer); + this.handle.publish({ + room: this.room, + jsep: { + type: 'offer', + sdp: this.getOffer() + } + }).then((result)=>{ + this.id = result.id; + this.answer = result.answer; + _.forEach(result.publishers, (publisher)=>{ + this.handle.createListener(this.room, publisher.id).then((listener)=>{ + this.addListener(listener); + return listener.createOffer(); + }).then(()=>{ + this.emitter.emit('joined', this.listeners[publisher.id]); + }).catch((err)=>{ + logger.error(err); + }); + }); + resolve(); + }).catch((err)=>{ + reject(err); + }); + }); + } + + onJoined(listener) { + this.emitter.on('joined', listener); + } +} + +/** + * @class + */ +class Listener extends VideoRoomParticipant { + + constructor(options) { + super(options); + this.feed = options.feed; + } + + getFeed() { + return this.feed; + } + + createOffer() { + return new Promise((resolve, reject)=>{ + this.handle.listen({ + room: this.room, + feed: this.feed + }).then((result)=>{ + this.offer = result.offer; + resolve(); + }).catch((err)=>{ + reject(err); + }); + }); + } + + setAnswer(sdp) { + this.answer = sdp.replace(/a=(sendrecv|sendonly)/, 'a=recvonly'); + } + + setRemoteAnswer(answer) { + return new Promise((resolve, reject)=>{ + this.setAnswer(answer); + this.handle.start({ + room: this.room, + feed: this.feed, + jsep: { + type: 'answer', + sdp: this.getAnswer() + } + }).then(()=>{ + resolve(); + }).catch((err)=>{ + reject(err); + }); + }); + } +} + +exports.VideoRoomHandle = VideoRoomHandle; diff --git a/src/session.js b/src/session.js new file mode 100644 index 0000000..73b6cb9 --- /dev/null +++ b/src/session.js @@ -0,0 +1,156 @@ +'use strict'; + +var _ = require('lodash'); +var Promise = require('bluebird'); +var Plugins = require('./plugins'); +var PluginNames = require('./constants').PluginNames; +var EventEmitter = require('events').EventEmitter; +var logger = require('debug-logger')('janus:session'); + +var State = { + alive: 'alive', + dying: 'dying', + dead: 'dead' +}; + +/** + * @class + */ +class Session { + + constructor(id, janus) { + this.id = id; + this.janus = janus; + this.pluginHandles = {}; + this.keepAliveTimer = null; + this.keepAliveInterval = 30000; + this.keepAliveFails = 2; + this.keepAliveFailCount = 0; + this.emitter = new EventEmitter(); + this.state = (this.janus.isConnected()) ? State.alive : State.dead; + this.startKeepAlive(); + } + + keepAlive() { + return this.janus.request({ + janus: 'keepalive', + session_id: this.id + }); + } + + startKeepAlive() { + this.stopKeepAlive(); + this.keepAliveTimer = setInterval(()=> { + this.keepAlive().then(()=>{ + this.keepAliveFailCount = 0; + this.state = State.alive; + this.emitter.emit('keepalive', true); + }).catch(()=>{ + this.keepAliveFailCount++; + this.state = State.dying; + this.emitter.emit('keepalive', false); + if(this.keepAliveFailCount === this.keepAliveFails) { + this.state = State.dead; + this.timeout(); + } + }); + }, this.keepAliveInterval); + } + + stopKeepAlive() { + if(this.keepAliveTimer !== null) { + clearInterval(this.keepAliveTimer); + } + } + + transact(obj) { + this.startKeepAlive(); + obj.session_id = this.id; + return this.janus.transact(obj); + } + + request(obj, options) { + this.startKeepAlive(); + obj.session_id = this.id; + return this.janus.request(obj, options); + } + + createPluginHandle(pluginName) { + return new Promise((resolve, reject)=>{ + this.request({ + janus: 'attach', + plugin: pluginName + }).then((res)=>{ + logger.info('Created handle plugin=%s handle=%s', pluginName, res.getResponse().data.id); + resolve(res.getResponse().data.id); + }).catch((err)=>{ + reject(err); + }); + }); + } + + addPluginHandle(pluginHandle) { + this.pluginHandles[pluginHandle.getId()] = pluginHandle; + } + + createVideoRoomHandle() { + return new Promise((resolve, reject)=>{ + this.createPluginHandle(PluginNames.VideoRoom).then((id)=>{ + var pluginHandle = new Plugins.VideoRoom({ + session: this, + id: id + }); + this.addPluginHandle(pluginHandle); + resolve(pluginHandle); + }).catch((err)=>{ + reject(err); + }); + }); + } + + getId() { + return this.id; + } + + getState() { + return this.state; + } + + isAlive() { + return this.state === State.alive; + } + + timeout() { + this.destroy(); + this.emitter.emit('timeout'); + } + + onTimeout(listener) { + this.emitter.on('timeout', listener); + } + + onKeepAlive(listener) { + this.emitter.on('keepalive', listener); + } + + /** + * @param event + * @param [event.sender] + */ + emitEvent(event) { + if(event.sender && _.isObject(this.pluginHandles[event.sender])) { + this.pluginHandles[event.sender].emitEvent(event); + } else { + this.emitter.emit('event', event); + } + } + + destroy() { + this.stopKeepAlive(); + this.pluginHandles = {}; + this.janus = null; + } +} + +exports.Session = Session; +exports.SessionState = State; diff --git a/src/transaction.js b/src/transaction.js new file mode 100644 index 0000000..ad9d3d7 --- /dev/null +++ b/src/transaction.js @@ -0,0 +1,183 @@ +'use strict'; + +var _ = require('lodash'); +var createId = require('uuid'); +var EventEmitter = require('events').EventEmitter; +var ResponseError = require('./errors').ResponseError; + +var State = { + new: 'new', + started: 'started', + ended: 'ended' +}; + +var Event = { + response: 'response', + end: 'end', + error: 'error' +}; + +/** + * @class + */ +class InvalidTransactionState { + + constructor(transaction) { + this.name = this.constructor.name; + this.message = 'Invalid transaction state'; + this.transaction = transaction; + this.state = transaction.getState(); + } +} + +/** + * @class + */ +class TransactionTimeoutError { + + constructor(transaction, timeout) { + this.name = this.constructor.name; + this.message = 'Transaction timeout'; + this.transaction = transaction; + this.timeout = timeout; + } +} + +/** + * @class + */ +class Transaction { + + constructor(request, handler) { + this.id = createId(); + this.request = request; + this.request.transaction = this.id; + this.emitter = new EventEmitter(); + this.handler = handler; + this.state = State.new; + this.timeoutTimer = null; + this.timeoutMilli = 6000; + this.useTimeout = false; + this.acknowledged = false; + } + + getId() { + return this.id; + } + + getRequest() { + return this.request; + } + + getState() { + return this.state; + } + + start() { + if(this.state === State.new) { + this.state = State.started; + this.startTimeout(); + this.handler(this.getRequest()); + } else { + throw new InvalidTransactionState(this); + } + return this; + } + + response(res) { + if(this.state === State.started) { + this.startTimeout(); + if(res.isError()) { + this.error(new ResponseError(res.getRequest(), res)); + } else if(res.isAck()) { + this.acknowledged = true; + this.emitter.emit('ack'); + } else { + this.emitter.emit(Event.response, res); + } + } else { + throw new InvalidTransactionState(this); + } + } + + end() { + if(this.state !== State.ended) { + this.state = State.ended; + this.stopTimeout(); + this.emitter.emit(Event.end); + } else { + throw new InvalidTransactionState(this); + } + } + + error(err) { + this.end(); + this.emitter.emit(Event.error, err); + } + + onAck(listener) { + this.emitter.on('ack', listener); + return this; + } + + onResponse(listener) { + this.emitter.on(Event.response, listener); + return this; + } + + offResponse(listener) { + this.emitter.removeListener(Event.response, listener); + return this; + } + + onEnd(listener) { + this.emitter.on(Event.end, listener); + return this; + } + + offEnd(listener) { + this.emitter.removeListener(Event.end, listener); + return this; + } + + onError(listener) { + this.emitter.on(Event.error, listener); + return this; + } + + offError(listener) { + this.emitter.removeListener(Event.error, listener); + return this; + } + + timeout(timeout) { + if(this.state === State.new) { + this.useTimeout = true; + this.timeoutMilli = timeout; + } else { + throw new InvalidTransactionState(this); + } + return this; + } + + startTimeout() { + if(this.useTimeout) { + this.stopTimeout(); + this.timeoutTimer = setTimeout(()=> { + this.error(new TransactionTimeoutError(this, this.timeoutMilli)); + }, this.timeoutMilli); + } + } + + stopTimeout() { + if(this.timeoutTimer !== null) { + clearTimeout(this.timeoutTimer); + } + } + + isAcknowledged() { + return this.acknowledged; + } +} + +exports.Transaction = Transaction; diff --git a/test/clientSpec.js b/test/clientSpec.js new file mode 100644 index 0000000..2c703e5 --- /dev/null +++ b/test/clientSpec.js @@ -0,0 +1,125 @@ +'use strict'; + +var Janus = require('../src/janus').Janus; +var WebSocketServer = require('ws').Server; +var WebServer = require('http').createServer; +var Plugins = require('../src/constants').Plugins; + +var server = WebServer(); +var webSocketServer = new WebSocketServer({ + server: server +}); + +server.listen(6000, function(){ + + describe('Client', function(){ + + /* + it('should connect', function(done){ + + var client = new Client({ + url: 'ws://localhost:6000' + }); + + client.connect(); + client.on('connected', ()=>{ + done(); + }); + }); + */ + + it('should create a janus client', function(done){ + + this.timeout(6000); + + var janus = new Janus({ + url: 'ws://192.168.99.100:8188' + }); + + var videoRoomPluginInstance = null; + janus.on('connected', ()=>{ + console.log(janus.getVersion()); + janus.createSession().then((session)=>{ + return session.createVideoRoomPlugin(); + }).then((videoRoomPlugin)=>{ + videoRoomPluginInstance = videoRoomPlugin; + return videoRoomPluginInstance.create(); + }).then((videoRoom)=>{ + console.log(videoRoom); + return videoRoom.join({ + jsep: { + type: 'offer', + sdp: 'v=0\no=- 3778014195707572222 2 IN IP4 127.0.0.1\ns=-\nt=0 0\na=group:BUNDLE audio video\na=msid-semantic: WMS IuDZgxTv72hOf4c0Sp4dOz1bnTsXlhKHQG80\nm=audio 58770 UDP/TLS/RTP/SAVPF 111 103 104 9 0 8 106 105 13 126\nc=IN IP4 192.168.0.198\na=rtcp:58773 IN IP4 192.168.0.198\na=candidate:3081437784 1 udp 2122260223 192.168.0.198 58770 typ host generation 0 network-id 3\na=candidate:2218435994 1 udp 2122194687 192.168.99.1 58771 typ host generation 0 network-id 2\na=candidate:2999745851 1 udp 2122129151 192.168.56.1 58772 typ host generation 0 network-id 1\na=candidate:3081437784 2 udp 2122260222 192.168.0.198 58773 typ host generation 0 network-id 3\na=candidate:2218435994 2 udp 2122194686 192.168.99.1 58774 typ host generation 0 network-id 2\na=candidate:2999745851 2 udp 2122129150 192.168.56.1 58775 typ host generation 0 network-id 1\na=candidate:4180213416 1 tcp 1518280447 192.168.0.198 9 typ host tcptype active generation 0 network-id 3\na=candidate:3401144682 1 tcp 1518214911 192.168.99.1 9 typ host tcptype active generation 0 network-id 2\na=candidate:4233069003 1 tcp 1518149375 192.168.56.1 9 typ host tcptype active generation 0 network-id 1\na=candidate:4180213416 2 tcp 1518280446 192.168.0.198 9 typ host tcptype active generation 0 network-id 3\na=candidate:3401144682 2 tcp 1518214910 192.168.99.1 9 typ host tcptype active generation 0 network-id 2\na=candidate:4233069003 2 tcp 1518149374 192.168.56.1 9 typ host tcptype active generation 0 network-id 1\na=ice-ufrag:nOllhv2MTK+OPVca\na=ice-pwd:vzS+woR3AlcgWJjCRXuyB7pD\na=fingerprint:sha-256 8C:C6:01:C7:75:4B:C4:79:D3:9E:2A:91:EE:48:52:EF:4E:B5:1E:FE:2B:49:96:F5:DB:69:E9:38:84:AD:4A:C4\na=setup:actpass\na=mid:audio\na=extmap:1 urn:ietf:params:rtp-hdrext:ssrc-audio-level\na=extmap:3 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time\na=sendrecv\na=rtcp-mux\na=rtpmap:111 opus/48000/2\na=rtcp-fb:111 transport-cc\na=fmtp:111 minptime=10;useinbandfec=1\na=rtpmap:103 ISAC/16000\na=rtpmap:104 ISAC/32000\na=rtpmap:9 G722/8000\na=rtpmap:0 PCMU/8000\na=rtpmap:8 PCMA/8000\na=rtpmap:106 CN/32000\na=rtpmap:105 CN/16000\na=rtpmap:13 CN/8000\na=rtpmap:126 telephone-event/8000\na=maxptime:60\na=ssrc:4014372561 cname:lB9RT6li8dOUYWxB\na=ssrc:4014372561 msid:IuDZgxTv72hOf4c0Sp4dOz1bnTsXlhKHQG80 744d96ab-eb3e-41aa-acda-2bfa89cacd48\na=ssrc:4014372561 mslabel:IuDZgxTv72hOf4c0Sp4dOz1bnTsXlhKHQG80\na=ssrc:4014372561 label:744d96ab-eb3e-41aa-acda-2bfa89cacd48\nm=video 58776 UDP/TLS/RTP/SAVPF 100 101 116 117 96 97 98\nc=IN IP4 192.168.0.198\na=rtcp:58779 IN IP4 192.168.0.198\na=candidate:3081437784 1 udp 2122260223 192.168.0.198 58776 typ host generation 0 network-id 3\na=candidate:2218435994 1 udp 2122194687 192.168.99.1 58777 typ host generation 0 network-id 2\na=candidate:2999745851 1 udp 2122129151 192.168.56.1 58778 typ host generation 0 network-id 1\na=candidate:3081437784 2 udp 2122260222 192.168.0.198 58779 typ host generation 0 network-id 3\na=candidate:2218435994 2 udp 2122194686 192.168.99.1 58780 typ host generation 0 network-id 2\na=candidate:2999745851 2 udp 2122129150 192.168.56.1 58781 typ host generation 0 network-id 1\na=candidate:4180213416 1 tcp 1518280447 192.168.0.198 9 typ host tcptype active generation 0 network-id 3\na=candidate:3401144682 1 tcp 1518214911 192.168.99.1 9 typ host tcptype active generation 0 network-id 2\na=candidate:4233069003 1 tcp 1518149375 192.168.56.1 9 typ host tcptype active generation 0 network-id 1\na=candidate:4180213416 2 tcp 1518280446 192.168.0.198 9 typ host tcptype active generation 0 network-id 3\na=candidate:3401144682 2 tcp 1518214910 192.168.99.1 9 typ host tcptype active generation 0 network-id 2\na=candidate:4233069003 2 tcp 1518149374 192.168.56.1 9 typ host tcptype active generation 0 network-id 1\na=ice-ufrag:nOllhv2MTK+OPVca\na=ice-pwd:vzS+woR3AlcgWJjCRXuyB7pD\na=fingerprint:sha-256 8C:C6:01:C7:75:4B:C4:79:D3:9E:2A:91:EE:48:52:EF:4E:B5:1E:FE:2B:49:96:F5:DB:69:E9:38:84:AD:4A:C4\na=setup:actpass\na=mid:video\na=extmap:2 urn:ietf:params:rtp-hdrext:toffset\na=extmap:3 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time\na=extmap:4 urn:3gpp:video-orientation\na=sendrecv\na=rtcp-mux\na=rtcp-rsize\na=rtpmap:100 VP8/90000\na=rtcp-fb:100 ccm fir\na=rtcp-fb:100 nack\na=rtcp-fb:100 nack pli\na=rtcp-fb:100 goog-remb\na=rtcp-fb:100 transport-cc\na=rtpmap:101 VP9/90000\na=rtcp-fb:101 ccm fir\na=rtcp-fb:101 nack\na=rtcp-fb:101 nack pli\na=rtcp-fb:101 goog-remb\na=rtcp-fb:101 transport-cc\na=rtpmap:116 red/90000\na=rtpmap:117 ulpfec/90000\na=rtpmap:96 rtx/90000\na=fmtp:96 apt=100\na=rtpmap:97 rtx/90000\na=fmtp:97 apt=101\na=rtpmap:98 rtx/90000\na=fmtp:98 apt=116\na=ssrc-group:FID 3405380226 37764778\na=ssrc:3405380226 cname:lB9RT6li8dOUYWxB\na=ssrc:3405380226 msid:IuDZgxTv72hOf4c0Sp4dOz1bnTsXlhKHQG80 5db10470-35eb-4028-b073-e8081b21c8f6\na=ssrc:3405380226 mslabel:IuDZgxTv72hOf4c0Sp4dOz1bnTsXlhKHQG80\na=ssrc:3405380226 label:5db10470-35eb-4028-b073-e8081b21c8f6\na=ssrc:37764778 cname:lB9RT6li8dOUYWxB\na=ssrc:37764778 msid:IuDZgxTv72hOf4c0Sp4dOz1bnTsXlhKHQG80 5db10470-35eb-4028-b073-e8081b21c8f6\na=ssrc:37764778 mslabel:IuDZgxTv72hOf4c0Sp4dOz1bnTsXlhKHQG80\na=ssrc:37764778 label:5db10470-35eb-4028-b073-e8081b21c8f6\n' + } + }); + }).then((joined)=>{ + console.log(joined); + done(); + }).catch((err)=>{ + done(err); + }); + }); + + janus.on('error', (err)=>{ + done(err); + }); + + janus.connect(); + + /* + var client = new Client({ + url: 'ws://192.168.99.100:8188' + }); + + client.connect(); + client.on('connected', ()=>{ + client.createSession().then((res)=>{ + done(); + }).catch((err)=>{ + done(err); + }); + }); + */ + }); + + /* + it('should create a janus session', function(done){ + + this.timeout(6000); + + var client = new Client({ + url: 'ws://192.168.99.100:8188' + }); + + client.connect(); + client.on('connected', ()=>{ + client.createSession().then((res)=>{ + done(); + }).catch((err)=>{ + done(err); + }); + }); + }); + + it('should get janus gateway info', function(done){ + + this.timeout(6000); + + var client = new Client({ + url: 'ws://192.168.99.100:8188' + }); + + client.connect(); + client.on('connected', ()=>{ + client.getInfo().then((res)=>{ + done(); + }).catch((err)=>{ + done(err); + }); + }); + }); + */ + }); + + run(); +});