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,459 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_cable"
4
+
5
+ require_relative "error"
6
+ require_relative "protocol"
7
+ require_relative "state_codec"
8
+
9
+ module Orbitcast
10
+ # The Rails-dispatch channel base.
11
+ #
12
+ # module ApplicationCable
13
+ # class Channel < Orbitcast::Channel; end
14
+ # end
15
+ #
16
+ # class ChatChannel < ApplicationCable::Channel
17
+ # orbitcast_state :last_read_id
18
+ #
19
+ # def subscribed
20
+ # room = Room.find_by(id: params[:room])
21
+ # return reject unless room && current_user.can_read?(room)
22
+ #
23
+ # stream_for room
24
+ # grant_presence broadcasting_for(room), subject_id: "user:#{current_user.id}"
25
+ # transmit(backlog: room.recent_messages)
26
+ # end
27
+ #
28
+ # def speak(data)
29
+ # room.messages.create!(body: data["body"], user: current_user)
30
+ # end
31
+ # end
32
+ #
33
+ # ## How it reuses ActionCable
34
+ #
35
+ # It is a **subclass of `ActionCable::Channel::Base`**, so `subscribed`,
36
+ # `unsubscribed`, `before_subscribe`/`after_subscribe`, `reject`,
37
+ # `perform_action` with its action allowlist and `rescue_from` handlers,
38
+ # `broadcasting_for`, `broadcast_to`, and the `current_user`-style delegation
39
+ # to the connection's identifiers are all Rails' own code, running unmodified.
40
+ #
41
+ # What a subclass overrides is the four methods whose *implementation* is a
42
+ # live pub/sub subscription — `stream_from`, `stop_stream_from`,
43
+ # `stop_all_streams`, and (through `stream_from`) `stream_for`. In dispatch
44
+ # mode the relay owns the pub/sub side of a stream, so these record ordered
45
+ # `add_stream` / `remove_stream` / `remove_all_streams` effects instead of
46
+ # touching `connection.pubsub`. `transmit` needs no override at all: it
47
+ # already goes through `connection.transmit`, which is where the transmit
48
+ # effect is collected.
49
+ #
50
+ # Ordinary Ruby inheritance, and nothing else: no `prepend`, no patched
51
+ # `ActionCable` constant, no fake socket.
52
+ #
53
+ # ## What it adds
54
+ #
55
+ # `grant_presence` / `revoke_presence` are the one piece of DSL that is not
56
+ # ActionCable's. They exist because presence identity is an
57
+ # authorization decision: the relay will not invent a `subject_id`, and a
58
+ # client cannot supply one, so Rails hands it over explicitly — as an
59
+ # ordered effect, like every other thing an operation produces.
60
+ #
61
+ # ## What cannot work
62
+ #
63
+ # A stream transformation block and a periodic timer are Ruby that would have
64
+ # to run inside the Rust process on every message or every tick. They are
65
+ # refused loudly rather than silently ignored — use an ordinary server-side
66
+ # broadcast, or a job.
67
+ class Channel < ActionCable::Channel::Base
68
+ include StateCodec::Instance
69
+ extend StateCodec::Declaration
70
+
71
+ V1 = Protocol::V1
72
+
73
+ # The presence operations a grant may authorize, spelled exactly as
74
+ # `PresenceOp` spells them (`src/protocol/v1/presence.rs`). The same three
75
+ # strings are what a client's `orbitcast_presence` command may ask for.
76
+ PRESENCE_OPERATIONS = V1::PRESENCE_OPERATIONS
77
+
78
+ # Default lease lifetime, in seconds, when `grant_presence` is called
79
+ # without a `ttl:`. Short on purpose: a lease is refreshed by every `join`,
80
+ # and a tab that dies stops refreshing.
81
+ DEFAULT_PRESENCE_TTL = 45
82
+
83
+ # Public methods that must never become client-callable actions: the
84
+ # plumbing the RPC dispatcher calls, and the server-side presence DSL —
85
+ # `grant_presence` is how identity is decided, so it is the last method a
86
+ # client may ever name in `data["action"]`.
87
+ #
88
+ # `action_methods` subtracts `internal_methods`; that is the hook
89
+ # ActionCable's own `Callbacks` module uses, and it is the reason a channel
90
+ # can carry non-action public methods at all. Methods declared on
91
+ # `ActionCable::Channel::Base` (`stream_from`, `transmit`, ...) are
92
+ # subtracted by ActionCable itself; these are declared here, so they are
93
+ # ours to exclude.
94
+ INTERNAL_METHODS = %i[
95
+ orbitcast_effects
96
+ orbitcast_streams
97
+ orbitcast_installed_streams=
98
+ orbitcast_processable_action?
99
+ orbitcast_rejected?
100
+ orbitcast_dump_state
101
+ orbitcast_restore_state
102
+ grant_presence
103
+ revoke_presence
104
+ ].freeze
105
+
106
+ # Action names beginning with this prefix belong to the relay.
107
+ #
108
+ # `orbitcast_presence` is the one that exists today (see
109
+ # `src/protocol/v1/presence.rs`): the server answers that action itself and
110
+ # never forwards it to Rails. The whole prefix is reserved so the next such
111
+ # action does not silently start shadowing an application method that was
112
+ # already deployed.
113
+ RESERVED_ACTION_PREFIX = "orbitcast_"
114
+
115
+ class << self
116
+ # Periodic timers live in the ActionCable event loop, which is not where
117
+ # this channel runs.
118
+ def periodically(*, **, &)
119
+ raise UnsupportedChannelFeature,
120
+ "#{name || "this channel"} declares `periodically`, which needs an in-process " \
121
+ "ActionCable event loop. In Rails-dispatch mode the socket is held by the relay: " \
122
+ "use a recurring job that broadcasts to the stream instead."
123
+ end
124
+
125
+ # Fail at class-definition time on a reserved action name.
126
+ #
127
+ # A public method on a channel *is* a client-callable action
128
+ # (`action_methods` minus `internal_methods`), so a channel defining
129
+ # `orbitcast_presence` would be declaring an action the relay intercepts
130
+ # and Rails is never asked about — the method would simply never run.
131
+ # Excluding it with a warning would leave that dead code in the
132
+ # application; raising here surfaces it on the line that wrote it, while
133
+ # the class body is still on the stack.
134
+ #
135
+ # Only *public* methods are actions, so a helper with the same prefix
136
+ # declared under a `private` (or `protected`) section is left alone —
137
+ # visibility is decided before `method_added` runs there, which the
138
+ # inline `private def` form cannot offer. The plumbing this base class
139
+ # declares is allowlisted in {INTERNAL_METHODS}.
140
+ def method_added(name)
141
+ super
142
+
143
+ if name.to_s.start_with?(RESERVED_ACTION_PREFIX) &&
144
+ !INTERNAL_METHODS.include?(name) &&
145
+ public_method_defined?(name)
146
+ raise UnsupportedChannelFeature,
147
+ "#{self.name || "this channel"} defines `#{name}`, and `" \
148
+ "#{RESERVED_ACTION_PREFIX}` is a reserved action namespace: the relay answers " \
149
+ "those actions itself, so Rails would never be asked to run this method. " \
150
+ "Rename it, or make it private if it is not meant to be a client-callable " \
151
+ "action."
152
+ end
153
+ end
154
+
155
+ private
156
+
157
+ def internal_methods
158
+ super + INTERNAL_METHODS
159
+ end
160
+ end
161
+
162
+ # Record an `add_stream` effect for an owned subscription.
163
+ #
164
+ # @param broadcasting [String, #to_s] the logical stream name
165
+ def stream_from(broadcasting, callback = nil, coder: nil, &block)
166
+ return if unsubscribed?
167
+
168
+ if callback || block || coder
169
+ raise UnsupportedChannelFeature,
170
+ "stream_from with a callback, block, or coder cannot run in Rails-dispatch mode: " \
171
+ "the relay delivers published payloads to the browser without calling back into " \
172
+ "Ruby. Transform the payload where you broadcast it."
173
+ end
174
+
175
+ broadcasting = String(broadcasting)
176
+ orbitcast_effects.add_stream(identifier, broadcasting)
177
+ orbitcast_streams[broadcasting] = true
178
+ broadcasting
179
+ end
180
+
181
+ # `stream_for model` keeps working: the broadcasting name still comes from
182
+ # ActionCable's own `broadcasting_for`, so a `broadcast_to` from anywhere in
183
+ # the application reaches this subscription.
184
+ def stream_for(broadcastables, callback = nil, coder: nil, &block)
185
+ stream_from(broadcasting_for(broadcastables), callback || block, coder: coder)
186
+ end
187
+
188
+ def stop_stream_from(broadcasting)
189
+ broadcasting = String(broadcasting)
190
+ return unless orbitcast_streams.delete(broadcasting)
191
+
192
+ orbitcast_effects.remove_stream(identifier, broadcasting)
193
+ broadcasting
194
+ end
195
+
196
+ def stop_stream_for(model)
197
+ stop_stream_from(broadcasting_for(model))
198
+ end
199
+
200
+ # Registered by ActionCable itself as an `on_unsubscribe` callback, which is
201
+ # why the collector is suppressed for teardown operations: the relay has
202
+ # already dropped the subscription this would address.
203
+ def stop_all_streams
204
+ return if orbitcast_streams.empty?
205
+
206
+ orbitcast_streams.clear
207
+ orbitcast_effects.remove_all_streams(identifier)
208
+ end
209
+
210
+ # Grant this subscription an authorized presence membership on `stream`.
211
+ #
212
+ # def subscribed
213
+ # stream_for room
214
+ # grant_presence broadcasting_for(room),
215
+ # subject_id: "user:#{current_user.id}",
216
+ # metadata: { name: current_user.name },
217
+ # ttl: 45
218
+ # end
219
+ #
220
+ # ## Identity is decided here, and only here
221
+ #
222
+ # A client's `orbitcast_presence` command carries the operation and a
223
+ # snapshot page window — nothing else. `subject_id` and `metadata` come
224
+ # from this grant, so a browser cannot name, impersonate, or decorate a
225
+ # subject: a `subject_id` it sends anyway is an unknown field on the
226
+ # command envelope and is ignored on decode.
227
+ #
228
+ # ## The grant is bound to a stream this subscription holds
229
+ #
230
+ # Call it *after* `stream_from` / `stream_for` for the same name. The relay
231
+ # refuses a grant for a stream the subscription does not stream from, and
232
+ # releases the grant (announcing the leave) the moment the subscription
233
+ # stops streaming from it — `stop_stream_from`, `stop_all_streams`,
234
+ # unsubscribe, or the socket closing. Granting before streaming is the
235
+ # one ordering mistake that is easy to make, so it raises here.
236
+ #
237
+ # A second grant for the same stream replaces the first: the newest
238
+ # subject, metadata, TTL, and operations win. If the client had joined, a
239
+ # different subject leaves (announced) before the new one joins, and the
240
+ # same subject keeps its presence with the new metadata.
241
+ #
242
+ # ## Arguments
243
+ #
244
+ # @param stream [String, Symbol] the logical stream the roster belongs to —
245
+ # the same name `stream_from` installs. A model is *not* converted for
246
+ # you: pass `broadcasting_for(model)`, exactly as `stream_for` does
247
+ # internally, so the roster and the fanout agree on one name.
248
+ # @param subject_id [String] the identity the roster reports, at most
249
+ # {Orbitcast::Protocol::V1::MAX_SUBJECT_ID_LEN} bytes. One subject, many
250
+ # sessions: tabs and devices of one subject produce one join and one
251
+ # leave.
252
+ # @param metadata [Hash] public per-subject data, encoded at most
253
+ # {Orbitcast::Protocol::V1::MAX_PRESENCE_METADATA_LEN} bytes. It is
254
+ # broadcast to every member of the stream — nothing private belongs in
255
+ # it. Keys travel as strings.
256
+ # @param ttl [Numeric, ActiveSupport::Duration, nil] lease lifetime in
257
+ # *seconds*; defaults to {DEFAULT_PRESENCE_TTL}. Emitted as `ttl_ms`.
258
+ # @param operations [Array<String, Symbol>] the subset of
259
+ # {PRESENCE_OPERATIONS} this grant authorizes. Defaults to all three.
260
+ # @return [Hash, nil] the recorded effect, or nil when the collector is
261
+ # suppressed (`close_channel` / `release_connection` address a
262
+ # subscription the relay has already tombstoned).
263
+ # @raise [ArgumentError] when a value is outside the limits the relay
264
+ # enforces, or when this subscription does not stream from `stream`.
265
+ # Rails fails here rather than emitting a reply Rust rejects.
266
+ def grant_presence(stream, subject_id:, metadata: {}, ttl: nil,
267
+ operations: PRESENCE_OPERATIONS)
268
+ orbitcast_effects.presence_grant(
269
+ identifier,
270
+ stream: presence_streamed!(presence_stream!(stream)),
271
+ subject_id: presence_subject_id!(subject_id),
272
+ metadata: presence_metadata!(metadata),
273
+ ttl_ms: presence_ttl_ms!(ttl),
274
+ operations: presence_operations!(operations)
275
+ )
276
+ end
277
+
278
+ # Drop a presence membership this subscription was granted.
279
+ #
280
+ # Idempotent by construction: the effect names `(identifier, stream,
281
+ # subject_id)`, and revoking a membership that is already gone is a no-op
282
+ # at the relay rather than an error here.
283
+ #
284
+ # @return [Hash, nil] the recorded effect, or nil when suppressed
285
+ # @raise [ArgumentError] on an unusable stream or subject id
286
+ def revoke_presence(stream, subject_id:)
287
+ orbitcast_effects.presence_revoke(
288
+ identifier,
289
+ stream: presence_stream!(stream),
290
+ subject_id: presence_subject_id!(subject_id)
291
+ )
292
+ end
293
+
294
+ # The streams this subscription holds, as the relay reported them.
295
+ def orbitcast_streams
296
+ @orbitcast_streams ||= {}
297
+ end
298
+
299
+ # Seed the channel with the streams the relay already installed, so
300
+ # `stop_stream_from` in a later `perform` knows what there is to remove.
301
+ def orbitcast_installed_streams=(streams)
302
+ @orbitcast_streams = Array(streams).each_with_object({}) { |stream, out| out[stream] = true }
303
+ end
304
+
305
+ # Whether `data["action"]` names a callable action on this channel.
306
+ # ActionCable logs and ignores anything else; the RPC boundary answers with
307
+ # an explicit denial instead, so the relay can record it.
308
+ def orbitcast_processable_action?(data)
309
+ processable_action?(extract_action(data))
310
+ end
311
+
312
+ # `subscription_rejected?` is private in ActionCable; the dispatcher needs
313
+ # the answer to turn `reject` into a denied outcome.
314
+ def orbitcast_rejected?
315
+ !!subscription_rejected?
316
+ end
317
+
318
+ def orbitcast_effects
319
+ connection.orbitcast_effects
320
+ end
321
+
322
+ private
323
+
324
+ # --- presence argument checks ---------------------------------------------
325
+ #
326
+ # Each one mirrors the matching check in `Effect::validate` in
327
+ # `src/protocol/v1/rpc.rs`, so an unusable grant fails in the application's
328
+ # own callback — with the application's own stack trace — instead of
329
+ # arriving as a rejected reply the relay logs and the browser never sees.
330
+ # `ArgumentError` because this is the application-facing DSL, the same way
331
+ # `Orbitcast::Streams` and `Orbitcast::Recovery` report bad arguments; the
332
+ # `EffectCollector` behind it still raises `ProtocolError` for anything
333
+ # that reaches it by another path.
334
+
335
+ def presence_stream!(stream)
336
+ unless stream.is_a?(String) || stream.is_a?(Symbol)
337
+ raise ArgumentError,
338
+ "presence stream must be a String, got #{stream.class}. For a model pass " \
339
+ "broadcasting_for(model) — the same name stream_for installs."
340
+ end
341
+
342
+ stream = stream.to_s
343
+ raise ArgumentError, "presence stream must not be empty" if stream.empty?
344
+
345
+ if stream.bytesize > V1::MAX_STREAM_LEN
346
+ raise ArgumentError,
347
+ "presence stream is #{stream.bytesize} bytes; the maximum is #{V1::MAX_STREAM_LEN}"
348
+ end
349
+
350
+ stream
351
+ end
352
+
353
+ # The relay binds a grant to a stream the subscription holds and refuses
354
+ # anything else; failing here keeps that refusal out of the relay's log
355
+ # and in the callback that got the order wrong.
356
+ def presence_streamed!(stream)
357
+ return stream if orbitcast_streams.key?(stream)
358
+
359
+ raise ArgumentError,
360
+ "grant_presence(#{stream.inspect}) must follow stream_from/stream_for for that " \
361
+ "stream: the relay binds the roster to a stream this subscription holds " \
362
+ "(currently #{orbitcast_streams.keys.inspect})"
363
+ end
364
+
365
+ def presence_subject_id!(subject_id)
366
+ unless subject_id.is_a?(String)
367
+ raise ArgumentError, "subject_id must be a String, got #{subject_id.class}"
368
+ end
369
+ raise ArgumentError, "subject_id must not be empty" if subject_id.empty?
370
+
371
+ if subject_id.bytesize > V1::MAX_SUBJECT_ID_LEN
372
+ raise ArgumentError,
373
+ "subject_id is #{subject_id.bytesize} bytes; the maximum is " \
374
+ "#{V1::MAX_SUBJECT_ID_LEN}"
375
+ end
376
+
377
+ subject_id
378
+ end
379
+
380
+ # The *encoded* size is what travels and what is stored, so one huge value
381
+ # is bounded exactly like many small ones.
382
+ def presence_metadata!(metadata)
383
+ unless metadata.is_a?(Hash)
384
+ raise ArgumentError, "metadata must be a Hash, got #{metadata.class}"
385
+ end
386
+
387
+ metadata = metadata.transform_keys do |key|
388
+ unless key.is_a?(String) || key.is_a?(Symbol)
389
+ raise ArgumentError, "metadata keys must be Strings, got #{key.class}"
390
+ end
391
+
392
+ key.to_s
393
+ end
394
+
395
+ encoded =
396
+ begin
397
+ JSON.generate(metadata)
398
+ rescue JSON::GeneratorError, SystemStackError => e
399
+ raise ArgumentError, "metadata must be JSON-encodable: #{e.message}"
400
+ end
401
+
402
+ if encoded.bytesize > V1::MAX_PRESENCE_METADATA_LEN
403
+ raise ArgumentError,
404
+ "metadata encodes to #{encoded.bytesize} bytes; the maximum is " \
405
+ "#{V1::MAX_PRESENCE_METADATA_LEN}"
406
+ end
407
+
408
+ metadata
409
+ end
410
+
411
+ # Seconds in, milliseconds out: `ttl: 45` and `ttl: 45.seconds` are the
412
+ # same lease, and the wire field is `ttl_ms`.
413
+ def presence_ttl_ms!(ttl)
414
+ seconds = ttl.nil? ? DEFAULT_PRESENCE_TTL : ttl
415
+ unless seconds.is_a?(Numeric)
416
+ raise ArgumentError, "ttl must be a number of seconds, got #{seconds.class}"
417
+ end
418
+
419
+ ttl_ms = (seconds.to_f * 1000).round
420
+ unless ttl_ms.positive?
421
+ raise ArgumentError, "ttl must be greater than zero (it became #{ttl_ms}ms)"
422
+ end
423
+
424
+ ttl_ms
425
+ end
426
+
427
+ # A grant names what it authorizes. An operation the server does not know
428
+ # could never be performed, and an empty allowlist grants nothing at all —
429
+ # both are caller mistakes, not lenient defaults.
430
+ def presence_operations!(operations)
431
+ unless operations.is_a?(Array)
432
+ raise ArgumentError, "operations must be an Array, got #{operations.class}"
433
+ end
434
+
435
+ operations = operations.map do |operation|
436
+ unless operation.is_a?(String) || operation.is_a?(Symbol)
437
+ raise ArgumentError, "operations must be Strings, got #{operation.class}"
438
+ end
439
+
440
+ operation = operation.to_s
441
+ unless PRESENCE_OPERATIONS.include?(operation)
442
+ raise ArgumentError,
443
+ "unknown presence operation #{operation.inspect}; the operations are " \
444
+ "#{PRESENCE_OPERATIONS.join(", ")}"
445
+ end
446
+
447
+ operation
448
+ end.uniq
449
+
450
+ if operations.empty?
451
+ raise ArgumentError,
452
+ "operations must name at least one of #{PRESENCE_OPERATIONS.join(", ")}; " \
453
+ "a grant that authorizes nothing can never be used"
454
+ end
455
+
456
+ operations
457
+ end
458
+ end
459
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "error"
4
+
5
+ module Orbitcast
6
+ # The allowlist of channel classes a client may subscribe to.
7
+ #
8
+ # ActionCable resolves `identifier["channel"]` with `safe_constantize`, which
9
+ # is fine when the only thing on the other end is a browser talking to your
10
+ # own process. Here the name arrives over an RPC boundary and is attacker
11
+ # controlled, so it is matched against an explicit list of strings *first* and
12
+ # only then resolved. Nothing unlisted is ever constantized, and a listed name
13
+ # that does not resolve to an `Orbitcast::Channel` is refused too:
14
+ # `"Kernel"` is a constant, it is just not a channel.
15
+ #
16
+ # config.orbitcast.channels = %w[ChatChannel Turbo::StreamsChannel]
17
+ # config.orbitcast.channels = :auto # derive from app/channels
18
+ #
19
+ # `:auto` derivation reads the application's `app/channels` tree, turns each
20
+ # file name into the constant Zeitwerk would define, and keeps the ones that
21
+ # are subclasses of `Orbitcast::Channel`. It is a convenience for development;
22
+ # an explicit list is what a reviewed deployment ships.
23
+ class ChannelRegistry
24
+ AUTO = :auto
25
+
26
+ # `ApplicationCable::Channel` is a base class, never a subscribable target.
27
+ NEVER_SUBSCRIBABLE = %w[ApplicationCable::Channel Orbitcast::Channel].freeze
28
+
29
+ class << self
30
+ # @param names [Array<String, Class>, :auto, nil]
31
+ def build(names, root: nil)
32
+ case names
33
+ when nil, AUTO, "auto" then auto(root)
34
+ when Array then new(names)
35
+ else
36
+ raise ConfigurationError,
37
+ "channels must be an Array of channel class names or :auto, got #{names.class}"
38
+ end
39
+ end
40
+
41
+ # Derive the allowlist from `app/channels/**/*_channel.rb`.
42
+ def auto(root = nil)
43
+ root ||= default_root
44
+ return new([]) if root.nil?
45
+
46
+ base = File.join(root.to_s, "app", "channels")
47
+ names = Dir[File.join(base, "**", "*_channel.rb")].sort.map do |path|
48
+ constant_name_for(path.delete_prefix("#{base}/"))
49
+ end
50
+
51
+ new(names - NEVER_SUBSCRIBABLE)
52
+ end
53
+
54
+ def default_root
55
+ return nil unless defined?(::Rails) && ::Rails.respond_to?(:root) && ::Rails.root
56
+
57
+ ::Rails.root
58
+ end
59
+
60
+ # "chat/room_channel.rb" -> "Chat::RoomChannel", the constant Zeitwerk
61
+ # defines for that path. Acronym inflections are respected when
62
+ # ActiveSupport is loaded.
63
+ def constant_name_for(relative_path)
64
+ relative_path.delete_suffix(".rb").split("/").map { |part| camelize(part) }.join("::")
65
+ end
66
+
67
+ private
68
+
69
+ def camelize(part)
70
+ if part.respond_to?(:camelize)
71
+ part.camelize
72
+ else
73
+ part.split("_").map(&:capitalize).join
74
+ end
75
+ end
76
+ end
77
+
78
+ def initialize(names)
79
+ @names = Array(names).map { |name| name.is_a?(Class) ? name.name : name.to_s }.uniq.freeze
80
+ end
81
+
82
+ attr_reader :names
83
+
84
+ def empty?
85
+ @names.empty?
86
+ end
87
+
88
+ def include?(name)
89
+ @names.include?(name)
90
+ end
91
+
92
+ # @param name [String] the client-supplied `identifier["channel"]`
93
+ # @return [Class, nil] the channel class, or nil when the name is not
94
+ # allowlisted or does not resolve to a usable channel
95
+ def resolve(name)
96
+ return nil unless name.is_a?(String)
97
+ return nil unless include?(name)
98
+
99
+ constant = constantize(name)
100
+ return nil unless constant.is_a?(Class)
101
+ return nil unless defined?(Orbitcast::Channel) && constant <= Orbitcast::Channel
102
+
103
+ constant
104
+ end
105
+
106
+ # Boot-time check: every allowlisted name must actually be a channel, so a
107
+ # typo is a deployment error rather than a runtime denial.
108
+ def validate!
109
+ unresolved = @names.reject { |name| resolve(name) }
110
+ return self if unresolved.empty?
111
+
112
+ raise ConfigurationError,
113
+ "config.orbitcast.channels lists #{unresolved.join(", ")}, which " \
114
+ "#{unresolved.one? ? "is not a" : "are not"} subclass#{unresolved.one? ? "" : "es"} " \
115
+ "of Orbitcast::Channel"
116
+ end
117
+
118
+ private
119
+
120
+ # Only ever called with a name that is already on the allowlist.
121
+ def constantize(name)
122
+ if name.respond_to?(:safe_constantize)
123
+ name.safe_constantize
124
+ else
125
+ Object.const_get(name)
126
+ end
127
+ rescue NameError
128
+ nil
129
+ end
130
+ end
131
+ end