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 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,2 @@
1
+ //= link_directory ../javascripts/broadcast_hub .js
2
+
@@ -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,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BroadcastHub
4
+ class Engine < ::Rails::Engine
5
+ isolate_namespace BroadcastHub
6
+ end
7
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module BroadcastHub
4
+ VERSION = "0.1.0"
5
+
6
+ class Version
7
+ def self.to_s
8
+ VERSION
9
+ end
10
+ end
11
+ 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: []