I am hoping to stream data that I receive from a stock exchange (via a websocket) and broadcast that data to the client. I believe action cable is the best approach, but have been unsuccessful thus far.
The "DataProvider" built an SDK on Ruby to create the connection between my server and the stock-exchange server. I have been able to successfully connect, but have not seen data stream in.
Any suggestions?
Channel:
class StockPriceChannel < ApplicationCable::Channel
def subscribed
stream_from "portfolio_#{params[:portfolio_id]}"
end
def unsubscribed
# Any cleanup needed when channel is unsubscribed
EventMachine.stop()
end
def receive(data)
tickers = data["message"]
tickers = tickers.split(",")
puts tickers
options = {
username: ENV["DataProvider_USERNAME"],
password: ENV["DataProvider_PASSWORD"],
channels: tickers
}
EventMachine.run do
client = DataProvider::Realtime::Client.new(options)
client.on_quote do |quote|
StockPriceChannel.server.broadcast_to("portfolio_#
{params[:portfolio_id]}",quote)
end
client.connect()
EventMachine.add_timer(1) do
puts "joining tickers"
client.join(tickers)
end
# EventMachine.add_timer(10) do
# client.disconnect()
# EventMachine.stop()
# end
end
end
end
ReactJS (in componentDidMount() )
App.portfolioChannel = App.cable.subscriptions.create(
{
channel:"StockPriceChannel",
portfolio_id:this.props.match.params.port_id
},
{
connected: () => console.log("StockPriceChannel connected"),
disconnected: () => console.log("StockPriceChannel disconnected"),
received: data => {
debugger;
console.log(data)
}
}
)
App.portfolioChannel.send({
message:"AAPL,MSFT"
})
Well... I hope this (at the very least) helps some one else.
I realized my issue was
StockPriceChannel.server.broadcast_to("portfolio_#
{params[:portfolio_id]}",quote)
should be
ActionCable.server.broadcast_to("portfolio_#
{params[:portfolio_id]}",quote)
Related
I am using action cable and rails 6.
I am trying to broadcast messages to a particular organization.
My client js code picks up the organization id on the page an creates a subscription on the organization channel, passing up the organization_id.
const organizationID = document.getElementById("organization-wrapper").dataset.organizationId
consumer.subscriptions.create({
channel: "OrganizationChannel",
organization_id: organizationID
},{
connected() {
console.log('connected')
},
received(data) {
alert("hey")
const budgetSpentCents = parseInt(data['budget_spent_cents'])
const budgetSpentUnit = budgetSpentCents / 100.0
const trainingBudgetTotal = document.getElementById('training-budget-total')
const currentTrainingBudgetTotal = parseInt(trainingBudgetTotal.textContent)
trainingBudgetTotal.textContent = currentTrainingBudgetTotal + budgetSpentUnit
}
This works well, and I can see the ruby channel code being executed :
class OrganizationChannel < ApplicationCable::Channel
def subscribed
binding.pry
organization = Organization.find(params[:organization_id])
stream_from organization
end
def unsubscribed
stop_all_streams
# Any cleanup needed when channel is unsubscribed
end
end
However I am stuck when I try to broadcast to that particular client (organization). Here 's my attempt :
OrganizationChannel.broadcast_to(
Organization.first,
budget_spent_cents: Registration.last&.training_budget_transaction&.total_cents
)
which returns 0 and
[ActionCable] Broadcasting to organization:Z2lkOi8veXVub28vT3JnYW5pemF0aW9uLzE: {:budget_spent_cents=>-5000}
the received(data) hook in my client code is never executed... Im not sure why ? My guess is because Z2lkOi8veXVub28vT3JnYW5pemF0aW9uLzE is not the actual id of the organization (what is that string actually ?) and it doesnt find the proper channel to broadcast to.
How would you setup the broadcasting right in that situation ?
Z2lkOi8veXVub28vT3JnYW5pemF0aW9uLzE is the global id for the organization you are testing with. organization:Z2lkOi8veXVub28vT3JnYW5pemF0aW9uLzE is the channel name generated and used by the OrganizationChannel.broadcast_to method based on the arguments you are passing.
On your OrganizationChannel class, instead of using stream_from organization use stream_for organization. That will tell the client to properly stream from the organization:#{global_id} channel.
In my rails auction app, authorized users can connect to 2 channels simultaneously within the product page (one is all_users channel for the product, the other is user specific channel for direct messaging.)
Now I would like to send sensitive information only to admin group users. I tought I can define a third channel connection request (admin_channel) in the coffee script but I couldn't figured out how I can authorize user connection for the 3rd channel based on role.
Another alternative might be utilizing the existing user specific channel but here I couldn't figured out how backend classes may know which users in the admin group currently is online (has a user channel up&running)..
Do you have any idea how I can achieve it? Any kind of support will be appreciated..
Below you can find my existing connection.rb file and coffeescript files.
Here is my connection.rb file:
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user
def connect
self.current_user = find_verified_user
end
protected
def find_verified_user # this checks whether a user is authenticated with devise
if verified_user = env['warden'].user
verified_user
else
reject_unauthorized_connection
end
end
end
end
coffee script:
$( document ).ready ->
App.myauction = App.cable.subscriptions.create({
channel: 'MyauctionChannel'
id: $('#auctionID').attr('data-id')
},
connected: ->
console.log "Connected"
# Called when the subscription is ready for use on the server
disconnected: ->
# Called when the subscription has been terminated by the server
speak: (message) ->
#perform 'speak', message: message
received: (data) ->
console.log(data)
# Called when there's incoming data on the websocket for this channel
)
App.myauctionuser = App.cable.subscriptions.create({
channel: 'MyauctionChannel',
id: $('#auctionID').attr('data-uuid-code')
},
connected: ->
console.log "user connected"
# Called when the subscription is ready for use on the server
disconnected: ->
# Called when the subscription has been terminated by the server
speak: (message) ->
#perform 'speak', message: message
received: (data) ->
# console.log ("user channel ")
# console.log(data)
)
$(document).ready ->
App.privateAdminMesssagesChannel = App.cable.subscriptions.create({
channel: 'PrivateAdminMessagesChannel'
},
connected: ->
disconnected: ->
// call this function to send a message from a Non-Admin to All Admins
sendMessageToAdmins: (message) ->
#perform 'send_messsage_to_admins', message: message
// call this function to send a messsage from an Admin to (a Non-admin + all Admins)
sendMessageToUserAndAdmins: (message, toUserId) ->
#perform 'send_messsage_to_user_and_admins', message: message, to_user_id: toUserId
received: (data) ->
console.log(data.from_user_id)
console.log(data.to_user_id)
console.log(data.message)
if data.to_user_id
// this means the message was sent from an Admin to (a Non-admin + all admins)
else
// this means the message was sent from a Non-admin to All Admins
// do some logic here i.e. if current user is an admin, open up one Chatbox
// on the page for each unique `from_user_id`, and put data.message
// in that box accordingly
)
private_admin_messages_channel.rb
class PrivateAdminMessagesChannel < ActionCable::Channel::Base
def subscribed
stream_from :private_admin_messages_channel, coder: ActiveSupport::JSON do |data|
from_user = User.find(data.fetch('from_user_id'))
to_user = User.find(data['to_user_id']) if data['to_user_id']
message = data.fetch('message')
# authorize if "message" is sent to you (a non-admin), and also
# authorize if "message" is sent to you (an admin)
if (to_user && to_user == current_user) || (!to_user && current_user.is_admin?)
# now, finally send the Hash data below and transmit it to the client to be received in the JS-side "received(data)" callback
transmit(
from_user_id: from_user.id,
to_user_id: to_user&.id,
message: message
)
end
end
end
def send_message_to_admins(data)
ActionCable.server.broadcast 'private_admin_messages_channel',
from_user_id: current_user.id,
message: data.fetch('message')
end
def send_message_to_user_and_admins(data)
from_user = current_user
reject unless from_user.is_admin?
ActionCable.server.broadcast 'private_admin_messages_channel',
from_user_id: from_user.id,
to_user_id: data.fetch('to_user_id'),
message: data.fetch('message')
end
end
Above is the easiest way I could think of. Not the most efficient one because there's an extra level of authorization happening per stream (see inside stream_from block) unlike if we have different broadcast-names of which the authorization would happen only once on the "connecting" itself, and not each "streaming"... which can be done via something like:
Admin User1 opens page then JS-subscribes to UserConnectedChannel
Non-admin User2 opens page then JS-subscribes to PrivateAdminMessagesChannel passing in data: user_id: CURRENT_USER_ID
From 2. above, as User2 has just subscribed; then on the backend, inside def subscribed upon connection, you ActionCable.server.broadcast :user_connected, { user_id: current_user.id }
Admin User1 being subscribed to UserConnectedChannel then receives with data { user_id: THAT_USER2_id }
From 4 above, inside the JS received(data) callback, you then now JS-subscribe to PrivateAdminMessagesChannel passing in data: THAT_USER2_id`.
Now User1 and User2 are both subscribed to PrivateAdminMessagesChannel user_id: THAT_USER2_id which means that they can privately talk with each other (other admins should also have had received :user_connected's JS data: { user_id: THAT_USER2_ID }, and so they should also be subscribed as well, because it makes sense that AdminUser1, NonAdminUser2, and AdminUser3 can talk in the same chat channel... from what I was getting with your requirements)
TODO: From 1 to 6 above, do something similar also with the "disconnection" process
Trivias:
Those you define with identified_by in your ApplicationCable::Connection can be acceessed in your channel files. In particular, in this case, current_user can be called.
Regarding, rejecting subscriptions, see docs here
How can I send with javascript a global message to all of our subscribed websocket connections without the need of a channel etc. (like the ping message that the actioncable is sending by default globally to all open connections)?
As far as I know, you cannot do it directly from JavaScript without channels (it needs to go over Redis first).
I would suggest you do that as a normal post action, and then send the message in Rails.
I would do something like this:
JavaScript:
$.ajax({type: "POST", url: "/notifications", data: {notification: {message: "Hello world"}}})
Controller:
class NotificationsController < ApplicationController
def create
ActionCable.server.broadcast(
"notifications_channel",
message: params[:notification][:message]
)
end
end
Channel:
class NotificationsChannel < ApplicationCable::Channel
def subscribed
stream_from("notifications_channel", coder: ActiveSupport::JSON) do |data|
# data => {message: "Hello world"}
transmit data
end
end
end
Listen JavaScript:
App.cable.subscriptions.create(
{channel: "NotificationsChannel"},
{
received: function(json) {
console.log("Received notification: " + JSON.stringify(json))
}
}
)
How in Rails 4 using Server Sent Events and listen multiple callbakcs (create and destroy)? For example,
Model:
class Job < ActiveRecord::Base
after_create :notify_job_created
after_destroy :notify_job_destroyed
def self.on_create
Job.connection.execute "LISTEN create_jobs"
loop do
Job.connection.raw_connection.wait_for_notify do |event, pid, job|
yield job
end
end
ensure
Job.connection.execute "UNLISTEN create_jobs"
end
def self.on_destroy
Job.connection.execute "LISTEN destroy_jobs"
loop do
Job.connection.raw_connection.wait_for_notify do |event, pid, job|
yield job
end
end
ensure
Job.connection.execute "UNLISTEN destroy_jobs"
end
def notify_job_created
Job.connection.execute "NOTIFY create_jobs, '#{self.id}'"
end
def notify_job_destroyed
Job.connection.execute "NOTIFY destroy_jobs, '#{self.id}'"
end
end
Controller:
class StreamJobsController < ApplicationController
include ActionController::Live
def index_stream
response.headers['Content-Type'] = 'text/event-stream'
sse = SSE.new response.stream
begin
Job.on_create do |id|
job = Job.find(id)
stand = Stand.find(job.stand_id)
t = render_to_string(
partial: 'projects/stand',
formats: [:html],
locals: {stand: stand}
)
sse.write(t, event: 'create')
end
Job.on_destroy do |id|
job = Job.find(id)
sse.write(job.stand_id, event: 'destroy')
end
rescue IOError
# When the client disconnects, we'll get an IOError on write
ensure
sse.close
end
end
end
JS code:
$(function () {
var source = new EventSource('/jobs_stream');
source.addEventListener('create', function(e){
console.log('Create stand:', e.data);
$("table.project-stands.table.table-condensed").find("tbody#stand-list").prepend($.parseHTML(e.data));
});
source.addEventListener('destroy', function(e){
console.log('Destroy stand: ', e.data);
var id = e.data;
$("table.project-stands.table.table-condensed").find("tr#stand_" + id).remove();
});
source.addEventListener('finished', function(e){
console.log('Close:', e.data);
source.close();
});
});
As result, I get only LISTEN create_jobs. What's wrong in my controller? Thanks
I don't think anything is wrong with your controller. Your question is more about Postgres notifications than anything else. Do you have tests for the methods you are using in your ActiveRecord models that ensure the NOTIFY/LISTEN combinations function as they should?
As result, I get only LISTEN create_jobs
What do you mean by that? I don't see any LISTEN stuff coming into the SSE output in the example you have posted. I would really suggest you experiment with the model on it's own to see if the blocking waits you are using etc. function in isolation, and then try putting SSE on top as a transport.
I am using for the first time redis to put chat functionality in my rails app, following this
I have in my javascript`
$(document).ready ->
source = new EventSource('/messages/events')
source.addEventListener 'messages.create', (e) ->
message = $.parseJSON(e.data).message
console.log(message)
$(".chat-messages").append "#some code"
and in my message controller
def create
response.headers["Content-Type"] = "text/javascript"
attributes = params.require(:message).permit(:content, :sender_id, :sendee_id)
#message = Message.create(attributes)
respond_to do |format|
format.js { render 'messages/create.js.erb' }
end
$redis.publish('messages.create', #message.to_json)
end
def events
response.headers["Content-Type"] = "text/event-stream"
redis = Redis.new
redis.subscribe('messages.*') do |on|
on.message do |pattern, event, data|
response.stream.write("event: #{event}\n")
response.stream.write("data: #{data}\n\n")
end
end
rescue IOError
logger.info "Stream closed"
ensure
redis.quit
response.stream.close
end
The problem is that first, nothing is logged in my console, and second I get numbers of random ConnectionTimeoutError errors. Some one hava an idea what's going on
Pre-Reqs:
Ruby 2.0.0+
Rails 4.0.0+
Redis
Puma
Initializer:
Create a redis.rb initializer file in the config/initializers directory, globalizing an instance of redis. It's also a good idea to set up a heartbeat thread (Anything from 5 seconds to 5 minutes is okay, depending on your requirements):
$redis = Redis.new
heartbeat_thread = Thread.new do
while true
$redis.publish("heartbeat","thump")
sleep 15.seconds
end
end
at_exit do
# not sure this is needed, but just in case
heartbeat_thread.kill
$redis.quit
end
Controller:
You need to add two methods to your ChatController, pub and sub. The role of pub is to publish chat events and messages to redis, and sub to subscribe to these events. It should look something like this:
class ChatController < ApplicationController
include ActionController::Live
skip_before_filter :verify_authenticity_token
def index
end
def pub
$redis.publish 'chat_event', params[:chat_data].to_json
render json: {}, status: 200
end
def sub
response.headers["Content-Type"] = "text/event-stream"
redis = Redis.new
redis.subscribe(['chat_event']) do |on|
on.message do |event, data|
response.stream.write "event: #{event}\ndata: #{data}\n\n"
end
end
rescue IOError
logger.info "Stream Closed"
ensure
redis.quit
response.stream.close
end
end
In your routes, make pub a POST and sub a GET, and match the path to something like /chat/publish and /chat/subscribe.
Coffeescript / Javascript:
Assuming your actual webpage for the chat app is at /chat, you need to write some Javascript to actually send and receive chat messages.
For ease of understanding, let's suppose your webpage only has a textbox and a button. Hitting the button should publish the content of the textbox to the chat stream, we can do that using AJAX:
$('button#send').click (e) ->
e.preventDefault()
$.ajax '/chat/publish',
type: 'POST'
data: {
chat_data: {
message: $("input#message").val(),
timestamp: $.now()
}
}
error: (jqXHR, textStatus, errorThrown) ->
console.log "Failed: " + textStatus
success: (data, textStatus, jqXHR) ->
console.log "Success: " + textStatus
Now, you need to be able to subscribe and receive the chat messages as well. You need to use EventSource for this. Using EventSource, open a channel for SSE so that you can receive events, and use that data to update the view. In this example, we will only log them to the javascript console.
The code should look something like this:
$(document).ready ->
source = new EventSource('/chat/subscribe')
source.addEventListener 'chat_event', (e) ->
console.log(e.data)
Enable Parallel Requests:
In your development environment, you'll have to enable parallel requests by adding these two lines to your config/environments/development.rb:
config.preload_frameworks = true
config.allow_concurrency = true
Now fire up your browser, browse to /chat and see the magic. When you type a message and click the button, the message will be received by all instances of that webpage.
Well this is how you make a basic chat application in rails using ActionController::Live and Redis. The final code would obviously be very different depending on your requirements but this should get you started.
Some more resources you should check out:
Tender Love Making - Is it Live?
Railscasts - #401 - ActionController::Live
SitePoint - Mini Chat with Rails and SSEs
Github - mohanraj-ramanujam / live-stream
Thoughtbot - Chat Example using SSEs
Although I've not used redis in this capacity (a mediator for "live" data), I managed to get this functionality working with Pusher
Redis
I don't understand how you're keeping the connection open between your app & Redis. You'll need some sort of web socket or concurrent-connection tech in place to handle the updates -- and to my knowledge, Redis does not handle this directly
If you look at this example, it uses a server called Goliath to handle the asynchronous connectivity:
When tiny-chat connects to the server it sends a GET request to
/subscribe/everyone where everyone is the name of the channel and with
the “Accept” header set to text/event-stream. The streaming middleware
(above) receives this request and subscribes to a redis Pub/Sub
channel. Since Goliath is non-blocking multiple clients can be
listening for events without tying up a Heroku dyno. The payload of a
server sent event looks like this:
That basically uses Middleware to connect you to the redis server -- allowing you to receive updates as required
Code
Although I can't pinpoint any errors specifically, I can give you some code we're using (using Pusher):
#config/initializers/pusher.rb
Pusher.url = ENV["PUSHER_URL"]
Pusher.app_id = ENV["PUSHER_APP_ID"]
Pusher.key = ENV["PUSHER_KEY"]
Pusher.secret = ENV["PUSHER_SECRET"]
#app/controllers/messages_controller.rb
def send_message
id = params[:id]
message = Message.find(id).broadcast!
public_key = self.user.public_key
Pusher['private-user-' + public_key].trigger('message_sent', {
message: "Message Sent"
})
end
#app/views/layouts/application.html.erb
<%= javascript_include_tag "http://js.pusher.com/2.1/pusher.min.js" %>
#app/assets/javascripts/application.js
$(document).ready(function(){
#Pusher
pusher = new Pusher("************",
cluster: 'eu'
)
channel = pusher.subscribe("private-user-#{gon.user}")
channel.bind "multi_destroy", (data) ->
alert data.message
channel.bind "message_sent", (data) ->
alert data.message
});