orbitcast 0.3.1-arm64-darwin

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/arm64-darwin/bin/orbitcast +0 -0
  40. data/vendor/arm64-darwin/manifest.json +14 -0
  41. metadata +130 -0
@@ -0,0 +1,306 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_cable"
4
+ require "active_support/core_ext/hash/indifferent_access"
5
+ require "active_support/core_ext/string/inflections"
6
+
7
+ require_relative "channel"
8
+ require_relative "channel_registry"
9
+ require_relative "connection"
10
+ require_relative "error"
11
+ require_relative "protocol"
12
+
13
+ module Orbitcast
14
+ # Runs one validated RPC request against the application's connection and
15
+ # channel classes and returns one reply.
16
+ #
17
+ # This is the whole Rails execution model in one place:
18
+ #
19
+ # describe capability handshake; no session, no callbacks
20
+ # identify connect / reject_unauthorized_connection
21
+ # open_channel registry lookup, subscribed, reject
22
+ # close_channel unsubscribed, best effort, effects suppressed
23
+ # run_action perform_action through the action allowlist + filters
24
+ # release_connection remaining unsubscribes + disconnect, best effort
25
+ #
26
+ # Requests are **stateless**: every call rebuilds the connection from the
27
+ # identity and declared state the relay handed back, runs the callbacks, and
28
+ # returns what changed. Nothing is memoized between calls, no database
29
+ # connection is held for a socket's lifetime, and an instance variable that
30
+ # was not declared with `orbitcast_state` does not survive the request.
31
+ class Dispatcher
32
+ V1 = Protocol::V1
33
+
34
+ # Named capabilities reported by `describe`. The relay admits a node by
35
+ # envelope version and capability, never by build equality.
36
+ CAPABILITIES = %w[rails_dispatch].freeze
37
+
38
+ def initialize(config: nil, registry: nil, connection_class: nil, logger: nil)
39
+ @config = config || Orbitcast.config
40
+ @registry = registry
41
+ @connection_class = connection_class
42
+ @logger = logger
43
+ end
44
+
45
+ attr_reader :config
46
+
47
+ def logger
48
+ @logger ||= (defined?(::Rails) && ::Rails.respond_to?(:logger) && ::Rails.logger) || null_logger
49
+ end
50
+
51
+ def registry
52
+ @registry ||= ChannelRegistry.build(@config.channels)
53
+ end
54
+
55
+ # @param request [Orbitcast::Protocol::V1::Request] already validated
56
+ # @return [Orbitcast::Protocol::V1::Reply]
57
+ def call(request)
58
+ case request.operation
59
+ when V1::OPERATION_DESCRIBE then describe(request)
60
+ when V1::OPERATION_IDENTIFY then identify(request)
61
+ when V1::OPERATION_OPEN_CHANNEL then open_channel(request)
62
+ when V1::OPERATION_CLOSE_CHANNEL then close_channel(request)
63
+ when V1::OPERATION_RUN_ACTION then run_action(request)
64
+ when V1::OPERATION_RELEASE_CONNECTION then release_connection(request)
65
+ else
66
+ # Unreachable: Request.from refuses unknown operations.
67
+ V1::Reply.fault(request, "unknown operation")
68
+ end
69
+ rescue ActionCable::Connection::Authorization::UnauthorizedError
70
+ # An application may refuse from a channel callback too, not just from
71
+ # `connect`. A refusal is a decision, so it is a denial, not a fault.
72
+ V1::Reply.denied(request, "unauthorized")
73
+ rescue StandardError => e
74
+ # A Rails-side failure. The relay closes the session with
75
+ # `reconnect: true`; it never falls back to signed-only acceptance.
76
+ log_exception(request, e)
77
+ V1::Reply.fault(request, e.class.name)
78
+ end
79
+
80
+ # The connection class the application wired up. Resolved per call so a
81
+ # reloaded class is picked up (the endpoint runs inside the executor).
82
+ def connection_class
83
+ name = @connection_class || config.connection_class
84
+ return name if name.is_a?(Class)
85
+
86
+ constant = name.to_s.safe_constantize
87
+ unless constant.is_a?(Class) && constant <= Orbitcast::Connection
88
+ raise ConfigurationError,
89
+ "#{name} is not an Orbitcast::Connection. In Rails-dispatch mode " \
90
+ "ApplicationCable::Connection must inherit from Orbitcast::Connection " \
91
+ "(config.orbitcast.connection_class selects another class)."
92
+ end
93
+
94
+ constant
95
+ end
96
+
97
+ # Whether this endpoint is ready to serve dispatch traffic.
98
+ def ready?
99
+ connection_class
100
+ !registry.empty?
101
+ rescue StandardError
102
+ false
103
+ end
104
+
105
+ private
106
+
107
+ # --- operations ---------------------------------------------------------
108
+
109
+ def describe(request)
110
+ V1::Reply.describe(
111
+ request,
112
+ supported_versions: [V1::VERSION],
113
+ software_version: Orbitcast::VERSION,
114
+ capabilities: CAPABILITIES,
115
+ ready: ready?
116
+ )
117
+ end
118
+
119
+ def identify(request)
120
+ effects = V1::EffectCollector.new
121
+ connection = build_connection(request, effects, restore_identity: false)
122
+
123
+ begin
124
+ connection.orbitcast_connect
125
+ rescue ActionCable::Connection::Authorization::UnauthorizedError
126
+ return V1::Reply.denied(request, "unauthorized")
127
+ end
128
+
129
+ V1::Reply.accepted(
130
+ request,
131
+ effects: effects,
132
+ identity: connection.orbitcast_identity,
133
+ state: state_change(request.connection.state, connection.orbitcast_dump_state)
134
+ )
135
+ end
136
+
137
+ def open_channel(request)
138
+ effects = V1::EffectCollector.new
139
+ identifier = request.subscription.identifier
140
+
141
+ params = decode_identifier(identifier)
142
+ return V1::Reply.denied(request, "malformed channel identifier") if params.nil?
143
+
144
+ channel_class = registry.resolve(params[:channel])
145
+ if channel_class.nil?
146
+ logger.error { "Orbitcast: refused subscription to #{params[:channel].inspect}" }
147
+ return V1::Reply.denied(request, "channel is not in the Orbitcast channel registry")
148
+ end
149
+
150
+ connection = build_connection(request, effects)
151
+ channel = build_channel(channel_class, connection, request)
152
+ connection.subscriptions.add(channel)
153
+
154
+ connection.orbitcast_run_command { channel.subscribe_to_channel }
155
+
156
+ if channel.orbitcast_rejected?
157
+ # Everything the callbacks collected before the refusal is dropped by
158
+ # the reply builder: a denial changes nothing.
159
+ return V1::Reply.denied(request, "subscription rejected")
160
+ end
161
+
162
+ V1::Reply.accepted(
163
+ request,
164
+ effects: effects,
165
+ state: state_change(request.connection.state, connection.orbitcast_dump_state),
166
+ subscription_state: state_change(request.subscription.state, channel.orbitcast_dump_state)
167
+ )
168
+ end
169
+
170
+ def run_action(request)
171
+ effects = V1::EffectCollector.new
172
+ identifier = request.subscription.identifier
173
+
174
+ params = decode_identifier(identifier)
175
+ return V1::Reply.denied(request, "malformed channel identifier") if params.nil?
176
+
177
+ channel_class = registry.resolve(params[:channel])
178
+ return V1::Reply.denied(request, "channel is not in the Orbitcast channel registry") if channel_class.nil?
179
+
180
+ data = begin
181
+ request.action.data
182
+ rescue JSON::ParserError
183
+ nil
184
+ end
185
+ return V1::Reply.denied(request, "action data is not JSON") unless data.is_a?(Hash)
186
+
187
+ connection = build_connection(request, effects)
188
+ channel = build_channel(channel_class, connection, request)
189
+ connection.subscriptions.add(channel)
190
+
191
+ unless channel.orbitcast_processable_action?(data)
192
+ return V1::Reply.denied(request, "unprocessable action")
193
+ end
194
+
195
+ connection.orbitcast_run_command { channel.perform_action(data) }
196
+
197
+ V1::Reply.accepted(
198
+ request,
199
+ effects: effects,
200
+ state: state_change(request.connection.state, connection.orbitcast_dump_state),
201
+ subscription_state: state_change(request.subscription.state, channel.orbitcast_dump_state)
202
+ )
203
+ end
204
+
205
+ # Post-hoc: the client has already left and the relay has already dropped
206
+ # the membership. Rails cannot veto it, and nothing it returns here can
207
+ # resurrect the subscription, so the collector is suppressed.
208
+ def close_channel(request)
209
+ effects = V1::EffectCollector.new(suppressed: true)
210
+ params = decode_identifier(request.subscription.identifier)
211
+ channel_class = params && registry.resolve(params[:channel])
212
+
213
+ if channel_class
214
+ connection = build_connection(request, effects)
215
+ channel = build_channel(channel_class, connection, request)
216
+ connection.orbitcast_run_command { channel.unsubscribe_from_channel }
217
+ end
218
+
219
+ V1::Reply.accepted(request)
220
+ end
221
+
222
+ # Best effort, by definition: on process or network loss this never runs at
223
+ # all. Every subscription is unwound independently so one raising
224
+ # `unsubscribed` cannot swallow the `disconnect` callback.
225
+ def release_connection(request)
226
+ effects = V1::EffectCollector.new(suppressed: true)
227
+ connection = build_connection(request, effects)
228
+
229
+ request.release.subscriptions.each do |snapshot|
230
+ params = decode_identifier(snapshot.identifier)
231
+ channel_class = params && registry.resolve(params[:channel])
232
+ next unless channel_class
233
+
234
+ begin
235
+ channel = build_channel(channel_class, connection, request, subscription: snapshot)
236
+ connection.orbitcast_run_command { channel.unsubscribe_from_channel }
237
+ rescue StandardError => e
238
+ log_exception(request, e)
239
+ end
240
+ end
241
+
242
+ connection.orbitcast_disconnect
243
+
244
+ V1::Reply.accepted(request)
245
+ end
246
+
247
+ # --- construction -------------------------------------------------------
248
+
249
+ def build_connection(request, effects, restore_identity: true)
250
+ connection = connection_class.new(request.connection, effects: effects, logger: logger)
251
+ connection.orbitcast_restore_identity(request.connection.identity) if restore_identity
252
+ connection.orbitcast_restore_state(StateCodec.load_map(request.connection.state))
253
+ connection
254
+ end
255
+
256
+ def build_channel(channel_class, connection, request, subscription: nil)
257
+ subscription ||= request.subscription
258
+ params = decode_identifier(subscription.identifier)
259
+
260
+ channel = channel_class.new(connection, subscription.identifier, params)
261
+ channel.orbitcast_installed_streams = subscription.streams
262
+ channel.orbitcast_restore_state(StateCodec.load_map(subscription.state))
263
+ channel
264
+ end
265
+
266
+ # ActionCable decodes the identifier itself and treats the decoded hash as
267
+ # `params`; so does this, because the identifier is what the relay routes
268
+ # by. The `params` field of the snapshot is a convenience copy and is never
269
+ # authoritative.
270
+ #
271
+ # Plain `JSON.parse`, not `ActiveSupport::JSON.decode`: an identifier is
272
+ # routing data, and no part of it should be coerced into a Time (or
273
+ # anything else) on the way to `params`.
274
+ #
275
+ # @return [ActiveSupport::HashWithIndifferentAccess, nil]
276
+ def decode_identifier(identifier)
277
+ decoded = JSON.parse(identifier)
278
+ decoded.is_a?(Hash) ? decoded.with_indifferent_access : nil
279
+ rescue StandardError
280
+ nil
281
+ end
282
+
283
+ # Absent means unchanged, so a map that did not move is omitted entirely.
284
+ # A map that lost every key is `{}`, which is an explicit clear — the two
285
+ # must never be confused, which is why this compares rather than always
286
+ # emitting.
287
+ def state_change(previous, current)
288
+ previous = {} unless previous.is_a?(Hash)
289
+ return V1::Reply::UNCHANGED if current == previous
290
+
291
+ current
292
+ end
293
+
294
+ def log_exception(request, error)
295
+ logger.error do
296
+ "Orbitcast #{request.operation} raised #{error.class}: #{error.message}\n" \
297
+ "#{Array(error.backtrace).first(10).join("\n")}"
298
+ end
299
+ end
300
+
301
+ def null_logger
302
+ require "logger"
303
+ @null_logger ||= ::Logger.new(IO::NULL)
304
+ end
305
+ end
306
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/engine"
4
+
5
+ module Orbitcast
6
+ # Minimal Rails engine.
7
+ #
8
+ # It exists for two reasons only: to expose `config.orbitcast.*` and to carry
9
+ # the installer generator and the migration path. It has no `app/` tree, adds
10
+ # nothing to the autoload or eager-load paths, and mounts no routes in
11
+ # signed mode.
12
+ #
13
+ # It deliberately does NOT start the relay. The Rust process is separately
14
+ # supervised (`bundle exec orbitcast serve` under bin/dev or the production
15
+ # process manager — Puma, shuttlebay, or otherwise); launching it from an
16
+ # initializer would spawn one relay per app-server worker, per console, and
17
+ # per rake task, each fighting over the same listener socket.
18
+ class Engine < ::Rails::Engine
19
+ isolate_namespace Orbitcast
20
+
21
+ # Registers `config.orbitcast` in the shared railtie options so an
22
+ # application can write `config.orbitcast.mode = :signed` in
23
+ # config/environments/*.rb.
24
+ config.orbitcast = ActiveSupport::OrderedOptions.new
25
+
26
+ # No autoloading surprises: nothing in this gem's lib/ is autoloaded, and
27
+ # the engine contributes no paths of its own.
28
+ config.autoload_once_paths = []
29
+ config.eager_load_paths = []
30
+
31
+ initializer "orbitcast.configuration", before: :load_config_initializers do |app|
32
+ options = app.config.orbitcast
33
+ # Unknown keys raise here, at boot, rather than silently disabling a
34
+ # security-relevant setting at runtime.
35
+ Orbitcast.configure { |config| config.assign(options.to_h) }
36
+ end
37
+
38
+ # The installer copies Orbitcast's migrations into the application's own
39
+ # `db/orbitcast_migrate/` (never into `db/migrate`, so they are
40
+ # recognisable and removable). When that directory exists it is appended
41
+ # to the application's migration paths, which is what `bin/rails
42
+ # db:migrate` and `ActiveRecord::Tasks::DatabaseTasks.migrations_paths`
43
+ # read. Nothing runs DDL at boot.
44
+ initializer "orbitcast.migrations" do |app|
45
+ migrate = app.root.join("db", "orbitcast_migrate")
46
+ next unless migrate.directory?
47
+
48
+ paths = app.config.paths["db/migrate"]
49
+ paths << migrate.to_s unless paths.expanded.include?(migrate.to_s)
50
+ end
51
+
52
+ # The derived Orbitcast stream key: when no
53
+ # `streams_secret` is configured explicitly, `Orbitcast::Configuration`
54
+ # derives one from the application's key generator under the purpose
55
+ # `orbitcast/streams/v1/<namespace>`. Only the derived 32 bytes ever leave
56
+ # this process; `secret_key_base` and the master key do not. The namespace
57
+ # may still arrive later (cable.yml is read lazily by the adapter and the
58
+ # launcher), which is why the generator is handed over and the key is
59
+ # derived on first use rather than here.
60
+ config.after_initialize do |app|
61
+ Orbitcast.config.key_generator ||= app.key_generator
62
+ end
63
+
64
+ # Rails-dispatch mode only: mount the private RPC endpoint the relay calls
65
+ # back into. Signed-mode applications mount nothing.
66
+ #
67
+ # ##########################################################################
68
+ # The mounted path speaks for every user of this application: it runs
69
+ # `connect`, channel callbacks, and actions on behalf of whoever the relay
70
+ # says is connected. It authenticates the relay's bearer credential itself,
71
+ # but it must ALSO be blocked at the public proxy / load balancer:
72
+ #
73
+ # # nginx
74
+ # location /_orbitcast/ { deny all; }
75
+ #
76
+ # # Traefik / Kamal
77
+ # middlewares: [ orbitcast-internal-only ]
78
+ #
79
+ # Reach it from the relay over the private network or over TLS with a
80
+ # rotating credential, never from the internet.
81
+ # ##########################################################################
82
+ initializer "orbitcast.rails_endpoint", after: :load_config_initializers do |app|
83
+ next unless Orbitcast.config.dispatch?
84
+
85
+ require "orbitcast/rails_endpoint"
86
+
87
+ mount_path = Orbitcast.config.rpc_mount_path
88
+ app.routes.append do
89
+ mount Orbitcast::RailsEndpoint.new, at: mount_path, as: :orbitcast_rails_endpoint
90
+ end
91
+
92
+ Rails.logger&.info(
93
+ "[orbitcast] Rails-dispatch endpoint mounted at #{mount_path}; block this path at the " \
94
+ "public proxy."
95
+ )
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Orbitcast
4
+ # Base class for every Orbitcast-raised error.
5
+ #
6
+ # All error classes live in this one file on purpose: they are needed by files
7
+ # that must load without the rest of the gem (see `orbitcast/binary.rb`, which
8
+ # the executable wrapper loads before Rails exists). Zeitwerk only requires
9
+ # `Orbitcast::Error` to be defined here; the siblings are plain extra constants
10
+ # and are never expected at their own paths.
11
+ class Error < StandardError; end
12
+
13
+ # Raised for unknown keys or invalid values in Orbitcast configuration.
14
+ # Inherits ArgumentError so callers may rescue either.
15
+ class ConfigurationError < ArgumentError; end
16
+
17
+ # Raised when the resolved Orbitcast executable is unusable: it cannot be
18
+ # found, or an `ORBITCAST_BINARY` override fails the `doctor --json` check
19
+ # (wrong version, missing feature, unsupported envelope version).
20
+ class BinaryError < Error; end
21
+
22
+ # Raised when no usable Orbitcast executable can be resolved.
23
+ class BinaryNotFoundError < BinaryError; end
24
+
25
+ # Raised when an encoded notice exceeds PostgreSQL's NOTIFY payload limit.
26
+ class PayloadTooLarge < Error; end
27
+
28
+ # Raised when the publisher pool cannot hand out a connection in time.
29
+ class PoolTimeout < Error; end
30
+
31
+ # Raised when the overflow relay is enabled but `orbitcast_payloads` does not
32
+ # exist. Orbitcast never creates it implicitly: install the migration.
33
+ class OverflowTableMissing < Error; end
34
+
35
+ # Raised when a v1 envelope is not usable: unsupported version, unknown
36
+ # tagged value, missing payload, or a value outside the documented limits.
37
+ # The `field` is the envelope field that failed, for logs and tests.
38
+ class ProtocolError < Error
39
+ attr_reader :field
40
+
41
+ def initialize(message, field: nil)
42
+ super(message)
43
+ @field = field
44
+ end
45
+ end
46
+
47
+ # Raised when the application asks for a channel construct the relay cannot
48
+ # run (a stream transformation block, a periodic timer). Unsupported uses
49
+ # are detected and fail clearly instead of pretending.
50
+ class UnsupportedChannelFeature < Error; end
51
+
52
+ # Raised when a private HTTP publication ingress call fails.
53
+ class IngressError < Error; end
54
+ end