actioncable 7.0.0.alpha2 → 7.0.0.rc1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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.