orbitcast 0.3.1-x86_64-linux-gnu

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.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +28 -0
  3. data/README.md +100 -0
  4. data/app/assets/javascripts/orbitcast/recovery.js +196 -0
  5. data/db/orbitcast_migrate/001_create_orbitcast_payloads.rb +39 -0
  6. data/db/orbitcast_migrate/002_create_orbitcast_presence.rb +54 -0
  7. data/db/orbitcast_migrate/003_create_orbitcast_broker.rb +102 -0
  8. data/exe/orbitcast +11 -0
  9. data/lib/action_cable/subscription_adapter/orbitcast.rb +102 -0
  10. data/lib/generators/orbitcast/install/install_generator.rb +223 -0
  11. data/lib/generators/orbitcast/install/templates/cable.yml.tt +48 -0
  12. data/lib/generators/orbitcast/install/templates/orbitcast.rb.tt +156 -0
  13. data/lib/orbitcast/binary.rb +234 -0
  14. data/lib/orbitcast/channel.rb +459 -0
  15. data/lib/orbitcast/channel_registry.rb +131 -0
  16. data/lib/orbitcast/configuration.rb +636 -0
  17. data/lib/orbitcast/connection.rb +338 -0
  18. data/lib/orbitcast/control.rb +72 -0
  19. data/lib/orbitcast/dispatcher.rb +306 -0
  20. data/lib/orbitcast/engine.rb +98 -0
  21. data/lib/orbitcast/error.rb +54 -0
  22. data/lib/orbitcast/launcher.rb +302 -0
  23. data/lib/orbitcast/protocol/v1/control_message.rb +134 -0
  24. data/lib/orbitcast/protocol/v1/effects.rb +172 -0
  25. data/lib/orbitcast/protocol/v1/reply.rb +268 -0
  26. data/lib/orbitcast/protocol/v1/request.rb +364 -0
  27. data/lib/orbitcast/protocol/v1.rb +291 -0
  28. data/lib/orbitcast/protocol.rb +19 -0
  29. data/lib/orbitcast/publisher/control.rb +67 -0
  30. data/lib/orbitcast/publisher/http.rb +180 -0
  31. data/lib/orbitcast/publisher/instrumentation.rb +47 -0
  32. data/lib/orbitcast/publisher/postgres.rb +407 -0
  33. data/lib/orbitcast/rails_endpoint.rb +207 -0
  34. data/lib/orbitcast/recovery.rb +166 -0
  35. data/lib/orbitcast/state_codec.rb +186 -0
  36. data/lib/orbitcast/streams.rb +129 -0
  37. data/lib/orbitcast/version.rb +10 -0
  38. data/lib/orbitcast.rb +68 -0
  39. data/vendor/x86_64-linux-gnu/bin/orbitcast +0 -0
  40. data/vendor/x86_64-linux-gnu/manifest.json +14 -0
  41. metadata +130 -0
@@ -0,0 +1,338 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_cable"
4
+ require "action_dispatch"
5
+ require "active_support/json"
6
+ require "active_support/rescuable"
7
+ require "stringio"
8
+ require "uri"
9
+
10
+ require_relative "error"
11
+ require_relative "protocol"
12
+ require_relative "state_codec"
13
+
14
+ module Orbitcast
15
+ # The Rails-dispatch connection base.
16
+ #
17
+ # module ApplicationCable
18
+ # class Connection < Orbitcast::Connection
19
+ # identified_by :current_user
20
+ # orbitcast_identity_key :current_user
21
+ #
22
+ # def connect
23
+ # self.current_user = find_verified_user
24
+ # end
25
+ #
26
+ # private
27
+ #
28
+ # def find_verified_user
29
+ # User.find_by(id: cookies.signed[:user_id]) || reject_unauthorized_connection
30
+ # end
31
+ # end
32
+ # end
33
+ #
34
+ # ## What this is, and what it is not
35
+ #
36
+ # It is **not** an `ActionCable::Connection::Base`. That class owns a live
37
+ # WebSocket: a socket, a message buffer, an event loop, a worker pool, and a
38
+ # `process`/`handle_open`/`handle_close` lifecycle driven by a Rack hijack.
39
+ # None of that exists here — the socket is in the Rust relay, and this object
40
+ # lives for exactly one HTTP request.
41
+ #
42
+ # What it *is* is the same **composition** ActionCable builds that class from:
43
+ # `Identification` (so `identified_by` and the channel-side delegation work
44
+ # unchanged), `Authorization` (so `reject_unauthorized_connection` is the same
45
+ # method raising the same error), `Callbacks` (so `around_command` still wraps
46
+ # subscribe/unsubscribe/perform), and `Rescuable`. It then implements the small
47
+ # surface `ActionCable::Channel::Base` actually calls on its connection —
48
+ # `identifiers`, `logger`, `transmit`, `subscriptions`, `config` — which is
49
+ # what lets a real, unpatched `Channel::Base` run against it.
50
+ #
51
+ # No `prepend`, no monkey patch, no synthetic socket.
52
+ #
53
+ # ## Identity
54
+ #
55
+ # `identify` returns the identifiers as declared-state values, so the relay
56
+ # can hand them back on the next call. Add `orbitcast_identity_key` to make
57
+ # the connection addressable by `Orbitcast.disconnect`; without it the session
58
+ # is a perfectly valid anonymous one that simply cannot be reached by a
59
+ # control message.
60
+ class Connection
61
+ include ActionCable::Connection::Identification
62
+ include ActionCable::Connection::Authorization
63
+ include ActionCable::Connection::Callbacks
64
+ include ActiveSupport::Rescuable
65
+ include StateCodec::Instance
66
+ extend StateCodec::Declaration
67
+
68
+ # The key Rust indexes sessions by. It is an ordinary entry in the identity
69
+ # map, spelled out so nothing has to guess which identifier is canonical.
70
+ IDENTITY_KEY = "identity_key"
71
+
72
+ class << self
73
+ # Declare how this connection is addressed by a remote disconnect.
74
+ #
75
+ # orbitcast_identity_key :current_user # to_gid_param / to_s
76
+ # orbitcast_identity_key { "team:#{team.id}" }
77
+ def orbitcast_identity_key(name = nil, &block)
78
+ if name.nil? && block.nil?
79
+ return @orbitcast_identity_key ||
80
+ (superclass.respond_to?(:orbitcast_identity_key) ? superclass.orbitcast_identity_key : nil)
81
+ end
82
+
83
+ if name && block
84
+ raise ArgumentError, "pass a method name or a block to orbitcast_identity_key, not both"
85
+ end
86
+
87
+ @orbitcast_identity_key = block || name.to_sym
88
+ end
89
+ end
90
+
91
+ # @param snapshot [Orbitcast::Protocol::V1::Request::ConnectionSnapshot]
92
+ # @param effects [Orbitcast::Protocol::V1::EffectCollector]
93
+ def initialize(snapshot, effects:, logger: nil)
94
+ @orbitcast_snapshot = snapshot
95
+ @orbitcast_effects = effects
96
+ @orbitcast_logger = logger
97
+ @orbitcast_identifier = nil
98
+ end
99
+
100
+ attr_reader :orbitcast_snapshot, :orbitcast_effects
101
+
102
+ # --- the request, reconstructed from validated metadata -----------------
103
+
104
+ # The original WebSocket request as an ActionDispatch::Request. The browser
105
+ # could not submit any of it: the relay rebuilt it from the verified URL,
106
+ # origin, client address, and the configured header allowlist.
107
+ def request
108
+ @request ||= begin
109
+ environment = rack_env
110
+ if defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
111
+ environment = ::Rails.application.env_config.merge(environment)
112
+ end
113
+
114
+ ActionDispatch::Request.new(environment)
115
+ end
116
+ end
117
+
118
+ def cookies
119
+ # In a booted application the cookie jar arrives with the middleware
120
+ # stack; this endpoint can also be driven directly, so load it on demand.
121
+ require "action_dispatch/middleware/cookies" unless request.respond_to?(:cookie_jar)
122
+
123
+ request.cookie_jar
124
+ end
125
+
126
+ def env
127
+ request.env
128
+ end
129
+
130
+ def logger
131
+ @orbitcast_logger ||= default_logger
132
+ end
133
+
134
+ def server
135
+ ActionCable.server
136
+ end
137
+
138
+ def config
139
+ server.config
140
+ end
141
+
142
+ # The relay owns fan-out. A connection in dispatch mode never reaches the
143
+ # in-process pub/sub, and pretending otherwise would silently install a
144
+ # subscription no browser is attached to.
145
+ def pubsub
146
+ raise UnsupportedChannelFeature,
147
+ "an Orbitcast connection has no in-process pub/sub: streams are installed by the " \
148
+ "relay through add_stream effects"
149
+ end
150
+ alias worker_pool pubsub
151
+ alias event_loop pubsub
152
+
153
+ # --- the surface ActionCable::Channel::Base calls -----------------------
154
+
155
+ # Channel::Base#transmit sends `identifier:, message:`; the framing helpers
156
+ # send `identifier:, type:`. Only the first is the application's: `welcome`,
157
+ # `confirm_subscription`, `reject_subscription`, and `disconnect` are framed
158
+ # by Rust from the reply outcome, and are dropped here on purpose.
159
+ def transmit(cable_message)
160
+ return if cable_message[:message].nil? && cable_message["message"].nil?
161
+
162
+ identifier = cable_message[:identifier] || cable_message["identifier"]
163
+ message = cable_message.key?(:message) ? cable_message[:message] : cable_message["message"]
164
+
165
+ orbitcast_effects.transmit(identifier, ActiveSupport::JSON.encode(message))
166
+ end
167
+
168
+ # `reject` inside `subscribed` routes through here in ActionCable. The
169
+ # collection is one subscription wide: one RPC call carries one.
170
+ def subscriptions
171
+ @subscriptions ||= Subscriptions.new
172
+ end
173
+
174
+ # Close the whole connection with an allowlisted reason. `Orbitcast::Channel`
175
+ # and the application call this instead of ActionCable's socket close.
176
+ def close(reason: Protocol::V1::CLOSE_REASON_REMOTE, reconnect: false)
177
+ orbitcast_effects.close(reason: reason, reconnect: reconnect)
178
+ end
179
+
180
+ # --- lifecycle ----------------------------------------------------------
181
+
182
+ # Run the application's `connect`, if it defined one.
183
+ def orbitcast_connect
184
+ connect if respond_to?(:connect)
185
+ self
186
+ end
187
+
188
+ # Run the application's `disconnect`, if it defined one. Best effort: the
189
+ # browser is already gone by the time this runs.
190
+ def orbitcast_disconnect
191
+ disconnect if respond_to?(:disconnect)
192
+ self
193
+ end
194
+
195
+ # Subscribe / unsubscribe / perform run inside the `command` callbacks, the
196
+ # same way ActionCable runs them, so `around_command :set_current_account`
197
+ # keeps working.
198
+ def orbitcast_run_command(&block)
199
+ run_callbacks(:command, &block)
200
+ end
201
+
202
+ # --- identity -----------------------------------------------------------
203
+
204
+ # The identity map returned by an accepted `identify`.
205
+ def orbitcast_identity
206
+ map = self.class.identifiers.each_with_object({}) do |name, out|
207
+ ivar = :"@#{name}"
208
+ next unless instance_variable_defined?(ivar)
209
+
210
+ value = instance_variable_get(ivar)
211
+ next if value.nil?
212
+
213
+ out[name.to_s] = StateCodec.dump(value)
214
+ end
215
+
216
+ key = orbitcast_identity_key
217
+ map[IDENTITY_KEY] = key unless key.nil?
218
+ Protocol::V1.validate_state_map!("identity", map)
219
+ end
220
+
221
+ # Restore the identifiers an earlier `identify` returned.
222
+ def orbitcast_restore_identity(map)
223
+ return self unless map.is_a?(Hash)
224
+
225
+ self.class.identifiers.each do |name|
226
+ key = name.to_s
227
+ next unless map.key?(key)
228
+
229
+ instance_variable_set(:"@#{name}", StateCodec.load(map[key]))
230
+ end
231
+
232
+ self
233
+ end
234
+
235
+ # The value a remote disconnect addresses this connection by, or nil when
236
+ # the application declared none.
237
+ def orbitcast_identity_key
238
+ declared = self.class.orbitcast_identity_key
239
+ return nil if declared.nil?
240
+
241
+ value = declared.is_a?(Proc) ? instance_exec(&declared) : public_send(declared)
242
+ return nil if value.nil?
243
+
244
+ key = value.respond_to?(:to_gid_param) ? value.to_gid_param : value.to_s
245
+ return nil if key.empty?
246
+
247
+ Protocol::V1.validate_bounded!(
248
+ "identity_key", key, Protocol::V1::MAX_IDENTITY_KEY_LEN
249
+ )
250
+ end
251
+
252
+ def inspect
253
+ "#<#{self.class.name} identifiers=#{self.class.identifiers.to_a.inspect}>"
254
+ end
255
+
256
+ private
257
+
258
+ def default_logger
259
+ if defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger
260
+ ::Rails.logger
261
+ else
262
+ require "logger"
263
+ ::Logger.new(IO::NULL)
264
+ end
265
+ end
266
+
267
+ def rack_env
268
+ uri = parsed_url
269
+ scheme = uri.scheme || origin_scheme
270
+ host = uri.host || origin_host
271
+
272
+ env = {
273
+ "REQUEST_METHOD" => "GET",
274
+ "SCRIPT_NAME" => "",
275
+ "PATH_INFO" => uri.path.to_s.empty? ? "/" : uri.path,
276
+ "QUERY_STRING" => uri.query.to_s,
277
+ "SERVER_NAME" => host,
278
+ "SERVER_PORT" => (uri.port || (scheme == "https" ? 443 : 80)).to_s,
279
+ "HTTP_HOST" => host,
280
+ "REMOTE_ADDR" => orbitcast_snapshot.client_address,
281
+ "rack.url_scheme" => scheme,
282
+ "rack.input" => StringIO.new(""),
283
+ "rack.errors" => $stderr,
284
+ # Marks the request as the reconstruction it is, for application code
285
+ # that wants to know it is not serving a browser directly.
286
+ "orbitcast.dispatch" => true
287
+ }
288
+
289
+ env["HTTP_ORIGIN"] = orbitcast_snapshot.origin if orbitcast_snapshot.origin
290
+ orbitcast_snapshot.headers.each do |name, value|
291
+ env["HTTP_#{name.to_s.upcase.tr("-", "_")}"] = value
292
+ end
293
+
294
+ env
295
+ end
296
+
297
+ def parsed_url
298
+ URI.parse(orbitcast_snapshot.url.to_s)
299
+ rescue URI::InvalidURIError
300
+ URI.parse("/")
301
+ end
302
+
303
+ def origin_scheme
304
+ origin = orbitcast_snapshot.origin.to_s
305
+ origin.start_with?("https://") ? "https" : "http"
306
+ end
307
+
308
+ def origin_host
309
+ URI.parse(orbitcast_snapshot.origin.to_s).host || "localhost"
310
+ rescue URI::InvalidURIError
311
+ "localhost"
312
+ end
313
+
314
+ # The one-subscription stand-in for
315
+ # `ActionCable::Connection::Subscriptions`. `Channel::Base#reject` calls
316
+ # `connection.subscriptions.remove_subscription(self)`, which in ActionCable
317
+ # runs the unsubscribe callbacks; it does the same here, so a rejected
318
+ # subscription unwinds exactly as it does in a plain Rails application.
319
+ class Subscriptions
320
+ def initialize
321
+ @subscriptions = {}
322
+ end
323
+
324
+ def add(subscription)
325
+ @subscriptions[subscription.identifier] = subscription
326
+ end
327
+
328
+ def remove_subscription(subscription)
329
+ subscription.unsubscribe_from_channel
330
+ @subscriptions.delete(subscription.identifier)
331
+ end
332
+
333
+ def identifiers
334
+ @subscriptions.keys
335
+ end
336
+ end
337
+ end
338
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "protocol"
4
+
5
+ module Orbitcast
6
+ # Rails-initiated remote disconnect.
7
+ #
8
+ # Orbitcast.disconnect(identity_key: user.to_gid_param)
9
+ #
10
+ # One published control message reaches every node; each looks the identity
11
+ # key up in its *local* session index and closes only what matches. No node
12
+ # scans all sessions, no control data is ever visible to a browser, and
13
+ # `control_id` deduplicates a message that arrives over more than one path.
14
+ #
15
+ # The key is the `"identity_key"` entry of the identity map an accepted
16
+ # `identify` returned — see `Orbitcast::Connection.orbitcast_identity_key`. A
17
+ # connection that declared none is anonymous to control: it is a valid
18
+ # session that simply cannot be addressed.
19
+ module Control
20
+ class << self
21
+ # @param identity_key [String] as returned by `identify`
22
+ # @param reason [String] one of Protocol::V1::CLOSE_REASONS
23
+ # @param reconnect [Boolean] the policy handed to the ActionCable client
24
+ # @return [String] the control UUID
25
+ def disconnect(identity_key:, reason: Protocol::V1::CLOSE_REASON_REMOTE, reconnect: false,
26
+ config: nil)
27
+ config ||= Orbitcast.config
28
+ message = Protocol::V1::ControlMessage.disconnect(
29
+ namespace: config.namespace,
30
+ identity_key: identity_key.to_s,
31
+ reason: reason,
32
+ reconnect: reconnect
33
+ )
34
+
35
+ publisher(config).publish_control(message)
36
+ end
37
+
38
+ # The process-wide control publisher. Separate from the ActionCable
39
+ # adapter's publisher on purpose: control messages must still go out when
40
+ # the adapter was never instantiated (a console, a job runner).
41
+ def publisher(config = nil)
42
+ config ||= Orbitcast.config
43
+ @publisher ||= build_publisher(config)
44
+ end
45
+
46
+ def publisher=(publisher)
47
+ @publisher = publisher
48
+ end
49
+
50
+ def shutdown
51
+ @publisher&.shutdown
52
+ @publisher = nil
53
+ end
54
+
55
+ private
56
+
57
+ def build_publisher(config)
58
+ require_relative "publisher/control"
59
+ Publisher::Control.new(config)
60
+ end
61
+ end
62
+ end
63
+
64
+ class << self
65
+ # See Orbitcast::Control.disconnect.
66
+ def disconnect(identity_key:, reason: Protocol::V1::CLOSE_REASON_REMOTE, reconnect: false)
67
+ Control.disconnect(
68
+ identity_key: identity_key, reason: reason, reconnect: reconnect, config: config
69
+ )
70
+ end
71
+ end
72
+ end