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,291 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ require_relative "../error"
6
+
7
+ module Orbitcast
8
+ module Protocol
9
+ # Version 1 envelopes: limits, tagged values, and the shared semantic
10
+ # checks.
11
+ #
12
+ # Every constant here mirrors one in `src/protocol/v1/`. Rust is the strict
13
+ # validator of what crosses the wire; Ruby validates its own output before
14
+ # emitting it, so an envelope Rust would reject never leaves this process.
15
+ #
16
+ # Tolerant reading, exactly as documented:
17
+ #
18
+ # | wire condition | result |
19
+ # | --------------------- | --------------------- |
20
+ # | unknown field | ignored |
21
+ # | absent `version` | reads as 1 |
22
+ # | unknown tagged value | hard error |
23
+ # | absent `state`/`null` | unchanged |
24
+ # | `state: {}` | clear |
25
+ module V1
26
+ VERSION = 1
27
+
28
+ # --- operations (also the last path segment of the HTTP binding) -------
29
+
30
+ OPERATION_DESCRIBE = "describe"
31
+ OPERATION_IDENTIFY = "identify"
32
+ OPERATION_OPEN_CHANNEL = "open_channel"
33
+ OPERATION_CLOSE_CHANNEL = "close_channel"
34
+ OPERATION_RUN_ACTION = "run_action"
35
+ OPERATION_RELEASE_CONNECTION = "release_connection"
36
+
37
+ OPERATIONS = [
38
+ OPERATION_DESCRIBE,
39
+ OPERATION_IDENTIFY,
40
+ OPERATION_OPEN_CHANNEL,
41
+ OPERATION_CLOSE_CHANNEL,
42
+ OPERATION_RUN_ACTION,
43
+ OPERATION_RELEASE_CONNECTION
44
+ ].freeze
45
+
46
+ # `describe` is a node-level handshake: no session, no revision.
47
+ STATEFUL_OPERATIONS = (OPERATIONS - [OPERATION_DESCRIBE]).freeze
48
+
49
+ # Operations that run *after* the thing they describe is already gone.
50
+ # Their effects would address a subscription the relay has tombstoned.
51
+ TEARDOWN_OPERATIONS = [OPERATION_CLOSE_CHANNEL, OPERATION_RELEASE_CONNECTION].freeze
52
+
53
+ # --- outcomes ---------------------------------------------------------
54
+
55
+ OUTCOME_ACCEPTED = "accepted"
56
+ OUTCOME_DENIED = "denied"
57
+ OUTCOME_FAULT = "fault"
58
+ OUTCOMES = [OUTCOME_ACCEPTED, OUTCOME_DENIED, OUTCOME_FAULT].freeze
59
+
60
+ # --- effects ----------------------------------------------------------
61
+
62
+ EFFECT_ADD_STREAM = "add_stream"
63
+ EFFECT_REMOVE_STREAM = "remove_stream"
64
+ EFFECT_REMOVE_ALL_STREAMS = "remove_all_streams"
65
+ EFFECT_TRANSMIT = "transmit"
66
+ EFFECT_CLOSE = "close"
67
+ EFFECT_PRESENCE_GRANT = "presence_grant"
68
+ EFFECT_PRESENCE_REVOKE = "presence_revoke"
69
+
70
+ EFFECT_KINDS = [
71
+ EFFECT_ADD_STREAM, EFFECT_REMOVE_STREAM, EFFECT_REMOVE_ALL_STREAMS,
72
+ EFFECT_TRANSMIT, EFFECT_CLOSE, EFFECT_PRESENCE_GRANT, EFFECT_PRESENCE_REVOKE
73
+ ].freeze
74
+
75
+ # --- presence (src/protocol/v1/presence.rs) ---------------------------
76
+ #
77
+ # Presence needs no transport extension: a client operation rides the
78
+ # `data` of an ordinary `message`/`perform` command under the reserved
79
+ # action below, and a server answer rides an ordinary addressed message.
80
+ #
81
+ # `PRESENCE_OPERATIONS` is one spelling used in two places: the
82
+ # `operations` allowlist of a `presence_grant` effect, and the `op` a
83
+ # client may ask for. The server-to-client `event` values are the same
84
+ # three words.
85
+
86
+ PRESENCE_ACTION = "orbitcast_presence"
87
+ PRESENCE_MESSAGE_TYPE = "presence"
88
+
89
+ PRESENCE_OP_JOIN = "join"
90
+ PRESENCE_OP_LEAVE = "leave"
91
+ PRESENCE_OP_SNAPSHOT = "snapshot"
92
+
93
+ PRESENCE_OPERATIONS = [
94
+ PRESENCE_OP_JOIN, PRESENCE_OP_LEAVE, PRESENCE_OP_SNAPSHOT
95
+ ].freeze
96
+
97
+ # Snapshot page window, as the server clamps it.
98
+ DEFAULT_PRESENCE_PAGE_SIZE = 50
99
+ MAX_PRESENCE_PAGE_SIZE = 100
100
+ # Deep paging over a roster is a scan; the ceiling keeps one client from
101
+ # walking an unbounded OFFSET across the table.
102
+ MAX_PRESENCE_PAGE_OFFSET = 100_000
103
+
104
+ # --- close reasons ----------------------------------------------------
105
+ #
106
+ # Rails does not get to invent wire-visible close reasons: the browser
107
+ # facing `disconnect` frame is framed by Rust and the ActionCable client
108
+ # only understands these four.
109
+
110
+ CLOSE_REASON_UNAUTHORIZED = "unauthorized"
111
+ CLOSE_REASON_INVALID_REQUEST = "invalid_request"
112
+ CLOSE_REASON_SERVER_RESTART = "server_restart"
113
+ CLOSE_REASON_REMOTE = "remote"
114
+
115
+ CLOSE_REASONS = [
116
+ CLOSE_REASON_UNAUTHORIZED, CLOSE_REASON_INVALID_REQUEST,
117
+ CLOSE_REASON_SERVER_RESTART, CLOSE_REASON_REMOTE
118
+ ].freeze
119
+
120
+ # --- control ----------------------------------------------------------
121
+
122
+ CONTROL_KIND_DISCONNECT = "disconnect"
123
+ CONTROL_KINDS = [CONTROL_KIND_DISCONNECT].freeze
124
+
125
+ # --- limits (src/protocol/v1/rpc.rs) ----------------------------------
126
+
127
+ MAX_EFFECTS = 64
128
+ MAX_IDENTIFIER_LEN = 4096
129
+ MAX_NODE_ID_LEN = 128
130
+ MAX_NAMESPACE_LEN = 128
131
+ MAX_STREAM_LEN = 1024
132
+ MAX_URL_LEN = 8192
133
+ MAX_ADDRESS_LEN = 256
134
+ MAX_HEADERS = 32
135
+ MAX_HEADER_NAME_LEN = 128
136
+ MAX_HEADER_VALUE_LEN = 8192
137
+ MAX_STATE_ENTRIES = 64
138
+ MAX_STATE_KEY_LEN = 128
139
+ MAX_STATE_LEN = 64 * 1024
140
+ MAX_PAYLOAD_LEN = 1024 * 1024
141
+ MAX_TRANSMIT_LEN = MAX_PAYLOAD_LEN
142
+ MAX_SUBSCRIPTION_STREAMS = 64
143
+ MAX_RELEASE_SUBSCRIPTIONS = 256
144
+ MAX_REASON_LEN = 256
145
+ MAX_SUBJECT_ID_LEN = 256
146
+ MAX_PRESENCE_METADATA_LEN = 4096
147
+ MAX_PRESENCE_OPERATIONS = 16
148
+ MAX_DEADLINE_MS = 60_000
149
+ MAX_CAPABILITIES = 64
150
+ MAX_LABEL_LEN = 128
151
+ MAX_IDENTITY_KEY_LEN = 512
152
+
153
+ # Headers that are never forwarded, whatever the allowlist says: they
154
+ # describe the relay's own hop and reconstructing them here would be a
155
+ # request-smuggling hazard.
156
+ HOP_BY_HOP_HEADERS = %w[
157
+ connection content-length host keep-alive proxy-authenticate
158
+ proxy-authorization te trailer transfer-encoding upgrade
159
+ ].freeze
160
+
161
+ UUID_PATTERN = /\A\h{8}-\h{4}-\h{4}-\h{4}-\h{12}\z/
162
+
163
+ class << self
164
+ # An absent version reads as 1; serializers always emit it.
165
+ def version_of(hash)
166
+ value = hash["version"]
167
+ return VERSION if value.nil?
168
+
169
+ value
170
+ end
171
+
172
+ def validate_version!(version, field: "version")
173
+ return VERSION if version == VERSION
174
+
175
+ raise ProtocolError.new("unsupported envelope version #{version.inspect}", field: field)
176
+ end
177
+
178
+ def validate_bounded!(field, value, max)
179
+ unless value.is_a?(String)
180
+ raise ProtocolError.new("#{field} must be a String, got #{value.class}", field: field)
181
+ end
182
+ raise ProtocolError.new("#{field} must not be empty", field: field) if value.empty?
183
+
184
+ if value.bytesize > max
185
+ raise ProtocolError.new(
186
+ "#{field} is #{value.bytesize} bytes; the maximum is #{max}", field: field
187
+ )
188
+ end
189
+
190
+ value
191
+ end
192
+
193
+ def validate_namespace!(namespace)
194
+ validate_bounded!("namespace", namespace, MAX_NAMESPACE_LEN)
195
+ end
196
+
197
+ def validate_stream!(stream)
198
+ validate_bounded!("stream", stream, MAX_STREAM_LEN)
199
+ end
200
+
201
+ def validate_identifier!(identifier)
202
+ validate_bounded!("identifier", identifier, MAX_IDENTIFIER_LEN)
203
+ end
204
+
205
+ def validate_uuid!(field, value)
206
+ validate_bounded!(field, value, 64)
207
+ return value if value.match?(UUID_PATTERN)
208
+
209
+ raise ProtocolError.new("#{field} #{value.inspect} is not a UUID", field: field)
210
+ end
211
+
212
+ def validate_close_reason!(reason)
213
+ raise ProtocolError.new("reason must not be empty", field: "reason") if reason.to_s.empty?
214
+ return reason if CLOSE_REASONS.include?(reason)
215
+
216
+ raise ProtocolError.new(
217
+ "reason #{reason.inspect} is not one of #{CLOSE_REASONS.join(", ")}", field: "reason"
218
+ )
219
+ end
220
+
221
+ # Bounds the entry count, each key, and the *encoded* size: one huge
222
+ # value is as expensive as many small ones.
223
+ def validate_state_map!(field, map)
224
+ unless map.is_a?(Hash)
225
+ raise ProtocolError.new("#{field} must be an object, got #{map.class}", field: field)
226
+ end
227
+
228
+ if map.size > MAX_STATE_ENTRIES
229
+ raise ProtocolError.new(
230
+ "#{field} has #{map.size} entries; the maximum is #{MAX_STATE_ENTRIES}", field: field
231
+ )
232
+ end
233
+
234
+ map.each_key do |key|
235
+ unless key.is_a?(String)
236
+ raise ProtocolError.new("#{field} keys must be Strings, got #{key.class}", field: field)
237
+ end
238
+
239
+ validate_bounded!("state_key", key, MAX_STATE_KEY_LEN)
240
+ end
241
+
242
+ encoded = JSON.generate(map)
243
+ if encoded.bytesize > MAX_STATE_LEN
244
+ raise ProtocolError.new(
245
+ "#{field} encodes to #{encoded.bytesize} bytes; the maximum is #{MAX_STATE_LEN}",
246
+ field: field
247
+ )
248
+ end
249
+
250
+ map
251
+ end
252
+
253
+ # Check that an embedded application payload is valid JSON *without*
254
+ # keeping the decoded value: the exact encoded text is what travels.
255
+ def validate_json_text!(field, text)
256
+ unless text.is_a?(String)
257
+ raise ProtocolError.new("#{field} must be a String, got #{text.class}", field: field)
258
+ end
259
+ raise ProtocolError.new("#{field} must not be empty", field: field) if text.empty?
260
+
261
+ if text.bytesize > MAX_PAYLOAD_LEN
262
+ raise ProtocolError.new(
263
+ "#{field} is #{text.bytesize} bytes; the maximum is #{MAX_PAYLOAD_LEN}", field: field
264
+ )
265
+ end
266
+
267
+ begin
268
+ JSON.parse(text)
269
+ rescue JSON::ParserError => e
270
+ raise ProtocolError.new("#{field} is not valid JSON: #{e.message}", field: field)
271
+ end
272
+
273
+ text
274
+ end
275
+
276
+ def stateful?(operation)
277
+ STATEFUL_OPERATIONS.include?(operation)
278
+ end
279
+
280
+ def teardown?(operation)
281
+ TEARDOWN_OPERATIONS.include?(operation)
282
+ end
283
+ end
284
+ end
285
+ end
286
+ end
287
+
288
+ require_relative "v1/effects"
289
+ require_relative "v1/request"
290
+ require_relative "v1/reply"
291
+ require_relative "v1/control_message"
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "protocol/v1"
4
+
5
+ module Orbitcast
6
+ # Handwritten JSON envelopes for the Orbitcast wire protocol.
7
+ #
8
+ # There is no schema compiler and no generated code: `src/protocol/` is the
9
+ # authority, these classes are the Ruby reading of it, and
10
+ # `tests/fixtures/**/*.json` is what keeps the two honest. Both suites consume
11
+ # the same fixtures; neither regenerates them.
12
+ #
13
+ # Everything here must load without Rails: the RPC endpoint needs Rails, the
14
+ # envelopes do not.
15
+ module Protocol
16
+ # The only envelope version this release speaks.
17
+ VERSION = V1::VERSION
18
+ end
19
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "postgres"
4
+ require_relative "../protocol"
5
+
6
+ module Orbitcast
7
+ module Publisher
8
+ # Publishes Rails-initiated control messages over the same PostgreSQL
9
+ # transport as ordinary publications.
10
+ #
11
+ # A control message is not a publication: it never reaches a browser, it
12
+ # carries no application payload, and every node consumes it rather than
13
+ # forwarding it. It does share the transport, so this subclasses the
14
+ # publisher to reuse its connection pool, fork safety, and channel-name
15
+ # derivation instead of opening a second pool for one NOTIFY.
16
+ #
17
+ # ## Channel derivation
18
+ #
19
+ # `orbitcast_` + the first 48 hex characters of SHA-256 over the
20
+ # length-prefixed `(namespace, CONTROL_STREAM)` tuple — the same function
21
+ # every other channel name comes from, with a reserved stream name. A
22
+ # control message for one namespace is therefore invisible to another
23
+ # deployment's listener, and the reserved name cannot collide with an
24
+ # application stream unless the application deliberately publishes to
25
+ # `__orbitcast_control__`.
26
+ #
27
+ # Relay side: the listener session holds a permanent `LISTEN` on this
28
+ # channel (`Backend::spawn_listener` in `src/server.rs`), and the same
29
+ # messages are also accepted over `POST /v1/control` on the private ingress
30
+ # (`src/transport/ingress.rs`). The envelope is pinned by
31
+ # `tests/fixtures/control/*.json` for both languages.
32
+ class Control < Postgres
33
+ # Reserved stream name. Never a browser-visible stream.
34
+ CONTROL_STREAM = "__orbitcast_control__"
35
+
36
+ def self.channel_for(namespace)
37
+ channel_name(namespace, CONTROL_STREAM)
38
+ end
39
+
40
+ # @param message [Orbitcast::Protocol::V1::ControlMessage]
41
+ # @return [String] the control UUID, for deduplication in logs
42
+ def publish_control(message)
43
+ message.validate!
44
+
45
+ unless message.namespace == configuration.namespace
46
+ raise ArgumentError,
47
+ "control message namespace #{message.namespace.inspect} does not match the " \
48
+ "configured namespace #{configuration.namespace.inspect}"
49
+ end
50
+
51
+ wire = message.to_json
52
+ if wire.bytesize >= MAX_NOTIFY_BYTES
53
+ raise PayloadTooLarge,
54
+ "control message is #{wire.bytesize} bytes; PostgreSQL NOTIFY payloads must be " \
55
+ "under #{MAX_NOTIFY_BYTES} bytes"
56
+ end
57
+
58
+ channel = self.class.channel_for(configuration.namespace)
59
+ # `@pool` is the parent's bounded, fork-safe pool. Reused deliberately:
60
+ # one process, one set of publisher connections.
61
+ @pool.with { |connection| connection.exec_params(NOTIFY_SQL, [channel, wire]) }
62
+
63
+ message.control_id
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,180 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "net/http"
5
+ require "securerandom"
6
+ require "uri"
7
+
8
+ require_relative "postgres"
9
+ require_relative "instrumentation"
10
+ require_relative "../error"
11
+ require_relative "../protocol"
12
+ require_relative "../streams"
13
+
14
+ module Orbitcast
15
+ module Publisher
16
+ # Private HTTP publication ingress: the alternative publication path to
17
+ # PostgreSQL.
18
+ #
19
+ # config.orbitcast.publish_via = :http
20
+ # config.orbitcast.ingress_url = "http://127.0.0.1:28081/v1/publications"
21
+ # config.orbitcast.ingress_token = Rails.application.credentials.dig(:orbitcast, :ingress_token)
22
+ #
23
+ # Chosen when the database credentials must stay on the Rust side, or in a
24
+ # memory-only build where there is no PostgreSQL to publish through. Rails
25
+ # publishes through *one* path: never both this and PostgreSQL for the same
26
+ # broadcast.
27
+ #
28
+ # ## Wire contract
29
+ #
30
+ # POST {ingress_url}
31
+ # Authorization: Bearer <ingress_token>
32
+ # Content-Type: application/json
33
+ #
34
+ # {"version":1,"namespace":"...","id":"<uuid>","stream":"...",
35
+ # "json_payload":"<exact ActionCable-encoded JSON text>"}
36
+ #
37
+ # which is the v1 `Publication` envelope from `src/protocol/v1/publication.rs`,
38
+ # byte for byte. A `202` means the backend accepted and committed the
39
+ # publication, not that a browser saw it.
40
+ #
41
+ # Relay side: `POST /v1/publications` on the private ingress listener
42
+ # (`src/transport/ingress.rs`), which answers `202` with the `IngressAck`
43
+ # envelope from `src/protocol/v1/publication.rs`. The ack is pinned by
44
+ # `tests/fixtures/ingress/ack.json` for both languages; this client treats
45
+ # the status code as the contract and does not require the body.
46
+ #
47
+ # ## Failure policy
48
+ #
49
+ # No retries. A publication that timed out may or may not have been
50
+ # accepted, and replaying it would duplicate a browser-visible message;
51
+ # durable cross-node deduplication belongs to the broker phase.
52
+ class Http
53
+ DEFAULT_OPEN_TIMEOUT = 2.0
54
+ DEFAULT_READ_TIMEOUT = 5.0
55
+
56
+ ACCEPTED_STATUSES = %w[200 201 202].freeze
57
+
58
+ # @param instrumenter [#instrument] defaults to
59
+ # `ActiveSupport::Notifications` when loaded; see
60
+ # `Orbitcast::Publisher::Instrumentation`
61
+ def initialize(configuration = Orbitcast.config, connection_factory: nil, instrumenter: nil)
62
+ @configuration = configuration.validate!
63
+ @instrumenter = Instrumentation.resolve(instrumenter)
64
+ @uri = URI.parse(configuration.ingress_url.to_s)
65
+ @pool = Postgres::Pool.new(
66
+ size: configuration.pool_size,
67
+ timeout: configuration.pool_timeout,
68
+ &(connection_factory || method(:connect))
69
+ )
70
+ end
71
+
72
+ attr_reader :configuration
73
+
74
+ # @param stream [String] logical stream name
75
+ # @param payload [String] JSON text already encoded by ActionCable
76
+ # @return [String] publication UUID
77
+ def publish(stream, payload)
78
+ # Both guards run before anything is instrumented or sent: an invalid
79
+ # stream (empty, over 1024 UTF-8 bytes) or a non-String payload is a
80
+ # caller error, not a publication that failed.
81
+ stream = Streams.validated_stream(stream)
82
+ payload = validated_payload(payload)
83
+
84
+ Instrumentation.publish(@instrumenter, stream: stream, bytes: payload.bytesize, via: :http) do
85
+ if payload.bytesize > configuration.max_publication_size
86
+ raise PayloadTooLarge,
87
+ "publication payload is #{payload.bytesize} bytes; the configured maximum is " \
88
+ "#{configuration.max_publication_size} bytes (max_publication_size)"
89
+ end
90
+
91
+ publication_id = SecureRandom.uuid
92
+ body = JSON.generate(
93
+ version: Protocol::V1::VERSION,
94
+ namespace: configuration.namespace,
95
+ id: publication_id,
96
+ stream: stream,
97
+ json_payload: payload
98
+ )
99
+
100
+ # `Pool#with` returns the checkin, not the block: capture the response.
101
+ response = nil
102
+ @pool.with { |connection| response = connection.post(@uri, body, bearer) }
103
+ verify!(response)
104
+
105
+ publication_id
106
+ end
107
+ end
108
+
109
+ def shutdown
110
+ @pool.shutdown
111
+ end
112
+
113
+ private
114
+
115
+ def bearer
116
+ token = configuration.ingress_token
117
+ raise ConfigurationError, "ingress_token is required when publish_via is :http" if token.nil?
118
+
119
+ "Bearer #{token}"
120
+ end
121
+
122
+ def verify!(response)
123
+ return if ACCEPTED_STATUSES.include?(response.code.to_s)
124
+
125
+ case response.code.to_s
126
+ when "401", "403"
127
+ raise IngressError, "publication ingress refused the service credential (#{response.code})"
128
+ when "413"
129
+ raise PayloadTooLarge, "publication ingress refused the payload as too large (413)"
130
+ else
131
+ raise IngressError, "publication ingress returned HTTP #{response.code}"
132
+ end
133
+ end
134
+
135
+ def validated_payload(payload)
136
+ unless payload.is_a?(String)
137
+ raise ArgumentError,
138
+ "payload must be the JSON text encoded by ActionCable, got #{payload.class}"
139
+ end
140
+ raise ArgumentError, "payload is not valid #{payload.encoding}" unless payload.valid_encoding?
141
+
142
+ payload.encoding == Encoding::UTF_8 ? payload : payload.encode(Encoding::UTF_8)
143
+ end
144
+
145
+ def connect
146
+ Connection.new(@uri, configuration)
147
+ end
148
+
149
+ # One keep-alive HTTP connection, shaped like the pooled objects the
150
+ # PostgreSQL publisher hands out (`#close`), so both publishers share the
151
+ # same bounded, fork-safe pool implementation.
152
+ class Connection
153
+ def initialize(uri, _configuration)
154
+ @http = Net::HTTP.new(uri.host, uri.port)
155
+ @http.use_ssl = uri.scheme == "https"
156
+ @http.open_timeout = DEFAULT_OPEN_TIMEOUT
157
+ @http.read_timeout = DEFAULT_READ_TIMEOUT
158
+ @http.keep_alive_timeout = 30
159
+ end
160
+
161
+ def post(uri, body, authorization)
162
+ request = Net::HTTP::Post.new(uri.request_uri)
163
+ request["content-type"] = "application/json"
164
+ request["accept"] = "application/json"
165
+ request["authorization"] = authorization
166
+ request.body = body
167
+
168
+ @http.start unless @http.started?
169
+ @http.request(request)
170
+ end
171
+
172
+ def close
173
+ @http.finish if @http.started?
174
+ rescue IOError
175
+ nil
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Orbitcast
4
+ module Publisher
5
+ # `ActiveSupport::Notifications` for publishers, without requiring it.
6
+ #
7
+ # Every publish, on every transport, emits one `publish.orbitcast` event
8
+ # with `stream:`, `bytes:` (the encoded payload size) and `via:`
9
+ # (`:postgres` or `:http`). A publish that raises still emits the event —
10
+ # `ActiveSupport::Notifications` adds `:exception` / `:exception_object`
11
+ # to the payload and the error propagates unchanged — so a subscriber sees
12
+ # the failure path, never a silent drop:
13
+ #
14
+ # ActiveSupport::Notifications.subscribe("publish.orbitcast") do |event|
15
+ # StatsD.timing("orbitcast.publish", event.duration, tags: ["via:#{event.payload[:via]}"])
16
+ # Rails.error.report(event.payload[:exception_object]) if event.payload[:exception_object]
17
+ # end
18
+ #
19
+ # The plain-ruby unit suite has no ActiveSupport; it gets a null
20
+ # instrumenter (or injects a recording one).
21
+ module Instrumentation
22
+ EVENT = "publish.orbitcast"
23
+
24
+ # Yields without recording. Same contract as
25
+ # `ActiveSupport::Notifications.instrument`: the block's value is
26
+ # returned, an exception propagates.
27
+ module Null
28
+ def self.instrument(_name, payload = {})
29
+ yield payload
30
+ end
31
+ end
32
+
33
+ class << self
34
+ def resolve(instrumenter)
35
+ return instrumenter if instrumenter
36
+ return ::ActiveSupport::Notifications if defined?(::ActiveSupport::Notifications)
37
+
38
+ Null
39
+ end
40
+
41
+ def publish(instrumenter, stream:, bytes:, via:, &block)
42
+ instrumenter.instrument(EVENT, { stream: stream, bytes: bytes, via: via }, &block)
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end