1 |
|
2 |
|
3 |
|
4 | 'use strict'
|
5 |
|
6 | const argx = require('argx')
|
7 | const asleep = require('asleep')
|
8 | const cookies = require('browser-cookies')
|
9 | const { restore, save } = require('bstorage')
|
10 | const { get } = require('bwindow')
|
11 | const qs = require('qs')
|
12 | const { RFuncClient } = require('rfunc-client/shim/browser')
|
13 | const io = require('socket.io-client')
|
14 | const { isBrowser, isProduction, unlessProduction } = require('the-check')
|
15 | const { ThePack } = require('the-pack')
|
16 | const { resolve: resolveUrl } = require('url')
|
17 | const uuid = require('uuid')
|
18 | const IOEvents = require('./constants/IOEvents')
|
19 | const {
|
20 | asController,
|
21 | debugController,
|
22 | debugStream,
|
23 | parseClientUrl,
|
24 | } = require('./helpers')
|
25 | const { infoMix, pingPongMix, streamMix } = require('./mixins')
|
26 | const debug = require('debug')('the:client')
|
27 | const NAMESPACE = '/rpc'
|
28 |
|
29 | const TheClientBase = [
|
30 | pingPongMix,
|
31 | infoMix,
|
32 | streamMix,
|
33 | ].reduce((Class, mix) => mix(Class), RFuncClient)
|
34 |
|
35 | const { decode, encode } = new ThePack({})
|
36 |
|
37 |
|
38 | class TheClient extends TheClientBase {
|
39 |
|
40 | |
41 |
|
42 |
|
43 |
|
44 |
|
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 |
|
99 | callerKey: rpc && rpc.as,
|
100 |
|
101 | cid: cid,
|
102 |
|
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 |
|
163 |
|
164 |
|
165 |
|
166 |
|
167 | async invoke (moduleName, methodName, ...params) {
|
168 | this.assertNotClosed()
|
169 | const { cid, socket } = this
|
170 | const iid = uuid.v4()
|
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 |
|
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 |
|
267 |
|
268 |
|
269 |
|
270 |
|
271 |
|
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 |
|
287 |
|
288 |
|
289 |
|
290 |
|
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 |
|
317 |
|
318 |
|
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 |
|
346 | TheClient.RPC_ACTOR_NAME = 'rpc'
|
347 | TheClient.CID_KEY = 'the:cid'
|
348 |
|
349 | TheClient.newCID = () => uuid.v4()
|
350 |
|
351 | module.exports = TheClient
|