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