laravel-cloud-rails 0.1.1

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.
@@ -0,0 +1,338 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "logger"
4
+
5
+ module LaravelCloud
6
+ module Queue
7
+ # Runs managed-queue jobs (`laravel-cloud queue:work`). Receives one message
8
+ # at a time — from the Cloud agent, or straight from SQS without one — and
9
+ # reports exactly one outcome per message:
10
+ #
11
+ # - success / discard → delete ("processed")
12
+ # - `retry_on` retrying → visibility reset ("released")
13
+ # - an exception, tries left → visibility reset ("released")
14
+ # - an exception, no tries → delete + failed job ("failed")
15
+ #
16
+ # `tries` / `backoff` are Laravel's `queue:work` options; the default of one
17
+ # try keeps Active Job's behaviour (an escaping exception is terminal).
18
+ # `tries` also caps deliveries, so a job that crashes the worker can't come
19
+ # back forever; a job's `retry_on` attempts raise its cap (RetryAttempts).
20
+ class Worker
21
+ # The message has been delivered more times than its job may be attempted.
22
+ class MaxAttemptsExceeded < LaravelCloud::Error; end
23
+
24
+ # Raised into the worker thread to abandon a long-poll or an overrunning
25
+ # job at shutdown. Not a StandardError, so `retry_on` and a job's own
26
+ # `rescue` don't treat it as a failure.
27
+ class Shutdown < Exception; end # rubocop:disable Lint/InheritException
28
+
29
+ SIGNALS = %w[TERM INT].freeze
30
+
31
+ # `queues` only applies without the agent, which is pinned to one queue.
32
+ # `shutdown_timeout` is how long a running job gets after a stop request
33
+ # before it's abandoned and released.
34
+ def initialize(queues: [], tries: 1, backoff: 0, max_jobs: nil, max_time: nil, shutdown_timeout: 15,
35
+ stop_when_empty: false, runtime: Queue.runtime, logger: nil)
36
+ @queues = queues
37
+ @tries = [tries.to_i, 1].max
38
+ @backoff = backoff.to_i.clamp(0, Dispatcher::MAX_VISIBILITY_SECONDS)
39
+ @max_jobs = max_jobs
40
+ @max_time = max_time
41
+ @shutdown_timeout = shutdown_timeout
42
+ @stop_when_empty = stop_when_empty
43
+ @runtime = runtime
44
+ @logger = logger || ActiveJob::Base.logger || Logger.new($stdout)
45
+ @stop_requested_at = nil
46
+ @polling = false
47
+ @executing = false
48
+ @processed = 0
49
+ end
50
+
51
+ def run
52
+ @thread = Thread.current
53
+ @started_at = monotonic
54
+ previous_handlers = trap_signals
55
+ watchdog = start_watchdog
56
+
57
+ @logger.info("[LaravelCloud] Processing managed queue jobs #{source_description}")
58
+
59
+ until stopping? || limit_reached?
60
+ delivery = poll
61
+
62
+ if delivery.nil?
63
+ break if @stop_when_empty
64
+
65
+ next
66
+ end
67
+
68
+ process(delivery)
69
+ @processed += 1
70
+ end
71
+ rescue Shutdown
72
+ # Shut down mid-poll; nothing was taken.
73
+ ensure
74
+ watchdog&.kill
75
+ restore_signals(previous_handlers) if previous_handlers
76
+ @logger.info("[LaravelCloud] Stopped processing managed queue jobs")
77
+ end
78
+
79
+ # Finish the current job, then exit. An idle long-poll (up to ~55s) is
80
+ # abandoned so shutdown isn't held up; a message received at that exact
81
+ # moment is redelivered by SQS after its visibility timeout.
82
+ def stop
83
+ @stop_requested_at ||= monotonic
84
+ @thread.raise(Shutdown) if @polling && @thread
85
+ end
86
+
87
+ def stopping?
88
+ !@stop_requested_at.nil?
89
+ end
90
+
91
+ def process(delivery)
92
+ job_data = begin
93
+ Message.decode(delivery.body, receive_count: delivery.receive_count)
94
+ rescue Message::Undecodable => e
95
+ # Drop the poison message so it isn't redelivered forever.
96
+ @logger.error("[LaravelCloud] Deleting undecodable managed queue message #{delivery.message_id}: #{e.message}")
97
+ acknowledge(delivery)
98
+ return
99
+ end
100
+
101
+ delivery.bind(job_data)
102
+
103
+ started_at = observer.now
104
+ observer.started(delivery.queue, started_at)
105
+
106
+ begin
107
+ job_class = JobClass.resolve(job_data["job_class"], allowed: @runtime.settings.allowed_job_classes)
108
+ enforce_max_attempts!(delivery, job_class)
109
+ execute(delivery, job_data)
110
+ rescue Shutdown
111
+ # Release now so another worker can take it immediately. As with a
112
+ # killed Laravel job, this counts as an attempt.
113
+ @logger.warn("[LaravelCloud] Shutdown timeout exceeded; releasing #{job_data['job_class']} (#{delivery.message_id}) for redelivery")
114
+ release(delivery, 0)
115
+ observer.released(delivery.queue, started_at, observer.now)
116
+ return
117
+ rescue StandardError => e
118
+ handle_failure(delivery, job_data, e, started_at)
119
+ return
120
+ end
121
+
122
+ if delivery.released?
123
+ release(delivery, delivery.release_delay)
124
+ observer.released(delivery.queue, started_at, observer.now)
125
+ else
126
+ acknowledge(delivery)
127
+
128
+ if delivery.retried?
129
+ observer.released(delivery.queue, started_at, observer.now)
130
+ else
131
+ observer.processed(delivery.queue, started_at, observer.now)
132
+ end
133
+ end
134
+ end
135
+
136
+ private
137
+
138
+ def config
139
+ @runtime.config
140
+ end
141
+
142
+ def observer
143
+ @runtime.observer
144
+ end
145
+
146
+ # The watchdog only interrupts while @executing, so the interrupt lands in
147
+ # the job, not the outcome reporting. A job finishing at that same instant
148
+ # may run again; SQS is at-least-once anyway.
149
+ def execute(delivery, job_data)
150
+ @executing = true
151
+ Delivery.within(delivery) { ActiveJob::Base.execute(job_data) }
152
+ ensure
153
+ @executing = false
154
+ end
155
+
156
+ def enforce_max_attempts!(delivery, job_class)
157
+ limit = [@tries, job_class.cloud_retry_attempts].max
158
+
159
+ return if delivery.receive_count <= limit
160
+
161
+ raise MaxAttemptsExceeded, "#{job_class.name} has been attempted too many times (#{delivery.receive_count} deliveries, limit #{limit})."
162
+ end
163
+
164
+ def handle_failure(delivery, job_data, error, started_at)
165
+ if retryable?(error) && delivery.receive_count < @tries
166
+ release(delivery, @backoff)
167
+ observer.released(delivery.queue, started_at, observer.now)
168
+ return
169
+ end
170
+
171
+ # Terminal: delete it (the agent has no "failed" outcome) and report the
172
+ # failure through observability.
173
+ @logger.error("[LaravelCloud] #{job_data['job_class']} (#{delivery.message_id}) failed: #{error.class}: #{error.message}")
174
+ acknowledge(delivery)
175
+ observer.failed(delivery, error, started_at, observer.now)
176
+ notify_failure(error, job_data)
177
+ end
178
+
179
+ # Whether another delivery could turn out differently.
180
+ def retryable?(error)
181
+ !error.is_a?(JobClass::Unrunnable) && !error.is_a?(MaxAttemptsExceeded)
182
+ end
183
+
184
+ def notify_failure(error, job_data)
185
+ @runtime.settings.on_failure&.call(error, job_data)
186
+ rescue StandardError => e
187
+ @logger.error("[LaravelCloud] The on_failure handler raised #{e.class}: #{e.message}")
188
+ end
189
+
190
+ def poll
191
+ @polling = true
192
+ config.agent_available? ? receive_from_agent : receive_from_sqs
193
+ ensure
194
+ @polling = false
195
+ end
196
+
197
+ def receive_from_agent
198
+ data = @runtime.agent.next_message
199
+ return nil if data.nil?
200
+
201
+ message_id = data["messageId"]
202
+ return nil unless message_id.is_a?(String) && !message_id.empty?
203
+
204
+ # The queue the message actually came from, for metrics.
205
+ queue_url = data["queueUrl"].is_a?(String) && !data["queueUrl"].empty? ? data["queueUrl"] : config.queue_url
206
+ attributes = data["attributes"].is_a?(Hash) ? data["attributes"] : {}
207
+
208
+ Delivery.new(
209
+ message_id: message_id,
210
+ receipt_handle: data["receiptHandle"].is_a?(String) ? data["receiptHandle"] : nil,
211
+ queue_url: queue_url,
212
+ queue: config.normalize_queue(queue_url),
213
+ body: data["body"].to_s,
214
+ receive_count: attributes["ApproximateReceiveCount"] || 1,
215
+ from_agent: true,
216
+ )
217
+ end
218
+
219
+ # Polls each queue in turn and takes the first message found.
220
+ def receive_from_sqs
221
+ queue_names.each do |name|
222
+ queue_url = config.queue_url_for(name)
223
+ raise NotConfigured, "No Laravel Cloud managed queue is configured (LARAVEL_CLOUD_MANAGED_QUEUES_CONFIG is missing)." if queue_url.nil?
224
+
225
+ message = @runtime.sqs.receive_message(
226
+ queue_url: queue_url,
227
+ max_number_of_messages: 1,
228
+ wait_time_seconds: 20,
229
+ message_attribute_names: ["All"],
230
+ # The attempt counter (see Message.decode).
231
+ message_system_attribute_names: ["ApproximateReceiveCount"],
232
+ ).messages.first
233
+
234
+ next if message.nil?
235
+
236
+ return Delivery.new(
237
+ message_id: message.message_id,
238
+ receipt_handle: message.receipt_handle,
239
+ queue_url: queue_url,
240
+ queue: config.normalize_queue(queue_url),
241
+ body: message.body.to_s,
242
+ receive_count: message.attributes&.fetch("ApproximateReceiveCount", nil) || 1,
243
+ from_agent: false,
244
+ )
245
+ end
246
+
247
+ nil
248
+ end
249
+
250
+ def queue_names
251
+ @queues.empty? ? [config.queue] : @queues
252
+ end
253
+
254
+ def acknowledge(delivery)
255
+ if delivery.from_agent?
256
+ report(delivery, "processed")
257
+ elsif delivery.receipt_handle
258
+ @runtime.sqs.delete_message(queue_url: delivery.queue_url, receipt_handle: delivery.receipt_handle)
259
+ end
260
+ end
261
+
262
+ # Have SQS redeliver the original message after `delay` seconds.
263
+ def release(delivery, delay)
264
+ if delivery.from_agent?
265
+ report(delivery, "released", delay: delay)
266
+ elsif delivery.receipt_handle
267
+ @runtime.sqs.change_message_visibility(
268
+ queue_url: delivery.queue_url,
269
+ receipt_handle: delivery.receipt_handle,
270
+ visibility_timeout: delay,
271
+ )
272
+ end
273
+ end
274
+
275
+ # An unavailable agent ends the worker (it only recovers on restart); a
276
+ # rejected outcome only concerns this message, so it's just logged.
277
+ def report(delivery, status, delay: nil)
278
+ @runtime.agent.report(delivery.message_id, delivery.receipt_handle, status, delay: delay)
279
+ rescue AgentClient::Rejected => e
280
+ @logger.error("[LaravelCloud] #{e.message} (message #{delivery.message_id}, outcome #{status})")
281
+ end
282
+
283
+ def limit_reached?
284
+ if @max_jobs && @processed >= @max_jobs
285
+ @logger.info("[LaravelCloud] Processed #{@processed} jobs; stopping (max jobs)")
286
+ return true
287
+ end
288
+
289
+ if @max_time && monotonic - @started_at >= @max_time
290
+ @logger.info("[LaravelCloud] Ran for #{@max_time}s; stopping (max time)")
291
+ return true
292
+ end
293
+
294
+ false
295
+ end
296
+
297
+ # Enforces the shutdown timeout: signal handlers can't wait or lock, so
298
+ # they only note the stop time and this thread watches the clock.
299
+ def start_watchdog
300
+ return nil if @shutdown_timeout.nil?
301
+
302
+ Thread.new do
303
+ loop do
304
+ sleep 0.25
305
+
306
+ next unless stopping? && @executing && monotonic - @stop_requested_at >= @shutdown_timeout
307
+
308
+ @thread.raise(Shutdown)
309
+ break
310
+ end
311
+ end
312
+ end
313
+
314
+ def monotonic
315
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
316
+ end
317
+
318
+ def source_description
319
+ if config.agent_available?
320
+ "from the Laravel Cloud agent"
321
+ else
322
+ "directly from SQS (#{queue_names.join(', ')})"
323
+ end
324
+ end
325
+
326
+ def trap_signals
327
+ SIGNALS.to_h { |signal| [signal, Signal.trap(signal) { stop }] }
328
+ rescue ArgumentError
329
+ # Not on the main thread (e.g. in tests).
330
+ nil
331
+ end
332
+
333
+ def restore_signals(handlers)
334
+ handlers.each { |signal, handler| Signal.trap(signal, handler || "DEFAULT") }
335
+ end
336
+ end
337
+ end
338
+ end
@@ -0,0 +1,85 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "aws-sdk-sqs"
4
+
5
+ module LaravelCloud
6
+ # Laravel Cloud Managed Queues for Active Job. Jobs are pushed straight to
7
+ # SQS, and received through the Cloud agent or, without one, from SQS.
8
+ module Queue
9
+ # LARAVEL_CLOUD_MANAGED_QUEUES_CONFIG is missing.
10
+ class NotConfigured < LaravelCloud::Error; end
11
+
12
+ # A dispatch the queue can't honour, raised rather than silently degraded.
13
+ class InvalidDispatch < LaravelCloud::Error; end
14
+
15
+ # What the adapter and worker share. Built lazily, so an app boots without
16
+ # a managed queue; injectable for tests.
17
+ class Runtime
18
+ attr_reader :settings
19
+
20
+ def initialize(config: nil, sqs: nil, agent: nil, events: nil, settings: Settings.new)
21
+ @config = config
22
+ @sqs = sqs
23
+ @agent = agent
24
+ @events = events
25
+ @settings = settings
26
+ end
27
+
28
+ def config
29
+ @config ||= ManagedQueueConfig.from_env
30
+ end
31
+
32
+ def sqs
33
+ @sqs ||= build_sqs_client
34
+ end
35
+
36
+ def agent
37
+ @agent ||= AgentClient.new(config.agent_socket)
38
+ end
39
+
40
+ def observer
41
+ @observer ||= Observer.new(@events || LaravelCloud.events)
42
+ end
43
+
44
+ def dispatcher
45
+ @dispatcher ||= Dispatcher.new(self)
46
+ end
47
+
48
+ private
49
+
50
+ def build_sqs_client
51
+ args = { region: config.region || "us-east-1" }
52
+
53
+ # "ecs" means the container credential provider; otherwise the SDK's
54
+ # default chain.
55
+ args[:credentials] = Aws::ECSCredentials.new if config.ecs_credentials?
56
+
57
+ Aws::SQS::Client.new(**args)
58
+ end
59
+ end
60
+
61
+ class << self
62
+ def runtime
63
+ @runtime ||= Runtime.new
64
+ end
65
+
66
+ attr_writer :runtime
67
+
68
+ def configure
69
+ yield runtime.settings
70
+ end
71
+ end
72
+ end
73
+ end
74
+
75
+ require_relative "queue/settings"
76
+ require_relative "queue/retry_attempts"
77
+ require_relative "queue/job_class"
78
+ require_relative "queue/managed_queue_config"
79
+ require_relative "queue/agent_client"
80
+ require_relative "queue/delivery"
81
+ require_relative "queue/message"
82
+ require_relative "queue/observer"
83
+ require_relative "queue/dispatcher"
84
+ require_relative "queue/message_attributes"
85
+ require_relative "queue/worker"
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LaravelCloud
4
+ # A placeholder: the release workflow sets this from the release's tag.
5
+ VERSION = "0.1.1"
6
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module LaravelCloud
4
+ module Websockets
5
+ # A server-side Pusher protocol connection, subscribed to private channels
6
+ # it signs itself. Calls on_failure once the connection is lost.
7
+ class Client
8
+ def initialize(logger: nil, &on_failure)
9
+ @logger = logger
10
+ @failure = on_failure
11
+ @monitor = Monitor.new
12
+ @changed = @monitor.new_cond
13
+ @subscriptions = {}
14
+ @joined = {}
15
+ @closed = false
16
+ @last_received = Process.clock_gettime(Process::CLOCK_MONOTONIC)
17
+ @executor = Concurrent::ThreadPoolExecutor.new(min_threads: 1, max_threads: 8, max_queue: 1024, fallback_policy: :abort)
18
+ @thread = Thread.new { run }
19
+ @thread.name = "laravel-cloud-rails-reverb"
20
+ end
21
+
22
+ def url
23
+ config = Websockets.settings
24
+ scheme = config[:scheme] == "https" ? "wss" : "ws"
25
+ "#{scheme}://#{config[:host]}:#{config[:port]}/app/#{URI.encode_www_form_component(config[:key])}?protocol=7&client=laravel-cloud-rails&version=1"
26
+ end
27
+
28
+ def write(bytes)
29
+ @socket.write(bytes)
30
+ end
31
+
32
+ def subscribe(channel, callback, success = nil)
33
+ @monitor.synchronize do
34
+ wait_until { @socket_id }
35
+ if !@subscriptions.key?(channel)
36
+ @subscriptions[channel] = []
37
+ send_event(event: "pusher:subscribe", data: { channel: channel, auth: Websockets.authorize(@socket_id, channel) })
38
+ end
39
+ @subscriptions[channel] << callback
40
+ wait_until { @joined[channel] }
41
+ end
42
+ success&.call
43
+ end
44
+
45
+ def unsubscribe(channel, callback)
46
+ @monitor.synchronize do
47
+ callbacks = @subscriptions[channel]
48
+ return unless callbacks
49
+ callbacks.delete(callback)
50
+ if callbacks.empty?
51
+ @subscriptions.delete(channel)
52
+ @joined.delete(channel)
53
+ send_event(event: "pusher:unsubscribe", data: { channel: channel }) unless @closed
54
+ end
55
+ end
56
+ end
57
+
58
+ def ping
59
+ @monitor.synchronize do
60
+ raise Unavailable, "Backend heartbeat timed out" if @closed || Process.clock_gettime(Process::CLOCK_MONOTONIC) - @last_received > 12
61
+ send_event(event: "pusher:ping", data: {})
62
+ end
63
+ end
64
+
65
+ def close
66
+ @monitor.synchronize do
67
+ return if @closed
68
+ @closed = true
69
+ @changed.broadcast
70
+ @socket&.close
71
+ end
72
+ @executor.shutdown
73
+ end
74
+
75
+ private
76
+
77
+ def wait_until
78
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 5
79
+ until yield
80
+ raise Unavailable, "WebSocket backend connection closed" if @closed
81
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
82
+ raise Unavailable, "WebSocket backend subscription timed out" if remaining <= 0
83
+ @changed.wait(remaining)
84
+ end
85
+ end
86
+
87
+ def application_origin
88
+ uri = URI.parse(ENV["APP_URL"].to_s)
89
+ return unless uri.is_a?(URI::HTTP) && uri.host
90
+ port = uri.port unless uri.port == uri.default_port
91
+ URI::Generic.build(scheme: uri.scheme, host: uri.host, port: port).to_s
92
+ end
93
+
94
+ def run
95
+ config = Websockets.settings
96
+ tcp = Socket.tcp(config[:host], config[:port], connect_timeout: 3)
97
+ @socket = tcp
98
+ if config[:scheme] == "https"
99
+ context = OpenSSL::SSL::SSLContext.new
100
+ context.set_params
101
+ tls = OpenSSL::SSL::SSLSocket.new(tcp, context)
102
+ tls.hostname = config[:host]
103
+ tls.sync_close = true
104
+ @socket = tls
105
+ Timeout.timeout(3, Unavailable, "Backend TLS handshake timed out") do
106
+ tls.connect
107
+ tls.post_connection_check(config[:host])
108
+ end
109
+ end
110
+ @driver = WebSocket::Driver.client(self, max_length: 1_000_000)
111
+ origin = application_origin
112
+ @driver.set_header("Origin", origin) if origin
113
+ @driver.on(:message) { |event| receive(JSON.parse(event.data)) }
114
+ @driver.on(:error) { |event| raise Unavailable, event.message }
115
+ @driver.on(:close) { raise Unavailable, "Backend WebSocket closed" }
116
+ @monitor.synchronize { @driver.start }
117
+ loop do
118
+ data = @socket.readpartial(16384)
119
+ @monitor.synchronize { @driver.parse(data) }
120
+ end
121
+ rescue StandardError => error
122
+ # Only our own messages are logged; they may quote the backend (e.g. "Origin not allowed").
123
+ detail = error.is_a?(Unavailable) ? error.message : error.class
124
+ @logger&.warn("laravel-cloud-rails: backend connection lost (#{detail})") unless @closed
125
+ ensure
126
+ close
127
+ @failure.call
128
+ end
129
+
130
+ def send_event(event)
131
+ @driver.text(JSON.generate(event))
132
+ end
133
+
134
+ def receive(frame)
135
+ @last_received = Process.clock_gettime(Process::CLOCK_MONOTONIC)
136
+ data = frame["data"].is_a?(String) ? JSON.parse(frame["data"]) : frame["data"]
137
+ case frame["event"]
138
+ when "pusher:connection_established"
139
+ @socket_id = data.fetch("socket_id")
140
+ @changed.broadcast
141
+ when "pusher_internal:subscription_succeeded"
142
+ @joined[frame.fetch("channel")] = true
143
+ @changed.broadcast
144
+ when "pusher:ping"
145
+ send_event(event: "pusher:pong", data: {})
146
+ when "pusher:error", "pusher:subscription_error"
147
+ raise Unavailable, "Backend error: #{data.is_a?(Hash) ? data["message"] : data}"
148
+ else
149
+ @subscriptions.fetch(frame["channel"], []).dup.each do |callback|
150
+ @executor.post { callback.call(frame["event"], data) }
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "net/http"
4
+ require "openssl"
5
+ require "digest"
6
+ require "json"
7
+ require "monitor"
8
+ require "socket"
9
+ require "concurrent"
10
+ require "websocket/driver"
11
+
12
+ module LaravelCloud
13
+ # Laravel Cloud managed WebSockets: a Pusher-compatible (Reverb) server whose
14
+ # connection settings Cloud injects as PUSHER_* variables.
15
+ module Websockets
16
+ class Error < LaravelCloud::Error; end
17
+
18
+ # The PUSHER_* variables are missing.
19
+ class NotConfigured < Error; end
20
+
21
+ # The server can't be reached, or refused the request or connection.
22
+ class Unavailable < Error; end
23
+
24
+ # An event too large for the server to accept.
25
+ class PayloadTooLarge < Error; end
26
+
27
+ VARIABLES = %w[PUSHER_APP_ID PUSHER_APP_KEY PUSHER_APP_SECRET PUSHER_HOST PUSHER_PORT PUSHER_SCHEME].freeze
28
+
29
+ def self.enabled?
30
+ VARIABLES.all? { |key| ENV[key] && !ENV[key].empty? }
31
+ end
32
+
33
+ def self.settings
34
+ raise NotConfigured, "Laravel Cloud WebSockets need #{VARIABLES.join(', ')}" unless enabled?
35
+ scheme = ENV.fetch("PUSHER_SCHEME")
36
+ raise Error, "PUSHER_SCHEME must be http or https" unless %w[http https].include?(scheme)
37
+ port = Integer(ENV.fetch("PUSHER_PORT"))
38
+ raise Error, "Invalid PUSHER_PORT" unless (1..65535).cover?(port)
39
+ { host: ENV.fetch("PUSHER_HOST"), port: port, scheme: scheme, key: ENV.fetch("PUSHER_APP_KEY") }
40
+ end
41
+
42
+ # Signs a socket's subscription to a private channel.
43
+ def self.authorize(socket_id, channel)
44
+ "#{ENV.fetch('PUSHER_APP_KEY')}:#{sign("#{socket_id}:#{channel}")}"
45
+ end
46
+
47
+ def self.sign(value)
48
+ OpenSSL::HMAC.hexdigest("SHA256", ENV.fetch("PUSHER_APP_SECRET"), value)
49
+ end
50
+
51
+ # Triggers an event through the server's HTTP API.
52
+ def self.publish(channel, event, data)
53
+ body = JSON.generate(name: event, channels: [channel], data: JSON.generate(data))
54
+ # Reverb refuses HTTP requests over 10 KB (max_request_size), headers
55
+ # included; the auth query and headers take roughly 350 bytes.
56
+ raise PayloadTooLarge, "Message exceeds the backend payload limit" if body.bytesize > 9500
57
+ path = "/apps/#{ENV.fetch('PUSHER_APP_ID')}/events"
58
+ parameters = { "auth_key" => ENV.fetch("PUSHER_APP_KEY"), "auth_timestamp" => Time.now.to_i.to_s, "auth_version" => "1.0", "body_md5" => Digest::MD5.hexdigest(body) }
59
+ query = URI.encode_www_form(parameters.sort)
60
+ config = settings
61
+ klass = config[:scheme] == "https" ? URI::HTTPS : URI::HTTP
62
+ uri = klass.build(host: config[:host], port: config[:port], path: path, query: "#{query}&auth_signature=#{sign("POST\n#{path}\n#{query}")}")
63
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: 2, read_timeout: 3) do |http|
64
+ http.post(uri.request_uri, body, { "Content-Type" => "application/json" })
65
+ end
66
+ raise Unavailable, "WebSocket backend returned HTTP #{response.code}" unless response.is_a?(Net::HTTPSuccess)
67
+ true
68
+ rescue IOError, SystemCallError, Timeout::Error, OpenSSL::SSL::SSLError => error
69
+ raise Unavailable, "WebSocket backend unavailable (#{error.class})"
70
+ end
71
+ end
72
+ end
73
+
74
+ require_relative "websockets/client"
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_job"
4
+
5
+ require_relative "laravel_cloud/version"
6
+
7
+ # Laravel Cloud features for Rails. This module holds what features share (the
8
+ # dashboard event socket); each feature lives in its own namespace, starting
9
+ # with Managed Queues (LaravelCloud::Queue).
10
+ module LaravelCloud
11
+ # Laravel's default too; LARAVEL_CLOUD_LOG_SOCKET overrides it.
12
+ DEFAULT_LOG_SOCKET = "unix:///tmp/cloud-init.sock"
13
+
14
+ class Error < StandardError; end
15
+
16
+ class << self
17
+ # The one dashboard event channel per process, shared by every feature.
18
+ def events
19
+ @events ||= Events.new(ENV.fetch("LARAVEL_CLOUD_LOG_SOCKET", DEFAULT_LOG_SOCKET))
20
+ end
21
+
22
+ attr_writer :events
23
+ end
24
+ end
25
+
26
+ require_relative "laravel_cloud/events"
27
+ require_relative "laravel_cloud/queue"
28
+ require_relative "active_job/queue_adapters/cloud_adapter"
29
+
30
+ # Loaded up front so its Railtie can install the browser transport. Only for
31
+ # Rails apps that load Action Cable; the gem doesn't depend on it.
32
+ require_relative "action_cable/subscription_adapter/cloud" if defined?(Rails::Railtie) && defined?(ActionCable::Engine)