import recursiveAssign from 'recursive-object-assign' import WebSocket from 'isomorphic-ws' import fetch from 'cross-fetch' const CMD_RESPONSE = 'response' const CMD_PING = 'ping' const CMD_PONG = 'pong' /** * Client class for ZeroFrame WebSocket. */ class ZeroFrame { // Initialization & Connection /** * Construct the class and set up a connection to the WebSocket server. * * @param {string} site - Target ZeroNet site address * @param {ZeroFrame#constructorOptions} [options=ZeroFrame#constructorOptions] - Client options */ constructor (site, options = {}) { options = recursiveAssign(ZeroFrame.constructorOptions, options) if (!site) { throw new Error('Site address is not specified') } this.site = site this.multiuser = options.multiuser this.instance = options.instance this.show = options.show this.reconnect = options.reconnect this.websocketConnected = false this.websocketClosing = false this.waitingCallbacks = {} this.waitingMessages = [] this.nextMessageId = 1 this.nextAttemptId = 1 /** * Proxy for accessing ZeroFrame commands. * * Command name is accepted as an object's property and parameters are accepted as * a method's arguments. Command returns `Promise` with the result. * * * Command with no arguments can be accessed with `zeroframe.proxy.cmdName()`. * * Command with keyword arguments can be accessed with `zeroframe.proxy.cmdName({key1: value1, key2: value2})`. * * Command with normal arguments can be accessed with `zeroframe.proxy.cmdName(value1, value2)`. * * @name ZeroFrame#proxy * @type Proxy */ this.proxy = new Proxy(this, { get: function get (target, name) { return function () { if (arguments.length === 0) { return target.cmdp(name) } if (arguments.length === 1 && typeof arguments[0] === 'object' && arguments[0] !== null) { return target.cmdp(name, arguments[0]) } const params = Array.prototype.slice.call(arguments) return target.cmdp(name, params) } } }) this._connect() } /** * User-based initialization code. * * @return {ZeroFrame} */ init () { return this } /** * Get wrapper key and connect to WebSocket. * * @return {ZeroFrame} * * @private */ async _connect () { this.wrapperKey = await this._getWrapperKey() this.websocket = await this._getWebsocket() return this.init() } /** * Get and return wrapper key. * * @return {string} - Wrapper key * * @private */ async _getWrapperKey () { const siteUrl = 'http' + (this.instance.secure ? 's' : '') + '://' + this.instance.host + ':' + this.instance.port + '/' + this.site const wrapperRequest = await fetch(siteUrl, { headers: { Accept: 'text/html' } }) const wrapperBody = await wrapperRequest.text() const wrapperKey = wrapperBody.match(/wrapper_key = "(.*?)"/)[1] return wrapperKey } /** * Connect and return WebSocket. * * @return {object} - WebSocket connection * * @private */ async _getWebsocket () { const wsUrl = 'ws' + (this.instance.secure ? 's' : '') + '://' + this.instance.host + ':' + this.instance.port + '/Websocket?wrapper_key=' + this.wrapperKey let wsClient if (!this.multiuser.masterAddress) { wsClient = new WebSocket(wsUrl) } else { wsClient = new WebSocket(wsUrl, [], { headers: { Cookie: 'master_address=' + this.multiuser.masterAddress } }) } wsClient.onmessage = this._onRequest.bind(this) wsClient.onopen = this._onOpenWebsocket.bind(this) wsClient.onerror = this._onErrorWebsocket.bind(this) wsClient.onclose = this._onCloseWebsocket.bind(this) return wsClient } // Internal handlers /** * Internal on request handler. * * It is triggered on every message from the WebSocket server. * It handles built-in commands and forwards others * to the user-based handler. * * @see ZeroFrame#onRequest * * @param {MessageEvent} event - Message event from the WebSocket library * * @return {void} * * @private */ _onRequest (event) { const message = JSON.parse(event.data) const cmd = message.cmd if (cmd === CMD_RESPONSE) { if (this.waitingCallbacks[message.to] !== undefined) { this.waitingCallbacks[message.to](message.result) delete this.waitingCallbacks[message.to] } } else if (cmd === CMD_PING) { this.response(message.id, CMD_PONG) } else { this.onRequest(cmd, message) } } /** * Internal on open websocket handler. * * It is triggered when the WebSocket connection is opened. * It sends waiting message and calls the user-based handler. * * @see ZeroFrame#onOpenWebsocket * * @param {OpenEvent} event - Open event from the WebSocket library * * @return {void} * * @private */ _onOpenWebsocket (event) { this.websocketConnected = true this.waitingMessages.forEach((message) => { if (!message.processed) { this.websocket.send(JSON.stringify(message)) message.processed = true } }) this.onOpenWebsocket(event) } /** * Internal on error websocket handler. * * It is triggered on the WebSocket error. It calls the user-based client. * * @see ZeroFrame#onErrorWebsocket * * @param {ErrorEvent} event - Error event from the WebSocket library * * @return {void} * * @private */ _onErrorWebsocket (event) { this.onErrorWebsocket(event) } /** * Internal on close websocket handler. * * It is triggered when the WebSocket connection is closed. * It tries to reconnect if enabled and calls the user-based handler. * * @see ZeroFrame#onCloseWebsocket * * @param {CloseEvent} event - Close event from the WebSocket library * * @return {void} * * @private */ _onCloseWebsocket (event) { this.websocketConnected = false this.onCloseWebsocket(event) // Don't attempt reconnection if user closes socket if (this.websocketClosing) return if (this.reconnect.attempts === 0) { return } if (this.reconnect.attempts !== -1 && this.nextAttemptId > this.reconnect.attempts) { return } setTimeout(async () => { this.websocket = await this._getWebsocket() }, this.reconnect.delay) } // External handlers /** * User-based on request handler. * * It is triggered on every message from the WebSocket server. * It can be used to add additional functionalities to * the client or handle received messages. * * @param {string} cmd - Name of received command * @param {object} message - Message of received command * * @return {void} */ onRequest (cmd, message) { this.log('Unknown request', message) } /** * User-based on open websocket handler. * * It is triggered when the WebSocket connection is opened. * It can be used to notify user or check for server details. * * @param {OpenEvent} event - Open event from the WebSocket library * * @return {void} */ onOpenWebsocket (event) { this.log('Websocket open') } /** * User-based on error websocket handler. * * It is triggered on the WebSocket error. * It can be used to notify user or display errors. * * @param {ErrorEvent} event - Error event from the WebSocket library */ onErrorWebsocket (event) { this.error('Websocket error') } /** * User-based on close websocket handler. * * It is triggered when the WebSocket connection is closed. * It can be used to notify user or display connection error. * * @param {CloseEvent} event - Close event from the WebSocket library */ onCloseWebsocket (event) { this.log('Websocket close') } // Logging functions /** * Add log to console if enabled. * * @param {...*} args Logs to add to console * * @return {void} */ log (...args) { if (this.show.log) { console.log.apply(console, ['[ZeroFrame]'].concat(args)) } } /** * Add error to console if enabled. * * @param {...*} args Errors to add to console * * @return {void} */ error (...args) { if (this.show.error) { console.error.apply(console, ['[ZeroFrame]'].concat(args)) } } // Command functions /** * Callback with command result. * * Result will depend on called command. * * In most cases, the result will be object which contains data. * Some commands don't have any result. In this case, the result * will probably be string `ok`. * * @see {@link https://zeronet.io/docs/site_development/zeroframe_api_reference/|ZeroFrame API Reference} * * @typedef {function((object|string))} ZeroFrame#cmdCallback * @callback ZeroFrame#cmdCallback * * @param {object|string} [result] - Result from command * * @return {void} */ /** * Internally send raw message to ZeroFrame server and call callback. * * If the connection is available, it directly sends a message. If the * connection is not available, it adds message to waiting message queue. * * @see ZeroFrame#cmd * @see ZeroFrame#cmdp * @see ZeroFrame#response * * @param {object} message - Message to send * @param {ZeroFrame#cmdCallback} [cb] - Message callback * * @return {void} * * @private */ _send (message, cb = null) { if (!message.id) { message.id = this.nextMessageId this.nextMessageId++ } if (this.websocketConnected) { this.websocket.send(JSON.stringify(message)) } else { this.waitingMessages.push(message) } if (cb) { this.waitingCallbacks[message.id] = cb } } /** * Send command to ZeroFrame server and call callback. * * @param {string} cmd - Name of command to send * @param {object} [params] - Parameters of command to send * @param {ZeroFrame#cmdCallback} [cb] - Command callback * * @return {void} */ cmd (cmd, params = {}, cb = null) { this._send({ cmd: cmd, params: params }, cb) } /** * Send command to ZeroFrame server and return the result as promise. * * In most cases, the result will be object which contains data. * Some commands don't have any result. In this case, the result * will probably be string `ok`. * * @see {@link https://zeronet.io/docs/site_development/zeroframe_api_reference/|ZeroFrame API Reference} * * @param {string} cmd - Name of command to send * @param {object} [params] - Parameters of command to send * * @return {Promise<(object|string)>} Command response */ cmdp (cmd, params = {}) { return new Promise((resolve, reject) => { this.cmd(cmd, params, (response) => { if (response && response.error) { reject(response.error) } else { resolve(response) } }) }) } /** * Response to ZeroFrame message. * * @param {number} cmd - Message ID to response * @param {object} result - Result to send * * @return {void} */ response (to, result) { this._send({ cmd: CMD_RESPONSE, to: to, result: result }) } /** * Close websocket connection. * * @return {void} */ close () { this.websocketClosing = true this.websocket.close() this.onCloseWebsocket() } } /** * Constructor's options structure with default values. * * @typedef {object} ZeroFrame#constructorOptions * @name ZeroFrame#constructorOptions * * @property {object} [multiuser] - Multiuser options * @property {string} [multiuser.masterAddress=null] - Master address for multiuser ZeroNet instance * @property {string} [multiuser.masterSeed=null] - Master seed for multiuser ZeroNet instance * * @property {object} [instance] - Instance options * @property {string} [instance.host=127.0.0.1] - Host of ZeroNet instance * @property {number} [instance.port=43110] - Port of ZeroNet instance * @property {boolean} [instance.secure=false] - Secure connection of ZeroNet instance * * @property {object} [show] - Showing options * @property {boolean} [show.log=false] - Show log messages in console * @property {boolean} [show.error=false] - Show error messages in console * * @property {object} [reconnect] - Reconnecting options * @property {number} [reconnect.attempts=-1] - Number of attempts (no limit with `-1` & no reconnect with `0`) * @property {number} [reconnect.delay=5000] - Number of delay in milliseconds */ ZeroFrame.constructorOptions = { multiuser: { masterAddress: null, masterSeed: null }, instance: { host: '127.0.0.1', port: 43110, secure: false }, show: { log: false, error: false }, reconnect: { attempts: -1, delay: 5000 } } try { module.exports = ZeroFrame } catch (err) { } export default ZeroFrame