broadcast_hub 0.1.0
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/CHANGELOG.md +31 -0
- data/MIT-LICENSE +21 -0
- data/README.md +152 -0
- data/Rakefile +20 -0
- data/app/assets/config/manifest.js +2 -0
- data/app/channels/broadcast_hub/stream_channel.rb +25 -0
- data/app/javascripts/broadcast_hub/index.js +30 -0
- data/app/javascripts/broadcast_hub/jquery_controller.js +69 -0
- data/app/javascripts/broadcast_hub/subscription.js +33 -0
- data/app/models/concerns/broadcast_hub/broadcaster.rb +133 -0
- data/app/services/broadcast_hub/payload_builder.rb +73 -0
- data/app/services/broadcast_hub/renderer.rb +20 -0
- data/app/services/broadcast_hub/stream_key_context.rb +61 -0
- data/app/services/broadcast_hub/stream_key_resolver.rb +48 -0
- data/lib/broadcast_hub/configuration.rb +21 -0
- data/lib/broadcast_hub/engine.rb +7 -0
- data/lib/broadcast_hub/version.rb +11 -0
- data/lib/broadcast_hub.rb +20 -0
- data/lib/generators/broadcast_hub/install_generator.rb +43 -0
- data/lib/generators/broadcast_hub/templates/broadcast_hub.rb.tt +35 -0
- data/vendor/assets/javascripts/broadcast_hub/index.js +8 -0
- data/vendor/assets/javascripts/broadcast_hub/jquery_controller.js +71 -0
- data/vendor/assets/javascripts/broadcast_hub/subscription.js +35 -0
- metadata +139 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 907683a270f04805b2b85e4944654acbe1cb840e5ba71883b0e23fc34d9d293e
|
|
4
|
+
data.tar.gz: b8752676687f1194caf0e90019041aaa77b2796ab654aac4e009078c7a7c141f
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: e3ce1fed49774c3a83009f3f65388de3f6ff7cc4d0ced5747a9b50518afdc2963e5c31019bc802950891b458df77ec49a5ed4887db130c4bb4240ab8dda3992b
|
|
7
|
+
data.tar.gz: b435a099c2559500fce4c455ea72d5a6571d7cdaf98b9998e7a7f320df02831fbd995e59be544cdcfeee87319db63924702879073ded0bf315206cb3cb0a6c8e
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
## [0.1.0] - 2026-03-24
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- Initial setup of broadcast_hub Rails engine
|
|
11
|
+
- Stream channel for broadcasting
|
|
12
|
+
- JavaScript controllers (jQuery and subscription)
|
|
13
|
+
- Broadcaster concern for models
|
|
14
|
+
- Services: PayloadBuilder, Renderer, StreamKeyContext, StreamKeyResolver
|
|
15
|
+
- Install generator for broadcast_hub
|
|
16
|
+
- Configuration support
|
|
17
|
+
- Rubocop configuration
|
|
18
|
+
- RSpec test setup with dummy Rails application
|
|
19
|
+
- Devise integration with user authentication
|
|
20
|
+
- Todo model with CRUD operations and broadcasting
|
|
21
|
+
- Multiple UI components (alert, datatable, modal, toast, tooltip)
|
|
22
|
+
- Multiple JavaScript controllers (todos, toggle_theme, render_errors, resource_table)
|
|
23
|
+
- Localization support (English and Portuguese - Brazil)
|
|
24
|
+
- Test factories for users and todos
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
- Initial project setup
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
- Updated Gemfile dependencies
|
|
31
|
+
- Enhanced dummy application with full Rails stack
|
data/MIT-LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Alef Oliveira
|
|
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,152 @@
|
|
|
1
|
+
# BroadcastHub
|
|
2
|
+
|
|
3
|
+
BroadcastHub is a reusable Action Cable broadcasting layer for Rails 5/6 apps that use server-rendered HTML and Sprockets. It replaces model-level Turbo stream helpers with an explicit payload contract sent over `BroadcastHub::StreamChannel`.
|
|
4
|
+
|
|
5
|
+
## 1) What BroadcastHub is
|
|
6
|
+
|
|
7
|
+
- Rails engine (`broadcast_hub`) scoped to Rails `>= 5.2`, `< 7.0`
|
|
8
|
+
- Server concern (`BroadcastHub::Broadcaster`) for model callbacks and payload publishing
|
|
9
|
+
- Generic Action Cable channel (`BroadcastHub::StreamChannel`) with authorization and stream key resolution
|
|
10
|
+
- Browser helpers (`BroadcastHub.Subscription` and `BroadcastHub.JQueryController`) for applying append/prepend/update/remove actions
|
|
11
|
+
|
|
12
|
+
BroadcastHub is designed to work without `turbo-rails`.
|
|
13
|
+
|
|
14
|
+
## 2) Installation in host app
|
|
15
|
+
|
|
16
|
+
Add the engine gem to the host app `Gemfile`:
|
|
17
|
+
|
|
18
|
+
```ruby
|
|
19
|
+
gem 'broadcast_hub', path: 'engines/broadcast_hub'
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Install dependencies, then generate the initializer template:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
bundle install
|
|
26
|
+
bin/rails generate broadcast_hub:install
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
This creates `config/initializers/broadcast_hub.rb`.
|
|
30
|
+
|
|
31
|
+
## 3) Initializer configuration
|
|
32
|
+
|
|
33
|
+
Minimum required settings:
|
|
34
|
+
|
|
35
|
+
- `allowed_resources`: allowlist of resource keys clients can subscribe to
|
|
36
|
+
- `authorize_scope`: lambda that decides if the Action Cable connection can subscribe
|
|
37
|
+
- `stream_key_resolver`: lambda that maps subscription context to a stream name used by both channel + model broadcaster
|
|
38
|
+
|
|
39
|
+
Authenticated example:
|
|
40
|
+
|
|
41
|
+
```ruby
|
|
42
|
+
BroadcastHub.configure do |config|
|
|
43
|
+
config.allowed_resources = %w[todo]
|
|
44
|
+
|
|
45
|
+
config.authorize_scope = lambda do |context|
|
|
46
|
+
context.current_user.present?
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
config.stream_key_resolver = lambda do |context|
|
|
50
|
+
"resource:#{context.resource_name}:user:#{context.current_user.id}"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
No-auth/session example:
|
|
56
|
+
|
|
57
|
+
```ruby
|
|
58
|
+
BroadcastHub.configure do |config|
|
|
59
|
+
config.allowed_resources = %w[todo]
|
|
60
|
+
|
|
61
|
+
config.authorize_scope = lambda do |context|
|
|
62
|
+
context.session_id.present?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
config.stream_key_resolver = lambda do |context|
|
|
66
|
+
"resource:#{context.resource_name}:session:#{context.session_id}"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
If your Action Cable connection does not expose `current_user`, expose a safe identifier (for example `session_id`) in `ApplicationCable::Connection`.
|
|
72
|
+
|
|
73
|
+
## 4) Model integration
|
|
74
|
+
|
|
75
|
+
Include the concern and declare broadcast settings in each model:
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
class Todo < ApplicationRecord
|
|
79
|
+
include BroadcastHub::Broadcaster
|
|
80
|
+
|
|
81
|
+
broadcast_to :todo, partial: 'todos/partials/todo', target: '#todos'
|
|
82
|
+
end
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`broadcast_to` wires callbacks:
|
|
86
|
+
|
|
87
|
+
- `after_create_commit` -> append
|
|
88
|
+
- `after_update_commit` -> update
|
|
89
|
+
- `after_destroy_commit` -> remove
|
|
90
|
+
|
|
91
|
+
Optional context hook for stream-key alignment (recommended when keys depend on tenant/user/session):
|
|
92
|
+
|
|
93
|
+
```ruby
|
|
94
|
+
def broadcast_hub_stream_key_context_attributes
|
|
95
|
+
{
|
|
96
|
+
tenant_id: nil,
|
|
97
|
+
current_user: user,
|
|
98
|
+
session_id: nil,
|
|
99
|
+
params: {}
|
|
100
|
+
}
|
|
101
|
+
end
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## 5) Client-side integration (Sprockets)
|
|
105
|
+
|
|
106
|
+
Require BroadcastHub in `app/assets/javascripts/application.js`:
|
|
107
|
+
|
|
108
|
+
```js
|
|
109
|
+
//= require broadcast_hub/index
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Basic subscription wiring (compatible with this repo style):
|
|
113
|
+
|
|
114
|
+
```js
|
|
115
|
+
(function (global) {
|
|
116
|
+
function wireTodoChannel(consumer, $) {
|
|
117
|
+
var controller = new BroadcastHubJQueryController($);
|
|
118
|
+
var subscription = new BroadcastHubSubscription(consumer, controller);
|
|
119
|
+
|
|
120
|
+
return subscription.subscribe('todo');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (global.App && global.App.cable && global.jQuery) {
|
|
124
|
+
global.App.todo_channel = wireTodoChannel(global.App.cable, global.jQuery);
|
|
125
|
+
}
|
|
126
|
+
})(this);
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
`BroadcastHubSubscription` sends `{ channel: 'BroadcastHub::StreamChannel', resource: 'todo' }` and the controller applies incoming payloads to the DOM.
|
|
130
|
+
|
|
131
|
+
## 6) Payload contract
|
|
132
|
+
|
|
133
|
+
Payloads emitted by `BroadcastHub::PayloadBuilder` follow this shape:
|
|
134
|
+
|
|
135
|
+
```json
|
|
136
|
+
{
|
|
137
|
+
"version": 1,
|
|
138
|
+
"action": "append",
|
|
139
|
+
"target": "#todos",
|
|
140
|
+
"content": "<div id=\"todo_1\">...</div>",
|
|
141
|
+
"id": "todo_1"
|
|
142
|
+
}
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Field meaning:
|
|
146
|
+
|
|
147
|
+
- `action`: one of `append`, `prepend`, `update`, `remove`
|
|
148
|
+
- `target`: CSS selector used as insertion/update/remove target
|
|
149
|
+
- `content`: rendered HTML for append/prepend/update (typically `null` on remove)
|
|
150
|
+
- `id`: DOM id used by update/remove fast-path replacement
|
|
151
|
+
- `version`: payload contract version from `BroadcastHub.configuration.payload_version`
|
|
152
|
+
|
data/Rakefile
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'bundler/gem_tasks'
|
|
4
|
+
require 'rdoc/task'
|
|
5
|
+
|
|
6
|
+
RDoc::Task.new :rdoc do |rdoc|
|
|
7
|
+
rdoc.main = 'README.md'
|
|
8
|
+
rdoc.rdoc_dir = 'doc'
|
|
9
|
+
rdoc.title = 'BroadcastHub Documentation'
|
|
10
|
+
rdoc.options << '--line-numbers' << '--inline-muted'
|
|
11
|
+
rdoc.rdoc_files.include 'README.md', 'CHANGELOG.md', 'lib/**/*.rb'
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
begin
|
|
15
|
+
require 'yard'
|
|
16
|
+
YARD::Rake::YardocTask.new do |t|
|
|
17
|
+
t.options = ['--no-output']
|
|
18
|
+
end
|
|
19
|
+
rescue LoadError
|
|
20
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BroadcastHub
|
|
4
|
+
# Action Cable channel that subscribes clients to authorized BroadcastHub streams.
|
|
5
|
+
class StreamChannel < ApplicationCable::Channel
|
|
6
|
+
# Starts stream subscription for the current channel connection.
|
|
7
|
+
#
|
|
8
|
+
# @return [void]
|
|
9
|
+
def subscribed
|
|
10
|
+
stream_from(BroadcastHub::StreamKeyResolver.resolve!(stream_key_context))
|
|
11
|
+
rescue BroadcastHub::StreamKeyResolver::Unauthorized => e
|
|
12
|
+
logger.info("broadcast_hub.reject reason=#{e.message}") if defined?(Rails) && Rails.env.development?
|
|
13
|
+
reject
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
# Builds the stream key context from connection data and params.
|
|
19
|
+
#
|
|
20
|
+
# @return [BroadcastHub::StreamKeyContext]
|
|
21
|
+
def stream_key_context
|
|
22
|
+
BroadcastHub::StreamKeyContext.from_connection(connection: connection, params: params)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import BroadcastHubJQueryController from './jquery_controller';
|
|
2
|
+
import BroadcastHubSubscription from './subscription';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Global object where browser runtime references are attached.
|
|
6
|
+
*
|
|
7
|
+
* @type {Window|typeof globalThis}
|
|
8
|
+
*/
|
|
9
|
+
const root = typeof window !== 'undefined' ? window : globalThis;
|
|
10
|
+
|
|
11
|
+
if (root) {
|
|
12
|
+
root.BroadcastHubJQueryController = BroadcastHubJQueryController;
|
|
13
|
+
root.BroadcastHubSubscription = BroadcastHubSubscription;
|
|
14
|
+
|
|
15
|
+
root.BroadcastHub = root.BroadcastHub || {};
|
|
16
|
+
root.BroadcastHub.JQueryController = root.BroadcastHubJQueryController;
|
|
17
|
+
root.BroadcastHub.Subscription = root.BroadcastHubSubscription;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export { BroadcastHubJQueryController, BroadcastHubSubscription };
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Public API exported by the BroadcastHub package entrypoint.
|
|
24
|
+
*
|
|
25
|
+
* @type {{BroadcastHubJQueryController: typeof BroadcastHubJQueryController, BroadcastHubSubscription: typeof BroadcastHubSubscription}}
|
|
26
|
+
*/
|
|
27
|
+
export default {
|
|
28
|
+
BroadcastHubJQueryController,
|
|
29
|
+
BroadcastHubSubscription
|
|
30
|
+
};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
function isBlank(value) {
|
|
2
|
+
return value == null || String(value).trim() === '';
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export default class BroadcastHubJQueryController {
|
|
6
|
+
constructor($, options) {
|
|
7
|
+
this.$ = $;
|
|
8
|
+
this.env = (options && options.env) || 'production';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
apply(payload) {
|
|
12
|
+
const action = payload && payload.action;
|
|
13
|
+
const targetSelector = payload && payload.target;
|
|
14
|
+
const content = payload && payload.content;
|
|
15
|
+
const id = payload && payload.id;
|
|
16
|
+
|
|
17
|
+
if (!this._isValidPayload(action, targetSelector, content)) {
|
|
18
|
+
this._warnInvalidPayload();
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const $target = this.$(targetSelector);
|
|
23
|
+
const $byId = id ? this.$(`#${id}`) : this.$();
|
|
24
|
+
|
|
25
|
+
switch (action) {
|
|
26
|
+
case 'append':
|
|
27
|
+
$target.append(content);
|
|
28
|
+
return;
|
|
29
|
+
case 'prepend':
|
|
30
|
+
$target.prepend(content);
|
|
31
|
+
return;
|
|
32
|
+
case 'update':
|
|
33
|
+
if ($byId.length > 0) {
|
|
34
|
+
$byId.replaceWith(content);
|
|
35
|
+
} else {
|
|
36
|
+
$target.html(content);
|
|
37
|
+
}
|
|
38
|
+
return;
|
|
39
|
+
case 'remove':
|
|
40
|
+
if (id) {
|
|
41
|
+
const $withinTarget = $target.filter(`#${id}`).add($target.find(`#${id}`)).first();
|
|
42
|
+
if ($withinTarget.length > 0) {
|
|
43
|
+
$withinTarget.remove();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return;
|
|
47
|
+
default:
|
|
48
|
+
this._warnInvalidPayload();
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
_isValidPayload(action, targetSelector, content) {
|
|
53
|
+
if (isBlank(action) || isBlank(targetSelector)) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if ((action === 'append' || action === 'prepend' || action === 'update') && isBlank(content)) {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
_warnInvalidPayload() {
|
|
65
|
+
if (this.env === 'development' && typeof console !== 'undefined' && typeof console.warn === 'function') {
|
|
66
|
+
console.warn('[BroadcastHub] Invalid payload ignored.');
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
function isBlank(value) {
|
|
2
|
+
return value == null || String(value).trim() === '';
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export default class BroadcastHubSubscription {
|
|
6
|
+
constructor(consumer, controller) {
|
|
7
|
+
this.consumer = consumer;
|
|
8
|
+
this.controller = controller;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
subscribe(resource, tenant) {
|
|
12
|
+
if (isBlank(resource)) {
|
|
13
|
+
throw new Error('resource is required');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const params = {
|
|
17
|
+
channel: 'BroadcastHub::StreamChannel',
|
|
18
|
+
resource: String(resource)
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
if (!isBlank(tenant)) {
|
|
22
|
+
params.tenant = String(tenant);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return this.consumer.subscriptions.create(params, {
|
|
26
|
+
received: this._handleReceived.bind(this)
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
_handleReceived(payload) {
|
|
31
|
+
this.controller.apply(payload);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BroadcastHub
|
|
4
|
+
# Adds lifecycle-driven Action Cable broadcasting helpers to models.
|
|
5
|
+
module Broadcaster
|
|
6
|
+
extend ActiveSupport::Concern
|
|
7
|
+
|
|
8
|
+
# Broadcasts an append action for the model instance.
|
|
9
|
+
#
|
|
10
|
+
# @param target [String] DOM target for insertion
|
|
11
|
+
# @return [void]
|
|
12
|
+
def broadcast_append(target)
|
|
13
|
+
broadcast_action("append", target)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Broadcasts a prepend action for the model instance.
|
|
17
|
+
#
|
|
18
|
+
# @param target [String] DOM target for insertion
|
|
19
|
+
# @return [void]
|
|
20
|
+
def broadcast_prepend(target)
|
|
21
|
+
broadcast_action("prepend", target)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Broadcasts an update action for the model instance.
|
|
25
|
+
#
|
|
26
|
+
# @param target [String] DOM target to replace
|
|
27
|
+
# @return [void]
|
|
28
|
+
def broadcast_update(target)
|
|
29
|
+
broadcast_action("update", target)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Broadcasts a remove action for the model instance.
|
|
33
|
+
#
|
|
34
|
+
# @param target [String] DOM target to remove
|
|
35
|
+
# @return [void]
|
|
36
|
+
def broadcast_remove(target)
|
|
37
|
+
broadcast_action("remove", target)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
class_methods do
|
|
41
|
+
# Configures callbacks and rendering metadata for model broadcasts.
|
|
42
|
+
#
|
|
43
|
+
# @param resource_name [String, Symbol] stream resource identifier
|
|
44
|
+
# @param partial [String] partial used to render broadcast content
|
|
45
|
+
# @param target [String] default DOM target used by lifecycle callbacks
|
|
46
|
+
# @return [void]
|
|
47
|
+
def broadcast_to(resource_name, partial:, target:)
|
|
48
|
+
define_method(:broadcast_hub_resource_name) { resource_name.to_s }
|
|
49
|
+
define_method(:broadcast_hub_partial) { partial }
|
|
50
|
+
define_method(:broadcast_hub_target) { target }
|
|
51
|
+
|
|
52
|
+
after_create_commit { broadcast_append(broadcast_hub_target) }
|
|
53
|
+
after_update_commit { broadcast_update(broadcast_hub_target) }
|
|
54
|
+
after_destroy_commit { broadcast_remove(broadcast_hub_target) }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# Broadcasts a payload to the configured stream key.
|
|
61
|
+
#
|
|
62
|
+
# @param action [String] payload action
|
|
63
|
+
# @param target [String] DOM target
|
|
64
|
+
# @return [void]
|
|
65
|
+
def broadcast_action(action, target)
|
|
66
|
+
content = action == "remove" ? nil : render_broadcast_content
|
|
67
|
+
payload = BroadcastHub::PayloadBuilder.build(
|
|
68
|
+
action: action,
|
|
69
|
+
target: target,
|
|
70
|
+
content: content,
|
|
71
|
+
id: broadcast_hub_dom_id,
|
|
72
|
+
meta: {}
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
ActionCable.server.broadcast(broadcast_hub_stream_key, payload)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Renders model content used in append/prepend/update actions.
|
|
79
|
+
#
|
|
80
|
+
# @return [String]
|
|
81
|
+
def render_broadcast_content
|
|
82
|
+
BroadcastHub::Renderer.new.render(
|
|
83
|
+
partial: broadcast_hub_partial,
|
|
84
|
+
locals: { self.class.model_name.singular.to_sym => self }
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# Resolves the stream key for the current model event.
|
|
89
|
+
#
|
|
90
|
+
# @return [String]
|
|
91
|
+
# @raise [RuntimeError] when stream key resolver is not configured
|
|
92
|
+
def broadcast_hub_stream_key
|
|
93
|
+
resolver = BroadcastHub.configuration.stream_key_resolver
|
|
94
|
+
raise "stream_key_resolver not configured" unless resolver
|
|
95
|
+
|
|
96
|
+
context_attributes = {
|
|
97
|
+
tenant_id: nil,
|
|
98
|
+
current_user: nil,
|
|
99
|
+
session_id: nil,
|
|
100
|
+
params: {}
|
|
101
|
+
}.merge((broadcast_hub_stream_key_context_attributes || {}).to_h)
|
|
102
|
+
|
|
103
|
+
resolver.call(
|
|
104
|
+
BroadcastHub::StreamKeyContext.new(
|
|
105
|
+
resource_name: broadcast_hub_resource_name,
|
|
106
|
+
tenant_id: context_attributes[:tenant_id],
|
|
107
|
+
current_user: context_attributes[:current_user],
|
|
108
|
+
session_id: context_attributes[:session_id],
|
|
109
|
+
params: context_attributes[:params]
|
|
110
|
+
)
|
|
111
|
+
)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Default context attributes used for stream key resolution.
|
|
115
|
+
#
|
|
116
|
+
# @return [Hash]
|
|
117
|
+
def broadcast_hub_stream_key_context_attributes
|
|
118
|
+
{
|
|
119
|
+
tenant_id: nil,
|
|
120
|
+
current_user: nil,
|
|
121
|
+
session_id: nil,
|
|
122
|
+
params: {}
|
|
123
|
+
}
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Generates a stable payload identifier for this instance.
|
|
127
|
+
#
|
|
128
|
+
# @return [String]
|
|
129
|
+
def broadcast_hub_dom_id
|
|
130
|
+
"#{self.class.model_name.singular}_#{id}"
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BroadcastHub
|
|
4
|
+
# Builds and validates normalized payloads sent through Action Cable.
|
|
5
|
+
class PayloadBuilder
|
|
6
|
+
# Raised when payload data is invalid.
|
|
7
|
+
class ValidationError < StandardError; end
|
|
8
|
+
|
|
9
|
+
VALID_ACTIONS = %w[append prepend update remove].freeze
|
|
10
|
+
ACTIONS_REQUIRING_CONTENT = %w[append prepend update].freeze
|
|
11
|
+
ALLOWED_KEYS = %i[version action target content id meta].freeze
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
# Builds the broadcast payload hash.
|
|
15
|
+
#
|
|
16
|
+
# @param action [String] one of {VALID_ACTIONS}
|
|
17
|
+
# @param target [String] DOM target identifier
|
|
18
|
+
# @param content [String, nil] rendered HTML for non-remove actions
|
|
19
|
+
# @param id [String] unique entry identifier
|
|
20
|
+
# @param meta [Hash, nil] optional metadata included in the payload
|
|
21
|
+
# @return [Hash] payload constrained to {ALLOWED_KEYS}
|
|
22
|
+
# @raise [ValidationError] when any input fails validation
|
|
23
|
+
def build(action:, target:, content:, id:, meta: {})
|
|
24
|
+
validate_action!(action)
|
|
25
|
+
validate_target!(target)
|
|
26
|
+
validate_content!(action, content)
|
|
27
|
+
|
|
28
|
+
payload = {
|
|
29
|
+
version: BroadcastHub.configuration.payload_version,
|
|
30
|
+
action: action,
|
|
31
|
+
target: target,
|
|
32
|
+
content: content,
|
|
33
|
+
id: id,
|
|
34
|
+
meta: normalize_meta(meta)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
payload.slice(*ALLOWED_KEYS)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# @param action [String]
|
|
43
|
+
# @raise [ValidationError]
|
|
44
|
+
def validate_action!(action)
|
|
45
|
+
raise ValidationError, "invalid action" unless VALID_ACTIONS.include?(action)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# @param target [String]
|
|
49
|
+
# @raise [ValidationError]
|
|
50
|
+
def validate_target!(target)
|
|
51
|
+
raise ValidationError, "target required" if target.to_s.strip.empty?
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# @param action [String]
|
|
55
|
+
# @param content [String, nil]
|
|
56
|
+
# @raise [ValidationError]
|
|
57
|
+
def validate_content!(action, content)
|
|
58
|
+
return unless ACTIONS_REQUIRING_CONTENT.include?(action)
|
|
59
|
+
raise ValidationError, "content required" if content.to_s.strip.empty?
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @param meta [Hash, nil]
|
|
63
|
+
# @return [Hash]
|
|
64
|
+
# @raise [ValidationError]
|
|
65
|
+
def normalize_meta(meta)
|
|
66
|
+
return {} if meta.nil?
|
|
67
|
+
raise ValidationError, "meta must be a hash" unless meta.is_a?(Hash)
|
|
68
|
+
|
|
69
|
+
meta
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BroadcastHub
|
|
4
|
+
# Renders partials used in broadcast payloads.
|
|
5
|
+
class Renderer
|
|
6
|
+
# @param renderer [#render] rendering backend, defaults to Rails renderer
|
|
7
|
+
def initialize(renderer: ApplicationController.renderer)
|
|
8
|
+
@renderer = renderer
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Renders a partial with locals.
|
|
12
|
+
#
|
|
13
|
+
# @param partial [String] partial path
|
|
14
|
+
# @param locals [Hash] locals passed to the partial
|
|
15
|
+
# @return [String] rendered HTML fragment
|
|
16
|
+
def render(partial:, locals: {})
|
|
17
|
+
@renderer.render(partial: partial, locals: locals)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BroadcastHub
|
|
4
|
+
# Immutable context object shared by stream key authorization and resolution.
|
|
5
|
+
class StreamKeyContext
|
|
6
|
+
attr_reader :resource_name, :tenant_id, :current_user, :session_id, :params
|
|
7
|
+
|
|
8
|
+
# Builds a context from an Action Cable connection and channel params.
|
|
9
|
+
#
|
|
10
|
+
# @param connection [Object] Action Cable connection
|
|
11
|
+
# @param params [#to_h, #to_unsafe_h] channel subscription params
|
|
12
|
+
# @return [BroadcastHub::StreamKeyContext]
|
|
13
|
+
def self.from_connection(connection:, params: {})
|
|
14
|
+
normalized_params = normalize_params(params)
|
|
15
|
+
|
|
16
|
+
new(
|
|
17
|
+
resource_name: normalized_params[:resource],
|
|
18
|
+
tenant_id: normalized_params[:tenant],
|
|
19
|
+
current_user: connection_value(connection, :current_user),
|
|
20
|
+
session_id: connection_value(connection, :session_id) || normalized_params[:session_id],
|
|
21
|
+
params: normalized_params
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# @param resource_name [String, Symbol, nil] resource requested by the client
|
|
26
|
+
# @param tenant_id [Object] tenant identity for scoped streams
|
|
27
|
+
# @param current_user [Object] authenticated user on the connection
|
|
28
|
+
# @param session_id [String, nil] optional session identifier
|
|
29
|
+
# @param params [Hash] normalized channel params
|
|
30
|
+
def initialize(resource_name:, tenant_id:, current_user:, session_id:, params: {})
|
|
31
|
+
@resource_name = resource_name
|
|
32
|
+
@tenant_id = tenant_id
|
|
33
|
+
@current_user = current_user
|
|
34
|
+
@session_id = session_id
|
|
35
|
+
@params = (params || {}).dup.freeze
|
|
36
|
+
freeze
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
class << self
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
# @param params [#to_h, #to_unsafe_h]
|
|
43
|
+
# @return [Hash] params normalized to symbol keys
|
|
44
|
+
def normalize_params(params)
|
|
45
|
+
hash = params.respond_to?(:to_unsafe_h) ? params.to_unsafe_h : params.to_h
|
|
46
|
+
hash.symbolize_keys
|
|
47
|
+
rescue StandardError
|
|
48
|
+
{}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @param connection [Object]
|
|
52
|
+
# @param key [Symbol]
|
|
53
|
+
# @return [Object, nil]
|
|
54
|
+
def connection_value(connection, key)
|
|
55
|
+
return nil unless connection.respond_to?(key)
|
|
56
|
+
|
|
57
|
+
connection.public_send(key)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BroadcastHub
|
|
4
|
+
# Validates subscription context and resolves an Action Cable stream key.
|
|
5
|
+
class StreamKeyResolver
|
|
6
|
+
# Raised when the context cannot subscribe to the requested resource.
|
|
7
|
+
class Unauthorized < StandardError; end
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
# Resolves the stream key for a subscription context.
|
|
11
|
+
#
|
|
12
|
+
# @param context [BroadcastHub::StreamKeyContext] normalized subscription context
|
|
13
|
+
# @return [String] stream identifier used by Action Cable
|
|
14
|
+
# @raise [Unauthorized] when the context is invalid or not authorized
|
|
15
|
+
def resolve!(context)
|
|
16
|
+
reject!("missing_resource") if context.resource_name.to_s.strip.empty?
|
|
17
|
+
|
|
18
|
+
allowed_resources = Array(configuration.allowed_resources).map(&:to_s)
|
|
19
|
+
reject!("invalid_resource") unless allowed_resources.include?(context.resource_name.to_s)
|
|
20
|
+
|
|
21
|
+
authorize_scope = configuration.authorize_scope
|
|
22
|
+
reject!("missing_authorize_scope") unless authorize_scope.respond_to?(:call)
|
|
23
|
+
reject!("unauthorized_scope") unless authorize_scope.call(context)
|
|
24
|
+
|
|
25
|
+
stream_key_resolver = configuration.stream_key_resolver
|
|
26
|
+
reject!("missing_stream_key_resolver") unless stream_key_resolver.respond_to?(:call)
|
|
27
|
+
|
|
28
|
+
stream_key = stream_key_resolver.call(context)
|
|
29
|
+
reject!("missing_identity") if stream_key.to_s.strip.empty?
|
|
30
|
+
|
|
31
|
+
stream_key
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
# @return [BroadcastHub::Configuration]
|
|
37
|
+
def configuration
|
|
38
|
+
BroadcastHub.configuration
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @param reason [String] symbolic rejection reason
|
|
42
|
+
# @raise [Unauthorized]
|
|
43
|
+
def reject!(reason)
|
|
44
|
+
raise Unauthorized, reason
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module BroadcastHub
|
|
4
|
+
class Configuration
|
|
5
|
+
attr_accessor :payload_version,
|
|
6
|
+
:update_strategy,
|
|
7
|
+
:strict_client_validation,
|
|
8
|
+
:allowed_resources,
|
|
9
|
+
:authorize_scope,
|
|
10
|
+
:stream_key_resolver
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
@payload_version = 1
|
|
14
|
+
@update_strategy = :replace_with
|
|
15
|
+
@strict_client_validation = false
|
|
16
|
+
@allowed_resources = []
|
|
17
|
+
@authorize_scope = nil
|
|
18
|
+
@stream_key_resolver = nil
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
require "broadcast_hub/version"
|
|
5
|
+
require "broadcast_hub/configuration"
|
|
6
|
+
require "broadcast_hub/engine"
|
|
7
|
+
|
|
8
|
+
module BroadcastHub
|
|
9
|
+
class << self
|
|
10
|
+
attr_writer :configuration
|
|
11
|
+
|
|
12
|
+
def configuration
|
|
13
|
+
@configuration ||= Configuration.new
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def configure
|
|
17
|
+
yield(configuration)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "logger"
|
|
4
|
+
require "rails/generators"
|
|
5
|
+
|
|
6
|
+
module BroadcastHub
|
|
7
|
+
module Generators
|
|
8
|
+
class InstallGenerator < Rails::Generators::Base
|
|
9
|
+
source_root File.expand_path("templates", __dir__)
|
|
10
|
+
|
|
11
|
+
def copy_initializer
|
|
12
|
+
template "broadcast_hub.rb.tt", "config/initializers/broadcast_hub.rb"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def add_javascript_requires
|
|
16
|
+
manifest_path = "app/assets/javascripts/application.js"
|
|
17
|
+
absolute_manifest_path = File.expand_path(manifest_path, destination_root)
|
|
18
|
+
|
|
19
|
+
unless File.exist?(absolute_manifest_path)
|
|
20
|
+
say_status :skip, "#{manifest_path} not found", :yellow
|
|
21
|
+
return
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
directives = [
|
|
25
|
+
"//= require jquery3",
|
|
26
|
+
"//= require broadcast_hub/index"
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
manifest_content = File.read(absolute_manifest_path)
|
|
30
|
+
missing_directives = directives.reject { |directive| manifest_content.include?(directive) }
|
|
31
|
+
return if missing_directives.empty?
|
|
32
|
+
|
|
33
|
+
append_to_file manifest_path, "\n#{missing_directives.join("\n")}\n"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def show_javascript_install_hint
|
|
37
|
+
say "Add BroadcastHub assets in app/assets/javascripts/application.js:"
|
|
38
|
+
say "//= require jquery3"
|
|
39
|
+
say "//= require broadcast_hub/index"
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
BroadcastHub.configure do |config|
|
|
2
|
+
# Allowlist channel resources that clients are permitted to subscribe to.
|
|
3
|
+
# Add your resource keys here.
|
|
4
|
+
config.allowed_resources = %w[todo]
|
|
5
|
+
|
|
6
|
+
# Required: return true when the connection context can subscribe to
|
|
7
|
+
# the requested scope. Keep this check strict.
|
|
8
|
+
config.authorize_scope = lambda do |context|
|
|
9
|
+
context.current_user.present? || context.session_id.present?
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Default stream key keeps publisher and subscriber contexts aligned even when
|
|
13
|
+
# models do not provide current_user/session_id/tenant_id attributes.
|
|
14
|
+
#
|
|
15
|
+
# If your app needs tenant/user/session isolation, customize this resolver.
|
|
16
|
+
# See examples below.
|
|
17
|
+
config.stream_key_resolver = lambda do |context|
|
|
18
|
+
"resource:#{context.resource_name}"
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Auth mode example (customize authorize_scope to match your policy):
|
|
22
|
+
#
|
|
23
|
+
# config.stream_key_resolver = lambda do |context|
|
|
24
|
+
# "tenant:#{context.tenant_id}:#{context.resource_name}:user:#{context.current_user.id}"
|
|
25
|
+
# end
|
|
26
|
+
|
|
27
|
+
# No-auth mode example (for public/session-based channels):
|
|
28
|
+
#
|
|
29
|
+
# config.stream_key_resolver = lambda do |context|
|
|
30
|
+
# "tenant:#{context.tenant_id}:#{context.resource_name}:session:#{context.session_id}"
|
|
31
|
+
# end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# For apps without `current_user` in Action Cable, expose a safe `session_id`
|
|
35
|
+
# identifier in ApplicationCable::Connection.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
//= require broadcast_hub/jquery_controller
|
|
2
|
+
//= require broadcast_hub/subscription
|
|
3
|
+
|
|
4
|
+
(function (global) {
|
|
5
|
+
global.BroadcastHub = global.BroadcastHub || {};
|
|
6
|
+
global.BroadcastHub.JQueryController = global.BroadcastHubJQueryController;
|
|
7
|
+
global.BroadcastHub.Subscription = global.BroadcastHubSubscription;
|
|
8
|
+
})(this);
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
(function (global) {
|
|
2
|
+
function isBlank(value) {
|
|
3
|
+
return value == null || String(value).trim() === '';
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
function BroadcastHubJQueryController($, options) {
|
|
7
|
+
this.$ = $;
|
|
8
|
+
this.env = (options && options.env) || 'production';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
BroadcastHubJQueryController.prototype.apply = function (payload) {
|
|
12
|
+
var action = payload && payload.action;
|
|
13
|
+
var targetSelector = payload && payload.target;
|
|
14
|
+
var content = payload && payload.content;
|
|
15
|
+
var id = payload && payload.id;
|
|
16
|
+
|
|
17
|
+
if (!this._isValidPayload(action, targetSelector, content)) {
|
|
18
|
+
this._warnInvalidPayload();
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
var $target = this.$(targetSelector);
|
|
23
|
+
var $byId = id ? this.$('#' + id) : this.$();
|
|
24
|
+
|
|
25
|
+
switch (action) {
|
|
26
|
+
case 'append':
|
|
27
|
+
$target.append(content);
|
|
28
|
+
return;
|
|
29
|
+
case 'prepend':
|
|
30
|
+
$target.prepend(content);
|
|
31
|
+
return;
|
|
32
|
+
case 'update':
|
|
33
|
+
if ($byId.length > 0) {
|
|
34
|
+
$byId.replaceWith(content);
|
|
35
|
+
} else {
|
|
36
|
+
$target.html(content);
|
|
37
|
+
}
|
|
38
|
+
return;
|
|
39
|
+
case 'remove':
|
|
40
|
+
if (id) {
|
|
41
|
+
var $withinTarget = $target.filter('#' + id).add($target.find('#' + id)).first();
|
|
42
|
+
if ($withinTarget.length > 0) {
|
|
43
|
+
$withinTarget.remove();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return;
|
|
47
|
+
default:
|
|
48
|
+
this._warnInvalidPayload();
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
BroadcastHubJQueryController.prototype._isValidPayload = function (action, targetSelector, content) {
|
|
53
|
+
if (isBlank(action) || isBlank(targetSelector)) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if ((action === 'append' || action === 'prepend' || action === 'update') && isBlank(content)) {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return true;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
BroadcastHubJQueryController.prototype._warnInvalidPayload = function () {
|
|
65
|
+
if (this.env === 'development' && global.console && typeof global.console.warn === 'function') {
|
|
66
|
+
global.console.warn('[BroadcastHub] Invalid payload ignored.');
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
global.BroadcastHubJQueryController = BroadcastHubJQueryController;
|
|
71
|
+
})(this);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
(function (global) {
|
|
2
|
+
function isBlank(value) {
|
|
3
|
+
return value == null || String(value).trim() === '';
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
function BroadcastHubSubscription(consumer, controller) {
|
|
7
|
+
this.consumer = consumer;
|
|
8
|
+
this.controller = controller;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
BroadcastHubSubscription.prototype.subscribe = function (resource, tenant) {
|
|
12
|
+
if (isBlank(resource)) {
|
|
13
|
+
throw new Error('resource is required');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
var params = {
|
|
17
|
+
channel: 'BroadcastHub::StreamChannel',
|
|
18
|
+
resource: String(resource)
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
if (!isBlank(tenant)) {
|
|
22
|
+
params.tenant = String(tenant);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return this.consumer.subscriptions.create(params, {
|
|
26
|
+
received: this._handleReceived.bind(this)
|
|
27
|
+
});
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
BroadcastHubSubscription.prototype._handleReceived = function (payload) {
|
|
31
|
+
this.controller.apply(payload);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
global.BroadcastHubSubscription = BroadcastHubSubscription;
|
|
35
|
+
})(this);
|
metadata
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: broadcast_hub
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Alef Oliveira
|
|
8
|
+
autorequire:
|
|
9
|
+
bindir: bin
|
|
10
|
+
cert_chain: []
|
|
11
|
+
date: 2026-03-25 00:00:00.000000000 Z
|
|
12
|
+
dependencies:
|
|
13
|
+
- !ruby/object:Gem::Dependency
|
|
14
|
+
name: jquery-rails
|
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
|
16
|
+
requirements:
|
|
17
|
+
- - ">="
|
|
18
|
+
- !ruby/object:Gem::Version
|
|
19
|
+
version: '0'
|
|
20
|
+
type: :runtime
|
|
21
|
+
prerelease: false
|
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
23
|
+
requirements:
|
|
24
|
+
- - ">="
|
|
25
|
+
- !ruby/object:Gem::Version
|
|
26
|
+
version: '0'
|
|
27
|
+
- !ruby/object:Gem::Dependency
|
|
28
|
+
name: rails
|
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
|
30
|
+
requirements:
|
|
31
|
+
- - ">="
|
|
32
|
+
- !ruby/object:Gem::Version
|
|
33
|
+
version: '5.2'
|
|
34
|
+
- - "<"
|
|
35
|
+
- !ruby/object:Gem::Version
|
|
36
|
+
version: '7.0'
|
|
37
|
+
type: :runtime
|
|
38
|
+
prerelease: false
|
|
39
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
40
|
+
requirements:
|
|
41
|
+
- - ">="
|
|
42
|
+
- !ruby/object:Gem::Version
|
|
43
|
+
version: '5.2'
|
|
44
|
+
- - "<"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '7.0'
|
|
47
|
+
- !ruby/object:Gem::Dependency
|
|
48
|
+
name: yard
|
|
49
|
+
requirement: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - ">="
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '0'
|
|
54
|
+
type: :development
|
|
55
|
+
prerelease: false
|
|
56
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - ">="
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '0'
|
|
61
|
+
- !ruby/object:Gem::Dependency
|
|
62
|
+
name: redcarpet
|
|
63
|
+
requirement: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - ">="
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '0'
|
|
68
|
+
type: :development
|
|
69
|
+
prerelease: false
|
|
70
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - ">="
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '0'
|
|
75
|
+
description:
|
|
76
|
+
email:
|
|
77
|
+
executables: []
|
|
78
|
+
extensions: []
|
|
79
|
+
extra_rdoc_files:
|
|
80
|
+
- README.md
|
|
81
|
+
- CHANGELOG.md
|
|
82
|
+
files:
|
|
83
|
+
- CHANGELOG.md
|
|
84
|
+
- MIT-LICENSE
|
|
85
|
+
- README.md
|
|
86
|
+
- Rakefile
|
|
87
|
+
- app/assets/config/manifest.js
|
|
88
|
+
- app/channels/broadcast_hub/stream_channel.rb
|
|
89
|
+
- app/javascripts/broadcast_hub/index.js
|
|
90
|
+
- app/javascripts/broadcast_hub/jquery_controller.js
|
|
91
|
+
- app/javascripts/broadcast_hub/subscription.js
|
|
92
|
+
- app/models/concerns/broadcast_hub/broadcaster.rb
|
|
93
|
+
- app/services/broadcast_hub/payload_builder.rb
|
|
94
|
+
- app/services/broadcast_hub/renderer.rb
|
|
95
|
+
- app/services/broadcast_hub/stream_key_context.rb
|
|
96
|
+
- app/services/broadcast_hub/stream_key_resolver.rb
|
|
97
|
+
- lib/broadcast_hub.rb
|
|
98
|
+
- lib/broadcast_hub/configuration.rb
|
|
99
|
+
- lib/broadcast_hub/engine.rb
|
|
100
|
+
- lib/broadcast_hub/version.rb
|
|
101
|
+
- lib/generators/broadcast_hub/install_generator.rb
|
|
102
|
+
- lib/generators/broadcast_hub/templates/broadcast_hub.rb.tt
|
|
103
|
+
- vendor/assets/javascripts/broadcast_hub/index.js
|
|
104
|
+
- vendor/assets/javascripts/broadcast_hub/jquery_controller.js
|
|
105
|
+
- vendor/assets/javascripts/broadcast_hub/subscription.js
|
|
106
|
+
homepage: https://github.com/nemuba/broadcast_hub
|
|
107
|
+
licenses: []
|
|
108
|
+
metadata:
|
|
109
|
+
homepage_uri: https://github.com/nemuba/broadcast_hub
|
|
110
|
+
source_code_uri: https://github.com/nemuba/broadcast_hub
|
|
111
|
+
changelog_uri: https://github.com/nemuba/broadcast_hub/blob/main/CHANGELOG.md
|
|
112
|
+
bug_tracker_uri: https://github.com/nemuba/broadcast_hub/issues
|
|
113
|
+
rubygems_mfa_required: 'true'
|
|
114
|
+
post_install_message:
|
|
115
|
+
rdoc_options:
|
|
116
|
+
- "--title"
|
|
117
|
+
- BroadcastHub
|
|
118
|
+
- "--main"
|
|
119
|
+
- README.md
|
|
120
|
+
- "--line-numbers"
|
|
121
|
+
- "--inline-muted"
|
|
122
|
+
require_paths:
|
|
123
|
+
- lib
|
|
124
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
125
|
+
requirements:
|
|
126
|
+
- - ">="
|
|
127
|
+
- !ruby/object:Gem::Version
|
|
128
|
+
version: '0'
|
|
129
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
130
|
+
requirements:
|
|
131
|
+
- - ">="
|
|
132
|
+
- !ruby/object:Gem::Version
|
|
133
|
+
version: '0'
|
|
134
|
+
requirements: []
|
|
135
|
+
rubygems_version: 3.3.3
|
|
136
|
+
signing_key:
|
|
137
|
+
specification_version: 4
|
|
138
|
+
summary: Reusable Action Cable broadcasting engine for Rails 5/6
|
|
139
|
+
test_files: []
|