/lowdown

A Ruby client for the HTTP/2 version of the Apple Push Notification Service.

Primary LanguageRubyMIT LicenseMIT

Lowdown

Build Status

⚠︎ NOTE: This is not battle-tested yet, which will follow over the next few weeks. A v1 will be released at that time.

Lowdown is a Ruby client for the HTTP/2 version of the Apple Push Notification Service.

For efficiency, multiple notification requests are multiplexed and a single client can manage a pool of connections.

$ bundle exec ruby examples/simple.rb path/to/certificate.pem development <device-token>
Sent notification with ID: 13
Sent notification with ID: 1
Sent notification with ID: 10
Sent notification with ID: 7
Sent notification with ID: 25
...
Sent notification with ID: 10000
Sent notification with ID: 9984
Sent notification with ID: 9979
Sent notification with ID: 9992
Sent notification with ID: 9999
Finished in 14.98157 seconds

This example was run with a pool of 10 connections.

Installation

Add this line to your application's Gemfile:

gem 'lowdown'

Or install it yourself, for instance for the command-line usage, as:

$ gem install lowdown

Usage

You can use the lowdown bin that comes with this gem or for code usage see the documentation.

There are mainly two different modes in which you’ll typically use this client. Either you deliver a batch of notifications every now and then, in which case you only want to open a connection to the remote service when needed, or you need to be able to continuously deliver transactional notifications, in which case you’ll want to maintain persistent connections. You can find examples of both these modes in the examples directory.

But first things first, this is how you create a notification object:

notification = Lowdown::Notification.new(:token => "device-token", :payload => { :alert => "Hello World!" })

There’s plenty more options for a notification, please refer to the Notification documentation.

Short-lived connection

After obtaining a client, the simplest way to open a connection for a short period is by passing a block to connect. This will open the connection, yield the block, and close the connection by the end of the block:

client = Lowdown::Client.production(true, File.read("path/to/certificate.pem")
client.connect do |group|
  # ...
end

Persistent connection

⚠︎ NOTE: See the ‘Gotchas’ section, specifically about process forking.

The trick to creating a persistent connection is to specify the keep_alive: true option when creating the client:

client = Lowdown::Client.production(true, File.read("path/to/certificate.pem"), keep_alive: true)

# Send a batch of notifications
client.group do |group|
  # ...
end

# Send another batch of notifications
client.group do |group|
  # ...
end

One big difference you’ll notice with the short-lived connection example, is that you no longer use the Client#connect method, nor do you close the connection (at least not until your process ends). Instead you use the group method to group a set of deliveries.

Grouping requests

Because Lowdown uses background threads to deliver notifications, the thread you’re delivering them from would normally chug along, which is often not what you’d want. To solve this, the group method provides you with a group object which allows you to handle responses for the requests made in that group and halts the caller thread until all responses have been handled.

All responses in a group will be handled in a single background thread, without halting the connection threads.

In typical Ruby fashion, a group provides a way to specify callbacks as blocks:

group.send_notification(notification) do |response|
  # ...
end

But there’s another possiblity, which is to provide a delegate object which gets a message sent for each response:

class Delegate
  def handle_apns_response(response, context:)
    # ...
  end
end

delegate = Delegate.new

client.group do |group|
  group.send_notification(notification, delegate: delegate)
end

Keep in mind that, like with the block version, this message is sent on the group’s background thread.

Threading

While we’re on the topic of threading anyways, here’s an important thing to keep in mind; each set of group callbacks is performed on its own thread. It is thus your responsibility to take this into account. E.g. if you are planning to update a DB model with the status of a notification delivery, be sure to respect the threading rules of your DB client, which usually means to not re-use models that were loaded on a different thread.

A simple approach to this is by passing the data you need to be able to update the DB as a context, which can be any type of object or an array objects:

group.send_notification(notification, context: model.id) do |response, model_id|
  reloaded_model = Model.find(model_id)
  if response.success?
    reloaded_model.touch(:sent_at)
  else
    reloaded_model.update_attribute(:last_response, response.status)
  end
end

Connection pool

When you need to be able to deliver many notifications in a short amount of time, it can be beneficial to open multiple connections to the remote service. By default Lowdown will initialize clients with a single connection, but you may increase this with the pool_size option:

Lowdown::Client.production(true, File.read("path/to/certificate.pem"), pool_size: 3)

Connect to APNS via proxy

Lowdown::Connection#initialize accepts a lambda to build TCPSocket. Build a duck type of TCPSocket which go through proxy.

socket_maker = lambda do |uri|
  Proxifier::Proxy('http://127.0.0.1:3128').open \
    uri.host, uri.port, nil, nil, Celluloid::IO::TCPSocket
end

connection_pool = Lowdown::Connection.pool \
  size: 2,
  args: [uri, cert.ssl_context, true, socket_maker]

client = Lowdown::Client.client_with_connection connection_pool, certificate: cert

Gotchas

  • If you’re forking your process, be sure to not load lowdown before forking (because this does not work well with Celluloid, or with threading and Ruby in general).

    Forking is done by, e.g. Spring and DelayedJob, when daemonizing workers. In practice, this means that e.g. you should not initialize a client from a Rails initializer, but rather do it lazily when it’s really required. E.g.:

class PushNotificationService
  def initialize(certificate_path)
    @certificate_path = certificate_path
    @client_mutex = Mutex.new
  end

  def client
    client = nil
    @client_mutex.synchronize do
      @client ||= Lowdown::Client.production(true, File.read(certificate_path), keep_alive: true)
      client = @client
    end
    client
  end
end

# In your initializer:
PUSH_NOTIFICATION_SERVICE = PushNotificationService.new("path/to/certificate.pem")

Related tool ☞

Also checkout this library for scheduling across time zones.

Contributing

Bug reports and pull requests are welcome on GitHub at https://github.com/alloy/lowdown.

License

The gem is available as open source under the terms of the MIT License.