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 +7 -0
- data/LICENSE.md +21 -0
- data/README.md +234 -0
- data/exe/laravel-cloud +7 -0
- data/lib/action_cable/subscription_adapter/cloud/middleware.rb +129 -0
- data/lib/action_cable/subscription_adapter/cloud/railtie.rb +15 -0
- data/lib/action_cable/subscription_adapter/cloud/runtime.rb +255 -0
- data/lib/action_cable/subscription_adapter/cloud/transport.js +167 -0
- data/lib/action_cable/subscription_adapter/cloud.rb +86 -0
- data/lib/active_job/queue_adapters/cloud_adapter.rb +33 -0
- data/lib/laravel-cloud-rails.rb +4 -0
- data/lib/laravel_cloud/cli.rb +90 -0
- data/lib/laravel_cloud/events.rb +93 -0
- data/lib/laravel_cloud/queue/agent_client.rb +139 -0
- data/lib/laravel_cloud/queue/delivery.rb +81 -0
- data/lib/laravel_cloud/queue/dispatcher.rb +214 -0
- data/lib/laravel_cloud/queue/job_class.rb +33 -0
- data/lib/laravel_cloud/queue/managed_queue_config.rb +132 -0
- data/lib/laravel_cloud/queue/message.rb +48 -0
- data/lib/laravel_cloud/queue/message_attributes.rb +55 -0
- data/lib/laravel_cloud/queue/observer.rb +126 -0
- data/lib/laravel_cloud/queue/retry_attempts.rb +27 -0
- data/lib/laravel_cloud/queue/settings.rb +30 -0
- data/lib/laravel_cloud/queue/worker.rb +338 -0
- data/lib/laravel_cloud/queue.rb +85 -0
- data/lib/laravel_cloud/version.rb +6 -0
- data/lib/laravel_cloud/websockets/client.rb +156 -0
- data/lib/laravel_cloud/websockets.rb +74 -0
- data/lib/laravel_cloud.rb +32 -0
- metadata +96 -0
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,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
|