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,223 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+
5
+ module Orbitcast
6
+ module Generators
7
+ # `bin/rails generate orbitcast:install`
8
+ #
9
+ # Applies everything an application needs to talk to the relay,
10
+ # idempotently — a second run changes nothing:
11
+ #
12
+ # 1. config/cable.yml — `adapter: orbitcast`, namespace, cable DSN
13
+ # 2. config/initializers/orbitcast.rb — mode and signing keys
14
+ # 3. config/environments/{development,production}.rb
15
+ # — mount_path = nil, url, allowed origins, mode
16
+ # 4. app/views/layouts/application.html.erb
17
+ # — `action_cable_meta_tag` in <head>, if missing
18
+ # 5. Procfile.dev — a `cable:` process (created when absent)
19
+ # 6. db/orbitcast_migrate/ — the overflow relay table, with --overflow
20
+ #
21
+ # It touches nothing else: importmap pins, `@rails/actioncable` imports,
22
+ # Solid Queue/Cache configuration, and any environment setting already
23
+ # present are left exactly as they are.
24
+ class InstallGenerator < Rails::Generators::Base
25
+ source_root File.expand_path("templates", __dir__)
26
+
27
+ desc "Configure Rails to publish through Orbitcast and let the Rust relay serve /cable."
28
+
29
+ class_option :overflow,
30
+ type: :boolean,
31
+ default: false,
32
+ desc: "Copy the overflow-payload migration (db/orbitcast_migrate) and enable " \
33
+ "the relay for Turbo broadcasts larger than PostgreSQL's 8000-byte " \
34
+ "NOTIFY limit"
35
+
36
+ # gem/lib/generators/orbitcast/install -> gem/db/orbitcast_migrate
37
+ MIGRATION_SOURCE_DIR = File.expand_path("../../../../db/orbitcast_migrate", __dir__)
38
+ MIGRATION_FILENAME = "001_create_orbitcast_payloads.rb"
39
+
40
+ PROCFILE = "Procfile.dev"
41
+ CABLE_PROCESS = "cable: bundle exec orbitcast serve"
42
+ WEB_PROCESS = "web: bin/rails server"
43
+
44
+ LAYOUT = "app/views/layouts/application.html.erb"
45
+ META_TAG = "<%= action_cable_meta_tag %>"
46
+
47
+ ENVIRONMENT_ANCHOR = /^Rails\.application\.configure do[ \t]*\n/
48
+
49
+ # The settings injected per environment, keyed by the configuration
50
+ # attribute they set. A line whose attribute is already assigned in the
51
+ # file (uncommented) is skipped, so an application's own value wins.
52
+ ENVIRONMENT_SETTINGS = {
53
+ "development" => {
54
+ "config.action_cable.mount_path" => "nil",
55
+ "config.action_cable.url" => '"ws://localhost:28080/cable"',
56
+ "config.action_cable.allowed_request_origins" =>
57
+ '["http://localhost:3000", "http://127.0.0.1:3000"]',
58
+ "config.orbitcast.mode" => ":signed"
59
+ },
60
+ "production" => {
61
+ "config.action_cable.mount_path" => "nil",
62
+ "config.action_cable.url" => 'ENV.fetch("ORBITCAST_CABLE_URL") # wss://app.example/cable',
63
+ "config.action_cable.allowed_request_origins" => '[ENV.fetch("APP_ORIGIN")]',
64
+ "config.orbitcast.mode" => ":signed"
65
+ }
66
+ }.freeze
67
+
68
+ def create_cable_yml
69
+ template "cable.yml.tt", "config/cable.yml"
70
+ end
71
+
72
+ def create_initializer
73
+ template "orbitcast.rb.tt", "config/initializers/orbitcast.rb"
74
+ end
75
+
76
+ # `mount_path = nil` is what stops Rails serving a second /cable; the
77
+ # URL and origins are what the relay and `action_cable_meta_tag` agree
78
+ # on. Applied, not printed — and only for the lines not already there.
79
+ def configure_environments
80
+ ENVIRONMENT_SETTINGS.each do |environment, settings|
81
+ path = "config/environments/#{environment}.rb"
82
+ absolute = File.join(destination_root, path)
83
+
84
+ unless File.file?(absolute)
85
+ say_status :skip, "#{path} does not exist; add the Orbitcast settings by hand", :yellow
86
+ next
87
+ end
88
+
89
+ content = File.read(absolute)
90
+ unless content.match?(ENVIRONMENT_ANCHOR)
91
+ say_status :skip, "#{path} has no `Rails.application.configure do` block to inject into", :yellow
92
+ next
93
+ end
94
+
95
+ missing = settings.reject { |attribute, _| content.match?(/^\s*#{Regexp.escape(attribute)}\s*=/) }
96
+ if missing.empty?
97
+ say_status :identical, path, :blue
98
+ next
99
+ end
100
+
101
+ block = +" # Orbitcast: the Rust relay serves /cable; Rails must not mount a second one.\n"
102
+ missing.each { |attribute, value| block << " #{attribute} = #{value}\n" }
103
+ block << "\n"
104
+
105
+ inject_into_file path, block, after: ENVIRONMENT_ANCHOR
106
+ end
107
+ end
108
+
109
+ # `createConsumer()` reads the cable URL from this tag. Without it the
110
+ # browser falls back to /cable on the Rails host, which no longer serves
111
+ # WebSockets.
112
+ def add_action_cable_meta_tag
113
+ absolute = File.join(destination_root, LAYOUT)
114
+
115
+ unless File.file?(absolute)
116
+ say_status :skip, "#{LAYOUT} does not exist; add #{META_TAG} to your layout's <head>", :yellow
117
+ return
118
+ end
119
+
120
+ content = File.read(absolute)
121
+ if content.include?("action_cable_meta_tag")
122
+ say_status :identical, "#{LAYOUT} (action_cable_meta_tag)", :blue
123
+ return
124
+ end
125
+
126
+ unless content.match?(%r{^\s*</head>})
127
+ say_status :skip, "#{LAYOUT} has no </head> to insert #{META_TAG} before", :yellow
128
+ return
129
+ end
130
+
131
+ inject_into_file LAYOUT, " #{META_TAG}\n", before: %r{^\s*</head>}
132
+ end
133
+
134
+ # Development uses the application's existing process manager. Never start
135
+ # the relay from an engine initializer: that would spawn one per
136
+ # app-server worker, console, and rake task.
137
+ def add_cable_process
138
+ absolute = File.join(destination_root, PROCFILE)
139
+
140
+ unless File.file?(absolute)
141
+ create_file PROCFILE, "#{WEB_PROCESS}\n#{CABLE_PROCESS}\n"
142
+ return
143
+ end
144
+
145
+ if File.read(absolute).match?(/^cable:/)
146
+ say_status :skip, "#{PROCFILE} already defines a cable process", :yellow
147
+ return
148
+ end
149
+
150
+ append_to_file PROCFILE, "#{CABLE_PROCESS}\n"
151
+ end
152
+
153
+ def copy_overflow_migration
154
+ return unless options[:overflow]
155
+
156
+ source = File.join(MIGRATION_SOURCE_DIR, MIGRATION_FILENAME)
157
+
158
+ unless File.file?(source)
159
+ say_status :skip,
160
+ "overflow migration is not bundled with this build (#{MIGRATION_FILENAME})",
161
+ :yellow
162
+ return
163
+ end
164
+
165
+ create_file "db/orbitcast_migrate/#{MIGRATION_FILENAME}", File.read(source)
166
+ end
167
+
168
+ def print_post_install_instructions
169
+ say ""
170
+ say "Orbitcast installed.", :green
171
+ say ""
172
+ say "What was applied:"
173
+ say " config/cable.yml, config/initializers/orbitcast.rb,"
174
+ say " config/environments/development.rb + production.rb (mount_path = nil, url,"
175
+ say " allowed_request_origins, config.orbitcast.mode = :signed; existing values kept),"
176
+ say " action_cable_meta_tag in the application layout, and a Procfile.dev cable process."
177
+ say " Importmap pins, @rails/actioncable imports, Solid Queue and Solid Cache were not touched."
178
+ if options[:overflow]
179
+ say " db/orbitcast_migrate/#{MIGRATION_FILENAME} (run bin/rails db:migrate) and"
180
+ say " overflow_enabled: true in cable.yml and the initializer."
181
+ end
182
+ say ""
183
+ say "Next:"
184
+ say " 1. Production: set ORBITCAST_CABLE_URL (wss://app.example/cable), APP_ORIGIN,"
185
+ say " ORBITCAST_DATABASE_URL and ORBITCAST_LISTEN_ADDR for the relay process."
186
+ say " 2. Run the relay as a separate, supervised process:"
187
+ say " #{CABLE_PROCESS}"
188
+ say ""
189
+ say "Signed-mode capability boundary: signed streams only.", :yellow
190
+ say "In signed mode NO connection or channel callbacks run:"
191
+ say "ApplicationCable::Connection#connect, Channel#subscribed/#unsubscribed, and"
192
+ say "`perform` actions are NOT executed by the relay. turbo_stream_from and"
193
+ say "ActionCable.server.broadcast work; custom channel classes need mode: :dispatch."
194
+ say ""
195
+ end
196
+
197
+ private
198
+
199
+ # Namespace every publication and NOTIFY channel is scoped to. Derived
200
+ # from the application module name so two applications sharing one
201
+ # PostgreSQL database never share channels.
202
+ def application_namespace
203
+ base =
204
+ if defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
205
+ ::Rails.application.class.module_parent_name.to_s
206
+ else
207
+ File.basename(destination_root.to_s)
208
+ end
209
+
210
+ sanitized = base.gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase.gsub(/[^a-z0-9_]+/, "_")
211
+ sanitized = sanitized.sub(/\A[^a-z0-9]+/, "").sub(/_+\z/, "")
212
+ sanitized = "app" if sanitized.empty?
213
+ # Configuration::NAMESPACE_PATTERN allows 64 characters; leave room for
214
+ # the "_development" / "_production" suffix.
215
+ sanitized[0, 40]
216
+ end
217
+
218
+ def overflow?
219
+ options[:overflow]
220
+ end
221
+ end
222
+ end
223
+ end
@@ -0,0 +1,48 @@
1
+ # Orbitcast cable configuration.
2
+ #
3
+ # `adapter: orbitcast` selects a publish-only ActionCable adapter: Rails
4
+ # publishes broadcasts through PostgreSQL NOTIFY and the separately supervised
5
+ # Rust relay serves /cable. A local subscribe raises instead of starting a
6
+ # second WebSocket server.
7
+ #
8
+ # database_url DSN of the cable database. It may be a dedicated PostgreSQL
9
+ # database even when the application itself runs on SQLite or
10
+ # MySQL; nothing here converts your primary database.
11
+ # The relay's listener needs a real session (direct connection
12
+ # or session pooling) — never PgBouncer transaction pooling.
13
+ # namespace Scopes every NOTIFY channel and every publication envelope.
14
+ # Two deployments sharing one database MUST use different
15
+ # namespaces, and isolated namespaces need distinct Turbo keys.
16
+ # publish_via postgres (default). The alternative, http, publishes to
17
+ # the relay's private ingress instead of PostgreSQL and
18
+ # needs ingress_url + ingress_token.
19
+ # connection pool (default): a dedicated pg pool for database_url.
20
+ # active_record: publish on the application's Active Record
21
+ # connection so a broadcast joins (and rolls back with) the
22
+ # surrounding transaction; the cable database must then be
23
+ # the application's primary database.
24
+ # overflow_enabled Relay payloads above the NOTIFY limit through the
25
+ # orbitcast_payloads table (needs the --overflow migration).
26
+
27
+ development:
28
+ adapter: orbitcast
29
+ database_url: <%%= ENV.fetch("ORBITCAST_DATABASE_URL", "postgres://localhost/<%= application_namespace %>_cable_development") %>
30
+ namespace: "<%= application_namespace %>_development"
31
+ publish_via: postgres
32
+ <% if overflow? -%>
33
+ overflow_enabled: true
34
+ <% end -%>
35
+
36
+ # Rails' in-process test adapter keeps application tests isolated; Orbitcast's
37
+ # real publication path is covered by integration tests against a live database.
38
+ test:
39
+ adapter: test
40
+
41
+ production:
42
+ adapter: orbitcast
43
+ database_url: <%%= ENV.fetch("ORBITCAST_DATABASE_URL") %>
44
+ namespace: "<%= application_namespace %>_production"
45
+ publish_via: postgres
46
+ <% if overflow? -%>
47
+ overflow_enabled: true
48
+ <% end -%>
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Orbitcast configuration.
4
+ #
5
+ # Precedence is CLI > ORBITCAST_* environment > this file / cable.yml >
6
+ # defaults. `bundle exec orbitcast serve` boots Rails once, reads these values,
7
+ # and exports an allowlisted ORBITCAST_* environment to the relay process.
8
+ # Key material is exported as strict base64 (ORBITCAST_*_SECRET_BASE64) and
9
+ # never as a command-line argument. RAILS_MASTER_KEY and SECRET_KEY_BASE are
10
+ # never exported.
11
+ #
12
+ # namespace, database_url, and publish_via come from config/cable.yml.
13
+ Orbitcast.configure do |config|
14
+ # The default mode. Signed streams are read capabilities issued by an
15
+ # authorized Rails request; the relay runs no application Ruby, so
16
+ # ApplicationCable::Connection#connect, channel #subscribed, and `perform`
17
+ # actions are NOT executed. See the :dispatch section below for the mode
18
+ # that does execute them.
19
+ config.mode = :signed
20
+
21
+ # Key for Orbitcast::StreamsChannel tokens (Orbitcast.signed_stream_name).
22
+ #
23
+ # Without an explicit key, the gem derives one from the application's key
24
+ # generator under the purpose "orbitcast/streams/v1/<namespace>" (32 bytes):
25
+ # stable across restarts, different per namespace, and never the master key
26
+ # or secret_key_base themselves. Set one explicitly to rotate independently
27
+ # of secret_key_base, or to share a key between deployments:
28
+ #
29
+ # bin/rails credentials:edit
30
+ # orbitcast:
31
+ # streams_secret: <32+ random bytes, base64 or hex>
32
+ streams_secret =
33
+ Rails.application.credentials.dig(:orbitcast, :streams_secret) ||
34
+ ENV["ORBITCAST_STREAMS_SECRET"]
35
+ config.streams_secret = streams_secret if streams_secret.present?
36
+
37
+ # Key rotation: retired keys that still VERIFY (never sign) while old
38
+ # signed names expire. Same for Turbo keys (config.turbo_previous_secrets).
39
+ # config.streams_previous_secrets = [Rails.application.credentials.dig(:orbitcast, :previous_streams_secret)].compact
40
+
41
+ # Overflow relay for broadcasts above PostgreSQL's 8000-byte NOTIFY limit.
42
+ # Needs db/orbitcast_migrate/001_create_orbitcast_payloads.rb migrated on
43
+ # the cable database (bin/rails generate orbitcast:install --overflow).
44
+ <% if overflow? -%>
45
+ config.overflow_enabled = true
46
+ <% else -%>
47
+ # config.overflow_enabled = true
48
+ <% end -%>
49
+
50
+ # --- Relay process ---------------------------------------------------------
51
+ #
52
+ # `bundle exec orbitcast serve` exports these to the relay. In development
53
+ # and test the listener defaults to 127.0.0.1:28080 (matching
54
+ # config.action_cable.url); production MUST set one here or through
55
+ # ORBITCAST_LISTEN_ADDR in the relay's environment.
56
+ # config.listen_addr = "0.0.0.0:28080"
57
+ # config.listener_database_url = ENV["ORBITCAST_LISTENER_DATABASE_URL"] # session-pooled DSN for LISTEN
58
+ # config.trusted_proxies = ["10.0.0.0/8"] # CIDRs allowed to set forwarded headers
59
+ # config.outbound_queue_messages = 1024 # per-connection outbound ceilings
60
+ # config.outbound_queue_bytes = 4 * 1024 * 1024
61
+
62
+ # Optional tuning.
63
+ # config.connection = :pool # or :active_record to join the app transaction
64
+ # config.pool_size = 5 # dedicated pg publisher connections
65
+ # config.pool_timeout = 5.0 # seconds to wait for one
66
+ # config.stream_expires_in = 3600 # default TTL of a signed stream name
67
+
68
+ # --- Rails-dispatch mode (opt-in) ----------------------------------------
69
+ #
70
+ # In :dispatch mode the relay still holds the socket, but it calls back into
71
+ # this application over a private HTTP endpoint for every decision:
72
+ # ApplicationCable::Connection#connect, channel #subscribed / #unsubscribed,
73
+ # and `perform` actions all run here, as ordinary Rails code. Channels
74
+ # inherit from Orbitcast::Channel (a real ActionCable::Channel::Base
75
+ # subclass) and record ordered effects instead of touching pubsub directly.
76
+ #
77
+ # Turn it on deliberately: it is a callback surface, and it is authenticated
78
+ # by a dedicated service credential. Without rpc_token, boot fails rather
79
+ # than exposing an open endpoint.
80
+ #
81
+ # config.mode = :dispatch
82
+ # config.rpc_token = Rails.application.credentials.dig(:orbitcast, :rpc_token)
83
+ # config.rpc_endpoint_url = "https://app.internal/_orbitcast/v1/rails"
84
+ # config.connection_class = "ApplicationCable::Connection"
85
+ # # The channel allowlist. :auto derives it from app/channels; a list is
86
+ # # explicit. A client-supplied channel name outside it is never
87
+ # # constantized.
88
+ # config.channels = :auto
89
+
90
+ # --- Recovery (opt-in) ---------------------------------------------------
91
+ #
92
+ # A recovery token lets one browser session replay retained history on the
93
+ # reserved Orbitcast::RecoveryChannel. It is a separate capability with a
94
+ # separate key: purpose "recover", never "subscribe", and there is no
95
+ # fallback to streams_secret in either direction. It also needs the relay
96
+ # running with the broker enabled and the orbitcast_broker migration
97
+ # installed; without both, nothing here does anything.
98
+ #
99
+ # recovery_secret =
100
+ # Rails.application.credentials.dig(:orbitcast, :recovery_secret) ||
101
+ # ENV["ORBITCAST_RECOVERY_SECRET"]
102
+ # config.recovery_secret = recovery_secret if recovery_secret.present?
103
+ #
104
+ # Mint one from the same authorization decision that produced the
105
+ # subscription, and keep the lifetime short:
106
+ #
107
+ # Orbitcast.recovery_token(
108
+ # session_id: "session:#{current_user.id}",
109
+ # streams: [Room.find(params[:id]).to_gid_param],
110
+ # expires_in: 900
111
+ # )
112
+ end
113
+
114
+ # --- Presence (dispatch mode only) -----------------------------------------
115
+ #
116
+ # Presence needs no transport extension and no client patch: a stock
117
+ # @rails/actioncable subscription speaks it. What it does need is a Rails
118
+ # decision, because identity is never client-chosen — the browser sends an
119
+ # operation and a page window, and nothing else.
120
+ #
121
+ # Grant it from a channel callback, where the application already knows who is
122
+ # asking:
123
+ #
124
+ # class RoomChannel < ApplicationCable::Channel
125
+ # def subscribed
126
+ # room = Room.find(params[:id])
127
+ # return reject unless current_user.can_read?(room)
128
+ #
129
+ # stream_for room
130
+ # grant_presence broadcasting_for(room),
131
+ # subject_id: "user:#{current_user.id}",
132
+ # metadata: { name: current_user.name }, # public to the room
133
+ # ttl: 45, # seconds
134
+ # operations: %w[join leave snapshot]
135
+ # end
136
+ #
137
+ # def kick(data)
138
+ # revoke_presence broadcasting_for(room), subject_id: "user:#{data["id"]}"
139
+ # end
140
+ # end
141
+ #
142
+ # `metadata` is broadcast to every member of the stream, so nothing private
143
+ # belongs in it. A `subject_id` a client sends anyway is an unknown field on
144
+ # the presence command and is ignored — the grant above is the only source.
145
+
146
+ # Turbo's signed stream names use Turbo's own key, which Rails derives with the
147
+ # key generator (NOT secret_key_base) inside an after_initialize hook. Reading
148
+ # it any earlier raises, so export it from one too: the launcher base64-encodes
149
+ # these exact bytes into ORBITCAST_TURBO_SECRET_BASE64.
150
+ #
151
+ # Remove this block if the application does not use turbo_stream_from.
152
+ Rails.application.config.after_initialize do
153
+ Orbitcast.configure do |config|
154
+ config.turbo_secret = Turbo.signed_stream_verifier_key if defined?(Turbo)
155
+ end
156
+ end
@@ -0,0 +1,234 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+ require "rbconfig"
6
+ require "rubygems"
7
+
8
+ require_relative "error"
9
+ require_relative "version"
10
+
11
+ module Orbitcast
12
+ # Resolves the vendored Orbitcast executable.
13
+ #
14
+ # Resolution is relative to *this* file, i.e. the activated gem, never a
15
+ # recursive `PATH` search: a native gem ships exactly one binary and that is
16
+ # the one it must run. `ORBITCAST_BINARY` overrides it for source builds and
17
+ # for platforms without a published artifact. There is no download, no
18
+ # compiler invocation, and no architecture guessing.
19
+ #
20
+ # An override is *verified* before it is used: `<binary> doctor
21
+ # --json` must report this gem's
22
+ # version, envelope version 1, and every feature the configuration needs.
23
+ # The vendored binary was packaged with this exact gem version and skips the
24
+ # check.
25
+ module Binary
26
+ EXECUTABLE = "orbitcast"
27
+ OVERRIDE_ENV = "ORBITCAST_BINARY"
28
+
29
+ # Envelope version this gem speaks; the binary must list it.
30
+ ENVELOPE_VERSION = 1
31
+
32
+ # Upper bound on `doctor --json`. A binary that cannot describe itself in
33
+ # this long is not one to hand the listener socket to.
34
+ DOCTOR_TIMEOUT = 5.0
35
+
36
+ class << self
37
+ # @param env [Hash] process environment (ENV, or a Hash in tests)
38
+ # @param verify [Boolean] run `doctor --json` on an override
39
+ # @param require_features [Array<String>] features the override must
40
+ # have been built with (e.g. `["postgres"]` for `publish_via: postgres`)
41
+ # @param timeout [Numeric] seconds to wait for `doctor --json`
42
+ # @return [String] absolute path to an existing executable file
43
+ def path(env: ENV, verify: false, require_features: [], timeout: DOCTOR_TIMEOUT)
44
+ override = env[OVERRIDE_ENV]
45
+ return vendored_path if override.nil? || override.empty?
46
+
47
+ resolved = override_path(override)
48
+ verify!(resolved, require_features: require_features, timeout: timeout, env: env) if verify
49
+ resolved
50
+ end
51
+
52
+ # Run `<binary> doctor --json` and check what it reports against this
53
+ # gem. Public so `orbitcast doctor` style tooling can reuse it.
54
+ #
55
+ # @raise [BinaryError] with a message naming the exact mismatch
56
+ # @return [Hash] the parsed doctor report
57
+ def verify!(binary, require_features: [], timeout: DOCTOR_TIMEOUT, env: ENV)
58
+ report = doctor_report(binary, timeout: timeout, env: env)
59
+
60
+ version = report["version"].to_s
61
+ unless version == VERSION
62
+ raise BinaryError,
63
+ "#{OVERRIDE_ENV}=#{binary} is orbitcast #{version.empty? ? "(unknown version)" : version}, " \
64
+ "but this gem is orbitcast #{VERSION}. Build the matching release " \
65
+ "(git checkout v#{VERSION} && cargo build --release --features postgres) or install " \
66
+ "the platform gem instead of overriding the binary."
67
+ end
68
+
69
+ envelopes = Array(report["envelope_versions"]).map { |value| Integer(value, exception: false) }
70
+ unless envelopes.include?(ENVELOPE_VERSION)
71
+ raise BinaryError,
72
+ "#{OVERRIDE_ENV}=#{binary} speaks envelope versions " \
73
+ "#{envelopes.compact.inspect}, but this gem requires version #{ENVELOPE_VERSION}."
74
+ end
75
+
76
+ features = Array(report["features"]).map(&:to_s)
77
+ missing = Array(require_features).map(&:to_s) - features
78
+ unless missing.empty?
79
+ raise BinaryError,
80
+ "#{OVERRIDE_ENV}=#{binary} was built without the #{missing.join(", ")} " \
81
+ "feature#{"s" if missing.size > 1} (reported features: #{features.inspect}). " \
82
+ "Rebuild with: cargo build --release --features #{missing.join(",")}"
83
+ end
84
+
85
+ report
86
+ end
87
+
88
+ # @return [Boolean] whether a binary is available without an override
89
+ def vendored?
90
+ File.file?(vendored_binary) && File.executable?(vendored_binary)
91
+ end
92
+
93
+ def vendored_binary(platform = gem_platform)
94
+ File.join(vendor_root, platform, "bin", EXECUTABLE)
95
+ end
96
+
97
+ def manifest_path(platform = gem_platform)
98
+ File.join(vendor_root, platform, "manifest.json")
99
+ end
100
+
101
+ # gem/vendor -- `__dir__` is gem/lib/orbitcast.
102
+ def vendor_root
103
+ File.expand_path("../../vendor", __dir__)
104
+ end
105
+
106
+ # Gem platform label used for the vendor directory.
107
+ #
108
+ # `Gem::Platform.local` carries an OS version we do not build against
109
+ # (`arm64-darwin-23`), so darwin is truncated to `<cpu>-darwin`. On Linux
110
+ # the version field is the libc: RubyGems treats a bare `*-linux` as a
111
+ # libc wildcard that can also match musl, so we resolve it explicitly to
112
+ # `gnu` here and only use `-musl` for an actual musl build.
113
+ def gem_platform(platform = Gem::Platform.local)
114
+ cpu = platform.cpu || "unknown"
115
+
116
+ case platform.os
117
+ when "linux"
118
+ libc = platform.version.to_s
119
+ libc = "gnu" if libc.empty? || libc == "linux"
120
+ "#{cpu}-linux-#{libc}"
121
+ when "darwin"
122
+ "#{cpu}-darwin"
123
+ else
124
+ [cpu, platform.os, platform.version].compact.reject(&:empty?).join("-")
125
+ end
126
+ end
127
+
128
+ private
129
+
130
+ # `doctor --json` in a minimal environment: it needs no configuration to
131
+ # report its version and build, and must not see application secrets.
132
+ def doctor_report(binary, timeout:, env:)
133
+ child_env = { "PATH" => env["PATH"], "HOME" => env["HOME"], "ORBITCAST_LOG" => "error" }.compact
134
+ stdout = stderr = ""
135
+ status = nil
136
+
137
+ Open3.popen3(child_env, [binary, binary], "doctor", "--json",
138
+ unsetenv_others: true, close_others: true) do |stdin, out, err, waiter|
139
+ stdin.close
140
+ out_reader = Thread.new { out.read }
141
+ err_reader = Thread.new { err.read }
142
+ # popen3 closes the pipes when this block unwinds; a reader still
143
+ # blocked in IO#read would then die with IOError and Ruby would print
144
+ # it. The readers are joined on every exit path below instead.
145
+ [out_reader, err_reader].each { |thread| thread.report_on_exception = false }
146
+
147
+ unless waiter.join(timeout)
148
+ begin
149
+ Process.kill("KILL", waiter.pid)
150
+ rescue SystemCallError
151
+ nil
152
+ end
153
+ waiter.join(1)
154
+ [out_reader, err_reader].each { |thread| thread.join(1) }
155
+ raise BinaryError,
156
+ "#{OVERRIDE_ENV}=#{binary}: `doctor --json` did not finish within #{timeout}s; " \
157
+ "refusing to use a binary that cannot describe itself."
158
+ end
159
+
160
+ status = waiter.value
161
+ stdout = out_reader.value.to_s
162
+ stderr = err_reader.value.to_s
163
+ end
164
+
165
+ parse_report(binary, stdout, stderr, status)
166
+ rescue SystemCallError => e
167
+ raise BinaryError, "#{OVERRIDE_ENV}=#{binary}: could not run `doctor --json` (#{e.message})"
168
+ end
169
+
170
+ def parse_report(binary, stdout, stderr, status)
171
+ report = JSON.parse(stdout)
172
+ raise JSON::ParserError, "not a JSON object" unless report.is_a?(Hash)
173
+
174
+ report
175
+ rescue JSON::ParserError => e
176
+ detail = stderr.strip.lines.first(3).join.strip
177
+ raise BinaryError,
178
+ "#{OVERRIDE_ENV}=#{binary}: `doctor --json` did not print a JSON report " \
179
+ "(#{e.message}; exit status #{status&.exitstatus.inspect}" \
180
+ "#{detail.empty? ? "" : "; stderr: #{detail}"}). Is this an orbitcast #{VERSION} build?"
181
+ end
182
+
183
+ def override_path(override)
184
+ expanded = File.expand_path(override)
185
+
186
+ unless File.file?(expanded)
187
+ raise BinaryNotFoundError,
188
+ "#{OVERRIDE_ENV}=#{override} does not point at a file " \
189
+ "(resolved to #{expanded}). Set it to an orbitcast #{VERSION} executable, " \
190
+ "or unset it to use the binary vendored in this gem."
191
+ end
192
+
193
+ unless File.executable?(expanded)
194
+ raise BinaryNotFoundError,
195
+ "#{OVERRIDE_ENV}=#{override} is not executable (resolved to #{expanded}). " \
196
+ "Run: chmod +x #{expanded}"
197
+ end
198
+
199
+ expanded
200
+ end
201
+
202
+ def vendored_path
203
+ candidate = vendored_binary
204
+
205
+ unless File.file?(candidate)
206
+ raise BinaryNotFoundError, missing_vendored_message(candidate)
207
+ end
208
+
209
+ unless File.executable?(candidate)
210
+ raise BinaryNotFoundError,
211
+ "vendored orbitcast binary at #{candidate} is not executable. " \
212
+ "Reinstall the gem, or run: chmod +x #{candidate}"
213
+ end
214
+
215
+ candidate
216
+ end
217
+
218
+ def missing_vendored_message(candidate)
219
+ <<~MESSAGE.strip
220
+ No orbitcast executable for platform #{gem_platform} (expected #{candidate}).
221
+
222
+ This gem installation has no binary for your platform. Either:
223
+ 1. install the platform gem for #{gem_platform}, e.g.
224
+ bundle lock --add-platform #{gem_platform}
225
+ 2. build orbitcast #{VERSION} from source and point at it:
226
+ cargo build --release --features postgres
227
+ export #{OVERRIDE_ENV}=/path/to/target/release/#{EXECUTABLE}
228
+
229
+ Orbitcast never downloads or compiles a binary for you.
230
+ MESSAGE
231
+ end
232
+ end
233
+ end
234
+ end