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,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+ require "openssl"
6
+
7
+ require_relative "error"
8
+
9
+ module Orbitcast
10
+ # Recovery tokens: the capability that lets one browser session replay
11
+ # retained history on `Orbitcast::RecoveryChannel`.
12
+ #
13
+ # base64(JSON(claims)) + "--" + hex(HMAC-SHA256(recovery_secret, base64_part))
14
+ #
15
+ # Same wire shape as `Orbitcast::Streams`, and deliberately **not** the same
16
+ # key or the same purpose:
17
+ #
18
+ # * the key is `config.recovery_secret`, exported to the relay as
19
+ # `ORBITCAST_RECOVERY_SECRET_BASE64`. There is no fallback to
20
+ # `streams_secret`, in either direction.
21
+ # * the purpose is `"recover"`, so a subscribe token can never be presented as
22
+ # a recovery capability (and vice versa).
23
+ # * the claims name the exact streams that may be replayed, so a recovery
24
+ # token never widens access. Mint it from the same authorization decision
25
+ # that produced the subscription, and keep the lifetime short.
26
+ #
27
+ # Recovery is opt-in on both sides: nothing here is used unless the
28
+ # application asks for a token *and* the relay runs with `broker_enabled`.
29
+ # An unchanged ActionCable or Turbo client never sees any of it.
30
+ #
31
+ # Two things the token does **not** do:
32
+ #
33
+ # * It does not make history exist. Only publications that pass through the
34
+ # relay are retained, which means `config.publish_via = :http`. The
35
+ # default `:postgres` path emits `pg_notify` from this process and never
36
+ # reaches the broker, so nothing broadcast that way can be replayed - the
37
+ # relay has no Ruby-side switch for this, and this module cannot detect
38
+ # it; it is a deployment decision.
39
+ # * It does not authorize by itself. The relay re-checks on every resume
40
+ # that the connection is identified, that the token is still valid, and
41
+ # that the connection holds a confirmed subscription to each stream it
42
+ # replays: `streams` must be the logical names the channels `stream_from`,
43
+ # and the browser must subscribe to those channels before it creates the
44
+ # recovery subscription.
45
+ module Recovery
46
+ CLAIMS_VERSION = 1
47
+ PURPOSE = "recover"
48
+ SEPARATOR = "--"
49
+ DIGEST = "SHA256"
50
+
51
+ # Same bound the server enforces (`MAX_RECOVERY_STREAMS`).
52
+ MAX_STREAMS = 64
53
+
54
+ # Default token lifetime, in seconds. Deliberately short: a recovery token
55
+ # is used at reconnect time, not held for a session's lifetime.
56
+ DEFAULT_EXPIRES_IN = 900
57
+
58
+ class << self
59
+ # Issue a recovery capability.
60
+ #
61
+ # @param session_id [String] an identity *reference* the application
62
+ # recognizes. Never a cookie, never sensitive connection state: it is
63
+ # stored in the relay's session row.
64
+ # @param streams [Array<String>] the logical streams this token may
65
+ # replay. Exactly the streams the caller already authorized.
66
+ # @param expires_in [Integer, nil] seconds; defaults to
67
+ # {DEFAULT_EXPIRES_IN}
68
+ # @return [String] the token for a
69
+ # `{"channel":"Orbitcast::RecoveryChannel","token":"..."}` identifier
70
+ def recovery_token(session_id:, streams:, expires_in: nil, config: Orbitcast.config)
71
+ payload = claims(session_id: session_id, streams: streams,
72
+ expires_in: expires_in, config: config)
73
+ encoded = Base64.strict_encode64(JSON.generate(payload))
74
+
75
+ "#{encoded}#{SEPARATOR}#{signature(encoded, config.recovery_secret!)}"
76
+ end
77
+
78
+ # The claim set, in the fixed key order the server's fixtures pin:
79
+ # version, namespace, session_id, streams, purpose, expires_at.
80
+ def claims(session_id:, streams:, expires_in: nil, config: Orbitcast.config, now: Time.now)
81
+ ttl = expires_in.nil? ? DEFAULT_EXPIRES_IN : Integer(expires_in)
82
+ raise ConfigurationError, "expires_in must be greater than zero" unless ttl.positive?
83
+
84
+ namespace = config.namespace
85
+ raise ConfigurationError, "namespace is required to sign recovery tokens" if namespace.nil?
86
+
87
+ {
88
+ version: CLAIMS_VERSION,
89
+ namespace: namespace,
90
+ session_id: validated_session_id(session_id),
91
+ streams: validated_streams(streams),
92
+ purpose: PURPOSE,
93
+ expires_at: now.to_i + ttl
94
+ }
95
+ end
96
+
97
+ # Exposed for tests and for `orbitcast doctor`; the server owns the
98
+ # authoritative verifier.
99
+ def signature(encoded, key)
100
+ OpenSSL::HMAC.hexdigest(DIGEST, key, encoded)
101
+ end
102
+
103
+ private
104
+
105
+ def validated_session_id(session_id)
106
+ unless session_id.is_a?(String)
107
+ raise ArgumentError, "session_id must be a String, got #{session_id.class}"
108
+ end
109
+ raise ArgumentError, "session_id must not be empty" if session_id.empty?
110
+
111
+ session_id
112
+ end
113
+
114
+ # A token grants exactly what it names, so an empty list is refused here
115
+ # rather than minted into a token the server would reject anyway.
116
+ def validated_streams(streams)
117
+ unless streams.is_a?(Array)
118
+ raise ArgumentError, "streams must be an Array of stream names, got #{streams.class}"
119
+ end
120
+ raise ArgumentError, "streams must not be empty" if streams.empty?
121
+
122
+ if streams.length > MAX_STREAMS
123
+ raise ArgumentError, "streams must name at most #{MAX_STREAMS} streams"
124
+ end
125
+
126
+ streams.map do |stream|
127
+ unless stream.is_a?(String)
128
+ raise ArgumentError, "stream must be a String, got #{stream.class}"
129
+ end
130
+ raise ArgumentError, "stream must not be empty" if stream.empty?
131
+
132
+ stream
133
+ end
134
+ end
135
+ end
136
+ end
137
+
138
+ # The dedicated recovery key.
139
+ #
140
+ # Added here rather than in `Configuration`'s attribute allowlist so that
141
+ # recovery stays a separate, opt-in concern: set it explicitly
142
+ # (`Orbitcast.config.recovery_secret = ...`), and export it to the relay as
143
+ # `ORBITCAST_RECOVERY_SECRET_BASE64`.
144
+ class Configuration
145
+ # `recovery_secret` reader/writer live in configuration.rb with the other
146
+ # secrets (shared normalizer); only the bang helper is recovery-specific.
147
+ def recovery_secret!
148
+ return recovery_secret if recovery_secret
149
+
150
+ raise ConfigurationError,
151
+ "recovery_secret is not configured; set config.orbitcast.recovery_secret " \
152
+ "(exported to the relay as ORBITCAST_RECOVERY_SECRET_BASE64) before signing " \
153
+ "recovery tokens"
154
+ end
155
+ end
156
+
157
+ class << self
158
+ # Issue a recovery capability for `streams`. The result goes into an
159
+ # ordinary ActionCable identifier:
160
+ # {"channel":"Orbitcast::RecoveryChannel","token":"..."}.
161
+ def recovery_token(session_id:, streams:, expires_in: nil)
162
+ Recovery.recovery_token(session_id: session_id, streams: streams,
163
+ expires_in: expires_in, config: config)
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "error"
6
+ require_relative "protocol"
7
+
8
+ module Orbitcast
9
+ # Declared state serialization.
10
+ #
11
+ # Rails-dispatch requests are stateless: the relay holds the declared
12
+ # connection and channel state between calls and hands it back on the next
13
+ # one. That only works if what crosses the wire is *data*, so this codec
14
+ # supports exactly two things:
15
+ #
16
+ # * JSON primitives (and arrays/objects of them), and
17
+ # * records, tagged explicitly as `{"_gid": "gid://app/User/7"}`.
18
+ #
19
+ # There is no Marshal, no YAML, no arbitrary instance variables, and no
20
+ # implicit capture of anything: state is declared with `orbitcast_state`, or
21
+ # it does not survive the request. Ordinary memoization stays per-call.
22
+ #
23
+ # class ApplicationCable::Connection < Orbitcast::Connection
24
+ # identified_by :current_user
25
+ # orbitcast_state :locale, :cart
26
+ # end
27
+ #
28
+ # A `_gid` whose record was deleted between two requests restores as **nil**,
29
+ # not an exception: the relay cannot retry a callback that has already run,
30
+ # and a channel that requires the record must check for it. The same goes for
31
+ # a reference the application is no longer allowed to locate — `GlobalID`
32
+ # locators raise, and a raised locator is a missing record here.
33
+ module StateCodec
34
+ # The tag that makes a record reference explicit. A plain Hash carrying this
35
+ # key cannot be encoded, because it would be indistinguishable from one.
36
+ GID_KEY = "_gid"
37
+
38
+ class << self
39
+ # @return [Object] a JSON-ready value
40
+ # @raise [ProtocolError] for anything that is not declared-state material
41
+ def dump(value)
42
+ case value
43
+ when nil, true, false, String, Integer then value
44
+ when Float
45
+ unless value.finite?
46
+ raise Orbitcast::ProtocolError.new("declared state cannot hold #{value}", field: "state")
47
+ end
48
+
49
+ value
50
+ when Symbol then value.to_s
51
+ when Array then value.map { |entry| dump(entry) }
52
+ when Hash then dump_hash(value)
53
+ else
54
+ dump_record(value)
55
+ end
56
+ end
57
+
58
+ # @return [Object] the restored value; a missing record restores as nil
59
+ def load(value)
60
+ case value
61
+ when Array then value.map { |entry| load(entry) }
62
+ when Hash
63
+ gid = value[GID_KEY]
64
+ gid.is_a?(String) && value.size == 1 ? locate(gid) : value.transform_values { |entry| load(entry) }
65
+ else
66
+ value
67
+ end
68
+ end
69
+
70
+ # Dump a whole declared-state map, validating it against the wire limits
71
+ # before it can reach a reply.
72
+ def dump_map(hash)
73
+ map = hash.each_with_object({}) { |(key, value), out| out[key.to_s] = dump(value) }
74
+ Protocol::V1.validate_state_map!("state", map)
75
+ end
76
+
77
+ def load_map(hash)
78
+ return {} unless hash.is_a?(Hash)
79
+
80
+ hash.each_with_object({}) { |(key, value), out| out[key.to_s] = load(value) }
81
+ end
82
+
83
+ private
84
+
85
+ def dump_hash(hash)
86
+ hash.each_with_object({}) do |(key, value), out|
87
+ key = key.to_s
88
+ if key == GID_KEY
89
+ raise Orbitcast::ProtocolError.new(
90
+ "declared state cannot hold a raw #{GID_KEY.inspect} key; it is reserved for " \
91
+ "record references", field: "state"
92
+ )
93
+ end
94
+
95
+ out[key] = dump(value)
96
+ end
97
+ end
98
+
99
+ def dump_record(value)
100
+ unless value.respond_to?(:to_global_id)
101
+ raise Orbitcast::ProtocolError.new(
102
+ "declared state holds #{value.class}, which is neither a JSON value nor a " \
103
+ "GlobalID-identified record. Declared state is serialized, not captured: store an " \
104
+ "id, or include GlobalID::Identification in the model.", field: "state"
105
+ )
106
+ end
107
+
108
+ { GID_KEY => value.to_global_id.to_s }
109
+ end
110
+
111
+ # A deleted (or no longer locatable) record is nil, by design.
112
+ def locate(gid)
113
+ return nil unless defined?(::GlobalID)
114
+
115
+ ::GlobalID::Locator.locate(gid)
116
+ rescue StandardError
117
+ nil
118
+ end
119
+ end
120
+
121
+ # `orbitcast_state :cursor, :user` on a Connection or a Channel.
122
+ #
123
+ # Declaring a key defines an accessor for it and nothing else: the value is
124
+ # dumped after every operation and restored before the next one.
125
+ #
126
+ # The accessors are **private**. `cursor` and `self.cursor = value` work
127
+ # from inside the channel, exactly as an attribute should; what cannot
128
+ # happen is a client performing `{"action":"cursor=","..."}` and writing
129
+ # declared state directly — ActionCable's action allowlist is every public
130
+ # method a channel adds.
131
+ module Declaration
132
+ def orbitcast_state(*names)
133
+ names = names.flatten.map(&:to_sym)
134
+ names.each do |name|
135
+ send(:attr_accessor, name)
136
+ send(:private, name, :"#{name}=")
137
+ end
138
+
139
+ @orbitcast_declared_state_keys = (declared_state_keys + names).uniq.freeze
140
+ end
141
+
142
+ # Declarations inherit: a base class may declare state every channel
143
+ # keeps, and a subclass adds its own.
144
+ def orbitcast_state_keys
145
+ inherited =
146
+ superclass.respond_to?(:orbitcast_state_keys) ? superclass.orbitcast_state_keys : []
147
+
148
+ (inherited + declared_state_keys).uniq.freeze
149
+ end
150
+
151
+ private
152
+
153
+ def declared_state_keys
154
+ @orbitcast_declared_state_keys ||= [].freeze
155
+ end
156
+ end
157
+
158
+ # The per-instance half of the declaration.
159
+ module Instance
160
+ # @return [Hash] the declared state, JSON-ready. Keys whose accessor was
161
+ # never written are omitted rather than emitted as null.
162
+ def orbitcast_dump_state
163
+ self.class.orbitcast_state_keys.each_with_object({}) do |name, out|
164
+ ivar = :"@#{name}"
165
+ next unless instance_variable_defined?(ivar)
166
+
167
+ out[name.to_s] = StateCodec.dump(instance_variable_get(ivar))
168
+ end
169
+ end
170
+
171
+ # @param map [Hash] the state the relay held for this session
172
+ def orbitcast_restore_state(map)
173
+ return self unless map.is_a?(Hash)
174
+
175
+ self.class.orbitcast_state_keys.each do |name|
176
+ key = name.to_s
177
+ next unless map.key?(key)
178
+
179
+ instance_variable_set(:"@#{name}", StateCodec.load(map[key]))
180
+ end
181
+
182
+ self
183
+ end
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "json"
5
+ require "openssl"
6
+
7
+ require_relative "error"
8
+
9
+ module Orbitcast
10
+ # Orbitcast signed-stream profile.
11
+ #
12
+ # base64(JSON(claims)) + "--" + hex(HMAC-SHA256(key, base64_part))
13
+ #
14
+ # The HMAC covers the *encoded* bytes, so a verifier can authenticate the
15
+ # token before it parses anything. Claims always carry every
16
+ # authorization-bearing field; a verifier that finds one missing must fail
17
+ # closed rather than widen access.
18
+ #
19
+ # A signed name is a *read* capability. Whisper (client-originated fanout
20
+ # on the stream) is granted only by the optional `whisper: true` claim,
21
+ # emitted only when asked for so plain tokens keep their exact bytes; the
22
+ # relay never grants it to Turbo, CableReady, or public streams
23
+ #
24
+ # This is not Turbo's profile. `Turbo::StreamsChannel.signed_stream_name`
25
+ # signs a bare stream string with Turbo's own verifier key and is parsed
26
+ # separately by the server.
27
+ module Streams
28
+ CLAIMS_VERSION = 1
29
+ PURPOSE = "subscribe"
30
+ SEPARATOR = "--"
31
+ DIGEST = "SHA256"
32
+
33
+ # The relay refuses a stream longer than this (UTF-8 bytes) on every path:
34
+ # signed-name claims, Turbo payloads, publications. Mirrored here so the
35
+ # producer fails before signing or publishing something undeliverable.
36
+ MAX_STREAM_BYTES = 1024
37
+
38
+ class << self
39
+ # @param stream [String] logical stream name
40
+ # @param expires_in [Integer, nil] seconds; defaults to
41
+ # `config.stream_expires_in`
42
+ # @param whisper [Boolean] also grant whisper on the stream
43
+ # @return [String] signed stream name for a channel identifier
44
+ def signed_stream_name(stream, expires_in: nil, whisper: false, config: Orbitcast.config)
45
+ claims = claims(stream, expires_in: expires_in, whisper: whisper, config: config)
46
+ encoded = Base64.strict_encode64(JSON.generate(claims))
47
+
48
+ "#{encoded}#{SEPARATOR}#{signature(encoded, config.streams_secret!)}"
49
+ end
50
+
51
+ # The claims, in the fixed key order the fixtures pin: version,
52
+ # namespace, stream, purpose, expires_at, then `whisper` only when true.
53
+ def claims(stream, expires_in: nil, whisper: false, config: Orbitcast.config, now: Time.now)
54
+ stream = validated_stream(stream)
55
+ ttl = expires_in.nil? ? config.stream_expires_in : Integer(expires_in)
56
+ raise ConfigurationError, "expires_in must be greater than zero" unless ttl.positive?
57
+
58
+ namespace = config.namespace
59
+ raise ConfigurationError, "namespace is required to sign stream names" if namespace.nil?
60
+
61
+ unless [true, false].include?(whisper)
62
+ raise ArgumentError, "whisper must be true or false, got #{whisper.inspect}"
63
+ end
64
+
65
+ claims = {
66
+ version: CLAIMS_VERSION,
67
+ namespace: namespace,
68
+ stream: stream,
69
+ purpose: PURPOSE,
70
+ expires_at: now.to_i + ttl
71
+ }
72
+ claims[:whisper] = true if whisper
73
+ claims
74
+ end
75
+
76
+ # Exposed for tests and for `orbitcast doctor`; the server owns the
77
+ # authoritative verifier.
78
+ def signature(encoded, key)
79
+ OpenSSL::HMAC.hexdigest(DIGEST, key, encoded)
80
+ end
81
+
82
+ # The one stream-name guard, shared with the publisher
83
+ # (`Orbitcast::Publisher::Postgres`): a stream name is the routing key of
84
+ # both a signed token and a notice envelope, so the two paths must reject
85
+ # exactly the same values.
86
+ #
87
+ # @param stream [String] logical stream name
88
+ # @return [String] the validated name
89
+ # @raise [ArgumentError] when it is not a String, is empty, is not valid
90
+ # UTF-8, or exceeds {MAX_STREAM_BYTES}
91
+ def validated_stream(stream)
92
+ unless stream.is_a?(String)
93
+ raise ArgumentError, "stream must be a String, got #{stream.class}"
94
+ end
95
+
96
+ stream = stream.to_s
97
+ raise ArgumentError, "stream must not be empty" if stream.empty?
98
+
99
+ unless stream.valid_encoding?
100
+ raise ArgumentError, "stream is not valid #{stream.encoding}"
101
+ end
102
+
103
+ unless stream.encoding == Encoding::UTF_8
104
+ begin
105
+ stream = stream.encode(Encoding::UTF_8)
106
+ rescue EncodingError => e
107
+ raise ArgumentError, "stream must be UTF-8 text (#{e.message})"
108
+ end
109
+ end
110
+ if stream.bytesize > MAX_STREAM_BYTES
111
+ raise ArgumentError,
112
+ "stream is #{stream.bytesize} bytes; the maximum is #{MAX_STREAM_BYTES} " \
113
+ "bytes of UTF-8 (the relay refuses longer streams)"
114
+ end
115
+
116
+ stream
117
+ end
118
+ end
119
+ end
120
+
121
+ class << self
122
+ # Issue a read capability for `stream` (plus whisper when `whisper:
123
+ # true`). The result goes into an ordinary ActionCable identifier:
124
+ # {"channel":"Orbitcast::StreamsChannel","signed_stream_name":"..."}.
125
+ def signed_stream_name(stream, expires_in: nil, whisper: false)
126
+ Streams.signed_stream_name(stream, expires_in: expires_in, whisper: whisper, config: config)
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Orbitcast
4
+ # Release version.
5
+ #
6
+ # `Cargo.toml` is the release authority; release-please keeps this constant in
7
+ # lockstep through its extra-file updater. The trailing annotation below is the
8
+ # updater marker -- do not reformat the line or the bump will silently no-op.
9
+ VERSION = "0.3.1" # x-release-please-version
10
+ end
data/lib/orbitcast.rb ADDED
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "orbitcast/version"
4
+ require_relative "orbitcast/error"
5
+ require_relative "orbitcast/configuration"
6
+ require_relative "orbitcast/protocol"
7
+ require_relative "orbitcast/state_codec"
8
+ require_relative "orbitcast/streams"
9
+ require_relative "orbitcast/recovery"
10
+
11
+ # Orbitcast: Rails real-time stack backed by the Orbitcast Rust relay.
12
+ #
13
+ # Loading this file must not require Rails, ActionCable, or pg. Rails-touching
14
+ # code is loaded lazily:
15
+ #
16
+ # * `Orbitcast::Engine` only when `Rails::Engine` is already defined
17
+ # * `ActionCable::SubscriptionAdapter::Orbitcast` by ActionCable's own adapter
18
+ # lookup
19
+ # * `pg` by `Orbitcast::Publisher::Postgres` when it opens a connection
20
+ #
21
+ # Constant and file names follow the Zeitwerk convention so an application can
22
+ # reference `Orbitcast::Publisher::Postgres` without a classic-loader fallback.
23
+ module Orbitcast
24
+ # Rails-dispatch surface. These need ActionCable, so they load on first
25
+ # reference rather than at require time: a signed-mode
26
+ # application, the executable wrapper, and the plain unit suite never touch
27
+ # them.
28
+ autoload :Channel, "orbitcast/channel"
29
+ autoload :ChannelRegistry, "orbitcast/channel_registry"
30
+ autoload :Connection, "orbitcast/connection"
31
+ autoload :Dispatcher, "orbitcast/dispatcher"
32
+ autoload :RailsEndpoint, "orbitcast/rails_endpoint"
33
+
34
+ class << self
35
+ # Global configuration. Applications normally write through the engine
36
+ # (`config.orbitcast.*`) or `cable.yml`; `configure` is the direct path.
37
+ def config
38
+ @config ||= Configuration.new
39
+ end
40
+
41
+ def configure
42
+ yield config if block_given?
43
+ config
44
+ end
45
+
46
+ # Test/boot helper: drop the memoized configuration.
47
+ def reset_config!
48
+ @config = nil
49
+ end
50
+
51
+ def version
52
+ VERSION
53
+ end
54
+ end
55
+ end
56
+
57
+ require_relative "orbitcast/binary"
58
+ require_relative "orbitcast/publisher/postgres"
59
+ require_relative "orbitcast/control"
60
+
61
+ # Rails integration, only when a host application already loaded Rails.
62
+ #
63
+ # The Rails-dispatch execution bases (`Orbitcast::Connection`,
64
+ # `Orbitcast::Channel`, `Orbitcast::Dispatcher`, `Orbitcast::RailsEndpoint`)
65
+ # need ActionCable and are NOT loaded here: the Engine requires the endpoint in
66
+ # dispatch mode, and an application's `ApplicationCable` classes require the
67
+ # bases themselves. A signed-mode application loads none of it.
68
+ require_relative "orbitcast/engine" if defined?(::Rails::Engine)
@@ -0,0 +1,14 @@
1
+ {
2
+ "version": "0.3.1",
3
+ "target": "x86_64-unknown-linux-gnu",
4
+ "features": [
5
+ "postgres"
6
+ ],
7
+ "envelope_versions": [
8
+ 1
9
+ ],
10
+ "rust_toolchain": "1.99",
11
+ "commit": "b9d10e789b0530850e7ce4e7788111ce9d012a2f",
12
+ "glibc_floor": "2.34",
13
+ "gem_platform": "x86_64-linux-gnu"
14
+ }