skyfall 0.6.1 → 0.7.0

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.
@@ -1,17 +1,85 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'eventmachine'
2
4
  require 'faye/websocket'
3
5
  require 'uri'
4
6
 
7
+ require_relative 'errors'
8
+ require_relative 'events'
5
9
  require_relative 'version'
6
10
 
7
11
  module Skyfall
12
+
13
+ # Base class of a websocket client. It provides basic websocket client functionality such as
14
+ # connecting to the service, keeping the connection alive and running lifecycle callbacks.
15
+ #
16
+ # In most cases, you will not create instances of this class directly, but rather use either
17
+ # {Firehose} or {Jetstream}. Use this class as a superclass if you need to implement some
18
+ # custom client for a websocket API that isn't supported yet.
19
+
8
20
  class Stream
9
- EVENTS = %w(message raw_message connecting connect disconnect reconnect error timeout)
10
- MAX_RECONNECT_INTERVAL = 300
21
+ extend Events
11
22
 
12
- attr_accessor :auto_reconnect, :last_update, :user_agent
13
- attr_accessor :heartbeat_timeout, :heartbeat_interval, :check_heartbeat
23
+ MAX_RECONNECT_INTERVAL = 300
14
24
 
25
+ # If enabled, the client will try to reconnect if the connection is closed unexpectedly.
26
+ # (Default: true)
27
+ #
28
+ # When the reconnect attempt fails, it will wait with an exponential backoff delay before
29
+ # retrying again, up to {MAX_RECONNECT_INTERVAL} seconds.
30
+ #
31
+ # @return [Boolean]
32
+ attr_accessor :auto_reconnect
33
+
34
+ # User agent sent in the header when connecting.
35
+ #
36
+ # Default value is {#default_user_agent} = {#version_string} `(Skyfall/x.y)`. It's recommended
37
+ # to set it or extend it with some information that indicates what service this is and who is
38
+ # running it (e.g. a Bluesky handle).
39
+ #
40
+ # @return [String]
41
+ # @example
42
+ # client.user_agent = "my.service (@my.handle) #{client.version_string}"
43
+ attr_accessor :user_agent
44
+
45
+ # If enabled, runs a timer which does periodical "heatbeat checks".
46
+ #
47
+ # The heartbeat timer is started when the client connects to the service, and checks if the stream
48
+ # hasn't stalled and is still regularly sending new messages. If no messages are detected for some
49
+ # period of time, the client forces a reconnect.
50
+ #
51
+ # This is **not** enabled by default, because depending on the service you're connecting to, it
52
+ # might be normal to not receive any messages for a while.
53
+ #
54
+ # @see #heartbeat_timeout
55
+ # @see #heartbeat_interval
56
+ # @return [Boolean]
57
+ attr_accessor :check_heartbeat
58
+
59
+ # Interval in seconds between heartbeat checks (default: 10). Only used if {#check_heartbeat} is set.
60
+ # @return [Numeric]
61
+ attr_accessor :heartbeat_interval
62
+
63
+ # Number of seconds without messages after which reconnect is triggered (default: 300).
64
+ # Only used if {#check_heartbeat} is set.
65
+ # @return [Numeric]
66
+ attr_accessor :heartbeat_timeout
67
+
68
+ # Time when the most recent message was received from the websocket.
69
+ #
70
+ # Note: this is _local time_ when the message was received; this is different from the timestamp
71
+ # of the message, which is the server time of the original source (PDS) when emitting the message,
72
+ # and different from a potential `created_at` saved in the record.
73
+ #
74
+ # @return [Time, nil]
75
+ attr_reader :last_update
76
+
77
+ #
78
+ # @param server [String] Address of the server to connect to.
79
+ # Expects a string with either just a hostname, or a ws:// or wss:// URL.
80
+ #
81
+ # @raise [ArgumentError] if the server parameter is invalid
82
+ #
15
83
  def initialize(server)
16
84
  @root_url = build_root_url(server)
17
85
 
@@ -27,9 +95,23 @@ module Skyfall
27
95
  @handlers[:error] = proc { |e| puts "ERROR: #{e}" }
28
96
  end
29
97
 
98
+ #
99
+ # Opens a connection to the configured websocket.
100
+ #
101
+ # This method starts an EventMachine reactor on the current thread, and will only return
102
+ # once the connection is closed.
103
+ #
104
+ # @return [nil]
105
+ # @raise [ConfigError] if no message handler has been configured
106
+ # @raise [ReactorActiveError] if another stream is already running
107
+ #
30
108
  def connect
31
109
  return if @ws
32
110
 
111
+ if @handlers[:message].nil? && @handlers[:raw_message].nil?
112
+ raise ConfigError, "Either on_message or on_raw_message handler needs to be set"
113
+ end
114
+
33
115
  url = build_websocket_url
34
116
 
35
117
  @handlers[:connecting]&.call(url)
@@ -86,11 +168,10 @@ module Skyfall
86
168
  end
87
169
  end
88
170
 
89
- def handle_message(msg)
90
- data = msg.data
91
- @handlers[:raw_message]&.call(data)
92
- end
93
-
171
+ #
172
+ # Forces a reconnect, closing the connection and calling {#connect} again.
173
+ # @return [nil]
174
+ #
94
175
  def reconnect
95
176
  @reconnecting = true
96
177
  @connection_attempts = 0
@@ -98,6 +179,10 @@ module Skyfall
98
179
  @ws ? @ws.close : connect
99
180
  end
100
181
 
182
+ #
183
+ # Closes the connection and stops the EventMachine reactor thread.
184
+ # @return [nil]
185
+ #
101
186
  def disconnect
102
187
  return unless EM.reactor_running?
103
188
 
@@ -108,10 +193,18 @@ module Skyfall
108
193
 
109
194
  alias close disconnect
110
195
 
196
+ #
197
+ # Default user agent sent when connecting to the service. (Currently `"#{version_string}"`)
198
+ # @return [String]
199
+ #
111
200
  def default_user_agent
112
201
  version_string
113
202
  end
114
203
 
204
+ #
205
+ # Skyfall version string for use in user agent strings (`"Skyfall/x.y"`).
206
+ # @return [String]
207
+ #
115
208
  def version_string
116
209
  "Skyfall/#{Skyfall::VERSION}"
117
210
  end
@@ -126,60 +219,172 @@ module Skyfall
126
219
  end
127
220
  end
128
221
 
129
- def start_heartbeat_timer
130
- return if !@check_heartbeat || @heartbeat_interval.to_f <= 0 || @heartbeat_timeout.to_f <= 0
131
- return if @heartbeat_timer
132
222
 
133
- @heartbeat_timer = EM::PeriodicTimer.new(@heartbeat_interval) do
134
- next if @ws.nil? || @heartbeat_timeout.to_f <= 0
135
- time_passed = Time.now - @last_update
136
-
137
- if time_passed > @heartbeat_timeout
138
- @handlers[:timeout]&.call
139
- reconnect
140
- end
141
- end
223
+ # @!method on_connecting(block)
224
+ # Defines a callback to be run when the client tries to open a connection to the websocket.
225
+ # Can be also run as a setter `on_connecting=`.
226
+ # @param [Proc] block
227
+ # @yieldparam [String] url URL to which the client is connecting
228
+ # @return [nil]
229
+
230
+ event_handler :connecting
231
+
232
+ # @!method on_connect(block)
233
+ # Defines a callback to be run after a connection to the websocket is opened.
234
+ # Can be also run as a setter `on_connect=`.
235
+ # @param [Proc] block
236
+ # @return [nil]
237
+
238
+ event_handler :connect
239
+
240
+ # @!method on_raw_message(block)
241
+ # Defines a callback to be run when a message is received, passing a raw data packet as
242
+ # received from the websocket (plain text or binary). Can be also run as a setter `on_raw_message=`.
243
+ # @param [Proc] block
244
+ # @yieldparam [String] data payload of the received message
245
+ # @return [nil]
246
+
247
+ event_handler :raw_message
248
+
249
+ # @!method on_message(block)
250
+ # Defines a callback to be run when a message is received, passing the message as a parsed
251
+ # object of an appropriate message class. Can be also run as a setter `on_message=`.
252
+ # @param [Proc] block
253
+ # @yieldparam [Object] message parsed message of an appropriate class
254
+ # @return [nil]
255
+
256
+ event_handler :message
257
+
258
+ # @!method on_disconnect(block)
259
+ # Defines a callback to be run after a connection to the websocket is closed (and the client
260
+ # does not reconnect). Can be also run as a setter `on_disconnect=`.
261
+ #
262
+ # This callback is not run when `on_reconnect` fires.
263
+ # @param [Proc] block
264
+ # @return [nil]
265
+
266
+ event_handler :disconnect
267
+
268
+ # @!method on_reconnect(block)
269
+ # Defines a callback to be run when a connection to the websocket is broken, but the client
270
+ # initiates or schedules a reconnect (which may happen after a delay). Can be also run as
271
+ # a setter `on_reconnect=`.
272
+ # @param [Proc] block
273
+ # @return [nil]
274
+
275
+ event_handler :reconnect
276
+
277
+ # @!method on_timeout(block)
278
+ # Defines a callback to be run when the heartbeat timer forces a reconnect. A reconnect is
279
+ # triggered after not receiving any messages for a period of time specified in {#heartbeat_timeout}
280
+ # (if {#check_heartbeat} is enabled). Can be also run as a setter `on_timeout=`.
281
+ #
282
+ # This callback is also followed by `on_reconnect`.
283
+ # @param [Proc] block
284
+ # @return [nil]
285
+
286
+ event_handler :timeout
287
+
288
+ # @!method on_error(block)
289
+ # Defines a callback to be run when the websocket connection returns an error. Can be also
290
+ # run as a setter `on_error=`.
291
+ #
292
+ # Default handler prints the error to stdout.
293
+ #
294
+ # @param [Proc] block
295
+ # @yieldparam [Exception] error the received error
296
+ # @return [nil]
297
+
298
+ event_handler :error
299
+
300
+
301
+ # Returns a string with a representation of the object for debugging purposes.
302
+ # @return [String]
303
+ def inspect
304
+ vars = inspectable_variables.map { |v| "#{v}=#{instance_variable_get(v).inspect}" }.join(", ")
305
+ "#<#{self.class}:0x#{object_id} #{vars}>"
142
306
  end
143
307
 
144
- def stop_heartbeat_timer
145
- @heartbeat_timer&.cancel
146
- @heartbeat_timer = nil
147
- end
148
308
 
149
- EVENTS.each do |event|
150
- define_method "on_#{event}" do |&block|
151
- @handlers[event.to_sym] = block
152
- end
309
+ protected
153
310
 
154
- define_method "on_#{event}=" do |block|
155
- @handlers[event.to_sym] = block
156
- end
157
- end
311
+ # @note This method is designed to be overridden in subclasses.
312
+ #
313
+ # Returns the full URL of the websocket endpoint to connect to, with path and query parameters
314
+ # if needed. The base implementation simply returns the base URL passed to the initializer.
315
+ #
316
+ # Override this method in subclasses to point to the specific endpoint and add necessary
317
+ # parameters like cursor or filters, depending on the arguments passed to the constructor.
318
+ #
319
+ # @return [String]
158
320
 
159
- def inspectable_variables
160
- instance_variables - [:@handlers, :@ws]
321
+ def build_websocket_url
322
+ @root_url
161
323
  end
162
324
 
163
- def inspect
164
- vars = inspectable_variables.map { |v| "#{v}=#{instance_variable_get(v).inspect}" }.join(", ")
165
- "#<#{self.class}:0x#{object_id} #{vars}>"
325
+ # Builds and configures a websocket client object that is used to connect to the requested service.
326
+ #
327
+ # @return [Faye::WebSocket::Client]
328
+ # see {https://rubydoc.info/gems/faye-websocket/Faye/WebSocket/Client Faye::WebSocket::Client}
329
+
330
+ def build_websocket_client(url)
331
+ Faye::WebSocket::Client.new(url, nil, { headers: { 'User-Agent' => user_agent }.merge(request_headers) })
166
332
  end
167
333
 
334
+ # @note This method is designed to be overridden in subclasses.
335
+ #
336
+ # Processes a single message received from the websocket. The implementation is expected to
337
+ # parse the message from a plain text or binary form, build an appropriate message object,
338
+ # and call the `:message` and/or `:raw_message` callback handlers, passing the right parameters.
339
+ #
340
+ # The base implementation simply takes the message data and passes it as is to `:raw_message`,
341
+ # and does not call `:message` at all.
342
+ #
343
+ # @param msg
344
+ # {https://rubydoc.info/gems/faye-websocket/Faye/WebSocket/API/MessageEvent Faye::WebSocket::API::MessageEvent}
345
+ # @return [nil]
168
346
 
169
- protected
347
+ def handle_message(msg)
348
+ data = msg.data
349
+ @handlers[:raw_message]&.call(data)
350
+ end
351
+
352
+ # Additional headers to pass with the request when connecting to the websocket endpoint.
353
+ # The user agent header (built from {#user_agent}) is added separately.
354
+ #
355
+ # The base implementation returns an empty hash.
356
+ #
357
+ # @return [Hash] a hash of `{ header_name => header_value }`
170
358
 
171
359
  def request_headers
172
360
  {}
173
361
  end
174
362
 
363
+ # Returns the underlying websocket client object. It can be used e.g. to send messages back
364
+ # to the server (but see also: {#send_data}).
365
+ #
366
+ # @return [Faye::WebSocket::Client]
367
+ # see {https://rubydoc.info/gems/faye-websocket/Faye/WebSocket/Client Faye::WebSocket::Client}
368
+
175
369
  def socket
176
370
  @ws
177
371
  end
178
372
 
373
+ # Sends a message back to the server.
374
+ #
375
+ # @param data [String, Array] the message to send -
376
+ # a string for text websockets, a binary string or byte array for binary websockets
377
+ # @return [Boolean] true if the message was sent successfully
378
+
179
379
  def send_data(data)
180
380
  @ws.send(data)
181
381
  end
182
382
 
383
+ # @return [Array<Symbol>] list of instance variables to be printed in the {#inspect} output
384
+ def inspectable_variables
385
+ instance_variables - [:@handlers, :@ws]
386
+ end
387
+
183
388
 
184
389
  private
185
390
 
@@ -187,6 +392,26 @@ module Skyfall
187
392
  EM.reactor_running? && !@engines_on
188
393
  end
189
394
 
395
+ def start_heartbeat_timer
396
+ return if !@check_heartbeat || @heartbeat_interval.to_f <= 0 || @heartbeat_timeout.to_f <= 0
397
+ return if @heartbeat_timer
398
+
399
+ @heartbeat_timer = EM::PeriodicTimer.new(@heartbeat_interval) do
400
+ next if @ws.nil? || @heartbeat_timeout.to_f <= 0
401
+ time_passed = Time.now - @last_update
402
+
403
+ if time_passed > @heartbeat_timeout
404
+ @handlers[:timeout]&.call
405
+ reconnect
406
+ end
407
+ end
408
+ end
409
+
410
+ def stop_heartbeat_timer
411
+ @heartbeat_timer&.cancel
412
+ @heartbeat_timer = nil
413
+ end
414
+
190
415
  def reconnect_delay
191
416
  if @connection_attempts == 0
192
417
  0
@@ -195,14 +420,6 @@ module Skyfall
195
420
  end
196
421
  end
197
422
 
198
- def build_websocket_client(url)
199
- Faye::WebSocket::Client.new(url, nil, { headers: { 'User-Agent' => user_agent }.merge(request_headers) })
200
- end
201
-
202
- def build_websocket_url
203
- @root_url
204
- end
205
-
206
423
  def build_root_url(server)
207
424
  if !server.is_a?(String)
208
425
  raise ArgumentError, "Server parameter should be a string"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Skyfall
4
- VERSION = "0.6.1"
4
+ VERSION = "0.7.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: skyfall
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.1
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kuba Suder
@@ -117,18 +117,17 @@ files:
117
117
  - lib/skyfall/cid.rb
118
118
  - lib/skyfall/collection.rb
119
119
  - lib/skyfall/errors.rb
120
+ - lib/skyfall/events.rb
120
121
  - lib/skyfall/extensions.rb
121
122
  - lib/skyfall/firehose.rb
122
123
  - lib/skyfall/firehose/account_message.rb
123
124
  - lib/skyfall/firehose/commit_message.rb
124
- - lib/skyfall/firehose/handle_message.rb
125
125
  - lib/skyfall/firehose/identity_message.rb
126
126
  - lib/skyfall/firehose/info_message.rb
127
127
  - lib/skyfall/firehose/labels_message.rb
128
128
  - lib/skyfall/firehose/message.rb
129
129
  - lib/skyfall/firehose/operation.rb
130
130
  - lib/skyfall/firehose/sync_message.rb
131
- - lib/skyfall/firehose/tombstone_message.rb
132
131
  - lib/skyfall/firehose/unknown_message.rb
133
132
  - lib/skyfall/jetstream.rb
134
133
  - lib/skyfall/jetstream/account_message.rb
@@ -1,14 +0,0 @@
1
- require_relative '../firehose'
2
-
3
- module Skyfall
4
-
5
- #
6
- # Note: this event type is deprecated and will stop being emitted at some point.
7
- # You should instead listen for 'identity' events (Skyfall::Firehose::IdentityMessage).
8
- #
9
- class Firehose::HandleMessage < Firehose::Message
10
- def handle
11
- @data_object['handle']
12
- end
13
- end
14
- end
@@ -1,11 +0,0 @@
1
- require_relative '../firehose'
2
-
3
- module Skyfall
4
-
5
- #
6
- # Note: this event type is deprecated and will stop being emitted at some point.
7
- # You should instead listen for 'account' events (Skyfall::Firehose::AccountMessage).
8
- #
9
- class Firehose::TombstoneMessage < Firehose::Message
10
- end
11
- end