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.
- checksums.yaml +7 -0
- data/LICENSE.txt +28 -0
- data/README.md +100 -0
- data/app/assets/javascripts/orbitcast/recovery.js +196 -0
- data/db/orbitcast_migrate/001_create_orbitcast_payloads.rb +39 -0
- data/db/orbitcast_migrate/002_create_orbitcast_presence.rb +54 -0
- data/db/orbitcast_migrate/003_create_orbitcast_broker.rb +102 -0
- data/exe/orbitcast +11 -0
- data/lib/action_cable/subscription_adapter/orbitcast.rb +102 -0
- data/lib/generators/orbitcast/install/install_generator.rb +223 -0
- data/lib/generators/orbitcast/install/templates/cable.yml.tt +48 -0
- data/lib/generators/orbitcast/install/templates/orbitcast.rb.tt +156 -0
- data/lib/orbitcast/binary.rb +234 -0
- data/lib/orbitcast/channel.rb +459 -0
- data/lib/orbitcast/channel_registry.rb +131 -0
- data/lib/orbitcast/configuration.rb +636 -0
- data/lib/orbitcast/connection.rb +338 -0
- data/lib/orbitcast/control.rb +72 -0
- data/lib/orbitcast/dispatcher.rb +306 -0
- data/lib/orbitcast/engine.rb +98 -0
- data/lib/orbitcast/error.rb +54 -0
- data/lib/orbitcast/launcher.rb +302 -0
- data/lib/orbitcast/protocol/v1/control_message.rb +134 -0
- data/lib/orbitcast/protocol/v1/effects.rb +172 -0
- data/lib/orbitcast/protocol/v1/reply.rb +268 -0
- data/lib/orbitcast/protocol/v1/request.rb +364 -0
- data/lib/orbitcast/protocol/v1.rb +291 -0
- data/lib/orbitcast/protocol.rb +19 -0
- data/lib/orbitcast/publisher/control.rb +67 -0
- data/lib/orbitcast/publisher/http.rb +180 -0
- data/lib/orbitcast/publisher/instrumentation.rb +47 -0
- data/lib/orbitcast/publisher/postgres.rb +407 -0
- data/lib/orbitcast/rails_endpoint.rb +207 -0
- data/lib/orbitcast/recovery.rb +166 -0
- data/lib/orbitcast/state_codec.rb +186 -0
- data/lib/orbitcast/streams.rb +129 -0
- data/lib/orbitcast/version.rb +10 -0
- data/lib/orbitcast.rb +68 -0
- data/vendor/x86_64-linux-gnu/bin/orbitcast +0 -0
- data/vendor/x86_64-linux-gnu/manifest.json +14 -0
- 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
|