UNPKG

12.3 kBJavaScriptView Raw
1var Promise = require('bluebird');
2var logger = require('debug');
3var debug = logger('socket.io-rpc');
4var lldebug = logger('socket.io-rpc:low-level');
5var _ = require('lodash');
6var path = require('path');
7/**
8 * @param {Manager} ioP socket.io manager instance returned by require('socket.io').listen(server);
9 * @param {Express} expApp express app
10 * @returns {{expose: Function, loadClientChannel: Function, masterChannel: Object}} rpc backend instance
11 */
12function createServer(ioP, expApp) {
13
14 var runDate = new Date();
15 var io;
16
17 //channel template support hash, will store the templates
18 var channelTemplates = {};
19 var channelTemplatesSize = 0;
20 var clientKnownChannels = {};
21
22
23 var deferreds = [];
24 var serverChannels = {};
25 var clientChannels = {};
26 var getClientChannel = function (id, name) {
27 if (!clientChannels.hasOwnProperty(id)) {
28 lldebug("creating an object for client channels for ID " + id);
29 clientChannels[id] = {};
30 }
31 if (!clientChannels[id].hasOwnProperty(name)) {
32 lldebug("creating a client channel for client with ID " + id + " and channel name " + name);
33 clientChannels[id][name] = {};
34 }
35 return clientChannels[id][name];
36 };
37
38 var getChannelNames = function () {
39 var names = [];
40 for(var channel in serverChannels){
41 names.push(channel);
42 }
43 return names;
44 };
45
46
47 /**
48 * @param {String} name
49 * @param {Object} toExpose
50 * @returns {RpcChannel}
51 * @constructor
52 */
53 var RpcChannel = function(name, toExpose) {
54 this.fns = toExpose;
55
56 /**
57 * @type {Array<String>}
58 */
59 this.fnNames = [];
60
61 for (var fnName in toExpose) {
62 this.fnNames.push(fnName);
63 }
64 Object.freeze(toExpose); //we won't support adding new methods to a channel after creation
65
66
67 for (var tplId in channelTemplates) {
68 if (_.isEqual(channelTemplates[tplId], this.fnNames)) {
69 this.tplId = Number(tplId); // converting string key to number
70 }
71 }
72
73 if (!_.isNumber(this.tplId)) { //if no template matches the methods collection we save this channel methods as new template
74 var index = channelTemplatesSize + 1;
75 channelTemplates[index] = this.fnNames;
76 channelTemplatesSize = index;
77 this.tplId = index;
78 }
79
80 this._socket = io.of('/rpc/' + name);
81
82 this._socket.on('connection', function(socket) {
83 var invocationRes = function(data) {
84 if (toExpose.hasOwnProperty(data.fnName) && typeof toExpose[data.fnName] === 'function') {
85 var retVal;
86 try {
87 retVal = toExpose[data.fnName].apply(socket, data.args);
88 } catch (e) {
89 //we explicitly print the error into the console, because uncatched errors should not occur
90 console.error('RPC method ' + data.fnName + ' on channel ' + name + ': ', e);
91 retVal = e;
92 }
93 /* NOTE: Will return true for *any thenable object*, and isn't truly safe, since it will access the `then` property*/
94 if (retVal && typeof retVal.then === 'function') { // this is async function, so 'return' is emitted after it finishes
95 retVal.then(function (asyncRetVal) {
96 socket.emit('resolve', { Id: data.Id, value: asyncRetVal });
97 }, function (error) {
98 if (error instanceof Error) {
99 error = error.toString();
100 }
101 socket.emit('reject', { Id: data.Id, reason: error });
102
103 });
104 } else {
105 //synchronous
106 if (retVal instanceof Error) {
107 socket.emit('reject', { Id: data.Id, reason: retVal.toString() });
108 } else {
109 socket.emit('resolve', { Id: data.Id, value: retVal });
110 }
111 }
112
113 } else {
114 socket.emit('reject', {Id: data.Id, reason: 'no such function has been exposed: ' + data.fnName });
115 }
116 };
117
118 socket.on('call', invocationRes);
119
120 });
121
122 return this;
123 };
124
125 var invocationCounter = 0;
126 var endCounter = 0;
127 var callToClientEnded = function (Id) {
128 if (deferreds[Id]) {
129 delete deferreds[Id];
130 endCounter++;
131 if (endCounter == invocationCounter) {
132 invocationCounter = 0;
133 endCounter = 0;
134 }
135 } else {
136 //the client can maliciously try and resolve/reject something more than once. We should not throw an error on this, just warn
137 console.error("Deferred Id " + Id + " was resolved/rejected more than once, this should not occur.");
138 }
139 };
140
141 var rpcInstance = {
142 /**
143 * @returns {object} has of all channels
144 */
145 get channels() {
146 return serverChannels;
147 },
148 /**
149 * Makes a hash of functions available for client's consumption
150 * @param {String} name
151 * @param {Object} toExpose
152 * @param {String} [urlForModule] used when registering express app.get callback
153 * @returns {rpcInstance}
154 */
155 expose: function (name, toExpose, urlForModule) {
156 if (!urlForModule) {
157 urlForModule = '/rpc/' + name + '.js';
158 }
159 var fnNames = Object.keys(toExpose);
160
161 expApp.get(urlForModule, function (req, res){
162 res.type('application/javascript; charset=utf-8');
163 var fullUrl = "'" + req.protocol + '://' + req.get('host') + "'";
164
165 var clSideScript = 'var fns = ' + JSON.stringify(fnNames) + '\n' + '' +
166 'var chnl = require("rpc/export-channel")("' + name + '", fns, ' + fullUrl + ') \n' +
167 'module.exports = chnl;';
168 res.send(clSideScript);
169 res.end();
170 });
171
172 if (serverChannels[name]) {
173 console.warn("This channel name(" + name + ") is already exposed-ignoring the command.");
174 } else {
175 if (toExpose.rpcProps) {
176 throw new Error('Failed to expose channel, rpcProps property is reserved for socket and rpc stuff');
177 }
178 serverChannels[name] = new RpcChannel(name, toExpose);
179 }
180 return rpcInstance;
181 },
182 /**
183 * Makes a file's exported methods available for consumption
184 * @param {String} mPath to the module you want to require on the client side
185 * @returns {rpcInstance}
186 */
187 exposeFile: function (mPath) {
188 var stack = new Error().stack;
189
190 var callerFile = stack.split('\n')[2].match(/\(.*\)/g)[0];
191 callerFile = callerFile.substr(1, callerFile.indexOf('.js') + 2);
192
193 var absToModule = path.join(path.dirname(callerFile), mPath);
194 var toModule = path.relative('./', absToModule).split(path.sep); //relative to the execution context
195 var exposedObj = require(absToModule);
196
197 var url = '/' + toModule.join('/') + '.js';
198
199 return this.expose(mPath, exposedObj, url);
200 },
201 /**
202 *
203 * @param {Socket} socket
204 * @param {String} name
205 * @returns {Promise} which will get resolved when client channel is ready
206 */
207 loadClientChannel: function (socket, name) {
208 var channel = getClientChannel(socket.id, name);
209
210 /**
211 * @type {Promise}
212 */
213 if (!channel.dfd) {
214 channel.dfd = Promise.defer();
215
216 socket.on('disconnect', function onDisconnect() {
217 var err = function () {
218 throw new Error('Client channel disconnected, this channel is not available anymore')
219 };
220 for (var method in channel.fns) {
221 channel.fns[method] = err; // references to client channel might be hold in client code, so we need to invalidate them
222 }
223 debug("disconnected clc " + name);
224 });
225 }
226 return channel.dfd.promise;
227 }
228 };
229
230 /**
231 * @param {String} rel path
232 * @returns {String}
233 */
234 var absPath = function(rel) {
235 return path.join(__dirname, rel);
236 };
237
238 var fileMap = {
239 '/rpc/client.js': 'node_modules/socket.io-rpc-client/client.js', //raw client, do not use this unless you know what you are doing
240 '/rpc/rpc-client.js': 'node_modules/socket.io-rpc-client/socket.io-rpc-client.js', //normal browser client
241 '/rpc/export-channel.js': 'node_modules/socket.io-rpc-client/export-channel.js', //angular client
242 '/rpc/rpc-client-angular.js': 'node_modules/socket.io-rpc-client/socket.io-rpc-client-angular.js' //angular client
243 };
244
245 Object.keys(fileMap).forEach(function (serverPath){
246 fileMap[serverPath] = absPath(fileMap[serverPath]);
247 expApp.get(serverPath, function(req, res) {
248 res.sendFile(fileMap[serverPath]);
249 });
250 });
251
252 io = ioP;
253
254 io.sockets.on('connection', function (socket) {
255 clientKnownChannels[socket.id] = [];
256
257 var timeoutId;
258
259 socket.on('disconnect', function() {
260 timeoutId = setTimeout(function () {
261 debug("cleaning client channels of " + socket.id);
262 delete clientChannels[socket.id]; //cleaning up in disconnect
263 delete clientKnownChannels[socket.id]; //cleaning up in disconnect
264 }, 300000); // after five minutes, get rid of client channels
265 });
266
267 socket.on('reconnect', function () {
268 if (timeoutId) {
269 clearTimeout(timeoutId);
270 }
271 var thisClientChnls = clientChannels[socket.id];
272 if (!thisClientChnls) {
273 //TODO ask client to reexpose channels
274 socket.emit('reexposeChannels');
275 } else {
276 var index = thisClientChnls.length;
277 while(index--) {
278 socket.emit('clientChannelCreated', thisClientChnls[index].name);
279 }
280 }
281 })
282 });
283
284 var authProcess = function (data, callback, socket) {
285 if (serverChannels.hasOwnProperty(data.name)) {
286 var authFn = serverChannels[data.name].authFn;
287 if (typeof authFn === 'function') { // check whether this is private channel
288 serverChannels[data.name].authFn.call(socket, data.handshake, callback);
289 } else {
290 callback(true);
291 }
292 } else {
293 socket.emit('channelDoesNotExist', {name: data.name});
294 }
295 };
296
297 rpcInstance.masterChannel = io.of('/rpc-master')
298 .on('connection', function (socket) {
299
300 socket.on('load channel', function (data) {
301 var callback = function (authorized) {
302 if (authorized) {
303 var channel = serverChannels[data.name];
304
305 if (data.cachedDate && data.cachedDate > runDate) {
306 socket.emit('channelFns', {name: data.name});
307 } else {
308 var channelFnPayload;
309
310 if (clientKnownChannels[socket.id].indexOf(channel.tplId) !== -1) {
311 channelFnPayload = {name: data.name, tplId: channel.tplId}; //channel template is known to the client
312 } else {
313 channelFnPayload = {name: data.name, fnNames: channel.fnNames, tplId: channel.tplId};
314 clientKnownChannels[socket.id].push(channel.tplId);
315 }
316
317 socket.emit('channelFns', channelFnPayload);
318 }
319 } else {
320 socket.emit('AuthorizationFailed', data.name);
321 }
322 };
323 authProcess(data, callback, socket);
324 });
325
326 socket.on('load channelList', function () {
327 socket.emit('channels', { list: getChannelNames() });
328 });
329
330 socket.on('exposeChannel', function (data) { // client wants to expose a channel
331 var clId = socket.id;
332 debug("client with id " + clId +" exposed rpc channel " + data.name);
333 var channel = getClientChannel(clId, data.name);
334 channel.dfd = channel.dfd || Promise.defer();
335
336 channel.fns = channel.fns || {};
337 channel.socket = io.of('/rpcC-'+data.name + '/' + socket.id); //rpcC stands for rpc Client
338 data.fns.forEach(function (fnName) {
339 channel.fns[fnName] = function () {
340 invocationCounter++;
341 channel.socket.emit('call',
342 {Id: invocationCounter, fnName: fnName, args: Array.prototype.slice.call(arguments, 0)}
343 );
344 deferreds[invocationCounter] = Promise.defer();
345 return deferreds[invocationCounter].promise;
346 };
347 });
348 channel.socket.on('connection', function (socket) {
349
350 socket.on('resolve', function (data) {
351 deferreds[data.Id].resolve(data.value);
352 callToClientEnded(data.Id);
353 });
354 socket.on('reject', function (data) {
355 deferreds[data.Id].reject(data.reason);
356 callToClientEnded(data.Id);
357 });
358
359 socket.on('reconnect', function () {
360 //not sure about the deferred in this case-it should be there ready for being resolved/rejected,
361 // but who will reset it?
362 debug("reconnected to client chnl " + data.name);
363 channel.dfd.resolve(channel.fns);
364
365 });
366
367 debug("client connected to its own rpc channel " + data.name);
368 channel.dfd.resolve(channel.fns);
369
370 });
371
372 socket.emit('clientChannelCreated', data.name);
373
374 });
375
376 socket.emit('serverRunDate', runDate);
377 }
378 );
379
380 return rpcInstance;
381}
382
383createServer.client = require('socket.io-rpc-client');
384
385module.exports = createServer;