sugo-actor
Version:
Actor component of SUGOS.
406 lines (358 loc) • 10.8 kB
JavaScript
/**
* @class SugoActor
* @augments {SugoClient}
* @param {string} url - Cloud server url
* @param {object} config - Configurations
* @param {string} config.key - Key of actor
* @param {object} config.auth - Auth object
* @param {object.<String, SugoActorModule>} config.modules - Modules to load.
* @param {string} config.path - Socket.IO option.
*/
'use strict'
const {SugoClient} = require('sugo-client')
const sgSocketClient = require('sg-socket-client')
const assert = require('assert')
const asleep = require('asleep')
const Module = require('./module')
const sgQueue = require('sg-queue')
const {ReservedEvents, GreetingEvents, RemoteEvents} = require('sg-socket-constants')
const {ClientTypes, CallerEvents, HubNotices} = require('sugo-constants')
const {CALLER_GONE} = HubNotices
const {validatePerformConfig, validateModules} = require('./validating')
const {ERROR} = ReservedEvents
const {HI, BYE} = GreetingEvents
const {JOIN, LEAVE, SPEC, DESPEC, PERFORM, RESULT, NOTICE} = RemoteEvents
const {ACTOR} = ClientTypes
const {authorize} = require('sugo-client-auth')
const {parseActorUrl} = require('./parsing')
const {messageFormatter, ModulePipeEmitter, ActorPipeEmitter} = require('./emitting')
const {ok, ng} = messageFormatter
const argx = require('argx')
const {parse, format} = require('sg-serializer')
const debug = require('debug')('sg:actor')
let _connectQueue = sgQueue()
/** @lends SugoActor */
class SugoActor extends SugoClient {
constructor (url, config = {}) {
let args = argx(arguments)
url = args.shift('string')
config = args.shift('object') || {}
if (!url) {
url = parseActorUrl(config)
}
super()
this.url = url
this.socket = null
this.modulePipes = {}
this.actorPipe = null
if (config.interfaces) {
console.warn('`config.interfaces` is now deprecated. Use `config.modules` instead. ')
config.modules = config.interfaces
delete config.interfaces
}
let {
key,
auth = false,
multiplex = true,
reconnection = true,
path = '/socket.io',
modules = {}
} = config
{
let noModuleFound = Object.keys(modules).length === 0
if (noModuleFound) {
console.warn('[SUGO-Actor] config.modules is empty. You should pass at lease one module')
}
}
{
let modulesError = validateModules(modules)
if (modulesError) {
throw modulesError
}
}
Object.assign(this, {key, auth, multiplex, reconnection, path})
this.modules = Module.compose(modules)
this.loadedModules = {}
this.onError = (err) => this.emitLocally(err) || Promise.reject(err)
}
/**
* Connect to hub.
* By call this, actor share specification of the modules to hub so that callers can access them.
* @returns {Promise}
*/
async connect () {
let {key, auth, multiplex, reconnection, modules, url, path} = this
assert.ok(key, '[SUGO-Actor] key is missing')
const doConnect = async () => {
if (this.socket) {
throw new Error('Already connected')
}
await asleep(0)
const isBrowser = typeof window !== 'undefined'
const socket = this.socket = sgSocketClient(url, {
multiplex,
reconnection,
path,
transports: isBrowser ? ['polling', 'websocket'] : ['websocket'],
})
socket.on(ERROR, (err) => {
this.emitLocally(ERROR, err)
})
if (auth) {
try {
await authorize(socket, auth)
} catch (err) {
throw new Error(
`[SUGO-Actor] Authentication failed: ${err.message} ( url: ${JSON.stringify(url)}, auth: ${JSON.stringify(auth)} )`
)
}
}
await socket.waitToConnect()
this.actorPipe = new ActorPipeEmitter(socket, {key})
this.actorPipe.open()
let hi = await socket.call(HI, {key})
{
let {payload} = hi
this.key = this.key || payload.key
}
for (let name of Object.keys(modules || {})) {
await this.load(name, modules[name])
}
socket.on(JOIN, (data) => {
const {caller, messages} = data || {}
debug('Caller joined', caller)
this.emitLocally(CallerEvents.JOIN, {
caller: this.asCallerInstance(caller),
messages
})
})
socket.on(LEAVE, (data) => {
const {caller, messages} = data
debug('Caller left', caller)
this.emitLocally(CallerEvents.LEAVE, {
caller: this.asCallerInstance(caller),
messages
})
})
socket.on(NOTICE, ({name, data}) => {
switch (name) {
case CALLER_GONE: {
const caller = this.asCallerInstance({key: data.key})
debug('Caller gone', caller)
this.emitLocally(CallerEvents.LEAVE, {caller})
break
}
default: {
break
}
}
})
socket.on(PERFORM, async (data) => {
const {meta = {}, pid} = data
if (!pid) {
throw new Error(`[SUGO-Actor] pid not found in PERFORM action. You need to use sugo-hub@6.x or later`)
}
const callback = (data) => (socket.emit(RESULT, Object.assign(data, {key, pid})))
let {types} = meta
if (types) {
data = parse(data, types, {})
}
try {
let result = await
this.perform(data)
let isObject = typeof result === 'object' && result !== null
let {payload, meta: types} = isObject ? format(result) : {payload: result}
let meta = {types}
callback(ok(payload, meta))
} catch (err) {
callback(ng(err))
}
})
}
let {connectQueue} = SugoActor
return connectQueue.push(doConnect)
}
/**
* Disconnect from the hub
* @returns {Promise}
*/
async disconnect () {
const {key, actorPipe, modulePipes, loadedModules} = this
const {socket} = this
if (!socket) {
return
}
for (const name of Object.keys(loadedModules || {})) {
await this.unload(name)
}
for (const name of Object.keys(modulePipes)) {
const modulePipe = modulePipes[name]
modulePipe.close()
delete modulePipes[name]
}
if (actorPipe) {
actorPipe.close()
this.actorPipe = null
}
await socket.call(BYE, {key})
socket.off(PERFORM)
socket.close()
await socket.waitToDisconnect()
this.socket = null
}
/**
* Handle perform event
* @param {object} data
* @returns {Promise}
*/
async perform (data) {
const {loadedModules} = this
const error = validatePerformConfig(data)
if (error) {
throw error
}
const {
module: modulesName,
method,
params
} = data
const module = loadedModules[modulesName]
if (!module) {
throw new Error(`[SUGO-Actor] Module not loaded: ${modulesName}`)
}
return module.$$invoke(method, params)
}
/**
* Load a module
* @param {string} moduleName - Name of module
* @param {Object} module - Module to load
* @returns {Promise}
*/
async load (moduleName, module) {
this.assertConnection()
let isModule = Module.isModule(module)
if (!isModule) {
throw new Error(`[SUGO-Actor] Failed to load since it is not a module: ${moduleName}`)
}
const {key, socket} = this
{
let {$$actor} = module
if ($$actor) {
throw new Error(
`[SUGO-Actor] Module already loaded: "${moduleName}" to actor: ${$$actor.key}`
)
}
}
const spec = module.$spec
await socket.call(SPEC, {name: moduleName, spec, key})
const modulePipe = new ModulePipeEmitter(socket, moduleName, {key})
module.$$registerEmitter(modulePipe)
module.$$actor = this
modulePipe.open()
this.modulePipes[moduleName] = modulePipe
this.loadedModules[moduleName] = module
// Load sub modules
{
for (const key of Object.keys(module)) {
const val = module[key]
if (Module.isModule(val)) {
let hasEmitter = !!val.$emitter
if (!hasEmitter) {
await this.loadSub(moduleName, {[key]: val})
}
}
}
}
}
/**
* Load sub modules
* @param {string} moduleName
* @param {Object} subModules
* @returns {Promise}
*/
async loadSub (moduleName, subModules) {
this.assertConnection()
subModules = Module.compose(subModules)
for (const subModuleName of Object.keys(subModules)) {
const fullName = [moduleName, subModuleName].join('.')
await this.load(fullName, subModules[subModuleName])
}
}
/**
* Unload a module
* @param {string} moduleName - Name of module
* @returns {Promise}
*/
async unload (moduleName) {
this.assertConnection()
const {key, socket, loadedModules} = this
const module = loadedModules[moduleName]
await socket.call(DESPEC, {name: moduleName, key})
const modulePipe = this.modulePipes[moduleName]
module.$$unregisterEmitter(modulePipe)
delete module.$$actor
modulePipe.close()
delete this.modulePipes[moduleName]
delete this.loadedModules[moduleName]
}
/**
* Unload sub module
* @param {string} moduleName - Name of module
* @param {string[]} subModuleNames - Name of sub modules
* @returns {*}
*/
async unloadSub (moduleName, subModuleNames) {
for (const subModuleName of subModuleNames) {
await this.unload([moduleName, subModuleName].join('.'))
}
}
emit (event, data, options = {}) {
const {local = false, only = null} = options
super.emit(event, data)
if (!local) {
this.emitToRemote(event, data, {only})
}
}
emitLocally (event, data) {
this.emit(event, data, {local: true})
}
emitToRemote (event, data, options = {}) {
let {actorPipe} = this
if (actorPipe) {
actorPipe.emit(event, data, options)
} else {
console.warn('[SUGO-Actor] Not connected!')
}
}
asCallerInstance (caller) {
const only = [caller.key]
const emit = (event, data) => this.emitToRemote(event, data, {only})
return Object.assign({}, caller, {emit})
}
/**
* Assert if the connected to hub
* @throws {Error}
*/
assertConnection () {
assert.ok(this.socket, '[SUGO-Actor] Not connected!')
}
/** @override */
get clientType () {
return ACTOR
}
/** @deprecated */
static urlFromConfig () {
console.warn('`SugoActor.urlFromConfig` is now deprecated. Use `SugoActor.parseActorUrl` instead')
return this.parseActorUrl(...arguments)
}
/**
* Parse actor url
*/
static parseActorUrl () {
return parseActorUrl(...arguments)
}
static get connectQueue () {
return _connectQueue
}
}
module.exports = SugoActor