chrono_cable 0.0.1
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 +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +69 -0
- data/Rakefile +6 -0
- data/app/assets/javascripts/chronocable.js +607 -0
- data/app/assets/javascripts/chronocable_turbo.js +5970 -0
- data/app/assets/javascripts/chronocable_turbo.min.js +29 -0
- data/app/javascript/action_cable/adapters.js +4 -0
- data/app/javascript/action_cable/connection.js +183 -0
- data/app/javascript/action_cable/connection_monitor.js +124 -0
- data/app/javascript/action_cable/consumer.js +80 -0
- data/app/javascript/action_cable/index.js +33 -0
- data/app/javascript/action_cable/internal.js +21 -0
- data/app/javascript/action_cable/logger.js +22 -0
- data/app/javascript/action_cable/stream.js +73 -0
- data/app/javascript/action_cable/subscription.js +103 -0
- data/app/javascript/action_cable/subscription_guarantor.js +50 -0
- data/app/javascript/action_cable/subscriptions.js +141 -0
- data/app/javascript/turbo/cable.js +19 -0
- data/app/javascript/turbo/cable_stream_source_element.js +53 -0
- data/app/javascript/turbo/fetch_requests.js +59 -0
- data/app/javascript/turbo/index.js +13 -0
- data/app/javascript/turbo/snakeize.js +31 -0
- data/app/models/chrono_cable/application_record.rb +5 -0
- data/app/models/solid_cable/channel.rb +12 -0
- data/db/migrate/20260628205427_create_solid_cable_channels.rb +12 -0
- data/db/migrate/20260629194211_add_channel_id_to_solid_cable_messages.rb +7 -0
- data/lib/action_cable/subscription_adapter/message.rb +12 -0
- data/lib/chrono_cable/channel_base.rb +30 -0
- data/lib/chrono_cable/channel_streams.rb +121 -0
- data/lib/chrono_cable/connection_subscriptions.rb +16 -0
- data/lib/chrono_cable/engine.rb +11 -0
- data/lib/chrono_cable/solid_cable_adapter.rb +26 -0
- data/lib/chrono_cable/solid_cable_adapter_listener.rb +28 -0
- data/lib/chrono_cable/solid_cable_broadcasting.rb +19 -0
- data/lib/chrono_cable/solid_cable_message_class_methods.rb +9 -0
- data/lib/chrono_cable/subscription_adapter_base_extensions.rb +5 -0
- data/lib/chrono_cable/turbo_streams_channel.rb +5 -0
- data/lib/chrono_cable/version.rb +3 -0
- data/lib/chrono_cable.rb +38 -0
- data/lib/generators/chrono_cable/install/USAGE +9 -0
- data/lib/generators/chrono_cable/install/install_generator.rb +32 -0
- data/lib/generators/chrono_cable/install/templates/add_channel_id_to_solid_cable_messages.rb +7 -0
- data/lib/generators/chrono_cable/install/templates/create_solid_cable_channels.rb +12 -0
- data/lib/tasks/chrono_cable_tasks.rake +6 -0
- metadata +116 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 5e886a58153518aa77bece5dc2941adcb8270f7a2a69cd208354e1d3da48ce9b
|
|
4
|
+
data.tar.gz: 4c4eb824af127906f890c548de82aa70ccbea28c9ab86b45289882dc55f4be08
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 1d59cc5d1e8c6a2f037f0eed8807dc49e2c968eb23b55fc23cb8cec8049bb51bed1297131e9a2388b9797655dfa6578f0468ce51283d8beacedc856665f3283b
|
|
7
|
+
data.tar.gz: f35ab338187a1f659be2d72d531d8ce0960b8c457b137035c4f3eac278678f19bc072019019bd666e252e360e077606202d5c603f115ee3ed40c7098717d8f62
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
Copyright Nick Pezza
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining
|
|
4
|
+
a copy of this software and associated documentation files (the
|
|
5
|
+
"Software"), to deal in the Software without restriction, including
|
|
6
|
+
without limitation the rights to use, copy, modify, merge, publish,
|
|
7
|
+
distribute, sublicense, and/or sell copies of the Software, and to
|
|
8
|
+
permit persons to whom the Software is furnished to do so, subject to
|
|
9
|
+
the following conditions:
|
|
10
|
+
|
|
11
|
+
The above copyright notice and this permission notice shall be
|
|
12
|
+
included in all copies or substantial portions of the Software.
|
|
13
|
+
|
|
14
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
15
|
+
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
16
|
+
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
17
|
+
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
18
|
+
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
19
|
+
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
20
|
+
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# ChronoCable
|
|
2
|
+
|
|
3
|
+
ChronoCable adds opt-in ordering and gap recovery to Action Cable streams backed by Solid Cable.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
Add the gem:
|
|
8
|
+
|
|
9
|
+
```ruby
|
|
10
|
+
gem "chrono_cable"
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Then install it:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
bundle install
|
|
17
|
+
bin/rails generate chrono_cable:install
|
|
18
|
+
bin/rails db:prepare
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The generator adds sequence storage and, in importmap apps, replaces the Action Cable and Turbo pins with ChronoCable's browser bundles.
|
|
22
|
+
|
|
23
|
+
Your importmap should look like:
|
|
24
|
+
|
|
25
|
+
```ruby
|
|
26
|
+
pin "@rails/actioncable", to: "chronocable.js"
|
|
27
|
+
pin "@hotwired/turbo-rails", to: "chronocable_turbo.min.js"
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Apps without Turbo only need the Action Cable pin.
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
Enable ordered delivery per broadcasting:
|
|
35
|
+
|
|
36
|
+
```ruby
|
|
37
|
+
stream_from "metrics", deliver_in_order: true
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
For Turbo Streams, pass the option through a data attribute:
|
|
41
|
+
|
|
42
|
+
```erb
|
|
43
|
+
<%= turbo_stream_from "metrics", data: { deliver_in_order: true } %>
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
ChronoCable assigns sequence numbers per broadcasting. The browser queues out-of-order messages, replays retained history, and resumes after the last delivered message on reconnect. History is authoritative, so missing or trimmed sequence numbers are skipped.
|
|
47
|
+
|
|
48
|
+
Streams are unordered by default.
|
|
49
|
+
|
|
50
|
+
### Custom callbacks
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
stream_from "metrics", deliver_in_order: true do |message|
|
|
54
|
+
transmit message
|
|
55
|
+
end
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Historical messages are passed through the callback. The callback is responsible for transmitting them when appropriate.
|
|
59
|
+
|
|
60
|
+
## Development
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
bin/rails test
|
|
64
|
+
bun run test
|
|
65
|
+
bundle exec rubocop
|
|
66
|
+
bun run build
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Bug reports and pull requests are welcome. ChronoCable is released under the [MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
// app/javascript/action_cable/adapters.js
|
|
2
|
+
var adapters_default = {
|
|
3
|
+
logger: typeof console !== "undefined" ? console : undefined,
|
|
4
|
+
WebSocket: typeof WebSocket !== "undefined" ? WebSocket : undefined
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
// app/javascript/action_cable/logger.js
|
|
8
|
+
var logger_default = {
|
|
9
|
+
log(...messages) {
|
|
10
|
+
if (this.enabled) {
|
|
11
|
+
messages.push(Date.now());
|
|
12
|
+
adapters_default.logger.log("[ActionCable]", ...messages);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// app/javascript/action_cable/connection_monitor.js
|
|
18
|
+
var now = () => new Date().getTime();
|
|
19
|
+
var secondsSince = (time) => (now() - time) / 1000;
|
|
20
|
+
|
|
21
|
+
class ConnectionMonitor {
|
|
22
|
+
constructor(connection) {
|
|
23
|
+
this.visibilityDidChange = this.visibilityDidChange.bind(this);
|
|
24
|
+
this.connection = connection;
|
|
25
|
+
this.reconnectAttempts = 0;
|
|
26
|
+
}
|
|
27
|
+
start() {
|
|
28
|
+
if (!this.isRunning()) {
|
|
29
|
+
this.startedAt = now();
|
|
30
|
+
delete this.stoppedAt;
|
|
31
|
+
this.startPolling();
|
|
32
|
+
addEventListener("visibilitychange", this.visibilityDidChange);
|
|
33
|
+
logger_default.log(`ConnectionMonitor started. stale threshold = ${this.constructor.staleThreshold} s`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
stop() {
|
|
37
|
+
if (this.isRunning()) {
|
|
38
|
+
this.stoppedAt = now();
|
|
39
|
+
this.stopPolling();
|
|
40
|
+
removeEventListener("visibilitychange", this.visibilityDidChange);
|
|
41
|
+
logger_default.log("ConnectionMonitor stopped");
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
isRunning() {
|
|
45
|
+
return this.startedAt && !this.stoppedAt;
|
|
46
|
+
}
|
|
47
|
+
recordMessage() {
|
|
48
|
+
this.pingedAt = now();
|
|
49
|
+
}
|
|
50
|
+
recordConnect() {
|
|
51
|
+
this.reconnectAttempts = 0;
|
|
52
|
+
delete this.disconnectedAt;
|
|
53
|
+
logger_default.log("ConnectionMonitor recorded connect");
|
|
54
|
+
}
|
|
55
|
+
recordDisconnect() {
|
|
56
|
+
this.disconnectedAt = now();
|
|
57
|
+
logger_default.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, 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 * 1000 * backoff * (1 + jitter);
|
|
78
|
+
}
|
|
79
|
+
reconnectIfStale() {
|
|
80
|
+
if (this.connectionIsStale()) {
|
|
81
|
+
logger_default.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_default.log(`ConnectionMonitor skipping reopening recent disconnect. time disconnected = ${secondsSince(this.disconnectedAt)} s`);
|
|
85
|
+
} else {
|
|
86
|
+
logger_default.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_default.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 = 0.15;
|
|
113
|
+
var connection_monitor_default = ConnectionMonitor;
|
|
114
|
+
|
|
115
|
+
// app/javascript/action_cable/internal.js
|
|
116
|
+
var internal_default = {
|
|
117
|
+
message_types: {
|
|
118
|
+
welcome: "welcome",
|
|
119
|
+
disconnect: "disconnect",
|
|
120
|
+
ping: "ping",
|
|
121
|
+
history: "history",
|
|
122
|
+
confirmation: "confirm_subscription",
|
|
123
|
+
rejection: "reject_subscription"
|
|
124
|
+
},
|
|
125
|
+
disconnect_reasons: {
|
|
126
|
+
unauthorized: "unauthorized",
|
|
127
|
+
invalid_request: "invalid_request",
|
|
128
|
+
server_restart: "server_restart",
|
|
129
|
+
remote: "remote"
|
|
130
|
+
},
|
|
131
|
+
default_mount_path: "/cable",
|
|
132
|
+
protocols: [
|
|
133
|
+
"actioncable-v1-json",
|
|
134
|
+
"actioncable-unsupported"
|
|
135
|
+
]
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// app/javascript/action_cable/connection.js
|
|
139
|
+
var { message_types, protocols } = internal_default;
|
|
140
|
+
var supportedProtocols = protocols.slice(0, protocols.length - 1);
|
|
141
|
+
var indexOf = [].indexOf;
|
|
142
|
+
|
|
143
|
+
class Connection {
|
|
144
|
+
constructor(consumer) {
|
|
145
|
+
this.open = this.open.bind(this);
|
|
146
|
+
this.consumer = consumer;
|
|
147
|
+
this.subscriptions = this.consumer.subscriptions;
|
|
148
|
+
this.monitor = new connection_monitor_default(this);
|
|
149
|
+
this.disconnected = true;
|
|
150
|
+
}
|
|
151
|
+
send(data) {
|
|
152
|
+
if (this.isOpen()) {
|
|
153
|
+
this.webSocket.send(JSON.stringify(data));
|
|
154
|
+
return true;
|
|
155
|
+
} else {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
open() {
|
|
160
|
+
if (this.isActive()) {
|
|
161
|
+
logger_default.log(`Attempted to open WebSocket, but existing socket is ${this.getState()}`);
|
|
162
|
+
return false;
|
|
163
|
+
} else {
|
|
164
|
+
const socketProtocols = [...protocols, ...this.consumer.subprotocols || []];
|
|
165
|
+
logger_default.log(`Opening WebSocket, current state is ${this.getState()}, subprotocols: ${socketProtocols}`);
|
|
166
|
+
if (this.webSocket) {
|
|
167
|
+
this.uninstallEventHandlers();
|
|
168
|
+
}
|
|
169
|
+
this.webSocket = new adapters_default.WebSocket(this.consumer.url, socketProtocols);
|
|
170
|
+
this.installEventHandlers();
|
|
171
|
+
this.monitor.start();
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
close({ allowReconnect } = { allowReconnect: true }) {
|
|
176
|
+
if (!allowReconnect) {
|
|
177
|
+
this.monitor.stop();
|
|
178
|
+
}
|
|
179
|
+
if (this.isOpen()) {
|
|
180
|
+
return this.webSocket.close();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
reopen() {
|
|
184
|
+
logger_default.log(`Reopening WebSocket, current state is ${this.getState()}`);
|
|
185
|
+
if (this.isActive()) {
|
|
186
|
+
try {
|
|
187
|
+
return this.close();
|
|
188
|
+
} catch (error) {
|
|
189
|
+
logger_default.log("Failed to reopen WebSocket", error);
|
|
190
|
+
} finally {
|
|
191
|
+
logger_default.log(`Reopening WebSocket in ${this.constructor.reopenDelay}ms`);
|
|
192
|
+
setTimeout(this.open, this.constructor.reopenDelay);
|
|
193
|
+
}
|
|
194
|
+
} else {
|
|
195
|
+
return this.open();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
getProtocol() {
|
|
199
|
+
if (this.webSocket) {
|
|
200
|
+
return this.webSocket.protocol;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
isOpen() {
|
|
204
|
+
return this.isState("open");
|
|
205
|
+
}
|
|
206
|
+
isActive() {
|
|
207
|
+
return this.isState("open", "connecting");
|
|
208
|
+
}
|
|
209
|
+
triedToReconnect() {
|
|
210
|
+
return this.monitor.reconnectAttempts > 0;
|
|
211
|
+
}
|
|
212
|
+
isProtocolSupported() {
|
|
213
|
+
return indexOf.call(supportedProtocols, this.getProtocol()) >= 0;
|
|
214
|
+
}
|
|
215
|
+
isState(...states) {
|
|
216
|
+
return indexOf.call(states, this.getState()) >= 0;
|
|
217
|
+
}
|
|
218
|
+
getState() {
|
|
219
|
+
if (this.webSocket) {
|
|
220
|
+
for (let state in adapters_default.WebSocket) {
|
|
221
|
+
if (adapters_default.WebSocket[state] === this.webSocket.readyState) {
|
|
222
|
+
return state.toLowerCase();
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
installEventHandlers() {
|
|
229
|
+
for (let eventName in this.events) {
|
|
230
|
+
const handler = this.events[eventName].bind(this);
|
|
231
|
+
this.webSocket[`on${eventName}`] = handler;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
uninstallEventHandlers() {
|
|
235
|
+
for (let eventName in this.events) {
|
|
236
|
+
this.webSocket[`on${eventName}`] = function() {};
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
Connection.reopenDelay = 500;
|
|
241
|
+
Connection.prototype.events = {
|
|
242
|
+
message(event) {
|
|
243
|
+
if (!this.isProtocolSupported()) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const { identifier, ids, message, reason, reconnect, type, broadcasting, id } = JSON.parse(event.data);
|
|
247
|
+
this.monitor.recordMessage();
|
|
248
|
+
switch (type) {
|
|
249
|
+
case message_types.welcome:
|
|
250
|
+
if (this.triedToReconnect()) {
|
|
251
|
+
this.reconnectAttempted = true;
|
|
252
|
+
}
|
|
253
|
+
this.monitor.recordConnect();
|
|
254
|
+
return this.subscriptions.reload();
|
|
255
|
+
case message_types.disconnect:
|
|
256
|
+
logger_default.log(`Disconnecting. Reason: ${reason}`);
|
|
257
|
+
return this.close({ allowReconnect: reconnect });
|
|
258
|
+
case message_types.history:
|
|
259
|
+
return this.subscriptions.ingestHistory(identifier, broadcasting, message);
|
|
260
|
+
case message_types.ping:
|
|
261
|
+
return null;
|
|
262
|
+
case message_types.confirmation:
|
|
263
|
+
this.subscriptions.confirmSubscription(identifier, ids);
|
|
264
|
+
if (this.reconnectAttempted) {
|
|
265
|
+
this.reconnectAttempted = false;
|
|
266
|
+
return this.subscriptions.notify(identifier, "connected", { reconnected: true });
|
|
267
|
+
} else {
|
|
268
|
+
return this.subscriptions.notify(identifier, "connected", { reconnected: false });
|
|
269
|
+
}
|
|
270
|
+
case message_types.rejection:
|
|
271
|
+
return this.subscriptions.reject(identifier);
|
|
272
|
+
default:
|
|
273
|
+
return this.subscriptions.receive(identifier, message, id, broadcasting);
|
|
274
|
+
}
|
|
275
|
+
},
|
|
276
|
+
open() {
|
|
277
|
+
logger_default.log(`WebSocket onopen event, using '${this.getProtocol()}' subprotocol`);
|
|
278
|
+
this.disconnected = false;
|
|
279
|
+
if (!this.isProtocolSupported()) {
|
|
280
|
+
logger_default.log("Protocol is unsupported. Stopping monitor and disconnecting.");
|
|
281
|
+
return this.close({ allowReconnect: false });
|
|
282
|
+
}
|
|
283
|
+
},
|
|
284
|
+
close(event) {
|
|
285
|
+
logger_default.log("WebSocket onclose event");
|
|
286
|
+
if (this.disconnected) {
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
this.disconnected = true;
|
|
290
|
+
this.monitor.recordDisconnect();
|
|
291
|
+
return this.subscriptions.notifyAll("disconnected", { willAttemptReconnect: this.monitor.isRunning() });
|
|
292
|
+
},
|
|
293
|
+
error() {
|
|
294
|
+
logger_default.log("WebSocket onerror event");
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
var connection_default = Connection;
|
|
298
|
+
|
|
299
|
+
// app/javascript/action_cable/stream.js
|
|
300
|
+
class Stream {
|
|
301
|
+
constructor(subscription, broadcasting, id) {
|
|
302
|
+
this.subscription = subscription;
|
|
303
|
+
this.broadcasting = broadcasting;
|
|
304
|
+
this.id = id;
|
|
305
|
+
this.recovering = false;
|
|
306
|
+
this.queue = [];
|
|
307
|
+
}
|
|
308
|
+
isBehind(id) {
|
|
309
|
+
return this.id != null && this.id < id;
|
|
310
|
+
}
|
|
311
|
+
recover(subscriptions, id, message) {
|
|
312
|
+
this.queue.push({ id, message });
|
|
313
|
+
this.requestHistory(subscriptions);
|
|
314
|
+
}
|
|
315
|
+
requestHistory(subscriptions) {
|
|
316
|
+
if (!this.recovering) {
|
|
317
|
+
this.recovering = true;
|
|
318
|
+
subscriptions.sendCommand(this.subscription, "history", { broadcasting: this.broadcasting, id: this.id });
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
restartRecovery(subscriptions) {
|
|
322
|
+
this.recovering = false;
|
|
323
|
+
this.requestHistory(subscriptions);
|
|
324
|
+
}
|
|
325
|
+
processMessage(id, payload, callback) {
|
|
326
|
+
if (this.id != null && id <= this.id)
|
|
327
|
+
return true;
|
|
328
|
+
if (this.id == null || id === this.id + 1) {
|
|
329
|
+
this.id = id;
|
|
330
|
+
callback(payload);
|
|
331
|
+
return true;
|
|
332
|
+
}
|
|
333
|
+
return false;
|
|
334
|
+
}
|
|
335
|
+
processMessages(messages, callback) {
|
|
336
|
+
const history = messages.map(({ id, payload }) => ({ id, message: JSON.parse(payload) }));
|
|
337
|
+
this.queue = history.concat(this.queue);
|
|
338
|
+
this.queue.sort((a, b) => a.id - b.id).forEach(({ id, message }) => {
|
|
339
|
+
if (this.id == null || id > this.id) {
|
|
340
|
+
this.id = id;
|
|
341
|
+
callback(message);
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
this.queue = [];
|
|
345
|
+
this.recovering = false;
|
|
346
|
+
return this.subscription;
|
|
347
|
+
}
|
|
348
|
+
processQueue(callback) {
|
|
349
|
+
const remaining = [];
|
|
350
|
+
this.queue.sort((a, b) => a.id - b.id).filter(({ id }) => id > this.id).forEach(({ id, message }) => {
|
|
351
|
+
if (!this.processMessage(id, message, callback))
|
|
352
|
+
remaining.push({ id, message });
|
|
353
|
+
});
|
|
354
|
+
this.queue = remaining;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// app/javascript/action_cable/subscription.js
|
|
359
|
+
var extend = function(object, properties) {
|
|
360
|
+
if (properties != null) {
|
|
361
|
+
for (let key in properties) {
|
|
362
|
+
const value = properties[key];
|
|
363
|
+
object[key] = value;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return object;
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
class Subscription {
|
|
370
|
+
constructor(consumer, params = {}, mixin) {
|
|
371
|
+
this.consumer = consumer;
|
|
372
|
+
this.identifier = JSON.stringify(params);
|
|
373
|
+
this.streams = {};
|
|
374
|
+
extend(this, mixin);
|
|
375
|
+
}
|
|
376
|
+
perform(action, data = {}) {
|
|
377
|
+
data.action = action;
|
|
378
|
+
return this.send(data);
|
|
379
|
+
}
|
|
380
|
+
send(data) {
|
|
381
|
+
return this.consumer.send({ command: "message", identifier: this.identifier, data: JSON.stringify(data) });
|
|
382
|
+
}
|
|
383
|
+
unsubscribe() {
|
|
384
|
+
return this.consumer.subscriptions.remove(this);
|
|
385
|
+
}
|
|
386
|
+
reset() {
|
|
387
|
+
this.streams = {};
|
|
388
|
+
}
|
|
389
|
+
findOrCreateStream(broadcasting, id = null) {
|
|
390
|
+
if (this.streams[broadcasting] == null) {
|
|
391
|
+
this.streams[broadcasting] = new Stream(this, broadcasting, id);
|
|
392
|
+
}
|
|
393
|
+
return this.streams[broadcasting];
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// app/javascript/action_cable/subscription_guarantor.js
|
|
398
|
+
class SubscriptionGuarantor {
|
|
399
|
+
constructor(subscriptions) {
|
|
400
|
+
this.subscriptions = subscriptions;
|
|
401
|
+
this.pendingSubscriptions = [];
|
|
402
|
+
}
|
|
403
|
+
guarantee(subscription) {
|
|
404
|
+
if (this.pendingSubscriptions.indexOf(subscription) == -1) {
|
|
405
|
+
logger_default.log(`SubscriptionGuarantor guaranteeing ${subscription.identifier}`);
|
|
406
|
+
this.pendingSubscriptions.push(subscription);
|
|
407
|
+
} else {
|
|
408
|
+
logger_default.log(`SubscriptionGuarantor already guaranteeing ${subscription.identifier}`);
|
|
409
|
+
}
|
|
410
|
+
this.startGuaranteeing();
|
|
411
|
+
}
|
|
412
|
+
forget(subscription) {
|
|
413
|
+
logger_default.log(`SubscriptionGuarantor forgetting ${subscription.identifier}`);
|
|
414
|
+
this.pendingSubscriptions = this.pendingSubscriptions.filter((s) => s !== subscription);
|
|
415
|
+
}
|
|
416
|
+
startGuaranteeing() {
|
|
417
|
+
this.stopGuaranteeing();
|
|
418
|
+
this.retrySubscribing();
|
|
419
|
+
}
|
|
420
|
+
stopGuaranteeing() {
|
|
421
|
+
clearTimeout(this.retryTimeout);
|
|
422
|
+
}
|
|
423
|
+
retrySubscribing() {
|
|
424
|
+
this.retryTimeout = setTimeout(() => {
|
|
425
|
+
if (this.subscriptions && typeof this.subscriptions.subscribe === "function") {
|
|
426
|
+
this.pendingSubscriptions.map((subscription) => {
|
|
427
|
+
logger_default.log(`SubscriptionGuarantor resubscribing ${subscription.identifier}`);
|
|
428
|
+
this.subscriptions.subscribe(subscription);
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
}, 500);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
var subscription_guarantor_default = SubscriptionGuarantor;
|
|
435
|
+
|
|
436
|
+
// app/javascript/action_cable/subscriptions.js
|
|
437
|
+
class Subscriptions {
|
|
438
|
+
constructor(consumer) {
|
|
439
|
+
this.consumer = consumer;
|
|
440
|
+
this.guarantor = new subscription_guarantor_default(this);
|
|
441
|
+
this.subscriptions = [];
|
|
442
|
+
}
|
|
443
|
+
create(channelName, mixin) {
|
|
444
|
+
const channel = channelName;
|
|
445
|
+
const params = typeof channel === "object" ? channel : { channel };
|
|
446
|
+
const subscription = new Subscription(this.consumer, params, mixin);
|
|
447
|
+
return this.add(subscription);
|
|
448
|
+
}
|
|
449
|
+
add(subscription) {
|
|
450
|
+
this.subscriptions.push(subscription);
|
|
451
|
+
this.consumer.ensureActiveConnection();
|
|
452
|
+
this.notify(subscription, "initialized");
|
|
453
|
+
this.subscribe(subscription);
|
|
454
|
+
return subscription;
|
|
455
|
+
}
|
|
456
|
+
remove(subscription) {
|
|
457
|
+
this.forget(subscription);
|
|
458
|
+
if (!this.findAll(subscription.identifier).length) {
|
|
459
|
+
this.sendCommand(subscription, "unsubscribe");
|
|
460
|
+
}
|
|
461
|
+
return subscription;
|
|
462
|
+
}
|
|
463
|
+
reject(identifier) {
|
|
464
|
+
return this.findAll(identifier).map((subscription) => {
|
|
465
|
+
this.forget(subscription);
|
|
466
|
+
this.notify(subscription, "rejected");
|
|
467
|
+
return subscription;
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
forget(subscription) {
|
|
471
|
+
this.guarantor.forget(subscription);
|
|
472
|
+
subscription.reset();
|
|
473
|
+
this.subscriptions = this.subscriptions.filter((s) => s !== subscription);
|
|
474
|
+
return subscription;
|
|
475
|
+
}
|
|
476
|
+
findAll(identifier) {
|
|
477
|
+
return this.subscriptions.filter((s) => s.identifier === identifier);
|
|
478
|
+
}
|
|
479
|
+
reload() {
|
|
480
|
+
return this.subscriptions.map((subscription) => this.subscribe(subscription));
|
|
481
|
+
}
|
|
482
|
+
notifyAll(callbackName, ...args) {
|
|
483
|
+
return this.subscriptions.map((subscription) => this.notify(subscription, callbackName, ...args));
|
|
484
|
+
}
|
|
485
|
+
notify(subscription, callbackName, ...args) {
|
|
486
|
+
let subscriptions;
|
|
487
|
+
if (typeof subscription === "string") {
|
|
488
|
+
subscriptions = this.findAll(subscription);
|
|
489
|
+
} else {
|
|
490
|
+
subscriptions = [subscription];
|
|
491
|
+
}
|
|
492
|
+
return subscriptions.map((subscription2) => typeof subscription2[callbackName] === "function" ? subscription2[callbackName](...args) : undefined);
|
|
493
|
+
}
|
|
494
|
+
subscribe(subscription) {
|
|
495
|
+
if (this.sendCommand(subscription, "subscribe")) {
|
|
496
|
+
this.guarantor.guarantee(subscription);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
confirmSubscription(identifier, ids) {
|
|
500
|
+
logger_default.log(`Subscription confirmed ${identifier}`);
|
|
501
|
+
this.findAll(identifier).forEach((subscription) => {
|
|
502
|
+
if (ids != null) {
|
|
503
|
+
Object.entries(ids).forEach(([broadcasting, id]) => {
|
|
504
|
+
const stream = subscription.findOrCreateStream(broadcasting, id);
|
|
505
|
+
if (stream.isBehind(id))
|
|
506
|
+
stream.restartRecovery(this);
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
this.guarantor.forget(subscription);
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
sendCommand(subscription, command, data = {}) {
|
|
513
|
+
const { identifier } = subscription;
|
|
514
|
+
return this.consumer.send({ command, identifier, ...data });
|
|
515
|
+
}
|
|
516
|
+
receive(identifier, message, id, broadcasting) {
|
|
517
|
+
if (id == null || broadcasting == null) {
|
|
518
|
+
return this.notify(identifier, "received", message);
|
|
519
|
+
}
|
|
520
|
+
return this.findAll(identifier).map((subscription) => {
|
|
521
|
+
const stream = subscription.findOrCreateStream(broadcasting);
|
|
522
|
+
const receive = (message2) => this.notify(subscription, "received", message2);
|
|
523
|
+
if (stream.processMessage(id, message, receive)) {
|
|
524
|
+
stream.processQueue(receive);
|
|
525
|
+
} else {
|
|
526
|
+
stream.recover(this, id, message);
|
|
527
|
+
}
|
|
528
|
+
return subscription;
|
|
529
|
+
});
|
|
530
|
+
}
|
|
531
|
+
ingestHistory(identifier, broadcasting, { messages = [], id }) {
|
|
532
|
+
return this.findAll(identifier).map((subscription) => {
|
|
533
|
+
const stream = subscription.findOrCreateStream(broadcasting, id);
|
|
534
|
+
stream.processMessages(messages, (message) => this.notify(subscription, "received", message));
|
|
535
|
+
return subscription;
|
|
536
|
+
});
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
// app/javascript/action_cable/consumer.js
|
|
541
|
+
class Consumer {
|
|
542
|
+
constructor(url) {
|
|
543
|
+
this._url = url;
|
|
544
|
+
this.subscriptions = new Subscriptions(this);
|
|
545
|
+
this.connection = new connection_default(this);
|
|
546
|
+
this.subprotocols = [];
|
|
547
|
+
}
|
|
548
|
+
get url() {
|
|
549
|
+
return createWebSocketURL(this._url);
|
|
550
|
+
}
|
|
551
|
+
send(data) {
|
|
552
|
+
return this.connection.send(data);
|
|
553
|
+
}
|
|
554
|
+
connect() {
|
|
555
|
+
return this.connection.open();
|
|
556
|
+
}
|
|
557
|
+
disconnect() {
|
|
558
|
+
return this.connection.close({ allowReconnect: false });
|
|
559
|
+
}
|
|
560
|
+
ensureActiveConnection() {
|
|
561
|
+
if (!this.connection.isActive()) {
|
|
562
|
+
return this.connection.open();
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
addSubProtocol(subprotocol) {
|
|
566
|
+
this.subprotocols = [...this.subprotocols, subprotocol];
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
function createWebSocketURL(url) {
|
|
570
|
+
if (typeof url === "function") {
|
|
571
|
+
url = url();
|
|
572
|
+
}
|
|
573
|
+
if (url && !/^wss?:/i.test(url)) {
|
|
574
|
+
const a = document.createElement("a");
|
|
575
|
+
a.href = url;
|
|
576
|
+
a.href = a.href;
|
|
577
|
+
a.protocol = a.protocol.replace("http", "ws");
|
|
578
|
+
return a.href;
|
|
579
|
+
} else {
|
|
580
|
+
return url;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// app/javascript/action_cable/index.js
|
|
585
|
+
function createConsumer(url = getConfig("url") || internal_default.default_mount_path) {
|
|
586
|
+
return new Consumer(url);
|
|
587
|
+
}
|
|
588
|
+
function getConfig(name) {
|
|
589
|
+
const element = document.head.querySelector(`meta[name='action-cable-${name}']`);
|
|
590
|
+
if (element) {
|
|
591
|
+
return element.getAttribute("content");
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
export {
|
|
595
|
+
connection_default as Connection,
|
|
596
|
+
connection_monitor_default as ConnectionMonitor,
|
|
597
|
+
Consumer,
|
|
598
|
+
internal_default as INTERNAL,
|
|
599
|
+
Subscription,
|
|
600
|
+
subscription_guarantor_default as SubscriptionGuarantor,
|
|
601
|
+
Subscriptions,
|
|
602
|
+
adapters_default as adapters,
|
|
603
|
+
createConsumer,
|
|
604
|
+
createWebSocketURL,
|
|
605
|
+
getConfig,
|
|
606
|
+
logger_default as logger
|
|
607
|
+
};
|