UNPKG

9.21 kBJavaScriptView Raw
1/**
2 * @class TheClient
3 */
4'use strict'
5
6const argx = require('argx')
7const asleep = require('asleep')
8const cookies = require('browser-cookies')
9const { restore, save } = require('bstorage')
10const { get } = require('bwindow')
11const qs = require('qs')
12const { RFuncClient } = require('rfunc-client/shim/browser')
13const io = require('socket.io-client')
14const { isBrowser, isProduction, unlessProduction } = require('the-check')
15const { ThePack } = require('the-pack')
16const { resolve: resolveUrl } = require('url')
17const uuid = require('uuid')
18const IOEvents = require('./constants/IOEvents')
19const {
20 asController,
21 debugController,
22 debugStream,
23 parseClientUrl,
24} = require('./helpers')
25const { infoMix, pingPongMix, streamMix } = require('./mixins')
26const debug = require('debug')('the:client')
27const NAMESPACE = '/rpc'
28
29const TheClientBase = [
30 pingPongMix,
31 infoMix,
32 streamMix,
33].reduce((Class, mix) => mix(Class), RFuncClient)
34
35const { decode, encode } = new ThePack({})
36
37/** @lends TheClient */
38class TheClient extends TheClientBase {
39 // noinspection ReservedWordAsName
40 /**
41 * Create the client instance
42 * @param {string} namespace
43 * @param {Object} [config={}]
44 * @returns {TheClient}
45 */
46 static for (namespace = 'default', config = {}) {
47 const key = [TheClient.CID_KEY, namespace].join('/').trim()
48 const cid = restore(key) || TheClient.newCID()
49 const client = new TheClient({ ...config, cid })
50 const isBrowser = !!get('document')
51 if (isBrowser) {
52 save(key, cid)
53 cookies.set(key, cid, {})
54 }
55 return client
56 }
57
58 constructor (url, config) {
59 const args = argx(arguments)
60 url = args.shift('string')
61 config = args.pop('object') || {}
62 if (!url) {
63 url = parseClientUrl(config)
64 }
65 const {
66 cid = TheClient.newCID(),
67 forceNewSocket = false,
68 onGone,
69 version = 'unknown',
70 ...restOptions
71 } = config
72 if (!url) {
73 throw new Error(`[TheClient] Failed to parse urls with args ${JSON.stringify(arguments)}`)
74 }
75 super(url, restOptions)
76 this._onGone = onGone
77 this._forceNewSocket = forceNewSocket
78 this._gone = false
79 this._controllers = {}
80 this._cid = cid
81 this._version = version
82 this._socket = null
83 this._closed = false
84 }
85
86 get cid () {
87 return this._cid
88 }
89
90 get closed () {
91 return this._closed
92 }
93
94 get scope () {
95 const { _cid: cid, _rpc: rpc, _version: version } = this
96 const language = get('navigator.language')
97 return {
98 /** Caller key */
99 callerKey: rpc && rpc.as,
100 /** Client ID */
101 cid: cid,
102 /** Host of client */
103 host: get('location.host'),
104 /** Detected lang */
105 lang: language && language.split('-')[0],
106 /** Connecting protocol */
107 protocol: get('location.protocol'),
108 /** Client instance version number */
109 v: version,
110 /** Via client */
111 via: 'client',
112 }
113 }
114
115 get socket () {
116 return this._socket
117 }
118
119 assertNotClosed () {
120 if (this.closed) {
121 throw new Error(`[TheClient] Already closed!`)
122 }
123 }
124
125 handleCallback (data) {
126 data = decode(data)
127 const { controller: controllerName, name, values } = data
128 const controller = this._controllers[controllerName]
129 if (!controller) {
130 return
131 }
132 const callback = controller.callbacks[name]
133 if (!callback) {
134 return
135 }
136 unlessProduction(() => {
137 console.groupCollapsed(`[TheClient] Callback \`${controllerName}.${name}()\``)
138 console.log('Signature', `\`${controllerName}.${name}()\``)
139 console.log('Arguments', values)
140 console.groupEnd()
141 })
142 callback(...values)
143 }
144
145 markAsGone () {
146 if (this._gone) {
147 return
148 }
149 this._onGone && this._onGone()
150 this._gone = true
151 }
152
153 async close () {
154 this._closed = true
155 const socket = this._socket
156 if (socket) {
157 socket.close()
158 }
159 }
160
161 /**
162 * Invoke a method
163 * @param {string} moduleName
164 * @param {string} methodName - Name of method to invoke
165 * @param {...*} params - Parameters
166 */
167 async invoke (moduleName, methodName, ...params) {
168 this.assertNotClosed()
169 const { cid, socket } = this
170 const iid = uuid.v4() // Invocation id
171
172 return await new Promise((resolve, reject) => {
173 let keptGoneTimer = -1
174 let onReturn = (returned) => {
175 if (!onReturn) {
176 return null
177 }
178 returned = decode(returned)
179 if (returned.iid !== iid) {
180 return
181 }
182 socket.off(IOEvents.RPC_RETURN, onReturn)
183 onReturn = null
184 onKeep = null
185 clearTimeout(keptGoneTimer)
186 const { data, errors, ok } = returned
187 debug('rpc return', moduleName, methodName, returned)
188 if (ok) {
189 resolve(data)
190 } else {
191 const e = errors[0]
192 reject(e.message || e)
193 }
194 }
195 let onKeep = (kept) => {
196 if (!onKeep) {
197 return
198 }
199 kept = decode(kept)
200 if (kept.iid !== iid) {
201 return
202 }
203 socket.off(IOEvents.RPC_KEEP, onKeep)
204 onKeep = null
205 debug('rpc keep', moduleName, methodName)
206 clearTimeout(keptGoneTimer)
207 const { duration } = kept
208 keptGoneTimer = setTimeout(() => {
209 // TODO throw error?
210 console.error(`[TheClient] RPC call seems gone: \`${moduleName}.${methodName}()\``)
211 }, duration * 2)
212 }
213 socket.on(IOEvents.RPC_RETURN, onReturn)
214
215 socket.emit(IOEvents.RPC_CALL, encode({
216 cid,
217 iid,
218 methodName,
219 moduleName,
220 params,
221 }))
222 debug('rpc call', moduleName, methodName, params)
223 })
224 }
225
226 async newSocket () {
227 this.assertNotClosed()
228 const query = qs.stringify(this.scope)
229 const socket = io(resolveUrl(this.baseUrl, `${NAMESPACE}?${query}`), {
230 forceNew: this._forceNewSocket,
231 })
232 socket.on(IOEvents.CLIENT_CALLBACK, (data) => this.handleCallback(data))
233 socket.on('disconnect', () => {
234 debug('disconnect')
235 if (this.closed) {
236 return
237 }
238 const goneTimer = setTimeout(
239 () => this.markAsGone(),
240 2 * 1000 + 2 * 1000 * Math.random()
241 )
242 const cancelGone = () => {
243 debug('cancelGone')
244 clearTimeout(goneTimer)
245 socket.off('connect', cancelGone)
246 socket.off('reconnect', cancelGone)
247 }
248 socket.once('connect', cancelGone)
249 socket.once('reconnect', cancelGone)
250 socket.connect()
251 })
252 await new Promise((resolve, reject) => {
253 socket.on('connect', () => {
254 debug('connect')
255 resolve(socket)
256 })
257 socket.on('error', (e) => {
258 debug('error', e)
259 reject(e)
260 })
261 })
262 return socket
263 }
264
265 /**
266 * Create an stream to server
267 * @param {string} name
268 * @param {Object} params - Stream params
269 * @param {Object} [options={}] - Optional setting
270 * @param {Boolean} [options.debug] - With debug mode
271 * @returns {*}
272 */
273 async stream (name, params = {}, options = {}) {
274 this.assertNotClosed()
275 const {
276 debug = !isProduction() && isBrowser(),
277 } = options
278 if (!this._socket) {
279 this._socket = await this.newSocket()
280 }
281 const stream = await this.openStream(name, params)
282 return debug ? debugStream(stream) : stream
283 }
284
285 /**
286 * Use a controller module
287 * @param {string} controllerName - Module name
288 * @param {Object} [options={}] - Optional setting
289 * @param {Boolean} [options.debug] - With debug mode
290 * @returns {*}
291 */
292 async use (controllerName, options = {}) {
293 this.assertNotClosed()
294 const {
295 debug = !isProduction() && isBrowser(),
296 } = options
297 let controller = this._controllers[controllerName]
298 if (!this._socket) {
299 this._socket = await this.newSocket()
300 }
301 if (!controller) {
302 const instance = await this.connect(controllerName)
303 const spec = await this.describe(controllerName)
304 const { cid } = this
305 controller = asController(instance, spec, { cid })
306 if (debug) {
307 controller = debugController(controller)
308 }
309 this._controllers[controllerName] = controller
310 }
311
312 return controller
313 }
314
315 /**
316 * Use all controller modules
317 * @param {Object} [options={}] - Optional setting
318 * @returns {Promise<Object>}
319 */
320 async useAll (options = {}) {
321 const serverInfo = await this.serverInfo()
322 const controllers = {}
323 const controllerSpecs = serverInfo.controllers
324 for (const { methods, name } of controllerSpecs) {
325 this.specs[name] = { methods, name }
326 controllers[name] = await this.use(name, options)
327 }
328 unlessProduction(() => {
329 if (typeof Proxy === 'undefined') {
330 return controllers
331 }
332 return new Proxy(controllers, {
333 get (target, key) {
334 const has = key in target
335 if (!has) {
336 console.warn(`[TheClient] Unknown controller name: "${key}"`)
337 }
338 return target[key]
339 },
340 })
341 })
342 return controllers
343 }
344}
345
346TheClient.RPC_ACTOR_NAME = 'rpc'
347TheClient.CID_KEY = 'the:cid'
348
349TheClient.newCID = () => uuid.v4()
350
351module.exports = TheClient