actioncable 7.0.0.alpha2 → 7.0.0.rc1

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8a0c741b2d34d3c376aa96f0d1f4aaf02991e387a05caa23097b11004c0dc7a1
4
- data.tar.gz: 30d7657409916c61a7e2cabbdee7ceac274872bf8b149ea60e7f0f48a3aea575
3
+ metadata.gz: 77fed9177b7396d25b06497f0415166d69ec9548766d1f4d626f8969f8479182
4
+ data.tar.gz: 9dab0cc363d5b4a4f9f6c351ca07640ecf51a19e309fe407b6ffc447eaa7537b
5
5
  SHA512:
6
- metadata.gz: cf8fc4b6c400845726cf1dab3ad2a145494785f98f58572ad4b6355974477759122456c06de790116ac287c29aaade50ddacf61dc4ed746f3e95de6380715e0c
7
- data.tar.gz: 37b7b67fe3fcf93a7e4472ebd5b4471259715f6aacb6b19b0a169fc0f4d32d8de4d180c8b974923a86ad36b891ceecd2ba54a1bf36133e030d5efce0202b0858
6
+ metadata.gz: 776737a1c60a7f7c32eded16455f2394c9554833746f587578d2ff38f8af074029cd511a933726ad21d7bae0d8f5a369468521531d95473f409ffe545c7b882c
7
+ data.tar.gz: 276df3bead6e608db6ab342d5de7f460598cb5a23cb1a0623135090e8fefffd2a5ae18e88ec7ec87fd66a8231d1fa5a4f1c0a9b2d66d743c1aee7df291242f19
data/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ * The Action Cable client now ensures successful channel subscriptions:
2
+
3
+ * The client maintains a set of pending subscriptions until either
4
+ the server confirms the subscription or the channel is torn down.
5
+ * Rectifies the race condition where an unsubscribe is rapidly followed
6
+ by a subscribe (on the same channel identifier) and the requests are
7
+ handled out of order by the ActionCable server, thereby ignoring the
8
+ subscribe command.
9
+
10
+ *Daniel Spinosa*
11
+
12
+
1
13
  ## Rails 7.0.0.alpha2 (September 15, 2021) ##
2
14
 
3
15
  * No changes.
@@ -246,6 +246,7 @@
246
246
  return this.monitor.recordPing();
247
247
 
248
248
  case message_types.confirmation:
249
+ this.subscriptions.confirmSubscription(identifier);
249
250
  return this.subscriptions.notify(identifier, "connected");
250
251
 
251
252
  case message_types.rejection:
@@ -310,9 +311,46 @@
310
311
  return this.consumer.subscriptions.remove(this);
311
312
  }
312
313
  }
314
+ class SubscriptionGuarantor {
315
+ constructor(subscriptions) {
316
+ this.subscriptions = subscriptions;
317
+ this.pendingSubscriptions = [];
318
+ }
319
+ guarantee(subscription) {
320
+ if (this.pendingSubscriptions.indexOf(subscription) == -1) {
321
+ logger.log(`SubscriptionGuarantor guaranteeing ${subscription.identifier}`);
322
+ this.pendingSubscriptions.push(subscription);
323
+ } else {
324
+ logger.log(`SubscriptionGuarantor already guaranteeing ${subscription.identifier}`);
325
+ }
326
+ this.startGuaranteeing();
327
+ }
328
+ forget(subscription) {
329
+ logger.log(`SubscriptionGuarantor forgetting ${subscription.identifier}`);
330
+ this.pendingSubscriptions = this.pendingSubscriptions.filter((s => s !== subscription));
331
+ }
332
+ startGuaranteeing() {
333
+ this.stopGuaranteeing();
334
+ this.retrySubscribing();
335
+ }
336
+ stopGuaranteeing() {
337
+ clearTimeout(this.retryTimeout);
338
+ }
339
+ retrySubscribing() {
340
+ this.retryTimeout = setTimeout((() => {
341
+ if (this.subscriptions && typeof this.subscriptions.subscribe === "function") {
342
+ this.pendingSubscriptions.map((subscription => {
343
+ logger.log(`SubscriptionGuarantor resubscribing ${subscription.identifier}`);
344
+ this.subscriptions.subscribe(subscription);
345
+ }));
346
+ }
347
+ }), 500);
348
+ }
349
+ }
313
350
  class Subscriptions {
314
351
  constructor(consumer) {
315
352
  this.consumer = consumer;
353
+ this.guarantor = new SubscriptionGuarantor(this);
316
354
  this.subscriptions = [];
317
355
  }
318
356
  create(channelName, mixin) {
@@ -327,7 +365,7 @@
327
365
  this.subscriptions.push(subscription);
328
366
  this.consumer.ensureActiveConnection();
329
367
  this.notify(subscription, "initialized");
330
- this.sendCommand(subscription, "subscribe");
368
+ this.subscribe(subscription);
331
369
  return subscription;
332
370
  }
333
371
  remove(subscription) {
@@ -345,6 +383,7 @@
345
383
  }));
346
384
  }
347
385
  forget(subscription) {
386
+ this.guarantor.forget(subscription);
348
387
  this.subscriptions = this.subscriptions.filter((s => s !== subscription));
349
388
  return subscription;
350
389
  }
@@ -352,7 +391,7 @@
352
391
  return this.subscriptions.filter((s => s.identifier === identifier));
353
392
  }
354
393
  reload() {
355
- return this.subscriptions.map((subscription => this.sendCommand(subscription, "subscribe")));
394
+ return this.subscriptions.map((subscription => this.subscribe(subscription)));
356
395
  }
357
396
  notifyAll(callbackName, ...args) {
358
397
  return this.subscriptions.map((subscription => this.notify(subscription, callbackName, ...args)));
@@ -366,6 +405,15 @@
366
405
  }
367
406
  return subscriptions.map((subscription => typeof subscription[callbackName] === "function" ? subscription[callbackName](...args) : undefined));
368
407
  }
408
+ subscribe(subscription) {
409
+ if (this.sendCommand(subscription, "subscribe")) {
410
+ this.guarantor.guarantee(subscription);
411
+ }
412
+ }
413
+ confirmSubscription(identifier) {
414
+ logger.log(`Subscription confirmed ${identifier}`);
415
+ this.findAll(identifier).map((subscription => this.guarantor.forget(subscription)));
416
+ }
369
417
  sendCommand(subscription, command) {
370
418
  const {identifier: identifier} = subscription;
371
419
  return this.consumer.send({
@@ -429,6 +477,7 @@
429
477
  exports.Consumer = Consumer;
430
478
  exports.INTERNAL = INTERNAL;
431
479
  exports.Subscription = Subscription;
480
+ exports.SubscriptionGuarantor = SubscriptionGuarantor;
432
481
  exports.Subscriptions = Subscriptions;
433
482
  exports.adapters = adapters;
434
483
  exports.createConsumer = createConsumer;
@@ -0,0 +1,491 @@
1
+ var adapters = {
2
+ logger: self.console,
3
+ WebSocket: self.WebSocket
4
+ };
5
+
6
+ var logger = {
7
+ log(...messages) {
8
+ if (this.enabled) {
9
+ messages.push(Date.now());
10
+ adapters.logger.log("[ActionCable]", ...messages);
11
+ }
12
+ }
13
+ };
14
+
15
+ const now = () => (new Date).getTime();
16
+
17
+ const secondsSince = time => (now() - time) / 1e3;
18
+
19
+ class ConnectionMonitor {
20
+ constructor(connection) {
21
+ this.visibilityDidChange = this.visibilityDidChange.bind(this);
22
+ this.connection = connection;
23
+ this.reconnectAttempts = 0;
24
+ }
25
+ start() {
26
+ if (!this.isRunning()) {
27
+ this.startedAt = now();
28
+ delete this.stoppedAt;
29
+ this.startPolling();
30
+ addEventListener("visibilitychange", this.visibilityDidChange);
31
+ logger.log(`ConnectionMonitor started. stale threshold = ${this.constructor.staleThreshold} s`);
32
+ }
33
+ }
34
+ stop() {
35
+ if (this.isRunning()) {
36
+ this.stoppedAt = now();
37
+ this.stopPolling();
38
+ removeEventListener("visibilitychange", this.visibilityDidChange);
39
+ logger.log("ConnectionMonitor stopped");
40
+ }
41
+ }
42
+ isRunning() {
43
+ return this.startedAt && !this.stoppedAt;
44
+ }
45
+ recordPing() {
46
+ this.pingedAt = now();
47
+ }
48
+ recordConnect() {
49
+ this.reconnectAttempts = 0;
50
+ this.recordPing();
51
+ delete this.disconnectedAt;
52
+ logger.log("ConnectionMonitor recorded connect");
53
+ }
54
+ recordDisconnect() {
55
+ this.disconnectedAt = now();
56
+ logger.log("ConnectionMonitor recorded disconnect");
57
+ }
58
+ startPolling() {
59
+ this.stopPolling();
60
+ this.poll();
61
+ }
62
+ stopPolling() {
63
+ clearTimeout(this.pollTimeout);
64
+ }
65
+ poll() {
66
+ this.pollTimeout = setTimeout((() => {
67
+ this.reconnectIfStale();
68
+ this.poll();
69
+ }), this.getPollInterval());
70
+ }
71
+ getPollInterval() {
72
+ const {staleThreshold: staleThreshold, reconnectionBackoffRate: reconnectionBackoffRate} = this.constructor;
73
+ const backoff = Math.pow(1 + reconnectionBackoffRate, Math.min(this.reconnectAttempts, 10));
74
+ const jitterMax = this.reconnectAttempts === 0 ? 1 : reconnectionBackoffRate;
75
+ const jitter = jitterMax * Math.random();
76
+ return staleThreshold * 1e3 * backoff * (1 + jitter);
77
+ }
78
+ reconnectIfStale() {
79
+ if (this.connectionIsStale()) {
80
+ logger.log(`ConnectionMonitor detected stale connection. reconnectAttempts = ${this.reconnectAttempts}, time stale = ${secondsSince(this.refreshedAt)} s, stale threshold = ${this.constructor.staleThreshold} s`);
81
+ this.reconnectAttempts++;
82
+ if (this.disconnectedRecently()) {
83
+ logger.log(`ConnectionMonitor skipping reopening recent disconnect. time disconnected = ${secondsSince(this.disconnectedAt)} s`);
84
+ } else {
85
+ logger.log("ConnectionMonitor reopening");
86
+ this.connection.reopen();
87
+ }
88
+ }
89
+ }
90
+ get refreshedAt() {
91
+ return this.pingedAt ? this.pingedAt : this.startedAt;
92
+ }
93
+ connectionIsStale() {
94
+ return secondsSince(this.refreshedAt) > this.constructor.staleThreshold;
95
+ }
96
+ disconnectedRecently() {
97
+ return this.disconnectedAt && secondsSince(this.disconnectedAt) < this.constructor.staleThreshold;
98
+ }
99
+ visibilityDidChange() {
100
+ if (document.visibilityState === "visible") {
101
+ setTimeout((() => {
102
+ if (this.connectionIsStale() || !this.connection.isOpen()) {
103
+ logger.log(`ConnectionMonitor reopening stale connection on visibilitychange. visibilityState = ${document.visibilityState}`);
104
+ this.connection.reopen();
105
+ }
106
+ }), 200);
107
+ }
108
+ }
109
+ }
110
+
111
+ ConnectionMonitor.staleThreshold = 6;
112
+
113
+ ConnectionMonitor.reconnectionBackoffRate = .15;
114
+
115
+ var INTERNAL = {
116
+ message_types: {
117
+ welcome: "welcome",
118
+ disconnect: "disconnect",
119
+ ping: "ping",
120
+ confirmation: "confirm_subscription",
121
+ rejection: "reject_subscription"
122
+ },
123
+ disconnect_reasons: {
124
+ unauthorized: "unauthorized",
125
+ invalid_request: "invalid_request",
126
+ server_restart: "server_restart"
127
+ },
128
+ default_mount_path: "/cable",
129
+ protocols: [ "actioncable-v1-json", "actioncable-unsupported" ]
130
+ };
131
+
132
+ const {message_types: message_types, protocols: protocols} = INTERNAL;
133
+
134
+ const supportedProtocols = protocols.slice(0, protocols.length - 1);
135
+
136
+ const indexOf = [].indexOf;
137
+
138
+ class Connection {
139
+ constructor(consumer) {
140
+ this.open = this.open.bind(this);
141
+ this.consumer = consumer;
142
+ this.subscriptions = this.consumer.subscriptions;
143
+ this.monitor = new ConnectionMonitor(this);
144
+ this.disconnected = true;
145
+ }
146
+ send(data) {
147
+ if (this.isOpen()) {
148
+ this.webSocket.send(JSON.stringify(data));
149
+ return true;
150
+ } else {
151
+ return false;
152
+ }
153
+ }
154
+ open() {
155
+ if (this.isActive()) {
156
+ logger.log(`Attempted to open WebSocket, but existing socket is ${this.getState()}`);
157
+ return false;
158
+ } else {
159
+ logger.log(`Opening WebSocket, current state is ${this.getState()}, subprotocols: ${protocols}`);
160
+ if (this.webSocket) {
161
+ this.uninstallEventHandlers();
162
+ }
163
+ this.webSocket = new adapters.WebSocket(this.consumer.url, protocols);
164
+ this.installEventHandlers();
165
+ this.monitor.start();
166
+ return true;
167
+ }
168
+ }
169
+ close({allowReconnect: allowReconnect} = {
170
+ allowReconnect: true
171
+ }) {
172
+ if (!allowReconnect) {
173
+ this.monitor.stop();
174
+ }
175
+ if (this.isActive()) {
176
+ return this.webSocket.close();
177
+ }
178
+ }
179
+ reopen() {
180
+ logger.log(`Reopening WebSocket, current state is ${this.getState()}`);
181
+ if (this.isActive()) {
182
+ try {
183
+ return this.close();
184
+ } catch (error) {
185
+ logger.log("Failed to reopen WebSocket", error);
186
+ } finally {
187
+ logger.log(`Reopening WebSocket in ${this.constructor.reopenDelay}ms`);
188
+ setTimeout(this.open, this.constructor.reopenDelay);
189
+ }
190
+ } else {
191
+ return this.open();
192
+ }
193
+ }
194
+ getProtocol() {
195
+ if (this.webSocket) {
196
+ return this.webSocket.protocol;
197
+ }
198
+ }
199
+ isOpen() {
200
+ return this.isState("open");
201
+ }
202
+ isActive() {
203
+ return this.isState("open", "connecting");
204
+ }
205
+ isProtocolSupported() {
206
+ return indexOf.call(supportedProtocols, this.getProtocol()) >= 0;
207
+ }
208
+ isState(...states) {
209
+ return indexOf.call(states, this.getState()) >= 0;
210
+ }
211
+ getState() {
212
+ if (this.webSocket) {
213
+ for (let state in adapters.WebSocket) {
214
+ if (adapters.WebSocket[state] === this.webSocket.readyState) {
215
+ return state.toLowerCase();
216
+ }
217
+ }
218
+ }
219
+ return null;
220
+ }
221
+ installEventHandlers() {
222
+ for (let eventName in this.events) {
223
+ const handler = this.events[eventName].bind(this);
224
+ this.webSocket[`on${eventName}`] = handler;
225
+ }
226
+ }
227
+ uninstallEventHandlers() {
228
+ for (let eventName in this.events) {
229
+ this.webSocket[`on${eventName}`] = function() {};
230
+ }
231
+ }
232
+ }
233
+
234
+ Connection.reopenDelay = 500;
235
+
236
+ Connection.prototype.events = {
237
+ message(event) {
238
+ if (!this.isProtocolSupported()) {
239
+ return;
240
+ }
241
+ const {identifier: identifier, message: message, reason: reason, reconnect: reconnect, type: type} = JSON.parse(event.data);
242
+ switch (type) {
243
+ case message_types.welcome:
244
+ this.monitor.recordConnect();
245
+ return this.subscriptions.reload();
246
+
247
+ case message_types.disconnect:
248
+ logger.log(`Disconnecting. Reason: ${reason}`);
249
+ return this.close({
250
+ allowReconnect: reconnect
251
+ });
252
+
253
+ case message_types.ping:
254
+ return this.monitor.recordPing();
255
+
256
+ case message_types.confirmation:
257
+ this.subscriptions.confirmSubscription(identifier);
258
+ return this.subscriptions.notify(identifier, "connected");
259
+
260
+ case message_types.rejection:
261
+ return this.subscriptions.reject(identifier);
262
+
263
+ default:
264
+ return this.subscriptions.notify(identifier, "received", message);
265
+ }
266
+ },
267
+ open() {
268
+ logger.log(`WebSocket onopen event, using '${this.getProtocol()}' subprotocol`);
269
+ this.disconnected = false;
270
+ if (!this.isProtocolSupported()) {
271
+ logger.log("Protocol is unsupported. Stopping monitor and disconnecting.");
272
+ return this.close({
273
+ allowReconnect: false
274
+ });
275
+ }
276
+ },
277
+ close(event) {
278
+ logger.log("WebSocket onclose event");
279
+ if (this.disconnected) {
280
+ return;
281
+ }
282
+ this.disconnected = true;
283
+ this.monitor.recordDisconnect();
284
+ return this.subscriptions.notifyAll("disconnected", {
285
+ willAttemptReconnect: this.monitor.isRunning()
286
+ });
287
+ },
288
+ error() {
289
+ logger.log("WebSocket onerror event");
290
+ }
291
+ };
292
+
293
+ const extend = function(object, properties) {
294
+ if (properties != null) {
295
+ for (let key in properties) {
296
+ const value = properties[key];
297
+ object[key] = value;
298
+ }
299
+ }
300
+ return object;
301
+ };
302
+
303
+ class Subscription {
304
+ constructor(consumer, params = {}, mixin) {
305
+ this.consumer = consumer;
306
+ this.identifier = JSON.stringify(params);
307
+ extend(this, mixin);
308
+ }
309
+ perform(action, data = {}) {
310
+ data.action = action;
311
+ return this.send(data);
312
+ }
313
+ send(data) {
314
+ return this.consumer.send({
315
+ command: "message",
316
+ identifier: this.identifier,
317
+ data: JSON.stringify(data)
318
+ });
319
+ }
320
+ unsubscribe() {
321
+ return this.consumer.subscriptions.remove(this);
322
+ }
323
+ }
324
+
325
+ class SubscriptionGuarantor {
326
+ constructor(subscriptions) {
327
+ this.subscriptions = subscriptions;
328
+ this.pendingSubscriptions = [];
329
+ }
330
+ guarantee(subscription) {
331
+ if (this.pendingSubscriptions.indexOf(subscription) == -1) {
332
+ logger.log(`SubscriptionGuarantor guaranteeing ${subscription.identifier}`);
333
+ this.pendingSubscriptions.push(subscription);
334
+ } else {
335
+ logger.log(`SubscriptionGuarantor already guaranteeing ${subscription.identifier}`);
336
+ }
337
+ this.startGuaranteeing();
338
+ }
339
+ forget(subscription) {
340
+ logger.log(`SubscriptionGuarantor forgetting ${subscription.identifier}`);
341
+ this.pendingSubscriptions = this.pendingSubscriptions.filter((s => s !== subscription));
342
+ }
343
+ startGuaranteeing() {
344
+ this.stopGuaranteeing();
345
+ this.retrySubscribing();
346
+ }
347
+ stopGuaranteeing() {
348
+ clearTimeout(this.retryTimeout);
349
+ }
350
+ retrySubscribing() {
351
+ this.retryTimeout = setTimeout((() => {
352
+ if (this.subscriptions && typeof this.subscriptions.subscribe === "function") {
353
+ this.pendingSubscriptions.map((subscription => {
354
+ logger.log(`SubscriptionGuarantor resubscribing ${subscription.identifier}`);
355
+ this.subscriptions.subscribe(subscription);
356
+ }));
357
+ }
358
+ }), 500);
359
+ }
360
+ }
361
+
362
+ class Subscriptions {
363
+ constructor(consumer) {
364
+ this.consumer = consumer;
365
+ this.guarantor = new SubscriptionGuarantor(this);
366
+ this.subscriptions = [];
367
+ }
368
+ create(channelName, mixin) {
369
+ const channel = channelName;
370
+ const params = typeof channel === "object" ? channel : {
371
+ channel: channel
372
+ };
373
+ const subscription = new Subscription(this.consumer, params, mixin);
374
+ return this.add(subscription);
375
+ }
376
+ add(subscription) {
377
+ this.subscriptions.push(subscription);
378
+ this.consumer.ensureActiveConnection();
379
+ this.notify(subscription, "initialized");
380
+ this.subscribe(subscription);
381
+ return subscription;
382
+ }
383
+ remove(subscription) {
384
+ this.forget(subscription);
385
+ if (!this.findAll(subscription.identifier).length) {
386
+ this.sendCommand(subscription, "unsubscribe");
387
+ }
388
+ return subscription;
389
+ }
390
+ reject(identifier) {
391
+ return this.findAll(identifier).map((subscription => {
392
+ this.forget(subscription);
393
+ this.notify(subscription, "rejected");
394
+ return subscription;
395
+ }));
396
+ }
397
+ forget(subscription) {
398
+ this.guarantor.forget(subscription);
399
+ this.subscriptions = this.subscriptions.filter((s => s !== subscription));
400
+ return subscription;
401
+ }
402
+ findAll(identifier) {
403
+ return this.subscriptions.filter((s => s.identifier === identifier));
404
+ }
405
+ reload() {
406
+ return this.subscriptions.map((subscription => this.subscribe(subscription)));
407
+ }
408
+ notifyAll(callbackName, ...args) {
409
+ return this.subscriptions.map((subscription => this.notify(subscription, callbackName, ...args)));
410
+ }
411
+ notify(subscription, callbackName, ...args) {
412
+ let subscriptions;
413
+ if (typeof subscription === "string") {
414
+ subscriptions = this.findAll(subscription);
415
+ } else {
416
+ subscriptions = [ subscription ];
417
+ }
418
+ return subscriptions.map((subscription => typeof subscription[callbackName] === "function" ? subscription[callbackName](...args) : undefined));
419
+ }
420
+ subscribe(subscription) {
421
+ if (this.sendCommand(subscription, "subscribe")) {
422
+ this.guarantor.guarantee(subscription);
423
+ }
424
+ }
425
+ confirmSubscription(identifier) {
426
+ logger.log(`Subscription confirmed ${identifier}`);
427
+ this.findAll(identifier).map((subscription => this.guarantor.forget(subscription)));
428
+ }
429
+ sendCommand(subscription, command) {
430
+ const {identifier: identifier} = subscription;
431
+ return this.consumer.send({
432
+ command: command,
433
+ identifier: identifier
434
+ });
435
+ }
436
+ }
437
+
438
+ class Consumer {
439
+ constructor(url) {
440
+ this._url = url;
441
+ this.subscriptions = new Subscriptions(this);
442
+ this.connection = new Connection(this);
443
+ }
444
+ get url() {
445
+ return createWebSocketURL(this._url);
446
+ }
447
+ send(data) {
448
+ return this.connection.send(data);
449
+ }
450
+ connect() {
451
+ return this.connection.open();
452
+ }
453
+ disconnect() {
454
+ return this.connection.close({
455
+ allowReconnect: false
456
+ });
457
+ }
458
+ ensureActiveConnection() {
459
+ if (!this.connection.isActive()) {
460
+ return this.connection.open();
461
+ }
462
+ }
463
+ }
464
+
465
+ function createWebSocketURL(url) {
466
+ if (typeof url === "function") {
467
+ url = url();
468
+ }
469
+ if (url && !/^wss?:/i.test(url)) {
470
+ const a = document.createElement("a");
471
+ a.href = url;
472
+ a.href = a.href;
473
+ a.protocol = a.protocol.replace("http", "ws");
474
+ return a.href;
475
+ } else {
476
+ return url;
477
+ }
478
+ }
479
+
480
+ function createConsumer(url = getConfig("url") || INTERNAL.default_mount_path) {
481
+ return new Consumer(url);
482
+ }
483
+
484
+ function getConfig(name) {
485
+ const element = document.head.querySelector(`meta[name='action-cable-${name}']`);
486
+ if (element) {
487
+ return element.getAttribute("content");
488
+ }
489
+ }
490
+
491
+ export { Connection, ConnectionMonitor, Consumer, INTERNAL, Subscription, SubscriptionGuarantor, Subscriptions, adapters, createConsumer, createWebSocketURL, getConfig, logger };
@@ -0,0 +1,489 @@
1
+ (function(global, factory) {
2
+ typeof exports === "object" && typeof module !== "undefined" ? factory(exports) : typeof define === "function" && define.amd ? define([ "exports" ], factory) : (global = typeof globalThis !== "undefined" ? globalThis : global || self,
3
+ factory(global.ActionCable = {}));
4
+ })(this, (function(exports) {
5
+ "use strict";
6
+ var adapters = {
7
+ logger: self.console,
8
+ WebSocket: self.WebSocket
9
+ };
10
+ var logger = {
11
+ log(...messages) {
12
+ if (this.enabled) {
13
+ messages.push(Date.now());
14
+ adapters.logger.log("[ActionCable]", ...messages);
15
+ }
16
+ }
17
+ };
18
+ const now = () => (new Date).getTime();
19
+ const secondsSince = time => (now() - time) / 1e3;
20
+ class ConnectionMonitor {
21
+ constructor(connection) {
22
+ this.visibilityDidChange = this.visibilityDidChange.bind(this);
23
+ this.connection = connection;
24
+ this.reconnectAttempts = 0;
25
+ }
26
+ start() {
27
+ if (!this.isRunning()) {
28
+ this.startedAt = now();
29
+ delete this.stoppedAt;
30
+ this.startPolling();
31
+ addEventListener("visibilitychange", this.visibilityDidChange);
32
+ logger.log(`ConnectionMonitor started. stale threshold = ${this.constructor.staleThreshold} s`);
33
+ }
34
+ }
35
+ stop() {
36
+ if (this.isRunning()) {
37
+ this.stoppedAt = now();
38
+ this.stopPolling();
39
+ removeEventListener("visibilitychange", this.visibilityDidChange);
40
+ logger.log("ConnectionMonitor stopped");
41
+ }
42
+ }
43
+ isRunning() {
44
+ return this.startedAt && !this.stoppedAt;
45
+ }
46
+ recordPing() {
47
+ this.pingedAt = now();
48
+ }
49
+ recordConnect() {
50
+ this.reconnectAttempts = 0;
51
+ this.recordPing();
52
+ delete this.disconnectedAt;
53
+ logger.log("ConnectionMonitor recorded connect");
54
+ }
55
+ recordDisconnect() {
56
+ this.disconnectedAt = now();
57
+ logger.log("ConnectionMonitor recorded disconnect");
58
+ }
59
+ startPolling() {
60
+ this.stopPolling();
61
+ this.poll();
62
+ }
63
+ stopPolling() {
64
+ clearTimeout(this.pollTimeout);
65
+ }
66
+ poll() {
67
+ this.pollTimeout = setTimeout((() => {
68
+ this.reconnectIfStale();
69
+ this.poll();
70
+ }), this.getPollInterval());
71
+ }
72
+ getPollInterval() {
73
+ const {staleThreshold: staleThreshold, reconnectionBackoffRate: reconnectionBackoffRate} = this.constructor;
74
+ const backoff = Math.pow(1 + reconnectionBackoffRate, Math.min(this.reconnectAttempts, 10));
75
+ const jitterMax = this.reconnectAttempts === 0 ? 1 : reconnectionBackoffRate;
76
+ const jitter = jitterMax * Math.random();
77
+ return staleThreshold * 1e3 * backoff * (1 + jitter);
78
+ }
79
+ reconnectIfStale() {
80
+ if (this.connectionIsStale()) {
81
+ logger.log(`ConnectionMonitor detected stale connection. reconnectAttempts = ${this.reconnectAttempts}, time stale = ${secondsSince(this.refreshedAt)} s, stale threshold = ${this.constructor.staleThreshold} s`);
82
+ this.reconnectAttempts++;
83
+ if (this.disconnectedRecently()) {
84
+ logger.log(`ConnectionMonitor skipping reopening recent disconnect. time disconnected = ${secondsSince(this.disconnectedAt)} s`);
85
+ } else {
86
+ logger.log("ConnectionMonitor reopening");
87
+ this.connection.reopen();
88
+ }
89
+ }
90
+ }
91
+ get refreshedAt() {
92
+ return this.pingedAt ? this.pingedAt : this.startedAt;
93
+ }
94
+ connectionIsStale() {
95
+ return secondsSince(this.refreshedAt) > this.constructor.staleThreshold;
96
+ }
97
+ disconnectedRecently() {
98
+ return this.disconnectedAt && secondsSince(this.disconnectedAt) < this.constructor.staleThreshold;
99
+ }
100
+ visibilityDidChange() {
101
+ if (document.visibilityState === "visible") {
102
+ setTimeout((() => {
103
+ if (this.connectionIsStale() || !this.connection.isOpen()) {
104
+ logger.log(`ConnectionMonitor reopening stale connection on visibilitychange. visibilityState = ${document.visibilityState}`);
105
+ this.connection.reopen();
106
+ }
107
+ }), 200);
108
+ }
109
+ }
110
+ }
111
+ ConnectionMonitor.staleThreshold = 6;
112
+ ConnectionMonitor.reconnectionBackoffRate = .15;
113
+ var INTERNAL = {
114
+ message_types: {
115
+ welcome: "welcome",
116
+ disconnect: "disconnect",
117
+ ping: "ping",
118
+ confirmation: "confirm_subscription",
119
+ rejection: "reject_subscription"
120
+ },
121
+ disconnect_reasons: {
122
+ unauthorized: "unauthorized",
123
+ invalid_request: "invalid_request",
124
+ server_restart: "server_restart"
125
+ },
126
+ default_mount_path: "/cable",
127
+ protocols: [ "actioncable-v1-json", "actioncable-unsupported" ]
128
+ };
129
+ const {message_types: message_types, protocols: protocols} = INTERNAL;
130
+ const supportedProtocols = protocols.slice(0, protocols.length - 1);
131
+ const indexOf = [].indexOf;
132
+ class Connection {
133
+ constructor(consumer) {
134
+ this.open = this.open.bind(this);
135
+ this.consumer = consumer;
136
+ this.subscriptions = this.consumer.subscriptions;
137
+ this.monitor = new ConnectionMonitor(this);
138
+ this.disconnected = true;
139
+ }
140
+ send(data) {
141
+ if (this.isOpen()) {
142
+ this.webSocket.send(JSON.stringify(data));
143
+ return true;
144
+ } else {
145
+ return false;
146
+ }
147
+ }
148
+ open() {
149
+ if (this.isActive()) {
150
+ logger.log(`Attempted to open WebSocket, but existing socket is ${this.getState()}`);
151
+ return false;
152
+ } else {
153
+ logger.log(`Opening WebSocket, current state is ${this.getState()}, subprotocols: ${protocols}`);
154
+ if (this.webSocket) {
155
+ this.uninstallEventHandlers();
156
+ }
157
+ this.webSocket = new adapters.WebSocket(this.consumer.url, protocols);
158
+ this.installEventHandlers();
159
+ this.monitor.start();
160
+ return true;
161
+ }
162
+ }
163
+ close({allowReconnect: allowReconnect} = {
164
+ allowReconnect: true
165
+ }) {
166
+ if (!allowReconnect) {
167
+ this.monitor.stop();
168
+ }
169
+ if (this.isActive()) {
170
+ return this.webSocket.close();
171
+ }
172
+ }
173
+ reopen() {
174
+ logger.log(`Reopening WebSocket, current state is ${this.getState()}`);
175
+ if (this.isActive()) {
176
+ try {
177
+ return this.close();
178
+ } catch (error) {
179
+ logger.log("Failed to reopen WebSocket", error);
180
+ } finally {
181
+ logger.log(`Reopening WebSocket in ${this.constructor.reopenDelay}ms`);
182
+ setTimeout(this.open, this.constructor.reopenDelay);
183
+ }
184
+ } else {
185
+ return this.open();
186
+ }
187
+ }
188
+ getProtocol() {
189
+ if (this.webSocket) {
190
+ return this.webSocket.protocol;
191
+ }
192
+ }
193
+ isOpen() {
194
+ return this.isState("open");
195
+ }
196
+ isActive() {
197
+ return this.isState("open", "connecting");
198
+ }
199
+ isProtocolSupported() {
200
+ return indexOf.call(supportedProtocols, this.getProtocol()) >= 0;
201
+ }
202
+ isState(...states) {
203
+ return indexOf.call(states, this.getState()) >= 0;
204
+ }
205
+ getState() {
206
+ if (this.webSocket) {
207
+ for (let state in adapters.WebSocket) {
208
+ if (adapters.WebSocket[state] === this.webSocket.readyState) {
209
+ return state.toLowerCase();
210
+ }
211
+ }
212
+ }
213
+ return null;
214
+ }
215
+ installEventHandlers() {
216
+ for (let eventName in this.events) {
217
+ const handler = this.events[eventName].bind(this);
218
+ this.webSocket[`on${eventName}`] = handler;
219
+ }
220
+ }
221
+ uninstallEventHandlers() {
222
+ for (let eventName in this.events) {
223
+ this.webSocket[`on${eventName}`] = function() {};
224
+ }
225
+ }
226
+ }
227
+ Connection.reopenDelay = 500;
228
+ Connection.prototype.events = {
229
+ message(event) {
230
+ if (!this.isProtocolSupported()) {
231
+ return;
232
+ }
233
+ const {identifier: identifier, message: message, reason: reason, reconnect: reconnect, type: type} = JSON.parse(event.data);
234
+ switch (type) {
235
+ case message_types.welcome:
236
+ this.monitor.recordConnect();
237
+ return this.subscriptions.reload();
238
+
239
+ case message_types.disconnect:
240
+ logger.log(`Disconnecting. Reason: ${reason}`);
241
+ return this.close({
242
+ allowReconnect: reconnect
243
+ });
244
+
245
+ case message_types.ping:
246
+ return this.monitor.recordPing();
247
+
248
+ case message_types.confirmation:
249
+ this.subscriptions.confirmSubscription(identifier);
250
+ return this.subscriptions.notify(identifier, "connected");
251
+
252
+ case message_types.rejection:
253
+ return this.subscriptions.reject(identifier);
254
+
255
+ default:
256
+ return this.subscriptions.notify(identifier, "received", message);
257
+ }
258
+ },
259
+ open() {
260
+ logger.log(`WebSocket onopen event, using '${this.getProtocol()}' subprotocol`);
261
+ this.disconnected = false;
262
+ if (!this.isProtocolSupported()) {
263
+ logger.log("Protocol is unsupported. Stopping monitor and disconnecting.");
264
+ return this.close({
265
+ allowReconnect: false
266
+ });
267
+ }
268
+ },
269
+ close(event) {
270
+ logger.log("WebSocket onclose event");
271
+ if (this.disconnected) {
272
+ return;
273
+ }
274
+ this.disconnected = true;
275
+ this.monitor.recordDisconnect();
276
+ return this.subscriptions.notifyAll("disconnected", {
277
+ willAttemptReconnect: this.monitor.isRunning()
278
+ });
279
+ },
280
+ error() {
281
+ logger.log("WebSocket onerror event");
282
+ }
283
+ };
284
+ const extend = function(object, properties) {
285
+ if (properties != null) {
286
+ for (let key in properties) {
287
+ const value = properties[key];
288
+ object[key] = value;
289
+ }
290
+ }
291
+ return object;
292
+ };
293
+ class Subscription {
294
+ constructor(consumer, params = {}, mixin) {
295
+ this.consumer = consumer;
296
+ this.identifier = JSON.stringify(params);
297
+ extend(this, mixin);
298
+ }
299
+ perform(action, data = {}) {
300
+ data.action = action;
301
+ return this.send(data);
302
+ }
303
+ send(data) {
304
+ return this.consumer.send({
305
+ command: "message",
306
+ identifier: this.identifier,
307
+ data: JSON.stringify(data)
308
+ });
309
+ }
310
+ unsubscribe() {
311
+ return this.consumer.subscriptions.remove(this);
312
+ }
313
+ }
314
+ class SubscriptionGuarantor {
315
+ constructor(subscriptions) {
316
+ this.subscriptions = subscriptions;
317
+ this.pendingSubscriptions = [];
318
+ }
319
+ guarantee(subscription) {
320
+ if (this.pendingSubscriptions.indexOf(subscription) == -1) {
321
+ logger.log(`SubscriptionGuarantor guaranteeing ${subscription.identifier}`);
322
+ this.pendingSubscriptions.push(subscription);
323
+ } else {
324
+ logger.log(`SubscriptionGuarantor already guaranteeing ${subscription.identifier}`);
325
+ }
326
+ this.startGuaranteeing();
327
+ }
328
+ forget(subscription) {
329
+ logger.log(`SubscriptionGuarantor forgetting ${subscription.identifier}`);
330
+ this.pendingSubscriptions = this.pendingSubscriptions.filter((s => s !== subscription));
331
+ }
332
+ startGuaranteeing() {
333
+ this.stopGuaranteeing();
334
+ this.retrySubscribing();
335
+ }
336
+ stopGuaranteeing() {
337
+ clearTimeout(this.retryTimeout);
338
+ }
339
+ retrySubscribing() {
340
+ this.retryTimeout = setTimeout((() => {
341
+ if (this.subscriptions && typeof this.subscriptions.subscribe === "function") {
342
+ this.pendingSubscriptions.map((subscription => {
343
+ logger.log(`SubscriptionGuarantor resubscribing ${subscription.identifier}`);
344
+ this.subscriptions.subscribe(subscription);
345
+ }));
346
+ }
347
+ }), 500);
348
+ }
349
+ }
350
+ class Subscriptions {
351
+ constructor(consumer) {
352
+ this.consumer = consumer;
353
+ this.guarantor = new SubscriptionGuarantor(this);
354
+ this.subscriptions = [];
355
+ }
356
+ create(channelName, mixin) {
357
+ const channel = channelName;
358
+ const params = typeof channel === "object" ? channel : {
359
+ channel: channel
360
+ };
361
+ const subscription = new Subscription(this.consumer, params, mixin);
362
+ return this.add(subscription);
363
+ }
364
+ add(subscription) {
365
+ this.subscriptions.push(subscription);
366
+ this.consumer.ensureActiveConnection();
367
+ this.notify(subscription, "initialized");
368
+ this.subscribe(subscription);
369
+ return subscription;
370
+ }
371
+ remove(subscription) {
372
+ this.forget(subscription);
373
+ if (!this.findAll(subscription.identifier).length) {
374
+ this.sendCommand(subscription, "unsubscribe");
375
+ }
376
+ return subscription;
377
+ }
378
+ reject(identifier) {
379
+ return this.findAll(identifier).map((subscription => {
380
+ this.forget(subscription);
381
+ this.notify(subscription, "rejected");
382
+ return subscription;
383
+ }));
384
+ }
385
+ forget(subscription) {
386
+ this.guarantor.forget(subscription);
387
+ this.subscriptions = this.subscriptions.filter((s => s !== subscription));
388
+ return subscription;
389
+ }
390
+ findAll(identifier) {
391
+ return this.subscriptions.filter((s => s.identifier === identifier));
392
+ }
393
+ reload() {
394
+ return this.subscriptions.map((subscription => this.subscribe(subscription)));
395
+ }
396
+ notifyAll(callbackName, ...args) {
397
+ return this.subscriptions.map((subscription => this.notify(subscription, callbackName, ...args)));
398
+ }
399
+ notify(subscription, callbackName, ...args) {
400
+ let subscriptions;
401
+ if (typeof subscription === "string") {
402
+ subscriptions = this.findAll(subscription);
403
+ } else {
404
+ subscriptions = [ subscription ];
405
+ }
406
+ return subscriptions.map((subscription => typeof subscription[callbackName] === "function" ? subscription[callbackName](...args) : undefined));
407
+ }
408
+ subscribe(subscription) {
409
+ if (this.sendCommand(subscription, "subscribe")) {
410
+ this.guarantor.guarantee(subscription);
411
+ }
412
+ }
413
+ confirmSubscription(identifier) {
414
+ logger.log(`Subscription confirmed ${identifier}`);
415
+ this.findAll(identifier).map((subscription => this.guarantor.forget(subscription)));
416
+ }
417
+ sendCommand(subscription, command) {
418
+ const {identifier: identifier} = subscription;
419
+ return this.consumer.send({
420
+ command: command,
421
+ identifier: identifier
422
+ });
423
+ }
424
+ }
425
+ class Consumer {
426
+ constructor(url) {
427
+ this._url = url;
428
+ this.subscriptions = new Subscriptions(this);
429
+ this.connection = new Connection(this);
430
+ }
431
+ get url() {
432
+ return createWebSocketURL(this._url);
433
+ }
434
+ send(data) {
435
+ return this.connection.send(data);
436
+ }
437
+ connect() {
438
+ return this.connection.open();
439
+ }
440
+ disconnect() {
441
+ return this.connection.close({
442
+ allowReconnect: false
443
+ });
444
+ }
445
+ ensureActiveConnection() {
446
+ if (!this.connection.isActive()) {
447
+ return this.connection.open();
448
+ }
449
+ }
450
+ }
451
+ function createWebSocketURL(url) {
452
+ if (typeof url === "function") {
453
+ url = url();
454
+ }
455
+ if (url && !/^wss?:/i.test(url)) {
456
+ const a = document.createElement("a");
457
+ a.href = url;
458
+ a.href = a.href;
459
+ a.protocol = a.protocol.replace("http", "ws");
460
+ return a.href;
461
+ } else {
462
+ return url;
463
+ }
464
+ }
465
+ function createConsumer(url = getConfig("url") || INTERNAL.default_mount_path) {
466
+ return new Consumer(url);
467
+ }
468
+ function getConfig(name) {
469
+ const element = document.head.querySelector(`meta[name='action-cable-${name}']`);
470
+ if (element) {
471
+ return element.getAttribute("content");
472
+ }
473
+ }
474
+ exports.Connection = Connection;
475
+ exports.ConnectionMonitor = ConnectionMonitor;
476
+ exports.Consumer = Consumer;
477
+ exports.INTERNAL = INTERNAL;
478
+ exports.Subscription = Subscription;
479
+ exports.SubscriptionGuarantor = SubscriptionGuarantor;
480
+ exports.Subscriptions = Subscriptions;
481
+ exports.adapters = adapters;
482
+ exports.createConsumer = createConsumer;
483
+ exports.createWebSocketURL = createWebSocketURL;
484
+ exports.getConfig = getConfig;
485
+ exports.logger = logger;
486
+ Object.defineProperty(exports, "__esModule", {
487
+ value: true
488
+ });
489
+ }));
@@ -62,6 +62,21 @@ module ActionCable
62
62
  def transmit(cable_message)
63
63
  transmissions << cable_message.with_indifferent_access
64
64
  end
65
+
66
+ def connection_identifier
67
+ @connection_identifier ||= connection_gid(identifiers.filter_map { |id| send(id.to_sym) if id })
68
+ end
69
+
70
+ private
71
+ def connection_gid(ids)
72
+ ids.map do |o|
73
+ if o.respond_to?(:to_gid_param)
74
+ o.to_gid_param
75
+ else
76
+ o.to_s
77
+ end
78
+ end.sort.join(":")
79
+ end
65
80
  end
66
81
 
67
82
  # Superclass for Action Cable channel functional tests.
@@ -10,7 +10,7 @@ module ActionCable
10
10
  MAJOR = 7
11
11
  MINOR = 0
12
12
  TINY = 0
13
- PRE = "alpha2"
13
+ PRE = "rc1"
14
14
 
15
15
  STRING = [MAJOR, MINOR, TINY, PRE].compact.join(".")
16
16
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: actioncable
3
3
  version: !ruby/object:Gem::Version
4
- version: 7.0.0.alpha2
4
+ version: 7.0.0.rc1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Pratik Naik
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2021-09-15 00:00:00.000000000 Z
12
+ date: 2021-12-06 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: activesupport
@@ -17,28 +17,28 @@ dependencies:
17
17
  requirements:
18
18
  - - '='
19
19
  - !ruby/object:Gem::Version
20
- version: 7.0.0.alpha2
20
+ version: 7.0.0.rc1
21
21
  type: :runtime
22
22
  prerelease: false
23
23
  version_requirements: !ruby/object:Gem::Requirement
24
24
  requirements:
25
25
  - - '='
26
26
  - !ruby/object:Gem::Version
27
- version: 7.0.0.alpha2
27
+ version: 7.0.0.rc1
28
28
  - !ruby/object:Gem::Dependency
29
29
  name: actionpack
30
30
  requirement: !ruby/object:Gem::Requirement
31
31
  requirements:
32
32
  - - '='
33
33
  - !ruby/object:Gem::Version
34
- version: 7.0.0.alpha2
34
+ version: 7.0.0.rc1
35
35
  type: :runtime
36
36
  prerelease: false
37
37
  version_requirements: !ruby/object:Gem::Requirement
38
38
  requirements:
39
39
  - - '='
40
40
  - !ruby/object:Gem::Version
41
- version: 7.0.0.alpha2
41
+ version: 7.0.0.rc1
42
42
  - !ruby/object:Gem::Dependency
43
43
  name: nio4r
44
44
  requirement: !ruby/object:Gem::Requirement
@@ -80,6 +80,8 @@ files:
80
80
  - MIT-LICENSE
81
81
  - README.md
82
82
  - app/assets/javascripts/action_cable.js
83
+ - app/assets/javascripts/actioncable.esm.js
84
+ - app/assets/javascripts/actioncable.js
83
85
  - lib/action_cable.rb
84
86
  - lib/action_cable/channel.rb
85
87
  - lib/action_cable/channel/base.rb
@@ -140,10 +142,11 @@ licenses:
140
142
  - MIT
141
143
  metadata:
142
144
  bug_tracker_uri: https://github.com/rails/rails/issues
143
- changelog_uri: https://github.com/rails/rails/blob/v7.0.0.alpha2/actioncable/CHANGELOG.md
144
- documentation_uri: https://api.rubyonrails.org/v7.0.0.alpha2/
145
+ changelog_uri: https://github.com/rails/rails/blob/v7.0.0.rc1/actioncable/CHANGELOG.md
146
+ documentation_uri: https://api.rubyonrails.org/v7.0.0.rc1/
145
147
  mailing_list_uri: https://discuss.rubyonrails.org/c/rubyonrails-talk
146
- source_code_uri: https://github.com/rails/rails/tree/v7.0.0.alpha2/actioncable
148
+ source_code_uri: https://github.com/rails/rails/tree/v7.0.0.rc1/actioncable
149
+ rubygems_mfa_required: 'true'
147
150
  post_install_message:
148
151
  rdoc_options: []
149
152
  require_paths:
@@ -159,7 +162,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
159
162
  - !ruby/object:Gem::Version
160
163
  version: 1.3.1
161
164
  requirements: []
162
- rubygems_version: 3.1.6
165
+ rubygems_version: 3.2.22
163
166
  signing_key:
164
167
  specification_version: 4
165
168
  summary: WebSocket framework for Rails.