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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5ef19c3543f84778c71e51537a0213808fbadd1207511bbad5692812dce5cdd2
4
+ data.tar.gz: efae967d66fc0eb612102f8a5bd89bb3616ecffa1b9be54821fc551991fbabaa
5
+ SHA512:
6
+ metadata.gz: fb9895734bd22d3712c126424d41d0ace0bcd9ed2dbc8724f27ab1331e51bf87d172a5b1f09eac5f645a85dace9a06a776101043a9e8801b6779b8535153a05b
7
+ data.tar.gz: 9961f7167b4ed7ae350f53e86ae1dda35d4d93c3eec6f40b588a0a1ff96ee001ee87ec1ad52e76278838b0155b2bc62fae1713ce2d5d98008b38bd3bdd09fffc
data/LICENSE.md ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) Taylor Otwell
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,234 @@
1
+ # laravel-cloud-rails
2
+
3
+ Laravel Cloud features for Ruby on Rails applications.
4
+
5
+ - [Requirements](#requirements)
6
+ - [Installation](#installation)
7
+ - [Features](#features)
8
+ - [Managed Queues](#managed-queues)
9
+ - [WebSockets](#websockets)
10
+ - [Development](#development)
11
+ - [License](#license)
12
+
13
+ ## Requirements
14
+
15
+ - Ruby 3.2+
16
+ - Rails / Active Job 7.1+
17
+
18
+ ## Installation
19
+
20
+ Install the gem from GitHub:
21
+
22
+ ```bash
23
+ bundle add laravel-cloud-rails --git https://github.com/laravel/laravel-cloud-rails.git
24
+ ```
25
+
26
+ ## Features
27
+
28
+ | Feature | Summary |
29
+ | --- | --- |
30
+ | [Managed Queues](#managed-queues) | Laravel Cloud managed queues as an Active Job backend. |
31
+ | [WebSockets](#websockets) | Action Cable over Laravel Cloud managed WebSockets. |
32
+
33
+ ---
34
+
35
+ ## Managed Queues
36
+
37
+ Run Active Job on Laravel Cloud managed queues. Laravel Cloud runs and scales
38
+ the queue workers for you. Throughput, duration and failed jobs appear in the
39
+ Laravel Cloud dashboard, and failed jobs can be retried from there.
40
+
41
+ ### Setup
42
+
43
+ Use the `:cloud` adapter in production:
44
+
45
+ ```ruby
46
+ # config/environments/production.rb
47
+ config.active_job.queue_adapter = :cloud
48
+ ```
49
+
50
+ That's all. Laravel Cloud provides the rest.
51
+
52
+ ### Dispatching jobs
53
+
54
+ Use Active Job as usual. A job's queue name is the managed queue it goes to;
55
+ jobs on Active Job's default queue go to your environment's default queue.
56
+
57
+ ```ruby
58
+ class ProcessReportJob < ApplicationJob
59
+ queue_as :critical
60
+ end
61
+
62
+ ProcessReportJob.perform_later(report)
63
+ ProcessReportJob.set(wait: 5.minutes).perform_later(report)
64
+ ActiveJob.perform_all_later(jobs)
65
+ ```
66
+
67
+ - `wait:` / `wait_until:` can be at most 15 minutes; longer raises
68
+ `LaravelCloud::Queue::InvalidDispatch`.
69
+ - `priority` isn't supported and is ignored.
70
+
71
+ ### FIFO queues
72
+
73
+ A queue whose name ends in `.fifo` delivers jobs in order and deduplicates
74
+ them. It works without extra setup. To control the ordering group or the
75
+ deduplication key:
76
+
77
+ ```ruby
78
+ class ProcessOrderJob < ApplicationJob
79
+ include LaravelCloud::Queue::MessageAttributes
80
+
81
+ queue_as "orders.fifo"
82
+
83
+ message_group_id { "customer-#{arguments.first.customer_id}" }
84
+ message_deduplication_id { "order-#{arguments.first.id}" }
85
+ end
86
+ ```
87
+
88
+ FIFO queues don't support `wait:`.
89
+
90
+ ### Fair queues
91
+
92
+ On a standard queue, a message group ID marks the tenant a job belongs to, so
93
+ one busy tenant can't starve the others:
94
+
95
+ ```ruby
96
+ class SyncAccountJob < ApplicationJob
97
+ include LaravelCloud::Queue::MessageAttributes
98
+
99
+ message_group_id { "account-#{arguments.first.id}" }
100
+ end
101
+ ```
102
+
103
+ ### Retries and failures
104
+
105
+ `retry_on` and `discard_on` work as usual. Retry waits can be up to 12 hours.
106
+
107
+ ```ruby
108
+ class ProcessReportJob < ApplicationJob
109
+ retry_on Net::OpenTimeout, wait: :polynomially_longer, attempts: 5
110
+ end
111
+ ```
112
+
113
+ A job's attempts are counted together across all its `retry_on` rules, rather
114
+ than per exception.
115
+
116
+ An exception that isn't handled fails the job. Failed jobs show up in the
117
+ Laravel Cloud dashboard, where you can retry them.
118
+
119
+ ### Configuration
120
+
121
+ Optional, in an initializer:
122
+
123
+ ```ruby
124
+ # config/initializers/laravel_cloud.rb
125
+ LaravelCloud::Queue.configure do |queue|
126
+ # Only run these job classes (default: any Active Job class).
127
+ queue.allowed_job_classes = %w[ProcessReportJob SendInvoiceJob]
128
+
129
+ # Called when a job fails, e.g. to report it to an error tracker.
130
+ queue.on_failure = ->(error, job_data) { Rails.error.report(error, context: job_data) }
131
+ end
132
+ ```
133
+
134
+ ### Local development
135
+
136
+ Managed queues only exist on Laravel Cloud, so use another adapter locally:
137
+
138
+ ```ruby
139
+ # config/environments/development.rb
140
+ config.active_job.queue_adapter = :async
141
+ ```
142
+
143
+ ### Errors
144
+
145
+ | Error | Raised when |
146
+ | --- | --- |
147
+ | `LaravelCloud::Queue::InvalidDispatch` | A dispatch the queue can't honour: a wait over 15 minutes, a wait on a FIFO queue, or a deduplication ID on a standard queue. |
148
+ | `LaravelCloud::Queue::NotConfigured` | No managed queue is configured. |
149
+ | `ActiveJob::EnqueueError` | The job couldn't be sent to the queue. `perform_later` returns `false`, as with any adapter. |
150
+
151
+ ---
152
+
153
+ ## WebSockets
154
+
155
+ Run Action Cable on Laravel Cloud managed WebSockets. Your channels and
156
+ `@rails/actioncable` client work unchanged, and broadcasts reach every Puma
157
+ worker and replica.
158
+
159
+ ### Setup
160
+
161
+ Attach a WebSocket application to your Laravel Cloud environment, then use the
162
+ `cloud` adapter in production:
163
+
164
+ ```yaml
165
+ # config/cable.yml
166
+ production:
167
+ adapter: cloud
168
+ ```
169
+
170
+ That's all. Laravel Cloud provides the connection settings, and the gem adds
171
+ its browser transport to your HTML pages. No JavaScript changes are needed.
172
+
173
+ ### Usage
174
+
175
+ Use Action Cable as usual:
176
+
177
+ ```ruby
178
+ class RoomChannel < ApplicationCable::Channel
179
+ def subscribed
180
+ stream_from "room:demo"
181
+ end
182
+ end
183
+
184
+ ActionCable.server.broadcast("room:demo", { text: "Your report is ready" })
185
+ ```
186
+
187
+ Connection authentication, subscription rejections, `stream_for`, Turbo Streams
188
+ and `remote_connections.where(...).disconnect` all work as usual.
189
+
190
+ ### Limitations
191
+
192
+ - Each message sent to a browser must stay under about 9.5 KB, Reverb's request
193
+ limit. A larger message closes that connection without reconnecting.
194
+ - Messages broadcast while a client is reconnecting aren't replayed, as with
195
+ Action Cable's own adapters.
196
+ - Clients outside HTML pages, such as Web Workers, use Rails' native `/cable`
197
+ endpoint.
198
+
199
+ ### Local development
200
+
201
+ Managed WebSockets only exist on Laravel Cloud, so use another adapter locally:
202
+
203
+ ```yaml
204
+ # config/cable.yml
205
+ development:
206
+ adapter: async
207
+ ```
208
+
209
+ To try the `cloud` adapter locally, run a Reverb server that both Rails
210
+ and the browser can reach, and set `PUSHER_APP_ID`, `PUSHER_APP_KEY`,
211
+ `PUSHER_APP_SECRET`, `PUSHER_HOST`, `PUSHER_PORT` and `PUSHER_SCHEME`.
212
+
213
+ ### Errors
214
+
215
+ | Error | Raised when |
216
+ | --- | --- |
217
+ | `LaravelCloud::Websockets::NotConfigured` | The `cloud` adapter is used without the `PUSHER_*` variables. |
218
+ | `LaravelCloud::Websockets::PayloadTooLarge` | A broadcast is too large to send. |
219
+ | `LaravelCloud::Websockets::Unavailable` | The WebSocket server can't be reached. |
220
+
221
+ If Rails logs `backend connection lost (Backend error: Origin not allowed)`,
222
+ check that `APP_URL` matches an allowed origin of your WebSocket application.
223
+
224
+ ## Development
225
+
226
+ ```bash
227
+ bundle install
228
+ bundle exec rake test
229
+ node --test test/*_test.mjs
230
+ ```
231
+
232
+ ## License
233
+
234
+ `laravel-cloud-rails` is open-sourced software licensed under the [MIT license](LICENSE.md).
data/exe/laravel-cloud ADDED
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "laravel_cloud"
5
+ require "laravel_cloud/cli"
6
+
7
+ exit LaravelCloud::CLI.new(ARGV).run
@@ -0,0 +1,129 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionCable
4
+ module SubscriptionAdapter
5
+ class Cloud
6
+ # Serves the browser transport, injects it into HTML pages, and receives
7
+ # the transport's channel commands.
8
+ class Middleware
9
+ ENDPOINT = "/__laravel_cloud/cable"
10
+ COMMANDS = %w[connect ready lease disconnect subscribe unsubscribe message].freeze
11
+ MAX_BYTES = 8192
12
+
13
+ def initialize(app)
14
+ @app = app
15
+ end
16
+
17
+ def call(environment)
18
+ return @app.call(environment) unless active?
19
+ request = ActionDispatch::Request.new(environment)
20
+ if request.path == "#{ENDPOINT}.js" && request.get?
21
+ return with_owner(request, [200, { "content-type" => "text/javascript", "cache-control" => "no-store", "x-content-type-options" => "nosniff" }, [script]])
22
+ end
23
+ return command(request) if request.path == ENDPOINT && request.post?
24
+
25
+ status, headers, body = @app.call(environment)
26
+ return [status, headers, body] unless status == 200 && headers["content-type"].to_s.include?("text/html") && !headers["content-encoding"] && body.respond_to?(:each)
27
+ allow_backend(request)
28
+ nonce = request.content_security_policy_nonce
29
+ tag = "<script src=\"#{ENDPOINT}.js\"#{nonce ? " nonce=\"#{ERB::Util.html_escape(nonce)}\"" : ''}></script>"
30
+ with_owner(request, [status, headers.except("content-length", "etag"), HtmlBody.new(body, tag)])
31
+ end
32
+
33
+ private
34
+
35
+ # Decided on the first request, once cable.yml and initializers have
36
+ # configured Action Cable. Without the variables, Action Cable's native
37
+ # /cable endpoint is left to report the misconfiguration.
38
+ def active?
39
+ return @active unless @active.nil?
40
+ adapter = (ActionCable.server.config.cable || {}).with_indifferent_access[:adapter]
41
+ @active = adapter.to_s == "cloud" && Websockets.enabled?
42
+ end
43
+
44
+ def command(request)
45
+ return json(403, error: "Invalid origin") unless request.get_header("HTTP_ORIGIN") == request.base_url
46
+ return json(415, error: "JSON required") unless request.media_type == "application/json"
47
+ return json(413, error: "Command too large") if request.content_length.to_i > MAX_BYTES
48
+ raw = request.body.read(MAX_BYTES + 1)
49
+ return json(413, error: "Command too large") if raw.bytesize > MAX_BYTES
50
+ payload = JSON.parse(raw)
51
+ raise InvalidCommand, "Invalid command" unless payload.is_a?(Hash) && COMMANDS.include?(payload["command"])
52
+ unless payload["command"] == "connect"
53
+ raise InvalidCommand, "Request ID required" unless payload["request_id"].is_a?(String) && payload["request_id"].bytesize.between?(1, 64)
54
+ end
55
+ if %w[subscribe unsubscribe message].include?(payload["command"])
56
+ raise InvalidCommand, "Invalid identifier" unless payload["identifier"].is_a?(String) && JSON.parse(payload["identifier"]).is_a?(Hash)
57
+ raise InvalidCommand, "Invalid message" if payload["command"] == "message" && (!payload["data"].is_a?(String) || !JSON.parse(payload["data"]).is_a?(Hash))
58
+ end
59
+ return json(401, error: "Connection cookie required") unless Cloud.owner(request)
60
+ status, response = Cloud.runtime.execute(payload, request)
61
+ json(status, response)
62
+ rescue JSON::ParserError, KeyError, ArgumentError, URI::InvalidURIError, InvalidCommand
63
+ json(400, error: "Invalid channel command")
64
+ rescue Websockets::Unavailable
65
+ json(503, error: "WebSocket backend unavailable")
66
+ end
67
+
68
+ def allow_backend(request)
69
+ policy = request.content_security_policy
70
+ return unless policy
71
+ policy = policy.dup
72
+ # Without either directive, connections are unrestricted; adding one would restrict them.
73
+ sources = policy.directives["connect-src"] || policy.directives["default-src"]
74
+ return unless sources
75
+ config = Websockets.settings
76
+ origin = "#{config[:scheme] == 'https' ? 'wss' : 'ws'}://#{config[:host]}:#{config[:port]}"
77
+ policy.connect_src(*(sources + [origin]).uniq)
78
+ request.content_security_policy = policy
79
+ end
80
+
81
+ def with_owner(request, response)
82
+ unless Cloud.owner(request)
83
+ response[1]["cache-control"] = "private, no-store"
84
+ Rack::Utils.set_cookie_header!(response[1], "__laravel_cloud_owner", value: Cloud.owner_cookie, path: "/", httponly: true, same_site: :lax, secure: request.ssl?)
85
+ end
86
+ response
87
+ end
88
+
89
+ def json(status, payload)
90
+ [status, { "content-type" => "application/json", "cache-control" => "no-store" }, [JSON.generate(payload)]]
91
+ end
92
+
93
+ def script
94
+ "globalThis.__laravelCloudWebsockets = #{JSON.generate(Websockets.settings)};\n" + File.read(File.join(__dir__, "transport.js"))
95
+ end
96
+ end
97
+
98
+ # Inserts the transport right after <head>, before application scripts
99
+ # capture the WebSocket constructor, without buffering the whole page.
100
+ class HtmlBody
101
+ def initialize(body, tag)
102
+ @body, @tag = body, tag
103
+ end
104
+
105
+ def each
106
+ buffer = +""
107
+ pending = true
108
+ @body.each do |part|
109
+ unless pending
110
+ yield part
111
+ next
112
+ end
113
+ buffer << part
114
+ if buffer.sub!(/<head(?:\s[^>]*)?>/i) { |head| "#{head}#{@tag}" } || buffer.bytesize > 65536
115
+ yield buffer
116
+ buffer = +""
117
+ pending = false
118
+ end
119
+ end
120
+ yield buffer unless buffer.empty?
121
+ end
122
+
123
+ def close
124
+ @body.close if @body.respond_to?(:close)
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionCable
4
+ module SubscriptionAdapter
5
+ class Cloud
6
+ class Railtie < Rails::Railtie
7
+ # Always installed: the middleware only acts once cable.yml selects the
8
+ # cloud adapter, which isn't known until Action Cable loads.
9
+ initializer "laravel_cloud.action_cable" do |app|
10
+ app.middleware.use(Middleware)
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionCable
4
+ module SubscriptionAdapter
5
+ class Cloud
6
+ module BridgedConnection
7
+ # The runtime's shared heartbeat channel replaces per-connection pings,
8
+ # which would otherwise each cost a backend publish every 3 seconds.
9
+ def beat; end
10
+ end
11
+
12
+ # Stands in for the WebSocket of a virtual Action Cable connection, so the
13
+ # connection encodes, closes and rejects exactly as it would natively.
14
+ class Session
15
+ attr_reader :connection, :owner, :token, :last_seen
16
+
17
+ def initialize(runtime, token, owner, environment)
18
+ @runtime, @token, @owner = runtime, token, owner
19
+ @lock = Monitor.new
20
+ @closed = @active = false
21
+ @pending = []
22
+ @sequence = 0
23
+ @requests = {}
24
+ @last_seen = Process.clock_gettime(Process::CLOCK_MONOTONIC)
25
+ @connection = ActionCable.server.config.connection_class.call.new(ActionCable.server, environment)
26
+ @connection.extend(BridgedConnection)
27
+ @connection.instance_variable_set(:@websocket, self)
28
+ end
29
+
30
+ def open
31
+ @connection.send(:handle_open)
32
+ end
33
+
34
+ def alive?
35
+ !@closed
36
+ end
37
+
38
+ def possible?
39
+ true
40
+ end
41
+
42
+ def protocol
43
+ "actioncable-v1-json"
44
+ end
45
+
46
+ def channel
47
+ "private-roc-client-#{Digest::SHA256.hexdigest(@token)}"
48
+ end
49
+
50
+ def transmit(data)
51
+ @lock.synchronize do
52
+ return if @closed
53
+ if @active
54
+ publish(data)
55
+ else
56
+ raise InvalidCommand, "Too many initial connection frames" if @pending.size >= 256
57
+ @pending << data
58
+ end
59
+ end
60
+ end
61
+
62
+ def execute(payload)
63
+ @lock.synchronize do
64
+ return [410, { error: "Connection expired" }] if @closed
65
+ @last_seen = Process.clock_gettime(Process::CLOCK_MONOTONIC)
66
+ request_id = payload.fetch("request_id")
67
+ return @requests[request_id] if @requests.key?(request_id)
68
+ case payload.fetch("command")
69
+ when "ready"
70
+ @active = true
71
+ @pending.each { |data| transmit(data) }
72
+ @pending.clear
73
+ when "disconnect"
74
+ close
75
+ when "lease"
76
+ else
77
+ @connection.handle_channel_command(payload.except("token", "request_id"))
78
+ end
79
+ result = [200, { ok: true }]
80
+ @requests[request_id] = result
81
+ @requests.shift if @requests.size > 128
82
+ result
83
+ end
84
+ end
85
+
86
+ def disconnect(reason)
87
+ @connection.close(reason: reason)
88
+ rescue Websockets::Unavailable
89
+ close
90
+ end
91
+
92
+ def close
93
+ @lock.synchronize do
94
+ return if @closed
95
+ @closed = true
96
+ begin
97
+ @connection.send(:handle_close)
98
+ ensure
99
+ @runtime.remove(@token)
100
+ end
101
+ end
102
+ end
103
+
104
+ private
105
+
106
+ def publish(data)
107
+ @sequence += 1
108
+ # Re-parsed so the frame is published as JSON once, not as a string.
109
+ Websockets.publish(channel, "cloud-frame", { sequence: @sequence, frame: JSON.parse(data) })
110
+ rescue Websockets::PayloadTooLarge
111
+ @sequence -= 1
112
+ @connection.close(reason: "message_too_large", reconnect: false)
113
+ end
114
+ end
115
+
116
+ class Runtime
117
+ attr_reader :pid, :id
118
+
119
+ def initialize
120
+ @pid = Process.pid
121
+ @id = SecureRandom.hex(16)
122
+ @lock = Mutex.new
123
+ @sessions, @pending, @listeners = {}, {}, {}
124
+ @alive = true
125
+ @bus = Websockets::Client.new(logger: ActionCable.server.logger) { stop }
126
+ @bus.subscribe(control_channel, method(:receive))
127
+ @timer = ActionCable.server.event_loop.timer(3) { pulse }
128
+ end
129
+
130
+ def alive?
131
+ @alive
132
+ end
133
+
134
+ def control_channel
135
+ "private-roc-worker-#{@id}"
136
+ end
137
+
138
+ def heartbeat_channel
139
+ "private-roc-live-#{@id}"
140
+ end
141
+
142
+ def subscribe(stream, callback, success = nil)
143
+ wrapped = ->(event, data) { callback.call(data) if event == "cloud-broadcast" }
144
+ @lock.synchronize { @listeners[[stream, callback]] = wrapped }
145
+ @bus.subscribe(Cloud.stream_channel(stream), wrapped, success)
146
+ end
147
+
148
+ def unsubscribe(stream, callback)
149
+ wrapped = @lock.synchronize { @listeners.delete([stream, callback]) }
150
+ @bus.unsubscribe(Cloud.stream_channel(stream), wrapped) if wrapped
151
+ end
152
+
153
+ def execute(payload, request)
154
+ owner = Cloud.owner(request)
155
+ return connect(payload, request, owner) if payload.fetch("command") == "connect"
156
+ worker = verify_token(payload.fetch("token"))
157
+ return [404, { error: "Unknown connection" }] unless worker
158
+ return local(payload, owner) if worker == @id
159
+
160
+ request_id = SecureRandom.hex(16)
161
+ queue = ::Queue.new
162
+ @lock.synchronize { @pending[request_id] = queue }
163
+ Websockets.publish("private-roc-worker-#{worker}", "cloud-rpc", { id: request_id, reply_to: control_channel, owner: owner, payload: payload })
164
+ queue.pop(timeout: 5) || [410, { error: "Connection owner unavailable" }]
165
+ ensure
166
+ @lock.synchronize { @pending.delete(request_id) } if request_id
167
+ end
168
+
169
+ def remove(token)
170
+ @lock.synchronize { @sessions.delete(token) }
171
+ end
172
+
173
+ def stop
174
+ return unless @alive
175
+ @alive = false
176
+ @timer&.shutdown
177
+ sessions = @lock.synchronize { @sessions.values.dup }
178
+ sessions.each do |session|
179
+ Rails.application.executor.wrap { session.disconnect("server_restart") }
180
+ rescue StandardError => error
181
+ ActionCable.server.logger.error("laravel-cloud-rails cleanup failed (#{error.class})")
182
+ end
183
+ ActionCable.server.connections.dup.each do |connection|
184
+ connection.close(reason: "server_restart") unless connection.is_a?(BridgedConnection)
185
+ end
186
+ @bus.close
187
+ end
188
+
189
+ private
190
+
191
+ def verify_token(token)
192
+ return unless token.is_a?(String) && token.match?(/\A[0-9a-f]{32}\.[0-9a-f]{48}\.[0-9a-f]{64}\z/)
193
+ worker, nonce, signature = token.split(".")
194
+ worker if ActiveSupport::SecurityUtils.secure_compare(Websockets.sign("#{worker}.#{nonce}"), signature)
195
+ end
196
+
197
+ def connect(payload, request, owner)
198
+ socket_id = payload.fetch("socket_id")
199
+ raise InvalidCommand, "Invalid socket ID" unless socket_id.is_a?(String) && socket_id.match?(/\A\d+\.\d+\z/)
200
+ url = URI.parse(payload.fetch("url", "/cable"))
201
+ raise InvalidCommand, "Invalid connection URL" unless url.path&.start_with?("/") && url.path.length < 2048
202
+ value = "#{@id}.#{SecureRandom.hex(24)}"
203
+ token = "#{value}.#{Websockets.sign(value)}"
204
+ environment = request.env.merge("PATH_INFO" => url.path, "QUERY_STRING" => url.query.to_s)
205
+ session = Session.new(self, token, owner, environment)
206
+ @lock.synchronize do
207
+ raise Websockets::Unavailable, "Too many connections" if @sessions.size >= 10000 || @sessions.values.count { |s| s.owner == owner } >= 32
208
+ @sessions[token] = session
209
+ end
210
+ session.connection.worker_pool.work(session.connection) { session.open }
211
+ return [401, { error: "Unauthorized connection" }] unless session.alive?
212
+ [200, { token: token, channels: [session.channel, heartbeat_channel].map { |channel| { name: channel, auth: Websockets.authorize(socket_id, channel) } } }]
213
+ rescue StandardError
214
+ session&.close
215
+ raise
216
+ end
217
+
218
+ def local(payload, owner)
219
+ session = @lock.synchronize { @sessions[payload["token"]] }
220
+ return [410, { error: "Connection expired" }] unless session
221
+ return [404, { error: "Unknown connection" }] unless ActiveSupport::SecurityUtils.secure_compare(session.owner, owner)
222
+ session.connection.worker_pool.work(session.connection) { session.execute(payload) }
223
+ rescue InvalidCommand, KeyError
224
+ [400, { error: "Invalid channel command" }]
225
+ rescue Websockets::Unavailable
226
+ [503, { error: "WebSocket backend unavailable" }]
227
+ end
228
+
229
+ def receive(event, data)
230
+ if event == "cloud-reply"
231
+ @lock.synchronize { @pending[data["id"]] }&.push(data.fetch("result"))
232
+ elsif event == "cloud-rpc"
233
+ result = Rails.application.executor.wrap { local(data.fetch("payload"), data.fetch("owner")) }
234
+ Websockets.publish(data.fetch("reply_to"), "cloud-reply", { id: data.fetch("id"), result: result })
235
+ end
236
+ rescue StandardError => error
237
+ ActionCable.server.logger.error("laravel-cloud-rails command failed (#{error.class})")
238
+ end
239
+
240
+ def pulse
241
+ @bus.ping
242
+ sessions = @lock.synchronize { @sessions.values.dup }
243
+ return if sessions.empty?
244
+ cutoff = Process.clock_gettime(Process::CLOCK_MONOTONIC) - 90
245
+ sessions.each do |session|
246
+ Rails.application.executor.wrap { session.disconnect("connection_timeout") } if session.last_seen < cutoff
247
+ end
248
+ Websockets.publish(heartbeat_channel, "cloud-heartbeat", { time: Time.now.to_i })
249
+ rescue Websockets::Unavailable
250
+ stop
251
+ end
252
+ end
253
+ end
254
+ end
255
+ end