ws_service

Easier, cleaner websocket services for Crystal web servers and web frameworks

WS_Service is a base class for WebSocket services that does a lot of the work for you:

Services are implemented as classes that are children of WS_Service. WS_Middleware.instance is an HTTP::Handler that connects to HTTP::Server and accepts WebSocket connections for you, selecting the required WS_Service class out of many potential services, and instantiating it per connection.

How To Use

First, connect WS_Middleware.instance to HTTP::Server. If you have a stand-alone server, this is done as you instantiate HTTP::Server:

 server = HTTP::Server.new([
   WS_Middleware.instance,
   # Additional handlers go here.
 ])

On a web framework, there is generally a file used to declare middleware. On the Lucky web framework, it's src/app_server.cr, and it would be modified this way:

class AppServer < Lucky::BaseAppServer
  # Learn about middleware with HTTP::Handlers:
  # https://luckyframework.org/guides/http-and-routing/http-handlers
  def middleware : Array(HTTP::Handler)
    [
      WS_Middleware.instance, # Add this one.

      # There is a long list of handlers here.

    ] of HTTP::Handler
  end

Create your own service as a child of WS_Service. Define a self.path class method to return the path to your WebSocket service. This example sets the path to "/inform":

class MyService < WS_Service
  # You must define a self.path method. The path should start with a '/' character.
  def self.path
    "/inform"
  end
end

Your class will automatically be registered with WS_Middleware.instance, and when WS_Middleware.instance gets a request for a WebSocket service with "/inform" as the path, it will instantiate your class. There may be any number of different WebSocket services, implement a child class of WS_Service for each one.

Implement whatever of these methods you need in your class:

  # Authenticate the incoming connection. Return `true` if it should be accepted,
  # `false` otherwise. The WebSocket isn't opened until after this method returns,
  # so it's not possible to send to the client in this method.
  def authenticate(
   # The requested path, without any query parameters.
   path : String,

   # A `URI::Params containing the query parmaeters, which can be used by the client
   # to send additional data for authentication and initialization. For example,
   # if the client connects to "ws://my.host/inform?a=1&b=2&c=Bruce",
   # params["a"] will contain "1", and params["c"] will contain "Bruce".
   params : URI::Params,

   # You may optionally implement subprotocols in your service. If you do, the client
   # can ask for one or more subprotocols, in order of preference. They will be
   # listed here. If you implement subprotocols, set `self.subprotocol=` to
   # the client-requested one you choose to accept, in the `authenticate` method.
   # It will be returned in the "Sec-Websocket-Protocol" header in the response.
   requested_subprotocols : Array(String),

   # The HTTP request. You can read the headers from here, and other information
   # about the connection.
   request : HTTP::Request,

   # The address of the client, or nil if it can't be determined.
   remote_address : Socket::Address?
  ) : Bool
    true
  end

  # This is called when binary data is received.
  def on_binary(b : Bytes)
  end

  # This is called when the connection is closed.
  def on_close(code : HTTP::WebSocket::CloseCode, message : String)
  end

  # This is called when your WebSocket is connected. You may now send to the peer.
  def on_connect
  end

  # This is called when string data is received.
  def on_message(message : String)
  end
end

There are methods available to your class for sending data and managing the connection:

   # Close the connection.
   close(
    # Optional argument, the code sent to the client's close handler.
    # The default is HTTP::WebSocket::NormalClose.
    code : HTTP::WebSocket::CloseCode,

    # Message to send to the client on closing. The client can process this
    # information, but most don't. Example message: "'asta la vista, baby!".
    message : String
   )

   # Is the connection open?
   is_open? : Bool

   # This will send a binary message if the argument is `Bytes`, and a textual
   # message if the argument is `String`.
   send(data)

You can gracefully close all WS_Service connections by calling this:

  WS_Service.graceful_shutdown(message : String)

This is generally done when shutting down a server, etc.

The connection is automatically closed in the finalize method of your class. This will also gracefully close the WebSocket when your application exits, or when your class is garbage-collected.

You can implement less-often-used methods which mirror those in WebSocket. But WS_Service will send keep-alive pings to clients, and close connections to unresponding clients, even if you don't.

   # Called upon receipt of a ping.
   def on_ping(message : String)
   end

   # Called upon receipt of a pong.
   def on_pong(message : String)
   end

And these less-often-used methods are available to you for sending pings and pongs.

   # Send a ping.
   ping(message : String)

   # Send a pong.
   pong(message : String)

Security Notes

WebSockets are not restricted by the same-origin policy (see https://developer.mozilla.org/en-US/docs/Web/Security/Same-origin_policy). Thus, it's not advised to use cookies for authentication.

To enforce your own origin policy, you can check that the "Origin" header names your site. This may protect users from cross-site websocket hijacking in sites that they visit. Crafty software can still put anything it wishes in the origin header.

Writing Your Client

There is a symmtrical shard for building clients, see https://github.com/BrucePerens/ws_client . It exports the same API as this class, but for clients.