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
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import Connection from "./connection"
|
|
2
|
+
import Subscriptions from "./subscriptions"
|
|
3
|
+
|
|
4
|
+
// The ActionCable.Consumer establishes the connection to a server-side Ruby Connection object. Once established,
|
|
5
|
+
// the ActionCable.ConnectionMonitor will ensure that its properly maintained through heartbeats and checking for stale updates.
|
|
6
|
+
// The Consumer instance is also the gateway to establishing subscriptions to desired channels through the #createSubscription
|
|
7
|
+
// method.
|
|
8
|
+
//
|
|
9
|
+
// The following example shows how this can be set up:
|
|
10
|
+
//
|
|
11
|
+
// App = {}
|
|
12
|
+
// App.cable = ActionCable.createConsumer("ws://example.com/accounts/1")
|
|
13
|
+
// App.appearance = App.cable.subscriptions.create("AppearanceChannel")
|
|
14
|
+
//
|
|
15
|
+
// For more details on how you'd configure an actual channel subscription, see ActionCable.Subscription.
|
|
16
|
+
//
|
|
17
|
+
// When a consumer is created, it automatically connects with the server.
|
|
18
|
+
//
|
|
19
|
+
// To disconnect from the server, call
|
|
20
|
+
//
|
|
21
|
+
// App.cable.disconnect()
|
|
22
|
+
//
|
|
23
|
+
// and to restart the connection:
|
|
24
|
+
//
|
|
25
|
+
// App.cable.connect()
|
|
26
|
+
//
|
|
27
|
+
// Any channel subscriptions which existed prior to disconnecting will
|
|
28
|
+
// automatically resubscribe.
|
|
29
|
+
|
|
30
|
+
export default class Consumer {
|
|
31
|
+
constructor(url) {
|
|
32
|
+
this._url = url
|
|
33
|
+
this.subscriptions = new Subscriptions(this)
|
|
34
|
+
this.connection = new Connection(this)
|
|
35
|
+
this.subprotocols = []
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
get url() {
|
|
39
|
+
return createWebSocketURL(this._url)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
send(data) {
|
|
43
|
+
return this.connection.send(data)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
connect() {
|
|
47
|
+
return this.connection.open()
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
disconnect() {
|
|
51
|
+
return this.connection.close({allowReconnect: false})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
ensureActiveConnection() {
|
|
55
|
+
if (!this.connection.isActive()) {
|
|
56
|
+
return this.connection.open()
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
addSubProtocol(subprotocol) {
|
|
61
|
+
this.subprotocols = [...this.subprotocols, subprotocol]
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function createWebSocketURL(url) {
|
|
66
|
+
if (typeof url === "function") {
|
|
67
|
+
url = url()
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (url && !/^wss?:/i.test(url)) {
|
|
71
|
+
const a = document.createElement("a")
|
|
72
|
+
a.href = url
|
|
73
|
+
// Fix populating Location properties in IE. Otherwise, protocol will be blank.
|
|
74
|
+
a.href = a.href // eslint-disable-line
|
|
75
|
+
a.protocol = a.protocol.replace("http", "ws")
|
|
76
|
+
return a.href
|
|
77
|
+
} else {
|
|
78
|
+
return url
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import adapters from "./adapters"
|
|
2
|
+
import Connection from "./connection"
|
|
3
|
+
import ConnectionMonitor from "./connection_monitor"
|
|
4
|
+
import Consumer, { createWebSocketURL } from "./consumer"
|
|
5
|
+
import INTERNAL from "./internal"
|
|
6
|
+
import logger from "./logger"
|
|
7
|
+
import Subscription from "./subscription"
|
|
8
|
+
import SubscriptionGuarantor from "./subscription_guarantor"
|
|
9
|
+
import Subscriptions from "./subscriptions"
|
|
10
|
+
|
|
11
|
+
export {
|
|
12
|
+
Connection,
|
|
13
|
+
ConnectionMonitor,
|
|
14
|
+
Consumer,
|
|
15
|
+
INTERNAL,
|
|
16
|
+
Subscription,
|
|
17
|
+
Subscriptions,
|
|
18
|
+
SubscriptionGuarantor,
|
|
19
|
+
adapters,
|
|
20
|
+
createWebSocketURL,
|
|
21
|
+
logger,
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createConsumer(url = getConfig("url") || INTERNAL.default_mount_path) {
|
|
25
|
+
return new Consumer(url)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getConfig(name) {
|
|
29
|
+
const element = document.head.querySelector(`meta[name='action-cable-${name}']`)
|
|
30
|
+
if (element) {
|
|
31
|
+
return element.getAttribute("content")
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
"message_types": {
|
|
3
|
+
"welcome": "welcome",
|
|
4
|
+
"disconnect": "disconnect",
|
|
5
|
+
"ping": "ping",
|
|
6
|
+
"history": "history",
|
|
7
|
+
"confirmation": "confirm_subscription",
|
|
8
|
+
"rejection": "reject_subscription"
|
|
9
|
+
},
|
|
10
|
+
"disconnect_reasons": {
|
|
11
|
+
"unauthorized": "unauthorized",
|
|
12
|
+
"invalid_request": "invalid_request",
|
|
13
|
+
"server_restart": "server_restart",
|
|
14
|
+
"remote": "remote"
|
|
15
|
+
},
|
|
16
|
+
"default_mount_path": "/cable",
|
|
17
|
+
"protocols": [
|
|
18
|
+
"actioncable-v1-json",
|
|
19
|
+
"actioncable-unsupported"
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import adapters from "./adapters"
|
|
2
|
+
|
|
3
|
+
// The logger is disabled by default. You can enable it with:
|
|
4
|
+
//
|
|
5
|
+
// ActionCable.logger.enabled = true
|
|
6
|
+
//
|
|
7
|
+
// Example:
|
|
8
|
+
//
|
|
9
|
+
// import * as ActionCable from '@rails/actioncable'
|
|
10
|
+
//
|
|
11
|
+
// ActionCable.logger.enabled = true
|
|
12
|
+
// ActionCable.logger.log('Connection Established.')
|
|
13
|
+
//
|
|
14
|
+
|
|
15
|
+
export default {
|
|
16
|
+
log(...messages) {
|
|
17
|
+
if (this.enabled) {
|
|
18
|
+
messages.push(Date.now())
|
|
19
|
+
adapters.logger.log("[ActionCable]", ...messages)
|
|
20
|
+
}
|
|
21
|
+
},
|
|
22
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
export default class Stream {
|
|
2
|
+
constructor(subscription, broadcasting, id) {
|
|
3
|
+
this.subscription = subscription
|
|
4
|
+
this.broadcasting = broadcasting
|
|
5
|
+
this.id = id
|
|
6
|
+
this.recovering = false
|
|
7
|
+
this.queue = []
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
isBehind(id) {
|
|
11
|
+
return this.id != null && this.id < id
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
recover(subscriptions, id, message) {
|
|
15
|
+
this.queue.push({id, message})
|
|
16
|
+
this.requestHistory(subscriptions)
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
requestHistory(subscriptions) {
|
|
20
|
+
if (!this.recovering) {
|
|
21
|
+
this.recovering = true
|
|
22
|
+
subscriptions.sendCommand(this.subscription, "history",
|
|
23
|
+
{broadcasting: this.broadcasting, id: this.id})
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
restartRecovery(subscriptions) {
|
|
28
|
+
this.recovering = false
|
|
29
|
+
this.requestHistory(subscriptions)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
processMessage(id, payload, callback) {
|
|
33
|
+
if (this.id != null && id <= this.id) return true
|
|
34
|
+
|
|
35
|
+
if (this.id == null || id === this.id + 1) {
|
|
36
|
+
this.id = id
|
|
37
|
+
callback(payload)
|
|
38
|
+
return true
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return false
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
processMessages(messages, callback) {
|
|
45
|
+
const history = messages.map(({ id, payload }) => ({ id, message: JSON.parse(payload) }))
|
|
46
|
+
this.queue = history.concat(this.queue)
|
|
47
|
+
|
|
48
|
+
this.queue.sort((a, b) => a.id - b.id).forEach(({ id, message }) => {
|
|
49
|
+
if (this.id == null || id > this.id) {
|
|
50
|
+
this.id = id
|
|
51
|
+
callback(message)
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
this.queue = []
|
|
56
|
+
this.recovering = false
|
|
57
|
+
|
|
58
|
+
return this.subscription
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
processQueue(callback) {
|
|
62
|
+
const remaining = []
|
|
63
|
+
|
|
64
|
+
this.queue
|
|
65
|
+
.sort((a, b) => a.id - b.id)
|
|
66
|
+
.filter(({id}) => id > this.id)
|
|
67
|
+
.forEach(({ id, message }) => {
|
|
68
|
+
if (!this.processMessage(id, message, callback)) remaining.push({id, message})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
this.queue = remaining
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import Stream from "./stream"
|
|
2
|
+
|
|
3
|
+
// A new subscription is created through the ActionCable.Subscriptions instance available on the consumer.
|
|
4
|
+
// It provides a number of callbacks and a method for calling remote procedure calls on the corresponding
|
|
5
|
+
// Channel instance on the server side.
|
|
6
|
+
//
|
|
7
|
+
// An example demonstrates the basic functionality:
|
|
8
|
+
//
|
|
9
|
+
// App.appearance = App.cable.subscriptions.create("AppearanceChannel", {
|
|
10
|
+
// connected() {
|
|
11
|
+
// // Called once the subscription has been successfully completed
|
|
12
|
+
// },
|
|
13
|
+
//
|
|
14
|
+
// disconnected({ willAttemptReconnect: boolean }) {
|
|
15
|
+
// // Called when the client has disconnected with the server.
|
|
16
|
+
// // The object will have an `willAttemptReconnect` property which
|
|
17
|
+
// // says whether the client has the intention of attempting
|
|
18
|
+
// // to reconnect.
|
|
19
|
+
// },
|
|
20
|
+
//
|
|
21
|
+
// appear() {
|
|
22
|
+
// this.perform('appear', {appearing_on: this.appearingOn()})
|
|
23
|
+
// },
|
|
24
|
+
//
|
|
25
|
+
// away() {
|
|
26
|
+
// this.perform('away')
|
|
27
|
+
// },
|
|
28
|
+
//
|
|
29
|
+
// appearingOn() {
|
|
30
|
+
// $('main').data('appearing-on')
|
|
31
|
+
// }
|
|
32
|
+
// })
|
|
33
|
+
//
|
|
34
|
+
// The methods #appear and #away forward their intent to the remote AppearanceChannel instance on the server
|
|
35
|
+
// by calling the `perform` method with the first parameter being the action (which maps to AppearanceChannel#appear/away).
|
|
36
|
+
// The second parameter is a hash that'll get JSON encoded and made available on the server in the data parameter.
|
|
37
|
+
//
|
|
38
|
+
// This is how the server component would look:
|
|
39
|
+
//
|
|
40
|
+
// class AppearanceChannel < ApplicationActionCable::Channel
|
|
41
|
+
// def subscribed
|
|
42
|
+
// current_user.appear
|
|
43
|
+
// end
|
|
44
|
+
//
|
|
45
|
+
// def unsubscribed
|
|
46
|
+
// current_user.disappear
|
|
47
|
+
// end
|
|
48
|
+
//
|
|
49
|
+
// def appear(data)
|
|
50
|
+
// current_user.appear on: data['appearing_on']
|
|
51
|
+
// end
|
|
52
|
+
//
|
|
53
|
+
// def away
|
|
54
|
+
// current_user.away
|
|
55
|
+
// end
|
|
56
|
+
// end
|
|
57
|
+
//
|
|
58
|
+
// The "AppearanceChannel" name is automatically mapped between the client-side subscription creation and the server-side Ruby class name.
|
|
59
|
+
// The AppearanceChannel#appear/away public methods are exposed automatically to client-side invocation through the perform method.
|
|
60
|
+
|
|
61
|
+
const extend = function(object, properties) {
|
|
62
|
+
if (properties != null) {
|
|
63
|
+
for (let key in properties) {
|
|
64
|
+
const value = properties[key]
|
|
65
|
+
object[key] = value
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return object
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export default class Subscription {
|
|
72
|
+
constructor(consumer, params = {}, mixin) {
|
|
73
|
+
this.consumer = consumer
|
|
74
|
+
this.identifier = JSON.stringify(params)
|
|
75
|
+
this.streams = {}
|
|
76
|
+
extend(this, mixin)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Perform a channel action with the optional data passed as an attribute
|
|
80
|
+
perform(action, data = {}) {
|
|
81
|
+
data.action = action
|
|
82
|
+
return this.send(data)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
send(data) {
|
|
86
|
+
return this.consumer.send({command: "message", identifier: this.identifier, data: JSON.stringify(data)})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
unsubscribe() {
|
|
90
|
+
return this.consumer.subscriptions.remove(this)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
reset() {
|
|
94
|
+
this.streams = {}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
findOrCreateStream(broadcasting, id = null) {
|
|
98
|
+
if (this.streams[broadcasting] == null) {
|
|
99
|
+
this.streams[broadcasting] = new Stream(this, broadcasting, id)
|
|
100
|
+
}
|
|
101
|
+
return this.streams[broadcasting]
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import logger from "./logger"
|
|
2
|
+
|
|
3
|
+
// Responsible for ensuring channel subscribe command is confirmed, retrying until confirmation is received.
|
|
4
|
+
// Internal class, not intended for direct user manipulation.
|
|
5
|
+
|
|
6
|
+
class SubscriptionGuarantor {
|
|
7
|
+
constructor(subscriptions) {
|
|
8
|
+
this.subscriptions = subscriptions
|
|
9
|
+
this.pendingSubscriptions = []
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
guarantee(subscription) {
|
|
13
|
+
if(this.pendingSubscriptions.indexOf(subscription) == -1){
|
|
14
|
+
logger.log(`SubscriptionGuarantor guaranteeing ${subscription.identifier}`)
|
|
15
|
+
this.pendingSubscriptions.push(subscription)
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
logger.log(`SubscriptionGuarantor already guaranteeing ${subscription.identifier}`)
|
|
19
|
+
}
|
|
20
|
+
this.startGuaranteeing()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
forget(subscription) {
|
|
24
|
+
logger.log(`SubscriptionGuarantor forgetting ${subscription.identifier}`)
|
|
25
|
+
this.pendingSubscriptions = (this.pendingSubscriptions.filter((s) => s !== subscription))
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
startGuaranteeing() {
|
|
29
|
+
this.stopGuaranteeing()
|
|
30
|
+
this.retrySubscribing()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
stopGuaranteeing() {
|
|
34
|
+
clearTimeout(this.retryTimeout)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
retrySubscribing() {
|
|
38
|
+
this.retryTimeout = setTimeout(() => {
|
|
39
|
+
if (this.subscriptions && typeof(this.subscriptions.subscribe) === "function") {
|
|
40
|
+
this.pendingSubscriptions.map((subscription) => {
|
|
41
|
+
logger.log(`SubscriptionGuarantor resubscribing ${subscription.identifier}`)
|
|
42
|
+
this.subscriptions.subscribe(subscription)
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
, 500)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export default SubscriptionGuarantor
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import logger from "./logger"
|
|
2
|
+
import Subscription from "./subscription"
|
|
3
|
+
import SubscriptionGuarantor from "./subscription_guarantor"
|
|
4
|
+
|
|
5
|
+
// Collection class for creating (and internally managing) channel subscriptions.
|
|
6
|
+
// The only method intended to be triggered by the user is ActionCable.Subscriptions#create,
|
|
7
|
+
// and it should be called through the consumer like so:
|
|
8
|
+
//
|
|
9
|
+
// App = {}
|
|
10
|
+
// App.cable = ActionCable.createConsumer("ws://example.com/accounts/1")
|
|
11
|
+
// App.appearance = App.cable.subscriptions.create("AppearanceChannel")
|
|
12
|
+
//
|
|
13
|
+
// For more details on how you'd configure an actual channel subscription, see ActionCable.Subscription.
|
|
14
|
+
|
|
15
|
+
export default class Subscriptions {
|
|
16
|
+
constructor(consumer) {
|
|
17
|
+
this.consumer = consumer
|
|
18
|
+
this.guarantor = new SubscriptionGuarantor(this)
|
|
19
|
+
this.subscriptions = []
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
create(channelName, mixin) {
|
|
23
|
+
const channel = channelName
|
|
24
|
+
const params = typeof channel === "object" ? channel : {channel}
|
|
25
|
+
const subscription = new Subscription(this.consumer, params, mixin)
|
|
26
|
+
return this.add(subscription)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Private
|
|
30
|
+
|
|
31
|
+
add(subscription) {
|
|
32
|
+
this.subscriptions.push(subscription)
|
|
33
|
+
this.consumer.ensureActiveConnection()
|
|
34
|
+
this.notify(subscription, "initialized")
|
|
35
|
+
this.subscribe(subscription)
|
|
36
|
+
return subscription
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
remove(subscription) {
|
|
40
|
+
this.forget(subscription)
|
|
41
|
+
if (!this.findAll(subscription.identifier).length) {
|
|
42
|
+
this.sendCommand(subscription, "unsubscribe")
|
|
43
|
+
}
|
|
44
|
+
return subscription
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
reject(identifier) {
|
|
48
|
+
return this.findAll(identifier).map((subscription) => {
|
|
49
|
+
this.forget(subscription)
|
|
50
|
+
this.notify(subscription, "rejected")
|
|
51
|
+
return subscription
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
forget(subscription) {
|
|
56
|
+
this.guarantor.forget(subscription)
|
|
57
|
+
subscription.reset()
|
|
58
|
+
this.subscriptions = (this.subscriptions.filter((s) => s !== subscription))
|
|
59
|
+
return subscription
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
findAll(identifier) {
|
|
63
|
+
return this.subscriptions.filter((s) => s.identifier === identifier)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
reload() {
|
|
67
|
+
return this.subscriptions.map((subscription) =>
|
|
68
|
+
this.subscribe(subscription))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
notifyAll(callbackName, ...args) {
|
|
72
|
+
return this.subscriptions.map((subscription) =>
|
|
73
|
+
this.notify(subscription, callbackName, ...args))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
notify(subscription, callbackName, ...args) {
|
|
77
|
+
let subscriptions
|
|
78
|
+
if (typeof subscription === "string") {
|
|
79
|
+
subscriptions = this.findAll(subscription)
|
|
80
|
+
} else {
|
|
81
|
+
subscriptions = [subscription]
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return subscriptions.map((subscription) =>
|
|
85
|
+
(typeof subscription[callbackName] === "function" ? subscription[callbackName](...args) : undefined))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
subscribe(subscription) {
|
|
89
|
+
if (this.sendCommand(subscription, "subscribe")) {
|
|
90
|
+
this.guarantor.guarantee(subscription)
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
confirmSubscription(identifier, ids) {
|
|
95
|
+
logger.log(`Subscription confirmed ${identifier}`)
|
|
96
|
+
this.findAll(identifier).forEach((subscription) => {
|
|
97
|
+
if (ids != null) {
|
|
98
|
+
Object.entries(ids).forEach(([broadcasting, id]) => {
|
|
99
|
+
const stream = subscription.findOrCreateStream(broadcasting, id)
|
|
100
|
+
if (stream.isBehind(id)) stream.restartRecovery(this)
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
this.guarantor.forget(subscription)
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
sendCommand(subscription, command, data = {}) {
|
|
108
|
+
const {identifier} = subscription
|
|
109
|
+
return this.consumer.send({command, identifier, ...data})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
receive(identifier, message, id, broadcasting) {
|
|
113
|
+
if (id == null || broadcasting == null) {
|
|
114
|
+
return this.notify(identifier, "received", message)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return this.findAll(identifier).map((subscription) => {
|
|
118
|
+
const stream = subscription.findOrCreateStream(broadcasting)
|
|
119
|
+
const receive = (message) => this.notify(subscription, "received", message)
|
|
120
|
+
|
|
121
|
+
if (stream.processMessage(id, message, receive)) {
|
|
122
|
+
stream.processQueue(receive)
|
|
123
|
+
} else {
|
|
124
|
+
stream.recover(this, id, message)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return subscription
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
ingestHistory(identifier, broadcasting, {messages = [], id}) {
|
|
132
|
+
return this.findAll(identifier).map((subscription) => {
|
|
133
|
+
const stream = subscription.findOrCreateStream(broadcasting, id)
|
|
134
|
+
|
|
135
|
+
stream.processMessages(messages,
|
|
136
|
+
(message) => this.notify(subscription, "received", message))
|
|
137
|
+
|
|
138
|
+
return subscription
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { createConsumer as createActionCableConsumer } from "../action_cable"
|
|
2
|
+
let consumer
|
|
3
|
+
|
|
4
|
+
export async function getConsumer() {
|
|
5
|
+
return consumer || setConsumer(createConsumer().then(setConsumer))
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function setConsumer(newConsumer) {
|
|
9
|
+
return consumer = newConsumer
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function createConsumer(...args) {
|
|
13
|
+
return createActionCableConsumer(...args)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function subscribeTo(channel, mixin) {
|
|
17
|
+
const { subscriptions } = await getConsumer()
|
|
18
|
+
return subscriptions.create(channel, mixin)
|
|
19
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { connectStreamSource, disconnectStreamSource } from "@hotwired/turbo"
|
|
2
|
+
import { subscribeTo } from "./cable"
|
|
3
|
+
import snakeize from "./snakeize"
|
|
4
|
+
|
|
5
|
+
class TurboCableStreamSourceElement extends HTMLElement {
|
|
6
|
+
static observedAttributes = ["channel", "signed-stream-name"]
|
|
7
|
+
|
|
8
|
+
async connectedCallback() {
|
|
9
|
+
connectStreamSource(this)
|
|
10
|
+
this.subscription = await subscribeTo(this.channel, {
|
|
11
|
+
received: this.dispatchMessageEvent.bind(this),
|
|
12
|
+
connected: this.subscriptionConnected.bind(this),
|
|
13
|
+
disconnected: this.subscriptionDisconnected.bind(this)
|
|
14
|
+
})
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
disconnectedCallback() {
|
|
18
|
+
disconnectStreamSource(this)
|
|
19
|
+
if (this.subscription) this.subscription.unsubscribe()
|
|
20
|
+
this.subscriptionDisconnected()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
attributeChangedCallback() {
|
|
24
|
+
if (this.subscription) {
|
|
25
|
+
this.disconnectedCallback()
|
|
26
|
+
this.connectedCallback()
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
dispatchMessageEvent(data) {
|
|
31
|
+
const event = new MessageEvent("message", { data })
|
|
32
|
+
return this.dispatchEvent(event)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
subscriptionConnected() {
|
|
36
|
+
this.setAttribute("connected", "")
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
subscriptionDisconnected() {
|
|
40
|
+
this.removeAttribute("connected")
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
get channel() {
|
|
44
|
+
const channel = this.getAttribute("channel")
|
|
45
|
+
const signed_stream_name = this.getAttribute("signed-stream-name")
|
|
46
|
+
return { channel, signed_stream_name, ...snakeize({ ...this.dataset }) }
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
if (customElements.get("turbo-cable-stream-source") === undefined) {
|
|
52
|
+
customElements.define("turbo-cable-stream-source", TurboCableStreamSourceElement)
|
|
53
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export function encodeMethodIntoRequestBody(event) {
|
|
2
|
+
if (event.target instanceof HTMLFormElement) {
|
|
3
|
+
const { target: form, detail: { fetchOptions } } = event
|
|
4
|
+
|
|
5
|
+
form.addEventListener("turbo:submit-start", ({ detail: { formSubmission: { submitter } } }) => {
|
|
6
|
+
const body = isBodyInit(fetchOptions.body) ? fetchOptions.body : new URLSearchParams()
|
|
7
|
+
const method = determineFetchMethod(submitter, body, form)
|
|
8
|
+
|
|
9
|
+
if (!/get/i.test(method)) {
|
|
10
|
+
if (/post/i.test(method)) {
|
|
11
|
+
body.delete("_method")
|
|
12
|
+
} else {
|
|
13
|
+
body.set("_method", method)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
fetchOptions.method = "post"
|
|
17
|
+
}
|
|
18
|
+
}, { once: true })
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function determineFetchMethod(submitter, body, form) {
|
|
23
|
+
const formMethod = determineFormMethod(submitter)
|
|
24
|
+
const overrideMethod = body.get("_method")
|
|
25
|
+
const method = form.getAttribute("method") || "get"
|
|
26
|
+
|
|
27
|
+
if (typeof formMethod == "string") {
|
|
28
|
+
return formMethod
|
|
29
|
+
} else if (typeof overrideMethod == "string") {
|
|
30
|
+
return overrideMethod
|
|
31
|
+
} else {
|
|
32
|
+
return method
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function determineFormMethod(submitter) {
|
|
37
|
+
if (submitter instanceof HTMLButtonElement || submitter instanceof HTMLInputElement) {
|
|
38
|
+
// Rails 7 ActionView::Helpers::FormBuilder#button method has an override
|
|
39
|
+
// for formmethod if the button does not have name or value attributes
|
|
40
|
+
// set, which is the default. This means that if you use <%= f.button
|
|
41
|
+
// formmethod: :delete %>, it will generate a <button name="_method"
|
|
42
|
+
// value="delete" formmethod="post">. Therefore, if the submitter's name
|
|
43
|
+
// is already _method, it's value attribute already contains the desired
|
|
44
|
+
// method.
|
|
45
|
+
if (submitter.name === '_method') {
|
|
46
|
+
return submitter.value
|
|
47
|
+
} else if (submitter.hasAttribute("formmethod")) {
|
|
48
|
+
return submitter.formMethod
|
|
49
|
+
} else {
|
|
50
|
+
return null
|
|
51
|
+
}
|
|
52
|
+
} else {
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isBodyInit(body) {
|
|
58
|
+
return body instanceof FormData || body instanceof URLSearchParams
|
|
59
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import "./cable_stream_source_element"
|
|
2
|
+
|
|
3
|
+
import * as Turbo from "@hotwired/turbo"
|
|
4
|
+
export { Turbo }
|
|
5
|
+
|
|
6
|
+
import * as cable from "./cable"
|
|
7
|
+
export { cable }
|
|
8
|
+
|
|
9
|
+
import { encodeMethodIntoRequestBody } from "./fetch_requests"
|
|
10
|
+
|
|
11
|
+
window.Turbo = Turbo
|
|
12
|
+
|
|
13
|
+
addEventListener("turbo:before-fetch-request", encodeMethodIntoRequestBody)
|