58 | ```
59 |
--------------------------------------------------------------------------------
/examples/sinatra/app.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "sinatra"
4 | require "sinatra/cookies"
5 |
6 | CABLE_URL = ENV.fetch("CABLE_URL", "/cable")
7 |
8 | class App < Sinatra::Application # :nodoc:
9 | set :public_folder, "assets"
10 |
11 | enable :sessions
12 | set :session_secret, "secret_key_with_size_of_32_bytes_dff054b19c2de43fc406f251376ad40"
13 |
14 | get "/" do
15 | if session[:user]
16 | slim :index
17 | else
18 | slim :login
19 | end
20 | end
21 |
22 | get "/sign_in" do
23 | slim :login
24 | end
25 |
26 | post "/sign_in" do
27 | if params["user"]
28 | session[:user] = params["user"]
29 | cookies["user"] = params["user"]
30 | redirect "/"
31 | else
32 | slim :login
33 | end
34 | end
35 |
36 | post "/rooms" do
37 | if params["id"]
38 | redirect "/rooms/#{params["id"]}"
39 | else
40 | slim :index
41 | end
42 | end
43 |
44 | get "/rooms/:id" do
45 | if session[:user]
46 | @room_id = params["id"]
47 | @user = session[:user]
48 | slim :room
49 | else
50 | slim :login
51 | end
52 | end
53 | end
54 |
--------------------------------------------------------------------------------
/examples/sinatra/assets/app.css:
--------------------------------------------------------------------------------
1 | html, body {
2 | height: 100%;
3 | width: 100%;
4 | min-width: 300px;
5 | }
6 |
7 | body {
8 | background: #fff;
9 | color: #363636;
10 | font: 18px/30px "Arial", sans-serif;
11 | }
12 |
13 | p, div, span, a, ul, li {
14 | box-sizing: border-box;
15 | }
16 |
17 | a {
18 | text-decoration: none;
19 | color: #ff5e5e;
20 |
21 | &:visited,
22 | &:active {
23 | color: #ff5e5e;
24 | }
25 |
26 | &:hover {
27 | opacity: 0.8;
28 | }
29 | }
30 |
31 | h1 {
32 | font-size: 70px;
33 | line-height: 90px;
34 | letter-spacing: 2px;
35 | font-weight: bold;
36 | }
37 |
38 | h2 {
39 | font-size: 40px;
40 | line-height: 50px;
41 | letter-spacing: 1.5px;
42 | margin: 20px 0;
43 | }
44 |
45 | .main {
46 | height: 100%;
47 | width: 100%;
48 | padding: 0 20%;
49 | }
50 |
51 | .header {
52 | width: 100%;
53 | display: flex;
54 | justify-content: center;
55 | }
56 |
57 | input[type="text"] {
58 | width: 100%;
59 | height: 40px;
60 | line-height: 40px;
61 | display: block;
62 | margin: 0;
63 | font-size: 18px;
64 | appearance: none;
65 | box-shadow: none;
66 | border-radius: none;
67 | }
68 |
69 | button:focus, input[type="text"]:focus {
70 | outline: none;
71 | }
72 |
73 | .btn {
74 | cursor: pointer;
75 | height: 40px;
76 | text-decoration: none;
77 | padding: 0 20px;
78 | text-align: center;
79 | background: #ff5e5e;
80 | transition: opacity 200ms;
81 | color: white;
82 | font-weight: bold;
83 | font-size: 16px;
84 | letter-spacing: 1.5px;
85 | display: flex;
86 | align-items: center;
87 | justify-content: center;
88 | width: 100px;
89 | margin-top: 30px;
90 | }
91 |
92 | .btn:hover {
93 | opacity: 0.8;
94 | }
95 |
96 | .message-form {
97 | position: fixed;
98 | bottom: 0;
99 | left: 0;
100 | width: 100%;
101 | border-top: 1px #e3e3e3 solid;
102 | z-index: 10;
103 | padding: 20px 20% 20px 20%;
104 | background: white;
105 | opacity: 0.9;
106 | }
107 |
108 | .messages {
109 | display: flex;
110 | flex-direction: column;
111 | padding-bottom: 160px;
112 | }
113 |
114 | .message {
115 | display: flex;
116 | flex-direction: column;
117 | }
118 |
119 | .message.me {
120 | align-self: flex-end;
121 | }
122 |
123 | .messages .message .author {
124 | color: #ff5e5e;
125 | }
126 |
127 | .messages .message.me .author {
128 | align-self: flex-end;
129 | color: #7ed321;
130 | }
131 |
132 | .messages .message.system .author {
133 | color: #9e9e9e;
134 | }
135 |
136 | @media (max-width: 800px) and (min-width: 601px) {
137 | body {
138 | font-size: 3vw;
139 | line-height: 5vw;
140 | }
141 |
142 | h1 {
143 | font-size: 14vw;
144 | line-height: 18vw;
145 | }
146 |
147 | h2 {
148 | font-size: 5vw;
149 | line-height: 7vw;
150 | }
151 | }
152 |
153 |
154 | @media (max-width: 600px) {
155 | body {
156 | font-size: 4vw;
157 | line-height: 6vw;
158 | }
159 |
160 | h1 {
161 | font-size: 14vw;
162 | line-height: 18vw;
163 | }
164 |
165 | h2 {
166 | font-size: 10vw;
167 | line-height: 12vw;
168 | }
169 | }
--------------------------------------------------------------------------------
/examples/sinatra/assets/cable.js:
--------------------------------------------------------------------------------
1 | (function() {
2 | var slice = [].slice;
3 |
4 | this.ActionCable = {
5 | INTERNAL: {
6 | "message_types": {
7 | "welcome": "welcome",
8 | "ping": "ping",
9 | "confirmation": "confirm_subscription",
10 | "rejection": "reject_subscription"
11 | },
12 | "default_mount_path": "/cable",
13 | "protocols": ["actioncable-v1-json", "actioncable-unsupported"]
14 | },
15 | WebSocket: window.WebSocket,
16 | logger: window.console,
17 | createConsumer: function(url) {
18 | var ref;
19 | if (url == null) {
20 | url = (ref = this.getConfig("url")) != null ? ref : this.INTERNAL.default_mount_path;
21 | }
22 | return new ActionCable.Consumer(this.createWebSocketURL(url));
23 | },
24 | getConfig: function(name) {
25 | var element;
26 | element = document.head.querySelector("meta[name='action-cable-" + name + "']");
27 | return element != null ? element.getAttribute("content") : void 0;
28 | },
29 | createWebSocketURL: function(url) {
30 | var a;
31 | if (url && !/^wss?:/i.test(url)) {
32 | a = document.createElement("a");
33 | a.href = url;
34 | a.href = a.href;
35 | a.protocol = a.protocol.replace("http", "ws");
36 | return a.href;
37 | } else {
38 | return url;
39 | }
40 | },
41 | startDebugging: function() {
42 | return this.debugging = true;
43 | },
44 | stopDebugging: function() {
45 | return this.debugging = null;
46 | },
47 | log: function() {
48 | var messages, ref;
49 | messages = 1 <= arguments.length ? slice.call(arguments, 0) : [];
50 | if (this.debugging) {
51 | messages.push(Date.now());
52 | return (ref = this.logger).log.apply(ref, ["[ActionCable]"].concat(slice.call(messages)));
53 | }
54 | }
55 | };
56 |
57 | }).call(this);
58 | (function() {
59 | var bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; };
60 |
61 | ActionCable.ConnectionMonitor = (function() {
62 | var clamp, now, secondsSince;
63 |
64 | ConnectionMonitor.pollInterval = {
65 | min: 3,
66 | max: 30
67 | };
68 |
69 | ConnectionMonitor.staleThreshold = 6;
70 |
71 | function ConnectionMonitor(connection) {
72 | this.connection = connection;
73 | this.visibilityDidChange = bind(this.visibilityDidChange, this);
74 | this.reconnectAttempts = 0;
75 | }
76 |
77 | ConnectionMonitor.prototype.start = function() {
78 | if (!this.isRunning()) {
79 | this.startedAt = now();
80 | delete this.stoppedAt;
81 | this.startPolling();
82 | document.addEventListener("visibilitychange", this.visibilityDidChange);
83 | return ActionCable.log("ConnectionMonitor started. pollInterval = " + (this.getPollInterval()) + " ms");
84 | }
85 | };
86 |
87 | ConnectionMonitor.prototype.stop = function() {
88 | if (this.isRunning()) {
89 | this.stoppedAt = now();
90 | this.stopPolling();
91 | document.removeEventListener("visibilitychange", this.visibilityDidChange);
92 | return ActionCable.log("ConnectionMonitor stopped");
93 | }
94 | };
95 |
96 | ConnectionMonitor.prototype.isRunning = function() {
97 | return (this.startedAt != null) && (this.stoppedAt == null);
98 | };
99 |
100 | ConnectionMonitor.prototype.recordPing = function() {
101 | return this.pingedAt = now();
102 | };
103 |
104 | ConnectionMonitor.prototype.recordConnect = function() {
105 | this.reconnectAttempts = 0;
106 | this.recordPing();
107 | delete this.disconnectedAt;
108 | return ActionCable.log("ConnectionMonitor recorded connect");
109 | };
110 |
111 | ConnectionMonitor.prototype.recordDisconnect = function() {
112 | this.disconnectedAt = now();
113 | return ActionCable.log("ConnectionMonitor recorded disconnect");
114 | };
115 |
116 | ConnectionMonitor.prototype.startPolling = function() {
117 | this.stopPolling();
118 | return this.poll();
119 | };
120 |
121 | ConnectionMonitor.prototype.stopPolling = function() {
122 | return clearTimeout(this.pollTimeout);
123 | };
124 |
125 | ConnectionMonitor.prototype.poll = function() {
126 | return this.pollTimeout = setTimeout((function(_this) {
127 | return function() {
128 | _this.reconnectIfStale();
129 | return _this.poll();
130 | };
131 | })(this), this.getPollInterval());
132 | };
133 |
134 | ConnectionMonitor.prototype.getPollInterval = function() {
135 | var interval, max, min, ref;
136 | ref = this.constructor.pollInterval, min = ref.min, max = ref.max;
137 | interval = 5 * Math.log(this.reconnectAttempts + 1);
138 | return Math.round(clamp(interval, min, max) * 1000);
139 | };
140 |
141 | ConnectionMonitor.prototype.reconnectIfStale = function() {
142 | if (this.connectionIsStale()) {
143 | ActionCable.log("ConnectionMonitor detected stale connection. reconnectAttempts = " + this.reconnectAttempts + ", pollInterval = " + (this.getPollInterval()) + " ms, time disconnected = " + (secondsSince(this.disconnectedAt)) + " s, stale threshold = " + this.constructor.staleThreshold + " s");
144 | this.reconnectAttempts++;
145 | if (this.disconnectedRecently()) {
146 | return ActionCable.log("ConnectionMonitor skipping reopening recent disconnect");
147 | } else {
148 | ActionCable.log("ConnectionMonitor reopening");
149 | return this.connection.reopen();
150 | }
151 | }
152 | };
153 |
154 | ConnectionMonitor.prototype.connectionIsStale = function() {
155 | var ref;
156 | return secondsSince((ref = this.pingedAt) != null ? ref : this.startedAt) > this.constructor.staleThreshold;
157 | };
158 |
159 | ConnectionMonitor.prototype.disconnectedRecently = function() {
160 | return this.disconnectedAt && secondsSince(this.disconnectedAt) < this.constructor.staleThreshold;
161 | };
162 |
163 | ConnectionMonitor.prototype.visibilityDidChange = function() {
164 | if (document.visibilityState === "visible") {
165 | return setTimeout((function(_this) {
166 | return function() {
167 | if (_this.connectionIsStale() || !_this.connection.isOpen()) {
168 | ActionCable.log("ConnectionMonitor reopening stale connection on visibilitychange. visbilityState = " + document.visibilityState);
169 | return _this.connection.reopen();
170 | }
171 | };
172 | })(this), 200);
173 | }
174 | };
175 |
176 | now = function() {
177 | return new Date().getTime();
178 | };
179 |
180 | secondsSince = function(time) {
181 | return (now() - time) / 1000;
182 | };
183 |
184 | clamp = function(number, min, max) {
185 | return Math.max(min, Math.min(max, number));
186 | };
187 |
188 | return ConnectionMonitor;
189 |
190 | })();
191 |
192 | }).call(this);
193 | (function() {
194 | var i, message_types, protocols, ref, supportedProtocols, unsupportedProtocol,
195 | slice = [].slice,
196 | bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; },
197 | indexOf = [].indexOf || function(item) { for (var i = 0, l = this.length; i < l; i++) { if (i in this && this[i] === item) return i; } return -1; };
198 |
199 | ref = ActionCable.INTERNAL, message_types = ref.message_types, protocols = ref.protocols;
200 |
201 | supportedProtocols = 2 <= protocols.length ? slice.call(protocols, 0, i = protocols.length - 1) : (i = 0, []), unsupportedProtocol = protocols[i++];
202 |
203 | ActionCable.Connection = (function() {
204 | Connection.reopenDelay = 500;
205 |
206 | function Connection(consumer) {
207 | this.consumer = consumer;
208 | this.open = bind(this.open, this);
209 | this.subscriptions = this.consumer.subscriptions;
210 | this.monitor = new ActionCable.ConnectionMonitor(this);
211 | this.disconnected = true;
212 | }
213 |
214 | Connection.prototype.send = function(data) {
215 | if (this.isOpen()) {
216 | this.webSocket.send(JSON.stringify(data));
217 | return true;
218 | } else {
219 | return false;
220 | }
221 | };
222 |
223 | Connection.prototype.open = function() {
224 | if (this.isActive()) {
225 | ActionCable.log("Attempted to open WebSocket, but existing socket is " + (this.getState()));
226 | return false;
227 | } else {
228 | ActionCable.log("Opening WebSocket, current state is " + (this.getState()) + ", subprotocols: " + protocols);
229 | if (this.webSocket != null) {
230 | this.uninstallEventHandlers();
231 | }
232 | this.webSocket = new ActionCable.WebSocket(this.consumer.url, protocols);
233 | this.installEventHandlers();
234 | this.monitor.start();
235 | return true;
236 | }
237 | };
238 |
239 | Connection.prototype.close = function(arg) {
240 | var allowReconnect, ref1;
241 | allowReconnect = (arg != null ? arg : {
242 | allowReconnect: true
243 | }).allowReconnect;
244 | if (!allowReconnect) {
245 | this.monitor.stop();
246 | }
247 | if (this.isActive()) {
248 | return (ref1 = this.webSocket) != null ? ref1.close() : void 0;
249 | }
250 | };
251 |
252 | Connection.prototype.reopen = function() {
253 | var error;
254 | ActionCable.log("Reopening WebSocket, current state is " + (this.getState()));
255 | if (this.isActive()) {
256 | try {
257 | return this.close();
258 | } catch (error1) {
259 | error = error1;
260 | return ActionCable.log("Failed to reopen WebSocket", error);
261 | } finally {
262 | ActionCable.log("Reopening WebSocket in " + this.constructor.reopenDelay + "ms");
263 | setTimeout(this.open, this.constructor.reopenDelay);
264 | }
265 | } else {
266 | return this.open();
267 | }
268 | };
269 |
270 | Connection.prototype.getProtocol = function() {
271 | var ref1;
272 | return (ref1 = this.webSocket) != null ? ref1.protocol : void 0;
273 | };
274 |
275 | Connection.prototype.isOpen = function() {
276 | return this.isState("open");
277 | };
278 |
279 | Connection.prototype.isActive = function() {
280 | return this.isState("open", "connecting");
281 | };
282 |
283 | Connection.prototype.isProtocolSupported = function() {
284 | var ref1;
285 | return ref1 = this.getProtocol(), indexOf.call(supportedProtocols, ref1) >= 0;
286 | };
287 |
288 | Connection.prototype.isState = function() {
289 | var ref1, states;
290 | states = 1 <= arguments.length ? slice.call(arguments, 0) : [];
291 | return ref1 = this.getState(), indexOf.call(states, ref1) >= 0;
292 | };
293 |
294 | Connection.prototype.getState = function() {
295 | var ref1, state, value;
296 | for (state in WebSocket) {
297 | value = WebSocket[state];
298 | if (value === ((ref1 = this.webSocket) != null ? ref1.readyState : void 0)) {
299 | return state.toLowerCase();
300 | }
301 | }
302 | return null;
303 | };
304 |
305 | Connection.prototype.installEventHandlers = function() {
306 | var eventName, handler;
307 | for (eventName in this.events) {
308 | handler = this.events[eventName].bind(this);
309 | this.webSocket["on" + eventName] = handler;
310 | }
311 | };
312 |
313 | Connection.prototype.uninstallEventHandlers = function() {
314 | var eventName;
315 | for (eventName in this.events) {
316 | this.webSocket["on" + eventName] = function() {};
317 | }
318 | };
319 |
320 | Connection.prototype.events = {
321 | message: function(event) {
322 | var identifier, message, ref1, type;
323 | if (!this.isProtocolSupported()) {
324 | return;
325 | }
326 | ref1 = JSON.parse(event.data), identifier = ref1.identifier, message = ref1.message, type = ref1.type;
327 | switch (type) {
328 | case message_types.welcome:
329 | this.monitor.recordConnect();
330 | return this.subscriptions.reload();
331 | case message_types.ping:
332 | return this.monitor.recordPing();
333 | case message_types.confirmation:
334 | return this.subscriptions.notify(identifier, "connected");
335 | case message_types.rejection:
336 | return this.subscriptions.reject(identifier);
337 | default:
338 | return this.subscriptions.notify(identifier, "received", message);
339 | }
340 | },
341 | open: function() {
342 | ActionCable.log("WebSocket onopen event, using '" + (this.getProtocol()) + "' subprotocol");
343 | this.disconnected = false;
344 | if (!this.isProtocolSupported()) {
345 | ActionCable.log("Protocol is unsupported. Stopping monitor and disconnecting.");
346 | return this.close({
347 | allowReconnect: false
348 | });
349 | }
350 | },
351 | close: function(event) {
352 | ActionCable.log("WebSocket onclose event");
353 | if (this.disconnected) {
354 | return;
355 | }
356 | this.disconnected = true;
357 | this.monitor.recordDisconnect();
358 | return this.subscriptions.notifyAll("disconnected", {
359 | willAttemptReconnect: this.monitor.isRunning()
360 | });
361 | },
362 | error: function() {
363 | return ActionCable.log("WebSocket onerror event");
364 | }
365 | };
366 |
367 | return Connection;
368 |
369 | })();
370 |
371 | }).call(this);
372 | (function() {
373 | var slice = [].slice;
374 |
375 | ActionCable.Subscriptions = (function() {
376 | function Subscriptions(consumer) {
377 | this.consumer = consumer;
378 | this.subscriptions = [];
379 | }
380 |
381 | Subscriptions.prototype.create = function(channelName, mixin) {
382 | var channel, params, subscription;
383 | channel = channelName;
384 | params = typeof channel === "object" ? channel : {
385 | channel: channel
386 | };
387 | subscription = new ActionCable.Subscription(this.consumer, params, mixin);
388 | return this.add(subscription);
389 | };
390 |
391 | Subscriptions.prototype.add = function(subscription) {
392 | this.subscriptions.push(subscription);
393 | this.consumer.ensureActiveConnection();
394 | this.notify(subscription, "initialized");
395 | this.sendCommand(subscription, "subscribe");
396 | return subscription;
397 | };
398 |
399 | Subscriptions.prototype.remove = function(subscription) {
400 | this.forget(subscription);
401 | if (!this.findAll(subscription.identifier).length) {
402 | this.sendCommand(subscription, "unsubscribe");
403 | }
404 | return subscription;
405 | };
406 |
407 | Subscriptions.prototype.reject = function(identifier) {
408 | var i, len, ref, results, subscription;
409 | ref = this.findAll(identifier);
410 | results = [];
411 | for (i = 0, len = ref.length; i < len; i++) {
412 | subscription = ref[i];
413 | this.forget(subscription);
414 | this.notify(subscription, "rejected");
415 | results.push(subscription);
416 | }
417 | return results;
418 | };
419 |
420 | Subscriptions.prototype.forget = function(subscription) {
421 | var s;
422 | this.subscriptions = (function() {
423 | var i, len, ref, results;
424 | ref = this.subscriptions;
425 | results = [];
426 | for (i = 0, len = ref.length; i < len; i++) {
427 | s = ref[i];
428 | if (s !== subscription) {
429 | results.push(s);
430 | }
431 | }
432 | return results;
433 | }).call(this);
434 | return subscription;
435 | };
436 |
437 | Subscriptions.prototype.findAll = function(identifier) {
438 | var i, len, ref, results, s;
439 | ref = this.subscriptions;
440 | results = [];
441 | for (i = 0, len = ref.length; i < len; i++) {
442 | s = ref[i];
443 | if (s.identifier === identifier) {
444 | results.push(s);
445 | }
446 | }
447 | return results;
448 | };
449 |
450 | Subscriptions.prototype.reload = function() {
451 | var i, len, ref, results, subscription;
452 | ref = this.subscriptions;
453 | results = [];
454 | for (i = 0, len = ref.length; i < len; i++) {
455 | subscription = ref[i];
456 | results.push(this.sendCommand(subscription, "subscribe"));
457 | }
458 | return results;
459 | };
460 |
461 | Subscriptions.prototype.notifyAll = function() {
462 | var args, callbackName, i, len, ref, results, subscription;
463 | callbackName = arguments[0], args = 2 <= arguments.length ? slice.call(arguments, 1) : [];
464 | ref = this.subscriptions;
465 | results = [];
466 | for (i = 0, len = ref.length; i < len; i++) {
467 | subscription = ref[i];
468 | results.push(this.notify.apply(this, [subscription, callbackName].concat(slice.call(args))));
469 | }
470 | return results;
471 | };
472 |
473 | Subscriptions.prototype.notify = function() {
474 | var args, callbackName, i, len, results, subscription, subscriptions;
475 | subscription = arguments[0], callbackName = arguments[1], args = 3 <= arguments.length ? slice.call(arguments, 2) : [];
476 | if (typeof subscription === "string") {
477 | subscriptions = this.findAll(subscription);
478 | } else {
479 | subscriptions = [subscription];
480 | }
481 | results = [];
482 | for (i = 0, len = subscriptions.length; i < len; i++) {
483 | subscription = subscriptions[i];
484 | results.push(typeof subscription[callbackName] === "function" ? subscription[callbackName].apply(subscription, args) : void 0);
485 | }
486 | return results;
487 | };
488 |
489 | Subscriptions.prototype.sendCommand = function(subscription, command) {
490 | var identifier;
491 | identifier = subscription.identifier;
492 | return this.consumer.send({
493 | command: command,
494 | identifier: identifier
495 | });
496 | };
497 |
498 | return Subscriptions;
499 |
500 | })();
501 |
502 | }).call(this);
503 | (function() {
504 | ActionCable.Subscription = (function() {
505 | var extend;
506 |
507 | function Subscription(consumer, params, mixin) {
508 | this.consumer = consumer;
509 | if (params == null) {
510 | params = {};
511 | }
512 | this.identifier = JSON.stringify(params);
513 | extend(this, mixin);
514 | }
515 |
516 | Subscription.prototype.perform = function(action, data) {
517 | if (data == null) {
518 | data = {};
519 | }
520 | data.action = action;
521 | return this.send(data);
522 | };
523 |
524 | Subscription.prototype.send = function(data) {
525 | return this.consumer.send({
526 | command: "message",
527 | identifier: this.identifier,
528 | data: JSON.stringify(data)
529 | });
530 | };
531 |
532 | Subscription.prototype.unsubscribe = function() {
533 | return this.consumer.subscriptions.remove(this);
534 | };
535 |
536 | extend = function(object, properties) {
537 | var key, value;
538 | if (properties != null) {
539 | for (key in properties) {
540 | value = properties[key];
541 | object[key] = value;
542 | }
543 | }
544 | return object;
545 | };
546 |
547 | return Subscription;
548 |
549 | })();
550 |
551 | }).call(this);
552 | (function() {
553 | ActionCable.Consumer = (function() {
554 | function Consumer(url) {
555 | this.url = url;
556 | this.subscriptions = new ActionCable.Subscriptions(this);
557 | this.connection = new ActionCable.Connection(this);
558 | }
559 |
560 | Consumer.prototype.send = function(data) {
561 | return this.connection.send(data);
562 | };
563 |
564 | Consumer.prototype.connect = function() {
565 | return this.connection.open();
566 | };
567 |
568 | Consumer.prototype.disconnect = function() {
569 | return this.connection.close({
570 | allowReconnect: false
571 | });
572 | };
573 |
574 | Consumer.prototype.ensureActiveConnection = function() {
575 | if (!this.connection.isActive()) {
576 | return this.connection.open();
577 | }
578 | };
579 |
580 | return Consumer;
581 |
582 | })();
583 |
584 | }).call(this);
585 |
--------------------------------------------------------------------------------
/examples/sinatra/assets/reset.css:
--------------------------------------------------------------------------------
1 | /* Reset
2 | ----------------------------------------------------------------------------- */
3 |
4 | /* stylelint-disable */
5 |
6 | html, body,
7 | h1, h2, h3, h4, h5, h6, p, blockquote, pre,
8 | address, code, img,
9 | dl, dt, dd, ol, ul, li,
10 | fieldset, form, label,
11 | table, th, td,
12 | article, aside, nav, section, figure, figcaption, footer, header,
13 | audio, video {
14 | margin: 0;
15 | padding: 0;
16 | }
17 | blockquote, img, fieldset, form {
18 | border: 0;
19 | }
20 | a, strong, em, b, i, small, sub, sup, img, label, th, td, audio, video {
21 | vertical-align: baseline;
22 | }
23 | applet, object, iframe,
24 | abbr, acronym, big, cite,
25 | del, dfn, ins, kbd, q, s, samp,
26 | strike, tt, var, u, center, legend,
27 | caption, tbody, tfoot, thead, tr,
28 | canvas, details, embed,
29 | menu, output, ruby, summary,
30 | time, mark {
31 | margin: 0;
32 | padding: 0;
33 | border: 0;
34 | vertical-align: baseline;
35 | font: inherit;
36 | font-size: 100%;
37 | }
38 | ul, ol {
39 | list-style: none;
40 | }
41 |
42 | /* Border-box FTW
43 | https://css-tricks.com/inheriting-box-sizing-probably-slightly-better-best-practice/ */
44 | *,
45 | *:before,
46 | *:after {
47 | box-sizing: inherit;
48 | }
49 | html {
50 | overflow-y: scroll;
51 | box-sizing: border-box;
52 | text-size-adjust: 100%;
53 | }
54 | a {
55 | background-color: transparent; /* Remove the gray background color from active links in IE 10. */
56 | -webkit-text-decoration-skip: none;
57 |
58 | &:hover,
59 | &:active {
60 | outline: 0;
61 | }
62 | }
63 | img {
64 | vertical-align: middle;
65 | }
66 | strong, b {
67 | font-weight: bold;
68 | }
69 | em, i {
70 | font-style: italic;
71 | }
72 | h1, h2, h3, h4, h5, h6 {
73 | font-weight: bold;
74 | }
75 | table {
76 | border-spacing: 0;
77 | border-collapse: collapse;
78 | }
79 | th {
80 | font-weight: bold;
81 | }
82 | td {
83 | vertical-align: top;
84 | }
85 | input,
86 | select,
87 | textarea,
88 | button {
89 | margin: 0;
90 | vertical-align: middle;
91 | font-size: 100%;
92 | font-family: inherit;
93 | }
94 |
95 | /**
96 | * 1. Add the correct box sizing in IE 10-.
97 | * 2. Remove the padding in IE 10-.
98 | */
99 | [type="checkbox"],
100 | [type="radio"] {
101 | box-sizing: border-box; /* 1 */
102 | padding: 0; /* 2 */
103 | }
104 |
105 | /**
106 | * Show the overflow in IE.
107 | * 1. Show the overflow in Edge.
108 | * 2. Show the overflow in Edge, Firefox, and IE.
109 | */
110 | button,
111 | input, /* 1 */
112 | select { /* 2 */
113 | overflow: visible;
114 | }
115 |
116 | /**
117 | * Address inconsistent `text-transform` inheritance for `button` and `select`.
118 | * All other form control elements do not inherit `text-transform` values.
119 | * Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera.
120 | * Correct `select` style inheritance in Firefox.
121 | */
122 | button,
123 | select {
124 | text-transform: none;
125 | }
126 |
127 | /**
128 | * 1. Prevent a WebKit bug where (2) destroys native `audio` and `video`
129 | * controls in Android 4.
130 | * 2. Correct inability to style clickable `input` types in iOS.
131 | * 3. Improve usability and consistency of cursor style between image-type
132 | * `input` and others.
133 | */
134 | button,
135 | html [type="button"], /* 1 */
136 | [type="reset"],
137 | [type="submit"] {
138 | cursor: pointer; /* 3 */
139 | -webkit-appearance: button; /* 2 */
140 | }
141 |
142 | /**
143 | * Remove the inner border and padding in Firefox.
144 | */
145 | button::-moz-focus-inner,
146 | input::-moz-focus-inner {
147 | padding: 0;
148 | border: 0;
149 | }
150 | /**
151 | * 1. Remove the default vertical scrollbar in IE.
152 | */
153 | textarea {
154 | overflow: auto; /* 1 */
155 | resize: vertical;
156 | }
157 | svg:not(:root) {
158 | overflow: hidden; /* Correct overflow not hidden in IE. */
159 | }
160 |
161 | /**
162 | * Correct the odd appearance of search inputs in Chrome and Safari.
163 | */
164 | [type="search"] {
165 | -webkit-appearance: textfield;
166 | }
167 |
168 | /**
169 | * Remove the inner padding and cancel buttons in Chrome on OS X and
170 | * Safari on OS X.
171 | */
172 | [type="search"]::-webkit-search-cancel-button,
173 | [type="search"]::-webkit-search-decoration {
174 | -webkit-appearance: none;
175 | }
176 | /* stylelint-enable */
177 |
178 | [role="button"],
179 | input[type="submit"],
180 | input[type="reset"],
181 | input[type="button"],
182 | button {
183 | -webkit-box-sizing: content-box;
184 | -moz-box-sizing: content-box;
185 | box-sizing: content-box;
186 | }
187 |
188 | /* Reset `button` and button-style `input` default styles */
189 | input[type="submit"],
190 | input[type="reset"],
191 | input[type="button"],
192 | button {
193 | background: none;
194 | border: 0;
195 | color: inherit;
196 | /* cursor: default; */
197 | font: inherit;
198 | line-height: normal;
199 | overflow: visible;
200 | padding: 0;
201 | -webkit-appearance: button; /* for input */
202 | -webkit-user-select: none; /* for button */
203 | -moz-user-select: none;
204 | -ms-user-select: none;
205 | }
206 | input::-moz-focus-inner,
207 | button::-moz-focus-inner {
208 | border: 0;
209 | padding: 0;
210 | }
211 |
212 | /* Make `a` like a button */
213 | [role="button"] {
214 | color: inherit;
215 | cursor: default;
216 | display: inline-block;
217 | text-align: center;
218 | text-decoration: none;
219 | white-space: pre;
220 | -webkit-user-select: none;
221 | -moz-user-select: none;
222 | -ms-user-select: none;
223 | }
224 |
--------------------------------------------------------------------------------
/examples/sinatra/chat.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "litecable"
4 | require "anycable"
5 |
6 | # Sample chat application
7 | module Chat
8 | class Connection < LiteCable::Connection::Base # :nodoc:
9 | identified_by :user, :sid
10 |
11 | def connect
12 | self.user = cookies["user"]
13 | self.sid = request.params["sid"]
14 | reject_unauthorized_connection unless user
15 | $stdout.puts "#{user} connected"
16 | end
17 |
18 | def disconnect
19 | $stdout.puts "#{user} disconnected"
20 | end
21 | end
22 |
23 | class Channel < LiteCable::Channel::Base # :nodoc:
24 | identifier :chat
25 |
26 | def subscribed
27 | reject unless chat_id
28 | stream_from "chat_#{chat_id}"
29 | end
30 |
31 | def speak(data)
32 | LiteCable.broadcast "chat_#{chat_id}", {user: user, message: data["message"], sid: sid}
33 | end
34 |
35 | private
36 |
37 | def chat_id
38 | params.fetch("id")
39 | end
40 | end
41 | end
42 |
--------------------------------------------------------------------------------
/examples/sinatra/config.ru:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative "config/environment"
4 |
5 | app = Rack::Builder.new do
6 | map "/" do
7 | run App
8 | end
9 | end
10 |
11 | unless ENV["ANYCABLE"]
12 | # Start built-in rack hijack middleware to serve websockets
13 | require "lite_cable/server"
14 |
15 | app.map "/cable" do
16 | use LiteCable::Server::Middleware, connection_class: Chat::Connection
17 | run(proc { |_| [200, {"Content-Type" => "text/plain"}, ["OK"]] })
18 | end
19 | end
20 |
21 | run app
22 |
--------------------------------------------------------------------------------
/examples/sinatra/config/anycable.yml:
--------------------------------------------------------------------------------
1 | broadcast_adapter: http
2 |
--------------------------------------------------------------------------------
/examples/sinatra/config/environment.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | lib = File.expand_path("../../../lib", __dir__)
4 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
5 |
6 | require_relative "../app"
7 | require_relative "../chat"
8 |
9 | LiteCable.config.log_level = Logger::DEBUG
10 |
11 | AnyCable.connection_factory = Chat::Connection
12 |
--------------------------------------------------------------------------------
/examples/sinatra/views/index.slim:
--------------------------------------------------------------------------------
1 | h2 Room Id
2 |
3 | form action="/rooms" method="POST"
4 | .row
5 | input type="text" required="required" name="id"
6 | .row
7 | button.btn type="submit"
8 | span Go!
9 |
--------------------------------------------------------------------------------
/examples/sinatra/views/layout.slim:
--------------------------------------------------------------------------------
1 | doctype html
2 | html
3 | head
4 | title LiteCable Sinatra Demo
5 | meta name="viewport" content="width=device-width"
6 | meta charset="UTF-8"
7 | link rel="stylesheet" href="/reset.css"
8 | link rel="stylesheet" href="/app.css"
9 | script type="text/javascript" src="/cable.js"
10 | body
11 | .header
12 | h1.title
13 | a href='/' LiteCable
14 | .container.main
15 | == yield
16 |
--------------------------------------------------------------------------------
/examples/sinatra/views/login.slim:
--------------------------------------------------------------------------------
1 | h2 Your Name
2 |
3 | form action="/sign_in" method="POST"
4 | .row
5 | input type="text" required="required" name="user"
6 | .row
7 | button.btn type="submit"
8 | span Go!
9 |
--------------------------------------------------------------------------------
/examples/sinatra/views/resetcss.slim:
--------------------------------------------------------------------------------
1 | css:
2 | /* Reset
3 | ----------------------------------------------------------------------------- */
4 |
5 | /* stylelint-disable */
6 |
7 | html, body,
8 | h1, h2, h3, h4, h5, h6, p, blockquote, pre,
9 | address, code, img,
10 | dl, dt, dd, ol, ul, li,
11 | fieldset, form, label,
12 | table, th, td,
13 | article, aside, nav, section, figure, figcaption, footer, header,
14 | audio, video {
15 | margin: 0;
16 | padding: 0;
17 | }
18 | blockquote, img, fieldset, form {
19 | border: 0;
20 | }
21 | a, strong, em, b, i, small, sub, sup, img, label, th, td, audio, video {
22 | vertical-align: baseline;
23 | }
24 | applet, object, iframe,
25 | abbr, acronym, big, cite,
26 | del, dfn, ins, kbd, q, s, samp,
27 | strike, tt, var, u, center, legend,
28 | caption, tbody, tfoot, thead, tr,
29 | canvas, details, embed,
30 | menu, output, ruby, summary,
31 | time, mark {
32 | margin: 0;
33 | padding: 0;
34 | border: 0;
35 | vertical-align: baseline;
36 | font: inherit;
37 | font-size: 100%;
38 | }
39 | ul, ol {
40 | list-style: none;
41 | }
42 |
43 | /* Border-box FTW
44 | https://css-tricks.com/inheriting-box-sizing-probably-slightly-better-best-practice/ */
45 | *,
46 | *:before,
47 | *:after {
48 | box-sizing: inherit;
49 | }
50 | html {
51 | overflow-y: scroll;
52 | box-sizing: border-box;
53 | text-size-adjust: 100%;
54 | }
55 | a {
56 | background-color: transparent; /* Remove the gray background color from active links in IE 10. */
57 | -webkit-text-decoration-skip: none;
58 |
59 | &:hover,
60 | &:active {
61 | outline: 0;
62 | }
63 | }
64 | img {
65 | vertical-align: middle;
66 | }
67 | strong, b {
68 | font-weight: bold;
69 | }
70 | em, i {
71 | font-style: italic;
72 | }
73 | h1, h2, h3, h4, h5, h6 {
74 | font-weight: bold;
75 | }
76 | table {
77 | border-spacing: 0;
78 | border-collapse: collapse;
79 | }
80 | th {
81 | font-weight: bold;
82 | }
83 | td {
84 | vertical-align: top;
85 | }
86 | input,
87 | select,
88 | textarea,
89 | button {
90 | margin: 0;
91 | vertical-align: middle;
92 | font-size: 100%;
93 | font-family: inherit;
94 | }
95 |
96 | /**
97 | * 1. Add the correct box sizing in IE 10-.
98 | * 2. Remove the padding in IE 10-.
99 | */
100 | [type="checkbox"],
101 | [type="radio"] {
102 | box-sizing: border-box; /* 1 */
103 | padding: 0; /* 2 */
104 | }
105 |
106 | /**
107 | * Show the overflow in IE.
108 | * 1. Show the overflow in Edge.
109 | * 2. Show the overflow in Edge, Firefox, and IE.
110 | */
111 | button,
112 | input, /* 1 */
113 | select { /* 2 */
114 | overflow: visible;
115 | }
116 |
117 | /**
118 | * Address inconsistent `text-transform` inheritance for `button` and `select`.
119 | * All other form control elements do not inherit `text-transform` values.
120 | * Correct `button` style inheritance in Firefox, IE 8/9/10/11, and Opera.
121 | * Correct `select` style inheritance in Firefox.
122 | */
123 | button,
124 | select {
125 | text-transform: none;
126 | }
127 |
128 | /**
129 | * 1. Prevent a WebKit bug where (2) destroys native `audio` and `video`
130 | * controls in Android 4.
131 | * 2. Correct inability to style clickable `input` types in iOS.
132 | * 3. Improve usability and consistency of cursor style between image-type
133 | * `input` and others.
134 | */
135 | button,
136 | html [type="button"], /* 1 */
137 | [type="reset"],
138 | [type="submit"] {
139 | cursor: pointer; /* 3 */
140 | -webkit-appearance: button; /* 2 */
141 | }
142 |
143 | /**
144 | * Remove the inner border and padding in Firefox.
145 | */
146 | button::-moz-focus-inner,
147 | input::-moz-focus-inner {
148 | padding: 0;
149 | border: 0;
150 | }
151 | /**
152 | * 1. Remove the default vertical scrollbar in IE.
153 | */
154 | textarea {
155 | overflow: auto; /* 1 */
156 | resize: vertical;
157 | }
158 | svg:not(:root) {
159 | overflow: hidden; /* Correct overflow not hidden in IE. */
160 | }
161 |
162 | /**
163 | * Correct the odd appearance of search inputs in Chrome and Safari.
164 | */
165 | [type="search"] {
166 | -webkit-appearance: textfield;
167 | }
168 |
169 | /**
170 | * Remove the inner padding and cancel buttons in Chrome on OS X and
171 | * Safari on OS X.
172 | */
173 | [type="search"]::-webkit-search-cancel-button,
174 | [type="search"]::-webkit-search-decoration {
175 | -webkit-appearance: none;
176 | }
177 | /* stylelint-enable */
178 |
179 | [role="button"],
180 | input[type="submit"],
181 | input[type="reset"],
182 | input[type="button"],
183 | button {
184 | -webkit-box-sizing: content-box;
185 | -moz-box-sizing: content-box;
186 | box-sizing: content-box;
187 | }
188 |
189 | /* Reset `button` and button-style `input` default styles */
190 | input[type="submit"],
191 | input[type="reset"],
192 | input[type="button"],
193 | button {
194 | background: none;
195 | border: 0;
196 | color: inherit;
197 | /* cursor: default; */
198 | font: inherit;
199 | line-height: normal;
200 | overflow: visible;
201 | padding: 0;
202 | -webkit-appearance: button; /* for input */
203 | -webkit-user-select: none; /* for button */
204 | -moz-user-select: none;
205 | -ms-user-select: none;
206 | }
207 | input::-moz-focus-inner,
208 | button::-moz-focus-inner {
209 | border: 0;
210 | padding: 0;
211 | }
212 |
213 | /* Make `a` like a button */
214 | [role="button"] {
215 | color: inherit;
216 | cursor: default;
217 | display: inline-block;
218 | text-align: center;
219 | text-decoration: none;
220 | white-space: pre;
221 | -webkit-user-select: none;
222 | -moz-user-select: none;
223 | -ms-user-select: none;
224 | }
225 |
--------------------------------------------------------------------------------
/examples/sinatra/views/room.slim:
--------------------------------------------------------------------------------
1 | h2 ="Room: #{@room_id}"
2 |
3 | .messages#message_list
4 |
5 | .message-form
6 | form#message_form
7 | .row
8 | input#message_txt type="text" required="required"
9 | .row
10 | button.btn type="submit"
11 | span Send!
12 |
13 | javascript:
14 | var roomId = "#{{ @room_id }}";
15 | var user = "#{{ @user }}";
16 | var socketId = Date.now();
17 |
18 | var messageList = document.getElementById("message_list");
19 | var messageForm = document.getElementById("message_form");
20 | var textInput = document.getElementById("message_txt");
21 |
22 | messageForm.onsubmit = function(e){
23 | e.preventDefault();
24 | var msg = textInput.value;
25 | console.log("Send message", msg);
26 | textInput.value = null;
27 | chatChannel.perform('speak', { message: msg });
28 | };
29 |
30 | var escape = function(str) {
31 | return ('' + str).replace(/&/g, '&')
32 | .replace(//g, '>')
34 | .replace(/"/g, '"');
35 | }
36 |
37 | var addMessage = function(data){
38 | var node = document.createElement('div');
39 | var me = data['user'] == user && data['sid'] == socketId
40 | node.className = "message" + (me ? ' me' : '') + (data['system'] ? ' system' : '');
41 | node.innerHTML =
42 | '' + escape(data['user']) + '
' +
43 | '' + escape(data['message']) + '
';
44 | messageList.appendChild(node);
45 | };
46 |
47 | ActionCable.startDebugging();
48 | var cable = ActionCable.createConsumer('#{{ CABLE_URL }}?sid=' + socketId);
49 |
50 | var chatChannel = cable.subscriptions.create(
51 | { channel: 'chat', id: roomId },
52 | {
53 | connected: function(){
54 | console.log("Connected");
55 | addMessage({ user: 'BOT', message: "I'm connected", system: true });
56 | },
57 |
58 | disconnected: function(){
59 | console.log("Connected");
60 | addMessage({ user: 'BOT', message: "Sorry, but you've been disconnected(", system: true });
61 | },
62 |
63 | received: function(data){
64 | console.log("Received", data);
65 | addMessage(data);
66 | }
67 | }
68 | )
--------------------------------------------------------------------------------
/forspell.dict:
--------------------------------------------------------------------------------
1 | # Format: one word per line. Empty lines and #-comments are supported too.
2 | # If you want to add word with its forms, you can write 'word: example' (without quotes) on the line,
3 | # where 'example' is existing word with the same possible forms (endings) as your word.
4 | # Example: deduplicate: duplicate
5 | Adapterize
6 | Hanami
7 |
--------------------------------------------------------------------------------
/gemfiles/rubocop.gemfile:
--------------------------------------------------------------------------------
1 | source "https://rubygems.org" do
2 | gem "rubocop-md", "~> 1.0"
3 | gem "rubocop-rspec"
4 | gem "standard", "~> 1.0"
5 | end
6 |
--------------------------------------------------------------------------------
/lib/lite_cable.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "lite_cable/version"
4 | require "lite_cable/internal"
5 | require "lite_cable/logging"
6 |
7 | # Lightwieght ActionCable implementation.
8 | #
9 | # Contains application logic (channels, streams, broadcasting) and
10 | # also (optional) Rack hijack based server (suitable only for development and test).
11 | #
12 | # Compatible with AnyCable (for production usage).
13 | module LiteCable
14 | require "lite_cable/connection"
15 | require "lite_cable/channel"
16 | require "lite_cable/coders"
17 | require "lite_cable/config"
18 | require "lite_cable/broadcast_adapters"
19 | require "lite_cable/anycable"
20 |
21 | class << self
22 | def config
23 | @config ||= Config.new
24 | end
25 |
26 | attr_accessor :channel_registry
27 |
28 | # Broadcast encoded message to the stream
29 | def broadcast(stream, message, coder: LiteCable.config.coder)
30 | broadcast_adapter.broadcast(stream, message, coder: coder)
31 | end
32 |
33 | def broadcast_adapter
34 | return @broadcast_adapter if defined?(@broadcast_adapter)
35 | self.broadcast_adapter = LiteCable.config.broadcast_adapter.to_sym
36 | @broadcast_adapter
37 | end
38 |
39 | def broadcast_adapter=(adapter)
40 | if adapter.is_a?(Symbol) || adapter.is_a?(Array)
41 | adapter = BroadcastAdapters.lookup_adapter(adapter)
42 | end
43 |
44 | unless adapter.respond_to?(:broadcast)
45 | raise ArgumentError, "BroadcastAdapter must implement #broadcast method. " \
46 | "#{adapter.class} doesn't implement it."
47 | end
48 |
49 | @broadcast_adapter = adapter
50 | end
51 | end
52 |
53 | self.channel_registry = Channel::Registry
54 | end
55 |
--------------------------------------------------------------------------------
/lib/lite_cable/anycable.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module LiteCable # :nodoc:
4 | # AnyCable extensions
5 | module AnyCable
6 | module Connection # :nodoc:
7 | def self.extended(base)
8 | base.prepend InstanceMethods
9 | end
10 |
11 | def call(socket, **options)
12 | new(socket, **options)
13 | end
14 |
15 | module InstanceMethods # :nodoc:
16 | def initialize(socket, subscriptions: nil, **hargs)
17 | super(socket, **hargs)
18 | # Initialize channels if any
19 | subscriptions&.each { |id| @subscriptions.add(id, false) }
20 | end
21 |
22 | def request
23 | @request ||= Rack::Request.new(socket.env)
24 | end
25 |
26 | def handle_channel_command(identifier, command, data)
27 | channel = subscriptions.add(identifier, false)
28 | case command
29 | when "subscribe"
30 | !subscriptions.send(:subscribe_channel, channel).nil?
31 | when "unsubscribe"
32 | subscriptions.remove(identifier)
33 | true
34 | when "message"
35 | subscriptions.perform_action identifier, data
36 | true
37 | else
38 | false
39 | end
40 | rescue LiteCable::Connection::Subscriptions::Error,
41 | LiteCable::Channel::Error,
42 | LiteCable::Channel::Registry::Error => e
43 | log(:error, log_fmt("Connection command failed: #{e}"))
44 | close
45 | false
46 | end
47 | end
48 | end
49 | end
50 |
51 | # Patch Lite Cable with AnyCable functionality
52 | def self.anycable!
53 | LiteCable::Connection::Base.extend LiteCable::AnyCable::Connection
54 | end
55 | end
56 |
57 | if defined?(AnyCable)
58 | AnyCable.configure_server do
59 | # Make sure broadcast adapter is valid
60 | require "lite_cable/broadcast_adapters/any_cable"
61 | unless LiteCable::BroadcastAdapters::AnyCable === LiteCable.broadcast_adapter
62 | raise "You should use :any_cable broadcast adapter (current: #{LiteCable.broadcast_adapter.class}). " \
63 | "Set it via LITECABLE_BROADCAST_ADAPTER=any_cable or in the code/YML."
64 | end
65 |
66 | # Turn AnyCable compatibility mode for anycable RPC server automatically
67 | LiteCable.anycable!
68 | end
69 | end
70 |
--------------------------------------------------------------------------------
/lib/lite_cable/broadcast_adapters.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "lite_cable/broadcast_adapters/base"
4 |
5 | module LiteCable
6 | module BroadcastAdapters # :nodoc:
7 | module_function
8 |
9 | def lookup_adapter(args)
10 | adapter, options = Array(args)
11 | path_to_adapter = "lite_cable/broadcast_adapters/#{adapter}"
12 | adapter_class_name = adapter.to_s.split("_").map(&:capitalize).join
13 |
14 | unless BroadcastAdapters.const_defined?(adapter_class_name, false)
15 | begin
16 | require path_to_adapter
17 | rescue LoadError => e
18 | # We couldn't require the adapter itself.
19 | if e.path == path_to_adapter
20 | raise e.class, "Couldn't load the '#{adapter}' broadcast adapter for LiteCable",
21 | e.backtrace
22 | # Bubbled up from the adapter require.
23 | else
24 | raise e.class, "Error loading the '#{adapter}' broadcast adapter for LiteCable",
25 | e.backtrace
26 | end
27 | end
28 | end
29 |
30 | BroadcastAdapters.const_get(adapter_class_name, false).new(**(options || {}))
31 | end
32 | end
33 | end
34 |
--------------------------------------------------------------------------------
/lib/lite_cable/broadcast_adapters/any_cable.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module LiteCable
4 | module BroadcastAdapters
5 | class AnyCable < Base
6 | def broadcast(stream, message, coder:)
7 | ::AnyCable.broadcast stream, coder.encode(message)
8 | end
9 | end
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/lib/lite_cable/broadcast_adapters/base.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module LiteCable
4 | module BroadcastAdapters
5 | class Base
6 | def initialize(**options)
7 | @options = options
8 | end
9 |
10 | private
11 |
12 | attr_reader :options
13 | end
14 | end
15 | end
16 |
--------------------------------------------------------------------------------
/lib/lite_cable/broadcast_adapters/memory.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module LiteCable
4 | module BroadcastAdapters
5 | class Memory < Base
6 | def broadcast(stream, message, coder:)
7 | Server.subscribers_map.broadcast stream, message, coder
8 | end
9 | end
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/lib/lite_cable/channel.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module LiteCable
4 | module Channel # :nodoc:
5 | require "lite_cable/channel/registry"
6 | require "lite_cable/channel/streams"
7 | require "lite_cable/channel/base"
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/lib/lite_cable/channel/base.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module LiteCable
4 | module Channel
5 | class Error < StandardError; end
6 |
7 | class RejectedError < Error; end
8 |
9 | class UnproccessableActionError < Error; end
10 |
11 | # The channel provides the basic structure of grouping behavior into logical units when communicating over the connection.
12 | # You can think of a channel like a form of controller, but one that's capable of pushing content to the subscriber in addition to simply
13 | # responding to the subscriber's direct requests.
14 | #
15 | # == Identification
16 | #
17 | # Each channel must have a unique identifier, which is used by the connection to resolve the channel's class.
18 | #
19 | # Example:
20 | #
21 | # class SecretChannel < LiteCable::Channel::Base
22 | # identifier 'my_super_secret_channel'
23 | # end
24 | #
25 | # # client-side
26 | # App.cable.subscriptions.create('my_super_secret_channel')
27 | #
28 | # == Action processing
29 | #
30 | # You can declare any public method on the channel (optionally taking a `data` argument),
31 | # and this method is automatically exposed as callable to the client.
32 | #
33 | # Example:
34 | #
35 | # class AppearanceChannel < LiteCable::Channel::Base
36 | # def unsubscribed
37 | # # here `current_user` is a connection identifier
38 | # current_user.disappear
39 | # end
40 | #
41 | # def appear(data)
42 | # current_user.appear on: data['appearing_on']
43 | # end
44 | #
45 | # def away
46 | # current_user.away
47 | # end
48 | # end
49 | #
50 | # == Rejecting subscription requests
51 | #
52 | # A channel can reject a subscription request in the #subscribed callback by
53 | # invoking the #reject method:
54 | #
55 | # class ChatChannel < ApplicationCable::Channel
56 | # def subscribed
57 | # room = Chat::Room[params['room_number']]
58 | # reject unless current_user.can_access?(room)
59 | # end
60 | # end
61 | #
62 | # In this example, the subscription will be rejected if the
63 | # current_user does not have access to the chat room. On the
64 | # client-side, the Channel#rejected callback will get invoked when
65 | # the server rejects the subscription request.
66 | class Base
67 | class << self
68 | # A set of method names that should be considered actions.
69 | # This includes all public instance methods on a channel except from Channel::Base methods.
70 | def action_methods
71 | @action_methods ||= begin
72 | # All public instance methods of this class, including ancestors
73 | methods = (public_instance_methods(true) -
74 | # Except for public instance methods of Base and its ancestors
75 | LiteCable::Channel::Base.public_instance_methods(true) +
76 | # Be sure to include shadowed public instance methods of this class
77 | public_instance_methods(false)).uniq.map(&:to_s)
78 | methods.to_set
79 | end
80 | end
81 |
82 | attr_reader :id
83 |
84 | # Register the channel by its unique identifier
85 | # (in order to resolve the channel's class for connections)
86 | def identifier(id)
87 | Registry.add(id.to_s, self)
88 | @id = id
89 | end
90 | end
91 |
92 | include Logging
93 | prepend Streams
94 |
95 | attr_reader :connection, :identifier, :params
96 |
97 | def initialize(connection, identifier, params)
98 | @connection = connection
99 | @identifier = identifier
100 | @params = params.freeze
101 |
102 | delegate_connection_identifiers
103 | end
104 |
105 | def handle_subscribe
106 | subscribed if respond_to?(:subscribed)
107 | end
108 |
109 | def handle_unsubscribe
110 | unsubscribed if respond_to?(:unsubscribed)
111 | end
112 |
113 | def handle_action(encoded_message)
114 | perform_action connection.coder.decode(encoded_message)
115 | end
116 |
117 | protected
118 |
119 | def reject
120 | raise RejectedError
121 | end
122 |
123 | def transmit(data)
124 | connection.transmit identifier: identifier, message: data
125 | end
126 |
127 | # Extract the action name from the passed data and process it via the channel.
128 | def perform_action(data)
129 | action = extract_action(data)
130 |
131 | raise UnproccessableActionError unless processable_action?(action)
132 |
133 | log(:debug) { log_fmt("Perform action #{action}(#{data})") }
134 | dispatch_action(action, data)
135 | end
136 |
137 | def dispatch_action(action, data)
138 | if method(action).arity == 1
139 | public_send action, data
140 | else
141 | public_send action
142 | end
143 | end
144 |
145 | def extract_action(data)
146 | data.delete("action") || "receive"
147 | end
148 |
149 | def processable_action?(action)
150 | self.class.action_methods.include?(action)
151 | end
152 |
153 | def delegate_connection_identifiers
154 | connection.identifiers.each do |identifier|
155 | define_singleton_method(identifier) do
156 | connection.send(identifier)
157 | end
158 | end
159 | end
160 |
161 | # Add prefix to channel log messages
162 | def log_fmt(msg)
163 | "[connection:#{connection.identifier}] [channel:#{self.class.id}] #{msg}"
164 | end
165 | end
166 | end
167 | end
168 |
--------------------------------------------------------------------------------
/lib/lite_cable/channel/registry.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module LiteCable
4 | module Channel
5 | # Stores channels identifiers and corresponding classes.
6 | module Registry
7 | class Error < StandardError; end
8 |
9 | class AlreadyRegisteredError < Error; end
10 |
11 | class UnknownChannelError < Error; end
12 |
13 | class << self
14 | def add(id, channel_class)
15 | raise AlreadyRegisteredError if find(id)
16 |
17 | channels[id] = channel_class
18 | end
19 |
20 | def find(id)
21 | channels[id]
22 | end
23 |
24 | alias_method :lookup, :find
25 |
26 | def find!(id)
27 | channel_class = find(id)
28 | raise UnknownChannelError unless channel_class
29 |
30 | channel_class
31 | end
32 |
33 | private
34 |
35 | def channels
36 | @channels ||= {}
37 | end
38 | end
39 | end
40 | end
41 | end
42 |
--------------------------------------------------------------------------------
/lib/lite_cable/channel/streams.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module LiteCable
4 | module Channel
5 | # Streams allow channels to route broadcastings to the subscriber. A broadcasting is a pubsub queue where any data
6 | # placed into it is automatically sent to the clients that are connected at that time.
7 |
8 | # Most commonly, the streamed broadcast is sent straight to the subscriber on the client-side. The channel just acts as a connector between
9 | # the two parties (the broadcaster and the channel subscriber). Here's an example of a channel that allows subscribers to get all new
10 | # comments on a given page:
11 | #
12 | # class CommentsChannel < ApplicationCable::Channel
13 | # def follow(data)
14 | # stream_from "comments_for_#{data['recording_id']}"
15 | # end
16 | #
17 | # def unfollow
18 | # stop_all_streams
19 | # end
20 | # end
21 | #
22 | # Based on the above example, the subscribers of this channel will get whatever data is put into the,
23 | # let's say, `comments_for_45` broadcasting as soon as it's put there.
24 | #
25 | # An example broadcasting for this channel looks like so:
26 | #
27 | # LiteCable.server.broadcast "comments_for_45", author: 'Donald Duck', content: 'Quack-quack-quack'
28 | #
29 | # You can stop streaming from all broadcasts by calling #stop_all_streams or use #stop_from to stop streaming broadcasts from the specified stream.
30 | module Streams
31 | def handle_unsubscribe
32 | stop_all_streams
33 | super
34 | end
35 |
36 | # Start streaming from the named broadcasting pubsub queue.
37 | def stream_from(broadcasting)
38 | log(:debug) { log_fmt("Stream from #{broadcasting}") }
39 | connection.streams.add(identifier, broadcasting)
40 | end
41 |
42 | # Stop streaming from the named broadcasting pubsub queue.
43 | def stop_stream(broadcasting)
44 | log(:debug) { log_fmt("Stop stream from #{broadcasting}") }
45 | connection.streams.remove(identifier, broadcasting)
46 | end
47 |
48 | # Unsubscribes all streams associated with this channel from the pubsub queue.
49 | def stop_all_streams
50 | log(:debug) { log_fmt("Stop all streams") }
51 | connection.streams.remove_all(identifier)
52 | end
53 | end
54 | end
55 | end
56 |
--------------------------------------------------------------------------------
/lib/lite_cable/coders.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module LiteCable
4 | module Coders # :nodoc:
5 | require "lite_cable/coders/raw"
6 | require "lite_cable/coders/json"
7 | end
8 | end
9 |
--------------------------------------------------------------------------------
/lib/lite_cable/coders/json.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "json"
4 |
5 | module LiteCable
6 | module Coders
7 | # Wrapper over JSON
8 | module JSON
9 | class << self
10 | def decode(json_str)
11 | ::JSON.parse(json_str)
12 | end
13 |
14 | def encode(ruby_obj)
15 | ruby_obj.to_json
16 | end
17 | end
18 | end
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/lib/lite_cable/coders/raw.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module LiteCable
4 | module Coders
5 | # No-op coder
6 | module Raw
7 | class << self
8 | def decode(val)
9 | val
10 | end
11 |
12 | alias_method :encode, :decode
13 | end
14 | end
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/lib/lite_cable/config.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "anyway_config"
4 | require "logger"
5 |
6 | module LiteCable
7 | # AnyCable configuration
8 | class Config < Anyway::Config
9 | require "lite_cable/coders/json"
10 | require "lite_cable/coders/raw"
11 |
12 | config_name :litecable
13 |
14 | attr_config :logger,
15 | coder: Coders::JSON,
16 | broadcast_adapter: defined?(::AnyCable::VERSION) ? :any_cable : :memory,
17 | identifier_coder: Coders::Raw,
18 | log_level: Logger::INFO
19 | end
20 | end
21 |
--------------------------------------------------------------------------------
/lib/lite_cable/connection.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module LiteCable
4 | module Connection # :nodoc:
5 | require "lite_cable/connection/authorization"
6 | require "lite_cable/connection/identification"
7 | require "lite_cable/connection/base"
8 | require "lite_cable/connection/streams"
9 | require "lite_cable/connection/subscriptions"
10 | end
11 | end
12 |
--------------------------------------------------------------------------------
/lib/lite_cable/connection/authorization.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module LiteCable
4 | module Connection
5 | class UnauthorizedError < StandardError; end
6 |
7 | # Include methods to control authorization flow
8 | module Authorization
9 | def reject_unauthorized_connection
10 | raise UnauthorizedError
11 | end
12 | end
13 | end
14 | end
15 |
--------------------------------------------------------------------------------
/lib/lite_cable/connection/base.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module LiteCable
4 | module Connection
5 | # A Connection object represents a client "connected" to the application.
6 | # It contains all of the channel subscriptions. Incoming messages are then routed to these channel subscriptions
7 | # based on an identifier sent by the consumer.
8 | # The Connection itself does not deal with any specific application logic beyond authentication and authorization.
9 | #
10 | # Here's a basic example:
11 | #
12 | # module MyApplication
13 | # class Connection < LiteCable::Connection::Base
14 | # identified_by :current_user
15 | #
16 | # def connect
17 | # self.current_user = find_verified_user
18 | # end
19 | #
20 | # def disconnect
21 | # # Any cleanup work needed when the cable connection is cut.
22 | # end
23 | #
24 | # private
25 | # def find_verified_user
26 | # User.find_by_identity(cookies[:identity]) ||
27 | # reject_unauthorized_connection
28 | # end
29 | # end
30 | # end
31 | #
32 | # First, we declare that this connection can be identified by its current_user. This allows us to later be able to find all connections
33 | # established for that current_user (and potentially disconnect them). You can declare as many
34 | # identification indexes as you like. Declaring an identification means that an attr_accessor is automatically set for that key.
35 | #
36 | # Second, we rely on the fact that the connection is established with the cookies from the domain being sent along. This makes
37 | # it easy to use cookies that were set when logging in via a web interface to authorize the connection.
38 | #
39 | class Base
40 | include Authorization
41 | prepend Identification
42 | include Logging
43 |
44 | attr_reader :subscriptions, :streams, :coder
45 |
46 | def initialize(socket, coder: nil)
47 | @socket = socket
48 | @coder = coder || LiteCable.config.coder
49 |
50 | @subscriptions = Subscriptions.new(self)
51 | @streams = Streams.new(socket)
52 | end
53 |
54 | def handle_open
55 | connect if respond_to?(:connect)
56 | send_welcome_message
57 | log(:debug) { log_fmt("Opened") }
58 | rescue UnauthorizedError
59 | log(:debug) { log_fmt("Authorization failed") }
60 | close
61 | end
62 |
63 | def handle_close
64 | disconnected!
65 | subscriptions.remove_all
66 |
67 | disconnect if respond_to?(:disconnect)
68 | log(:debug) { log_fmt("Closed") }
69 | end
70 |
71 | def handle_command(websocket_message)
72 | command = decode(websocket_message)
73 | subscriptions.execute_command command
74 | rescue Subscriptions::Error, Channel::Error, Channel::Registry::Error => e
75 | log(:error, log_fmt("Connection command failed: #{e}"))
76 | close
77 | end
78 |
79 | def transmit(cable_message)
80 | return if disconnected?
81 |
82 | socket.transmit encode(cable_message)
83 | end
84 |
85 | def close
86 | socket.close
87 | end
88 |
89 | # Rack::Request instance of underlying socket
90 | def request
91 | socket.request
92 | end
93 |
94 | # Request cookies
95 | def cookies
96 | request.cookies
97 | end
98 |
99 | def disconnected?
100 | @_disconnected == true
101 | end
102 |
103 | private
104 |
105 | attr_reader :socket
106 |
107 | def disconnected!
108 | @_disconnected = true
109 | end
110 |
111 | def send_welcome_message
112 | # Send welcome message to the internal connection monitor channel.
113 | # This ensures the connection monitor state is reset after a successful
114 | # websocket connection.
115 | transmit type: LiteCable::INTERNAL[:message_types][:welcome]
116 | end
117 |
118 | def encode(cable_message)
119 | coder.encode cable_message
120 | end
121 |
122 | def decode(websocket_message)
123 | coder.decode websocket_message
124 | end
125 |
126 | def log_fmt(msg)
127 | "[connection:#{identifier}] #{msg}"
128 | end
129 | end
130 | end
131 | end
132 |
--------------------------------------------------------------------------------
/lib/lite_cable/connection/identification.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "set"
4 |
5 | module LiteCable
6 | module Connection
7 | module Identification # :nodoc:
8 | module ClassMethods # :nodoc:
9 | # Mark a key as being a connection identifier index
10 | # that can then be used to find the specific connection again later.
11 | def identified_by(*identifiers)
12 | Array(identifiers).each do |identifier|
13 | attr_writer identifier
14 | define_method(identifier) do
15 | return instance_variable_get(:"@#{identifier}") if
16 | instance_variable_defined?(:"@#{identifier}")
17 |
18 | fetch_identifier(identifier.to_s)
19 | end
20 | end
21 |
22 | self.identifiers += identifiers
23 | end
24 | end
25 |
26 | def self.prepended(base)
27 | base.class_eval do
28 | class << self
29 | attr_writer :identifiers
30 |
31 | def identifiers
32 | @identifiers ||= Set.new
33 | end
34 |
35 | include ClassMethods
36 | end
37 | end
38 | end
39 |
40 | def initialize(socket, identifiers: nil, **hargs)
41 | @encoded_ids = identifiers ? JSON.parse(identifiers) : {}
42 | super(socket, **hargs)
43 | end
44 |
45 | def identifiers
46 | self.class.identifiers
47 | end
48 |
49 | # Return a single connection identifier
50 | # that combines the value of all the registered identifiers into a single id.
51 | #
52 | # You can specify a custom identifier_coder in config
53 | # to implement specific logic of encoding/decoding
54 | # custom classes to identifiers.
55 | #
56 | # By default uses Raw coder.
57 | def identifier
58 | unless defined? @identifier
59 | values = identifiers_hash.values.compact
60 | @identifier = values.empty? ? nil : values.map(&:to_s).sort.join(":")
61 | end
62 |
63 | @identifier
64 | end
65 |
66 | # Generate identifiers info as hash.
67 | def identifiers_hash
68 | identifiers.each_with_object({}) do |id, acc|
69 | obj = instance_variable_get("@#{id}")
70 | next unless obj
71 |
72 | acc[id.to_s] = LiteCable.config.identifier_coder.encode(obj)
73 | end
74 | end
75 |
76 | def identifiers_json
77 | identifiers_hash.to_json
78 | end
79 |
80 | # Fetch identifier and deserialize if neccessary
81 | def fetch_identifier(name)
82 | val = @encoded_ids[name]
83 | val = LiteCable.config.identifier_coder.decode(val) unless val.nil?
84 | instance_variable_set(
85 | :"@#{name}",
86 | val
87 | )
88 | end
89 | end
90 | end
91 | end
92 |
--------------------------------------------------------------------------------
/lib/lite_cable/connection/streams.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module LiteCable
4 | module Connection
5 | # Manage the connection streams
6 | class Streams
7 | attr_reader :socket
8 |
9 | def initialize(socket)
10 | @socket = socket
11 | end
12 |
13 | # Start streaming from broadcasting to the channel.
14 | def add(channel_id, broadcasting)
15 | socket.subscribe(channel_id, broadcasting)
16 | end
17 |
18 | # Stop streaming from broadcasting to the channel.
19 | def remove(channel_id, broadcasting)
20 | socket.unsubscribe(channel_id, broadcasting)
21 | end
22 |
23 | # Stop all streams for the channel
24 | def remove_all(channel_id)
25 | socket.unsubscribe_from_all(channel_id)
26 | end
27 | end
28 | end
29 | end
30 |
--------------------------------------------------------------------------------
/lib/lite_cable/connection/subscriptions.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module LiteCable
4 | class UnknownChannelError < StandardError
5 | attr_reader :channel_id
6 |
7 | def initialize(channel_id)
8 | @channel_id = channel_id
9 | super("Unknown channel: #{channel_id}")
10 | end
11 | end
12 |
13 | module Connection
14 | # Manage the connection channels and route messages
15 | class Subscriptions
16 | class Error < StandardError; end
17 |
18 | class AlreadySubscribedError < Error; end
19 |
20 | class UnknownCommandError < Error; end
21 |
22 | class ChannelNotFoundError < Error; end
23 |
24 | include Logging
25 |
26 | def initialize(connection)
27 | @connection = connection
28 | @subscriptions = {}
29 | end
30 |
31 | def identifiers
32 | subscriptions.keys
33 | end
34 |
35 | def add(identifier, subscribe = true)
36 | raise AlreadySubscribedError if find(identifier)
37 |
38 | params = connection.coder.decode(identifier)
39 |
40 | channel_id = params.delete("channel")
41 |
42 | channel_class = LiteCable.channel_registry.lookup(channel_id)
43 |
44 | raise UnknownChannelError, channel_id unless channel_class
45 |
46 | subscriptions[identifier] = channel_class.new(connection, identifier, params)
47 | subscribe ? subscribe_channel(subscriptions[identifier]) : subscriptions[identifier]
48 | end
49 |
50 | def remove(identifier)
51 | channel = find!(identifier)
52 | subscriptions.delete(identifier)
53 | channel.handle_unsubscribe
54 | log(:debug) { log_fmt("Unsubscribed from channel #{channel.class.id}") }
55 | transmit_subscription_cancel(channel.identifier)
56 | end
57 |
58 | def remove_all
59 | subscriptions.keys.each(&method(:remove))
60 | end
61 |
62 | def perform_action(identifier, data)
63 | channel = find!(identifier)
64 | channel.handle_action data
65 | end
66 |
67 | def execute_command(data)
68 | command = data.delete("command")
69 | case command
70 | when "subscribe" then add(data["identifier"])
71 | when "unsubscribe" then remove(data["identifier"])
72 | when "message" then perform_action(data["identifier"], data["data"])
73 | else
74 | raise UnknownCommandError, "Command not found #{command}"
75 | end
76 | end
77 |
78 | def find(identifier)
79 | subscriptions[identifier]
80 | end
81 |
82 | def find!(identifier)
83 | channel = find(identifier)
84 | raise ChannelNotFoundError unless channel
85 |
86 | channel
87 | end
88 |
89 | private
90 |
91 | attr_reader :connection, :subscriptions
92 |
93 | def subscribe_channel(channel)
94 | channel.handle_subscribe
95 | log(:debug) { log_fmt("Subscribed to channel #{channel.class.id}") }
96 | transmit_subscription_confirmation(channel.identifier)
97 | channel
98 | rescue Channel::RejectedError
99 | subscriptions.delete(channel.identifier)
100 | transmit_subscription_rejection(channel.identifier)
101 | nil
102 | end
103 |
104 | def transmit_subscription_confirmation(identifier)
105 | connection.transmit identifier: identifier,
106 | type: LiteCable::INTERNAL[:message_types][:confirmation]
107 | end
108 |
109 | def transmit_subscription_rejection(identifier)
110 | connection.transmit identifier: identifier,
111 | type: LiteCable::INTERNAL[:message_types][:rejection]
112 | end
113 |
114 | def transmit_subscription_cancel(identifier)
115 | connection.transmit identifier: identifier,
116 | type: LiteCable::INTERNAL[:message_types][:cancel]
117 | end
118 |
119 | def log_fmt(msg)
120 | "[connection:#{connection.identifier}] #{msg}"
121 | end
122 | end
123 | end
124 | end
125 |
--------------------------------------------------------------------------------
/lib/lite_cable/internal.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module LiteCable
4 | INTERNAL = {
5 | message_types: {
6 | welcome: "welcome",
7 | ping: "ping",
8 | confirmation: "confirm_subscription",
9 | rejection: "reject_subscription",
10 | cancel: "cancel_subscription"
11 | }.freeze,
12 | protocols: ["actioncable-v1-json", "actioncable-unsupported"].freeze
13 | }.freeze
14 | end
15 |
--------------------------------------------------------------------------------
/lib/lite_cable/logging.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "logger"
4 |
5 | module LiteCable
6 | module Logging # :nodoc:
7 | PREFIX = "LiteCable"
8 |
9 | class << self
10 | def logger
11 | return @logger if instance_variable_defined?(:@logger)
12 |
13 | @logger = LiteCable.config.logger
14 | return if @logger == false
15 |
16 | @logger ||= ::Logger.new($stderr).tap do |logger|
17 | logger.level = LiteCable.config.log_level
18 | end
19 | end
20 | end
21 |
22 | private
23 |
24 | def log(level, message = nil)
25 | return unless LiteCable::Logging.logger
26 |
27 | LiteCable::Logging.logger.send(level, PREFIX) { message || yield }
28 | end
29 | end
30 | end
31 |
--------------------------------------------------------------------------------
/lib/lite_cable/server.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module LiteCable
4 | # Rack middleware to hijack sockets.
5 | #
6 | # Uses thread-per-connection model (thus recommended only for development and test usage).
7 | #
8 | # Inspired by https://github.com/ngauthier/tubesock/blob/master/lib/tubesock.rb
9 | module Server
10 | require "websocket"
11 | require "lite_cable/server/subscribers_map"
12 | require "lite_cable/server/client_socket"
13 | require "lite_cable/server/heart_beat"
14 | require "lite_cable/server/middleware"
15 |
16 | class << self
17 | attr_accessor :subscribers_map
18 | end
19 |
20 | self.subscribers_map = SubscribersMap.new
21 | end
22 | end
23 |
--------------------------------------------------------------------------------
/lib/lite_cable/server/client_socket.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module LiteCable
4 | module Server
5 | module ClientSocket # :nodoc:
6 | require "lite_cable/server/client_socket/subscriptions"
7 | require "lite_cable/server/client_socket/base"
8 | end
9 | end
10 | end
11 |
--------------------------------------------------------------------------------
/lib/lite_cable/server/client_socket/base.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module LiteCable
4 | module Server
5 | module ClientSocket
6 | # Wrapper over web socket
7 | class Base
8 | include Logging
9 | include Subscriptions
10 |
11 | attr_reader :version, :active
12 |
13 | def initialize(env, socket, version)
14 | log(:debug, "WebSocket version #{version}")
15 | @env = env
16 | @socket = socket
17 | @version = version
18 | @active = true
19 |
20 | @open_handlers = []
21 | @message_handlers = []
22 | @close_handlers = []
23 | @error_handlers = []
24 |
25 | @close_on_error = true
26 | end
27 |
28 | def prevent_close_on_error
29 | @close_on_error = false
30 | end
31 |
32 | def transmit(data, type: :text)
33 | frame = WebSocket::Frame::Outgoing::Server.new(
34 | version: version,
35 | data: data,
36 | type: type
37 | )
38 | socket.write frame.to_s
39 | rescue EOFError, Errno::ECONNRESET => e
40 | log(:debug, "Socket gone: #{e}")
41 | close
42 | rescue IOError, Errno::EPIPE, Errno::ETIMEDOUT => e
43 | log(:error, "Socket send failed: #{e}")
44 | close
45 | end
46 |
47 | def request
48 | @request ||= Rack::Request.new(@env)
49 | end
50 |
51 | def onopen(&block)
52 | @open_handlers << block
53 | end
54 |
55 | def onmessage(&block)
56 | @message_handlers << block
57 | end
58 |
59 | def onclose(&block)
60 | @close_handlers << block
61 | end
62 |
63 | def onerror(&block)
64 | @error_handlers << block
65 | end
66 |
67 | def listen
68 | keepalive
69 | Thread.new do
70 | Thread.current.abort_on_exception = true
71 | begin
72 | @open_handlers.each(&:call)
73 | each_frame do |data|
74 | @message_handlers.each do |h|
75 | h.call(data)
76 | rescue => e
77 | log(:error, "Socket receive failed: #{e}")
78 | @error_handlers.each { |eh| eh.call(e, data) }
79 | close if close_on_error
80 | end
81 | end
82 | ensure
83 | close
84 | end
85 | end
86 | end
87 |
88 | def close
89 | return unless @active
90 |
91 | @close_handlers.each(&:call)
92 | close!
93 |
94 | @active = false
95 | end
96 |
97 | def closed?
98 | @socket.closed?
99 | end
100 |
101 | private
102 |
103 | attr_reader :socket, :close_on_error
104 |
105 | def close!
106 | if @socket.respond_to?(:closed?)
107 | close_socket unless @socket.closed?
108 | else
109 | close_socket
110 | end
111 | end
112 |
113 | def close_socket
114 | frame = WebSocket::Frame::Outgoing::Server.new(version: version, type: :close, code: 1000)
115 | @socket.write(frame.to_s) if frame.supported?
116 | @socket.close
117 | rescue IOError, Errno::EPIPE, Errno::ETIMEDOUT, Errno::ECONNRESET
118 | # already closed
119 | end
120 |
121 | def keepalive
122 | thread = Thread.new do
123 | Thread.current.abort_on_exception = true
124 | loop do
125 | sleep 5
126 | transmit nil, type: :ping
127 | end
128 | end
129 |
130 | onclose do
131 | thread.kill
132 | end
133 | end
134 |
135 | def each_frame
136 | framebuffer = WebSocket::Frame::Incoming::Server.new(version: version)
137 |
138 | while socket.wait_readable
139 | data = socket.respond_to?(:recv) ? socket.recv(2000) : socket.readpartial(2000)
140 | break if data.nil? || data.empty?
141 |
142 | framebuffer << data
143 | while frame = framebuffer.next # rubocop:disable Lint/AssignmentInCondition
144 | case frame.type
145 | when :close
146 | return
147 | when :text, :binary
148 | yield frame.data
149 | end
150 | end
151 | end
152 | rescue Errno::EHOSTUNREACH, Errno::ETIMEDOUT, Errno::ECONNRESET, IOError, Errno::EBADF => e
153 | log(:debug, "Socket frame error: #{e}")
154 | nil # client disconnected or timed out
155 | end
156 | end
157 | end
158 | end
159 | end
160 |
--------------------------------------------------------------------------------
/lib/lite_cable/server/client_socket/subscriptions.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module LiteCable
4 | module Server
5 | module ClientSocket
6 | # Handle socket subscriptions
7 | module Subscriptions
8 | def subscribe(channel, broadcasting)
9 | LiteCable::Server.subscribers_map
10 | .add_subscriber(broadcasting, self, channel)
11 | end
12 |
13 | def unsubscribe(channel, broadcasting)
14 | LiteCable::Server.subscribers_map
15 | .remove_subscriber(broadcasting, self, channel)
16 | end
17 |
18 | def unsubscribe_from_all(channel)
19 | LiteCable::Server.subscribers_map.remove_socket(self, channel)
20 | end
21 | end
22 | end
23 | end
24 | end
25 |
--------------------------------------------------------------------------------
/lib/lite_cable/server/heart_beat.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module LiteCable
4 | module Server
5 | # Sends pings to sockets
6 | class HeartBeat
7 | BEAT_INTERVAL = 3
8 |
9 | def initialize
10 | @sockets = []
11 | run
12 | end
13 |
14 | def add(socket)
15 | @sockets << socket
16 | end
17 |
18 | def remove(socket)
19 | @sockets.delete(socket)
20 | end
21 |
22 | def stop
23 | @stopped = true
24 | end
25 |
26 | def run
27 | Thread.new do
28 | Thread.current.abort_on_exception = true
29 | loop do
30 | break if @stopped
31 |
32 | unless @sockets.empty?
33 | msg = ping_message Time.now.to_i
34 | @sockets.each do |socket|
35 | socket.transmit msg
36 | end
37 | end
38 |
39 | sleep BEAT_INTERVAL
40 | end
41 | end
42 | end
43 |
44 | private
45 |
46 | def ping_message(time)
47 | {type: LiteCable::INTERNAL[:message_types][:ping], message: time}.to_json
48 | end
49 | end
50 | end
51 | end
52 |
--------------------------------------------------------------------------------
/lib/lite_cable/server/middleware.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module LiteCable
4 | module Server
5 | # Rack middleware to hijack the socket
6 | class Middleware
7 | class HijackNotAvailable < RuntimeError; end
8 |
9 | def initialize(_app, connection_class:)
10 | @connection_class = connection_class
11 | @heart_beat = HeartBeat.new
12 | end
13 |
14 | def call(env)
15 | return [404, {"Content-Type" => "text/plain"}, ["Not Found"]] unless
16 | env["HTTP_UPGRADE"] == "websocket"
17 |
18 | raise HijackNotAvailable unless env["rack.hijack"]
19 |
20 | env["rack.hijack"].call
21 | handshake = send_handshake(env)
22 |
23 | socket = ClientSocket::Base.new env, env["rack.hijack_io"], handshake.version
24 | init_connection socket
25 | init_heartbeat socket
26 | socket.listen
27 | [-1, {}, []]
28 | end
29 |
30 | private
31 |
32 | def send_handshake(env)
33 | handshake = WebSocket::Handshake::Server.new(
34 | protocols: LiteCable::INTERNAL[:protocols]
35 | )
36 |
37 | handshake.from_rack env
38 | env["rack.hijack_io"].write handshake.to_s
39 | handshake
40 | end
41 |
42 | def init_connection(socket)
43 | connection = @connection_class.new(socket)
44 |
45 | socket.onopen { connection.handle_open }
46 | socket.onclose { connection.handle_close }
47 | socket.onmessage { |data| connection.handle_command(data) }
48 | end
49 |
50 | def init_heartbeat(socket)
51 | @heart_beat.add(socket)
52 | socket.onclose { @heart_beat.remove(socket) }
53 | end
54 | end
55 | end
56 | end
57 |
--------------------------------------------------------------------------------
/lib/lite_cable/server/subscribers_map.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module LiteCable
4 | module Server
5 | # From https://github.com/rails/rails/blob/v5.0.1/actioncable/lib/action_cable/subscription_adapter/subscriber_map.rb
6 | class SubscribersMap
7 | attr_reader :streams, :sockets
8 |
9 | def initialize
10 | @streams = Hash.new do |streams, stream_id|
11 | streams[stream_id] = Hash.new { |channels, channel_id| channels[channel_id] = [] }
12 | end
13 | @sockets = Hash.new { |h, k| h[k] = [] }
14 | @sync = Mutex.new
15 | end
16 |
17 | def add_subscriber(stream, socket, channel)
18 | @sync.synchronize do
19 | @streams[stream][channel] << socket
20 | @sockets[socket] << [channel, stream]
21 | end
22 | end
23 |
24 | def remove_subscriber(stream, socket, channel)
25 | @sync.synchronize do
26 | @streams[stream][channel].delete(socket)
27 | @sockets[socket].delete([channel, stream])
28 | cleanup stream, socket, channel
29 | end
30 | end
31 |
32 | def remove_socket(socket, channel)
33 | list = @sync.synchronize do
34 | return unless @sockets.key?(socket)
35 |
36 | @sockets[socket].dup
37 | end
38 |
39 | list.each do |(channel_id, stream)|
40 | remove_subscriber(stream, socket, channel) if channel == channel_id
41 | end
42 | end
43 |
44 | def broadcast(stream, message, coder)
45 | list = @sync.synchronize do
46 | return unless @streams.key?(stream)
47 |
48 | @streams[stream].to_a
49 | end
50 |
51 | list.each do |(channel_id, sockets)|
52 | cmessage = channel_message(channel_id, message, coder)
53 | sockets.each { |s| s.transmit cmessage }
54 | end
55 | end
56 |
57 | private
58 |
59 | def cleanup(stream, socket, channel)
60 | @streams[stream].delete(channel) if @streams[stream][channel].empty?
61 | @streams.delete(stream) if @streams[stream].empty?
62 | @sockets.delete(socket) if @sockets[socket].empty?
63 | end
64 |
65 | def channel_message(channel_id, message, coder)
66 | coder.encode(identifier: channel_id, message: message)
67 | end
68 | end
69 | end
70 | end
71 |
--------------------------------------------------------------------------------
/lib/lite_cable/version.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module LiteCable
4 | VERSION = "0.8.2"
5 | end
6 |
--------------------------------------------------------------------------------
/lib/litecable.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "lite_cable"
4 |
--------------------------------------------------------------------------------
/litecable.gemspec:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative "lib/lite_cable/version"
4 |
5 | Gem::Specification.new do |spec|
6 | spec.name = "litecable"
7 | spec.version = LiteCable::VERSION
8 | spec.authors = ["palkan"]
9 | spec.email = ["dementiev.vm@gmail.com"]
10 |
11 | spec.summary = "Fat-free ActionCable implementation"
12 | spec.description = "Fat-free ActionCable implementation for using with AnyCable (and without Rails)"
13 | spec.homepage = "https://github.com/palkan/litecable"
14 | spec.license = "MIT"
15 | spec.metadata = {
16 | "bug_tracker_uri" => "http://github.com/palkan/litecable/issues",
17 | "changelog_uri" => "https://github.com/palkan/litecable/blob/master/CHANGELOG.md",
18 | "documentation_uri" => "http://github.com/palkan/litecable",
19 | "homepage_uri" => "http://github.com/palkan/litecable",
20 | "source_code_uri" => "http://github.com/palkan/litecable"
21 | }
22 |
23 | spec.files = Dir.glob("lib/**/*") + %w[README.md LICENSE.txt CHANGELOG.md]
24 | spec.require_paths = ["lib"]
25 |
26 | spec.required_ruby_version = ">= 2.7.0"
27 |
28 | spec.add_dependency "anyway_config", ">= 1.0"
29 |
30 | spec.add_development_dependency "rack", "~> 2.0"
31 | spec.add_development_dependency "websocket", "~> 1.2.4"
32 | spec.add_development_dependency "websocket-client-simple", "~> 0.3.0"
33 | spec.add_development_dependency "concurrent-ruby", "~> 1.1"
34 | spec.add_development_dependency "puma", ">= 6.0"
35 |
36 | spec.add_development_dependency "bundler", ">= 1.13"
37 | spec.add_development_dependency "rake", ">= 10.0"
38 | spec.add_development_dependency "rspec", ">= 3.0"
39 | end
40 |
--------------------------------------------------------------------------------
/spec/integrations/server_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "spec_helper"
4 |
5 | require "puma"
6 |
7 | describe "Lite Cable server", :async do
8 | module ServerTest # rubocop:disable Lint/ConstantDefinitionInBlock
9 | class << self
10 | def logs
11 | @logs ||= []
12 | end
13 | end
14 |
15 | class Connection < LiteCable::Connection::Base
16 | identified_by :user, :sid
17 |
18 | def connect
19 | reject_unauthorized_connection unless cookies["user"]
20 | @user = cookies["user"]
21 | @sid = request.params["sid"]
22 | end
23 |
24 | def disconnect
25 | ServerTest.logs << "#{user} disconnected"
26 | end
27 | end
28 |
29 | class EchoChannel < LiteCable::Channel::Base
30 | identifier :echo
31 |
32 | def subscribed
33 | stream_from "global"
34 | end
35 |
36 | def unsubscribed
37 | transmit message: "Goodbye, #{user}!"
38 | end
39 |
40 | def ding(data)
41 | transmit(dong: data["message"])
42 | end
43 |
44 | def delay(data)
45 | sleep 1
46 | transmit(dong: data["message"])
47 | end
48 |
49 | def bulk(data)
50 | LiteCable.broadcast "global", {message: data["message"], from: user, sid: sid}
51 | end
52 | end
53 | end
54 |
55 | before(:all) do
56 | @server = ::Puma::Server.new(
57 | LiteCable::Server::Middleware.new(nil, connection_class: ServerTest::Connection),
58 | nil,
59 | {min_threads: 1, max_threads: 4}
60 | )
61 | @server.add_tcp_listener "127.0.0.1", 3099
62 |
63 | @server_t = Thread.new { @server.run.join }
64 | end
65 |
66 | after(:all) do
67 | @server&.stop(true)
68 | @server_t&.join
69 | end
70 |
71 | let(:cookies) { "user=john" }
72 | let(:path) { "/?sid=123" }
73 | let(:client) { @client = SyncClient.new("ws://127.0.0.1:3099#{path}", cookies: cookies) }
74 | let(:logs) { ServerTest.logs }
75 |
76 | after { logs.clear }
77 |
78 | describe "connect" do
79 | it "receives welcome message" do
80 | expect(client.read_message).to eq("type" => "welcome")
81 | end
82 |
83 | context "when unauthorized" do
84 | let(:cookies) { "" }
85 |
86 | it "disconnects" do
87 | client.wait_for_close
88 | expect(client).to be_closed
89 | end
90 | end
91 | end
92 |
93 | describe "disconnect" do
94 | it "calls disconnect handlers" do
95 | expect(client.read_message).to eq("type" => "welcome")
96 | client.close
97 | client.wait_for_close
98 | expect(client).to be_closed
99 |
100 | wait { !logs.size.zero? }
101 |
102 | expect(logs.last).to include "john disconnected"
103 | end
104 | end
105 |
106 | describe "channels" do
107 | it "subscribes to channels and perform actions" do
108 | expect(client.read_message).to eq("type" => "welcome")
109 |
110 | client.send_message command: "subscribe", identifier: JSON.generate(channel: "echo")
111 | expect(client.read_message).to eq("identifier" => "{\"channel\":\"echo\"}", "type" => "confirm_subscription")
112 |
113 | client.send_message command: "message", identifier: JSON.generate(channel: "echo"), data: JSON.generate(action: "ding", message: "hello")
114 | expect(client.read_message).to eq("identifier" => "{\"channel\":\"echo\"}", "message" => {"dong" => "hello"})
115 | end
116 |
117 | it "unsubscribes from channels and receive cleanup messages" do
118 | expect(client.read_message).to eq("type" => "welcome")
119 |
120 | client.send_message command: "subscribe", identifier: JSON.generate(channel: "echo")
121 | expect(client.read_message).to eq("identifier" => "{\"channel\":\"echo\"}", "type" => "confirm_subscription")
122 |
123 | client.send_message command: "unsubscribe", identifier: JSON.generate(channel: "echo")
124 | expect(client.read_message).to eq("identifier" => "{\"channel\":\"echo\"}", "message" => {"message" => "Goodbye, john!"})
125 | expect(client.read_message).to eq("identifier" => "{\"channel\":\"echo\"}", "type" => "cancel_subscription")
126 | end
127 | end
128 |
129 | describe "broadcasts" do
130 | let(:client2) { @client2 = SyncClient.new("ws://127.0.0.1:3099/?sid=234", cookies: "user=alice") }
131 |
132 | let(:clients) { [client, client2] }
133 |
134 | before do
135 | concurrently(clients) do |c|
136 | expect(c.read_message).to eq("type" => "welcome")
137 |
138 | c.send_message command: "subscribe", identifier: JSON.generate(channel: "echo")
139 | expect(c.read_message).to eq("identifier" => "{\"channel\":\"echo\"}", "type" => "confirm_subscription")
140 | end
141 | end
142 |
143 | it "transmit messages to connected clients" do
144 | client.send_message command: "message", identifier: JSON.generate(channel: "echo"), data: JSON.generate(action: "bulk", message: "Good news, everyone!")
145 |
146 | concurrently(clients) do |c|
147 | expect(c.read_message).to eq("identifier" => "{\"channel\":\"echo\"}", "message" => {"message" => "Good news, everyone!", "from" => "john", "sid" => "123"})
148 | end
149 |
150 | client2.send_message command: "message", identifier: JSON.generate(channel: "echo"), data: JSON.generate(action: "bulk", message: "A-W-E-S-O-M-E")
151 |
152 | concurrently(clients) do |c|
153 | expect(c.read_message).to eq("identifier" => "{\"channel\":\"echo\"}", "message" => {"message" => "A-W-E-S-O-M-E", "from" => "alice", "sid" => "234"})
154 | end
155 | end
156 | end
157 | end
158 |
--------------------------------------------------------------------------------
/spec/lite_cable/channel/base_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "spec_helper"
4 |
5 | class TestChannel < LiteCable::Channel::Base
6 | attr_reader :subscribe, :unsubscribe, :follows, :received
7 |
8 | def subscribed
9 | reject if params["reject"]
10 | @subscribe = true
11 | end
12 |
13 | def unsubscribed
14 | @unsubscribe = true
15 | end
16 |
17 | def receive(data)
18 | @received = data
19 | end
20 |
21 | def follow_all
22 | @follows = true
23 | end
24 |
25 | def follow(data)
26 | transmit follow_id: data["id"]
27 | end
28 | end
29 |
30 | describe TestChannel do
31 | let(:user) { "john" }
32 | let(:socket) { TestSocket.new }
33 | let(:connection) { TestConnection.new(socket, identifiers: {"user" => user}.to_json) }
34 | let(:params) { {} }
35 |
36 | subject { described_class.new(connection, "test", params) }
37 |
38 | describe "connection identifiers" do
39 | specify { expect(subject.user).to eq "john" }
40 | end
41 |
42 | describe "#handle_subscribe" do
43 | it "calls #subscribed method" do
44 | subject.handle_subscribe
45 | expect(subject.subscribe).to eq true
46 | end
47 |
48 | context "when rejects" do
49 | let(:params) { {"reject" => true} }
50 |
51 | it "raises error" do
52 | expect { subject.handle_subscribe }.to raise_error(LiteCable::Channel::RejectedError)
53 | end
54 | end
55 | end
56 |
57 | describe "#handle_unsubscribe" do
58 | it "calls #unsubscribed method" do
59 | subject.handle_unsubscribe
60 | expect(subject.unsubscribe).to eq true
61 | end
62 | end
63 |
64 | describe "#handle_action" do
65 | it "call actions without parameters" do
66 | subject.handle_action({"action" => "follow_all"}.to_json)
67 | expect(subject.follows).to eq true
68 | end
69 |
70 | it "call actions with parameters" do
71 | expect { subject.handle_action({"action" => "follow", "id" => 15}.to_json) }.to change(socket.transmissions, :size).by(1)
72 | expect(socket.last_transmission).to eq("message" => {"follow_id" => 15}, "identifier" => "test")
73 | end
74 |
75 | it "calls 'receive' when no action param" do
76 | subject.handle_action({"message" => "Recieve me!"}.to_json)
77 | expect(subject.received).to eq("message" => "Recieve me!")
78 | end
79 |
80 | it "raises error when action is not public" do
81 | expect { subject.handle_action({"action" => "reject"}.to_json) }.to raise_error(LiteCable::Channel::UnproccessableActionError)
82 | end
83 |
84 | it "raises error when action doesn't exist" do
85 | expect { subject.handle_action({"action" => "foobar"}.to_json) }.to raise_error(LiteCable::Channel::UnproccessableActionError)
86 | end
87 | end
88 | end
89 |
--------------------------------------------------------------------------------
/spec/lite_cable/channel/streams_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "spec_helper"
4 |
5 | class TestStreamsChannel < LiteCable::Channel::Base
6 | attr_reader :all_stopped
7 |
8 | def subscribed
9 | stream_from "notifications_#{user}"
10 | end
11 |
12 | def follow(data)
13 | stream_from "chat_#{data["id"]}"
14 | end
15 |
16 | def unfollow(data)
17 | stop_stream "chat_#{data["id"]}"
18 | end
19 |
20 | def stop_all_streams
21 | super
22 | @all_stopped = true
23 | end
24 | end
25 |
26 | describe TestStreamsChannel do
27 | let(:user) { "john" }
28 | let(:socket) { TestSocket.new }
29 | let(:connection) { TestConnection.new(socket, identifiers: {"user" => user}.to_json) }
30 | let(:params) { {} }
31 |
32 | subject { described_class.new(connection, "test", params) }
33 |
34 | describe "#stream_from" do
35 | it "subscribes channel to stream" do
36 | subject.handle_subscribe
37 | expect(socket.streams["test"]).to eq(["notifications_john"])
38 | end
39 | end
40 |
41 | describe "#stop_stream" do
42 | it "unsubscribes channel from stream", :aggregate_failures do
43 | subject.handle_action({"action" => "follow", "id" => 1}.to_json)
44 | expect(socket.streams["test"]).to eq(["chat_1"])
45 |
46 | subject.handle_action({"action" => "unfollow", "id" => 1}.to_json)
47 | expect(socket.streams["test"]).to eq([])
48 | end
49 | end
50 |
51 | describe "#stop_all_streams" do
52 | it "call stop_all_streams on unsubscribe", :aggregate_failures do
53 | subject.handle_subscribe
54 | expect(socket.streams["test"]).to eq(["notifications_john"])
55 |
56 | subject.handle_unsubscribe
57 | expect(subject.all_stopped).to eq true
58 | expect(socket.streams["test"]).to be_nil
59 | end
60 | end
61 | end
62 |
--------------------------------------------------------------------------------
/spec/lite_cable/config_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "spec_helper"
4 |
5 | describe LiteCable::Config do
6 | let(:config) { LiteCable.config }
7 |
8 | it "sets defailts", :aggregate_failures do
9 | expect(config.coder).to eq LiteCable::Coders::JSON
10 | expect(config.identifier_coder).to eq LiteCable::Coders::Raw
11 | end
12 | end
13 |
--------------------------------------------------------------------------------
/spec/lite_cable/connection/authorization_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "spec_helper"
4 |
5 | class TestAuthorizationConnection < LiteCable::Connection::Base
6 | attr_reader :connected
7 |
8 | def connect
9 | reject_unauthorized_connection unless cookies["user"]
10 | @connected = true
11 | end
12 | end
13 |
14 | describe TestAuthorizationConnection do
15 | let(:cookies) { "" }
16 | let(:socket_params) { {env: {"HTTP_COOKIE" => cookies}} }
17 | let(:socket) { TestSocket.new(**socket_params) }
18 |
19 | subject { described_class.new(socket) }
20 |
21 | describe "#handle_open" do
22 | it "raises exception if rejected" do
23 | expect(subject).to receive(:close)
24 | expect { subject.handle_open }.not_to change(socket.transmissions, :size)
25 | end
26 |
27 | context "when accepted" do
28 | let(:cookies) { "user=john;" }
29 |
30 | it "succesfully connects" do
31 | subject.handle_open
32 | expect(subject.connected).to eq true
33 | end
34 | end
35 | end
36 | end
37 |
--------------------------------------------------------------------------------
/spec/lite_cable/connection/base_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "spec_helper"
4 |
5 | class TestBaseConnection < LiteCable::Connection::Base
6 | attr_reader :connected, :disconnected
7 |
8 | def connect
9 | @connected = true
10 | end
11 |
12 | def disconnect
13 | @disconnected = true
14 | end
15 | end
16 |
17 | describe TestBaseConnection do
18 | let(:socket_params) { {} }
19 | let(:socket) { TestSocket.new(**socket_params) }
20 |
21 | subject { described_class.new(socket) }
22 |
23 | describe "#handle_open" do
24 | it "calls #connect method" do
25 | subject.handle_open
26 | expect(subject.connected).to eq true
27 | end
28 |
29 | it "sends welcome message" do
30 | expect { subject.handle_open }.to change(socket.transmissions, :size).by(1)
31 | expect(socket.last_transmission).to eq("type" => "welcome")
32 | end
33 | end
34 |
35 | describe "#handle_close" do
36 | it "calls #disconnect method" do
37 | subject.handle_close
38 | expect(subject.disconnected).to eq true
39 | expect(subject).to be_disconnected
40 | end
41 |
42 | it "calls #unsubscribe_from_all on subscriptions" do
43 | expect(subject.subscriptions).to receive(:remove_all)
44 | subject.handle_close
45 | end
46 | end
47 |
48 | describe "#close" do
49 | it "closes socket" do
50 | subject.close
51 | expect(socket).to be_closed
52 | end
53 | end
54 |
55 | describe "#transmit" do
56 | context "when disconnected" do
57 | it "doesn't transmit messages" do
58 | subject.handle_close
59 | expect { subject.transmit(data: "I'm alive!") }.not_to change(socket.transmissions, :size)
60 | end
61 | end
62 |
63 | context "with non-default coder" do
64 | subject { described_class.new(socket, coder: LiteCable::Coders::Raw) }
65 |
66 | it "uses specified coder" do
67 | subject.transmit '{"coder": "raw"}'
68 | expect(socket.last_transmission).to eq("coder" => "raw")
69 | end
70 | end
71 | end
72 |
73 | describe "#handle_command" do
74 | it "runs subscriptions #execute_command" do
75 | expect(subject.subscriptions).to receive(:execute_command).with({"command" => "test"})
76 | subject.handle_command('{"command":"test"}')
77 | end
78 | end
79 | end
80 |
--------------------------------------------------------------------------------
/spec/lite_cable/connection/identification_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "spec_helper"
4 |
5 | class TestIdentificationConnection < LiteCable::Connection::Base
6 | identified_by :user, :john
7 |
8 | def connect
9 | @user = cookies["user"]
10 | @john = @user == "john"
11 | end
12 | end
13 |
14 | class CustomIdCoder
15 | class << self
16 | def encode(val)
17 | if val.is_a?(String)
18 | val.reverse
19 | else
20 | val
21 | end
22 | end
23 |
24 | alias_method :decode, :encode
25 | end
26 | end
27 |
28 | describe TestIdentificationConnection do
29 | let(:cookies) { "user=john;" }
30 | let(:socket_params) { {env: {"HTTP_COOKIE" => cookies}} }
31 | let(:socket) { TestSocket.new(**socket_params) }
32 |
33 | subject do
34 | described_class.new(socket).tap(&:handle_open)
35 | end
36 |
37 | it "create accessors" do
38 | expect(subject.user).to eq "john"
39 | expect(subject.john).to eq true
40 | end
41 |
42 | describe "#identifier" do
43 | it "returns string identifier" do
44 | expect(subject.identifier).to eq("john:true")
45 | end
46 |
47 | context "when some identifiers are nil" do
48 | let(:cookies) { "user=jack" }
49 |
50 | it "returns string identifier" do
51 | expect(subject.identifier).to eq("jack")
52 | end
53 | end
54 |
55 | context "when all identifiers are nil" do
56 | let(:cookies) { "" }
57 |
58 | it "returns string identifier" do
59 | expect(subject.identifier).to be_nil
60 | end
61 | end
62 |
63 | context "with custom identifier coder" do
64 | prepend_before { allow(LiteCable.config).to receive(:identifier_coder).and_return(CustomIdCoder) }
65 |
66 | it "uses custom id coder" do
67 | expect(subject.identifier).to eq("nhoj:true")
68 | end
69 | end
70 | end
71 |
72 | describe "#identifiers_hash" do
73 | it "returns a hash" do
74 | expect(subject.identifiers_hash).to eq("user" => "john", "john" => true)
75 | end
76 | end
77 |
78 | context "with encoded_identifiers" do
79 | prepend_before { allow(LiteCable.config).to receive(:identifier_coder).and_return(CustomIdCoder) }
80 |
81 | let(:identifiers) { {"user" => "kcaj", "john" => false}.to_json }
82 |
83 | subject { described_class.new(socket, identifiers: identifiers) }
84 |
85 | it "deserialize values from provided hash" do
86 | expect(subject.user).to eq "jack"
87 | expect(subject.john).to eq false
88 | end
89 |
90 | it "calls decoded only once" do
91 | expect(CustomIdCoder).to receive(:decode).once
92 | subject.user
93 | subject.user
94 | end
95 | end
96 | end
97 |
--------------------------------------------------------------------------------
/spec/lite_cable/connection/subscriptions_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "spec_helper"
4 |
5 | Class.new(LiteCable::Channel::Base) do
6 | identifier "subscription_test"
7 |
8 | def subscribed
9 | reject if params["reject"] == true
10 | @subscribed = true
11 | end
12 |
13 | def subscribed?
14 | @subscribed == true
15 | end
16 |
17 | def unsubscribed
18 | @unsubscribed = true
19 | end
20 |
21 | def unsubscribed?
22 | @unsubscribed == true
23 | end
24 | end
25 |
26 | Class.new(LiteCable::Channel::Base) do
27 | identifier "subscription_test2"
28 | end
29 |
30 | describe LiteCable::Connection::Subscriptions do
31 | let(:socket) { TestSocket.new }
32 | let(:connection) { LiteCable::Connection::Base.new(socket) }
33 |
34 | subject { described_class.new(connection) }
35 |
36 | describe "#add" do
37 | it "creates channel", :aggregate_failures do
38 | id = {channel: "subscription_test"}.to_json
39 | channel = subject.add(id)
40 | expect(channel).to be_subscribed
41 | expect(subject.identifiers).to include(id)
42 | end
43 |
44 | it "sends confirmation" do
45 | id = {channel: "subscription_test"}.to_json
46 | expect { subject.add(id) }.to change(socket.transmissions, :size).by(1)
47 | expect(socket.last_transmission).to eq("identifier" => id, "type" => "confirm_subscription")
48 | end
49 |
50 | it "handles params and identifier", :aggregate_failures do
51 | id = {channel: "subscription_test", id: 1, type: "test"}.to_json
52 | channel = subject.add(id)
53 | expect(channel.identifier).to eq id
54 | expect(channel.params).to eq("id" => 1, "type" => "test")
55 | end
56 |
57 | it "handles rejection", :aggregate_failures do
58 | id = {channel: "subscription_test", reject: true}.to_json
59 | channel = subject.add(id)
60 | expect(channel).to be_nil
61 | expect(subject.identifiers).not_to include(id)
62 | expect(socket.last_transmission).to eq("identifier" => id, "type" => "reject_subscription")
63 | end
64 | end
65 |
66 | describe "#remove" do
67 | let(:id) { {channel: "subscription_test"}.to_json }
68 | let!(:channel) { subject.add(id) }
69 |
70 | it "removes subscription and send cancel confirmation", :aggregate_failures do
71 | subject.remove(id)
72 | expect(channel).to be_unsubscribed
73 | expect(subject.identifiers).not_to include(id)
74 | expect(socket.last_transmission).to eq("identifier" => id, "type" => "cancel_subscription")
75 | end
76 | end
77 |
78 | describe "#remove_all" do
79 | let(:id) { {channel: "subscription_test"}.to_json }
80 | let(:id2) { {channel: "subscription_test2"}.to_json }
81 |
82 | let(:channel) { subject.add(id) }
83 | let(:channel2) { subject.add(id2) }
84 |
85 | it "removes all subscriptions and send confirmations", :aggregate_failures do
86 | expect(channel).to receive(:handle_unsubscribe)
87 | expect(channel2).to receive(:handle_unsubscribe)
88 |
89 | subject.remove_all
90 | expect(subject.identifiers).to eq([])
91 | end
92 | end
93 |
94 | describe "#execute_command" do
95 | it "handles subscribe" do
96 | expect(subject).to receive(:add).with("subscription_test")
97 | subject.execute_command("command" => "subscribe", "identifier" => "subscription_test")
98 | end
99 |
100 | it "handles unsubscribe" do
101 | expect(subject).to receive(:remove).with("subscription_test")
102 | subject.execute_command("command" => "unsubscribe", "identifier" => "subscription_test")
103 | end
104 |
105 | it "handles message" do
106 | channel = double("channel")
107 | expect(subject).to receive(:find).with("subscription_test").and_return(channel)
108 | expect(channel).to receive(:handle_action).with('{"action":"test"}')
109 | subject.execute_command("command" => "message", "identifier" => "subscription_test", "data" => {action: "test"}.to_json)
110 | end
111 |
112 | it "raises error on unknown command error" do
113 | expect { subject.execute_command("command" => "test") }.to raise_error(LiteCable::Connection::Subscriptions::UnknownCommandError)
114 | end
115 | end
116 | end
117 |
--------------------------------------------------------------------------------
/spec/lite_cable/server/subscribers_map_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "spec_helper"
4 |
5 | describe LiteCable::Server::SubscribersMap do
6 | let(:socket) { spy("socket") }
7 | let(:socket2) { spy("socket2") }
8 | let(:coder) { LiteCable::Coders::JSON }
9 |
10 | subject { described_class.new }
11 |
12 | describe "#add_subscriber" do
13 | it "adds one socket" do
14 | subject.add_subscriber "test", socket, "channel"
15 | subject.broadcast "test", "blabla", coder
16 | expect(socket).to have_received(:transmit).with({identifier: "channel", message: "blabla"}.to_json)
17 | end
18 |
19 | it "adds several sockets", :aggregate_failures do
20 | subject.add_subscriber "test", socket, "channel"
21 | subject.add_subscriber "test", socket2, "channel2"
22 | subject.add_subscriber "test2", socket, "channel"
23 |
24 | subject.broadcast "test", "blabla", coder
25 | expect(socket).to have_received(:transmit).with({identifier: "channel", message: "blabla"}.to_json)
26 | expect(socket2).to have_received(:transmit).with({identifier: "channel2", message: "blabla"}.to_json)
27 |
28 | subject.broadcast "test2", "blublu", coder
29 | expect(socket).to have_received(:transmit).with({identifier: "channel", message: "blublu"}.to_json)
30 | expect(socket2).not_to have_received(:transmit).with({identifier: "channel2", message: "blublu"}.to_json)
31 | end
32 | end
33 |
34 | describe "#remove_subscriber" do
35 | before do
36 | subject.add_subscriber "test", socket, "channel"
37 | subject.add_subscriber "test2", socket, "channel"
38 | end
39 |
40 | it "removes socket from stream" do
41 | subject.remove_subscriber "test", socket, "channel"
42 | subject.broadcast "test", "blabla", coder
43 | subject.broadcast "test2", "blublu", coder
44 |
45 | expect(socket).not_to have_received(:transmit).with({identifier: "channel", message: "blabla"}.to_json)
46 | expect(socket).to have_received(:transmit).with({identifier: "channel", message: "blublu"}.to_json)
47 | end
48 | end
49 |
50 | describe "#remove_socket" do
51 | before do
52 | subject.add_subscriber "test", socket, "channel"
53 | subject.add_subscriber "test2", socket, "channel"
54 | subject.add_subscriber "test3", socket, "channel2"
55 | end
56 |
57 | it "removes socket from all streams" do
58 | subject.remove_socket socket, "channel"
59 | subject.broadcast "test", "blabla", coder
60 | subject.broadcast "test2", "blublu", coder
61 | subject.broadcast "test3", "brobro", coder
62 |
63 | expect(socket).not_to have_received(:transmit).with({identifier: "channel", message: "blabla"}.to_json)
64 | expect(socket).not_to have_received(:transmit).with({identifier: "channel", message: "blublu"}.to_json)
65 | expect(socket).to have_received(:transmit).with({identifier: "channel2", message: "brobro"}.to_json)
66 | end
67 | end
68 | end
69 |
--------------------------------------------------------------------------------
/spec/litecable_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "spec_helper"
4 |
5 | describe LiteCable do
6 | it "has a version number" do
7 | expect(LiteCable::VERSION).not_to be nil
8 | end
9 | end
10 |
--------------------------------------------------------------------------------
/spec/spec_helper.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | begin
4 | require "debug" unless ENV["CI"] == "true"
5 | rescue LoadError
6 | end
7 |
8 | $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
9 | require "lite_cable"
10 | require "lite_cable/server"
11 |
12 | Dir["#{File.dirname(__FILE__)}/support/**/*.rb"].sort.each { |f| require f }
13 |
14 | RSpec.configure do |config|
15 | config.mock_with :rspec do |mocks|
16 | mocks.verify_partial_doubles = true
17 | end
18 |
19 | config.include AsyncHelpers, async: true
20 |
21 | config.example_status_persistence_file_path = "tmp/rspec_examples.txt"
22 | config.filter_run :focus
23 | config.run_all_when_everything_filtered = true
24 |
25 | config.order = :random
26 | Kernel.srand config.seed
27 | end
28 |
--------------------------------------------------------------------------------
/spec/support/async_helpers.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module AsyncHelpers
4 | # Wait for block to return true of raise error
5 | def wait(timeout = 1)
6 | until yield
7 | sleep 0.1
8 | timeout -= 0.1
9 | raise "Timeout error" unless timeout > 0
10 | end
11 | end
12 |
13 | def concurrently(enum)
14 | enum.map { |*x| Concurrent::Future.execute { yield(*x) } }.map(&:value!)
15 | end
16 | end
17 |
--------------------------------------------------------------------------------
/spec/support/sync_client.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Synchronous websocket client
4 | # Based on https://github.com/rails/rails/blob/v5.0.1/actioncable/test/client_test.rb
5 | class SyncClient
6 | require "websocket-client-simple"
7 | require "concurrent"
8 |
9 | WAIT_WHEN_EXPECTING_EVENT = 5
10 | WAIT_WHEN_NOT_EXPECTING_EVENT = 0.5
11 |
12 | attr_reader :pings
13 |
14 | def initialize(url, cookies: "")
15 | messages = @messages = Queue.new
16 | closed = @closed = Concurrent::Event.new
17 | has_messages = @has_messages = Concurrent::Semaphore.new(0)
18 | pings = @pings = Concurrent::AtomicFixnum.new(0)
19 |
20 | open = Concurrent::Promise.new
21 |
22 | @ws = WebSocket::Client::Simple.connect(
23 | url,
24 | headers: {
25 | "COOKIE" => cookies
26 | }
27 | ) do |ws|
28 | ws.on(:error) do |event|
29 | event = RuntimeError.new(event.message) unless event.is_a?(Exception)
30 |
31 | if open.pending?
32 | open.fail(event)
33 | else
34 | messages << event
35 | has_messages.release
36 | end
37 | end
38 |
39 | ws.on(:open) do |_event|
40 | open.set(true)
41 | end
42 |
43 | ws.on(:message) do |event|
44 | if event.type == :close
45 | closed.set
46 | else
47 | message = JSON.parse(event.data)
48 | if message["type"] == "ping"
49 | pings.increment
50 | else
51 | messages << message
52 | has_messages.release
53 | end
54 | end
55 | end
56 |
57 | ws.on(:close) do |_event|
58 | closed.set
59 | end
60 | end
61 |
62 | open.wait!(WAIT_WHEN_EXPECTING_EVENT)
63 | end
64 |
65 | def read_message
66 | @has_messages.try_acquire(1, WAIT_WHEN_EXPECTING_EVENT)
67 |
68 | msg = @messages.pop(true)
69 | raise msg if msg.is_a?(Exception)
70 |
71 | msg
72 | end
73 |
74 | def read_messages(expected_size = 0)
75 | list = []
76 | loop do
77 | break unless @has_messages.try_acquire(1, (list.size < expected_size) ? WAIT_WHEN_EXPECTING_EVENT : WAIT_WHEN_NOT_EXPECTING_EVENT)
78 |
79 | msg = @messages.pop(true)
80 | raise msg if msg.is_a?(Exception)
81 |
82 | list << msg
83 | end
84 | list
85 | end
86 |
87 | def send_message(message)
88 | @ws.send(JSON.generate(message))
89 | end
90 |
91 | def close
92 | sleep WAIT_WHEN_NOT_EXPECTING_EVENT
93 |
94 | raise "#{@messages.size} messages unprocessed" unless @messages.empty?
95 |
96 | @ws.close
97 | wait_for_close
98 | end
99 |
100 | def wait_for_close
101 | @closed.wait(WAIT_WHEN_EXPECTING_EVENT)
102 | end
103 |
104 | def closed?
105 | @closed.set?
106 | end
107 | end
108 |
--------------------------------------------------------------------------------
/spec/support/test_connection.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Test connection with `user` identifier
4 | class TestConnection < LiteCable::Connection::Base
5 | identified_by :user
6 | end
7 |
--------------------------------------------------------------------------------
/spec/support/test_socket.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "rack"
4 | # Stub connection socket
5 | class TestSocket
6 | attr_reader :transmissions, :streams
7 |
8 | def initialize(coder: LiteCable::Coders::JSON, env: {})
9 | @transmissions = []
10 | @streams = {}
11 | @coder = coder
12 | @env = env
13 | end
14 |
15 | def transmit(websocket_message)
16 | @transmissions << websocket_message
17 | end
18 |
19 | def last_transmission
20 | decode(@transmissions.last) if @transmissions.any?
21 | end
22 |
23 | def decode(websocket_message)
24 | @coder.decode websocket_message
25 | end
26 |
27 | def subscribe(channel, broadcasting)
28 | streams[channel] ||= []
29 | streams[channel] << broadcasting
30 | end
31 |
32 | def unsubscribe(channel, broadcasting)
33 | streams[channel]&.delete(broadcasting)
34 | end
35 |
36 | def unsubscribe_from_all(channel)
37 | streams.delete(channel)
38 | end
39 |
40 | def close
41 | @closed = true
42 | end
43 |
44 | def closed?
45 | @closed == true
46 | end
47 |
48 | def request
49 | @request ||= Rack::Request.new(@env)
50 | end
51 | end
52 |
--------------------------------------------------------------------------------