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,268 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../v1"
4
+
5
+ module Orbitcast
6
+ module Protocol
7
+ module V1
8
+ # Rails' answer to one request, built fail-closed.
9
+ #
10
+ # The builder is the only place a reply is constructed, because the rules
11
+ # below are the ones that keep a half-finished Rails request from widening
12
+ # access:
13
+ #
14
+ # * `denied` and `fault` carry no effects, no state replacement, and no
15
+ # identity. They are a decision (or a failure), never a state change.
16
+ # Anything the callbacks collected before the refusal is dropped here,
17
+ # not filtered by the caller.
18
+ # * An unchanged state is *omitted*. Absent and `null` both mean
19
+ # unchanged, `{}` means clear — so a reply that emitted `{}` for "I
20
+ # did not touch it" would silently wipe the session's declared state.
21
+ # * `identity` belongs to `identify` only, and an empty map there is a
22
+ # valid accepted anonymous connection.
23
+ # * `new_revision` never moves backwards: accepted stateful operations
24
+ # advance it by one, everything else echoes `expected_revision`.
25
+ #
26
+ # `validate!` runs the same limit checks Rust runs on receipt, so an
27
+ # envelope the relay would reject never leaves this process.
28
+ class Reply
29
+ UNCHANGED = :unchanged
30
+
31
+ class << self
32
+ def accepted(request, effects: [], state: UNCHANGED, subscription_state: UNCHANGED,
33
+ identity: nil, describe: nil)
34
+ new(
35
+ request: request,
36
+ outcome: OUTCOME_ACCEPTED,
37
+ effects: effects,
38
+ state: state,
39
+ subscription_state: subscription_state,
40
+ identity: identity,
41
+ describe: describe
42
+ )
43
+ end
44
+
45
+ def denied(request, reason)
46
+ new(request: request, outcome: OUTCOME_DENIED, reason: reason)
47
+ end
48
+
49
+ def fault(request, reason)
50
+ new(request: request, outcome: OUTCOME_FAULT, reason: reason)
51
+ end
52
+
53
+ # The capability handshake. Not stateful: the revision is echoed.
54
+ def describe(request, supported_versions: [VERSION], software_version: nil,
55
+ capabilities: [], ready: true)
56
+ accepted(
57
+ request,
58
+ describe: {
59
+ "supported_versions" => supported_versions,
60
+ "software_version" => software_version.to_s,
61
+ "capabilities" => capabilities,
62
+ "ready" => ready
63
+ }
64
+ )
65
+ end
66
+ end
67
+
68
+ def initialize(request:, outcome:, effects: [], state: UNCHANGED,
69
+ subscription_state: UNCHANGED, identity: nil, reason: nil, describe: nil)
70
+ unless OUTCOMES.include?(outcome)
71
+ raise ProtocolError.new("unknown outcome #{outcome.inspect}", field: "outcome")
72
+ end
73
+
74
+ @operation = request.operation
75
+ @request_id = request.request_id
76
+ @sequence = request.sequence
77
+ @expected_revision = request.expected_revision
78
+ @outcome = outcome
79
+ @reason = reason.nil? ? nil : truncate_reason(reason.to_s)
80
+ @describe = describe
81
+
82
+ if accepted?
83
+ @effects = effects.respond_to?(:to_a) ? effects.to_a : Array(effects)
84
+ @state = state
85
+ @subscription_state = subscription_state
86
+ @identity = identity
87
+ else
88
+ # Fail closed: a refusal or a failure changes nothing, whatever the
89
+ # callbacks managed to collect before it.
90
+ @effects = [].freeze
91
+ @state = UNCHANGED
92
+ @subscription_state = UNCHANGED
93
+ @identity = nil
94
+ end
95
+ end
96
+
97
+ attr_reader :operation, :outcome, :request_id, :sequence, :effects, :reason, :describe
98
+
99
+ def accepted?
100
+ @outcome == OUTCOME_ACCEPTED
101
+ end
102
+
103
+ def denied?
104
+ @outcome == OUTCOME_DENIED
105
+ end
106
+
107
+ def fault?
108
+ @outcome == OUTCOME_FAULT
109
+ end
110
+
111
+ # Accepted stateful operations advance the revision; `describe` and any
112
+ # non-acceptance echo what the relay believed.
113
+ def new_revision
114
+ return @expected_revision unless accepted? && V1.stateful?(operation)
115
+
116
+ @expected_revision + 1
117
+ end
118
+
119
+ def state
120
+ @state == UNCHANGED ? nil : @state
121
+ end
122
+
123
+ def subscription_state
124
+ @subscription_state == UNCHANGED ? nil : @subscription_state
125
+ end
126
+
127
+ def identity
128
+ @identity
129
+ end
130
+
131
+ def to_h
132
+ validate!
133
+
134
+ hash = {
135
+ "version" => VERSION,
136
+ "operation" => operation,
137
+ "outcome" => outcome,
138
+ "request_id" => request_id,
139
+ "sequence" => sequence,
140
+ "new_revision" => new_revision
141
+ }
142
+
143
+ # Absent means unchanged. Never `null`, never `{}` for "untouched".
144
+ hash["state"] = @state unless @state == UNCHANGED
145
+ hash["subscription_state"] = @subscription_state unless @subscription_state == UNCHANGED
146
+ hash["identity"] = @identity || {} if emits_identity?
147
+ hash["effects"] = @effects
148
+ hash["reason"] = @reason unless @reason.nil?
149
+ hash["describe"] = @describe unless @describe.nil?
150
+ hash
151
+ end
152
+
153
+ def to_json(*args)
154
+ JSON.generate(to_h, *args)
155
+ end
156
+
157
+ # The same checks Rust runs on receipt.
158
+ def validate!
159
+ V1.validate_uuid!("request_id", request_id)
160
+
161
+ unless @reason.nil?
162
+ V1.validate_bounded!("reason", @reason, MAX_REASON_LEN)
163
+ end
164
+
165
+ unless accepted?
166
+ raise_scope("effects") unless @effects.empty?
167
+ raise_scope("state") unless @state == UNCHANGED
168
+ raise_scope("subscription_state") unless @subscription_state == UNCHANGED
169
+ raise_scope("identity") unless @identity.nil? || @identity.empty?
170
+ end
171
+
172
+ if @effects.size > MAX_EFFECTS
173
+ raise ProtocolError.new(
174
+ "one reply may carry at most #{MAX_EFFECTS} effects", field: "effects"
175
+ )
176
+ end
177
+ @effects.each { |effect| validate_effect!(effect) }
178
+
179
+ V1.validate_state_map!("identity", @identity) unless @identity.nil?
180
+ V1.validate_state_map!("state", @state) unless @state == UNCHANGED
181
+ unless @subscription_state == UNCHANGED
182
+ V1.validate_state_map!("subscription_state", @subscription_state)
183
+ end
184
+
185
+ validate_describe! if operation == OPERATION_DESCRIBE && accepted?
186
+ self
187
+ end
188
+
189
+ private
190
+
191
+ def emits_identity?
192
+ accepted? && operation == OPERATION_IDENTIFY
193
+ end
194
+
195
+ def raise_scope(field)
196
+ raise ProtocolError.new("#{field} is not allowed on a #{outcome} reply", field: field)
197
+ end
198
+
199
+ def validate_effect!(effect)
200
+ kind = effect["kind"]
201
+ unless EFFECT_KINDS.include?(kind)
202
+ raise ProtocolError.new("unknown effect kind #{kind.inspect}", field: "kind")
203
+ end
204
+
205
+ case kind
206
+ when EFFECT_ADD_STREAM, EFFECT_REMOVE_STREAM
207
+ V1.validate_identifier!(effect["identifier"])
208
+ V1.validate_stream!(effect["stream"])
209
+ when EFFECT_REMOVE_ALL_STREAMS
210
+ V1.validate_identifier!(effect["identifier"])
211
+ when EFFECT_TRANSMIT
212
+ V1.validate_identifier!(effect["identifier"])
213
+ V1.validate_json_text!("json_payload", effect["json_payload"])
214
+ when EFFECT_CLOSE
215
+ V1.validate_close_reason!(effect["reason"])
216
+ when EFFECT_PRESENCE_GRANT
217
+ V1.validate_identifier!(effect["identifier"])
218
+ V1.validate_stream!(effect["stream"])
219
+ V1.validate_bounded!("subject_id", effect["subject_id"], MAX_SUBJECT_ID_LEN)
220
+ unless effect["ttl_ms"].is_a?(Integer) && effect["ttl_ms"].positive?
221
+ raise ProtocolError.new("ttl_ms must be greater than zero", field: "ttl_ms")
222
+ end
223
+ when EFFECT_PRESENCE_REVOKE
224
+ V1.validate_identifier!(effect["identifier"])
225
+ V1.validate_stream!(effect["stream"])
226
+ V1.validate_bounded!("subject_id", effect["subject_id"], MAX_SUBJECT_ID_LEN)
227
+ end
228
+ end
229
+
230
+ def validate_describe!
231
+ unless @describe.is_a?(Hash)
232
+ raise ProtocolError.new("an accepted describe requires a describe payload",
233
+ field: "describe")
234
+ end
235
+
236
+ versions = @describe["supported_versions"]
237
+ unless versions.is_a?(Array) && versions.include?(VERSION)
238
+ raise ProtocolError.new("describe must advertise version #{VERSION}",
239
+ field: "supported_versions")
240
+ end
241
+
242
+ capabilities = @describe["capabilities"] || []
243
+ if capabilities.size > MAX_CAPABILITIES
244
+ raise ProtocolError.new(
245
+ "describe may advertise at most #{MAX_CAPABILITIES} capabilities",
246
+ field: "capabilities"
247
+ )
248
+ end
249
+ capabilities.each { |capability| V1.validate_bounded!("capability", capability, MAX_LABEL_LEN) }
250
+
251
+ software_version = @describe["software_version"].to_s
252
+ unless software_version.empty?
253
+ V1.validate_bounded!("software_version", software_version, MAX_LABEL_LEN)
254
+ end
255
+ end
256
+
257
+ # The reason is a diagnostic for relay logs; it is never shown to a
258
+ # browser, and it is bounded so a stack-trace-sized string cannot make
259
+ # the reply unparseable.
260
+ def truncate_reason(reason)
261
+ return reason if reason.bytesize <= MAX_REASON_LEN
262
+
263
+ reason.byteslice(0, MAX_REASON_LEN).scrub("")
264
+ end
265
+ end
266
+ end
267
+ end
268
+ end
@@ -0,0 +1,364 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../v1"
4
+
5
+ module Orbitcast
6
+ module Protocol
7
+ module V1
8
+ # One request from a relay node to this Rails endpoint.
9
+ #
10
+ # `POST {mount}/{operation}`; the envelope's `operation` must match the
11
+ # route, which is what stops a body being replayed against a different
12
+ # operation.
13
+ #
14
+ # Parsing is tolerant in exactly the documented ways (unknown fields
15
+ # ignored, absent fields defaulted); `validate!` is where a defaulted
16
+ # envelope fails closed. The browser cannot submit any of this: the URL,
17
+ # origin, client address, and allowlisted headers are reconstructed by the
18
+ # relay from validated metadata.
19
+ class Request
20
+ # Everything Rails is allowed to know about the original WebSocket
21
+ # request.
22
+ class ConnectionSnapshot
23
+ def self.from(hash)
24
+ hash = {} unless hash.is_a?(Hash)
25
+ new(
26
+ url: hash["url"].is_a?(String) ? hash["url"] : "",
27
+ origin: hash["origin"].is_a?(String) ? hash["origin"] : nil,
28
+ client_address: hash["client_address"].is_a?(String) ? hash["client_address"] : "",
29
+ headers: hash["headers"].is_a?(Hash) ? hash["headers"] : {},
30
+ identity: hash["identity"].is_a?(Hash) ? hash["identity"] : {},
31
+ state: hash["state"].is_a?(Hash) ? hash["state"] : {}
32
+ )
33
+ end
34
+
35
+ def initialize(url:, origin:, client_address:, headers:, identity:, state:)
36
+ @url = url
37
+ @origin = origin
38
+ @client_address = client_address
39
+ @headers = headers.freeze
40
+ @identity = identity.freeze
41
+ @state = state.freeze
42
+ end
43
+
44
+ attr_reader :url, :origin, :client_address, :headers, :identity, :state
45
+
46
+ def validate!
47
+ V1.validate_bounded!("url", url, MAX_URL_LEN)
48
+ V1.validate_bounded!("client_address", client_address, MAX_ADDRESS_LEN)
49
+ V1.validate_bounded!("origin", origin, MAX_ADDRESS_LEN) unless origin.nil?
50
+
51
+ if headers.size > MAX_HEADERS
52
+ raise ProtocolError.new(
53
+ "headers has #{headers.size} entries; the maximum is #{MAX_HEADERS}",
54
+ field: "headers"
55
+ )
56
+ end
57
+
58
+ headers.each do |name, value|
59
+ V1.validate_bounded!("header_name", name, MAX_HEADER_NAME_LEN)
60
+ if name.match?(/[[:upper:]\s]/)
61
+ raise ProtocolError.new(
62
+ "header name #{name.inspect} must be lowercase and unpadded",
63
+ field: "header_name"
64
+ )
65
+ end
66
+ if HOP_BY_HOP_HEADERS.include?(name)
67
+ raise ProtocolError.new(
68
+ "header #{name.inspect} is hop-by-hop and is never forwarded",
69
+ field: "header_name"
70
+ )
71
+ end
72
+ unless value.is_a?(String) && value.bytesize <= MAX_HEADER_VALUE_LEN
73
+ raise ProtocolError.new("header #{name.inspect} has an unusable value",
74
+ field: "header_value")
75
+ end
76
+ end
77
+
78
+ V1.validate_state_map!("identity", identity)
79
+ V1.validate_state_map!("state", state)
80
+ self
81
+ end
82
+
83
+ def to_h
84
+ {
85
+ "url" => url,
86
+ "origin" => origin,
87
+ "client_address" => client_address,
88
+ "headers" => headers,
89
+ "identity" => identity,
90
+ "state" => state
91
+ }
92
+ end
93
+ end
94
+
95
+ # One subscription as the relay currently holds it. `identifier` is the
96
+ # client's original string, byte for byte; `params` is a convenience
97
+ # decode of it and is never authoritative.
98
+ class SubscriptionSnapshot
99
+ def self.from(hash)
100
+ hash = {} unless hash.is_a?(Hash)
101
+ new(
102
+ identifier: hash["identifier"].is_a?(String) ? hash["identifier"] : "",
103
+ params: hash["params"].is_a?(Hash) ? hash["params"] : {},
104
+ streams: hash["streams"].is_a?(Array) ? hash["streams"] : [],
105
+ state: hash["state"].is_a?(Hash) ? hash["state"] : {}
106
+ )
107
+ end
108
+
109
+ def initialize(identifier:, params:, streams:, state:)
110
+ @identifier = identifier
111
+ @params = params.freeze
112
+ @streams = streams.freeze
113
+ @state = state.freeze
114
+ end
115
+
116
+ attr_reader :identifier, :params, :streams, :state
117
+
118
+ def validate!
119
+ V1.validate_identifier!(identifier)
120
+
121
+ if streams.size > MAX_SUBSCRIPTION_STREAMS
122
+ raise ProtocolError.new(
123
+ "streams has #{streams.size} entries; the maximum is " \
124
+ "#{MAX_SUBSCRIPTION_STREAMS}", field: "streams"
125
+ )
126
+ end
127
+ streams.each { |stream| V1.validate_stream!(stream) }
128
+
129
+ V1.validate_state_map!("params", params)
130
+ V1.validate_state_map!("state", state)
131
+ self
132
+ end
133
+
134
+ def to_h
135
+ {
136
+ "identifier" => identifier,
137
+ "params" => params,
138
+ "streams" => streams,
139
+ "state" => state
140
+ }
141
+ end
142
+ end
143
+
144
+ # The `message`/`perform` payload of a `run_action` request.
145
+ class ActionPayload
146
+ def self.from(hash)
147
+ hash = {} unless hash.is_a?(Hash)
148
+ new(
149
+ identifier: hash["identifier"].is_a?(String) ? hash["identifier"] : "",
150
+ json_data: hash["json_data"].is_a?(String) ? hash["json_data"] : ""
151
+ )
152
+ end
153
+
154
+ def initialize(identifier:, json_data:)
155
+ @identifier = identifier
156
+ @json_data = json_data
157
+ end
158
+
159
+ attr_reader :identifier, :json_data
160
+
161
+ def validate!
162
+ V1.validate_identifier!(identifier)
163
+ V1.validate_json_text!("json_data", json_data)
164
+ self
165
+ end
166
+
167
+ # The decoded command data. Rails reads `data["action"]` out of the
168
+ # bytes the client actually sent.
169
+ def data
170
+ JSON.parse(json_data)
171
+ end
172
+
173
+ def to_h
174
+ { "identifier" => identifier, "json_data" => json_data }
175
+ end
176
+ end
177
+
178
+ # The final snapshot handed to `release_connection`.
179
+ class ReleasePayload
180
+ def self.from(hash)
181
+ hash = {} unless hash.is_a?(Hash)
182
+ subscriptions = hash["subscriptions"].is_a?(Array) ? hash["subscriptions"] : []
183
+ new(
184
+ reason: hash["reason"].is_a?(String) ? hash["reason"] : "",
185
+ reconnect: hash["reconnect"] == true,
186
+ subscriptions: subscriptions.map { |entry| SubscriptionSnapshot.from(entry) }
187
+ )
188
+ end
189
+
190
+ def initialize(reason:, reconnect:, subscriptions:)
191
+ @reason = reason
192
+ @reconnect = reconnect
193
+ @subscriptions = subscriptions.freeze
194
+ end
195
+
196
+ attr_reader :reason, :reconnect, :subscriptions
197
+
198
+ def reconnect?
199
+ @reconnect
200
+ end
201
+
202
+ def validate!
203
+ V1.validate_close_reason!(reason)
204
+
205
+ if subscriptions.size > MAX_RELEASE_SUBSCRIPTIONS
206
+ raise ProtocolError.new(
207
+ "subscriptions has #{subscriptions.size} entries; the maximum is " \
208
+ "#{MAX_RELEASE_SUBSCRIPTIONS}", field: "subscriptions"
209
+ )
210
+ end
211
+ subscriptions.each(&:validate!)
212
+ self
213
+ end
214
+
215
+ def to_h
216
+ {
217
+ "reason" => reason,
218
+ "reconnect" => reconnect,
219
+ "subscriptions" => subscriptions.map(&:to_h)
220
+ }
221
+ end
222
+ end
223
+
224
+ # Decode one request body. Raises ProtocolError when the body is not an
225
+ # object or names an operation this release does not implement: an
226
+ # unknown tagged value is a hard error, never a tolerated unknown.
227
+ def self.from(hash)
228
+ unless hash.is_a?(Hash)
229
+ raise ProtocolError.new("request body must be a JSON object", field: "body")
230
+ end
231
+
232
+ operation = hash["operation"]
233
+ unless OPERATIONS.include?(operation)
234
+ raise ProtocolError.new("unknown operation #{operation.inspect}", field: "operation")
235
+ end
236
+
237
+ new(
238
+ version: V1.version_of(hash),
239
+ operation: operation,
240
+ namespace: string_or_empty(hash["namespace"]),
241
+ node_id: string_or_empty(hash["node_id"]),
242
+ session_id: string_or_empty(hash["session_id"]),
243
+ request_id: string_or_empty(hash["request_id"]),
244
+ sequence: integer_or_zero(hash["sequence"]),
245
+ expected_revision: integer_or_zero(hash["expected_revision"]),
246
+ deadline_ms: integer_or_zero(hash["deadline_ms"]),
247
+ connection: hash.key?("connection") ? ConnectionSnapshot.from(hash["connection"]) : nil,
248
+ subscription: hash.key?("subscription") ? SubscriptionSnapshot.from(hash["subscription"]) : nil,
249
+ action: hash.key?("action") ? ActionPayload.from(hash["action"]) : nil,
250
+ release: hash.key?("release") ? ReleasePayload.from(hash["release"]) : nil
251
+ )
252
+ end
253
+
254
+ def self.parse(text)
255
+ from(JSON.parse(text))
256
+ end
257
+
258
+ def self.string_or_empty(value)
259
+ value.is_a?(String) ? value : ""
260
+ end
261
+
262
+ def self.integer_or_zero(value)
263
+ value.is_a?(Integer) ? value : 0
264
+ end
265
+ private_class_method :string_or_empty, :integer_or_zero
266
+
267
+ def initialize(version:, operation:, namespace:, node_id:, session_id:, request_id:,
268
+ sequence:, expected_revision:, deadline_ms:,
269
+ connection: nil, subscription: nil, action: nil, release: nil)
270
+ @version = version
271
+ @operation = operation
272
+ @namespace = namespace
273
+ @node_id = node_id
274
+ @session_id = session_id
275
+ @request_id = request_id
276
+ @sequence = sequence
277
+ @expected_revision = expected_revision
278
+ @deadline_ms = deadline_ms
279
+ @connection = connection
280
+ @subscription = subscription
281
+ @action = action
282
+ @release = release
283
+ end
284
+
285
+ attr_reader :version, :operation, :namespace, :node_id, :session_id, :request_id,
286
+ :sequence, :expected_revision, :deadline_ms,
287
+ :connection, :subscription, :action, :release
288
+
289
+ def stateful?
290
+ V1.stateful?(operation)
291
+ end
292
+
293
+ def teardown?
294
+ V1.teardown?(operation)
295
+ end
296
+
297
+ # Semantic validation. Parsing defaults never bypass this.
298
+ def validate!
299
+ V1.validate_version!(version)
300
+ V1.validate_namespace!(namespace)
301
+ V1.validate_bounded!("node_id", node_id, MAX_NODE_ID_LEN)
302
+ V1.validate_uuid!("session_id", session_id)
303
+ V1.validate_uuid!("request_id", request_id)
304
+
305
+ if deadline_ms <= 0 || deadline_ms > MAX_DEADLINE_MS
306
+ raise ProtocolError.new(
307
+ "deadline_ms must be 1..#{MAX_DEADLINE_MS}, got #{deadline_ms}", field: "deadline_ms"
308
+ )
309
+ end
310
+
311
+ require_payload!(connection, "connection") unless operation == OPERATION_DESCRIBE
312
+ case operation
313
+ when OPERATION_OPEN_CHANNEL, OPERATION_CLOSE_CHANNEL
314
+ require_payload!(subscription, "subscription")
315
+ when OPERATION_RUN_ACTION
316
+ require_payload!(subscription, "subscription")
317
+ require_payload!(action, "action")
318
+ when OPERATION_RELEASE_CONNECTION
319
+ require_payload!(release, "release")
320
+ end
321
+
322
+ # A payload that is present but not required is still validated: a
323
+ # tolerated field is never an unvalidated field.
324
+ connection&.validate!
325
+ subscription&.validate!
326
+ action&.validate!
327
+ release&.validate!
328
+ self
329
+ end
330
+
331
+ def to_h
332
+ hash = {
333
+ "version" => version,
334
+ "operation" => operation,
335
+ "namespace" => namespace,
336
+ "node_id" => node_id,
337
+ "session_id" => session_id,
338
+ "request_id" => request_id,
339
+ "sequence" => sequence,
340
+ "expected_revision" => expected_revision,
341
+ "deadline_ms" => deadline_ms
342
+ }
343
+ hash["connection"] = connection.to_h if connection
344
+ hash["subscription"] = subscription.to_h if subscription
345
+ hash["action"] = action.to_h if action
346
+ hash["release"] = release.to_h if release
347
+ hash
348
+ end
349
+
350
+ def to_json(*args)
351
+ JSON.generate(to_h, *args)
352
+ end
353
+
354
+ private
355
+
356
+ def require_payload!(payload, field)
357
+ return if payload
358
+
359
+ raise ProtocolError.new("#{operation} requires a #{field} payload", field: field)
360
+ end
361
+ end
362
+ end
363
+ end
364
+ end