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,302 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "error"
|
|
4
|
+
require_relative "version"
|
|
5
|
+
require_relative "binary"
|
|
6
|
+
|
|
7
|
+
module Orbitcast
|
|
8
|
+
# `exe/orbitcast` implementation.
|
|
9
|
+
#
|
|
10
|
+
# The launcher is not a supervisor: it resolves the executable, optionally
|
|
11
|
+
# boots Rails *once* to export configuration, then replaces its own process
|
|
12
|
+
# image with `Process.exec`. argv, stdio, exit status, and signals therefore
|
|
13
|
+
# belong to the Rust process directly.
|
|
14
|
+
#
|
|
15
|
+
# This file must stay loadable without Rails. It deliberately does not
|
|
16
|
+
# `require "orbitcast"`: doing so before `Bundler.require` would mark the gem
|
|
17
|
+
# loaded and `Orbitcast::Engine` would never be registered with the
|
|
18
|
+
# application.
|
|
19
|
+
class Launcher
|
|
20
|
+
# Commands that must never boot Rails or touch PostgreSQL.
|
|
21
|
+
PASSTHROUGH_FLAGS = %w[-h --help -V --version].freeze
|
|
22
|
+
PASSTHROUGH_COMMANDS = %w[help version].freeze
|
|
23
|
+
|
|
24
|
+
# Flags of the Rust CLI (`src/main.rs`) that consume the next argv element
|
|
25
|
+
# as their value, so `orbitcast -l debug serve` is a `serve`, not a
|
|
26
|
+
# `debug`. The `--flag=value` spelling needs no entry.
|
|
27
|
+
VALUE_FLAGS = %w[-l --log-level].freeze
|
|
28
|
+
|
|
29
|
+
# A bare `orbitcast` is `orbitcast serve`, exactly as the Rust binary
|
|
30
|
+
# treats it.
|
|
31
|
+
DEFAULT_COMMAND = "serve"
|
|
32
|
+
|
|
33
|
+
# Non-secret process environment forwarded to the child in *both* modes.
|
|
34
|
+
# Anything not listed here, in EXPORTED_KEYS, or (direct mode only) an
|
|
35
|
+
# `ORBITCAST_*` key of the parent is dropped (`unsetenv_others: true`).
|
|
36
|
+
#
|
|
37
|
+
# The `ORBITCAST_*` entries here are read by the Rust relay but never
|
|
38
|
+
# produced by the Rails export: they only ever arrive from the parent
|
|
39
|
+
# environment (operator/Procfile), so they are inherited, not exported.
|
|
40
|
+
INHERITED_KEYS = %w[
|
|
41
|
+
PATH HOME LANG LC_ALL LC_CTYPE TZ TMPDIR TERM NO_COLOR
|
|
42
|
+
SSL_CERT_FILE SSL_CERT_DIR
|
|
43
|
+
RUST_LOG RUST_BACKTRACE
|
|
44
|
+
ORBITCAST_CONFIG
|
|
45
|
+
ORBITCAST_LOG
|
|
46
|
+
ORBITCAST_MAX_MESSAGE_SIZE
|
|
47
|
+
ORBITCAST_MAX_CONNECTIONS
|
|
48
|
+
ORBITCAST_SHUTDOWN_TIMEOUT_MS
|
|
49
|
+
ORBITCAST_PUBLIC_STREAMS
|
|
50
|
+
ORBITCAST_RATE_SUBSCRIBES_PER_CONN_PER_SEC
|
|
51
|
+
].freeze
|
|
52
|
+
|
|
53
|
+
# `ORBITCAST_*` keys the Rails export may produce; every key here is read
|
|
54
|
+
# by the Rust relay (`src/config.rs`). A value already present in the
|
|
55
|
+
# parent environment wins over the Rails-exported value: precedence is
|
|
56
|
+
# CLI > environment > Rails/TOML configuration > defaults.
|
|
57
|
+
EXPORTED_KEYS = %w[
|
|
58
|
+
ORBITCAST_NAMESPACE
|
|
59
|
+
ORBITCAST_DATABASE_URL
|
|
60
|
+
ORBITCAST_LISTENER_DATABASE_URL
|
|
61
|
+
ORBITCAST_LISTEN_ADDR
|
|
62
|
+
ORBITCAST_ALLOWED_ORIGINS
|
|
63
|
+
ORBITCAST_TRUSTED_PROXIES
|
|
64
|
+
ORBITCAST_OUTBOUND_QUEUE_MESSAGES
|
|
65
|
+
ORBITCAST_OUTBOUND_QUEUE_BYTES
|
|
66
|
+
ORBITCAST_OVERFLOW_ENABLED
|
|
67
|
+
ORBITCAST_OVERFLOW_TTL_MS
|
|
68
|
+
ORBITCAST_MAX_PUBLICATION_SIZE
|
|
69
|
+
ORBITCAST_TURBO_STREAMS
|
|
70
|
+
ORBITCAST_STREAMS_SECRET_BASE64
|
|
71
|
+
ORBITCAST_STREAMS_PREVIOUS_SECRETS_BASE64
|
|
72
|
+
ORBITCAST_TURBO_SECRET_BASE64
|
|
73
|
+
ORBITCAST_TURBO_PREVIOUS_SECRETS_BASE64
|
|
74
|
+
ORBITCAST_RECOVERY_SECRET_BASE64
|
|
75
|
+
ORBITCAST_RPC_ENDPOINT_URL
|
|
76
|
+
ORBITCAST_RPC_TOKEN_BASE64
|
|
77
|
+
].freeze
|
|
78
|
+
|
|
79
|
+
# Application secrets that must never cross into the relay process, even if
|
|
80
|
+
# someone adds them to an allowlist by accident.
|
|
81
|
+
FORBIDDEN_KEYS = %w[RAILS_MASTER_KEY SECRET_KEY_BASE].freeze
|
|
82
|
+
|
|
83
|
+
# Environments that get a loopback listener for free. Anything else must
|
|
84
|
+
# configure one: the relay exits with NoTransport otherwise.
|
|
85
|
+
DEFAULT_LISTENER_ENVIRONMENTS = %w[development test].freeze
|
|
86
|
+
|
|
87
|
+
def self.call(argv, env: ENV, cwd: Dir.pwd)
|
|
88
|
+
new(argv, env: env, cwd: cwd).run
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# @param application [#config_for, #config] the booted Rails application;
|
|
92
|
+
# tests inject a stand-in, production resolves `::Rails.application`
|
|
93
|
+
# after the boot
|
|
94
|
+
# @param rails_env [String] the Rails environment name; same rule
|
|
95
|
+
def initialize(argv, env: ENV, cwd: Dir.pwd, application: nil, rails_env: nil)
|
|
96
|
+
@argv = argv.dup.freeze
|
|
97
|
+
@env = env
|
|
98
|
+
@cwd = cwd
|
|
99
|
+
@application = application
|
|
100
|
+
@rails_env = rails_env
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
attr_reader :argv, :env, :cwd
|
|
104
|
+
|
|
105
|
+
def run
|
|
106
|
+
return exec_direct(verify: false) if passthrough?
|
|
107
|
+
return exec_serve if command == DEFAULT_COMMAND && rails_application?
|
|
108
|
+
|
|
109
|
+
# Direct invocation: TOML/environment configured, no Rails boot.
|
|
110
|
+
exec_direct
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# The child environment for `serve`. Pure function of the exported values
|
|
114
|
+
# and the parent environment, so it is testable without Rails.
|
|
115
|
+
def child_env(exported = {})
|
|
116
|
+
exported.each_key do |key|
|
|
117
|
+
next if EXPORTED_KEYS.include?(key.to_s)
|
|
118
|
+
|
|
119
|
+
raise ConfigurationError,
|
|
120
|
+
"refusing to export #{key} to the orbitcast process; " \
|
|
121
|
+
"allowed keys: #{EXPORTED_KEYS.join(", ")}"
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
child = {}
|
|
125
|
+
INHERITED_KEYS.each { |key| child[key] = env[key] if env[key] }
|
|
126
|
+
exported.each { |key, value| child[key.to_s] = value.to_s unless value.nil? }
|
|
127
|
+
# Parent ORBITCAST_* overrides win over exported Rails configuration.
|
|
128
|
+
EXPORTED_KEYS.each { |key| child[key] = env[key] if env[key] && !env[key].empty? }
|
|
129
|
+
|
|
130
|
+
strip_forbidden(child)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# The child environment for a direct (non-Rails) invocation: the inherited
|
|
134
|
+
# allowlist plus every `ORBITCAST_*` key the operator set. The parent's
|
|
135
|
+
# other variables — application database URLs, cloud credentials, the
|
|
136
|
+
# Rails master key — never reach the relay.
|
|
137
|
+
def direct_env
|
|
138
|
+
child = {}
|
|
139
|
+
INHERITED_KEYS.each { |key| child[key] = env[key] if env[key] }
|
|
140
|
+
env.each_pair do |key, value|
|
|
141
|
+
child[key] = value if key.to_s.start_with?("ORBITCAST_")
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
strip_forbidden(child)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# The positional command, ignoring flags and the values they consume.
|
|
148
|
+
# `nil` never: a bare invocation is `serve`.
|
|
149
|
+
def command
|
|
150
|
+
args = argv.dup
|
|
151
|
+
|
|
152
|
+
until args.empty?
|
|
153
|
+
arg = args.shift
|
|
154
|
+
return args.first || DEFAULT_COMMAND if arg == "--"
|
|
155
|
+
next if arg.start_with?("--") && arg.include?("=")
|
|
156
|
+
|
|
157
|
+
if VALUE_FLAGS.include?(arg)
|
|
158
|
+
args.shift
|
|
159
|
+
next
|
|
160
|
+
end
|
|
161
|
+
next if arg.start_with?("-")
|
|
162
|
+
|
|
163
|
+
return arg
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
DEFAULT_COMMAND
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def passthrough?
|
|
170
|
+
argv.any? { |arg| PASSTHROUGH_FLAGS.include?(arg) } ||
|
|
171
|
+
PASSTHROUGH_COMMANDS.include?(command)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# Binary resolution. An `ORBITCAST_BINARY` override is checked with
|
|
175
|
+
# `doctor --json` unless this is a `--help`/`--version` passthrough.
|
|
176
|
+
def binary_path(verify: true, require_features: [])
|
|
177
|
+
Binary.path(env: env, verify: verify, require_features: require_features)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# The relay needs a listener. Development and test get loopback; anything
|
|
181
|
+
# else must say where to bind, in Rails configuration or in the relay's
|
|
182
|
+
# own environment.
|
|
183
|
+
def resolve_listen_addr!(config, rails_env)
|
|
184
|
+
return config if config.listen_addr
|
|
185
|
+
return config if env["ORBITCAST_LISTEN_ADDR"] && !env["ORBITCAST_LISTEN_ADDR"].empty?
|
|
186
|
+
|
|
187
|
+
if DEFAULT_LISTENER_ENVIRONMENTS.include?(rails_env.to_s)
|
|
188
|
+
config.listen_addr = Configuration::DEFAULT_DEVELOPMENT_LISTEN_ADDR
|
|
189
|
+
return config
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
raise ConfigurationError,
|
|
193
|
+
"listen_addr is not configured for the #{rails_env} environment and " \
|
|
194
|
+
"ORBITCAST_LISTEN_ADDR is not set: the relay would start with no transport. Set " \
|
|
195
|
+
"config.orbitcast.listen_addr (e.g. \"0.0.0.0:28080\") or export ORBITCAST_LISTEN_ADDR."
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# `config/cable.yml` for the current environment, minus ActionCable's own
|
|
199
|
+
# `adapter`/`id` keys. A missing file is fine (the initializer may have
|
|
200
|
+
# configured everything); an unreadable or malformed one is not — a typo
|
|
201
|
+
# in the file must not silently become an empty configuration.
|
|
202
|
+
def cable_configuration(application = self.application)
|
|
203
|
+
return {} unless File.file?(File.join(cwd, "config", "cable.yml"))
|
|
204
|
+
|
|
205
|
+
settings =
|
|
206
|
+
begin
|
|
207
|
+
application.config_for(:cable)
|
|
208
|
+
rescue StandardError => e
|
|
209
|
+
raise ConfigurationError,
|
|
210
|
+
"config/cable.yml could not be loaded (#{e.class}: #{e.message}); fix the file " \
|
|
211
|
+
"rather than starting a relay with an empty configuration"
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
settings = settings.to_h.transform_keys(&:to_sym)
|
|
215
|
+
adapter = settings[:adapter]&.to_s
|
|
216
|
+
# Another adapter's keys are not Orbitcast settings; leave them alone.
|
|
217
|
+
return {} if adapter && adapter != "orbitcast"
|
|
218
|
+
|
|
219
|
+
settings.except(:adapter, :id)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# `config.action_cable.allowed_request_origins`, for the relay's own
|
|
223
|
+
# origin check. The relay matches literal origins only.
|
|
224
|
+
def rails_cable_env(application = self.application)
|
|
225
|
+
origins = Array(application.config.action_cable.allowed_request_origins)
|
|
226
|
+
|
|
227
|
+
origins.each do |origin|
|
|
228
|
+
next unless origin.is_a?(Regexp)
|
|
229
|
+
|
|
230
|
+
raise ConfigurationError,
|
|
231
|
+
"config.action_cable.allowed_request_origins contains a Regexp (#{origin.inspect}); " \
|
|
232
|
+
"the Orbitcast relay matches literal origins only. List each allowed origin as a " \
|
|
233
|
+
"string, e.g. [\"https://app.example\"]."
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
strings = origins.map(&:to_s).reject(&:empty?)
|
|
237
|
+
exported = {}
|
|
238
|
+
exported["ORBITCAST_ALLOWED_ORIGINS"] = strings.join(",") unless strings.empty?
|
|
239
|
+
exported
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Everything `serve` exports, given a booted application. Separated from
|
|
243
|
+
# the boot so it is testable with a stand-in application.
|
|
244
|
+
def exported_configuration(application = self.application, rails_env = self.rails_env)
|
|
245
|
+
# `orbitcast` is fully loaded by Bundler at this point, so referencing the
|
|
246
|
+
# constant here (not at the top of the file) is intentional.
|
|
247
|
+
config = ::Orbitcast.config
|
|
248
|
+
config.assign(cable_configuration(application))
|
|
249
|
+
resolve_listen_addr!(config, rails_env)
|
|
250
|
+
config.validate!
|
|
251
|
+
config.exported_env.merge(rails_cable_env(application))
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
private
|
|
255
|
+
|
|
256
|
+
def application
|
|
257
|
+
@application || ::Rails.application
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def rails_env
|
|
261
|
+
@rails_env || ::Rails.env.to_s
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def environment_file
|
|
265
|
+
File.join(cwd, "config", "environment.rb")
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def rails_application?
|
|
269
|
+
File.file?(environment_file)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# Boot Rails once, in this process, purely to read configuration.
|
|
273
|
+
def exec_serve
|
|
274
|
+
require environment_file
|
|
275
|
+
|
|
276
|
+
exported = exported_configuration
|
|
277
|
+
features = ::Orbitcast.config.publish_via == :postgres ? ["postgres"] : []
|
|
278
|
+
|
|
279
|
+
exec_process(child_env(exported), *argv, require_features: features)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Direct mode is configured through ORBITCAST_* / TOML by whoever started
|
|
283
|
+
# us; the allowlist still applies.
|
|
284
|
+
def exec_direct(verify: true)
|
|
285
|
+
exec_process(direct_env, *argv, verify: verify)
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
def strip_forbidden(child)
|
|
289
|
+
FORBIDDEN_KEYS.each { |key| child.delete(key) }
|
|
290
|
+
child
|
|
291
|
+
end
|
|
292
|
+
|
|
293
|
+
def exec_process(child, *args, verify: true, require_features: [])
|
|
294
|
+
binary = binary_path(verify: verify, require_features: require_features)
|
|
295
|
+
# [path, argv0] form: never shell-interpreted, even with no arguments and
|
|
296
|
+
# a path containing shell metacharacters.
|
|
297
|
+
command = [binary, binary]
|
|
298
|
+
|
|
299
|
+
Process.exec(child, command, *args, close_others: true, unsetenv_others: true)
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
require_relative "../v1"
|
|
6
|
+
|
|
7
|
+
module Orbitcast
|
|
8
|
+
module Protocol
|
|
9
|
+
module V1
|
|
10
|
+
# One Rails-initiated control message.
|
|
11
|
+
#
|
|
12
|
+
# This is how Rails reaches sessions it does not hold: a multi-device
|
|
13
|
+
# sign-out publishes one disconnect message, every node looks the identity
|
|
14
|
+
# key up in its local index, and only the matching sessions are closed. No
|
|
15
|
+
# node scans every session, and no control data is ever visible to a
|
|
16
|
+
# browser.
|
|
17
|
+
#
|
|
18
|
+
# `identity_key` is the value the application put under the
|
|
19
|
+
# `"identity_key"` key of the accepted `identify` identity map. A
|
|
20
|
+
# connection that returned no `identity_key` is anonymous as far as
|
|
21
|
+
# control is concerned: it is a valid session, it is simply not
|
|
22
|
+
# addressable.
|
|
23
|
+
class ControlMessage
|
|
24
|
+
def self.disconnect(namespace:, identity_key:, reason: CLOSE_REASON_REMOTE,
|
|
25
|
+
reconnect: false, control_id: nil)
|
|
26
|
+
new(
|
|
27
|
+
kind: CONTROL_KIND_DISCONNECT,
|
|
28
|
+
namespace: namespace,
|
|
29
|
+
control_id: control_id || SecureRandom.uuid,
|
|
30
|
+
identity_key: identity_key,
|
|
31
|
+
reason: reason,
|
|
32
|
+
reconnect: reconnect
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Tolerant decode, for tests and for a Ruby-side ingress consumer. An
|
|
37
|
+
# unknown `kind` is a hard error: a node that cannot understand a
|
|
38
|
+
# control message must not apply a guessed-at fraction of it.
|
|
39
|
+
def self.from(hash)
|
|
40
|
+
unless hash.is_a?(Hash)
|
|
41
|
+
raise ProtocolError.new("control message must be a JSON object", field: "body")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
kind = hash["kind"]
|
|
45
|
+
unless CONTROL_KINDS.include?(kind)
|
|
46
|
+
raise ProtocolError.new("unknown control kind #{kind.inspect}", field: "kind")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
new(
|
|
50
|
+
version: V1.version_of(hash),
|
|
51
|
+
kind: kind,
|
|
52
|
+
namespace: hash["namespace"].is_a?(String) ? hash["namespace"] : "",
|
|
53
|
+
control_id: hash["control_id"].is_a?(String) ? hash["control_id"] : "",
|
|
54
|
+
identity_key: hash["identity_key"].is_a?(String) ? hash["identity_key"] : "",
|
|
55
|
+
reason: hash["reason"].is_a?(String) ? hash["reason"] : "",
|
|
56
|
+
# An absent reconnect policy defaults to false: a control message
|
|
57
|
+
# that fails to say otherwise ends the session rather than inviting
|
|
58
|
+
# an immediate reconnect with credentials just revoked.
|
|
59
|
+
reconnect: reconnect_value(hash)
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.parse(text)
|
|
64
|
+
from(JSON.parse(text))
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def self.reconnect_value(hash)
|
|
68
|
+
return false unless hash.key?("reconnect")
|
|
69
|
+
|
|
70
|
+
value = hash["reconnect"]
|
|
71
|
+
return value if value == true || value == false
|
|
72
|
+
|
|
73
|
+
raise ProtocolError.new("reconnect must be true or false", field: "reconnect")
|
|
74
|
+
end
|
|
75
|
+
private_class_method :reconnect_value
|
|
76
|
+
|
|
77
|
+
def initialize(kind:, namespace:, control_id:, identity_key:, reason:, reconnect:,
|
|
78
|
+
version: VERSION)
|
|
79
|
+
@version = version
|
|
80
|
+
@kind = kind
|
|
81
|
+
@namespace = namespace
|
|
82
|
+
@control_id = control_id
|
|
83
|
+
@identity_key = identity_key
|
|
84
|
+
@reason = reason
|
|
85
|
+
@reconnect = reconnect
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
attr_reader :version, :kind, :namespace, :control_id, :identity_key, :reason
|
|
89
|
+
|
|
90
|
+
def reconnect?
|
|
91
|
+
@reconnect
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def validate!
|
|
95
|
+
V1.validate_version!(version)
|
|
96
|
+
V1.validate_namespace!(namespace)
|
|
97
|
+
V1.validate_uuid!("control_id", control_id)
|
|
98
|
+
V1.validate_bounded!("identity_key", identity_key, MAX_IDENTITY_KEY_LEN)
|
|
99
|
+
V1.validate_close_reason!(reason)
|
|
100
|
+
unless @reconnect == true || @reconnect == false
|
|
101
|
+
raise ProtocolError.new("reconnect must be true or false", field: "reconnect")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
self
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Validation runs first: a message is never applied because its
|
|
108
|
+
# namespace happens to match.
|
|
109
|
+
def applies_to?(other_namespace)
|
|
110
|
+
validate!
|
|
111
|
+
namespace == other_namespace
|
|
112
|
+
rescue ProtocolError
|
|
113
|
+
false
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def to_h
|
|
117
|
+
{
|
|
118
|
+
"version" => version,
|
|
119
|
+
"kind" => kind,
|
|
120
|
+
"namespace" => namespace,
|
|
121
|
+
"control_id" => control_id,
|
|
122
|
+
"identity_key" => identity_key,
|
|
123
|
+
"reason" => reason,
|
|
124
|
+
"reconnect" => @reconnect
|
|
125
|
+
}
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def to_json(*args)
|
|
129
|
+
JSON.generate(to_h, *args)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../v1"
|
|
4
|
+
|
|
5
|
+
module Orbitcast
|
|
6
|
+
module Protocol
|
|
7
|
+
module V1
|
|
8
|
+
# The ordered, request-scoped list of typed effects one operation
|
|
9
|
+
# produced.
|
|
10
|
+
#
|
|
11
|
+
# Rails never returns raw WebSocket frames: `welcome`,
|
|
12
|
+
# `confirm_subscription`, `reject_subscription`, and `disconnect` framing
|
|
13
|
+
# belong to Rust. What the application *can* ask for is this fixed set,
|
|
14
|
+
# and the relay applies it in the order it was collected — which is the
|
|
15
|
+
# order the application's callbacks ran in.
|
|
16
|
+
#
|
|
17
|
+
# ## Suppression
|
|
18
|
+
#
|
|
19
|
+
# `close_channel` and `release_connection` run after the relay has already
|
|
20
|
+
# tombstoned the subscription (a client leaving cannot be vetoed). A
|
|
21
|
+
# collector built with `suppressed: true` therefore accepts every call and
|
|
22
|
+
# records nothing: `stop_all_streams`, which ActionCable itself registers
|
|
23
|
+
# as an `on_unsubscribe` callback, must not produce effects addressing a
|
|
24
|
+
# subscription that no longer exists.
|
|
25
|
+
class EffectCollector
|
|
26
|
+
def initialize(suppressed: false)
|
|
27
|
+
@effects = []
|
|
28
|
+
@suppressed = suppressed
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
attr_reader :effects
|
|
32
|
+
|
|
33
|
+
def suppressed?
|
|
34
|
+
@suppressed
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def add_stream(identifier, stream)
|
|
38
|
+
push(
|
|
39
|
+
"kind" => EFFECT_ADD_STREAM,
|
|
40
|
+
"identifier" => V1.validate_identifier!(identifier),
|
|
41
|
+
"stream" => V1.validate_stream!(stream)
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def remove_stream(identifier, stream)
|
|
46
|
+
push(
|
|
47
|
+
"kind" => EFFECT_REMOVE_STREAM,
|
|
48
|
+
"identifier" => V1.validate_identifier!(identifier),
|
|
49
|
+
"stream" => V1.validate_stream!(stream)
|
|
50
|
+
)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def remove_all_streams(identifier)
|
|
54
|
+
push(
|
|
55
|
+
"kind" => EFFECT_REMOVE_ALL_STREAMS,
|
|
56
|
+
"identifier" => V1.validate_identifier!(identifier)
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# @param json_payload [String] the exact Rails-encoded JSON text; it is
|
|
61
|
+
# embedded verbatim and never re-encoded on the way to the browser.
|
|
62
|
+
def transmit(identifier, json_payload)
|
|
63
|
+
V1.validate_identifier!(identifier)
|
|
64
|
+
if json_payload.to_s.bytesize > MAX_TRANSMIT_LEN
|
|
65
|
+
raise ProtocolError.new(
|
|
66
|
+
"json_payload is #{json_payload.to_s.bytesize} bytes; the maximum is " \
|
|
67
|
+
"#{MAX_TRANSMIT_LEN}", field: "json_payload"
|
|
68
|
+
)
|
|
69
|
+
end
|
|
70
|
+
V1.validate_json_text!("json_payload", json_payload)
|
|
71
|
+
|
|
72
|
+
push(
|
|
73
|
+
"kind" => EFFECT_TRANSMIT,
|
|
74
|
+
"identifier" => identifier,
|
|
75
|
+
"json_payload" => json_payload
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def close(reason:, reconnect: false)
|
|
80
|
+
push(
|
|
81
|
+
"kind" => EFFECT_CLOSE,
|
|
82
|
+
"reason" => V1.validate_close_reason!(reason),
|
|
83
|
+
"reconnect" => boolean!(reconnect)
|
|
84
|
+
)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Grants an authorized presence membership.
|
|
88
|
+
def presence_grant(identifier, stream:, subject_id:, ttl_ms:, metadata: {}, operations: [])
|
|
89
|
+
V1.validate_identifier!(identifier)
|
|
90
|
+
V1.validate_stream!(stream)
|
|
91
|
+
V1.validate_bounded!("subject_id", subject_id, MAX_SUBJECT_ID_LEN)
|
|
92
|
+
|
|
93
|
+
ttl = Integer(ttl_ms)
|
|
94
|
+
unless ttl.positive?
|
|
95
|
+
raise ProtocolError.new("ttl_ms must be greater than zero", field: "ttl_ms")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
unless metadata.is_a?(Hash)
|
|
99
|
+
raise ProtocolError.new("metadata must be an object", field: "metadata")
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
if JSON.generate(metadata).bytesize > MAX_PRESENCE_METADATA_LEN
|
|
103
|
+
raise ProtocolError.new(
|
|
104
|
+
"metadata exceeds #{MAX_PRESENCE_METADATA_LEN} bytes", field: "metadata"
|
|
105
|
+
)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
operations = Array(operations).map(&:to_s)
|
|
109
|
+
if operations.size > MAX_PRESENCE_OPERATIONS
|
|
110
|
+
raise ProtocolError.new(
|
|
111
|
+
"operations has #{operations.size} entries; the maximum is " \
|
|
112
|
+
"#{MAX_PRESENCE_OPERATIONS}", field: "operations"
|
|
113
|
+
)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
push(
|
|
117
|
+
"kind" => EFFECT_PRESENCE_GRANT,
|
|
118
|
+
"identifier" => identifier,
|
|
119
|
+
"stream" => stream,
|
|
120
|
+
"subject_id" => subject_id,
|
|
121
|
+
"metadata" => metadata,
|
|
122
|
+
"ttl_ms" => ttl,
|
|
123
|
+
"operations" => operations
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def presence_revoke(identifier, stream:, subject_id:)
|
|
128
|
+
push(
|
|
129
|
+
"kind" => EFFECT_PRESENCE_REVOKE,
|
|
130
|
+
"identifier" => V1.validate_identifier!(identifier),
|
|
131
|
+
"stream" => V1.validate_stream!(stream),
|
|
132
|
+
"subject_id" => V1.validate_bounded!("subject_id", subject_id, MAX_SUBJECT_ID_LEN)
|
|
133
|
+
)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def empty?
|
|
137
|
+
@effects.empty?
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def size
|
|
141
|
+
@effects.size
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# The ordered effects, as plain JSON-ready hashes.
|
|
145
|
+
def to_a
|
|
146
|
+
@effects.dup
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
def push(effect)
|
|
152
|
+
return nil if @suppressed
|
|
153
|
+
|
|
154
|
+
if @effects.size >= MAX_EFFECTS
|
|
155
|
+
raise ProtocolError.new(
|
|
156
|
+
"one reply may carry at most #{MAX_EFFECTS} effects", field: "effects"
|
|
157
|
+
)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
@effects << effect
|
|
161
|
+
effect
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def boolean!(value)
|
|
165
|
+
return value if value == true || value == false
|
|
166
|
+
|
|
167
|
+
raise ProtocolError.new("reconnect must be true or false", field: "reconnect")
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|