orbitcast 0.3.1-arm64-darwin

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +28 -0
  3. data/README.md +100 -0
  4. data/app/assets/javascripts/orbitcast/recovery.js +196 -0
  5. data/db/orbitcast_migrate/001_create_orbitcast_payloads.rb +39 -0
  6. data/db/orbitcast_migrate/002_create_orbitcast_presence.rb +54 -0
  7. data/db/orbitcast_migrate/003_create_orbitcast_broker.rb +102 -0
  8. data/exe/orbitcast +11 -0
  9. data/lib/action_cable/subscription_adapter/orbitcast.rb +102 -0
  10. data/lib/generators/orbitcast/install/install_generator.rb +223 -0
  11. data/lib/generators/orbitcast/install/templates/cable.yml.tt +48 -0
  12. data/lib/generators/orbitcast/install/templates/orbitcast.rb.tt +156 -0
  13. data/lib/orbitcast/binary.rb +234 -0
  14. data/lib/orbitcast/channel.rb +459 -0
  15. data/lib/orbitcast/channel_registry.rb +131 -0
  16. data/lib/orbitcast/configuration.rb +636 -0
  17. data/lib/orbitcast/connection.rb +338 -0
  18. data/lib/orbitcast/control.rb +72 -0
  19. data/lib/orbitcast/dispatcher.rb +306 -0
  20. data/lib/orbitcast/engine.rb +98 -0
  21. data/lib/orbitcast/error.rb +54 -0
  22. data/lib/orbitcast/launcher.rb +302 -0
  23. data/lib/orbitcast/protocol/v1/control_message.rb +134 -0
  24. data/lib/orbitcast/protocol/v1/effects.rb +172 -0
  25. data/lib/orbitcast/protocol/v1/reply.rb +268 -0
  26. data/lib/orbitcast/protocol/v1/request.rb +364 -0
  27. data/lib/orbitcast/protocol/v1.rb +291 -0
  28. data/lib/orbitcast/protocol.rb +19 -0
  29. data/lib/orbitcast/publisher/control.rb +67 -0
  30. data/lib/orbitcast/publisher/http.rb +180 -0
  31. data/lib/orbitcast/publisher/instrumentation.rb +47 -0
  32. data/lib/orbitcast/publisher/postgres.rb +407 -0
  33. data/lib/orbitcast/rails_endpoint.rb +207 -0
  34. data/lib/orbitcast/recovery.rb +166 -0
  35. data/lib/orbitcast/state_codec.rb +186 -0
  36. data/lib/orbitcast/streams.rb +129 -0
  37. data/lib/orbitcast/version.rb +10 -0
  38. data/lib/orbitcast.rb +68 -0
  39. data/vendor/arm64-darwin/bin/orbitcast +0 -0
  40. data/vendor/arm64-darwin/manifest.json +14 -0
  41. metadata +130 -0
@@ -0,0 +1,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