cable_room 0.6.0 → 0.6.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 +4 -4
- data/README.md +642 -0
- data/lib/cable_room/room_member.rb +14 -2
- data/lib/cable_room/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 652ffcb10ea146215a77d64543cce7c60d1e67d45db773eaaae6487d50fb5340
|
|
4
|
+
data.tar.gz: 5be41bc8fb4fbfa16575f5693c939d3b7877fd520f0daac373e4e44b505aeefa
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: be5a1a35fa28418a373b219c25b9f994291feea230840078584d9bd447b5d334ef31b205c542032570cd0148bb9ab42ced9543bd21eee2f50ea131f519ef3b94
|
|
7
|
+
data.tar.gz: 65fd75d2bda566796cc84b495c7ff32c7a3de15331c8ec891f33c36a549acfa14289f3817ee84eb086694891bf970c4ba6b99337b68aafb5e5acc0e5dea3fc69
|
data/README.md
CHANGED
|
@@ -1 +1,643 @@
|
|
|
1
1
|
# CableRoom
|
|
2
|
+
|
|
3
|
+
Build live Rooms on top of ActionCable.
|
|
4
|
+
|
|
5
|
+
A **Room** is a long-lived, server-side object that owns a piece of shared realtime state — a
|
|
6
|
+
quiz session, a collaborative document, a game lobby, a live dashboard. Exactly one instance of a
|
|
7
|
+
Room runs across your whole cluster at a time, it processes messages one at a time on its own
|
|
8
|
+
thread, and clients attach to it as **ports**.
|
|
9
|
+
|
|
10
|
+
ActionCable gives you channels, which are per-connection and stateless. CableRoom gives you the
|
|
11
|
+
thing on the other side of those channels: a single authoritative object that outlives any one
|
|
12
|
+
connection, holds state in memory, and shuts itself down when nobody needs it any more.
|
|
13
|
+
|
|
14
|
+
## Contents.
|
|
15
|
+
|
|
16
|
+
- [How it works](#how-it-works)
|
|
17
|
+
- [Requirements](#requirements)
|
|
18
|
+
- [Installation](#installation)
|
|
19
|
+
- [Quick start](#quick-start)
|
|
20
|
+
- [Defining a Room](#defining-a-room)
|
|
21
|
+
- [Ports and messaging](#ports-and-messaging)
|
|
22
|
+
- [Users](#users)
|
|
23
|
+
- [Authorization](#authorization)
|
|
24
|
+
- [Reaping and the watchdog](#reaping-and-the-watchdog)
|
|
25
|
+
- [Joining a Room from a channel](#joining-a-room-from-a-channel)
|
|
26
|
+
- [Background work](#background-work)
|
|
27
|
+
- [Instrumentation and errors](#instrumentation-and-errors)
|
|
28
|
+
- [Configuration](#configuration)
|
|
29
|
+
- [Introspection](#introspection)
|
|
30
|
+
- [Subclassing](#subclassing)
|
|
31
|
+
- [Development](#development)
|
|
32
|
+
|
|
33
|
+
## How it works.
|
|
34
|
+
|
|
35
|
+
Every Room is backed by a synthetic ActionCable channel that has no browser connection behind it.
|
|
36
|
+
That channel holds a Redis lock on the Room's key, which is what guarantees a single instance
|
|
37
|
+
cluster-wide. Members don't talk to the Room directly. They publish to, and subscribe from, Redis
|
|
38
|
+
pubsub keys called ports.
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
Browser Your ActionCable Channel The Room
|
|
42
|
+
| (include RoomMember) (Room::Base subclass)
|
|
43
|
+
| | |
|
|
44
|
+
| --- websocket message --> | --- to_room port -------------> | handle_received_message
|
|
45
|
+
| | |
|
|
46
|
+
| <-- websocket message --- | <-- from_room port ----------- | broadcast / self <<
|
|
47
|
+
| | <-- <token> port ------------- | reply
|
|
48
|
+
| | <-- <user> / <tag> port ------ | broadcast(tag: :admin)
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Four things follow from that design:
|
|
52
|
+
|
|
53
|
+
- **One instance, many processes.** Any process can call `MyRoom.ensure(key)`. The first one to
|
|
54
|
+
win the Redis lock runs the Room; the rest get `false` and just publish to its ports.
|
|
55
|
+
- **No connection affinity.** Members can be spread across every app server. They only need Redis.
|
|
56
|
+
- **Single-threaded Room state.** Messages and timers run one at a time, so you can touch
|
|
57
|
+
instance variables without locks.
|
|
58
|
+
- **Rooms are disposable.** A Room is expected to die when it's idle and be re-created on demand.
|
|
59
|
+
Persist anything you can't lose.
|
|
60
|
+
|
|
61
|
+
## Requirements.
|
|
62
|
+
|
|
63
|
+
- Ruby 3.4 (CI runs 3.4)
|
|
64
|
+
- Rails 7.2 through 8.x
|
|
65
|
+
- Redis, for both the ActionCable adapter and the Room locks
|
|
66
|
+
|
|
67
|
+
## Installation.
|
|
68
|
+
|
|
69
|
+
```ruby
|
|
70
|
+
gem "cable_room"
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Then point it at Redis:
|
|
74
|
+
|
|
75
|
+
```sh
|
|
76
|
+
export CABLEROOM_REDIS_URL=redis://localhost:6379/1
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
See [Configuration](#configuration) for the full list of variables.
|
|
80
|
+
|
|
81
|
+
## Quick start.
|
|
82
|
+
|
|
83
|
+
Define a Room:
|
|
84
|
+
|
|
85
|
+
```ruby
|
|
86
|
+
class ChatRoom < CableRoom::Room::Base
|
|
87
|
+
# Shut down 30 seconds after the last member leaves
|
|
88
|
+
reap_when { connected_clients.empty? }
|
|
89
|
+
|
|
90
|
+
after_startup do
|
|
91
|
+
@history = []
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Handles { "type": "chat", "body": "..." } from any member
|
|
95
|
+
def on_chat(msg)
|
|
96
|
+
entry = { user: message_origin.user, body: msg["body"], at: Time.current }
|
|
97
|
+
@history << entry
|
|
98
|
+
broadcast({ type: "chat", **entry })
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Send the backlog only to the member who just connected
|
|
102
|
+
on_port_connected do
|
|
103
|
+
reply({ type: "history", entries: @history })
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Define a channel that joins it:
|
|
109
|
+
|
|
110
|
+
```ruby
|
|
111
|
+
class ChatChannel < ApplicationCable::Channel
|
|
112
|
+
include CableRoom::RoomProxyChannel
|
|
113
|
+
|
|
114
|
+
subscribe_to_room do
|
|
115
|
+
join_room ChatRoom, params[:room_id], create: true, tags: params[:tags]
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
That's the whole loop. `RoomProxyChannel` forwards everything the client sends into the Room and
|
|
121
|
+
everything the Room broadcasts back out to the client. `create: true` means this channel will
|
|
122
|
+
provision the Room if it isn't already running somewhere.
|
|
123
|
+
|
|
124
|
+
## Defining a Room.
|
|
125
|
+
|
|
126
|
+
### Lifecycle.
|
|
127
|
+
|
|
128
|
+
```ruby
|
|
129
|
+
class MyRoom < CableRoom::Room::Base
|
|
130
|
+
before_startup { } # streams aren't open yet
|
|
131
|
+
after_startup { } # aliased as on_startup
|
|
132
|
+
before_shutdown { } # last chance to broadcast
|
|
133
|
+
after_shutdown { } # aliased as on_shutdown
|
|
134
|
+
end
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
You can also just define `startup` and `shutdown` methods; they run inside the corresponding
|
|
138
|
+
callback chain.
|
|
139
|
+
|
|
140
|
+
Out of the box a Room broadcasts `{ type: "room_opened" }` after startup and
|
|
141
|
+
`{ type: "room_closed", reason: ... }` before shutdown.
|
|
142
|
+
|
|
143
|
+
To stop a Room from inside itself:
|
|
144
|
+
|
|
145
|
+
```ruby
|
|
146
|
+
shutdown!("everyone left") # graceful: drains queued messages first
|
|
147
|
+
stop! # immediate: drops anything pending
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
`lifecycle_state` returns `:initializing`, `:starting`, `:started`, `:shutting_down`, or `:dead`.
|
|
151
|
+
|
|
152
|
+
### Handling messages.
|
|
153
|
+
|
|
154
|
+
Inbound messages arrive on the `:to_room` port and dispatch by `type`. A message of type
|
|
155
|
+
`"start_quiz"` (or `"StartQuiz"`) calls `on_start_quiz`. Unknown types log a warning.
|
|
156
|
+
|
|
157
|
+
```ruby
|
|
158
|
+
def on_start_quiz(msg)
|
|
159
|
+
logger.info "starting with #{msg['question_count']} questions"
|
|
160
|
+
end
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Inside a handler:
|
|
164
|
+
|
|
165
|
+
| Helper | What it gives you |
|
|
166
|
+
| ----------------- | ------------------------------------------------------- |
|
|
167
|
+
| `message` | The raw message hash |
|
|
168
|
+
| `message_origin` | The `PortClient` that sent it |
|
|
169
|
+
| `reply(data)` | Send back to that port alone |
|
|
170
|
+
| `broadcast(data)` | Send to every member |
|
|
171
|
+
|
|
172
|
+
Override `handle_received_message(message)` if you'd rather dispatch yourself. Call `super` for
|
|
173
|
+
anything you don't handle, so the built-in port and user bookkeeping keeps working.
|
|
174
|
+
|
|
175
|
+
Sending the string `"KILL"` to the `:to_room` port shuts the Room down. It's a blunt instrument,
|
|
176
|
+
useful in a console.
|
|
177
|
+
|
|
178
|
+
### Timers.
|
|
179
|
+
|
|
180
|
+
```ruby
|
|
181
|
+
class MyRoom < CableRoom::Room::Base
|
|
182
|
+
periodically :tick, every: 5.seconds
|
|
183
|
+
periodically -> { broadcast({ type: "still_here" }) }, every: 1.minute
|
|
184
|
+
|
|
185
|
+
def tick; end
|
|
186
|
+
end
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
Timer bodies run on the Room's thread, so they're serialized against message handling.
|
|
190
|
+
|
|
191
|
+
## Ports and messaging.
|
|
192
|
+
|
|
193
|
+
A port is one Redis pubsub key derived from the Room class, the Room key, and a port name. Two
|
|
194
|
+
are reserved:
|
|
195
|
+
|
|
196
|
+
- `:to_room` — many-to-one. Members publish here; the Room streams from it.
|
|
197
|
+
- `:from_room` — one-to-many. The Room publishes here; every member streams from it.
|
|
198
|
+
|
|
199
|
+
Every member also gets a private port named after its random token, plus a port for the user it
|
|
200
|
+
joined as and one for each tag it carries. That's how targeted delivery works without the Room
|
|
201
|
+
tracking connections.
|
|
202
|
+
|
|
203
|
+
### Sending.
|
|
204
|
+
|
|
205
|
+
```ruby
|
|
206
|
+
self << { type: "tick" } # to :from_room, i.e. everyone
|
|
207
|
+
broadcast({ type: "tick" }) # same thing
|
|
208
|
+
broadcast({ type: "secret" }, client_port: token) # one port
|
|
209
|
+
broadcast({ type: "hi" }, user: "user_42") # every port that user joined from
|
|
210
|
+
broadcast({ type: "tools" }, tag: :admin) # every port carrying the tag
|
|
211
|
+
reply({ type: "pong" }) # the port whose message you're handling
|
|
212
|
+
message_origin << { type: "pong" } # the same, spelled differently
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
Combining a target with a tag makes the tag a filter, not a second audience.
|
|
216
|
+
`broadcast(msg, user: "user_42", tag: :admin)` reaches that user only if one of their ports is
|
|
217
|
+
tagged `admin`, and sends nothing otherwise.
|
|
218
|
+
|
|
219
|
+
### Scoping.
|
|
220
|
+
|
|
221
|
+
`with_port_scope` sets an ambient target so nested code doesn't have to pass it around:
|
|
222
|
+
|
|
223
|
+
```ruby
|
|
224
|
+
with_port_scope(tag: :admin) do
|
|
225
|
+
broadcast({ type: "diagnostics", data: expensive_report })
|
|
226
|
+
end
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Scopes merge when nested. `without_port_scope` clears them. `with_port_scope!` skips the block
|
|
230
|
+
entirely when nothing matches, which is the cheap way to avoid building a payload nobody will
|
|
231
|
+
receive. The block form of `reply` does the same for a single port:
|
|
232
|
+
|
|
233
|
+
```ruby
|
|
234
|
+
reply do
|
|
235
|
+
broadcast({ type: "a" })
|
|
236
|
+
broadcast({ type: "b" })
|
|
237
|
+
end
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
### Custom ports.
|
|
241
|
+
|
|
242
|
+
Ports aren't limited to the built-ins. Open your own for a side channel:
|
|
243
|
+
|
|
244
|
+
```ruby
|
|
245
|
+
ports[:telemetry] << { fps: 60 }
|
|
246
|
+
|
|
247
|
+
stream_port(:control) do |message|
|
|
248
|
+
logger.info "control: #{message.inspect}"
|
|
249
|
+
end
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
Ports opened with `stream_port` close automatically at shutdown.
|
|
253
|
+
|
|
254
|
+
### Port liveness.
|
|
255
|
+
|
|
256
|
+
Members ping every 10 seconds. A port that goes quiet for 30 seconds
|
|
257
|
+
(`PortManagement::PORT_TIMEOUT`) is dropped, and `on_port_disconnected` runs for it with
|
|
258
|
+
`message_origin` still set, so cleanup can tell which port went away.
|
|
259
|
+
|
|
260
|
+
```ruby
|
|
261
|
+
on_port_connected { logger.info "port #{message_origin.token} joined" }
|
|
262
|
+
on_port_disconnected { logger.info "port #{message_origin.token} gone" }
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
`connected_clients` returns the live `PortClient` objects. Each one carries a `token`, its `tags`,
|
|
266
|
+
its `user`, and any extra metadata the member passed in. Read and write metadata with `[]` and
|
|
267
|
+
`[]=`.
|
|
268
|
+
|
|
269
|
+
## Users.
|
|
270
|
+
|
|
271
|
+
Members can join *as* a user. CableRoom then collapses that user's ports into a single identity,
|
|
272
|
+
so a person with three browser tabs joins once and leaves once.
|
|
273
|
+
|
|
274
|
+
```ruby
|
|
275
|
+
class MyRoom < CableRoom::Room::Base
|
|
276
|
+
on_user_joined { broadcast({ type: "joined", user: message_origin.user }) }
|
|
277
|
+
on_user_left { broadcast({ type: "left", user: message_origin.user }) }
|
|
278
|
+
end
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
`on_user_joined` fires on the first port for that user; `on_user_left` fires when the last one
|
|
282
|
+
goes away. `connected_users` lists them, and `all_user_tags(user)` unions the tags across every
|
|
283
|
+
port that user is connected from.
|
|
284
|
+
|
|
285
|
+
A `RoomMember` channel that defines `current_user` passes it automatically. Pass `as:` to override
|
|
286
|
+
it, or `as: nil` for an anonymous port. The value is serialized with ActiveJob's argument
|
|
287
|
+
serializer, so an ActiveRecord object survives the trip and arrives as the same record.
|
|
288
|
+
|
|
289
|
+
## Authorization.
|
|
290
|
+
|
|
291
|
+
Two layers, and they compose. Use guards for anything that depends on the message; use tag
|
|
292
|
+
policies for anything that depends on who's asking.
|
|
293
|
+
|
|
294
|
+
### Guards.
|
|
295
|
+
|
|
296
|
+
```ruby
|
|
297
|
+
class MyRoom < CableRoom::Room::Base
|
|
298
|
+
# Block, symbol, or proc. Return false to drop the message.
|
|
299
|
+
authorize_inbound { |message| message["body"].to_s.length < 1_000 }
|
|
300
|
+
authorize_inbound :quiz_running?, only: [:answer, :skip]
|
|
301
|
+
authorize_inbound :not_locked?, except: :leave
|
|
302
|
+
|
|
303
|
+
protected
|
|
304
|
+
|
|
305
|
+
# Zero-arity guards read `message` themselves
|
|
306
|
+
def quiz_running? = @state == :running
|
|
307
|
+
def not_locked?(msg) = !@locked
|
|
308
|
+
end
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
A dropped message logs a warning and never reaches a handler.
|
|
312
|
+
|
|
313
|
+
### Tag policies.
|
|
314
|
+
|
|
315
|
+
Members join with tags (`join_room MyRoom, key, tags: [:admin]`). Policies then say which tags
|
|
316
|
+
may trigger which handlers.
|
|
317
|
+
|
|
318
|
+
```ruby
|
|
319
|
+
class MyRoom < CableRoom::Room::Base
|
|
320
|
+
inbound_tag_policy do
|
|
321
|
+
deny :muted, :chat # muted members can't chat...
|
|
322
|
+
allow :*, :chat # ...but everyone else can
|
|
323
|
+
allow :admin, [:kick, :ban] # admins get the moderation verbs
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
Two rules govern how this resolves:
|
|
329
|
+
|
|
330
|
+
1. **Declaring any policy flips the default to deny.** Before you write one, everything is
|
|
331
|
+
allowed. After, only what you allow is allowed. The built-in connection and user messages stay
|
|
332
|
+
permitted, so members can still join and leave.
|
|
333
|
+
2. **Highest priority wins.** Rules default to priority 10. Pass `priority:` to layer a base
|
|
334
|
+
policy under, or an override over, another. `inbound_tag_policy(priority: -10)` adds
|
|
335
|
+
permissions without flipping the default.
|
|
336
|
+
|
|
337
|
+
Within a priority, the first matching rule decides, and rules match in declaration order. That
|
|
338
|
+
means a `deny` exception has to come **before** the broad `allow` it carves out of — write
|
|
339
|
+
`allow :*, :chat` first and it swallows every member, muted ones included. When the ordering
|
|
340
|
+
matters a lot, give the two rules different priorities instead of relying on where they sit in
|
|
341
|
+
the block:
|
|
342
|
+
|
|
343
|
+
```ruby
|
|
344
|
+
inbound_tag_policy(priority: 20) { deny :muted, :chat }
|
|
345
|
+
inbound_tag_policy(priority: 10) { allow :*, :chat }
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
Group related handlers behind one name with `define_tag_alias`. A rule written against the alias
|
|
349
|
+
covers everything it implies:
|
|
350
|
+
|
|
351
|
+
```ruby
|
|
352
|
+
class MyRoom < CableRoom::Room::Base
|
|
353
|
+
define_tag_alias :moderation, [:kick, :ban, :mute]
|
|
354
|
+
|
|
355
|
+
inbound_tag_policy do
|
|
356
|
+
allow :admin, :moderation
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
Aliases are per Room class and inherited by subclasses, so one Room's vocabulary can't change how
|
|
362
|
+
another Room reads its policies.
|
|
363
|
+
|
|
364
|
+
### System message types.
|
|
365
|
+
|
|
366
|
+
Some message types are the framework's, not the client's. Members can't forge them:
|
|
367
|
+
|
|
368
|
+
```ruby
|
|
369
|
+
class MyRoom < CableRoom::Room::Base
|
|
370
|
+
system_message_types :score_awarded, :quiz_finished
|
|
371
|
+
end
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
Attempts to send one from a member are dropped with a warning at the sender. `port_connected`,
|
|
375
|
+
`port_disconnected`, `port_ping`, `user_joined`, and `user_left` are already protected.
|
|
376
|
+
|
|
377
|
+
## Reaping and the watchdog.
|
|
378
|
+
|
|
379
|
+
Rooms hold memory and a Redis lock, so they need to know when to quit. `reap_when` declares a
|
|
380
|
+
check that runs on a timer:
|
|
381
|
+
|
|
382
|
+
```ruby
|
|
383
|
+
class MyRoom < CableRoom::Room::Base
|
|
384
|
+
# Idle for 30 seconds with nobody connected -> shut down
|
|
385
|
+
reap_when { connected_clients.empty? }
|
|
386
|
+
|
|
387
|
+
# Tighter window, and named so the shutdown reason says which check fired
|
|
388
|
+
reap_when(key: :abandoned, grace: 5.minutes, interval: 30.seconds) do
|
|
389
|
+
connected_users.empty?
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
# Return :reap to skip the grace period entirely
|
|
393
|
+
reap_when(grace: 1.hour) { @cancelled ? :reap : false }
|
|
394
|
+
end
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
- **Truthy** starts the grace clock. Once the condition has held for `grace:` (default 30
|
|
398
|
+
seconds), the Room shuts down.
|
|
399
|
+
- **Falsey** resets the clock and pings the watchdog.
|
|
400
|
+
- **`:reap`** shuts down now, whatever the grace period says.
|
|
401
|
+
|
|
402
|
+
Declare as many checks as you like; each gets its own timer and its own grace clock. Call
|
|
403
|
+
`check_reapers_now!` to run them all immediately instead of waiting for the next tick.
|
|
404
|
+
|
|
405
|
+
### The watchdog.
|
|
406
|
+
|
|
407
|
+
Separately, every Room is supervised. Every five seconds its channel extends the Redis lock and
|
|
408
|
+
confirms the Room has pinged its watchdog within the last 15 seconds
|
|
409
|
+
(`Room::Base::WATCH_DOG_INTERVAL`). Lose the lock and the Room stops, since another process may
|
|
410
|
+
now own the key. Miss the ping and it shuts down as wedged.
|
|
411
|
+
|
|
412
|
+
**Reaper checks are what ping the watchdog.** A Room that declares no `reap_when` has nothing
|
|
413
|
+
pinging it, so the watchdog will shut it down about 15 seconds after startup. Every long-lived
|
|
414
|
+
Room needs at least one `reap_when` — or its own timer calling `ping_watchdog` — to stay up.
|
|
415
|
+
|
|
416
|
+
## Joining a Room from a channel.
|
|
417
|
+
|
|
418
|
+
### The proxy shortcut.
|
|
419
|
+
|
|
420
|
+
When the client only needs a pipe to the Room, `RoomProxyChannel` is the whole channel:
|
|
421
|
+
|
|
422
|
+
```ruby
|
|
423
|
+
class QuizChannel < ApplicationCable::Channel
|
|
424
|
+
include CableRoom::RoomProxyChannel
|
|
425
|
+
|
|
426
|
+
subscribe_to_room do
|
|
427
|
+
join_room QuizRoom, params[:quiz_id], create: true
|
|
428
|
+
end
|
|
429
|
+
end
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
It wires up `subscribed`, `receive`, and `unsubscribed`, and forwards messages both ways.
|
|
433
|
+
|
|
434
|
+
### Full control.
|
|
435
|
+
|
|
436
|
+
`RoomMember` gives you the membership without the forwarding, so the channel can filter,
|
|
437
|
+
transform, or fan out:
|
|
438
|
+
|
|
439
|
+
```ruby
|
|
440
|
+
class QuizChannel < ApplicationCable::Channel
|
|
441
|
+
include CableRoom::RoomMember
|
|
442
|
+
|
|
443
|
+
def subscribed
|
|
444
|
+
@membership = join_room(
|
|
445
|
+
QuizRoom,
|
|
446
|
+
params[:quiz_id],
|
|
447
|
+
create: true,
|
|
448
|
+
tags: current_user.teacher? ? [:admin] : [:student],
|
|
449
|
+
extra: { device: params[:device] },
|
|
450
|
+
on_joined: ->(m) { transmit(type: "ready") },
|
|
451
|
+
on_message: ->(msg) { transmit(msg) if msg["type"] != "internal" },
|
|
452
|
+
on_room_closed: ->(m) { transmit(type: "over") },
|
|
453
|
+
on_left: ->(m) { logger.info "left #{m.key}" }
|
|
454
|
+
)
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
def answer(data)
|
|
458
|
+
@membership << { type: "answer", choice: data["choice"] }
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
def unsubscribed
|
|
462
|
+
@membership&.leave!
|
|
463
|
+
end
|
|
464
|
+
end
|
|
465
|
+
```
|
|
466
|
+
|
|
467
|
+
`join_room` options:
|
|
468
|
+
|
|
469
|
+
| Option | Meaning |
|
|
470
|
+
| ------------------------------- | -------------------------------------------------------------------- |
|
|
471
|
+
| `create:` | Provision the Room if it isn't running. Defaults to `false`. |
|
|
472
|
+
| `as:` | The user identity. Defaults to `current_user` when the channel has one. |
|
|
473
|
+
| `tags:` | Tags this port carries, for policies and targeted broadcasts. |
|
|
474
|
+
| `extra:` | Extra metadata, readable on the Room's `PortClient`. |
|
|
475
|
+
| `forward:` | Pipe every Room message straight to the websocket. |
|
|
476
|
+
| `on_joined:` | The Room acknowledged this port. |
|
|
477
|
+
| `on_message:` | Any message from the Room. |
|
|
478
|
+
| `on_room_opened:` | The Room opened while we were connecting. Not guaranteed. |
|
|
479
|
+
| `on_room_closed:` | The Room closed while we were connected. Not guaranteed. |
|
|
480
|
+
| `on_left:` | This membership ended. |
|
|
481
|
+
|
|
482
|
+
The returned membership responds to `<<`, `connected?`, `left?`, `key`, `ping!`, `leave!`, and
|
|
483
|
+
`rejoin!`.
|
|
484
|
+
|
|
485
|
+
With `create: true`, provisioning is retried on every ping, not just at join. If the Room's host
|
|
486
|
+
process dies, the next ping from any member brings it back somewhere else.
|
|
487
|
+
|
|
488
|
+
### From outside a channel.
|
|
489
|
+
|
|
490
|
+
```ruby
|
|
491
|
+
QuizRoom.ensure("quiz_9") # => true if this process now runs it
|
|
492
|
+
QuizRoom.send_message("quiz_9", { type: "extend", by: 60 }) # publish to :to_room
|
|
493
|
+
QuizRoom.room_port_key("quiz_9", :from_room) # the raw pubsub key
|
|
494
|
+
```
|
|
495
|
+
|
|
496
|
+
## Background work.
|
|
497
|
+
|
|
498
|
+
A Room is single-threaded on purpose. Slow work belongs off its thread:
|
|
499
|
+
|
|
500
|
+
```ruby
|
|
501
|
+
def on_export(msg)
|
|
502
|
+
token = message_origin.token # capture before leaving the Room's thread
|
|
503
|
+
|
|
504
|
+
async do
|
|
505
|
+
report = build_expensive_report
|
|
506
|
+
|
|
507
|
+
on_room_thread do
|
|
508
|
+
broadcast({ type: "export_ready", url: report.url }, client_port: token)
|
|
509
|
+
end
|
|
510
|
+
end
|
|
511
|
+
end
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
`async` borrows a thread from the pool shared by every Room in the process and runs concurrently
|
|
515
|
+
with the Room, so **the block must not touch Room state.** Capture what it needs first. Inside
|
|
516
|
+
it, `message` is nil, and `message_origin` and `reply` point at whatever the Room is handling
|
|
517
|
+
*now* rather than what it was handling when you called `async`.
|
|
518
|
+
|
|
519
|
+
`on_room_thread` queues work back onto the Room's thread, where state is safe again. Prefer
|
|
520
|
+
handing results back that way over blocking on `async` work, since a blocked Room thread can
|
|
521
|
+
starve its neighbours.
|
|
522
|
+
|
|
523
|
+
## Instrumentation and errors.
|
|
524
|
+
|
|
525
|
+
Rooms swallow exceptions so one bad message can't take the Room down. That makes the error
|
|
526
|
+
handler the only place you'll hear about it:
|
|
527
|
+
|
|
528
|
+
```ruby
|
|
529
|
+
CableRoom.error_handler = ->(error, context) do
|
|
530
|
+
Sentry.capture_exception(error, extra: context)
|
|
531
|
+
end
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
The context includes the Room, its class, its key, and the channel. An
|
|
535
|
+
`error.cable_room` notification fires either way.
|
|
536
|
+
|
|
537
|
+
ActiveSupport notifications:
|
|
538
|
+
|
|
539
|
+
| Event | Payload |
|
|
540
|
+
| -------------------------------- | -------------------------- |
|
|
541
|
+
| `room_opened.cable_room` | `room` |
|
|
542
|
+
| `room_closed.cable_room` | `room`, `reason` |
|
|
543
|
+
| `message_received.cable_room` | `room`, `message` |
|
|
544
|
+
| `port_connected.cable_room` | `room`, `message` |
|
|
545
|
+
| `port_disconnected.cable_room` | `room`, `reason`, `message` |
|
|
546
|
+
| `user_joined.cable_room` | `room`, `user` |
|
|
547
|
+
| `user_left.cable_room` | `room`, `user` |
|
|
548
|
+
| `error.cable_room` | `error`, plus context |
|
|
549
|
+
|
|
550
|
+
`port_disconnected` reports a `reason` of `:left` for a clean departure and `:timeout` for a port
|
|
551
|
+
that stopped pinging.
|
|
552
|
+
|
|
553
|
+
Every Room also gets a tagged logger, so `logger.info` from inside a Room is prefixed with the
|
|
554
|
+
Room class and a short UUID. That UUID is how you follow one instance through the logs.
|
|
555
|
+
|
|
556
|
+
## Configuration.
|
|
557
|
+
|
|
558
|
+
CableRoom keeps its own Redis pool, separate from the ActionCable adapter's, for locking.
|
|
559
|
+
Configure it with these variables:
|
|
560
|
+
|
|
561
|
+
| Variable | Purpose |
|
|
562
|
+
| ---------------------------- | -------------------------------------------------------- |
|
|
563
|
+
| `CABLEROOM_REDIS_URL` | The connection URL. |
|
|
564
|
+
| `CABLEROOM_REDIS_PROVIDER` | Name of another variable holding the URL. |
|
|
565
|
+
| `CABLEROOM_REDIS_POOL_SIZE` | Pool size. Defaults to `RAILS_MAX_THREADS`, then five. |
|
|
566
|
+
|
|
567
|
+
Without a prefixed variable it falls back to `REDIS_PROVIDER` and `REDIS_URL`, so a single-Redis
|
|
568
|
+
app needs no CableRoom-specific configuration at all. Reach the pool directly with
|
|
569
|
+
`CableRoom.redis { |conn| ... }` and the lock manager with `CableRoom.lock_manager`.
|
|
570
|
+
|
|
571
|
+
Room threads come from a pool sized by ActionCable's own `worker_pool_size`.
|
|
572
|
+
|
|
573
|
+
Timings live in constants:
|
|
574
|
+
|
|
575
|
+
| Constant | Default | What it controls |
|
|
576
|
+
| --------------------------------- | -------------- | --------------------------------------- |
|
|
577
|
+
| `Room::Base::LOCK_DURATION` | `15.seconds` | Redis lock TTL, extended on every beat. |
|
|
578
|
+
| `Room::Base::WATCH_DOG_INTERVAL` | `15.seconds` | How stale a watchdog ping may get. |
|
|
579
|
+
| `PortManagement::PORT_TIMEOUT` | `30.seconds` | How long a silent port survives. |
|
|
580
|
+
| `ChannelTracker::BEAT_INTERVAL` | `5.seconds` | Lock extension and watchdog sweep. |
|
|
581
|
+
|
|
582
|
+
The first two are read as `self::CONSTANT`, so a Room subclass can redefine them. The other two
|
|
583
|
+
are module constants that apply process-wide.
|
|
584
|
+
|
|
585
|
+
### Shutdown.
|
|
586
|
+
|
|
587
|
+
On process exit, CableRoom asks every local Room to shut down gracefully and waits up to 15
|
|
588
|
+
seconds for them to drain. It also hooks ActionCable's `restart`, so a code reload in development
|
|
589
|
+
stops Rooms instead of orphaning their locks.
|
|
590
|
+
|
|
591
|
+
## Introspection.
|
|
592
|
+
|
|
593
|
+
```ruby
|
|
594
|
+
CableRoom::Room.locally_open_rooms # every Room running in this process
|
|
595
|
+
QuizRoom.locally_running_instances # just the QuizRooms
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
Both are process-local. There's no cluster-wide registry — the Redis lock is the only source of
|
|
599
|
+
truth about who owns a key.
|
|
600
|
+
|
|
601
|
+
## Subclassing.
|
|
602
|
+
|
|
603
|
+
Room classes build a private `Channel` and `PortClient` for each subclass, chained to the
|
|
604
|
+
parent's. Periodic timers, callbacks, policies, and tag aliases all inherit correctly through
|
|
605
|
+
however many levels you need:
|
|
606
|
+
|
|
607
|
+
```ruby
|
|
608
|
+
class BaseGameRoom < CableRoom::Room::Base
|
|
609
|
+
periodically :tick, every: 1.second
|
|
610
|
+
reap_when { connected_users.empty? }
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
class TriviaRoom < BaseGameRoom
|
|
614
|
+
# keeps tick and the reaper, adds its own
|
|
615
|
+
periodically :rotate_question, every: 30.seconds
|
|
616
|
+
end
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
Note that a Room's pubsub keys derive from its class name, so anonymous Room classes won't work.
|
|
620
|
+
|
|
621
|
+
## Development.
|
|
622
|
+
|
|
623
|
+
Rooms need Redis and, for the test suite, Postgres:
|
|
624
|
+
|
|
625
|
+
```sh
|
|
626
|
+
bundle install
|
|
627
|
+
bundle exec rspec
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
To run against every supported Rails version:
|
|
631
|
+
|
|
632
|
+
```sh
|
|
633
|
+
bundle exec appraisal install
|
|
634
|
+
bundle exec appraisal rspec
|
|
635
|
+
```
|
|
636
|
+
|
|
637
|
+
The suite has two halves. Unit specs use `RoomHarness#build_room`, which runs a Room against a
|
|
638
|
+
stub channel with no Redis and no pubsub, so logic is testable synchronously. End-to-end specs
|
|
639
|
+
run the async ActionCable adapter and real message delivery, and wait on observable conditions
|
|
640
|
+
with `wait_until` rather than sleeping.
|
|
641
|
+
|
|
642
|
+
`spec/internal` holds a Combustion app, so `rackup` boots a minimal Rails host if you want to
|
|
643
|
+
poke at Rooms by hand.
|
|
@@ -101,7 +101,17 @@ module CableRoom
|
|
|
101
101
|
|
|
102
102
|
def ping!
|
|
103
103
|
return if left?
|
|
104
|
-
|
|
104
|
+
if @has_established
|
|
105
|
+
port_transmit(room_class::ROOM_IN_CHANNEL, { type: 'port_ping' }, secure_context: true)
|
|
106
|
+
else
|
|
107
|
+
# The port_connected sent by initiate_connection can be lost: stream subscriptions are
|
|
108
|
+
# asynchronous, so a room on another server can acknowledge before our private stream is
|
|
109
|
+
# live — and an unestablished membership silently drops everything the client sends
|
|
110
|
+
# (see #<<). Until acknowledged, keep re-announcing instead of pinging: port_connected is
|
|
111
|
+
# idempotent on the room side (the port is merged, user_joined fires only once), and the
|
|
112
|
+
# re-acknowledgement lands once our subscription is up.
|
|
113
|
+
transmit_port_connected
|
|
114
|
+
end
|
|
105
115
|
@mutex.synchronize do
|
|
106
116
|
maybe_provision_room
|
|
107
117
|
end
|
|
@@ -155,8 +165,10 @@ module CableRoom
|
|
|
155
165
|
|
|
156
166
|
case message['type']
|
|
157
167
|
when 'port_acknowledged'
|
|
168
|
+
# A re-announced port (see ping!) is re-acknowledged; only the first one is a join.
|
|
169
|
+
first_acknowledgement = !@has_established
|
|
158
170
|
@has_established = true
|
|
159
|
-
@on_joined&.call(self)
|
|
171
|
+
@on_joined&.call(self) if first_acknowledgement
|
|
160
172
|
when 'room_opened'
|
|
161
173
|
transmit_port_connected
|
|
162
174
|
@on_room_opened&.call(self)
|
data/lib/cable_room/version.rb
CHANGED