cotag/spider-gazelle

Example on how to use the WebSockets promise?

dariocravero opened this issue · 6 comments

Hi @stakach,

I hope you're doing well and that the demo went out just fine :).

I'd like to ask you, would you have an example on how to use the WebSockets promise with a Rack application?

Thanks,
Darío

NOTE:: updated with the latest websocket update

Hi @dariocravero I will be open sourcing the application we built spider gazelle for soon. However here is the websocket management code:

# Require the websocket abstraction
require 'spider-gazelle/upgrades/websocket'


module Orchestrator
    class PersistenceController < ActionController::Metal
        include ActionController::Rendering

        # the promise resolution means this method will be running on the sockets event loop
        # We have a custom class for managing the websocket requests
        # The start method could have easily been called from within the websocket manager class too
        def self.start(hijacked)
            ws = ::SpiderGazelle::Websocket.new(hijacked.socket, hijacked.env)
            WebsocketManager.new(ws)
            ws.start
        end

        # Cache the callback method for the promise (avoids object creation each time)
        START_WS = self.method(:start)


        def websocket
            hijack = request.env['rack.hijack']
            if hijack
                promise = hijack.call
                promise.then START_WS

                throw :async     # to prevent rails from complaining 
            else
                render nothing: true, status: :method_not_allowed
            end
        end
    end
end

in routes we have:

get 'websocket', to: 'persistence#websocket', via: :all

then the websocket manager:
There is way more to this class however these are the main parts that interact with the websocket

        def initialize(ws, user = OpenStruct.new({id: 'anonymous'}))
            @ws = ws
            @user = user
            @loop = @ws.loop

            @bindings = ::ThreadSafe::Cache.new
            @stattrak = @loop.observer
            @notify_update = method(:notify_update)

            @logger = ::Orchestrator::Logger.new(@loop, user)

            @ws.progress method(:on_message)
            @ws.finally method(:on_shutdown)
            @ws.on_open method(:on_open)
        end

        def on_message(data, ws)
            begin
                raw_parameters = ::JSON.parse(data, DECODE_OPTIONS)
                parameters = ::ActionController::Parameters.new(raw_parameters)
                params = parameters.permit(PARAMS)
            rescue => e
                @logger.print_error(e, 'error parsing websocket request')
                error_response(nil, ERRORS[:parse_error], e.message)
                return
            end

            if check_requirements(params)
                if security_check(params)
                    begin
                        cmd = params[:cmd].to_sym
                        if COMMANDS.include?(cmd)
                            self.__send__(cmd, params)
                        else
                            @logger.warn("websocket requested unknown command '#{params[:cmd]}'")
                            error_response(params[:id], ERRORS[:unknown_command], "unknown command: #{params[:cmd]}")
                        end
                    rescue => e
                        @logger.print_error(e, "websocket request failed: #{data}")
                        error_response(params[:id], ERRORS[:request_failed], e.message)
                    end
                else
                    # log access attempt here (possible hacking attempt)
                    @logger.warn('security check failed for websocket request')
                    error_response(params[:id], ERRORS[:access_denied], 'the access attempt has been recorded')
                end
            else
                # log user information here (possible probing attempt)
                reason = 'required parameters were missing from the request'
                @logger.warn(reason)
                error_response(params[:id], ERRORS[:bad_request], reason)
            end
        end

        def on_shutdown
            @pingger.cancel if @pingger
            @bindings.each_value &method(:do_unbind)
            @bindings = nil
            @debug.resolve(true) if @debug # detach debug listeners
        end


        protected


        # Maintain the connection if ping frames are supported
        def on_open(evt)
            if @ws.ping('pong')
                variation = 1 + rand(20000)
                @pingger = @loop.scheduler.every(40000 + variation, method(:do_ping))
            end
        end

        def do_ping(time1, time2)
            @ws.ping('pong')
        end

        def error_response(id, code, message)
            @ws.text(::JSON.generate({
                id: id,
                type: :error,
                code: code,
                msg: message
            }))
        end

Pretty much the @ws methods such as @ws.progress in the websocket manager are relevant the rest is application specific code.

In the on_open event method, the scheduler function is monkey patched into libuv via the uv-rays gem

Also, it is probably worth noting, I've wrapped the faye websocket driver into a promise for spider-gazelle use. So non-promise methods such as @ws.driver.on(:open, &method(:on_open)) need to access the driver directly

Although I guess we could use something like ruby Forwardable to proxy commonly used methods.

NOTE:: This has been implemented

That's amazing @stakach! I took the gist of it and created a gem that abstracts this implementation for @padrino. Check it out here, I'll be releasing it soon but I'd like to know what you think about it.

It includes the latest changes you did on hiding the internals and using Ruby's Forwardable instead.

Padrino was lacking an easy and consistent WebSockets base and I guess this is a good step towards allowing different WS backends to be plugged in while using a common interface.

It lets you do something like this:

websocket :channel do
  event :test do |context, message|
    "test on channel"
    send_message message
  end
end

websocket :another_channel, map: '/blah' do
  event :test do |context, message|
    "test on channel"
    broadcast message
  end
end

It still has a few quirks that need to be fixed and the tests to be added. I first wanted to be sure that it was going down a good path.

There's a simple Padrino's example app here.

/cc @nesquena @DAddYE @skade @namusyaka @Ortuna @ujifgc

Thoughts?

Very nice abstraction!
I like the idea of passing on requests if they are not a good match for the handler too. Seems elegant.

Yeah, otherwise it might clash with an HTTP route that you may have for another application... event could also be abstracted to on (perhaps just aliased?) as faye-websocket-ruby does.